@tranhoangnguyen0310/pi-flow-external 3.1.0-external.0 → 3.2.0-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 -2
- package/CHANGELOG.md +47 -0
- package/README.md +7 -5
- package/docs/public-contract.md +110 -0
- package/package.json +2 -2
- package/src/contract/agent.ts +29 -0
- package/src/contract/envelope.ts +97 -0
- package/src/contract/run.ts +157 -0
- package/src/contract/runs.ts +34 -0
- package/src/contract/workflow.ts +31 -0
- package/src/core/errors.ts +6 -1
- package/src/core/run-inspection.ts +92 -57
- package/src/external-command.ts +71 -28
- package/src/external-runs.ts +384 -170
- package/src/pi-subagent.ts +2 -1
- package/src/types.ts +2 -0
- package/src/workflow/journal.ts +63 -18
- package/src/workflow/registry.ts +10 -7
- package/src/workflow/runtime.ts +2 -0
- package/src/workflow/source.ts +32 -37
- package/src/workflow/tool.ts +75 -29
- package/src/workflow/types.ts +6 -0
- package/docs/agent-public-contract.md +0 -21
- package/src/public-contract.ts +0 -73
package/AGENTS.md
CHANGED
|
@@ -43,7 +43,7 @@ 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
|
|
46
|
+
- Public contract v1 (`Agent`, `workflow`, every `external_runs` action; `src/contract/`) uses one discriminated `ok` envelope with `isError === !ok`; `ok` describes the operation, so inspecting or waiting on a failed run is `ok`. Renderer details remain independent. Expected errors are coded at their source and returned, not thrown or parsed from prose; `/external runs` reads the same structured contract a script reads. A workflow root that handled child failures stays `ok` with `children.failed` counted at the single non-fatal delivery site (never from snapshot rows) and a `handled_child_failures` warning. Supervision text is redacted at its source before it is measured or sliced; never redact an already-cut page. 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
47
|
|
|
48
48
|
- Success requires a recognized backend terminal-success event, a zero process exit, and a non-empty result.
|
|
49
49
|
- A backend failure with a complete local record is different from an incomplete or damaged record; preserve that distinction in reports.
|
|
@@ -51,7 +51,7 @@ This fork changes the original pi-flow contract: `Agent` is not a generic Pi sub
|
|
|
51
51
|
- Settings retention automatically prunes eligible completed records beyond `maxRunRecords`; active, interrupted, incomplete, and damaged records are not eligible.
|
|
52
52
|
- Structured nested-agent activity may extend the wall-clock deadline once, by one fresh base timeout, capped at twice the original deadline.
|
|
53
53
|
- Do not automatically retry failed or aborted external runs. Preserve the receipt and retry only when the user asks. Exception: the agy backend retries once on infrastructure-classified failures (auth, eligibility, network); the retry is disclosed in the receipt details (`retries`, `retryOf`) and never applies to agent-level failures, aborts, or timeouts.
|
|
54
|
-
- `external_runs` is session/project scoped. `list` pages runs and workflow roots, each carrying a shared timing projection (`queuedAt`/`executionStartedAt`/`processStartedAt`/activity timestamps plus derived `queueDelayMs`/`elapsedMs`/`activityAgeMs`/`processDurationMs`); `activityAgeMs` and a running `elapsedMs` are computed only for a target independently confirmed live by the registry, never inferred from a durable record's status field alone (an unowned "running"-looking record may be an orphaned/crashed run, not a live one). `inspect` pages `summary`/`output`/`diagnostics`/`final` for one `runId`; `final` returns only the same canonical terminal result every other view already reads (no separate backend parser, no narration promoted to final) and is empty with `finalAvailable: false` until a verified successful terminal boundary exists — including for workflows, where an intentional JSON `null` result still counts as available. `inspect` also accepts a bounded batch `runIds` (up to 20, deduplicated, order preserved, summary-only, mutually exclusive with `runId`): every requested ID's ownership is validated before any page returns, entries share one consistent nested shape regardless of live/durable/agent/workflow origin (a workflow entry omits its unbounded children array in favor of `outputRef`/`diagnosticsRef`), reuses the existing per-inspect `limitBytes` cap with no second budget
|
|
54
|
+
- `external_runs` is session/project scoped. `list` pages runs and workflow roots, each carrying a shared timing projection (`queuedAt`/`executionStartedAt`/`processStartedAt`/activity timestamps plus derived `queueDelayMs`/`elapsedMs`/`activityAgeMs`/`processDurationMs`); `activityAgeMs` and a running `elapsedMs` are computed only for a target independently confirmed live by the registry, never inferred from a durable record's status field alone (an unowned "running"-looking record may be an orphaned/crashed run, not a live one). `inspect` pages `summary`/`output`/`diagnostics`/`final` for one `runId`; `final` returns only the same canonical terminal result every other view already reads (no separate backend parser, no narration promoted to final) and is empty with `finalAvailable: false` until a verified successful terminal boundary exists — including for workflows, where an intentional JSON `null` result still counts as available. `inspect` also accepts a bounded batch `runIds` (up to 20, deduplicated, order preserved, summary-only, mutually exclusive with `runId`): every requested ID's ownership is validated before any page returns, entries share one consistent nested shape regardless of live/durable/agent/workflow origin (a workflow entry omits its unbounded children array in favor of `outputRef`/`diagnosticsRef`), reuses the existing per-inspect `limitBytes` cap with no second budget (an entry is served only when both the legacy text and the full structured envelope fit), and returns `page_too_small` naming the run rather than ever silently dropping a target or serving a page over the caller's own byte budget. `wait` returns outcomes only for the selected one/any/all target set and never cancels pending work; while waiting it reports bounded live progress (watched targets, completed/pending counts, recent activity) through the tool's own update channel on a fixed heartbeat, stopped on settlement/error/interruption, that never mutates run state or affects cancellation; each settled outcome's `result` is spent from one shared byte budget (`limitBytes`, default 32768) across the whole response in the caller's requested order, never settlement race order — complete when it fits, `resultTruncated: true` plus the existing `outputRef`/`diagnosticsRef` when it does not, never a fixed-length teaser regardless of size; a target's evidence is never read from disk once that shared budget is already exhausted. The structured `completed[i].output.value` is inline only from memory when the legacy text was delivered whole and its encoded JSON fits both 16 KiB and what remains of the structured channel's own copy of `limitBytes` (spent in the same request order); otherwise it is delivered by reference (always for durable targets), with no extra read. An agent `final` view serves a settled successful registry result from memory, so a promised `refs.final` resolves even without persisted evidence. Output/diagnostics pages concatenate losslessly (a `\n` separates each new message); inspection cursors from the previous page format fail as `cursor_stale`. `cancel` targets one stable ID and preserves its reason. An unresolvable `wf_...` ID fails as unknown/unavailable across `inspect`/`cancel`/`wait`/batch `inspect`, never falling through to the agent-only durable reader. No routine run event sends a parent message or notification.
|
|
55
55
|
- `list`, `inspect`, and `wait` share one run projection (`src/core/run-projection.ts`: `projectLiveAgent`/`projectDurableAgent` for agents, `projectWorkflowRun` for workflows) instead of independent per-surface field extraction. Task identity includes the resolved profile and an explicit `harness` (registered pi-* config, or equal to backend for agy/claude/codex/grok/muse/opencode), persisted at the source (run-record metadata, live progress nodes, workflow child snapshots) by every backend — never reparsed or guessed from a profile/subagentType name.
|
|
56
56
|
|
|
57
57
|
## Workflow contract
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,53 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to pi-flow external are documented here.
|
|
4
4
|
|
|
5
|
+
## [3.2.0-external.0] - 2026-10-02
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- Versioned public contracts for `workflow` and every `external_runs` action (issue #77), sharing the `Agent` envelope from 3.1.0: `outputSchema`, matching `structuredContent`, and `isError === !ok`. `ok` describes the operation, so inspecting or waiting on a failed run is `ok: true`. See [docs/public-contract.md](docs/public-contract.md), which replaces `docs/agent-public-contract.md`.
|
|
9
|
+
- `external_runs` payloads:
|
|
10
|
+
|
|
11
|
+
| Action | `data` |
|
|
12
|
+
|---|---|
|
|
13
|
+
| `list` | `{runs, workflows, nextCursor?, nextWorkflowCursor?}` |
|
|
14
|
+
| batch `inspect` | `{mode: "batch", entries, nextCursor?}` |
|
|
15
|
+
| single `inspect` | `{mode: "single", runId, view, page: {text, encoding, complete, nextCursor?}, finalAvailable?, outputStatus?}` |
|
|
16
|
+
| `wait` | `{mode, completed, pending}` |
|
|
17
|
+
| `cancel` | `{runId, status}` |
|
|
18
|
+
|
|
19
|
+
Every row is one `PublicRun` shape, with evidence integrity and callable `refs`. In `wait`, a structured value is inline only when it is in memory, its text result was delivered whole, and its encoded JSON fits both 16 KiB and what remains of the structured channel's own copy of `limitBytes`, spent in request order. Otherwise it is delivered by reference.
|
|
20
|
+
- An agent's `final` view now serves a settled successful result still held in the registry, so a `refs.final` reference resolves even when the run's evidence was never saved.
|
|
21
|
+
- Workflow receipts. A foreground root that failed, was cancelled or timed out is `ok: false` and keeps its run. A background launch is `ok: true` once accepted. A root that succeeded after catching child failures stays `ok: true`, reports `run.children.failed`, and adds the warning `handled_child_failures` and a sentence in its text result. Failures are counted where the script receives them, including calls that fail before a child is indexed; always-fatal failures are excluded. Journals record the count (`childFailures`) and report integrity: a torn last line is `incomplete`, a bad line mid-file is `damaged`.
|
|
22
|
+
- New error codes: `request_invalid`, `run_unavailable`, `run_not_live`, `cursor_invalid`, `cursor_stale`, `page_too_small`, `session_unavailable`, `script_invalid`, `storage_unavailable`.
|
|
23
|
+
|
|
24
|
+
### Changed (compatibility)
|
|
25
|
+
- **Expected `external_runs` and `workflow` failures are returned, not thrown.** Messages are unchanged except where noted. Direct callers and scripts that relied on a rejection must check `ok` or `isError`. Interrupted waits, unexpected exceptions, schema validation and `tool_call` blocks still throw.
|
|
26
|
+
|
|
27
|
+
| Previously thrown message | Now returned with code |
|
|
28
|
+
|---|---|
|
|
29
|
+
| "Invalid run ID"; "Invalid workflow run ID"; "inspect accepts either runId or runIds, not both"; "cancel accepts either runId or runIds, not both"; "cancel targets one run at a time"; "inspect runIds requires 1-20 run IDs"; "wait requires 1-100 run IDs"; 'Batch inspect (runIds) only supports view: "summary"'; "limitBytes must be finite" | `request_invalid` |
|
|
30
|
+
| "Run is unknown or unavailable in this session" | `run_unavailable` |
|
|
31
|
+
| "Run is no longer live in this session; cancellation cannot be confirmed"; "Run … is unavailable for live waiting" | `run_not_live` |
|
|
32
|
+
| "Invalid cursor"; "Cursor belongs to a different run/view/list scope"; "Batch cursor does not match the requested run IDs…"; a batch cursor from another session (message is now "Cursor belongs to a different session or project") | `cursor_invalid` |
|
|
33
|
+
| "Run changed while paging; restart inspection without a cursor"; "Cursor points past available evidence"/"outside available workflow content" (message is now the "Run changed while paging" text) | `cursor_stale` |
|
|
34
|
+
| "Batch summary for … does not fit within limitBytes…" | `page_too_small` |
|
|
35
|
+
| "external_runs requires a persisted originating session" | `session_unavailable` |
|
|
36
|
+
|
|
37
|
+
- Failed workflow rows now set `isError`, so they render red like failed `Agent` calls.
|
|
38
|
+
- **Output and diagnostics pages concatenate losslessly.** Each new message after the first carries a leading `\n` separator inside the page text and items; agy/muse streaming deltas still join into one message. Previously, two messages split across a page boundary ran together. Byte offsets shift, so inspection cursors issued by earlier versions fail with `cursor_stale`; restart without a cursor.
|
|
39
|
+
- Batch `inspect` serves an entry only when it fits `limitBytes` in both the text and the structured result, so text pages may hold fewer entries than before.
|
|
40
|
+
- Live output pages, in-memory wait results and workflow views (from memory or the journal) are now redacted in full before they are measured or sliced. Previously they were served unredacted.
|
|
41
|
+
- `/external runs` reads the structured contract. Stale cursors restart from page 1 by code, now including paged summaries. Other failures show a notice and keep the navigator open instead of closing it.
|
|
42
|
+
- An unreadable workflow journal in this project is reported as `run_unavailable` with its read error; another project's journal is reported like an unknown run.
|
|
43
|
+
|
|
44
|
+
## [3.1.1-external.0] - 2026-10-02
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
- `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.
|
|
48
|
+
- 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.
|
|
49
|
+
- 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.
|
|
50
|
+
- 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.
|
|
51
|
+
|
|
5
52
|
## [3.1.0-external.0] - 2026-10-02
|
|
6
53
|
|
|
7
54
|
### Added
|
package/README.md
CHANGED
|
@@ -14,11 +14,13 @@ 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.
|
|
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.
|
|
21
21
|
|
|
22
|
+
`Agent`, `workflow` and `external_runs` return a [versioned public contract](docs/public-contract.md) to native Pi codemode. Expected failures are returned with a typed `error.code` rather than thrown, so scripts must check `ok`.
|
|
23
|
+
|
|
22
24
|
`Agent` and `workflow` accept a role with an optional harness — `agy`, `claude`, `codex`, `grok`, `muse`, `opencode`, or a registered `pi-*` name. A registered Pi harness uses that same selection. Legacy exact `subagent_type` remains available and cannot be combined with `role` or `harness`. `pi_flow_role_create` is active only during `/external config role assist`. `pi_flow_harness_create` is active only during `/external config harness assist`. Worked calls are `external_help` topic `usage`.
|
|
23
25
|
|
|
24
26
|
## Install
|
|
@@ -535,9 +537,9 @@ Agent({
|
|
|
535
537
|
|
|
536
538
|
Use `external_runs` with these actions:
|
|
537
539
|
|
|
538
|
-
- `list`: current session/project runs; use `cursor` for run pages and `workflowCursor` for workflow pages. `workflowRunId` filters children of one workflow. Rows carry a `timing` projection (`queueDelayMs`, `elapsedMs`, `activityAgeMs` when live, `processDurationMs`) plus `outputAvailable`/`finalAvailable`.
|
|
539
|
-
- `inspect`: single-entry `runIds` with `view: "summary" | "output" | "diagnostics" | "final"`, and optional opaque `cursor`/`limitBytes` (max 64 KiB, same cap for every view). Follow `nextCursor` to avoid truncation. `summary` includes the same `timing` projection as `list`, plus `output.finalAvailable`. `final` returns only the verified canonical terminal answer — empty with `finalAvailable: false` until a successful terminal boundary exists; it never promotes partial/narration text. `output` stays the combined stream (assistant messages plus canonical result) and is
|
|
540
|
-
- `inspect` with `runIds` (summary view) (up to 20, deduplicated, order preserved): a single bounded batch of `summary`-only projections — one cheap request to see whether several selected background children are queued, running, or terminal, each with `outputRef`/`diagnosticsRef` for follow-up detail. Ownership of every requested ID is validated before any page is returned. Reuses the same `limitBytes` cap as single-run inspection; pages contain whole target entries and continue through `nextCursor`, without invalidation from ordinary live progress. If one compact entry cannot fit,
|
|
540
|
+
- `list`: current session/project runs; use `cursor` for run pages and `workflowCursor` for workflow pages. `workflowRunId` filters children of one workflow. Rows carry a `timing` projection (`queueDelayMs`, `elapsedMs`, `activityAgeMs` when live, `processDurationMs`) plus `outputAvailable`/`finalAvailable`. A live run shows live state on the page where its record falls; live runs with no record yet come first and count against `limit`. `nextCursor` means the scan stopped early, not that more rows exist, so a next page can be empty.
|
|
541
|
+
- `inspect`: single-entry `runIds` with `view: "summary" | "output" | "diagnostics" | "final" | "launch"`, and optional opaque `cursor`/`limitBytes` (max 64 KiB, same cap for every view). Follow `nextCursor` to avoid truncation. Output and diagnostics pages concatenate losslessly. `summary` includes the same `timing` projection as `list`, plus `output.finalAvailable`. `final` returns only the verified canonical terminal answer — empty with `finalAvailable: false` until a successful terminal boundary exists; it never promotes partial/narration text. `output` stays the combined stream (assistant messages plus canonical result). `launch` shows the recorded prompt, shared context and execution configuration; it is sensitive and only for explicit inspection.
|
|
542
|
+
- `inspect` with `runIds` (summary view) (up to 20, deduplicated, order preserved): a single bounded batch of `summary`-only projections — one cheap request to see whether several selected background children are queued, running, or terminal, each with `outputRef`/`diagnosticsRef` for follow-up detail. A single-entry `runIds` with the summary view (or no view) also returns this batch form; use `view` to read one run's pages instead. Ownership of every requested ID is validated before any page is returned. Reuses the same `limitBytes` cap as single-run inspection, which must fit both the text and the structured result; pages contain whole target entries and continue through `nextCursor`, without invalidation from ordinary live progress. If one compact entry cannot fit, the call fails with `page_too_small`, asking you to increase `limitBytes` or inspect that run individually; no target is silently dropped. The legacy `runId` selector remains accepted by programmatic callers but is no longer advertised to models. Multiple targets require `summary`.
|
|
541
543
|
- `wait`: selected `runIds` (one or more), with `mode: "any" | "all"`. It returns terminal outcomes plus still-pending IDs; an unsuccessful workflow returns early even in `all` mode. It never chooses a winner or cancels pending work. While waiting, a bounded heartbeat (independent of any single target settling) reports live progress — watched targets, completed/pending counts, and recent activity — through the tool's update channel; it stops automatically on settlement, error, or interruption. Each settled outcome's `result` is spent from one shared byte budget (`limitBytes`, default 32768) across the whole response, in the requested `runId`/`runIds` order — never settlement race order, so the same targets and final states spend the budget identically regardless of which one happened to settle first: a result that fits is returned complete, one that does not is truncated with `resultTruncated: true` and the existing `outputRef`/`diagnosticsRef` to continue reading it — not a fixed-length teaser regardless of size. A target's evidence is never read from disk once the shared budget is already exhausted.
|
|
542
544
|
- `cancel`: single-entry `runIds` and optional reason. Whole-workflow cancellation stops active children; targeted child cancellation remains a catchable workflow outcome. Cancellation does not roll back edits or other side effects.
|
|
543
545
|
|
|
@@ -594,7 +596,7 @@ Example request:
|
|
|
594
596
|
Use the workflow tool to ask role "explorer" on harness "claude" for an architecture map and role "reviewer" on harness "codex" for a risk review, then synthesize their findings.
|
|
595
597
|
```
|
|
596
598
|
|
|
597
|
-
Direct `Agent` calls and workflow children share one concurrency limiter (`maxConcurrentSubagents`, default 12) and the same timeout controls. There is no per-harness cap: three `agy` children run together when started together — `parallel([() => agent(..., { harness: "agy" }), ...])` in a workflow, or three `background: true` `Agent` calls in one turn followed by `external_runs wait`. Sequentially `await`ed children stay serial by construction. Direct calls default `executionMode: "parallel"` so top-level `Agent` calls issued together run together. Workflow `agent(prompt, { role, context: { mode: "recent", turns: 5 } })` accepts the same context modes. Every current-version `agent()` returns its value or throws a catchable `ChildRunError` with `runId`, `outcome` (`failed`, `cancelled`, or `timed_out`), `message`, and output/diagnostic references. Catch an optional failure explicitly; an uncaught child error fails the workflow and drains active siblings. `parallel` and `pipeline` preserve this contract and never convert failure to `null`.
|
|
599
|
+
Direct `Agent` calls and workflow children share one concurrency limiter (`maxConcurrentSubagents`, default 12) and the same timeout controls. There is no per-harness cap: three `agy` children run together when started together — `parallel([() => agent(..., { harness: "agy" }), ...])` in a workflow, or three `background: true` `Agent` calls in one turn followed by `external_runs wait`. Sequentially `await`ed children stay serial by construction. Direct calls default `executionMode: "parallel"` so top-level `Agent` calls issued together run together. Workflow `agent(prompt, { role, context: { mode: "recent", turns: 5 } })` accepts the same context modes. Every current-version `agent()` returns its value or throws a catchable `ChildRunError` with `runId`, `outcome` (`failed`, `cancelled`, or `timed_out`), `message`, and output/diagnostic references. Catch an optional failure explicitly; an uncaught child error fails the workflow and drains active siblings. A workflow that succeeded after catching failures stays successful: its receipt counts them in `run.children.failed`, adds the warning `handled_child_failures`, and says so in the text result. `parallel` and `pipeline` preserve this contract and never convert failure to `null`.
|
|
598
600
|
|
|
599
601
|
Every child selects from one parent snapshot and effective settings frozen at workflow invocation; earlier child results must still be passed explicitly. `resumeFromRunId` is explicit replay for a persisted `scriptPath`: it reuses only the longest unchanged prefix of successful child calls. The API version, transferred context, prompt, selection, and relevant options participate in fingerprints. The first changed, failed, cancelled, or timed-out call and everything after it executes again. Recomposition is cheap, but rerun children may cost money or repeat side effects; the runtime never retries or replays a repaired script automatically.
|
|
600
602
|
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Public contract v1: Agent, workflow and external_runs
|
|
2
|
+
|
|
3
|
+
`Agent`, `workflow` and `external_runs` declare `outputSchema` and return matching `structuredContent`. Human `content` and renderer `details` stay separate. Native Pi codemode receives the structured result, 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
|
+
## Envelope
|
|
12
|
+
|
|
13
|
+
Every result carries:
|
|
14
|
+
- `contractVersion: 1`;
|
|
15
|
+
- `tool` and `action`;
|
|
16
|
+
- an ISO `observedAt` timestamp;
|
|
17
|
+
- `ok`;
|
|
18
|
+
- `data`;
|
|
19
|
+
- `warnings` (strings).
|
|
20
|
+
|
|
21
|
+
`error: {code, message}` exists only when `ok` is false, and `isError === !ok`. A red tool row therefore means the *operation* failed, not that some target run failed. V1 consumers must ignore unknown fields. Removing fields or changing their meaning requires a new contract version. The host uses the schema for declarations, not to validate output at run time; tests validate actual results.
|
|
22
|
+
|
|
23
|
+
| Tool | `action` | Success `data` | Failure `data` |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| `Agent` | `delegate` | `{run}` | `{run}`, or `{run: null}` before registration |
|
|
26
|
+
| `workflow` | `run` | `{run}` | `{run}`, or `{run: null}` before registration |
|
|
27
|
+
| `external_runs` | the requested action | the action's payload (below) | `null` |
|
|
28
|
+
|
|
29
|
+
A failure that has a run keeps that run's evidence. Read-only supervision never returns partial data on failure.
|
|
30
|
+
|
|
31
|
+
## Runs
|
|
32
|
+
|
|
33
|
+
A run reports:
|
|
34
|
+
- task identity;
|
|
35
|
+
- registry-confirmed liveness;
|
|
36
|
+
- lifecycle state and outcome;
|
|
37
|
+
- timing;
|
|
38
|
+
- evidence integrity;
|
|
39
|
+
- output availability and delivery;
|
|
40
|
+
- inspect references, where they resolve.
|
|
41
|
+
|
|
42
|
+
`kind` is `agent` or `workflow`. Supervision adds the status `interrupted_or_uncertain` for unfinished evidence with no live owner. Private evidence paths and launch/configuration details are not included.
|
|
43
|
+
|
|
44
|
+
**Integrity** is one of:
|
|
45
|
+
- `complete`;
|
|
46
|
+
- `incomplete`: live, or not yet finalized;
|
|
47
|
+
- `damaged`;
|
|
48
|
+
- `unknown`: evidence was not read, or could not be read.
|
|
49
|
+
|
|
50
|
+
Missing or unreadable evidence never discards a settled result. For a workflow journal:
|
|
51
|
+
- **`complete`:** a start line and a terminal line, with no bad line.
|
|
52
|
+
- **`incomplete`:** there is no terminal line, or only the last line is torn (an unfinished write).
|
|
53
|
+
- **`damaged`:** there is a bad line with records after it, or the run ID does not match.
|
|
54
|
+
- **`unknown`:** the journal version is unsupported or the file is unreadable.
|
|
55
|
+
|
|
56
|
+
**Delivery.** A complete canonical result is inline (`output.delivery: "inline"`, `output.value`) only within **16 KiB of UTF-8 JSON after secret redaction**. Otherwise it is delivered by reference: `refs.final` points to a pageable `external_runs` inspection. A value is never truncated. False, null, zero and empty strings are valid results. Partial narration is never the canonical value. `warnings` includes `output_redacted` when redaction changed an inlined value. This is best-effort pattern redaction, not a guarantee that arbitrary sensitive prose is caught.
|
|
57
|
+
|
|
58
|
+
`refs` are ready-to-call `external_runs` arguments:
|
|
59
|
+
- `refs.summary` is a batch inspection with one ID, and returns `{mode: "batch", entries}`.
|
|
60
|
+
- `refs.final` and `refs.diagnostics` are single-run page inspections.
|
|
61
|
+
|
|
62
|
+
## Workflow receipts
|
|
63
|
+
|
|
64
|
+
`ok` describes the workflow root:
|
|
65
|
+
- A foreground root that succeeded is `ok: true`.
|
|
66
|
+
- A foreground root that failed, was cancelled or timed out is `ok: false`, with that outcome as `error.code` and its run evidence kept.
|
|
67
|
+
- A background launch is `ok: true` once it is **accepted**. That does not mean it has finished; use `external_runs wait`.
|
|
68
|
+
|
|
69
|
+
`run.children.count` is the number of indexed `agent()` children. `run.children.failed` counts every failed `agent()` call delivered to the script, including calls that failed before a child was indexed. Failures that are always fatal (an unknown role, the call limit) are excluded, because they fail the root. A root that succeeded after catching child failures stays `ok: true` with the warning `handled_child_failures`, and its `content` says so too. `failed` is absent, meaning unknown, for journals written before it was recorded.
|
|
70
|
+
|
|
71
|
+
## external_runs payloads
|
|
72
|
+
|
|
73
|
+
| Action | `data` |
|
|
74
|
+
|---|---|
|
|
75
|
+
| `list` | `{runs, workflows, nextCursor?, nextWorkflowCursor?}`. The two cursor streams are independent. `nextCursor` means the scan stopped early, not that more rows exist; a next page may be empty. A live run with a record appears on the page where that record falls, with live state. Live runs with no record yet (for example, while evidence writes fail) come first and count against `limit`. Their stage of the cursor bookmarks the last one served, so a run saved between pages is never skipped; it can appear twice (once live, once from its record). If the bookmarked run has left the registry, the next page fails with `cursor_stale`; restart without a cursor. A run started after the listing reached its saved rows appears in the next fresh listing. |
|
|
76
|
+
| `inspect` (`runIds`, summary) | `{mode: "batch", entries, nextCursor?}`. An entry is served only if it fits `limitBytes` in **both** the legacy text and the full structured envelope, so both channels carry the same entries and one cursor. If even one entry cannot fit, the error is `page_too_small`; targets are never silently dropped. A one-element `runIds` with the summary view also returns batch entries. |
|
|
77
|
+
| `inspect` (one run) | `{mode: "single", runId, view, page: {text, encoding, complete, nextCursor?}, finalAvailable?, outputStatus?}`. `page.text` is bounded by `limitBytes` in UTF-8 bytes; the envelope adds a small fixed overhead. `encoding` is `json` for agent `summary`/`launch` and for every workflow view, and `text` for agent `output`/`diagnostics`/`final`. JSON pages may be fragments: concatenate one cursor sequence before parsing. Output and diagnostics pages concatenate losslessly, because each new message carries a leading `\n` separator. Unavailable output leaves `page.text` empty, with the human fallback only in `content`. An unavailable `final` is empty with `finalAvailable: false`, never partial narration. A settled successful agent result still held in memory is served as `final` even if its evidence was never saved, so `refs.final` always resolves. A schema-constrained child stores JSON text as its agent final value. |
|
|
78
|
+
| `wait` | `{mode, completed, pending}`, in request order. Pending work is never cancelled, and an unsuccessful workflow returns early. The legacy `content` spends one shared byte budget (`limitBytes`). The structured channel spends its own copy of `limitBytes`, in the same order. A completed run's value is inline only when it is held in memory, its legacy text was delivered whole, and its encoded JSON fits both 16 KiB and what remains of that structured budget. Everything else, including every durable target, is delivered by reference. No evidence is read once the budget is spent. |
|
|
79
|
+
| `cancel` | `{runId, status: "requested" \| "terminal"}`. `requested` does not mean the run has stopped. |
|
|
80
|
+
|
|
81
|
+
Supervision text is redacted at its source before it is measured or sliced. Durable run records are redacted when written. Memory-sourced text (live output, in-memory wait results, workflow views and journal values) is redacted in full before paging, so `limitBytes` holds and a secret split across a page boundary is still caught.
|
|
82
|
+
|
|
83
|
+
## Error codes
|
|
84
|
+
|
|
85
|
+
| Code | Meaning |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `configuration_invalid`, `harness_unavailable`, `selection_invalid`, `model_unavailable`, `context_invalid` | Configuration or selection problems found before launch. `selection_invalid` also covers an unknown saved workflow. |
|
|
88
|
+
| `session_closed` | The owning session closed before the run registered. |
|
|
89
|
+
| `failed`, `cancelled`, `timed_out` | Terminal outcome of the run this receipt is about. |
|
|
90
|
+
| `request_invalid` | Malformed or contradictory parameters (bad ID, both selectors, target count, view, source count). |
|
|
91
|
+
| `run_unavailable` | Unknown, belonging to another session or project, or pruned by retention. These cases are deliberately indistinguishable. Also covers an unreadable journal in this project. |
|
|
92
|
+
| `run_not_live` | The run is in this session but has no live owner and its evidence is not terminal, so a wait cannot complete and a cancel cannot be confirmed. |
|
|
93
|
+
| `cursor_invalid` | A malformed cursor, or one from a different run, view, scope or target set. |
|
|
94
|
+
| `cursor_stale` | The evidence changed while paging, or the cursor predates the current page format. Restart without a cursor. |
|
|
95
|
+
| `page_too_small` | One whole batch entry does not fit `limitBytes`. |
|
|
96
|
+
| `session_unavailable` | Supervision needs a persisted originating session. |
|
|
97
|
+
| `script_invalid` | Workflow parse, `meta` or `apiVersion` failure. Nothing was launched. |
|
|
98
|
+
| `storage_unavailable` | Workflow script or journal persistence failed before registration. |
|
|
99
|
+
|
|
100
|
+
Still thrown, not returned:
|
|
101
|
+
- an interrupted wait ("Run wait aborted");
|
|
102
|
+
- unexpected exceptions;
|
|
103
|
+
- predispatch schema or permission failures;
|
|
104
|
+
- `tool_call` blocks.
|
|
105
|
+
|
|
106
|
+
A host redaction hook that replaces content drops structured content unless it explicitly supplies a replacement.
|
|
107
|
+
|
|
108
|
+
## Discovery
|
|
109
|
+
|
|
110
|
+
Pi 0.99.2 generates discriminated return types from these schemas through native `describeTool`. The `Agent` input declaration currently loses its input properties, because Pi renders the root `anyOf` constraints before the 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.2.0-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,7 +9,7 @@
|
|
|
9
9
|
"index.ts",
|
|
10
10
|
"src",
|
|
11
11
|
"scripts/field-report.mjs",
|
|
12
|
-
"docs/
|
|
12
|
+
"docs/public-contract.md",
|
|
13
13
|
"docs/ARCHITECTURE_SNAPSHOT.md",
|
|
14
14
|
"docs/field-testing.md",
|
|
15
15
|
"docs/releasing.md",
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { type JsonObject } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import type { RegisteredRunEntry } from "../core/run-registry.ts";
|
|
4
|
+
import { contractEnvelope, envelope, INLINE_RESULT_BYTES, type PublicError } from "./envelope.ts";
|
|
5
|
+
import { agentRunSchema, liveAgentRun, type EvidenceIntegrity } from "./run.ts";
|
|
6
|
+
|
|
7
|
+
const agentData = Type.Object({ run: Type.Union([Type.Null(), agentRunSchema]) });
|
|
8
|
+
export const agentOutputSchema = contractEnvelope("Agent", Type.Literal("delegate"), agentData, agentData);
|
|
9
|
+
export type AgentReceipt = Static<typeof agentOutputSchema>;
|
|
10
|
+
|
|
11
|
+
/** Only registry-confirmed entries may supply a public handle. Never infer liveness from disk. */
|
|
12
|
+
export function agentReceipt({ entry, integrity = "unknown", error, inspectable = true }: {
|
|
13
|
+
entry?: RegisteredRunEntry;
|
|
14
|
+
integrity?: EvidenceIntegrity;
|
|
15
|
+
error?: PublicError;
|
|
16
|
+
inspectable?: boolean;
|
|
17
|
+
}): AgentReceipt & JsonObject {
|
|
18
|
+
const warnings: string[] = [];
|
|
19
|
+
let run = null;
|
|
20
|
+
if (entry) {
|
|
21
|
+
const built = liveAgentRun(entry, { integrity, inlineBudget: INLINE_RESULT_BYTES, inspectable });
|
|
22
|
+
run = built.run;
|
|
23
|
+
if (built.redacted) warnings.push("output_redacted");
|
|
24
|
+
if (!error && entry.outcome && entry.outcome.status !== "done") {
|
|
25
|
+
error = { code: entry.outcome.outcome === "succeeded" ? "failed" : entry.outcome.outcome, message: entry.outcome.error ?? "Child execution failed" };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return envelope({ tool: "Agent", action: "delegate", data: { run }, warnings, error, redact: true }) as AgentReceipt & JsonObject;
|
|
29
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { StringEnum, type JsonObject } from "@earendil-works/pi-ai";
|
|
2
|
+
import { Type, type Static, type TSchema } from "typebox";
|
|
3
|
+
import { ExpectedFlowError, FLOW_ERROR_CODES } from "../core/errors.ts";
|
|
4
|
+
import { redactSecrets } from "../core/run-record.ts";
|
|
5
|
+
|
|
6
|
+
/** Inline cap for a canonical value, in UTF-8 bytes of its redacted JSON. */
|
|
7
|
+
export const INLINE_RESULT_BYTES = 16 * 1024;
|
|
8
|
+
|
|
9
|
+
export const strings = <T extends string[]>(...values: T) => StringEnum(values);
|
|
10
|
+
export const errorSchema = Type.Object({ code: StringEnum(FLOW_ERROR_CODES), message: Type.String() });
|
|
11
|
+
export type PublicError = Static<typeof errorSchema>;
|
|
12
|
+
|
|
13
|
+
const envelopeFields = (tool: string, action: TSchema) => ({ contractVersion: Type.Literal(1), tool: Type.Literal(tool), action, observedAt: Type.String(), warnings: Type.Array(Type.String()) });
|
|
14
|
+
|
|
15
|
+
export function successVariant<S extends TSchema>(tool: string, action: TSchema, data: S) {
|
|
16
|
+
return Type.Object({ ...envelopeFields(tool, action), ok: Type.Literal(true), data, error: Type.Optional(Type.Never()) });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function failureVariant<F extends TSchema>(tool: string, action: TSchema, data: F) {
|
|
20
|
+
return Type.Object({ ...envelopeFields(tool, action), ok: Type.Literal(false), data, error: errorSchema });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The versioned envelope shared by every public tool: `ok` discriminates `data` and `error`. */
|
|
24
|
+
export function contractEnvelope<S extends TSchema, F extends TSchema>(tool: string, action: TSchema, success: S, failure: F) {
|
|
25
|
+
return Type.Union([successVariant(tool, action, success), failureVariant(tool, action, failure)]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Builds one envelope as plain JSON. `redact` re-redacts the whole envelope (Agent and workflow receipts). */
|
|
29
|
+
export function envelope({ tool, action, data, warnings = [], error, redact = false }: {
|
|
30
|
+
tool: string;
|
|
31
|
+
action: string;
|
|
32
|
+
data: unknown;
|
|
33
|
+
warnings?: string[];
|
|
34
|
+
error?: PublicError;
|
|
35
|
+
redact?: boolean;
|
|
36
|
+
}): JsonObject {
|
|
37
|
+
const base = { contractVersion: 1, tool, action, observedAt: new Date().toISOString(), data, warnings };
|
|
38
|
+
const value = error ? { ...base, ok: false, error: { code: error.code, message: error.message } } : { ...base, ok: true };
|
|
39
|
+
return JSON.parse(JSON.stringify(redact ? redactSecrets(value) : value)) as JsonObject;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Redacts memory-sourced text in full, before anything measures or slices it. */
|
|
43
|
+
export function redactedText(text: string): string {
|
|
44
|
+
return redactSecrets(text) as string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The single canonical-value delivery rule: redact, measure, then inline the
|
|
49
|
+
* whole value or deliver it by reference. A value is never truncated.
|
|
50
|
+
*/
|
|
51
|
+
export function deliverValue(value: unknown, { finalAvailable, inlineBudget, inspectable }: { finalAvailable: boolean; inlineBudget: number; inspectable: boolean }): {
|
|
52
|
+
output: { delivery: "inline" | "reference" | "none"; value?: unknown };
|
|
53
|
+
redacted: boolean;
|
|
54
|
+
} {
|
|
55
|
+
let encoded: string | undefined;
|
|
56
|
+
try { encoded = finalAvailable ? JSON.stringify(value) : undefined; } catch { /* Non-JSON values are never presented as complete. */ }
|
|
57
|
+
const redactedValue = encoded === undefined ? undefined : redactSecrets(JSON.parse(encoded));
|
|
58
|
+
const redactedEncoded = encoded === undefined ? undefined : JSON.stringify(redactedValue);
|
|
59
|
+
const inline = redactedEncoded !== undefined && Buffer.byteLength(redactedEncoded, "utf8") <= inlineBudget;
|
|
60
|
+
return {
|
|
61
|
+
output: { delivery: inline ? "inline" : finalAvailable && inspectable ? "reference" : "none", ...(inline ? { value: redactedValue } : {}) },
|
|
62
|
+
redacted: redactedEncoded !== encoded,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type TextContent = { type: "text"; text: string };
|
|
67
|
+
|
|
68
|
+
export interface ContractResult<D = unknown> {
|
|
69
|
+
content: TextContent[];
|
|
70
|
+
details: D;
|
|
71
|
+
data: unknown;
|
|
72
|
+
warnings?: string[];
|
|
73
|
+
/** A returned failure keeps the caller's own content, details and data. */
|
|
74
|
+
error?: PublicError;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Runs one tool body and returns `content`/`details` plus the public
|
|
79
|
+
* `structuredContent`, with `isError === !ok`. A thrown ExpectedFlowError
|
|
80
|
+
* becomes a returned failure with `failureData` and `{error, code}` details
|
|
81
|
+
* (so `D` must accept that shape); anything else is rethrown.
|
|
82
|
+
*/
|
|
83
|
+
export async function withContract<D>(
|
|
84
|
+
{ tool, action, failureData, redact = false }: { tool: string; action: string; failureData: unknown; redact?: boolean },
|
|
85
|
+
body: () => Promise<ContractResult<D>>,
|
|
86
|
+
): Promise<{ content: TextContent[]; details: D; structuredContent: JsonObject; isError: boolean }> {
|
|
87
|
+
let settled: ContractResult<D>;
|
|
88
|
+
try {
|
|
89
|
+
settled = await body();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (!(error instanceof ExpectedFlowError)) throw error;
|
|
92
|
+
settled = { content: [{ type: "text", text: error.message }], details: { error: error.message, code: error.code } as D, data: failureData, error };
|
|
93
|
+
}
|
|
94
|
+
const structuredContent = envelope({ tool, action, data: settled.data, warnings: settled.warnings, error: settled.error, redact });
|
|
95
|
+
const content = redact ? redactSecrets(settled.content) as TextContent[] : settled.content;
|
|
96
|
+
return { content, details: settled.details, structuredContent, isError: structuredContent.ok !== true };
|
|
97
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import { Type, type Static, type TProperties } from "typebox";
|
|
2
|
+
import { projectDurableAgent, projectLiveAgent } from "../core/run-projection.ts";
|
|
3
|
+
import type { RunRecordListItem } from "../core/run-inspection.ts";
|
|
4
|
+
import type { RegisteredRunEntry } from "../core/run-registry.ts";
|
|
5
|
+
import type { WorkflowToolDetails } from "../types.ts";
|
|
6
|
+
import type { LoadedWorkflowJournal, UnreadableWorkflowJournal } from "../workflow/journal.ts";
|
|
7
|
+
import { deliverValue, strings } from "./envelope.ts";
|
|
8
|
+
|
|
9
|
+
export type EvidenceIntegrity = "complete" | "incomplete" | "damaged" | "unknown";
|
|
10
|
+
|
|
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 timing = Type.Object({
|
|
13
|
+
queuedAt: Type.Optional(Type.String()), executionStartedAt: Type.Optional(Type.String()),
|
|
14
|
+
processStartedAt: Type.Optional(Type.String()), firstActivityAt: Type.Optional(Type.String()),
|
|
15
|
+
lastActivityAt: Type.Optional(Type.String()), finishedAt: Type.Optional(Type.String()),
|
|
16
|
+
queueDelayMs: Type.Optional(Type.Number()), elapsedMs: Type.Optional(Type.Number()),
|
|
17
|
+
activityAgeMs: Type.Optional(Type.Number()), processDurationMs: Type.Optional(Type.Number()),
|
|
18
|
+
});
|
|
19
|
+
const outcome = strings("succeeded", "failed", "cancelled", "timed_out");
|
|
20
|
+
const optionalStrings = (...names: string[]): TProperties => Object.fromEntries(names.map((name) => [name, Type.Optional(Type.String())]));
|
|
21
|
+
|
|
22
|
+
/** One PublicRun builder; each tool declares only the kinds, statuses and fields it can return. */
|
|
23
|
+
function publicRunSchema(kind: ReturnType<typeof Type.Literal> | ReturnType<typeof strings>, statuses: ReturnType<typeof strings>, task: TProperties, children: boolean) {
|
|
24
|
+
return Type.Object({
|
|
25
|
+
runId: Type.String(), kind, live: Type.Boolean(),
|
|
26
|
+
task: Type.Object(task),
|
|
27
|
+
state: Type.Object({ status: statuses, outcome: Type.Optional(outcome) }),
|
|
28
|
+
timing,
|
|
29
|
+
output: Type.Object({ available: Type.Boolean(), finalAvailable: Type.Boolean(), delivery: strings("inline", "reference", "none"), value: Type.Optional(Type.Unknown()) }),
|
|
30
|
+
evidence: Type.Object({ integrity: strings("complete", "incomplete", "damaged", "unknown") }),
|
|
31
|
+
...(children ? { children: Type.Optional(Type.Object({ count: Type.Optional(Type.Integer()), failed: Type.Optional(Type.Integer()) })) } : {}),
|
|
32
|
+
refs: Type.Optional(Type.Object({ summary: inspectRef, final: inspectRef, diagnostics: inspectRef })),
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const agentRunSchema = publicRunSchema(Type.Literal("agent"), strings("queued", "running", "done", "error", "aborted"), optionalStrings("description", "profile", "backend", "harness"), false);
|
|
37
|
+
export const workflowRunSchema = publicRunSchema(Type.Literal("workflow"), strings("running", "done", "error", "aborted"), optionalStrings("name", "source"), true);
|
|
38
|
+
export const supervisedRunSchema = publicRunSchema(
|
|
39
|
+
strings("agent", "workflow"),
|
|
40
|
+
strings("queued", "running", "done", "error", "aborted", "interrupted_or_uncertain"),
|
|
41
|
+
optionalStrings("description", "profile", "backend", "harness", "workflowRunId", "name", "source"),
|
|
42
|
+
true,
|
|
43
|
+
);
|
|
44
|
+
type InspectRef = Static<typeof inspectRef>;
|
|
45
|
+
export interface PublicRun {
|
|
46
|
+
runId: string;
|
|
47
|
+
kind: "agent" | "workflow";
|
|
48
|
+
live: boolean;
|
|
49
|
+
task: Record<string, string>;
|
|
50
|
+
state: { status: "queued" | "running" | "done" | "error" | "aborted" | "interrupted_or_uncertain"; outcome?: Static<typeof outcome> };
|
|
51
|
+
timing: Static<typeof timing>;
|
|
52
|
+
output: { available: boolean; finalAvailable: boolean; delivery: "inline" | "reference" | "none"; value?: unknown };
|
|
53
|
+
evidence: { integrity: EvidenceIntegrity };
|
|
54
|
+
children?: { count?: number; failed?: number };
|
|
55
|
+
refs?: { summary: InspectRef; final: InspectRef; diagnostics: InspectRef };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function runRefs(runId: string): NonNullable<PublicRun["refs"]> {
|
|
59
|
+
const ref = (view: "summary" | "final" | "diagnostics") => ({ tool: "external_runs" as const, action: "inspect" as const, runIds: [runId], view });
|
|
60
|
+
return { summary: ref("summary"), final: ref("final"), diagnostics: ref("diagnostics") };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface RunDelivery {
|
|
64
|
+
/** Evidence integrity; defaults to incomplete while live, unknown once settled without an evidence read. */
|
|
65
|
+
integrity?: EvidenceIntegrity;
|
|
66
|
+
/** 0 never inlines (list and batch rows). */
|
|
67
|
+
inlineBudget?: number;
|
|
68
|
+
inspectable?: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Built { run: PublicRun; redacted: boolean }
|
|
72
|
+
|
|
73
|
+
function defaultIntegrity(entry: RegisteredRunEntry): EvidenceIntegrity {
|
|
74
|
+
return entry.state === "running" ? "incomplete" : "unknown";
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pick(task: Record<string, unknown>, keys: string[]): Record<string, string> {
|
|
78
|
+
return Object.fromEntries(keys.flatMap((key) => typeof task[key] === "string" ? [[key, task[key] as string]] : []));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Only registry-confirmed entries are live; liveness is never inferred from disk. */
|
|
82
|
+
export function liveAgentRun(entry: RegisteredRunEntry, { integrity, inlineBudget = 0, inspectable = true }: RunDelivery = {}): Built {
|
|
83
|
+
const projected = projectLiveAgent(entry);
|
|
84
|
+
const finalAvailable = projected.output.finalAvailable;
|
|
85
|
+
const delivered = deliverValue(entry.outcome?.result, { finalAvailable, inlineBudget, inspectable });
|
|
86
|
+
return {
|
|
87
|
+
run: {
|
|
88
|
+
runId: entry.runId, kind: "agent", live: projected.live,
|
|
89
|
+
task: pick(projected.task as Record<string, unknown>, ["description", "profile", "backend", "harness", "workflowRunId"]),
|
|
90
|
+
state: { status: projected.state.status as PublicRun["state"]["status"], ...(entry.outcome ? { outcome: entry.outcome.outcome } : {}) },
|
|
91
|
+
timing: projected.timing,
|
|
92
|
+
output: { available: projected.output.available, finalAvailable, ...delivered.output },
|
|
93
|
+
evidence: { integrity: integrity ?? defaultIntegrity(entry) },
|
|
94
|
+
...(inspectable ? { refs: runRefs(entry.runId) } : {}),
|
|
95
|
+
},
|
|
96
|
+
redacted: delivered.redacted,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function durableAgentRun(item: RunRecordListItem): PublicRun {
|
|
101
|
+
const projected = projectDurableAgent(item);
|
|
102
|
+
return {
|
|
103
|
+
runId: item.runId, kind: "agent", live: false,
|
|
104
|
+
task: pick(projected.task as Record<string, unknown>, ["description", "profile", "backend", "harness", "workflowRunId"]),
|
|
105
|
+
state: { status: projected.state.status as PublicRun["state"]["status"], ...(item.outcome ? { outcome: item.outcome } : {}) },
|
|
106
|
+
timing: item.timing,
|
|
107
|
+
output: { available: item.outputAvailable, finalAvailable: item.finalAvailable, delivery: item.finalAvailable ? "reference" : "none" },
|
|
108
|
+
evidence: { integrity: item.integrity },
|
|
109
|
+
refs: runRefs(item.runId),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function liveWorkflowRun(entry: RegisteredRunEntry, { integrity, inlineBudget = 0, inspectable = true }: RunDelivery = {}): Built {
|
|
114
|
+
const observation = entry.observation as WorkflowToolDetails | undefined;
|
|
115
|
+
const status = entry.state === "running" ? "running" : entry.outcome?.status ?? "error";
|
|
116
|
+
const finalAvailable = entry.outcome?.status === "done" && entry.outcome.result !== undefined;
|
|
117
|
+
const delivered = deliverValue(entry.outcome?.result, { finalAvailable, inlineBudget, inspectable });
|
|
118
|
+
const count = observation?.agentCount ?? observation?.agents?.length;
|
|
119
|
+
return {
|
|
120
|
+
run: {
|
|
121
|
+
runId: entry.runId, kind: "workflow", live: entry.state === "running",
|
|
122
|
+
task: pick({ name: observation?.name, source: observation?.source }, ["name", "source"]),
|
|
123
|
+
state: { status, ...(entry.outcome ? { outcome: entry.outcome.outcome } : {}) },
|
|
124
|
+
timing: {},
|
|
125
|
+
output: { available: entry.outcome?.result !== undefined, finalAvailable, ...delivered.output },
|
|
126
|
+
evidence: { integrity: integrity ?? defaultIntegrity(entry) },
|
|
127
|
+
children: { ...(count !== undefined ? { count } : {}), ...(observation?.childFailures !== undefined ? { failed: observation.childFailures } : {}) },
|
|
128
|
+
...(inspectable ? { refs: runRefs(entry.runId) } : {}),
|
|
129
|
+
},
|
|
130
|
+
redacted: delivered.redacted,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function journalWorkflowRun(journal: LoadedWorkflowJournal): PublicRun {
|
|
135
|
+
const finalAvailable = journal.status === "done" && journal.result !== undefined;
|
|
136
|
+
return {
|
|
137
|
+
runId: journal.runId, kind: "workflow", live: false,
|
|
138
|
+
task: pick({ name: journal.name, source: journal.source }, ["name", "source"]),
|
|
139
|
+
state: { status: journal.status === "running" ? "interrupted_or_uncertain" : journal.status, ...(journal.outcome ? { outcome: journal.outcome } : {}) },
|
|
140
|
+
timing: {},
|
|
141
|
+
output: { available: journal.result !== undefined, finalAvailable, delivery: finalAvailable ? "reference" : "none" },
|
|
142
|
+
evidence: { integrity: journal.integrity },
|
|
143
|
+
children: { count: journal.children.length, ...(journal.childFailures !== undefined ? { failed: journal.childFailures } : {}) },
|
|
144
|
+
refs: runRefs(journal.runId),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** No refs: inspecting an unreadable journal reports its read error rather than a page. */
|
|
149
|
+
export function unreadableWorkflowRun(journal: UnreadableWorkflowJournal): PublicRun {
|
|
150
|
+
return {
|
|
151
|
+
runId: journal.runId, kind: "workflow", live: false, task: {},
|
|
152
|
+
state: { status: "interrupted_or_uncertain" },
|
|
153
|
+
timing: {},
|
|
154
|
+
output: { available: false, finalAvailable: false, delivery: "none" },
|
|
155
|
+
evidence: { integrity: journal.integrity },
|
|
156
|
+
};
|
|
157
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import { failureVariant, strings, successVariant } from "./envelope.ts";
|
|
3
|
+
import { supervisedRunSchema } from "./run.ts";
|
|
4
|
+
|
|
5
|
+
const TOOL = "external_runs";
|
|
6
|
+
const runs = Type.Array(supervisedRunSchema);
|
|
7
|
+
const cursor = Type.Optional(Type.String());
|
|
8
|
+
|
|
9
|
+
export const SINGLE_INSPECT_VIEWS = ["summary", "output", "diagnostics", "final", "launch"] as const;
|
|
10
|
+
|
|
11
|
+
/** One success variant per action (inspect has batch and single shapes); every expected failure has `data: null`. */
|
|
12
|
+
export const externalRunsOutputSchema = Type.Union([
|
|
13
|
+
successVariant(TOOL, Type.Literal("list"), Type.Object({ runs, workflows: runs, nextCursor: cursor, nextWorkflowCursor: cursor })),
|
|
14
|
+
successVariant(TOOL, Type.Literal("inspect"), Type.Union([
|
|
15
|
+
Type.Object({ mode: Type.Literal("batch"), entries: runs, nextCursor: cursor }),
|
|
16
|
+
Type.Object({
|
|
17
|
+
mode: Type.Literal("single"),
|
|
18
|
+
runId: Type.String(),
|
|
19
|
+
view: strings(...SINGLE_INSPECT_VIEWS),
|
|
20
|
+
page: Type.Object({
|
|
21
|
+
/** UTF-8 bytes bounded by limitBytes. JSON pages may be fragments: concatenate one cursor sequence before parsing. */
|
|
22
|
+
text: Type.String(),
|
|
23
|
+
encoding: strings("json", "text"),
|
|
24
|
+
complete: Type.Boolean(),
|
|
25
|
+
nextCursor: cursor,
|
|
26
|
+
}),
|
|
27
|
+
finalAvailable: Type.Optional(Type.Boolean()),
|
|
28
|
+
outputStatus: Type.Optional(strings("preliminary", "final", "interrupted")),
|
|
29
|
+
}),
|
|
30
|
+
])),
|
|
31
|
+
successVariant(TOOL, Type.Literal("wait"), Type.Object({ mode: strings("any", "all"), completed: runs, pending: Type.Array(Type.String()) })),
|
|
32
|
+
successVariant(TOOL, Type.Literal("cancel"), Type.Object({ runId: Type.String(), status: strings("requested", "terminal") })),
|
|
33
|
+
failureVariant(TOOL, strings("list", "inspect", "wait", "cancel"), Type.Null()),
|
|
34
|
+
]);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { Type } from "typebox";
|
|
2
|
+
import type { RegisteredRunEntry } from "../core/run-registry.ts";
|
|
3
|
+
import { contractEnvelope, INLINE_RESULT_BYTES, type PublicError } from "./envelope.ts";
|
|
4
|
+
import { liveWorkflowRun, workflowRunSchema, type EvidenceIntegrity, type PublicRun } from "./run.ts";
|
|
5
|
+
|
|
6
|
+
const workflowData = Type.Object({ run: Type.Union([Type.Null(), workflowRunSchema]) });
|
|
7
|
+
/** `ok` describes the workflow operation: a background launch is accepted, a foreground one settled successfully. */
|
|
8
|
+
export const workflowOutputSchema = contractEnvelope("workflow", Type.Literal("run"), workflowData, workflowData);
|
|
9
|
+
|
|
10
|
+
export const HANDLED_CHILD_FAILURES = "handled_child_failures";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The public receipt for one registered workflow. A foreground root that
|
|
14
|
+
* succeeded after handling child failures stays ok, with a warning; an
|
|
15
|
+
* unsuccessful root is a failed operation that keeps its run evidence.
|
|
16
|
+
*/
|
|
17
|
+
export function workflowReceipt(entry: RegisteredRunEntry, { integrity, inspectable }: { integrity?: EvidenceIntegrity; inspectable: boolean }): {
|
|
18
|
+
data: { run: PublicRun };
|
|
19
|
+
warnings: string[];
|
|
20
|
+
error?: PublicError;
|
|
21
|
+
} {
|
|
22
|
+
const built = liveWorkflowRun(entry, { integrity, inlineBudget: INLINE_RESULT_BYTES, inspectable });
|
|
23
|
+
const warnings: string[] = [];
|
|
24
|
+
if (built.redacted) warnings.push("output_redacted");
|
|
25
|
+
const outcome = entry.outcome;
|
|
26
|
+
if (outcome && outcome.status !== "done") {
|
|
27
|
+
return { data: { run: built.run }, warnings, error: { code: outcome.outcome === "succeeded" ? "failed" : outcome.outcome, message: outcome.error ?? "Workflow failed" } };
|
|
28
|
+
}
|
|
29
|
+
if (outcome && (built.run.children?.failed ?? 0) > 0) warnings.push(HANDLED_CHILD_FAILURES);
|
|
30
|
+
return { data: { run: built.run }, warnings };
|
|
31
|
+
}
|