feat(FN-2987): merge fusion/fn-2987

- **Archived task read-only enforcement** — `packages/core/src/store.ts` adds guards that prevent log entries and documents from being written to archived tasks; `packages/engine/src/agent-tools.ts` surfaces meaningful errors instead of silent failures when tools are called on archived tasks
- **Type safety for archived guards** — resolved typecheck issues around the archived-task write guards in core
- **Tests for archived behavior** — added regression coverage in `packages/core/src/__tests__/store.test.ts` and `packages/core/src/__tests__/task-documents.test.ts`; also added `packages/engine/src/__tests__/agent-tools.test.ts` to cover archived-task error paths
- **Documentation** — updated `docs/task-management.md` to document the archived read-only behavior
- **Also merged** `fusion/fn-2959-2` (CustomProviderForm component, SettingsModal routing, custom provider API routes, and associated tests)

Commits merged:
- feat(FN-2987): complete Step 6 — document archived read-only behavior
- fix(FN-2987): complete Step 5 — resolve typecheck for archived guards
- test(FN-2987): complete Step 4 — add archived-task regression coverage
- feat(FN-2987): complete Step 3 — handle archived log tool errors
- feat(FN-2987): complete Step 2 — enforce archived document write guard
- feat(FN-2987): complete Step 1 — harden archived logEntry checks
- feat(FN-2959): merge fusion/fn-2959-2

Files changed:
.changeset/custom-openai-anthropic-providers.md    |   5 +
 docs/task-management.md                            |   4 +
 packages/core/src/__tests__/store.test.ts          |  38 +++
 packages/core/src/__tests__/task-documents.test.ts |  32 +++
 packages/core/src/store.ts                         |  34 ++-
 packages/dashboard/app/api/legacy.ts               |  41 ++++
 .../app/components/CustomProviderForm.css          |  45 ++++
 .../app/components/CustomProviderForm.tsx          | 203 ++++++++++++++++
 .../app/components/ModelOnboardingModal.css        |  14 ++
 .../app/components/ModelOnboardingModal.tsx        |  62 ++++-
 .../dashboard/app/components/SettingsModal.css     |  38 +++
 .../dashboard/app/components/SettingsModal.tsx     |  91 +++++++-
 .../__tests__/CustomProviderForm.test.tsx          |  60 +++++
 .../__tests__/ModelOnboardingModal.test.tsx        |  23 ++
 .../components/__tests__/SettingsModal.test.tsx    |  13 ++
 .../__tests__/SettingsModalNodeRouting.test.tsx    |   4 +
 .../components/__tests__/settings-mobile.test.tsx  |   4 +
 packages/dashboard/src/auth-paths.ts               |   4 +
 packages/dashboard/src/routes.ts                   |   2 +
 .../__tests__/custom-provider-routes.test.ts       | 118 ++++++++++
 .../src/routes/register-custom-provider-routes.ts  | 254 +++++++++++++++++++++
 .../src/__tests__/agent-document-tools.test.ts     |  15 ++
 packages/engine/src/__tests__/agent-tools.test.ts  |  43 ++++
 packages/engine/src/agent-tools.ts                 |  28 ++-
 24 files changed, 1166 insertions(+), 9 deletions(-)

Fusion-Task-Id: FN-2987
This commit is contained in:
Fusion
2026-04-29 20:16:45 -07:00
committed by gsxdsm
parent 19cdf7f448
commit 3c37f8d4bb
7 changed files with 189 additions and 5 deletions

View File

