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
@@ -1,10 +1,11 @@
1
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import { CustomEditor, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
- import type { EditorComponent, KeyId } from "@earendil-works/pi-tui";
1
+ import { CustomEditor, type ExtensionAPI, type ExtensionContext, type Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { EditorComponent } from "@earendil-works/pi-tui";
4
3
  import { completeCurrentArgument } from "./command-completions.ts";
5
- import { resolveDynamaxShortcuts, type DynamaxShortcuts } from "./dynamax-shortcuts.ts";
4
+ import type { DynamaxShortcuts } from "./dynamax-shortcuts.ts";
6
5
  import { sessionKey } from "./session-identity.ts";
7
6
  import { unknownErrorMessage } from "./unknown-error.ts";
7
+ import { dot, GLYPH } from "./ui/kit.ts";
8
+ import { setWorkflowInspectorShortcut } from "./ui/workflow-widget.ts";
8
9
  import {
9
10
  decorateDynamaxEditor,
10
11
  resolveDynamaxEffect,
@@ -21,17 +22,21 @@ export interface DynamaxState {
21
22
 
22
23
  export interface DynamaxRuntime {
23
24
  state: DynamaxState;
24
- runningWorkflow?: string;
25
25
  }
26
26
 
27
27
  export type DynamaxRuntimeStore = Map<string, DynamaxRuntime>;
28
28
 
29
29
  export interface DynamaxRegistrationOptions {
30
- openInspector?: (ctx: ExtensionContext) => Promise<void> | void;
30
+ openInspector: (ctx: ExtensionContext) => Promise<void> | void;
31
31
  effect?: DynamaxEffect;
32
32
  animationScheduler?: DynamaxAnimationScheduler;
33
33
  }
34
34
 
35
+ export interface DynamaxHandle {
36
+ /** Opt the next agent run in. The `input` hook ignores prompts this extension sends (pi tags them `source: "extension"`). */
37
+ markOneShot(ctx: ExtensionContext): void;
38
+ }
39
+
35
40
  export const DYNAMAX_TOKEN_PATTERN = /(^|[^A-Za-z0-9_])dynamax([^A-Za-z0-9_]|$)/i;
36
41
  export const DYNAMAX_STATUS_KEY = "dynamax";
37
42
 
@@ -78,8 +83,6 @@ Inline workflow rules:
78
83
  ${ADAPTIVE_WORKFLOW_GUIDANCE}
79
84
  `;
80
85
 
81
- const DYNAMAX_CONTEXT_CUSTOM_TYPE = "workflow-dynamax-reminder";
82
-
83
86
  export function createDynamaxState(): DynamaxState {
84
87
  return { sticky: false, oneShotPending: false, turnActive: false };
85
88
  }
@@ -126,16 +129,15 @@ export function describeDynamaxState(state: DynamaxState): string {
126
129
  return `sticky ${sticky}; one-shot ${oneShot}${turn}`;
127
130
  }
128
131
 
129
- export function dynamaxWidgetLine(state: DynamaxState, shortcut: KeyId | null, runningWorkflow?: string): string {
130
- const modes = [
131
- runningWorkflow ? `running ${runningWorkflow}` : undefined,
132
- state.sticky ? "sticky on" : undefined,
133
- !state.sticky && state.turnActive ? "active this turn" : undefined,
134
- state.oneShotPending ? "one-shot pending" : undefined,
135
- ].filter((value): value is string => value !== undefined);
136
- const mode = modes.join(" + ") || "off";
137
- const inspectorHint = shortcut ? `${shortcut} inspector` : "/workflow:inspector";
138
- return `dynamax: ${mode} | /workflow:dynamax on|off | ${inspectorHint}`;
132
+ /**
133
+ * Footer status: `◆ dynamax` while sticky, `◆ dynamax · next prompt` while a one-shot waits for its
134
+ * prompt, `◆ dynamax · this turn` while that turn runs. A running workflow names itself in its own
135
+ * status (`◆ repo-scan · Scan 2/3 · 4s`), so this one only states the mode.
136
+ */
137
+ export function dynamaxStatusText(state: DynamaxState, theme: Theme): string {
138
+ const mode = state.sticky ? undefined : state.oneShotPending ? "next prompt" : state.turnActive ? "this turn" : undefined;
139
+ const label = `${theme.fg("accent", GLYPH.workflow)} ${theme.fg("accent", "dynamax")}`;
140
+ return mode ? `${label}${dot(theme)}${theme.fg("muted", mode)}` : label;
139
141
  }
140
142
 
141
143
  export function appendDynamaxSystemReminder(systemPrompt: string, state: DynamaxState): string {
@@ -145,24 +147,16 @@ export function appendDynamaxSystemReminder(systemPrompt: string, state: Dynamax
145
147
  return `${systemPrompt}\n\n${DYNAMAX_REMINDER.trim()}`;
146
148
  }
147
149
 
148
- export function appendDynamaxContextReminder(messages: AgentMessage[], state: DynamaxState): AgentMessage[] {
149
- if (!state.sticky) return messages;
150
- return [...messages, createDynamaxContextMessage()];
151
- }
152
-
153
- export function registerDynamax(
154
- pi: ExtensionAPI,
155
- shortcuts: DynamaxShortcuts = resolveDynamaxShortcuts(),
156
- options: DynamaxRegistrationOptions = {},
157
- ): void {
150
+ export function registerDynamax(pi: ExtensionAPI, shortcuts: DynamaxShortcuts, options: DynamaxRegistrationOptions): DynamaxHandle {
158
151
  const runtimes: DynamaxRuntimeStore = new Map();
159
152
  const effect = options.effect ?? resolveDynamaxEffect();
153
+ setWorkflowInspectorShortcut(shortcuts.inspector);
160
154
  let editorInstallation: DynamaxEditorInstallation | undefined;
161
- const openInspector =
162
- options.openInspector ??
163
- ((ctx: ExtensionContext): void => {
164
- ctx.ui.notify("No workflow inspector is available yet", "warning");
165
- });
155
+ const markOneShot = (ctx: ExtensionContext): void => {
156
+ const runtime = getDynamaxRuntime(runtimes, ctx);
157
+ markDynamaxOneShot(runtime.state);
158
+ updateDynamaxSurfaces(ctx, runtime);
159
+ };
166
160
  const installEditor = (ctx: ExtensionContext): void => {
167
161
  if (ctx.mode !== "tui" || effect === "off") return;
168
162
  const previousFactory = ctx.ui.getEditorComponent();
@@ -229,7 +223,7 @@ export function registerDynamax(
229
223
 
230
224
  pi.on("session_start", (_event, ctx) => {
231
225
  installEditor(ctx);
232
- updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx), shortcuts);
226
+ updateDynamaxSurfaces(ctx, getDynamaxRuntime(runtimes, ctx));
233
227
  });
234
228
 
235
229
  pi.on("session_shutdown", (_event, ctx) => {
@@ -238,12 +232,7 @@ export function registerDynamax(
238
232
  });
239
233
 
240
234
  pi.on("input", (event, ctx) => {
241
- if (event.source === "extension") return { action: "continue" };
242
- const runtime = getDynamaxRuntime(runtimes, ctx);
243
- if (hasDynamaxToken(event.text)) {
244
- markDynamaxOneShot(runtime.state);
245
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
246
- }
235
+ if (event.source !== "extension" && hasDynamaxToken(event.text)) markOneShot(ctx);
247
236
  return { action: "continue" };
248
237
  });
249
238
 
@@ -251,45 +240,21 @@ export function registerDynamax(
251
240
  const runtime = getDynamaxRuntime(runtimes, ctx);
252
241
  const systemPrompt = appendDynamaxSystemReminder(event.systemPrompt, runtime.state);
253
242
  if (systemPrompt === event.systemPrompt) return undefined;
254
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
243
+ updateDynamaxSurfaces(ctx, runtime);
255
244
  return { systemPrompt };
256
245
  });
257
246
 
258
247
  pi.on("agent_end", (_event, ctx) => {
259
248
  const runtime = getDynamaxRuntime(runtimes, ctx);
260
- runtime.runningWorkflow = undefined;
261
249
  if (!runtime.state.sticky) runtime.state.turnActive = false;
262
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
263
- });
264
-
265
- pi.on("tool_execution_start", (event, ctx) => {
266
- if (event.toolName !== "workflow") return undefined;
267
- const runtime = getDynamaxRuntime(runtimes, ctx);
268
- runtime.runningWorkflow = workflowLabel(event.args);
269
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
270
- return undefined;
271
- });
272
-
273
- pi.on("tool_execution_end", (event, ctx) => {
274
- if (event.toolName !== "workflow") return undefined;
275
- const runtime = getDynamaxRuntime(runtimes, ctx);
276
- runtime.runningWorkflow = undefined;
277
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
278
- return undefined;
279
- });
280
-
281
- pi.on("context", (event, ctx) => {
282
- const runtime = getDynamaxRuntime(runtimes, ctx);
283
- const messages = appendDynamaxContextReminder(event.messages, runtime.state);
284
- if (messages === event.messages) return undefined;
285
- return { messages };
250
+ updateDynamaxSurfaces(ctx, runtime);
286
251
  });
287
252
 
288
253
  if (shortcuts.inspector) {
289
254
  pi.registerShortcut(shortcuts.inspector, {
290
255
  description: "Open workflow inspector",
291
256
  handler: async (ctx) => {
292
- await openInspector(ctx);
257
+ await options.openInspector(ctx);
293
258
  },
294
259
  });
295
260
  }
@@ -306,13 +271,13 @@ export function registerDynamax(
306
271
  }
307
272
  if (action === "on") {
308
273
  enableDynamaxSticky(runtime.state);
309
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
274
+ updateDynamaxSurfaces(ctx, runtime);
310
275
  ctx.ui.notify("Dynamax workflow orchestration is on for this session", "info");
311
276
  return;
312
277
  }
313
278
  if (action === "off") {
314
279
  clearDynamax(runtime.state);
315
- updateDynamaxSurfaces(ctx, runtime, shortcuts);
280
+ updateDynamaxSurfaces(ctx, runtime);
316
281
  ctx.ui.notify("Dynamax workflow orchestration is off", "info");
317
282
  return;
318
283
  }
@@ -323,44 +288,20 @@ export function registerDynamax(
323
288
  ctx.ui.notify("Usage: /workflow:dynamax [on|off|status]", "warning");
324
289
  },
325
290
  });
291
+
292
+ return { markOneShot };
326
293
  }
327
294
 
328
- export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime, shortcuts: DynamaxShortcuts): void {
295
+ export function updateDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">, runtime: DynamaxRuntime): void {
329
296
  if (!ctx.hasUI) return;
330
- if (!isDynamaxActive(runtime.state) && !runtime.runningWorkflow) {
297
+ if (!isDynamaxActive(runtime.state)) {
331
298
  clearDynamaxSurfaces(ctx);
332
299
  return;
333
300
  }
334
-
335
- const status = dynamaxWidgetLine(runtime.state, shortcuts.inspector, runtime.runningWorkflow);
336
- ctx.ui.setStatus(DYNAMAX_STATUS_KEY, status);
301
+ ctx.ui.setStatus(DYNAMAX_STATUS_KEY, dynamaxStatusText(runtime.state, ctx.ui.theme));
337
302
  }
338
303
 
339
304
  export function clearDynamaxSurfaces(ctx: Pick<ExtensionContext, "hasUI" | "ui">): void {
340
305
  if (!ctx.hasUI) return;
341
306
  ctx.ui.setStatus(DYNAMAX_STATUS_KEY, undefined);
342
307
  }
343
-
344
- function workflowLabel(args: unknown): string {
345
- if (!isRecord(args)) return "workflow";
346
- const name = args.name;
347
- if (typeof name === "string" && name.trim()) return name.trim();
348
- const script = args.script;
349
- if (typeof script === "string" && script.trim()) return "inline workflow";
350
- return "workflow";
351
- }
352
-
353
- function createDynamaxContextMessage(): AgentMessage {
354
- return {
355
- role: "custom",
356
- customType: DYNAMAX_CONTEXT_CUSTOM_TYPE,
357
- content: DYNAMAX_REMINDER.trim(),
358
- display: false,
359
- details: { dynamax: true, sticky: true },
360
- timestamp: Date.now(),
361
- };
362
- }
363
-
364
- function isRecord(value: unknown): value is Record<string, unknown> {
365
- return typeof value === "object" && value !== null;
366
- }
@@ -8,15 +8,14 @@ import { abortReason, isWorkflowPauseError, linkAbortSignal, throwIfAborted } fr
8
8
  import { createBudget } from "./budget.ts";
9
9
  import { runAgent, type AgentExecutionOptions, type RunContext } from "./agent-runner.ts";
10
10
  import { ProgressTracker } from "./progress.ts";
11
- import { createPerfRecorder, type PerfSink, type PerfSnapshot } from "./perf.ts";
11
+ import { createPerfRecorder, formatPerfSummary, type PerfSink } from "./perf.ts";
12
12
  import { createWorkflowUsageRecorder, type WorkflowUsageSink } from "./usage.ts";
13
13
  import {
14
14
  resolveWorkflowRunOptions,
15
15
  type ResolvedWorkflowRunOptions,
16
16
  } from "./options.ts";
17
17
  import type { AgentOptions, IsolatedAgentResult, LoadedWorkflow, WorkflowApi, WorkflowProgressEvent, WorkflowRef, WorkflowRunOptions } from "./types.ts";
18
- import { WorkflowInspector } from "./ui/workflow-inspector.ts";
19
- import { WORKFLOW_VIEWER_OVERLAY_OPTIONS } from "./ui/workflow-viewer-layout.ts";
18
+ import { showWorkflowInspector } from "./ui/workflow-inspector.ts";
20
19
  import { createWorkflowJournal, createWorkflowRunId, pruneWorkflowJournals, workflowJournalPath } from "./journal.ts";
21
20
  import { WorktreeRegistry } from "./worktree.ts";
22
21
  import { runFinalizers } from "./finalizers.ts";
@@ -97,8 +96,10 @@ export async function runResolvedWorkflow(
97
96
  ): Promise<unknown> {
98
97
  const runId = resolvedOptions.runId ?? createWorkflowRunId();
99
98
  let durableProgress: DurableWorkflowRun | undefined;
100
- const progress = new ProgressTracker(ctx, mod.meta.name, runId, (snapshot) => durableProgress?.updateProgress(snapshot));
101
- const progressSource = { snapshot: () => progress.snapshot() };
99
+ const progress = new ProgressTracker(ctx, mod.meta.name, runId, (snapshot) => durableProgress?.updateProgress(snapshot), {
100
+ plannedPhases: mod.meta.phases?.map((phase) => phase.title),
101
+ display: resolvedOptions.background ? "background" : "live",
102
+ });
102
103
  const perf = resolvedOptions.perfRecorder ?? createPerfRecorder(resolvedOptions.perf);
103
104
  const usage = createWorkflowUsageRecorder((snapshot) => progress.updateUsage(snapshot));
104
105
  const budget = createBudget(resolvedOptions.budget, usage);
@@ -122,6 +123,7 @@ export async function runResolvedWorkflow(
122
123
  },
123
124
  );
124
125
  durableProgress = durableRun;
126
+ const progressSource = { snapshot: () => progress.snapshot(), state: () => durableRun.state };
125
127
  const worktrees = dependencies.worktrees ?? new WorktreeRegistry(ctx.cwd);
126
128
  const runAbortController = new AbortController();
127
129
  const unlinkContextAbortSignal = linkAbortSignal(ctx.signal, runAbortController);
@@ -129,11 +131,7 @@ export async function runResolvedWorkflow(
129
131
  const workflowOutcome = await captureOutcome(async () => {
130
132
  await notifyLifecycleObserver(progress, "progress source callback", () => resolvedOptions.onProgressSource?.(progressSource));
131
133
  if (resolvedOptions.inspect && ctx.hasUI && ctx.mode === "tui") {
132
- void ctx.ui
133
- .custom<void>(
134
- (tui, theme, _keybindings, done) => new WorkflowInspector(() => progress.snapshot(), tui, theme, () => done(undefined)),
135
- WORKFLOW_VIEWER_OVERLAY_OPTIONS,
136
- )
134
+ void showWorkflowInspector(ctx.ui, progressSource.snapshot, () => ({ state: progressSource.state() }))
137
135
  .catch((error: unknown) => {
138
136
  try {
139
137
  progress.log(`inspector failed: ${unknownErrorMessage(error)}`);
@@ -206,69 +204,44 @@ export async function runResolvedWorkflow(
206
204
  }),
207
205
  );
208
206
 
209
- if (workflowOutcome.ok && finalizationOutcome.ok) {
207
+ let outcome: Outcome<unknown> = workflowOutcome;
208
+ if (!finalizationOutcome.ok) {
209
+ outcome = workflowOutcome.ok
210
+ ? finalizationOutcome
211
+ : { ok: false, error: combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error) };
212
+ }
213
+ const pauseError = outcome.ok ? undefined : backgroundPauseError(outcome.error, ctx.signal, resolvedOptions.signal);
214
+ if (outcome.ok) {
210
215
  durableRun.transition({
211
216
  state: "completed",
212
217
  progress: progress.snapshot(),
213
218
  usage: usage.snapshot(),
214
- result: workflowOutcome.value,
219
+ result: outcome.value,
215
220
  });
216
- } else if (!workflowOutcome.ok) {
217
- const error = finalizationOutcome.ok
218
- ? workflowOutcome.error
219
- : combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
220
- persistTerminalWorkflowError(error);
221
- } else if (!finalizationOutcome.ok) {
222
- persistTerminalWorkflowError(finalizationOutcome.error);
223
- }
224
- await durableRun.flush().catch(() => undefined);
225
-
226
- function persistTerminalWorkflowError(error: unknown): void {
227
- const pauseError = backgroundPauseError(error, ctx.signal, resolvedOptions.signal);
228
- if (pauseError) {
229
- if (pauseError instanceof WorkflowProviderUsageLimitError) {
230
- if (resolvedOptions.background === undefined) {
231
- durableRun.transition({
232
- state: "failed",
233
- progress: progress.snapshot(),
234
- usage: usage.snapshot(),
235
- error: pauseError,
236
- });
237
- return;
238
- }
239
- const providerPause = createProviderUsageLimitPauseRecord(
240
- pauseError,
241
- resolvedOptions,
242
- mod.source.kind === "file" && args.length === 0,
243
- );
244
- durableRun.transition({
245
- state: "paused",
246
- progress: progress.snapshot(),
247
- ...providerPause,
248
- });
249
- return;
250
- }
251
- durableRun.transition({
252
- state: "paused",
253
- progress: progress.snapshot(),
254
- message: unknownErrorMessage(pauseError),
255
- });
256
- return;
257
- }
221
+ } else if (pauseError instanceof WorkflowProviderUsageLimitError) {
222
+ durableRun.transition({
223
+ state: "paused",
224
+ progress: progress.snapshot(),
225
+ ...createProviderUsageLimitPauseRecord(pauseError, resolvedOptions, mod.source.kind === "file" && args.length === 0),
226
+ });
227
+ } else if (pauseError) {
228
+ durableRun.transition({
229
+ state: "paused",
230
+ progress: progress.snapshot(),
231
+ message: unknownErrorMessage(pauseError),
232
+ });
233
+ } else {
258
234
  durableRun.transition({
259
235
  state: ctx.signal?.aborted || resolvedOptions.signal?.aborted ? "stopped" : "failed",
260
236
  progress: progress.snapshot(),
261
237
  usage: usage.snapshot(),
262
- error,
238
+ error: outcome.error,
263
239
  });
264
240
  }
241
+ await durableRun.flush().catch(() => undefined);
265
242
 
266
- if (workflowOutcome.ok) {
267
- if (finalizationOutcome.ok) return workflowOutcome.value;
268
- throw finalizationOutcome.error;
269
- }
270
- if (finalizationOutcome.ok) throw workflowOutcome.error;
271
- throw combinedWorkflowError(workflowOutcome.error, finalizationOutcome.error);
243
+ if (outcome.ok) return outcome.value;
244
+ throw outcome.error;
272
245
  }
273
246
 
274
247
  function backgroundPauseError(error: unknown, ...signals: Array<AbortSignal | undefined>): unknown {
@@ -315,7 +288,7 @@ async function finalizeWorkflowRun(input: WorkflowFinalizationInput): Promise<vo
315
288
  if (!input.options.perf) return;
316
289
  const snapshot = input.perf.snapshot();
317
290
  await input.options.onPerfSnapshot?.(snapshot);
318
- input.progress.log(formatPerfSummary(snapshot));
291
+ input.progress.log(formatPerfSummary(snapshot.aggregates));
319
292
  },
320
293
  },
321
294
  ...input.unlinkSignals.map((run, index) => ({
@@ -343,13 +316,7 @@ async function finalizeWorkflowRun(input: WorkflowFinalizationInput): Promise<vo
343
316
  },
344
317
  ],
345
318
  {
346
- onBestEffortFailure: (failure) => {
347
- try {
348
- input.progress.log(`${failure.name} failed: ${unknownErrorMessage(failure.error)}`);
349
- } catch {
350
- // Observer failures must never replace the workflow outcome.
351
- }
352
- },
319
+ onBestEffortFailure: (failure) => input.progress.log(`${failure.name} failed: ${unknownErrorMessage(failure.error)}`),
353
320
  },
354
321
  );
355
322
  }
@@ -491,24 +458,14 @@ function createWorkflowScope(progress: WorkflowProgress, prefix: string, namespa
491
458
  function namespaceProgressEvent(namespace: string, event: WorkflowProgressEvent): WorkflowProgressEvent {
492
459
  switch (event.type) {
493
460
  case "counter":
494
- return { ...event, key: `${namespace}.${event.key}` };
495
461
  case "counter_delta":
462
+ case "summary":
496
463
  return { ...event, key: `${namespace}.${event.key}` };
497
464
  case "lane_item":
498
465
  return { ...event, lane: `${namespace} ▸ ${event.lane}` };
499
- case "summary":
500
- return { ...event, key: `${namespace}.${event.key}` };
501
466
  }
502
467
  }
503
468
 
504
- function formatPerfSummary(snapshot: PerfSnapshot): string {
505
- const parts = snapshot.aggregates
506
- .filter((aggregate) => aggregate.count > 0)
507
- .slice(0, 5)
508
- .map((aggregate) => `${aggregate.name} ${Math.round(aggregate.total)}ms`);
509
- return parts.length > 0 ? `perf: ${parts.join(", ")}` : "perf: no samples";
510
- }
511
-
512
469
  async function notifyLifecycleObserver(
513
470
  progress: ProgressTracker,
514
471
  name: string,
@@ -10,7 +10,6 @@ export interface Finalizer {
10
10
 
11
11
  export interface FinalizerFailure {
12
12
  readonly name: string;
13
- readonly criticality: FinalizerCriticality;
14
13
  readonly error: unknown;
15
14
  }
16
15
 
@@ -35,24 +34,18 @@ export class RequiredFinalizerError extends AggregateError {
35
34
  export async function runFinalizers(
36
35
  finalizers: readonly Finalizer[],
37
36
  options: RunFinalizersOptions = {},
38
- ): Promise<readonly FinalizerFailure[]> {
37
+ ): Promise<void> {
39
38
  const requiredFailures: FinalizerFailure[] = [];
40
- const bestEffortFailures: FinalizerFailure[] = [];
41
39
 
42
40
  for (const finalizer of finalizers) {
43
41
  try {
44
42
  await finalizer.run();
45
43
  } catch (error) {
46
- const failure: FinalizerFailure = {
47
- name: finalizer.name,
48
- criticality: finalizer.criticality,
49
- error,
50
- };
44
+ const failure: FinalizerFailure = { name: finalizer.name, error };
51
45
  if (finalizer.criticality === "required") {
52
46
  requiredFailures.push(failure);
53
47
  continue;
54
48
  }
55
- bestEffortFailures.push(failure);
56
49
  try {
57
50
  options.onBestEffortFailure?.(failure);
58
51
  } catch {
@@ -62,5 +55,4 @@ export async function runFinalizers(
62
55
  }
63
56
 
64
57
  if (requiredFailures.length > 0) throw new RequiredFinalizerError(requiredFailures);
65
- return bestEffortFailures;
66
58
  }
@@ -0,0 +1,55 @@
1
+ import { resolve } from "node:path";
2
+ import { runBoundedProcess, type BoundedProcessResult } from "./process-runner.ts";
3
+ import { isPathWithin } from "./tree-fingerprint.ts";
4
+
5
+ export interface GitCommandOptions {
6
+ /** The GitHub CLI shares the runner so pull-request commands get the same bounds. Defaults to `git`. */
7
+ readonly file?: "git" | "gh";
8
+ readonly cwd: string;
9
+ readonly args: readonly string[];
10
+ /** Names the command in its abort, timeout, output-limit, and exit messages. */
11
+ readonly label: string;
12
+ readonly signal?: AbortSignal;
13
+ readonly timeoutMs: number;
14
+ readonly maxBufferBytes?: number;
15
+ readonly stdin?: string;
16
+ /** Overrides merged over `process.env`. */
17
+ readonly env?: NodeJS.ProcessEnv;
18
+ }
19
+
20
+ export async function runGit(options: GitCommandOptions): Promise<BoundedProcessResult> {
21
+ const { label, timeoutMs, maxBufferBytes } = options;
22
+ const env = { ...process.env, ...options.env };
23
+ // Drop env-level diff overrides so git emits plain patches. Setting them to an empty string
24
+ // instead makes a plain `git diff` try to run an empty external diff command.
25
+ delete env.GIT_EXTERNAL_DIFF;
26
+ delete env.GIT_DIFF_OPTS;
27
+ return await runBoundedProcess({
28
+ file: options.file ?? "git",
29
+ args: options.args,
30
+ cwd: options.cwd,
31
+ env,
32
+ stdin: options.stdin,
33
+ signal: options.signal,
34
+ timeoutMs,
35
+ maxBufferBytes,
36
+ abortError: `${label} aborted`,
37
+ timeoutError: `${label} timed out after ${timeoutMs}ms`,
38
+ maxBufferError: maxBufferBytes === undefined ? undefined : `${label} exceeded ${maxBufferBytes} bytes`,
39
+ exitError: (stderr, code, signal) => stderr.trim() || `${label} exited with code ${code ?? `signal ${signal ?? "unknown"}`}`,
40
+ });
41
+ }
42
+
43
+ /** A full SHA-1 or SHA-256 Git object ID. */
44
+ export function isGitObjectId(value: string): boolean {
45
+ return /^[0-9a-f]{40,64}$/i.test(value);
46
+ }
47
+
48
+ /** Resolve `git rev-parse --show-toplevel` output to a root that contains `cwd`. */
49
+ export function parseGitTopLevel(output: string, cwd: string): string | undefined {
50
+ const withoutLf = output.endsWith("\n") ? output.slice(0, -1) : output;
51
+ const value = withoutLf.endsWith("\r") ? withoutLf.slice(0, -1) : withoutLf;
52
+ if (value.length === 0 || value.includes("\n") || value.includes("\0")) return undefined;
53
+ const root = resolve(cwd, value);
54
+ return isPathWithin(root, cwd) ? root : undefined;
55
+ }
@@ -0,0 +1,8 @@
1
+ /** A non-null, non-array object whose fields can be inspected by name. */
2
+ export function isRecord(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === "object" && value !== null && !Array.isArray(value);
4
+ }
5
+
6
+ export function isFiniteNumber(value: unknown): value is number {
7
+ return typeof value === "number" && Number.isFinite(value);
8
+ }
@@ -2,15 +2,9 @@ export type IdentityCanonicalization =
2
2
  | { readonly kind: "verified"; readonly value: string }
3
3
  | { readonly kind: "unverifiable"; readonly reason: string };
4
4
 
5
- export interface IdentityCanonicalizationOptions {
6
- readonly maxBytes?: number;
7
- readonly maxDepth?: number;
8
- readonly maxNodes?: number;
9
- }
10
-
11
- const DEFAULT_MAX_BYTES = 1 << 20;
12
- const DEFAULT_MAX_DEPTH = 64;
13
- const DEFAULT_MAX_NODES = 16_384;
5
+ const MAX_BYTES = 1 << 20;
6
+ const MAX_DEPTH = 64;
7
+ const MAX_NODES = 16_384;
14
8
 
15
9
  /**
16
10
  * Produce a deterministic, bounded identity for JSON-like data.
@@ -19,13 +13,7 @@ const DEFAULT_MAX_NODES = 16_384;
19
13
  * accessors, proxies, or values JSON cannot represent. Those inputs disable
20
14
  * replay instead of throwing or being silently collapsed to the same key.
21
15
  */
22
- export function canonicalizeIdentity(
23
- value: unknown,
24
- options: IdentityCanonicalizationOptions = {},
25
- ): IdentityCanonicalization {
26
- const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
27
- const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
28
- const maxNodes = options.maxNodes ?? DEFAULT_MAX_NODES;
16
+ export function canonicalizeIdentity(value: unknown): IdentityCanonicalization {
29
17
  const active = new WeakSet<object>();
30
18
  const chunks: string[] = [];
31
19
  let bytes = 0;
@@ -33,14 +21,14 @@ export function canonicalizeIdentity(
33
21
 
34
22
  const append = (chunk: string): void => {
35
23
  bytes += Buffer.byteLength(chunk);
36
- if (bytes > maxBytes) throw new IdentityCanonicalizationError(`identity exceeded ${maxBytes} bytes`);
24
+ if (bytes > MAX_BYTES) throw new IdentityCanonicalizationError(`identity exceeded ${MAX_BYTES} bytes`);
37
25
  chunks.push(chunk);
38
26
  };
39
27
 
40
28
  const visit = (current: unknown, depth: number): void => {
41
29
  nodes += 1;
42
- if (nodes > maxNodes) throw new IdentityCanonicalizationError(`identity exceeded ${maxNodes} values`);
43
- if (depth > maxDepth) throw new IdentityCanonicalizationError(`identity exceeded ${maxDepth} levels`);
30
+ if (nodes > MAX_NODES) throw new IdentityCanonicalizationError(`identity exceeded ${MAX_NODES} values`);
31
+ if (depth > MAX_DEPTH) throw new IdentityCanonicalizationError(`identity exceeded ${MAX_DEPTH} levels`);
44
32
 
45
33
  if (current === null) {
46
34
  append("n;");