feat(FN-2962): merge fusion/fn-2962
- Add changeset for `@runfusion/fusion` minor release introducing custom provider registration support Commits merged: - feat(FN-2962): complete Step 8 — add changeset and documentation Files changed: .changeset/register-custom-providers.md | 5 +++++ 1 file changed, 5 insertions(+) Fusion-Task-Id: FN-2962
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CustomProvider } from "@fusion/core";
|
||||
import {
|
||||
registerCustomProviders,
|
||||
reregisterCustomProviders,
|
||||
resolveApiType,
|
||||
} from "../custom-provider-registry.js";
|
||||
|
||||
describe("custom-provider-registry", () => {
|
||||
it.each([
|
||||
["openai-compatible", "openai-completions"],
|
||||
["anthropic-compatible", "anthropic"],
|
||||
])("resolveApiType maps %s -> %s", (apiType, expectedApi) => {
|
||||
expect(resolveApiType(apiType)).toBe(expectedApi);
|
||||
});
|
||||
|
||||
it("registers providers with expected config shape", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
const logFn = vi.fn();
|
||||
const providers: CustomProvider[] = [
|
||||
{
|
||||
id: "openai-custom",
|
||||
name: "OpenAI Custom",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://example.test/v1",
|
||||
apiKey: "CUSTOM_KEY",
|
||||
models: [{ id: "m1", name: "Model 1" }],
|
||||
},
|
||||
{
|
||||
id: "anthropic-custom",
|
||||
name: "Anthropic Custom",
|
||||
apiType: "anthropic-compatible",
|
||||
baseUrl: "https://anthropic.test",
|
||||
apiKey: "ANTHROPIC_KEY",
|
||||
models: [{ id: "claude-x", name: "Claude X" }],
|
||||
},
|
||||
];
|
||||
|
||||
registerCustomProviders({ registerProvider, refresh }, providers, logFn);
|
||||
|
||||
expect(registerProvider).toHaveBeenNthCalledWith(1, "openai-custom", expect.objectContaining({
|
||||
baseUrl: "https://example.test/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "CUSTOM_KEY",
|
||||
models: [expect.objectContaining({ id: "m1", name: "Model 1" })],
|
||||
}));
|
||||
expect(registerProvider).toHaveBeenNthCalledWith(2, "anthropic-custom", expect.objectContaining({
|
||||
baseUrl: "https://anthropic.test",
|
||||
api: "anthropic",
|
||||
apiKey: "ANTHROPIC_KEY",
|
||||
models: [expect.objectContaining({ id: "claude-x", name: "Claude X" })],
|
||||
}));
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles empty provider list and still refreshes", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
|
||||
registerCustomProviders({ registerProvider, refresh }, [], vi.fn());
|
||||
|
||||
expect(registerProvider).not.toHaveBeenCalled();
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses empty models when models is missing", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
|
||||
registerCustomProviders(
|
||||
{ registerProvider, refresh },
|
||||
[{
|
||||
id: "no-models",
|
||||
name: "No Models",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://nomodels.test",
|
||||
}],
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(registerProvider).toHaveBeenCalledWith("no-models", expect.objectContaining({ models: [] }));
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("continues when one provider registration fails", () => {
|
||||
const registerProvider = vi
|
||||
.fn()
|
||||
.mockImplementationOnce(() => {
|
||||
throw new Error("boom");
|
||||
})
|
||||
.mockImplementationOnce(() => undefined);
|
||||
const refresh = vi.fn();
|
||||
const logFn = vi.fn();
|
||||
|
||||
registerCustomProviders(
|
||||
{ registerProvider, refresh },
|
||||
[
|
||||
{
|
||||
id: "bad",
|
||||
name: "Bad",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://bad.test",
|
||||
},
|
||||
{
|
||||
id: "good",
|
||||
name: "Good",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://good.test",
|
||||
},
|
||||
],
|
||||
logFn,
|
||||
);
|
||||
|
||||
expect(registerProvider).toHaveBeenCalledTimes(2);
|
||||
expect(logFn).toHaveBeenCalledWith(expect.stringContaining("Failed to register custom provider bad"));
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reregisters new providers", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
|
||||
reregisterCustomProviders(
|
||||
{ registerProvider, refresh },
|
||||
[{ id: "old", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" }],
|
||||
[
|
||||
{ id: "old", name: "Old", apiType: "openai-compatible", baseUrl: "https://old.test" },
|
||||
{ id: "new", name: "New", apiType: "anthropic-compatible", baseUrl: "https://new.test" },
|
||||
],
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(registerProvider).toHaveBeenCalledTimes(1);
|
||||
expect(registerProvider).toHaveBeenCalledWith("new", expect.objectContaining({ api: "anthropic" }));
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reregisters changed providers", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
|
||||
reregisterCustomProviders(
|
||||
{ registerProvider, refresh },
|
||||
[{ id: "same-id", name: "Provider", apiType: "openai-compatible", baseUrl: "https://one.test", apiKey: "A" }],
|
||||
[{ id: "same-id", name: "Provider", apiType: "openai-compatible", baseUrl: "https://two.test", apiKey: "B" }],
|
||||
vi.fn(),
|
||||
);
|
||||
|
||||
expect(registerProvider).toHaveBeenCalledTimes(1);
|
||||
expect(registerProvider).toHaveBeenCalledWith("same-id", expect.objectContaining({
|
||||
baseUrl: "https://two.test",
|
||||
apiKey: "B",
|
||||
}));
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("handles empty previous/current arrays", () => {
|
||||
const registerProvider = vi.fn();
|
||||
const refresh = vi.fn();
|
||||
|
||||
reregisterCustomProviders({ registerProvider, refresh }, [], [], vi.fn());
|
||||
|
||||
expect(registerProvider).not.toHaveBeenCalled();
|
||||
expect(refresh).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,10 @@ function makeMockStore() {
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
updateTask: vi.fn().mockResolvedValue({}),
|
||||
getFusionDir: vi.fn().mockReturnValue("/tmp/test/.fusion"),
|
||||
getGlobalSettingsStore: vi.fn(() => ({
|
||||
getSettings: mockGlobalSettingsGetSettings,
|
||||
updateSettings: mockGlobalSettingsUpdateSettings,
|
||||
})),
|
||||
getActiveMergingTask: vi.fn().mockReturnValue(undefined),
|
||||
getMissionStore: vi.fn().mockReturnValue(mockMissionStore),
|
||||
close: vi.fn(),
|
||||
|
||||
@@ -73,6 +73,9 @@ const mocks = vi.hoisted(() => {
|
||||
watch: vi.fn().mockResolvedValue(undefined),
|
||||
close: vi.fn(),
|
||||
getFusionDir: vi.fn().mockReturnValue(`/repo${projectId ? `/${projectId}` : ""}/.fusion`),
|
||||
getGlobalSettingsStore: vi.fn(() => ({
|
||||
getSettings: vi.fn().mockResolvedValue({}),
|
||||
})),
|
||||
getMissionStore: vi.fn().mockReturnValue(missionStore),
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
maxConcurrent: 2,
|
||||
|
||||
96
packages/cli/src/commands/custom-provider-registry.ts
Normal file
96
packages/cli/src/commands/custom-provider-registry.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { CustomProvider } from "@fusion/core";
|
||||
|
||||
interface ModelRegistryLike {
|
||||
registerProvider: (name: string, config: {
|
||||
baseUrl: string;
|
||||
api: string;
|
||||
apiKey?: string;
|
||||
models: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
reasoning: boolean;
|
||||
input: ("text" | "image")[];
|
||||
cost: { input: number; output: number; cacheRead: number; cacheWrite: number };
|
||||
contextWindow: number;
|
||||
maxTokens: number;
|
||||
}>;
|
||||
}) => void;
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
export function resolveApiType(apiType: string): string {
|
||||
if (apiType === "anthropic-compatible") {
|
||||
return "anthropic";
|
||||
}
|
||||
return "openai-completions";
|
||||
}
|
||||
|
||||
function toProviderConfig(provider: CustomProvider) {
|
||||
return {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: resolveApiType(provider.apiType),
|
||||
apiKey: provider.apiKey,
|
||||
models: (provider.models ?? []).map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
reasoning: false,
|
||||
input: ["text" as const],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function providersDiffer(previous: CustomProvider, current: CustomProvider): boolean {
|
||||
return JSON.stringify(toProviderConfig(previous)) !== JSON.stringify(toProviderConfig(current));
|
||||
}
|
||||
|
||||
export function registerCustomProviders(
|
||||
modelRegistry: ModelRegistryLike,
|
||||
customProviders: CustomProvider[] | undefined,
|
||||
logFn: (message: string) => void,
|
||||
): void {
|
||||
for (const provider of customProviders ?? []) {
|
||||
try {
|
||||
modelRegistry.registerProvider(provider.id, toProviderConfig(provider));
|
||||
logFn(`Registered custom provider ${provider.id}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logFn(`Failed to register custom provider ${provider.id}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
modelRegistry.refresh();
|
||||
}
|
||||
|
||||
export function reregisterCustomProviders(
|
||||
modelRegistry: ModelRegistryLike,
|
||||
previousProviders: CustomProvider[] | undefined,
|
||||
currentProviders: CustomProvider[] | undefined,
|
||||
logFn: (message: string) => void,
|
||||
): void {
|
||||
const previousById = new Map((previousProviders ?? []).map((provider) => [provider.id, provider]));
|
||||
|
||||
for (const provider of currentProviders ?? []) {
|
||||
const previous = previousById.get(provider.id);
|
||||
if (previous && !providersDiffer(previous, provider)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
modelRegistry.registerProvider(provider.id, toProviderConfig(provider));
|
||||
logFn(`${previous ? "Updated" : "Registered"} custom provider ${provider.id}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logFn(`Failed to register custom provider ${provider.id}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
modelRegistry.refresh();
|
||||
}
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
} from "./claude-cli-extension.js";
|
||||
import { getCachedUpdateStatus, isUpdateCheckEnabled } from "../update-cache.js";
|
||||
import { resolveSelfExtension } from "./self-extension.js";
|
||||
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
|
||||
import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo, type GitStatus, type GitCommit, type GitCommitDetail, type GitBranch, type GitWorktree, type FileEntry, type FileReadResult, type TaskStep as TUITaskStep, type TaskLogEntry as TUITaskLogEntry, type TaskDetailData, type TaskEvent } from "./dashboard-tui/index.js";
|
||||
|
||||
// Re-export for backward compatibility with tests
|
||||
@@ -1244,6 +1245,18 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
extensionsResult.runtime.pendingProviderRegistrations = [];
|
||||
modelRegistry.refresh();
|
||||
|
||||
try {
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
registerCustomProviders(
|
||||
modelRegistry,
|
||||
globalSettings.customProviders,
|
||||
(message) => logSink.log(message, "custom-providers"),
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
logSink.warn(`Failed to load custom providers from global settings: ${message}`, "custom-providers");
|
||||
}
|
||||
|
||||
// Eagerly sync OpenRouter models — the pi-openrouter-realtime extension
|
||||
// only registers providers on session_start (TUI-only event), so kick off
|
||||
// a fetch here so the dashboard model list is populated. Respects the
|
||||
@@ -1293,6 +1306,21 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
modelRegistry.refresh();
|
||||
}
|
||||
|
||||
registerHandler(store, "settings:updated", ({ settings, previous }) => {
|
||||
const currentProviders = settings.customProviders;
|
||||
const previousProviders = previous.customProviders;
|
||||
if (JSON.stringify(currentProviders ?? []) === JSON.stringify(previousProviders ?? [])) {
|
||||
return;
|
||||
}
|
||||
|
||||
reregisterCustomProviders(
|
||||
modelRegistry,
|
||||
previousProviders,
|
||||
currentProviders,
|
||||
(message) => logSink.log(message, "custom-providers"),
|
||||
);
|
||||
});
|
||||
|
||||
// ── Skills adapter for skills discovery and execution toggling ─────────────
|
||||
//
|
||||
// Create the skills adapter using the same DefaultPackageManager instance
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
setCachedClaudeCliResolution,
|
||||
} from "./claude-cli-extension.js";
|
||||
import { resolveSelfExtension } from "./self-extension.js";
|
||||
import { registerCustomProviders, reregisterCustomProviders } from "./custom-provider-registry.js";
|
||||
|
||||
const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
||||
let diagnosticIntervalHandle: ReturnType<typeof setInterval> | null = null;
|
||||
@@ -522,6 +523,18 @@ export async function runServe(
|
||||
extensionsResult.runtime.pendingProviderRegistrations = [];
|
||||
modelRegistry.refresh();
|
||||
|
||||
try {
|
||||
const globalSettings = await store.getGlobalSettingsStore().getSettings();
|
||||
registerCustomProviders(
|
||||
modelRegistry,
|
||||
globalSettings.customProviders,
|
||||
(message) => console.log(`[custom-providers] ${message}`),
|
||||
);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn(`[custom-providers] Failed to load custom providers from global settings: ${message}`);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const settings = await store.getSettings();
|
||||
@@ -606,6 +619,21 @@ export async function runServe(
|
||||
modelRegistry.refresh();
|
||||
}
|
||||
|
||||
store.on("settings:updated", ({ settings, previous }) => {
|
||||
const currentProviders = settings.customProviders;
|
||||
const previousProviders = previous.customProviders;
|
||||
if (JSON.stringify(currentProviders ?? []) === JSON.stringify(previousProviders ?? [])) {
|
||||
return;
|
||||
}
|
||||
|
||||
reregisterCustomProviders(
|
||||
modelRegistry,
|
||||
previousProviders,
|
||||
currentProviders,
|
||||
(message) => console.log(`[custom-providers] ${message}`),
|
||||
);
|
||||
});
|
||||
|
||||
// ── Daemon token resolution ─────────────────────────────────────────────
|
||||
//
|
||||
// When --daemon flag is set, resolve the daemon token using the same
|
||||
|
||||
@@ -131,7 +131,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
});
|
||||
|
||||
it("seeds lastModified", () => {
|
||||
@@ -154,7 +154,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
});
|
||||
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
@@ -761,7 +761,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -786,11 +786,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -825,7 +825,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -866,7 +866,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -935,7 +935,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -994,7 +994,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1068,7 +1068,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -1092,7 +1092,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -1196,7 +1196,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1647,7 +1647,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
|
||||
@@ -779,7 +779,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(53);
|
||||
expect(db1.getSchemaVersion()).toBe(54);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -814,7 +814,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(53);
|
||||
expect(db3.getSchemaVersion()).toBe(54);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -845,12 +845,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(53);
|
||||
expect(db1.getSchemaVersion()).toBe(54);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(53);
|
||||
expect(db2.getSchemaVersion()).toBe(54);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
|
||||
@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -742,7 +742,7 @@ describe("RoadmapStore", () => {
|
||||
|
||||
describe("schema version", () => {
|
||||
it("schema version is 40 after init", () => {
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(53);
|
||||
expect(db.getSchemaVersion()).toBe(54);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -36,7 +36,7 @@ import type {
|
||||
AgentRatingInput,
|
||||
Task,
|
||||
} from "./types.js";
|
||||
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError } from "./types.js";
|
||||
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH } from "./types.js";
|
||||
import type { RunMutationContext } from "./types.js";
|
||||
import type { TaskStore } from "./store.js";
|
||||
import { computeAccessState } from "./agent-permissions.js";
|
||||
@@ -115,6 +115,7 @@ interface AgentData {
|
||||
soul?: string;
|
||||
memory?: string;
|
||||
bundleConfig?: InstructionsBundleConfig;
|
||||
heartbeatProcedurePath?: string;
|
||||
}
|
||||
interface AgentRow {
|
||||
id: string;
|
||||
@@ -374,6 +375,13 @@ export class AgentStore extends EventEmitter {
|
||||
const metadata = input.metadata ?? {};
|
||||
const runtimeConfig = resolveCreationRuntimeConfig(input.runtimeConfig, metadata);
|
||||
|
||||
// Default heartbeatProcedurePath for new non-ephemeral agents so operators
|
||||
// get an editable HEARTBEAT.md file from day one. Ephemeral task workers
|
||||
// skip this — they're short-lived and don't need persistent procedure files.
|
||||
const ephemeral = isEphemeralAgent({ metadata, name: input.name, role: input.role, reportsTo: input.reportsTo });
|
||||
const resolvedHeartbeatProcedurePath = input.heartbeatProcedurePath
|
||||
?? (ephemeral ? undefined : DEFAULT_HEARTBEAT_PROCEDURE_PATH);
|
||||
|
||||
const agent: Agent = {
|
||||
id: agentId,
|
||||
name: input.name.trim(),
|
||||
@@ -392,6 +400,7 @@ export class AgentStore extends EventEmitter {
|
||||
...(input.soul && { soul: input.soul }),
|
||||
...(input.memory && { memory: input.memory }),
|
||||
...(input.bundleConfig && { bundleConfig: input.bundleConfig }),
|
||||
...(resolvedHeartbeatProcedurePath && { heartbeatProcedurePath: resolvedHeartbeatProcedurePath }),
|
||||
};
|
||||
|
||||
await this.writeAgent(agent);
|
||||
@@ -841,6 +850,7 @@ export class AgentStore extends EventEmitter {
|
||||
...(updates.soul !== undefined && { soul: updates.soul }),
|
||||
...(updates.memory !== undefined && { memory: updates.memory }),
|
||||
...("bundleConfig" in updates && { bundleConfig: updates.bundleConfig }),
|
||||
...("heartbeatProcedurePath" in updates && { heartbeatProcedurePath: updates.heartbeatProcedurePath }),
|
||||
};
|
||||
|
||||
await this.writeAgent(updated);
|
||||
@@ -1807,6 +1817,7 @@ export class AgentStore extends EventEmitter {
|
||||
| "soul"
|
||||
| "memory"
|
||||
| "bundleConfig"
|
||||
| "heartbeatProcedurePath"
|
||||
| "metadata"
|
||||
> {
|
||||
return {
|
||||
@@ -1827,6 +1838,7 @@ export class AgentStore extends EventEmitter {
|
||||
files: [...snapshot.bundleConfig.files],
|
||||
}
|
||||
: undefined,
|
||||
heartbeatProcedurePath: snapshot.heartbeatProcedurePath,
|
||||
metadata: { ...snapshot.metadata },
|
||||
};
|
||||
}
|
||||
@@ -2007,6 +2019,7 @@ export class AgentStore extends EventEmitter {
|
||||
soul: data.soul,
|
||||
memory: data.memory,
|
||||
bundleConfig: data.bundleConfig,
|
||||
heartbeatProcedurePath: data.heartbeatProcedurePath,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2035,6 +2048,7 @@ export class AgentStore extends EventEmitter {
|
||||
soul: agent.soul,
|
||||
memory: agent.memory,
|
||||
bundleConfig: agent.bundleConfig,
|
||||
heartbeatProcedurePath: agent.heartbeatProcedurePath,
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
|
||||
@@ -86,7 +86,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 53;
|
||||
const SCHEMA_VERSION = 54;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -189,6 +189,8 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
columnMovedAt TEXT,
|
||||
executionStartedAt TEXT,
|
||||
executionCompletedAt TEXT,
|
||||
-- JSON columns for nested arrays/objects
|
||||
dependencies TEXT DEFAULT '[]',
|
||||
steps TEXT DEFAULT '[]',
|
||||
@@ -1988,6 +1990,15 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
// Wall-clock end-to-end execution timestamps for card runtime display.
|
||||
// Set on first in-progress / done transitions, cleared only on retry.
|
||||
if (version < 54) {
|
||||
this.applyMigration(54, () => {
|
||||
this.addColumnIfMissing("tasks", "executionStartedAt", "TEXT");
|
||||
this.addColumnIfMissing("tasks", "executionCompletedAt", "TEXT");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
|
||||
export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, DEFAULT_HEARTBEAT_PROCEDURE_PATH, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, TASK_PRIORITIES, DEFAULT_TASK_PRIORITY, validateMessageMetadata, normalizeMergeConflictStrategy } from "./types.js";
|
||||
export type { Column, IssueInfo, IssueState, TaskSourceIssue, PrInfo, PrStatus, Task, TaskTokenUsage, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskSource, SourceType, TaskDetail, InboxTask, TodoList, TodoItem, TodoListCreateInput, TodoListUpdateInput, TodoItemCreateInput, TodoItemUpdateInput, TodoListWithItems, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, MergeConflictStrategy, CanonicalMergeConflictStrategy, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, TaskPriority, UnavailableNodePolicy, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, NotificationEvent, NotificationPayload, NotificationProviderConfig, CustomProvider, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js";
|
||||
export { AGENT_VALID_TRANSITIONS } from "./types.js";
|
||||
export {
|
||||
|
||||
@@ -69,6 +69,8 @@ interface TaskRow {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
columnMovedAt: string | null;
|
||||
executionStartedAt: string | null;
|
||||
executionCompletedAt: string | null;
|
||||
dependencies: string | null;
|
||||
steps: string | null;
|
||||
log: string | null;
|
||||
@@ -554,6 +556,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
columnMovedAt: row.columnMovedAt || undefined,
|
||||
executionStartedAt: row.executionStartedAt || undefined,
|
||||
executionCompletedAt: row.executionCompletedAt || undefined,
|
||||
dependencies: fromJson<string[]>(row.dependencies) || [],
|
||||
steps: fromJson<import("./types.js").TaskStep[]>(row.steps) || [],
|
||||
log: fromJson<import("./types.js").TaskLogEntry[]>(row.log) || [],
|
||||
@@ -662,6 +666,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
createdAt: entry.createdAt,
|
||||
updatedAt: entry.updatedAt,
|
||||
columnMovedAt: entry.columnMovedAt,
|
||||
executionStartedAt: entry.executionStartedAt,
|
||||
executionCompletedAt: entry.executionCompletedAt,
|
||||
modelPresetId: entry.modelPresetId,
|
||||
modelProvider: entry.modelProvider,
|
||||
modelId: entry.modelId,
|
||||
@@ -784,6 +790,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
createdAt: task.createdAt,
|
||||
updatedAt: task.updatedAt,
|
||||
columnMovedAt: task.columnMovedAt,
|
||||
executionStartedAt: task.executionStartedAt,
|
||||
executionCompletedAt: task.executionCompletedAt,
|
||||
archivedAt,
|
||||
modelPresetId: task.modelPresetId,
|
||||
modelProvider: task.modelProvider,
|
||||
@@ -853,7 +861,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "verificationFailureCount", "mergeConflictBounceCount", "nextRecoveryAt",
|
||||
"error", "summary", "thinkingLevel", "executionMode",
|
||||
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
|
||||
"createdAt", "updatedAt", "columnMovedAt",
|
||||
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
|
||||
"dependencies", "steps", "comments", "workflowStepResults", "steeringComments",
|
||||
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
||||
@@ -902,7 +910,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"mergeRetries", "workflowStepRetries", "stuckKillCount", "postReviewFixCount", "recoveryRetryCount", "taskDoneRetryCount", "verificationFailureCount", "mergeConflictBounceCount", "nextRecoveryAt",
|
||||
"error", "summary", "thinkingLevel", "executionMode",
|
||||
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
|
||||
"createdAt", "updatedAt", "columnMovedAt",
|
||||
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
|
||||
"dependencies", "steps", "attachments", "steeringComments",
|
||||
"comments", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
||||
@@ -945,12 +953,13 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
workflowStepRetries, stuckKillCount, postReviewFixCount, recoveryRetryCount, taskDoneRetryCount, verificationFailureCount, mergeConflictBounceCount, nextRecoveryAt, error,
|
||||
summary, thinkingLevel, executionMode, tokenUsageInputTokens, tokenUsageOutputTokens, tokenUsageCachedTokens,
|
||||
tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, createdAt, updatedAt, columnMovedAt,
|
||||
executionStartedAt, executionCompletedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, workflowStepResults, prInfo, issueInfo,
|
||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
@@ -996,6 +1005,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
createdAt = excluded.createdAt,
|
||||
updatedAt = excluded.updatedAt,
|
||||
columnMovedAt = excluded.columnMovedAt,
|
||||
executionStartedAt = excluded.executionStartedAt,
|
||||
executionCompletedAt = excluded.executionCompletedAt,
|
||||
dependencies = excluded.dependencies,
|
||||
steps = excluded.steps,
|
||||
log = excluded.log,
|
||||
@@ -1075,6 +1086,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.createdAt,
|
||||
task.updatedAt,
|
||||
task.columnMovedAt ?? null,
|
||||
task.executionStartedAt ?? null,
|
||||
task.executionCompletedAt ?? null,
|
||||
toJson(task.dependencies || []),
|
||||
toJson(task.steps || []),
|
||||
toJson(task.log || []),
|
||||
@@ -2661,6 +2674,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.columnMovedAt = new Date().toISOString();
|
||||
task.updatedAt = task.columnMovedAt;
|
||||
|
||||
// Wall-clock end-to-end runtime: set on first transition into in-progress
|
||||
// and first transition into done. Never overwritten — see retry-clear
|
||||
// logic below for the path that resets these for a fresh run.
|
||||
if (toColumn === "in-progress" && !task.executionStartedAt) {
|
||||
task.executionStartedAt = task.columnMovedAt;
|
||||
}
|
||||
if (toColumn === "done" && !task.executionCompletedAt) {
|
||||
task.executionCompletedAt = task.columnMovedAt;
|
||||
}
|
||||
|
||||
// Clear transient fields when moving to done (matches moveToDone behavior)
|
||||
if (toColumn === "done") {
|
||||
this.clearDoneTransientFields(task);
|
||||
@@ -2680,6 +2703,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
task.error = undefined;
|
||||
task.worktree = undefined;
|
||||
task.blockedBy = undefined;
|
||||
// Reset wall-clock runtime so the next run gets a fresh timer.
|
||||
task.executionStartedAt = undefined;
|
||||
task.executionCompletedAt = undefined;
|
||||
this.resetAllStepsToPending(task);
|
||||
await this.resetPromptCheckboxes(dir);
|
||||
}
|
||||
@@ -4068,6 +4094,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
this.clearDoneTransientFields(task);
|
||||
task.columnMovedAt = new Date().toISOString();
|
||||
task.updatedAt = task.columnMovedAt;
|
||||
if (!task.executionCompletedAt) {
|
||||
task.executionCompletedAt = task.columnMovedAt;
|
||||
}
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
|
||||
|
||||
@@ -1005,6 +1005,17 @@ export interface Task {
|
||||
/** ISO-8601 timestamp of when the task last entered its current column.
|
||||
* Used to sort cards within a column so that recently-moved cards appear at the top. */
|
||||
columnMovedAt?: string;
|
||||
/** ISO-8601 wall-clock timestamp when the task first entered `in-progress`.
|
||||
* Set on first transition into in-progress and never overwritten on retry,
|
||||
* so cards in in-progress / in-review / done can show end-to-end runtime
|
||||
* rather than just instrumented (`[timing]`) execution slices. Cleared
|
||||
* alongside `executionCompletedAt` when a task is reopened from
|
||||
* done/in-review back to todo/triage so a fresh run gets a fresh timer. */
|
||||
executionStartedAt?: string;
|
||||
/** ISO-8601 wall-clock timestamp when the task first entered `done`.
|
||||
* Set on first transition into done and never overwritten. Cleared
|
||||
* alongside `executionStartedAt` on reopen-for-retry. */
|
||||
executionCompletedAt?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -2005,6 +2016,10 @@ export interface ArchivedTaskEntry {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
columnMovedAt?: string;
|
||||
/** Wall-clock timestamps for end-to-end runtime — preserved through archive
|
||||
* so unarchived tasks still show their original execution duration. */
|
||||
executionStartedAt?: string;
|
||||
executionCompletedAt?: string;
|
||||
/** Timestamp when the task was archived to the log */
|
||||
archivedAt: string;
|
||||
/** Optional: model preset and override fields for executor and validator */
|
||||
@@ -2871,6 +2886,10 @@ export interface Agent {
|
||||
memory?: string;
|
||||
/** Structured instruction bundle configuration for managed/external markdown files. */
|
||||
bundleConfig?: InstructionsBundleConfig;
|
||||
/** Optional path to a markdown file containing this agent's per-tick heartbeat procedure
|
||||
* (overrides the default HEARTBEAT_PROCEDURE constant). Resolved relative to project root.
|
||||
* Must end in `.md`, no `..` traversal. Max 500 chars. */
|
||||
heartbeatProcedurePath?: string;
|
||||
}
|
||||
|
||||
/** Recursive node in the agent org tree. */
|
||||
@@ -2973,6 +2992,7 @@ export interface AgentCreateInput {
|
||||
soul?: string;
|
||||
memory?: string;
|
||||
bundleConfig?: InstructionsBundleConfig;
|
||||
heartbeatProcedurePath?: string;
|
||||
}
|
||||
|
||||
/** Input for updating an existing agent */
|
||||
@@ -2994,6 +3014,7 @@ export interface AgentUpdateInput {
|
||||
soul?: string;
|
||||
memory?: string;
|
||||
bundleConfig?: InstructionsBundleConfig;
|
||||
heartbeatProcedurePath?: string;
|
||||
}
|
||||
|
||||
/** An API key associated with an agent for bearer token authentication. */
|
||||
@@ -3086,6 +3107,7 @@ export interface AgentConfigSnapshot {
|
||||
soul?: string;
|
||||
memory?: string;
|
||||
bundleConfig?: InstructionsBundleConfig;
|
||||
heartbeatProcedurePath?: string;
|
||||
metadata: Record<string, unknown>;
|
||||
}
|
||||
|
||||
@@ -3118,6 +3140,15 @@ export interface AgentConfigRevision {
|
||||
rollbackToRevisionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Project-relative default path for the per-tick heartbeat procedure markdown
|
||||
* file. New non-ephemeral agents get this as their `heartbeatProcedurePath`,
|
||||
* and the engine seeds the file with the built-in HEARTBEAT_PROCEDURE constant
|
||||
* on first use so operators can edit it freely. Existing agents can be
|
||||
* upgraded onto this path via the dashboard's "Upgrade Heartbeat" action.
|
||||
*/
|
||||
export const DEFAULT_HEARTBEAT_PROCEDURE_PATH = ".fusion/HEARTBEAT.md";
|
||||
|
||||
/** Extract trackable config fields from an Agent into a snapshot */
|
||||
export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot {
|
||||
return {
|
||||
@@ -3138,6 +3169,7 @@ export function agentToConfigSnapshot(agent: Agent): AgentConfigSnapshot {
|
||||
files: [...agent.bundleConfig.files],
|
||||
}
|
||||
: undefined,
|
||||
heartbeatProcedurePath: agent.heartbeatProcedurePath,
|
||||
metadata: { ...agent.metadata },
|
||||
};
|
||||
}
|
||||
@@ -3160,6 +3192,7 @@ export function diffConfigSnapshots(
|
||||
"soul",
|
||||
"memory",
|
||||
"bundleConfig",
|
||||
"heartbeatProcedurePath",
|
||||
"metadata",
|
||||
];
|
||||
|
||||
|
||||
@@ -4229,6 +4229,17 @@ export function updateAgent(agentId: string, updates: AgentUpdateInput, projectI
|
||||
});
|
||||
}
|
||||
|
||||
/** Backfill an existing agent onto the default heartbeat procedure file. */
|
||||
export function upgradeAgentHeartbeatProcedure(
|
||||
agentId: string,
|
||||
projectId?: string,
|
||||
): Promise<{ agent: Agent; heartbeatProcedurePath: string; procedureFileSeeded: boolean }> {
|
||||
return api(
|
||||
withProjectId(`/agents/${encodeURIComponent(agentId)}/upgrade-heartbeat-procedure`, projectId),
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
|
||||
/** Update agent custom instructions */
|
||||
export function updateAgentInstructions(
|
||||
agentId: string,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import type { AgentDetail, AgentState, AgentHeartbeatRun, AgentBudgetStatus, ModelInfo, MemoryFileInfo, AgentCapability, PluginRuntimeInfo } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents } from "../api";
|
||||
import { fetchAgent, updateAgent, updateAgentState, deleteAgent, fetchAgentLogsWithMeta, fetchAgentRunLogs, fetchAgentChildren, fetchAgentRuns, fetchAgentRunDetail, startAgentRun, stopAgentRun, updateAgentInstructions, updateAgentSoul, updateAgentMemory, fetchAgentMemoryFiles, fetchAgentMemoryFile, saveAgentMemoryFile, fetchAgentTasks, fetchChainOfCommand, fetchAgentBudgetStatus, resetAgentBudget, fetchWorkspaceFileContent, saveWorkspaceFileContent, fetchModels, fetchPluginRuntimes, fetchAgents, upgradeAgentHeartbeatProcedure } from "../api";
|
||||
import type { Agent } from "../api";
|
||||
import type { AgentLogEntry, Task } from "@fusion/core";
|
||||
import { getErrorMessage } from "@fusion/core";
|
||||
@@ -2509,14 +2509,93 @@ function deriveBudgetValues(runtimeConfig: AgentDetail["runtimeConfig"] | undefi
|
||||
return nextValues;
|
||||
}
|
||||
|
||||
function ConfigTab({
|
||||
function HeartbeatProcedureSection({
|
||||
agent,
|
||||
projectId,
|
||||
addToast,
|
||||
onSaved,
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
onSaved: () => Promise<void>;
|
||||
}) {
|
||||
const [isUpgrading, setIsUpgrading] = useState(false);
|
||||
const currentPath = agent.heartbeatProcedurePath?.trim();
|
||||
const onDefault = currentPath === ".fusion/HEARTBEAT.md";
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
setIsUpgrading(true);
|
||||
try {
|
||||
const result = await upgradeAgentHeartbeatProcedure(agent.id, projectId);
|
||||
addToast(
|
||||
result.procedureFileSeeded
|
||||
? `Heartbeat procedure file ready at ${result.heartbeatProcedurePath}`
|
||||
: `Heartbeat procedure path set to ${result.heartbeatProcedurePath}`,
|
||||
"success",
|
||||
);
|
||||
await onSaved();
|
||||
} catch (err) {
|
||||
addToast(`Failed to upgrade heartbeat procedure: ${getErrorMessage(err)}`, "error");
|
||||
} finally {
|
||||
setIsUpgrading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="config-section">
|
||||
<h3>Heartbeat Procedure</h3>
|
||||
<p className="config-description">
|
||||
The per-tick procedure this agent runs every wake. Defaults to a project-level
|
||||
markdown file you can edit. Resets on every tick — no need to restart the agent
|
||||
after editing.
|
||||
</p>
|
||||
<div className="config-fields">
|
||||
<div className="config-field">
|
||||
<span className="config-hint">
|
||||
Current path: <code>{currentPath || "(none — using built-in default)"}</code>
|
||||
</span>
|
||||
</div>
|
||||
<div className="config-field">
|
||||
<button
|
||||
className="btn"
|
||||
disabled={isUpgrading || onDefault}
|
||||
onClick={() => void handleUpgrade()}
|
||||
aria-label="Upgrade agent to default heartbeat procedure file"
|
||||
>
|
||||
{isUpgrading ? (
|
||||
<>
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
Upgrading…
|
||||
</>
|
||||
) : onDefault ? (
|
||||
<>
|
||||
<CheckCircle size={16} />
|
||||
Already on default
|
||||
</>
|
||||
) : (
|
||||
"Upgrade to Default Heartbeat Procedure"
|
||||
)}
|
||||
</button>
|
||||
<span className="config-hint">
|
||||
Sets <code>heartbeatProcedurePath</code> to <code>.fusion/HEARTBEAT.md</code>
|
||||
{" "}and seeds the file from the built-in template if it doesn't exist.
|
||||
Operator edits to the file are preserved.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigTab({
|
||||
agent,
|
||||
projectId,
|
||||
addToast,
|
||||
onSaved,
|
||||
onHasChangesChange,
|
||||
onDelete,
|
||||
}: {
|
||||
}: {
|
||||
agent: AgentDetail;
|
||||
projectId?: string;
|
||||
addToast: (message: string, type?: "success" | "error") => void;
|
||||
@@ -3603,6 +3682,13 @@ function ConfigTab({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HeartbeatProcedureSection
|
||||
agent={agent}
|
||||
projectId={projectId}
|
||||
addToast={addToast}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
|
||||
<div className="config-section config-section--danger">
|
||||
<h3>Danger Zone</h3>
|
||||
<p className="config-description">
|
||||
|
||||
@@ -746,7 +746,14 @@ export function ModelOnboardingModal({
|
||||
const loadCustomProviders = useCallback(async () => {
|
||||
try {
|
||||
const data = await fetchCustomProviders();
|
||||
setCustomProviders(data.providers ?? []);
|
||||
setCustomProviders((data.providers ?? []).map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
|
||||
apiKey: provider.apiKey,
|
||||
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
||||
})));
|
||||
} catch {
|
||||
// best effort
|
||||
}
|
||||
|
||||
@@ -786,7 +786,14 @@ export function SettingsModal({
|
||||
if (activeSection === "authentication") {
|
||||
setAuthLoading(true);
|
||||
loadAuthStatus().finally(() => setAuthLoading(false));
|
||||
void fetchCustomProviders().then((data) => setCustomProviders(data.providers ?? [])).catch(() => undefined);
|
||||
void fetchCustomProviders().then((data) => setCustomProviders((data.providers ?? []).map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
|
||||
apiKey: provider.apiKey,
|
||||
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
||||
})))).catch(() => undefined);
|
||||
}
|
||||
// Clean up polling when leaving auth section
|
||||
return () => {
|
||||
@@ -799,7 +806,14 @@ export function SettingsModal({
|
||||
|
||||
const loadCustomProviders = useCallback(async () => {
|
||||
const data = await fetchCustomProviders();
|
||||
setCustomProviders(data.providers ?? []);
|
||||
setCustomProviders((data.providers ?? []).map((provider) => ({
|
||||
id: provider.id,
|
||||
name: provider.name,
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic-messages" : "openai-completions",
|
||||
apiKey: provider.apiKey,
|
||||
models: (provider.models ?? []).map((model) => ({ id: model.id, name: model.name })),
|
||||
})));
|
||||
}, []);
|
||||
|
||||
const handleSaveCustomProvider = useCallback(async (config: CustomProviderConfig) => {
|
||||
|
||||
@@ -125,6 +125,21 @@ function getInProgressElapsedMs(task: Task, nowMs: number): number | null {
|
||||
return Math.max(0, nowMs - startedMs);
|
||||
}
|
||||
|
||||
// Wall-clock end-to-end runtime: from when the task first entered in-progress
|
||||
// to when it first entered done (or `now` if not yet done). Preferred over the
|
||||
// instrumented `[timing]` sum on cards in in-progress / in-review / done so the
|
||||
// timer reflects how long the task actually took, not just the time spent
|
||||
// inside instrumented code paths. Returns null on legacy tasks that completed
|
||||
// before `executionStartedAt` was tracked, so callers can fall back.
|
||||
function getEndToEndDurationMs(task: Task, nowMs: number): number | null {
|
||||
const startedMs = parseTimestampToMs(task.executionStartedAt);
|
||||
if (startedMs == null) return null;
|
||||
|
||||
const completedMs = parseTimestampToMs(task.executionCompletedAt);
|
||||
const endMs = completedMs != null && completedMs >= startedMs ? completedMs : nowMs;
|
||||
return Math.max(0, endMs - startedMs);
|
||||
}
|
||||
|
||||
// Mirrors summarizeWorkflowTiming in TaskTokenStatsPanel: completed steps use
|
||||
// completedAt-startedAt; in-progress steps contribute live elapsed (now-startedAt).
|
||||
function getWorkflowRuntimeMs(task: Task, nowMs: number): number | null {
|
||||
@@ -697,16 +712,18 @@ function TaskCardComponent({
|
||||
const merging = task.status != null && ACTIVE_MERGE_STATUSES.has(task.status);
|
||||
|
||||
if (!merging && task.column === "in-progress") {
|
||||
const endToEndMs = getEndToEndDurationMs(task, Date.now());
|
||||
const elapsedMs = getInProgressElapsedMs(task, Date.now());
|
||||
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
|
||||
if (elapsedMs == null && instrumentedMs == null) {
|
||||
if (endToEndMs == null && elapsedMs == null && instrumentedMs == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!merging && task.column === "in-review") {
|
||||
const endToEndMs = getEndToEndDurationMs(task, Date.now());
|
||||
const instrumentedMs = getInstrumentedDurationMs(task, Date.now());
|
||||
if (instrumentedMs == null) {
|
||||
if (endToEndMs == null && instrumentedMs == null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -717,7 +734,7 @@ function TaskCardComponent({
|
||||
}, LIVE_TIME_INDICATOR_POLL_MS);
|
||||
|
||||
return () => window.clearInterval(interval);
|
||||
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs]);
|
||||
}, [task.column, task.status, task.columnMovedAt, task.updatedAt, task.workflowStepResults, task.timedExecutionMs, task.executionStartedAt, task.executionCompletedAt]);
|
||||
|
||||
const timeIndicator = useMemo(() => {
|
||||
if (!TIME_INDICATOR_COLUMNS.has(task.column)) {
|
||||
@@ -744,8 +761,12 @@ function TaskCardComponent({
|
||||
}
|
||||
|
||||
if (task.column === "in-progress") {
|
||||
// Prefer the persistent execution start (set on first transition to
|
||||
// in-progress, never reset on retry-loop bounces). Fall back to the
|
||||
// columnMovedAt heuristic for legacy tasks predating the new field.
|
||||
const elapsedMs =
|
||||
getInProgressElapsedMs(task, timeIndicatorNowMs)
|
||||
getEndToEndDurationMs(task, timeIndicatorNowMs)
|
||||
?? getInProgressElapsedMs(task, timeIndicatorNowMs)
|
||||
?? getInstrumentedDurationMs(task, timeIndicatorNowMs);
|
||||
if (elapsedMs == null) {
|
||||
return null;
|
||||
@@ -756,20 +777,23 @@ function TaskCardComponent({
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasColumnElapsed = getInProgressElapsedMs(task, timeIndicatorNowMs) != null;
|
||||
return {
|
||||
label: elapsedLabel,
|
||||
title: hasColumnElapsed ? `In progress ${elapsedLabel}` : `Execution time ${elapsedLabel}`,
|
||||
ariaLabel: hasColumnElapsed ? `In progress ${elapsedLabel}` : `Execution time ${elapsedLabel}`,
|
||||
title: `In progress ${elapsedLabel}`,
|
||||
ariaLabel: `In progress ${elapsedLabel}`,
|
||||
};
|
||||
}
|
||||
|
||||
const instrumentedMs = getInstrumentedDurationMs(task, timeIndicatorNowMs);
|
||||
if (instrumentedMs == null) {
|
||||
// in-review and done: show wall-clock end-to-end runtime. Falls back to
|
||||
// the instrumented `[timing]` aggregate for tasks completed before
|
||||
// `executionStartedAt`/`executionCompletedAt` were tracked.
|
||||
const endToEndMs = getEndToEndDurationMs(task, timeIndicatorNowMs);
|
||||
const totalMs = endToEndMs ?? getInstrumentedDurationMs(task, timeIndicatorNowMs);
|
||||
if (totalMs == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elapsedLabel = formatElapsedDurationDone(instrumentedMs);
|
||||
const elapsedLabel = formatElapsedDurationDone(totalMs);
|
||||
if (!elapsedLabel) {
|
||||
return null;
|
||||
}
|
||||
@@ -789,7 +813,7 @@ function TaskCardComponent({
|
||||
title: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
|
||||
ariaLabel: `Execution time ${elapsedLabel}. Completed ${completedAt}`,
|
||||
};
|
||||
}, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, timeIndicatorNowMs]);
|
||||
}, [task.column, task.status, task.columnMovedAt, task.timedExecutionMs, task.updatedAt, task.workflowStepResults, task.log, task.executionStartedAt, task.executionCompletedAt, timeIndicatorNowMs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasGitHubBadge || !isInViewport) {
|
||||
|
||||
@@ -8546,6 +8546,40 @@ describe("Git Management endpoints", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/changes", () => {
|
||||
const resetGitRepo = () => {
|
||||
const { headSha } = getSharedGitTestRepo();
|
||||
execFileSync("git", ["-C", gitRepoDir, "reset", "--hard", headSha], { stdio: "pipe" });
|
||||
execFileSync("git", ["-C", gitRepoDir, "clean", "-fd"], { stdio: "pipe" });
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
resetGitRepo();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetGitRepo();
|
||||
});
|
||||
|
||||
it("preserves the first unstaged entry instead of misclassifying it as staged", async () => {
|
||||
const readmePath = join(gitRepoDir, "README.md");
|
||||
const original = readFileSync(readmePath, "utf-8");
|
||||
const marker = `\nchanges-first-line-${Date.now()}\n`;
|
||||
writeFileSync(readmePath, `${original}${marker}`);
|
||||
|
||||
const res = await GET(buildApp(), "/api/git/changes");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual([
|
||||
{
|
||||
file: "README.md",
|
||||
status: "modified",
|
||||
staged: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GET /git/diff/file", () => {
|
||||
const resetGitRepo = () => {
|
||||
const { headSha } = getSharedGitTestRepo();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { Agent, AgentCapability, AgentUpdateInput, TaskStore } from "@fusion/core";
|
||||
import { DEFAULT_HEARTBEAT_PROCEDURE_PATH } from "@fusion/core";
|
||||
import { ApiError, badRequest, notFound } from "../api-error.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
|
||||
@@ -66,6 +67,7 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
soul,
|
||||
memory,
|
||||
bundleConfig,
|
||||
heartbeatProcedurePath,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
@@ -107,6 +109,12 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
if (typeof memory === "string" && memory.length > 50000) {
|
||||
throw badRequest("memory must be at most 50,000 characters");
|
||||
}
|
||||
if (heartbeatProcedurePath !== undefined && heartbeatProcedurePath !== null && typeof heartbeatProcedurePath !== "string") {
|
||||
throw badRequest("heartbeatProcedurePath must be a string");
|
||||
}
|
||||
if (typeof heartbeatProcedurePath === "string" && heartbeatProcedurePath.length > 500) {
|
||||
throw badRequest("heartbeatProcedurePath must be at most 500 characters");
|
||||
}
|
||||
if (bundleConfig !== undefined && bundleConfig !== null) {
|
||||
if (typeof bundleConfig !== "object" || Array.isArray(bundleConfig)) {
|
||||
throw badRequest("bundleConfig must be an object");
|
||||
@@ -144,7 +152,21 @@ export function registerAgentCoreListCreateRoutes(ctx: ApiRoutesContext, deps: A
|
||||
soul: soul ?? undefined,
|
||||
memory: memory ?? undefined,
|
||||
bundleConfig: bundleConfig ?? undefined,
|
||||
heartbeatProcedurePath: heartbeatProcedurePath ?? undefined,
|
||||
});
|
||||
|
||||
// Seed the default heartbeat procedure file if the new agent landed on
|
||||
// the default path (which createAgent fills in for non-ephemeral agents
|
||||
// when no override is provided). Idempotent — operator edits are kept.
|
||||
if (agent.heartbeatProcedurePath === DEFAULT_HEARTBEAT_PROCEDURE_PATH) {
|
||||
try {
|
||||
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
|
||||
await ensureDefaultHeartbeatProcedureFile(scopedStore.getRootDir(), DEFAULT_HEARTBEAT_PROCEDURE_PATH, HEARTBEAT_PROCEDURE);
|
||||
} catch {
|
||||
// Non-fatal — the heartbeat resolver falls back to the in-memory constant.
|
||||
}
|
||||
}
|
||||
|
||||
res.status(201).json(agent);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
@@ -394,6 +416,16 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
|
||||
updates.memory = body.memory ?? undefined;
|
||||
}
|
||||
|
||||
if ("heartbeatProcedurePath" in body) {
|
||||
if (body.heartbeatProcedurePath !== null && typeof body.heartbeatProcedurePath !== "string") {
|
||||
throw badRequest("heartbeatProcedurePath must be a string");
|
||||
}
|
||||
if (typeof body.heartbeatProcedurePath === "string" && body.heartbeatProcedurePath.length > 500) {
|
||||
throw badRequest("heartbeatProcedurePath must be at most 500 characters");
|
||||
}
|
||||
updates.heartbeatProcedurePath = body.heartbeatProcedurePath ?? undefined;
|
||||
}
|
||||
|
||||
if ("bundleConfig" in body) {
|
||||
if (body.bundleConfig !== null) {
|
||||
if (typeof body.bundleConfig !== "object" || Array.isArray(body.bundleConfig)) {
|
||||
@@ -436,6 +468,52 @@ export function registerAgentCoreRoutes(ctx: ApiRoutesContext, deps: AgentCoreRo
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/agents/:id/upgrade-heartbeat-procedure
|
||||
* Backfill an existing agent onto the default heartbeat procedure file.
|
||||
* Sets `heartbeatProcedurePath` to DEFAULT_HEARTBEAT_PROCEDURE_PATH and
|
||||
* seeds the file with the built-in HEARTBEAT_PROCEDURE if it doesn't exist.
|
||||
* Idempotent: existing operator edits to the file are preserved.
|
||||
*/
|
||||
router.post("/agents/:id/upgrade-heartbeat-procedure", async (req, res) => {
|
||||
try {
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
await agentStore.init();
|
||||
|
||||
const existing = await agentStore.getAgent(req.params.id);
|
||||
if (!existing) {
|
||||
throw notFound(`agent ${req.params.id} not found`);
|
||||
}
|
||||
|
||||
const { ensureDefaultHeartbeatProcedureFile, HEARTBEAT_PROCEDURE } = await import("@fusion/engine");
|
||||
const filePath = await ensureDefaultHeartbeatProcedureFile(
|
||||
scopedStore.getRootDir(),
|
||||
DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||
HEARTBEAT_PROCEDURE,
|
||||
);
|
||||
|
||||
const updated = await agentStore.updateAgent(req.params.id, {
|
||||
heartbeatProcedurePath: DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||
});
|
||||
|
||||
res.json({
|
||||
agent: updated,
|
||||
heartbeatProcedurePath: DEFAULT_HEARTBEAT_PROCEDURE_PATH,
|
||||
procedureFileSeeded: filePath !== null,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
}
|
||||
if ((err instanceof Error ? err.message : String(err)).includes("not found")) {
|
||||
throw notFound(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
rethrowAsApiError(err);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/agents/:id
|
||||
* Delete an agent.
|
||||
|
||||
@@ -717,15 +717,19 @@ export async function dropGitStash(index: number, cwd?: string): Promise<string>
|
||||
|
||||
export async function getGitFileChanges(cwd?: string): Promise<GitFileChange[]> {
|
||||
try {
|
||||
const output = (await runGitCommand(["status", "--porcelain=v1"], cwd, 5000)).trim();
|
||||
if (!output) return [];
|
||||
const output = await runGitCommand(["status", "--porcelain=v1"], cwd, 5000);
|
||||
if (!output.trim()) return [];
|
||||
|
||||
const changes: GitFileChange[] = [];
|
||||
for (const line of output.split("\n")) {
|
||||
if (line.length < 3) continue;
|
||||
const indexStatus = line[0];
|
||||
const workTreeStatus = line[1];
|
||||
const filePath = line.slice(3).trim();
|
||||
// Preserve leading status spaces from porcelain output. Trimming the
|
||||
// whole command output corrupts the first unstaged entry (`" M foo"` →
|
||||
// `"M foo"`), which misclassifies it as staged and truncates the path.
|
||||
const normalizedLine = line.replace(/\r$/, "");
|
||||
if (normalizedLine.length < 3) continue;
|
||||
const indexStatus = normalizedLine[0];
|
||||
const workTreeStatus = normalizedLine[1];
|
||||
const filePath = normalizedLine.slice(3).trim();
|
||||
|
||||
const mapStatus = (code: string): GitFileChange["status"] => {
|
||||
switch (code) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type HeartbeatExecutionOptions,
|
||||
HEARTBEAT_SYSTEM_PROMPT,
|
||||
HEARTBEAT_NO_TASK_SYSTEM_PROMPT,
|
||||
HEARTBEAT_PROCEDURE,
|
||||
} from "../agent-heartbeat.js";
|
||||
import { AgentLogger } from "../agent-logger.js";
|
||||
import * as agentTools from "../agent-tools.js";
|
||||
@@ -1655,6 +1656,10 @@ describe("HeartbeatMonitor", () => {
|
||||
// Should NOT include task-specific content
|
||||
expect(executionPrompt).not.toContain("Assigned task:");
|
||||
expect(executionPrompt).not.toContain("Task description:");
|
||||
// Should include Wake Delta + Heartbeat Procedure (paperclip-style per-tick anchoring)
|
||||
expect(executionPrompt).toContain("## Wake Delta");
|
||||
expect(executionPrompt).toContain("wake reason:");
|
||||
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
|
||||
});
|
||||
|
||||
it("task-scoped run receives HEARTBEAT_SYSTEM_PROMPT as system prompt", async () => {
|
||||
@@ -2123,6 +2128,60 @@ describe("HeartbeatMonitor", () => {
|
||||
expect(executionPrompt).toContain("Pending Messages:");
|
||||
expect(executionPrompt).toContain("[id: msg-1] [from: agent:agent-2] Hello from agent-2");
|
||||
expect(executionPrompt).toContain("[id: msg-2] [from: user:user-1] Hello from user");
|
||||
// Task-scoped prompts must include Wake Delta + Heartbeat Procedure so
|
||||
// the agent re-runs its procedure each tick instead of grinding on the
|
||||
// assigned task (paperclip-parity).
|
||||
expect(executionPrompt).toContain("## Wake Delta");
|
||||
expect(executionPrompt).toContain("wake reason: message_received");
|
||||
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
|
||||
});
|
||||
|
||||
it("substitutes per-agent heartbeatProcedurePath content for the default procedure", async () => {
|
||||
const tmpRoot = mkdtempSync(join(tmpdir(), "fn-hb-procedure-"));
|
||||
try {
|
||||
const customProcedure = "## Custom CEO Procedure\n\n1. Review reports\n2. Update strategy\n3. Exit";
|
||||
writeFileSync(join(tmpRoot, "MY-PROCEDURE.md"), customProcedure, "utf-8");
|
||||
|
||||
const store = createStoreWithAgentForExec({
|
||||
heartbeatProcedurePath: "MY-PROCEDURE.md",
|
||||
});
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: tmpRoot });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
const promptCalls = mockSession.prompt.mock.calls;
|
||||
expect(promptCalls.length).toBeGreaterThan(0);
|
||||
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||
|
||||
// Custom procedure should appear; default constant should not.
|
||||
expect(executionPrompt).toContain("## Custom CEO Procedure");
|
||||
expect(executionPrompt).toContain("1. Review reports");
|
||||
expect(executionPrompt).not.toContain(HEARTBEAT_PROCEDURE);
|
||||
// Wake Delta still rendered.
|
||||
expect(executionPrompt).toContain("## Wake Delta");
|
||||
} finally {
|
||||
rmSync(tmpRoot, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to default procedure when heartbeatProcedurePath is invalid (traversal)", async () => {
|
||||
const store = createStoreWithAgentForExec({
|
||||
heartbeatProcedurePath: "../escape.md",
|
||||
});
|
||||
const mockSession = createMockAgentSession();
|
||||
mockedCreateFnAgent.mockResolvedValue({ session: mockSession as any });
|
||||
|
||||
const monitor = new HeartbeatMonitor({ store, taskStore: mockTaskStore, rootDir: "/tmp" });
|
||||
const result = await monitor.executeHeartbeat({ agentId: "agent-001", source: "timer" });
|
||||
|
||||
expect(result.status).toBe("completed");
|
||||
const promptCalls = mockSession.prompt.mock.calls;
|
||||
const executionPrompt = promptCalls[promptCalls.length - 1][0];
|
||||
// Invalid path → fall back to the default constant.
|
||||
expect(executionPrompt).toContain(HEARTBEAT_PROCEDURE);
|
||||
});
|
||||
|
||||
it("does not include message section when no unread messages", async () => {
|
||||
|
||||
@@ -21,6 +21,7 @@ const reloadMock = vi.fn(async () => {});
|
||||
const execSyncMock = vi.fn((_cmd?: any, _opts?: any) => "");
|
||||
const existsSyncMock = vi.fn((_path: PathLike) => false);
|
||||
const readFileSyncMock = vi.fn((_path?: any) => "{}");
|
||||
const readCustomProvidersMock = vi.fn(() => []);
|
||||
|
||||
// Route async `exec` through the `execSync` mock so the promisify bridge works.
|
||||
// Use Symbol.for("nodejs.util.promisify.custom") directly to avoid async imports
|
||||
@@ -68,6 +69,10 @@ vi.mock("node:fs", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../custom-providers.js", () => ({
|
||||
readCustomProviders: readCustomProvidersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
||||
AuthStorage: {
|
||||
create: () => ({
|
||||
@@ -382,6 +387,7 @@ describe("createFnAgent", () => {
|
||||
execSyncMock.mockReturnValue("");
|
||||
existsSyncMock.mockReturnValue(false);
|
||||
readFileSyncMock.mockReturnValue("{}");
|
||||
readCustomProvidersMock.mockReturnValue([]);
|
||||
findMock.mockImplementation((provider: string, modelId: string) => ({ provider, id: modelId }));
|
||||
createAgentSessionMock.mockResolvedValue({
|
||||
session: {
|
||||
@@ -484,6 +490,36 @@ describe("createFnAgent", () => {
|
||||
expect(refreshMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers custom providers from global settings", async () => {
|
||||
readCustomProvidersMock.mockReturnValue([
|
||||
{
|
||||
id: "custom-openai",
|
||||
name: "Custom OpenAI",
|
||||
apiType: "openai-compatible",
|
||||
baseUrl: "https://custom.example/v1",
|
||||
apiKey: "CUSTOM_API_KEY",
|
||||
models: [{ id: "custom-model", name: "Custom Model" }],
|
||||
},
|
||||
] as any);
|
||||
|
||||
const { createFnAgent } = await import("../pi.js");
|
||||
|
||||
await createFnAgent({
|
||||
cwd: "/tmp",
|
||||
systemPrompt: "test",
|
||||
tools: "readonly",
|
||||
defaultProvider: "openai-codex",
|
||||
defaultModelId: "gpt-5.4",
|
||||
});
|
||||
|
||||
expect(registerProviderMock).toHaveBeenCalledWith("custom-openai", expect.objectContaining({
|
||||
baseUrl: "https://custom.example/v1",
|
||||
api: "openai-completions",
|
||||
apiKey: "CUSTOM_API_KEY",
|
||||
models: [expect.objectContaining({ id: "custom-model", name: "Custom Model" })],
|
||||
}));
|
||||
});
|
||||
|
||||
it("avoids lock-based SettingsManager.create when loading extension providers", async () => {
|
||||
const { createFnAgent } = await import("../pi.js");
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import type { ToolDefinition } from "@mariozechner/pi-coding-agent";
|
||||
import { Type, type Static } from "@mariozechner/pi-ai";
|
||||
import { createTaskCreateTool, createTaskLogToolWithContext, createTaskDocumentWriteTool, createTaskDocumentReadTool, createListAgentsTool, createDelegateTaskTool, createSendMessageTool, createReadMessagesTool, createMemoryTools, taskCreateParams } from "./agent-tools.js";
|
||||
import { AgentLogger } from "./agent-logger.js";
|
||||
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions } from "./agent-instructions.js";
|
||||
import { resolveAgentInstructionsWithRatings, buildSystemPromptWithInstructions, resolveAgentHeartbeatProcedure } from "./agent-instructions.js";
|
||||
import { heartbeatLog, formatError } from "./logger.js";
|
||||
import { createRunAuditor, type EngineRunContext } from "./run-audit.js";
|
||||
import { promptWithFallback } from "./pi.js";
|
||||
@@ -285,6 +285,37 @@ When sending messages:
|
||||
// Backward-compatible alias; prefer HEARTBEAT_NO_TASK_SYSTEM_PROMPT.
|
||||
export const HEARTBEAT_SYSTEM_PROMPT_NO_TASK = HEARTBEAT_NO_TASK_SYSTEM_PROMPT;
|
||||
|
||||
/**
|
||||
* Per-tick heartbeat procedure appended to every execution prompt. Forces the
|
||||
* agent to re-anchor on its own operating procedure each wake instead of
|
||||
* silently grinding on a previously assigned task.
|
||||
*/
|
||||
export const HEARTBEAT_PROCEDURE = `## Heartbeat Procedure (run every tick, in order)
|
||||
|
||||
1. **Identity & context** — review your soul, instructions, and memory (already
|
||||
loaded in the system prompt). Confirm who you are and what you're responsible
|
||||
for before continuing prior work.
|
||||
2. **Inbox** — when fn_read_messages is available, call it. Process any pending
|
||||
messages first; reply with reply_to_message_id when answering.
|
||||
3. **Wake delta** — read the Wake Delta block above. The wake reason is the
|
||||
highest-priority change for this heartbeat. If you were woken by a comment
|
||||
or a message, acknowledge it before doing anything else.
|
||||
4. **Assignment review** — if you have an assigned task, re-read its current
|
||||
description, latest comments, and any task documents. Decide whether the
|
||||
prior plan is still valid given the wake delta. Do not assume yesterday's
|
||||
plan is still correct.
|
||||
5. **Pick the next concrete action** — exactly ONE useful action this heartbeat:
|
||||
advance the task, create a follow-up, log findings, delegate, or update
|
||||
memory. Don't stop at planning unless the task is a planning task.
|
||||
6. **Persist progress** — fn_task_log for observations, fn_task_document_write
|
||||
for durable findings, status updates only when the work warrants it.
|
||||
7. **Exit** — call fn_heartbeat_done with a one-line summary of what changed
|
||||
this tick. If you took no action, say so and explain why.
|
||||
|
||||
Critical: a heartbeat without observable progress (a log, a document write, a
|
||||
status change, a comment, a delegation, or an explicit "no-op with reason") is
|
||||
a bug. Do not loop on the same plan across heartbeats without recording why.`;
|
||||
|
||||
/** Parameter schema for the fn_heartbeat_done tool */
|
||||
const heartbeatDoneParams = Type.Object({
|
||||
summary: Type.Optional(Type.String({ description: "Summary of what was accomplished this heartbeat" })),
|
||||
@@ -1337,6 +1368,30 @@ export class HeartbeatMonitor {
|
||||
let pendingMessages: Message[] = [];
|
||||
let executionPrompt: string;
|
||||
|
||||
// Derive a stable wake reason from source, triggerDetail, and trigger
|
||||
// type so the agent can change its strategy based on *why* it woke up.
|
||||
// Mirrors paperclip's PAPERCLIP_WAKE_REASON (see plan: wake delta).
|
||||
const deriveWakeReason = (): string => {
|
||||
if (effectiveTriggeringCommentType) return `comment_${effectiveTriggeringCommentType}`;
|
||||
if (triggerDetail === "wake-on-message") return "message_received";
|
||||
if (triggerDetail === "wake-on-comment") return "comment_mention";
|
||||
if (triggerDetail === "task-assigned") return "task_assigned";
|
||||
if (source === "timer") return "timer";
|
||||
if (source === "assignment") return "task_assigned";
|
||||
if (source === "automation") return "automation";
|
||||
if (source === "routine") return "routine";
|
||||
return triggerDetail || source;
|
||||
};
|
||||
const wakeReason = deriveWakeReason();
|
||||
|
||||
// Per-agent override of the default HEARTBEAT_PROCEDURE: if the agent
|
||||
// configured a heartbeatProcedurePath pointing to a markdown file in
|
||||
// the project, use that instead. Reloaded fresh each tick (matches the
|
||||
// existing instructionsPath/instructionsText reload contract) so an
|
||||
// operator can iterate on procedure text without restarting agents.
|
||||
const customProcedure = await resolveAgentHeartbeatProcedure(agent, rootDir);
|
||||
const heartbeatProcedureText = customProcedure ?? HEARTBEAT_PROCEDURE;
|
||||
|
||||
if (isNoTaskRun) {
|
||||
// No-task heartbeat: agent has identity but no assigned task
|
||||
// Fetch unread messages when messageStore is available (for all trigger types)
|
||||
@@ -1365,6 +1420,16 @@ export class HeartbeatMonitor {
|
||||
`Heartbeat execution for agent "${agent.name}" (ID: ${agent.id})`,
|
||||
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
"",
|
||||
"## Wake Delta",
|
||||
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
`- wake reason: ${wakeReason}`,
|
||||
`- assigned task: none`,
|
||||
`- pending messages: ${pendingMessages.length}`,
|
||||
"",
|
||||
"Treat this wake delta as the highest-priority change for this heartbeat.",
|
||||
"Run the Heartbeat Procedure (below) before doing anything else — even a",
|
||||
"timer-only wake should re-check messages, memory, and project state.",
|
||||
"",
|
||||
"**No assigned task** — This heartbeat run has no task assignment.",
|
||||
"",
|
||||
"You have identity (soul, instructions, and/or memory) loaded, which means you can perform",
|
||||
@@ -1388,6 +1453,9 @@ export class HeartbeatMonitor {
|
||||
"",
|
||||
"Your soul, instructions, and memory are already loaded in the system prompt.",
|
||||
"Focus on work that benefits the project without requiring a specific task context.",
|
||||
"",
|
||||
heartbeatProcedureText,
|
||||
"",
|
||||
"Call fn_heartbeat_done when finished.",
|
||||
].join("\n");
|
||||
} else {
|
||||
@@ -1450,6 +1518,18 @@ export class HeartbeatMonitor {
|
||||
`Source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
`Assigned task: ${taskId} — ${taskTitle}`,
|
||||
"",
|
||||
"## Wake Delta",
|
||||
`- source: ${source}${triggerDetail ? ` (${triggerDetail})` : ""}`,
|
||||
`- wake reason: ${wakeReason}`,
|
||||
`- assigned task: ${taskId}`,
|
||||
`- pending messages: ${pendingMessages.length}`,
|
||||
`- triggering comments: ${effectiveTriggeringCommentIds?.length ?? 0}`,
|
||||
"",
|
||||
"Treat this wake delta as the highest-priority change for this heartbeat.",
|
||||
"Before resuming prior task work, run the Heartbeat Procedure (below) and",
|
||||
"decide what action this delta requires. Your assigned task is one input",
|
||||
"to the procedure — not the only thing to consider.",
|
||||
"",
|
||||
"Task description:",
|
||||
taskDetail!.description,
|
||||
"",
|
||||
@@ -1457,7 +1537,9 @@ export class HeartbeatMonitor {
|
||||
...triggeringCommentLines,
|
||||
...pendingMessagesLines,
|
||||
"",
|
||||
"Review the task status and take appropriate action. Call fn_heartbeat_done when finished.",
|
||||
heartbeatProcedureText,
|
||||
"",
|
||||
"Run the Heartbeat Procedure above. Call fn_heartbeat_done when finished.",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { isAbsolute, resolve, relative, normalize, sep } from "node:path";
|
||||
import { readFile, writeFile, mkdir, access } from "node:fs/promises";
|
||||
import { constants as fsConstants } from "node:fs";
|
||||
import { isAbsolute, resolve, relative, normalize, sep, dirname } from "node:path";
|
||||
import {
|
||||
readProjectMemory,
|
||||
type Agent,
|
||||
@@ -32,7 +33,12 @@ function isPathTraversal(path: string): boolean {
|
||||
return path.split(/[\\/]+/).includes("..");
|
||||
}
|
||||
|
||||
function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null {
|
||||
function resolveValidatedMarkdownPath(
|
||||
rawPath: string,
|
||||
rootDir: string,
|
||||
agentId: string,
|
||||
fieldLabel: string,
|
||||
): string | null {
|
||||
const trimmed = rawPath.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
@@ -40,37 +46,118 @@ function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agen
|
||||
|
||||
if (trimmed.length > MAX_INSTRUCTIONS_PATH_LENGTH) {
|
||||
log.warn(
|
||||
`instructionsPath too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`,
|
||||
`${fieldLabel} too long for agent ${agentId} (${trimmed.length} > ${MAX_INSTRUCTIONS_PATH_LENGTH})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!trimmed.toLowerCase().endsWith(".md")) {
|
||||
log.warn(`instructionsPath must end in .md for agent ${agentId}: ${trimmed}`);
|
||||
log.warn(`${fieldLabel} must end in .md for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isAbsolute(trimmed)) {
|
||||
log.warn(`instructionsPath must be project-relative for agent ${agentId}: ${trimmed}`);
|
||||
log.warn(`${fieldLabel} must be project-relative for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = normalize(trimmed);
|
||||
if (isPathTraversal(normalized)) {
|
||||
log.warn(`instructionsPath traversal is not allowed for agent ${agentId}: ${trimmed}`);
|
||||
log.warn(`${fieldLabel} traversal is not allowed for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedPath = resolve(rootDir, normalized);
|
||||
const rel = relative(rootDir, resolvedPath);
|
||||
if (!rel || rel.startsWith(`..${sep}`) || rel === ".." || isAbsolute(rel)) {
|
||||
log.warn(`instructionsPath escapes project root for agent ${agentId}: ${trimmed}`);
|
||||
log.warn(`${fieldLabel} escapes project root for agent ${agentId}: ${trimmed}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return resolvedPath;
|
||||
}
|
||||
|
||||
function resolveValidatedInstructionsPath(rawPath: string, rootDir: string, agentId: string): string | null {
|
||||
return resolveValidatedMarkdownPath(rawPath, rootDir, agentId, "instructionsPath");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a per-agent heartbeat procedure markdown file. Returns the file
|
||||
* contents (trimmed/clamped to MAX_INSTRUCTIONS_TEXT_LENGTH), or null if no
|
||||
* path is configured, the path is invalid, or the file is unreadable. Caller
|
||||
* substitutes a default constant on null.
|
||||
*/
|
||||
export async function resolveAgentHeartbeatProcedure(
|
||||
agent: Agent | null | undefined,
|
||||
rootDir: string,
|
||||
): Promise<string | null> {
|
||||
const rawPath = agent?.heartbeatProcedurePath?.trim();
|
||||
if (!agent || !rawPath) {
|
||||
return null;
|
||||
}
|
||||
const filePath = resolveValidatedMarkdownPath(rawPath, rootDir, agent.id, "heartbeatProcedurePath");
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const content = await readFile(filePath, "utf-8");
|
||||
const normalized = trimAndClamp(
|
||||
content,
|
||||
MAX_INSTRUCTIONS_TEXT_LENGTH,
|
||||
"heartbeat procedure file content",
|
||||
agent.id,
|
||||
);
|
||||
return normalized || null;
|
||||
} catch (err: unknown) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === "ENOENT") {
|
||||
log.warn(`Heartbeat procedure file not found for agent ${agent.id}: ${filePath}`);
|
||||
} else {
|
||||
log.warn(`Failed to read heartbeat procedure file for agent ${agent.id}: ${filePath} (${code})`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write the default heartbeat procedure file at `pathRel` (project-relative)
|
||||
* if it doesn't already exist. Idempotent: leaves an existing file untouched
|
||||
* so operators can edit the procedure without losing their changes on the
|
||||
* next upgrade run. Returns the absolute path that was ensured (or null if
|
||||
* the path is invalid).
|
||||
*
|
||||
* The default content is supplied by the caller — kept as a parameter rather
|
||||
* than imported from agent-heartbeat.ts to avoid a circular dependency
|
||||
* (agent-heartbeat.ts already imports from this module).
|
||||
*/
|
||||
export async function ensureDefaultHeartbeatProcedureFile(
|
||||
rootDir: string,
|
||||
procedurePathRel: string,
|
||||
defaultContent: string,
|
||||
): Promise<string | null> {
|
||||
// Reuse the same path validation as the per-agent loader so we never write
|
||||
// outside the project root.
|
||||
const filePath = resolveValidatedMarkdownPath(procedurePathRel, rootDir, "system", "heartbeatProcedurePath");
|
||||
if (!filePath) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await access(filePath, fsConstants.F_OK);
|
||||
return filePath; // Already exists — preserve operator edits.
|
||||
} catch {
|
||||
// Falls through to write below.
|
||||
}
|
||||
try {
|
||||
await mkdir(dirname(filePath), { recursive: true });
|
||||
await writeFile(filePath, defaultContent, "utf-8");
|
||||
log.log(`Seeded default heartbeat procedure file at ${filePath}`);
|
||||
return filePath;
|
||||
} catch (err: unknown) {
|
||||
log.warn(`Failed to seed default heartbeat procedure file at ${filePath}: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getTrendLabel(trend: AgentRatingSummary["trend"]): string {
|
||||
switch (trend) {
|
||||
case "improving":
|
||||
|
||||
15
packages/engine/src/custom-providers.ts
Normal file
15
packages/engine/src/custom-providers.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CustomProvider } from "@fusion/core";
|
||||
|
||||
export function readCustomProviders(): CustomProvider[] {
|
||||
try {
|
||||
const settingsPath = join(homedir(), ".fusion", "settings.json");
|
||||
const raw = readFileSync(settingsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw) as { customProviders?: CustomProvider[] };
|
||||
return Array.isArray(parsed.customProviders) ? parsed.customProviders : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,10 @@ export {
|
||||
resolveAgentInstructionsWithRatings,
|
||||
resolveAgentInstructions,
|
||||
buildSystemPromptWithInstructions,
|
||||
resolveAgentHeartbeatProcedure,
|
||||
ensureDefaultHeartbeatProcedureFile,
|
||||
} from "./agent-instructions.js";
|
||||
export { HEARTBEAT_PROCEDURE, HEARTBEAT_SYSTEM_PROMPT, HEARTBEAT_NO_TASK_SYSTEM_PROMPT } from "./agent-heartbeat.js";
|
||||
export { WorktreePool, scanIdleWorktrees, cleanupOrphanedWorktrees, reapOrphanWorktrees } from "./worktree-pool.js";
|
||||
export { createLogger, type Logger } from "./logger.js";
|
||||
export { isUsageLimitError, UsageLimitPauser } from "./usage-limit-detector.js";
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
import { isContextLimitError } from "./context-limit-detector.js";
|
||||
import { createFusionAuthStorage, getModelRegistryModelsPath } from "./auth-storage.js";
|
||||
import { piLog, extensionsLog } from "./logger.js";
|
||||
import { readCustomProviders } from "./custom-providers.js";
|
||||
|
||||
export interface AgentResult {
|
||||
session: AgentSession;
|
||||
@@ -998,6 +999,35 @@ export async function createFnAgent(options: AgentOptions): Promise<AgentResult>
|
||||
const modelRegistry = ModelRegistry.create(authStorage, getModelRegistryModelsPath());
|
||||
await registerExtensionProviders(options.cwd, modelRegistry);
|
||||
|
||||
for (const provider of readCustomProviders()) {
|
||||
try {
|
||||
modelRegistry.registerProvider(provider.id, {
|
||||
baseUrl: provider.baseUrl,
|
||||
api: provider.apiType === "anthropic-compatible" ? "anthropic" : "openai-completions",
|
||||
apiKey: provider.apiKey,
|
||||
models: (provider.models ?? []).map((model) => ({
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
reasoning: false,
|
||||
input: ["text" as const],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128000,
|
||||
maxTokens: 16384,
|
||||
})),
|
||||
});
|
||||
piLog.log(`Registered custom provider ${provider.id}`);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
piLog.warn(`Failed to register custom provider ${provider.id}: ${message}`);
|
||||
}
|
||||
}
|
||||
modelRegistry.refresh();
|
||||
|
||||
// Build the pi built-in tool set. We deliberately do NOT use the bundled
|
||||
// `createCodingTools` / `createReadOnlyTools` presets — they're missing
|
||||
// tools that pi-claude-cli's Claude→pi name mapping depends on (Glob→find,
|
||||
|
||||
Reference in New Issue
Block a user