feat(pi-workflow): add human-readable run labels
This commit is contained in:
@@ -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 ` · <run-id-prefix>`, 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 <run-id>
|
||||
```
|
||||
|
||||
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/<run-id>/
|
||||
```
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ` · <prefix>`, 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 <run-id>` and `/workflow stop <run-id>` 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 <message>` 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 <run-id>` 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.
|
||||
|
||||
|
||||
@@ -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, /�|😀/);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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]!) : {};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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) {
|
||||
|
||||
@@ -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"],
|
||||
);
|
||||
});
|
||||
@@ -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)}`;
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user