pi-subagents 0.57.0 → 0.59.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/CHANGELOG.md +104 -0
- package/docs/agents.md +18 -9
- package/docs/configuration.md +4 -4
- package/docs/extension-api.md +40 -1
- package/docs/models.md +27 -4
- package/docs/observability.md +1 -1
- package/docs/tool-reference.md +92 -6
- package/docs/workflows.md +115 -0
- package/package.json +3 -1
- package/prompts/review-loop.md +2 -2
- package/skills/council-mode/SKILL.md +1 -1
- package/skills/pi-subagents/SKILL.md +3 -1
- package/skills/pi-subagents/references/execution-controls.md +6 -2
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
- package/skills/pi-subagents/references/prompting-and-roles.md +26 -5
- package/src/agents/agent-management.ts +28 -17
- package/src/agents/agent-serializer.ts +9 -2
- package/src/agents/agents.ts +103 -27
- package/src/agents/runtime-agent-events.ts +70 -0
- package/src/agents/runtime-agent-registry.ts +19 -18
- package/src/api/agents.ts +10 -5
- package/src/api/background-work.ts +5 -1
- package/src/api/delegation.ts +0 -7
- package/src/api/preflight.ts +32 -10
- package/src/extension/fanout-child.ts +5 -3
- package/src/extension/index.ts +66 -27
- package/src/extension/public-execution.ts +15 -2
- package/src/extension/rpc.ts +35 -14
- package/src/extension/schemas.ts +37 -12
- package/src/extension/tool-description.ts +25 -6
- package/src/integrations/herdr-status.ts +5 -0
- package/src/intercom/result-intercom.ts +2 -0
- package/src/profiles/profiles.ts +5 -6
- package/src/runs/background/active-async-capacity.ts +2 -2
- package/src/runs/background/async-execution.ts +78 -46
- package/src/runs/background/async-job-tracker.ts +14 -12
- package/src/runs/background/async-resume.ts +29 -27
- package/src/runs/background/async-status-snapshot.ts +23 -261
- package/src/runs/background/async-status.ts +68 -7
- package/src/runs/background/chain-append.ts +8 -3
- package/src/runs/background/chain-root-attachment.ts +60 -8
- package/src/runs/background/fleet-view.ts +21 -11
- package/src/runs/background/notify.ts +184 -9
- package/src/runs/background/result-delivery-ownership.ts +45 -0
- package/src/runs/background/result-files.ts +2 -1
- package/src/runs/background/result-watcher.ts +29 -10
- package/src/runs/background/resume-guidance.ts +1 -1
- package/src/runs/background/retained-children.ts +1 -1
- package/src/runs/background/run-status.ts +18 -8
- package/src/runs/background/scheduled-runs.ts +86 -7
- package/src/runs/background/stale-run-reconciler.ts +10 -4
- package/src/runs/background/steering.ts +4 -14
- package/src/runs/background/subagent-runner.ts +389 -350
- package/src/runs/background/subagent-wait.ts +58 -10
- package/src/runs/background/terminal-run-index.ts +1 -1
- package/src/runs/background/wait-completions.ts +22 -1
- package/src/runs/background/wait-config.ts +23 -9
- package/src/runs/background/wait-tool.ts +9 -2
- package/src/runs/foreground/async-steering-action.ts +2 -2
- package/src/runs/foreground/execution.ts +134 -121
- package/src/runs/foreground/foreground-control.ts +3 -0
- package/src/runs/foreground/foreground-history.ts +1 -0
- package/src/runs/foreground/subagent-executor.ts +527 -257
- package/src/runs/foreground/workflow-detach-reconcile.ts +130 -120
- package/src/runs/shared/abort-recovery.ts +119 -0
- package/src/runs/shared/async-status-projection.ts +463 -0
- package/src/runs/shared/child-identity.ts +19 -4
- package/src/runs/shared/child-launch-plan.ts +151 -0
- package/src/runs/shared/completion-evidence.ts +89 -0
- package/src/runs/shared/completion-guard.ts +5 -4
- package/src/runs/shared/dynamic-fanout.ts +3 -3
- package/src/runs/shared/fast-mode-extension.ts +5 -5
- package/src/runs/shared/host-step-status.ts +230 -0
- package/src/runs/shared/lane-metadata.ts +105 -0
- package/src/runs/shared/launch-cwd.ts +16 -0
- package/src/runs/shared/long-running-guard.ts +2 -1
- package/src/runs/shared/mcp-config-sources.ts +422 -0
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +221 -158
- package/src/runs/shared/mcp-direct-tool-grant.ts +197 -0
- package/src/runs/shared/model-exclusions.ts +12 -0
- package/src/runs/shared/model-fallback.ts +25 -6
- package/src/runs/shared/nested-events.ts +6 -2
- package/src/runs/shared/nested-render.ts +7 -3
- package/src/runs/shared/parallel-handoff.ts +419 -7
- package/src/runs/shared/parallel-utils.ts +15 -1
- package/src/runs/shared/pi-args.ts +81 -8
- package/src/runs/shared/single-output.ts +44 -4
- package/src/runs/shared/subagent-prompt-runtime.ts +98 -13
- package/src/runs/shared/worktree-cleanup-plan.ts +847 -0
- package/src/runs/shared/worktree.ts +18 -0
- package/src/shared/child-session-name.ts +46 -0
- package/src/shared/extension-context.ts +24 -0
- package/src/shared/formatters.ts +11 -2
- package/src/shared/launch-contract.ts +5 -1
- package/src/shared/settings.ts +9 -103
- package/src/shared/types.ts +240 -30
- package/src/shared/utils.ts +41 -84
- package/src/slash/delegation-adapters.ts +1 -8
- package/src/slash/delegation-request.ts +0 -4
- package/src/slash/slash-bridge.ts +1 -2
- package/src/slash/slash-commands.ts +369 -90
- package/src/slash/slash-live-state.ts +22 -11
- package/src/slash/subagents-admin.ts +3 -0
- package/src/tui/fleet-status.ts +125 -74
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render.ts +330 -33
- package/src/watchdog/turn-delta.ts +1 -1
- package/src/workflows/chat-progress.ts +6 -3
- package/src/workflows/host-command.ts +230 -0
- package/src/workflows/scripted-workflow.ts +496 -35
- package/src/workflows/workflow-child-summary.ts +9 -5
- package/src/workflows/workflow-preflight.ts +270 -0
- package/src/workflows/workflow-receipt.ts +79 -5
- package/src/workflows/workflow-settlement.ts +246 -0
- package/src/runs/shared/turn-budget.ts +0 -98
|
@@ -3,6 +3,7 @@ import type { WorkflowScriptChildResult, WorkflowScriptTraceEntry } from "./scri
|
|
|
3
3
|
|
|
4
4
|
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
5
5
|
const TERMINAL_STATES = new Set(["completed", "failed", "paused", "stopped", "rejected", "detached"]);
|
|
6
|
+
const MAX_REQUIRED_ID_BYTES = 4_096;
|
|
6
7
|
|
|
7
8
|
function bounded(value: unknown, maxBytes: number): string | undefined {
|
|
8
9
|
if (typeof value !== "string" || !value.trim() || Buffer.byteLength(value, "utf8") > maxBytes) return undefined;
|
|
@@ -10,8 +11,8 @@ function bounded(value: unknown, maxBytes: number): string | undefined {
|
|
|
10
11
|
}
|
|
11
12
|
|
|
12
13
|
function requiredId(value: string, label: string): string {
|
|
13
|
-
const id = bounded(value,
|
|
14
|
-
if (!id) throw new Error(`${label} must be a non-empty identifier of at most
|
|
14
|
+
const id = bounded(value, MAX_REQUIRED_ID_BYTES);
|
|
15
|
+
if (!id) throw new Error(`${label} must be a non-empty identifier of at most ${MAX_REQUIRED_ID_BYTES} UTF-8 bytes.`);
|
|
15
16
|
return id;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -38,6 +39,7 @@ export function workflowChildSummary(input: {
|
|
|
38
39
|
state,
|
|
39
40
|
...(bounded(entry.runId, 256) ? { runId: bounded(entry.runId, 256) } : {}),
|
|
40
41
|
...(previous?.agent ? { agent: previous.agent } : {}),
|
|
42
|
+
...(previous?.sessionName ? { sessionName: previous.sessionName } : {}),
|
|
41
43
|
...(previous?.model ? { model: previous.model } : {}),
|
|
42
44
|
...(previous?.thinking ? { thinking: previous.thinking } : {}),
|
|
43
45
|
});
|
|
@@ -57,6 +59,7 @@ export function workflowChildSummary(input: {
|
|
|
57
59
|
state,
|
|
58
60
|
...(bounded(step.runId, 256) ? { runId: bounded(step.runId, 256) } : {}),
|
|
59
61
|
...(launchResolved && bounded(step.agent, 256) ? { agent: bounded(step.agent, 256) } : {}),
|
|
62
|
+
...(bounded(step.sessionName, 256) ? { sessionName: bounded(step.sessionName, 256) } : {}),
|
|
60
63
|
...(bounded(step.model, 256) ? { model: bounded(step.model, 256) } : {}),
|
|
61
64
|
...(bounded(step.thinking, 32) ? { thinking: bounded(step.thinking, 32) } : {}),
|
|
62
65
|
});
|
|
@@ -70,6 +73,7 @@ export function workflowChildSummary(input: {
|
|
|
70
73
|
state,
|
|
71
74
|
...(bounded(child.runId, 256) ? { runId: bounded(child.runId, 256) } : {}),
|
|
72
75
|
...(bounded(child.agent, 256) ? { agent: bounded(child.agent, 256) } : {}),
|
|
76
|
+
...(bounded(result?.sessionName, 256) ? { sessionName: bounded(result?.sessionName, 256) } : {}),
|
|
73
77
|
...(bounded(result?.model, 256) ? { model: bounded(result?.model, 256) } : {}),
|
|
74
78
|
...(bounded(result?.thinking, 32) ? { thinking: bounded(result?.thinking, 32) } : {}),
|
|
75
79
|
});
|
|
@@ -101,14 +105,14 @@ export function parseWorkflowChildSummary(value: unknown): WorkflowChildSummaryV
|
|
|
101
105
|
const children = input.children.map((row): WorkflowChildSummaryV1["children"][number] => {
|
|
102
106
|
if (!row || typeof row !== "object" || Array.isArray(row)) throw new Error("workflowChildren child row is invalid.");
|
|
103
107
|
const child = row as Record<string, unknown>;
|
|
104
|
-
if (Object.keys(child).some((key) => !["childId", "runId", "agent", "model", "thinking", "state"].includes(key))) throw new Error("workflowChildren child row has unsupported fields.");
|
|
108
|
+
if (Object.keys(child).some((key) => !["childId", "runId", "agent", "sessionName", "model", "thinking", "state"].includes(key))) throw new Error("workflowChildren child row has unsupported fields.");
|
|
105
109
|
if (typeof child.childId !== "string" || !KEY_PATTERN.test(child.childId)) throw new Error("workflowChildren childId is invalid.");
|
|
106
110
|
const state = child.state;
|
|
107
111
|
if (state !== "pending" && state !== "running" && state !== "completed" && state !== "failed" && state !== "paused" && state !== "stopped" && state !== "rejected" && state !== "detached") throw new Error("workflowChildren child state is invalid.");
|
|
108
|
-
for (const [field, maxBytes] of [["runId", 256], ["agent", 256], ["model", 256], ["thinking", 32]] as const) {
|
|
112
|
+
for (const [field, maxBytes] of [["runId", 256], ["agent", 256], ["sessionName", 256], ["model", 256], ["thinking", 32]] as const) {
|
|
109
113
|
if (child[field] !== undefined && bounded(child[field], maxBytes) === undefined) throw new Error(`workflowChildren child ${field} is invalid.`);
|
|
110
114
|
}
|
|
111
|
-
return { childId: child.childId, state, ...(child.runId ? { runId: child.runId as string } : {}), ...(child.agent ? { agent: child.agent as string } : {}), ...(child.model ? { model: child.model as string } : {}), ...(child.thinking ? { thinking: child.thinking as string } : {}) };
|
|
115
|
+
return { childId: child.childId, state, ...(child.runId ? { runId: child.runId as string } : {}), ...(child.agent ? { agent: child.agent as string } : {}), ...(child.sessionName ? { sessionName: child.sessionName as string } : {}), ...(child.model ? { model: child.model as string } : {}), ...(child.thinking ? { thinking: child.thinking as string } : {}) };
|
|
112
116
|
});
|
|
113
117
|
if (new Set(children.map((child) => child.childId)).size !== children.length) throw new Error("workflowChildren has duplicate childId values.");
|
|
114
118
|
if (typeof input.parentToolCallId !== "string") throw new Error("workflowChildren.parentToolCallId is invalid.");
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { WorkflowPreflightLaneV1, WorkflowPreflightV1 } from "../shared/types.ts";
|
|
2
|
+
|
|
3
|
+
export const WORKFLOW_PREFLIGHT_VERSION = 1 as const;
|
|
4
|
+
export const WORKFLOW_PREFLIGHT_MAX_LANES = 64;
|
|
5
|
+
export const WORKFLOW_PREFLIGHT_MAX_STRING_LENGTH = 256;
|
|
6
|
+
export const WORKFLOW_PREFLIGHT_MAX_CLAIMS = 16;
|
|
7
|
+
export const WORKFLOW_PREFLIGHT_MAX_DEPTH = 3;
|
|
8
|
+
export const WORKFLOW_PREFLIGHT_MAX_BYTES = 16 * 1024;
|
|
9
|
+
export const WORKFLOW_PREFLIGHT_MAX_WARNINGS = 16;
|
|
10
|
+
|
|
11
|
+
const WORKFLOW_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
12
|
+
const PREFLIGHT_FIELDS = new Set(["version", "coverage", "lanes"]);
|
|
13
|
+
const LANE_FIELDS = new Set(["key", "mode", "decision", "claims", "expectedOutput", "independence"]);
|
|
14
|
+
const PREFLIGHT_MODES = new Set<WorkflowPreflightLaneV1["mode"]>(["mutation", "review", "scout", "gate"]);
|
|
15
|
+
|
|
16
|
+
type WorkflowTraceLike = {
|
|
17
|
+
operation: string;
|
|
18
|
+
key: string;
|
|
19
|
+
phase?: string;
|
|
20
|
+
generatedLaneKey?: string;
|
|
21
|
+
warning?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export interface WorkflowPreflightValidationResult {
|
|
25
|
+
ok: boolean;
|
|
26
|
+
preflight?: WorkflowPreflightV1;
|
|
27
|
+
error?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
31
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
32
|
+
const prototype = Object.getPrototypeOf(value);
|
|
33
|
+
return prototype === Object.prototype || prototype === null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function assertPlainObject(value: unknown, path: string): asserts value is Record<string, unknown> {
|
|
37
|
+
if (!isPlainObject(value)) throw new Error(`${path} must be a plain JSON object.`);
|
|
38
|
+
if (Object.getOwnPropertySymbols(value).length > 0) throw new Error(`${path} must not contain symbol keys.`);
|
|
39
|
+
for (const [key, descriptor] of Object.entries(Object.getOwnPropertyDescriptors(value))) {
|
|
40
|
+
if (!descriptor.enumerable || !("value" in descriptor)) throw new Error(`${path}.${key} must be an enumerable data property.`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function assertKnownFields(value: Record<string, unknown>, allowed: ReadonlySet<string>, path: string): void {
|
|
45
|
+
for (const key of Object.keys(value)) {
|
|
46
|
+
if (!allowed.has(key)) throw new Error(`${path} contains unsupported field '${key}'.`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeDisplayString(value: unknown, path: string): string {
|
|
51
|
+
if (typeof value !== "string") throw new Error(`${path} must be a string.`);
|
|
52
|
+
const normalized = value
|
|
53
|
+
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
|
54
|
+
.replace(/\s+/g, " ")
|
|
55
|
+
.trim();
|
|
56
|
+
if (!normalized) throw new Error(`${path} must be a non-empty string.`);
|
|
57
|
+
if (normalized.length > WORKFLOW_PREFLIGHT_MAX_STRING_LENGTH) {
|
|
58
|
+
throw new Error(`${path} exceeds the maximum length of ${WORKFLOW_PREFLIGHT_MAX_STRING_LENGTH}.`);
|
|
59
|
+
}
|
|
60
|
+
return normalized;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalizeLane(value: unknown, index: number): WorkflowPreflightLaneV1 {
|
|
64
|
+
const path = `preflight.lanes[${index}]`;
|
|
65
|
+
assertPlainObject(value, path);
|
|
66
|
+
assertKnownFields(value, LANE_FIELDS, path);
|
|
67
|
+
const key = normalizeDisplayString(value.key, `${path}.key`);
|
|
68
|
+
if (!WORKFLOW_KEY_PATTERN.test(key)) throw new Error(`${path}.key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.`);
|
|
69
|
+
let mode: WorkflowPreflightLaneV1["mode"];
|
|
70
|
+
if (value.mode !== undefined) {
|
|
71
|
+
if (typeof value.mode !== "string" || !PREFLIGHT_MODES.has(value.mode as WorkflowPreflightLaneV1["mode"])) {
|
|
72
|
+
throw new Error(`${path}.mode must be one of: mutation, review, scout, gate.`);
|
|
73
|
+
}
|
|
74
|
+
mode = value.mode as WorkflowPreflightLaneV1["mode"];
|
|
75
|
+
}
|
|
76
|
+
let claims: string[] | undefined;
|
|
77
|
+
if (value.claims !== undefined) {
|
|
78
|
+
if (!Array.isArray(value.claims)) throw new Error(`${path}.claims must be an array of strings.`);
|
|
79
|
+
if (value.claims.length > WORKFLOW_PREFLIGHT_MAX_CLAIMS) throw new Error(`${path}.claims supports at most ${WORKFLOW_PREFLIGHT_MAX_CLAIMS} entries.`);
|
|
80
|
+
claims = value.claims.map((claim, claimIndex) => normalizeDisplayString(claim, `${path}.claims[${claimIndex}]`));
|
|
81
|
+
for (let claimIndex = 0; claimIndex < value.claims.length; claimIndex += 1) {
|
|
82
|
+
if (!Object.prototype.hasOwnProperty.call(value.claims, claimIndex)) throw new Error(`${path}.claims must not contain sparse arrays.`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return {
|
|
86
|
+
key,
|
|
87
|
+
...(mode ? { mode } : {}),
|
|
88
|
+
...(value.decision !== undefined ? { decision: normalizeDisplayString(value.decision, `${path}.decision`) } : {}),
|
|
89
|
+
...(claims ? { claims } : {}),
|
|
90
|
+
...(value.expectedOutput !== undefined ? { expectedOutput: normalizeDisplayString(value.expectedOutput, `${path}.expectedOutput`) } : {}),
|
|
91
|
+
...(value.independence !== undefined ? { independence: normalizeDisplayString(value.independence, `${path}.independence`) } : {}),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Validate and canonicalize the explicit preflight input. This is intentionally
|
|
97
|
+
* independent of workflowScript parsing: callers may describe dynamic fanout
|
|
98
|
+
* without making the metadata a second execution graph.
|
|
99
|
+
*/
|
|
100
|
+
export function normalizeWorkflowPreflight(input: unknown): WorkflowPreflightV1 | undefined {
|
|
101
|
+
if (input === undefined) return undefined;
|
|
102
|
+
assertPlainObject(input, "preflight");
|
|
103
|
+
assertKnownFields(input, PREFLIGHT_FIELDS, "preflight");
|
|
104
|
+
if (input.version !== WORKFLOW_PREFLIGHT_VERSION) throw new Error(`preflight.version must be ${WORKFLOW_PREFLIGHT_VERSION}.`);
|
|
105
|
+
const coverage = input.coverage === undefined ? "partial" : input.coverage;
|
|
106
|
+
if (coverage !== "complete" && coverage !== "partial") throw new Error("preflight.coverage must be 'complete' or 'partial'.");
|
|
107
|
+
if (!Array.isArray(input.lanes)) throw new Error("preflight.lanes must be an array.");
|
|
108
|
+
if (input.lanes.length > WORKFLOW_PREFLIGHT_MAX_LANES) throw new Error(`preflight.lanes supports at most ${WORKFLOW_PREFLIGHT_MAX_LANES} lanes.`);
|
|
109
|
+
for (let index = 0; index < input.lanes.length; index += 1) {
|
|
110
|
+
if (!Object.prototype.hasOwnProperty.call(input.lanes, index)) throw new Error("preflight.lanes must not contain sparse arrays.");
|
|
111
|
+
}
|
|
112
|
+
const lanes = input.lanes.map(normalizeLane);
|
|
113
|
+
const seenKeys = new Set<string>();
|
|
114
|
+
for (const lane of lanes) {
|
|
115
|
+
if (seenKeys.has(lane.key)) throw new Error(`preflight.lanes contains duplicate key '${lane.key}'.`);
|
|
116
|
+
seenKeys.add(lane.key);
|
|
117
|
+
}
|
|
118
|
+
const normalized = { version: WORKFLOW_PREFLIGHT_VERSION, coverage, lanes } satisfies WorkflowPreflightV1;
|
|
119
|
+
const depth = 1 + (lanes.length > 0 ? 1 : 0) + (lanes.some((lane) => lane.claims !== undefined) ? 1 : 0);
|
|
120
|
+
if (depth > WORKFLOW_PREFLIGHT_MAX_DEPTH) throw new Error(`preflight exceeds the maximum nesting depth of ${WORKFLOW_PREFLIGHT_MAX_DEPTH}.`);
|
|
121
|
+
const bytes = Buffer.byteLength(JSON.stringify(normalized), "utf8");
|
|
122
|
+
if (bytes > WORKFLOW_PREFLIGHT_MAX_BYTES) throw new Error(`preflight canonical JSON is ${bytes} bytes; maximum is ${WORKFLOW_PREFLIGHT_MAX_BYTES}.`);
|
|
123
|
+
return normalized;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function validateWorkflowPreflight(input: unknown): WorkflowPreflightValidationResult {
|
|
127
|
+
try {
|
|
128
|
+
const preflight = normalizeWorkflowPreflight(input);
|
|
129
|
+
return { ok: true, ...(preflight ? { preflight } : {}) };
|
|
130
|
+
} catch (error) {
|
|
131
|
+
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function warningLimit(messages: string[]): string[] {
|
|
136
|
+
if (messages.length <= WORKFLOW_PREFLIGHT_MAX_WARNINGS) return messages;
|
|
137
|
+
const omitted = messages.length - WORKFLOW_PREFLIGHT_MAX_WARNINGS + 1;
|
|
138
|
+
return [...messages.slice(0, WORKFLOW_PREFLIGHT_MAX_WARNINGS - 1), `Preflight advisory: ${omitted} additional mismatch warning(s) omitted.`];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function declaredKeys(preflight: WorkflowPreflightV1): Set<string> {
|
|
142
|
+
return new Set(preflight.lanes.map((lane) => lane.key));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Treat declared lane keys as plan roots: `lane` is the lane itself and
|
|
147
|
+
* `lane.stage` is a stage by convention, even without generated provenance.
|
|
148
|
+
*/
|
|
149
|
+
function declaredLaneCoversWorkflowKey(entry: WorkflowTraceLike, laneKey: string): boolean {
|
|
150
|
+
return entry.key === laneKey || entry.key.startsWith(`${laneKey}.`);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function keyCoveredByDeclaredLane(entry: WorkflowTraceLike, declared: ReadonlySet<string>): boolean {
|
|
154
|
+
for (const laneKey of declared) if (declaredLaneCoversWorkflowKey(entry, laneKey)) return true;
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function launchedEntries(trace: readonly WorkflowTraceLike[]): WorkflowTraceLike[] {
|
|
159
|
+
const entries: WorkflowTraceLike[] = [];
|
|
160
|
+
const seen = new Set<string>();
|
|
161
|
+
for (const entry of trace) {
|
|
162
|
+
if (entry.operation !== "run" || entry.phase === "auto-resume" || seen.has(entry.key)) continue;
|
|
163
|
+
seen.add(entry.key);
|
|
164
|
+
entries.push(entry);
|
|
165
|
+
}
|
|
166
|
+
return entries;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function undeclaredWarning(key: string): string {
|
|
170
|
+
return `Preflight advisory: workflow key '${key}' launched without a declared lane.`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function unlaunchedWarning(key: string): string {
|
|
174
|
+
return `Preflight advisory: declared lane '${key}' was not launched.`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Return bounded advisory mismatch warnings; these never reject a child launch. */
|
|
178
|
+
export function workflowPreflightWarnings(
|
|
179
|
+
preflight: WorkflowPreflightV1 | undefined,
|
|
180
|
+
trace: readonly WorkflowTraceLike[],
|
|
181
|
+
options: { settled?: boolean } = {},
|
|
182
|
+
): string[] {
|
|
183
|
+
if (!preflight) return [];
|
|
184
|
+
const declared = declaredKeys(preflight);
|
|
185
|
+
const launched = launchedEntries(trace);
|
|
186
|
+
const messages = launched.filter((entry) => !keyCoveredByDeclaredLane(entry, declared)).map((entry) => undeclaredWarning(entry.key));
|
|
187
|
+
if (options.settled === true) {
|
|
188
|
+
for (const lane of preflight.lanes) if (!launched.some((entry) => declaredLaneCoversWorkflowKey(entry, lane.key))) messages.push(unlaunchedWarning(lane.key));
|
|
189
|
+
}
|
|
190
|
+
return warningLimit(messages);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Attach the first undeclared-key warning to its first trace row for status/debug views. */
|
|
194
|
+
export function annotateWorkflowPreflightTrace<T extends WorkflowTraceLike>(trace: readonly T[], preflight: WorkflowPreflightV1 | undefined): T[] {
|
|
195
|
+
if (!preflight) return [...trace];
|
|
196
|
+
const declared = declaredKeys(preflight);
|
|
197
|
+
const warned = new Set<string>();
|
|
198
|
+
return trace.map((entry) => {
|
|
199
|
+
if (entry.operation !== "run" || entry.phase === "auto-resume" || keyCoveredByDeclaredLane(entry, declared) || warned.has(entry.key)) return entry;
|
|
200
|
+
warned.add(entry.key);
|
|
201
|
+
return { ...entry, warning: entry.warning ?? undeclaredWarning(entry.key) };
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function laneCell(value: string | undefined): string {
|
|
206
|
+
return value ?? "—";
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const WORKFLOW_PREFLIGHT_PLAN_LABEL_LENGTH = 96;
|
|
210
|
+
|
|
211
|
+
function planLaneLabel(lane: WorkflowPreflightLaneV1): string {
|
|
212
|
+
const value = lane.decision?.trim() || lane.key;
|
|
213
|
+
if (value.length <= WORKFLOW_PREFLIGHT_PLAN_LABEL_LENGTH) return value;
|
|
214
|
+
return `${value.slice(0, WORKFLOW_PREFLIGHT_PLAN_LABEL_LENGTH - 1).trimEnd()}…`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Render the detailed bounded table reserved for tool output and expanded/debug views. */
|
|
218
|
+
export function formatWorkflowPreflight(preflight: WorkflowPreflightV1 | undefined, options: { indent?: string } = {}): string {
|
|
219
|
+
if (!preflight) return "";
|
|
220
|
+
const indent = options.indent ?? "";
|
|
221
|
+
const count = preflight.lanes.length;
|
|
222
|
+
const lines = [
|
|
223
|
+
`${indent}Preflight: v${preflight.version} · ${preflight.coverage} · ${count} lane${count === 1 ? "" : "s"}`,
|
|
224
|
+
`${indent} key | mode | decision | claims | expected output | independence`,
|
|
225
|
+
];
|
|
226
|
+
for (const lane of preflight.lanes) {
|
|
227
|
+
lines.push(`${indent} ${[
|
|
228
|
+
lane.key,
|
|
229
|
+
laneCell(lane.mode),
|
|
230
|
+
laneCell(lane.decision),
|
|
231
|
+
lane.claims?.join(", ") ?? "—",
|
|
232
|
+
laneCell(lane.expectedOutput),
|
|
233
|
+
laneCell(lane.independence),
|
|
234
|
+
].join(" | ")}`);
|
|
235
|
+
}
|
|
236
|
+
return lines.join("\n");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function formatWorkflowPreflightSummary(preflight: WorkflowPreflightV1 | undefined): string {
|
|
240
|
+
if (!preflight) return "";
|
|
241
|
+
const keys = preflight.lanes.slice(0, 4).map((lane) => lane.key).join(", ");
|
|
242
|
+
const remainder = preflight.lanes.length > 4 ? `, +${preflight.lanes.length - 4}` : "";
|
|
243
|
+
return `preflight · ${preflight.coverage} · ${preflight.lanes.length} lane${preflight.lanes.length === 1 ? "" : "s"}${keys ? `: ${keys}${remainder}` : ""}`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Render the operator-facing one-line plan shown in routine status views. */
|
|
247
|
+
export function formatWorkflowPreflightPlanSummary(preflight: WorkflowPreflightV1 | undefined, options: { indent?: string } = {}): string {
|
|
248
|
+
if (!preflight) return "";
|
|
249
|
+
const indent = options.indent ?? "";
|
|
250
|
+
const count = preflight.lanes.length;
|
|
251
|
+
const labels = count === 1
|
|
252
|
+
? planLaneLabel(preflight.lanes[0]!)
|
|
253
|
+
: preflight.lanes.slice(0, 4).map((lane) => lane.key).join(", ") + (count > 4 ? `, +${count - 4}` : "");
|
|
254
|
+
return `${indent}Plan: ${count} lane${count === 1 ? "" : "s"}${labels ? ` · ${labels}` : ""}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Render a bounded, non-alarming warning for routine status views. */
|
|
258
|
+
export function formatWorkflowPreflightWarningSummary(warnings: readonly string[] | undefined, options: { indent?: string; hint?: string } = {}): string {
|
|
259
|
+
if (!warnings?.length) return "";
|
|
260
|
+
const indent = options.indent ?? "";
|
|
261
|
+
const hint = options.hint ?? "details available for debug";
|
|
262
|
+
const count = warnings.length;
|
|
263
|
+
return `${indent}Plan note: ${count} preflight mismatch${count === 1 ? "" : "es"} · ${hint}.`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function formatWorkflowPreflightWarnings(warnings: readonly string[] | undefined, options: { indent?: string } = {}): string {
|
|
267
|
+
if (!warnings?.length) return "";
|
|
268
|
+
const indent = options.indent ?? "";
|
|
269
|
+
return [`${indent}Preflight warnings:`, ...warnings.map((warning) => `${indent} - ${warning.replace(/\s+/g, " ").trim()}`)].join("\n");
|
|
270
|
+
}
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
|
|
4
|
-
import type { ExternalCliReceiptMetadata, WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState } from "../shared/types.ts";
|
|
4
|
+
import type { ExternalCliReceiptMetadata, WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState, WorkflowRecoveryAction, WorkflowTerminalOutcome, WorkflowTerminalResolution } from "../shared/types.ts";
|
|
5
5
|
import type { WorkflowReceiptResumeReference, WorkflowScriptChildResult } from "./scripted-workflow.ts";
|
|
6
6
|
import { parseWorkflowChildSummary } from "./workflow-child-summary.ts";
|
|
7
|
+
import { HOST_STEP_MAX_COUNT, assertUniqueHostStepIds, parseHostStepNode } from "../runs/shared/host-step-status.ts";
|
|
8
|
+
import { assertWorkflowLaneKey, normalizeWorkflowLaneMetadata } from "../runs/shared/lane-metadata.ts";
|
|
7
9
|
|
|
8
10
|
export type { WorkflowReceipt, WorkflowReceiptEntry, WorkflowReceiptState } from "../shared/types.ts";
|
|
9
11
|
|
|
10
12
|
export const WORKFLOW_RECEIPT_VERSION = 1;
|
|
11
13
|
export const WORKFLOW_RECEIPT_FILE = "workflow-receipt.json";
|
|
14
|
+
const MAX_WORKFLOW_RECEIPT_BYTES = 2 * 1024 * 1024;
|
|
12
15
|
|
|
13
16
|
const KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
14
17
|
|
|
@@ -33,7 +36,9 @@ export function buildWorkflowReceipt(input: {
|
|
|
33
36
|
workflowRunId: string;
|
|
34
37
|
state: WorkflowReceiptState;
|
|
35
38
|
children: WorkflowScriptChildResult[];
|
|
39
|
+
hostSteps?: WorkflowReceipt["hostSteps"];
|
|
36
40
|
workflowChildren?: WorkflowReceipt["workflowChildren"];
|
|
41
|
+
terminalOutcome?: WorkflowTerminalOutcome;
|
|
37
42
|
createdAt?: number;
|
|
38
43
|
}): WorkflowReceipt {
|
|
39
44
|
const workflowRunId = assertSafeRunId(input.workflowRunId, "workflowRunId");
|
|
@@ -42,12 +47,16 @@ export function buildWorkflowReceipt(input: {
|
|
|
42
47
|
for (const child of input.children) {
|
|
43
48
|
const key = assertKey(child.key, "workflow receipt child key");
|
|
44
49
|
if (entries[key]) throw new Error(`Workflow receipt has duplicate child key '${key}'.`);
|
|
50
|
+
const lane = normalizeWorkflowLaneMetadata(child.lane, `workflow receipt child '${key}'.lane`);
|
|
51
|
+
assertWorkflowLaneKey(lane, key, `workflow receipt child '${key}'.lane`);
|
|
45
52
|
const runIds = [...new Set((child.continuation?.runIds ?? (child.runId ? [child.runId] : [])).filter((runId) => typeof runId === "string" && runId.trim()).map((runId) => runId.trim()))];
|
|
46
53
|
const latestRunId = runIds.at(-1);
|
|
47
54
|
const resumability = child.resumability ?? { state: "not-resumable", reason: child.runId ? "resumability was not recorded" : "child produced no run id" };
|
|
48
55
|
if (resumability.state === "resumable" && !latestRunId) throw new Error(`Workflow receipt child '${key}' is resumable but has no retained run id.`);
|
|
49
56
|
const base = {
|
|
50
57
|
key,
|
|
58
|
+
...(lane ? { lane } : {}),
|
|
59
|
+
...(child.terminalOutcome ? { terminalOutcome: child.terminalOutcome } : {}),
|
|
51
60
|
...(child.agent ? { agent: child.agent } : {}),
|
|
52
61
|
...(child.requestedContext ? { requestedContext: child.requestedContext } : {}),
|
|
53
62
|
...(child.resolvedContext ? { resolvedContext: child.resolvedContext } : {}),
|
|
@@ -59,7 +68,10 @@ export function buildWorkflowReceipt(input: {
|
|
|
59
68
|
? { ...base, latestRunId: latestRunId!, resumability }
|
|
60
69
|
: { ...base, ...(latestRunId ? { latestRunId } : {}), resumability };
|
|
61
70
|
}
|
|
62
|
-
|
|
71
|
+
if (input.hostSteps && input.hostSteps.length > HOST_STEP_MAX_COUNT) throw new Error(`Workflow receipt has more than ${HOST_STEP_MAX_COUNT} host steps.`);
|
|
72
|
+
const hostSteps = input.hostSteps?.map((hostStep, index) => parseHostStepNode(hostStep, `workflow receipt hostSteps[${index}]`));
|
|
73
|
+
if (hostSteps) assertUniqueHostStepIds(hostSteps, "workflow receipt");
|
|
74
|
+
return { version: WORKFLOW_RECEIPT_VERSION, workflowRunId, state: input.state, createdAt: input.createdAt ?? Date.now(), entries, ...(hostSteps?.length ? { hostSteps } : {}), ...(input.workflowChildren ? { workflowChildren: input.workflowChildren } : {}), ...(input.terminalOutcome ? { terminalOutcome: input.terminalOutcome } : {}) };
|
|
63
75
|
}
|
|
64
76
|
|
|
65
77
|
export function writeWorkflowReceipt(asyncDir: string, receipt: WorkflowReceipt): string {
|
|
@@ -68,6 +80,26 @@ export function writeWorkflowReceipt(asyncDir: string, receipt: WorkflowReceipt)
|
|
|
68
80
|
return receiptPath;
|
|
69
81
|
}
|
|
70
82
|
|
|
83
|
+
function readWorkflowReceiptFile(receiptPath: string): unknown {
|
|
84
|
+
let fd: number | undefined;
|
|
85
|
+
try {
|
|
86
|
+
fd = fs.openSync(receiptPath, "r");
|
|
87
|
+
const stat = fs.fstatSync(fd);
|
|
88
|
+
if (!stat.isFile()) throw new Error("workflow receipt is not a regular file.");
|
|
89
|
+
if (stat.size > MAX_WORKFLOW_RECEIPT_BYTES) throw new Error(`workflow receipt exceeds the ${MAX_WORKFLOW_RECEIPT_BYTES}-byte limit.`);
|
|
90
|
+
const buffer = Buffer.allocUnsafe(stat.size);
|
|
91
|
+
let offset = 0;
|
|
92
|
+
while (offset < buffer.length) {
|
|
93
|
+
const bytesRead = fs.readSync(fd, buffer, offset, buffer.length - offset, null);
|
|
94
|
+
if (bytesRead <= 0) throw new Error("workflow receipt ended before its declared size.");
|
|
95
|
+
offset += bytesRead;
|
|
96
|
+
}
|
|
97
|
+
return JSON.parse(buffer.toString("utf-8")) as unknown;
|
|
98
|
+
} finally {
|
|
99
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
71
103
|
const EXTERNAL_CLI_CAPABILITIES = {
|
|
72
104
|
stop: true,
|
|
73
105
|
steer: false,
|
|
@@ -170,6 +202,14 @@ function parseExternalCliReceiptMetadata(value: unknown, key: string, source: st
|
|
|
170
202
|
return value as ExternalCliReceiptMetadata;
|
|
171
203
|
}
|
|
172
204
|
|
|
205
|
+
function parseTerminalOutcome(value: unknown, label: string): WorkflowTerminalOutcome | undefined {
|
|
206
|
+
if (value === undefined) return undefined;
|
|
207
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${label} must be an object.`);
|
|
208
|
+
const outcome = value as Record<string, unknown>;
|
|
209
|
+
if (outcome.state !== "partial" || (outcome.reason !== "budget_exhausted" && outcome.reason !== "timeout")) throw new Error(`${label} is invalid.`);
|
|
210
|
+
return { state: "partial", reason: outcome.reason };
|
|
211
|
+
}
|
|
212
|
+
|
|
173
213
|
function parseEntry(value: unknown, key: string, source: string): WorkflowReceiptEntry {
|
|
174
214
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' must be an object.`);
|
|
175
215
|
const entry = value as Record<string, unknown>;
|
|
@@ -190,15 +230,39 @@ function parseEntry(value: unknown, key: string, source: string): WorkflowReceip
|
|
|
190
230
|
const reason = (resumability as Record<string, unknown>).reason;
|
|
191
231
|
if (state === "resumable" && latestRunId === undefined) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' resumable entry has no retained run id.`);
|
|
192
232
|
if (state === "not-resumable" && (typeof reason !== "string" || !reason.trim())) throw new Error(`Invalid workflow receipt '${source}': entry '${key}' non-resumable reason is missing.`);
|
|
233
|
+
const terminalOutcome = parseTerminalOutcome(entry.terminalOutcome, `Invalid workflow receipt '${source}': entry '${key}' terminalOutcome`);
|
|
193
234
|
parseExternalCliReceiptMetadata(entry.externalAdapter, key, source);
|
|
194
|
-
|
|
235
|
+
const lane = normalizeWorkflowLaneMetadata(entry.lane, `Invalid workflow receipt '${source}': entry '${key}'.lane`);
|
|
236
|
+
assertWorkflowLaneKey(lane, key, `Invalid workflow receipt '${source}': entry '${key}'.lane`);
|
|
237
|
+
return { ...(value as WorkflowReceiptEntry), ...(lane ? { lane } : {}), ...(terminalOutcome ? { terminalOutcome } : {}) };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function parseWorkflowResolution(value: unknown, source: string): WorkflowTerminalResolution | undefined {
|
|
241
|
+
if (value === undefined) return undefined;
|
|
242
|
+
if (value !== "settled-awaiting-resume" && value !== "failed-child" && value !== "interrupted-child") throw new Error(`Invalid workflow receipt '${source}': workflowResolution is invalid.`);
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function parseRecovery(value: unknown, workflowRunId: string, entries: Record<string, WorkflowReceiptEntry>, source: string): WorkflowRecoveryAction[] | undefined {
|
|
247
|
+
if (value === undefined) return undefined;
|
|
248
|
+
if (!Array.isArray(value)) throw new Error(`Invalid workflow receipt '${source}': recovery must be an array.`);
|
|
249
|
+
return value.map((item, index) => {
|
|
250
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) throw new Error(`Invalid workflow receipt '${source}': recovery[${index}] must be an object.`);
|
|
251
|
+
const action = item as Record<string, unknown>;
|
|
252
|
+
const key = action.key;
|
|
253
|
+
const resume = action.resume;
|
|
254
|
+
if (typeof key !== "string" || !entries[key] || action.call !== "runs.run" || action.taskRequired !== true || !resume || typeof resume !== "object" || Array.isArray(resume)) throw new Error(`Invalid workflow receipt '${source}': recovery[${index}] is invalid.`);
|
|
255
|
+
const reference = resume as Record<string, unknown>;
|
|
256
|
+
if (reference.workflowRunId !== workflowRunId || reference.key !== key || reference.latest !== true || entries[key].resumability.state !== "resumable") throw new Error(`Invalid workflow receipt '${source}': recovery[${index}] does not identify a resumable entry.`);
|
|
257
|
+
return { key, call: "runs.run", resume: { workflowRunId, key, latest: true }, taskRequired: true };
|
|
258
|
+
});
|
|
195
259
|
}
|
|
196
260
|
|
|
197
261
|
export function readWorkflowReceipt(asyncDirRoot: string, workflowRunId: string): WorkflowReceipt {
|
|
198
262
|
const receiptPath = workflowReceiptPath(asyncDirRoot, workflowRunId);
|
|
199
263
|
let value: unknown;
|
|
200
264
|
try {
|
|
201
|
-
value =
|
|
265
|
+
value = readWorkflowReceiptFile(receiptPath);
|
|
202
266
|
} catch (error) {
|
|
203
267
|
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
204
268
|
const workflowDir = path.dirname(receiptPath);
|
|
@@ -222,7 +286,17 @@ export function readWorkflowReceipt(asyncDirRoot: string, workflowRunId: string)
|
|
|
222
286
|
for (const [key, entry] of Object.entries(receipt.entries as Record<string, unknown>)) entries[assertKey(key, "workflow receipt key")] = parseEntry(entry, key, receiptPath);
|
|
223
287
|
const workflowChildren = parseWorkflowChildSummary(receipt.workflowChildren);
|
|
224
288
|
if (workflowChildren && workflowChildren.workflowRunId !== workflowRunId) throw new Error(`Workflow receipt '${receiptPath}' is stale: workflowChildren.workflowRunId does not match.`);
|
|
225
|
-
|
|
289
|
+
let hostSteps: NonNullable<WorkflowReceipt["hostSteps"]> | undefined;
|
|
290
|
+
if (receipt.hostSteps !== undefined) {
|
|
291
|
+
if (!Array.isArray(receipt.hostSteps)) throw new Error(`Invalid workflow receipt '${receiptPath}': hostSteps must be an array.`);
|
|
292
|
+
if (receipt.hostSteps.length > HOST_STEP_MAX_COUNT) throw new Error(`Invalid workflow receipt '${receiptPath}': hostSteps exceeds ${HOST_STEP_MAX_COUNT} entries.`);
|
|
293
|
+
hostSteps = receipt.hostSteps.map((hostStep, index) => parseHostStepNode(hostStep, `${receiptPath} hostSteps[${index}]`));
|
|
294
|
+
assertUniqueHostStepIds(hostSteps, receiptPath);
|
|
295
|
+
}
|
|
296
|
+
const workflowResolution = parseWorkflowResolution(receipt.workflowResolution, receiptPath);
|
|
297
|
+
const terminalOutcome = parseTerminalOutcome(receipt.terminalOutcome, `Invalid workflow receipt '${receiptPath}': terminalOutcome`);
|
|
298
|
+
const recovery = parseRecovery(receipt.recovery, workflowRunId, entries, receiptPath);
|
|
299
|
+
return { version: 1, workflowRunId, state: receipt.state, createdAt: receipt.createdAt, entries, ...(hostSteps?.length ? { hostSteps } : {}), ...(workflowChildren ? { workflowChildren } : {}), ...(workflowResolution ? { workflowResolution } : {}), ...(terminalOutcome ? { terminalOutcome } : {}), ...(recovery ? { recovery } : {}) };
|
|
226
300
|
}
|
|
227
301
|
|
|
228
302
|
export function resolveWorkflowReceiptResumeEntry(input: {
|