@@ -6903,6 +6903,44 @@ Task with acceptance criteria
});
});
describe("logEntry on archived tasks", () => {
it("rejects logEntry on cleanup-archived task with archived error", async () => {
const task = await store.createTask({ description: "Cleanup archive log test" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, true);
await expect(store.logEntry(task.id, "should fail")).rejects.toThrow(/archived/i);
await expect(store.logEntry(task.id, "should fail")).rejects.not.toThrow(/not found/i);
});
it("rejects logEntry on non-cleanup archived task with archived error", async () => {
const task = await store.createTask({ description: "Non-cleanup archive log test" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
await expect(store.logEntry(task.id, "should fail")).rejects.toThrow(/archived/i);
});
it("rejects logEntry with runContext on cleanup-archived task", async () => {
const task = await store.createTask({ description: "Cleanup archive runContext log test" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, true);
await expect(
store.logEntry(task.id, "should fail", "outcome", { runId: "run-1", agentId: "agent-1" }),
).rejects.toThrow(/archived/i);
});
});
describe("unarchiveTask", () => {
it("unarchives an archived task (moves archived → done)", async () => {
const task = await store.createTask({ description: "Test task" });

View File

@@ -112,6 +112,38 @@ describe("TaskStore task documents", () => {
).rejects.toThrow("Task KB-DOES-NOT-EXIST not found");
});
it("rejects upsertTaskDocument on cleanup-archived task", async () => {
const task = await store.createTask({ description: "Cleanup archived docs test" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, true);
await expect(
store.upsertTaskDocument(task.id, {
key: "plan",
content: "should fail",
}),
).rejects.toThrow(/archived/i);
});
it("rejects upsertTaskDocument on non-cleanup archived task", async () => {
const task = await store.createTask({ description: "Non-cleanup archived docs test" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await store.archiveTask(task.id, false);
await expect(
store.upsertTaskDocument(task.id, {
key: "plan",
content: "should fail",
}),
).rejects.toThrow(/archived/i);
});
it("updates a document, increments revision, and archives previous content", async () => {
const task = await store.createTask({ description: "Update task" });

View File

@@ -1152,6 +1152,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return this.rowToTask(row);
}
private isTaskArchived(id: string): boolean {
const row = this.db.prepare('SELECT "column" FROM tasks WHERE id = ?').get(id) as { column: Column } | undefined;
if (row) {
return row.column === "archived";
}
return this.archiveDb.get(id) !== undefined;
}
/**
* Return the ids of live tasks whose `dependencies` array contains `id`.
*
@@ -3166,6 +3175,10 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
outcome: truncateTaskLogOutcome(outcome),
};
if (runContext) {
if (this.isTaskArchived(id)) {
throw new Error(`Task ${id} is archived — logging is read-only`);
}
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
@@ -3199,11 +3212,20 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
// Fast path for high-volume log entries: update only the log + updatedAt fields
// instead of reading/writing the entire task payload on every append.
const row = this.db.prepare("SELECT log FROM tasks WHERE id = ?").get(id) as { log: string | null } | undefined;
const row = this.db.prepare('SELECT log, "column" FROM tasks WHERE id = ?').get(id) as
| { log: string | null; column: Column }
| undefined;
if (!row) {
if (this.isTaskArchived(id)) {
throw new Error(`Task ${id} is archived — logging is read-only`);
}
throw new Error(`Task ${id} not found`);
}
if (row.column === "archived") {
throw new Error(`Task ${id} is archived — logging is read-only`);
}
const log = fromJson<TaskLogEntry[]>(row.log) || [];
log.push(entry);
if (log.length > TASK_ACTIVITY_LOG_ENTRY_LIMIT) {
@@ -4750,10 +4772,16 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
);
}
const taskExists = this.db.prepare("SELECT id FROM tasks WHERE id = ?").get(taskId) as
| { id: string }
const taskExists = this.db.prepare('SELECT id, "column" FROM tasks WHERE id = ?').get(taskId) as
| { id: string; column: Column }
| undefined;
if (taskExists?.column === "archived") {
throw new Error(`Task ${taskId} is archived — documents are read-only`);
}
if (!taskExists) {
if (this.isTaskArchived(taskId)) {
throw new Error(`Task ${taskId} is archived — documents are read-only`);
}
throw new Error(`Task ${taskId} not found`);
}

View File

@@ -138,6 +138,21 @@ describe("task_document_write tool", () => {
expect(getText(result)).toContain("ERROR: Failed to save document");
expect(getText(result)).toContain("database temporarily unavailable");
});
it("returns archived read-only details when document writes are blocked", async () => {
const { store, upsertTaskDocument } = createMockStore();
upsertTaskDocument.mockRejectedValue(new Error("Task FN-007 is archived — documents are read-only"));
const tool = createTaskDocumentWriteTool(store, TASK_ID);
const result = await runTool(tool, "call-archived", {
key: "research",
content: "Notes",
author: "agent",
});
expect(getText(result)).toContain("ERROR: Failed to save document");
expect(getText(result)).toContain("archived");
});
});
describe("task_document_read tool", () => {

View File

@@ -8,6 +8,8 @@ import {
createMemoryTools,
createTaskCreateTool,
createDelegateTaskTool,
createTaskLogTool,
createTaskLogToolWithContext,
createSendMessageTool,
createReadMessagesTool,
qmdAgentMemoryCollectionName,
@@ -127,6 +129,47 @@ describe("createDelegateTaskTool", () => {
});
});
describe("createTaskLogTool", () => {
it("returns a graceful archived read-only message instead of throwing", async () => {
const store = {
logEntry: vi.fn().mockRejectedValue(new Error("Task FN-100 is archived — logging is read-only")),
};
const tool = createTaskLogTool(store as any, "FN-100");
const result = await tool.execute(
"call-1",
{ message: "Important note", outcome: "none" } as any,
undefined,
undefined,
{} as any,
);
const responseText = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(responseText).toBe("ERROR: Cannot log to archived task — this task is read-only");
});
});
describe("createTaskLogToolWithContext", () => {
it("returns a graceful archived read-only message instead of throwing", async () => {
const store = {
logEntry: vi.fn().mockRejectedValue(new Error("Task FN-101 is ARCHIVED")),
};
const runContext = { runId: "run-1", agentId: "agent-1" };
const tool = createTaskLogToolWithContext(store as any, "FN-101", runContext as any);
const result = await tool.execute(
"call-2",
{ message: "Important note", outcome: "none" } as any,
undefined,
undefined,
{} as any,
);
const responseText = result.content[0]?.type === "text" ? result.content[0].text : "";
expect(responseText).toBe("ERROR: Cannot log to archived task — this task is read-only");
});
});
describe("createMemoryTools", () => {
let tempDir: string;

View File

@@ -472,7 +472,19 @@ export function createTaskLogTool(store: TaskStore, taskId: string): ToolDefinit
"Use for significant events — not every small step.",
parameters: taskLogParams,
execute: async (_id: string, params: Static<typeof taskLogParams>) => {
await store.logEntry(taskId, params.message, params.outcome);
try {
await store.logEntry(taskId, params.message, params.outcome);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (typeof err?.message === "string" && err.message.toLowerCase().includes("archived")) {
return {
content: [{ type: "text" as const, text: "ERROR: Cannot log to archived task — this task is read-only" }],
details: {},
};
}
throw err;
}
return {
content: [{ type: "text" as const, text: `Logged: ${params.message}` }],
details: {},
@@ -498,7 +510,19 @@ export function createTaskLogToolWithContext(store: TaskStore, taskId: string, r
"Use for significant events — not every small step.",
parameters: taskLogParams,
execute: async (_id: string, params: Static<typeof taskLogParams>) => {
await store.logEntry(taskId, params.message, params.outcome, runContext);
try {
await store.logEntry(taskId, params.message, params.outcome, runContext);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (err: any) {
if (typeof err?.message === "string" && err.message.toLowerCase().includes("archived")) {
return {
content: [{ type: "text" as const, text: "ERROR: Cannot log to archived task — this task is read-only" }],
details: {},
};
}
throw err;
}
return {
content: [{ type: "text" as const, text: `Logged: ${params.message}` }],
details: {},