pi-workflow-engine 0.12.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +106 -391
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-challenge.ts +75 -0
  3. package/.pi/extensions/pi-workflow-engine/src/advisory-evidence.ts +90 -0
  4. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +20 -2
  5. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +48 -62
  6. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +46 -67
  7. package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +14 -37
  8. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +5 -11
  9. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +15 -21
  10. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +13 -43
  11. package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +17 -5
  12. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +93 -100
  13. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +11 -43
  14. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +18 -18
  15. package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +33 -86
  16. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +1 -5
  17. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +37 -86
  18. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +16 -23
  19. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +1 -4
  20. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +39 -98
  21. package/.pi/extensions/pi-workflow-engine/src/engine.ts +37 -80
  22. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +2 -10
  23. package/.pi/extensions/pi-workflow-engine/src/git.ts +55 -0
  24. package/.pi/extensions/pi-workflow-engine/src/guards.ts +8 -0
  25. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +7 -19
  26. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +171 -106
  27. package/.pi/extensions/pi-workflow-engine/src/journal.ts +1 -12
  28. package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +37 -8
  29. package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +15 -66
  30. package/.pi/extensions/pi-workflow-engine/src/options.ts +97 -16
  31. package/.pi/extensions/pi-workflow-engine/src/perf.ts +43 -72
  32. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +14 -4
  33. package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +2 -0
  34. package/.pi/extensions/pi-workflow-engine/src/progress.ts +79 -80
  35. package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +28 -20
  36. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +2 -7
  37. package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +0 -1
  38. package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +27 -31
  39. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +128 -142
  40. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +14 -31
  41. package/.pi/extensions/pi-workflow-engine/src/review/patch-validation.ts +119 -0
  42. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +1 -1
  43. package/.pi/extensions/pi-workflow-engine/src/review/review-diff-lines.ts +54 -0
  44. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +41 -7
  45. package/.pi/extensions/pi-workflow-engine/src/review/review-format.ts +51 -116
  46. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +1 -1
  47. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +9 -14
  48. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +3 -7
  49. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +158 -241
  50. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +28 -38
  51. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +35 -44
  52. package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +13 -14
  53. package/.pi/extensions/pi-workflow-engine/src/text.ts +29 -0
  54. package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +2 -1
  55. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +16 -28
  56. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +34 -14
  57. package/.pi/extensions/pi-workflow-engine/src/types.ts +9 -0
  58. package/.pi/extensions/pi-workflow-engine/src/ui/kit.ts +193 -0
  59. package/.pi/extensions/pi-workflow-engine/src/ui/research-result-renderer.ts +177 -0
  60. package/.pi/extensions/pi-workflow-engine/src/ui/result-layout.ts +209 -0
  61. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +17 -44
  62. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +703 -235
  63. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-picker.ts +48 -0
  64. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +239 -126
  65. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-runs-browser.ts +461 -0
  66. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-tool-renderer.ts +91 -0
  67. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +316 -98
  68. package/.pi/extensions/pi-workflow-engine/src/usage.ts +21 -32
  69. package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +178 -188
  70. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +19 -24
  71. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +17 -37
  72. package/.pi/extensions/pi-workflow-engine/src/workflow-result-context.ts +79 -0
  73. package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +4 -14
  74. package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +77 -63
  75. package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +42 -29
  76. package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +15 -14
  77. package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +27 -31
  78. package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +1 -5
  79. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +3 -6
  80. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +55 -110
  81. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +101 -203
  82. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +51 -187
  83. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +33 -109
  84. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +36 -106
  85. package/.pi/extensions/pi-workflow-engine/workflows/research.ts +74 -66
  86. package/USAGE.md +90 -17
  87. package/package.json +1 -2
  88. package/skills/workflow-code-review-actions/SKILL.md +12 -23
  89. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +0 -81
  90. package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +0 -49
  91. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +0 -52
  92. package/.pi/settings.json +0 -3
