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,138 @@
|
|
|
1
|
+
export type IdentityCanonicalization =
|
|
2
|
+
| { readonly kind: "verified"; readonly value: string }
|
|
3
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
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;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Produce a deterministic, bounded identity for JSON-like data.
|
|
17
|
+
*
|
|
18
|
+
* Replay inputs are not trusted: schemas and tool metadata can contain cycles,
|
|
19
|
+
* accessors, proxies, or values JSON cannot represent. Those inputs disable
|
|
20
|
+
* replay instead of throwing or being silently collapsed to the same key.
|
|
21
|
+
*/
|
|
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;
|
|
29
|
+
const active = new WeakSet<object>();
|
|
30
|
+
const chunks: string[] = [];
|
|
31
|
+
let bytes = 0;
|
|
32
|
+
let nodes = 0;
|
|
33
|
+
|
|
34
|
+
const append = (chunk: string): void => {
|
|
35
|
+
bytes += Buffer.byteLength(chunk);
|
|
36
|
+
if (bytes > maxBytes) throw new IdentityCanonicalizationError(`identity exceeded ${maxBytes} bytes`);
|
|
37
|
+
chunks.push(chunk);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const visit = (current: unknown, depth: number): void => {
|
|
41
|
+
nodes += 1;
|
|
42
|
+
if (nodes > maxNodes) throw new IdentityCanonicalizationError(`identity exceeded ${maxNodes} values`);
|
|
43
|
+
if (depth > maxDepth) throw new IdentityCanonicalizationError(`identity exceeded ${maxDepth} levels`);
|
|
44
|
+
|
|
45
|
+
if (current === null) {
|
|
46
|
+
append("n;");
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
switch (typeof current) {
|
|
50
|
+
case "undefined":
|
|
51
|
+
append("u;");
|
|
52
|
+
return;
|
|
53
|
+
case "boolean":
|
|
54
|
+
append(current ? "b1;" : "b0;");
|
|
55
|
+
return;
|
|
56
|
+
case "number":
|
|
57
|
+
if (!Number.isFinite(current)) throw new IdentityCanonicalizationError("identity contains a non-finite number");
|
|
58
|
+
append(`d${Object.is(current, -0) ? "-0" : String(current)};`);
|
|
59
|
+
return;
|
|
60
|
+
case "string":
|
|
61
|
+
append(`s${Buffer.byteLength(current)}:${current};`);
|
|
62
|
+
return;
|
|
63
|
+
case "bigint":
|
|
64
|
+
case "function":
|
|
65
|
+
case "symbol":
|
|
66
|
+
throw new IdentityCanonicalizationError(`identity contains unsupported ${typeof current} data`);
|
|
67
|
+
case "object":
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (active.has(current)) throw new IdentityCanonicalizationError("identity contains a cycle");
|
|
72
|
+
active.add(current);
|
|
73
|
+
try {
|
|
74
|
+
const prototype = Object.getPrototypeOf(current);
|
|
75
|
+
if (Array.isArray(current)) {
|
|
76
|
+
if (prototype !== Array.prototype) throw new IdentityCanonicalizationError("identity contains an array with a custom prototype");
|
|
77
|
+
const descriptors = Object.getOwnPropertyDescriptors(current);
|
|
78
|
+
const ownKeys = Reflect.ownKeys(current);
|
|
79
|
+
if (ownKeys.some((key) => typeof key === "symbol")) {
|
|
80
|
+
throw new IdentityCanonicalizationError("identity contains symbol keys");
|
|
81
|
+
}
|
|
82
|
+
if (ownKeys.some((key) => typeof key === "string" && key !== "length" && !isArrayIndex(key, current.length))) {
|
|
83
|
+
throw new IdentityCanonicalizationError("identity array contains custom properties");
|
|
84
|
+
}
|
|
85
|
+
append(`a${current.length}[`);
|
|
86
|
+
for (let index = 0; index < current.length; index++) {
|
|
87
|
+
const descriptor = descriptors[String(index)];
|
|
88
|
+
if (!descriptor) {
|
|
89
|
+
append("h;");
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (!("value" in descriptor)) throw new IdentityCanonicalizationError("identity contains an array accessor");
|
|
93
|
+
visit(descriptor.value, depth + 1);
|
|
94
|
+
}
|
|
95
|
+
append("];");
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (prototype !== Object.prototype && prototype !== null) {
|
|
100
|
+
throw new IdentityCanonicalizationError("identity contains an object with a custom prototype");
|
|
101
|
+
}
|
|
102
|
+
const descriptors = Object.getOwnPropertyDescriptors(current);
|
|
103
|
+
const ownKeys = Reflect.ownKeys(current);
|
|
104
|
+
if (ownKeys.some((key) => typeof key === "symbol")) {
|
|
105
|
+
throw new IdentityCanonicalizationError("identity contains symbol keys");
|
|
106
|
+
}
|
|
107
|
+
const keys = Object.keys(descriptors).sort();
|
|
108
|
+
append(`o${keys.length}{`);
|
|
109
|
+
for (const key of keys) {
|
|
110
|
+
const descriptor = descriptors[key]!;
|
|
111
|
+
if (!("value" in descriptor)) throw new IdentityCanonicalizationError(`identity property ${key} is an accessor`);
|
|
112
|
+
append(`k${Buffer.byteLength(key)}:${key};`);
|
|
113
|
+
append(`p${descriptor.enumerable ? 1 : 0}${descriptor.configurable ? 1 : 0}${descriptor.writable ? 1 : 0};`);
|
|
114
|
+
visit(descriptor.value, depth + 1);
|
|
115
|
+
}
|
|
116
|
+
append("};");
|
|
117
|
+
} finally {
|
|
118
|
+
active.delete(current);
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
visit(value, 0);
|
|
124
|
+
return { kind: "verified", value: chunks.join("") };
|
|
125
|
+
} catch (error) {
|
|
126
|
+
return {
|
|
127
|
+
kind: "unverifiable",
|
|
128
|
+
reason: error instanceof IdentityCanonicalizationError ? error.message : "identity inspection failed",
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
class IdentityCanonicalizationError extends Error {}
|
|
134
|
+
|
|
135
|
+
function isArrayIndex(key: string, length: number): boolean {
|
|
136
|
+
const index = Number(key);
|
|
137
|
+
return Number.isInteger(index) && index >= 0 && index < length && String(index) === key;
|
|
138
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { canonicalizeIdentity } from "./identity-canonicalization.ts";
|
|
3
|
+
|
|
4
|
+
export function hashIdentity(value: unknown): string {
|
|
5
|
+
const canonical = canonicalizeIdentity(value);
|
|
6
|
+
if (canonical.kind === "unverifiable") throw new Error(canonical.reason);
|
|
7
|
+
return `sha256:${createHash("sha256").update(canonical.value).digest("hex")}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function inspectableFunctionSource(value: unknown, label: string): string {
|
|
11
|
+
if (typeof value !== "function") throw new Error(`${label} is not a function`);
|
|
12
|
+
const source = Function.prototype.toString.call(value);
|
|
13
|
+
if (source.length === 0 || source.includes("[native code]")) throw new Error(`${label} has no inspectable source`);
|
|
14
|
+
return source;
|
|
15
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { Type } from "typebox";
|
|
2
|
-
import
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { LoadedWorkflow, WorkflowApi } from "./types.ts";
|
|
4
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
3
5
|
import { parseWorkflowMeta } from "./workflow-module.ts";
|
|
4
6
|
|
|
5
7
|
/**
|
|
@@ -28,7 +30,7 @@ type AsyncFunctionConstructor = new (...args: string[]) => InlineWorkflowExecuto
|
|
|
28
30
|
|
|
29
31
|
const AsyncFunction = Object.getPrototypeOf(async function inlineWorkflowCompilerSentinel() {}).constructor as AsyncFunctionConstructor;
|
|
30
32
|
|
|
31
|
-
export function compileInlineWorkflow(source: string):
|
|
33
|
+
export function compileInlineWorkflow(source: string): LoadedWorkflow {
|
|
32
34
|
rejectForbiddenModuleSyntax(source);
|
|
33
35
|
const metaLiteral = extractMetaLiteral(source);
|
|
34
36
|
const parsedMeta = parseWorkflowMeta(metaLiteral.value);
|
|
@@ -36,7 +38,11 @@ export function compileInlineWorkflow(source: string): WorkflowModule {
|
|
|
36
38
|
|
|
37
39
|
const defaultExpression = extractDefaultWorkflowExpression(source, metaLiteral.endOffset);
|
|
38
40
|
const executor = compileExecutor(defaultExpression);
|
|
39
|
-
return {
|
|
41
|
+
return {
|
|
42
|
+
meta: parsedMeta.meta,
|
|
43
|
+
default: (api) => executor(api, Type),
|
|
44
|
+
source: { kind: "fingerprint", fingerprint: createHash("sha256").update(source).digest("hex") },
|
|
45
|
+
};
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
export function extractMetaLiteral(source: string): InlineMetaLiteral {
|
|
@@ -133,8 +139,7 @@ function compileExecutor(defaultExpression: string): InlineWorkflowExecutor {
|
|
|
133
139
|
try {
|
|
134
140
|
return new AsyncFunction("api", "Type", `"use strict";\nconst workflow = ${defaultExpression};\nreturn await workflow(api);`);
|
|
135
141
|
} catch (error) {
|
|
136
|
-
|
|
137
|
-
throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${message}`);
|
|
142
|
+
throw new InlineWorkflowCompileError(`inline workflow default export did not compile: ${unknownErrorMessage(error)}`);
|
|
138
143
|
}
|
|
139
144
|
}
|
|
140
145
|
|
|
@@ -2,21 +2,54 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { mkdir, readdir, readFile, rm, stat, appendFile } from "node:fs/promises";
|
|
3
3
|
import { dirname, join } from "node:path";
|
|
4
4
|
import type { AgentOptions } from "./types.ts";
|
|
5
|
+
import type { WorktreeBaseline } from "./worktree.ts";
|
|
6
|
+
import { canonicalizeIdentity } from "./identity-canonicalization.ts";
|
|
7
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
8
|
+
import {
|
|
9
|
+
isAgentResumeContext,
|
|
10
|
+
resumeContextMismatchReason,
|
|
11
|
+
type AgentResumeContext,
|
|
12
|
+
type ResumeContextComparisonOptions,
|
|
13
|
+
} from "./resume-context.ts";
|
|
5
14
|
|
|
6
15
|
export const WORKFLOW_RUNS_DIR = join(".pi", ".workflow-runs");
|
|
7
16
|
export const WORKFLOW_JOURNAL_KEEP = 50;
|
|
8
17
|
|
|
9
|
-
export interface
|
|
18
|
+
export interface JournalEntryV2 {
|
|
19
|
+
readonly version: 2;
|
|
20
|
+
readonly key: string;
|
|
21
|
+
readonly result: unknown;
|
|
22
|
+
readonly identity: AgentResumeContext;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parsed so journals written before effective-session identity can fail closed without aborting resume. */
|
|
26
|
+
export interface LegacyJournalEntryV2 {
|
|
27
|
+
readonly version: 2;
|
|
28
|
+
readonly key: string;
|
|
29
|
+
readonly result: unknown;
|
|
30
|
+
readonly identity: unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Parsed only so resume can explain why an older entry is not replayed. */
|
|
34
|
+
export interface LegacyJournalEntryV1 {
|
|
35
|
+
readonly version?: 1;
|
|
10
36
|
readonly key: string;
|
|
11
37
|
readonly value: unknown;
|
|
38
|
+
readonly context?: unknown;
|
|
12
39
|
}
|
|
13
40
|
|
|
14
|
-
export type
|
|
41
|
+
export type JournalEntry = LegacyJournalEntryV2 | LegacyJournalEntryV1;
|
|
42
|
+
|
|
43
|
+
export type JournalLookup = { readonly hit: true; readonly value: unknown } | { readonly hit: false; readonly reason?: string };
|
|
15
44
|
export type JournalRecordResult = { readonly ok: true } | { readonly ok: false; readonly error: string };
|
|
16
45
|
|
|
46
|
+
export type AgentJournalKeyCapture =
|
|
47
|
+
| { readonly kind: "verified"; readonly key: string }
|
|
48
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
49
|
+
|
|
17
50
|
export interface WorkflowJournal {
|
|
18
|
-
lookup(key: string): JournalLookup;
|
|
19
|
-
record(key: string,
|
|
51
|
+
lookup(key: string, identity: AgentResumeContext, options?: ResumeContextComparisonOptions): JournalLookup;
|
|
52
|
+
record(key: string, result: unknown, identity: AgentResumeContext): Promise<JournalRecordResult>;
|
|
20
53
|
}
|
|
21
54
|
|
|
22
55
|
export class WorkflowJournalLoadError extends Error {
|
|
@@ -28,31 +61,75 @@ export function createWorkflowRunId(): string {
|
|
|
28
61
|
}
|
|
29
62
|
|
|
30
63
|
export function workflowJournalPath(cwd: string, runId: string): string {
|
|
31
|
-
return join(cwd, WORKFLOW_RUNS_DIR, `${
|
|
64
|
+
return join(cwd, WORKFLOW_RUNS_DIR, `${validateWorkflowRunId(runId)}.jsonl`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function agentJournalKey(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
|
|
68
|
+
const capture = captureAgentJournalKey(prompt, opts, worktreeBaseline);
|
|
69
|
+
return capture.kind === "verified" ? capture.key : `agent:unverifiable:${randomUUID()}`;
|
|
32
70
|
}
|
|
33
71
|
|
|
34
|
-
export function
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
72
|
+
export function hashAgentCall(prompt: string, opts: AgentOptions = {}, worktreeBaseline?: WorktreeBaseline): string {
|
|
73
|
+
const capture = captureAgentCallHash(prompt, opts, worktreeBaseline);
|
|
74
|
+
return capture.kind === "verified"
|
|
75
|
+
? capture.hash
|
|
76
|
+
: createHash("sha256").update(`unverifiable:${randomUUID()}`).digest("hex");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Capture a replay key without allowing hostile or oversized schemas to escape as exceptions. */
|
|
80
|
+
export function captureAgentJournalKey(
|
|
81
|
+
prompt: string,
|
|
82
|
+
opts: AgentOptions = {},
|
|
83
|
+
worktreeBaseline?: WorktreeBaseline,
|
|
84
|
+
): AgentJournalKeyCapture {
|
|
85
|
+
const behavior = captureAgentCallHash(prompt, opts, worktreeBaseline);
|
|
86
|
+
if (behavior.kind === "unverifiable") return behavior;
|
|
87
|
+
let cacheKey: string | undefined;
|
|
88
|
+
try {
|
|
89
|
+
cacheKey = opts.cacheKey?.trim();
|
|
90
|
+
} catch {
|
|
91
|
+
return { kind: "unverifiable", reason: "agent cache key could not be inspected" };
|
|
92
|
+
}
|
|
93
|
+
if (!cacheKey) return { kind: "verified", key: `agent:${behavior.hash}` };
|
|
38
94
|
const cacheKeyHash = createHash("sha256").update(cacheKey).digest("hex");
|
|
39
|
-
return `agent:${cacheKeyHash}:${
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
95
|
+
return { kind: "verified", key: `agent:${cacheKeyHash}:${behavior.hash}` };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type AgentCallHashCapture =
|
|
99
|
+
| { readonly kind: "verified"; readonly hash: string }
|
|
100
|
+
| { readonly kind: "unverifiable"; readonly reason: string };
|
|
101
|
+
|
|
102
|
+
function captureAgentCallHash(
|
|
103
|
+
prompt: string,
|
|
104
|
+
opts: AgentOptions,
|
|
105
|
+
worktreeBaseline: WorktreeBaseline | undefined,
|
|
106
|
+
): AgentCallHashCapture {
|
|
107
|
+
try {
|
|
108
|
+
const behavior = {
|
|
109
|
+
prompt,
|
|
110
|
+
opts: {
|
|
111
|
+
thinkingLevel: opts.thinkingLevel,
|
|
112
|
+
tools: opts.tools,
|
|
113
|
+
toolHints: opts.toolHints,
|
|
114
|
+
requireToolHints: opts.requireToolHints === true ? true : undefined,
|
|
115
|
+
skills: opts.skills,
|
|
116
|
+
schema: opts.schema,
|
|
117
|
+
isolation: opts.isolation,
|
|
118
|
+
worktreeBaseline: worktreeBaseline
|
|
119
|
+
? {
|
|
120
|
+
ref: worktreeBaseline.ref,
|
|
121
|
+
patchFingerprint: createHash("sha256").update(worktreeBaseline.patch ?? "").digest("hex"),
|
|
122
|
+
}
|
|
123
|
+
: undefined,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
const canonical = canonicalizeIdentity(behavior);
|
|
127
|
+
return canonical.kind === "verified"
|
|
128
|
+
? { kind: "verified", hash: createHash("sha256").update(canonical.value).digest("hex") }
|
|
129
|
+
: canonical;
|
|
130
|
+
} catch {
|
|
131
|
+
return { kind: "unverifiable", reason: "agent replay inputs could not be inspected" };
|
|
132
|
+
}
|
|
56
133
|
}
|
|
57
134
|
|
|
58
135
|
export async function loadJournalEntries(path: string, options: { readonly required?: boolean } = {}): Promise<JournalEntry[]> {
|
|
@@ -61,7 +138,7 @@ export async function loadJournalEntries(path: string, options: { readonly requi
|
|
|
61
138
|
content = await readFile(path, "utf8");
|
|
62
139
|
} catch (error) {
|
|
63
140
|
if (options.required) {
|
|
64
|
-
throw new WorkflowJournalLoadError(`Could not load workflow resume journal at ${path}: ${
|
|
141
|
+
throw new WorkflowJournalLoadError(`Could not load workflow resume journal at ${path}: ${unknownErrorMessage(error)}`);
|
|
65
142
|
}
|
|
66
143
|
return [];
|
|
67
144
|
}
|
|
@@ -95,29 +172,50 @@ export async function createWorkflowJournal(options: {
|
|
|
95
172
|
readonly writePath: string;
|
|
96
173
|
}): Promise<WorkflowJournal> {
|
|
97
174
|
const priorEntries = options.resumePath ? await loadJournalEntries(options.resumePath, { required: true }) : [];
|
|
175
|
+
await mkdir(dirname(options.writePath), { recursive: true });
|
|
176
|
+
await appendFile(options.writePath, "", "utf8");
|
|
98
177
|
return createMemoryBackedJournal(priorEntries, options.writePath);
|
|
99
178
|
}
|
|
100
179
|
|
|
101
180
|
export function createMemoryBackedJournal(priorEntries: readonly JournalEntry[] = [], writePath?: string): WorkflowJournal {
|
|
102
|
-
const priorByKey = new Map<string, JournalEntry
|
|
181
|
+
const priorByKey = new Map<string, JournalEntry[]>();
|
|
103
182
|
for (const entry of priorEntries) {
|
|
104
|
-
|
|
183
|
+
const entries = priorByKey.get(entry.key) ?? [];
|
|
184
|
+
entries.push(entry);
|
|
185
|
+
priorByKey.set(entry.key, entries);
|
|
105
186
|
}
|
|
106
187
|
|
|
107
188
|
return {
|
|
108
|
-
lookup(key) {
|
|
109
|
-
const
|
|
110
|
-
if (!
|
|
111
|
-
|
|
189
|
+
lookup(key, identity, options) {
|
|
190
|
+
const entries = priorByKey.get(key);
|
|
191
|
+
if (!entries || entries.length === 0) return { hit: false };
|
|
192
|
+
|
|
193
|
+
const current = entries.filter(
|
|
194
|
+
(entry): entry is JournalEntryV2 => entry.version === 2 && isAgentResumeContext(entry.identity),
|
|
195
|
+
);
|
|
196
|
+
if (current.length === 0) {
|
|
197
|
+
return {
|
|
198
|
+
hit: false,
|
|
199
|
+
reason: entries.some((entry) => entry.version === 2)
|
|
200
|
+
? "journal entry predates effective replay identity"
|
|
201
|
+
: "journal entry predates replay contract v2",
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
const matches = current.filter(
|
|
205
|
+
(entry) => resumeContextMismatchReason(entry.identity, identity, options) === undefined,
|
|
206
|
+
);
|
|
207
|
+
if (matches.length === 1) return { hit: true, value: matches[0]!.result };
|
|
208
|
+
if (matches.length > 1) return { hit: false, reason: "multiple cached entries match this agent call" };
|
|
209
|
+
return { hit: false, reason: resumeContextMismatchReason(current[0]!.identity, identity, options) };
|
|
112
210
|
},
|
|
113
|
-
async record(key,
|
|
114
|
-
const entry = { key,
|
|
211
|
+
async record(key, result, identity) {
|
|
212
|
+
const entry: JournalEntryV2 = { version: 2, key, result, identity };
|
|
115
213
|
if (writePath) {
|
|
116
214
|
try {
|
|
117
215
|
await mkdir(dirname(writePath), { recursive: true });
|
|
118
216
|
await appendFile(writePath, `${JSON.stringify(entry)}\n`, "utf8");
|
|
119
217
|
} catch (error) {
|
|
120
|
-
|
|
218
|
+
return { ok: false, error: unknownErrorMessage(error) };
|
|
121
219
|
}
|
|
122
220
|
}
|
|
123
221
|
return { ok: true };
|
|
@@ -156,7 +254,7 @@ export async function pruneWorkflowJournals(cwd: string, keep = WORKFLOW_JOURNAL
|
|
|
156
254
|
await Promise.all(stale.map((entry) => rm(entry.path, { force: true }).catch(() => undefined)));
|
|
157
255
|
}
|
|
158
256
|
|
|
159
|
-
function
|
|
257
|
+
export function validateWorkflowRunId(runId: string): string {
|
|
160
258
|
const trimmed = runId.trim();
|
|
161
259
|
if (!/^[A-Za-z0-9_.-]+$/.test(trimmed)) {
|
|
162
260
|
throw new Error("Workflow run id must contain only letters, numbers, dots, underscores, or hyphens.");
|
|
@@ -164,37 +262,29 @@ function validateRunId(runId: string): string {
|
|
|
164
262
|
return trimmed;
|
|
165
263
|
}
|
|
166
264
|
|
|
167
|
-
function sortedArray(values: readonly string[] | undefined): readonly string[] | undefined {
|
|
168
|
-
return values ? [...values].sort() : undefined;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
function stableStringify(value: unknown): string {
|
|
172
|
-
return JSON.stringify(normalizeStable(value));
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function normalizeStable(value: unknown): unknown {
|
|
176
|
-
if (value === undefined) return undefined;
|
|
177
|
-
if (value === null) return null;
|
|
178
|
-
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") return value;
|
|
179
|
-
if (Array.isArray(value)) return value.map(normalizeStable);
|
|
180
|
-
if (typeof value === "object") {
|
|
181
|
-
const record = value as Record<string, unknown>;
|
|
182
|
-
const out: Record<string, unknown> = {};
|
|
183
|
-
for (const key of Object.keys(record).sort()) {
|
|
184
|
-
const normalized = normalizeStable(record[key]);
|
|
185
|
-
if (normalized !== undefined) out[key] = normalized;
|
|
186
|
-
}
|
|
187
|
-
return out;
|
|
188
|
-
}
|
|
189
|
-
return String(value);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
265
|
function isJournalEntry(value: unknown): value is JournalEntry {
|
|
193
266
|
if (typeof value !== "object" || value === null) return false;
|
|
194
|
-
const entry = value as {
|
|
195
|
-
|
|
267
|
+
const entry = value as {
|
|
268
|
+
readonly version?: unknown;
|
|
269
|
+
readonly key?: unknown;
|
|
270
|
+
readonly result?: unknown;
|
|
271
|
+
readonly identity?: unknown;
|
|
272
|
+
readonly value?: unknown;
|
|
273
|
+
readonly context?: unknown;
|
|
274
|
+
};
|
|
275
|
+
if (entry.version === 2) {
|
|
276
|
+
return typeof entry.key === "string" && "result" in entry && "identity" in entry;
|
|
277
|
+
}
|
|
278
|
+
return (
|
|
279
|
+
(entry.version === undefined || entry.version === 1) &&
|
|
280
|
+
typeof entry.key === "string" &&
|
|
281
|
+
"value" in entry &&
|
|
282
|
+
(entry.context === undefined || isLegacyAgentResumeContext(entry.context))
|
|
283
|
+
);
|
|
196
284
|
}
|
|
197
285
|
|
|
198
|
-
function
|
|
199
|
-
|
|
286
|
+
function isLegacyAgentResumeContext(value: unknown): boolean {
|
|
287
|
+
if (typeof value !== "object" || value === null) return false;
|
|
288
|
+
const context = value as { readonly repository?: unknown; readonly workflow?: unknown; readonly model?: unknown };
|
|
289
|
+
return "repository" in context && "workflow" in context && "model" in context;
|
|
200
290
|
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AutocompleteItem } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
clearWorkflowModelProfile,
|
|
5
|
+
isWorkflowModelProfileName,
|
|
6
|
+
isWorkflowThinkingLevel,
|
|
7
|
+
resolveWorkflowModelProfiles,
|
|
8
|
+
setWorkflowModelProfile,
|
|
9
|
+
workflowModelProfilePaths,
|
|
10
|
+
WORKFLOW_MODEL_PROFILE_NAMES,
|
|
11
|
+
WORKFLOW_THINKING_LEVELS,
|
|
12
|
+
type ResolvedWorkflowModelProfiles,
|
|
13
|
+
type WorkflowModelProfilePaths,
|
|
14
|
+
type WorkflowModelProfileName,
|
|
15
|
+
} from "./model-profiles.ts";
|
|
16
|
+
import { unknownErrorMessage } from "./unknown-error.ts";
|
|
17
|
+
import { completeCurrentArgument, splitArgumentPrefix } from "./command-completions.ts";
|
|
18
|
+
|
|
19
|
+
type ConfigScope = "user" | "project";
|
|
20
|
+
|
|
21
|
+
type WorkflowModelsCommand =
|
|
22
|
+
| { readonly kind: "status" }
|
|
23
|
+
| {
|
|
24
|
+
readonly kind: "set";
|
|
25
|
+
readonly scope: ConfigScope;
|
|
26
|
+
readonly profile: WorkflowModelProfileName;
|
|
27
|
+
readonly model: string;
|
|
28
|
+
readonly thinkingLevel?: Parameters<typeof setWorkflowModelProfile>[0]["profile"]["thinkingLevel"];
|
|
29
|
+
}
|
|
30
|
+
| { readonly kind: "clear"; readonly scope: ConfigScope; readonly profile: WorkflowModelProfileName }
|
|
31
|
+
| { readonly kind: "error"; readonly message: string };
|
|
32
|
+
|
|
33
|
+
const USAGE =
|
|
34
|
+
"Usage: /workflow:models [status | set <small|medium|big> <provider/model> [thinkingLevel] [--user|--project] | clear <small|medium|big> [--user|--project]]";
|
|
35
|
+
const MODEL_COMMAND_ACTIONS = [
|
|
36
|
+
{ value: "status", description: "Show resolved workflow model profiles" },
|
|
37
|
+
{ value: "set", description: "Configure an exact model for a profile" },
|
|
38
|
+
{ value: "clear", description: "Remove a configured profile" },
|
|
39
|
+
] as const;
|
|
40
|
+
const MODEL_COMMAND_SCOPES = [
|
|
41
|
+
{ value: "--user", description: "Write the user-level workflow model config" },
|
|
42
|
+
{ value: "--project", description: "Write the project-level workflow model config" },
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
export function registerWorkflowModelProfileCommand(pi: ExtensionAPI): void {
|
|
46
|
+
pi.registerCommand("workflow:models", {
|
|
47
|
+
description: "Inspect or configure exact small, medium, and big workflow model profiles",
|
|
48
|
+
getArgumentCompletions: workflowModelProfileArgumentCompletions,
|
|
49
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
50
|
+
const command = parseWorkflowModelsCommand(args);
|
|
51
|
+
if (command.kind === "error") {
|
|
52
|
+
ctx.ui.notify(`${command.message}\n${USAGE}`, "warning");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const paths = workflowModelProfilePaths(ctx.cwd);
|
|
58
|
+
if (command.kind === "set") {
|
|
59
|
+
setWorkflowModelProfile({
|
|
60
|
+
configPath: paths[command.scope],
|
|
61
|
+
name: command.profile,
|
|
62
|
+
profile: {
|
|
63
|
+
model: command.model,
|
|
64
|
+
...(command.thinkingLevel === undefined ? {} : { thinkingLevel: command.thinkingLevel }),
|
|
65
|
+
},
|
|
66
|
+
modelRegistry: ctx.modelRegistry,
|
|
67
|
+
});
|
|
68
|
+
} else if (command.kind === "clear") {
|
|
69
|
+
clearWorkflowModelProfile(paths[command.scope], command.profile);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const profiles = resolveWorkflowModelProfiles({
|
|
73
|
+
cwd: ctx.cwd,
|
|
74
|
+
modelRegistry: ctx.modelRegistry,
|
|
75
|
+
hostModel: ctx.model,
|
|
76
|
+
paths,
|
|
77
|
+
});
|
|
78
|
+
const prefix = command.kind === "status"
|
|
79
|
+
? "Workflow model profiles"
|
|
80
|
+
: `${command.profile} ${command.kind === "set" ? "updated" : "cleared"} in ${command.scope} config`;
|
|
81
|
+
ctx.ui.notify(`${prefix}\n${formatWorkflowModelProfiles(profiles, paths)}`, "info");
|
|
82
|
+
} catch (error) {
|
|
83
|
+
ctx.ui.notify(unknownErrorMessage(error), "error");
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function workflowModelProfileArgumentCompletions(argumentPrefix: string): AutocompleteItem[] | null {
|
|
90
|
+
const parts = splitArgumentPrefix(argumentPrefix);
|
|
91
|
+
const completedWithoutScopes = parts.completed.filter((token) => token !== "--user" && token !== "--project");
|
|
92
|
+
const action = completedWithoutScopes[0];
|
|
93
|
+
const scopes = parts.completed.some((token) => token === "--user" || token === "--project")
|
|
94
|
+
? []
|
|
95
|
+
: MODEL_COMMAND_SCOPES;
|
|
96
|
+
|
|
97
|
+
if (completedWithoutScopes.length === 0) {
|
|
98
|
+
return completeCurrentArgument(argumentPrefix, MODEL_COMMAND_ACTIONS);
|
|
99
|
+
}
|
|
100
|
+
if (action === "status") return null;
|
|
101
|
+
if (completedWithoutScopes.length === 1) {
|
|
102
|
+
return completeCurrentArgument(
|
|
103
|
+
argumentPrefix,
|
|
104
|
+
WORKFLOW_MODEL_PROFILE_NAMES.map((value) => ({ value, description: `${value} workflow model profile` })),
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (action === "clear") return completeCurrentArgument(argumentPrefix, scopes);
|
|
108
|
+
if (action !== "set") return null;
|
|
109
|
+
|
|
110
|
+
if (completedWithoutScopes.length === 2) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
if (completedWithoutScopes.length === 3) {
|
|
114
|
+
return completeCurrentArgument(argumentPrefix, [
|
|
115
|
+
...WORKFLOW_THINKING_LEVELS.map((value) => ({ value, description: "Thinking level" })),
|
|
116
|
+
...scopes,
|
|
117
|
+
]);
|
|
118
|
+
}
|
|
119
|
+
return completeCurrentArgument(argumentPrefix, scopes);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function parseWorkflowModelsCommand(args: string): WorkflowModelsCommand {
|
|
123
|
+
const tokens = args.trim().split(/\s+/).filter(Boolean);
|
|
124
|
+
if (tokens.length === 0 || (tokens.length === 1 && tokens[0] === "status")) return { kind: "status" };
|
|
125
|
+
|
|
126
|
+
const userScope = tokens.includes("--user");
|
|
127
|
+
const projectScope = tokens.includes("--project");
|
|
128
|
+
if (userScope && projectScope) return { kind: "error", message: "Choose either --user or --project, not both." };
|
|
129
|
+
const scope: ConfigScope = projectScope ? "project" : "user";
|
|
130
|
+
const positional = tokens.filter((token) => token !== "--user" && token !== "--project");
|
|
131
|
+
const [action, profile, model, thinkingLevel, ...extra] = positional;
|
|
132
|
+
if (extra.length > 0) return { kind: "error", message: "Too many /workflow:models arguments." };
|
|
133
|
+
if (!profile || !isWorkflowModelProfileName(profile)) {
|
|
134
|
+
return { kind: "error", message: "Profile must be small, medium, or big." };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (action === "clear") {
|
|
138
|
+
if (model !== undefined) return { kind: "error", message: "The clear action accepts only a profile and optional scope." };
|
|
139
|
+
return { kind: "clear", scope, profile };
|
|
140
|
+
}
|
|
141
|
+
if (action !== "set") return { kind: "error", message: `Unknown /workflow:models action "${action ?? ""}".` };
|
|
142
|
+
if (!model) return { kind: "error", message: "The set action requires an exact provider/model identity." };
|
|
143
|
+
if (thinkingLevel !== undefined && !isWorkflowThinkingLevel(thinkingLevel)) {
|
|
144
|
+
return { kind: "error", message: "Thinking level must be off, minimal, low, medium, high, xhigh, or max." };
|
|
145
|
+
}
|
|
146
|
+
return {
|
|
147
|
+
kind: "set",
|
|
148
|
+
scope,
|
|
149
|
+
profile,
|
|
150
|
+
model,
|
|
151
|
+
...(thinkingLevel === undefined ? {} : { thinkingLevel }),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function formatWorkflowModelProfiles(
|
|
156
|
+
profiles: ResolvedWorkflowModelProfiles,
|
|
157
|
+
paths: WorkflowModelProfilePaths,
|
|
158
|
+
): string {
|
|
159
|
+
const lines = WORKFLOW_MODEL_PROFILE_NAMES.map((name) => {
|
|
160
|
+
const profile = profiles[name];
|
|
161
|
+
const model = profile.model ? `${profile.model.provider}/${profile.model.id}` : "(no host model)";
|
|
162
|
+
const thinking = profile.thinkingLevel ?? "host";
|
|
163
|
+
const source = profile.source === "host" ? "host fallback" : `${profile.source}: ${profile.configPath}`;
|
|
164
|
+
return `${name}: ${model} · thinking ${thinking} · ${source}`;
|
|
165
|
+
});
|
|
166
|
+
return [...lines, `user: ${paths.user}`, `project: ${paths.project} (overrides user)`].join("\n");
|
|
167
|
+
}
|