290 lines
8.7 KiB
TypeScript
290 lines
8.7 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import * as path from "node:path";
|
|
import test from "node:test";
|
|
import type {
|
|
ExtensionAPI,
|
|
ExtensionContext,
|
|
} from "@earendil-works/pi-coding-agent";
|
|
import {
|
|
DEFAULT_WORKFLOW_CONFIG,
|
|
type WorkflowRun,
|
|
} from "./domain.ts";
|
|
import {
|
|
reconcileWorkflowDeliveries,
|
|
workflowNotificationType,
|
|
} from "./notifications.ts";
|
|
import { WorkflowStore } from "./store.ts";
|
|
|
|
async function withTempDir(run: (directory: string) => Promise<void>) {
|
|
const directory = await mkdtemp(path.join(tmpdir(), "workflow-delivery-"));
|
|
try {
|
|
await run(directory);
|
|
} finally {
|
|
await rm(directory, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function workflow(background = false): WorkflowRun {
|
|
const gateId = "gate-1";
|
|
return {
|
|
runId: "run-1",
|
|
revision: 0,
|
|
mode: "feature",
|
|
status: "waiting",
|
|
background,
|
|
request: "Build search",
|
|
featureName: "search",
|
|
parentSessionIds: ["session-1"],
|
|
lease: {
|
|
processId: 100,
|
|
activeParentSessionId: "session-1",
|
|
heartbeatAt: 1,
|
|
},
|
|
workspace: {
|
|
state: "ready",
|
|
sourceRoot: "/repo",
|
|
root: "/repo/.worktrees/run-1",
|
|
branch: "feature/search",
|
|
baseHead: "abc123",
|
|
},
|
|
effectiveConfig: structuredClone(DEFAULT_WORKFLOW_CONFIG),
|
|
pinnedProfiles: [],
|
|
preflightEligibility: [],
|
|
facts: {
|
|
stage: "design",
|
|
taskKind: "feature",
|
|
requestTerms: ["search"],
|
|
candidatePaths: [],
|
|
languages: [],
|
|
tags: [],
|
|
riskTags: [],
|
|
},
|
|
stages: [],
|
|
gates: [
|
|
{
|
|
id: gateId,
|
|
kind: "plan-approval",
|
|
status: "open",
|
|
revision: 0,
|
|
openedAt: 1,
|
|
},
|
|
],
|
|
decisions: [],
|
|
assignments: [],
|
|
prototypeCandidates: [],
|
|
prototypeCleanupCompleted: false,
|
|
deliveries: [
|
|
{
|
|
key: `gate:run-1:${gateId}:0`,
|
|
state: "pending",
|
|
createdAt: 1,
|
|
},
|
|
],
|
|
commandReceipts: [],
|
|
uiImpactEvidence: [],
|
|
affectedUiSurfaces: [],
|
|
mutatorProfileNames: [],
|
|
reviewCycles: 0,
|
|
verificationCycles: 0,
|
|
};
|
|
}
|
|
|
|
function deliveryHarness(entries: unknown[], persistImmediately = true) {
|
|
const sent: Array<{ message: unknown; options: unknown }> = [];
|
|
const pi = {
|
|
sendMessage(message: unknown, options: unknown) {
|
|
sent.push({ message, options });
|
|
if (persistImmediately) {
|
|
const record = message as Record<string, unknown>;
|
|
entries.push({
|
|
type: "message",
|
|
message: {
|
|
role: "custom",
|
|
customType: record.customType,
|
|
details: record.details,
|
|
},
|
|
});
|
|
}
|
|
},
|
|
} as unknown as Pick<ExtensionAPI, "sendMessage">;
|
|
const ctx = {
|
|
sessionManager: {
|
|
getSessionId: () => "session-1",
|
|
getEntries: () => entries,
|
|
},
|
|
} as unknown as ExtensionContext;
|
|
return { pi, ctx, sent };
|
|
}
|
|
|
|
test("delivery receipt closes the send/save crash window exactly once", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const store = new WorkflowStore(path.join(directory, "state"));
|
|
await store.createRun(workflow());
|
|
const entries: unknown[] = [];
|
|
const harness = deliveryHarness(entries);
|
|
await reconcileWorkflowDeliveries(
|
|
harness.pi,
|
|
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
|
harness.ctx,
|
|
);
|
|
assert.equal(harness.sent.length, 1);
|
|
const message = harness.sent[0]?.message as Record<string, unknown>;
|
|
assert.equal(message.customType, workflowNotificationType);
|
|
assert.match(String(message.content), /workflow search is waiting/);
|
|
assert.equal(
|
|
(message.details as Record<string, unknown>).runId,
|
|
"run-1",
|
|
);
|
|
const delivered = await store.loadRun("run-1");
|
|
assert.equal(delivered.deliveries[0]?.state, "sent");
|
|
assert.equal(delivered.revision, 0);
|
|
await reconcileWorkflowDeliveries(
|
|
harness.pi,
|
|
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
|
harness.ctx,
|
|
);
|
|
assert.equal(harness.sent.length, 1);
|
|
});
|
|
});
|
|
|
|
test("queued foreground delivery suppresses immediate duplicates and retries a missing receipt", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const originalNow = Date.now;
|
|
let now = 1_000;
|
|
Date.now = () => now;
|
|
try {
|
|
const store = new WorkflowStore(path.join(directory, "state"));
|
|
const run = workflow();
|
|
await store.createRun(run);
|
|
const entries: unknown[] = [];
|
|
const harness = deliveryHarness(entries, false);
|
|
const runtime = {
|
|
store,
|
|
engineForContext: () => Promise.reject(new Error("unused")),
|
|
};
|
|
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
|
|
now++;
|
|
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
|
|
assert.equal(harness.sent.length, 1);
|
|
assert.deepEqual(harness.sent[0]?.options, { deliverAs: "steer" });
|
|
now += 30_000;
|
|
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
|
|
assert.equal(harness.sent.length, 2);
|
|
const sentMessage = harness.sent[1]?.message as Record<string, unknown>;
|
|
entries.push({
|
|
type: "message",
|
|
message: {
|
|
role: "custom",
|
|
customType: sentMessage.customType,
|
|
details: sentMessage.details,
|
|
},
|
|
});
|
|
await reconcileWorkflowDeliveries(harness.pi, runtime, harness.ctx);
|
|
assert.equal(harness.sent.length, 2);
|
|
assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent");
|
|
} finally {
|
|
Date.now = originalNow;
|
|
}
|
|
});
|
|
});
|
|
|
|
test("obsolete Gate deliveries settle without sending contradictory notices", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const store = new WorkflowStore(path.join(directory, "state"));
|
|
const run = workflow();
|
|
run.status = "running";
|
|
run.gates[0]!.status = "satisfied";
|
|
await store.createRun(run);
|
|
const harness = deliveryHarness([]);
|
|
await reconcileWorkflowDeliveries(
|
|
harness.pi,
|
|
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
|
harness.ctx,
|
|
);
|
|
assert.equal(harness.sent.length, 0);
|
|
assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent");
|
|
});
|
|
});
|
|
|
|
test("an existing receiver receipt marks pending delivery without resending", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const store = new WorkflowStore(path.join(directory, "state"));
|
|
const run = workflow(true);
|
|
await store.createRun(run);
|
|
const entries = [
|
|
{
|
|
type: "message",
|
|
message: {
|
|
role: "custom",
|
|
customType: workflowNotificationType,
|
|
details: { deliveryKey: run.deliveries[0]?.key },
|
|
},
|
|
},
|
|
];
|
|
const harness = deliveryHarness(entries);
|
|
await reconcileWorkflowDeliveries(
|
|
harness.pi,
|
|
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
|
harness.ctx,
|
|
);
|
|
assert.equal(harness.sent.length, 0);
|
|
assert.equal(
|
|
(await store.loadRun("run-1")).deliveries[0]?.state,
|
|
"sent",
|
|
);
|
|
});
|
|
});
|
|
|
|
test("terminal deliveries include the persisted report and settle once", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const store = new WorkflowStore(path.join(directory, "state"));
|
|
const run = workflow(true);
|
|
run.status = "completed";
|
|
run.gates = [];
|
|
run.reportArtifact = {
|
|
path: "/state/report.md",
|
|
digest: "digest",
|
|
revision: 4,
|
|
};
|
|
run.deliveries = [
|
|
{
|
|
key: "terminal:run-1:completed:4",
|
|
state: "pending",
|
|
createdAt: 4,
|
|
},
|
|
];
|
|
await store.createRun(run);
|
|
const harness = deliveryHarness([]);
|
|
await reconcileWorkflowDeliveries(
|
|
harness.pi,
|
|
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
|
harness.ctx,
|
|
);
|
|
assert.equal(harness.sent.length, 1);
|
|
assert.match(
|
|
String((harness.sent[0]?.message as Record<string, unknown>).content),
|
|
/workflow search finished with status completed.*Report: \/state\/report.md/,
|
|
);
|
|
assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent");
|
|
});
|
|
});
|
|
|
|
test("background deliveries request a follow-up turn", async () => {
|
|
await withTempDir(async (directory) => {
|
|
const store = new WorkflowStore(path.join(directory, "state"));
|
|
await store.createRun(workflow(true));
|
|
const harness = deliveryHarness([]);
|
|
await reconcileWorkflowDeliveries(
|
|
harness.pi,
|
|
{ store, engineForContext: () => Promise.reject(new Error("unused")) },
|
|
harness.ctx,
|
|
);
|
|
assert.deepEqual(harness.sent[0]?.options, {
|
|
deliverAs: "followUp",
|
|
triggerTurn: true,
|
|
});
|
|
});
|
|
});
|