Files
fusion/plugins/fusion-plugin-compound-engineering/src/__tests__/work-bridge.test.ts
gsxdsm b71e531735 feat(compound-engineering): work->board bridge with traceable tasks (U7)
When the work stage completes with a derived task list, create Fusion tasks via
ctx.taskStore.createTask tagged CE-originated (sourceType workflow_step +
sourceMetadata marker) and record an authoritative ce_pipeline_links row per
task (back-reference lives in the link table, not task-row JSON, per FN-5719).
Tasks then run the normal lifecycle untouched. Zero derived tasks is a clean
no-op. The link store is intentionally minimal for U8 to extend with the
bidirectional pipeline-state machine.
2026-06-02 19:42:14 -07:00

160 lines
6.0 KiB
TypeScript

import { rm } from "node:fs/promises";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { InteractiveAiSessionEvent, PluginContext } from "@fusion/core";
import { TaskStore } from "@fusion/core";
import {
CeOrchestrator,
CE_PLUGIN_ID,
CE_WORK_SOURCE_TYPE,
WORK_STAGE_ID,
} from "../session/orchestrator.js";
import { getCePipelineStore } from "../sync/pipeline-store.js";
import { makeScriptedSession } from "./_harness.js";
/**
* U7 work bridge tests. These use the REAL in-memory TaskStore (so created tasks
* are genuine board tasks under the normal lifecycle) and a scripted fake
* interactive session (the same deterministic driver U5/U6 use).
*/
let rootDir: string;
let globalDir: string;
let taskStore: TaskStore;
let ctx: PluginContext;
let emitted: Array<{ event: string; data: unknown }>;
beforeEach(async () => {
rootDir = mkdtempSync(join(tmpdir(), "ce-work-bridge-"));
globalDir = join(rootDir, ".fusion-global");
taskStore = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
await taskStore.init();
emitted = [];
ctx = {
pluginId: CE_PLUGIN_ID,
taskStore,
settings: {},
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
emitEvent: (event: string, data: unknown) => {
emitted.push({ event, data });
},
} as unknown as PluginContext;
});
afterEach(async () => {
taskStore?.close();
await rm(rootDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
});
function makeOrch(script: InteractiveAiSessionEvent[]) {
const session = makeScriptedSession(script);
return new CeOrchestrator({
ctx,
createInteractiveAiSession: vi.fn(async () => ({ session })),
projectRoot: rootDir,
turnTimeoutMs: 5000,
});
}
describe("work bridge (U7)", () => {
it("lands derived tasks on the board, tagged CE-originated with a resolvable back-reference", async () => {
const orch = makeOrch([
{
type: "complete",
data: {
artifact: "# Work log\n",
tasks: [
{ title: "Wire the thing", description: "Implement the thing in module X." },
{ description: "Add tests for the thing.", column: "todo" },
],
},
},
]);
const started = await orch.start(WORK_STAGE_ID, { openingMessage: "do the work" });
expect(started.session.status).toBe("completed");
const cePipelineId = started.session.id;
// Two board tasks created.
const tasks = await taskStore.listTasks();
expect(tasks).toHaveLength(2);
const pipelineStore = getCePipelineStore(ctx);
for (const task of tasks) {
// CE-originated provenance: valid SourceType + CE marker + back-ref copy.
// (TaskStore exposes provenance as flat top-level fields on the Task.)
expect(task.sourceType).toBe(CE_WORK_SOURCE_TYPE);
const meta = task.sourceMetadata as Record<string, unknown> | undefined;
expect(meta?.pluginId).toBe(CE_PLUGIN_ID);
expect(meta?.cePipelineId).toBe(cePipelineId);
expect(meta?.ceStageId).toBe(WORK_STAGE_ID);
// Authoritative back-reference: the link row resolves task→pipeline/artifact.
const link = pipelineStore.findByTaskId(task.id);
expect(link).toBeDefined();
expect(link?.cePipelineId).toBe(cePipelineId);
expect(link?.ceStageId).toBe(WORK_STAGE_ID);
expect(link?.ceArtifactPath).toBe(started.session.artifactPath);
}
// Pipeline lists exactly its two links.
expect(pipelineStore.listByPipeline(cePipelineId)).toHaveLength(2);
// Optional column honored.
const todoTask = tasks.find((t) => t.description.includes("Add tests"));
expect(todoTask?.column).toBe("todo");
});
it("created tasks run the NORMAL lifecycle with no plugin interference", async () => {
const orch = makeOrch([
{ type: "complete", data: { tasks: [{ description: "A normal task." }] } },
]);
const started = await orch.start(WORK_STAGE_ID, { openingMessage: "go" });
const tasks = await taskStore.listTasks();
expect(tasks).toHaveLength(1);
const task = tasks[0];
// It is an ordinary board task: default column, normal mutation works, and the
// plugin attached no extra status/hook state beyond provenance metadata.
expect(task.column).toBe("triage");
const moved = await taskStore.moveTask(task.id, "todo");
expect(moved.column).toBe("todo");
// Re-read is a clean, normal task (provenance is the only CE footprint).
const reread = await taskStore.getTask(task.id);
expect(reread?.column).toBe("todo");
expect((reread?.sourceMetadata as Record<string, unknown>)?.pluginId).toBe(CE_PLUGIN_ID);
void started;
});
it("zero derived tasks is a clean no-op (no board tasks, no orphan link rows)", async () => {
const orch = makeOrch([{ type: "complete", data: { artifact: "# Nothing to do\n", tasks: [] } }]);
const started = await orch.start(WORK_STAGE_ID, { openingMessage: "nothing here" });
expect(started.session.status).toBe("completed");
expect(await taskStore.listTasks()).toHaveLength(0);
expect(getCePipelineStore(ctx).listByPipeline(started.session.id)).toHaveLength(0);
});
it("a completion payload with NO tasks field is also a no-op", async () => {
const orch = makeOrch([{ type: "complete", data: { artifact: "# Just an artifact\n" } }]);
const started = await orch.start(WORK_STAGE_ID, { openingMessage: "x" });
expect(started.session.status).toBe("completed");
expect(await taskStore.listTasks()).toHaveLength(0);
expect(getCePipelineStore(ctx).listByPipeline(started.session.id)).toHaveLength(0);
});
it("a non-work stage with a tasks payload does NOT land board tasks (bridge is work-only)", async () => {
const orch = makeOrch([
{ type: "complete", data: { artifact: "# Brainstorm\n", tasks: [{ description: "should be ignored" }] } },
]);
await orch.start("brainstorm", { openingMessage: "ideas" });
expect(await taskStore.listTasks()).toHaveLength(0);
});
});