diff --git a/docs/pi-workflow/README.md b/docs/pi-workflow/README.md index c782a3d..f5eacc9 100644 --- a/docs/pi-workflow/README.md +++ b/docs/pi-workflow/README.md @@ -37,7 +37,7 @@ The assistant must inspect the current run before each state-changing call and o ## Inspect and continue -Use `/workflow` for the focused-card dashboard. It lists runs attached to the current session and shows persisted status, progress, Gate, plan, report, branch, and worktree information. +Use `/workflow` for the focused-card dashboard. It lists runs attached to the current session and shows persisted status, progress, Gate, plan, report, branch, and worktree information. Human-facing surfaces use the feature name as the run label. Analysis and legacy runs fall back to a request label of at most 48 characters, then to the run ID when no request exists. Duplicate labels in one available-run list receive ` · `, using the shortest unique prefix of at least eight characters. Use `/workflow continue` to select a run with a pending fresh-session handoff or normal-compaction continuation. `/workflow resume` is an alias. A run ID may be supplied directly: @@ -46,7 +46,7 @@ Use `/workflow continue` to select a run with a pending fresh-session handoff or /workflow resume ``` -Selection requires interactive TUI mode. In non-interactive modes, provide the run ID. +Selection requires interactive TUI mode. In non-interactive modes, provide the run ID. Labels are presentation-only and cannot be used as lookup aliases; copyable continue and stop commands always retain the full run ID. They require no schema migration and do not change persisted paths. ## Stop active work @@ -80,7 +80,7 @@ Run state and bounded artifacts are stored under: ~/.pi/agent/pi-workflows/runs// ``` -Important files include immutable plan revisions, Assignment transcripts, retained prototype reports and screenshots, `result.json`, and the terminal Markdown report. Completed worktrees remain available for manual inspection and integration. +Important files include immutable plan revisions, Assignment transcripts, retained prototype reports and screenshots, `result.json`, and the terminal Markdown report. Report headings use the readable label and include the full canonical Run ID as explicit metadata. Completed worktrees remain available for manual inspection and integration. ## Reload after exposure diff --git a/docs/pi-workflow/dashboard-mocks.md b/docs/pi-workflow/dashboard-mocks.md index a5142ef..c344268 100644 --- a/docs/pi-workflow/dashboard-mocks.md +++ b/docs/pi-workflow/dashboard-mocks.md @@ -128,6 +128,14 @@ Recent workflow events cache-audit Completed 31m ``` +If labels collide in this available list, only the duplicates gain the shortest unique run-ID prefix (at least eight characters): + +```text +> search-page · 12345678a Plan Approval + search-page · 12345678b Implement + cache-audit Completed +``` + Narrow terminals preserve the same timeline and omit timestamps first. ## Direction criteria @@ -137,5 +145,7 @@ The implementation should: - Start dark-mode friendly through Pi theme tokens. - Keep the default view compact and reveal paths, evidence, and transcripts on demand. - Show only valid actions for the current persisted Gate or continuation intent. +- Use feature-name-first presentation labels, with bounded request fallbacks and collision suffixes only when needed. +- Keep the full run ID out of ordinary prose while retaining it in copyable commands, structured details, and report metadata. - Keep run status, revision, branch, worktree, plan, report, and prototype evidence discoverable. - Remain usable from narrow terminals through 4K-width terminals without continuous animation. diff --git a/docs/pi-workflow/design.md b/docs/pi-workflow/design.md index 82cbe47..f6c0932 100644 --- a/docs/pi-workflow/design.md +++ b/docs/pi-workflow/design.md @@ -149,13 +149,15 @@ type GateDecision = Feature and feature-script starts require `featureName` as a concise lowercase kebab-case name that describes what the feature does, such as `searchable-workflow-dashboard`. Analysis starts omit it. The host validates the name before persisting launch intent. +Human-facing dashboard, selector, notification, handoff, compaction, tool, and report prose uses a shared presentation label. It prefers `featureName`; otherwise it normalizes the request to one line and bounds it to 48 characters, preserving whole words where possible and adding `…` when truncated; only a missing request falls back to `runId`. When derived labels collide within an available run list, only those labels receive ` · `, where the run-ID prefix is the shortest unique prefix with a minimum of eight characters. This label is never persisted as identity or accepted as a lookup alias. It requires no schema migration and does not change persisted paths. The complete `runId` remains canonical for snapshots, structured fields, command arguments, paths, receipts, leases, and idempotency. Copyable `/workflow continue ` and `/workflow stop ` commands retain it, and reports include it as `Run ID` metadata beneath the readable heading. + The `pi_workflow` tool calls a single internal `execute(command)` interface. `/workflow` opens the dashboard and may accept a run ID for direct inspection. `/workflow start ` sends the initial request through the assistant so the normal `pi_workflow` tool path owns feature naming and idempotency; in interactive TUI mode, omitting the message opens an input prompt. `/workflow continue` presents eligible pending context transitions for selection, while `/workflow continue ` continues one directly; `/workflow resume` is an alias for both forms. `/workflow stop [run-id]` aborts a non-terminal run owned by the active parent session and presents a selector when multiple owned runs are active. The misspelled `/worlflow` alias is intentionally unsupported. Continue and stop selectors require interactive TUI mode and provide direct-run-ID guidance otherwise. Continuation performs only a pending fresh-session handoff or normal-compaction fallback after the run is safely waiting; it cannot approve plans, select prototypes, or make other Decisions. Stop treats `running` and `waiting` runs as active and excludes terminal runs and runs owned only by historical parent sessions. When a run reaches a Gate, the tool returns a structured waiting view. The parent agent asks the user through its normal interaction tools, then calls `pi_workflow` with `action: "decide"`. Child agents never receive `ask_user`. -Background runs return a run ID immediately. When they reach a Gate or settle, the extension sends one custom follow-up message with a stable delivery key to the active parent session. +Background runs return a structured run ID immediately. When they reach a Gate or settle, the extension sends one custom follow-up message with a stable delivery key to the active parent session and a readable run label in its prose. After `start` creates the run lease, every subsequent state-changing parent operation is authenticated against its active parent-session ID. Session IDs retained in `parentSessionIds` are historical discovery links and permit inspection only. diff --git a/extensions/pi-workflow/context-transition.test.ts b/extensions/pi-workflow/context-transition.test.ts index a20198d..f8e1d3e 100644 --- a/extensions/pi-workflow/context-transition.test.ts +++ b/extensions/pi-workflow/context-transition.test.ts @@ -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, /�|😀/); diff --git a/extensions/pi-workflow/context-transition.ts b/extensions/pi-workflow/context-transition.ts index 5472c58..0facd21 100644 --- a/extensions/pi-workflow/context-transition.ts +++ b/extensions/pi-workflow/context-transition.ts @@ -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( diff --git a/extensions/pi-workflow/dashboard.test.ts b/extensions/pi-workflow/dashboard.test.ts index f308190..894afe0 100644 --- a/extensions/pi-workflow/dashboard.test.ts +++ b/extensions/pi-workflow/dashboard.test.ts @@ -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" }, diff --git a/extensions/pi-workflow/dashboard.ts b/extensions/pi-workflow/dashboard.ts index 3856718..939b7a3 100644 --- a/extensions/pi-workflow/dashboard.ts +++ b/extensions/pi-workflow/dashboard.ts @@ -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]!) : {}; } diff --git a/extensions/pi-workflow/engine.test.ts b/extensions/pi-workflow/engine.test.ts index eb18cbd..af1117a 100644 --- a/extensions/pi-workflow/engine.test.ts +++ b/extensions/pi-workflow/engine.test.ts @@ -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; + }, ); }); }); diff --git a/extensions/pi-workflow/engine.ts b/extensions/pi-workflow/engine.ts index 45894cd..ab355b9 100644 --- a/extensions/pi-workflow/engine.ts +++ b/extensions/pi-workflow/engine.ts @@ -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, diff --git a/extensions/pi-workflow/notifications.test.ts b/extensions/pi-workflow/notifications.test.ts index 136e481..f3d6897 100644 --- a/extensions/pi-workflow/notifications.test.ts +++ b/extensions/pi-workflow/notifications.test.ts @@ -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; + assert.equal(message.customType, workflowNotificationType); + assert.match(String(message.content), /pi-workflow search is waiting/); assert.equal( - (harness.sent[0]?.message as Record).customType, - workflowNotificationType, + (message.details as Record).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).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"); }); diff --git a/extensions/pi-workflow/notifications.ts b/extensions/pi-workflow/notifications.ts index e97fab5..5b9621a 100644 --- a/extensions/pi-workflow/notifications.ts +++ b/extensions/pi-workflow/notifications.ts @@ -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) { diff --git a/extensions/pi-workflow/run-label.test.ts b/extensions/pi-workflow/run-label.test.ts new file mode 100644 index 0000000..66b050b --- /dev/null +++ b/extensions/pi-workflow/run-label.test.ts @@ -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"], + ); +}); diff --git a/extensions/pi-workflow/run-label.ts b/extensions/pi-workflow/run-label.ts new file mode 100644 index 0000000..d65d804 --- /dev/null +++ b/extensions/pi-workflow/run-label.ts @@ -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(runs: readonly T[]) { + const baseLabels = runs.map(deriveRunLabel); + const collisions = new Map(); + 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)}`; + }); +} diff --git a/extensions/pi-workflow/store.test.ts b/extensions/pi-workflow/store.test.ts index a51d161..ea220dc 100644 --- a/extensions/pi-workflow/store.test.ts +++ b/extensions/pi-workflow/store.test.ts @@ -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) { 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); diff --git a/extensions/pi-workflow/store.ts b/extensions/pi-workflow/store.ts index 0cac6b0..caad03b 100644 --- a/extensions/pi-workflow/store.ts +++ b/extensions/pi-workflow/store.ts @@ -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}`, diff --git a/extensions/pi-workflow/tool.test.ts b/extensions/pi-workflow/tool.test.ts index ea672e4..dc6713f 100644 --- a/extensions/pi-workflow/tool.test.ts +++ b/extensions/pi-workflow/tool.test.ts @@ -14,6 +14,8 @@ import { function workflowView(overrides: Partial = {}): 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[0] | undefined; + const pi = { + registerTool(candidate: Parameters[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[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"); }); diff --git a/extensions/pi-workflow/tool.ts b/extensions/pi-workflow/tool.ts index 8b0b49f..7842b5b 100644 --- a/extensions/pi-workflow/tool.ts +++ b/extensions/pi-workflow/tool.ts @@ -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, }; },