pi-workflow-engine 0.10.2 → 0.11.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 (55) hide show
  1. package/.pi/extensions/pi-workflow-engine/index.ts +199 -120
  2. package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -0
  3. package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +145 -0
  4. package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +376 -0
  5. package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +87 -0
  6. package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +84 -529
  7. package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
  8. package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +413 -0
  9. package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +43 -1
  10. package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +81 -0
  11. package/.pi/extensions/pi-workflow-engine/src/budget.ts +7 -0
  12. package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +17 -3
  13. package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +164 -61
  14. package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +117 -20
  15. package/.pi/extensions/pi-workflow-engine/src/discovery.ts +64 -15
  16. package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
  17. package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +14 -3
  18. package/.pi/extensions/pi-workflow-engine/src/engine.ts +208 -74
  19. package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
  20. package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
  21. package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
  22. package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +8 -3
  23. package/.pi/extensions/pi-workflow-engine/src/journal.ts +148 -64
  24. package/.pi/extensions/pi-workflow-engine/src/options.ts +9 -6
  25. package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
  26. package/.pi/extensions/pi-workflow-engine/src/progress.ts +2 -5
  27. package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
  28. package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +665 -0
  29. package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
  30. package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
  31. package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
  32. package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
  33. package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
  34. package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
  35. package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
  36. package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +17 -22
  37. package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +143 -52
  38. package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
  39. package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +295 -0
  40. package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +153 -0
  41. package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +404 -0
  42. package/.pi/extensions/pi-workflow-engine/src/types.ts +38 -10
  43. package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +5 -69
  44. package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
  45. package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
  46. package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
  47. package/.pi/extensions/pi-workflow-engine/src/workflows.ts +28 -3
  48. package/.pi/extensions/pi-workflow-engine/src/worktree.ts +282 -41
  49. package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +48 -26
  50. package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +8 -1
  51. package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +8 -2
  52. package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +8 -1
  53. package/README.md +170 -226
  54. package/USAGE.md +92 -12
  55. package/package.json +9 -9
