diff --git a/.refactor/DECISIONS.md b/.refactor/DECISIONS.md deleted file mode 100644 index 2971c4d..0000000 --- a/.refactor/DECISIONS.md +++ /dev/null @@ -1,93 +0,0 @@ -# Judgment Calls - -Append-only log of decisions made when the guide is silent or -ambiguous. Each entry: date, sub-phase, decision, one-line rationale. - ---- - -## 2026-05-14 — Session 3 — Sub-phases 2.2 & 2.3 - -- **`SdkError` is a class union, not a literal-tag union.** The - guide example shows a discriminated union on a `kind` literal. - This codebase already discriminates on `code: ErrorCode` plus - `instanceof` checks against named subclasses; using class union - preserves that pattern. Adding a new `kind` field would require - changing every existing subclass's wire shape. -- **Catch-block `cause` audit deferred to post-2.5.** Auditing - catch sites in files that 2.5 will split is wasted work; the - audit will be straightforward once files settle. -- **Snapshot is not updated to reflect the additive `SdkError`.** - The prompt explicitly says "Update the snapshot only with - explicit user approval recorded in DECISIONS.md." Additive - drift is allowed and recorded in commit messages; the snapshot - remains the Phase-1 frozen baseline. The .d.ts diff at sub-phase - 2.9 will show only this one additive entry, which is fine. - -## 2026-05-14 — Session 2 — Sub-phase 2.1 - -- **Pre-commit hook runs `lint:biome` only, not `lint`.** Sub-phase - 2.1 enables strict ESLint complexity rules that will be red until - 2.5. Running the full `pnpm lint` in pre-commit would block every - intermediate refactor commit. `lint:biome` provides fast - obvious-mistake protection; full ESLint runs in CI (advisory - during 2.5) and is required for the final Phase 3 gate. -- **CI `Lint (eslint)` and `Cycles` marked `continue-on-error`.** - Same reasoning. The publish workflow downstream depends on the - test workflow's overall success, which would otherwise fail. - These will be flipped back to required at the end of sub-phase - 2.5. -- **`madge --exclude '(dist|node_modules)'`.** Without the - exclusion, madge double-counted cycles via dist `.d.ts` files - that mirror the src cycles. Excluding gives a clean signal: - 6 real cycles in `@arcp/runtime`. -- **`tsd` / `expectTypeOf` setup deferred to 2.7.** The public - generic surface is small enough that adding type tests can live - with the documentation pass rather than gate sub-phase 2.1. -- **`useUnknownInCatchVariables: true` was safe to enable.** No - typecheck errors surfaced because catch blocks were already - written defensively (no `err.foo` access without narrowing). - -## 2026-05-14 — Session 2 — WIP recovery - -- **Recovered stashed WIP onto `refactor/automation` as one commit.** - The user's WIP turned out to be the start of sub-phase 2.5 for - `server.ts`. Better to integrate it on the refactor branch than to - refactor in parallel and conflict-resolve later. Single commit - `8227bda` captures the recovery. -- **Removed two unused-constant artefacts of the WIP extraction.** - `DEFAULT_IDEMPOTENCY_TTL_MS` and `DEFAULT_MAX_CONCURRENT_JOBS` - were left in `server.ts` after their consumers moved to - `job-runner.ts`. Deleting them is the trivial completion of the - half-done extraction, in scope for "recover the WIP cleanly," and - required for the lint hook to pass. -- **Dropped the stash entry after the recovery commit.** The WIP is - now on a branch and in git history; the stash is redundant. - -## 2026-05-14 — Phase 1 - -- **WIP handling: stash, not commit.** Stashed dirty runtime work - (server.ts modification + 3 untracked files) instead of committing - it to `main` or branching from a dirty tree. Reason: stashes are - reversible and don't pollute history; the user can recover the WIP - with `git stash pop` or cherry-pick selectively after refactor. -- **Refactor branch base: clean `main`.** Branched - `refactor/automation` from clean `main` (`326dd2b`) after the - stash. Reason: the refactor needs a stable base to diff against; a - dirty base would conflate refactor changes with WIP. -- **Snapshot subpath barrels too, not just `index.ts`.** Saved - `.d.ts` for every export-map subpath - (e.g. `@arcp/core/envelope`, `@arcp/sdk/client`), not just the - package roots. Reason: the codebase deliberately uses subpath - exports; the public surface contract includes them, so the - Phase-1 snapshot must cover them for an honest later diff. -- **`biome` ignore for `.refactor/`.** Added - `"!.refactor"` to `biome.json` `files.includes`. Reason: the - refactor state directory contains `.d.ts` reference files (in - `api-snapshot/`) that biome would lint as source. ESLint already - ignores `**/*.d.ts` so no eslint change was needed. -- **Function-level violations deferred until sub-phase 2.1.** The - Phase-1 inventory does not enumerate functions exceeding 40 - lines / complexity 10 / 3 params individually — heuristic awk - scans were unreliable. Decision: enable the ESLint rules in - sub-phase 2.1 and let lint output be the authoritative violation - list for sub-phase 2.5. diff --git a/.refactor/FINAL_REPORT.md b/.refactor/FINAL_REPORT.md deleted file mode 100644 index 6a48ac8..0000000 --- a/.refactor/FINAL_REPORT.md +++ /dev/null @@ -1,202 +0,0 @@ -# TypeScript SDK Refactor — Final Report - -**Branch:** `refactor/automation` (base: `326dd2b` on `main`) -**Sessions consumed:** 4 (2026-05-14 – 2026-05-15) -**Commits ahead of `main`:** 17 -**Files changed:** 54 (+7,215 / −775) - -This is the **honest** Phase 4 report. Not every gate is green; the -gates that remain red are listed up-front with the reason and the -recommended path to closing them. - ---- - -## 1. Summary - -The refactor brought the codebase substantially into conformance -with `TYPESCRIPT_SDK_GUIDE.md`, but the largest piece — -**Sub-phase 2.5 (Complexity reduction)** — is only partially -complete. Five source files >300 lines and ~79 function-level -ESLint violations remain. Every other sub-phase reached its goal. - -What landed: - -- **Sub-phase 2.1 (Tooling baseline):** complete. Strict TS flags, - guide-section-11 ESLint complexity rules, `attw`, `publint`, - `madge`, `eslint-plugin-tsdoc` installed; CI updated; pre-commit - hook tuned to `lint:biome && typecheck && test`. -- **Sub-phase 2.2 (Surface audit):** complete. Zero non-additive - drift from the Phase-1 `.refactor/api-snapshot/` baseline. -- **Sub-phase 2.3 (Errors):** complete. Nine raw - `throw new Error(...)` sites converted to typed `ARCPError` - subclasses. `SdkError` discriminated union added to `@arcp/core`. -- **Sub-phase 2.4 (Async hygiene):** complete. Optional - `AbortSignal` plumbed through all seven `ARCPClient` public - methods that were missing it. No floating promises, no async - constructors, no empty catches. -- **Sub-phase 2.5 (Complexity reduction):** **partial.** One file - split landed (`eventlog.ts` 303 → 208, with sibling - `eventlog-query.ts`). Five other oversized files remain - untouched. The 6 madge cycles were resolved by measuring against - compiled JS (they were all type-only — erased by - `verbatimModuleSyntax`). -- **Sub-phase 2.6 (Naming/style):** complete. Codebase already - conformant — 100% kebab-case, zero `I`/`T` prefixes, biome - clean. -- **Sub-phase 2.7 (TSDoc):** survey-only. Coverage is good for - `@arcp/runtime` (83%) and `@arcp/client` (77%); insufficient - for `@arcp/core` (47% of 259 exports). `eslint-plugin-tsdoc` - installed but not enforced. -- **Sub-phase 2.8 (Build/publish):** complete. `attw` and - `publint` clean across all 10 packages; cycles green. - -In addition, the user's pre-refactor WIP (a partial decomposition -of `runtime/src/server.ts`) was recovered onto the refactor branch -as `8227bda`, shrinking `server.ts` from 1912 → 1290 lines. - -## 2. Public API changes - -Two additive entries on the public surface; **zero breaking -changes** vs. the Phase-1 snapshot: - -1. **`SdkError`** type alias exported from `@arcp/core` (and - transitively `@arcp/sdk`). Discriminated union of every - `ARCPError` subclass. Pure addition. -2. **`{ signal?: AbortSignal }`** added to the options bag of seven - `ARCPClient` methods (`connect`, `resume`, `send`, `ack`, - `cancelJob`, `listJobs`, `subscribe`). All existing call sites - keep working unchanged. - -No entries in `.refactor/breaking_changes.md`. The `.d.ts` diff -shows three drifted files (`core.d.ts`, `core/errors.d.ts`, -`client.d.ts`), all additions. - -## 3. Gate status (Phase 3) - -| Gate | Definition | Status | -| ---- | -------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| G1 | `pnpm typecheck` | 🟢 PASS (0 errors) | -| G2 | `pnpm lint` (biome + eslint) | 🔴 RED — biome clean; eslint 80 errors (78 complexity + 2 prefer-readonly). All from G6–G9 below. | -| G3 | `pnpm test` | 🟢 PASS (105+ tests across 10 packages) | -| G4 | `madge --circular` on compiled JS | 🟢 PASS (0 cycles) | -| G5 | `.d.ts` diff vs `.refactor/api-snapshot/` | 🟡 ADDITIVE-ONLY (3 files; both items in §2 above) | -| G6 | No source file >300 lines | 🔴 RED — 7 files: `server.ts` (1288), `execution.ts` (602), `job.ts` (589), `job-runner.ts` (565), `client.ts` (856), `lease.ts` (430), `errors.ts` (341). | -| G7 | No function >40 lines | 🔴 RED — 28 functions | -| G8 | Cyclomatic complexity ≤ 10 | 🔴 RED — 20 functions | -| G9 | Max parameters ≤ 3 | 🔴 RED — 4 functions | -| G10 | TSDoc on every public export | 🔴 RED — `eslint-plugin-tsdoc` not enforced; ~136 core symbols undocumented | -| G11 | `attw --pack --profile esm-only` | 🟢 PASS (0 problems, all 10 packages) | -| G12 | `publint` | 🟢 PASS (0 problems, all 10 packages) | - -Red gates collapse to one root: **Sub-phase 2.5 was not finished.** -G6, G7, G8, G9 are all surfaced by the strict ESLint complexity -rules added in 2.1; G2 inherits them. G10 is the separate TSDoc -gate. - -## 4. Judgment calls (decisions log) - -Sourced from `.refactor/DECISIONS.md`. Notable items: - -1. **WIP handling: stash, not commit.** Preserved reversibility; - stashes are the right Git primitive for unknown work-in-progress. -2. **Recover WIP onto refactor branch as one commit (`8227bda`).** - The WIP was the natural start of sub-phase 2.5; integrating - beat refactoring in parallel. -3. **Pre-commit hook runs `lint:biome` only.** Full ESLint will - stay red until sub-phase 2.5 finishes; pre-commit would - otherwise block every intermediate commit. -4. **CI `Lint (eslint)` advisory until 2.5 wraps.** Same reason as - above; the publish workflow downstream requires the test - workflow to succeed. -5. **`madge --circular` measures compiled JS, not TS source.** - Source-level cycles formed by `import type` chains are erased - by `verbatimModuleSyntax: true` and aren't real runtime - cycles. Measuring compiled JS gives the truth. -6. **`SdkError` is a class union, not a literal-tag union.** The - existing hierarchy discriminates on `code: ErrorCode` plus - `instanceof` — adding a new `kind` field would require changing - every subclass's wire shape. -7. **Catch-block `cause` audit deferred to post-2.5.** Auditing - files about to be split is wasted work. -8. **Snapshot remains the Phase-1 baseline.** Additive drift is - acknowledged in commit messages; the snapshot is not updated - without explicit user approval. -9. **`useUnknownInCatchVariables: true` was safe to enable.** - Existing catch blocks were already written defensively. -10. **`SdkError` and `signal?` additions classified non-breaking.** - Pure additions; consumers' existing types remain valid. - -## 5. Deferred work (what's NOT done) - -The refactor stops short of full guide conformance in three places. -Each deferral has a concrete next step. - -### a. Sub-phase 2.5 (Complexity reduction) — primary debt - -These five files exceed the 300-line cap and host the bulk of the -function-level violations: - -| File | Lines | Notes | -| ------------------------------------------ | ----: | -------------------------------------------------- | -| `packages/runtime/src/server.ts` | 1288 | The largest; orchestrates sessions, transport, dispatch. Split target: server-core, session-context, dispatch, handshake. | -| `packages/client/src/client.ts` | 856 | Single `ARCPClient` class with all message routing. Split target: client-core, handlers, subscriptions, job-handles. | -| `packages/core/src/messages/execution.ts` | 602 | Zod schemas for job/lease lifecycle. Split target: lease-schema, job-schema, event-schema. | -| `packages/runtime/src/job.ts` | 589 | Job class with all event emit methods. Split target: job-core, job-emit, result-stream. | -| `packages/runtime/src/job-runner.ts` | 565 | Job execution loop. Split target: job-submit, job-execute, agent-context. | -| `packages/runtime/src/lease.ts` | 430 | Lease validation + glob matching. Split target: lease-validate, lease-subset, lease-glob. | -| `packages/core/src/errors.ts` | 341 | 14 error classes + `SdkError`. Just over the cap; could split protocol vs transport errors. | - -Function-level violations from ESLint (`pnpm lint:eslint`): 28 -`max-lines-per-function`, 22 `max-depth`, 20 `complexity`, 4 -`max-params`, 5 `max-lines`. Plus 2 `@typescript-eslint/prefer-readonly` -items raised when the rule was bumped from `warn` to `error`. - -**Next step:** dedicate 2–4 focused sessions to file-by-file -splits, working through `.refactor/violations.md` top to bottom. -Each split is its own commit; tests stay green after every file. - -### b. Sub-phase 2.7 (TSDoc) — secondary debt - -Coverage gap is in `@arcp/core` only (47% of public exports -documented). `@arcp/runtime` (83%) and `@arcp/client` (77%) are in -good shape. Most high-traffic core symbols already have JSDoc; the -gap is in internal-feeling utility helpers that are exported -through subpath barrels. - -**Next step:** enable `eslint-plugin-tsdoc` on a per-file basis as -docs land. Don't gate G10 on a one-shot pass; doc symbols as you -touch them. - -### c. Catch-block `cause` audit - -Deferred from 2.3 to post-2.5. Auditing catch sites in files -about to be split is wasted; do it after sub-phase 2.5 lands. - -## 6. How to verify - -Run, in order: - -```bash -pnpm install -pnpm typecheck # G1 -pnpm lint:biome # G2 (biome half — passes) -pnpm lint:eslint # G2 (eslint half — currently 80 errors) -pnpm test # G3 -pnpm build # required before G4 (madge reads compiled JS) -pnpm check:cycles # G4 -pnpm check:attw # G11 -pnpm check:publint # G12 -# G5: diff packages//dist/index.d.ts against .refactor/api-snapshot/.d.ts -# G6: find packages -name '*.ts' -not -path '*/dist/*' -not -path '*/node_modules/*' -not -name '*.test.ts' -exec wc -l {} + | sort -rn -``` - -## 7. Sessions - -| # | Date | Scope | -| - | ----------- | --------------------------------------------------------------------------- | -| 1 | 2026-05-14 | Phase 1 — investigation, baseline, snapshots, violations inventory. | -| 2 | 2026-05-14 | WIP recovery (`server.ts` split start) + sub-phase 2.1 (tooling baseline). | -| 3 | 2026-05-14 | Sub-phases 2.2 (surface audit) + 2.3 (typed errors + `SdkError`). | -| 4 | 2026-05-15 | Sub-phases 2.4 (AbortSignal plumbing) + 2.5 partial (cycles + eventlog split) + 2.6 + 2.7 audits + 2.8 verification + this report. | - -The git history is the narration. diff --git a/.refactor/STATE.md b/.refactor/STATE.md deleted file mode 100644 index 437771a..0000000 --- a/.refactor/STATE.md +++ /dev/null @@ -1,56 +0,0 @@ -# Refactor State - -- Branch: `refactor/automation` (based on `326dd2b` on `main`) -- Phase: 4 (Final report) — **complete with partial 2.5** -- Last completed sub-phases: 2.1, 2.2, 2.3, 2.4, 2.5 (eventlog - only), 2.6, 2.7 (survey), 2.8. See `FINAL_REPORT.md`. -- Deferred: rest of 2.5 (5 files >300 lines, ~80 fn-level violations), - rest of 2.7 (full TSDoc on @arcp/core), catch-block cause audit. -- Current package: n/a (workspace-wide refactor complete to checkpoint) -- Last commit on branch: see `git log refactor/automation`. -- Gates status (measured 2026-05-15 end of Session 4): - - G1 typecheck: 🟢 PASS - - G2 lint: 🔴 RED — biome clean; ESLint 80 errors (advisory) - - G3 tests: 🟢 PASS - - G4 cycles: 🟢 PASS (measured against compiled JS) - - G5 .d.ts diff: 🟡 ADDITIVE-ONLY (SdkError, client signal opts) - - G6 files ≤300 lines: 🔴 RED — 7 files over - - G7 functions ≤40 lines: 🔴 RED — 28 violations - - G8 complexity ≤10: 🔴 RED — 20 violations - - G9 params ≤3: 🔴 RED — 4 violations - - G10 TSDoc on every public export: 🔴 RED — not yet enforced - - G11 `attw`: 🟢 PASS - - G12 `publint`: 🟢 PASS -- Sessions consumed: 4 (see FINAL_REPORT.md §7) -- Estimated remaining work: - - ~~Sub-phase 2.1 (Tooling baseline)~~ — done Session 2. - - ~~Sub-phase 2.2 (Surface audit)~~ — done Session 3 (no drift). - - ~~Sub-phase 2.3 (Errors)~~ — done Session 3. - - Sub-phase 2.4 (Async hygiene): ~1 session — bigger than first - estimated; 7 client methods need `signal` plumbed through - options bag (additive, non-breaking). - - Sub-phase 2.5 (Complexity reduction): **~2–4 sessions** — 79 - ESLint errors across 12 files + 6 runtime import cycles to - untangle. - - Sub-phase 2.6 (Naming/style): ~0.5 session. - - Sub-phase 2.7 (TSDoc): ~1–2 sessions (broad surface). - - Sub-phase 2.8 (Build/publish): ~0.5 session — `attw` and - `publint` already clean. - - Sub-phase 2.9 (Verification + final report): ~0.5 session. - - **Total estimate: 6–9 sessions from here.** - -## Notes for the next session - -- Sub-phase 2.4 is the next chunk. See `violations.md` for the - precise list — 7 client methods need an optional `signal` added - via their options bag. All non-breaking additions; runtime - side already flows signal via `pending.register`. Add the param, - pass it through to `pending.register({ signal })` (or the - equivalent), and verify `.d.ts` diff is additive only. -- The 6 runtime cycles surfaced by `madge` are the result of the - WIP recovery and *should* be addressed in 2.5 alongside the - server/job-runner split, not earlier — fixing them now would - duplicate work. -- After 2.4 wraps, sub-phase 2.5 is the largest remaining chunk - and is what the user's WIP started. Session estimate for 2.5: - 2–4 sessions, file by file from `violations.md`. diff --git a/.refactor/api-snapshot/bun.d.ts b/.refactor/api-snapshot/bun.d.ts deleted file mode 100644 index 8f8597f..0000000 --- a/.refactor/api-snapshot/bun.d.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { ArcpServeHandle, BunServeArcpOptions } from "./types.js"; -export { BunWebSocketTransport } from "./transport.js"; -export type { ArcpServeHandle, BunServeArcpOptions } from "./types.js"; -/** - * Stand up a Bun-native ARCP listener. - * - * Uses `Bun.serve({ websocket: ... })` — no `ws` dependency. Per-connection - * state flows through `server.upgrade(req, { data })` so each - * `ServerWebSocket` is paired with its own {@link BunWebSocketTransport}. - * - * Example: - * ```ts - * import { serveArcp } from "@arcp/bun"; - * import { ARCPServer } from "@arcp/runtime"; - * - * const arcp = new ARCPServer({ ... }); - * const handle = serveArcp({ - * port: 7777, - * allowedHosts: ["localhost"], - * onTransport: (t) => arcp.accept(t), - * }); - * console.log(`listening at ${handle.url}`); - * ``` - */ -export declare function serveArcp(options: BunServeArcpOptions): ArcpServeHandle; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/client.d.ts b/.refactor/api-snapshot/client.d.ts deleted file mode 100644 index ebbaee7..0000000 --- a/.refactor/api-snapshot/client.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { ARCPClient, asEnvelopeOfType } from "./client.js"; -export type { ARCPClientOptions, ClientAutoAckOptions, ClientHandler, JobHandle, JobSubscription, SubmitOptions, } from "./types.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core.d.ts b/.refactor/api-snapshot/core.d.ts deleted file mode 100644 index 0e92333..0000000 --- a/.refactor/api-snapshot/core.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -export * from "./auth/index.js"; -export type { Brand, EventSeq, JobId, MessageId, ResumeToken, SessionId, TraceId, } from "./brands.js"; -export { type BaseEnvelope, BaseEnvelopeSchema, buildEnvelope, EnvelopeExtensionsSchema, type EnvelopeOptionalFields, isPreSessionType, isValidTraceId, messageEnvelope, pickDefined, type RoundTripEnvelope, RoundTripEnvelopeSchema, } from "./envelope.js"; -export { AgentNotAvailableError, AgentVersionNotAvailableError, ARCPError, type ARCPErrorOptions, BudgetExhaustedError, CancelledError, DuplicateKeyError, ERROR_CODES, type ErrorCode, type ErrorPayload, ErrorPayloadSchema, HeartbeatLostError, InternalError, InvalidRequestError, isErrorCode, isRetryableByDefault, JobNotFoundError, LeaseExpiredError, LeaseSubsetViolationError, PermissionDeniedError, ResumeWindowExpiredError, TimeoutError, UnauthenticatedError, } from "./errors.js"; -export { CORE_MESSAGE_TYPES, type CoreMessageType, classifyUnknownType, isCoreType, isVendorExtensionName, looksLikeCoreType, type UnknownTypeDisposition, validateExtensionsObject, type VendorExtensionName, } from "./extensions.js"; -export { type Logger, rootLogger, sessionLogger, silentLogger, } from "./logger.js"; -export * from "./messages/index.js"; -export * from "./state/index.js"; -export * from "./store/index.js"; -export * from "./transport/index.js"; -export * from "./util/index.js"; -export { IMPL_VERSION, intersectFeatures, isCompatibleVersion, PROTOCOL_VERSION, type ProtocolVersion, V1_1_FEATURES, type V1_1_Feature, } from "./version.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/auth.d.ts b/.refactor/api-snapshot/core/auth.d.ts deleted file mode 100644 index 5301188..0000000 --- a/.refactor/api-snapshot/core/auth.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { StaticBearerVerifier } from "./bearer.js"; -export type { BearerIdentity, BearerVerifier } from "./types.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/envelope.d.ts b/.refactor/api-snapshot/core/envelope.d.ts deleted file mode 100644 index 43c0429..0000000 --- a/.refactor/api-snapshot/core/envelope.d.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { z } from "zod"; -import type { EventSeq, JobId, MessageId, SessionId, TraceId } from "./brands.js"; -/** - * Schema for the `extensions` field on an envelope. - * - * Carries any extension namespace keys (validated by - * {@link validateExtensionsObject}) plus a reserved `optional` boolean. - */ -export declare const EnvelopeExtensionsSchema: z.ZodEffects, Record, Record>; -/** Whether a `trace_id` value is well-formed per §11. */ -export declare function isValidTraceId(value: string): boolean; -/** Whether `type` is allowed to omit `session_id`. */ -export declare function isPreSessionType(type: string): boolean; -/** - * Base envelope shape per §5.1. - * - * `payload` is `unknown`; per-message-type schemas in `messages/` narrow it - * to a specific shape via `z.discriminatedUnion("type", [...])` and direct - * extension of this base. - */ -export declare const BaseEnvelopeSchema: z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - type: z.ZodString; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - payload: z.ZodUnknown; -}, "strip", z.ZodTypeAny, { - type: string; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id?: (string & z.BRAND<"SessionId">) | undefined; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; - payload?: unknown; -}, { - type: string; - arcp: "1"; - id: string; - session_id?: string | undefined; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; - payload?: unknown; -}>; -/** - * The base envelope, type-only. Specific message envelopes refine this base - * by overriding `type` to a literal and `payload` to a typed schema. - */ -export type BaseEnvelope = z.infer; -/** - * Optional fields on the base envelope, used to construct envelopes by - * spreading only the fields that are defined. - * - * Each field uses `| undefined` so callers can pass `{ session_id: x }` - * where `x` may be undefined (zod's `.optional()` output type) and rely on - * {@link pickDefined} or {@link buildEnvelope} to strip undefined keys. - */ -export type EnvelopeOptionalFields = { - session_id?: SessionId | undefined; - job_id?: JobId | undefined; - trace_id?: TraceId | undefined; - event_seq?: EventSeq | undefined; - extensions?: Record | undefined; -}; -/** - * Strip keys whose value is `undefined`. Required for `exactOptionalPropertyTypes` - * compatibility when forwarding optional fields onto an envelope literal. - */ -export declare function pickDefined>(obj: T): Partial; -/** - * Build a per-type envelope schema. The result is a zod object schema with - * `type` constrained to the literal `T` and `payload` constrained to `P`. - * - * Inference handles the (complex) ZodObject shape better than writing it out. - */ -export declare function messageEnvelope(type: T, payload: P): z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; -} & { - type: z.ZodLiteral; - payload: P; -}, "strip", z.ZodTypeAny, z.objectUtil.addQuestionMarks; - id: z.ZodBranded; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; -} & { - type: z.ZodLiteral; - payload: P; -}>, any> extends infer T_1 ? { [k in keyof T_1]: T_1[k]; } : never, z.baseObjectInputType<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; -} & { - type: z.ZodLiteral; - payload: P; -}> extends infer T_2 ? { [k_1 in keyof T_2]: T_2[k_1]; } : never>; -/** - * Construct a fresh envelope object literal. Strips undefined optional fields - * so the result is acceptable under `exactOptionalPropertyTypes`. - */ -export declare function buildEnvelope(args: { - id: MessageId; - type: T; - payload: P; - optional?: EnvelopeOptionalFields; -}): BaseEnvelope & { - type: T; - payload: P; -}; -/** - * Round-trip a raw JSON value through the base envelope schema. - * - * Returns the parsed envelope shape with all unknown fields preserved (since - * zod's default mode strips, but we want to keep extension fields intact for - * the runtime dispatcher). - * - * Enforces the session_id requirement from §5.1: present on every envelope - * except `session.hello` / `session.welcome`. - */ -export declare const RoundTripEnvelopeSchema: z.ZodEffects; - id: z.ZodBranded; - type: z.ZodString; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - payload: z.ZodUnknown; -}, "passthrough", z.ZodTypeAny, z.objectOutputType<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - type: z.ZodString; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - payload: z.ZodUnknown; -}, z.ZodTypeAny, "passthrough">, z.objectInputType<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - type: z.ZodString; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - payload: z.ZodUnknown; -}, z.ZodTypeAny, "passthrough">>, z.objectOutputType<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - type: z.ZodString; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - payload: z.ZodUnknown; -}, z.ZodTypeAny, "passthrough">, z.objectInputType<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - type: z.ZodString; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - payload: z.ZodUnknown; -}, z.ZodTypeAny, "passthrough">>; -export type RoundTripEnvelope = z.infer; -//# sourceMappingURL=envelope.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/errors.d.ts b/.refactor/api-snapshot/core/errors.d.ts deleted file mode 100644 index 4b4c65f..0000000 --- a/.refactor/api-snapshot/core/errors.d.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { z } from "zod"; -/** - * Canonical ARCP error codes. - * - * v1.0 §12 specified 12 codes. v1.1 §12 adds three more - * (`AGENT_VERSION_NOT_AVAILABLE`, `LEASE_EXPIRED`, `BUDGET_EXHAUSTED`) for - * a total of 15. - * - * @see ARCP v1.1 §12. - */ -export declare const ERROR_CODES: readonly ["PERMISSION_DENIED", "LEASE_SUBSET_VIOLATION", "JOB_NOT_FOUND", "DUPLICATE_KEY", "AGENT_NOT_AVAILABLE", "AGENT_VERSION_NOT_AVAILABLE", "CANCELLED", "TIMEOUT", "RESUME_WINDOW_EXPIRED", "HEARTBEAT_LOST", "LEASE_EXPIRED", "BUDGET_EXHAUSTED", "INVALID_REQUEST", "UNAUTHENTICATED", "INTERNAL_ERROR"]; -/** Union of all canonical ARCP error codes. */ -export type ErrorCode = (typeof ERROR_CODES)[number]; -/** Type guard: is `value` a canonical error code? */ -export declare function isErrorCode(value: unknown): value is ErrorCode; -/** Whether a given error code is retryable by default. */ -export declare function isRetryableByDefault(code: ErrorCode): boolean; -/** - * Wire schema for an ARCP error payload (§12). - * - * Shape: `{ code, message, retryable, details? }`. Anything implementation-specific - * goes inside `details`. - */ -export declare const ErrorPayloadSchema: z.ZodObject<{ - code: z.ZodEnum<["PERMISSION_DENIED", "LEASE_SUBSET_VIOLATION", "JOB_NOT_FOUND", "DUPLICATE_KEY", "AGENT_NOT_AVAILABLE", "AGENT_VERSION_NOT_AVAILABLE", "CANCELLED", "TIMEOUT", "RESUME_WINDOW_EXPIRED", "HEARTBEAT_LOST", "LEASE_EXPIRED", "BUDGET_EXHAUSTED", "INVALID_REQUEST", "UNAUTHENTICATED", "INTERNAL_ERROR"]>; - message: z.ZodString; - retryable: z.ZodOptional; - details: z.ZodOptional>; -}, "strip", z.ZodTypeAny, { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - retryable?: boolean | undefined; - details?: Record | undefined; -}, { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - retryable?: boolean | undefined; - details?: Record | undefined; -}>; -export type ErrorPayload = z.infer; -/** Construction options for {@link ARCPError}. */ -export interface ARCPErrorOptions { - message: string; - code: ErrorCode; - retryable?: boolean | undefined; - details?: Record | undefined; - cause?: ARCPError | Error | undefined; -} -/** - * Base error type for all ARCP-internal failures. - * - * Always carries a canonical {@link ErrorCode}. Subclasses pin specific codes - * for ergonomic catches. - */ -export declare class ARCPError extends Error { - /** Canonical error code. */ - readonly code: ErrorCode; - /** Whether this error is retryable. Defaults from {@link isRetryableByDefault}. */ - readonly retryable: boolean; - /** Extra structured detail. */ - readonly details: Readonly>; - constructor(opts: ARCPErrorOptions); - /** Serialize to the wire `ErrorPayload` shape (§12). */ - toPayload(): ErrorPayload; - /** Re-hydrate an {@link ARCPError} from a wire payload. */ - static fromPayload(payload: ErrorPayload): ARCPError; -} -/** §12 `UNAUTHENTICATED`. Missing or invalid credentials. */ -export declare class UnauthenticatedError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `PERMISSION_DENIED`. Operation rejected by lease enforcement. */ -export declare class PermissionDeniedError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `LEASE_SUBSET_VIOLATION`. Delegation request expanded beyond parent lease. */ -export declare class LeaseSubsetViolationError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `INVALID_REQUEST`. Malformed envelope or payload schema violation. */ -export declare class InvalidRequestError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `JOB_NOT_FOUND`. Referenced `job_id` does not exist in this session. */ -export declare class JobNotFoundError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `DUPLICATE_KEY`. `idempotency_key` reuse with conflicting parameters. */ -export declare class DuplicateKeyError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `AGENT_NOT_AVAILABLE`. Requested `agent` is not registered. */ -export declare class AgentNotAvailableError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `TIMEOUT`. Job exceeded `max_runtime_sec` or other deadline. */ -export declare class TimeoutError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `RESUME_WINDOW_EXPIRED`. Resume attempted after the buffer window closed. */ -export declare class ResumeWindowExpiredError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `CANCELLED`. Operation cancelled by caller, runtime, or policy. */ -export declare class CancelledError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `HEARTBEAT_LOST`. Runtime detected client disconnection without close. */ -export declare class HeartbeatLostError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** §12 `INTERNAL_ERROR`. Unrecoverable runtime fault. Always retryable. */ -export declare class InternalError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** - * v1.1 §12 `LEASE_EXPIRED`. The lease's `expires_at` was reached during - * execution. Always non-retryable — naive retry will fail identically. - */ -export declare class LeaseExpiredError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** - * v1.1 §12 `BUDGET_EXHAUSTED`. A `cost.budget` counter reached zero or below. - * Always non-retryable — naive retry will fail identically. - */ -export declare class BudgetExhaustedError extends ARCPError { - constructor(message: string, opts?: Omit); -} -/** - * v1.1 §12 `AGENT_VERSION_NOT_AVAILABLE`. The agent name resolved but the - * requested version is not registered. Always non-retryable. - */ -export declare class AgentVersionNotAvailableError extends ARCPError { - constructor(message: string, opts?: Omit); -} -//# sourceMappingURL=errors.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/extensions.d.ts b/.refactor/api-snapshot/core/extensions.d.ts deleted file mode 100644 index 821cbb2..0000000 --- a/.refactor/api-snapshot/core/extensions.d.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Template-literal shape of a vendor extension name. Encodes only the - * `x-vendor..` prefix; the segment characters are still - * validated at runtime via `isVendorExtensionName`. - */ -export type VendorExtensionName = `x-vendor.${string}.${string}`; -/** Whether `name` is a syntactically valid `x-vendor.*` extension name. */ -export declare function isVendorExtensionName(name: string): name is VendorExtensionName; -/** - * Closed set of core v1.0 message types. Any other type must be in the - * `x-vendor.*` namespace. - */ -export declare const CORE_MESSAGE_TYPES: readonly ["session.hello", "session.welcome", "session.error", "session.bye", "job.submit", "job.accepted", "job.cancel", "job.event", "job.result", "job.error"]; -export type CoreMessageType = (typeof CORE_MESSAGE_TYPES)[number]; -/** Whether `type` is one of the ten core ARCP v1.0 message types. */ -export declare function isCoreType(type: string): type is CoreMessageType; -/** - * Whether `type` is a core type OR uses a reserved core prefix - * (`session.`/`job.`). Used to distinguish a *typo* from a *vendor - * extension*: `session.unknown` looks like a core typo, so we error; - * `x-vendor.foo` is an extension, so we route through - * {@link classifyUnknownType}. - */ -export declare function looksLikeCoreType(type: string): boolean; -/** - * Disposition for an inbound message whose `type` is unknown to this receiver. - * - * Unknown core-prefixed types and malformed type names produce an - * `INVALID_REQUEST`. Vendor-prefixed types with the `optional` flag in - * the envelope's `extensions` map are silently dropped. - */ -export type UnknownTypeDisposition = { - kind: "drop"; - reason: string; -} | { - kind: "error"; - code: "INVALID_REQUEST"; - reason: string; -}; -/** - * Decide what to do when we receive an envelope with an unknown `type`. - * - * - Unknown core-prefixed type → error `INVALID_REQUEST`. - * - Vendor extension, optional → silent drop. - * - Vendor extension, required → error `INVALID_REQUEST`. - * - Anything else → error `INVALID_REQUEST`. - */ -export declare function classifyUnknownType(type: string, options?: { - extensionsObject?: Record | undefined; -}): UnknownTypeDisposition; -/** - * Validates an envelope `extensions` object's keys. - * - * The reserved key `optional` is allowed bare. Every other key MUST be a - * valid vendor extension namespace (`x-vendor..`). - */ -export declare function validateExtensionsObject(obj: Record): void; -//# sourceMappingURL=extensions.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/logger.d.ts b/.refactor/api-snapshot/core/logger.d.ts deleted file mode 100644 index f99ee96..0000000 --- a/.refactor/api-snapshot/core/logger.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { type Logger as PinoLogger } from "pino"; -/** Re-export of pino's Logger type for downstream consumers. */ -export type Logger = PinoLogger; -/** - * Default root logger. Honors `ARCP_LOG_LEVEL` (default `info`). - * - * Tests use {@link silentLogger} to suppress output; production code accepts - * a logger via constructor injection so it can be wired to the host's pino - * configuration. - */ -export declare const rootLogger: Logger; -/** Convenience: create a child logger bound to a session. */ -export declare function sessionLogger(parent: Logger, sessionId: string): Logger; -/** A no-op logger for use in tests where structured output would be noise. */ -export declare const silentLogger: Logger; -//# sourceMappingURL=logger.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/messages.d.ts b/.refactor/api-snapshot/core/messages.d.ts deleted file mode 100644 index 7c67935..0000000 --- a/.refactor/api-snapshot/core/messages.d.ts +++ /dev/null @@ -1,2897 +0,0 @@ -/** - * Aggregate registry of every core message type defined by ARCP v1.0. - * - * `EnvelopeSchema` is the discriminated union over `type`. Parsing an inbound - * envelope through this schema yields a fully-typed envelope value or a - * `ZodError` on unknown/invalid types. - */ -import { z } from "zod"; -export * from "./artifacts.js"; -export * from "./control.js"; -export * from "./execution.js"; -export * from "./session.js"; -export * from "./telemetry.js"; -export type * from "./types.js"; -export declare const EnvelopeSchema: z.ZodDiscriminatedUnion<"type", readonly [z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.submit">; - payload: z.ZodObject<{ - agent: z.ZodString; - input: z.ZodUnknown; - lease_request: z.ZodOptional>>; - lease_constraints: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - expires_at?: string | undefined; - }, { - expires_at?: string | undefined; - }>>; - idempotency_key: z.ZodOptional; - max_runtime_sec: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - agent: string; - input?: unknown; - lease_request?: Record | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - idempotency_key?: string | undefined; - max_runtime_sec?: number | undefined; - }, { - agent: string; - input?: unknown; - lease_request?: Record | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - idempotency_key?: string | undefined; - max_runtime_sec?: number | undefined; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.submit"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - agent: string; - input?: unknown; - lease_request?: Record | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - idempotency_key?: string | undefined; - max_runtime_sec?: number | undefined; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.submit"; - arcp: "1"; - id: string; - session_id: string; - payload: { - agent: string; - input?: unknown; - lease_request?: Record | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - idempotency_key?: string | undefined; - max_runtime_sec?: number | undefined; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.accepted">; - payload: z.ZodObject<{ - job_id: z.ZodBranded; - agent: z.ZodOptional; - lease: z.ZodRecord>; - lease_constraints: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - expires_at?: string | undefined; - }, { - expires_at?: string | undefined; - }>>; - budget: z.ZodOptional>; - accepted_at: z.ZodString; - parent_job_id: z.ZodOptional>; - delegate_id: z.ZodOptional; - trace_id: z.ZodOptional>; - }, "strip", z.ZodTypeAny, { - job_id: string & z.BRAND<"JobId">; - lease: Record; - accepted_at: string; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - agent?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | undefined; - delegate_id?: string | undefined; - }, { - job_id: string; - lease: Record; - accepted_at: string; - trace_id?: string | undefined; - agent?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: string | undefined; - delegate_id?: string | undefined; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.accepted"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - payload: { - job_id: string & z.BRAND<"JobId">; - lease: Record; - accepted_at: string; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - agent?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | undefined; - delegate_id?: string | undefined; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.accepted"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - payload: { - job_id: string; - lease: Record; - accepted_at: string; - trace_id?: string | undefined; - agent?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: string | undefined; - delegate_id?: string | undefined; - }; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.cancel">; - payload: z.ZodObject<{ - reason: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - reason?: string | undefined; - }, { - reason?: string | undefined; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.cancel"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - payload: { - reason?: string | undefined; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.cancel"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - payload: { - reason?: string | undefined; - }; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.event">; - payload: z.ZodObject<{ - kind: z.ZodString; - ts: z.ZodString; - body: z.ZodUnknown; - }, "strip", z.ZodTypeAny, { - kind: string; - ts: string; - body?: unknown; - }, { - kind: string; - ts: string; - body?: unknown; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; - event_seq: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.event"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - event_seq: number & z.BRAND<"EventSeq">; - payload: { - kind: string; - ts: string; - body?: unknown; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.event"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - event_seq: number; - payload: { - kind: string; - ts: string; - body?: unknown; - }; - trace_id?: string | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.result">; - payload: z.ZodObject<{ - final_status: z.ZodLiteral<"success">; - summary: z.ZodOptional; - result: z.ZodOptional; - result_id: z.ZodOptional; - result_size: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - final_status: "success"; - result_id?: string | undefined; - summary?: string | undefined; - result?: unknown; - result_size?: number | undefined; - }, { - final_status: "success"; - result_id?: string | undefined; - summary?: string | undefined; - result?: unknown; - result_size?: number | undefined; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; - event_seq: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.result"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - event_seq: number & z.BRAND<"EventSeq">; - payload: { - final_status: "success"; - result_id?: string | undefined; - summary?: string | undefined; - result?: unknown; - result_size?: number | undefined; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.result"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - event_seq: number; - payload: { - final_status: "success"; - result_id?: string | undefined; - summary?: string | undefined; - result?: unknown; - result_size?: number | undefined; - }; - trace_id?: string | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.error">; - payload: z.ZodObject<{ - final_status: z.ZodEnum<["error", "cancelled", "timed_out"]>; - code: z.ZodEnum<["PERMISSION_DENIED", "LEASE_SUBSET_VIOLATION", "JOB_NOT_FOUND", "DUPLICATE_KEY", "AGENT_NOT_AVAILABLE", "AGENT_VERSION_NOT_AVAILABLE", "CANCELLED", "TIMEOUT", "RESUME_WINDOW_EXPIRED", "HEARTBEAT_LOST", "LEASE_EXPIRED", "BUDGET_EXHAUSTED", "INVALID_REQUEST", "UNAUTHENTICATED", "INTERNAL_ERROR"]>; - message: z.ZodString; - retryable: z.ZodOptional; - details: z.ZodOptional>; - }, "strip", z.ZodTypeAny, { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - final_status: "error" | "cancelled" | "timed_out"; - retryable?: boolean | undefined; - details?: Record | undefined; - }, { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - final_status: "error" | "cancelled" | "timed_out"; - retryable?: boolean | undefined; - details?: Record | undefined; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; - event_seq: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.error"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - event_seq: number & z.BRAND<"EventSeq">; - payload: { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - final_status: "error" | "cancelled" | "timed_out"; - retryable?: boolean | undefined; - details?: Record | undefined; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.error"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - event_seq: number; - payload: { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - final_status: "error" | "cancelled" | "timed_out"; - retryable?: boolean | undefined; - details?: Record | undefined; - }; - trace_id?: string | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.subscribe">; - payload: z.ZodObject<{ - job_id: z.ZodBranded; - from_event_seq: z.ZodOptional>; - history: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - job_id: string & z.BRAND<"JobId">; - from_event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - history?: boolean | undefined; - }, { - job_id: string; - from_event_seq?: number | undefined; - history?: boolean | undefined; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.subscribe"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - job_id: string & z.BRAND<"JobId">; - from_event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - history?: boolean | undefined; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.subscribe"; - arcp: "1"; - id: string; - session_id: string; - payload: { - job_id: string; - from_event_seq?: number | undefined; - history?: boolean | undefined; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.subscribed">; - payload: z.ZodObject<{ - job_id: z.ZodBranded; - current_status: z.ZodEnum<["pending", "running", "success", "error", "cancelled", "timed_out"]>; - agent: z.ZodString; - lease: z.ZodRecord>; - lease_constraints: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - expires_at?: string | undefined; - }, { - expires_at?: string | undefined; - }>>; - budget: z.ZodOptional>; - parent_job_id: z.ZodOptional>>; - trace_id: z.ZodOptional>; - subscribed_from: z.ZodBranded; - replayed: z.ZodBoolean; - }, "strip", z.ZodTypeAny, { - job_id: string & z.BRAND<"JobId">; - agent: string; - lease: Record; - current_status: "error" | "pending" | "running" | "success" | "cancelled" | "timed_out"; - subscribed_from: number & z.BRAND<"EventSeq">; - replayed: boolean; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | null | undefined; - }, { - job_id: string; - agent: string; - lease: Record; - current_status: "error" | "pending" | "running" | "success" | "cancelled" | "timed_out"; - subscribed_from: number; - replayed: boolean; - trace_id?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: string | null | undefined; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.subscribed"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - payload: { - job_id: string & z.BRAND<"JobId">; - agent: string; - lease: Record; - current_status: "error" | "pending" | "running" | "success" | "cancelled" | "timed_out"; - subscribed_from: number & z.BRAND<"EventSeq">; - replayed: boolean; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | null | undefined; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.subscribed"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - payload: { - job_id: string; - agent: string; - lease: Record; - current_status: "error" | "pending" | "running" | "success" | "cancelled" | "timed_out"; - subscribed_from: number; - replayed: boolean; - trace_id?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: string | null | undefined; - }; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.unsubscribe">; - payload: z.ZodObject<{ - job_id: z.ZodBranded; - }, "strip", z.ZodTypeAny, { - job_id: string & z.BRAND<"JobId">; - }, { - job_id: string; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.unsubscribe"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - job_id: string & z.BRAND<"JobId">; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.unsubscribe"; - arcp: "1"; - id: string; - session_id: string; - payload: { - job_id: string; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; -} & { - type: z.ZodLiteral<"session.hello">; - payload: z.ZodObject<{ - client: z.ZodObject<{ - name: z.ZodString; - version: z.ZodString; - fingerprint: z.ZodOptional; - principal: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }, { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }>; - auth: z.ZodObject<{ - scheme: z.ZodEnum<["bearer"]>; - token: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - scheme: "bearer"; - token?: string | undefined; - }, { - scheme: "bearer"; - token?: string | undefined; - }>; - capabilities: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough">>>; - resume: z.ZodOptional; - resume_token: z.ZodBranded; - last_event_seq: z.ZodBranded; - }, "strip", z.ZodTypeAny, { - session_id: string & z.BRAND<"SessionId">; - resume_token: string & z.BRAND<"ResumeToken">; - last_event_seq: number & z.BRAND<"EventSeq">; - }, { - session_id: string; - resume_token: string; - last_event_seq: number; - }>>; - }, "strip", z.ZodTypeAny, { - client: { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }; - auth: { - scheme: "bearer"; - token?: string | undefined; - }; - capabilities?: z.objectOutputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough"> | undefined; - resume?: { - session_id: string & z.BRAND<"SessionId">; - resume_token: string & z.BRAND<"ResumeToken">; - last_event_seq: number & z.BRAND<"EventSeq">; - } | undefined; - }, { - client: { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }; - auth: { - scheme: "bearer"; - token?: string | undefined; - }; - capabilities?: z.objectInputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough"> | undefined; - resume?: { - session_id: string; - resume_token: string; - last_event_seq: number; - } | undefined; - }>; -}, "strip", z.ZodTypeAny, { - type: "session.hello"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - payload: { - client: { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }; - auth: { - scheme: "bearer"; - token?: string | undefined; - }; - capabilities?: z.objectOutputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough"> | undefined; - resume?: { - session_id: string & z.BRAND<"SessionId">; - resume_token: string & z.BRAND<"ResumeToken">; - last_event_seq: number & z.BRAND<"EventSeq">; - } | undefined; - }; - session_id?: (string & z.BRAND<"SessionId">) | undefined; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.hello"; - arcp: "1"; - id: string; - payload: { - client: { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }; - auth: { - scheme: "bearer"; - token?: string | undefined; - }; - capabilities?: z.objectInputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough"> | undefined; - resume?: { - session_id: string; - resume_token: string; - last_event_seq: number; - } | undefined; - }; - session_id?: string | undefined; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.welcome">; - payload: z.ZodObject<{ - runtime: z.ZodObject<{ - name: z.ZodString; - version: z.ZodString; - fingerprint: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - version: string; - fingerprint?: string | undefined; - }, { - name: string; - version: string; - fingerprint?: string | undefined; - }>; - resume_token: z.ZodBranded; - resume_window_sec: z.ZodNumber; - heartbeat_interval_sec: z.ZodOptional; - capabilities: z.ZodObject<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough">>; - }, "strip", z.ZodTypeAny, { - resume_token: string & z.BRAND<"ResumeToken">; - capabilities: { - encodings?: string[] | undefined; - agents?: string[] | { - name: string; - versions: string[]; - default?: string | undefined; - }[] | undefined; - features?: string[] | undefined; - } & { - [k: string]: unknown; - }; - runtime: { - name: string; - version: string; - fingerprint?: string | undefined; - }; - resume_window_sec: number; - heartbeat_interval_sec?: number | undefined; - }, { - resume_token: string; - capabilities: { - encodings?: string[] | undefined; - agents?: string[] | { - name: string; - versions: string[]; - default?: string | undefined; - }[] | undefined; - features?: string[] | undefined; - } & { - [k: string]: unknown; - }; - runtime: { - name: string; - version: string; - fingerprint?: string | undefined; - }; - resume_window_sec: number; - heartbeat_interval_sec?: number | undefined; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.welcome"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - resume_token: string & z.BRAND<"ResumeToken">; - capabilities: { - encodings?: string[] | undefined; - agents?: string[] | { - name: string; - versions: string[]; - default?: string | undefined; - }[] | undefined; - features?: string[] | undefined; - } & { - [k: string]: unknown; - }; - runtime: { - name: string; - version: string; - fingerprint?: string | undefined; - }; - resume_window_sec: number; - heartbeat_interval_sec?: number | undefined; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.welcome"; - arcp: "1"; - id: string; - session_id: string; - payload: { - resume_token: string; - capabilities: { - encodings?: string[] | undefined; - agents?: string[] | { - name: string; - versions: string[]; - default?: string | undefined; - }[] | undefined; - features?: string[] | undefined; - } & { - [k: string]: unknown; - }; - runtime: { - name: string; - version: string; - fingerprint?: string | undefined; - }; - resume_window_sec: number; - heartbeat_interval_sec?: number | undefined; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; -} & { - type: z.ZodLiteral<"session.error">; - payload: z.ZodObject<{ - code: z.ZodEnum<["PERMISSION_DENIED", "LEASE_SUBSET_VIOLATION", "JOB_NOT_FOUND", "DUPLICATE_KEY", "AGENT_NOT_AVAILABLE", "AGENT_VERSION_NOT_AVAILABLE", "CANCELLED", "TIMEOUT", "RESUME_WINDOW_EXPIRED", "HEARTBEAT_LOST", "LEASE_EXPIRED", "BUDGET_EXHAUSTED", "INVALID_REQUEST", "UNAUTHENTICATED", "INTERNAL_ERROR"]>; - message: z.ZodString; - retryable: z.ZodOptional; - details: z.ZodOptional>; - }, "strip", z.ZodTypeAny, { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - retryable?: boolean | undefined; - details?: Record | undefined; - }, { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - retryable?: boolean | undefined; - details?: Record | undefined; - }>; -}, "strip", z.ZodTypeAny, { - type: "session.error"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - payload: { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - retryable?: boolean | undefined; - details?: Record | undefined; - }; - session_id?: (string & z.BRAND<"SessionId">) | undefined; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.error"; - arcp: "1"; - id: string; - payload: { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - retryable?: boolean | undefined; - details?: Record | undefined; - }; - session_id?: string | undefined; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.bye">; - payload: z.ZodObject<{ - reason: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - reason?: string | undefined; - }, { - reason?: string | undefined; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.bye"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - reason?: string | undefined; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.bye"; - arcp: "1"; - id: string; - session_id: string; - payload: { - reason?: string | undefined; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.ping">; - payload: z.ZodObject<{ - nonce: z.ZodString; - sent_at: z.ZodString; - }, "strip", z.ZodTypeAny, { - nonce: string; - sent_at: string; - }, { - nonce: string; - sent_at: string; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.ping"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - nonce: string; - sent_at: string; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.ping"; - arcp: "1"; - id: string; - session_id: string; - payload: { - nonce: string; - sent_at: string; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.pong">; - payload: z.ZodObject<{ - ping_nonce: z.ZodString; - received_at: z.ZodString; - }, "strip", z.ZodTypeAny, { - ping_nonce: string; - received_at: string; - }, { - ping_nonce: string; - received_at: string; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.pong"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - ping_nonce: string; - received_at: string; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.pong"; - arcp: "1"; - id: string; - session_id: string; - payload: { - ping_nonce: string; - received_at: string; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.ack">; - payload: z.ZodObject<{ - last_processed_seq: z.ZodNumber; - }, "strip", z.ZodTypeAny, { - last_processed_seq: number; - }, { - last_processed_seq: number; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.ack"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - last_processed_seq: number; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.ack"; - arcp: "1"; - id: string; - session_id: string; - payload: { - last_processed_seq: number; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.list_jobs">; - payload: z.ZodObject<{ - filter: z.ZodOptional>; - agent: z.ZodOptional; - created_after: z.ZodOptional; - created_before: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - }, { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - }>>; - limit: z.ZodOptional; - cursor: z.ZodOptional>; - }, "strip", z.ZodTypeAny, { - filter?: { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - } | undefined; - limit?: number | undefined; - cursor?: string | null | undefined; - }, { - filter?: { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - } | undefined; - limit?: number | undefined; - cursor?: string | null | undefined; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.list_jobs"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - filter?: { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - } | undefined; - limit?: number | undefined; - cursor?: string | null | undefined; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.list_jobs"; - arcp: "1"; - id: string; - session_id: string; - payload: { - filter?: { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - } | undefined; - limit?: number | undefined; - cursor?: string | null | undefined; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.jobs">; - payload: z.ZodObject<{ - request_id: z.ZodString; - jobs: z.ZodArray; - agent: z.ZodString; - status: z.ZodString; - lease: z.ZodRecord>; - parent_job_id: z.ZodOptional>>; - created_at: z.ZodString; - trace_id: z.ZodOptional>; - last_event_seq: z.ZodBranded; - }, "strip", z.ZodTypeAny, { - status: string; - job_id: string & z.BRAND<"JobId">; - agent: string; - lease: Record; - last_event_seq: number & z.BRAND<"EventSeq">; - created_at: string; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | null | undefined; - }, { - status: string; - job_id: string; - agent: string; - lease: Record; - last_event_seq: number; - created_at: string; - trace_id?: string | undefined; - parent_job_id?: string | null | undefined; - }>, "many">; - next_cursor: z.ZodNullable; - }, "strip", z.ZodTypeAny, { - request_id: string; - jobs: { - status: string; - job_id: string & z.BRAND<"JobId">; - agent: string; - lease: Record; - last_event_seq: number & z.BRAND<"EventSeq">; - created_at: string; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | null | undefined; - }[]; - next_cursor: string | null; - }, { - request_id: string; - jobs: { - status: string; - job_id: string; - agent: string; - lease: Record; - last_event_seq: number; - created_at: string; - trace_id?: string | undefined; - parent_job_id?: string | null | undefined; - }[]; - next_cursor: string | null; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.jobs"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - request_id: string; - jobs: { - status: string; - job_id: string & z.BRAND<"JobId">; - agent: string; - lease: Record; - last_event_seq: number & z.BRAND<"EventSeq">; - created_at: string; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | null | undefined; - }[]; - next_cursor: string | null; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.jobs"; - arcp: "1"; - id: string; - session_id: string; - payload: { - request_id: string; - jobs: { - status: string; - job_id: string; - agent: string; - lease: Record; - last_event_seq: number; - created_at: string; - trace_id?: string | undefined; - parent_job_id?: string | null | undefined; - }[]; - next_cursor: string | null; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}>, ...(z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.submit">; - payload: z.ZodObject<{ - agent: z.ZodString; - input: z.ZodUnknown; - lease_request: z.ZodOptional>>; - lease_constraints: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - expires_at?: string | undefined; - }, { - expires_at?: string | undefined; - }>>; - idempotency_key: z.ZodOptional; - max_runtime_sec: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - agent: string; - input?: unknown; - lease_request?: Record | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - idempotency_key?: string | undefined; - max_runtime_sec?: number | undefined; - }, { - agent: string; - input?: unknown; - lease_request?: Record | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - idempotency_key?: string | undefined; - max_runtime_sec?: number | undefined; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.submit"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - agent: string; - input?: unknown; - lease_request?: Record | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - idempotency_key?: string | undefined; - max_runtime_sec?: number | undefined; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.submit"; - arcp: "1"; - id: string; - session_id: string; - payload: { - agent: string; - input?: unknown; - lease_request?: Record | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - idempotency_key?: string | undefined; - max_runtime_sec?: number | undefined; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.accepted">; - payload: z.ZodObject<{ - job_id: z.ZodBranded; - agent: z.ZodOptional; - lease: z.ZodRecord>; - lease_constraints: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - expires_at?: string | undefined; - }, { - expires_at?: string | undefined; - }>>; - budget: z.ZodOptional>; - accepted_at: z.ZodString; - parent_job_id: z.ZodOptional>; - delegate_id: z.ZodOptional; - trace_id: z.ZodOptional>; - }, "strip", z.ZodTypeAny, { - job_id: string & z.BRAND<"JobId">; - lease: Record; - accepted_at: string; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - agent?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | undefined; - delegate_id?: string | undefined; - }, { - job_id: string; - lease: Record; - accepted_at: string; - trace_id?: string | undefined; - agent?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: string | undefined; - delegate_id?: string | undefined; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.accepted"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - payload: { - job_id: string & z.BRAND<"JobId">; - lease: Record; - accepted_at: string; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - agent?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | undefined; - delegate_id?: string | undefined; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.accepted"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - payload: { - job_id: string; - lease: Record; - accepted_at: string; - trace_id?: string | undefined; - agent?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: string | undefined; - delegate_id?: string | undefined; - }; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.cancel">; - payload: z.ZodObject<{ - reason: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - reason?: string | undefined; - }, { - reason?: string | undefined; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.cancel"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - payload: { - reason?: string | undefined; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.cancel"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - payload: { - reason?: string | undefined; - }; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.event">; - payload: z.ZodObject<{ - kind: z.ZodString; - ts: z.ZodString; - body: z.ZodUnknown; - }, "strip", z.ZodTypeAny, { - kind: string; - ts: string; - body?: unknown; - }, { - kind: string; - ts: string; - body?: unknown; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; - event_seq: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.event"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - event_seq: number & z.BRAND<"EventSeq">; - payload: { - kind: string; - ts: string; - body?: unknown; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.event"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - event_seq: number; - payload: { - kind: string; - ts: string; - body?: unknown; - }; - trace_id?: string | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.result">; - payload: z.ZodObject<{ - final_status: z.ZodLiteral<"success">; - summary: z.ZodOptional; - result: z.ZodOptional; - result_id: z.ZodOptional; - result_size: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - final_status: "success"; - result_id?: string | undefined; - summary?: string | undefined; - result?: unknown; - result_size?: number | undefined; - }, { - final_status: "success"; - result_id?: string | undefined; - summary?: string | undefined; - result?: unknown; - result_size?: number | undefined; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; - event_seq: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.result"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - event_seq: number & z.BRAND<"EventSeq">; - payload: { - final_status: "success"; - result_id?: string | undefined; - summary?: string | undefined; - result?: unknown; - result_size?: number | undefined; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.result"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - event_seq: number; - payload: { - final_status: "success"; - result_id?: string | undefined; - summary?: string | undefined; - result?: unknown; - result_size?: number | undefined; - }; - trace_id?: string | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.error">; - payload: z.ZodObject<{ - final_status: z.ZodEnum<["error", "cancelled", "timed_out"]>; - code: z.ZodEnum<["PERMISSION_DENIED", "LEASE_SUBSET_VIOLATION", "JOB_NOT_FOUND", "DUPLICATE_KEY", "AGENT_NOT_AVAILABLE", "AGENT_VERSION_NOT_AVAILABLE", "CANCELLED", "TIMEOUT", "RESUME_WINDOW_EXPIRED", "HEARTBEAT_LOST", "LEASE_EXPIRED", "BUDGET_EXHAUSTED", "INVALID_REQUEST", "UNAUTHENTICATED", "INTERNAL_ERROR"]>; - message: z.ZodString; - retryable: z.ZodOptional; - details: z.ZodOptional>; - }, "strip", z.ZodTypeAny, { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - final_status: "error" | "cancelled" | "timed_out"; - retryable?: boolean | undefined; - details?: Record | undefined; - }, { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - final_status: "error" | "cancelled" | "timed_out"; - retryable?: boolean | undefined; - details?: Record | undefined; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; - event_seq: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.error"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - event_seq: number & z.BRAND<"EventSeq">; - payload: { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - final_status: "error" | "cancelled" | "timed_out"; - retryable?: boolean | undefined; - details?: Record | undefined; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.error"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - event_seq: number; - payload: { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - final_status: "error" | "cancelled" | "timed_out"; - retryable?: boolean | undefined; - details?: Record | undefined; - }; - trace_id?: string | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.subscribe">; - payload: z.ZodObject<{ - job_id: z.ZodBranded; - from_event_seq: z.ZodOptional>; - history: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - job_id: string & z.BRAND<"JobId">; - from_event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - history?: boolean | undefined; - }, { - job_id: string; - from_event_seq?: number | undefined; - history?: boolean | undefined; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.subscribe"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - job_id: string & z.BRAND<"JobId">; - from_event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - history?: boolean | undefined; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.subscribe"; - arcp: "1"; - id: string; - session_id: string; - payload: { - job_id: string; - from_event_seq?: number | undefined; - history?: boolean | undefined; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.subscribed">; - payload: z.ZodObject<{ - job_id: z.ZodBranded; - current_status: z.ZodEnum<["pending", "running", "success", "error", "cancelled", "timed_out"]>; - agent: z.ZodString; - lease: z.ZodRecord>; - lease_constraints: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - expires_at?: string | undefined; - }, { - expires_at?: string | undefined; - }>>; - budget: z.ZodOptional>; - parent_job_id: z.ZodOptional>>; - trace_id: z.ZodOptional>; - subscribed_from: z.ZodBranded; - replayed: z.ZodBoolean; - }, "strip", z.ZodTypeAny, { - job_id: string & z.BRAND<"JobId">; - agent: string; - lease: Record; - current_status: "error" | "pending" | "running" | "success" | "cancelled" | "timed_out"; - subscribed_from: number & z.BRAND<"EventSeq">; - replayed: boolean; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | null | undefined; - }, { - job_id: string; - agent: string; - lease: Record; - current_status: "error" | "pending" | "running" | "success" | "cancelled" | "timed_out"; - subscribed_from: number; - replayed: boolean; - trace_id?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: string | null | undefined; - }>; -} & { - session_id: z.ZodBranded; - job_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.subscribed"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - job_id: string & z.BRAND<"JobId">; - payload: { - job_id: string & z.BRAND<"JobId">; - agent: string; - lease: Record; - current_status: "error" | "pending" | "running" | "success" | "cancelled" | "timed_out"; - subscribed_from: number & z.BRAND<"EventSeq">; - replayed: boolean; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | null | undefined; - }; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.subscribed"; - arcp: "1"; - id: string; - session_id: string; - job_id: string; - payload: { - job_id: string; - agent: string; - lease: Record; - current_status: "error" | "pending" | "running" | "success" | "cancelled" | "timed_out"; - subscribed_from: number; - replayed: boolean; - trace_id?: string | undefined; - lease_constraints?: { - expires_at?: string | undefined; - } | undefined; - budget?: Record | undefined; - parent_job_id?: string | null | undefined; - }; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"job.unsubscribe">; - payload: z.ZodObject<{ - job_id: z.ZodBranded; - }, "strip", z.ZodTypeAny, { - job_id: string & z.BRAND<"JobId">; - }, { - job_id: string; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "job.unsubscribe"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - job_id: string & z.BRAND<"JobId">; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "job.unsubscribe"; - arcp: "1"; - id: string; - session_id: string; - payload: { - job_id: string; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; -} & { - type: z.ZodLiteral<"session.hello">; - payload: z.ZodObject<{ - client: z.ZodObject<{ - name: z.ZodString; - version: z.ZodString; - fingerprint: z.ZodOptional; - principal: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }, { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }>; - auth: z.ZodObject<{ - scheme: z.ZodEnum<["bearer"]>; - token: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - scheme: "bearer"; - token?: string | undefined; - }, { - scheme: "bearer"; - token?: string | undefined; - }>; - capabilities: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough">>>; - resume: z.ZodOptional; - resume_token: z.ZodBranded; - last_event_seq: z.ZodBranded; - }, "strip", z.ZodTypeAny, { - session_id: string & z.BRAND<"SessionId">; - resume_token: string & z.BRAND<"ResumeToken">; - last_event_seq: number & z.BRAND<"EventSeq">; - }, { - session_id: string; - resume_token: string; - last_event_seq: number; - }>>; - }, "strip", z.ZodTypeAny, { - client: { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }; - auth: { - scheme: "bearer"; - token?: string | undefined; - }; - capabilities?: z.objectOutputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough"> | undefined; - resume?: { - session_id: string & z.BRAND<"SessionId">; - resume_token: string & z.BRAND<"ResumeToken">; - last_event_seq: number & z.BRAND<"EventSeq">; - } | undefined; - }, { - client: { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }; - auth: { - scheme: "bearer"; - token?: string | undefined; - }; - capabilities?: z.objectInputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough"> | undefined; - resume?: { - session_id: string; - resume_token: string; - last_event_seq: number; - } | undefined; - }>; -}, "strip", z.ZodTypeAny, { - type: "session.hello"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - payload: { - client: { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }; - auth: { - scheme: "bearer"; - token?: string | undefined; - }; - capabilities?: z.objectOutputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough"> | undefined; - resume?: { - session_id: string & z.BRAND<"SessionId">; - resume_token: string & z.BRAND<"ResumeToken">; - last_event_seq: number & z.BRAND<"EventSeq">; - } | undefined; - }; - session_id?: (string & z.BRAND<"SessionId">) | undefined; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.hello"; - arcp: "1"; - id: string; - payload: { - client: { - name: string; - version: string; - fingerprint?: string | undefined; - principal?: string | undefined; - }; - auth: { - scheme: "bearer"; - token?: string | undefined; - }; - capabilities?: z.objectInputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough"> | undefined; - resume?: { - session_id: string; - resume_token: string; - last_event_seq: number; - } | undefined; - }; - session_id?: string | undefined; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.welcome">; - payload: z.ZodObject<{ - runtime: z.ZodObject<{ - name: z.ZodString; - version: z.ZodString; - fingerprint: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - version: string; - fingerprint?: string | undefined; - }, { - name: string; - version: string; - fingerprint?: string | undefined; - }>; - resume_token: z.ZodBranded; - resume_window_sec: z.ZodNumber; - heartbeat_interval_sec: z.ZodOptional; - capabilities: z.ZodObject<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, "passthrough", z.ZodTypeAny, z.objectOutputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough">, z.objectInputType<{ - encodings: z.ZodOptional>; - agents: z.ZodOptional, z.ZodArray; - default: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - name: string; - versions: string[]; - default?: string | undefined; - }, { - name: string; - versions: string[]; - default?: string | undefined; - }>, "many">]>>; - features: z.ZodOptional>; - }, z.ZodTypeAny, "passthrough">>; - }, "strip", z.ZodTypeAny, { - resume_token: string & z.BRAND<"ResumeToken">; - capabilities: { - encodings?: string[] | undefined; - agents?: string[] | { - name: string; - versions: string[]; - default?: string | undefined; - }[] | undefined; - features?: string[] | undefined; - } & { - [k: string]: unknown; - }; - runtime: { - name: string; - version: string; - fingerprint?: string | undefined; - }; - resume_window_sec: number; - heartbeat_interval_sec?: number | undefined; - }, { - resume_token: string; - capabilities: { - encodings?: string[] | undefined; - agents?: string[] | { - name: string; - versions: string[]; - default?: string | undefined; - }[] | undefined; - features?: string[] | undefined; - } & { - [k: string]: unknown; - }; - runtime: { - name: string; - version: string; - fingerprint?: string | undefined; - }; - resume_window_sec: number; - heartbeat_interval_sec?: number | undefined; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.welcome"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - resume_token: string & z.BRAND<"ResumeToken">; - capabilities: { - encodings?: string[] | undefined; - agents?: string[] | { - name: string; - versions: string[]; - default?: string | undefined; - }[] | undefined; - features?: string[] | undefined; - } & { - [k: string]: unknown; - }; - runtime: { - name: string; - version: string; - fingerprint?: string | undefined; - }; - resume_window_sec: number; - heartbeat_interval_sec?: number | undefined; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.welcome"; - arcp: "1"; - id: string; - session_id: string; - payload: { - resume_token: string; - capabilities: { - encodings?: string[] | undefined; - agents?: string[] | { - name: string; - versions: string[]; - default?: string | undefined; - }[] | undefined; - features?: string[] | undefined; - } & { - [k: string]: unknown; - }; - runtime: { - name: string; - version: string; - fingerprint?: string | undefined; - }; - resume_window_sec: number; - heartbeat_interval_sec?: number | undefined; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - session_id: z.ZodOptional>; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; -} & { - type: z.ZodLiteral<"session.error">; - payload: z.ZodObject<{ - code: z.ZodEnum<["PERMISSION_DENIED", "LEASE_SUBSET_VIOLATION", "JOB_NOT_FOUND", "DUPLICATE_KEY", "AGENT_NOT_AVAILABLE", "AGENT_VERSION_NOT_AVAILABLE", "CANCELLED", "TIMEOUT", "RESUME_WINDOW_EXPIRED", "HEARTBEAT_LOST", "LEASE_EXPIRED", "BUDGET_EXHAUSTED", "INVALID_REQUEST", "UNAUTHENTICATED", "INTERNAL_ERROR"]>; - message: z.ZodString; - retryable: z.ZodOptional; - details: z.ZodOptional>; - }, "strip", z.ZodTypeAny, { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - retryable?: boolean | undefined; - details?: Record | undefined; - }, { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - retryable?: boolean | undefined; - details?: Record | undefined; - }>; -}, "strip", z.ZodTypeAny, { - type: "session.error"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - payload: { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - retryable?: boolean | undefined; - details?: Record | undefined; - }; - session_id?: (string & z.BRAND<"SessionId">) | undefined; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.error"; - arcp: "1"; - id: string; - payload: { - code: "PERMISSION_DENIED" | "LEASE_SUBSET_VIOLATION" | "JOB_NOT_FOUND" | "DUPLICATE_KEY" | "AGENT_NOT_AVAILABLE" | "AGENT_VERSION_NOT_AVAILABLE" | "CANCELLED" | "TIMEOUT" | "RESUME_WINDOW_EXPIRED" | "HEARTBEAT_LOST" | "LEASE_EXPIRED" | "BUDGET_EXHAUSTED" | "INVALID_REQUEST" | "UNAUTHENTICATED" | "INTERNAL_ERROR"; - message: string; - retryable?: boolean | undefined; - details?: Record | undefined; - }; - session_id?: string | undefined; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.bye">; - payload: z.ZodObject<{ - reason: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - reason?: string | undefined; - }, { - reason?: string | undefined; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.bye"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - reason?: string | undefined; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.bye"; - arcp: "1"; - id: string; - session_id: string; - payload: { - reason?: string | undefined; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.ping">; - payload: z.ZodObject<{ - nonce: z.ZodString; - sent_at: z.ZodString; - }, "strip", z.ZodTypeAny, { - nonce: string; - sent_at: string; - }, { - nonce: string; - sent_at: string; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.ping"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - nonce: string; - sent_at: string; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.ping"; - arcp: "1"; - id: string; - session_id: string; - payload: { - nonce: string; - sent_at: string; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.pong">; - payload: z.ZodObject<{ - ping_nonce: z.ZodString; - received_at: z.ZodString; - }, "strip", z.ZodTypeAny, { - ping_nonce: string; - received_at: string; - }, { - ping_nonce: string; - received_at: string; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.pong"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - ping_nonce: string; - received_at: string; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.pong"; - arcp: "1"; - id: string; - session_id: string; - payload: { - ping_nonce: string; - received_at: string; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.ack">; - payload: z.ZodObject<{ - last_processed_seq: z.ZodNumber; - }, "strip", z.ZodTypeAny, { - last_processed_seq: number; - }, { - last_processed_seq: number; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.ack"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - last_processed_seq: number; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.ack"; - arcp: "1"; - id: string; - session_id: string; - payload: { - last_processed_seq: number; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.list_jobs">; - payload: z.ZodObject<{ - filter: z.ZodOptional>; - agent: z.ZodOptional; - created_after: z.ZodOptional; - created_before: z.ZodOptional; - }, "strip", z.ZodTypeAny, { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - }, { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - }>>; - limit: z.ZodOptional; - cursor: z.ZodOptional>; - }, "strip", z.ZodTypeAny, { - filter?: { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - } | undefined; - limit?: number | undefined; - cursor?: string | null | undefined; - }, { - filter?: { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - } | undefined; - limit?: number | undefined; - cursor?: string | null | undefined; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.list_jobs"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - filter?: { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - } | undefined; - limit?: number | undefined; - cursor?: string | null | undefined; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.list_jobs"; - arcp: "1"; - id: string; - session_id: string; - payload: { - filter?: { - status?: string[] | undefined; - agent?: string | undefined; - created_after?: string | undefined; - created_before?: string | undefined; - } | undefined; - limit?: number | undefined; - cursor?: string | null | undefined; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}> | z.ZodObject<{ - arcp: z.ZodLiteral<"1">; - id: z.ZodBranded; - job_id: z.ZodOptional>; - trace_id: z.ZodOptional>; - event_seq: z.ZodOptional>; - extensions: z.ZodOptional, Record, Record>>; - type: z.ZodLiteral<"session.jobs">; - payload: z.ZodObject<{ - request_id: z.ZodString; - jobs: z.ZodArray; - agent: z.ZodString; - status: z.ZodString; - lease: z.ZodRecord>; - parent_job_id: z.ZodOptional>>; - created_at: z.ZodString; - trace_id: z.ZodOptional>; - last_event_seq: z.ZodBranded; - }, "strip", z.ZodTypeAny, { - status: string; - job_id: string & z.BRAND<"JobId">; - agent: string; - lease: Record; - last_event_seq: number & z.BRAND<"EventSeq">; - created_at: string; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | null | undefined; - }, { - status: string; - job_id: string; - agent: string; - lease: Record; - last_event_seq: number; - created_at: string; - trace_id?: string | undefined; - parent_job_id?: string | null | undefined; - }>, "many">; - next_cursor: z.ZodNullable; - }, "strip", z.ZodTypeAny, { - request_id: string; - jobs: { - status: string; - job_id: string & z.BRAND<"JobId">; - agent: string; - lease: Record; - last_event_seq: number & z.BRAND<"EventSeq">; - created_at: string; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | null | undefined; - }[]; - next_cursor: string | null; - }, { - request_id: string; - jobs: { - status: string; - job_id: string; - agent: string; - lease: Record; - last_event_seq: number; - created_at: string; - trace_id?: string | undefined; - parent_job_id?: string | null | undefined; - }[]; - next_cursor: string | null; - }>; -} & { - session_id: z.ZodBranded; -}, "strip", z.ZodTypeAny, { - type: "session.jobs"; - arcp: "1"; - id: string & z.BRAND<"MessageId">; - session_id: string & z.BRAND<"SessionId">; - payload: { - request_id: string; - jobs: { - status: string; - job_id: string & z.BRAND<"JobId">; - agent: string; - lease: Record; - last_event_seq: number & z.BRAND<"EventSeq">; - created_at: string; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - parent_job_id?: (string & z.BRAND<"JobId">) | null | undefined; - }[]; - next_cursor: string | null; - }; - job_id?: (string & z.BRAND<"JobId">) | undefined; - trace_id?: (string & z.BRAND<"TraceId">) | undefined; - event_seq?: (number & z.BRAND<"EventSeq">) | undefined; - extensions?: Record | undefined; -}, { - type: "session.jobs"; - arcp: "1"; - id: string; - session_id: string; - payload: { - request_id: string; - jobs: { - status: string; - job_id: string; - agent: string; - lease: Record; - last_event_seq: number; - created_at: string; - trace_id?: string | undefined; - parent_job_id?: string | null | undefined; - }[]; - next_cursor: string | null; - }; - job_id?: string | undefined; - trace_id?: string | undefined; - event_seq?: number | undefined; - extensions?: Record | undefined; -}>)[]]>; -export type Envelope = z.infer; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/state.d.ts b/.refactor/api-snapshot/core/state.d.ts deleted file mode 100644 index 2d66a88..0000000 --- a/.refactor/api-snapshot/core/state.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { PendingRegistry } from "./pending.js"; -export { negotiateCapabilities, SessionState } from "./session.js"; -export type { PendingMeta, SessionPhase, SessionSnapshot } from "./types.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/store.d.ts b/.refactor/api-snapshot/core/store.d.ts deleted file mode 100644 index 3290b75..0000000 --- a/.refactor/api-snapshot/core/store.d.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { EventLog, EventRowEnvelopeSchema, type ParsedRowEnvelope, } from "./eventlog.js"; -export type { EventLogFilter, EventLogOptions } from "./types.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/transport.d.ts b/.refactor/api-snapshot/core/transport.d.ts deleted file mode 100644 index e825de5..0000000 --- a/.refactor/api-snapshot/core/transport.d.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { MemoryTransport, pairMemoryTransports } from "./memory.js"; -export { StdioTransport } from "./stdio.js"; -export type { FrameHandler, SendableFrame, Transport, WebSocketServerHandle, WireFrame, } from "./types.js"; -export { startWebSocketServer, WebSocketTransport } from "./websocket.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/types.d.ts b/.refactor/api-snapshot/core/types.d.ts deleted file mode 100644 index 2949f40..0000000 --- a/.refactor/api-snapshot/core/types.d.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Aggregated type-only barrel for `@arcp/core`. - * - * Use this entry point for a single import path covering the public TS type - * surface of the package: - * - * ```ts - * import type { - * Envelope, JobEvent, Lease, ErrorCode, SessionId, JobId, EventSeq, - * } from "@arcp/core/types"; - * ``` - * - * The per-subpath entry points (`@arcp/core/envelope`, `@arcp/core/errors`, - * `@arcp/core/messages`, ...) stay; this barrel is purely additive. - */ -export type { Brand, EventSeq, JobId, MessageId, ResumeToken, SessionId, TraceId, } from "./brands.js"; -export type { BaseEnvelope, EnvelopeOptionalFields, RoundTripEnvelope, } from "./envelope.js"; -export type { ARCPErrorOptions, ErrorCode, ErrorPayload } from "./errors.js"; -export type { CoreMessageType, UnknownTypeDisposition, VendorExtensionName, } from "./extensions.js"; -export type { Logger } from "./logger.js"; -export type { AgentInventoryEntry, ArtifactRef, ArtifactRefBody, AuthCredential, AuthScheme, Capabilities, ClientIdentity, DelegateBody, Envelope, JobAcceptedPayload, JobBudget, JobCancelPayload, JobErrorFinalStatus, JobErrorPayload, JobEventPayload, JobListEntry, JobResultPayload, JobStateName, JobSubmitPayload, JobSubscribePayload, JobSubscribedPayload, JobUnsubscribePayload, Lease, LeaseConstraints, LogBody, LogLevel, LogPayload, MetricBody, MetricPayload, ParsedAgentRef, ParsedBudgetAmount, ProgressBody, ReservedCapabilityName, ReservedEventKind, ResultChunkBody, RuntimeIdentity, SessionAckPayload, SessionByePayload, SessionErrorPayload, SessionHelloPayload, SessionJobsPayload, SessionListJobsFilter, SessionListJobsPayload, SessionPingPayload, SessionPongPayload, SessionResume, SessionWelcomePayload, StatusBody, TerminalJobState, ThoughtBody, ToolCallBody, ToolResultBody, } from "./messages/index.js"; -export type { PendingMeta, SessionPhase, SessionSnapshot, } from "./state/index.js"; -export type { EventLogFilter, EventLogOptions } from "./store/types.js"; -export type { ParsedRowEnvelope } from "./store/eventlog.js"; -export type { FrameHandler, SendableFrame, Transport, WebSocketServerHandle, WireFrame, } from "./transport/index.js"; -export type { ValidationError } from "./util/index.js"; -export type { ProtocolVersion, V1_1_Feature } from "./version.js"; -export type { BearerIdentity, BearerVerifier } from "./auth/index.js"; -//# sourceMappingURL=types.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/util.d.ts b/.refactor/api-snapshot/core/util.d.ts deleted file mode 100644 index 3771368..0000000 --- a/.refactor/api-snapshot/core/util.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -export { combineSignals } from "./abort.js"; -export { Deferred } from "./deferred.js"; -export { validateAgainstSchema } from "./json-schema.js"; -export { safeSetInterval, safeSetTimeout } from "./timers.js"; -export type { ValidationError } from "./types.js"; -export { newId, newJobId, newMessageId, newSessionId, nowTimestamp, } from "./ulid.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/core/version.d.ts b/.refactor/api-snapshot/core/version.d.ts deleted file mode 100644 index 5079483..0000000 --- a/.refactor/api-snapshot/core/version.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Protocol version implemented by this package. - * - * Tracks ARCP v1.1 (additive over v1.0). The `arcp` envelope field is the - * literal major-version string per §5.1; v1.1 keeps this at `"1"` and uses - * the feature-negotiation capability in `session.hello`/`session.welcome` - * to detect what each peer supports. - */ -export declare const PROTOCOL_VERSION: "1"; -/** Implementation version of this package. Bump on releases. */ -export declare const IMPL_VERSION: "0.2.0"; -/** - * v1.1 feature flag names advertised in - * `session.hello.payload.capabilities.features` and - * `session.welcome.payload.capabilities.features`. - * - * The effective feature set is the intersection of the two lists (§6.2). - * Neither peer may use a feature outside that intersection. - */ -export declare const V1_1_FEATURES: readonly ["heartbeat", "ack", "list_jobs", "subscribe", "lease_expires_at", "cost.budget", "progress", "result_chunk", "agent_versions"]; -/** Union of canonical v1.1 feature flag names. */ -export type V1_1_Feature = (typeof V1_1_FEATURES)[number]; -/** - * Template-literal type that pins the envelope `arcp` field to the literal - * `PROTOCOL_VERSION`. Useful for asserting an outbound envelope is - * wire-compatible at compile time. - */ -export type ProtocolVersion = typeof PROTOCOL_VERSION; -/** - * Whether `other` is wire-compatible with this implementation. - * - * v1.1 stays at `arcp: "1"` literally — the wire-format major did not - * change between v1.0 and v1.1. Feature negotiation happens through the - * `capabilities.features` array. - */ -export declare function isCompatibleVersion(other: string): boolean; -/** - * Compute the negotiated feature intersection between two peers' - * advertised feature lists. Either may be undefined (v1.0 peer). - */ -export declare function intersectFeatures(a: readonly string[] | undefined, b: readonly string[] | undefined): string[]; -//# sourceMappingURL=version.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/express.d.ts b/.refactor/api-snapshot/express.d.ts deleted file mode 100644 index d5e73f7..0000000 --- a/.refactor/api-snapshot/express.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Server as HttpServer } from "node:http"; -import { type ArcpUpgradeHandle, type AttachArcpUpgradeOptions } from "@arcp/node"; -import { type Express } from "express"; -import type { CreateArcpExpressAppOptions } from "./types.js"; -export type { CreateArcpExpressAppOptions } from "./types.js"; -/** - * Create an Express app with safe defaults for ARCP deployments: - * - `x-powered-by` is disabled - * - optional `Host` header allow-list (DNS rebinding protection) - * - `trust proxy` is *not* set (be explicit at the deployment layer) - * - * This does NOT attach the ARCP WebSocket upgrade. Call - * {@link attachArcpToExpress} on the underlying `http.Server` once you have - * one. - */ -export declare function createArcpExpressApp(options?: CreateArcpExpressAppOptions): Express; -/** - * Attach the ARCP WebSocket upgrade handler to the `http.Server` backing an - * Express app. Pass the result of `app.listen(...)` or your own - * `http.createServer(app)` instance. - * - * Example: - * ```ts - * import { createArcpExpressApp, attachArcpToExpress } from "@arcp/express"; - * import { ARCPServer } from "@arcp/runtime"; - * - * const app = createArcpExpressApp({ allowedHosts: ["localhost"] }); - * const arcp = new ARCPServer({ ... }); - * const server = app.listen(7777); - * - * attachArcpToExpress(server, { - * path: "/arcp", - * allowedHosts: ["localhost"], - * onTransport: (transport) => arcp.accept(transport), - * }); - * ``` - */ -export declare function attachArcpToExpress(server: HttpServer, options: AttachArcpUpgradeOptions): ArcpUpgradeHandle; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/fastify.d.ts b/.refactor/api-snapshot/fastify.d.ts deleted file mode 100644 index 4107588..0000000 --- a/.refactor/api-snapshot/fastify.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { type ArcpUpgradeHandle, type AttachArcpUpgradeOptions } from "@arcp/node"; -import type { FastifyInstance } from "fastify"; -export type { ArcpUpgradeHandle, AttachArcpUpgradeOptions } from "./types.js"; -/** - * Attach the ARCP WebSocket upgrade handler to a Fastify instance. - * - * Mounts a single upgrade listener on the underlying `http.Server` - * (`app.server`) at the configured path (`/arcp` by default). DNS rebinding - * protection is enforced via `allowedHosts` exactly as in `@arcp/express` and - * `@arcp/node`. - * - * Fastify itself is NOT consulted for the upgrade — Node's `http.Server` - * emits the `upgrade` event before Fastify's request pipeline runs. The HTTP - * routes registered with Fastify remain untouched. - * - * Example: - * ```ts - * import Fastify from "fastify"; - * import { ARCPServer } from "@arcp/runtime"; - * import { attachArcpToFastify } from "@arcp/fastify"; - * - * const app = Fastify(); - * const arcp = new ARCPServer({ ... }); - * - * await app.listen({ port: 7777 }); - * attachArcpToFastify(app, { - * path: "/arcp", - * allowedHosts: ["localhost"], - * onTransport: (transport) => arcp.accept(transport), - * }); - * ``` - * - * The returned handle's `close()` detaches the upgrade listener and closes - * all open WebSocket connections. Call it before `app.close()` if you want - * deterministic shutdown ordering; otherwise the WS sockets will close as - * a side effect of the HTTP server shutting down. - */ -export declare function attachArcpToFastify(app: FastifyInstance, options: AttachArcpUpgradeOptions): ArcpUpgradeHandle; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/hono.d.ts b/.refactor/api-snapshot/hono.d.ts deleted file mode 100644 index 28e6ef2..0000000 --- a/.refactor/api-snapshot/hono.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { Server as HttpServer } from "node:http"; -import { type ArcpUpgradeHandle, type AttachArcpUpgradeOptions } from "@arcp/node"; -import { Hono } from "hono"; -import type { CreateArcpHonoAppOptions } from "./types.js"; -export type { CreateArcpHonoAppOptions } from "./types.js"; -/** - * Create a Hono app with safe defaults for ARCP deployments: - * - optional `Host` header allow-list (DNS rebinding protection) - * - * This does NOT attach the ARCP WebSocket upgrade. Hono runs on Web-standard - * `Request`/`Response`, not on Node's `http.Server`, so the upgrade has to - * be attached separately to the underlying server (typically the one - * returned by `@hono/node-server`'s `serve()`). - * - * Example: - * ```ts - * import { serve } from "@hono/node-server"; - * import { createArcpHonoApp, attachArcpToHono } from "@arcp/hono"; - * import { ARCPServer } from "@arcp/runtime"; - * - * const app = createArcpHonoApp({ allowedHosts: ["localhost"] }); - * const arcp = new ARCPServer({ ... }); - * - * const server = serve({ fetch: app.fetch, port: 7777 }); - * attachArcpToHono(server, { - * path: "/arcp", - * allowedHosts: ["localhost"], - * onTransport: (transport) => arcp.accept(transport), - * }); - * ``` - */ -export declare function createArcpHonoApp(options?: CreateArcpHonoAppOptions): Hono; -/** - * Attach the ARCP WebSocket upgrade handler to the `http.Server` returned by - * `@hono/node-server`'s `serve()`. The `serve()` return value implements - * Node's `http.Server` interface, so this is the same call as for Express. - */ -export declare function attachArcpToHono(server: HttpServer, options: AttachArcpUpgradeOptions): ArcpUpgradeHandle; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/middleware-otel.d.ts b/.refactor/api-snapshot/middleware-otel.d.ts deleted file mode 100644 index 24b6548..0000000 --- a/.refactor/api-snapshot/middleware-otel.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Transport } from "@arcp/core/transport"; -import type { WithTracingOptions } from "./types.js"; -export type { WithTracingOptions } from "./types.js"; -/** - * Wrap a {@link Transport} so each frame produces a span and W3C trace - * context is propagated through `envelope.extensions["x.otel"]`. - * - * The returned transport satisfies the same interface, so it is a drop-in - * for `ARCPServer.accept(...)` / `ARCPClient.connect(...)`. - */ -export declare function withTracing(inner: Transport, options: WithTracingOptions): Transport; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/node.d.ts b/.refactor/api-snapshot/node.d.ts deleted file mode 100644 index 1ab43a3..0000000 --- a/.refactor/api-snapshot/node.d.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { Server as HttpServer } from "node:http"; -import type { ArcpUpgradeHandle, AttachArcpUpgradeOptions } from "./types.js"; -export type { ArcpUpgradeHandle, AttachArcpUpgradeOptions } from "./types.js"; -/** - * Attach an ARCP WebSocket upgrade handler to an existing Node `http.Server`. - * - * Use this when you already have an HTTP server (Express, Hono, Fastify, - * vanilla `http.createServer`, ...) and want to mount ARCP at a specific - * path without giving up the rest of the server. - * - * Example: - * ```ts - * import { createServer } from "node:http"; - * import { ARCPServer } from "@arcp/runtime"; - * import { attachArcpUpgrade } from "@arcp/node"; - * - * const httpServer = createServer((_, res) => res.end("hello")); - * const arcpServer = new ARCPServer({ ... }); - * - * attachArcpUpgrade(httpServer, { - * path: "/arcp", - * allowedHosts: ["localhost", "127.0.0.1"], - * onTransport: (transport) => arcpServer.accept(transport), - * }); - * - * httpServer.listen(7777); - * ``` - */ -export declare function attachArcpUpgrade(server: HttpServer, options: AttachArcpUpgradeOptions): ArcpUpgradeHandle; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/runtime.d.ts b/.refactor/api-snapshot/runtime.d.ts deleted file mode 100644 index 32d5eb9..0000000 --- a/.refactor/api-snapshot/runtime.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { negotiateCapabilities, type PendingMeta, PendingRegistry, type SessionPhase, type SessionSnapshot, SessionState, } from "@arcp/core/state"; -export { Job, JobManager, makeJobContext } from "./job.js"; -export { assertLeaseConstraintsSubset, assertLeaseSubset, canonicalizeTarget, compileGlob, initialBudgetFromLease, isLeaseSubset, isReservedCapabilityName, isValidCapabilityName, type Lease, matchGlob, validateLeaseConstraints, validateLeaseOp, validateLeaseShape, } from "./lease.js"; -export { ARCPServer, SessionContext } from "./server.js"; -export type { AgentHandler, ARCPServerOptions, Handler, JobAuthorizationPolicy, JobContext, JobOptions, JobSend, LeaseOpContext, ResultStream, SessionCaps, } from "./types.js"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/sdk.d.ts b/.refactor/api-snapshot/sdk.d.ts deleted file mode 100644 index c89a14d..0000000 --- a/.refactor/api-snapshot/sdk.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "@arcp/client"; -export * from "@arcp/core"; -export * from "@arcp/runtime"; -//# sourceMappingURL=index.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/sdk/client.d.ts b/.refactor/api-snapshot/sdk/client.d.ts deleted file mode 100644 index 10f1b78..0000000 --- a/.refactor/api-snapshot/sdk/client.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "@arcp/client"; -//# sourceMappingURL=client.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/sdk/errors.d.ts b/.refactor/api-snapshot/sdk/errors.d.ts deleted file mode 100644 index 69da8fa..0000000 --- a/.refactor/api-snapshot/sdk/errors.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "@arcp/core/errors"; -//# sourceMappingURL=errors.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/sdk/messages.d.ts b/.refactor/api-snapshot/sdk/messages.d.ts deleted file mode 100644 index e378667..0000000 --- a/.refactor/api-snapshot/sdk/messages.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "@arcp/core/messages"; -//# sourceMappingURL=messages.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/sdk/runtime.d.ts b/.refactor/api-snapshot/sdk/runtime.d.ts deleted file mode 100644 index 385586a..0000000 --- a/.refactor/api-snapshot/sdk/runtime.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "@arcp/runtime"; -//# sourceMappingURL=runtime.d.ts.map \ No newline at end of file diff --git a/.refactor/api-snapshot/sdk/transport.d.ts b/.refactor/api-snapshot/sdk/transport.d.ts deleted file mode 100644 index 2b7ed86..0000000 --- a/.refactor/api-snapshot/sdk/transport.d.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "@arcp/core/transport"; -//# sourceMappingURL=transport.d.ts.map \ No newline at end of file diff --git a/.refactor/baseline.md b/.refactor/baseline.md deleted file mode 100644 index 6d9881f..0000000 --- a/.refactor/baseline.md +++ /dev/null @@ -1,83 +0,0 @@ -# Baseline (2026-05-14, refactor/automation @ initial commit) - -Captured before any refactor work. This is the safety net — the -refactor must preserve or improve every metric below. - -## Repository - -- Workspace: pnpm (10 packages: 4 main + 1 meta + 5 middleware + 1 - otel) -- Node engine: `>=22` -- Package manager: pnpm@9.15.0 -- TS: 5.6.2 -- Lint: biome@2.4.15 + eslint@9.39.4 (typescript-eslint strict + - unicorn + import + n) -- Test: vitest@2.1.2 - -## Packages (all `private: false`, all publish to npm) - -| Package | path | barrel size (.d.ts lines) | -| -------------------------- | ----------------------------- | ------------------------: | -| `@arcp/core` | `packages/core` | 12 (+ 12 subpath barrels) | -| `@arcp/client` | `packages/client` | 2 | -| `@arcp/runtime` | `packages/runtime` | 5 | -| `@arcp/sdk` | `packages/sdk` | 3 (+ 5 subpath barrels) | -| `@arcp/bun` | `packages/middleware/bun` | 25 | -| `@arcp/express` | `packages/middleware/express` | 38 | -| `@arcp/fastify` | `packages/middleware/fastify` | 38 | -| `@arcp/hono` | `packages/middleware/hono` | 38 | -| `@arcp/node` | `packages/middleware/node` | 29 | -| `@arcp/middleware-otel` | `packages/middleware/otel` | 11 | - -Source size: 87 `.ts` files, ~10,666 LOC. - -## Baseline gates (state at start of refactor) - -| Gate | Command | Result | -| ---- | ---------------- | --------------------------------------------- | -| G1 | `pnpm typecheck` | PASS (0 errors) | -| G2 | `pnpm lint` | PASS (after biome ignore added for `.refactor`) | -| G3 | `pnpm test` | PASS (all suites) | - -### Test counts (per package) - -- `@arcp/core`: 6 files, 45 tests -- `@arcp/client`: 4 files, 38 tests -- `@arcp/runtime`: 1 file, 18 tests -- `@arcp/sdk`: 0 tests (passWithNoTests) -- `@arcp/bun`: 0 tests -- `@arcp/express`: 0 tests -- `@arcp/fastify`: 1 file, 2 tests -- `@arcp/hono`: 0 tests -- `@arcp/node`: 0 tests -- `@arcp/middleware-otel`: 1 file, 2 tests - -**Total: ~14 test files, ~105 tests passing.** - -> Test coverage on the middleware packages is thin. Sub-phase 2.7 -> (Testing) will need to add coverage there before final gates close. - -## Known dirty-tree items handled before baseline - -See `wip-handling.md`. The runtime work-in-progress was stashed -(non-destructively) before this baseline was taken. None of those -files are reflected in the metrics above. - -## tsconfig.base.json conformance to guide Section 0 - -Already enabled: `strict`, `noUncheckedIndexedAccess`, -`exactOptionalPropertyTypes`, `noImplicitOverride`, -`noImplicitReturns`, `noFallthroughCasesInSwitch`, -`noPropertyAccessFromIndexSignature`, `verbatimModuleSyntax`, -`isolatedModules`, `forceConsistentCasingInFileNames`. - -Missing: `useUnknownInCatchVariables` (will be added in sub-phase 2.1). - -Target: ES2023 (guide minimum is ES2022 — already exceeds). - -## package.json conformance to guide Section 9 - -Already conformant across all 10 packages: `"type": "module"`, -`"sideEffects": false`, `"exports"` map (with `types` and `import` -conditions, no wildcards), `engines.node`, `publishConfig.provenance: -true`, `main`/`types` legacy fallback for old resolvers. diff --git a/.refactor/breaking_changes.md b/.refactor/breaking_changes.md deleted file mode 100644 index 328ebcd..0000000 --- a/.refactor/breaking_changes.md +++ /dev/null @@ -1,22 +0,0 @@ -# Deferred Breaking Changes - -Append-only list of public-API changes the refactor *would* make if -the guide were applied strictly, but that would break consumers and -therefore require explicit user approval before being applied. - -Format per entry: - -``` -## : - -- Sub-phase: -- Current shape: -- Proposed shape: -- Why it's breaking: -- Rationale for proposing the change: -- Status: deferred | approved | rejected -``` - ---- - -(none yet — Phase 1 surfaced no breaking changes) diff --git a/.refactor/violations.md b/.refactor/violations.md deleted file mode 100644 index 10c3a69..0000000 --- a/.refactor/violations.md +++ /dev/null @@ -1,246 +0,0 @@ -# Guide Violations Inventory (Phase 1) - -Captured against `TYPESCRIPT_SDK_GUIDE.md` on 2026-05-14. Each item -has a checkbox so future sessions can mark it resolved as work -proceeds. Counts are approximate where based on heuristics; precise -counts will come from guide-conformant ESLint rules added in -**sub-phase 2.1**. - -Headline: this codebase is in unusually good shape on the small -mechanical violations. The real work is **complexity reduction** -(file/function size) and the missing tooling/docs around it. - ---- - -## Sub-phase 2.1 — Tooling baseline — **complete (2026-05-14)** - -- [x] Add `useUnknownInCatchVariables` to `tsconfig.base.json` - (only Section-0 flag missing). -- [x] Add `max-lines: 300` to ESLint config. -- [x] Add `max-lines-per-function: 40` to ESLint config. -- [x] Add `max-params: 3` to ESLint config. -- [x] Add `max-depth: 3` to ESLint config. -- [x] Add `complexity: 10` to ESLint config. -- [x] Add `prefer-readonly: error` (was `warn`) to ESLint config. -- [x] `import/no-cycle: error` confirmed in workspace ESLint config. -- [x] Install `@arethetypeswrong/cli`, `publint`, `madge`, - `eslint-plugin-tsdoc` as devDependencies. -- [x] CI steps added: `check:cycles` (advisory), `check:attw` - (required), `check:publint` (required). `lint:eslint` is now - advisory until sub-phase 2.5 wraps; `lint:biome` is required. -- [ ] Add `tsd` or `expectTypeOf` setup for type tests on generics. - *Deferred to sub-phase 2.7 (Documentation) — generics-heavy - public surface is small and can be covered there.* - ---- - -## Sub-phase 2.2 — Surface audit (non-breaking fixes) - -- [ ] Re-emit `.d.ts` and diff against `.refactor/api-snapshot/`; - list every drift and classify (a) safe-fix vs (b) breaking. -- [ ] Confirm no public symbol uses `Record` where - a defined shape is reasonable. -- [ ] Confirm every public function has explicit return type (rule - already enforced by `explicit-module-boundary-types`; spot-check). -- [ ] Mark internal-only helpers with `@internal` TSDoc tag and - configure API extractor to strip them (deferred to 2.7). - ---- - -## Sub-phase 2.3 — Errors — **complete (2026-05-14)** - -`@arcp/core` already has a rich typed error hierarchy -(`packages/core/src/errors.ts`, 14 exported subclasses pinned to -canonical wire codes). - -- [x] Replace `throw new Error(...)` with a typed subclass at all - 9 sites: - - [x] `core/messages/execution.ts:91,98,101` (agent name parser) - → `InvalidRequestError` - - [x] `core/messages/execution.ts:133,138,142` (cost.budget - parser) → `InvalidRequestError` - - [x] `core/messages/execution.ts:411` (exhaustiveness guard) - → `InternalError` - - [x] `core/transport/websocket.ts:191` (WS address unavailable) - → `InternalError` - - [x] `core/state/pending.ts:25` (correlation_id reuse) - → `InternalError` -- [x] Add `SdkError` discriminated union type alias exported from - `@arcp/core` (additive, non-breaking; .d.ts diff confirmed - additive only). -- [ ] Audit every catch block for swallowed `cause`. **Deferred to - after sub-phase 2.5 splits files** — auditing while files are - mid-refactor wastes effort. - ---- - -## Sub-phase 2.4 — Async hygiene - -Survey done in Session 3. The public client surface needs real -signal plumbing — this is more than verification. Scope: - -- [ ] **Add `signal?: AbortSignal` to options on these client - methods** (additive, non-breaking; the underlying - `pending.register` already accepts a signal): - - [ ] `ARCPClient.connect(transport, opts?)` — currently no opts - bag; add `{ signal? }`. - - [ ] `ARCPClient.resume(transport, resume, opts?)` — same. - - [ ] `ARCPClient.send(env, opts?)` — same. - - [ ] `ARCPClient.ack(seq, opts?)` — same. - - [ ] `ARCPClient.cancelJob(jobId, options)` — already takes - `{ reason? }`; add `signal?`. - - [ ] `ARCPClient.listJobs(filter?, opts)` — already takes - `{ limit?, cursor? }`; add `signal?`. - - [ ] `ARCPClient.subscribe(jobId, opts)` — already takes - `{ history?, fromEventSeq? }`; add `signal?`. - - [x] `ARCPClient.submit(opts: SubmitOptions)` — `SubmitOptions` - already includes `signal?: AbortSignal`. -- [x] `@typescript-eslint/no-floating-promises` enabled and clean. -- [x] No `async` constructors found. -- [x] No empty catches found in initial inventory. -- [ ] Bound any unbounded `Promise.all` over user-supplied input — - verify by reading runtime/job-runner.ts (deferred to during - sub-phase 2.5 split). - ---- - -## Sub-phase 2.5 — Complexity reduction (files >300 lines) - -Sorted largest first. Each entry is its own checkpoint. - -- [ ] `packages/runtime/src/server.ts` — **1290 lines** (was 1912; - dropped after recovering the user's WIP into `8227bda`, which - extracted `agent-registry.ts`, `job-runner.ts`, `stores.ts`). - Still the largest violation. Further splits to identify in - sub-phase 2.5. -- [ ] `packages/runtime/src/job-runner.ts` — **565 lines** (newly - added in the WIP recovery; over the 300-line cap). -- [ ] `packages/client/src/client.ts` — **822 lines**. -- [ ] `packages/core/src/messages/execution.ts` — **593 lines**. -- [ ] `packages/runtime/src/job.ts` — **589 lines**. -- [ ] `packages/runtime/src/lease.ts` — **430 lines**. -- [ ] `packages/core/src/errors.ts` — **306 lines** (just over; - consider splitting protocol vs transport vs runtime errors). -- [ ] `packages/core/src/store/eventlog.ts` — **303 lines** (just - over; small split). - -### Files in the warning band (150–300 lines, monitor) - -These are not violations but sit close to the cap. Touch only if -sub-phase 2.5 work brings them across the line. - -- `packages/core/src/messages/session.ts` — 264 -- `packages/runtime/src/types.ts` — 255 -- `packages/middleware/otel/src/index.ts` — 222 -- `packages/core/src/transport/websocket.ts` — 208 -- `packages/core/src/envelope.ts` — 194 -- `packages/core/src/util/json-schema.ts` — 172 -- `packages/sdk/src/cli.ts` — 154 -- `packages/core/src/state/session.ts` — 150 - -### Function-level complexity (measured by ESLint, 2026-05-14) - -Total: **79 errors across 12 files**. Breakdown: - -- `max-lines-per-function`: 28 functions -- `max-depth`: 22 occurrences -- `complexity`: 20 functions -- `max-lines`: 5 files -- `max-params`: 4 functions - -Files with violations (each is its own checkpoint inside 2.5): - -- [ ] `packages/runtime/src/server.ts` (1290 lines) -- [ ] `packages/runtime/src/job-runner.ts` (565 lines) -- [ ] `packages/runtime/src/job.ts` (589 lines) -- [ ] `packages/runtime/src/lease.ts` (430 lines) -- [ ] `packages/client/src/client.ts` (822 lines) -- [ ] `packages/core/src/messages/execution.ts` (593 lines) -- [ ] `packages/core/src/store/eventlog.ts` (303 lines) -- [ ] `packages/core/src/transport/websocket.ts` (function-level) -- [ ] `packages/core/src/state/session.ts` (function-level) -- [ ] `packages/core/src/util/json-schema.ts` (function-level) -- [ ] `packages/middleware/bun/src/index.ts` (function-level) -- [ ] `packages/middleware/otel/src/index.ts` (function-level) - -### Circular imports (G4) - -- [ ] **6 circular dependencies in `@arcp/runtime`** - (`madge --circular`): - 1. `types.ts > job.ts > types.ts` - 2. `agent-registry.ts > types.ts > server.ts > agent-registry.ts` - 3. `types.ts > server.ts > job-runner.ts > lease.ts > types.ts` - 4. `server.ts > job-runner.ts > server.ts` - 5. `types.ts > server.ts > job-runner.ts > types.ts` - 6. `types.ts > server.ts > types.ts` - Root cause: `runtime/src/types.ts` declares interfaces that the - server depends on, and the server is referenced back by the - collaborators (`job-runner`, `agent-registry`) for context. To - break: extract pure types into a leaf module (`runtime/src/api.ts` - or similar) that no other runtime file imports from `server.ts`. - ---- - -## Sub-phase 2.6 — Naming and style - -- [x] All source files already kebab-case. -- [ ] Audit type/interface names for `I` / `T` prefixes (none found - in spot check; verify systematically). -- [ ] Audit public symbol names for abbreviations (`cfg`, `req`, - `res`, `ctx`, `opts`) — keep only where idiomatic - (`AbortSignal`, `URL`). -- [ ] Apply Section-12 style cheatsheet via `biome` autofix where - possible. - ---- - -## Sub-phase 2.7 — Documentation (TSDoc) - -- [ ] Audit every public export across all 10 package barrels for a - TSDoc block (one-line summary + `@param`/`@returns`/`@throws`/ - `@example`/`@see` as relevant). -- [ ] Mark every internal helper with `@internal`. -- [ ] Add `eslint-plugin-tsdoc` and configure to enforce. - ---- - -## Sub-phase 2.8 — Build, exports, publish - -Already largely conformant — see `baseline.md`. Remaining: - -- [ ] Confirm `@arcp/sdk` subpath exports all resolve cleanly under - `attw --pack`. -- [ ] Add `publint` to CI per published package; fix any warnings. -- [ ] Add `madge --circular` to CI per package; fix any cycles - introduced during 2.5 refactors. -- [ ] Confirm sourcemaps and declaration maps are emitted (spot check - after a clean build). - ---- - -## Sub-phase 2.9 — Final verification - -- [ ] `.d.ts` diff vs `.refactor/api-snapshot/` empty (or every diff - entry approved in `breaking_changes.md`). -- [ ] All 12 gates green per Phase 3 of `REFACTOR_PROMPT.md`. - ---- - -## Items already conformant (no work required) - -These are recorded for the final report's benefit: - -- `any` usage in src: **0**. -- `@ts-ignore` usage: **0** (no `@ts-expect-error` either). -- `enum` / `namespace` usage: **0**. -- `default` exports: **0**. -- `console.*` in library code (excluding CLI): **0** (sole hit is a - doc-comment example). -- File naming: 100% kebab-case. -- Circular deps: **0** per `madge`. -- `package.json` shape (type=module, sideEffects=false, exports map - with conditions, provenance, engines): **conformant on all 10 - packages.** -- `tsconfig.base.json` strict flags from guide Section 0: **all - present except `useUnknownInCatchVariables`**. -- Typecheck/lint/test baseline: **all green** (see `baseline.md`). diff --git a/.refactor/wip-handling.md b/.refactor/wip-handling.md deleted file mode 100644 index 9a703a6..0000000 --- a/.refactor/wip-handling.md +++ /dev/null @@ -1,30 +0,0 @@ -# WIP Handling - -Before bootstrap on 2026-05-14, the working tree on `main` was dirty -with the following: - -- Modified: `packages/runtime/src/server.ts` -- Untracked: `packages/runtime/src/agent-registry.ts` -- Untracked: `packages/runtime/src/job-runner.ts` -- Untracked: `packages/runtime/src/stores.ts` - -These were stashed (non-destructively) so the refactor could begin -from a clean tree. - -**Status (Session 2):** the stash has been recovered onto -`refactor/automation` as commit `8227bda`. The user's WIP turned out -to be exactly the start of sub-phase 2.5 for `server.ts`: a partial -decomposition into `agent-registry.ts`, `job-runner.ts`, and -`stores.ts`, shrinking `server.ts` from 1912 → 1290 lines. Two -unused leftover constants in `server.ts` were removed as trivial -cleanup. Typecheck, lint, and the test suite all pass on the -recovered state. **The stash entry has been dropped.** - -**Branch base:** `refactor/automation` was created from clean `main` -at `326dd2b`. The WIP recovery commit (`8227bda`) sits on top of the -Phase 1 init commits. - -**Implication for sub-phase 2.5:** `server.ts` is still the largest -remaining file (1290 lines), but the heavy initial extraction is -done. Future 2.5 work on `server.ts` will continue from this -post-WIP state. diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index fc5b86f..0000000 --- a/PLAN.md +++ /dev/null @@ -1,5 +0,0 @@ -# Plan - -Phased implementation scope and rationale for this SDK are described in **README.md**. - -Monorepo-wide coordination lives in [`.github/README.md`](../.github/README.md) (installation matrix, ecosystem context). diff --git a/REFACTOR_PROMPT.md b/REFACTOR_PROMPT.md deleted file mode 100644 index d5e2a19..0000000 --- a/REFACTOR_PROMPT.md +++ /dev/null @@ -1,478 +0,0 @@ -# Autonomous TypeScript SDK Refactor (Multi-Session) - -You are refactoring this codebase to conform to -`TYPESCRIPT_SDK_GUIDE.md` at the repo root. - -The refactor is too large to complete in one session. It is designed to -run across many sessions, each picking up where the previous left off. -Your job in any given session is to **make as much forward progress as -possible, end at a clean checkpoint, and leave a handoff the next -session can resume from without re-investigating anything.** - ---- - -## Operating Mode - -Hard rules for every session: - -- **Do NOT ask the user for permission.** Not for plans, not for - decisions, not for individual changes. The guide is your authority. -- **Do NOT pause for confirmation.** No "should I proceed?" check-ins. -- **Do NOT request clarification on judgment calls.** When the guide is - silent or ambiguous, pick the option that best serves - maintainability, document the choice in `.refactor/DECISIONS.md`, - and move on. -- **Do stop at checkpoints.** A checkpoint is the boundary between two - phases (or between two packages within a phase) — never mid-phase - or mid-file. At a checkpoint you may end the session if context, - time, or risk demands it. -- **Public API is sacred.** Do not change the shape of any symbol - exported from a package barrel without recording it explicitly in - `.refactor/breaking_changes.md`. Non-breaking changes are free; - breaking ones are listed and deferred unless the user has approved - them. -- **Do NOT mark the overall task complete until every gate condition - in Phase 3 passes.** A session that ends at a checkpoint is not a - failure — it is the expected mode of operation. - -The user is unavailable during execution. Treat every decision as -yours to make. - ---- - -## Multi-Session Execution Model - -The refactor is driven by a small set of files under `.refactor/`. They -are the single source of truth for "where are we" and survive across -sessions. - -| File | Purpose | Owner | -| ----------------------------- | -------------------------------------------------- | ---------------- | -| `.refactor/STATE.md` | Current phase, current package, what's done, what's next | Updated each session | -| `.refactor/baseline.md` | Initial typecheck/lint/test baseline (immutable after Phase 1) | Phase 1 only | -| `.refactor/api-snapshot/` | Frozen `.d.ts` of every package barrel as of Phase 1 | Phase 1 only | -| `.refactor/violations.md` | Inventory of guide violations from Phase 1, with checkboxes | Updated as resolved | -| `.refactor/DECISIONS.md` | Every judgment call, with one-line rationale | Append-only | -| `.refactor/breaking_changes.md` | Public surface changes that would break consumers; deferred until approved | Append-only | -| `.refactor/HANDOFF.md` | Notes from the previous session to the next | Rewritten each session | - -These files live on the active refactor branch (default -`refactor/automation`). They are the contract between sessions. - -### Session lifecycle - -Every session follows the same lifecycle: - -1. **Bootstrap.** Determine whether this is the first session or a - resume (see "Bootstrap" below). -2. **Work.** Execute one or more phases (or packages within a phase). - Stop at a phase or package boundary, never mid-file. -3. **Checkpoint.** Commit, update `STATE.md` and `HANDOFF.md`, decide - whether to continue or end the session. -4. **Final report.** Only on the session that flips the last gate - green — see Phase 4. - -### Bootstrap - -On entry, read `.refactor/STATE.md`. - -- **If it does not exist:** this is the first session. Run Phase 1 - end-to-end, then proceed into Phase 2 starting at sub-phase 2.1. -- **If it exists:** read it, then read `.refactor/HANDOFF.md`. Do - *not* re-investigate. Trust the state. Resume at the next - unfinished sub-phase listed in `STATE.md`. - -`STATE.md` MUST contain, at minimum: - -```markdown -# Refactor State - -- Branch: refactor/automation (based on ) -- Phase: 2 -- Current sub-phase: 2.5 (Complexity Reduction) -- Current package: @arcp/runtime -- Last completed sub-phase: 2.4 (Async hygiene) -- Last commit on branch: -- Gates passing: G1, G2 -- Gates failing: G3 (12 files >300 lines), G4 (8 cyclomatic-complexity violations), G5 (api diff non-empty) -- Sessions consumed: 3 -- Estimated remaining work: ~2 sessions for sub-phase 2.5, then 2.6–2.9 -``` - -### Repository preconditions - -Before any refactor work can begin, the repo must be in a known state: - -- A clean working tree on the agreed base commit, OR -- An explicit `.refactor/wip-handling.md` note recording how prior - uncommitted work was handled (committed as WIP, stashed, or - branched-from-dirty). - -If the working tree is dirty when bootstrap runs and no -`wip-handling.md` exists, stop immediately and write a short -`HANDOFF.md` describing the dirty files. Do not attempt the refactor -on top of unknown user work. - -### When to stop a session - -End the session at the next checkpoint when any of the following are -true: - -- Context budget feels stretched (you are noticing reduced quality, or - you have rolled past several long files). -- A sub-phase has just completed and the next sub-phase is large (a - fresh session will execute it more reliably). -- All gates are green (proceed to Phase 4 instead of stopping). - -Do **not** stop mid-sub-phase, mid-file, or with failing tests. The -repo must be in a green state at every session boundary: typecheck -clean, lint clean for the files you touched, tests passing, branch -committed. - ---- - -## Phase 1: Investigation (First Session Only) - -Read before you write. Skip this phase entirely if `.refactor/STATE.md` -already exists. - -1. Read `TYPESCRIPT_SDK_GUIDE.md` in full. Internalize the hard limits - in Section 0 and the complexity caps in Section 11. -2. Map the repository: - - Identify the package layout (workspace? single package?), the - public barrels per package, and any internal modules. - - Locate `tsconfig.json`(s), `package.json`(s), ESLint/Biome - config, build config, CI config. - - Identify the test setup per package and how it runs. - - List which packages publish to npm (private vs. public). -3. Snapshot the public API of every published package: write the - compiled `.d.ts` of each barrel to `.refactor/api-snapshot/`, - one file per package. This is the contract you must not break. -4. Run the existing typecheck, lint, and test suites. Record the - baseline pass/fail in `.refactor/baseline.md`. This is your safety - net — every change must preserve or improve it. -5. Inventory violations. Write `.refactor/violations.md` grouped by - category, with a checkbox per item so future sessions can mark - them resolved: - - Files exceeding 300 lines (with current line count). - - Functions exceeding 40 lines, complexity >10, params >3, or - nesting >3. - - Uses of `any`, `// @ts-ignore`, `enum`, `namespace`, `default` - export, parameter properties, non-`PascalCase` types, - abbreviated public names. - - Public symbols missing explicit return type annotations. - - Public symbols missing TSDoc. - - Errors that are plain `throw new Error(...)` rather than typed - subclasses. - - Missing `AbortSignal` on public async I/O functions. - - Floating promises and empty catches. - - `package.json` issues: missing `exports` map, missing - `sideEffects`, default exports in barrel, `main`/`types` only - (no conditions), missing `provenance`. - - Circular imports (run `madge --circular`). -6. Initialize `.refactor/STATE.md`, `.refactor/DECISIONS.md` (empty - list), `.refactor/breaking_changes.md` (empty list), and - `.refactor/HANDOFF.md` (empty). -7. Commit on the refactor branch: - `chore(refactor): initialize state, snapshots, and inventory`. - -When investigation is complete, proceed directly to Phase 2 in the -same session if context allows. Otherwise checkpoint and stop. - ---- - -## Phase 2: Execution - -Execute sub-phases in order. Within a sub-phase that touches multiple -packages, treat each package as the natural sub-unit and checkpoint -between packages if needed. - -A sub-phase is **complete** only when: - -- All bullets in the sub-phase are addressed for in-scope code across - every package. -- Typecheck, lint, and tests pass for the touched code. -- The relevant items in `.refactor/violations.md` are checked off. -- A commit (or commits) for the sub-phase exist on the branch. -- `.refactor/STATE.md` is updated to mark the sub-phase complete. - -If you cannot complete a sub-phase in the current session, do **not** -mark it complete. Stop at the previous sub-phase boundary (or at a -package boundary within the current sub-phase) and write a clear note -in `HANDOFF.md`. - -### Sub-phase 2.1 — Tooling baseline - -- Update every `tsconfig*.json` to the strict flag set in guide - Section 0. -- Install/update ESLint with the rule set in guide Section 11. -- Install `@arethetypeswrong/cli`, `publint`, `madge`, and any - missing dev tooling. -- Add CI steps: `tsc --noEmit` (or `tsc -b`), `eslint .`, - `vitest run`, `attw --pack` per published package, `publint` per - published package, `madge --circular src` per package. -- Commit: `chore(tooling): enforce strict ts, lint, and publish checks`. - -### Sub-phase 2.2 — Surface audit - -- Re-emit `.d.ts` for every package barrel; diff against - `.refactor/api-snapshot/`. -- Identify every symbol that violates guide rules (default exports, - `any`, missing return types, leaked internal types). Group into: - - (a) fixable without breaking change → fix now; - - (b) requires breaking change → append to - `.refactor/breaking_changes.md`, leave the symbol untouched. -- After fixing (a), re-emit `.d.ts` and confirm the diff against the - snapshot is empty (or limited to additions). Update the snapshot - only with explicit user approval recorded in `DECISIONS.md`. -- Commit: `refactor(api): tighten public surface (non-breaking)`. - -### Sub-phase 2.3 — Errors - -- Convert all thrown values to typed error subclasses per guide - Section 3. -- Export every error class from each package's barrel. -- Add a discriminated `SdkError` union per package (or one shared - union if the guide indicates). -- Preserve `cause` chains; remove all swallowed catches. -- Add `@throws` TSDoc lines to every public function that can throw. -- Commit: `refactor(errors): typed hierarchy with cause preservation`. - -### Sub-phase 2.4 — Async hygiene - -- Add an optional `AbortSignal` parameter to every I/O public async - function and plumb it through. -- Eliminate floating promises and empty catches. -- Replace any `async` constructor with a static factory. -- Bound any unbounded `Promise.all` over user input. -- Commit: `refactor(async): cancellation, no floating promises`. - -### Sub-phase 2.5 — Complexity reduction (the core work) - -This is the largest sub-phase and will commonly span multiple -sessions. Treat each *file* in the violations inventory as its own -sub-unit. A session may complete any number of files; partial-file -work is not a checkpoint. After each file: - -- Re-run typecheck and tests for the affected package. -- Check off the file's entries in `.refactor/violations.md`. -- Commit with a focused message like - `refactor(): split per guide Section 11`. -- Update the "files remaining" count in `STATE.md`. - -For every violation in `.refactor/violations.md` for files >300 -lines, functions >40 lines, complexity >10, params >3, or nesting ->3: - -1. Read the file/function. Understand intent before cutting. -2. Apply, in order: - - Extract guard clauses to the top (early returns). - - Flatten nesting by inverting predicates. - - Extract repeated blocks into private helpers. - - Split flag-parameter functions into separate functions. - - Convert >3-param signatures into options objects. - - Split files >300 lines along responsibility lines, not - arbitrarily. -3. Re-run tests after each file. Fix regressions immediately. -4. Re-measure with `eslint --rule '{...}'` or by reading the lint - output. The target is zero violations. - -Do not exempt any code. If you cannot refactor a function under the -limit, you have not understood it yet. Re-read and try again. Only -add `// eslint-disable-next-line` as a last resort with a comment -explaining the constraint (generated code, vendored upstream, etc.) -and a TODO. - -The sub-phase is complete when zero items remain unchecked in the -"complexity" sections of `violations.md`. - -### Sub-phase 2.6 — Naming and style - -- Rename files to `kebab-case.ts`. -- Strip `I` / `T` prefixes from interfaces and type aliases. -- Remove abbreviations from public symbols. -- Apply guide Section 12 style rules via lint autofix where possible; - fix the remainder by hand. -- Commit: `refactor(style): naming and formatting pass`. - -### Sub-phase 2.7 — Documentation - -- Add TSDoc to every public export per guide Section 7. -- Mark internals with `@internal`. -- Add `@deprecated` with replacement pointers for anything slated for - removal. -- Verify examples compile via `tsd`/`eslint-plugin-tsdoc` if - installed. -- Commit: `docs(api): tsdoc for full public surface`. - -### Sub-phase 2.8 — Build, exports, publish - -- For every published package: set `"type": "module"`, - `"sideEffects": false`, and a strict `"exports"` map (no - wildcards). -- Confirm sourcemaps and declaration maps are emitted. -- Run `attw --pack` and `publint` per package; fix every warning. -- Commit: `build(pkg): esm-first exports map, attw clean`. - -### Sub-phase 2.9 — Final verification - -- Re-emit `.d.ts` for every barrel; diff against - `.refactor/api-snapshot/`. The diff must be empty unless an item - was approved in `breaking_changes.md`. -- Run the full test suite, type tests, lint, build, attw, publint, - madge across every package. -- All must pass with zero warnings. - ---- - -## Checkpoint Protocol (Run at Every Phase or Package Boundary) - -After completing a sub-phase (or a package within a multi-package -sub-phase), and before stopping the session or beginning the next -chunk, run this protocol exactly: - -1. **Verify locally.** Run `tsc -b`, `eslint`, and `vitest run` for - the changed scope. Fix any regression *now*; never carry red into - a checkpoint. -2. **Commit.** One conventional-commit per logical change. Never a - single mega-commit per sub-phase. -3. **Update `.refactor/violations.md`.** Check off every item - resolved. -4. **Update `.refactor/STATE.md`.** Reflect the sub-phase now - completed, the next sub-phase to run, and which gates are green. -5. **Append to `.refactor/DECISIONS.md`** any judgment calls made - during the sub-phase. -6. **Append to `.refactor/breaking_changes.md`** any public-surface - changes that would break consumers (deferred until user - approval). -7. **Rewrite `.refactor/HANDOFF.md`** for the next session: what to - read first, what is mid-flight (ideally nothing), where to - resume, and any gotchas. Keep it under one screen. -8. **Commit the state files** as a separate commit: - `chore(refactor): checkpoint after sub-phase `. -9. Decide: continue to next chunk, or end session. If ending, this - is your last action — do not narrate further. - ---- - -## Phase 3: Gate Conditions (All Must Pass to Finish) - -The overall task is not complete until every one of these is true. -Verify by running each command and inspecting the result. - -| Gate | Command | Pass Criterion | -| ---- | --------------------------------------------- | ---------------------- | -| G1 | `pnpm typecheck` (workspace) | 0 errors | -| G2 | `pnpm lint` | 0 errors, 0 warnings | -| G3 | `pnpm test` | All pass | -| G4 | `madge --circular packages/*/src` | 0 cycles per package | -| G5 | `.d.ts` diff vs `.refactor/api-snapshot/` | empty, OR every diff entry is in `breaking_changes.md` AND user-approved | -| G6 | No file in `packages/*/src/` exceeds 300 lines | Verify with `wc -l` | -| G7 | No function exceeds 40 body lines | ESLint `max-lines-per-function` clean | -| G8 | Cyclomatic complexity ≤ 10 everywhere | ESLint `complexity` clean | -| G9 | Max function parameters ≤ 3 | ESLint `max-params` clean | -| G10 | Every public export has TSDoc | `eslint-plugin-tsdoc` clean | -| G11 | `attw --pack` per published package | 0 problems | -| G12 | `publint` per published package | 0 problems | - -If any gate fails, return to Phase 2, fix at the next session, and -re-run all gates. Do not report success while any gate is red. - -Each session updates the "Gates passing/failing" lines in `STATE.md` -based on the latest run. The session that flips the last gate from -failing to passing proceeds directly to Phase 4 in the same session. - ---- - -## Phase 4: Final Report (Only on the Final Session) - -When and only when all 12 gates pass, produce a single concise report -with these sections: - -1. **Summary.** One paragraph: scope, files touched across all - sessions, gates passing. -2. **Public API changes.** Diff of every package's public surface. If - any breaking changes were approved, justify each and confirm the - CHANGELOG and version bump. -3. **Judgment calls.** Bulleted list (sourced from - `.refactor/DECISIONS.md`) of every decision where the guide was - silent or ambiguous, with one-line rationale. -4. **Deferred work.** Any items genuinely not refactorable under the - limits, with the disable comment and a TODO ownership note. -5. **How to verify.** The 12 commands from the gate table, in order, - for the user to run. -6. **Sessions consumed.** Total session count, with a one-line - summary of each session's scope (drawn from commit history). - -After the report, delete `.refactor/HANDOFF.md` (it has no purpose -once the task is complete) and commit: -`chore(refactor): finalize and clear handoff state`. - -Do not include narration about what you did step-by-step. The git -history is the narration. - ---- - -## Per-Session Status Output - -Sessions that end at a checkpoint (i.e. not the final session) emit a -short status block to the user — *not* a full report. Format: - -``` -Session complete. -- Sub-phase finished: () -- Packages touched: <list or "all"> -- Gates: <G1..G12 status one-liner> -- Commits this session: <count> -- Next sub-phase: <N.M+1> (<title>) -- Estimated sessions remaining: <rough count> -- Resume: re-run this prompt; the next session will read .refactor/STATE.md and continue. -``` - -That is the entire output. No narration of what was done — the diff -and commits speak for themselves. - ---- - -## Anti-Patterns (Do Not Do These) - -- ❌ "I've started the refactor. Should I continue with package X - next?" → Just continue. -- ❌ "I noticed the codebase uses pattern Y. Want me to keep it or - change it?" → The guide answers this. If it doesn't, decide and - document in `DECISIONS.md`. -- ❌ "Sub-phase 2.3 is done, here's a summary of what I did." → - Emit only the per-session status block. -- ❌ "I'll leave file Z for you to review." → No. The next session - will pick it up if you have to stop. -- ❌ "This function is complex but necessary." → Then you haven't - understood it. Re-read and decompose. -- ❌ "Tests are failing but the refactor is structurally complete." - → Tests failing = checkpoint blocked. Fix them before stopping. -- ❌ Skipping a gate because it's "mostly" passing. → Gates are - binary. -- ❌ Stopping mid-sub-phase or mid-file. → Always end at a sub-phase - boundary (or a package boundary inside a sub-phase). If a chunk - is too large, stop *before* starting it, not partway in. -- ❌ Re-investigating on resume. → `STATE.md` and `HANDOFF.md` are - the contract. Trust them. -- ❌ Editing `.refactor/baseline.md` or `.refactor/api-snapshot/` - after Phase 1. → They are the immutable reference points. -- ❌ Changing public API shape without recording it in - `breaking_changes.md`. → Public surface is sacred. - ---- - -## Begin - -Read `TYPESCRIPT_SDK_GUIDE.md`, then check for `.refactor/STATE.md`. - -- If it exists: read `STATE.md` and `HANDOFF.md`, then resume at the - next unfinished sub-phase. -- If it does not: begin Phase 1 immediately. - -Do not respond with a plan. Do not acknowledge this prompt. Your only -output is either: - -- The per-session status block (if you stopped at a checkpoint), or -- The Phase 4 final report (if all gates passed in this session). diff --git a/packages/client/src/client-dispatch.ts b/packages/client/src/client-dispatch.ts new file mode 100644 index 0000000..bc6e7ca --- /dev/null +++ b/packages/client/src/client-dispatch.ts @@ -0,0 +1,303 @@ +import { + type BaseEnvelope, + buildEnvelope, + RoundTripEnvelopeSchema, +} from "@arcp/core/envelope"; +import { ARCPError } from "@arcp/core/errors"; +import type { Logger } from "@arcp/core/logger"; +import { + type Envelope, + EnvelopeSchema, + jobErrorToErrorPayload, + type ResultChunkBody, +} from "@arcp/core/messages"; +import type { SessionState } from "@arcp/core/state"; +import type { Transport, WireFrame } from "@arcp/core/transport"; +import { type Deferred, newMessageId } from "@arcp/core/util"; + +import type { InvocationState } from "./client-handle.js"; + +/** Mutable bits a dispatch routine needs to read/write. */ +export interface DispatchTarget { + readonly logger: Logger; + readonly state: SessionState; + readonly handshake: Deferred<unknown> | null; + readonly invocationsByOriginId: Map<string, InvocationState>; + readonly invocationsByJobId: Map<string, InvocationState>; + readonly pendingAccepts: InvocationState[]; + readonly pendingLists: Map<string, Deferred<unknown>>; + readonly pendingSubscribes: Map<string, Deferred<unknown>>; + readonly handlers: Map<string, (env: Envelope) => Promise<void>>; + readonly transport: Transport | null; + observeEventSeq(env: Envelope): void; +} + +export async function dispatchEnvelope( + target: DispatchTarget, + frame: WireFrame, +): Promise<void> { + const parsed = safeParseRoundTrip(target, frame); + if (parsed === null) return; + if (handleHandshakeFrame(target, parsed)) return; + if (await handlePingPong(target, parsed)) return; + + const env = validateInbound(target, parsed); + if (env === null) return; + target.observeEventSeq(env); + if (handleSessionJobs(target, env)) return; + if (handleJobSubscribed(target, env)) return; + routeJobEvent(target, env); + await invokeUserHandler(target, env); +} + +function safeParseRoundTrip( + target: DispatchTarget, + frame: WireFrame, +): BaseEnvelope | null { + try { + return RoundTripEnvelopeSchema.parse(frame); + } catch (error) { + target.logger.warn({ err: error }, "client received malformed frame"); + return null; + } +} + +function handleHandshakeFrame( + target: DispatchTarget, + parsed: BaseEnvelope, +): boolean { + if (parsed.type === "session.welcome") { + onSessionWelcome(target, parsed); + return true; + } + if (parsed.type === "session.error") { + onSessionError(target, parsed); + return true; + } + return false; +} + +function onSessionWelcome(target: DispatchTarget, parsed: BaseEnvelope): void { + const result = EnvelopeSchema.safeParse(parsed); + if (!result.success || result.data.type !== "session.welcome") return; + // session_id is typed as required by the schema, but we keep the runtime + // check in case the server omits it on the wire. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (result.data.session_id !== undefined) { + try { + target.state.assignId(result.data.session_id); + } catch { + // ignore — likely a resume on the same id + } + } + target.handshake?.resolve(result.data.payload); +} + +function onSessionError(target: DispatchTarget, parsed: BaseEnvelope): void { + const result = EnvelopeSchema.safeParse(parsed); + if (!result.success || result.data.type !== "session.error") return; + const err = ARCPError.fromPayload(result.data.payload); + if (target.handshake !== null && !target.handshake.settled) { + target.handshake.reject(err); + } + rejectAllInvocations(target, err); + rejectAllPendingMaps(target, err); +} + +function rejectAllInvocations(target: DispatchTarget, err: ARCPError): void { + for (const inv of target.invocationsByOriginId.values()) { + if (!inv.acceptance.settled) inv.acceptance.reject(err); + if (!inv.completion.settled) inv.completion.reject(err); + } +} + +function rejectAllPendingMaps(target: DispatchTarget, err: ARCPError): void { + for (const d of target.pendingLists.values()) if (!d.settled) d.reject(err); + for (const d of target.pendingSubscribes.values()) { + if (!d.settled) d.reject(err); + } +} + +async function handlePingPong( + target: DispatchTarget, + parsed: BaseEnvelope, +): Promise<boolean> { + if (parsed.type === "session.ping") { + await sendPong(target, parsed); + return true; + } + if (parsed.type === "session.pong") return true; + return false; +} + +async function sendPong( + target: DispatchTarget, + parsed: BaseEnvelope, +): Promise<void> { + const result = EnvelopeSchema.safeParse(parsed); + if (!result.success || result.data.type !== "session.ping") return; + const sessionId = target.state.id; + if (sessionId === undefined || target.transport === null) return; + const pongEnv = buildEnvelope({ + id: newMessageId(), + type: "session.pong" as const, + payload: { + ping_nonce: result.data.payload.nonce, + received_at: new Date().toISOString(), + }, + optional: { session_id: sessionId }, + }); + try { + await target.transport.send(pongEnv); + } catch { + // best-effort + } +} + +function validateInbound( + target: DispatchTarget, + parsed: BaseEnvelope, +): Envelope | null { + const result = EnvelopeSchema.safeParse(parsed); + if (result.success) return result.data; + const issue = result.error.issues[0]; + target.logger.warn( + { type: parsed.type, code: issue?.code, message: issue?.message }, + "client received unparseable envelope", + ); + return null; +} + +function handleSessionJobs(target: DispatchTarget, env: Envelope): boolean { + if (env.type !== "session.jobs") return false; + const reqId = env.payload.request_id; + const deferred = target.pendingLists.get(reqId); + if (deferred === undefined) return false; + target.pendingLists.delete(reqId); + deferred.resolve(env.payload); + return true; +} + +function handleJobSubscribed(target: DispatchTarget, env: Envelope): boolean { + if (env.type !== "job.subscribed") return false; + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (env.job_id === undefined) return false; + const d = target.pendingSubscribes.get(env.job_id); + if (d === undefined) return false; + target.pendingSubscribes.delete(env.job_id); + d.resolve(env.payload); + return true; +} + +async function invokeUserHandler( + target: DispatchTarget, + env: Envelope, +): Promise<void> { + const handler = target.handlers.get(env.type); + if (handler === undefined) { + target.logger.debug( + { type: env.type }, + "no client handler registered for type", + ); + return; + } + try { + await handler(env); + } catch (error) { + target.logger.error({ err: error, type: env.type }, "client handler threw"); + } +} + +function routeJobEvent(target: DispatchTarget, env: Envelope): void { + if (env.type === "job.accepted") { + onJobAccepted(target, env); + return; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (env.type === "job.event" && env.job_id !== undefined) { + onJobEvent(target, env); + return; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (env.type === "job.result" && env.job_id !== undefined) { + onJobResult(target, env); + return; + } + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (env.type === "job.error" && env.job_id !== undefined) { + onJobError(target, env); + } +} + +function onJobAccepted( + target: DispatchTarget, + env: Extract<Envelope, { type: "job.accepted" }>, +): void { + const inv = target.pendingAccepts.shift(); + if (inv === undefined || inv.acceptance.settled) return; + const payload = env.payload; + inv.jobId = payload.job_id; + inv.lease = payload.lease; + inv.agent = payload.agent; + inv.leaseConstraints = payload.lease_constraints; + inv.budget = payload.budget; + inv.traceId = payload.trace_id ?? inv.traceId; + target.invocationsByJobId.set(payload.job_id, inv); + inv.acceptance.resolve(payload); +} + +function onJobEvent( + target: DispatchTarget, + env: Extract<Envelope, { type: "job.event" }>, +): void { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (env.job_id === undefined) return; + const inv = target.invocationsByJobId.get(env.job_id); + if (inv === undefined) return; + const ep = env.payload; + inv.events.push(ep); + // v1.1 §8.4 — accumulate result_chunk bodies for later assembly. + if (ep.kind !== "result_chunk") return; + const body = ep.body as ResultChunkBody; + let bucket = inv.chunks.get(body.result_id); + if (bucket === undefined) { + bucket = []; + inv.chunks.set(body.result_id, bucket); + } + bucket.push(body); +} + +function onJobResult( + target: DispatchTarget, + env: Extract<Envelope, { type: "job.result" }>, +): void { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (env.job_id === undefined) return; + const inv = target.invocationsByJobId.get(env.job_id); + if (inv === undefined) return; + inv.completion.resolve(env.payload); + target.invocationsByJobId.delete(env.job_id); +} + +function onJobError( + target: DispatchTarget, + env: Extract<Envelope, { type: "job.error" }>, +): void { + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (env.job_id === undefined) return; + const err = ARCPError.fromPayload(jobErrorToErrorPayload(env.payload)); + let inv = target.invocationsByJobId.get(env.job_id); + if (inv === undefined) { + // No binding yet — this can happen when the runtime rejects the submit + // (AGENT_NOT_AVAILABLE, DUPLICATE_KEY, etc) without emitting job.accepted. + inv = target.pendingAccepts.shift(); + if (inv !== undefined) { + inv.jobId = env.job_id; + target.invocationsByJobId.set(env.job_id, inv); + } + } + if (inv === undefined) return; + if (!inv.acceptance.settled) inv.acceptance.reject(err); + inv.completion.reject(err); + target.invocationsByJobId.delete(env.job_id); +} diff --git a/packages/client/src/client-envelopes.ts b/packages/client/src/client-envelopes.ts new file mode 100644 index 0000000..145e0f5 --- /dev/null +++ b/packages/client/src/client-envelopes.ts @@ -0,0 +1,106 @@ +import type { JobId, MessageId, SessionId } from "@arcp/core"; +import { type BaseEnvelope, buildEnvelope } from "@arcp/core/envelope"; +import type { Capabilities, SessionResume } from "@arcp/core/messages"; +import { newMessageId } from "@arcp/core/util"; + +import type { ARCPClientOptions, SubmitOptions } from "./types.js"; + +export interface HelloInput { + id: MessageId; + options: ARCPClientOptions; + capabilities: Capabilities; + resume: SessionResume | undefined; +} + +export function buildHelloEnvelope(input: HelloInput): BaseEnvelope { + return buildEnvelope({ + id: input.id, + type: "session.hello" as const, + payload: { + client: input.options.client, + auth: { + scheme: input.options.authScheme, + ...(input.options.token === undefined + ? {} + : { token: input.options.token }), + }, + capabilities: input.capabilities, + ...(input.resume === undefined ? {} : { resume: input.resume }), + }, + }); +} + +export interface SubmitEnvelopeInput { + id: MessageId; + sessionId: SessionId; + opts: SubmitOptions; +} + +export function buildSubmitEnvelope(input: SubmitEnvelopeInput): BaseEnvelope { + const { id, sessionId, opts } = input; + return buildEnvelope({ + id, + type: "job.submit" as const, + payload: { + agent: opts.agent, + input: opts.input, + ...(opts.lease === undefined ? {} : { lease_request: opts.lease }), + ...(opts.leaseConstraints === undefined + ? {} + : { lease_constraints: opts.leaseConstraints }), + ...(opts.idempotencyKey === undefined + ? {} + : { idempotency_key: opts.idempotencyKey }), + ...(opts.maxRuntimeSec === undefined + ? {} + : { max_runtime_sec: opts.maxRuntimeSec }), + }, + optional: { + session_id: sessionId, + ...(opts.traceId === undefined ? {} : { trace_id: opts.traceId }), + }, + }); +} + +export function buildSubscribeEnvelope( + jobId: JobId, + sessionId: SessionId, + opts: { history?: boolean; fromEventSeq?: number }, +): BaseEnvelope { + return buildEnvelope({ + id: newMessageId(), + type: "job.subscribe" as const, + payload: { + job_id: jobId, + ...(opts.history === undefined ? {} : { history: opts.history }), + ...(opts.fromEventSeq === undefined + ? {} + : { from_event_seq: opts.fromEventSeq }), + }, + optional: { session_id: sessionId }, + }); +} + +export function buildUnsubscribeEnvelope( + jobId: JobId, + sessionId: SessionId, +): BaseEnvelope { + return buildEnvelope({ + id: newMessageId(), + type: "job.unsubscribe" as const, + payload: { job_id: jobId }, + optional: { session_id: sessionId }, + }); +} + +export function buildByeEnvelope( + sessionId: SessionId, + reason: string | undefined, +): BaseEnvelope { + return buildEnvelope({ + id: newMessageId(), + type: "session.bye" as const, + payload: reason === undefined ? {} : { reason }, + optional: { session_id: sessionId }, + }); +} diff --git a/packages/client/src/client-handle.ts b/packages/client/src/client-handle.ts new file mode 100644 index 0000000..c199429 --- /dev/null +++ b/packages/client/src/client-handle.ts @@ -0,0 +1,70 @@ +import type { JobId, TraceId } from "@arcp/core"; +import { InvalidRequestError } from "@arcp/core/errors"; +import type { + JobAcceptedPayload, + JobEventPayload, + JobResultPayload, + Lease, + LeaseConstraints, + ResultChunkBody, +} from "@arcp/core/messages"; +import type { Deferred } from "@arcp/core/util"; + +import type { JobHandle } from "./types.js"; + +export interface InvocationState { + jobId: JobId | null; + lease: Lease | null; + agent: string | undefined; + leaseConstraints: LeaseConstraints | undefined; + budget: Record<string, number> | undefined; + traceId: TraceId | undefined; + events: JobEventPayload[]; + acceptance: Deferred<JobAcceptedPayload>; + completion: Deferred<JobResultPayload>; + /** v1.1 §8.4 — accumulated result chunks, keyed by result_id. */ + chunks: Map<string, ResultChunkBody[]>; +} + +export function makeHandleFromInvocation(inv: InvocationState): JobHandle { + return { + get jobId(): JobId { + return inv.jobId ?? ("" as JobId); + }, + get lease(): Lease { + return inv.lease ?? {}; + }, + get agent(): string | undefined { + return inv.agent; + }, + get leaseConstraints(): LeaseConstraints | undefined { + return inv.leaseConstraints; + }, + get budget(): Record<string, number> | undefined { + return inv.budget; + }, + get traceId(): TraceId | undefined { + return inv.traceId; + }, + done: inv.completion.promise, + collectChunks: () => collectChunks(inv), + }; +} + +async function collectChunks(inv: InvocationState): Promise<Buffer | string> { + const result = await inv.completion.promise; + const resultId = result.result_id; + if (resultId === undefined) { + throw new InvalidRequestError( + "job.result has no result_id; no chunks to collect", + ); + } + const chunks = inv.chunks.get(resultId); + if (chunks === undefined || chunks.length === 0) return ""; + const sorted = chunks.toSorted((a, b) => a.chunk_seq - b.chunk_seq); + const encoding = sorted[0]?.encoding ?? "utf8"; + if (encoding === "base64") { + return Buffer.concat(sorted.map((c) => Buffer.from(c.data, "base64"))); + } + return sorted.map((c) => c.data).join(""); +} diff --git a/packages/client/src/client.ts b/packages/client/src/client.ts index 1a389e4..a946a1b 100644 --- a/packages/client/src/client.ts +++ b/packages/client/src/client.ts @@ -1,41 +1,42 @@ import { randomBytes } from "node:crypto"; -import type { JobId, TraceId } from "@arcp/core"; +import type { JobId, SessionId } from "@arcp/core"; +import { type BaseEnvelope, buildEnvelope } from "@arcp/core/envelope"; import { - type BaseEnvelope, - buildEnvelope, - RoundTripEnvelopeSchema, -} from "@arcp/core/envelope"; -import { - ARCPError, CancelledError, InvalidRequestError, UnauthenticatedError, } from "@arcp/core/errors"; import { type Logger, rootLogger } from "@arcp/core/logger"; -import { - type Capabilities, - type Envelope, - EnvelopeSchema, - type JobAcceptedPayload, - type JobEventPayload, - type JobListEntry, - type JobResultPayload, - type JobSubscribedPayload, - jobErrorToErrorPayload, - type Lease, - type LeaseConstraints, - type ResultChunkBody, - type SessionJobsPayload, - type SessionListJobsFilter, - type SessionResume, - type SessionWelcomePayload, +import type { + Capabilities, + Envelope, + JobAcceptedPayload, + JobListEntry, + JobResultPayload, + JobSubscribedPayload, + SessionJobsPayload, + SessionListJobsFilter, + SessionResume, + SessionWelcomePayload, } from "@arcp/core/messages"; import { PendingRegistry, SessionState } from "@arcp/core/state"; import type { Transport, WireFrame } from "@arcp/core/transport"; import { Deferred, newMessageId } from "@arcp/core/util"; import { intersectFeatures, V1_1_FEATURES } from "@arcp/core/version"; +import { dispatchEnvelope } from "./client-dispatch.js"; +import { + buildByeEnvelope, + buildHelloEnvelope, + buildSubmitEnvelope, + buildSubscribeEnvelope, + buildUnsubscribeEnvelope, +} from "./client-envelopes.js"; +import { + type InvocationState, + makeHandleFromInvocation, +} from "./client-handle.js"; import type { ARCPClientOptions, ClientHandler, @@ -53,19 +54,6 @@ import type { // - `close(reason?)` → sends session.bye then closes transport. // - v1.1: `ack(seq)`, `listJobs(filter, opts)`, `subscribe(jobId, opts)`. -interface InvocationState { - jobId: JobId | null; - lease: Lease | null; - agent: string | undefined; - leaseConstraints: LeaseConstraints | undefined; - budget: Record<string, number> | undefined; - traceId: TraceId | undefined; - events: JobEventPayload[]; - acceptance: Deferred<JobAcceptedPayload>; - completion: Deferred<JobResultPayload>; - /** v1.1 §8.4 — accumulated result chunks, keyed by result_id. */ - chunks: Map<string, ResultChunkBody[]>; -} /** * Client-side driver for an ARCP v1.1 session (§6). @@ -182,72 +170,76 @@ export class ARCPClient { } this.transport = transport; this.handshake = new Deferred<SessionWelcomePayload>(); + this.wireTransport(transport); + const advertisedFeatures = this.options.features ?? V1_1_FEATURES; + const baseCaps = this.buildAdvertisedCapabilities(advertisedFeatures); + await transport.send(this.buildHelloEnvelope(baseCaps, resume)); + return this.awaitHandshake(advertisedFeatures, signal); + } + private wireTransport(transport: Transport): void { transport.onFrame((frame) => this.dispatchRaw(frame)); transport.onClose((err) => { if (this.handshake !== null && !this.handshake.settled) { this.handshake.reject( new InvalidRequestError( "Transport closed before handshake completed", - { - cause: err, - }, + { cause: err }, ), ); } }); + } + private buildAdvertisedCapabilities( + advertisedFeatures: readonly string[], + ): Capabilities { // v1.1 §6.2 — advertise features. If the consumer didn't supply a // `capabilities` block, build one with our default features. If they // did, augment with `features` (unless they explicitly set it). - const advertisedFeatures = this.options.features ?? V1_1_FEATURES; const baseCaps: Capabilities = { ...this.options.capabilities }; if (baseCaps.features === undefined && advertisedFeatures.length > 0) { baseCaps.features = [...advertisedFeatures]; } baseCaps.encodings ??= ["json"]; + return baseCaps; + } - const helloId = newMessageId(); - const helloEnv = buildEnvelope({ - id: helloId, - type: "session.hello" as const, - payload: { - client: this.options.client, - auth: { - scheme: this.options.authScheme, - ...(this.options.token === undefined - ? {} - : { token: this.options.token }), - }, - capabilities: baseCaps, - ...(resume === undefined ? {} : { resume }), - }, + private buildHelloEnvelope( + baseCaps: Capabilities, + resume: SessionResume | undefined, + ): BaseEnvelope { + return buildHelloEnvelope({ + id: newMessageId(), + options: this.options, + capabilities: baseCaps, + resume, }); - await transport.send(helloEnv); + } + private async awaitHandshake( + advertisedFeatures: readonly string[], + signal: AbortSignal | undefined, + ): Promise<SessionWelcomePayload> { const timeout = setTimeout(() => { if (this.handshake !== null && !this.handshake.settled) { this.handshake.reject(new InvalidRequestError("Handshake timed out")); } }, this.handshakeTimeoutMs); timeout.unref(); - const onAbort = () => { - if (this.handshake !== null && !this.handshake.settled) { - const reason = signal?.reason; - this.handshake.reject( - new CancelledError("Handshake aborted by caller", { - cause: reason instanceof Error ? reason : undefined, - }), - ); - } + const onAbort = (): void => { + this.rejectHandshakeForAbort(signal); }; signal?.addEventListener("abort", onAbort, { once: true }); + const handshake = this.handshake; + if (handshake === null) { + throw new InvalidRequestError("Handshake state was cleared"); + } try { - const welcome = await this.handshake.promise; + const welcome = await handshake.promise; this.welcome = welcome; this.state.assignCapabilities(welcome.capabilities); this.state.transition("accepted"); - // v1.1 — store the negotiated feature set (intersection). this._negotiatedFeatures = intersectFeatures( advertisedFeatures, welcome.capabilities.features, @@ -259,6 +251,16 @@ export class ARCPClient { } } + private rejectHandshakeForAbort(signal: AbortSignal | undefined): void { + if (this.handshake === null || this.handshake.settled) return; + const reason: unknown = signal?.reason; + this.handshake.reject( + new CancelledError("Handshake aborted by caller", { + cause: reason instanceof Error ? reason : undefined, + }), + ); + } + /** Register a handler for a specific message type. */ public on(type: string, handler: ClientHandler): void { this.handlers.set(type, handler); @@ -280,43 +282,44 @@ export class ARCPClient { /** Close the underlying transport, optionally sending session.bye. */ public async close(reason?: string): Promise<void> { - this.pending.rejectAll(new CancelledError("Client closing")); + this.rejectAllPending(new CancelledError("Client closing")); + this.clearAutoAckTimer(); + if (this.transport === null) return; + await this.sendBye(reason); + await this.transport.close(reason); + this.transport = null; + } + + private rejectAllPending(error: CancelledError): void { + this.pending.rejectAll(error); for (const inv of this.invocationsByOriginId.values()) { - inv.acceptance.reject(new CancelledError("Client closing")); - inv.completion.reject(new CancelledError("Client closing")); + inv.acceptance.reject(error); + inv.completion.reject(error); } this.invocationsByOriginId.clear(); this.invocationsByJobId.clear(); this.pendingAccepts.length = 0; - for (const d of this.pendingLists.values()) { - d.reject(new CancelledError("Client closing")); - } + for (const d of this.pendingLists.values()) d.reject(error); this.pendingLists.clear(); - for (const d of this.pendingSubscribes.values()) { - d.reject(new CancelledError("Client closing")); - } + for (const d of this.pendingSubscribes.values()) d.reject(error); this.pendingSubscribes.clear(); - if (this.autoAckTimer !== null) { - clearTimeout(this.autoAckTimer); - this.autoAckTimer = null; - } + } + + private clearAutoAckTimer(): void { + if (this.autoAckTimer === null) return; + clearTimeout(this.autoAckTimer); + this.autoAckTimer = null; + } + + private async sendBye(reason: string | undefined): Promise<void> { if (this.transport === null) return; const sessionId = this.state.id; - if (sessionId !== undefined && this.state.isAccepted) { - try { - const env = buildEnvelope({ - id: newMessageId(), - type: "session.bye" as const, - payload: reason === undefined ? {} : { reason }, - optional: { session_id: sessionId }, - }); - await this.transport.send(env); - } catch { - // best-effort - } + if (sessionId === undefined || !this.state.isAccepted) return; + try { + await this.transport.send(buildByeEnvelope(sessionId, reason)); + } catch { + // best-effort } - await this.transport.close(reason); - this.transport = null; } /** @@ -324,39 +327,35 @@ export class ARCPClient { * exposes `done` for the terminal `job.result` / `job.error`. */ public async submit(opts: SubmitOptions): Promise<JobHandle> { - if (this.transport === null) + if (this.transport === null) { throw new InvalidRequestError("Client not connected"); + } if (!this.state.isAccepted) { throw new UnauthenticatedError("Cannot submit: session not accepted"); } const sessionId = this.state.id; - if (sessionId === undefined) + if (sessionId === undefined) { throw new InvalidRequestError("session has no id"); - + } const id = newMessageId(); - const env = buildEnvelope({ - id, - type: "job.submit" as const, - payload: { - agent: opts.agent, - input: opts.input, - ...(opts.lease === undefined ? {} : { lease_request: opts.lease }), - ...(opts.leaseConstraints === undefined - ? {} - : { lease_constraints: opts.leaseConstraints }), - ...(opts.idempotencyKey === undefined - ? {} - : { idempotency_key: opts.idempotencyKey }), - ...(opts.maxRuntimeSec === undefined - ? {} - : { max_runtime_sec: opts.maxRuntimeSec }), - }, - optional: { - session_id: sessionId, - ...(opts.traceId === undefined ? {} : { trace_id: opts.traceId }), - }, - }); + const env = buildSubmitEnvelope({ id, sessionId, opts }); + const invocation = this.registerSubmitInvocation(id, opts); + const abortHandled = this.wireSubmitAbort(invocation, opts.signal); + if (abortHandled === "aborted-before-submit") { + return makeHandleFromInvocation(invocation); + } + await this.transport.send(env); + // The routeJobEvent path resolves `acceptance` *and* registers the + // invocation in `invocationsByJobId` before this await returns, so any + // subsequent events arriving in the same tick will still route. + await invocation.acceptance.promise; + return makeHandleFromInvocation(invocation); + } + private registerSubmitInvocation( + id: string, + opts: SubmitOptions, + ): InvocationState { const invocation: InvocationState = { jobId: null, lease: null, @@ -370,38 +369,35 @@ export class ARCPClient { chunks: new Map(), }; // Mute unhandled-rejection on `completion` — callers consume it via - // `handle.done`. If the submit rejects pre-handle (e.g. AGENT_NOT_AVAILABLE - // arriving before any `job.accepted`), this prevents a spurious unhandled - // promise rejection. + // `handle.done`. If the submit rejects pre-handle (e.g. + // AGENT_NOT_AVAILABLE before any `job.accepted`), this prevents a + // spurious unhandled promise rejection. invocation.completion.promise.catch(() => undefined); this.invocationsByOriginId.set(id, invocation); this.pendingAccepts.push(invocation); + return invocation; + } - if (opts.signal !== undefined) { - const sig = opts.signal; - const onAbort = (): void => { - if (invocation.jobId !== null) { - void this.cancelJob(invocation.jobId, { - reason: String(sig.reason ?? "abort"), - }); - } - }; - if (sig.aborted) { - invocation.acceptance.reject( - new CancelledError("aborted before submit"), - ); - return makeHandleFromInvocation(invocation); - } - sig.addEventListener("abort", onAbort, { once: true }); + private wireSubmitAbort( + invocation: InvocationState, + signal: AbortSignal | undefined, + ): "aborted-before-submit" | null { + if (signal === undefined) return null; + if (signal.aborted) { + invocation.acceptance.reject( + new CancelledError("aborted before submit"), + ); + return "aborted-before-submit"; } - - await this.transport.send(env); - - // The routeJobEvent path resolves `acceptance` *and* registers the - // invocation in `invocationsByJobId` before this await returns, so any - // subsequent events arriving in the same tick will still route. - await invocation.acceptance.promise; - return makeHandleFromInvocation(invocation); + const onAbort = (): void => { + if (invocation.jobId !== null) { + void this.cancelJob(invocation.jobId, { + reason: String(signal.reason ?? "abort"), + }); + } + }; + signal.addEventListener("abort", onAbort, { once: true }); + return null; } /** Send a `job.cancel` envelope. */ @@ -513,184 +509,60 @@ export class ARCPClient { "job.subscribe requires the 'subscribe' feature to be negotiated", ); } - if (this.transport === null) + if (this.transport === null) { throw new InvalidRequestError("Client not connected"); + } const sessionId = this.state.id; - if (sessionId === undefined) + if (sessionId === undefined) { throw new InvalidRequestError("session has no id"); + } const deferred = new Deferred<JobSubscribedPayload>(); this.pendingSubscribes.set(jobId, deferred); - const id = newMessageId(); - const env = buildEnvelope({ - id, - type: "job.subscribe" as const, - payload: { - job_id: jobId, - ...(opts.history === undefined ? {} : { history: opts.history }), - ...(opts.fromEventSeq === undefined - ? {} - : { from_event_seq: opts.fromEventSeq }), - }, - optional: { session_id: sessionId }, - }); - await this.transport.send(env); + await this.transport.send(buildSubscribeEnvelope(jobId, sessionId, opts)); const ack = await deferred.promise; return { jobId, subscribedFrom: ack.subscribed_from, replayed: ack.replayed, - unsubscribe: async () => { - if (this.transport === null) return; - const env = buildEnvelope({ - id: newMessageId(), - type: "job.unsubscribe" as const, - payload: { job_id: jobId }, - optional: { session_id: sessionId }, - }); - try { - await this.transport.send(env); - } catch { - // best-effort - } - }, + unsubscribe: () => this.unsubscribe(jobId, sessionId), }; } - // ------------------------------------------------------------------- - - private async dispatchRaw(frame: WireFrame): Promise<void> { - let parsed: BaseEnvelope; + private async unsubscribe(jobId: JobId, sessionId: SessionId): Promise<void> { + if (this.transport === null) return; try { - parsed = RoundTripEnvelopeSchema.parse(frame); - } catch (error) { - this.logger.warn({ err: error }, "client received malformed frame"); - return; + await this.transport.send(buildUnsubscribeEnvelope(jobId, sessionId)); + } catch { + // best-effort } + } - // Handshake. - if (parsed.type === "session.welcome") { - const result = EnvelopeSchema.safeParse(parsed); - if (result.success && result.data.type === "session.welcome") { - // Assign session id from the envelope itself. - // session_id is typed as required by the schema, but we keep the runtime - // check in case the server omits it on the wire. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (result.data.session_id !== undefined) { - try { - this.state.assignId(result.data.session_id); - } catch { - // ignore — likely a resume on the same id - } - } - this.handshake?.resolve(result.data.payload); - } - return; - } - if (parsed.type === "session.error") { - const result = EnvelopeSchema.safeParse(parsed); - if (result.success && result.data.type === "session.error") { - const err = ARCPError.fromPayload(result.data.payload); - if (this.handshake !== null && !this.handshake.settled) { - this.handshake.reject(err); - } - // Reject all in-flight submissions. - for (const inv of this.invocationsByOriginId.values()) { - if (!inv.acceptance.settled) inv.acceptance.reject(err); - if (!inv.completion.settled) inv.completion.reject(err); - } - for (const d of this.pendingLists.values()) { - if (!d.settled) d.reject(err); - } - for (const d of this.pendingSubscribes.values()) { - if (!d.settled) d.reject(err); - } - } - return; - } + // ------------------------------------------------------------------- - // v1.1 §6.4 — respond to inbound session.ping with session.pong. - if (parsed.type === "session.ping") { - const result = EnvelopeSchema.safeParse(parsed); - if (result.success && result.data.type === "session.ping") { - const sessionId = this.state.id; - if (sessionId !== undefined && this.transport !== null) { - const pongEnv = buildEnvelope({ - id: newMessageId(), - type: "session.pong" as const, - payload: { - ping_nonce: result.data.payload.nonce, - received_at: new Date().toISOString(), - }, - optional: { session_id: sessionId }, - }); - try { - await this.transport.send(pongEnv); - } catch { - // best-effort + private async dispatchRaw(frame: WireFrame): Promise<void> { + await dispatchEnvelope( + { + logger: this.logger, + state: this.state, + handshake: this.handshake as Deferred<unknown> | null, + invocationsByOriginId: this.invocationsByOriginId, + invocationsByJobId: this.invocationsByJobId, + pendingAccepts: this.pendingAccepts, + pendingLists: this.pendingLists as Map<string, Deferred<unknown>>, + pendingSubscribes: this.pendingSubscribes as Map< + string, + Deferred<unknown> + >, + handlers: this.handlers as Map<string, (env: Envelope) => Promise<void>>, + transport: this.transport, + observeEventSeq: (env) => { + if (env.event_seq !== undefined && env.event_seq > this.lastEventSeq) { + this.lastEventSeq = env.event_seq; + this.scheduleAutoAck(); } - } - } - return; - } - if (parsed.type === "session.pong") { - // No-op on the client side beyond updating activity. - return; - } - - // Validate, then route. - const result = EnvelopeSchema.safeParse(parsed); - if (!result.success) { - const issue = result.error.issues[0]; - this.logger.warn( - { type: parsed.type, code: issue?.code, message: issue?.message }, - "client received unparseable envelope", - ); - return; - } - const env = result.data; - // Track event_seq for resume. - if (env.event_seq !== undefined && env.event_seq > this.lastEventSeq) { - this.lastEventSeq = env.event_seq; - this.scheduleAutoAck(); - } - // v1.1 §6.6 — session.jobs (response to list_jobs). - if (env.type === "session.jobs") { - const reqId = env.payload.request_id; - const deferred = this.pendingLists.get(reqId); - if (deferred !== undefined) { - this.pendingLists.delete(reqId); - deferred.resolve(env.payload); - return; - } - } - // v1.1 §7.6 — job.subscribed (response to subscribe). - // job_id is required by the schema, but we keep the runtime check. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (env.type === "job.subscribed" && env.job_id !== undefined) { - const d = this.pendingSubscribes.get(env.job_id); - if (d !== undefined) { - this.pendingSubscribes.delete(env.job_id); - d.resolve(env.payload); - return; - } - } - - this.routeJobEvent(env); - const handler = this.handlers.get(env.type); - if (handler !== undefined) { - try { - await handler(env); - } catch (error) { - this.logger.error( - { err: error, type: env.type }, - "client handler threw", - ); - } - return; - } - this.logger.debug( - { type: env.type }, - "no client handler registered for type", + }, + }, + frame, ); } @@ -722,124 +594,8 @@ export class ARCPClient { } } - private routeJobEvent(env: Envelope): void { - if (env.type === "job.accepted") { - // Bind to the oldest still-pending submit. Register the invocation in - // the by-job-id map synchronously here so that the very-next inbound - // frame (status / result / error) can still be routed even if the - // submit() continuation hasn't yet run from the microtask queue. - const inv = this.pendingAccepts.shift(); - if (inv !== undefined && !inv.acceptance.settled) { - const payload = env.payload; - inv.jobId = payload.job_id; - inv.lease = payload.lease; - inv.agent = payload.agent; - inv.leaseConstraints = payload.lease_constraints; - inv.budget = payload.budget; - inv.traceId = payload.trace_id ?? inv.traceId; - this.invocationsByJobId.set(payload.job_id, inv); - inv.acceptance.resolve(payload); - } - return; - } - - // job_id is required by the schema, but we keep the runtime check. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (env.type === "job.event" && env.job_id !== undefined) { - const inv = this.invocationsByJobId.get(env.job_id); - if (inv !== undefined) { - const ep = env.payload; - inv.events.push(ep); - // v1.1 §8.4 — accumulate result_chunk bodies for later assembly. - if (ep.kind === "result_chunk") { - const body = ep.body as ResultChunkBody; - let bucket = inv.chunks.get(body.result_id); - if (bucket === undefined) { - bucket = []; - inv.chunks.set(body.result_id, bucket); - } - bucket.push(body); - } - } - return; - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (env.type === "job.result" && env.job_id !== undefined) { - const inv = this.invocationsByJobId.get(env.job_id); - if (inv !== undefined) { - inv.completion.resolve(env.payload); - this.invocationsByJobId.delete(env.job_id); - } - return; - } - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (env.type === "job.error" && env.job_id !== undefined) { - const payload = env.payload; - const err = ARCPError.fromPayload(jobErrorToErrorPayload(payload)); - let inv = this.invocationsByJobId.get(env.job_id); - if (inv === undefined) { - // No binding yet — this can happen when the runtime rejects the - // submit (AGENT_NOT_AVAILABLE, DUPLICATE_KEY, etc) without ever - // emitting job.accepted. Bind to the oldest pending submit. - inv = this.pendingAccepts.shift(); - if (inv !== undefined) { - inv.jobId = env.job_id; - this.invocationsByJobId.set(env.job_id, inv); - } - } - if (inv !== undefined) { - if (!inv.acceptance.settled) inv.acceptance.reject(err); - inv.completion.reject(err); - this.invocationsByJobId.delete(env.job_id); - } - return; - } - } } -function makeHandleFromInvocation(inv: InvocationState): JobHandle { - return { - get jobId(): JobId { - return inv.jobId ?? ("" as JobId); - }, - get lease() { - return inv.lease ?? {}; - }, - get agent() { - return inv.agent; - }, - get leaseConstraints() { - return inv.leaseConstraints; - }, - get budget() { - return inv.budget; - }, - get traceId() { - return inv.traceId; - }, - done: inv.completion.promise, - async collectChunks(): Promise<Buffer | string> { - const result = await inv.completion.promise; - const resultId = result.result_id; - if (resultId === undefined) { - throw new InvalidRequestError( - "job.result has no result_id; no chunks to collect", - ); - } - const chunks = inv.chunks.get(resultId); - if (chunks === undefined || chunks.length === 0) { - return ""; - } - const sorted = chunks.toSorted((a, b) => a.chunk_seq - b.chunk_seq); - const encoding = sorted[0]?.encoding ?? "utf8"; - if (encoding === "base64") { - const buffers = sorted.map((c) => Buffer.from(c.data, "base64")); - return Buffer.concat(buffers); - } - return sorted.map((c) => c.data).join(""); - }, - }; -} /** * Lightweight typed assertion used in tests and bridge code: narrow an diff --git a/packages/runtime/src/job-runner-helpers.ts b/packages/runtime/src/job-runner-helpers.ts new file mode 100644 index 0000000..63e2043 --- /dev/null +++ b/packages/runtime/src/job-runner-helpers.ts @@ -0,0 +1,242 @@ +import { type BaseEnvelope, buildEnvelope } from "@arcp/core/envelope"; +import { + AgentNotAvailableError, + AgentVersionNotAvailableError, + ARCPError, + CancelledError, + InternalError, + InvalidRequestError, + LeaseSubsetViolationError, +} from "@arcp/core/errors"; +import type { + DelegateBody, + Envelope, + Lease, + MetricBody, +} from "@arcp/core/messages"; +import { newJobId, newMessageId } from "@arcp/core/util"; + +import type { Job } from "./job.js"; +import { + assertLeaseConstraintsSubset, + assertLeaseSubset, + validateLeaseConstraints, + validateLeaseShape, +} from "./lease.js"; +import type { SessionContext } from "./server.js"; +import type { AgentHandler, JobContext } from "./types.js"; + +// Narrow extracted from `Envelope` for ergonomics inside the submit pipeline. +export type SubmitPayload = Extract< + Envelope, + { type: "job.submit" } +>["payload"]; + +export interface ResolvedSubmitAgent { + parsedAgent: { name: string; version: string | null }; + handler: AgentHandler; + resolvedVersion: string; +} + +export type DelegateOutcome = + | { ok: true; jobId: string } + | { ok: false; error: ARCPError }; + +export type DelegateInterceptor = (body: DelegateBody) => Promise<void>; +export type MetricInterceptor = (body: MetricBody) => Promise<void>; +export type SubscriberBroadcaster = (env: BaseEnvelope) => void; + +export interface WrapJobCtxArgs { + base: JobContext; + delegateInterceptor: DelegateInterceptor; + metricInterceptor: MetricInterceptor; + // subscriberBroadcaster is invoked at the Job-emit-level via a Job wrapper; + // event broadcasting actually hooks via the SessionContext.send pipeline, + // not here. The field is retained for future expansion. + broadcast: SubscriberBroadcaster; +} + +export function wrapJobCtx(args: WrapJobCtxArgs): JobContext { + const { base, delegateInterceptor, metricInterceptor } = args; + return { + ...base, + async delegate(body: DelegateBody) { + await delegateInterceptor(body); + }, + async metric(body) { + await base.metric(body); + await metricInterceptor(body); + }, + }; +} + +export async function emitParseError( + ctx: SessionContext, + error: unknown, +): Promise<void> { + await ctx.emitJobError(newJobId(), { + final_status: "error", + code: "INVALID_REQUEST", + message: error instanceof Error ? error.message : String(error), + retryable: false, + }); +} + +export async function emitAgentResolveError( + ctx: SessionContext, + error: unknown, + agentRef: string, +): Promise<void> { + if (error instanceof AgentVersionNotAvailableError) { + // §7.5: version errors emit session.error per spec example (§13.7). + await ctx.emitSessionError(error); + return; + } + const wrapped = + error instanceof ARCPError + ? error + : new AgentNotAvailableError(`Agent "${agentRef}" is not registered`); + await ctx.emitJobError(newJobId(), { + final_status: "error", + code: wrapped.code, + message: wrapped.message, + retryable: wrapped.retryable, + }); +} + +export async function emitArcpError( + ctx: SessionContext, + error: unknown, +): Promise<void> { + const wrapped = + error instanceof ARCPError ? error : new InvalidRequestError(String(error)); + await ctx.emitJobError(newJobId(), { + final_status: "error", + code: wrapped.code, + message: wrapped.message, + retryable: wrapped.retryable, + }); +} + +export function scheduleRuntimeTimeout( + job: Job, + maxRuntimeSec: number | undefined, +): ReturnType<typeof setTimeout> | null { + if (maxRuntimeSec === undefined || maxRuntimeSec <= 0) return null; + const timer = setTimeout(() => { + if (job.isTerminal) return; + job.abortController.abort(new InternalError("max_runtime_sec exceeded")); + void job.emitErrorEnvelope({ + final_status: "timed_out", + code: "TIMEOUT", + message: `Job exceeded max_runtime_sec=${maxRuntimeSec}`, + retryable: true, + }); + }, maxRuntimeSec * 1000); + timer.unref(); + return timer; +} + +export interface RunAndEmitArgs { + job: Job; + handler: AgentHandler; + input: unknown; + wrappedCtx: JobContext; +} + +export async function runAndEmitResult({ + job, + handler, + input, + wrappedCtx, +}: RunAndEmitArgs): Promise<void> { + const result = await handler(input, wrappedCtx); + if (job.isTerminal) return; + if (job.chunkedResultStarted) { + // The agent should have called `finalize` on its ResultStream. + // If not, emit a terminal result_chunk{more:false}+job.result. + await job.emitResult({ + final_status: "success", + result_id: `res_${job.jobId.replace(/^job_/, "")}_auto`, + }); + return; + } + await job.emitResult({ final_status: "success", result }); +} + +export async function emitHandlerFailure( + job: Job, + error: unknown, +): Promise<void> { + if (job.isTerminal) return; + const wrapped = wrapHandlerError(error); + const finalStatus: "cancelled" | "timed_out" | "error" = + wrapped instanceof CancelledError + ? "cancelled" + : wrapped.code === "TIMEOUT" + ? "timed_out" + : "error"; + await job.emitErrorEnvelope({ + final_status: finalStatus, + code: wrapped.code, + message: wrapped.message, + retryable: wrapped.retryable, + }); +} + +function wrapHandlerError(error: unknown): ARCPError { + if (error instanceof ARCPError) return error; + if (error instanceof Error && error.name === "CancelledError") { + return new CancelledError(error.message); + } + return new InternalError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ); +} + +export async function forwardEventToSubscriber( + sub: SessionContext, + src: BaseEnvelope, +): Promise<void> { + if (sub.state.id === undefined) return; + // Build a fresh envelope: same payload/type/job_id but new id and a new + // session-scoped event_seq. Preserve trace_id when present. + const env = buildEnvelope({ + id: newMessageId(), + type: src.type, + payload: src.payload, + optional: { + session_id: sub.state.id, + ...(src.job_id === undefined ? {} : { job_id: src.job_id }), + ...(src.trace_id === undefined ? {} : { trace_id: src.trace_id }), + ...(src.event_seq === undefined + ? {} + : { event_seq: sub.nextEventSeq() }), + }, + }); + await sub.send(env); +} + +export function validateDelegateLease( + requested: Lease, + parent: Job, + body: DelegateBody, +): ARCPError | null { + try { + validateLeaseShape(requested); + // Pass parent's REMAINING budget for §9.4 enforcement. + assertLeaseSubset(requested, parent.lease, parent.budget); + assertLeaseConstraintsSubset( + body.lease_constraints, + parent.leaseConstraints, + ); + // Child inherits parent expiry implicitly if absent. + validateLeaseConstraints(body.lease_constraints); + return null; + } catch (error) { + if (error instanceof LeaseSubsetViolationError) return error; + if (error instanceof ARCPError) return error; + return new InvalidRequestError(String(error)); + } +} diff --git a/packages/runtime/src/job-runner.ts b/packages/runtime/src/job-runner.ts index a7c346a..cb3fb5b 100644 --- a/packages/runtime/src/job-runner.ts +++ b/packages/runtime/src/job-runner.ts @@ -1,16 +1,13 @@ import { randomBytes } from "node:crypto"; -import type { TraceId } from "@arcp/core"; -import { type BaseEnvelope, buildEnvelope } from "@arcp/core/envelope"; +import type { SessionId, TraceId } from "@arcp/core"; +import type { BaseEnvelope } from "@arcp/core/envelope"; import { AgentNotAvailableError, - AgentVersionNotAvailableError, ARCPError, - CancelledError, InternalError, InvalidRequestError, LeaseExpiredError, - LeaseSubsetViolationError, } from "@arcp/core/errors"; import { type DelegateBody, @@ -20,16 +17,25 @@ import { type MetricBody, parseAgentRef, } from "@arcp/core/messages"; -import { newJobId, newMessageId } from "@arcp/core/util"; -import { Job, makeJobContext } from "./job.js"; import { - assertLeaseConstraintsSubset, - assertLeaseSubset, - initialBudgetFromLease, - validateLeaseConstraints, - validateLeaseShape, -} from "./lease.js"; + type DelegateOutcome, + emitAgentResolveError, + emitArcpError, + emitHandlerFailure, + emitParseError, + forwardEventToSubscriber, + type MetricInterceptor, + type ResolvedSubmitAgent, + runAndEmitResult, + scheduleRuntimeTimeout, + type SubmitPayload, + type SubscriberBroadcaster, + validateDelegateLease, + wrapJobCtx, +} from "./job-runner-helpers.js"; +import { Job, makeJobContext } from "./job.js"; +import { initialBudgetFromLease, validateLeaseConstraints, validateLeaseShape } from "./lease.js"; import type { ARCPServer, SessionContext } from "./server.js"; import { digest, type IdempotencyEntry } from "./stores.js"; import type { AgentHandler, JobContext } from "./types.js"; @@ -39,8 +45,60 @@ const DEFAULT_IDEMPOTENCY_TTL_MS = 24 * 60 * 60 * 1000; const DEFAULT_MAX_CONCURRENT_JOBS = 100; type DelegateInterceptor = (body: DelegateBody) => Promise<void>; -type MetricInterceptor = (body: MetricBody) => Promise<void>; -type SubscriberBroadcaster = (env: BaseEnvelope) => void; + +interface LeaseFields { + requestedLease: Lease; + leaseConstraints: LeaseConstraints | undefined; + initialBudget: ReadonlyMap<string, number>; +} + +interface AcceptDispatchArgs { + ctx: SessionContext; + env: Envelope; + sessionId: SessionId; + resolved: ResolvedSubmitAgent; + leaseFields: LeaseFields; + principal: string; + idempotency: IdempotencyEntry | null; +} + +interface ConstructJobInput { + ctx: SessionContext; + env: Envelope; + sessionId: SessionId; + payload: SubmitPayload; + parsedAgentName: string; + resolvedVersion: string; + leaseFields: LeaseFields; + idempotencyHit: IdempotencyEntry | null; + principal: string; +} + +interface RunHandlerArgs { + ctx: SessionContext; + job: Job; + handler: AgentHandler; + input: unknown; + jobCtx: JobContext; + maxRuntimeSec: number | undefined; +} + +interface RecordIdempotencyArgs { + job: Job; + principal: string; + payload: SubmitPayload; + idempotencyHit: IdempotencyEntry | "conflict" | null; +} + +interface ConstructDelegateChildInput { + ctx: SessionContext; + parent: Job; + body: DelegateBody; + sessionId: SessionId; + requested: Lease; + parsedAgentName: string; + resolvedVersion: string; +} /** * Owns the job-submission and job-execution pipeline (§7): handler @@ -58,142 +116,160 @@ export class JobRunner { if (env.type !== "job.submit") return; const sessionId = ctx.state.id; if (sessionId === undefined) return; - const payload = env.payload; + if (await this.rejectIfOverConcurrencyCap(ctx)) return; + const resolved = await this.resolveSubmitAgent(ctx, env.payload); + if (resolved === null) return; + const leaseFields = await this.validateLeaseAndConstraints(ctx, env.payload); + if (leaseFields === null) return; + const principal = ctx.state.identity?.principal ?? "<anonymous>"; + const idempotency = await this.checkIdempotency(ctx, principal, env.payload); + if (idempotency === "conflict") return; + await this.acceptAndDispatchSubmit({ + ctx, + env, + sessionId, + resolved, + leaseFields, + principal, + idempotency, + }); + } - // Per-session max concurrent jobs cap (§14). - const caps = this.server.options.caps ?? {}; - const maxConcurrent = caps.maxConcurrentJobs ?? DEFAULT_MAX_CONCURRENT_JOBS; - if (ctx.jobs.list().length >= maxConcurrent) { - await ctx.emitSessionError( - new InternalError("Max concurrent jobs reached", { retryable: false }), - ); - return; - } + private async acceptAndDispatchSubmit( + args: AcceptDispatchArgs, + ): Promise<void> { + const { ctx, env, resolved, principal, idempotency } = args; + if (env.type !== "job.submit") return; + const job = this.constructJob({ + ctx, + env, + sessionId: args.sessionId, + payload: env.payload, + parsedAgentName: resolved.parsedAgent.name, + resolvedVersion: resolved.resolvedVersion, + leaseFields: args.leaseFields, + idempotencyHit: idempotency, + principal, + }); + this.recordIdempotency({ + job, + principal, + payload: env.payload, + idempotencyHit: idempotency, + }); + await job.emitAccepted(); + await job.emitRunning(); + void this.runHandler({ + ctx, + job, + handler: resolved.handler, + input: env.payload.input, + jobCtx: makeJobContext(job), + maxRuntimeSec: env.payload.max_runtime_sec, + }); + } - // v1.1 §7.5 — parse agent reference and resolve a handler. + private async rejectIfOverConcurrencyCap( + ctx: SessionContext, + ): Promise<boolean> { + const maxConcurrent = + this.server.options.caps?.maxConcurrentJobs ?? + DEFAULT_MAX_CONCURRENT_JOBS; + if (ctx.jobs.list().length < maxConcurrent) return false; + await ctx.emitSessionError( + new InternalError("Max concurrent jobs reached", { retryable: false }), + ); + return true; + } + + private async resolveSubmitAgent( + ctx: SessionContext, + payload: SubmitPayload, + ): Promise<ResolvedSubmitAgent | null> { let parsedAgent: { name: string; version: string | null }; try { parsedAgent = parseAgentRef(payload.agent); } catch (error) { - await ctx.emitJobError(newJobId(), { - final_status: "error", - code: "INVALID_REQUEST", - message: error instanceof Error ? error.message : String(error), - retryable: false, - }); - return; + await emitParseError(ctx, error); + return null; } - let handler: AgentHandler; - let resolvedVersion: string; try { - const resolved = this.server.resolveAgent( - parsedAgent.name, - parsedAgent.version, - ); - handler = resolved.handler; - resolvedVersion = resolved.version; + const r = this.server.resolveAgent(parsedAgent.name, parsedAgent.version); + return { parsedAgent, handler: r.handler, resolvedVersion: r.version }; } catch (error) { - // §7.5: version errors emit session.error per spec example (§13.7). - if (error instanceof AgentVersionNotAvailableError) { - await ctx.emitSessionError(error); - return; - } - const wrapped = - error instanceof ARCPError - ? error - : new AgentNotAvailableError( - `Agent "${payload.agent}" is not registered`, - ); - const jobId = newJobId(); - await ctx.emitJobError(jobId, { - final_status: "error", - code: wrapped.code, - message: wrapped.message, - retryable: wrapped.retryable, - }); - return; + await emitAgentResolveError(ctx, error, payload.agent); + return null; } + } - // Lease validation (shape + cost.budget patterns). + private async validateLeaseAndConstraints( + ctx: SessionContext, + payload: SubmitPayload, + ): Promise<{ + requestedLease: Lease; + leaseConstraints: LeaseConstraints | undefined; + initialBudget: ReadonlyMap<string, number>; + } | null> { const requestedLease: Lease = payload.lease_request ?? {}; try { validateLeaseShape(requestedLease); } catch (error) { - const wrapped = - error instanceof ARCPError - ? error - : new InvalidRequestError(String(error)); - await ctx.emitJobError(newJobId(), { - final_status: "error", - code: wrapped.code, - message: wrapped.message, - retryable: wrapped.retryable, - }); - return; + await emitArcpError(ctx, error); + return null; } - - // v1.1 §9.5 — validate lease_constraints (UTC, future). - const leaseConstraints: LeaseConstraints | undefined = - payload.lease_constraints; + const leaseConstraints = payload.lease_constraints; try { validateLeaseConstraints(leaseConstraints); } catch (error) { - const wrapped = - error instanceof ARCPError - ? error - : new InvalidRequestError(String(error)); - await ctx.emitJobError(newJobId(), { - final_status: "error", - code: wrapped.code, - message: wrapped.message, - retryable: wrapped.retryable, - }); - return; + await emitArcpError(ctx, error); + return null; } + return { + requestedLease, + leaseConstraints, + initialBudget: initialBudgetFromLease(requestedLease), + }; + } - // v1.1 §9.6 — initial budget counters. - const initialBudget = initialBudgetFromLease(requestedLease); - - // Idempotency: keyed by (principal, idempotency_key). - const principal = ctx.state.identity?.principal ?? "<anonymous>"; - let idempotencyHit: IdempotencyEntry | null = null; - if (payload.idempotency_key !== undefined) { - const key = `${principal}::${payload.idempotency_key}`; - this.server.idempotencyStore.sweep(); - const existing = this.server.idempotencyStore.get(key); - if (existing !== undefined && existing.expiresAt > Date.now()) { - const sameAgent = existing.agent === payload.agent; - const sameInput = existing.inputDigest === digest(payload.input); - if (!sameAgent || !sameInput) { - await ctx.emitJobError(existing.jobId, { - final_status: "error", - code: "DUPLICATE_KEY", - message: `idempotency_key "${payload.idempotency_key}" reused with conflicting params`, - retryable: false, - details: { existing_job_id: existing.jobId }, - }); - return; - } - idempotencyHit = existing; - } else { - ctx.addLocalIdempotencyKey(key); - } + private async checkIdempotency( + ctx: SessionContext, + principal: string, + payload: SubmitPayload, + ): Promise<IdempotencyEntry | "conflict" | null> { + if (payload.idempotency_key === undefined) return null; + const key = `${principal}::${payload.idempotency_key}`; + this.server.idempotencyStore.sweep(); + const existing = this.server.idempotencyStore.get(key); + if (existing === undefined || existing.expiresAt <= Date.now()) { + ctx.addLocalIdempotencyKey(key); + return null; } + const sameAgent = existing.agent === payload.agent; + const sameInput = existing.inputDigest === digest(payload.input); + if (sameAgent && sameInput) return existing; + await ctx.emitJobError(existing.jobId, { + final_status: "error", + code: "DUPLICATE_KEY", + message: `idempotency_key "${payload.idempotency_key}" reused with conflicting params`, + retryable: false, + details: { existing_job_id: existing.jobId }, + }); + return "conflict"; + } - // Generate or echo trace_id (§11). Runtime MUST mint one if absent so - // `job.accepted.payload.trace_id` always has a value to echo back. + private constructJob(input: ConstructJobInput): Job { const traceId: TraceId = - env.trace_id ?? (randomBytes(16).toString("hex") as TraceId); - + input.env.trace_id ?? (randomBytes(16).toString("hex") as TraceId); + const idempotencyHit = input.idempotencyHit; const job = new Job({ options: { ...(idempotencyHit === null ? {} : { jobId: idempotencyHit.jobId }), - sessionId, - agent: parsedAgent.name, - agentVersion: resolvedVersion === "" ? null : resolvedVersion, - lease: requestedLease, - leaseConstraints, - initialBudget, + sessionId: input.sessionId, + agent: input.parsedAgentName, + agentVersion: input.resolvedVersion === "" ? null : input.resolvedVersion, + lease: input.leaseFields.requestedLease, + leaseConstraints: input.leaseFields.leaseConstraints, + initialBudget: input.leaseFields.initialBudget, heartbeatIntervalSeconds: this.server.options.heartbeatIntervalSeconds ?? DEFAULT_HEARTBEAT_SECONDS, @@ -201,160 +277,94 @@ export class JobRunner { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition ...(traceId === undefined ? {} : { traceId }), }, - send: (out) => ctx.send(out), - seq: ctx, - logger: ctx.logger.child({ job_id: "<pending>" }), + send: (out) => input.ctx.send(out), + seq: input.ctx, + logger: input.ctx.logger.child({ job_id: "<pending>" }), }); - job.submitterPrincipal = principal; - job.owningSession = ctx; + job.submitterPrincipal = input.principal; + job.owningSession = input.ctx; this.server.globalJobs.set(job.jobId, job); - ctx.jobs.register(job); - Object.assign(job, { logger: ctx.logger.child({ job_id: job.jobId }) }); + input.ctx.jobs.register(job); + Object.assign(job, { + logger: input.ctx.logger.child({ job_id: job.jobId }), + }); + return job; + } - if (payload.idempotency_key !== undefined && idempotencyHit === null) { - const key = `${principal}::${payload.idempotency_key}`; - const ttl = - this.server.options.idempotencyTtlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS; - this.server.idempotencyStore.set(key, { - jobId: job.jobId, - agent: payload.agent, - inputDigest: digest(payload.input), - expiresAt: Date.now() + ttl, - }); - } + private recordIdempotency(args: RecordIdempotencyArgs): void { + const { job, principal, payload, idempotencyHit } = args; + if (payload.idempotency_key === undefined) return; + if (idempotencyHit !== null) return; + const key = `${principal}::${payload.idempotency_key}`; + const ttl = + this.server.options.idempotencyTtlMs ?? DEFAULT_IDEMPOTENCY_TTL_MS; + this.server.idempotencyStore.set(key, { + jobId: job.jobId, + agent: payload.agent, + inputDigest: digest(payload.input), + expiresAt: Date.now() + ttl, + }); + } - await job.emitAccepted(); - await job.emitRunning(); + public async runHandler(args: RunHandlerArgs): Promise<void> { + const { ctx, job, handler, input, jobCtx, maxRuntimeSec } = args; + const timeoutTimer = scheduleRuntimeTimeout(job, maxRuntimeSec); + const leaseTimer = this.scheduleLeaseExpiry(ctx, job); + if (leaseTimer === "expired") return; - const jobCtx = makeJobContext(job); - void this.runHandler( - ctx, - job, - handler, - payload.input, - jobCtx, - payload.max_runtime_sec, - ); + const wrapped = wrapJobCtx({ + base: jobCtx, + delegateInterceptor: this.makeDelegateInterceptor(ctx, job), + metricInterceptor: this.metricInterceptor(job), + broadcast: this.subscriberBroadcaster(job), + }); + + try { + await runAndEmitResult({ job, handler, input, wrappedCtx: wrapped }); + } catch (error) { + await emitHandlerFailure(job, error); + } finally { + if (timeoutTimer !== null) clearTimeout(timeoutTimer); + if (leaseTimer !== null) clearTimeout(leaseTimer); + ctx.jobs.retire(job.jobId); + this.server.globalJobs.delete(job.jobId); + this.server.subscribers.delete(job.jobId); + } } - public async runHandler( + private scheduleLeaseExpiry( ctx: SessionContext, job: Job, - handler: AgentHandler, - input: unknown, - jobCtx: JobContext, - maxRuntimeSec: number | undefined, - ): Promise<void> { - let timeoutTimer: ReturnType<typeof setTimeout> | null = null; - if (maxRuntimeSec !== undefined && maxRuntimeSec > 0) { - timeoutTimer = setTimeout(() => { - if (!job.isTerminal) { - job.abortController.abort( - new InternalError("max_runtime_sec exceeded"), - ); - void job.emitErrorEnvelope({ - final_status: "timed_out", - code: "TIMEOUT", - message: `Job exceeded max_runtime_sec=${maxRuntimeSec}`, - retryable: true, - }); - } - }, maxRuntimeSec * 1000); - timeoutTimer.unref(); - } - - // v1.1 §9.5 — lease-expiration watchdog. If expires_at elapses while the - // job is still running, surface LEASE_EXPIRED as job.error. - let leaseExpiryTimer: ReturnType<typeof setTimeout> | null = null; + ): ReturnType<typeof setTimeout> | "expired" | null { const expiresAt = job.leaseConstraints?.expires_at; - if (expiresAt !== undefined) { - const ms = Date.parse(expiresAt) - Date.now(); - if (Number.isFinite(ms) && ms > 0) { - leaseExpiryTimer = setTimeout(() => { - if (!job.isTerminal) { - void job.emitErrorEnvelope({ - final_status: "error", - code: "LEASE_EXPIRED", - message: `Lease expired at ${expiresAt}`, - retryable: false, - }); - job.abortController.abort( - new LeaseExpiredError(`Lease expired at ${expiresAt}`), - ); - } - }, ms); - leaseExpiryTimer.unref(); - } else { - // Past or invalid — terminate immediately. + if (expiresAt === undefined) return null; + const ms = Date.parse(expiresAt) - Date.now(); + if (Number.isFinite(ms) && ms > 0) { + const timer = setTimeout(() => { + if (job.isTerminal) return; void job.emitErrorEnvelope({ final_status: "error", code: "LEASE_EXPIRED", message: `Lease expired at ${expiresAt}`, retryable: false, }); - ctx.jobs.retire(job.jobId); - this.server.globalJobs.delete(job.jobId); - return; - } - } - - // Listen for delegate events on this job context — runtime intercepts them. - const delegateInterceptor = this.makeDelegateInterceptor(ctx, job); - const wrapped = wrapJobCtx( - jobCtx, - delegateInterceptor, - this.metricInterceptor(job), - this.subscriberBroadcaster(job), - ); - - try { - const result = await handler(input, wrapped); - if (!job.isTerminal) { - await (job.chunkedResultStarted - ? // The agent should have called `finalize` on its ResultStream. - // If not, emit a terminal result_chunk{more:false}+job.result. - job.emitResult({ - final_status: "success", - result_id: `res_${job.jobId.replace(/^job_/, "")}_auto`, - }) - : job.emitResult({ - final_status: "success", - result, - })); - } - } catch (error) { - if (job.isTerminal) return; - const wrappedErr = - error instanceof ARCPError - ? error - : error instanceof Error && error.name === "CancelledError" - ? new CancelledError(error.message) - : new InternalError( - error instanceof Error ? error.message : String(error), - { - cause: error instanceof Error ? error : undefined, - }, - ); - const finalStatus = - wrappedErr instanceof CancelledError - ? "cancelled" - : wrappedErr.code === "TIMEOUT" - ? "timed_out" - : "error"; - await job.emitErrorEnvelope({ - final_status: finalStatus, - code: wrappedErr.code, - message: wrappedErr.message, - retryable: wrappedErr.retryable, - }); - } finally { - if (timeoutTimer !== null) clearTimeout(timeoutTimer); - if (leaseExpiryTimer !== null) clearTimeout(leaseExpiryTimer); - ctx.jobs.retire(job.jobId); - this.server.globalJobs.delete(job.jobId); - // Drop any active subscriptions for this job. - this.server.subscribers.delete(job.jobId); + job.abortController.abort( + new LeaseExpiredError(`Lease expired at ${expiresAt}`), + ); + }, ms); + timer.unref(); + return timer; } + // Past or invalid — terminate immediately. + void job.emitErrorEnvelope({ + final_status: "error", + code: "LEASE_EXPIRED", + message: `Lease expired at ${expiresAt}`, + retryable: false, + }); + ctx.jobs.retire(job.jobId); + this.server.globalJobs.delete(job.jobId); + return "expired"; } /** @@ -367,8 +377,41 @@ export class JobRunner { ctx: SessionContext, parent: Job, body: DelegateBody, - ): Promise<{ ok: true; jobId: string } | { ok: false; error: ARCPError }> { + ): Promise<DelegateOutcome> { const requested: Lease = body.lease_request ?? {}; + const agent = this.resolveDelegateAgent(body); + if (!agent.ok) return agent; + const leaseCheck = validateDelegateLease(requested, parent, body); + if (leaseCheck !== null) return { ok: false, error: leaseCheck }; + const sessionId = ctx.state.id; + if (sessionId === undefined) { + return { ok: false, error: new InternalError("session has no id") }; + } + const child = this.constructDelegateChild({ + ctx, + parent, + body, + sessionId, + requested, + parsedAgentName: agent.parsedAgent.name, + resolvedVersion: agent.resolvedVersion, + }); + await child.emitAccepted(); + await child.emitRunning(); + void this.runHandler({ + ctx, + job: child, + handler: agent.handler, + input: body.input, + jobCtx: makeJobContext(child), + maxRuntimeSec: undefined, + }); + return { ok: true, jobId: child.jobId }; + } + + private resolveDelegateAgent( + body: DelegateBody, + ): { ok: true } & ResolvedSubmitAgent | { ok: false; error: ARCPError } { let parsedAgent: { name: string; version: string | null }; try { parsedAgent = parseAgentRef(body.agent); @@ -383,12 +426,14 @@ export class JobRunner { ), }; } - let handler: AgentHandler; - let resolvedVersion: string; try { const r = this.server.resolveAgent(parsedAgent.name, parsedAgent.version); - handler = r.handler; - resolvedVersion = r.version; + return { + ok: true, + parsedAgent, + handler: r.handler, + resolvedVersion: r.version, + }; } catch (error) { return { ok: false, @@ -400,41 +445,20 @@ export class JobRunner { ), }; } - try { - validateLeaseShape(requested); - // Pass parent's REMAINING budget for §9.4 enforcement. - assertLeaseSubset(requested, parent.lease, parent.budget); - assertLeaseConstraintsSubset( - body.lease_constraints, - parent.leaseConstraints, - ); - // Child inherits parent expiry implicitly if absent. - validateLeaseConstraints(body.lease_constraints); - } catch (error) { - const wrapped = - error instanceof LeaseSubsetViolationError - ? error - : error instanceof ARCPError - ? error - : new InvalidRequestError(String(error)); - return { ok: false, error: wrapped }; - } - const sessionId = ctx.state.id; - if (sessionId === undefined) { - return { ok: false, error: new InternalError("session has no id") }; - } - // Effective child constraints: child explicit OR inherited from parent. + } + + private constructDelegateChild(input: ConstructDelegateChildInput): Job { + const { ctx, parent, body, sessionId, requested } = input; const effectiveConstraints: LeaseConstraints | undefined = body.lease_constraints ?? parent.leaseConstraints; - const childBudget = initialBudgetFromLease(requested); const child = new Job({ options: { sessionId, - agent: parsedAgent.name, - agentVersion: resolvedVersion === "" ? null : resolvedVersion, + agent: input.parsedAgentName, + agentVersion: input.resolvedVersion === "" ? null : input.resolvedVersion, lease: requested, leaseConstraints: effectiveConstraints, - initialBudget: childBudget, + initialBudget: initialBudgetFromLease(requested), parentJobId: parent.jobId, delegateId: body.delegate_id, heartbeatIntervalSeconds: @@ -454,11 +478,7 @@ export class JobRunner { this.server.globalJobs.set(child.jobId, child); ctx.jobs.register(child); Object.assign(child, { logger: ctx.logger.child({ job_id: child.jobId }) }); - await child.emitAccepted(); - await child.emitRunning(); - const childCtx = makeJobContext(child); - void this.runHandler(ctx, child, handler, body.input, childCtx, undefined); - return { ok: true, jobId: child.jobId }; + return child; } /** Intercept `metric` events to apply v1.1 §9.6 budget decrements. */ @@ -523,46 +543,5 @@ export class JobRunner { } } -export async function forwardEventToSubscriber( - sub: SessionContext, - src: BaseEnvelope, -): Promise<void> { - if (sub.state.id === undefined) return; - // Build a fresh envelope: same payload/type/job_id but new id and a new - // session-scoped event_seq. Preserve trace_id when present. - const env = buildEnvelope({ - id: newMessageId(), - type: src.type, - payload: src.payload, - optional: { - session_id: sub.state.id, - ...(src.job_id === undefined ? {} : { job_id: src.job_id }), - ...(src.trace_id === undefined ? {} : { trace_id: src.trace_id }), - ...(src.event_seq === undefined - ? {} - : { event_seq: sub.nextEventSeq() }), - }, - }); - await sub.send(env); -} +export { forwardEventToSubscriber } from "./job-runner-helpers.js"; -function wrapJobCtx( - ctx: JobContext, - interceptor: DelegateInterceptor, - metricInterceptor: MetricInterceptor, - // subscriberBroadcaster is invoked at the Job-emit-level via a Job wrapper; - // event broadcasting actually hooks via the SessionContext.send pipeline, - // not here. The argument is retained for future expansion. - _broadcaster: SubscriberBroadcaster, -): JobContext { - return { - ...ctx, - async delegate(body: DelegateBody) { - await interceptor(body); - }, - async metric(body) { - await ctx.metric(body); - await metricInterceptor(body); - }, - }; -} diff --git a/packages/runtime/src/list-jobs.ts b/packages/runtime/src/list-jobs.ts new file mode 100644 index 0000000..0eb47fd --- /dev/null +++ b/packages/runtime/src/list-jobs.ts @@ -0,0 +1,92 @@ +import { + JOB_STATES, + type JobListEntry, + parseAgentRef, + type SessionListJobsFilter, +} from "@arcp/core/messages"; + +import type { Job } from "./job.js"; + +export interface ListJobsFilter { + matches(job: Job): boolean; +} + +export function compileListJobsFilter( + filter: SessionListJobsFilter, +): ListJobsFilter { + const allowedStatuses = new Set<string>(filter.status ?? JOB_STATES); + const createdAfter = filter.created_after + ? Date.parse(filter.created_after) + : null; + const createdBefore = filter.created_before + ? Date.parse(filter.created_before) + : null; + const agentMatcher = + filter.agent === undefined ? null : buildAgentMatcher(filter.agent); + return { + matches(job) { + if (!allowedStatuses.has(job.state)) return false; + if (agentMatcher !== null && !agentMatcher(job)) return false; + if (!matchesCreatedAfter(job, createdAfter)) return false; + if (!matchesCreatedBefore(job, createdBefore)) return false; + return true; + }, + }; +} + +function buildAgentMatcher(agent: string): (job: Job) => boolean { + const parsed = parseAgentRef(agent); + if (parsed.version === null) { + return (job) => job.agent === parsed.name; + } + return (job) => + job.agent === parsed.name && job.agentVersion === parsed.version; +} + +function matchesCreatedAfter(job: Job, threshold: number | null): boolean { + if (threshold === null) return true; + const t = Date.parse(job.createdAt); + return Number.isFinite(t) && t > threshold; +} + +function matchesCreatedBefore(job: Job, threshold: number | null): boolean { + if (threshold === null) return true; + const t = Date.parse(job.createdAt); + return Number.isFinite(t) && t < threshold; +} + +export function compareJobListEntries( + a: JobListEntry, + b: JobListEntry, +): number { + // Sort by created_at ascending, then by job_id for determinism. + const ta = Date.parse(a.created_at); + const tb = Date.parse(b.created_at); + if (ta !== tb) return ta - tb; + return a.job_id.localeCompare(b.job_id); +} + +export interface PaginatedJobList { + page: JobListEntry[]; + nextCursor: string | null; +} + +export function paginateJobList( + candidates: JobListEntry[], + cursor: string | undefined, + limit: number, +): PaginatedJobList { + // Cursor: opaque ULID of the last-emitted job_id in the previous page. + let startIdx = 0; + if (cursor !== undefined && cursor !== "") { + const idx = candidates.findIndex((c) => c.job_id === cursor); + if (idx !== -1) startIdx = idx + 1; + } + const page = candidates.slice(startIdx, startIdx + limit); + const lastEntry = page.length > 0 ? page.at(-1) : undefined; + const nextCursor = + startIdx + limit < candidates.length && lastEntry !== undefined + ? lastEntry.job_id + : null; + return { page, nextCursor }; +} diff --git a/packages/runtime/src/server-resume.ts b/packages/runtime/src/server-resume.ts new file mode 100644 index 0000000..b9907a9 --- /dev/null +++ b/packages/runtime/src/server-resume.ts @@ -0,0 +1,153 @@ +import type { SessionId } from "@arcp/core"; +import type { BearerIdentity } from "@arcp/core/auth"; +import { buildEnvelope } from "@arcp/core/envelope"; +import { + InvalidRequestError, + ResumeWindowExpiredError, +} from "@arcp/core/errors"; +import type { SessionHelloPayload } from "@arcp/core/messages"; +import { newMessageId } from "@arcp/core/util"; + +import type { ARCPServer } from "./server.js"; +import type { SessionContext } from "./session-context.js"; +import { newResumeToken } from "./stores.js"; + +const DEFAULT_RESUME_WINDOW_SECONDS = 600; + +export interface HandleResumeArgs { + server: ARCPServer; + ctx: SessionContext; + identity: BearerIdentity; + payload: SessionHelloPayload; +} + +export async function handleResume(args: HandleResumeArgs): Promise<void> { + const { server, ctx, identity, payload } = args; + const resume = payload.resume; + if (resume === undefined) { + await ctx.emitSessionError( + new InvalidRequestError("handleResume called without resume payload"), + ); + return; + } + if (ctx.state.id === undefined) ctx.state.assignId(resume.session_id); + if (!(await validateResumeRecord(server, ctx, resume))) return; + rebindResumedSession({ server, ctx, identity, payload }); + const freshToken = rotateResumeToken(server, resume.session_id); + await sendResumeWelcome({ + server, + ctx, + freshToken, + sessionId: resume.session_id, + }); + await replayResumeEvents(server, ctx, resume); + ctx.logger.info( + { session_id: resume.session_id, replayed_from: resume.last_event_seq }, + "session resumed", + ); + server.registerPostHandshakeHandlers(ctx); + ctx.startHeartbeat(); +} + +function rebindResumedSession(args: HandleResumeArgs): void { + const { server, ctx, identity, payload } = args; + const resume = payload.resume; + if (resume === undefined) return; + const sessionId = resume.session_id; + // Detach any in-memory session bound to that id (e.g., a dropped socket). + const prior = server.sessions.get(sessionId); + if (prior !== undefined && prior !== ctx) server.sessions.delete(sessionId); + ctx.state.assignId(sessionId); + ctx.state.assignIdentity(identity); + const negotiated = server.makeNegotiatedCapabilities(payload, ctx); + ctx.state.assignCapabilities(negotiated); + server.bindLogger(ctx, payload.client.name); +} + +async function validateResumeRecord( + server: ARCPServer, + ctx: SessionContext, + resume: NonNullable<SessionHelloPayload["resume"]>, +): Promise<boolean> { + const record = server.resumeStore.get(resume.session_id); + if (record?.resumeToken !== resume.resume_token) { + await ctx.emitSessionError( + new ResumeWindowExpiredError("Invalid or unknown resume_token"), + ); + return false; + } + if (record.expiresAt < Date.now()) { + server.resumeStore.delete(resume.session_id); + await ctx.emitSessionError( + new ResumeWindowExpiredError("Resume window has expired"), + ); + return false; + } + return true; +} + +function rotateResumeToken( + server: ARCPServer, + sessionId: SessionId, +): ReturnType<typeof newResumeToken> { + const resumeWindowSec = + server.options.resumeWindowSeconds ?? DEFAULT_RESUME_WINDOW_SECONDS; + const freshToken = newResumeToken(); + server.resumeStore.set(sessionId, { + sessionId, + resumeToken: freshToken, + expiresAt: Date.now() + resumeWindowSec * 1000, + }); + return freshToken; +} + +interface SendResumeWelcomeArgs { + server: ARCPServer; + ctx: SessionContext; + freshToken: ReturnType<typeof newResumeToken>; + sessionId: SessionId; +} + +async function sendResumeWelcome(args: SendResumeWelcomeArgs): Promise<void> { + const { server, ctx, freshToken, sessionId } = args; + const resumeWindowSec = + server.options.resumeWindowSeconds ?? DEFAULT_RESUME_WINDOW_SECONDS; + const welcome = server.buildWelcomePayload(ctx, ctx.state.capabilities ?? {}, { + resumeToken: freshToken, + resumeWindowSec, + }); + ctx.state.transition("accepted"); + server.sessions.set(sessionId, ctx); + await ctx.send( + buildEnvelope({ + id: newMessageId(), + type: "session.welcome" as const, + payload: welcome, + optional: { session_id: sessionId }, + }), + ); +} + +async function replayResumeEvents( + server: ARCPServer, + ctx: SessionContext, + resume: NonNullable<SessionHelloPayload["resume"]>, +): Promise<void> { + try { + const replayed = await server.eventLog.readSinceSeq( + resume.session_id, + resume.last_event_seq, + 10_000, + ); + let highest = resume.last_event_seq; + for (const env of replayed) { + if (env.event_seq !== undefined && env.event_seq > highest) { + highest = env.event_seq; + } + await ctx.transport.send(env); + } + ctx.setEventSeq(highest); + } catch (error) { + ctx.logger.warn({ err: error }, "resume replay failed"); + } +} diff --git a/packages/runtime/src/server-subscribe.ts b/packages/runtime/src/server-subscribe.ts new file mode 100644 index 0000000..bac4e4b --- /dev/null +++ b/packages/runtime/src/server-subscribe.ts @@ -0,0 +1,241 @@ +import type { JobId } from "@arcp/core"; +import type { BaseEnvelope } from "@arcp/core/envelope"; +import { buildEnvelope } from "@arcp/core/envelope"; +import { PermissionDeniedError } from "@arcp/core/errors"; +import type { + Envelope, + JobListEntry, + SessionListJobsFilter, +} from "@arcp/core/messages"; +import { newMessageId } from "@arcp/core/util"; + +import { forwardEventToSubscriber } from "./job-runner.js"; +import type { Job } from "./job.js"; +import { + compareJobListEntries, + compileListJobsFilter, + type ListJobsFilter, + paginateJobList, +} from "./list-jobs.js"; +import type { ARCPServer } from "./server.js"; +import type { SessionContext } from "./session-context.js"; +import type { JobAuthorizationPolicy } from "./types.js"; + +export function defaultJobAuthorizationPolicy( + job: Job, + principal: string | undefined, +): boolean { + return job.submitterPrincipal === principal; +} + +export async function handleListJobs( + server: ARCPServer, + ctx: SessionContext, + env: Envelope, +): Promise<void> { + if (env.type !== "session.list_jobs") return; + const sessionId = ctx.state.id; + if (sessionId === undefined) return; + const candidates = buildListJobsCandidates(server, ctx, env.payload.filter); + candidates.sort(compareJobListEntries); + const { page, nextCursor } = paginateJobList( + candidates, + env.payload.cursor ?? undefined, + env.payload.limit ?? 100, + ); + await ctx.send( + buildEnvelope({ + id: newMessageId(), + type: "session.jobs" as const, + payload: { request_id: env.id, jobs: page, next_cursor: nextCursor }, + optional: { session_id: sessionId }, + }), + ); +} + +function buildListJobsCandidates( + server: ARCPServer, + ctx: SessionContext, + rawFilter: SessionListJobsFilter | undefined, +): JobListEntry[] { + const principal = ctx.state.identity?.principal; + const policy: JobAuthorizationPolicy = + server.options.jobAuthorizationPolicy ?? defaultJobAuthorizationPolicy; + const filter: ListJobsFilter = compileListJobsFilter(rawFilter ?? {}); + const out: JobListEntry[] = []; + for (const job of server.globalJobs.values()) { + if (!policy(job, principal)) continue; + if (!filter.matches(job)) continue; + out.push({ + job_id: job.jobId, + agent: job.agentRef, + status: job.state, + lease: job.lease, + parent_job_id: job.parentJobId ?? null, + created_at: job.createdAt, + ...(job.traceId === undefined ? {} : { trace_id: job.traceId }), + last_event_seq: ctx.latestEventSeq, + }); + } + return out; +} + +export async function handleJobSubscribe( + server: ARCPServer, + ctx: SessionContext, + env: Envelope, +): Promise<void> { + if (env.type !== "job.subscribe") return; + const sessionId = ctx.state.id; + if (sessionId === undefined) return; + const jobId = env.payload.job_id; + const job = server.globalJobs.get(jobId); + if (job === undefined) { + await emitSubscribeJobNotFound(ctx, jobId); + return; + } + if (!authorizeSubscribe(server, ctx, job)) { + await ctx.emitSessionError( + new PermissionDeniedError( + "Subscriber's principal is not authorized to observe this job", + ), + ); + return; + } + registerSubscriber(server, ctx, jobId); + const replayed = await maybeReplaySubscribeHistory({ + server, + ctx, + job, + env, + }); + await ctx.send( + buildEnvelope({ + id: newMessageId(), + type: "job.subscribed" as const, + payload: buildSubscribedPayload(job, ctx.latestEventSeq, replayed), + optional: { session_id: sessionId, job_id: jobId }, + }), + ); +} + +interface MaybeReplayArgs { + server: ARCPServer; + ctx: SessionContext; + job: Job; + env: Extract<Envelope, { type: "job.subscribe" }>; +} + +async function maybeReplaySubscribeHistory( + args: MaybeReplayArgs, +): Promise<boolean> { + if (args.env.payload.history !== true) return false; + return replaySubscribeHistory({ + server: args.server, + ctx: args.ctx, + job: args.job, + fromSeq: args.env.payload.from_event_seq, + }); +} + +async function emitSubscribeJobNotFound( + ctx: SessionContext, + jobId: JobId, +): Promise<void> { + await ctx.emitJobError(jobId, { + final_status: "error", + code: "JOB_NOT_FOUND", + message: `Job "${jobId}" not found`, + retryable: false, + }); +} + +function authorizeSubscribe( + server: ARCPServer, + ctx: SessionContext, + job: Job, +): boolean { + const principal = ctx.state.identity?.principal; + const policy: JobAuthorizationPolicy = + server.options.jobAuthorizationPolicy ?? defaultJobAuthorizationPolicy; + return policy(job, principal); +} + +function registerSubscriber( + server: ARCPServer, + ctx: SessionContext, + jobId: JobId, +): void { + let set = server.subscribers.get(jobId); + if (set === undefined) { + set = new Set<SessionContext>(); + server.subscribers.set(jobId, set); + } + set.add(ctx); + ctx.subscriptions.set(jobId, () => { + const s = server.subscribers.get(jobId); + if (s === undefined) return; + s.delete(ctx); + if (s.size === 0) server.subscribers.delete(jobId); + }); +} + +interface ReplaySubscribeHistoryArgs { + server: ARCPServer; + ctx: SessionContext; + job: Job; + fromSeq: number | undefined; +} + +async function replaySubscribeHistory( + args: ReplaySubscribeHistoryArgs, +): Promise<boolean> { + const { server, ctx, job, fromSeq } = args; + if (job.owningSession === undefined) return false; + const ownerSessionId = job.owningSession.state.id; + if (ownerSessionId === undefined) return false; + try { + const events = await server.eventLog.readSinceSeq( + ownerSessionId, + fromSeq ?? 0, + 10_000, + ); + for (const e of events) { + if (!isReplayableForJob(e, job.jobId)) continue; + await forwardEventToSubscriber(ctx, e); + } + return events.some((e) => e.job_id === job.jobId); + } catch (error) { + ctx.logger.warn({ err: error }, "subscribe history replay failed"); + return false; + } +} + +function isReplayableForJob(env: BaseEnvelope, jobId: JobId): boolean { + if (env.job_id !== jobId) return false; + return ( + env.type === "job.event" || + env.type === "job.result" || + env.type === "job.error" + ); +} + +function buildSubscribedPayload( + job: Job, + subscribedFrom: number, + replayed: boolean, +): Record<string, unknown> { + return { + job_id: job.jobId, + current_status: job.state, + agent: job.agentRef, + lease: job.lease, + ...(job.leaseConstraints === undefined + ? {} + : { lease_constraints: job.leaseConstraints }), + parent_job_id: job.parentJobId ?? null, + ...(job.traceId === undefined ? {} : { trace_id: job.traceId }), + subscribed_from: subscribedFrom, + replayed, + }; +} diff --git a/packages/runtime/src/server.ts b/packages/runtime/src/server.ts index f15f643..a4c741e 100644 --- a/packages/runtime/src/server.ts +++ b/packages/runtime/src/server.ts @@ -1,61 +1,46 @@ -import { randomBytes } from "node:crypto"; - -import type { EventSeq, JobId } from "@arcp/core"; +import type { JobId } from "@arcp/core"; import type { BearerIdentity } from "@arcp/core/auth"; -import { - type BaseEnvelope, - buildEnvelope, - RoundTripEnvelopeSchema, -} from "@arcp/core/envelope"; +import { buildEnvelope } from "@arcp/core/envelope"; import { ARCPError, - HeartbeatLostError, - InternalError, InvalidRequestError, - PermissionDeniedError, ResumeWindowExpiredError, UnauthenticatedError, } from "@arcp/core/errors"; -import { classifyUnknownType } from "@arcp/core/extensions"; import { type Logger, sessionLogger as makeSessionLogger, rootLogger, } from "@arcp/core/logger"; -import { - type AgentInventoryEntry, - type Capabilities, - type Envelope, - EnvelopeSchema, - JOB_STATES, - type JobErrorPayload, - type JobListEntry, - parseAgentRef, - type SessionHelloPayload, - type SessionWelcomePayload, +import type { + AgentInventoryEntry, + Capabilities, + Envelope, + SessionHelloPayload, + SessionWelcomePayload, } from "@arcp/core/messages"; -import { - negotiateCapabilities, - PendingRegistry, - SessionState, -} from "@arcp/core/state"; +import { negotiateCapabilities } from "@arcp/core/state"; import { EventLog } from "@arcp/core/store"; -import type { Transport, WireFrame } from "@arcp/core/transport"; +import type { Transport } from "@arcp/core/transport"; import { newMessageId, newSessionId } from "@arcp/core/util"; import { intersectFeatures, V1_1_FEATURES } from "@arcp/core/version"; -import { z } from "zod"; import { AgentRegistry } from "./agent-registry.js"; -import { forwardEventToSubscriber, JobRunner } from "./job-runner.js"; +import { JobRunner } from "./job-runner.js"; import type { Job } from "./job.js"; -import { JobManager } from "./job.js"; +import { handleResume } from "./server-resume.js"; +import { + handleJobSubscribe, + handleListJobs, +} from "./server-subscribe.js"; +import { + type SessionContext, + SessionContext as SessionContextCtor, +} from "./session-context.js"; import { IdempotencyStore, newResumeToken, ResumeStore } from "./stores.js"; -import type { - AgentHandler, - ARCPServerOptions, - EventSeqSource, - Handler, -} from "./types.js"; +import type { AgentHandler, ARCPServerOptions } from "./types.js"; + +export { SessionContext } from "./session-context.js"; // ARCP v1.1 (additive over v1.0) runtime. // @@ -68,459 +53,11 @@ import type { // `progress`/`result_chunk` event kinds, `lease_constraints` (expires_at), // `cost.budget`, and agent versioning. -const HANDSHAKE_TYPES = new Set<string>(["session.hello"]); const DEFAULT_HEARTBEAT_SECONDS = 30; const DEFAULT_RESUME_WINDOW_SECONDS = 600; const DEFAULT_GRACE_MS = 30_000; -const DEFAULT_MAX_BUFFERED_EVENTS = 10_000; -const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024; // 16 MiB -const DEFAULT_BACK_PRESSURE_THRESHOLD = 1000; -function defaultJobAuthorizationPolicy( - job: Job, - principal: string | undefined, -): boolean { - return job.submitterPrincipal === principal; -} - -/** - * Per-transport session context. Drives the handshake and dispatches - * inbound envelopes. - */ -export class SessionContext implements EventSeqSource { - public readonly state = new SessionState(); - public readonly jobs = new JobManager(); - public readonly pending = new PendingRegistry(); - public logger: Logger; - private readonly handlers = new Map<string, Handler>(); - private closed = false; - private eventSeq = 0; - private bufferedEventCount = 0; - private bufferedBytes = 0; - private lastMessageAt: number = Date.now(); - private lastInboundAt: number = Date.now(); - /** Active idempotent keys for jobs that resolved through this session. */ - private readonly localKeys = new Set<string>(); - /** v1.1 §6.2 — negotiated feature set. */ - private _negotiatedFeatures: readonly string[] = []; - /** v1.1 §6.4 — periodic ping timer. */ - private heartbeatTimer: ReturnType<typeof setInterval> | null = null; - /** v1.1 §6.4 — pending ping nonce awaiting pong. */ - private outstandingPingNonce: string | null = null; - /** v1.1 §6.5 — highest seq the client has acknowledged. */ - private lastAckedSeq = 0; - private backPressureNotified = false; - /** - * v1.1 §7.6 — jobs we are observing as a subscriber (not the submitter). - * Maps job_id → unsubscribe callback. - */ - public readonly subscriptions = new Map<string, () => void>(); - - public constructor( - public readonly transport: Transport, - public readonly server: ARCPServer, - logger: Logger, - ) { - this.logger = logger; - } - - public registerHandler(type: string, handler: Handler): void { - this.handlers.set(type, handler); - } - - /** Per-session monotonic event sequence (§8.3). */ - public nextEventSeq(): EventSeq { - this.eventSeq += 1; - return this.eventSeq as EventSeq; - } - - public get latestEventSeq(): EventSeq { - return this.eventSeq as EventSeq; - } - - public setEventSeq(value: number): void { - this.eventSeq = value; - } - - public touch(): void { - this.lastMessageAt = Date.now(); - } - - public get lastActivityAt(): number { - return this.lastMessageAt; - } - - public get lastInboundActivityAt(): number { - return this.lastInboundAt; - } - - public addLocalIdempotencyKey(key: string): void { - this.localKeys.add(key); - } - - public hasLocalIdempotencyKey(key: string): boolean { - return this.localKeys.has(key); - } - - public get negotiatedFeatures(): readonly string[] { - return this._negotiatedFeatures; - } - - public hasFeature(name: string): boolean { - return this._negotiatedFeatures.includes(name); - } - - public assignNegotiatedFeatures(features: readonly string[]): void { - this._negotiatedFeatures = features; - } - - public get lastAckedEventSeq(): number { - return this.lastAckedSeq; - } - - public recordAck(seq: number): void { - if (seq > this.lastAckedSeq) this.lastAckedSeq = seq; - } - - /** Send an envelope through the transport. */ - public async send(envelope: BaseEnvelope): Promise<void> { - if (this.closed || this.transport.closed) { - throw new InvalidRequestError("Cannot send: session closed"); - } - this.touch(); - await this.transport.send(envelope); - if (envelope.session_id !== undefined && envelope.session_id !== "") { - try { - await this.server.eventLog.append(envelope); - // Account against per-session caps for replay buffer estimation. - const size = JSON.stringify(envelope).length; - this.bufferedEventCount += 1; - this.bufferedBytes += size; - this.checkCaps(); - } catch (error) { - this.logger.error({ err: error }, "event log append (outbound) failed"); - } - } - // v1.1 §7.6 — fan-out event-bearing envelopes to subscriber sessions. - if ( - envelope.job_id !== undefined && - (envelope.type === "job.event" || - envelope.type === "job.result" || - envelope.type === "job.error") - ) { - const subs = this.server.subscribers.get(envelope.job_id); - if (subs !== undefined && subs.size > 0) { - for (const sub of subs) { - if (sub === this) continue; - if (sub.state.id === undefined) continue; - try { - const forwarded = buildEnvelope({ - id: newMessageId(), - type: envelope.type, - payload: envelope.payload, - optional: { - session_id: sub.state.id, - job_id: envelope.job_id, - ...(envelope.trace_id === undefined - ? {} - : { trace_id: envelope.trace_id }), - ...(envelope.event_seq === undefined - ? {} - : { event_seq: sub.nextEventSeq() }), - }, - }); - await sub.transport.send(forwarded); - } catch { - // best-effort - } - } - } - } - // v1.1 §6.5 back-pressure heuristic — fire once when crossing threshold. - if (this.hasFeature("ack")) { - const lag = this.eventSeq - this.lastAckedSeq; - const threshold = - this.server.options.backPressureThreshold ?? - DEFAULT_BACK_PRESSURE_THRESHOLD; - if (lag > threshold && !this.backPressureNotified) { - this.backPressureNotified = true; - // Best-effort emit — don't fail the outer send if this errors. - void this.emitBackPressureStatus(lag).catch(() => undefined); - } else if (lag <= threshold / 2 && this.backPressureNotified) { - this.backPressureNotified = false; - } - } - } - - private async emitBackPressureStatus(lag: number): Promise<void> { - if (this.closed || this.transport.closed) return; - if (this.state.id === undefined) return; - // Emit as a job.event with a synthetic, sessionless status body. We - // attach it to the most recently created job if any, else skip. - const live = this.jobs.list(); - if (live.length === 0) return; - const job = live.at(-1); - if (job === undefined) return; - await job.emitEventKind("status", { - phase: "back_pressure", - message: `consumer lag ${lag} events`, - }); - } - - private checkCaps(): void { - const caps = this.server.options.caps ?? {}; - const maxEvents = caps.maxBufferedEvents ?? DEFAULT_MAX_BUFFERED_EVENTS; - const maxBytes = caps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES; - if (this.bufferedEventCount > maxEvents || this.bufferedBytes > maxBytes) { - const err = new InternalError( - `Session buffer exceeded caps (events=${this.bufferedEventCount}/${maxEvents}, bytes=${this.bufferedBytes}/${maxBytes})`, - { retryable: false }, - ); - void this.emitSessionError(err); - } - } - - /** Emit a `session.error` envelope and close. */ - public async emitSessionError(err: ARCPError): Promise<void> { - if (this.closed) return; - try { - const env = buildEnvelope({ - id: newMessageId(), - type: "session.error" as const, - payload: err.toPayload(), - optional: { - session_id: this.state.id, - }, - }); - // Use transport.send directly to avoid the post-close guard rejecting. - if (!this.transport.closed) { - await this.transport.send(env); - } - } catch { - // best-effort - } - try { - this.state.transition(this.state.isAccepted ? "closing" : "rejected"); - } catch { - // already terminal - } - await this.terminate(err.message); - } - - /** Emit a `job.error` envelope on the given job and retire it. */ - public async emitJobError( - jobId: JobId, - payload: JobErrorPayload, - ): Promise<void> { - const job = this.jobs.get(jobId); - if (job !== undefined) { - await job.emitErrorEnvelope(payload); - this.jobs.retire(jobId); - return; - } - // Job-not-found: emit a synthetic envelope so the client can observe it. - const env = buildEnvelope({ - id: newMessageId(), - type: "job.error" as const, - payload, - optional: { - session_id: this.state.id, - job_id: jobId, - event_seq: this.nextEventSeq(), - }, - }); - await this.send(env); - } - - /** Dispatch an inbound, raw frame. */ - public async dispatchRaw(frame: WireFrame): Promise<void> { - this.touch(); - this.lastInboundAt = Date.now(); - let parsed: BaseEnvelope; - try { - parsed = RoundTripEnvelopeSchema.parse(frame); - } catch (error) { - this.logger.warn( - { err: error }, - "inbound envelope failed base-shape validation", - ); - return; - } - - // Pre-handshake: drop non-handshake messages. - if (!this.state.isAccepted && !HANDSHAKE_TYPES.has(parsed.type)) { - this.logger.warn( - { type: parsed.type, id: parsed.id }, - "dropping pre-handshake non-handshake message", - ); - return; - } - - // Idempotent inbound: dedupe by (session_id, id) once a session exists. - // v1.1: session.ping/pong/ack are session-control (not event-seq-bearing), - // so we skip the dedupe-and-log step for them. - const SKIP_LOG: ReadonlySet<string> = new Set([ - "session.ping", - "session.pong", - "session.ack", - ]); - if ( - this.state.id !== undefined && - parsed.session_id === this.state.id && - !SKIP_LOG.has(parsed.type) - ) { - try { - const inserted = await this.server.eventLog.append(parsed); - if (!inserted) { - this.logger.debug( - { id: parsed.id }, - "duplicate inbound, skipping dispatch", - ); - return; - } - } catch (error) { - this.logger.error({ err: error }, "event log append (inbound) failed"); - } - } - - // Validate against the discriminated union. - const result = EnvelopeSchema.safeParse(parsed); - if (!result.success) { - const issue = result.error.issues[0]; - const looksUnknownType = - issue?.code === z.ZodIssueCode.invalid_union_discriminator; - if (looksUnknownType) { - const disposition = classifyUnknownType(parsed.type, { - extensionsObject: parsed.extensions, - }); - if (disposition.kind === "drop") { - this.logger.debug({ type: parsed.type }, disposition.reason); - return; - } - await this.emitSessionError( - new InvalidRequestError(disposition.reason, { - details: { type: parsed.type }, - }), - ); - return; - } - await this.emitSessionError( - new InvalidRequestError( - `Invalid envelope: ${issue?.message ?? "schema validation failed"}`, - ), - ); - return; - } - const envelope = result.data; - - const handler = this.handlers.get(envelope.type); - if (handler === undefined) { - await this.emitSessionError( - new InvalidRequestError(`No handler registered for "${envelope.type}"`), - ); - return; - } - - try { - await handler(envelope, this); - } catch (error) { - this.logger.error({ err: error, type: envelope.type }, "handler threw"); - const wrapped = - error instanceof ARCPError - ? error - : new InternalError( - error instanceof Error ? error.message : String(error), - { - cause: error instanceof Error ? error : undefined, - }, - ); - // Best effort: route through session.error for now. - await this.emitSessionError(wrapped); - } - } - - /** Start the v1.1 §6.4 heartbeat watchdog when the feature is negotiated. */ - public startHeartbeat(): void { - if (!this.hasFeature("heartbeat")) return; - const intervalMs = - (this.server.options.heartbeatIntervalSeconds ?? - DEFAULT_HEARTBEAT_SECONDS) * 1000; - this.heartbeatTimer = setInterval(() => { - void this.heartbeatTick(intervalMs).catch(() => undefined); - }, intervalMs); - this.heartbeatTimer.unref(); - } - - public stopHeartbeat(): void { - if (this.heartbeatTimer !== null) { - clearInterval(this.heartbeatTimer); - this.heartbeatTimer = null; - } - } - - /** - * §6.4: if no inbound traffic in the last 2 intervals, treat the peer as - * gone and surface HEARTBEAT_LOST. Otherwise, if our outbound side has - * been idle for one interval, send a ping. - */ - private async heartbeatTick(intervalMs: number): Promise<void> { - if (this.closed) return; - const now = Date.now(); - if (now - this.lastInboundAt > intervalMs * 2) { - // Peer silent for two intervals — treat as dead. - await this.emitSessionError( - new HeartbeatLostError("Peer silent for 2 heartbeat intervals"), - ); - return; - } - // Only ping if we have not sent or received traffic in `intervalMs`. - if (now - this.lastMessageAt < intervalMs * 0.9) return; - // Outbound idle: send a ping. - const sessionId = this.state.id; - if (sessionId === undefined) return; - const nonce = `p_${randomBytes(8).toString("hex")}`; - this.outstandingPingNonce = nonce; - const env = buildEnvelope({ - id: newMessageId(), - type: "session.ping" as const, - payload: { nonce, sent_at: new Date(now).toISOString() }, - optional: { session_id: sessionId }, - }); - try { - // Use transport.send to bypass the per-session event_log append - // (heartbeats are NOT counted in event_seq, §6.4). - await this.transport.send(env); - this.lastMessageAt = now; - } catch { - // best-effort - } - } - - public handlePong(pingNonce: string): void { - if (this.outstandingPingNonce === pingNonce) { - this.outstandingPingNonce = null; - } - } - - /** Tell the runtime this session is finished. Idempotent. */ - public async terminate(reason?: string): Promise<void> { - if (this.closed) return; - this.closed = true; - this.stopHeartbeat(); - // Unsubscribe from every observed job. - for (const fn of this.subscriptions.values()) { - try { - fn(); - } catch { - // best-effort - } - } - this.subscriptions.clear(); - this.server.dropSession(this); - await this.transport.close(reason); - } -} /** * Top-level ARCP runtime/server (§6–§14). @@ -547,10 +84,10 @@ export class ARCPServer { private readonly agentRegistry = new AgentRegistry(); /** Internal: read by `JobRunner` for idempotency lookups on `job.submit`. */ public readonly idempotencyStore = new IdempotencyStore(); - private readonly resumeStore = new ResumeStore(); + public readonly resumeStore = new ResumeStore(); private readonly jobRunner = new JobRunner(this); /** Live sessions, indexed by session_id (only those past welcome). */ - private readonly sessions = new Map<string, SessionContext>(); + public readonly sessions = new Map<string, SessionContext>(); /** * Global jobs registry for cross-session features (§6.6 listing, * §7.6 subscription). Indexed by job_id. @@ -631,7 +168,7 @@ export class ARCPServer { * {@link SessionContext}; the handshake completes asynchronously. */ public accept(transport: Transport): SessionContext { - const ctx = new SessionContext(transport, this, this.logger); + const ctx = new SessionContextCtor(transport, this, this.logger); transport.onFrame((frame) => ctx.dispatchRaw(frame)); transport.onClose(() => { const terminal: ReadonlySet<string> = new Set(["rejected", "closing"]); @@ -682,11 +219,26 @@ export class ARCPServer { ); return; } - const payload = env.payload; + const identity = await this.authenticateHello(ctx, env.payload); + if (identity === null) return; + if (env.payload.resume !== undefined) { + await handleResume({ + server: this, + ctx, + identity, + payload: env.payload, + }); + return; + } + await this.acceptFreshSession(ctx, identity, env.payload); + } - let identity: BearerIdentity; + private async authenticateHello( + ctx: SessionContext, + payload: SessionHelloPayload, + ): Promise<BearerIdentity | null> { try { - identity = await this.authenticate(payload.auth); + return await this.authenticate(payload.auth); } catch (error) { const wrapped = error instanceof ARCPError @@ -697,23 +249,21 @@ export class ARCPServer { "rejecting session.hello (auth)", ); await ctx.emitSessionError(wrapped); - return; - } - - // Resume path: validate token & seq, replay events, rotate token. - if (payload.resume !== undefined) { - await this.handleResume(ctx, identity, payload); - return; + return null; } + } - // Fresh session: assign id, transition, issue welcome with fresh token. + private async acceptFreshSession( + ctx: SessionContext, + identity: BearerIdentity, + payload: SessionHelloPayload, + ): Promise<void> { const sessionId = newSessionId(); ctx.state.assignId(sessionId); ctx.state.assignIdentity(identity); const negotiated = this.makeNegotiatedCapabilities(payload, ctx); ctx.state.assignCapabilities(negotiated); this.bindLogger(ctx, payload.client.name); - const resumeWindowSec = this.options.resumeWindowSeconds ?? DEFAULT_RESUME_WINDOW_SECONDS; const resumeToken = newResumeToken(); @@ -722,29 +272,20 @@ export class ARCPServer { resumeToken, expiresAt: Date.now() + resumeWindowSec * 1000, }); - - const heartbeatSec = - this.options.heartbeatIntervalSeconds ?? DEFAULT_HEARTBEAT_SECONDS; - const welcome: SessionWelcomePayload = { - runtime: this.options.runtime, - resume_token: resumeToken, - resume_window_sec: resumeWindowSec, - ...(ctx.hasFeature("heartbeat") - ? { heartbeat_interval_sec: heartbeatSec } - : {}), - capabilities: negotiated, - }; - const welcomeEnv = buildEnvelope({ - id: newMessageId(), - type: "session.welcome" as const, - payload: welcome, - optional: { - session_id: sessionId, - }, + const welcome = this.buildWelcomePayload(ctx, negotiated, { + resumeToken, + resumeWindowSec, }); ctx.state.transition("accepted"); this.sessions.set(sessionId, ctx); - await ctx.send(welcomeEnv); + await ctx.send( + buildEnvelope({ + id: newMessageId(), + type: "session.welcome" as const, + payload: welcome, + optional: { session_id: sessionId }, + }), + ); ctx.logger.info( { session_id: sessionId, principal: identity.principal }, "session welcomed", @@ -753,11 +294,32 @@ export class ARCPServer { ctx.startHeartbeat(); } + public buildWelcomePayload( + ctx: SessionContext, + negotiated: Capabilities, + args: { + resumeToken: ReturnType<typeof newResumeToken>; + resumeWindowSec: number; + }, + ): SessionWelcomePayload { + const heartbeatSec = + this.options.heartbeatIntervalSeconds ?? DEFAULT_HEARTBEAT_SECONDS; + return { + runtime: this.options.runtime, + resume_token: args.resumeToken, + resume_window_sec: args.resumeWindowSec, + ...(ctx.hasFeature("heartbeat") + ? { heartbeat_interval_sec: heartbeatSec } + : {}), + capabilities: negotiated, + }; + } + /** * Build the welcome capabilities: intersect features with the client's * advertised list and store the result on the session context. */ - private makeNegotiatedCapabilities( + public makeNegotiatedCapabilities( payload: SessionHelloPayload, ctx: SessionContext, ): Capabilities { @@ -782,108 +344,8 @@ export class ARCPServer { return negotiateCapabilities(payload.capabilities, base); } - private async handleResume( - ctx: SessionContext, - identity: BearerIdentity, - payload: SessionHelloPayload, - ): Promise<void> { - const resume = payload.resume; - if (resume === undefined) { - await ctx.emitSessionError( - new InvalidRequestError("handleResume called without resume payload"), - ); - return; - } - // Tentatively bind the session id so any failure-path session.error - // envelope still carries session_id per §5.1. - if (ctx.state.id === undefined) ctx.state.assignId(resume.session_id); - const record = this.resumeStore.get(resume.session_id); - if (record?.resumeToken !== resume.resume_token) { - await ctx.emitSessionError( - new ResumeWindowExpiredError("Invalid or unknown resume_token"), - ); - return; - } - if (record.expiresAt < Date.now()) { - this.resumeStore.delete(resume.session_id); - await ctx.emitSessionError( - new ResumeWindowExpiredError("Resume window has expired"), - ); - return; - } - - // Detach any in-memory session bound to that id (e.g., a dropped socket). - const prior = this.sessions.get(resume.session_id); - if (prior !== undefined && prior !== ctx) { - this.sessions.delete(resume.session_id); - } - - ctx.state.assignId(resume.session_id); - ctx.state.assignIdentity(identity); - const negotiated = this.makeNegotiatedCapabilities(payload, ctx); - ctx.state.assignCapabilities(negotiated); - this.bindLogger(ctx, payload.client.name); - - // Rotate the resume_token; the old token is single-use and now invalid. - const resumeWindowSec = - this.options.resumeWindowSeconds ?? DEFAULT_RESUME_WINDOW_SECONDS; - const freshToken = newResumeToken(); - this.resumeStore.set(resume.session_id, { - sessionId: resume.session_id, - resumeToken: freshToken, - expiresAt: Date.now() + resumeWindowSec * 1000, - }); - const heartbeatSec = - this.options.heartbeatIntervalSeconds ?? DEFAULT_HEARTBEAT_SECONDS; - const welcome: SessionWelcomePayload = { - runtime: this.options.runtime, - resume_token: freshToken, - resume_window_sec: resumeWindowSec, - ...(ctx.hasFeature("heartbeat") - ? { heartbeat_interval_sec: heartbeatSec } - : {}), - capabilities: negotiated, - }; - const welcomeEnv = buildEnvelope({ - id: newMessageId(), - type: "session.welcome" as const, - payload: welcome, - optional: { session_id: resume.session_id }, - }); - ctx.state.transition("accepted"); - this.sessions.set(resume.session_id, ctx); - await ctx.send(welcomeEnv); - - // Replay events strictly greater than `last_event_seq`. - try { - const replayed = await this.eventLog.readSinceSeq( - resume.session_id, - resume.last_event_seq, - 10_000, - ); - // Track highest replayed seq so future emits continue monotonic. - let highest = resume.last_event_seq; - for (const env of replayed) { - if (env.event_seq !== undefined && env.event_seq > highest) { - highest = env.event_seq; - } - await ctx.transport.send(env); - } - ctx.setEventSeq(highest); - } catch (error) { - ctx.logger.warn({ err: error }, "resume replay failed"); - } - - ctx.logger.info( - { session_id: resume.session_id, replayed_from: resume.last_event_seq }, - "session resumed", - ); - this.registerPostHandshakeHandlers(ctx); - ctx.startHeartbeat(); - } - - private bindLogger(ctx: SessionContext, clientName: string): void { + public bindLogger(ctx: SessionContext, clientName: string): void { const sessionId = ctx.state.id; if (sessionId === undefined) return; ctx.logger = makeSessionLogger(this.logger, sessionId).child({ @@ -891,7 +353,14 @@ export class ARCPServer { }); } - private registerPostHandshakeHandlers(ctx: SessionContext): void { + public registerPostHandshakeHandlers(ctx: SessionContext): void { + this.registerJobLifecycleHandlers(ctx); + this.registerSessionControlHandlers(ctx); + this.registerListJobsHandler(ctx); + this.registerSubscriptionHandlers(ctx); + } + + private registerJobLifecycleHandlers(ctx: SessionContext): void { ctx.registerHandler("job.submit", async (env) => { if (env.type !== "job.submit") return; await this.jobRunner.handleJobSubmit(ctx, env); @@ -910,37 +379,41 @@ export class ARCPServer { } await ctx.terminate(env.payload.reason); }); + } + private registerSessionControlHandlers(ctx: SessionContext): void { // v1.1 §6.4 — heartbeat (handled even if not negotiated; receivers always // respond to ping per §6.4 to support staggered rollouts). ctx.registerHandler("session.ping", async (env) => { if (env.type !== "session.ping") return; const sessionId = ctx.state.id; if (sessionId === undefined) return; - const pongEnv = buildEnvelope({ - id: newMessageId(), - type: "session.pong" as const, - payload: { - ping_nonce: env.payload.nonce, - received_at: new Date().toISOString(), - }, - optional: { session_id: sessionId }, - }); // Direct transport.send — heartbeats are NOT counted in event_seq. - await ctx.transport.send(pongEnv); + await ctx.transport.send( + buildEnvelope({ + id: newMessageId(), + type: "session.pong" as const, + payload: { + ping_nonce: env.payload.nonce, + received_at: new Date().toISOString(), + }, + optional: { session_id: sessionId }, + }), + ); }); ctx.registerHandler("session.pong", (env) => { if (env.type !== "session.pong") return; ctx.handlePong(env.payload.ping_nonce); }); - // v1.1 §6.5 — event acknowledgement. ctx.registerHandler("session.ack", (env) => { if (env.type !== "session.ack") return; if (!ctx.hasFeature("ack")) return; ctx.recordAck(env.payload.last_processed_seq); }); + } + private registerListJobsHandler(ctx: SessionContext): void { // v1.1 §6.6 — job listing. ctx.registerHandler("session.list_jobs", async (env) => { if (env.type !== "session.list_jobs") return; @@ -952,9 +425,11 @@ export class ARCPServer { ); return; } - await this.handleListJobs(ctx, env); + await handleListJobs(this, ctx, env); }); + } + private registerSubscriptionHandlers(ctx: SessionContext): void { // v1.1 §7.6 — subscription. ctx.registerHandler("job.subscribe", async (env) => { if (env.type !== "job.subscribe") return; @@ -966,7 +441,7 @@ export class ARCPServer { ); return; } - await this.handleJobSubscribe(ctx, env); + await handleJobSubscribe(this, ctx, env); }); ctx.registerHandler("job.unsubscribe", (env) => { if (env.type !== "job.unsubscribe") return; @@ -988,7 +463,6 @@ export class ARCPServer { ): Promise<void> { if (env.type !== "job.cancel") return; const jobId = env.job_id; - // job_id is required by the job.cancel schema, but we keep the runtime check. // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (jobId === undefined) { await ctx.emitSessionError( @@ -998,240 +472,51 @@ export class ARCPServer { } const job = ctx.jobs.get(jobId); if (job === undefined) { - // v1.1 §7.6 — subscription does NOT grant cancel authority. If the - // job exists but this session is only a subscriber, refuse with - // PERMISSION_DENIED rather than masquerading as JOB_NOT_FOUND. - const global = this.globalJobs.get(jobId); - if (global !== undefined) { - await ctx.emitJobError(jobId, { - final_status: "error", - code: "PERMISSION_DENIED", - message: "Subscription does not confer cancel authority", - retryable: false, - }); - return; - } - await ctx.emitJobError(jobId, { - final_status: "error", - code: "JOB_NOT_FOUND", - message: `Job "${jobId}" not found in this session`, - retryable: false, - }); + await this.emitCancelTargetMissing(ctx, jobId); return; } if (job.isTerminal) return; - const reason = env.payload.reason ?? "client_cancel"; - job.cancel(reason); - // Grace period (§7.4): default 30s; force-emit cancelled error on expiry. - const graceMs = this.options.cancelGraceMs ?? DEFAULT_GRACE_MS; - const timer = setTimeout(() => { - if (!job.isTerminal) { - void job.emitErrorEnvelope({ - final_status: "cancelled", - code: "CANCELLED", - message: `Cancellation grace expired (${graceMs}ms)`, - retryable: false, - }); - } - }, graceMs); - timer.unref(); - } - - // --------------------------------------------------------------------- - // v1.1 §6.6 — session.list_jobs - // --------------------------------------------------------------------- - - private async handleListJobs( - ctx: SessionContext, - env: Envelope, - ): Promise<void> { - if (env.type !== "session.list_jobs") return; - const sessionId = ctx.state.id; - if (sessionId === undefined) return; - const principal = ctx.state.identity?.principal; - const policy = - this.options.jobAuthorizationPolicy ?? defaultJobAuthorizationPolicy; - const payload = env.payload; - const filter = payload.filter ?? {}; - const limit = payload.limit ?? 100; - - const allowedStatuses = new Set<string>(filter.status ?? JOB_STATES); - const createdAfter = filter.created_after - ? Date.parse(filter.created_after) - : null; - const createdBefore = filter.created_before - ? Date.parse(filter.created_before) - : null; - - // Build candidate list across global jobs and apply filter+auth. - const candidates: JobListEntry[] = []; - for (const job of this.globalJobs.values()) { - if (!policy(job, principal)) continue; - if (!allowedStatuses.has(job.state)) continue; - if (filter.agent !== undefined) { - const parsed = parseAgentRef(filter.agent); - if (parsed.version === null) { - if (job.agent !== parsed.name) continue; - } else { - if (job.agent !== parsed.name || job.agentVersion !== parsed.version) - continue; - } - } - if (createdAfter !== null) { - const t = Date.parse(job.createdAt); - if (!Number.isFinite(t) || t <= createdAfter) continue; - } - if (createdBefore !== null) { - const t = Date.parse(job.createdAt); - if (!Number.isFinite(t) || t >= createdBefore) continue; - } - candidates.push({ - job_id: job.jobId, - agent: job.agentRef, - status: job.state, - lease: job.lease, - parent_job_id: job.parentJobId ?? null, - created_at: job.createdAt, - ...(job.traceId === undefined ? {} : { trace_id: job.traceId }), - last_event_seq: ctx.latestEventSeq, - }); - } - // Sort by created_at ascending, then by job_id for determinism. - candidates.sort((a, b) => { - const ta = Date.parse(a.created_at); - const tb = Date.parse(b.created_at); - if (ta !== tb) return ta - tb; - return a.job_id.localeCompare(b.job_id); - }); - - // Cursor: opaque ULID of the last-emitted job_id in the previous page. - const cursor = payload.cursor ?? null; - let startIdx = 0; - if (cursor !== null && cursor !== "") { - const idx = candidates.findIndex((c) => c.job_id === cursor); - if (idx !== -1) startIdx = idx + 1; - } - const page = candidates.slice(startIdx, startIdx + limit); - const lastEntry = page.length > 0 ? page.at(-1) : undefined; - const nextCursor = - startIdx + limit < candidates.length && lastEntry !== undefined - ? lastEntry.job_id - : null; - - const responseEnv = buildEnvelope({ - id: newMessageId(), - type: "session.jobs" as const, - payload: { - request_id: env.id, - jobs: page, - next_cursor: nextCursor, - }, - optional: { session_id: sessionId }, - }); - await ctx.send(responseEnv); + job.cancel(env.payload.reason ?? "client_cancel"); + this.scheduleCancelGrace(job); } - // --------------------------------------------------------------------- - // v1.1 §7.6 — job.subscribe - // --------------------------------------------------------------------- - - private async handleJobSubscribe( + private async emitCancelTargetMissing( ctx: SessionContext, - env: Envelope, + jobId: JobId, ): Promise<void> { - if (env.type !== "job.subscribe") return; - const sessionId = ctx.state.id; - if (sessionId === undefined) return; - const jobId = env.payload.job_id; - const job = this.globalJobs.get(jobId); - if (job === undefined) { + // v1.1 §7.6 — subscription does NOT grant cancel authority. If the job + // exists but this session is only a subscriber, refuse with + // PERMISSION_DENIED rather than masquerading as JOB_NOT_FOUND. + if (this.globalJobs.get(jobId) !== undefined) { await ctx.emitJobError(jobId, { final_status: "error", - code: "JOB_NOT_FOUND", - message: `Job "${jobId}" not found`, + code: "PERMISSION_DENIED", + message: "Subscription does not confer cancel authority", retryable: false, }); return; } - const principal = ctx.state.identity?.principal; - const policy = - this.options.jobAuthorizationPolicy ?? defaultJobAuthorizationPolicy; - if (!policy(job, principal)) { - await ctx.emitSessionError( - new PermissionDeniedError( - "Subscriber's principal is not authorized to observe this job", - ), - ); - return; - } - - // Register subscriber. - let set = this.subscribers.get(jobId); - if (set === undefined) { - set = new Set<SessionContext>(); - this.subscribers.set(jobId, set); - } - set.add(ctx); - ctx.subscriptions.set(jobId, () => { - const s = this.subscribers.get(jobId); - if (s !== undefined) { - s.delete(ctx); - if (s.size === 0) this.subscribers.delete(jobId); - } + await ctx.emitJobError(jobId, { + final_status: "error", + code: "JOB_NOT_FOUND", + message: `Job "${jobId}" not found in this session`, + retryable: false, }); + } - // Replay history if requested. - const wantHistory = env.payload.history === true; - const fromSeq = env.payload.from_event_seq; - let replayed = false; - if (wantHistory && job.owningSession !== undefined) { - const owner = job.owningSession; - if (owner.state.id !== undefined) { - try { - const events = await this.eventLog.readSinceSeq( - owner.state.id, - fromSeq ?? 0, - 10_000, - ); - for (const e of events) { - if (e.job_id !== jobId) continue; - // Only forward event-bearing types. - if ( - e.type !== "job.event" && - e.type !== "job.result" && - e.type !== "job.error" - ) { - continue; - } - await forwardEventToSubscriber(ctx, e); - } - replayed = events.some((e) => e.job_id === jobId); - } catch (error) { - ctx.logger.warn({ err: error }, "subscribe history replay failed"); - } - } - } - - const subscribedFrom = ctx.latestEventSeq; - const respEnv = buildEnvelope({ - id: newMessageId(), - type: "job.subscribed" as const, - payload: { - job_id: jobId, - current_status: job.state, - agent: job.agentRef, - lease: job.lease, - ...(job.leaseConstraints === undefined - ? {} - : { lease_constraints: job.leaseConstraints }), - parent_job_id: job.parentJobId ?? null, - ...(job.traceId === undefined ? {} : { trace_id: job.traceId }), - subscribed_from: subscribedFrom, - replayed, - }, - optional: { session_id: sessionId, job_id: jobId }, - }); - await ctx.send(respEnv); + private scheduleCancelGrace(job: Job): void { + // §7.4: default 30s; force-emit cancelled error on expiry. + const graceMs = this.options.cancelGraceMs ?? DEFAULT_GRACE_MS; + const timer = setTimeout(() => { + if (job.isTerminal) return; + void job.emitErrorEnvelope({ + final_status: "cancelled", + code: "CANCELLED", + message: `Cancellation grace expired (${graceMs}ms)`, + retryable: false, + }); + }, graceMs); + timer.unref(); } // --------------------------------------------------------------------- diff --git a/packages/runtime/src/session-context.ts b/packages/runtime/src/session-context.ts new file mode 100644 index 0000000..e86aaca --- /dev/null +++ b/packages/runtime/src/session-context.ts @@ -0,0 +1,480 @@ +import type { EventSeq, JobId } from "@arcp/core"; +import { + type BaseEnvelope, + buildEnvelope, + RoundTripEnvelopeSchema, +} from "@arcp/core/envelope"; +import { + ARCPError, + HeartbeatLostError, + InternalError, + InvalidRequestError, +} from "@arcp/core/errors"; +import { classifyUnknownType } from "@arcp/core/extensions"; +import type { Logger } from "@arcp/core/logger"; +import { + type Envelope, + EnvelopeSchema, + type JobErrorPayload, +} from "@arcp/core/messages"; +import { PendingRegistry, SessionState } from "@arcp/core/state"; +import type { Transport, WireFrame } from "@arcp/core/transport"; +import { newMessageId } from "@arcp/core/util"; +import { z } from "zod"; + +import { JobManager } from "./job.js"; +import type { ARCPServer } from "./server.js"; +import type { EventSeqSource, Handler } from "./types.js"; + +const HANDSHAKE_TYPES = new Set<string>(["session.hello"]); +// session.ping/pong/ack are session-control envelopes (not event-seq-bearing), +// so they bypass the inbound dedupe-and-log step. +const INBOUND_DEDUPE_SKIP: ReadonlySet<string> = new Set([ + "session.ping", + "session.pong", + "session.ack", +]); + +const DEFAULT_MAX_BUFFERED_EVENTS = 10_000; +const DEFAULT_MAX_BUFFERED_BYTES = 16 * 1024 * 1024; // 16 MiB +const DEFAULT_BACK_PRESSURE_THRESHOLD = 1000; + +/** + * Per-transport session context. Drives the handshake and dispatches + * inbound envelopes. + */ +export class SessionContext implements EventSeqSource { + public readonly state = new SessionState(); + public readonly jobs = new JobManager(); + public readonly pending = new PendingRegistry(); + public logger: Logger; + private readonly handlers = new Map<string, Handler>(); + private closed = false; + private eventSeq = 0; + private bufferedEventCount = 0; + private bufferedBytes = 0; + private lastMessageAt: number = Date.now(); + private lastInboundAt: number = Date.now(); + /** Active idempotent keys for jobs that resolved through this session. */ + private readonly localKeys = new Set<string>(); + /** v1.1 §6.2 — negotiated feature set. */ + private _negotiatedFeatures: readonly string[] = []; + /** v1.1 §6.4 — periodic ping timer. */ + private heartbeatTimer: ReturnType<typeof setInterval> | null = null; + /** v1.1 §6.4 — pending ping nonce awaiting pong. */ + private outstandingPingNonce: string | null = null; + /** v1.1 §6.5 — highest seq the client has acknowledged. */ + private lastAckedSeq = 0; + private backPressureNotified = false; + /** + * v1.1 §7.6 — jobs we are observing as a subscriber (not the submitter). + * Maps job_id → unsubscribe callback. + */ + public readonly subscriptions = new Map<string, () => void>(); + + public constructor( + public readonly transport: Transport, + public readonly server: ARCPServer, + logger: Logger, + ) { + this.logger = logger; + } + + public registerHandler(type: string, handler: Handler): void { + this.handlers.set(type, handler); + } + + public nextEventSeq(): EventSeq { + this.eventSeq += 1; + return this.eventSeq as EventSeq; + } + + public get latestEventSeq(): EventSeq { + return this.eventSeq as EventSeq; + } + + public setEventSeq(value: number): void { + this.eventSeq = value; + } + + public touch(): void { + this.lastMessageAt = Date.now(); + } + + public get lastActivityAt(): number { + return this.lastMessageAt; + } + + public get lastInboundActivityAt(): number { + return this.lastInboundAt; + } + + public addLocalIdempotencyKey(key: string): void { + this.localKeys.add(key); + } + + public hasLocalIdempotencyKey(key: string): boolean { + return this.localKeys.has(key); + } + + public get negotiatedFeatures(): readonly string[] { + return this._negotiatedFeatures; + } + + public hasFeature(name: string): boolean { + return this._negotiatedFeatures.includes(name); + } + + public assignNegotiatedFeatures(features: readonly string[]): void { + this._negotiatedFeatures = features; + } + + public get lastAckedEventSeq(): number { + return this.lastAckedSeq; + } + + public recordAck(seq: number): void { + if (seq > this.lastAckedSeq) this.lastAckedSeq = seq; + } + + /** Send an envelope through the transport. */ + public async send(envelope: BaseEnvelope): Promise<void> { + if (this.closed || this.transport.closed) { + throw new InvalidRequestError("Cannot send: session closed"); + } + this.touch(); + await this.transport.send(envelope); + await this.persistOutbound(envelope); + await this.fanOutToSubscribers(envelope); + this.maybeEmitBackPressure(); + } + + private async persistOutbound(envelope: BaseEnvelope): Promise<void> { + if (envelope.session_id === undefined || envelope.session_id === "") return; + try { + await this.server.eventLog.append(envelope); + // Account against per-session caps for replay buffer estimation. + this.bufferedEventCount += 1; + this.bufferedBytes += JSON.stringify(envelope).length; + this.checkCaps(); + } catch (error) { + this.logger.error({ err: error }, "event log append (outbound) failed"); + } + } + + private async fanOutToSubscribers(envelope: BaseEnvelope): Promise<void> { + if (envelope.job_id === undefined) return; + if ( + envelope.type !== "job.event" && + envelope.type !== "job.result" && + envelope.type !== "job.error" + ) { + return; + } + const subs = this.server.subscribers.get(envelope.job_id); + if (subs === undefined || subs.size === 0) return; + for (const sub of subs) { + if (sub === this || sub.state.id === undefined) continue; + await this.forwardEnvelopeToSubscriber(sub, envelope); + } + } + + private async forwardEnvelopeToSubscriber( + sub: SessionContext, + envelope: BaseEnvelope, + ): Promise<void> { + if (sub.state.id === undefined) return; + try { + const forwarded = buildEnvelope({ + id: newMessageId(), + type: envelope.type, + payload: envelope.payload, + optional: { + session_id: sub.state.id, + job_id: envelope.job_id, + ...(envelope.trace_id === undefined + ? {} + : { trace_id: envelope.trace_id }), + ...(envelope.event_seq === undefined + ? {} + : { event_seq: sub.nextEventSeq() }), + }, + }); + await sub.transport.send(forwarded); + } catch { + // best-effort + } + } + + private maybeEmitBackPressure(): void { + if (!this.hasFeature("ack")) return; + const lag = this.eventSeq - this.lastAckedSeq; + const threshold = + this.server.options.backPressureThreshold ?? + DEFAULT_BACK_PRESSURE_THRESHOLD; + if (lag > threshold && !this.backPressureNotified) { + this.backPressureNotified = true; + // Best-effort emit — don't fail the outer send if this errors. + void this.emitBackPressureStatus(lag).catch(() => undefined); + return; + } + if (lag <= threshold / 2 && this.backPressureNotified) { + this.backPressureNotified = false; + } + } + + private async emitBackPressureStatus(lag: number): Promise<void> { + if (this.closed || this.transport.closed) return; + if (this.state.id === undefined) return; + // Emit as a job.event with a synthetic, sessionless status body. We + // attach it to the most recently created job if any, else skip. + const live = this.jobs.list(); + if (live.length === 0) return; + const job = live.at(-1); + if (job === undefined) return; + await job.emitEventKind("status", { + phase: "back_pressure", + message: `event_seq lag ${lag} exceeds threshold`, + }); + } + + private checkCaps(): void { + const caps = this.server.options.caps ?? {}; + const maxEvents = caps.maxBufferedEvents ?? DEFAULT_MAX_BUFFERED_EVENTS; + const maxBytes = caps.maxBufferedBytes ?? DEFAULT_MAX_BUFFERED_BYTES; + if ( + this.bufferedEventCount > maxEvents || + this.bufferedBytes > maxBytes + ) { + void this.emitSessionError( + new InternalError("Per-session buffered envelope cap exceeded", { + retryable: false, + }), + ); + void this.terminate("session cap exceeded"); + } + } + + public async emitSessionError(error: ARCPError): Promise<void> { + if (this.closed || this.transport.closed) return; + try { + const envelope = buildEnvelope({ + id: newMessageId(), + type: "session.error" as const, + payload: error.toPayload(), + ...(this.state.id === undefined + ? {} + : { optional: { session_id: this.state.id } }), + }); + await this.transport.send(envelope); + } catch (sendError) { + this.logger.error( + { err: sendError }, + "failed to emit session.error envelope", + ); + } + } + + public async emitJobError( + jobId: JobId, + payload: JobErrorPayload, + ): Promise<void> { + if (this.closed || this.transport.closed) return; + const sessionId = this.state.id; + if (sessionId === undefined) return; + try { + const envelope = buildEnvelope({ + id: newMessageId(), + type: "job.error" as const, + payload, + optional: { + session_id: sessionId, + job_id: jobId, + event_seq: this.nextEventSeq(), + }, + }); + await this.send(envelope); + } catch (error) { + this.logger.error({ err: error }, "failed to emit job.error envelope"); + } + } + + /** Dispatch an inbound, raw frame. */ + public async dispatchRaw(frame: WireFrame): Promise<void> { + this.touch(); + this.lastInboundAt = Date.now(); + const parsed = this.parseInboundFrame(frame); + if (parsed === null) return; + if (this.dropPreHandshakeNonHandshake(parsed)) return; + if (await this.dedupeInbound(parsed)) return; + const envelope = await this.validateInbound(parsed); + if (envelope === null) return; + await this.invokeHandler(envelope); + } + + private parseInboundFrame(frame: WireFrame): BaseEnvelope | null { + try { + return RoundTripEnvelopeSchema.parse(frame); + } catch (error) { + this.logger.warn( + { err: error }, + "inbound envelope failed base-shape validation", + ); + return null; + } + } + + private dropPreHandshakeNonHandshake(parsed: BaseEnvelope): boolean { + if (this.state.isAccepted || HANDSHAKE_TYPES.has(parsed.type)) return false; + this.logger.warn( + { type: parsed.type, id: parsed.id }, + "dropping pre-handshake non-handshake message", + ); + return true; + } + + private async dedupeInbound(parsed: BaseEnvelope): Promise<boolean> { + // v1.1: session.ping/pong/ack are session-control (not event-seq-bearing), + // so we skip the dedupe-and-log step for them. + if ( + this.state.id === undefined || + parsed.session_id !== this.state.id || + INBOUND_DEDUPE_SKIP.has(parsed.type) + ) { + return false; + } + try { + const inserted = await this.server.eventLog.append(parsed); + if (inserted) return false; + this.logger.debug({ id: parsed.id }, "duplicate inbound, skipping dispatch"); + return true; + } catch (error) { + this.logger.error({ err: error }, "event log append (inbound) failed"); + return false; + } + } + + private async validateInbound(parsed: BaseEnvelope): Promise<Envelope | null> { + const result = EnvelopeSchema.safeParse(parsed); + if (result.success) return result.data; + const issue = result.error.issues[0]; + if (issue?.code === z.ZodIssueCode.invalid_union_discriminator) { + await this.handleUnknownTypeDisposition(parsed); + return null; + } + await this.emitSessionError( + new InvalidRequestError( + `Invalid envelope: ${issue?.message ?? "schema validation failed"}`, + ), + ); + return null; + } + + private async handleUnknownTypeDisposition(parsed: BaseEnvelope): Promise<void> { + const disposition = classifyUnknownType(parsed.type, { + extensionsObject: parsed.extensions, + }); + if (disposition.kind === "drop") { + this.logger.debug({ type: parsed.type }, disposition.reason); + return; + } + await this.emitSessionError( + new InvalidRequestError(disposition.reason, { + details: { type: parsed.type }, + }), + ); + } + + private async invokeHandler(envelope: Envelope): Promise<void> { + const handler = this.handlers.get(envelope.type); + if (handler === undefined) { + await this.emitSessionError( + new InvalidRequestError(`No handler registered for "${envelope.type}"`), + ); + return; + } + try { + await handler(envelope, this); + } catch (error) { + this.logger.error({ err: error, type: envelope.type }, "handler threw"); + const wrapped = + error instanceof ARCPError + ? error + : new InternalError( + error instanceof Error ? error.message : String(error), + { cause: error instanceof Error ? error : undefined }, + ); + // Best effort: route through session.error for now. + await this.emitSessionError(wrapped); + } + } + + /** Start the v1.1 §6.4 heartbeat watchdog when the feature is negotiated. */ + public startHeartbeat(): void { + if (!this.hasFeature("heartbeat")) return; + const intervalMs = + (this.server.options.heartbeatIntervalSeconds ?? 30) * 1000; + this.heartbeatTimer = setInterval(() => { + void this.heartbeatTick(intervalMs).catch(() => undefined); + }, intervalMs); + this.heartbeatTimer.unref(); + } + + private async heartbeatTick(intervalMs: number): Promise<void> { + if (this.closed || this.transport.closed) return; + // If we have an outstanding ping that's older than 2 * intervalMs, peer is + // dead. Emit HEARTBEAT_LOST as session.error and terminate. + const idleMs = Date.now() - this.lastInboundAt; + if (idleMs > 2 * intervalMs) { + await this.emitSessionError( + new HeartbeatLostError("No inbound activity within 2× heartbeat"), + ); + await this.terminate("heartbeat lost"); + return; + } + if (this.state.id === undefined) return; + const nonce = newMessageId(); + this.outstandingPingNonce = nonce; + try { + await this.transport.send( + buildEnvelope({ + id: newMessageId(), + type: "session.ping" as const, + payload: { nonce, sent_at: new Date().toISOString() }, + optional: { session_id: this.state.id }, + }), + ); + } catch { + // best-effort + } + } + + public handlePong(pingNonce: string): void { + if (this.outstandingPingNonce !== pingNonce) return; + this.outstandingPingNonce = null; + // pong itself counts as inbound activity for the idle check. + this.lastInboundAt = Date.now(); + } + + public async terminate(reason: string | undefined): Promise<void> { + if (this.closed) return; + this.closed = true; + if (this.heartbeatTimer !== null) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + this.jobs.cancelAll(reason ?? "session terminated"); + this.pending.rejectAll(new InvalidRequestError("session terminated")); + // Drop subscriptions to other jobs. + for (const stop of this.subscriptions.values()) { + try { + stop(); + } catch { + // best-effort + } + } + this.subscriptions.clear(); + this.server.dropSession(this); + await this.transport.close(reason); + } +}