pi-workflow-engine 0.10.3 → 0.12.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.
- package/.pi/extensions/pi-workflow-engine/index.ts +418 -213
- package/.pi/extensions/pi-workflow-engine/src/advisory-schema.ts +11 -4
- package/.pi/extensions/pi-workflow-engine/src/agent-attempt.ts +147 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-limits.ts +35 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-replay.ts +399 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-retry.ts +116 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner-types.ts +91 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-runner.ts +154 -530
- package/.pi/extensions/pi-workflow-engine/src/agent-session-identity.ts +256 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session-providers.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-session.ts +368 -0
- package/.pi/extensions/pi-workflow-engine/src/agent-skills.ts +87 -39
- package/.pi/extensions/pi-workflow-engine/src/agent-workspace.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflow-tool.ts +75 -0
- package/.pi/extensions/pi-workflow-engine/src/background-workflows.ts +492 -0
- package/.pi/extensions/pi-workflow-engine/src/budget.ts +10 -3
- package/.pi/extensions/pi-workflow-engine/src/cancellation.ts +55 -3
- package/.pi/extensions/pi-workflow-engine/src/command-completions.ts +36 -0
- package/.pi/extensions/pi-workflow-engine/src/concurrency.ts +230 -61
- package/.pi/extensions/pi-workflow-engine/src/diff-capture.ts +38 -55
- package/.pi/extensions/pi-workflow-engine/src/discovery.ts +70 -17
- package/.pi/extensions/pi-workflow-engine/src/dynamax-shortcuts.ts +59 -13
- package/.pi/extensions/pi-workflow-engine/src/dynamax.ts +123 -63
- package/.pi/extensions/pi-workflow-engine/src/engine.ts +357 -75
- package/.pi/extensions/pi-workflow-engine/src/filesystem-error.ts +4 -0
- package/.pi/extensions/pi-workflow-engine/src/finalizers.ts +66 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-canonicalization.ts +138 -0
- package/.pi/extensions/pi-workflow-engine/src/identity-fingerprint.ts +15 -0
- package/.pi/extensions/pi-workflow-engine/src/inline-workflow.ts +10 -5
- package/.pi/extensions/pi-workflow-engine/src/journal.ts +156 -66
- package/.pi/extensions/pi-workflow-engine/src/model-profile-command.ts +167 -0
- package/.pi/extensions/pi-workflow-engine/src/model-profiles.ts +310 -0
- package/.pi/extensions/pi-workflow-engine/src/options.ts +96 -6
- package/.pi/extensions/pi-workflow-engine/src/pi-compat.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/process-runner.ts +203 -39
- package/.pi/extensions/pi-workflow-engine/src/progress-types.ts +50 -0
- package/.pi/extensions/pi-workflow-engine/src/progress.ts +74 -122
- package/.pi/extensions/pi-workflow-engine/src/provider-usage-limit.ts +189 -0
- package/.pi/extensions/pi-workflow-engine/src/replay-path-identity.ts +48 -0
- package/.pi/extensions/pi-workflow-engine/src/research-contract.ts +89 -0
- package/.pi/extensions/pi-workflow-engine/src/research-evidence.ts +289 -0
- package/.pi/extensions/pi-workflow-engine/src/resume-context.ts +701 -0
- package/.pi/extensions/pi-workflow-engine/src/review/code-review-orchestration.ts +49 -0
- package/.pi/extensions/pi-workflow-engine/src/review/github-pr-comments.ts +75 -16
- package/.pi/extensions/pi-workflow-engine/src/review/review-actions.ts +83 -17
- package/.pi/extensions/pi-workflow-engine/src/review/review-budget.ts +45 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-fix-workflow.ts +117 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-handoff.ts +14 -66
- package/.pi/extensions/pi-workflow-engine/src/review/review-issues.ts +38 -12
- package/.pi/extensions/pi-workflow-engine/src/review/review-report.ts +42 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-flow.ts +18 -22
- package/.pi/extensions/pi-workflow-engine/src/review/review-results-viewer.ts +128 -56
- package/.pi/extensions/pi-workflow-engine/src/review/review-session-coordinator.ts +153 -0
- package/.pi/extensions/pi-workflow-engine/src/review/review-snapshot.ts +294 -0
- package/.pi/extensions/pi-workflow-engine/src/review-diff-target.ts +130 -0
- package/.pi/extensions/pi-workflow-engine/src/session-identity.ts +6 -0
- package/.pi/extensions/pi-workflow-engine/src/structured-output.ts +32 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-capabilities.ts +44 -0
- package/.pi/extensions/pi-workflow-engine/src/tool-source-identity.ts +150 -0
- package/.pi/extensions/pi-workflow-engine/src/tree-fingerprint.ts +401 -0
- package/.pi/extensions/pi-workflow-engine/src/types.ts +87 -25
- package/.pi/extensions/pi-workflow-engine/src/ui/dynamax-editor-decoration.ts +220 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-format.ts +26 -6
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-inspector.ts +88 -46
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-result-renderer.ts +7 -70
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-viewer-layout.ts +51 -0
- package/.pi/extensions/pi-workflow-engine/src/ui/workflow-widget.ts +12 -67
- package/.pi/extensions/pi-workflow-engine/src/unknown-error.ts +8 -0
- package/.pi/extensions/pi-workflow-engine/src/usage.ts +124 -16
- package/.pi/extensions/pi-workflow-engine/src/workflow-advisory-utils.ts +23 -3
- package/.pi/extensions/pi-workflow-engine/src/workflow-execution.ts +121 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-module.ts +19 -1
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-background.ts +72 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-controller.ts +354 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-history.ts +162 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-record.ts +632 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-run-store.ts +172 -0
- package/.pi/extensions/pi-workflow-engine/src/workflow-usage-limit-scheduler.ts +80 -0
- package/.pi/extensions/pi-workflow-engine/src/workflows.ts +30 -3
- package/.pi/extensions/pi-workflow-engine/src/worktree.ts +321 -43
- package/.pi/extensions/pi-workflow-engine/workflows/code-review.ts +62 -64
- package/.pi/extensions/pi-workflow-engine/workflows/diagnose.ts +17 -17
- package/.pi/extensions/pi-workflow-engine/workflows/perf-review.ts +15 -16
- package/.pi/extensions/pi-workflow-engine/workflows/refactor-scout.ts +15 -15
- package/.pi/extensions/pi-workflow-engine/workflows/research.ts +170 -0
- package/README.md +82 -207
- package/USAGE.md +295 -21
- package/package.json +9 -9
|
@@ -0,0 +1,401 @@
|
|
|
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 { isMissingPathError } from "./filesystem-error.ts";
|
|
7
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
8
|
+
|
|
9
|
+
export type FingerprintCapture =
|
|
10
|
+
| { readonly kind: "verified"; readonly fingerprint: string }
|
|
11
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
12
|
+
|
|
13
|
+
export type TreeFileValidation =
|
|
14
|
+
| { readonly kind: "verified" }
|
|
15
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
16
|
+
|
|
17
|
+
export interface TreeFingerprintOptions {
|
|
18
|
+
readonly root: string;
|
|
19
|
+
/** Root-relative directory paths to exclude, never bare names at every depth. */
|
|
20
|
+
readonly excludedRelativePaths?: ReadonlySet<string>;
|
|
21
|
+
readonly maxBytes: number;
|
|
22
|
+
readonly maxFiles: number;
|
|
23
|
+
readonly signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface TreeFileValidationOptions {
|
|
27
|
+
readonly root: string;
|
|
28
|
+
readonly path: string;
|
|
29
|
+
readonly excludedRelativePaths?: ReadonlySet<string>;
|
|
30
|
+
readonly signal?: AbortSignal;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface DeclaredInputFingerprintOptions {
|
|
34
|
+
readonly root: string;
|
|
35
|
+
/** Explicit paths relative to `root`. Files and directories are both supported. */
|
|
36
|
+
readonly inputs: readonly string[];
|
|
37
|
+
/** Paths with these segment sequences are never valid declared inputs. */
|
|
38
|
+
readonly excludedRelativePaths?: ReadonlySet<string>;
|
|
39
|
+
readonly maxBytes: number;
|
|
40
|
+
readonly maxEntries: number;
|
|
41
|
+
/** Record direct directory identity without recursively walking descendants. */
|
|
42
|
+
readonly shallowDirectories?: boolean;
|
|
43
|
+
readonly signal?: AbortSignal;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export type DeclaredInputPathResolution =
|
|
47
|
+
| { readonly kind: "verified"; readonly paths: readonly string[] }
|
|
48
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
49
|
+
|
|
50
|
+
export interface FingerprintFileOperations {
|
|
51
|
+
readonly open: (path: string, flags: number) => Promise<FileHandle>;
|
|
52
|
+
readonly lstat: (path: string) => Promise<BigIntStats>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const NODE_FINGERPRINT_FILE_OPERATIONS: FingerprintFileOperations = {
|
|
56
|
+
open: async (path, flags) => await open(path, flags),
|
|
57
|
+
lstat: async (path) => await lstat(path, { bigint: true }),
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** A deterministic hash sink that enforces one shared byte budget across file reads. */
|
|
61
|
+
export class BoundedFingerprint {
|
|
62
|
+
readonly #hash = createHash("sha256");
|
|
63
|
+
#bytes = 0;
|
|
64
|
+
|
|
65
|
+
constructor(private readonly maxBytes: number) {}
|
|
66
|
+
|
|
67
|
+
add(label: string, value: string | Buffer): void {
|
|
68
|
+
this.#hash.update(label);
|
|
69
|
+
this.#hash.update("\0");
|
|
70
|
+
this.#hash.update(value);
|
|
71
|
+
this.#hash.update("\0");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async addFileHandle(label: string, handle: FileHandle, signal?: AbortSignal): Promise<void> {
|
|
75
|
+
this.#hash.update(label);
|
|
76
|
+
this.#hash.update("\0");
|
|
77
|
+
const stream = handle.createReadStream({ autoClose: false, highWaterMark: 64 << 10, signal });
|
|
78
|
+
try {
|
|
79
|
+
for await (const chunk of stream) {
|
|
80
|
+
throwIfAborted(signal);
|
|
81
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
82
|
+
this.#bytes += buffer.length;
|
|
83
|
+
if (this.#bytes > this.maxBytes) {
|
|
84
|
+
stream.destroy();
|
|
85
|
+
throw new Error(`content fingerprint exceeded ${this.maxBytes} bytes`);
|
|
86
|
+
}
|
|
87
|
+
this.#hash.update(buffer);
|
|
88
|
+
}
|
|
89
|
+
} catch (error) {
|
|
90
|
+
throwIfAborted(signal);
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
this.#hash.update("\0");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
digest(): string {
|
|
97
|
+
return this.#hash.digest("hex");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Open, validate, and use one regular file without reopening its pathname. */
|
|
102
|
+
export async function withValidatedFingerprintFile<T>(
|
|
103
|
+
path: string,
|
|
104
|
+
signal: AbortSignal | undefined,
|
|
105
|
+
use: (handle: FileHandle, info: BigIntStats) => Promise<T>,
|
|
106
|
+
operations: FingerprintFileOperations = NODE_FINGERPRINT_FILE_OPERATIONS,
|
|
107
|
+
): Promise<T> {
|
|
108
|
+
throwIfAborted(signal);
|
|
109
|
+
const flags = process.platform === "win32"
|
|
110
|
+
? constants.O_RDONLY
|
|
111
|
+
: constants.O_RDONLY | constants.O_NOFOLLOW;
|
|
112
|
+
const handle = await operations.open(path, flags);
|
|
113
|
+
try {
|
|
114
|
+
const [openedInfo, pathInfo] = await Promise.all([
|
|
115
|
+
handle.stat({ bigint: true }),
|
|
116
|
+
operations.lstat(path),
|
|
117
|
+
]);
|
|
118
|
+
throwIfAborted(signal);
|
|
119
|
+
if (!openedInfo.isFile()) throw new Error(`content fingerprint opened an unsupported entry: ${path}`);
|
|
120
|
+
if (pathInfo.isSymbolicLink()) throw new Error(`content fingerprint encountered a symbolic link: ${path}`);
|
|
121
|
+
if (!pathInfo.isFile()) throw new Error(`content fingerprint encountered an unsupported entry: ${path}`);
|
|
122
|
+
if (openedInfo.dev !== pathInfo.dev || openedInfo.ino !== pathInfo.ino) {
|
|
123
|
+
throw new Error(`content fingerprint path changed while it was being opened: ${path}`);
|
|
124
|
+
}
|
|
125
|
+
return await use(handle, openedInfo);
|
|
126
|
+
} finally {
|
|
127
|
+
await handle.close();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Fingerprint a bounded directory tree with stable path ordering and entry handling. */
|
|
132
|
+
export async function captureTreeFingerprint(options: TreeFingerprintOptions): Promise<FingerprintCapture> {
|
|
133
|
+
try {
|
|
134
|
+
throwIfAborted(options.signal);
|
|
135
|
+
const root = resolve(options.root);
|
|
136
|
+
const rootInfo = await lstat(root);
|
|
137
|
+
if (!rootInfo.isDirectory()) throw new Error(`fingerprint root is not a directory: ${root}`);
|
|
138
|
+
|
|
139
|
+
const fingerprint = new BoundedFingerprint(options.maxBytes);
|
|
140
|
+
const directories = [root];
|
|
141
|
+
let fileCount = 0;
|
|
142
|
+
|
|
143
|
+
for (let index = 0; index < directories.length; index++) {
|
|
144
|
+
throwIfAborted(options.signal);
|
|
145
|
+
const directory = directories[index]!;
|
|
146
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
147
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
148
|
+
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
throwIfAborted(options.signal);
|
|
151
|
+
const path = join(directory, entry.name);
|
|
152
|
+
const relativePath = relative(root, path);
|
|
153
|
+
if (entry.isDirectory()) {
|
|
154
|
+
if (!isExcludedRelativePath(relativePath, options.excludedRelativePaths)) directories.push(path);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
fileCount += 1;
|
|
159
|
+
if (fileCount > options.maxFiles) throw new Error(`content fingerprint exceeded ${options.maxFiles} files`);
|
|
160
|
+
fingerprint.add("path", relativePath);
|
|
161
|
+
|
|
162
|
+
if (entry.isSymbolicLink()) {
|
|
163
|
+
throw new Error(`content fingerprint encountered a symbolic link: ${path}`);
|
|
164
|
+
}
|
|
165
|
+
if (!entry.isFile()) throw new Error(`content fingerprint encountered an unsupported entry: ${path}`);
|
|
166
|
+
|
|
167
|
+
await withValidatedFingerprintFile(path, options.signal, async (handle, info) => {
|
|
168
|
+
fingerprint.add("mode", String(info.mode));
|
|
169
|
+
await fingerprint.addFileHandle("file", handle, options.signal);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return { kind: "verified", fingerprint: fingerprint.digest() };
|
|
175
|
+
} catch (error) {
|
|
176
|
+
throwIfAborted(options.signal);
|
|
177
|
+
return { kind: "unverifiable", reason: unknownErrorMessage(error) };
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Fingerprint only explicitly declared files/directories under one shared bound. */
|
|
182
|
+
export async function captureDeclaredInputFingerprint(
|
|
183
|
+
options: DeclaredInputFingerprintOptions,
|
|
184
|
+
): Promise<FingerprintCapture> {
|
|
185
|
+
try {
|
|
186
|
+
throwIfAborted(options.signal);
|
|
187
|
+
|
|
188
|
+
const root = resolve(options.root);
|
|
189
|
+
const rootInfo = await lstat(root);
|
|
190
|
+
if (!rootInfo.isDirectory()) throw new Error(`declared-input root is not a directory: ${root}`);
|
|
191
|
+
|
|
192
|
+
const resolvedInputs = resolveDeclaredInputPaths(root, options.inputs, options.excludedRelativePaths);
|
|
193
|
+
if (resolvedInputs.kind === "unverifiable") return resolvedInputs;
|
|
194
|
+
const inputs = resolvedInputs.paths;
|
|
195
|
+
const fingerprint = new BoundedFingerprint(options.maxBytes);
|
|
196
|
+
let entryCount = 0;
|
|
197
|
+
|
|
198
|
+
const recordEntry = (relativePath: string, kind: "directory" | "file" | "missing", mode?: number): void => {
|
|
199
|
+
entryCount += 1;
|
|
200
|
+
if (entryCount > options.maxEntries) {
|
|
201
|
+
throw new Error(`declared-input fingerprint exceeded ${options.maxEntries} entries`);
|
|
202
|
+
}
|
|
203
|
+
fingerprint.add("path", relativePath);
|
|
204
|
+
fingerprint.add("kind", kind);
|
|
205
|
+
if (mode !== undefined) fingerprint.add("mode", String(mode));
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const visit = async (path: string, relativePath: string, directInput = false): Promise<boolean> => {
|
|
209
|
+
throwIfAborted(options.signal);
|
|
210
|
+
|
|
211
|
+
let info;
|
|
212
|
+
try {
|
|
213
|
+
info = await lstat(path);
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (!isMissingPathError(error)) throw error;
|
|
216
|
+
recordEntry(relativePath, "missing");
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (info.isSymbolicLink()) throw new Error(`declared input contains a symbolic link: ${relativePath}`);
|
|
221
|
+
if (info.isFile()) {
|
|
222
|
+
await withValidatedFingerprintFile(path, options.signal, async (handle, openedInfo) => {
|
|
223
|
+
recordEntry(relativePath, "file", Number(openedInfo.mode));
|
|
224
|
+
await fingerprint.addFileHandle("file", handle, options.signal);
|
|
225
|
+
});
|
|
226
|
+
return true;
|
|
227
|
+
}
|
|
228
|
+
if (!info.isDirectory()) throw new Error(`declared input contains an unsupported entry: ${relativePath}`);
|
|
229
|
+
|
|
230
|
+
if (options.shallowDirectories === true) {
|
|
231
|
+
recordEntry(relativePath, "directory", info.mode);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
236
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
237
|
+
let hasObservableChild = false;
|
|
238
|
+
for (const entry of entries) {
|
|
239
|
+
const childPath = join(path, entry.name);
|
|
240
|
+
const childRelativePath = relativePath === "." ? entry.name : `${relativePath}/${entry.name}`;
|
|
241
|
+
if (isExcludedDeclaredInput(childRelativePath, options.excludedRelativePaths)) continue;
|
|
242
|
+
hasObservableChild = (await visit(childPath, childRelativePath)) || hasObservableChild;
|
|
243
|
+
}
|
|
244
|
+
const observableEmptyDirectory = entries.length === 0 && !isExcludedAncestor(relativePath, options.excludedRelativePaths);
|
|
245
|
+
if (!directInput && !hasObservableChild && !observableEmptyDirectory) return false;
|
|
246
|
+
recordEntry(relativePath, "directory", info.mode);
|
|
247
|
+
return true;
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
for (const relativePath of inputs) {
|
|
251
|
+
throwIfAborted(options.signal);
|
|
252
|
+
fingerprint.add("input", relativePath);
|
|
253
|
+
const segments = relativePath === "." ? [] : relativePath.split("/");
|
|
254
|
+
let path = root;
|
|
255
|
+
let missing = false;
|
|
256
|
+
for (let index = 0; index < Math.max(0, segments.length - 1); index++) {
|
|
257
|
+
path = join(path, segments[index]!);
|
|
258
|
+
let info;
|
|
259
|
+
try {
|
|
260
|
+
info = await lstat(path);
|
|
261
|
+
} catch (error) {
|
|
262
|
+
if (!isMissingPathError(error)) throw error;
|
|
263
|
+
recordEntry(relativePath, "missing");
|
|
264
|
+
missing = true;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
if (info.isSymbolicLink()) throw new Error(`declared input traverses a symbolic link: ${relativePath}`);
|
|
268
|
+
if (!info.isDirectory()) {
|
|
269
|
+
recordEntry(relativePath, "missing");
|
|
270
|
+
missing = true;
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (!missing) await visit(relativePath === "." ? root : join(root, ...segments), relativePath, true);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return { kind: "verified", fingerprint: fingerprint.digest() };
|
|
278
|
+
} catch (error) {
|
|
279
|
+
throwIfAborted(options.signal);
|
|
280
|
+
return { kind: "unverifiable", reason: unknownErrorMessage(error) };
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Validate and canonicalize caller-authored paths before passing them to Git or the filesystem. */
|
|
285
|
+
export function resolveDeclaredInputPaths(
|
|
286
|
+
root: string,
|
|
287
|
+
inputs: readonly string[],
|
|
288
|
+
excludedRelativePaths?: ReadonlySet<string>,
|
|
289
|
+
base = root,
|
|
290
|
+
): DeclaredInputPathResolution {
|
|
291
|
+
try {
|
|
292
|
+
if (!Array.isArray(inputs)) throw new Error("declared inputs must be an array");
|
|
293
|
+
const paths = [...new Set(inputs.map((input) => declaredInputPath(root, base, input)))].sort();
|
|
294
|
+
for (const path of paths) rejectExcludedDeclaredInput(path, excludedRelativePaths);
|
|
295
|
+
return { kind: "verified", paths };
|
|
296
|
+
} catch (error) {
|
|
297
|
+
return { kind: "unverifiable", reason: unknownErrorMessage(error) };
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Verify that a regular file is reached only through directories included in the tree hash. */
|
|
302
|
+
export async function validateTreeFile(options: TreeFileValidationOptions): Promise<TreeFileValidation> {
|
|
303
|
+
try {
|
|
304
|
+
throwIfAborted(options.signal);
|
|
305
|
+
const root = resolve(options.root);
|
|
306
|
+
const path = resolve(options.path);
|
|
307
|
+
if (!isPathWithin(root, path)) return { kind: "unverifiable", reason: "file is outside its fingerprint root" };
|
|
308
|
+
|
|
309
|
+
const rootInfo = await lstat(root);
|
|
310
|
+
if (!rootInfo.isDirectory()) throw new Error(`fingerprint root is not a directory: ${root}`);
|
|
311
|
+
const pathFromRoot = relative(root, path);
|
|
312
|
+
if (pathFromRoot === "") return { kind: "unverifiable", reason: "fingerprint source resolves to its root directory" };
|
|
313
|
+
|
|
314
|
+
const segments = pathFromRoot.split(/[\\/]/).filter(Boolean);
|
|
315
|
+
let current = root;
|
|
316
|
+
for (let index = 0; index < segments.length; index++) {
|
|
317
|
+
throwIfAborted(options.signal);
|
|
318
|
+
const segment = segments[index]!;
|
|
319
|
+
const isFile = index === segments.length - 1;
|
|
320
|
+
if (!isFile && isExcludedRelativePath(relative(root, join(current, segment)), options.excludedRelativePaths)) {
|
|
321
|
+
return { kind: "unverifiable", reason: "file is inside an excluded fingerprint directory" };
|
|
322
|
+
}
|
|
323
|
+
current = join(current, segment);
|
|
324
|
+
const info = await lstat(current);
|
|
325
|
+
if (isFile ? !info.isFile() : !info.isDirectory()) {
|
|
326
|
+
return {
|
|
327
|
+
kind: "unverifiable",
|
|
328
|
+
reason: isFile ? "fingerprint source is not a regular file" : "fingerprint source path traverses a non-directory entry",
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { kind: "verified" };
|
|
333
|
+
} catch (error) {
|
|
334
|
+
throwIfAborted(options.signal);
|
|
335
|
+
return { kind: "unverifiable", reason: unknownErrorMessage(error) };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
export function isPathWithin(root: string, path: string): boolean {
|
|
340
|
+
const pathFromRoot = relative(resolve(root), resolve(path));
|
|
341
|
+
return pathFromRoot === "" || (!isAbsolute(pathFromRoot) && pathFromRoot !== ".." && !pathFromRoot.startsWith(`..${sep}`));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function isExcludedRelativePath(path: string, excluded: ReadonlySet<string> | undefined): boolean {
|
|
345
|
+
if (!excluded || excluded.size === 0) return false;
|
|
346
|
+
const normalized = normalizeRelativePath(path);
|
|
347
|
+
for (const candidate of excluded) {
|
|
348
|
+
const normalizedCandidate = normalizeRelativePath(candidate);
|
|
349
|
+
if (normalized === normalizedCandidate || normalized.startsWith(`${normalizedCandidate}/`)) return true;
|
|
350
|
+
}
|
|
351
|
+
return false;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function normalizeRelativePath(path: string): string {
|
|
355
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "").replace(/\/$/, "");
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function declaredInputPath(root: string, base: string, input: string): string {
|
|
359
|
+
if (typeof input !== "string" || input.length === 0) throw new Error("declared input paths must be non-empty strings");
|
|
360
|
+
if (isAbsolute(input)) throw new Error(`declared input must be relative to the workflow cwd: ${input}`);
|
|
361
|
+
const path = resolve(base, input);
|
|
362
|
+
if (!isPathWithin(root, path)) throw new Error(`declared input escapes the repository root: ${input}`);
|
|
363
|
+
if (!isPathWithin(base, path)) throw new Error(`declared input escapes the workflow cwd: ${input}`);
|
|
364
|
+
const relativePath = relative(root, path).split(sep).join("/");
|
|
365
|
+
return relativePath.length === 0 ? "." : relativePath;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function rejectExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): void {
|
|
369
|
+
if (!isExcludedDeclaredInput(path, excluded)) return;
|
|
370
|
+
throw new Error(`declared input enters excluded path: ${path}`);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isExcludedDeclaredInput(path: string, excluded: ReadonlySet<string> | undefined): boolean {
|
|
374
|
+
if (!excluded) return false;
|
|
375
|
+
const normalized = normalizeRelativePath(path);
|
|
376
|
+
for (const candidate of excluded) {
|
|
377
|
+
const normalizedCandidate = normalizeRelativePath(candidate);
|
|
378
|
+
if (
|
|
379
|
+
normalized === normalizedCandidate ||
|
|
380
|
+
normalized.startsWith(`${normalizedCandidate}/`) ||
|
|
381
|
+
normalized.endsWith(`/${normalizedCandidate}`) ||
|
|
382
|
+
normalized.includes(`/${normalizedCandidate}/`)
|
|
383
|
+
) {
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function isExcludedAncestor(path: string, excluded: ReadonlySet<string> | undefined): boolean {
|
|
391
|
+
if (!excluded) return false;
|
|
392
|
+
const segments = normalizeRelativePath(path).split("/").filter((segment) => segment !== ".");
|
|
393
|
+
for (const candidate of excluded) {
|
|
394
|
+
const normalizedCandidate = normalizeRelativePath(candidate);
|
|
395
|
+
for (let index = 0; index < segments.length; index++) {
|
|
396
|
+
const suffix = segments.slice(index).join("/");
|
|
397
|
+
if (normalizedCandidate.startsWith(`${suffix}/`)) return true;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return false;
|
|
401
|
+
}
|
|
@@ -1,10 +1,14 @@
|
|
|
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
|
-
import type { WorkflowProgressSnapshot } from "./progress.ts";
|
|
6
|
+
import type { WorkflowLaneItemStatus, WorkflowProgressSnapshot } from "./progress-types.ts";
|
|
7
7
|
import type { WorkflowUsageSnapshot } from "./usage.ts";
|
|
8
|
+
import type { WorktreeBaseline } from "./worktree.ts";
|
|
9
|
+
import type { WorkflowModelProfileName } from "./model-profiles.ts";
|
|
10
|
+
|
|
11
|
+
export type { WorkflowLaneItemStatus } from "./progress-types.ts";
|
|
8
12
|
|
|
9
13
|
/** A reference to a registered workflow by name. */
|
|
10
14
|
export type WorkflowRef = string;
|
|
@@ -28,6 +32,13 @@ export interface WorkflowRunMetadata {
|
|
|
28
32
|
readonly runId: string;
|
|
29
33
|
readonly resumedFromRunId?: string;
|
|
30
34
|
readonly journalPath: string;
|
|
35
|
+
readonly recordPath: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface WorkflowBackgroundOrigin {
|
|
39
|
+
/** Stable pi session id used to route and deduplicate completion delivery. */
|
|
40
|
+
readonly sessionId: string;
|
|
41
|
+
readonly requestedAt: number;
|
|
31
42
|
}
|
|
32
43
|
|
|
33
44
|
export interface WorkflowRunOptions {
|
|
@@ -35,29 +46,47 @@ export interface WorkflowRunOptions {
|
|
|
35
46
|
perf?: boolean;
|
|
36
47
|
concurrency?: number;
|
|
37
48
|
parallelSubmissionLimit?: number;
|
|
38
|
-
/**
|
|
49
|
+
/** Maximum number of agent calls that may reach a live model session during this run. */
|
|
50
|
+
maxAgents?: number;
|
|
51
|
+
/** Maximum live duration of one agent call in milliseconds. */
|
|
52
|
+
agentTimeoutMs?: number;
|
|
53
|
+
/** Retry count for transient provider/session failures. Defaults to zero. */
|
|
54
|
+
agentRetries?: number;
|
|
55
|
+
/** Explicitly schedule resumable background runs after recognized provider usage windows. */
|
|
56
|
+
autoResumeOnUsageLimit?: boolean;
|
|
57
|
+
/** Maximum recognized usage-limit pauses before automatic scheduling stops. */
|
|
58
|
+
usageLimitMaxAttempts?: number;
|
|
59
|
+
/** Maximum delay accepted from a provider reset hint. */
|
|
60
|
+
usageLimitMaxDelayMs?: number;
|
|
61
|
+
/** Internal count carried into a scheduler-created successor run. */
|
|
62
|
+
usageLimitAttempt?: number;
|
|
63
|
+
/** Output-token ceiling across recorded subagent attempts. Omit for no limit (budget.total === null). */
|
|
39
64
|
budget?: number;
|
|
40
65
|
/** Internal/test override for the generated run id. Omit to generate a new id. */
|
|
41
66
|
runId?: string;
|
|
42
|
-
/**
|
|
67
|
+
/** Internal origin metadata for an explicitly backgrounded tool invocation. */
|
|
68
|
+
background?: WorkflowBackgroundOrigin;
|
|
69
|
+
/** Replay completed agent results from this prior run id when call and execution context still match. */
|
|
43
70
|
resumeFromRunId?: string;
|
|
71
|
+
/** Explicitly allow resume to ignore only a workflow-source fingerprint mismatch. */
|
|
72
|
+
resumeEditedWorkflow?: boolean;
|
|
44
73
|
resultViewer?: "open" | "skip";
|
|
45
74
|
/** Additional abort signal to compose with the host context signal. */
|
|
46
75
|
signal?: AbortSignal;
|
|
47
76
|
/** Internal recorder override for command/tool invocation timing. */
|
|
48
77
|
perfRecorder?: PerfSink;
|
|
49
78
|
/** Resolve a sub-workflow reference to a module, enabling `api.workflow()`. When omitted, `api.workflow()` throws. */
|
|
50
|
-
resolveWorkflow?: (ref: WorkflowRef) => Promise<
|
|
79
|
+
resolveWorkflow?: (ref: WorkflowRef) => Promise<LoadedWorkflow>;
|
|
51
80
|
/** Called with the final performance snapshot when perf is enabled. */
|
|
52
|
-
onPerfSnapshot?: (snapshot: PerfSnapshot) => void
|
|
81
|
+
onPerfSnapshot?: (snapshot: PerfSnapshot) => void | Promise<void>;
|
|
53
82
|
/** Called with the final workflow subagent usage snapshot. */
|
|
54
|
-
onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void
|
|
83
|
+
onUsageSnapshot?: (snapshot: WorkflowUsageSnapshot) => void | Promise<void>;
|
|
55
84
|
/** Called once run identity and journal paths are known. */
|
|
56
|
-
onRunMetadata?: (metadata: WorkflowRunMetadata) => void
|
|
85
|
+
onRunMetadata?: (metadata: WorkflowRunMetadata) => void | Promise<void>;
|
|
57
86
|
/** Called with the live progress source while a workflow is running, then undefined when it ends. */
|
|
58
|
-
onProgressSource?: (source: WorkflowProgressSource | undefined) => void
|
|
87
|
+
onProgressSource?: (source: WorkflowProgressSource | undefined) => void | Promise<void>;
|
|
59
88
|
/** Called with the final completed progress snapshot after live workflow UI teardown. */
|
|
60
|
-
onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void
|
|
89
|
+
onProgressSnapshot?: (snapshot: WorkflowProgressSnapshot) => void | Promise<void>;
|
|
61
90
|
}
|
|
62
91
|
|
|
63
92
|
export interface WorkflowProgressSource {
|
|
@@ -65,9 +94,8 @@ export interface WorkflowProgressSource {
|
|
|
65
94
|
}
|
|
66
95
|
|
|
67
96
|
/** Options for a single `agent()` call. */
|
|
68
|
-
export type
|
|
69
|
-
|
|
70
|
-
export type AgentToolHint = "search";
|
|
97
|
+
export type AgentToolHint = "search" | "external-search";
|
|
98
|
+
export type AgentResumePolicy = "read-only" | "off";
|
|
71
99
|
|
|
72
100
|
export interface IsolatedAgentResult<T> {
|
|
73
101
|
readonly result: T;
|
|
@@ -94,27 +122,46 @@ export interface AgentOptions<S extends TSchema = TSchema> {
|
|
|
94
122
|
/** Phase to group this agent under in the progress tree. */
|
|
95
123
|
phase?: string;
|
|
96
124
|
/**
|
|
97
|
-
* Optional model id.
|
|
98
|
-
*
|
|
125
|
+
* Optional model id. Overrides profile routing; when both model and profile are
|
|
126
|
+
* omitted, inherit the host model. Explicit refs are strict: bare ids resolve as
|
|
127
|
+
* Anthropic shorthand; use "provider/id" for other providers.
|
|
99
128
|
*/
|
|
100
129
|
model?: string;
|
|
101
|
-
/** Reasoning effort for this agent. */
|
|
130
|
+
/** Reasoning effort for this agent. Overrides the profile's configured effort. */
|
|
102
131
|
thinkingLevel?: ThinkingLevel;
|
|
132
|
+
/** Exact configured model route to use when model/thinkingLevel do not override it. */
|
|
133
|
+
profile?: WorkflowModelProfileName;
|
|
103
134
|
/**
|
|
104
135
|
* Stable identity hint for resume replay. Use this for repeated logical calls
|
|
105
136
|
* with identical prompts/options, e.g. `${stage}:${item.id}`.
|
|
106
137
|
*/
|
|
107
138
|
cacheKey?: string;
|
|
139
|
+
/**
|
|
140
|
+
* Resume policy for this call. Shared-workspace agents run live unless they
|
|
141
|
+
* explicitly declare themselves read-only. Those calls bind replay to the full
|
|
142
|
+
* Git-visible workspace; isolated agents bind to their disposable baseline.
|
|
143
|
+
* Use "off" to disable both journal reads and writes.
|
|
144
|
+
*/
|
|
145
|
+
resume?: AgentResumePolicy;
|
|
146
|
+
/**
|
|
147
|
+
* Additional ignored/generated inputs under the workflow cwd that this
|
|
148
|
+
* read-only agent may observe. Git-visible files across the repository are
|
|
149
|
+
* captured automatically; list cwd-relative ignored paths here or use
|
|
150
|
+
* `resume: "off"` when their bounded contents cannot be captured.
|
|
151
|
+
*/
|
|
152
|
+
resumeInputs?: readonly string[];
|
|
108
153
|
/** Run this agent in a disposable git worktree and return its patch with the result. */
|
|
109
154
|
isolation?: "worktree";
|
|
110
155
|
/** Allowlist of concrete tool names the agent may use (e.g. ["read", "bash"]). */
|
|
111
156
|
tools?: string[];
|
|
112
157
|
/**
|
|
113
|
-
* Dynamically include installed tools matching semantic categories.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
158
|
+
* Dynamically include installed tools matching semantic categories. "search"
|
|
159
|
+
* matches local grep/find/code-search tools; "external-search" matches installed
|
|
160
|
+
* web search, browsing, and URL extraction tools.
|
|
116
161
|
*/
|
|
117
162
|
toolHints?: readonly AgentToolHint[];
|
|
163
|
+
/** Fail before prompting unless every semantic tool hint matched an installed tool. */
|
|
164
|
+
requireToolHints?: boolean;
|
|
118
165
|
/**
|
|
119
166
|
* Skill names to expose to this subagent. Subagents receive no skills by default.
|
|
120
167
|
* When omitted, clear prompt text such as `/skill:name`, `include skill name`, or
|
|
@@ -122,7 +169,10 @@ export interface AgentOptions<S extends TSchema = TSchema> {
|
|
|
122
169
|
* prompt inference for prompts that mention skills only as subject matter.
|
|
123
170
|
*/
|
|
124
171
|
skills?: readonly string[];
|
|
125
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* typebox schema. The agent must call the validated `final_answer` tool; bounded
|
|
174
|
+
* repair exhaustion throws `WorkflowStructuredOutputError` instead of returning null.
|
|
175
|
+
*/
|
|
126
176
|
schema?: S;
|
|
127
177
|
}
|
|
128
178
|
|
|
@@ -135,11 +185,11 @@ export interface WorkflowApi {
|
|
|
135
185
|
agent<S extends TSchema>(
|
|
136
186
|
prompt: string,
|
|
137
187
|
opts: AgentOptions<S> & { schema: S; isolation: "worktree" },
|
|
138
|
-
): Promise<IsolatedAgentResult<Static<S
|
|
188
|
+
): Promise<IsolatedAgentResult<Static<S>>>;
|
|
139
189
|
/** Run a text subagent in a disposable worktree and return its final text plus patch. */
|
|
140
190
|
agent(prompt: string, opts: AgentOptions & { isolation: "worktree" }): Promise<IsolatedAgentResult<string>>;
|
|
141
|
-
/** Run a subagent and return
|
|
142
|
-
agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S
|
|
191
|
+
/** Run a subagent and return validated structured output; rejects with a recoverable typed error on repair exhaustion. */
|
|
192
|
+
agent<S extends TSchema>(prompt: string, opts: AgentOptions<S> & { schema: S }): Promise<Static<S>>;
|
|
143
193
|
/** Run a subagent and return its final assistant text. */
|
|
144
194
|
agent(prompt: string, opts?: AgentOptions): Promise<string>;
|
|
145
195
|
/**
|
|
@@ -151,9 +201,9 @@ export interface WorkflowApi {
|
|
|
151
201
|
workflow(ref: WorkflowRef, args?: string): Promise<unknown>;
|
|
152
202
|
/**
|
|
153
203
|
* Run every thunk concurrently and wait for all (a barrier). Recoverable thunk
|
|
154
|
-
* failures become null slots;
|
|
204
|
+
* failures become null slots by default; settled mode retains serialisable errors.
|
|
155
205
|
*/
|
|
156
|
-
parallel
|
|
206
|
+
parallel: WorkflowParallel;
|
|
157
207
|
/**
|
|
158
208
|
* Run each item through all stages independently — no barrier between stages.
|
|
159
209
|
* A recoverable stage failure drops that item to null and skips its later stages.
|
|
@@ -181,7 +231,19 @@ export interface WorkflowApi {
|
|
|
181
231
|
|
|
182
232
|
export type WorkflowRun = (api: WorkflowApi) => Promise<unknown>;
|
|
183
233
|
|
|
234
|
+
export type WorkflowSourceIdentity =
|
|
235
|
+
| { readonly kind: "file"; readonly path: string; readonly root: string; readonly fingerprint: string }
|
|
236
|
+
| { readonly kind: "fingerprint"; readonly fingerprint: string }
|
|
237
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
238
|
+
|
|
239
|
+
/** The authored exports of a workflow module. */
|
|
184
240
|
export interface WorkflowModule {
|
|
185
241
|
meta: WorkflowMeta;
|
|
186
242
|
default: WorkflowRun;
|
|
187
243
|
}
|
|
244
|
+
|
|
245
|
+
/** A validated workflow plus engine-owned provenance used for safe resume replay. */
|
|
246
|
+
export interface LoadedWorkflow extends WorkflowModule {
|
|
247
|
+
readonly source: WorkflowSourceIdentity;
|
|
248
|
+
readonly isolatedWorktreeBaseline?: WorktreeBaseline;
|
|
249
|
+
}
|