@@ -0,0 +1,404 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants, type BigIntStats } from "node:fs";
3
+ import { lstat, open, readdir, type FileHandle } from "node:fs/promises";
4
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { throwIfAborted } from "./cancellation.ts";
6
+ import { unknownErrorMessage } from "./unknown-error.ts";
7
+
8
+ export type FingerprintCapture =
9
+ | { readonly kind: "verified"; readonly fingerprint: string }
10
+ | { readonly kind: "unverifiable"; readonly reason: string };
11
+
12
+ export type TreeFileValidation =
13
+ | { readonly kind: "verified" }
14
+ | { readonly kind: "unverifiable"; readonly reason: string };
15
+
16
+ export interface TreeFingerprintOptions {
17
+ readonly root: string;
18
+ /** Root-relative directory paths to exclude, never bare names at every depth. */
19
+ readonly excludedRelativePaths?: ReadonlySet<string>;
20
+ readonly maxBytes: number;
21
+ readonly maxFiles: number;
22
+ readonly signal?: AbortSignal;
23
+ }
24
+
25
+ export interface TreeFileValidationOptions {
26
+ readonly root: string;
27
+ readonly path: string;
28
+ readonly excludedRelativePaths?: ReadonlySet<string>;
29
+ readonly signal?: AbortSignal;
30
+ }
31
+
32
+ export interface DeclaredInputFingerprintOptions {
33
+ readonly root: string;
34
+ /** Explicit paths relative to `root`. Files and directories are both supported. */
35
+ readonly inputs: readonly string[];
36
+ /** Paths with these segment sequences are never valid declared inputs. */
37
+ readonly excludedRelativePaths?: ReadonlySet<string>;
38
+ readonly maxBytes: number;
39
+ readonly maxEntries: number;
40
+ /** Record direct directory identity without recursively walking descendants. */
41
+ readonly shallowDirectories?: boolean;
42
+ readonly signal?: AbortSignal;
43
+ }
44
+
45
+ export type DeclaredInputPathResolution =
46
+ | { readonly kind: "verified"; readonly paths: readonly string[] }
47
+ | { readonly kind: "unverifiable"; readonly reason: string };
48
+
49
+ export interface FingerprintFileOperations {
50
+ readonly open: (path: string, flags: number) => Promise<FileHandle>;
51
+ readonly lstat: (path: string) => Promise<BigIntStats>;
52
+ }
53
+
54
+ const NODE_FINGERPRINT_FILE_OPERATIONS: FingerprintFileOperations = {
55
+ open: async (path, flags) => await open(path, flags),
56
+ lstat: async (path) => await lstat(path, { bigint: true }),
57
+ };
58
+
59
+ /** A deterministic hash sink that enforces one shared byte budget across file reads. */
60
+ export class BoundedFingerprint {
61
+ readonly #hash = createHash("sha256");
62
+ #bytes = 0;
63
+
64
+ constructor(private readonly maxBytes: number) {}
65
+
66
+ add(label: string, value: string | Buffer): void {
67
+ this.#hash.update(label);
68
+ this.#hash.update("\0");
69
+ this.#hash.update(value);
70
+ this.#hash.update("\0");
71
+ }
72
+
73
+ async addFileHandle(label: string, handle: FileHandle, signal?: AbortSignal): Promise<void> {
74
+ this.#hash.update(label);
75
+ this.#hash.update("\0");
76
+ const stream = handle.createReadStream({ autoClose: false, highWaterMark: 64 << 10, signal });
77
+ try {
78
+ for await (const chunk of stream) {
79
+ throwIfAborted(signal);
80
+ const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
81
+ this.#bytes += buffer.length;
82
+ if (this.#bytes > this.maxBytes) {
83
+ stream.destroy();
84
+ throw new Error(`content fingerprint exceeded ${this.maxBytes} bytes`);
85
+ }
86
+ this.#hash.update(buffer);
87
+ }
88
+ } catch (error) {
89
+ throwIfAborted(signal);
90
+ throw error;
91
+ }
92
+ this.#hash.update("\0");
93
+ }
94
+
95
+ digest(): string {
96
+ return this.#hash.digest("hex");
97
+ }
98
+ }
99
+
100
+ /** Open, validate, and use one regular file without reopening its pathname. */
101
+ export async function withValidatedFingerprintFile<T>(
102
+ path: string,
103
+ signal: AbortSignal | undefined,
104
+ use: (handle: FileHandle, info: BigIntStats) => Promise<T>,
105
+ operations: FingerprintFileOperations = NODE_FINGERPRINT_FILE_OPERATIONS,
106
+ ): Promise<T> {
107
+ throwIfAborted(signal);
108
+ const flags = process.platform === "win32"
109
+ ? constants.O_RDONLY
110
+ : constants.O_RDONLY | constants.O_NOFOLLOW;
111
+ const handle = await operations.open(path, flags);
112
+ try {
113
+ const [openedInfo, pathInfo] = await Promise.all([
114
+ handle.stat({ bigint: true }),
115
+ operations.lstat(path),
116
+ ]);
117
+ throwIfAborted(signal);
118
+ if (!openedInfo.isFile()) throw new Error(`content fingerprint opened an unsupported entry: ${path}`);
119
+ if (pathInfo.isSymbolicLink()) throw new Error(`content fingerprint encountered a symbolic link: ${path}`);
120
+ if (!pathInfo.isFile()) throw new Error(`content fingerprint encountered an unsupported entry: ${path}`);
121
+ if (openedInfo.dev !== pathInfo.dev || openedInfo.ino !== pathInfo.ino) {
122
+ throw new Error(`content fingerprint path changed while it was being opened: ${path}`);
123
+ }
124
+ return await use(handle, openedInfo);
125
+ } finally {
126
+ await handle.close();
127
+ }
128
+ }
129
+
130
+ /** Fingerprint a bounded directory tree with stable path ordering and entry handling. */
131
+ export async function captureTreeFingerprint(options: TreeFingerprintOptions): Promise<FingerprintCapture> {
132
+ try {
133
+ throwIfAborted(options.signal);
134
+ const root = resolve(options.root);
135
+ const rootInfo = await lstat(root);
136
+ if (!rootInfo.isDirectory()) throw new Error(`fingerprint root is not a directory: ${root}`);
137
+
138
+ const fingerprint = new BoundedFingerprint(options.maxBytes);
139
+ const directories = [root];
140
+ let fileCount = 0;
141
+
142
+ for (let index = 0; index < directories.length; index++) {
143
+ throwIfAborted(options.signal);
144
+ const directory = directories[index]!;
145
+ const entries = await readdir(directory, { withFileTypes: true });
146
+ entries.sort((left, right) => left.name.localeCompare(right.name));
147
+
148
+ for (const entry of entries) {
149
+ throwIfAborted(options.signal);
150
+ const path = join(directory, entry.name);
151
+ const relativePath = relative(root, path);
152
+ if (entry.isDirectory()) {
153
+ if (!isExcludedRelativePath(relativePath, options.excludedRelativePaths)) directories.push(path);
154
+ continue;
155
+ }
156
+
157
+ fileCount += 1;
158
+ if (fileCount > options.maxFiles) throw new Error(`content fingerprint exceeded ${options.maxFiles} files`);
159
+ fingerprint.add("path", relativePath);
160
+
161
+ if (entry.isSymbolicLink()) {
162
+ throw new Error(`content fingerprint encountered a symbolic link: ${path}`);
163
+ }
164
+ if (!entry.isFile()) throw new Error(`content fingerprint encountered an unsupported entry: ${path}`);
165
+
166
+ await withValidatedFingerprintFile(path, options.signal, async (handle, info) => {
167
+ fingerprint.add("mode", String(info.mode));
168
+ await fingerprint.addFileHandle("file", handle, options.signal);
169
+ });
170
+ }
171
+ }
172
+
173
+ return { kind: "verified", fingerprint: fingerprint.digest() };
174
+ } catch (error) {
175
+ throwIfAborted(options.signal);
176
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
177
+ }
178
+ }
179
+
180
+ /** Fingerprint only explicitly declared files/directories under one shared bound. */
181
+ export async function captureDeclaredInputFingerprint(
182
+ options: DeclaredInputFingerprintOptions,
183
+ ): Promise<FingerprintCapture> {
184
+ try {
185
+ throwIfAborted(options.signal);
186
+
187
+ const root = resolve(options.root);
188
+ const rootInfo = await lstat(root);
189
+ if (!rootInfo.isDirectory()) throw new Error(`declared-input root is not a directory: ${root}`);
190
+
191
+ const resolvedInputs = resolveDeclaredInputPaths(root, options.inputs, options.excludedRelativePaths);
192
+ if (resolvedInputs.kind === "unverifiable") return resolvedInputs;
193
+ const inputs = resolvedInputs.paths;
194
+ const fingerprint = new BoundedFingerprint(options.maxBytes);
195
+ let entryCount = 0;
196
+
197
+ const recordEntry = (relativePath: string, kind: "directory" | "file" | "missing", mode?: number): void => {
198
+ entryCount += 1;
199
+ if (entryCount > options.maxEntries) {
200
+ throw new Error(`declared-input fingerprint exceeded ${options.maxEntries} entries`);
201
+ }
202
+ fingerprint.add("path", relativePath);
203
+ fingerprint.add("kind", kind);
204
+ if (mode !== undefined) fingerprint.add("mode", String(mode));
205
+ };
206
+
207
+ const visit = async (path: string, relativePath: string, directInput = false): Promise<boolean> => {
208
+ throwIfAborted(options.signal);
209
+
210
+ let info;
211
+ try {
212
+ info = await lstat(path);
213
+ } catch (error) {
214
+ if (!isMissingPathError(error)) throw error;
215
+ recordEntry(relativePath, "missing");
216
+ return true;
217
+ }
218
+
219
+ if (info.isSymbolicLink()) throw new Error(`declared input contains a symbolic link: ${relativePath}`);
220
+ if (info.isFile()) {
221
+ await withValidatedFingerprintFile(path, options.signal, async (handle, openedInfo) => {
222
+ recordEntry(relativePath, "file", Number(openedInfo.mode));
223
+ await fingerprint.addFileHandle("file", handle, options.signal);
224
+ });
225
+ return true;
226
+ }
227
+ if (!info.isDirectory()) throw new Error(`declared input contains an unsupported entry: ${relativePath}`);
228
+
229
+ if (options.shallowDirectories === true) {
230
+ recordEntry(relativePath, "directory", info.mode);
231
+ return true;
232
+ }
233
+
234
+ const entries = await readdir(path, { withFileTypes: true });
235
+ entries.sort((left, right) => left.name.localeCompare(right.name));
236
+ let hasObservableChild = false;
237
+ for (const entry of entries) {
238
+ const childPath = join(path, entry.name);
239
+ const childRelativePath = relativePath === "." ? entry.name : `${relativePath}/${entry.name}`;
240
+ if (isExcludedDeclaredInput(childRelativePath, options.excludedRelativePaths)) continue;
241
+ hasObservableChild = (await visit(childPath, childRelativePath)) || hasObservableChild;
242
+ }
243
+ const observableEmptyDirectory = entries.length === 0 && !isExcludedAncestor(relativePath, options.excludedRelativePaths);
244
+ if (!directInput && !hasObservableChild && !observableEmptyDirectory) return false;
245
+ recordEntry(relativePath, "directory", info.mode);
246
+ return true;
247
+ };
248
+
249
+ for (const relativePath of inputs) {
250
+ throwIfAborted(options.signal);
251
+ fingerprint.add("input", relativePath);
252
+ const segments = relativePath === "." ? [] : relativePath.split("/");
253
+ let path = root;
254
+ let missing = false;
255
+ for (let index = 0; index < Math.max(0, segments.length - 1); index++) {
256
+ path = join(path, segments[index]!);
257
+ let info;
258
+ try {
259
+ info = await lstat(path);
260
+ } catch (error) {
261
+ if (!isMissingPathError(error)) throw error;
262
+ recordEntry(relativePath, "missing");
263
+ missing = true;
264
+ break;
265
+ }
266
+ if (info.isSymbolicLink()) throw new Error(`declared input traverses a symbolic link: ${relativePath}`);
267
+ if (!info.isDirectory()) {
268
+ recordEntry(relativePath, "missing");
269
+ missing = true;
270
+ break;
271
+ }
272
+ }
273
+ if (!missing) await visit(relativePath === "." ? root : join(root, ...segments), relativePath, true);
274
+ }
275
+
276
+ return { kind: "verified", fingerprint: fingerprint.digest() };
277
+ } catch (error) {
278
+ throwIfAborted(options.signal);
279
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
280
+ }
281
+ }
282
+
283
+ /** Validate and canonicalize caller-authored paths before passing them to Git or the filesystem. */
284
+ export function resolveDeclaredInputPaths(
285
+ root: string,
286
+ inputs: readonly string[],
287
+ excludedRelativePaths?: ReadonlySet<string>,
288
+ base = root,
289
+ ): DeclaredInputPathResolution {
290
+ try {
291
+ if (!Array.isArray(inputs)) throw new Error("declared inputs must be an array");
292
+ const paths = [...new Set(inputs.map((input) => declaredInputPath(root, base, input)))].sort();
293
+ for (const path of paths) rejectExcludedDeclaredInput(path, excludedRelativePaths);
294
+ return { kind: "verified", paths };
295
+ } catch (error) {
296
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
297
+ }
298
+ }
299
+
300
+ /** Verify that a regular file is reached only through directories included in the tree hash. */
301
+ export async function validateTreeFile(options: TreeFileValidationOptions): Promise<TreeFileValidation> {
302
+ try {
303
+ throwIfAborted(options.signal);
304
+ const root = resolve(options.root);
305
+ const path = resolve(options.path);
306
+ if (!isPathWithin(root, path)) return { kind: "unverifiable", reason: "file is outside its fingerprint root" };
307
+
308
+ const rootInfo = await lstat(root);
309
+ if (!rootInfo.isDirectory()) throw new Error(`fingerprint root is not a directory: ${root}`);
310
+ const pathFromRoot = relative(root, path);
311
+ if (pathFromRoot === "") return { kind: "unverifiable", reason: "fingerprint source resolves to its root directory" };
312
+
313
+ const segments = pathFromRoot.split(/[\\/]/).filter(Boolean);
314
+ let current = root;
315
+ for (let index = 0; index < segments.length; index++) {
316
+ throwIfAborted(options.signal);
317
+ const segment = segments[index]!;
318
+ const isFile = index === segments.length - 1;
319
+ if (!isFile && isExcludedRelativePath(relative(root, join(current, segment)), options.excludedRelativePaths)) {
320
+ return { kind: "unverifiable", reason: "file is inside an excluded fingerprint directory" };
321
+ }
322
+ current = join(current, segment);
323
+ const info = await lstat(current);
324
+ if (isFile ? !info.isFile() : !info.isDirectory()) {
325
+ return {
326
+ kind: "unverifiable",
327
+ reason: isFile ? "fingerprint source is not a regular file" : "fingerprint source path traverses a non-directory entry",
328
+ };
329
+ }
330
+ }
331
+ return { kind: "verified" };
332
+ } catch (error) {
333
+ throwIfAborted(options.signal);
334
+ return { kind: "unverifiable", reason: unknownErrorMessage(error) };
335
+ }
336
+ }
337
+
338
+ export function isPathWithin(root: string, path: string): boolean {
339
+ const pathFromRoot = relative(resolve(root), resolve(path));
340
+ return pathFromRoot === "" || (!isAbsolute(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`));
341
+ }
342
+
343
+ function isExcludedRelativePath(path: string, excluded: ReadonlySet<string> | undefined): boolean {
344
+ if (!excluded || excluded.size === 0) return false;
345
+ const normalized = normalizeRelativePath(path);
346
+ for (const candidate of excluded) {
347
+ const normalizedCandidate = normalizeRelativePath(candidate);
348
+ if (normalized === normalizedCandidate || normalized.startsWith(`${normalizedCandidate}/`)) return true;
349
+ }
350
+ return false;
351
+ }
352
+
353
+ function normalizeRelativePath(path: string): string {
354
+ return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
355
+ }
356
+
357
+ function declaredInputPath(root: string, base: string, input: string): string {
358
+ if (typeof input !== "string" || input.length === 0) throw new Error("declared input paths must be non-empty strings");
359
+ if (isAbsolute(input)) throw new Error(`declared input must be relative to the workflow cwd: ${input}`);
360
+ const path = resolve(base, input);
361
+ if (!isPathWithin(root, path)) throw new Error(`declared input escapes the repository root: ${input}`);
362
+ if (!isPathWithin(base, path)) throw new Error(`declared input escapes the workflow cwd: ${input}`);
363
+ const relativePath = relative(root, path).split(sep).join("/");
364
+ return relativePath.length === 0 ? "." : relativePath;
365
+ }
366
+
367
+ function rejectExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): void {
368
+ if (!isExcludedDeclaredInput(path, excluded)) return;
369
+ throw new Error(`declared input enters excluded path: ${path}`);
370
+ }
371
+
372
+ function isExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): boolean {
373
+ if (!excluded) return false;
374
+ const normalized = normalizeRelativePath(path);
375
+ for (const candidate of excluded) {
376
+ const normalizedCandidate = normalizeRelativePath(candidate);
377
+ if (
378
+ normalized === normalizedCandidate ||
379
+ normalized.startsWith(`${normalizedCandidate}/`) ||
380
+ normalized.endsWith(`/${normalizedCandidate}`) ||
381
+ normalized.includes(`/${normalizedCandidate}/`)
382
+ ) {
383
+ return true;
384
+ }
385
+ }
386
+ return false;
387
+ }
388
+
389
+ function isExcludedAncestor(path: string, excluded: ReadonlySet<string> | undefined): boolean {
390
+ if (!excluded) return false;
391
+ const segments = normalizeRelativePath(path).split("/").filter((segment) => segment !== ".");
392
+ for (const candidate of excluded) {
393
+ const normalizedCandidate = normalizeRelativePath(candidate);
394
+ for (let index = 0; index < segments.length; index++) {
395
+ const suffix = segments.slice(index).join("/");
396
+ if (normalizedCandidate.startsWith(`${suffix}/`)) return true;
397
+ }
398
+ }
399
+ return false;
400
+ }
401
+
402
+ function isMissingPathError(error: unknown): boolean {
403
+ return error instanceof Error && "code" in error && error.code === "ENOENT";
404
+ }
@@ -1,10 +1,11 @@
1
1
  import type { Static, TSchema } from "typebox";
2
2
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
3
3
  import type { WorkflowBudget } from "./budget.ts";
4
- import type { Pipeline } from "./concurrency.ts";
4
+ import type { Pipeline, WorkflowParallel } from "./concurrency.ts";
5
5
  import type { PerfSink, PerfSnapshot } from "./perf.ts";
6
6
  import type { WorkflowProgressSnapshot } from "./progress.ts";
7
7
  import type { WorkflowUsageSnapshot } from "./usage.ts";
8
+ import type { WorktreeBaseline } from "./worktree.ts";
8
9
 
9
10
  /** A reference to a registered workflow by name. */
10
11
  export type WorkflowRef = string;
@@ -39,7 +40,7 @@ export interface WorkflowRunOptions {
39
40
  budget?: number;
40
41
  /** Internal/test override for the generated run id. Omit to generate a new id. */
41
42
  runId?: string;
42
- /** Replay completed agent results from this prior run id when prompt/options hashes still match. */
43
+ /** Replay completed agent results from this prior run id when call and execution context still match. */
43
44
  resumeFromRunId?: string;
44
45
  resultViewer?: "open" | "skip";
45
46
  /** Additional abort signal to compose with the host context signal. */
@@ -47,17 +48,17 @@ export interface WorkflowRunOptions {
47
48
  /** Internal recorder override for command/tool invocation timing. */
48
49
  perfRecorder?: PerfSink;
49
50
  /** Resolve a sub-workflow reference to a module, enabling `api.workflow()`. When omitted, `api.workflow()` throws. */
50
- resolveWorkflow?: (ref: WorkflowRef) => Promise<WorkflowModule>;
51
+ resolveWorkflow?: (ref: WorkflowRef) => Promise<LoadedWorkflow>;
51
52
  /** Called with the final performance snapshot when perf is enabled. */
52
- onPerfSnapshot?: (snapshot: PerfSnapshot) => void;
53
+ onPerfSnapshot?: (snapshot: PerfSnapshot) => void | Promise<void>;
53
54
  /** Called with the final workflow subagent usage snapshot. */
54
- onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void;
55
+ onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void | Promise<void>;
55
56
  /** Called once run identity and journal paths are known. */
56
- onRunMetadata?: (metadata: WorkflowRunMetadata) => void;
57
+ onRunMetadata?: (metadata: WorkflowRunMetadata) => void | Promise<void>;
57
58
  /** Called with the live progress source while a workflow is running, then undefined when it ends. */
58
- onProgressSource?: (source: WorkflowProgressSource | undefined) => void;
59
+ onProgressSource?: (source: WorkflowProgressSource | undefined) => void | Promise<void>;
59
60
  /** Called with the final completed progress snapshot after live workflow UI teardown. */
60
- onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void;
61
+ onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void | Promise<void>;
61
62
  }
62
63
 
63
64
  export interface WorkflowProgressSource {
@@ -68,6 +69,7 @@ export interface WorkflowProgressSource {
68
69
  export type WorkflowLaneItemStatus = "pending" | "running" | "success" | "warning" | "error";
69
70
 
70
71
  export type AgentToolHint = "search";
72
+ export type AgentResumePolicy = "read-only" | "off";
71
73
 
72
74
  export interface IsolatedAgentResult<T> {
73
75
  readonly result: T;
@@ -105,6 +107,20 @@ export interface AgentOptions<S extends TSchema = TSchema> {
105
107
  * with identical prompts/options, e.g. `${stage}:${item.id}`.
106
108
  */
107
109
  cacheKey?: string;
110
+ /**
111
+ * Resume policy for this call. Shared-workspace agents run live unless they
112
+ * explicitly declare themselves read-only. Those calls bind replay to the full
113
+ * Git-visible workspace; isolated agents bind to their disposable baseline.
114
+ * Use "off" to disable both journal reads and writes.
115
+ */
116
+ resume?: AgentResumePolicy;
117
+ /**
118
+ * Additional ignored/generated inputs under the workflow cwd that this
119
+ * read-only agent may observe. Git-visible files across the repository are
120
+ * captured automatically; list cwd-relative ignored paths here or use
121
+ * `resume: "off"` when their bounded contents cannot be captured.
122
+ */
123
+ resumeInputs?: readonly string[];
108
124
  /** Run this agent in a disposable git worktree and return its patch with the result. */
109
125
  isolation?: "worktree";
110
126
  /** Allowlist of concrete tool names the agent may use (e.g. ["read", "bash"]). */
@@ -151,9 +167,9 @@ export interface WorkflowApi {
151
167
  workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
152
168
  /**
153
169
  * Run every thunk concurrently and wait for all (a barrier). Recoverable thunk
154
- * failures become null slots; filter nulls before consuming survivors.
170
+ * failures become null slots by default; settled mode retains serialisable errors.
155
171
  */
156
- parallel<T>(thunks: Array<() => Promise<T>>): Promise<Array<T | null>>;
172
+ parallel: WorkflowParallel;
157
173
  /**
158
174
  * Run each item through all stages independently — no barrier between stages.
159
175
  * A recoverable stage failure drops that item to null and skips its later stages.
@@ -181,7 +197,19 @@ export interface WorkflowApi {
181
197
 
182
198
  export type WorkflowRun = (api: WorkflowApi) => Promise<unknown>;
183
199
 
200
+ export type WorkflowSourceIdentity =
201
+ | { readonly kind: "file"; readonly path: string; readonly root: string; readonly fingerprint: string }
202
+ | { readonly kind: "fingerprint"; readonly fingerprint: string }
203
+ | { readonly kind: "unverifiable"; readonly reason: string };
204
+
205
+ /** The authored exports of a workflow module. */
184
206
  export interface WorkflowModule {
185
207
  meta: WorkflowMeta;
186
208
  default: WorkflowRun;
187
209
  }
210
+
211
+ /** A validated workflow plus engine-owned provenance used for safe resume replay. */
212
+ export interface LoadedWorkflow extends WorkflowModule {
213
+ readonly source: WorkflowSourceIdentity;
214
+ readonly isolatedWorktreeBaseline?: WorktreeBaseline;
215
+ }
@@ -1,68 +1,17 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { Box, type Component, Text } from "@earendil-works/pi-tui";
3
- import type { AdvisoryFinding, AdvisoryLocation, AdvisoryReport } from "../advisory-schema.ts";
4
- import type { PerfAggregate } from "../perf.ts";
3
+ import { isAdvisoryReport, type AdvisoryReportWithStats } from "../advisory-schema.ts";
5
4
  import { renderIssueDetails, renderIssuesTable } from "../review/review-format.ts";
6
- import { toReviewIssues, type ReviewContext } from "../review/review-issues.ts";
5
+ import { toReviewIssues } from "../review/review-issues.ts";
7
6
  import { formatCount } from "./workflow-format.ts";
8
- import { formatWorkflowUsageLine, type WorkflowUsageSnapshot } from "../usage.ts";
9
-
10
- export interface WorkflowPerfDetails {
11
- readonly enabled: boolean;
12
- readonly startedAt: number;
13
- readonly aggregates: readonly PerfAggregate[];
14
- }
15
-
16
- export interface WorkflowResultEnvelope {
17
- name: string;
18
- result: unknown;
19
- completedAt: number;
20
- usage?: WorkflowUsageSnapshot;
21
- perf?: WorkflowPerfDetails;
22
- runId?: string;
23
- resumedFromRunId?: string;
24
- }
25
-
26
- export interface AdvisoryWorkflowResult extends AdvisoryReport {
27
- stats?: Record<string, string | number>;
28
- reviewContext?: ReviewContext;
29
- }
7
+ import { formatWorkflowUsageLine } from "../usage.ts";
8
+ import type { WorkflowPerfDetails, WorkflowResultEnvelope } from "../workflow-execution.ts";
30
9
 
31
10
  export function isWorkflowResult(value: unknown): value is WorkflowResultEnvelope {
32
11
  if (!isRecord(value)) return false;
33
12
  return typeof value.name === "string" && "result" in value && typeof value.completedAt === "number";
34
13
  }
35
14
 
36
- function isAdvisoryLocation(value: unknown): value is AdvisoryLocation {
37
- if (!isRecord(value)) return false;
38
- if (typeof value.file !== "string") return false;
39
- if (value.line !== undefined && typeof value.line !== "number") return false;
40
- if (value.symbol !== undefined && typeof value.symbol !== "string") return false;
41
- return true;
42
- }
43
-
44
- function isAdvisoryFinding(value: unknown): value is AdvisoryFinding {
45
- if (!isRecord(value)) return false;
46
- if (typeof value.summary !== "string") return false;
47
- if (typeof value.category !== "string") return false;
48
- if (!isSeverity(value.severity)) return false;
49
- if (!isConfidence(value.confidence)) return false;
50
- if (!Array.isArray(value.locations) || !value.locations.every(isAdvisoryLocation)) return false;
51
- if (!Array.isArray(value.evidence) || !value.evidence.every((entry) => typeof entry === "string")) return false;
52
- if (typeof value.impact !== "string") return false;
53
- if (typeof value.recommendation !== "string") return false;
54
- return true;
55
- }
56
-
57
- export function isAdvisoryReport(value: unknown): value is AdvisoryWorkflowResult {
58
- if (!isRecord(value)) return false;
59
- if (typeof value.summary !== "string" || !Array.isArray(value.findings)) return false;
60
- if (!value.findings.every(isAdvisoryFinding)) return false;
61
- if (!Array.isArray(value.nextSteps) || !value.nextSteps.every((entry) => typeof entry === "string")) return false;
62
- if (value.stats !== undefined && !isStats(value.stats)) return false;
63
- return true;
64
- }
65
-
66
15
  export interface WorkflowRunDisplayMetadata {
67
16
  readonly runId?: string;
68
17
  readonly resumedFromRunId?: string;
@@ -105,7 +54,7 @@ export function renderWorkflowResultText(
105
54
 
106
55
  function renderAdvisoryResult(
107
56
  name: string,
108
- result: AdvisoryWorkflowResult,
57
+ result: AdvisoryReportWithStats,
109
58
  expanded: boolean,
110
59
  theme: Theme,
111
60
  usage?: unknown,
@@ -213,19 +162,6 @@ function safeJson(value: unknown): string {
213
162
  }
214
163
  }
215
164
 
216
- function isSeverity(value: unknown): value is AdvisoryFinding["severity"] {
217
- return value === "low" || value === "medium" || value === "high";
218
- }
219
-
220
- function isConfidence(value: unknown): value is AdvisoryFinding["confidence"] {
221
- return value === "low" || value === "medium" || value === "high";
222
- }
223
-
224
- function isStats(value: unknown): value is Record<string, string | number> {
225
- if (!isRecord(value)) return false;
226
- return Object.values(value).every((entry) => typeof entry === "string" || typeof entry === "number");
227
- }
228
-
229
165
  function isRecord(value: unknown): value is Record<string, unknown> {
230
166
  return typeof value === "object" && value !== null;
231
167
  }
@@ -0,0 +1,8 @@
1
+ /** Read an unknown thrown value without allowing hostile coercion to mask the original failure. */
2
+ export function unknownErrorMessage(error: unknown): string {
3
+ try {
4
+ return error instanceof Error ? error.message : String(error);
5
+ } catch {
6
+ return "unknown error";
7
+ }
8
+ }