@@ -0,0 +1,79 @@
1
+ import { formatAdvisoryLocation, isAdvisoryReport, type AdvisoryFinding, type AdvisoryReport } from "./advisory-schema.ts";
2
+ import { isRecord } from "./guards.ts";
3
+ import { toReviewIssues } from "./review/review-issues.ts";
4
+ import { formatPerfSummary } from "./perf.ts";
5
+ import { prettyJson } from "./text.ts";
6
+ import { formatWorkflowUsageLine } from "./usage.ts";
7
+ import { workflowResultSummary, type WorkflowResultEnvelope } from "./workflow-execution.ts";
8
+
9
+ /** Cap on the result JSON copied into the host agent's context; the run record keeps the full value. */
10
+ const MAX_CONTEXT_RESULT_JSON_CHARS = 20_000;
11
+
12
+ /** The host model only sees this text, never the envelope `details`, so it carries every result field. */
13
+ export function formatWorkflowResultForContext(envelope: WorkflowResultEnvelope): string {
14
+ const details = formatWorkflowDetailLines(envelope);
15
+ return `## Workflow: ${envelope.name}\n\n${formatResultForContext(envelope.result)}${details.length > 0 ? `\n\n${details.join("\n")}` : ""}`;
16
+ }
17
+
18
+ /** Run id, usage and perf lines appended for the host model. */
19
+ function formatWorkflowDetailLines(envelope: WorkflowResultEnvelope): string[] {
20
+ const run = envelope.runId
21
+ ? envelope.resumedFromRunId ? `Run: ${envelope.runId} (resumed from ${envelope.resumedFromRunId})` : `Run: ${envelope.runId}`
22
+ : undefined;
23
+ return [
24
+ run,
25
+ formatWorkflowUsageLine(envelope.usage),
26
+ envelope.perf ? formatPerfSummary(envelope.perf.aggregates) : undefined,
27
+ ].filter((line): line is string => line !== undefined);
28
+ }
29
+
30
+ function formatResultForContext(result: unknown): string {
31
+ if (typeof result === "string") return result;
32
+ if (isAdvisoryReport(result)) return formatAdvisoryReportForContext(result);
33
+ const summary = workflowResultSummary(result);
34
+ if (summary !== undefined && isRecord(result) && Object.keys(result).length === 1) return summary;
35
+ const json = formatResultJson(result);
36
+ if (json === undefined) return summary ?? "Workflow finished.";
37
+ return summary === undefined ? json : `${summary}\n\n${json}`;
38
+ }
39
+
40
+ function formatAdvisoryReportForContext(report: AdvisoryReport): string {
41
+ const lines = [report.summary];
42
+ const issues = toReviewIssues(report);
43
+ if (issues.length > 0) {
44
+ lines.push("", "Findings:");
45
+ for (const { id, finding } of issues) {
46
+ lines.push(
47
+ `\n### ${id}: ${finding.summary}`,
48
+ `- Severity: ${finding.severity}`,
49
+ `- Confidence: ${finding.confidence}`,
50
+ `- Category: ${finding.category}`,
51
+ `- Location: ${formatFindingLocations(finding)}`,
52
+ `- Impact: ${finding.impact}`,
53
+ `- Evidence: ${finding.evidence.length > 0 ? finding.evidence.join("; ") : "(none cited)"}`,
54
+ `- Recommendation: ${finding.recommendation}`,
55
+ );
56
+ }
57
+ }
58
+ if (report.gaps && report.gaps.length > 0) {
59
+ lines.push("", "Coverage gaps:", ...report.gaps.map((gap) => `- ${gap}`));
60
+ }
61
+ if (report.nextSteps.length > 0) {
62
+ lines.push("", "Next steps:", ...report.nextSteps.map((step) => `- ${step}`));
63
+ }
64
+ return lines.join("\n");
65
+ }
66
+
67
+ function formatResultJson(result: unknown): string | undefined {
68
+ if (result === null || result === undefined) return undefined;
69
+ const json = prettyJson(result);
70
+ if (json === undefined) return undefined;
71
+ if (json.length <= MAX_CONTEXT_RESULT_JSON_CHARS) return `Result:\n\`\`\`json\n${json}\n\`\`\``;
72
+ return `Result (first ${MAX_CONTEXT_RESULT_JSON_CHARS} of ${json.length} characters; truncated, the run record holds the full value):\n\`\`\`json\n${json.slice(0, MAX_CONTEXT_RESULT_JSON_CHARS)}\n\`\`\``;
73
+ }
74
+
75
+ /** The review anchor that the findings viewer and PR comments cite comes first, then every other cited location. */
76
+ function formatFindingLocations(finding: AdvisoryFinding): string {
77
+ const locations = finding.reviewAnchor ? [finding.reviewAnchor, ...finding.locations] : finding.locations;
78
+ return [...new Set(locations.map(formatAdvisoryLocation))].join(", ");
79
+ }
@@ -1,3 +1,5 @@
1
+ import { isFiniteNumber, isRecord } from "./guards.ts";
2
+ import { truncateText } from "./text.ts";
1
3
  import type { WorkflowBackgroundOrigin } from "./types.ts";
2
4
  import type { WorkflowRunRecord } from "./workflow-run-record.ts";
3
5
 
@@ -19,7 +21,7 @@ export function createPersistedWorkflowBackground(
19
21
  if (!origin) return undefined;
20
22
  return {
21
23
  origin: {
22
- sessionId: boundedText(origin.sessionId),
24
+ sessionId: truncateText(origin.sessionId, MAX_BACKGROUND_TEXT),
23
25
  requestedAt: origin.requestedAt,
24
26
  },
25
27
  delivery: { state: "pending" },
@@ -38,7 +40,7 @@ export function updateWorkflowRunDelivery(
38
40
  background: {
39
41
  origin: record.background.origin,
40
42
  delivery: delivery.state === "unavailable"
41
- ? { ...delivery, message: boundedText(delivery.message) }
43
+ ? { ...delivery, message: truncateText(delivery.message, MAX_BACKGROUND_TEXT) }
42
44
  : delivery,
43
45
  },
44
46
  };
@@ -58,15 +60,3 @@ export function isPersistedWorkflowBackground(value: unknown): value is Persiste
58
60
  return false;
59
61
  }
60
62
  }
