feat(pi-workflow): add human-readable run labels

This commit is contained in:
jb
2026-08-25 21:22:08 -04:00
parent a1d0b1415c
commit 54a7921227
17 changed files with 385 additions and 52 deletions
@@ -84,6 +84,8 @@ function workflowView(
): WorkflowView {
return {
runId: "run-1",
featureName: "example-feature",
request: "Build example feature",
revision,
status: context.status === "ready" ? "running" : "waiting",
background: false,
@@ -257,6 +259,7 @@ test("/workflow continue lists only valid runs and resume is an alias", async ()
revision: 10,
status: "waiting",
request: `Request ${runId}`,
featureName: runId === "historical" ? "historical" : "shared-feature",
parentSessionIds: [activeParentSessionId],
lease: { activeParentSessionId },
planArtifact: { path: "/plan.md", revision: 1, digest: "digest" },
@@ -336,6 +339,10 @@ test("/workflow continue lists only valid runs and resume is an alias", async ()
]);
assert.equal(selectedOptions[0]?.length, 2);
assert.equal(selectedOptions[1]?.length, 2);
assert.deepEqual(selectedOptions[0], [
"shared-feature · run-1 · waiting",
"shared-feature · run-2 · waiting",
]);
assert.ok(
selectedOptions.every((options) =>
options.every((label) => !label.includes("historical")),
@@ -366,6 +373,29 @@ test("/workflow continue without a run ID gives actionable non-UI guidance", asy
);
});
test("/workflow continue reports the supplied run ID when inspection cannot load it", async () => {
const registered = fakeApi();
registerWorkflowContextTransitions(registered.api, {
store: { listRuns: async () => [] },
engineForContext: () =>
({
async execute() {
throw new Error("not found");
},
}) as unknown as WorkflowEngine,
} as unknown as ContextTransitionRuntime);
const command = commandContext({
sessionId: "session-1",
sessionFile: "/sessions/source.jsonl",
});
await registered.command()("continue missing-run-id", command.context);
assert.ok(
command.notifications.some((message) =>
message.includes("Workflow Run missing-run-id could not be loaded"),
),
);
});
test("/workflow stop selects among active runs owned by the current session", async () => {
const run = (
runId: string,
@@ -377,6 +407,7 @@ test("/workflow stop selects among active runs owned by the current session", as
revision: 4,
status,
request: `Request ${runId}`,
featureName: "duplicate-label",
parentSessionIds: [activeParentSessionId],
lease: { activeParentSessionId },
}) as unknown as WorkflowRun;
@@ -419,8 +450,10 @@ test("/workflow stop selects among active runs owned by the current session", as
},
});
await registered.command()("stop", command.context);
assert.equal(choices.length, 2);
assert.ok(choices.every((label) => !label.includes("completed")));
assert.deepEqual(choices, [
"duplicate-label · run-1 · running",
"duplicate-label · run-2 · waiting",
]);
assert.deepEqual(
commands.map((entry) =>
`${entry.action}:${"runId" in entry ? entry.runId : ""}`,
@@ -565,6 +598,7 @@ test("/workflow continue invokes and fences normal compaction callbacks", async
},
});
await registered.command()("continue run-1", ctx);
assert.match(compactOptions?.customInstructions ?? "", /pi-workflow run example-feature/);
assert.match(compactOptions?.customInstructions ?? "", /Approved plan/);
compactOptions?.onComplete?.({
summary: "summary",
@@ -832,6 +866,8 @@ test("fresh continuation claims shutdown target and consumes only in withSession
});
await registered.command()("continue run-1", old.context);
assert.equal(sent.length, 1);
const handoffMessage = sent[0] as { content: string };
assert.match(handoffMessage.content, /Continue pi-workflow run example-feature/);
const handoff = JSON.stringify(sent[0]);
assert.match(handoff, /Handoff plan/);
assert.doesNotMatch(handoff, /�|😀/);
+22 -16
View File
@@ -15,10 +15,8 @@ import {
type WorkflowView,
} from "./engine.ts";
import { isValidContextContinuation } from "./continuation.ts";
import {
readVerifiedPlanArtifact,
type WorkflowStore,
} from "./store.ts";
import { deriveRunLabel, deriveRunLabels } from "./run-label.ts";
import { readVerifiedPlanArtifact, type WorkflowStore } from "./store.ts";
const MAX_HANDOFF_BYTES = 32 * 1024;
type NewSessionOptions = NonNullable<
@@ -74,7 +72,7 @@ async function boundedPlan(view: WorkflowView) {
function handoffMessage(view: WorkflowView, plan: string) {
return [
`Continue pi-workflow run ${view.runId}.`,
`Continue pi-workflow run ${deriveRunLabel(view)}.`,
`Worktree: ${view.workspace.root ?? "missing"}`,
`Plan revision: ${view.planArtifact?.revision ?? "missing"}`,
"The workflow state remains authoritative. Inspect it before taking action.",
@@ -133,7 +131,10 @@ async function consumeHandoff(
previousSessionFile: sourceSessionFile,
};
await current.engine.execute(command);
replacementCtx.ui.notify(`Workflow ${runId} context handoff is ready`, "info");
replacementCtx.ui.notify(
`Workflow ${deriveRunLabel(current.view)} context handoff is ready`,
"info",
);
}
async function continueFreshSession(
@@ -190,7 +191,7 @@ async function continueNormalCompaction(
};
const compacting = await engine.execute(command);
const instructions = [
`Prepare context for pi-workflow run ${view.runId}.`,
`Prepare context for pi-workflow run ${deriveRunLabel(view)}.`,
`Preserve the worktree path, accepted Decisions, and plan revision ${view.planArtifact?.revision}.`,
"Use this approved plan as the implementation focus:",
plan,
@@ -239,11 +240,6 @@ function isInteractive(ctx: ExtensionCommandContext) {
return ctx.hasUI && ctx.mode === "tui";
}
function runLabel(run: WorkflowRun) {
const name = run.featureName ?? run.request;
return `${name} · ${run.runId} · ${run.status}`;
}
function belongsToActiveParent(run: WorkflowRun, sessionId: string) {
return run.lease?.activeParentSessionId === sessionId;
}
@@ -263,7 +259,9 @@ async function selectRun(
title: string,
runs: WorkflowRun[],
) {
const labels = runs.map(runLabel);
const labels = deriveRunLabels(runs).map(
(label, index) => `${label} · ${runs[index]!.status}`,
);
const selected = await ctx.ui.select(title, labels);
return runs[labels.indexOf(selected ?? "")];
}
@@ -274,7 +272,13 @@ async function continueRun(
ctx: ExtensionCommandContext,
) {
await ctx.waitForIdle();
const { view } = await inspect(runtime, ctx, runId);
let view: WorkflowView;
try {
({ view } = await inspect(runtime, ctx, runId));
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`Workflow Run ${runId} could not be loaded: ${reason}`);
}
const status = view.implementationContext?.status;
if (status === "handoff-pending") {
await continueFreshSession(runtime, ctx, view);
@@ -284,7 +288,9 @@ async function continueRun(
await continueNormalCompaction(runtime, ctx, view);
return;
}
throw new Error(`Workflow Run ${runId} has no pending context continuation`);
throw new Error(
`Workflow Run ${deriveRunLabel(view)} has no pending context continuation`,
);
}
async function handleContinue(
@@ -392,7 +398,7 @@ async function handleStop(
expectedRevision: current.revision,
idempotencyKey: `workflow-stop-${current.runId}-${current.revision}-${randomUUID()}`,
});
ctx.ui.notify(`Workflow ${current.runId} stopped.`, "info");
ctx.ui.notify(`Workflow ${deriveRunLabel(current)} stopped.`, "info");
}
export function executeHostDecision(
+66
View File
@@ -134,6 +134,44 @@ test("focused dashboard renders compact and narrow cards within width", () => {
assert.equal(selected, "run-1");
});
test("dashboard disambiguates duplicate feature labels without changing selection IDs", () => {
let selected: string | null | undefined;
const dashboard = new FocusedWorkflowDashboard(
[
run({ runId: "12345678-aaaa", featureName: "search-page" }),
run({ runId: "12345678-bbbb", featureName: "search-page" }),
],
theme,
(value) => { selected = value; },
);
const lines = dashboard.render(100);
assert.ok(lines.some((line) => line.includes("search-page · 12345678-a")));
assert.ok(lines.some((line) => line.includes("search-page · 12345678-b")));
dashboard.handleInput("\r");
dashboard.handleInput("\r");
assert.equal(selected, "12345678-aaaa");
});
test("dashboard disambiguates collisions outside its visible window at common widths", () => {
const runs = Array.from({ length: 30 }, (_, index) =>
run({
runId:
index === 0
? "12345678-aaaa"
: index === 29
? "12345678-bbbb"
: `run-${index}`,
featureName: index === 0 || index === 29 ? "shared-label" : `feature-${index}`,
}),
);
const dashboard = new FocusedWorkflowDashboard(runs, theme, () => {});
for (const width of [80, 60]) {
const lines = dashboard.render(width);
assert.ok(lines.some((line) => line.includes("shared-label · 12345678-a")));
assert.ok(lines.every((line) => visibleWidth(line) <= width));
}
});
test("dashboard windows long-lived session run lists", () => {
const runs = Array.from({ length: 30 }, (_, index) =>
run({ runId: `run-${index}`, featureName: `feature-${index}` }),
@@ -339,6 +377,34 @@ test("dashboard verifies and opens Assignment transcripts on demand", async () =
}
});
test("dashboard reports the supplied run ID when direct inspection cannot resolve it", async () => {
const notifications: string[] = [];
const ctx = {
hasUI: true,
mode: "tui",
sessionManager: { getSessionId: () => "session-1" },
ui: {
custom: async () => {
throw new Error("missing runs must not open the dashboard");
},
notify: (message: string) => notifications.push(message),
},
} as unknown as ExtensionCommandContext;
const result = await openWorkflowDashboard(
{ getActiveTools: () => ["read"] } as unknown as ExtensionAPI,
{
store: { listRuns: async () => [run()] } as never,
engineForContext: () => Promise.reject(new Error("unused")),
},
ctx,
"missing-run-id",
);
assert.deepEqual(result, {});
assert.deepEqual(notifications, [
"Workflow Run missing-run-id was not found for this session.",
]);
});
test("dashboard exposes continuation only for pending handoff intents", async () => {
const workflow = run({
planArtifact: { path: "/state/plan.md", revision: 3, digest: "digest" },
+24 -11
View File
@@ -21,6 +21,7 @@ import {
executeHostDecision,
type ContextTransitionRuntime,
} from "./context-transition.ts";
import { deriveRunLabels } from "./run-label.ts";
import {
readVerifiedPlanArtifact,
readVerifiedReportArtifact,
@@ -88,6 +89,7 @@ export class FocusedWorkflowDashboard implements Component {
private readonly runs: WorkflowRun[];
private readonly theme: DashboardTheme;
private readonly done: (result: string | null) => void;
private readonly labels: string[];
private selected = 0;
private detail = false;
@@ -100,6 +102,7 @@ export class FocusedWorkflowDashboard implements Component {
this.runs = runs;
this.theme = theme;
this.done = done;
this.labels = deriveRunLabels(runs);
const initial = initialRunId
? runs.findIndex((run) => run.runId === initialRunId)
: -1;
@@ -150,7 +153,7 @@ export class FocusedWorkflowDashboard implements Component {
for (const [visibleIndex, run] of visibleRuns.entries()) {
const index = windowStart + visibleIndex;
const prefix = index === this.selected ? "> " : " ";
const name = run.featureName ?? run.request.slice(0, 24);
const name = this.labels[index]!;
const status = this.theme.fg(
statusColor(run.status),
run.status.toUpperCase().padEnd(9),
@@ -171,7 +174,7 @@ export class FocusedWorkflowDashboard implements Component {
const run = this.runs[this.selected]!;
const gate = selectedGate(run);
lines.push("", this.theme.fg("dim", "─".repeat(Math.max(1, width))));
lines.push(this.theme.bold(run.featureName ?? run.request));
lines.push(this.theme.bold(this.labels[this.selected]!));
if (run.workspace.branch) lines.push(this.theme.fg("muted", run.workspace.branch));
lines.push("", `${this.theme.bold("Progress")} ${stageLine(run, this.theme)}`);
if (gate) {
@@ -202,10 +205,10 @@ export class FocusedWorkflowDashboard implements Component {
invalidate() {}
}
function runSummary(run: WorkflowRun) {
function runSummary(run: WorkflowRun, label: string) {
const gate = selectedGate(run);
return [
`${run.runId} · ${run.status} · revision ${run.revision}`,
`${label} · ${run.status} · revision ${run.revision}`,
`Stage: ${activeStage(run)}`,
...(gate ? [`Gate: ${gate.kind} (${gate.id})`] : []),
...(run.workspace.branch ? [`Branch: ${run.workspace.branch}`] : []),
@@ -443,6 +446,7 @@ async function actionSheet(
runtime: ContextTransitionRuntime,
ctx: ExtensionCommandContext,
run: WorkflowRun,
label: string,
) {
const actions = [
"Inspect run",
@@ -461,8 +465,8 @@ async function actionSheet(
: []),
...(run.workspace.root ? ["Show worktree path"] : []),
];
const action = await ctx.ui.select(run.featureName ?? run.runId, actions);
if (action === "Inspect run") ctx.ui.notify(runSummary(run), "info");
const action = await ctx.ui.select(label, actions);
if (action === "Inspect run") ctx.ui.notify(runSummary(run, label), "info");
else if (action === "Open plan") {
const plan = await readVerifiedPlanArtifact(run.planArtifact!);
await ctx.ui.editor("Workflow plan", plan.slice(0, 32 * 1024));
@@ -495,11 +499,11 @@ async function actionSheet(
decision.strategy === "smart-compact"
) {
ctx.ui.notify(
`Workflow ${run.runId} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`,
`Workflow ${label} is ready for smart_compact. Ask the assistant to invoke it and report the Gate-bound outcome through pi_workflow.`,
"info",
);
} else {
ctx.ui.notify(`Workflow ${run.runId}: ${result.status}`, "info");
ctx.ui.notify(`Workflow ${label}: ${result.status}`, "info");
}
}
}
@@ -520,12 +524,20 @@ export async function openWorkflowDashboard(
const runs = (await runtime.store.listRuns())
.filter((run) => !sessionId || run.parentSessionIds.includes(sessionId))
.sort((left, right) => right.revision - left.revision);
if (initialRunId && !runs.some((run) => run.runId === initialRunId)) {
ctx.ui.notify(
`Workflow Run ${initialRunId} was not found for this session.`,
"error",
);
return {};
}
const labels = deriveRunLabels(runs);
if (!ctx.hasUI || ctx.mode !== "tui") {
const visibleRuns = runs.slice(0, 20);
const omitted = runs.length - visibleRuns.length;
ctx.ui.notify(
runs.length > 0
? `${visibleRuns.map(runSummary).join("\n\n")}${
? `${visibleRuns.map((run, index) => runSummary(run, labels[index]!)).join("\n\n")}${
omitted > 0 ? `\n\n[${omitted} older runs omitted]` : ""
}`
: "No Workflow Runs.",
@@ -552,6 +564,7 @@ export async function openWorkflowDashboard(
},
);
if (!selected) return {};
const run = runs.find((candidate) => candidate.runId === selected);
return run ? actionSheet(pi, runtime, ctx, run) : {};
const index = runs.findIndex((candidate) => candidate.runId === selected);
const run = runs[index];
return run ? actionSheet(pi, runtime, ctx, run, labels[index]!) : {};
}
+9 -4
View File
@@ -340,6 +340,8 @@ test("analysis scripts run persisted read-only Assignments with tool intersectio
};
const completed = await engine.execute(command);
assert.equal(completed.status, "completed");
assert.equal(completed.featureName, undefined);
assert.equal(completed.request, "Audit persistence behavior");
assert.equal(setup.workspace.prepareCalls, 0);
const persisted = await setup.store.loadRun(completed.runId);
assert.equal(persisted.mode, "analysis");
@@ -3649,13 +3651,16 @@ test("feature names and branch collisions fail with actionable guidance", async
setup.workspace.branchExists = false;
setup.executor.enqueue("discover", discoveryWithQuestions());
const started = await engine.execute(
startCommand({ idempotencyKey: "owner-key" }),
);
await engine.execute(startCommand({ idempotencyKey: "owner-key" }));
setup.workspace.branchExists = true;
const [owner] = await setup.store.listRuns();
await assert.rejects(
engine.execute(startCommand({ idempotencyKey: "second-owner-key" })),
new RegExp(`${started.runId}.*inspect or decide`),
(error: unknown) => {
assert.match((error as Error).message, /inspect or decide/);
assert.ok((error as Error).message.includes(owner!.runId));
return true;
},
);
});
});
+4
View File
@@ -170,6 +170,8 @@ export type WorkflowCommand =
export interface WorkflowView {
runId: string;
featureName?: string;
request?: string;
revision: number;
status: WorkflowRun["status"];
background: boolean;
@@ -1072,6 +1074,8 @@ function openGate(run: WorkflowRun, gate: WorkflowGate) {
function view(run: WorkflowRun): WorkflowView {
return {
runId: run.runId,
...(run.featureName ? { featureName: run.featureName } : {}),
request: run.request,
revision: run.revision,
status: run.status,
background: run.background,
+6 -3
View File
@@ -130,9 +130,12 @@ test("delivery receipt closes the send/save crash window exactly once", async ()
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), /pi-workflow search is waiting/);
assert.equal(
(harness.sent[0]?.message as Record<string, unknown>).customType,
workflowNotificationType,
(message.details as Record<string, unknown>).runId,
"run-1",
);
const delivered = await store.loadRun("run-1");
assert.equal(delivered.deliveries[0]?.state, "sent");
@@ -262,7 +265,7 @@ test("terminal deliveries include the persisted report and settle once", async (
assert.equal(harness.sent.length, 1);
assert.match(
String((harness.sent[0]?.message as Record<string, unknown>).content),
/finished with status completed.*Report: \/state\/report.md/,
/pi-workflow search finished with status completed.*Report: \/state\/report.md/,
);
assert.equal((await store.loadRun("run-1")).deliveries[0]?.state, "sent");
});
+5 -3
View File
@@ -5,6 +5,7 @@ import type {
} from "@earendil-works/pi-coding-agent";
import type { WorkflowRun } from "./domain.ts";
import type { ContextTransitionRuntime } from "./context-transition.ts";
import { deriveRunLabel } from "./run-label.ts";
const NOTIFICATION_TYPE = "pi-workflow-notification";
const DELIVERY_OWNER_TOKEN = `${process.pid}:${randomUUID()}`;
@@ -50,18 +51,19 @@ function deliveryIsCurrent(run: WorkflowRun, key: string) {
}
function deliveryText(run: WorkflowRun, key: string) {
const label = deriveRunLabel(run);
if (key.startsWith("gate:")) {
const gate = [...run.gates]
.reverse()
.find((candidate) => key.includes(`:${candidate.id}:`));
return gate
? `pi-workflow ${run.runId} is waiting at the ${gate.kind} Gate (revision ${run.revision}). Inspect the run before obtaining and recording the user's Decision.`
: `pi-workflow ${run.runId} is waiting for a Gate Decision.`;
? `pi-workflow ${label} is waiting at the ${gate.kind} Gate (revision ${run.revision}). Inspect the run before obtaining and recording the user's Decision.`
: `pi-workflow ${label} is waiting for a Gate Decision.`;
}
const report = run.reportArtifact?.path
? ` Report: ${run.reportArtifact.path}`
: "";
return `pi-workflow ${run.runId} finished with status ${run.status}.${report}`;
return `pi-workflow ${label} finished with status ${run.status}.${report}`;
}
function belongsToSession(run: WorkflowRun, sessionId: string) {
+72
View File
@@ -0,0 +1,72 @@
import assert from "node:assert/strict";
import test from "node:test";
import { deriveRunLabel, deriveRunLabels } from "./run-label.ts";
test("run labels prefer feature names and normalize request fallbacks", () => {
assert.equal(
deriveRunLabel({
runId: "12345678-aaaa",
featureName: "search-page",
request: "Ignored request",
}),
"search-page",
);
assert.equal(
deriveRunLabel({
runId: "12345678-bbbb",
request: " Audit\n\tcache behavior ",
}),
"Audit cache behavior",
);
assert.equal(
deriveRunLabel({ runId: "12345678-cccc", request: "x".repeat(48) }),
"x".repeat(48),
);
});
test("request labels truncate at whole words and hard-cut overlong words", () => {
assert.equal(
deriveRunLabel({
runId: "12345678-aaaa",
request: "Build a searchable dashboard that clearly identifies every active workflow run",
}),
"Build a searchable dashboard that clearly…",
);
assert.equal(
deriveRunLabel({
runId: "12345678-bbbb",
request: `${"one ".repeat(11)}two continued`,
}),
`${"one ".repeat(11)}two…`,
);
assert.equal(
deriveRunLabel({ runId: "12345678-cccc", request: "x".repeat(60) }),
`${"x".repeat(47)}…`,
);
});
test("run labels fall back to the canonical ID when requests are missing", () => {
assert.equal(deriveRunLabel({ runId: "12345678-aaaa" }), "12345678-aaaa");
assert.equal(
deriveRunLabel({ runId: "12345678-bbbb", request: "" }),
"12345678-bbbb",
);
});
test("run labels disambiguate collisions with extendable ID prefixes", () => {
assert.deepEqual(
deriveRunLabels([
{ runId: "12345678-aaaa", featureName: "search-page" },
{ runId: "12345678-bbbb", featureName: "search-page" },
{ runId: "87654321-cccc", featureName: "other" },
]),
["search-page · 12345678-a", "search-page · 12345678-b", "other"],
);
assert.deepEqual(
deriveRunLabels([
{ runId: "aaaaaaaa-1111", request: "Audit cache" },
{ runId: "bbbbbbbb-2222", request: "Audit cache" },
]),
["Audit cache · aaaaaaaa", "Audit cache · bbbbbbbb"],
);
});
+65
View File
@@ -0,0 +1,65 @@
const MAX_REQUEST_LABEL_CHARACTERS = 48;
const MIN_RUN_ID_PREFIX_CHARACTERS = 8;
export interface LabelledRun {
runId: string;
featureName?: string;
request?: string;
}
function requestRunLabel(request: string) {
const normalized = request.trim().replace(/\s+/g, " ");
const characters = [...normalized];
if (characters.length <= MAX_REQUEST_LABEL_CHARACTERS) return normalized;
const keptCharacters = characters.slice(0, MAX_REQUEST_LABEL_CHARACTERS - 1);
const candidate = keptCharacters.join("");
const nextCharacter = characters[MAX_REQUEST_LABEL_CHARACTERS - 1];
const lastWhitespace = candidate.search(/\s+\S*$/);
const bounded =
nextCharacter !== undefined && /\s/.test(nextCharacter)
? candidate.trimEnd()
: lastWhitespace > 0
? candidate.slice(0, lastWhitespace).trimEnd()
: candidate;
return `${bounded}…`;
}
export function deriveRunLabel(run: LabelledRun) {
const featureName = run.featureName?.trim();
if (featureName) return featureName;
const request = run.request?.trim();
return request ? requestRunLabel(request) : run.runId;
}
function uniqueRunIdPrefix(runId: string, peers: string[]) {
const characters = [...runId];
for (
let length = Math.min(MIN_RUN_ID_PREFIX_CHARACTERS, characters.length);
length < characters.length;
length++
) {
const prefix = characters.slice(0, length).join("");
if (peers.every((peer) => !peer.startsWith(prefix))) return prefix;
}
return runId;
}
/** Derives presentation labels and disambiguates only collisions in this list. */
export function deriveRunLabels<T extends LabelledRun>(runs: readonly T[]) {
const baseLabels = runs.map(deriveRunLabel);
const collisions = new Map<string, number[]>();
for (const [index, label] of baseLabels.entries()) {
const indexes = collisions.get(label) ?? [];
indexes.push(index);
collisions.set(label, indexes);
}
return baseLabels.map((label, index) => {
const indexes = collisions.get(label)!;
if (indexes.length === 1) return label;
const run = runs[index]!;
const peers = indexes
.filter((peerIndex) => peerIndex !== index)
.map((peerIndex) => runs[peerIndex]!.runId);
return `${label} · ${uniqueRunIdPrefix(run.runId, peers)}`;
});
}
+3 -4
View File
@@ -11,10 +11,7 @@ import { tmpdir } from "node:os";
import * as path from "node:path";
import test from "node:test";
import { DEFAULT_WORKFLOW_CONFIG, type WorkflowRun } from "./domain.ts";
import {
readVerifiedReportArtifact,
WorkflowStore,
} from "./store.ts";
import { readVerifiedReportArtifact, WorkflowStore } from "./store.ts";
async function withTempDir(run: (directory: string) => Promise<void>) {
const directory = await mkdtemp(path.join(tmpdir(), "pi-workflow-store-"));
@@ -141,6 +138,8 @@ test("terminal reports bound large persisted Decisions instead of blocking compl
await store.createRun(run);
const report = await store.saveReport(run);
const contents = await readFile(report.path, "utf8");
assert.match(contents, /^# pi-workflow build-feature/m);
assert.match(contents, /- Run ID: run-1/);
assert.match(contents, /\[truncated\]/);
assert.match(contents, /report truncated to 1 MiB/);
assert.ok(Buffer.byteLength(contents, "utf8") <= 1024 * 1024);
+3 -1
View File
@@ -17,6 +17,7 @@ import type {
WorkflowReportArtifact,
WorkflowRun,
} from "./domain.ts";
import { deriveRunLabel } from "./run-label.ts";
const SNAPSHOT_VERSION = 1;
const MAX_JSON_BYTES = 4 * 1024 * 1024;
@@ -340,8 +341,9 @@ export class WorkflowStore {
async saveReport(run: WorkflowRun) {
const lines = [
`# pi-workflow ${run.runId}`,
`# pi-workflow ${deriveRunLabel(run)}`,
"",
`- Run ID: ${run.runId}`,
`- Status: ${run.status}`,
`- Revision: ${run.revision}`,
`- Mode: ${run.mode}`,
+38 -1
View File
@@ -14,6 +14,8 @@ import {
function workflowView(overrides: Partial<WorkflowView> = {}): WorkflowView {
return {
runId: "run-1",
featureName: "search-page",
request: "Build search page",
revision: 4,
status: "waiting",
workspace: {
@@ -243,6 +245,39 @@ test("tool cancellation aborts its active Workflow Run before rejecting", async
assert.equal(commands.some((command) => command.action === "abort"), true);
});
test("inspect errors retain the supplied run ID when no label can be derived", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const pi = {
registerTool(candidate: Parameters<ExtensionAPI["registerTool"]>[0]) {
definition = candidate;
},
getActiveTools: () => ["read"],
} as unknown as ExtensionAPI;
registerPiWorkflowTool(pi, {
store: {} as never,
async engineForContext() {
return {
async execute() {
throw new Error("not found");
},
} as never;
},
});
assert.ok(definition);
await assert.rejects(
definition.execute(
"inspect-call",
{ action: "inspect", runId: "missing-run-id" },
new AbortController().signal,
undefined,
{
sessionManager: { getSessionId: () => "session-1" },
} as unknown as ExtensionContext,
),
/Workflow Run missing-run-id could not be loaded/,
);
});
test("registered tool executes through the context engine and injects host tools", async () => {
let definition: Parameters<ExtensionAPI["registerTool"]>[0] | undefined;
const commands: WorkflowCommand[] = [];
@@ -267,7 +302,7 @@ test("registered tool executes through the context engine and injects host tools
});
assert.equal(definition?.name, "pi_workflow");
assert.ok(definition);
await definition.execute(
const result = await definition.execute(
"call-1",
{
action: "decide",
@@ -293,4 +328,6 @@ test("registered tool executes through the context engine and injects host tools
commands[0]?.action === "decide" ? commands[0].activeToolNames : undefined,
["read", "smart_compact"],
);
assert.match(result.content[0]?.type === "text" ? result.content[0].text : "", /Workflow search-page/);
assert.equal((result.details as { runId: string }).runId, "run-1");
});
+14 -3
View File
@@ -11,6 +11,7 @@ import {
type ContextTransitionRuntime,
} from "./context-transition.ts";
import type { WorkflowCommand, WorkflowView } from "./engine.ts";
import { deriveRunLabel } from "./run-label.ts";
const ACTIONS = ["start", "inspect", "decide", "abort"] as const;
const WORKFLOW_MODES = ["feature", "feature-script", "analysis-script"] as const;
@@ -370,9 +371,9 @@ function boundToolOutput(contents: string) {
return `${characters.join("")}${marker}`;
}
function resultText(details: WorkflowToolDetails) {
function resultText(details: WorkflowToolDetails, label: string) {
const lines = [
`Workflow ${details.runId}: ${details.status} at revision ${details.revision}.`,
`Workflow ${label}: ${details.status} at revision ${details.revision}.`,
];
if (details.openGate) {
lines.push(
@@ -479,6 +480,14 @@ export function registerPiWorkflowTool(
await cancellationWork;
throw cancellationError();
}
} catch (error) {
if (params.action === "inspect" && params.runId) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Workflow Run ${params.runId} could not be loaded: ${reason}`,
);
}
throw error;
} finally {
executionSignal.removeEventListener("abort", onAbort);
}
@@ -488,7 +497,9 @@ export function registerPiWorkflowTool(
ctx.sessionManager.getSessionId(),
);
return {
content: [{ type: "text" as const, text: resultText(details) }],
content: [
{ type: "text" as const, text: resultText(details, deriveRunLabel(view)) },
],
details,
};
},