@tranhoangnguyen0310/pi-flow-external 3.0.0-external.0 → 3.1.1-external.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/AGENTS.md +2 -0
- package/CHANGELOG.md +18 -0
- package/README.md +4 -2
- package/docs/agent-public-contract.md +21 -0
- package/package.json +10 -9
- package/src/core/errors.ts +10 -0
- package/src/core/parent-context.ts +10 -7
- package/src/core/run-registry.ts +2 -1
- package/src/core/spawn.ts +23 -1
- package/src/external-runs.ts +143 -29
- package/src/harnesses.ts +2 -16
- package/src/pi-subagent.ts +51 -17
- package/src/profiles.ts +13 -12
- package/src/public-contract.ts +73 -0
- package/src/workflow/journal.ts +28 -6
- package/src/workflow/source.ts +17 -25
- package/src/workflow/tool.ts +29 -18
package/AGENTS.md
CHANGED
|
@@ -43,6 +43,8 @@ This fork changes the original pi-flow contract: `Agent` is not a generic Pi sub
|
|
|
43
43
|
|
|
44
44
|
## Receipt and evidence invariants
|
|
45
45
|
|
|
46
|
+
- Agent public contract v1 uses a discriminated `ok` envelope with `isError === !ok`; renderer details remain independent. Expected errors are coded at their source, not parsed from prose. Canonical output is inline only within 16 KiB UTF-8 JSON after redaction, with `output_redacted` disclosed when changed. Refs must be resolvable, liveness must be observed after evidence I/O, and unavailable evidence must not discard a settled result. Native codemode tests must assert script output and hook-captured flags outside hooks (the SDK catches hook exceptions).
|
|
47
|
+
|
|
46
48
|
- Success requires a recognized backend terminal-success event, a zero process exit, and a non-empty result.
|
|
47
49
|
- A backend failure with a complete local record is different from an incomplete or damaged record; preserve that distinction in reports.
|
|
48
50
|
- Normal runs write private best-effort evidence under `~/.pi/agent/pi-flow-external/runs/` or `PI_FLOW_EXTERNAL_RUNS_DIR`. Records may still contain sensitive prompts, excerpts, and tool output despite redaction.
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-flow external are documented here.
|
|
4
4
|
|
|
5
|
+
## [3.1.1-external.0] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- `external_runs list` no longer reports live work as `interrupted_or_uncertain` on later pages, or lists a live run twice. Live state now replaces a run's persisted row on whichever page that row falls. Live runs with no persisted row yet (for example, while evidence writes fail) are paged first and count against `limit`, so every page stays within `limit`. The cursor bookmarks the last such run in registry order, so persisting one between pages never skips another; it can only appear twice (once live, once persisted). If the bookmarked run has left the registry, the next page fails with "Run list changed while paging" instead of guessing. A run started after the listing reached its persisted rows appears in the next fresh listing.
|
|
9
|
+
- One unreadable workflow journal (an incompatible version, a corrupt start line, or a read error) no longer fails the whole `list`. It is listed as one `interrupted_or_uncertain` row with the read error; `inspect` of that ID still reports the error.
|
|
10
|
+
- A workflow launched after its session closed no longer throws with its journal left open. The journal is settled as failed, and the call returns an error result naming the closed session.
|
|
11
|
+
- Workflow resume no longer reveals that another project's run exists: it reports the same "run journal not found" message as an unknown run ID.
|
|
12
|
+
|
|
13
|
+
## [3.1.0-external.0] - 2026-10-02
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
- Versioned Agent receipts (`outputSchema`, `structuredContent`, and accurate `isError`) for native Pi codemode. Scripts must check `ok`; expected failures are data, while host dispatch failures and unexpected exceptions still throw.
|
|
17
|
+
- Registry-backed lifecycle/output/evidence projections, a 16 KiB UTF-8 JSON inline cap, inspect references, and explicit output-redaction warnings. Human renderer details remain separate. Workflow and supervision contracts remain follow-up work.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
- Require Pi `~0.99.2`, with development packages pinned to 0.99.2; older hosts are no longer claimed as supported. Pi children inherit registered providers and runtime API keys through ModelRuntime. Flow retains six concrete thinking levels (not SDK `max`); virtual models are rejected because their registrations cannot be inherited.
|
|
21
|
+
- Port isolated faux-provider and E2E harnesses to Pi 0.99.2. Native codemode regressions cover returned errors, host rejection/throws, redaction, declaration discovery, and background settlement during evidence reads.
|
|
22
|
+
|
|
5
23
|
## [3.0.0-external.0] - 2026-10-01
|
|
6
24
|
|
|
7
25
|
### Breaking: configuration v5 (issue #73)
|
package/README.md
CHANGED
|
@@ -14,7 +14,7 @@ Six built-in roles are available in memory on every one of those harnesses. A fr
|
|
|
14
14
|
|
|
15
15
|
The ordinary driver has four tools (`workflow` can be disabled):
|
|
16
16
|
|
|
17
|
-
- `Agent` resolves and runs one external role.
|
|
17
|
+
- `Agent` resolves and runs one external role. Its [versioned public receipt](docs/agent-public-contract.md) is available to native Pi codemode; scripts must check `ok`.
|
|
18
18
|
- `workflow` orchestrates multiple external roles with trusted JavaScript.
|
|
19
19
|
- `external_help` returns the usage playbook, role details, permission behavior, or workflow guidance on demand.
|
|
20
20
|
- `external_runs` lists, inspects, waits for, and cancels session-owned runs.
|
|
@@ -23,6 +23,8 @@ The ordinary driver has four tools (`workflow` can be disabled):
|
|
|
23
23
|
|
|
24
24
|
## Install
|
|
25
25
|
|
|
26
|
+
Host baseline: **Pi 0.99.2**, with Pi peers restricted to `~0.99.2` (0.99.x patches from 0.99.2). The offline suite is pinned to exactly 0.99.2; older hosts and later minor versions are not claimed as supported.
|
|
27
|
+
|
|
26
28
|
Global installation:
|
|
27
29
|
|
|
28
30
|
```bash
|
|
@@ -370,7 +372,7 @@ Disable a role everywhere with `/external config role disable reviewer`, or add
|
|
|
370
372
|
|
|
371
373
|
### Named Pi harness configurations
|
|
372
374
|
|
|
373
|
-
A named Pi harness runs in-process through Pi's own SDK, for
|
|
375
|
+
A named Pi harness runs in-process through Pi's own SDK, for physical models Pi can already resolve (built-in, self-hosted, or a custom-registered provider). Children inherit in-memory provider registrations and the selected provider's runtime API key; stored authentication uses the same agent directory. Virtual model registrations cannot be transferred through the SDK registry and are rejected. Flow retains six concrete thinking levels; SDK `max` remains unsupported. Register it with **Add a Pi agent…** in `/external`, or with `/external config harness create pi-NAME --model provider/model`: a `pi-<label>` name, a `provider/model` id, a reasoning policy (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, or `parent`, stored explicitly on creation), and a resource preset (`minimal` or `skills`). `minimal` is the default and leaves skills unloaded. `skills` loads installed skills, and project skills only when the project is trusted. Registration is saved into the `harnesses` object of `settings.json` without sending a request. `/external config harness assist` is the model-assisted alternative; its smoke test uses the selected preset and runs before saving. A legacy entry that omits `preset` is `minimal`. The six CLI harnesses are built in and do not need entries there.
|
|
374
376
|
|
|
375
377
|
```json
|
|
376
378
|
{
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Agent public contract v1
|
|
2
|
+
|
|
3
|
+
`Agent` declares `outputSchema` and returns matching `structuredContent`. Human `content` and renderer `details` remain separate. Native Pi codemode receives the structured receipt, including failures: **check `ok`**, not just whether the call threw.
|
|
4
|
+
|
|
5
|
+
```js
|
|
6
|
+
const receipt = await tools.Agent({ role: "worker", description: "Check build", prompt: "Run the build and report." });
|
|
7
|
+
if (!receipt.ok) return receipt.error;
|
|
8
|
+
return receipt.data.run;
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
The envelope carries `contractVersion: 1`, `tool: "Agent"`, `action: "delegate"`, ISO `observedAt`, `ok`, `data: {run}`, and `warnings`. `error: {code,message}` exists only on failure. Unknown fields must be ignored by v1 consumers; removing fields or changing their meaning requires a new contract version. The host uses the schema for declarations, not runtime output validation; tests validate actual receipts.
|
|
12
|
+
|
|
13
|
+
Pre-registration failures have `run:null`. Run receipts expose task identity, registry-confirmed liveness, lifecycle state/outcome, timing, evidence integrity (`complete`, `incomplete`, `damaged`, or `unknown`), output availability/delivery, and inspect references when resolvable. Missing evidence does not turn a settled successful run into an uncertain execution. While a run is live, `incomplete` means evidence has not yet been finalized, not that execution failed. Unreadable evidence reports `unknown` without discarding the child result. Private evidence paths and launch/configuration are not included.
|
|
14
|
+
|
|
15
|
+
Only complete canonical results are inline, within **16 KiB of UTF-8 JSON after secret redaction**. Larger values use references. False, null, zero, and empty strings are valid results. Partial narration is never the canonical value. Both model-visible channels use the existing secret redactor; `warnings` includes `output_redacted` when it changes canonical output. this is best-effort pattern redaction, not a guarantee that arbitrary sensitive prose is identified.
|
|
16
|
+
|
|
17
|
+
Codes include `configuration_invalid`, `harness_unavailable`, `selection_invalid`, `model_unavailable`, `context_invalid`, `session_closed`, and terminal outcomes `failed`, `cancelled`, `timed_out`. Unexpected exceptions remain host errors, as do predispatch schema/permission failures. A content-replacing host redaction hook drops structured content unless it explicitly supplies a replacement.
|
|
18
|
+
|
|
19
|
+
Background `ok:true` means accepted, not completed. Await foreground calls; use `background:true` for work that must outlive a codemode script. Inspect/wait through the existing `external_runs` API; its versioned contract is separate work in #77.
|
|
20
|
+
|
|
21
|
+
Pi 0.99.2 generates a useful discriminated return type (native `describeTool` result: 2941 UTF-8 bytes). Its input declaration currently loses all input properties because Pi renders the existing root `anyOf` constraints before properties; input validation itself remains intact. This is a known input-discovery limitation, not a runtime validation bypass.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tranhoangnguyen0310/pi-flow-external",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.1-external.0",
|
|
4
4
|
"description": "External Claude Code, Codex CLI, Antigravity, Grok Build CLI, Muse Code, and OpenCode delegation for pi.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.ts",
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
"index.ts",
|
|
10
10
|
"src",
|
|
11
11
|
"scripts/field-report.mjs",
|
|
12
|
+
"docs/agent-public-contract.md",
|
|
12
13
|
"docs/ARCHITECTURE_SNAPSHOT.md",
|
|
13
14
|
"docs/field-testing.md",
|
|
14
15
|
"docs/releasing.md",
|
|
@@ -42,17 +43,17 @@
|
|
|
42
43
|
"test": "vitest run"
|
|
43
44
|
},
|
|
44
45
|
"peerDependencies": {
|
|
45
|
-
"@earendil-works/pi-agent-core": "
|
|
46
|
-
"@earendil-works/pi-ai": "
|
|
47
|
-
"@earendil-works/pi-coding-agent": "
|
|
48
|
-
"@earendil-works/pi-tui": "
|
|
46
|
+
"@earendil-works/pi-agent-core": "~0.99.2",
|
|
47
|
+
"@earendil-works/pi-ai": "~0.99.2",
|
|
48
|
+
"@earendil-works/pi-coding-agent": "~0.99.2",
|
|
49
|
+
"@earendil-works/pi-tui": "~0.99.2",
|
|
49
50
|
"typebox": "*"
|
|
50
51
|
},
|
|
51
52
|
"devDependencies": {
|
|
52
|
-
"@earendil-works/pi-agent-core": "0.
|
|
53
|
-
"@earendil-works/pi-ai": "0.
|
|
54
|
-
"@earendil-works/pi-coding-agent": "0.
|
|
55
|
-
"@earendil-works/pi-tui": "0.
|
|
53
|
+
"@earendil-works/pi-agent-core": "0.99.2",
|
|
54
|
+
"@earendil-works/pi-ai": "0.99.2",
|
|
55
|
+
"@earendil-works/pi-coding-agent": "0.99.2",
|
|
56
|
+
"@earendil-works/pi-tui": "0.99.2",
|
|
56
57
|
"@types/node": "^24.12.4",
|
|
57
58
|
"typebox": "1.1.38",
|
|
58
59
|
"typescript": "^5.9.3",
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export const FLOW_ERROR_CODES = ["configuration_invalid", "harness_unavailable", "selection_invalid", "model_unavailable", "context_invalid", "session_closed", "failed", "cancelled", "timed_out"] as const;
|
|
2
|
+
export type FlowErrorCode = (typeof FLOW_ERROR_CODES)[number];
|
|
3
|
+
|
|
4
|
+
/** Expected caller/configuration failure, distinct from a programming error. */
|
|
5
|
+
export class ExpectedFlowError extends Error {
|
|
6
|
+
constructor(readonly code: FlowErrorCode, message: string) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ExpectedFlowError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ExpectedFlowError } from "./errors.ts";
|
|
1
2
|
import { buildSessionContext, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { Type, type Static } from "typebox";
|
|
3
4
|
|
|
@@ -20,12 +21,12 @@ export interface ParentContextReceipt {
|
|
|
20
21
|
|
|
21
22
|
export function parseParentContext(value: unknown): ParentContext | undefined {
|
|
22
23
|
if (value === undefined) return undefined;
|
|
23
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new
|
|
24
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new ExpectedFlowError("context_invalid", "context must be an object");
|
|
24
25
|
const { mode, turns } = value as Record<string, unknown>;
|
|
25
26
|
if (!["none", "recent", "full"].includes(mode as string) ||
|
|
26
27
|
Object.keys(value).some((key) => key !== "mode" && !(mode === "recent" && key === "turns")) ||
|
|
27
28
|
(mode === "recent" && (typeof turns !== "number" || !Number.isSafeInteger(turns) || turns < 1))) {
|
|
28
|
-
throw new
|
|
29
|
+
throw new ExpectedFlowError("context_invalid", "context must be {mode:'none'}, {mode:'recent',turns:positive integer}, or {mode:'full'}");
|
|
29
30
|
}
|
|
30
31
|
return mode === "recent" ? { mode, turns: turns as number } : { mode: mode as "none" | "full" };
|
|
31
32
|
}
|
|
@@ -46,8 +47,8 @@ export function prepareParentContext(
|
|
|
46
47
|
const context = parseParentContext(selection);
|
|
47
48
|
if (!context || context.mode === "none") return { prompt };
|
|
48
49
|
const resumeId = typeof resume === "string" && resume.trim() !== "" ? resume.trim() : undefined;
|
|
49
|
-
if (resumeId !== undefined) throw new
|
|
50
|
-
if (!messages) throw new
|
|
50
|
+
if (resumeId !== undefined) throw new ExpectedFlowError("context_invalid", "context sharing cannot be combined with resume; continue the child or start a new one");
|
|
51
|
+
if (!messages) throw new ExpectedFlowError("context_invalid", "Parent context is unavailable; use context:none and a self-contained prompt");
|
|
51
52
|
const compacted = messages.some((message) => message.role === "compactionSummary");
|
|
52
53
|
let start = 0;
|
|
53
54
|
if (context.mode === "recent") {
|
|
@@ -64,6 +65,8 @@ export function prepareParentContext(
|
|
|
64
65
|
const transcript: unknown[] = [];
|
|
65
66
|
let sharedTurns = 0;
|
|
66
67
|
for (const message of selected) {
|
|
68
|
+
// SDK transcripts include system messages; sharing excludes their content.
|
|
69
|
+
if (message.role === "system") continue;
|
|
67
70
|
if (message.role === "bashExecution") {
|
|
68
71
|
if (!message.excludeFromContext) transcript.push({ role: message.role, command: message.command, output: message.output, exitCode: message.exitCode, truncated: message.truncated });
|
|
69
72
|
continue;
|
|
@@ -73,7 +76,7 @@ export function prepareParentContext(
|
|
|
73
76
|
continue;
|
|
74
77
|
}
|
|
75
78
|
if (message.role !== "user" && message.role !== "assistant" && message.role !== "toolResult" && message.role !== "custom") {
|
|
76
|
-
throw new
|
|
79
|
+
throw new ExpectedFlowError("context_invalid", "Unsupported parent context message role");
|
|
77
80
|
}
|
|
78
81
|
if (message.role === "toolResult" && message.toolCallId === toolCallId) continue;
|
|
79
82
|
if (message.role === "toolResult") {
|
|
@@ -91,7 +94,7 @@ export function prepareParentContext(
|
|
|
91
94
|
if (block.type === "text") blocks.push({ type: "text", text: block.text });
|
|
92
95
|
else if (block.type === "toolCall") {
|
|
93
96
|
if (completed.has(block.id)) blocks.push({ type: "toolCall", id: block.id, name: block.name, arguments: block.arguments });
|
|
94
|
-
} else throw new
|
|
97
|
+
} else throw new ExpectedFlowError("context_invalid", `Unsupported parent context content: ${block.type}; share fewer turns or provide a text briefing`);
|
|
95
98
|
}
|
|
96
99
|
if (blocks.length) transcript.push({ role: message.role, content: blocks,
|
|
97
100
|
...(message.role === "toolResult" ? { toolCallId: message.toolCallId, toolName: message.toolName, isError: message.isError } : {}),
|
|
@@ -101,7 +104,7 @@ export function prepareParentContext(
|
|
|
101
104
|
const text = JSON.stringify(transcript);
|
|
102
105
|
const bytes = Buffer.byteLength(text, "utf8");
|
|
103
106
|
// ponytail: fixed transport ceiling, backend-specific token budgeting if needed.
|
|
104
|
-
if (bytes > 1024 * 1024) throw new
|
|
107
|
+
if (bytes > 1024 * 1024) throw new ExpectedFlowError("context_invalid", "Parent context is too large (over 1 MiB); share fewer turns or provide a text briefing. Nothing was truncated.");
|
|
105
108
|
const receipt: ParentContextReceipt = { mode: context.mode, ...(context.mode === "recent" ? { requestedTurns: context.turns } : {}), sharedTurns, messages: transcript.length, bytes, compacted };
|
|
106
109
|
return {
|
|
107
110
|
context: receipt,
|
package/src/core/run-registry.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ExpectedFlowError } from "./errors.ts";
|
|
1
2
|
export type RegisteredRunKind = "agent" | "workflow";
|
|
2
3
|
export type RegisteredRunState = "running" | "terminal";
|
|
3
4
|
export type RegisteredRunStatus = "done" | "error" | "aborted";
|
|
@@ -82,7 +83,7 @@ export class RunRegistry {
|
|
|
82
83
|
|
|
83
84
|
start<T>(params: StartRegisteredRun<T>): RegisteredRunHandle<T> {
|
|
84
85
|
if (this.closedSessions.has(params.sessionId) || (params.sessionVersion !== undefined && params.sessionVersion !== this.sessionVersion(params.sessionId))) {
|
|
85
|
-
throw new
|
|
86
|
+
throw new ExpectedFlowError("session_closed", `Session ${params.sessionId} is closed; run was not started`);
|
|
86
87
|
}
|
|
87
88
|
if (this.entries.has(params.runId)) {
|
|
88
89
|
throw new Error(`Run is already registered: ${params.runId}`);
|
package/src/core/spawn.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
createAgentSession,
|
|
3
3
|
DefaultResourceLoader,
|
|
4
4
|
getAgentDir,
|
|
5
|
+
ModelRuntime,
|
|
5
6
|
SessionManager,
|
|
6
7
|
SettingsManager,
|
|
7
8
|
type ExtensionContext,
|
|
@@ -925,12 +926,33 @@ async function spawnSubagentRuntime(params: SpawnSubagentRuntimeParams): Promise
|
|
|
925
926
|
// markModified()/save(), so this still never reaches the settings file.
|
|
926
927
|
settingsManager.applyOverrides({ retry: { enabled: false } });
|
|
927
928
|
|
|
929
|
+
// The extension context exposes a registry, not its owning runtime. Rebuild
|
|
930
|
+
// from the same files and carry over in-memory provider/auth registrations.
|
|
931
|
+
if (model.api === "pi-virtual") {
|
|
932
|
+
throw new Error("Named Pi harnesses require a physical model; virtual model registrations cannot be inherited through the SDK registry.");
|
|
933
|
+
}
|
|
934
|
+
const modelRuntime = await ModelRuntime.create({
|
|
935
|
+
authPath: join(agentDir, "auth.json"),
|
|
936
|
+
modelsPath: join(agentDir, "models.json"),
|
|
937
|
+
});
|
|
938
|
+
for (const id of ctx.modelRegistry.getRegisteredProviderIds()) {
|
|
939
|
+
const native = ctx.modelRegistry.getRegisteredNativeProvider(id);
|
|
940
|
+
if (native) modelRuntime.registerNativeProvider(native);
|
|
941
|
+
const config = ctx.modelRegistry.getRegisteredProviderConfig(id);
|
|
942
|
+
if (config) modelRuntime.registerProvider(id, config);
|
|
943
|
+
}
|
|
944
|
+
if (ctx.modelRegistry.getProviderAuthStatus(model.provider).source === "runtime") {
|
|
945
|
+
const key = await ctx.modelRegistry.getApiKeyForProvider(model.provider);
|
|
946
|
+
if (key) await modelRuntime.setRuntimeApiKey(model.provider, key);
|
|
947
|
+
}
|
|
948
|
+
if (signal?.aborted) throw new Error("Subagent aborted before prompt start");
|
|
949
|
+
|
|
928
950
|
({ session } = await createAgentSession({
|
|
929
951
|
cwd,
|
|
930
952
|
agentDir,
|
|
931
953
|
model,
|
|
932
954
|
thinkingLevel: thinkingLevel as NonNullable<Parameters<typeof createAgentSession>[0]>["thinkingLevel"],
|
|
933
|
-
|
|
955
|
+
modelRuntime,
|
|
934
956
|
settingsManager,
|
|
935
957
|
sessionManager: SessionManager.inMemory(cwd),
|
|
936
958
|
resourceLoader,
|
package/src/external-runs.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StringEnum } from "@earendil-works/pi-ai";
|
|
2
|
-
import {
|
|
2
|
+
import { type ExtensionContext, type Theme, type ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { Container, Text } from "@earendil-works/pi-tui";
|
|
4
4
|
import { Type, type Static } from "typebox";
|
|
5
5
|
import { createHash } from "node:crypto";
|
|
@@ -12,7 +12,7 @@ import { renderOutputText } from "./core/subagent-render.ts";
|
|
|
12
12
|
import { SPINNER_INTERVAL_MS } from "./core/spinner.ts";
|
|
13
13
|
import { EXTERNAL_RUNS_PROMPT_SNIPPET } from "./prompts.ts";
|
|
14
14
|
import type { WorkflowToolDetails } from "./types.ts";
|
|
15
|
-
import { getSessionWorkflowDir, listWorkflowJournals, loadWorkflowJournal, type LoadedWorkflowJournal } from "./workflow/journal.ts";
|
|
15
|
+
import { getSessionWorkflowDir, listWorkflowJournals, loadWorkflowJournal, WorkflowJournalReadError, type LoadedWorkflowJournal, type UnreadableWorkflowJournal } from "./workflow/journal.ts";
|
|
16
16
|
|
|
17
17
|
const RUN_ID = /^(?:run|wf)_[A-Za-z0-9_-]{1,128}$/;
|
|
18
18
|
const WORKFLOW_ID = /^wf_[A-Za-z0-9_-]{1,128}$/;
|
|
@@ -219,6 +219,97 @@ function journalSummary(journal: LoadedWorkflowJournal, allChildren = false) {
|
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
/** A listed journal that could not be loaded: one uncertain row instead of a failed listing. */
|
|
223
|
+
function unreadableJournalSummary(journal: UnreadableWorkflowJournal) {
|
|
224
|
+
return {
|
|
225
|
+
...projectWorkflowRun({
|
|
226
|
+
runId: journal.runId,
|
|
227
|
+
live: false,
|
|
228
|
+
status: "interrupted_or_uncertain",
|
|
229
|
+
error: clip(journal.unreadable),
|
|
230
|
+
resultAvailable: false,
|
|
231
|
+
finalAvailable: false,
|
|
232
|
+
}),
|
|
233
|
+
children: [],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Mirrors the listing's own inclusion rule, so a journal is counted persisted exactly when some page lists it. */
|
|
238
|
+
async function hasWorkflowJournal(dir: string, runId: string, project: string): Promise<boolean> {
|
|
239
|
+
try {
|
|
240
|
+
return (await loadWorkflowJournal(dir, runId))?.project === project;
|
|
241
|
+
} catch (error) {
|
|
242
|
+
const owner = error instanceof WorkflowJournalReadError ? error.project : undefined;
|
|
243
|
+
return owner === undefined || owner === project;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function listScope(stream: "runs" | "workflows", sessionId: string, project: string, workflowRunId?: string): string {
|
|
248
|
+
return JSON.stringify({ stream, sessionId, project, workflowRunId });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
type UnpersistedBookmark = { after: string } | { persisted: true };
|
|
252
|
+
|
|
253
|
+
function encodeUnpersistedCursor(scope: string, bookmark: UnpersistedBookmark): string {
|
|
254
|
+
return Buffer.from(JSON.stringify({ v: 1, kind: "unpersisted-list", scope, ...bookmark })).toString("base64url");
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** The bookmark of an unpersisted-stage cursor, or undefined for any other cursor (passed on to the persisted listing). */
|
|
258
|
+
function decodeUnpersistedCursor(value: string, scope: string): UnpersistedBookmark | undefined {
|
|
259
|
+
let parsed: Record<string, unknown> | undefined;
|
|
260
|
+
try {
|
|
261
|
+
parsed = record(JSON.parse(Buffer.from(value, "base64url").toString("utf8")));
|
|
262
|
+
} catch {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
if (parsed?.kind !== "unpersisted-list") return undefined;
|
|
266
|
+
const keys = Object.keys(parsed).sort().join(",");
|
|
267
|
+
const valid = parsed.v === 1 && ((keys === "after,kind,scope,v" && typeof parsed.after === "string" && RUN_ID.test(parsed.after)) || (keys === "kind,persisted,scope,v" && parsed.persisted === true));
|
|
268
|
+
if (!valid) throw new Error("Invalid list cursor");
|
|
269
|
+
if (parsed.scope !== scope) throw new Error("Cursor belongs to a different list scope");
|
|
270
|
+
return typeof parsed.after === "string" ? { after: parsed.after } : { persisted: true };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* One bounded list page: live runs with no persisted row first, then
|
|
275
|
+
* persisted rows, all within `pageSize`. The unpersisted stage bookmarks the
|
|
276
|
+
* last run it served, in registry order. Persisting a run never removes it
|
|
277
|
+
* from the registry, so a later page always resumes after the same run and
|
|
278
|
+
* none is skipped. If the bookmarked run has left the registry, continuation
|
|
279
|
+
* fails as stale rather than guessing. A run persisted between pages may
|
|
280
|
+
* appear in both stages; a run registered after the listing reached its
|
|
281
|
+
* persisted stage appears in the next fresh listing.
|
|
282
|
+
*/
|
|
283
|
+
async function pageUnpersistedFirst<L extends { runId: string }, D>(
|
|
284
|
+
scope: string,
|
|
285
|
+
cursor: string | undefined,
|
|
286
|
+
pageSize: number,
|
|
287
|
+
candidates: L[],
|
|
288
|
+
isUnpersisted: (candidate: L) => Promise<boolean>,
|
|
289
|
+
persisted: (limit: number, cursor: string | undefined) => Promise<{ items: D[]; nextCursor?: string }>,
|
|
290
|
+
): Promise<{ live: L[]; items: D[]; nextCursor?: string }> {
|
|
291
|
+
const bookmark = cursor === undefined ? undefined : decodeUnpersistedCursor(cursor, scope);
|
|
292
|
+
if (cursor !== undefined && bookmark === undefined) return { live: [], ...await persisted(pageSize, cursor) };
|
|
293
|
+
if (bookmark && "persisted" in bookmark) return { live: [], ...await persisted(pageSize, undefined) };
|
|
294
|
+
let start = 0;
|
|
295
|
+
if (bookmark) {
|
|
296
|
+
const index = candidates.findIndex((candidate) => candidate.runId === bookmark.after);
|
|
297
|
+
if (index < 0) throw new Error("Run list changed while paging; restart the listing without a cursor");
|
|
298
|
+
start = index + 1;
|
|
299
|
+
}
|
|
300
|
+
// Find one more than fits, to know whether the unpersisted stage continues.
|
|
301
|
+
const live: L[] = [];
|
|
302
|
+
for (let index = start; index < candidates.length && live.length <= pageSize; index++) {
|
|
303
|
+
if (await isUnpersisted(candidates[index]!)) live.push(candidates[index]!);
|
|
304
|
+
}
|
|
305
|
+
if (live.length > pageSize) {
|
|
306
|
+
const served = live.slice(0, pageSize);
|
|
307
|
+
return { live: served, items: [], nextCursor: encodeUnpersistedCursor(scope, { after: served.at(-1)!.runId }) };
|
|
308
|
+
}
|
|
309
|
+
if (live.length === pageSize) return { live, items: [], nextCursor: encodeUnpersistedCursor(scope, { persisted: true }) };
|
|
310
|
+
return { live, ...await persisted(pageSize - live.length, undefined) };
|
|
311
|
+
}
|
|
312
|
+
|
|
222
313
|
function projectionRevision(text: string): string {
|
|
223
314
|
return createHash("sha256").update(text).digest("hex");
|
|
224
315
|
}
|
|
@@ -655,16 +746,25 @@ function normalizeInspectSelectors(params: ExternalRunsParams): ExternalRunsPara
|
|
|
655
746
|
return { ...params, runId, runIds };
|
|
656
747
|
}
|
|
657
748
|
|
|
749
|
+
// Commands and tools share this executor; it needs no tool-only host capabilities.
|
|
750
|
+
type ExternalRunsTool = Omit<ToolDefinition<typeof externalRunsParameters, ExternalRunsDetails>, "execute"> & {
|
|
751
|
+
execute: (
|
|
752
|
+
toolCallId: string, params: ExternalRunsParams, signal: AbortSignal | undefined,
|
|
753
|
+
onUpdate: Parameters<ToolDefinition<typeof externalRunsParameters, ExternalRunsDetails>["execute"]>[3],
|
|
754
|
+
ctx: ExtensionContext,
|
|
755
|
+
) => ReturnType<ToolDefinition<typeof externalRunsParameters, ExternalRunsDetails>["execute"]>;
|
|
756
|
+
};
|
|
757
|
+
|
|
658
758
|
export function createExternalRunsTool(
|
|
659
759
|
options: CreateExternalRunsToolOptions,
|
|
660
|
-
):
|
|
661
|
-
return
|
|
760
|
+
): ExternalRunsTool {
|
|
761
|
+
return {
|
|
662
762
|
name: "external_runs",
|
|
663
763
|
label: "External Runs",
|
|
664
764
|
description: "List, inspect (single or batched summaries), wait for, or cancel session-owned external runs.",
|
|
665
765
|
promptSnippet: EXTERNAL_RUNS_PROMPT_SNIPPET,
|
|
666
766
|
parameters: externalRunsParameters,
|
|
667
|
-
async execute(_toolCallId, params: ExternalRunsParams, signal, onUpdate, ctx) {
|
|
767
|
+
async execute(_toolCallId, params: ExternalRunsParams, signal, onUpdate, ctx: ExtensionContext) {
|
|
668
768
|
if (params.action === "inspect") {
|
|
669
769
|
params = normalizeInspectSelectors(params);
|
|
670
770
|
if (params.runId === undefined && params.runIds !== undefined && params.runIds.length === 1 && params.view !== undefined && params.view !== "summary") {
|
|
@@ -676,30 +776,44 @@ export function createExternalRunsTool(
|
|
|
676
776
|
|
|
677
777
|
if (params.action === "list") {
|
|
678
778
|
if (params.workflowRunId !== undefined && !WORKFLOW_ID.test(params.workflowRunId)) throw new Error("Invalid workflow run ID");
|
|
679
|
-
const page = params.workflowCursor && !params.cursor
|
|
680
|
-
? { items: [] }
|
|
681
|
-
: await listRunRecords({
|
|
682
|
-
runsDirectory,
|
|
683
|
-
sessionId,
|
|
684
|
-
project,
|
|
685
|
-
workflowRunId: params.workflowRunId,
|
|
686
|
-
limit: params.limit,
|
|
687
|
-
cursor: params.cursor,
|
|
688
|
-
});
|
|
689
779
|
const workflowDir = getSessionWorkflowDir(ctx);
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
780
|
+
// Live state overlays its persisted row on whichever page that row
|
|
781
|
+
// falls, so a live run is never shown as interrupted or listed twice.
|
|
782
|
+
// Live runs with no persisted row yet are paged first, inside the same
|
|
783
|
+
// limit, before the persisted rows begin.
|
|
784
|
+
const registered = new Map(options.registry.list(sessionId, project).map((entry) => [entry.runId, entry]));
|
|
785
|
+
const runScope = listScope("runs", sessionId, project, params.workflowRunId);
|
|
786
|
+
const page = params.workflowCursor && !params.cursor
|
|
787
|
+
? { live: [], items: [] }
|
|
788
|
+
: await pageUnpersistedFirst(runScope, params.cursor, Math.max(1, Math.min(100, Math.floor(params.limit ?? 50))),
|
|
789
|
+
[...registered.values()].filter((entry) => entry.kind === "agent" && (params.workflowRunId === undefined || entry.workflowRunId === params.workflowRunId)),
|
|
790
|
+
async (entry) => {
|
|
791
|
+
const persisted = await getRunRecord(runsDirectory, entry.runId);
|
|
792
|
+
return !persisted || persisted.parentSessionId !== sessionId || persisted.project !== project
|
|
793
|
+
|| (params.workflowRunId !== undefined && persisted.workflowRunId !== params.workflowRunId);
|
|
794
|
+
},
|
|
795
|
+
(limit, cursor) => listRunRecords({ runsDirectory, sessionId, project, workflowRunId: params.workflowRunId, limit, cursor }));
|
|
796
|
+
const historical = params.workflowRunId
|
|
797
|
+
? { live: [], items: [] }
|
|
798
|
+
: await pageUnpersistedFirst(listScope("workflows", sessionId, project), params.workflowCursor, Math.max(1, Math.min(100, Math.floor(params.limit ?? 100))),
|
|
799
|
+
[...registered.values()].filter((entry) => entry.kind === "workflow"),
|
|
800
|
+
async (entry) => !workflowDir || !await hasWorkflowJournal(workflowDir, entry.runId, project),
|
|
801
|
+
async (limit, cursor) => workflowDir ? listWorkflowJournals(workflowDir, project, limit, cursor) : { items: [] });
|
|
802
|
+
const workflows = [
|
|
803
|
+
...historical.live.map((entry) => liveSummary(entry)),
|
|
804
|
+
...historical.items.map((journal) => {
|
|
805
|
+
const live = registered.get(journal.runId);
|
|
806
|
+
if (live?.kind === "workflow") return liveSummary(live);
|
|
807
|
+
return "unreadable" in journal ? unreadableJournalSummary(journal) : journalSummary(journal);
|
|
808
|
+
}),
|
|
809
|
+
];
|
|
810
|
+
const runs = [
|
|
811
|
+
...page.live.map(listedAgent),
|
|
812
|
+
...page.items.map((item) => {
|
|
813
|
+
const live = registered.get(item.runId);
|
|
814
|
+
return live?.kind === "agent" ? listedAgent(live) : historicalAgent(item);
|
|
815
|
+
}),
|
|
816
|
+
];
|
|
703
817
|
const list = { workflows, runs, nextCursor: page.nextCursor, nextWorkflowCursor: historical.nextCursor };
|
|
704
818
|
return result(JSON.stringify(list), list);
|
|
705
819
|
}
|
|
@@ -981,5 +1095,5 @@ export function createExternalRunsTool(
|
|
|
981
1095
|
renderResult(toolResult, { expanded }, theme) {
|
|
982
1096
|
return renderExternalRunsResult(toolResult, theme, expanded);
|
|
983
1097
|
},
|
|
984
|
-
}
|
|
1098
|
+
};
|
|
985
1099
|
}
|
package/src/harnesses.ts
CHANGED
|
@@ -3,24 +3,10 @@ import { loadExternalSettings, saveExternalSettings, externalSettingsPath, DEFAU
|
|
|
3
3
|
import type { ThinkingLevel as SdkThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
4
|
import { PI_RESOURCE_PRESETS, type PiResourcePreset } from "./types.ts";
|
|
5
5
|
|
|
6
|
-
/**
|
|
7
|
-
* The pinned SDK exposes its thinking-level union only as a TypeScript type,
|
|
8
|
-
* not a runtime-exported constant array, so this list is hand-maintained here.
|
|
9
|
-
* The `as const satisfies` + AssertNever guards below make drift a compile
|
|
10
|
-
* error rather than a procedural "re-check at upgrade time" reminder: if the
|
|
11
|
-
* SDK's ThinkingLevel union gains or loses a member, `npm run check` fails
|
|
12
|
-
* until this list is updated.
|
|
13
|
-
*/
|
|
6
|
+
/** Flow deliberately supports six levels; SDK additions do not expand settings. */
|
|
14
7
|
export const VALID_THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh"] as const satisfies readonly SdkThinkingLevel[];
|
|
15
8
|
|
|
16
|
-
|
|
17
|
-
// Both directions must collapse to never: every SDK level is listed (no missing
|
|
18
|
-
// members) and every listed value is a real SDK level (no extras — also
|
|
19
|
-
// enforced by `satisfies`, but asserted symmetrically for clarity).
|
|
20
|
-
type _MissingThinkingLevels = AssertNever<Exclude<SdkThinkingLevel, (typeof VALID_THINKING_LEVELS)[number]>>;
|
|
21
|
-
type _ExtraThinkingLevels = AssertNever<Exclude<(typeof VALID_THINKING_LEVELS)[number], SdkThinkingLevel>>;
|
|
22
|
-
|
|
23
|
-
export function isValidThinkingLevel(value: unknown): value is SdkThinkingLevel {
|
|
9
|
+
export function isValidThinkingLevel(value: unknown): value is (typeof VALID_THINKING_LEVELS)[number] {
|
|
24
10
|
return typeof value === "string" && (VALID_THINKING_LEVELS as readonly string[]).includes(value);
|
|
25
11
|
}
|
|
26
12
|
|
package/src/pi-subagent.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { ExpectedFlowError } from "./core/errors.ts";
|
|
2
|
+
import { agentOutputSchema, agentReceipt, type PublicError } from "./public-contract.ts";
|
|
3
|
+
import { getRunRecord } from "./core/run-inspection.ts";
|
|
4
|
+
import { redactSecrets } from "./core/run-record.ts";
|
|
1
5
|
import {
|
|
2
6
|
defineTool,
|
|
3
7
|
getAgentDir,
|
|
@@ -37,7 +41,7 @@ import { captureParentContext, parentContextSchema, prepareParentContext } from
|
|
|
37
41
|
import { resolvePermission, permissionLabel, resolveEffectivePermissionTier } from "./core/permissions.ts";
|
|
38
42
|
import { pruneRunRecords, runRecordsDirectory } from "./core/retention.ts";
|
|
39
43
|
import { createProgressNode, textResult, type AgentToolResult } from "./core/progress.ts";
|
|
40
|
-
import { RunRegistry } from "./core/run-registry.ts";
|
|
44
|
+
import { RunRegistry, type RegisteredRunEntry } from "./core/run-registry.ts";
|
|
41
45
|
import { formatUsage, renderSubagentNode } from "./core/subagent-render.ts";
|
|
42
46
|
import { SPINNER_INTERVAL_MS } from "./core/spinner.ts";
|
|
43
47
|
import { createWorkflowTool } from "./workflow/tool.ts";
|
|
@@ -359,19 +363,34 @@ function createAgentTool(
|
|
|
359
363
|
description: "Delegate one task to an external Claude Code, Codex CLI, Antigravity, Grok CLI, Muse Code, OpenCode, or registered Pi harness role.",
|
|
360
364
|
promptSnippet: AGENT_PROMPT_SNIPPET,
|
|
361
365
|
parameters: agentToolParameters,
|
|
366
|
+
outputSchema: agentOutputSchema,
|
|
362
367
|
executionMode: "parallel",
|
|
363
368
|
async execute(toolCallId, params, signal, onUpdate, ctx) {
|
|
364
|
-
const resume = typeof params.resume === "string" && params.resume.trim() !== "" ? params.resume.trim() : undefined;
|
|
365
|
-
const briefing = prepareParentContext(params.prompt, params.context,
|
|
366
|
-
params.context && params.context.mode !== "none" ? captureParentContext(ctx.sessionManager) : undefined,
|
|
367
|
-
toolCallId, resume);
|
|
368
369
|
const state = getState();
|
|
370
|
+
const receipt = async (result: AgentToolResult, error?: PublicError, settled?: RegisteredRunEntry) => {
|
|
371
|
+
const details = result.details as SubagentToolDetails;
|
|
372
|
+
const durable = details.runId ? await getRunRecord(runRecordsDirectory(), details.runId).catch(() => undefined) : undefined;
|
|
373
|
+
// Evidence I/O may outlast the child: observe live state only after it.
|
|
374
|
+
const entry = settled ?? (details.runId ? state.registry.get(details.runId) : undefined);
|
|
375
|
+
const structuredContent = agentReceipt({ entry, integrity: durable?.integrity, error, inspectable: !!durable || !!(entry && state.registry.get(entry.runId)) });
|
|
376
|
+
return { ...result, content: redactSecrets(result.content) as typeof result.content, structuredContent, isError: !structuredContent.ok };
|
|
377
|
+
};
|
|
378
|
+
const resume = typeof params.resume === "string" && params.resume.trim() !== "" ? params.resume.trim() : undefined;
|
|
379
|
+
let briefing: ReturnType<typeof prepareParentContext>;
|
|
380
|
+
try {
|
|
381
|
+
briefing = prepareParentContext(params.prompt, params.context,
|
|
382
|
+
params.context && params.context.mode !== "none" ? captureParentContext(ctx.sessionManager) : undefined,
|
|
383
|
+
toolCallId, resume);
|
|
384
|
+
} catch (error) {
|
|
385
|
+
if (!(error instanceof ExpectedFlowError)) throw error;
|
|
386
|
+
return receipt(textResult(error.message, { description: params.description, subagentType: "unknown", status: "error", error: error.message }), error);
|
|
387
|
+
}
|
|
369
388
|
const effectiveState: DelegationState = {
|
|
370
389
|
...state,
|
|
371
390
|
progressEnabled: state.progressEnabled || shouldEnableProgress(ctx),
|
|
372
391
|
};
|
|
373
392
|
const catalog = loadExternalCatalog(getAgentDir());
|
|
374
|
-
if (catalog.blocked) return textResult(catalog.diagnostics.join(" "), { description: params.description, subagentType: "unknown", status: "error", error: catalog.diagnostics.join(" ") });
|
|
393
|
+
if (catalog.blocked) return receipt(textResult(catalog.diagnostics.join(" "), { description: params.description, subagentType: "unknown", status: "error", error: catalog.diagnostics.join(" ") }), { code: "configuration_invalid", message: catalog.diagnostics.join(" ") });
|
|
375
394
|
const allProfiles = catalog.profiles;
|
|
376
395
|
const harnessConfigs = catalog.harnessConfigs;
|
|
377
396
|
const configuredHarnessNames: ReadonlySet<string> = new Set([...EXTERNAL_HARNESSES, ...harnessConfigs.keys()]);
|
|
@@ -388,12 +407,12 @@ function createAgentTool(
|
|
|
388
407
|
const defaultHarness = requestedDefault.harness;
|
|
389
408
|
if (params.role && !params.harness && !configuredHarnessNames.has(defaultHarness)) {
|
|
390
409
|
const error = `Default harness "${defaultHarness}" is not registered (missing from settings.json); pass harness explicitly or recreate it via /external config harness create.`;
|
|
391
|
-
return textResult(error, {
|
|
410
|
+
return receipt(textResult(error, {
|
|
392
411
|
description: params.description,
|
|
393
412
|
subagentType: "unknown",
|
|
394
413
|
status: "error",
|
|
395
414
|
error,
|
|
396
|
-
});
|
|
415
|
+
}), { code: "harness_unavailable", message: error });
|
|
397
416
|
}
|
|
398
417
|
let profile: SubagentProfile;
|
|
399
418
|
try {
|
|
@@ -403,8 +422,9 @@ function createAgentTool(
|
|
|
403
422
|
subagentType: params.subagent_type,
|
|
404
423
|
}, defaultHarness, { configuredHarnessNames, harnessConfigs, disabledHarnesses: catalog.disabledHarnesses });
|
|
405
424
|
} catch (error) {
|
|
406
|
-
|
|
407
|
-
|
|
425
|
+
if (!(error instanceof ExpectedFlowError)) throw error;
|
|
426
|
+
const message = error.message;
|
|
427
|
+
return receipt(textResult(
|
|
408
428
|
message,
|
|
409
429
|
{
|
|
410
430
|
description: params.description,
|
|
@@ -412,7 +432,7 @@ function createAgentTool(
|
|
|
412
432
|
status: "error",
|
|
413
433
|
error: message,
|
|
414
434
|
},
|
|
415
|
-
);
|
|
435
|
+
), { code: error.code, message });
|
|
416
436
|
}
|
|
417
437
|
const subagentType = profile.name;
|
|
418
438
|
profile = resolveExecutionProfile(profile, options.getThinkingLevel(), state.defaultMaxBudgetUsd);
|
|
@@ -420,14 +440,14 @@ function createAgentTool(
|
|
|
420
440
|
const model = resolveProfileModel(profile, ctx);
|
|
421
441
|
if (usesPiBackend(profile) && !model) {
|
|
422
442
|
const error = describeMissingModel(profile, ctx.modelRegistry);
|
|
423
|
-
return textResult(`Cannot launch subagent: ${error}.`, {
|
|
443
|
+
return receipt(textResult(`Cannot launch subagent: ${error}.`, {
|
|
424
444
|
description: params.description,
|
|
425
445
|
subagentType,
|
|
426
446
|
backend: profile.backend,
|
|
427
447
|
harness: selectorHarness(profile),
|
|
428
448
|
status: "error",
|
|
429
449
|
error,
|
|
430
|
-
});
|
|
450
|
+
}), { code: "model_unavailable", message: error });
|
|
431
451
|
}
|
|
432
452
|
|
|
433
453
|
const queuedAt = Date.now();
|
|
@@ -565,7 +585,9 @@ function createAgentTool(
|
|
|
565
585
|
}
|
|
566
586
|
};
|
|
567
587
|
|
|
568
|
-
|
|
588
|
+
let registered;
|
|
589
|
+
try {
|
|
590
|
+
registered = state.registry.start({
|
|
569
591
|
runId: runRecord.runId,
|
|
570
592
|
kind: "agent",
|
|
571
593
|
sessionId,
|
|
@@ -585,8 +607,20 @@ function createAgentTool(
|
|
|
585
607
|
};
|
|
586
608
|
},
|
|
587
609
|
});
|
|
588
|
-
|
|
589
|
-
|
|
610
|
+
} catch (error) {
|
|
611
|
+
if (!(error instanceof ExpectedFlowError)) throw error;
|
|
612
|
+
await runRecord.finish({ status: "error", error: error.message, backendStarted: false });
|
|
613
|
+
return receipt(textResult(error.message, { description: params.description, subagentType, status: "error", error: error.message }), error);
|
|
614
|
+
}
|
|
615
|
+
if (!background) {
|
|
616
|
+
const result = await registered.result;
|
|
617
|
+
const outcome = await registered.terminal;
|
|
618
|
+
return receipt(result, undefined, {
|
|
619
|
+
runId: runRecord.runId, kind: "agent", sessionId, project, state: "terminal",
|
|
620
|
+
observation: (result.details as SubagentToolDetails).progress ?? progress, outcome,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return receipt(textResult(
|
|
590
624
|
`Subagent "${params.description}" (${subagentType}) queued as ${runRecord.runId}. Use external_runs to inspect, wait, or cancel it.`,
|
|
591
625
|
{
|
|
592
626
|
description: params.description,
|
|
@@ -599,7 +633,7 @@ function createAgentTool(
|
|
|
599
633
|
progress,
|
|
600
634
|
backgroundReceipt: true,
|
|
601
635
|
},
|
|
602
|
-
);
|
|
636
|
+
));
|
|
603
637
|
},
|
|
604
638
|
renderCall(args, theme, context) {
|
|
605
639
|
const defaultHarness = options.getDefaultHarness(context.cwd);
|
package/src/profiles.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { ExpectedFlowError } from "./core/errors.ts";
|
|
1
2
|
import { existsSync, readdirSync, readFileSync, lstatSync } from "node:fs";
|
|
2
3
|
import { basename, join } from "node:path";
|
|
3
4
|
import { getAgentDir, parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
@@ -431,7 +432,7 @@ export function reconcilePiProfileWithHarness(
|
|
|
431
432
|
harnessConfigs: ReadonlyMap<string, HarnessConfig>,
|
|
432
433
|
): SubagentProfile {
|
|
433
434
|
const { profile: reconciled, conflict } = computeReconciledPiProfile(profile, harnessConfigs);
|
|
434
|
-
if (conflict) throw new
|
|
435
|
+
if (conflict) throw new ExpectedFlowError("selection_invalid", conflict);
|
|
435
436
|
return reconciled;
|
|
436
437
|
}
|
|
437
438
|
|
|
@@ -522,36 +523,36 @@ export function resolveExternalProfile(
|
|
|
522
523
|
|
|
523
524
|
if (subagentType) {
|
|
524
525
|
if (role || harness) {
|
|
525
|
-
throw new
|
|
526
|
+
throw new ExpectedFlowError("selection_invalid", "Choose either role (with optional harness) or legacy subagent_type; do not combine them.");
|
|
526
527
|
}
|
|
527
528
|
const matches = [...profiles.values()].filter(p => p.configVersion === 5 && p.role && `${selectorHarness(p)}-${p.role}` === subagentType);
|
|
528
|
-
if (!profiles.has(subagentType) && matches.length > 1) throw new
|
|
529
|
+
if (!profiles.has(subagentType) && matches.length > 1) throw new ExpectedFlowError("selection_invalid", `Ambiguous legacy selector "${subagentType}"; use role and harness explicitly.`);
|
|
529
530
|
const profile = profiles.get(subagentType) ?? matches[0];
|
|
530
531
|
if (!profile) {
|
|
531
|
-
throw new
|
|
532
|
+
throw new ExpectedFlowError("selection_invalid", unknownExternalProfileMessage(subagentType, profiles.keys()));
|
|
532
533
|
}
|
|
533
|
-
if (profile.configurationError) throw new
|
|
534
|
+
if (profile.configurationError) throw new ExpectedFlowError("selection_invalid", profile.configurationError);
|
|
534
535
|
return reconcilePiProfileWithHarness(profile, harnessConfigs);
|
|
535
536
|
}
|
|
536
537
|
|
|
537
538
|
if (!role) {
|
|
538
|
-
throw new
|
|
539
|
+
throw new ExpectedFlowError("selection_invalid", harness
|
|
539
540
|
? "role is required when harness is provided; otherwise provide role or legacy subagent_type."
|
|
540
541
|
: "Either role or legacy subagent_type is required.");
|
|
541
542
|
}
|
|
542
543
|
const selectedHarness = harness || defaultHarness;
|
|
543
544
|
if (!configuredHarnessNames.has(selectedHarness)) {
|
|
544
|
-
throw new
|
|
545
|
+
throw new ExpectedFlowError("selection_invalid", `Unknown external harness "${selectedHarness}". Choose one of: ${[...configuredHarnessNames].join(", ") || "none"}.`);
|
|
545
546
|
}
|
|
546
547
|
if ((options.disabledHarnesses ?? DEFAULT_RESOLVE_OPTIONS.disabledHarnesses).has(selectedHarness)) {
|
|
547
|
-
throw new
|
|
548
|
+
throw new ExpectedFlowError("selection_invalid", disabledHarnessMessage(selectedHarness, !harness));
|
|
548
549
|
}
|
|
549
550
|
|
|
550
551
|
const legacy = profiles.get(`${selectedHarness}-${role}`);
|
|
551
552
|
const exact = profiles.get(bindingKey(selectedHarness, role)) ?? (legacy?.configVersion === 5 ? undefined : legacy);
|
|
552
553
|
if (exact) {
|
|
553
|
-
if (exact.configurationError) throw new
|
|
554
|
-
if (selectorHarness(exact) !== selectedHarness || externalProfileRole(exact) !== role) throw new
|
|
554
|
+
if (exact.configurationError) throw new ExpectedFlowError("selection_invalid", exact.configurationError);
|
|
555
|
+
if (selectorHarness(exact) !== selectedHarness || externalProfileRole(exact) !== role) throw new ExpectedFlowError("selection_invalid", `Override "${exact.name}" does not match selected harness "${selectedHarness}".`);
|
|
555
556
|
return reconcilePiProfileWithHarness(exact, harnessConfigs);
|
|
556
557
|
}
|
|
557
558
|
|
|
@@ -566,12 +567,12 @@ export function resolveExternalProfile(
|
|
|
566
567
|
const availability = externalRoleAvailability(profiles);
|
|
567
568
|
const supported = availability.get(role);
|
|
568
569
|
if (supported?.length) {
|
|
569
|
-
throw new
|
|
570
|
+
throw new ExpectedFlowError("selection_invalid",
|
|
570
571
|
`Role "${role}" is unavailable for harness "${selectedHarness}". Supported harnesses for this role: ${supported.join(", ")}. Choose one of those harnesses or add profile "${selectedHarness}-${role}".`,
|
|
571
572
|
);
|
|
572
573
|
}
|
|
573
574
|
const knownRoles = new Set([...availability.keys(), ...(EXTERNAL_HARNESSES.includes(selectedHarness as ExternalHarness) ? [] : defaultRoleNames())]);
|
|
574
|
-
throw new
|
|
575
|
+
throw new ExpectedFlowError("selection_invalid",
|
|
575
576
|
`Unknown external role "${role}". Available roles: ${[...knownRoles].join(", ") || "none"}. Nonstandard profile names must be selected with legacy subagent_type.`,
|
|
576
577
|
);
|
|
577
578
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { StringEnum, type JsonObject } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { projectLiveAgent } from "./core/run-projection.ts";
|
|
4
|
+
import type { RegisteredRunEntry } from "./core/run-registry.ts";
|
|
5
|
+
import { FLOW_ERROR_CODES } from "./core/errors.ts";
|
|
6
|
+
import { redactSecrets } from "./core/run-record.ts";
|
|
7
|
+
|
|
8
|
+
export const INLINE_RESULT_BYTES = 16 * 1024;
|
|
9
|
+
const strings = <T extends string[]>(...values: T) => StringEnum(values);
|
|
10
|
+
const errorSchema = Type.Object({ code: StringEnum(FLOW_ERROR_CODES), message: Type.String() });
|
|
11
|
+
const inspectRef = Type.Object({ tool: Type.Literal("external_runs"), action: Type.Literal("inspect"), runIds: Type.Array(Type.String()), view: strings("summary", "final", "diagnostics") });
|
|
12
|
+
const runSchema = Type.Object({
|
|
13
|
+
runId: Type.String(), kind: Type.Literal("agent"), live: Type.Boolean(),
|
|
14
|
+
task: Type.Object({ description: Type.Optional(Type.String()), profile: Type.Optional(Type.String()), backend: Type.Optional(Type.String()), harness: Type.Optional(Type.String()) }),
|
|
15
|
+
state: Type.Object({ status: strings("queued", "running", "done", "error", "aborted"), outcome: Type.Optional(strings("succeeded", "failed", "cancelled", "timed_out")) }),
|
|
16
|
+
timing: Type.Object({
|
|
17
|
+
queuedAt: Type.Optional(Type.String()), executionStartedAt: Type.Optional(Type.String()),
|
|
18
|
+
processStartedAt: Type.Optional(Type.String()), firstActivityAt: Type.Optional(Type.String()),
|
|
19
|
+
lastActivityAt: Type.Optional(Type.String()), finishedAt: Type.Optional(Type.String()),
|
|
20
|
+
queueDelayMs: Type.Optional(Type.Number()), elapsedMs: Type.Optional(Type.Number()),
|
|
21
|
+
activityAgeMs: Type.Optional(Type.Number()), processDurationMs: Type.Optional(Type.Number()),
|
|
22
|
+
}),
|
|
23
|
+
output: Type.Object({ available: Type.Boolean(), finalAvailable: Type.Boolean(), delivery: strings("inline", "reference", "none"), value: Type.Optional(Type.Unknown()) }),
|
|
24
|
+
evidence: Type.Object({ integrity: strings("complete", "incomplete", "damaged", "unknown") }),
|
|
25
|
+
refs: Type.Optional(Type.Object({ summary: inspectRef, final: inspectRef, diagnostics: inspectRef })),
|
|
26
|
+
});
|
|
27
|
+
const common = {
|
|
28
|
+
contractVersion: Type.Literal(1), tool: Type.Literal("Agent"), action: Type.Literal("delegate"),
|
|
29
|
+
observedAt: Type.String(), data: Type.Object({ run: Type.Union([Type.Null(), runSchema]) }),
|
|
30
|
+
warnings: Type.Array(Type.String()),
|
|
31
|
+
};
|
|
32
|
+
export const agentOutputSchema = Type.Union([
|
|
33
|
+
Type.Object({ ...common, ok: Type.Literal(true), error: Type.Optional(Type.Never()) }),
|
|
34
|
+
Type.Object({ ...common, ok: Type.Literal(false), error: errorSchema }),
|
|
35
|
+
]);
|
|
36
|
+
export type AgentReceipt = Static<typeof agentOutputSchema>;
|
|
37
|
+
export type PublicError = Static<typeof errorSchema>;
|
|
38
|
+
|
|
39
|
+
/** Only registry-confirmed entries may supply a public handle. Never infer liveness from disk. */
|
|
40
|
+
export function agentReceipt({ entry, integrity = "unknown", error, inspectable = true }: {
|
|
41
|
+
entry?: RegisteredRunEntry;
|
|
42
|
+
integrity?: "complete" | "incomplete" | "damaged" | "unknown";
|
|
43
|
+
error?: PublicError;
|
|
44
|
+
inspectable?: boolean;
|
|
45
|
+
}): AgentReceipt & JsonObject {
|
|
46
|
+
const warnings: string[] = [];
|
|
47
|
+
let run: AgentReceipt["data"]["run"] = null;
|
|
48
|
+
if (entry) {
|
|
49
|
+
const projected = projectLiveAgent(entry);
|
|
50
|
+
const value = entry.outcome?.result;
|
|
51
|
+
const finalAvailable = projected.output.finalAvailable;
|
|
52
|
+
let encoded: string | undefined;
|
|
53
|
+
try { encoded = finalAvailable ? JSON.stringify(value) : undefined; } catch { /* Non-JSON values are never presented as complete. */ }
|
|
54
|
+
const redacted = encoded === undefined ? undefined : redactSecrets(JSON.parse(encoded));
|
|
55
|
+
const original = encoded;
|
|
56
|
+
encoded = encoded === undefined ? undefined : JSON.stringify(redacted);
|
|
57
|
+
if (encoded !== original) warnings.push("output_redacted");
|
|
58
|
+
const inline = encoded !== undefined && Buffer.byteLength(encoded, "utf8") <= INLINE_RESULT_BYTES;
|
|
59
|
+
const ref = (view: "summary" | "final" | "diagnostics") => ({ tool: "external_runs" as const, action: "inspect" as const, runIds: [entry.runId], view });
|
|
60
|
+
run = {
|
|
61
|
+
runId: entry.runId, kind: "agent", live: projected.live, task: projected.task,
|
|
62
|
+
state: { status: projected.state.status as NonNullable<AgentReceipt["data"]["run"]>["state"]["status"], ...(entry.outcome ? { outcome: entry.outcome.outcome } : {}) },
|
|
63
|
+
timing: projected.timing,
|
|
64
|
+
output: { available: projected.output.available, finalAvailable, delivery: inline ? "inline" : finalAvailable && inspectable ? "reference" : "none", ...(inline ? { value: redacted } : {}) },
|
|
65
|
+
evidence: { integrity }, ...(inspectable ? { refs: { summary: ref("summary"), final: ref("final"), diagnostics: ref("diagnostics") } } : {}),
|
|
66
|
+
};
|
|
67
|
+
if (!error && entry.outcome && entry.outcome.status !== "done") {
|
|
68
|
+
error = { code: entry.outcome.outcome === "succeeded" ? "failed" : entry.outcome.outcome, message: entry.outcome.error ?? "Child execution failed" };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const base = { contractVersion: 1 as const, tool: "Agent" as const, action: "delegate" as const, observedAt: new Date().toISOString(), data: { run }, warnings };
|
|
72
|
+
return JSON.parse(JSON.stringify(redactSecrets(error ? { ...base, ok: false, error: { code: error.code, message: error.message } } : { ...base, ok: true }))) as AgentReceipt & JsonObject;
|
|
73
|
+
}
|
package/src/workflow/journal.ts
CHANGED
|
@@ -41,6 +41,14 @@ export interface LoadedWorkflowJournal {
|
|
|
41
41
|
children: Array<{ index: number; runId?: string; label?: string; status: "queued" | "done" | "error" | "aborted"; outcome?: ChildRunOutcome; error?: unknown }>;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** A journal that exists but cannot be loaded; `project` is set when its own run_start line names one. */
|
|
45
|
+
export class WorkflowJournalReadError extends Error {
|
|
46
|
+
constructor(message: string, readonly project?: string) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.name = "WorkflowJournalReadError";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
export interface WorkflowJournalWriter {
|
|
45
53
|
runId: string;
|
|
46
54
|
path: string;
|
|
@@ -128,8 +136,9 @@ export async function loadWorkflowJournal(dir: string, runId: string): Promise<L
|
|
|
128
136
|
}
|
|
129
137
|
if (entry.type === "run_start") {
|
|
130
138
|
if (entry.version !== JOURNAL_VERSION || entry.apiVersion !== WORKFLOW_API_VERSION) {
|
|
131
|
-
throw new
|
|
139
|
+
throw new WorkflowJournalReadError(
|
|
132
140
|
`Workflow journal ${path} uses an incompatible API contract; recompose with meta.apiVersion: ${WORKFLOW_API_VERSION}. No children were launched`,
|
|
141
|
+
typeof entry.project === "string" ? entry.project : undefined,
|
|
133
142
|
);
|
|
134
143
|
}
|
|
135
144
|
seenRunStart = entry.runId === runId;
|
|
@@ -190,13 +199,19 @@ export async function loadWorkflowJournal(dir: string, runId: string): Promise<L
|
|
|
190
199
|
}
|
|
191
200
|
|
|
192
201
|
if (!seenRunStart) {
|
|
193
|
-
throw new
|
|
202
|
+
throw new WorkflowJournalReadError(`Workflow journal ${path} does not match run id ${runId}`, project);
|
|
194
203
|
}
|
|
195
204
|
return { launch, runId, path, agentResults, name, source, project, status, outcome, result, error: terminalError, children: children.filter(Boolean) };
|
|
196
205
|
}
|
|
197
206
|
|
|
207
|
+
/** A listed journal that failed to load: shown as its own row instead of failing the whole listing. */
|
|
208
|
+
export interface UnreadableWorkflowJournal {
|
|
209
|
+
runId: string;
|
|
210
|
+
unreadable: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
198
213
|
export interface WorkflowJournalPage {
|
|
199
|
-
items: LoadedWorkflowJournal
|
|
214
|
+
items: Array<LoadedWorkflowJournal | UnreadableWorkflowJournal>;
|
|
200
215
|
nextCursor?: string;
|
|
201
216
|
}
|
|
202
217
|
|
|
@@ -228,12 +243,19 @@ export async function listWorkflowJournals(dir: string, project: string, limit =
|
|
|
228
243
|
}
|
|
229
244
|
const start = workflowListStart(names, cursor);
|
|
230
245
|
const pageLimit = Math.max(1, Math.min(100, limit));
|
|
231
|
-
const journals:
|
|
246
|
+
const journals: WorkflowJournalPage["items"] = [];
|
|
232
247
|
let index = start;
|
|
233
248
|
for (; index < names.length && index < start + 200; index++) {
|
|
234
249
|
const name = names[index]!;
|
|
235
|
-
const
|
|
236
|
-
|
|
250
|
+
const runId = name.slice(4, -6);
|
|
251
|
+
try {
|
|
252
|
+
const journal = await loadWorkflowJournal(dir, runId);
|
|
253
|
+
if (journal?.project === project) journals.push(journal);
|
|
254
|
+
} catch (error) {
|
|
255
|
+
// The directory is this session's own; a journal whose project cannot be read is still listed so it is never silently dropped.
|
|
256
|
+
const owner = error instanceof WorkflowJournalReadError ? error.project : undefined;
|
|
257
|
+
if (owner === undefined || owner === project) journals.push({ runId, unreadable: error instanceof Error ? error.message : String(error) });
|
|
258
|
+
}
|
|
237
259
|
if (journals.length >= pageLimit) {
|
|
238
260
|
index++;
|
|
239
261
|
break;
|
package/src/workflow/source.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createWorkflowJournalWriter,
|
|
7
7
|
createWorkflowRunIdentity,
|
|
8
8
|
getSessionWorkflowDir,
|
|
9
|
+
WorkflowJournalReadError,
|
|
9
10
|
loadWorkflowJournal,
|
|
10
11
|
persistWorkflowScript,
|
|
11
12
|
type WorkflowJournalWriter,
|
|
@@ -270,19 +271,23 @@ export async function prepareWorkflowToolSource(
|
|
|
270
271
|
try {
|
|
271
272
|
journal = await loadWorkflowJournal(sessionWorkflowDir, resumeFromRunId);
|
|
272
273
|
} catch (error) {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
274
|
+
// Another project's unreadable journal is reported exactly like a missing one.
|
|
275
|
+
if (!(error instanceof WorkflowJournalReadError) || error.project === undefined || error.project === project) {
|
|
276
|
+
const message = `Cannot resume workflow: ${error instanceof Error ? error.message : String(error)}`;
|
|
277
|
+
return sourceError(message, {
|
|
278
|
+
name: metaName,
|
|
279
|
+
error: message,
|
|
280
|
+
logs: source.warnings,
|
|
281
|
+
source: source.source,
|
|
282
|
+
sourcePath: source.sourcePath,
|
|
283
|
+
scriptPath,
|
|
284
|
+
runId: identity.runId,
|
|
285
|
+
resumeFromRunId,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
284
288
|
}
|
|
285
|
-
|
|
289
|
+
// Another project's run is indistinguishable from an unknown one.
|
|
290
|
+
if (!journal || journal.project !== project) {
|
|
286
291
|
const message = `Cannot resume workflow: run journal not found for ${resumeFromRunId}.`;
|
|
287
292
|
return sourceError(message, {
|
|
288
293
|
name: metaName,
|
|
@@ -295,19 +300,6 @@ export async function prepareWorkflowToolSource(
|
|
|
295
300
|
resumeFromRunId,
|
|
296
301
|
});
|
|
297
302
|
}
|
|
298
|
-
if (journal.project !== project) {
|
|
299
|
-
const message = `Cannot resume workflow: ${resumeFromRunId} belongs to a different project. No children were launched.`;
|
|
300
|
-
return sourceError(message, {
|
|
301
|
-
name: metaName,
|
|
302
|
-
error: message,
|
|
303
|
-
logs: source.warnings,
|
|
304
|
-
source: source.source,
|
|
305
|
-
sourcePath: source.sourcePath,
|
|
306
|
-
scriptPath,
|
|
307
|
-
runId: identity.runId,
|
|
308
|
-
resumeFromRunId,
|
|
309
|
-
});
|
|
310
|
-
}
|
|
311
303
|
resumeAgentResults = journal.agentResults;
|
|
312
304
|
}
|
|
313
305
|
|
package/src/workflow/tool.ts
CHANGED
|
@@ -18,7 +18,8 @@ import { CHILD_EXCLUDED_TOOLS, spawnSubagent } from "../core/spawn.ts";
|
|
|
18
18
|
import { createRunRecord } from "../core/run-record.ts";
|
|
19
19
|
import { runRecordsDirectory } from "../core/retention.ts";
|
|
20
20
|
import { captureParentContext } from "../core/parent-context.ts";
|
|
21
|
-
import { RunRegistry } from "../core/run-registry.ts";
|
|
21
|
+
import { RunRegistry, type RegisteredRunHandle } from "../core/run-registry.ts";
|
|
22
|
+
import { ExpectedFlowError } from "../core/errors.ts";
|
|
22
23
|
import { OUTPUT_PREVIEW_CHARS } from "../core/progress.ts";
|
|
23
24
|
import { loadExternalCatalog, resolveExternalProfile, selectorHarness, unknownExternalProfileMessage } from "../profiles.ts";
|
|
24
25
|
import { resolveExecutionProfile } from '../execution-config.ts';
|
|
@@ -585,23 +586,33 @@ export function createWorkflowTool(
|
|
|
585
586
|
}
|
|
586
587
|
};
|
|
587
588
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
outcome:
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
? {
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
589
|
+
let registered: RegisteredRunHandle<ReturnType<typeof workflowResult>>;
|
|
590
|
+
try {
|
|
591
|
+
registered = options.registry.start({
|
|
592
|
+
runId: identity.runId,
|
|
593
|
+
kind: "workflow",
|
|
594
|
+
sessionId,
|
|
595
|
+
sessionVersion,
|
|
596
|
+
project,
|
|
597
|
+
...(background ? {} : { signal }),
|
|
598
|
+
run: executeWorkflow,
|
|
599
|
+
outcome: (result, workflowSignal) => ({
|
|
600
|
+
status: result.details.status === "completed" ? "done" : result.details.status === "aborted" ? "aborted" : "error",
|
|
601
|
+
outcome: result.details.outcome ?? (result.details.status === "completed" ? "succeeded" : result.details.status === "aborted" ? "cancelled" : "failed"),
|
|
602
|
+
...(result.details.result !== undefined ? { result: result.details.result } : {}),
|
|
603
|
+
...(workflowSignal.aborted && workflowSignal.reason !== undefined
|
|
604
|
+
? { error: workflowSignal.reason instanceof Error ? workflowSignal.reason.message : String(workflowSignal.reason) }
|
|
605
|
+
: result.details.error ? { error: result.details.error } : {}),
|
|
606
|
+
}),
|
|
607
|
+
});
|
|
608
|
+
} catch (error) {
|
|
609
|
+
// The run_start journal line is already written; settle it so the
|
|
610
|
+
// never-started run does not read as interrupted forever.
|
|
611
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
612
|
+
await journalWriter?.fail(message).catch(() => undefined);
|
|
613
|
+
if (!(error instanceof ExpectedFlowError)) throw error;
|
|
614
|
+
return workflowError(`Workflow "${metaName}" was not started: ${message}`, { name: metaName, error: message, status: "error", outcome: "failed", runId: identity.runId, journalPath: journalWriter?.path });
|
|
615
|
+
}
|
|
605
616
|
emit();
|
|
606
617
|
if (!background) return await registered.result;
|
|
607
618
|
return workflowResult(
|