61
-
62
- function boundedText(value: string): string {
63
- return value.length <= MAX_BACKGROUND_TEXT ? value : `${value.slice(0, MAX_BACKGROUND_TEXT - 1)}…`;
64
- }
65
-
66
- function isFiniteNumber(value: unknown): value is number {
67
- return typeof value === "number" && Number.isFinite(value);
68
- }
69
-
70
- function isRecord(value: unknown): value is Record<string, unknown> {
71
- return typeof value === "object" && value !== null && !Array.isArray(value);
72
- }
@@ -18,19 +18,21 @@ import {
18
18
  isWorkflowRunLifecycleAction,
19
19
  parseWorkflowRunsCommand,
20
20
  retainedWorkflowRunOutcome,
21
+ supersedingRuns,
21
22
  WORKFLOW_RUN_ACTIONS,
22
23
  WORKFLOW_RUN_HISTORY_LIMIT,
24
+ type WorkflowRunActionContext,
23
25
  type WorkflowRunLifecycleAction,
24
26
  } from "./workflow-run-history.ts";
25
- import { transitionWorkflowRun, type WorkflowRunRecord } from "./workflow-run-record.ts";
27
+ import { stopWorkflowRunRecord, type WorkflowRunRecord } from "./workflow-run-record.ts";
26
28
  import { ProjectWorkflowRunStore, type WorkflowRunStore } from "./workflow-run-store.ts";
27
29
  import { unknownErrorMessage } from "./unknown-error.ts";
28
- import { emptyWorkflowUsageTotals } from "./usage.ts";
29
30
  import {
30
31
  WorkflowUsageLimitScheduler,
31
32
  type WorkflowUsageLimitSchedulerClock,
32
33
  } from "./workflow-usage-limit-scheduler.ts";
33
- import { WorkflowInspector } from "./ui/workflow-inspector.ts";
34
+ import { showWorkflowInspector } from "./ui/workflow-inspector.ts";
35
+ import { WorkflowRunsBrowser, type WorkflowRunsBrowserChoice } from "./ui/workflow-runs-browser.ts";
34
36
  import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./ui/workflow-viewer-layout.ts";
35
37
  import { completeCurrentArgument, splitArgumentPrefix } from "./command-completions.ts";
36
38
 
