feat(FN-2276): merge fusion/fn-2276
This commit is contained in:
@@ -50,6 +50,29 @@ These fields are managed by the engine and cannot be directly edited:
|
||||
- `lastError` — Last error message (managed by engine)
|
||||
- `pauseReason` — Reason for paused state (managed by engine)
|
||||
|
||||
### Stale Task Link Sanitization
|
||||
|
||||
The `taskId` field is suppressed in API responses when the linked task is in a terminal state (`done` or `archived`). This prevents stale "working on" UI indicators in the Agents dashboard for agents whose task has already completed.
|
||||
|
||||
**Terminal task statuses:**
|
||||
- `done` — Task completed successfully
|
||||
- `archived` — Task archived
|
||||
|
||||
**Affected API endpoints:**
|
||||
- `GET /api/agents` — `taskId` is omitted from agents with terminal linked tasks
|
||||
- `GET /api/agents/:id` — `taskId` is omitted when the linked task is terminal
|
||||
- `GET /api/agents/stats` — `assignedTaskCount` excludes agents with terminal linked tasks
|
||||
|
||||
**Non-terminal task statuses (taskId is preserved):**
|
||||
- `triage`
|
||||
- `todo`
|
||||
- `in-progress`
|
||||
- `in-review`
|
||||
|
||||
**Graceful degradation:**
|
||||
- If task lookup fails (e.g., task deleted), `taskId` is preserved in the response to avoid false negatives
|
||||
- The underlying `taskId` is NOT modified in storage — only the API response is sanitized
|
||||
|
||||
### Update-Only Fields
|
||||
|
||||
These fields can only be set during update (not on create):
|
||||
|
||||
@@ -5,9 +5,10 @@ import { join } from "node:path";
|
||||
const repoRoot = join(import.meta.dirname!, "..", "..", "..", "..");
|
||||
|
||||
describe("Changeset configuration", () => {
|
||||
// Note: .changeset directory was intentionally removed to stop tracking local-only artifacts (FN-LOCAL).
|
||||
// These tests are skipped as the changeset files are no longer present.
|
||||
it.skip("should have a valid .changeset/config.json", () => {
|
||||
// Release guardrail: These tests protect the @runfusion/fusion release pipeline
|
||||
// by ensuring changeset configuration required for npm publishing remains intact.
|
||||
// If these tests fail, the automated release workflow will break.
|
||||
it("should have a valid .changeset/config.json", () => {
|
||||
const configPath = join(repoRoot, ".changeset", "config.json");
|
||||
expect(existsSync(configPath)).toBe(true);
|
||||
|
||||
@@ -16,13 +17,14 @@ describe("Changeset configuration", () => {
|
||||
expect(typeof config).toBe("object");
|
||||
});
|
||||
|
||||
it.skip("should have baseBranch set to main", () => {
|
||||
it("should have baseBranch set to 'main' for the default branch", () => {
|
||||
const configPath = join(repoRoot, ".changeset", "config.json");
|
||||
const config = JSON.parse(readFileSync(configPath, "utf-8"));
|
||||
expect(config.baseBranch).toBe("main");
|
||||
});
|
||||
|
||||
it.skip("should have changeset scripts in root package.json", () => {
|
||||
it("should have changeset scripts in root package.json", () => {
|
||||
// These scripts drive the changesets CLI workflow: changeset (add), version (bump), release:version (apply)
|
||||
const pkgPath = join(repoRoot, "package.json");
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
||||
|
||||
@@ -31,7 +33,7 @@ describe("Changeset configuration", () => {
|
||||
expect(pkg.scripts["release:version"]).toBe("changeset version");
|
||||
});
|
||||
|
||||
it("should have .github/workflows/version.yml with expected content", () => {
|
||||
it("should have .github/workflows/version.yml configured for manual releases", () => {
|
||||
const workflowPath = join(
|
||||
repoRoot,
|
||||
".github",
|
||||
@@ -41,7 +43,9 @@ describe("Changeset configuration", () => {
|
||||
expect(existsSync(workflowPath)).toBe(true);
|
||||
|
||||
const content = readFileSync(workflowPath, "utf-8");
|
||||
// Guardrail: workflow must use changesets/action for npm publishing
|
||||
expect(content).toContain("changesets/action");
|
||||
// Guardrail: workflow must be manually triggered (auto-trigger disabled for safety)
|
||||
expect(content).toContain("workflow_dispatch");
|
||||
expect(content).toContain("Auto-trigger disabled");
|
||||
});
|
||||
|
||||
@@ -56,7 +56,7 @@ function getStateBadgeClass(state: AgentState): string {
|
||||
}
|
||||
}
|
||||
|
||||
function getStateCardClass(prefix: "agent-card", state: AgentState): string {
|
||||
function getStateCardClass(prefix: "agent-card" | "agent-board-card", state: AgentState): string {
|
||||
switch (state) {
|
||||
case "running":
|
||||
return `${prefix}--running`;
|
||||
@@ -253,18 +253,17 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
const [agentView, setAgentView] = useState<"list" | "tree" | "org">(() => {
|
||||
const [agentView, setAgentView] = useState<"list" | "board" | "tree" | "org">(() => {
|
||||
if (typeof window === "undefined") return "list";
|
||||
const saved = getScopedItem("fn-agent-view", projectId);
|
||||
// "board" is a retired option — migrate any persisted preference to "list".
|
||||
return (saved === "list" || saved === "tree" || saved === "org") ? saved : "list";
|
||||
return (saved === "list" || saved === "board" || saved === "tree" || saved === "org") ? saved : "list";
|
||||
});
|
||||
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
|
||||
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getScopedItem("fn-agent-view", projectId);
|
||||
if (saved === "list" || saved === "tree" || saved === "org") {
|
||||
if (saved === "list" || saved === "board" || saved === "tree" || saved === "org") {
|
||||
setAgentView(saved);
|
||||
return;
|
||||
}
|
||||
@@ -516,6 +515,15 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
>
|
||||
<List size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${agentView === "board" ? " active" : ""}`}
|
||||
onClick={() => setAgentView("board")}
|
||||
title="Board view"
|
||||
aria-label="Board view"
|
||||
aria-pressed={agentView === "board"}
|
||||
>
|
||||
<Activity size={16} />
|
||||
</button>
|
||||
<button
|
||||
className={`view-toggle-btn${agentView === "tree" ? " active" : ""}`}
|
||||
onClick={() => setAgentView("tree")}
|
||||
@@ -660,6 +668,40 @@ export function AgentsView({ addToast, projectId }: AgentsViewProps) {
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : agentView === "board" ? (
|
||||
<div className="agent-board">
|
||||
{displayAgents.length === 0 ? (
|
||||
<AgentEmptyState onCtaClick={() => setIsCreating(true)} />
|
||||
) : (
|
||||
displayAgents.map((agent) => {
|
||||
const health = getHealthStatus(agent);
|
||||
const stateBadgeClass = getStateBadgeClass(agent.state);
|
||||
const stateCardClass = getStateCardClass("agent-board-card", agent.state);
|
||||
return (
|
||||
<div key={agent.id} className={`agent-board-card ${stateCardClass}`}>
|
||||
<div
|
||||
className="agent-board-clickable"
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => e.key === "Enter" && setSelectedAgentId(agent.id)}
|
||||
>
|
||||
<div className="agent-board-header">
|
||||
<span className="agent-board-icon">{getRoleIcon(agent.role)}</span>
|
||||
<span className="agent-board-badge badge text-secondary">{getRoleLabel(agent.role)}</span>
|
||||
<span className={`agent-board-badge badge ${stateBadgeClass}`}>{agent.state}</span>
|
||||
</div>
|
||||
<div className="agent-board-name">{agent.name}</div>
|
||||
<div className="agent-board-id">{agent.id}</div>
|
||||
<div className="agent-board-health" style={{ color: health.color }} title={health.label}>
|
||||
{health.icon}{!health.stateDerived && ` ${health.label}`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="agent-list">
|
||||
{displayAgents.length === 0 ? (
|
||||
|
||||
@@ -16469,3 +16469,363 @@ describe("Messaging Routes", () => {
|
||||
|
||||
// Note: Project pause/resume route tests are in src/__tests__/project-pause-resume-routes.test.ts
|
||||
// to avoid test isolation issues with vi.restoreAllMocks() from other tests in routes.test.ts
|
||||
|
||||
describe("Agent stale task-link sanitization", () => {
|
||||
let tempDir: string;
|
||||
let fusionDir: string;
|
||||
let agentId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "kb-routes-agent-stale-"));
|
||||
fusionDir = join(tempDir, ".fusion");
|
||||
mkdirSync(fusionDir, { recursive: true });
|
||||
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
const agent = await agentStore.createAgent({
|
||||
name: "Test Agent",
|
||||
role: "executor",
|
||||
});
|
||||
agentId = agent.id;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function buildAgentApp() {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
} as any);
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
return app;
|
||||
}
|
||||
|
||||
it("GET /api/agents omits taskId when linked task is done", async () => {
|
||||
const doneTaskId = "FN-DONE";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: doneTaskId,
|
||||
column: "done",
|
||||
description: "Completed task",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
prompt: "# Done\n\nDone task",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// Assign done task to agent
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, doneTaskId);
|
||||
|
||||
const res = await GET(app, "/api/agents");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
expect(testAgent).not.toHaveProperty("taskId");
|
||||
});
|
||||
|
||||
it("GET /api/agents omits taskId when linked task is archived", async () => {
|
||||
const archivedTaskId = "FN-ARCHIVED";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: archivedTaskId,
|
||||
column: "archived",
|
||||
description: "Archived task",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
prompt: "# Archived\n\nArchived task",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// Assign archived task to agent
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, archivedTaskId);
|
||||
|
||||
const res = await GET(app, "/api/agents");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
expect(testAgent).not.toHaveProperty("taskId");
|
||||
});
|
||||
|
||||
it("GET /api/agents preserves taskId for non-terminal linked tasks", async () => {
|
||||
const activeTaskId = "FN-ACTIVE";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: activeTaskId,
|
||||
column: "in-progress",
|
||||
description: "Active task",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
prompt: "# Active\n\nActive task",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// Assign active task to agent
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, activeTaskId);
|
||||
|
||||
const res = await GET(app, "/api/agents");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
expect(testAgent.taskId).toBe(activeTaskId);
|
||||
});
|
||||
|
||||
it("GET /api/agents/:id omits taskId when linked task is done", async () => {
|
||||
const doneTaskId = "FN-DONE-DETAIL";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: doneTaskId,
|
||||
column: "done",
|
||||
description: "Done task",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
prompt: "# Done\n\nDone task",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// Assign done task to agent
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, doneTaskId);
|
||||
|
||||
const res = await GET(app, `/api/agents/${agentId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeDefined();
|
||||
expect(res.body.id).toBe(agentId);
|
||||
expect(res.body).not.toHaveProperty("taskId");
|
||||
});
|
||||
|
||||
it("GET /api/agents/:id omits taskId when linked task is archived", async () => {
|
||||
const archivedTaskId = "FN-ARCHIVED-DETAIL";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: archivedTaskId,
|
||||
column: "archived",
|
||||
description: "Archived task",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
prompt: "# Archived\n\nArchived task",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// Assign archived task to agent
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, archivedTaskId);
|
||||
|
||||
const res = await GET(app, `/api/agents/${agentId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeDefined();
|
||||
expect(res.body.id).toBe(agentId);
|
||||
expect(res.body).not.toHaveProperty("taskId");
|
||||
});
|
||||
|
||||
it("GET /api/agents/:id preserves taskId for in-review linked tasks", async () => {
|
||||
const inReviewTaskId = "FN-IN-REVIEW";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTask: vi.fn().mockResolvedValue({
|
||||
id: inReviewTaskId,
|
||||
column: "in-review",
|
||||
description: "In review task",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
prompt: "# In Review\n\nIn review task",
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// Assign in-review task to agent
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, inReviewTaskId);
|
||||
|
||||
const res = await GET(app, `/api/agents/${agentId}`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toBeDefined();
|
||||
expect(res.body.id).toBe(agentId);
|
||||
expect(res.body.taskId).toBe(inReviewTaskId);
|
||||
});
|
||||
|
||||
it("GET /api/agents/stats excludes terminal task links from assignedTaskCount", async () => {
|
||||
const doneTaskId = "FN-STATS-DONE";
|
||||
const activeTaskId = "FN-STATS-ACTIVE";
|
||||
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTask: vi.fn().mockImplementation(async (taskId: string) => {
|
||||
if (taskId === doneTaskId) {
|
||||
return {
|
||||
id: doneTaskId,
|
||||
column: "done",
|
||||
description: "Done task",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
prompt: "# Done\n\nDone task",
|
||||
};
|
||||
}
|
||||
if (taskId === activeTaskId) {
|
||||
return {
|
||||
id: activeTaskId,
|
||||
column: "in-progress",
|
||||
description: "Active task",
|
||||
dependencies: [],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
prompt: "# Active\n\nActive task",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// Create two agents: one with done task, one with active task
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
|
||||
const agent1 = await agentStore.createAgent({ name: "Agent 1", role: "executor" });
|
||||
const agent2 = await agentStore.createAgent({ name: "Agent 2", role: "executor" });
|
||||
|
||||
await agentStore.assignTask(agent1.id, doneTaskId);
|
||||
await agentStore.assignTask(agent2.id, activeTaskId);
|
||||
|
||||
const res = await GET(app, "/api/agents/stats");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Only agent2 should count (active task), agent1 should be excluded (done task)
|
||||
expect(res.body.assignedTaskCount).toBe(1);
|
||||
});
|
||||
|
||||
it("GET /api/agents/stats preserves taskId for agents with no linked task", async () => {
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// Agent already created in beforeEach with no task assigned
|
||||
const res = await GET(app, "/api/agents/stats");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
// Agent has no task, so assignedTaskCount should be 0
|
||||
expect(res.body.assignedTaskCount).toBe(0);
|
||||
});
|
||||
|
||||
it("GET /api/agents handles task lookup failure gracefully", async () => {
|
||||
const taskId = "FN-LOOKUP-FAIL";
|
||||
const store = createMockStore({
|
||||
getFusionDir: vi.fn().mockReturnValue(fusionDir),
|
||||
getTask: vi.fn().mockRejectedValue(new Error("Database error")),
|
||||
} as any);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use("/api", createApiRoutes(store));
|
||||
|
||||
// Assign task to agent
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: fusionDir });
|
||||
await agentStore.init();
|
||||
await agentStore.assignTask(agentId, taskId);
|
||||
|
||||
// Should not throw, taskId should be preserved on lookup failure
|
||||
const res = await GET(app, "/api/agents");
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const agents = Array.isArray(res.body) ? res.body : [res.body];
|
||||
const testAgent = agents.find((a: { id: string }) => a.id === agentId);
|
||||
expect(testAgent).toBeDefined();
|
||||
// On lookup failure, taskId should be preserved (treated as non-terminal)
|
||||
expect(testAgent.taskId).toBe(taskId);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11333,6 +11333,62 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
|
||||
// ── Agent Routes ───────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Terminal task statuses — tasks in these states should not be displayed
|
||||
* as "working on" in agent UI surfaces to avoid stale activity indicators.
|
||||
*/
|
||||
const TERMINAL_TASK_STATUSES = new Set(["done", "archived"]);
|
||||
|
||||
/**
|
||||
* Check if a task status is terminal (done or archived).
|
||||
*/
|
||||
function isTerminalTaskStatus(status: string | undefined): boolean {
|
||||
return status !== undefined && TERMINAL_TASK_STATUSES.has(status);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize agent responses to omit taskId when the linked task is in a terminal state.
|
||||
* This prevents stale "working on" UI indicators for completed/archived tasks.
|
||||
*
|
||||
* @param agents - Array of agents to sanitize
|
||||
* @param scopedStore - Task store for looking up linked task status
|
||||
* @returns Agents with terminal-linked taskId omitted from response
|
||||
*/
|
||||
async function sanitizeAgentTaskLinks(
|
||||
agents: Array<import("@fusion/core").Agent>,
|
||||
scopedStore: TaskStore,
|
||||
): Promise<Array<import("@fusion/core").Agent>> {
|
||||
// Batch lookup all unique taskIds to minimize individual store calls
|
||||
const taskIds = [...new Set(agents.map((a) => a.taskId).filter((id): id is string => id !== undefined))];
|
||||
const taskStatusMap = new Map<string, string>();
|
||||
|
||||
// Parallel fetch all linked tasks
|
||||
await Promise.all(
|
||||
taskIds.map(async (taskId) => {
|
||||
try {
|
||||
const task = await scopedStore.getTask(taskId);
|
||||
if (task) {
|
||||
taskStatusMap.set(taskId, task.column);
|
||||
}
|
||||
} catch {
|
||||
// Task lookup failed — treat as non-terminal (preserve taskId)
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
return agents.map((agent) => {
|
||||
if (!agent.taskId) return agent;
|
||||
|
||||
const taskStatus = taskStatusMap.get(agent.taskId);
|
||||
if (isTerminalTaskStatus(taskStatus)) {
|
||||
// Omit taskId for terminal tasks — use spread to create shallow copy without taskId
|
||||
const { taskId: _omitted, ...sanitized } = agent;
|
||||
return sanitized as import("@fusion/core").Agent;
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/agents
|
||||
* List all agents with optional filtering.
|
||||
@@ -11357,7 +11413,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
await agentStore.init();
|
||||
|
||||
const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "paused" | "terminated"; role?: import("@fusion/core").AgentCapability; includeEphemeral?: boolean });
|
||||
res.json(agents);
|
||||
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
|
||||
res.json(sanitizedAgents);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
@@ -12313,6 +12370,7 @@ async function persistImportedSkills(
|
||||
* GET /api/agents/stats
|
||||
* Return aggregate stats across all agents.
|
||||
* Must be registered before /agents/:id to avoid "stats" matching :id.
|
||||
* Note: assignedTaskCount excludes agents whose linked task is in a terminal state.
|
||||
*/
|
||||
router.get("/agents/stats", async (req, res) => {
|
||||
try {
|
||||
@@ -12323,7 +12381,10 @@ async function persistImportedSkills(
|
||||
|
||||
const agents = await agentStore.listAgents();
|
||||
const activeCount = agents.filter((a) => a.state === "active" || a.state === "running").length;
|
||||
const assignedTaskCount = agents.filter((a) => a.taskId).length;
|
||||
|
||||
// Count only agents with non-terminal linked tasks
|
||||
const sanitizedAgents = await sanitizeAgentTaskLinks(agents, scopedStore);
|
||||
const assignedTaskCount = sanitizedAgents.filter((a) => a.taskId).length;
|
||||
|
||||
let completedRuns = 0;
|
||||
let failedRuns = 0;
|
||||
@@ -12396,6 +12457,7 @@ async function persistImportedSkills(
|
||||
/**
|
||||
* GET /api/agents/:id
|
||||
* Get agent by ID with heartbeat history.
|
||||
* taskId is omitted from response if the linked task is in a terminal state.
|
||||
*/
|
||||
router.get("/agents/:id", async (req, res) => {
|
||||
try {
|
||||
@@ -12408,7 +12470,9 @@ async function persistImportedSkills(
|
||||
if (!agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
res.json(agent);
|
||||
// Sanitize taskId for single-agent responses (omit if linked task is terminal)
|
||||
const [sanitizedAgent] = await sanitizeAgentTaskLinks([agent], scopedStore);
|
||||
res.json(sanitizedAgent);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof ApiError) {
|
||||
throw err;
|
||||
|
||||
@@ -27,6 +27,7 @@ vi.mock("node:fs", async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(() => true),
|
||||
chmodSync: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -81,6 +82,21 @@ describe("TerminalService", () => {
|
||||
error: "Shell not allowed. Please use a supported shell (bash, zsh, sh, cmd, powershell).",
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs env-provided node-pty native permissions before spawn", async () => {
|
||||
process.env.NODE_PTY_SPAWN_HELPER_DIR = "/native-assets";
|
||||
|
||||
const fs = await import("node:fs");
|
||||
const chmodSyncMock = vi.mocked(fs.chmodSync);
|
||||
|
||||
const result = await service.createSession();
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(chmodSyncMock).toHaveBeenCalledWith("/native-assets/spawn-helper", 0o755);
|
||||
expect(chmodSyncMock).toHaveBeenCalledWith("/native-assets/pty.node", 0o755);
|
||||
|
||||
delete process.env.NODE_PTY_SPAWN_HELPER_DIR;
|
||||
});
|
||||
});
|
||||
|
||||
describe("write", () => {
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { IPty, IPtyForkOptions, IWindowsPtyForkOptions } from "node-pty";
|
||||
import { EventEmitter } from "events";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { existsSync } from "node:fs";
|
||||
import { existsSync, chmodSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
|
||||
// Detect if we're running as a Bun-compiled binary
|
||||
@@ -29,13 +29,68 @@ let ptyLoadError: Error | null = null;
|
||||
* (when terminal is first used). This is acceptable as it only executes once per
|
||||
* service lifetime, not per-request.
|
||||
*/
|
||||
function findStagedNativeDir(): string | null {
|
||||
function getNativePrebuildName(): string {
|
||||
const platform = process.platform === "darwin" ? "darwin" :
|
||||
process.platform === "linux" ? "linux" :
|
||||
process.platform === "win32" ? "win32" : "unknown";
|
||||
const arch = process.arch === "arm64" ? "arm64" :
|
||||
process.arch === "x64" ? "x64" : "unknown";
|
||||
const prebuildName = `${platform}-${arch}`;
|
||||
return `${platform}-${arch}`;
|
||||
}
|
||||
|
||||
function findInstalledNodePtyNativeDir(): string | null {
|
||||
try {
|
||||
const packageJsonPath = require.resolve("node-pty/package.json");
|
||||
const nativeDir = join(dirname(packageJsonPath), "prebuilds", getNativePrebuildName());
|
||||
return existsSync(join(nativeDir, "pty.node")) ? nativeDir : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function ensureNodePtyNativePermissions(): void {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
|
||||
const candidateDirs = new Set<string>();
|
||||
const envNativeDir = process.env.NODE_PTY_SPAWN_HELPER_DIR || process.env.FUSION_NATIVE_ASSETS_PATH;
|
||||
if (envNativeDir) {
|
||||
candidateDirs.add(envNativeDir);
|
||||
}
|
||||
|
||||
const stagedNativeDir = findStagedNativeDir();
|
||||
if (stagedNativeDir) {
|
||||
candidateDirs.add(stagedNativeDir);
|
||||
}
|
||||
|
||||
const installedNativeDir = findInstalledNodePtyNativeDir();
|
||||
if (installedNativeDir) {
|
||||
candidateDirs.add(installedNativeDir);
|
||||
}
|
||||
|
||||
for (const nativeDir of candidateDirs) {
|
||||
const helperPath = join(nativeDir, "spawn-helper");
|
||||
const nativeModulePath = join(nativeDir, "pty.node");
|
||||
|
||||
try {
|
||||
if (existsSync(helperPath)) {
|
||||
chmodSync(helperPath, 0o755);
|
||||
}
|
||||
if (existsSync(nativeModulePath)) {
|
||||
chmodSync(nativeModulePath, 0o755);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[terminal] Failed to repair node-pty native permissions:", {
|
||||
nativeDir,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findStagedNativeDir(): string | null {
|
||||
const prebuildName = getNativePrebuildName();
|
||||
|
||||
// Check FUSION_RUNTIME_DIR env var first
|
||||
if (process.env.FUSION_RUNTIME_DIR) {
|
||||
@@ -93,6 +148,8 @@ async function loadPtyModule(): Promise<typeof import("node-pty")> {
|
||||
}
|
||||
|
||||
try {
|
||||
ensureNodePtyNativePermissions();
|
||||
|
||||
// Standard import path - the native-patch setup should have created
|
||||
// the necessary symlink structure for node-pty to find the module
|
||||
const mod = await import("node-pty");
|
||||
|
||||
Reference in New Issue
Block a user