@@ -77,8 +79,10 @@ export class WorkflowRunController {
77
79
  this.completionContext = { cwd: ctx.cwd, sessionManager: ctx.sessionManager };
78
80
  this.usageLimitScheduler.activateSession(ctx);
79
81
  try {
80
- for (const record of await this.storeForCwd(ctx.cwd).list()) {
81
- if (canRelaunchWorkflowRun(record)) this.usageLimitScheduler.arm(ctx, record);
82
+ const records = await this.storeForCwd(ctx.cwd).list();
83
+ const resumed = resumedRunIds(records);
84
+ for (const record of records) {
85
+ if (!resumed.has(record.runId) && canRelaunchWorkflowRun(record)) this.usageLimitScheduler.arm(ctx, record);
82
86
  }
83
87
  } catch (error) {
84
88
  this.log(`[workflow] provider-limit recovery could not load run history: ${unknownErrorMessage(error)}`);
@@ -101,11 +105,12 @@ export class WorkflowRunController {
101
105
  return;
102
106
  }
103
107
  if (!ctx.hasUI) {
104
- const records = await this.listRecent(ctx.cwd);
105
- ctx.ui.notify(formatWorkflowRunHistory(records, this.background.activeRunIds(ctx)), "info");
108
+ const { records, runs } = await this.history(ctx);
109
+ ctx.ui.notify(formatWorkflowRunHistory(records, runs), "info");
106
110
  return;
107
111
  }
108
- await this.openRunSelector(ctx);
112
+ if (ctx.mode === "tui") await this.openRunsBrowser(ctx);
113
+ else await this.openRunSelector(ctx);
109
114
  }
110
115
 
111
116
  async inspectStoredRun(ctx: ExtensionContext, runId: string): Promise<boolean> {
@@ -115,17 +120,35 @@ export class WorkflowRunController {
115
120
  return true;
116
121
  }
117
122
 
118
- private async openRunSelector(ctx: ExtensionCommandContext): Promise<void> {
123
+ /** The TUI runs overlay; it reopens on the same run after each action until closed. */
124
+ private async openRunsBrowser(ctx: ExtensionCommandContext): Promise<void> {
125
+ let initialRunId: string | undefined;
119
126
  while (true) {
120
- const records = await this.listRecent(ctx.cwd);
121
- const active = this.background.activeRunIds(ctx);
127
+ const { records, runs } = await this.history(ctx);
122
128
  if (records.length === 0) {
123
- ctx.ui.notify(formatWorkflowRunHistory(records, active), "info");
129
+ ctx.ui.notify(formatWorkflowRunHistory(records, runs), "info");
124
130
  return;
125
131
  }
126
- const options = records.map((record) =>
127
- formatWorkflowRunSummary(record, active.has(record.runId))
132
+ const choice = await ctx.ui.custom<WorkflowRunsBrowserChoice | undefined>(
133
+ (...[tui, theme, , done]) =>
134
+ new WorkflowRunsBrowser({ records, runs }, tui, theme, done, { initialRunId, refresh: () => this.history(ctx) }),
135
+ WORKFLOW_VIEWER_OVERLAY_OPTIONS,
128
136
  );
137
+ if (!choice) return;
138
+ initialRunId = choice.runId;
139
+ await this.perform(choice.action, choice.runId, ctx);
140
+ }
141
+ }
142
+
143
+ /** Two native selects (run, then action) for UI hosts that cannot show custom components, such as RPC. */
144
+ private async openRunSelector(ctx: ExtensionCommandContext): Promise<void> {
145
+ while (true) {
146
+ const { records, runs } = await this.history(ctx);
147
+ if (records.length === 0) {
148
+ ctx.ui.notify(formatWorkflowRunHistory(records, runs), "info");
149
+ return;
150
+ }
151
+ const options = records.map((record) => formatWorkflowRunSummary(record, runs));
129
152
  const selected = await ctx.ui.select("Workflow Runs", options);
130
153
  if (!selected) return;
131
154
  const selectedIndex = options.indexOf(selected);
@@ -135,7 +158,7 @@ export class WorkflowRunController {
135
158
  ctx.ui.notify("The selected workflow run is no longer available.", "warning");
136
159
  continue;
137
160
  }
138
- const actions = availableWorkflowRunActions(record, active.has(record.runId));
161
+ const actions = availableWorkflowRunActions(record, runs);
139
162
  const selectedAction = await ctx.ui.select(
140
163
  `${record.workflow.name} · ${record.runId}`,
141
164
  [...actions],
@@ -149,22 +172,10 @@ export class WorkflowRunController {
149
172
 
150
173
  private async inspect(record: WorkflowRunRecord, ctx: ExtensionContext): Promise<void> {
151
174
  if (!ctx.hasUI || ctx.mode !== "tui") {
152
- ctx.ui.notify(
153
- formatWorkflowRunDetails(record, this.background.activeRunIds(ctx).has(record.runId)),
154
- "info",
155
- );
175
+ ctx.ui.notify(formatWorkflowRunDetails(record, (await this.history(ctx)).runs), "info");
156
176
  return;
157
177
  }
158
- await ctx.ui.custom<void>(
159
- (tui, theme, _keybindings, done) => new WorkflowInspector(
160
- () => record.progress,
161
- tui,
162
- theme,
163
- () => done(undefined),
164
- { label: `${record.state.toUpperCase()} outcome`, text: retainedWorkflowRunOutcome(record) },
165
- ),
166
- WORKFLOW_VIEWER_OVERLAY_OPTIONS,
167
- );
178
+ await showWorkflowInspector(ctx.ui, () => record.progress, { text: retainedWorkflowRunOutcome(record), state: record.state });
168
179
  }
169
180
 
170
181
  private async perform(
@@ -181,12 +192,15 @@ export class WorkflowRunController {
181
192
  await this.inspect(record, ctx);
182
193
  return;
183
194
  }
184
- const available = availableWorkflowRunActions(
185
- record,
186
- this.background.activeRunIds(ctx).has(record.runId),
187
- );
188
- if (!available.includes(action)) {
189
- ctx.ui.notify(`Action ${action} is not available for ${record.state} run ${runId}.`, "warning");
195
+ const { runs } = await this.history(ctx);
196
+ if (!availableWorkflowRunActions(record, runs).includes(action)) {
197
+ const resumedAs = record.state === "paused" && action === "resume" ? runs.resumedAs.get(runId) : undefined;
198
+ ctx.ui.notify(
199
+ resumedAs
200
+ ? `Workflow run ${runId} was already resumed as ${resumedAs}.`
201
+ : `Action ${action} is not available for ${record.state} run ${runId}.`,
202
+ "warning",
203
+ );
190
204
  return;
191
205
  }
192
206
 
@@ -194,17 +208,7 @@ export class WorkflowRunController {
194
208
  if (action === "stop") {
195
209
  if (record.state === "paused") {
196
210
  this.usageLimitScheduler.cancel(runId);
197
- const stopped = transitionWorkflowRun(record, {
198
- state: "stopped",
199
- progress: record.progress,
200
- usage: record.usage ?? record.progress.usage ?? {
201
- agents: [],
202
- totals: emptyWorkflowUsageTotals(),
203
- assistantMessages: 0,
204
- },
205
- error: new Error("Workflow stopped by user."),
206
- });
207
- await this.storeForCwd(ctx.cwd).save(stopped);
211
+ await this.storeForCwd(ctx.cwd).save(stopWorkflowRunRecord(record));
208
212
  await this.background.durableRunSettled(ctx, runId);
209
213
  ctx.ui.notify(`Workflow run ${runId} is now stopped.`, "info");
210
214
  return;
@@ -227,10 +231,7 @@ export class WorkflowRunController {
227
231
  action: "resume" | "restart",
228
232
  ): Promise<string> {
229
233
  const unavailable = backgroundUnavailableResult(ctx.mode);
230
- if (unavailable) {
231
- const first = unavailable.content[0];
232
- throw new Error(first?.type === "text" ? first.text : "background workflows are unavailable");
233
- }
234
+ if (unavailable) throw new Error(unavailable.content[0].text);
234
235
  const workflow = await this.dependencies.resolveWorkflow(record.workflow.name);
235
236
  if (!workflow) throw new Error(`registered workflow ${record.workflow.name} is unavailable`);
236
237
  if (workflow.source.kind !== "file") {
@@ -243,7 +244,6 @@ export class WorkflowRunController {
243
244
  throw new Error("workflow source changed, so journal replay cannot resume safely");
244
245
  }
245
246
  const options = resolveWorkflowRunOptions({
246
- inspect: false,
247
247
  perf: record.options.perf,
248
248
  concurrency: record.options.concurrency,
249
249
  parallelSubmissionLimit: record.options.parallelSubmissionLimit ?? undefined,
@@ -257,7 +257,6 @@ export class WorkflowRunController {
257
257
  ? (record.state === "paused" ? record.pause?.attempt : undefined) ?? record.options.usageLimitAttempt
258
258
  : 0,
259
259
  budget: record.options.budget ?? undefined,
260
- resultViewer: "skip",
261
260
  resumeFromRunId: action === "resume" ? record.runId : undefined,
262
261
  });
263
262
  const result = await startBackgroundWorkflowTool({
@@ -268,8 +267,7 @@ export class WorkflowRunController {
268
267
  execute: (backgroundCtx, backgroundOptions) =>
269
268
  this.dependencies.execute(backgroundCtx, workflow.meta.name, workflow, backgroundOptions),
270
269
  });
271
- const first = result.content[0];
272
- const message = first?.type === "text" ? first.text : `Workflow ${action} started.`;
270
+ const message = result.content[0].text;
273
271
  if (typeof result.details.error === "string") throw new Error(message);
274
272
  return message;
275
273
  }
@@ -282,6 +280,7 @@ export class WorkflowRunController {
282
280
  || !record.pause.autoResume
283
281
  || record.pause.attempt !== attempt
284
282
  || !canRelaunchWorkflowRun(record)
283
+ || resumedRunIds(await this.storeForCwd(ctx.cwd).list()).has(runId)
285
284
  ) {
286
285
  return;
287
286
  }
@@ -298,11 +297,17 @@ export class WorkflowRunController {
298
297
  return await this.storeForCwd(cwd).load(runId);
299
298
  }
300
299
 
301
- private async listRecent(cwd: string): Promise<WorkflowRunRecord[]> {
302
- const records = await this.storeForCwd(cwd).list();
303
- return records
304
- .sort((left, right) => right.createdAt - left.createdAt)
305
- .slice(0, WORKFLOW_RUN_HISTORY_LIMIT);
300
+ /** The most recent runs and what their actions depend on, from one store listing. */
301
+ private async history(
302
+ ctx: WorkflowRunCompletionContext,
303
+ ): Promise<{ readonly records: WorkflowRunRecord[]; readonly runs: WorkflowRunActionContext }> {
304
+ const records = await this.storeForCwd(ctx.cwd).list();
305
+ return {
306
+ records: [...records]
307
+ .sort((left, right) => right.createdAt - left.createdAt)
308
+ .slice(0, WORKFLOW_RUN_HISTORY_LIMIT),
309
+ runs: { activeRunIds: this.background.activeRunIds(ctx), resumedAs: supersedingRuns(records) },
310
+ };
306
311
  }
307
312
 
308
313
  async argumentCompletions(argumentPrefix: string): Promise<AutocompleteItem[] | null> {
@@ -314,12 +319,11 @@ export class WorkflowRunController {
314
319
  if (parts.completed.length !== 1 || !ctx) return null;
315
320
  const action = parts.completed[0];
316
321
  if (!action || !isWorkflowRunLifecycleAction(action)) return null;
317
- const records = await this.listRecent(ctx.cwd);
318
- const active = this.background.activeRunIds(ctx);
322
+ const { records, runs } = await this.history(ctx);
319
323
  return completeCurrentArgument(
320
324
  argumentPrefix,
321
325
  records
322
- .filter((record) => availableWorkflowRunActions(record, active.has(record.runId)).includes(action))
326
+ .filter((record) => availableWorkflowRunActions(record, runs).includes(action))
323
327
  .map((record) => ({
324
328
  value: record.runId,
325
329
  description: `${record.state} · ${record.workflow.name}`,
@@ -331,7 +335,7 @@ export class WorkflowRunController {
331
335
  const ctx = this.completionContext;
332
336
  const parts = splitArgumentPrefix(argumentPrefix);
333
337
  if (parts.completed.length > 0) return null;
334
- const records = ctx ? await this.listRecent(ctx.cwd) : [];
338
+ const records = ctx ? (await this.history(ctx)).records : [];
335
339
  return completeCurrentArgument(argumentPrefix, [
336
340
  { value: "last", description: "Inspect the current or most recent in-session workflow" },
337
341
  ...records.map((record) => ({
@@ -342,6 +346,16 @@ export class WorkflowRunController {
342
346
  }
343
347
  }
344
348
 
349
+ /**
350
+ * Runs that any stored run resumed, whatever that resume's outcome. A resumed run keeps its paused
351
+ * record and pause attempt, so automatic resume treats the pause as spent: re-arming it after a
352
+ * failed or stopped resume would relaunch it on every session start. Manual resume instead uses
353
+ * `supersedingRuns`, which frees a run whose every resume failed or was stopped.
354
+ */
355
+ function resumedRunIds(records: readonly WorkflowRunRecord[]): ReadonlySet<string> {
356
+ return new Set(records.flatMap((record) => record.options.resumeFromRunId ?? []));
357
+ }
358
+
345
359
  export function registerWorkflowRunCommand(
346
360
  pi: Pick<ExtensionAPI, "registerCommand">,
347
361
  controller: WorkflowRunController,
@@ -1,5 +1,6 @@
1
+ import { truncateText } from "./text.ts";
1
2
  import { formatWorkflowUsageLine } from "./usage.ts";
2
- import type { WorkflowRunRecord, WorkflowRunState } from "./workflow-run-record.ts";
3
+ import type { WorkflowRunRecord } from "./workflow-run-record.ts";
3
4
  import { formatDuration } from "./ui/workflow-format.ts";
4
5
 
5
6
  export const WORKFLOW_RUN_HISTORY_LIMIT = 50;
@@ -43,15 +44,37 @@ export function workflowRunsUsage(): string {
43
44
  return `Usage: /workflow:runs [${WORKFLOW_RUN_ACTIONS.map((action) => action.value).join("|")} <run-id>]`;
44
45
  }
45
46
 
47
+ /** What a run's lifecycle actions depend on beyond its own record. */
48
+ export interface WorkflowRunActionContext {
49
+ /** Runs this session is executing in the background. */
50
+ readonly activeRunIds: ReadonlySet<string>;
51
+ /** Resumed runs, keyed to the run that resumed them; see {@link supersedingRuns}. */
52
+ readonly resumedAs: ReadonlyMap<string, string>;
53
+ }
54
+
55
+ /**
56
+ * Maps each run to a stored run that resumed it and is queued, running, paused or completed, so
57
+ * resuming it again would replay its journal a second time. A run whose every resume failed or
58
+ * was stopped is absent, which leaves manual resume as the way back to its journal.
59
+ */
60
+ export function supersedingRuns(records: readonly WorkflowRunRecord[]): ReadonlyMap<string, string> {
61
+ const resumedAs = new Map<string, string>();
62
+ for (const record of records) {
63
+ const source = record.options.resumeFromRunId;
64
+ if (source !== undefined && record.state !== "failed" && record.state !== "stopped") resumedAs.set(source, record.runId);
65
+ }
66
+ return resumedAs;
67
+ }
68
+
46
69
  export function availableWorkflowRunActions(
47
70
  record: WorkflowRunRecord,
48
- active: boolean,
71
+ runs: WorkflowRunActionContext,
49
72
  ): readonly WorkflowRunLifecycleAction[] {
50
73
  const actions: WorkflowRunLifecycleAction[] = ["inspect"];
51
- if ((record.state === "queued" || record.state === "running") && active) actions.push("stop");
74
+ if ((record.state === "queued" || record.state === "running") && runs.activeRunIds.has(record.runId)) actions.push("stop");
52
75
  if (record.state === "paused") {
53
76
  actions.push("stop");
54
- if (canRelaunchWorkflowRun(record)) actions.push("resume");
77
+ if (!runs.resumedAs.has(record.runId) && canRelaunchWorkflowRun(record)) actions.push("resume");
55
78
  }
56
79
  if (
57
80
  (record.state === "completed" || record.state === "failed" || record.state === "stopped")
@@ -70,33 +93,31 @@ export function canRelaunchWorkflowRun(record: WorkflowRunRecord): boolean {
70
93
 
71
94
  export function formatWorkflowRunHistory(
72
95
  records: readonly WorkflowRunRecord[],
73
- activeRunIds: ReadonlySet<string> = new Set(),
96
+ runs: WorkflowRunActionContext,
74
97
  now = Date.now(),
75
98
  ): string {
76
99
  if (records.length === 0) return "No durable workflow runs are available for this project.";
77
100
  const lines = ["Recent workflow runs:"];
78
- for (const record of records.slice(0, WORKFLOW_RUN_HISTORY_LIMIT)) {
79
- lines.push(`- ${formatWorkflowRunSummary(record, activeRunIds.has(record.runId), now)}`);
80
- }
81
- if (records.length > WORKFLOW_RUN_HISTORY_LIMIT) {
82
- lines.push(`… ${records.length - WORKFLOW_RUN_HISTORY_LIMIT} older runs hidden`);
101
+ for (const record of records) {
102
+ lines.push(`- ${formatWorkflowRunSummary(record, runs, now)}`);
83
103
  }
84
104
  return lines.join("\n");
85
105
  }
86
106
 
87
107
  export function formatWorkflowRunSummary(
88
108
  record: WorkflowRunRecord,
89
- active: boolean,
109
+ runs: WorkflowRunActionContext,
90
110
  now = Date.now(),
91
111
  ): string {
92
112
  const usage = formatWorkflowUsageLine(record.usage);
93
- const actions = availableWorkflowRunActions(record, active).filter((action) => action !== "inspect");
94
- return `${workflowRunStateLabel(record.state)} ${record.workflow.name} · age ${formatDuration(Math.max(0, now - record.createdAt))} · duration ${formatWorkflowRunDuration(record, now)}${usage ? ` · ${usage}` : ""} · ${record.runId}${actions.length > 0 ? ` · actions ${actions.join(", ")}` : ""}`;
113
+ const resumedAs = runs.resumedAs.get(record.runId);
114
+ const actions = availableWorkflowRunActions(record, runs).filter((action) => action !== "inspect");
115
+ return `${record.state.toUpperCase()} ${record.workflow.name} · age ${formatDuration(Math.max(0, now - record.createdAt))} · duration ${formatWorkflowRunDuration(record, now)}${usage ? ` · ${usage}` : ""} · ${record.runId}${resumedAs ? ` · resumed as ${resumedAs}` : ""}${actions.length > 0 ? ` · actions ${actions.join(", ")}` : ""}`;
95
116
  }
96
117
 
97
118
  export function formatWorkflowRunDetails(
98
119
  record: WorkflowRunRecord,
99
- active: boolean,
120
+ runs: WorkflowRunActionContext,
100
121
  now = Date.now(),
101
122
  ): string {
102
123
  const agents = record.progress.phases.flatMap((phase) =>
@@ -107,12 +128,14 @@ export function formatWorkflowRunDetails(
107
128
  const lines = [
108
129
  `Workflow run ${record.runId}`,
109
130
  `Workflow: ${record.workflow.name}`,
110
- `State: ${workflowRunStateLabel(record.state)}`,
131
+ `State: ${record.state.toUpperCase()}`,
111
132
  `Age: ${formatDuration(Math.max(0, now - record.createdAt))}`,
112
133
  `Duration: ${formatWorkflowRunDuration(record, now)}`,
113
134
  `Phase: ${record.progress.currentPhase}`,
114
- `Actions: ${availableWorkflowRunActions(record, active).join(", ")}`,
135
+ `Actions: ${availableWorkflowRunActions(record, runs).join(", ")}`,
115
136
  ];
137
+ const resumedAs = runs.resumedAs.get(record.runId);
138
+ if (resumedAs) lines.push(`Resumed as: ${resumedAs}`);
116
139
  if (usage) lines.push(usage);
117
140
  if (record.options.resumeEditedWorkflow) lines.push("Edited-workflow resume: enabled");
118
141
  const cachedAgents = record.progress.counters.find((counter) => counter.key === "resume.cached")?.value ?? 0;
@@ -136,11 +159,11 @@ export function formatWorkflowRunDetails(
136
159
 
137
160
  export function retainedWorkflowRunOutcome(record: WorkflowRunRecord): string {
138
161
  if (record.state === "completed") {
139
- if (record.result.kind === "unavailable") return boundedOutcome(`Result unavailable: ${record.result.reason}`);
140
- return boundedOutcome(JSON.stringify(record.result.value, null, 2));
162
+ if (record.result.kind === "unavailable") return truncateText(`Result unavailable: ${record.result.reason}`, WORKFLOW_RUN_OUTCOME_TEXT_LIMIT);
163
+ return truncateText(JSON.stringify(record.result.value, null, 2), WORKFLOW_RUN_OUTCOME_TEXT_LIMIT);
141
164
  }
142
165
  if (record.state === "failed" || record.state === "stopped" || record.state === "paused") {
143
- return boundedOutcome(record.message);
166
+ return truncateText(record.message, WORKFLOW_RUN_OUTCOME_TEXT_LIMIT);
144
167
  }
145
168
  return "Run is still in progress.";
146
169
  }
@@ -150,13 +173,3 @@ export function formatWorkflowRunDuration(record: WorkflowRunRecord, now = Date.
150
173
  const end = record.endedAt ?? (record.state === "paused" ? record.updatedAt : now);
151
174
  return formatDuration(Math.max(0, end - start));
152
175
  }
153
-
154
- export function workflowRunStateLabel(state: WorkflowRunState): string {
155
- return state.toUpperCase();
156
- }
157
-
158
- function boundedOutcome(value: string): string {
159
- return value.length <= WORKFLOW_RUN_OUTCOME_TEXT_LIMIT
160
- ? value
161
- : `${value.slice(0, WORKFLOW_RUN_OUTCOME_TEXT_LIMIT - 1)}…`;
162
- }
@@ -1,4 +1,5 @@
1
1
  import { validateWorkflowRunId } from "./journal.ts";
2
+ import { isFiniteNumber, isRecord } from "./guards.ts";
2
3
  import {
3
4
  type ResolvedWorkflowRunOptions,
4
5
  WORKFLOW_USAGE_LIMIT_ATTEMPTS_MAX,
@@ -8,7 +9,8 @@ import {
8
9
  } from "./options.ts";
9
10
  import type { WorkflowProgressSnapshot } from "./progress-types.ts";
10
11
  import type { LoadedWorkflow, WorkflowSourceIdentity } from "./types.ts";
11
- import { isWorkflowUsageSnapshot, type WorkflowUsageSnapshot } from "./usage.ts";
12
+ import { truncateText } from "./text.ts";
13
+ import { emptyWorkflowUsageTotals, isWorkflowUsageSnapshot, type WorkflowUsageSnapshot } from "./usage.ts";
12
14
  import { unknownErrorMessage } from "./unknown-error.ts";
13
15
  import {
14
16
  createPersistedWorkflowBackground,
@@ -112,7 +114,6 @@ export type WorkflowRunRecord = WorkflowRunRecordBase & (
112
114
  );
113
115
 
114
116
  export type WorkflowRunTransition =
115
- | { readonly state: "queued"; readonly progress: WorkflowProgressSnapshot; readonly at?: number }
116
117
  | { readonly state: "running"; readonly progress: WorkflowProgressSnapshot; readonly at?: number }
117
118
  | { readonly state: "paused"; readonly progress: WorkflowProgressSnapshot; readonly message: string; readonly pause?: WorkflowRunPause; readonly at?: number }
118
119
  | { readonly state: "completed"; readonly progress: WorkflowProgressSnapshot; readonly usage: WorkflowUsageSnapshot; readonly result: unknown; readonly at?: number }
@@ -177,8 +178,6 @@ export function transitionWorkflowRun(record: WorkflowRunRecord, transition: Wor
177
178
  };
178
179
 
179
180
  switch (transition.state) {
180
- case "queued":
181
- return { ...base, state: "queued", startedAt: record.startedAt };
182
181
  case "running":
183
182
  return { ...base, state: "running", startedAt: record.startedAt ?? at };
184
183
  case "paused":
@@ -211,6 +210,16 @@ export function transitionWorkflowRun(record: WorkflowRunRecord, transition: Wor
211
210
  }
212
211
  }
213
212
 
213
+ /** Records a user stop from the retained state, for runs whose engine cannot record it itself. */
214
+ export function stopWorkflowRunRecord(record: WorkflowRunRecord): WorkflowRunRecord {
215
+ return transitionWorkflowRun(record, {
216
+ state: "stopped",
217
+ progress: record.progress,
218
+ usage: record.usage ?? record.progress.usage ?? { agents: [], totals: emptyWorkflowUsageTotals(), assistantMessages: 0 },
219
+ error: "Workflow stopped by user.",
220
+ });
221
+ }
222
+
214
223
  export function captureWorkflowRunResult(value: unknown): WorkflowRunStoredResult {
215
224
  try {
216
225
  const state = { nodes: 0, bytes: 0, active: new WeakSet<object>() };
@@ -334,7 +343,7 @@ function isAllowedTransition(from: WorkflowRunState, to: WorkflowRunState): bool
334
343
  case "running":
335
344
  return to === "completed" || to === "failed" || to === "stopped" || to === "paused";
336
345
  case "paused":
337
- return to === "queued" || to === "running" || to === "failed" || to === "stopped";
346
+ return to === "running" || to === "failed" || to === "stopped";
338
347
  case "completed":
339
348
  case "failed":
340
349
  case "stopped":
@@ -458,7 +467,7 @@ function compactWorkflowRunPause(pause: WorkflowRunPause): WorkflowRunPause {
458
467
  }
459
468
 
460
469
  function boundedText(value: string): string {
461
- return value.length <= MAX_PERSISTED_TEXT ? value : `${value.slice(0, MAX_PERSISTED_TEXT - 1)}…`;
470
+ return truncateText(value, MAX_PERSISTED_TEXT);
462
471
  }
463
472
 
464
473
  export function isWorkflowRunRecord(value: unknown): value is WorkflowRunRecord {
@@ -617,16 +626,8 @@ function isValidRunId(value: unknown): value is string {
617
626
  }
618
627
  }
619
628
 
620
- function isFiniteNumber(value: unknown): value is number {
621
- return typeof value === "number" && Number.isFinite(value);
622
- }
623
-
624
629
  function isIntegerInRange(value: unknown, min: number, max: number): value is number {
625
630
  return typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max;
626
631
  }
627
632
 
628
- function isRecord(value: unknown): value is Record<string, unknown> {
629
- return typeof value === "object" && value !== null && !Array.isArray(value);
630
- }
631
-
632
633
  class WorkflowRunValueError extends Error {}