diff --git a/.gitignore b/.gitignore index a37ec968..a17cb231 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ apps/ # Coordination (ephemeral, per-session) .planning/coordination/instances/*.json .planning/coordination/claims/*.json +.planning/discoveries/ +.planning/workspace/ +.worktrees/ # Telemetry logs and runtime state .planning/telemetry/ diff --git a/.planning/architecture-citadel-app.md b/.planning/architecture-citadel-app.md new file mode 100644 index 00000000..bc56a9bb --- /dev/null +++ b/.planning/architecture-citadel-app.md @@ -0,0 +1,240 @@ +# Architecture: Citadel App + +> PRD: `.planning/prd-citadel-app.md` | Date: 2026-07-14 +> Mode: feature across `Citadel` and `Citadel-Studio` + +## File Tree + +### Citadel engine repository + +```text +~ packages/contracts/index.js +~ packages/contracts/package.json ++ packages/contracts/schemas/app-contracts-v1.json ++ packages/contracts/app/constants.js ++ packages/contracts/app/validation.js ++ packages/contracts/app/transitions.js ++ packages/contracts/app/index.js ++ core/app-contracts/index.js ++ scripts/generate-app-contract-schema.js ++ scripts/test-app-contracts.js +~ scripts/test-all.js ++ docs/APP_CONTRACTS.md ++ docs/CITADEL_APP_ARCHITECTURE.md +``` + +### Citadel-Studio application repository + +```text +~ package.json +~ package-lock.json +~ vite.config.ts +~ src/App.tsx +~ src/components/StudioShell.tsx +~ src/components/FactoryLibrary.tsx +~ src/components/MissionControl.tsx +~ src/run/rosterStore.ts +~ src/run/factorySpec.ts +~ src/run/bridgeClient.ts +~ server/bridge.ts ++ electron-builder.yml ++ electron/main.ts ++ electron/preload.ts ++ electron/ipc/contracts.ts ++ electron/ipc/handlers.ts ++ electron/security/credentials.ts ++ electron/security/paths.ts ++ electron/supervisor/supervisor.ts ++ electron/supervisor/scheduler.ts ++ electron/supervisor/processTree.ts ++ electron/supervisor/recovery.ts ++ electron/supervisor/workspaces.ts ++ electron/supervisor/worktrees.ts ++ electron/supervisor/terminals.ts ++ electron/supervisor/events.ts ++ electron/persistence/database.ts ++ electron/persistence/migrations/001-initial.ts ++ electron/runtime/adapter.ts ++ electron/runtime/claudeCode.ts ++ electron/runtime/codex.ts ++ src/app/contracts.ts ++ src/app/client.ts ++ src/app/store.ts ++ src/app/migrations.ts ++ src/components/AppFrame.tsx ++ src/components/CommandCenter.tsx ++ src/components/AgentRoster.tsx ++ src/components/AgentInstancePanel.tsx ++ src/components/NeedsYou.tsx ++ src/components/ReviewWorkspace.tsx ++ src/components/TerminalDock.tsx ++ src/components/HandoffCard.tsx ++ src/components/RecoveryCenter.tsx ++ src/components/WorkspaceSwitcher.tsx ++ src/components/__tests__/AppFrame.test.tsx ++ src/components/__tests__/HandoffCard.test.tsx ++ src/app/__tests__/store.test.ts ++ electron/__tests__/ipc-security.test.ts ++ electron/__tests__/supervisor-lifecycle.test.ts ++ electron/__tests__/recovery.test.ts ++ electron/__tests__/runtime-adapters.test.ts ++ electron/__tests__/installer-smoke.test.ts ++ scripts/verify-desktop.mjs ++ scripts/verify-visual.mjs ++ scripts/verify-recovery.mjs +``` + +## Component Breakdown + +### Feature: Versioned app contracts +- **Files:** dependency-free Citadel `packages/contracts/app/`, generated package schema, engine compatibility entrypoint, contract docs, and tests. +- **Dependencies:** Existing canonical JSON, operations validation, runtime IDs, evidence and receipt contracts. +- **Complexity:** high. + +### Feature: Native shell and supervisor +- **Files:** Studio `electron/main.ts`, `preload.ts`, `ipc/`, `supervisor/`, `persistence/`. +- **Dependencies:** App contracts, Electron, PTY provider, SQLite provider, git and filesystem adapters. +- **Complexity:** high. + +### Feature: Runtime adapters and isolated instances +- **Files:** Studio `electron/runtime/`, supervisor scheduler/worktree/terminal modules. +- **Dependencies:** Installed Claude Code and Codex CLIs, current CLI argument builder, Citadel executor profiles. +- **Complexity:** high. + +### Feature: Unified application experience +- **Files:** Studio `src/app/`, `AppFrame`, Command Center, roster, run, review, terminal, handoff, recovery, and workspace surfaces. +- **Dependencies:** Existing canvas, `FactorySpec`, roster, timeline, operation reports, and design system. +- **Complexity:** high. + +### Feature: Desktop distribution and trust +- **Files:** builder configuration, credentials, path security, updater/release scripts, installer/recovery/security tests. +- **Dependencies:** platform signing identities and release hosting. +- **Complexity:** high. + +## Data Model + +### AgentProfile +- **Fields:** `id`, `name`, `role`, `runtime`, `model`, `instructions`, `skillRefs`, `memoryPolicy`, `permissionPolicy`, `resourcePolicy`, `createdAt`, `updatedAt`. +- **Relationships:** belongs to zero or more Teams; creates AgentInstances; may be referenced by Factory crew slots. + +### AgentInstance +- **Fields:** `id`, `profileId`, `operationId`, `workspaceId`, `runtime`, `pid`, `status`, `branch`, `worktree`, `terminalId`, `budget`, `startedAt`, `endedAt`, `exit`. +- **Relationships:** owned by one supervisor; emits Events and Artifacts; sends and receives Handoffs. + +### Team +- **Fields:** `id`, `name`, `memberProfileIds`, `coordinationPolicy`, `handoffPolicy`, `resourcePolicy`. +- **Relationships:** used by Factories and Operations. + +### Operation +- **Fields:** `id`, `factoryId`, `workspaceId`, `objective`, `revision`, `status`, `policy`, `budget`, `createdAt`, `updatedAt`. +- **Relationships:** owns AgentInstances, Handoffs, Gates, Approvals, Events, Artifacts, and a final Receipt. + +### Handoff +- **Fields:** `id`, `operationId`, `fromInstanceId`, `toProfileId`, `toInstanceId`, `outcome`, `decisions`, `blockers`, `artifactRefs`, `verificationRefs`, `nextAction`, `status`, `createdAt`, `acceptedAt`. +- **Relationships:** immutable once accepted; may require an Approval; advances an operation edge. + +### Workspace +- **Fields:** `id`, `root`, `name`, `vcs`, `instructionFiles`, `runtimeAvailability`, `resourcePolicy`, `lastOpenedAt`. +- **Relationships:** owns worktrees, operations, factories, and workspace-scoped memory. + +### Event and Artifact +- **Fields:** versioned Citadel lineage fields plus operation, instance, sequence, kind, timestamp, payload digest, source and privacy class. +- **Relationships:** append-only operation journal; artifacts are content-addressed and referenced by handoffs, gates, and receipts. + +## Key Decisions + +### Product topology: separate engine and app repositories +- **Chosen:** Keep Citadel as the open-core protocol/orchestration engine and make Citadel-Studio the canonical desktop app consuming a versioned package contract, because their release cadence and commercial boundaries differ. +- **Rejected:** Move the complete Studio source into the Citadel repository, because it would collapse open-core and product concerns and overwrite a substantial independent worktree. +- **Rejected:** Maintain the ignored `apps/desktop` prototype as a second app, because two shells duplicate information architecture and runtime ownership. + +### Desktop technology: Electron +- **Chosen:** Electron with a hardened renderer/main boundary, because existing code and local agent execution are Node-native. +- **Rejected:** Tauri for v1, because it adds a Rust/Node sidecar and packaging boundary before supervisor contracts stabilize. +- **Rejected:** Browser/PWA as the execution owner, because it cannot safely own local processes, terminals, worktrees, and credentials. + +### Execution ownership: one local supervisor +- **Chosen:** One durable supervisor per installation with multiple client windows, because process, worktree, budget, and recovery ownership must be singular. +- **Rejected:** One supervisor per window, because duplicate locks and orphaned agents become unavoidable. +- **Rejected:** Renderer-owned execution, because renderer reloads and compromised web content would control child processes. + +### Persistence: supervisor SQLite, append-only journals, and renderer cache +- **Chosen:** SQLite for authoritative indexed entity state, existing Citadel append-only journals for effect/recovery truth, and IndexedDB only for renderer caches/preferences, because recovery and multi-window projections need transactions while execution effects need inspectable hash-linked evidence. +- **Rejected:** IndexedDB-only persistence, because the execution owner cannot depend on a renderer lifecycle. +- **Rejected:** Markdown/JSON alone for high-volume events, because process recovery and event queries require atomic indexed state; human-readable reports remain exported artifacts. + +### Runtime extensibility: adapter manifest +- **Chosen:** A strict runtime adapter interface covering discovery, launch, attach, normalize, interrupt, terminate, recover, capabilities, and evidence, with Claude Code and Codex first. +- **Rejected:** Runtime-specific branches across UI and supervisor code, because every new agent would become a cross-product rewrite. + +## Build Phases + +### Phase 0: Preserve and baseline +- **Goal:** Freeze current truth without stashing, resetting, or overwriting either dirty worktree. +- **Files:** planning documents and baseline evidence only. +- **Dependencies:** none. +- **End Conditions:** Citadel current checks have no observed failure; Studio typecheck, 182-test suite, and production build pass; both dirty states are recorded. + +### Phase 1: App contract foundation +- **Goal:** Publish the versioned cross-repo contract for agents, operations, handoffs, events, policies, and supervisor messages. +- **Files:** Citadel `packages/contracts/app/`, generated schema, `core/app-contracts/` compatibility entrypoint, package exports, docs, and tests. +- **Dependencies:** Phase 0. +- **End Conditions:** schema fixtures validate; unknown fields fail closed; migrations are explicit and non-mutating; Citadel strict tests pass with no new failures. + +### Phase 2: Canonical Studio baseline +- **Goal:** Commit a reviewable Studio baseline and replace the local file dependency with the versioned app contract. +- **Files:** Studio current worktree, package configuration, contract adapter, baseline documentation. +- **Dependencies:** Phase 1. +- **End Conditions:** clean or intentionally bounded Studio status; typecheck, tests, and build pass from a fresh install; current saved factories and rosters migrate without loss. + +### Phase 3: Desktop shell and supervisor +- **Goal:** Launch Studio as Citadel App with singular process ownership, IPC, persistence, terminals, worktrees, and recovery. +- **Files:** Studio `electron/`, desktop config, app client/store, supervisor tests. +- **Dependencies:** Phase 2. +- **End Conditions:** Windows development build launches; two windows observe one supervisor; four fixture agents run concurrently; close/reopen recovery test has no orphaned ownership. + +### Phase 4: Agent collaboration and review +- **Goal:** Make profiles, instances, teams, typed handoffs, approvals, gates, diffs, terminals, and reports first-class end-to-end workflows. +- **Files:** Studio application surfaces, app state, handoff/review components and tests. +- **Dependencies:** Phase 3. +- **End Conditions:** Scout to Mason to Sentinel to Verity fixture completes through typed handoffs; every state links to evidence; pause/retry/cancel/kill and revision conflicts pass tests. + +### Phase 5: Experience and visual quality +- **Goal:** Deliver the coherent Command Center, Factories, Agents, Runs, Review, Library, Memory, and Settings experience at the declared quality bar. +- **Files:** Studio app frame, navigation, surfaces, design tokens, performance and visual scripts. +- **Dependencies:** Phase 3; may proceed in parallel with Phase 4 after shared contracts freeze. +- **End Conditions:** real screenshot matrix passes; keyboard and reduced motion pass; 100-agent fixture meets interaction and event budgets; accessibility audit has no critical issue. + +### Phase 6: Security, packaging, and private alpha +- **Goal:** Produce a signed Windows-first installer with secure credentials, migrations, update/rollback, diagnostics, and threat-model verification. +- **Files:** security, builder, updater, installer and release verification surfaces. +- **Dependencies:** Phases 4 and 5. +- **End Conditions:** clean-machine install, update, rollback, migration, recovery, and uninstall tests pass; security suite passes; private-alpha artifact and checksums are reproducible. + +### Phase 7: Adapter and factory ecosystem +- **Goal:** Prove extensibility with a third runtime adapter and signed factory import/export while preserving local trust boundaries. +- **Files:** adapter SDK docs/fixtures, template signing/validation, library surfaces. +- **Dependencies:** Phase 6. +- **End Conditions:** an out-of-tree adapter passes conformance; signed factory import rejects tampering; no provider-specific UI branch is required. + +## Phase Dependency Graph + +`Phase 0 -> Phase 1 -> Phase 2 -> Phase 3 -> (Phase 4 + Phase 5) -> Phase 6 -> Phase 7` + +## Risk Register + +1. **Uncommitted Studio foundation:** preserve the current index and working tree, record baseline evidence, and do not restructure until a reviewable baseline commit is explicitly approved. +2. **Active Citadel campaign overlap:** keep Phase 1 additive under `core/app-contracts/`; coordinate any `packages/contracts` or `scripts/test-all.js` edit with existing executor/product-proof work. +3. **Electron attack surface:** sandbox the renderer, disable Node integration, validate every IPC message, isolate credentials, and prohibit arbitrary command IPC. +4. **Unsafe development bridge:** the current Vite bridge uses a broad host, unauthenticated mutation routes, raw caller paths, `shell:true`, direct-child cancellation, and `git add -N`; bind development to loopback immediately and replace the bridge rather than packaging it. +5. **PTY and process-tree portability:** define adapter/process contracts first and test Windows kill/recovery semantics before macOS expansion. +6. **Native SQLite/PTY packaging:** lock Electron ABI-compatible versions, keep normal agent execution pipe-based, isolate PTY as an optional terminal service, and verify clean-machine packaging in CI. +7. **Visual scope expansion:** freeze the experience decision model and first-release state matrix before adding marketplace, IDE, or remote collaboration surfaces. +8. **Regression in existing functionality:** run Citadel strict and Studio type/test/build gates after every build phase; five or more new errors park the campaign. + +## Deployment Strategy + +- **Platform:** Windows 11 private alpha, then macOS and Linux public beta. +- **Method:** Electron Builder NSIS/DMG/AppImage with signed release manifest and staged update channels. +- **Environment variables:** signing credentials and release tokens only in protected CI; provider credentials remain in operating-system storage or existing CLI stores. +- **Pre-deploy checks:** contracts, typecheck, tests, production build, visual matrix, recovery, IPC security, clean-machine installer, update, rollback, SBOM, and checksum reproducibility. diff --git a/.planning/citadel-app-experience-decisions.md b/.planning/citadel-app-experience-decisions.md new file mode 100644 index 00000000..0e089d11 --- /dev/null +++ b/.planning/citadel-app-experience-decisions.md @@ -0,0 +1,74 @@ +# Citadel App Experience Decision Model + +> Date: 2026-07-14 +> Experience: Citadel App +> Purpose: Make a multi-agent software factory visible, controllable, resumable, and trustworthy from one desktop surface. +> State: approved foundation; visual execution details remain provisional until the first native-shell prototype is verified. + +## Experience Identity + +- **Decided:** This is a local-first desktop operating environment for agent work, not a prettier dashboard and not a full IDE. +- **Decided:** The governing metaphor is a working citadel containing factories, crews, protocols, gates, memory, live operations, and reports. +- **Decided:** The emotional contract is calm command: density without chaos, power without hidden action, and proof instead of celebratory claims. +- **Rejected:** A chat window as the primary product surface. +- **Rejected:** Two separate Citadel Desktop and Citadel Studio applications. + +## Questions Every Surface Must Answer + +1. What is running, where, and for whom? +2. What changed, what evidence exists, and what remains unknown? +3. Does the operator need to decide or approve anything? +4. What will happen next, under which policy and budget? +5. Can this run be stopped, recovered, replayed, or handed off safely? + +## Information Architecture + +- **Decided global destinations:** Command Center, Factories, Agents, Runs, Review, Library, Memory, Settings. +- **Decided factory stations:** Input, Workspace, Crew, Protocol, Gates, Memory, Live View, Report. +- **Decided primary composition:** navigation/roster rail, central factory or run surface, contextual inspector, collapsible activity/transcript/diff console. +- **Provisional:** Factories is the default destination after a workspace is selected; Command Center is the default when active work needs attention. +- **Rejected:** Permanent navigation divided into Free and Pro feature lists. + +## Content and Component Model + +- **Decided:** Agent Profile, Agent Instance, Team, Factory, Operation, Handoff, Gate, Artifact, Approval, Receipt, Budget, and Workspace are first-class nouns. +- **Decided:** A profile is persistent identity; an instance is one runtime process. The UI must never conflate them. +- **Decided:** A handoff is a typed artifact with sender, recipient, outcome, decisions, blockers, artifacts, verification, and next action. +- **Decided:** Unknown, unreadable, blocked, and failed are visually distinct and never rendered as healthy zeroes. +- **Provisional:** A node canvas remains the strongest factory authoring surface, while run supervision may use a timeline/swarm view instead of editable nodes. + +## Visual Direction + +- **Decided:** Dark-first, real light mode, restrained glass only for hierarchy, high information density, calm operator copy, and semantic color. +- **Decided:** Motion narrates state transitions and work movement; it never exists as ambient decoration. +- **Decided:** Mono typography is reserved for evidence, paths, commands, revisions, cost, and time; prose and controls use a highly legible sans face. +- **Provisional:** Existing Citadel-Studio spatial tokens and node language are the starting design system. +- **Unknown:** Final icon family, type family licensing, product illustrations, and installer/OS integration assets. + +## Interaction and Motion + +- **Decided:** Command palette, full keyboard traversal, Escape restoration, attach/detach terminal, explicit destructive confirmations, and reversible navigation. +- **Decided:** Multiple app windows are clients of one supervisor, not separate execution owners. +- **Decided:** The interface may represent unlimited profiles and queued instances; admission and concurrency are resource-governed. +- **Provisional:** Drag-and-drop remains central to factory authoring but every graph mutation must have a keyboard-accessible equivalent. +- **Rejected:** Browser `confirm`, `alert`, and `prompt` for operation control. + +## State Variants + +- **Required:** first launch, no runtime installed, no workspace, empty roster, idle factory, running fleet, blocked approval, failed gate, disconnected runtime, crash recovery, update available, migration failed, read-only workspace, and corrupted artifact. +- **Required:** Windows scaling at 100, 150, and 200 percent; minimum supported window; ultrawide; reduced motion; high contrast; keyboard only. +- **Unknown:** Mobile control surface. Mobile is not a v1 execution owner and must not distort the desktop information architecture. + +## Performance and Quality Bar + +- **Decided:** Renderer interaction p95 under 100 ms for ordinary controls and under 250 ms for graph mutations on the 100-agent fixture. +- **Decided:** Live event projection must remain responsive at 1,000 events per minute without unbounded renderer memory growth. +- **Decided:** No operation is declared complete unless its configured evidence and handoff contracts are visible and inspectable. +- **Decided:** Visual acceptance requires real rendered screenshots and interaction capture, not source-token assertions alone. + +## Remaining Unknowns That Block Final Visual Form + +1. Native title bar versus custom title bar behavior across Windows and macOS. +2. Default split between authoring canvas, run timeline, swarm visualization, terminal, and diff review. +3. How much game-like agent identity survives professional enterprise presentation. +4. Whether Factory, Mission, and Campaign remain separate user-facing concepts or become saved, running, and long-lived states of one Operation noun. diff --git a/.planning/prd-citadel-app.md b/.planning/prd-citadel-app.md new file mode 100644 index 00000000..c7bc765f --- /dev/null +++ b/.planning/prd-citadel-app.md @@ -0,0 +1,69 @@ +# PRD: Citadel App + +> Description: A local-first desktop software factory for designing, running, supervising, and improving teams of coding agents across real repositories. +> Author: Seth Gammon +> Date: 2026-07-14 +> Status: approved +> Mode: feature + +## Problem + +Citadel has a mature orchestration engine and Citadel-Studio has a substantial visual factory, but neither is currently a launchable desktop product. Operators must move between terminal sessions, repository state, an experimental dashboard, and an uncommitted browser prototype. They cannot reliably create persistent named agents, launch many supervised instances, inspect handoffs, review diffs, recover processes, and control multiple workspaces from one coherent application. + +## Users + +1. A solo software builder who runs several local coding agents and needs durable orchestration, review, and recovery. +2. A technical lead who designs reusable agent teams and factories with explicit policies, gates, budgets, evidence, and handoffs. + +## Core Features + +1. **Workspace and roster control:** Open multiple repositories and manage persistent named agent profiles with runtime, model, role, instructions, skills, memory, permissions, and resource policy. +2. **Visual software factories:** Author reusable graphs that bind an objective, workspace, crew, protocol, gates, memory, live view, and report into one executable factory. +3. **Supervised multi-agent execution:** Launch resource-governed Claude Code and Codex instances in isolated worktrees, observe live events and terminals, and pause, resume, retry, cancel, or recover them. +4. **Typed collaboration and review:** Move work through durable handoffs carrying outcomes, decisions, blockers, artifacts, diffs, verification, and an explicit target agent or operator approval. +5. **Desktop trust and extensibility:** Ship a signed Windows-first desktop app with secure credentials, tamper-evident journals, upgrade-safe local persistence, and a versioned adapter contract for additional agents and sandbox providers. + +## Out of Scope (v1) + +- Hosted remote execution or a mandatory Citadel cloud account. +- Multi-user real-time collaboration, organization billing, or a public marketplace. +- A full source-code editor intended to replace VS Code, Codex, or Claude Code. +- Automatic merge, push, PR creation, or deployment without explicit capability and approval contracts. +- Unlimited physical concurrency; the interface may represent any number of agents, while the supervisor enforces local resource and provider limits. + +## Technical Decisions + +- **Frontend:** Existing React 19, Vite, Zustand, and Citadel-Studio design system, because the visual factory, named roster, adaptive operation, and proof surfaces already exist there. +- **Desktop:** Electron, because Citadel needs Node child processes, filesystem watching, git, PTYs, local CLI discovery, and cross-platform packaging without a new Rust sidecar boundary. +- **Backend:** A local Node supervisor owned by the Electron main process and exposed only through typed IPC, because running agents must survive renderer reloads and remain inaccessible to arbitrary web content. +- **Persistence:** Supervisor-owned SQLite for indexed entity state plus append-only Citadel journals for effects, events, handoffs, and recovery checkpoints; IndexedDB remains renderer-only cache and canvas preference storage, because process recovery cannot depend on one renderer profile or silently dropped browser writes. +- **Auth:** No product account for v1; local CLI authentication remains provider-owned and optional API keys use operating-system-protected storage. +- **Deployment:** Signed Windows NSIS installer first, followed by notarized macOS DMG and Linux AppImage after the Windows recovery and update path is proven. + +## Architecture + +Citadel remains the open-core orchestration and protocol engine. Citadel-Studio becomes the Citadel App renderer and desktop repository. A versioned `@citadel/app-contracts` package defines agent, team, operation, handoff, event, permission, and supervisor messages; a local client package consumes those contracts over Electron IPC. One supervisor process owns runtime adapters, worktrees, terminals, budgets, journals, and recovery while any number of app windows project the same truthful state. + +## Integration Points + +- **Existing files modified:** `packages/contracts/index.js`, `packages/contracts/package.json`, `packages/client/index.js`, Citadel-Studio `package.json`, `vite.config.ts`, `src/App.tsx`, `src/run/rosterStore.ts`, `src/run/factorySpec.ts`, `server/bridge.ts`. +- **New files created:** dependency-free `packages/contracts/app` implementation, schema, generator, conformance tests, and engine compatibility entrypoint in Citadel; Electron main/preload, supervisor, persistence, runtime adapters, IPC client, desktop tests, and release configuration in Citadel-Studio. +- **Dependencies added:** Electron toolchain, PTY integration, SQLite persistence, schema validation, and packaging/update dependencies selected during implementation. +- **Patterns followed:** Operations Protocol immutable records, explicit unknown state, revision-bound intents, local-first evidence, `FactorySpec`, adapter-based runtimes, worktree isolation, and trigger/action/proof/memory/stop loop contracts. + +## End Conditions (Definition of Done) + +- [ ] A clean Windows machine installs Citadel, launches it from Start, opens two repositories, and restores them after restart. +- [ ] An operator creates named Claude Code and Codex agent profiles, launches at least four concurrent isolated instances, and sees truthful live state, transcripts, branches, budgets, and terminal status. +- [ ] A factory executes Scout to Mason to Sentinel to Verity with durable typed handoffs, artifacts, diffs, gate results, and an operator-visible final report. +- [ ] Closing and reopening Citadel during an active run restores or honestly terminates every instance with no orphaned worktree ownership. +- [ ] Pause, resume, retry, cancel, kill, approval, and credential boundaries pass IPC, containment, race, tamper, and recovery tests. +- [ ] Dark/light, keyboard, reduced-motion, 100/150/200 percent scaling, empty/error/unknown states, and a 100-agent fixture pass visual and interaction verification. +- [ ] Existing Citadel strict tests and Citadel-Studio typecheck, tests, and production build pass with zero new failures. +- [ ] Signed installer, update, rollback, migration, and uninstall tests pass on the supported Windows release. + +## Open Questions + +- Final commercial boundary between the open-core engine and the desktop app after the private alpha. +- Whether macOS joins the first public beta or follows the proven Windows release. +- Which third runtime adapter follows Claude Code and Codex; the adapter SDK must make this a product choice rather than an architecture change. diff --git a/core/app-contracts/index.js b/core/app-contracts/index.js new file mode 100644 index 00000000..c40f7adf --- /dev/null +++ b/core/app-contracts/index.js @@ -0,0 +1,5 @@ +'use strict'; + +// Engine compatibility entrypoint. The public package owns the dependency-free +// contract implementation so desktop and browser consumers never reach into core/. +module.exports = require('../../packages/contracts/app'); diff --git a/core/contracts/index.js b/core/contracts/index.js index fc898bdb..4c72ceed 100644 --- a/core/contracts/index.js +++ b/core/contracts/index.js @@ -9,4 +9,5 @@ module.exports = Object.freeze({ skillManifest: require('./skill-manifest'), agentRole: require('./agent-role'), runtime: require('./runtime'), + provider: require('./provider'), }); diff --git a/core/operations/fixtures/research-fleet.graph.json b/core/operations/fixtures/research-fleet.graph.json new file mode 100644 index 00000000..48b45e87 --- /dev/null +++ b/core/operations/fixtures/research-fleet.graph.json @@ -0,0 +1,233 @@ +{ + "graph_version": "0.1", + "kind": "operation_graph_spec", + "graph_id": "research-fleet-proof", + "operation_spec_digest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "entry_node_ids": [ + "scope" + ], + "nodes": [ + { + "node_id": "scope", + "step_id": "scope", + "node_kind": "deterministic", + "input_schema_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "output_schema_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "executor_profile": "local-deterministic", + "scope_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "timeout_ms": 300000, + "max_attempts": 2, + "max_visits": 1, + "effect_class": "pure", + "verifier": { + "required": true, + "policy": "deterministic", + "evidence_types": [ + "artifact" + ] + } + }, + { + "node_id": "scout-claims", + "step_id": "scout-claims", + "node_kind": "agent", + "input_schema_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "output_schema_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "executor_profile": "research-scout", + "scope_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "timeout_ms": 300000, + "max_attempts": 2, + "max_visits": 1, + "effect_class": "pure", + "verifier": { + "required": true, + "policy": "single", + "evidence_types": [ + "review" + ] + } + }, + { + "node_id": "scout-taxonomy", + "step_id": "scout-taxonomy", + "node_kind": "agent", + "input_schema_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "output_schema_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "executor_profile": "research-scout", + "scope_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "timeout_ms": 300000, + "max_attempts": 2, + "max_visits": 1, + "effect_class": "pure", + "verifier": { + "required": true, + "policy": "single", + "evidence_types": [ + "review" + ] + } + }, + { + "node_id": "scout-fit", + "step_id": "scout-fit", + "node_kind": "agent", + "input_schema_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "output_schema_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "executor_profile": "research-scout", + "scope_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "timeout_ms": 300000, + "max_attempts": 2, + "max_visits": 1, + "effect_class": "pure", + "verifier": { + "required": true, + "policy": "single", + "evidence_types": [ + "review" + ] + } + }, + { + "node_id": "reduce", + "step_id": "reduce", + "node_kind": "deterministic", + "input_schema_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "output_schema_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "executor_profile": "local-deterministic", + "scope_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "timeout_ms": 300000, + "max_attempts": 2, + "max_visits": 1, + "effect_class": "pure", + "verifier": { + "required": true, + "policy": "deterministic", + "evidence_types": [ + "artifact" + ] + } + }, + { + "node_id": "synthesize", + "step_id": "synthesize", + "node_kind": "agent", + "input_schema_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "output_schema_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "executor_profile": "synthesis-agent", + "scope_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "timeout_ms": 300000, + "max_attempts": 2, + "max_visits": 1, + "effect_class": "pure", + "verifier": { + "required": true, + "policy": "single", + "evidence_types": [ + "artifact", + "review" + ] + } + }, + { + "node_id": "arbiter", + "step_id": "arbiter", + "node_kind": "gate", + "input_schema_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "output_schema_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc", + "executor_profile": "arbiter", + "scope_digest": "sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd", + "timeout_ms": 300000, + "max_attempts": 2, + "max_visits": 1, + "effect_class": "pure", + "verifier": { + "required": true, + "policy": "arbiter", + "evidence_types": [ + "review", + "test" + ] + } + } + ], + "edges": [ + { + "edge_id": "scope-to-claims", + "from_node_id": "scope", + "to_node_id": "scout-claims", + "edge_kind": "success", + "condition_digest": null, + "data_contract_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + { + "edge_id": "scope-to-taxonomy", + "from_node_id": "scope", + "to_node_id": "scout-taxonomy", + "edge_kind": "success", + "condition_digest": null, + "data_contract_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + { + "edge_id": "scope-to-fit", + "from_node_id": "scope", + "to_node_id": "scout-fit", + "edge_kind": "success", + "condition_digest": null, + "data_contract_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + { + "edge_id": "claims-to-reduce", + "from_node_id": "scout-claims", + "to_node_id": "reduce", + "edge_kind": "success", + "condition_digest": null, + "data_contract_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + { + "edge_id": "taxonomy-to-reduce", + "from_node_id": "scout-taxonomy", + "to_node_id": "reduce", + "edge_kind": "success", + "condition_digest": null, + "data_contract_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + { + "edge_id": "fit-to-reduce", + "from_node_id": "scout-fit", + "to_node_id": "reduce", + "edge_kind": "success", + "condition_digest": null, + "data_contract_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + { + "edge_id": "reduce-to-synthesize", + "from_node_id": "reduce", + "to_node_id": "synthesize", + "edge_kind": "success", + "condition_digest": null, + "data_contract_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + }, + { + "edge_id": "synthesize-to-arbiter", + "from_node_id": "synthesize", + "to_node_id": "arbiter", + "edge_kind": "success", + "condition_digest": null, + "data_contract_digest": "sha256:eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + } + ], + "joins": [ + { + "node_id": "reduce", + "policy": "all", + "threshold": null, + "missing_input_status": "blocked" + } + ], + "limits": { + "max_transitions": 40, + "max_parallel": 3, + "max_total_attempts": 14 + }, + "created_at": "2026-07-21T00:00:00.000Z" +} diff --git a/core/operations/fixtures/research-fleet.trace.json b/core/operations/fixtures/research-fleet.trace.json new file mode 100644 index 00000000..8c328cbe --- /dev/null +++ b/core/operations/fixtures/research-fleet.trace.json @@ -0,0 +1,26 @@ +{ + "schema": 1, + "graph_id": "research-fleet-proof", + "ready_batches": [ + [ + "scope" + ], + [ + "scout-claims", + "scout-taxonomy", + "scout-fit" + ], + [ + "reduce" + ], + [ + "synthesize" + ], + [ + "arbiter" + ] + ], + "final_transition_count": 14, + "final_total_attempts": 7, + "final_status": "passed" +} diff --git a/core/operations/graph-contract.js b/core/operations/graph-contract.js new file mode 100644 index 00000000..552ceefd --- /dev/null +++ b/core/operations/graph-contract.js @@ -0,0 +1,260 @@ +'use strict'; + +const { EVIDENCE_TYPES } = require('./constants'); +const { EFFECT_CLASSES } = require('./journal'); + +const GRAPH_VERSION = '0.1'; +const GRAPH_KIND = 'operation_graph_spec'; +const NODE_KINDS = Object.freeze(['agent', 'deterministic', 'gate', 'human']); +const EDGE_KINDS = Object.freeze(['success', 'conditional', 'failure', 'loop']); +const JOIN_POLICIES = Object.freeze(['all', 'quorum', 'first_success']); +const MISSING_INPUT_STATUSES = Object.freeze(['blocked', 'unknown', 'failed']); +const VERIFIER_POLICIES = Object.freeze(['none', 'deterministic', 'single', 'arbiter']); +const ID_PATTERN = /^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$/; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; + +const FIELDS = Object.freeze({ + graph: Object.freeze(['graph_version', 'kind', 'graph_id', 'operation_spec_digest', 'entry_node_ids', 'nodes', 'edges', 'joins', 'limits', 'created_at']), + node: Object.freeze(['node_id', 'step_id', 'node_kind', 'input_schema_digest', 'output_schema_digest', 'executor_profile', 'scope_digest', 'timeout_ms', 'max_attempts', 'max_visits', 'effect_class', 'verifier']), + edge: Object.freeze(['edge_id', 'from_node_id', 'to_node_id', 'edge_kind', 'condition_digest', 'data_contract_digest']), + join: Object.freeze(['node_id', 'policy', 'threshold', 'missing_input_status']), + limits: Object.freeze(['max_transitions', 'max_parallel', 'max_total_attempts']), + verifier: Object.freeze(['required', 'policy', 'evidence_types']), +}); + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + && Object.getPrototypeOf(value) === Object.prototype; +} + +function exactFields(value, expected, label, errors) { + if (!isPlainObject(value)) { + errors.push(label + ' must be a plain object'); + return false; + } + if (JSON.stringify(Object.keys(value).sort()) !== JSON.stringify([...expected].sort())) { + errors.push(label + ' fields must exactly match the allowlist'); + } + return true; +} + +function checkId(value, label, errors) { + if (typeof value !== 'string' || value.length > 128 || !ID_PATTERN.test(value)) { + errors.push(label + ' must be an opaque lowercase identifier'); + } +} + +function checkDigest(value, label, errors, nullable = false) { + if (nullable && value === null) return; + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) errors.push(label + ' must be a sha256 digest'); +} + +function checkInteger(value, label, errors, minimum, maximum) { + if (!Number.isInteger(value) || value < minimum || value > maximum) { + errors.push(label + ' must be an integer from ' + minimum + ' to ' + maximum); + } +} + +function checkUniqueIds(values, label, errors) { + if (!Array.isArray(values) || values.length < 1 || values.length > 256) { + errors.push(label + ' must contain 1 to 256 entries'); + return; + } + const unique = new Set(); + values.forEach((value, index) => { + checkId(value, label + '[' + index + ']', errors); + if (unique.has(value)) errors.push(label + ' cannot contain duplicates'); + unique.add(value); + }); +} + +function validateVerifier(verifier, label, errors) { + if (!exactFields(verifier, FIELDS.verifier, label, errors)) return; + if (typeof verifier.required !== 'boolean') errors.push(label + '.required must be boolean'); + if (!VERIFIER_POLICIES.includes(verifier.policy)) errors.push(label + '.policy is invalid'); + if (!Array.isArray(verifier.evidence_types) || verifier.evidence_types.length > EVIDENCE_TYPES.length) { + errors.push(label + '.evidence_types must be an array'); + } else { + const unique = new Set(); + verifier.evidence_types.forEach((type) => { + if (!EVIDENCE_TYPES.includes(type)) errors.push(label + '.evidence_types contains unknown type: ' + type); + if (unique.has(type)) errors.push(label + '.evidence_types cannot contain duplicates'); + unique.add(type); + }); + } + if (verifier.required && verifier.policy === 'none') errors.push(label + '.policy cannot be none when required'); + if (!verifier.required && verifier.policy !== 'none') errors.push(label + '.policy must be none when not required'); +} + +function validateNode(node, index, errors) { + const label = 'nodes[' + index + ']'; + if (!exactFields(node, FIELDS.node, label, errors)) return; + checkId(node.node_id, label + '.node_id', errors); + checkId(node.step_id, label + '.step_id', errors); + if (!NODE_KINDS.includes(node.node_kind)) errors.push(label + '.node_kind is invalid'); + checkDigest(node.input_schema_digest, label + '.input_schema_digest', errors); + checkDigest(node.output_schema_digest, label + '.output_schema_digest', errors); + checkId(node.executor_profile, label + '.executor_profile', errors); + checkDigest(node.scope_digest, label + '.scope_digest', errors); + checkInteger(node.timeout_ms, label + '.timeout_ms', errors, 1, 86400000); + checkInteger(node.max_attempts, label + '.max_attempts', errors, 1, 100); + checkInteger(node.max_visits, label + '.max_visits', errors, 1, 100); + if (!EFFECT_CLASSES.includes(node.effect_class)) errors.push(label + '.effect_class is invalid'); + validateVerifier(node.verifier, label + '.verifier', errors); +} + +function validateEdge(edge, index, errors) { + const label = 'edges[' + index + ']'; + if (!exactFields(edge, FIELDS.edge, label, errors)) return; + checkId(edge.edge_id, label + '.edge_id', errors); + checkId(edge.from_node_id, label + '.from_node_id', errors); + checkId(edge.to_node_id, label + '.to_node_id', errors); + if (!EDGE_KINDS.includes(edge.edge_kind)) errors.push(label + '.edge_kind is invalid'); + checkDigest(edge.condition_digest, label + '.condition_digest', errors, true); + checkDigest(edge.data_contract_digest, label + '.data_contract_digest', errors, true); + const needsCondition = edge.edge_kind === 'conditional' || edge.edge_kind === 'loop'; + if (needsCondition && edge.condition_digest === null) errors.push(label + '.condition_digest is required'); + if (!needsCondition && edge.condition_digest !== null) errors.push(label + '.condition_digest must be null'); + if (edge.from_node_id === edge.to_node_id && edge.edge_kind !== 'loop') errors.push(label + ' self-edge must be a loop'); +} + +function validateJoin(join, index, errors) { + const label = 'joins[' + index + ']'; + if (!exactFields(join, FIELDS.join, label, errors)) return; + checkId(join.node_id, label + '.node_id', errors); + if (!JOIN_POLICIES.includes(join.policy)) errors.push(label + '.policy is invalid'); + if (!MISSING_INPUT_STATUSES.includes(join.missing_input_status)) errors.push(label + '.missing_input_status is invalid'); + if (join.policy === 'quorum') checkInteger(join.threshold, label + '.threshold', errors, 1, 256); + else if (join.threshold !== null) errors.push(label + '.threshold must be null unless policy is quorum'); +} + +function validateLimits(limits, errors) { + if (!exactFields(limits, FIELDS.limits, 'limits', errors)) return; + checkInteger(limits.max_transitions, 'limits.max_transitions', errors, 1, 100000); + checkInteger(limits.max_parallel, 'limits.max_parallel', errors, 1, 64); + checkInteger(limits.max_total_attempts, 'limits.max_total_attempts', errors, 1, 100000); +} + +function validateTopology(graph, errors) { + if (![...graph.nodes, ...graph.edges, ...graph.joins].every(isPlainObject)) return; + const nodeIds = new Set(graph.nodes.map((node) => node.node_id)); + if (nodeIds.size !== graph.nodes.length) errors.push('nodes must have unique node_id values'); + const edgeIds = graph.edges.map((edge) => edge.edge_id); + if (new Set(edgeIds).size !== edgeIds.length) errors.push('edges must have unique edge_id values'); + const joinIds = graph.joins.map((join) => join.node_id); + if (new Set(joinIds).size !== joinIds.length) errors.push('joins must have unique node_id values'); + + const incoming = new Map([...nodeIds].map((id) => [id, []])); + const outgoing = new Map([...nodeIds].map((id) => [id, []])); + for (const entryId of graph.entry_node_ids) if (!nodeIds.has(entryId)) errors.push('entry node does not exist: ' + entryId); + for (const edge of graph.edges) { + if (!nodeIds.has(edge.from_node_id)) errors.push('edge source does not exist: ' + edge.from_node_id); + if (!nodeIds.has(edge.to_node_id)) errors.push('edge target does not exist: ' + edge.to_node_id); + if (outgoing.has(edge.from_node_id)) outgoing.get(edge.from_node_id).push(edge); + if (incoming.has(edge.to_node_id)) incoming.get(edge.to_node_id).push(edge); + } + + const entryIds = new Set(graph.entry_node_ids); + for (const nodeId of nodeIds) { + const edges = incoming.get(nodeId).filter((edge) => edge.edge_kind !== 'loop'); + const join = graph.joins.find((item) => item.node_id === nodeId); + if (!entryIds.has(nodeId) && edges.length === 0) errors.push('non-entry node has no incoming edge: ' + nodeId); + if (edges.length > 1 && !join) errors.push('node with multiple incoming edges requires a join: ' + nodeId); + if (join && edges.length < 2) errors.push('join requires at least two incoming edges: ' + nodeId); + if (join && join.policy === 'quorum' && Number.isInteger(join.threshold) && join.threshold > edges.length) { + errors.push('join threshold exceeds incoming edge count: ' + nodeId); + } + } + for (const join of graph.joins) if (!nodeIds.has(join.node_id)) errors.push('join node does not exist: ' + join.node_id); + for (const edge of graph.edges.filter((item) => item.edge_kind === 'loop')) { + const target = graph.nodes.find((node) => node.node_id === edge.to_node_id); + if (target && target.max_visits < 2) errors.push('loop target must allow at least two visits: ' + edge.to_node_id); + const reachableFromTarget = new Set([edge.to_node_id]); + const loopQueue = [edge.to_node_id]; + while (loopQueue.length) { + const current = loopQueue.shift(); + for (const candidate of (outgoing.get(current) || []).filter((item) => item.edge_kind !== 'loop')) { + if (!reachableFromTarget.has(candidate.to_node_id)) { + reachableFromTarget.add(candidate.to_node_id); + loopQueue.push(candidate.to_node_id); + } + } + } + if (!reachableFromTarget.has(edge.from_node_id)) { + errors.push('loop edge must close a non-loop path: ' + edge.edge_id); + } + const canReachSource = new Set([edge.from_node_id]); + const reverseQueue = [edge.from_node_id]; + while (reverseQueue.length) { + const current = reverseQueue.shift(); + for (const candidate of (incoming.get(current) || []).filter((item) => item.edge_kind !== 'loop')) { + if (!canReachSource.has(candidate.from_node_id)) { + canReachSource.add(candidate.from_node_id); + reverseQueue.push(candidate.from_node_id); + } + } + } + for (const node of graph.nodes.filter((item) => + reachableFromTarget.has(item.node_id) && canReachSource.has(item.node_id))) { + if (node.max_visits < 2) errors.push('loop cycle node must allow at least two visits: ' + node.node_id); + } + } + + const reachable = new Set(); + const queue = [...entryIds]; + while (queue.length) { + const nodeId = queue.shift(); + if (reachable.has(nodeId) || !outgoing.has(nodeId)) continue; + reachable.add(nodeId); + outgoing.get(nodeId).forEach((edge) => queue.push(edge.to_node_id)); + } + for (const nodeId of nodeIds) if (!reachable.has(nodeId)) errors.push('node is unreachable from an entry: ' + nodeId); + + const visiting = new Set(); + const visited = new Set(); + function visit(nodeId) { + if (visiting.has(nodeId)) return true; + if (visited.has(nodeId)) return false; + visiting.add(nodeId); + for (const edge of (outgoing.get(nodeId) || []).filter((item) => item.edge_kind !== 'loop')) { + if (visit(edge.to_node_id)) return true; + } + visiting.delete(nodeId); + visited.add(nodeId); + return false; + } + if ([...nodeIds].some((nodeId) => visit(nodeId))) errors.push('cycles must be expressed only with loop edges'); +} + +function validateOperationGraph(graph) { + const errors = []; + if (!exactFields(graph, FIELDS.graph, GRAPH_KIND, errors)) return errors; + if (graph.graph_version !== GRAPH_VERSION) errors.push('graph_version must be ' + GRAPH_VERSION); + if (graph.kind !== GRAPH_KIND) errors.push('kind must be ' + GRAPH_KIND); + checkId(graph.graph_id, 'graph_id', errors); + checkDigest(graph.operation_spec_digest, 'operation_spec_digest', errors); + checkUniqueIds(graph.entry_node_ids, 'entry_node_ids', errors); + if (!Array.isArray(graph.nodes) || graph.nodes.length < 1 || graph.nodes.length > 256) errors.push('nodes must contain 1 to 256 entries'); + else graph.nodes.forEach((node, index) => validateNode(node, index, errors)); + if (!Array.isArray(graph.edges) || graph.edges.length > 2048) errors.push('edges must contain 0 to 2048 entries'); + else graph.edges.forEach((edge, index) => validateEdge(edge, index, errors)); + if (!Array.isArray(graph.joins) || graph.joins.length > 256) errors.push('joins must contain 0 to 256 entries'); + else graph.joins.forEach((join, index) => validateJoin(join, index, errors)); + validateLimits(graph.limits, errors); + if (typeof graph.created_at !== 'string' || !Number.isFinite(Date.parse(graph.created_at)) + || new Date(graph.created_at).toISOString() !== graph.created_at) errors.push('created_at must be a canonical ISO timestamp'); + if (Array.isArray(graph.nodes) && Array.isArray(graph.edges) && Array.isArray(graph.joins) + && Array.isArray(graph.entry_node_ids)) validateTopology(graph, errors); + return errors; +} + +function assertValidOperationGraph(graph) { + const errors = validateOperationGraph(graph); + if (errors.length) throw new Error('invalid operation graph: ' + errors.join('; ')); + return graph; +} + +module.exports = Object.freeze({ + EDGE_KINDS, GRAPH_KIND, GRAPH_VERSION, JOIN_POLICIES, MISSING_INPUT_STATUSES, + NODE_KINDS, VERIFIER_POLICIES, assertValidOperationGraph, validateOperationGraph, +}); diff --git a/core/operations/graph-effects.js b/core/operations/graph-effects.js new file mode 100644 index 00000000..19f56273 --- /dev/null +++ b/core/operations/graph-effects.js @@ -0,0 +1,407 @@ +'use strict'; + +const { + appendJournalEntry, + JournalCorruptionError, + readJournal, +} = require('./journal'); +const { sha256Digest } = require('./canonical'); +const { + appendGraphRunSnapshot, + GraphJournalCorruptionError, + readGraphRunJournal, +} = require('./graph-journal'); +const { + assertValidGraphRun, + transitionGraphRun, +} = require('./graph-run'); +const { + createExecutionReceipt, + requiredStepSubject, + unsignedReceiptEnvelope, +} = require('./receipts'); +const { decisionFor } = require('./recovery'); +const { + DIGEST_PATTERN, + validateOperationSpec, +} = require('./validation'); + +const GRAPH_EFFECT_RESOLUTIONS = Object.freeze(['completed', 'retryable', 'unknown']); + +function assertDigest(value, label) { + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + throw new TypeError(label + ' must be a sha256 digest'); + } +} + +function assertGraphOperationBinding(graph, operation) { + const errors = validateOperationSpec(operation); + if (errors.length) throw new TypeError('Invalid bound operation: ' + errors.join('; ')); + if (sha256Digest(operation) !== graph.operation_spec_digest) { + throw new TypeError('operation digest does not match graph operation_spec_digest'); + } + const stepIds = graph.nodes.map((node) => node.step_id); + if (new Set(stepIds).size !== stepIds.length) { + throw new TypeError('graph nodes must map to unique operation step ids'); + } + if (JSON.stringify([...stepIds].sort()) !== JSON.stringify([...operation.step_ids].sort())) { + throw new TypeError('graph node step ids must exactly cover the operation'); + } + return operation; +} + +function tokenForNode(run, nodeId) { + return [...run.traversal_tokens].reverse().find((token) => token.node_id === nodeId) || null; +} + +function graphEffectIdentity(graph, run, nodeId) { + assertValidGraphRun(graph, run); + const node = graph.nodes.find((item) => item.node_id === nodeId); + if (!node) throw new Error('unknown graph node: ' + nodeId); + const token = tokenForNode(run, nodeId); + if (!token) throw new Error('graph node has no traversal token: ' + nodeId); + const identityDigest = sha256Digest({ + graph_digest: run.graph_digest, + run_id: run.run_id, + node_id: nodeId, + visit: token.visit, + }); + const suffix = identityDigest.slice('sha256:'.length, 'sha256:'.length + 24); + return Object.freeze({ + node, + token, + attempt_id: 'attempt-' + suffix, + idempotency_key: 'graph-effect-' + suffix, + }); +} + +function latestEffectEntry(effectJournalDir, idempotencyKey) { + const journal = readJournal(effectJournalDir); + return [...journal.entries].reverse() + .find((entry) => entry.idempotency_key === idempotencyKey) || null; +} + +function effectRecovery(effectJournalDir, binding, payloadDigest) { + assertDigest(payloadDigest, 'payloadDigest'); + const entry = latestEffectEntry(effectJournalDir, binding.idempotency_key); + if (!entry) { + return Object.freeze({ decision: 'execute', reason_code: 'NO_PRIOR_CHECKPOINT', entry: null }); + } + if (entry.payload_digest !== payloadDigest) { + return Object.freeze({ decision: 'block', reason_code: 'PAYLOAD_DIGEST_MISMATCH', entry }); + } + if (entry.attempt_id !== binding.attempt_id || entry.effect_class !== binding.node.effect_class) { + return Object.freeze({ decision: 'block', reason_code: 'EFFECT_BINDING_MISMATCH', entry }); + } + return Object.freeze({ ...decisionFor(entry), entry }); +} + +function checkpointInput(run, binding, payloadDigest, state, evidenceDigest = null) { + return { + run_id: run.run_id, + attempt_id: binding.attempt_id, + idempotency_key: binding.idempotency_key, + effect_class: binding.node.effect_class, + state, + payload_digest: payloadDigest, + evidence_digest: evidenceDigest, + }; +} + +function transitionAndAppend(graphJournalDir, graph, run, nodeId, status, now) { + const next = transitionGraphRun(graph, run, nodeId, status, { now }); + appendGraphRunSnapshot(graphJournalDir, graph, next, 'node_transition'); + return next; +} + +function startGraphNodeEffect(options) { + const { + graph, run, graphJournalDir, effectJournalDir, nodeId, payloadDigest, + } = options; + const now = options.now || new Date().toISOString(); + const binding = graphEffectIdentity(graph, run, nodeId); + const currentStatus = run.scheduler_state.node_statuses[nodeId]; + if (!['pending', 'blocked', 'running'].includes(currentStatus)) { + throw new Error('node cannot start an effect from status: ' + currentStatus); + } + const recovery = effectRecovery(effectJournalDir, binding, payloadDigest); + if (recovery.decision === 'block') { + return Object.freeze({ + status: 'blocked', + execution: 'blocked', + reason_code: recovery.reason_code, + run, + binding, + }); + } + + let next = run; + if (recovery.decision === 'skip') { + if (currentStatus !== 'running') { + next = transitionAndAppend(graphJournalDir, graph, next, nodeId, 'running', now); + } + next = transitionAndAppend(graphJournalDir, graph, next, nodeId, 'passed', now); + return Object.freeze({ + status: 'completed', + execution: 'skipped', + reason_code: recovery.reason_code, + run: next, + binding, + }); + } + + if (currentStatus === 'running' && recovery.decision === 'retry') { + next = transitionAndAppend(graphJournalDir, graph, next, nodeId, 'blocked', now); + } + if (next.scheduler_state.node_statuses[nodeId] !== 'running') { + next = transitionAndAppend(graphJournalDir, graph, next, nodeId, 'running', now); + } + appendJournalEntry(effectJournalDir, + checkpointInput(next, binding, payloadDigest, 'pending'), { now }); + return Object.freeze({ + status: 'ready', + execution: recovery.decision, + reason_code: recovery.reason_code, + run: next, + binding, + }); +} + +function completeGraphNodeEffect(options) { + const { + graph, run, graphJournalDir, effectJournalDir, nodeId, payloadDigest, evidenceDigest, + } = options; + const now = options.now || new Date().toISOString(); + assertDigest(evidenceDigest, 'evidenceDigest'); + const binding = graphEffectIdentity(graph, run, nodeId); + if (run.scheduler_state.node_statuses[nodeId] !== 'running') { + throw new Error('node must be running before effect completion'); + } + const latest = latestEffectEntry(effectJournalDir, binding.idempotency_key); + if (!latest) throw new Error('effect completion requires a pending checkpoint'); + if (latest.payload_digest !== payloadDigest || latest.attempt_id !== binding.attempt_id + || latest.effect_class !== binding.node.effect_class) { + throw new Error('effect completion does not match its pending checkpoint'); + } + let checkpoint = latest; + if (latest.state !== 'completed') { + if (!['pending', 'unknown'].includes(latest.state)) throw new Error('effect checkpoint is not ambiguous'); + checkpoint = appendJournalEntry(effectJournalDir, + checkpointInput(run, binding, payloadDigest, 'completed', evidenceDigest), { now }); + } else if (latest.evidence_digest !== evidenceDigest) { + throw new Error('completed effect evidence digest cannot change'); + } + if (options.faultAt === 'after_effect_checkpoint') { + const error = new Error('Injected fault after effect checkpoint'); + error.code = 'FAULT_INJECTED'; + throw error; + } + const next = transitionAndAppend(graphJournalDir, graph, run, nodeId, 'passed', now); + return Object.freeze({ + status: 'completed', + execution: latest.state === 'completed' ? 'reconciled' : 'recorded', + reason_code: latest.state === 'completed' ? 'COMPLETED_EFFECT_RECONCILED' : 'EVIDENCE_RECORDED', + run: next, + binding, + checkpoint, + }); +} + +function resolveGraphNodeEffect(options) { + const { + graph, run, graphJournalDir, effectJournalDir, nodeId, payloadDigest, resolution, + } = options; + const now = options.now || new Date().toISOString(); + if (!GRAPH_EFFECT_RESOLUTIONS.includes(resolution)) throw new Error('invalid graph effect resolution'); + const binding = graphEffectIdentity(graph, run, nodeId); + if (run.scheduler_state.node_statuses[nodeId] !== 'running') { + throw new Error('only an in-flight node can be resolved'); + } + const latest = latestEffectEntry(effectJournalDir, binding.idempotency_key); + if (!latest || !['pending', 'unknown'].includes(latest.state)) { + throw new Error('resolution requires an ambiguous effect checkpoint'); + } + if (latest.payload_digest !== payloadDigest) throw new Error('resolution payload digest does not match checkpoint'); + + if (resolution === 'completed') { + return completeGraphNodeEffect({ ...options, evidenceDigest: options.evidenceDigest, now }); + } + if (resolution === 'retryable') { + if (binding.node.effect_class !== 'external-nonrepeatable') { + throw new Error('retryable resolution is only required for external-nonrepeatable effects'); + } + assertDigest(options.evidenceDigest, 'evidenceDigest'); + appendJournalEntry(effectJournalDir, + checkpointInput(run, binding, payloadDigest, 'retryable', options.evidenceDigest), { now }); + const next = transitionAndAppend(graphJournalDir, graph, run, nodeId, 'blocked', now); + return Object.freeze({ + status: 'ready', + execution: 'retry_authorized', + reason_code: 'RETRY_AUTHORIZED_BY_EVIDENCE', + run: next, + binding, + }); + } + appendJournalEntry(effectJournalDir, + checkpointInput(run, binding, payloadDigest, 'unknown'), { now }); + const next = transitionAndAppend(graphJournalDir, graph, run, nodeId, 'unknown', now); + return Object.freeze({ + status: 'blocked', + execution: 'resolved_unknown', + reason_code: 'EFFECT_OUTCOME_UNKNOWN', + run: next, + binding, + }); +} + +function planGraphExecutionRecovery(graphJournalDir, effectJournalDir, graph) { + let graphJournal; + try { + graphJournal = readGraphRunJournal(graphJournalDir, graph); + } catch (error) { + if (!(error instanceof GraphJournalCorruptionError)) throw error; + return Object.freeze({ + status: 'blocked', reason_code: 'GRAPH_JOURNAL_CORRUPT', run: null, actions: Object.freeze([]), + }); + } + let effectJournal; + try { + effectJournal = readJournal(effectJournalDir); + } catch (error) { + if (!(error instanceof JournalCorruptionError)) throw error; + return Object.freeze({ + status: 'blocked', reason_code: 'EFFECT_JOURNAL_CORRUPT', + run: graphJournal.latest_run, actions: Object.freeze([]), + }); + } + if (!graphJournal.latest_run) { + return Object.freeze({ + status: 'empty', + reason_code: 'GRAPH_RUN_NOT_INITIALIZED', + run: null, + actions: Object.freeze([]), + }); + } + const run = graphJournal.latest_run; + const inFlight = Object.entries(run.scheduler_state.node_statuses) + .filter(([, status]) => status === 'running').map(([nodeId]) => nodeId); + const actions = inFlight.map((nodeId) => { + const binding = graphEffectIdentity(graph, run, nodeId); + const entry = [...effectJournal.entries].reverse() + .find((item) => item.idempotency_key === binding.idempotency_key) || null; + const action = entry ? decisionFor(entry) : { + decision: 'execute', + reason_code: 'NO_PRIOR_CHECKPOINT', + }; + return Object.freeze({ + node_id: nodeId, + attempt_id: binding.attempt_id, + idempotency_key: binding.idempotency_key, + effect_class: binding.node.effect_class, + state: entry ? entry.state : null, + ...action, + }); + }); + const blocked = actions.some((action) => action.decision === 'block'); + const terminalBlocked = ['failed', 'blocked', 'unknown'].includes(run.status); + return Object.freeze({ + status: blocked || terminalBlocked ? 'blocked' + : inFlight.length ? 'ready' : run.status === 'passed' ? 'complete' : 'ready', + reason_code: blocked ? 'AMBIGUOUS_GRAPH_EFFECT' + : terminalBlocked ? 'GRAPH_RUN_BLOCKED' + : inFlight.length ? 'GRAPH_EFFECT_RECOVERY_READY' + : run.status === 'passed' ? 'GRAPH_RUN_COMPLETE' : 'GRAPH_RUN_RESUMABLE', + run, + actions: Object.freeze(actions), + }); +} + +function createGraphProtocolProof(options) { + const { + graph, run, operation, effectJournalDir, issuedAt, issuerId, + } = options; + assertValidGraphRun(graph, run); + assertGraphOperationBinding(graph, operation); + if (!['passed', 'failed', 'blocked', 'unknown'].includes(run.status)) { + throw new Error('protocol proof requires a terminal graph run'); + } + const journal = readJournal(effectJournalDir); + const latestTokens = new Map(); + for (const token of run.traversal_tokens) latestTokens.set(token.node_id, token); + const attempts = []; + const evidence = []; + + for (const node of graph.nodes) { + const token = latestTokens.get(node.node_id); + if (!token) continue; + const binding = graphEffectIdentity(graph, run, node.node_id); + const entry = [...journal.entries].reverse() + .find((item) => item.idempotency_key === binding.idempotency_key && item.state === 'completed'); + if (!entry) continue; + const evidenceId = 'evidence-' + sha256Digest({ attempt_id: binding.attempt_id, evidence_digest: entry.evidence_digest }) + .slice('sha256:'.length, 'sha256:'.length + 24); + attempts.push(Object.freeze({ + protocol_version: '0.1', + kind: 'step_attempt', + attempt_id: binding.attempt_id, + run_id: run.run_id, + step_id: node.step_id, + attempt_number: run.scheduler_state.attempt_counts[node.node_id], + status: 'passed', + started_at: run.created_at, + completed_at: run.updated_at, + evidence_ids: Object.freeze([evidenceId]), + failure_code: null, + })); + evidence.push(Object.freeze({ + protocol_version: '0.1', + kind: 'evidence_envelope', + evidence_id: evidenceId, + run_id: run.run_id, + step_attempt_id: binding.attempt_id, + evidence_type: node.verifier.evidence_types[0] || 'other', + status: 'passed', + subject_digest: requiredStepSubject(operation, node.step_id), + artifact_digest: entry.evidence_digest, + recorded_at: run.updated_at, + redacted: true, + })); + } + + const operationRun = Object.freeze({ + protocol_version: '0.1', + kind: 'operation_run', + run_id: run.run_id, + operation_id: operation.operation_id, + spec_digest: sha256Digest(operation), + status: run.status, + started_at: run.created_at, + completed_at: run.updated_at, + intent_ids: Object.freeze([]), + step_attempt_ids: Object.freeze(attempts.map((attempt) => attempt.attempt_id)), + }); + const receipt = createExecutionReceipt({ + operation, + run: operationRun, + evidence, + issuedAt, + issuerId, + }); + return Object.freeze({ + operation_run: operationRun, + step_attempts: Object.freeze(attempts), + evidence: Object.freeze(evidence), + receipt_envelope: unsignedReceiptEnvelope(receipt), + }); +} + +module.exports = Object.freeze({ + GRAPH_EFFECT_RESOLUTIONS, + assertGraphOperationBinding, + completeGraphNodeEffect, + createGraphProtocolProof, + graphEffectIdentity, + planGraphExecutionRecovery, + resolveGraphNodeEffect, + startGraphNodeEffect, +}); diff --git a/core/operations/graph-journal.js b/core/operations/graph-journal.js new file mode 100644 index 00000000..3861ec22 --- /dev/null +++ b/core/operations/graph-journal.js @@ -0,0 +1,284 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { canonicalSerialize, sha256Digest } = require('./canonical'); +const { assertValidGraphRun } = require('./graph-run'); +const { DIGEST_PATTERN, ID_PATTERN } = require('./validation'); + +const GRAPH_JOURNAL_VERSION = '0.1'; +const GRAPH_JOURNAL_KIND = 'operation_graph_journal_entry'; +const GRAPH_EVENT_TYPES = Object.freeze([ + 'initialized', 'node_transition', 'edge_decision', 'checkpoint', +]); +const GRAPH_JOURNAL_FIELDS = Object.freeze([ + 'journal_version', 'kind', 'sequence', 'recorded_at', 'event_type', + 'run_id', 'graph_id', 'graph_digest', 'run_digest', 'run_snapshot', + 'previous_hash', 'entry_hash', +]); +const ENTRY_PATTERN = /^(\d{8})\.json$/; + +class GraphJournalCorruptionError extends Error { + constructor(code, message) { + super(message); + this.name = 'GraphJournalCorruptionError'; + this.code = code; + } +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + && Object.getPrototypeOf(value) === Object.prototype; +} + +function exactFields(value) { + return isPlainObject(value) + && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...GRAPH_JOURNAL_FIELDS].sort()); +} + +function canonicalTimestamp(value) { + return typeof value === 'string' && Number.isFinite(Date.parse(value)) + && new Date(value).toISOString() === value; +} + +function validateGraphJournalEntry(graph, entry, options = {}) { + const errors = []; + if (!exactFields(entry)) return ['graph journal entry fields must exactly match the privacy allowlist']; + if (entry.journal_version !== GRAPH_JOURNAL_VERSION) errors.push('journal_version must be ' + GRAPH_JOURNAL_VERSION); + if (entry.kind !== GRAPH_JOURNAL_KIND) errors.push('kind must be ' + GRAPH_JOURNAL_KIND); + if (!Number.isInteger(entry.sequence) || entry.sequence < 1) errors.push('sequence must be a positive integer'); + if (!canonicalTimestamp(entry.recorded_at)) errors.push('recorded_at must be a canonical ISO timestamp'); + if (!GRAPH_EVENT_TYPES.includes(entry.event_type)) errors.push('event_type is invalid'); + for (const field of ['run_id', 'graph_id']) { + if (typeof entry[field] !== 'string' || !ID_PATTERN.test(entry[field])) errors.push(field + ' is invalid'); + } + for (const field of ['graph_digest', 'run_digest']) { + if (typeof entry[field] !== 'string' || !DIGEST_PATTERN.test(entry[field])) errors.push(field + ' must be a sha256 digest'); + } + if (entry.previous_hash !== null + && (typeof entry.previous_hash !== 'string' || !DIGEST_PATTERN.test(entry.previous_hash))) { + errors.push('previous_hash must be null or a sha256 digest'); + } + if (typeof entry.entry_hash !== 'string' || !DIGEST_PATTERN.test(entry.entry_hash)) { + errors.push('entry_hash must be a sha256 digest'); + } + if (!isPlainObject(entry.run_snapshot)) errors.push('run_snapshot must be a plain object'); + else { + try { assertValidGraphRun(graph, entry.run_snapshot); } catch (error) { errors.push(error.message); } + if (entry.run_id !== entry.run_snapshot.run_id) errors.push('run_id does not match snapshot'); + if (entry.graph_id !== entry.run_snapshot.graph_id) errors.push('graph_id does not match snapshot'); + if (entry.graph_digest !== entry.run_snapshot.graph_digest) errors.push('graph_digest does not match snapshot'); + if (entry.run_digest !== sha256Digest(entry.run_snapshot)) errors.push('run_digest does not match snapshot'); + if (canonicalTimestamp(entry.recorded_at) && entry.run_snapshot.updated_at !== entry.recorded_at) { + errors.push('snapshot updated_at must match recorded_at'); + } + } + const unsigned = { ...entry }; + delete unsigned.entry_hash; + if (entry.entry_hash !== sha256Digest(unsigned)) errors.push('entry_hash does not match canonical entry content'); + if ('expectedSequence' in options && entry.sequence !== options.expectedSequence) errors.push('journal sequence is not contiguous'); + if ('expectedPreviousHash' in options && entry.previous_hash !== options.expectedPreviousHash) errors.push('journal hash chain is broken'); + if (options.previousEntry) { + const previous = options.previousEntry; + if (entry.run_id !== previous.run_id) errors.push('journal cannot change run_id'); + if (entry.graph_digest !== previous.graph_digest) errors.push('journal cannot change graph_digest'); + if (entry.run_snapshot.created_at !== previous.run_snapshot.created_at) errors.push('journal cannot change created_at'); + if (Date.parse(entry.recorded_at) < Date.parse(previous.recorded_at)) errors.push('journal timestamps must be monotonic'); + if (entry.run_snapshot.scheduler_state.transition_count + < previous.run_snapshot.scheduler_state.transition_count) errors.push('transition_count cannot decrease'); + if (entry.run_snapshot.scheduler_state.total_attempts + < previous.run_snapshot.scheduler_state.total_attempts) errors.push('total_attempts cannot decrease'); + const previousTokens = previous.run_snapshot.traversal_tokens; + const nextTokens = entry.run_snapshot.traversal_tokens; + if (nextTokens.length < previousTokens.length) errors.push('traversal token history cannot shrink'); + for (let index = 0; index < previousTokens.length && index < nextTokens.length; index++) { + const before = previousTokens[index]; + const after = nextTokens[index]; + for (const field of ['token_id', 'node_id', 'visit']) { + if (before[field] !== after[field]) errors.push('traversal token identity cannot change: ' + before.token_id); + } + for (const field of ['parent_token_ids', 'via_edge_ids']) { + if (JSON.stringify(before[field]) !== JSON.stringify(after[field])) { + errors.push('traversal token lineage cannot change: ' + before.token_id); + } + } + if (before.status !== after.status) { + const allowed = before.status === 'pending' && after.status === 'running' + || before.status === 'running' && ['passed', 'failed', 'blocked', 'unknown'].includes(after.status) + || before.status === 'blocked' && after.status === 'running'; + if (!allowed) errors.push('invalid traversal token status transition: ' + before.token_id); + } + } + } + return errors; +} + +function entryFiles(journalDir) { + if (!fs.existsSync(journalDir)) return []; + return fs.readdirSync(journalDir).filter((name) => ENTRY_PATTERN.test(name)).sort(); +} + +function readGraphRunJournal(journalDir, graph) { + const files = entryFiles(journalDir); + const entries = []; + let previousHash = null; + let previousEntry = null; + files.forEach((name, index) => { + const fileSequence = Number(name.match(ENTRY_PATTERN)[1]); + if (fileSequence !== index + 1) { + throw new GraphJournalCorruptionError('SEQUENCE_GAP', 'Graph journal sequence is not contiguous'); + } + let entry; + try { + entry = JSON.parse(fs.readFileSync(path.join(journalDir, name), 'utf8')); + } catch (_error) { + throw new GraphJournalCorruptionError('INVALID_JSON', 'Graph journal entry is not valid JSON'); + } + const errors = validateGraphJournalEntry(graph, entry, { + expectedSequence: index + 1, + expectedPreviousHash: previousHash, + previousEntry, + }); + if (errors.length) throw new GraphJournalCorruptionError('INVALID_ENTRY', errors.join('; ')); + entries.push(Object.freeze(entry)); + previousHash = entry.entry_hash; + previousEntry = entry; + }); + return Object.freeze({ + entries: Object.freeze(entries), + latest_run: entries.length ? entries[entries.length - 1].run_snapshot : null, + head_hash: previousHash, + next_sequence: entries.length + 1, + }); +} + +function acquireLock(journalDir) { + fs.mkdirSync(journalDir, { recursive: true }); + const lockPath = path.join(journalDir, '.graph-append.lock'); + let descriptor; + try { + descriptor = fs.openSync(lockPath, 'wx'); + fs.writeFileSync(descriptor, String(process.pid) + '\n', 'utf8'); + fs.fsyncSync(descriptor); + } catch (_error) { + if (descriptor !== undefined) fs.closeSync(descriptor); + throw new Error('Graph journal append is already locked'); + } + fs.closeSync(descriptor); + return lockPath; +} + +function atomicWrite(target, content) { + const temporary = path.join(path.dirname(target), + '.' + path.basename(target) + '.' + process.pid + '.' + crypto.randomBytes(8).toString('hex') + '.tmp'); + let descriptor; + try { + descriptor = fs.openSync(temporary, 'wx'); + fs.writeFileSync(descriptor, content, 'utf8'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + if (fs.existsSync(target)) throw new Error('Graph journal sequence already exists'); + fs.renameSync(temporary, target); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + if (fs.existsSync(temporary)) fs.rmSync(temporary, { force: true }); + } +} + +function appendGraphRunSnapshot(journalDir, graph, run, eventType, options = {}) { + assertValidGraphRun(graph, run); + if (!GRAPH_EVENT_TYPES.includes(eventType)) throw new Error('event_type is invalid'); + const lockPath = acquireLock(journalDir); + try { + const journal = readGraphRunJournal(journalDir, graph); + if (eventType === 'initialized' && journal.entries.length) throw new Error('Graph run is already initialized'); + if (eventType !== 'initialized' && !journal.entries.length) throw new Error('Graph run must be initialized first'); + if (journal.latest_run && journal.latest_run.run_id !== run.run_id) throw new Error('Graph journal cannot mix run ids'); + const recordedAt = options.now || run.updated_at; + if (recordedAt !== run.updated_at) throw new Error('journal time must match run updated_at'); + const unsigned = { + journal_version: GRAPH_JOURNAL_VERSION, + kind: GRAPH_JOURNAL_KIND, + sequence: journal.next_sequence, + recorded_at: recordedAt, + event_type: eventType, + run_id: run.run_id, + graph_id: run.graph_id, + graph_digest: run.graph_digest, + run_digest: sha256Digest(run), + run_snapshot: run, + previous_hash: journal.head_hash, + }; + const entry = { ...unsigned, entry_hash: sha256Digest(unsigned) }; + const errors = validateGraphJournalEntry(graph, entry, { + expectedSequence: journal.next_sequence, + expectedPreviousHash: journal.head_hash, + previousEntry: journal.entries.length ? journal.entries[journal.entries.length - 1] : null, + }); + if (errors.length) throw new TypeError('Invalid graph journal entry: ' + errors.join('; ')); + const target = path.join(journalDir, String(entry.sequence).padStart(8, '0') + '.json'); + atomicWrite(target, canonicalSerialize(entry) + '\n'); + return Object.freeze(entry); + } finally { + fs.rmSync(lockPath, { force: true }); + } +} + +function planGraphRunRecovery(journalDir, graph) { + let journal; + try { + journal = readGraphRunJournal(journalDir, graph); + } catch (error) { + if (!(error instanceof GraphJournalCorruptionError)) throw error; + return Object.freeze({ + status: 'blocked', + journal_status: 'corrupt', + reason_code: 'GRAPH_JOURNAL_CORRUPT', + run: null, + in_flight_node_ids: Object.freeze([]), + }); + } + if (!journal.latest_run) { + return Object.freeze({ + status: 'empty', journal_status: 'verified', reason_code: 'GRAPH_RUN_NOT_INITIALIZED', + run: null, in_flight_node_ids: Object.freeze([]), + }); + } + const run = journal.latest_run; + const inFlight = Object.entries(run.scheduler_state.node_statuses) + .filter(([, status]) => status === 'running').map(([nodeId]) => nodeId); + if (inFlight.length) { + return Object.freeze({ + status: 'blocked', + journal_status: 'verified', + reason_code: 'IN_FLIGHT_NODE_REQUIRES_EFFECT_RECOVERY', + run, + in_flight_node_ids: Object.freeze(inFlight), + }); + } + const complete = run.status === 'passed'; + const blocked = TERMINAL_BLOCKING_STATUSES.includes(run.status); + return Object.freeze({ + status: complete ? 'complete' : blocked ? 'blocked' : 'ready', + journal_status: 'verified', + reason_code: complete ? 'GRAPH_RUN_COMPLETE' : blocked ? 'GRAPH_RUN_BLOCKED' : 'GRAPH_RUN_RESUMABLE', + run, + in_flight_node_ids: Object.freeze([]), + }); +} + +const TERMINAL_BLOCKING_STATUSES = Object.freeze(['failed', 'blocked', 'unknown']); + +module.exports = Object.freeze({ + GRAPH_EVENT_TYPES, + GRAPH_JOURNAL_FIELDS, + GRAPH_JOURNAL_KIND, + GRAPH_JOURNAL_VERSION, + GraphJournalCorruptionError, + appendGraphRunSnapshot, + planGraphRunRecovery, + readGraphRunJournal, + validateGraphJournalEntry, +}); diff --git a/core/operations/graph-run.js b/core/operations/graph-run.js new file mode 100644 index 00000000..39bb385d --- /dev/null +++ b/core/operations/graph-run.js @@ -0,0 +1,329 @@ +'use strict'; + +const { EXECUTION_STATUSES, TERMINAL_STATUSES } = require('./constants'); +const { sha256Digest } = require('./canonical'); +const { assertValidOperationGraph } = require('./graph-contract'); +const { + assertExecutableGraph, + assertValidGraphState, + classifyEdge, + createGraphState, + evaluateGraph, + recordEdgeDecision, + transitionNode, +} = require('./graph-scheduler'); +const { ID_PATTERN } = require('./validation'); + +const GRAPH_RUN_VERSION = '0.1'; +const GRAPH_RUN_KIND = 'operation_graph_run'; +const RUN_FIELDS = Object.freeze([ + 'run_version', 'kind', 'run_id', 'graph_id', 'graph_digest', 'status', + 'scheduler_state', 'traversal_tokens', 'created_at', 'updated_at', +]); +const TOKEN_FIELDS = Object.freeze([ + 'token_id', 'node_id', 'visit', 'parent_token_ids', 'via_edge_ids', 'status', +]); + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + && Object.getPrototypeOf(value) === Object.prototype; +} + +function exactFields(value, fields) { + return isPlainObject(value) + && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...fields].sort()); +} + +function canonicalTimestamp(value) { + return typeof value === 'string' && Number.isFinite(Date.parse(value)) + && new Date(value).toISOString() === value; +} + +function plainState(state) { + return Object.freeze({ + state_version: state.state_version, + graph_id: state.graph_id, + node_statuses: Object.freeze({ ...state.node_statuses }), + visit_counts: Object.freeze({ ...state.visit_counts }), + attempt_counts: Object.freeze({ ...state.attempt_counts }), + edge_decisions: Object.freeze({ ...state.edge_decisions }), + loop_decision_visits: Object.freeze({ ...state.loop_decision_visits }), + transition_count: state.transition_count, + total_attempts: state.total_attempts, + }); +} + +function freezeToken(token) { + return Object.freeze({ + token_id: token.token_id, + node_id: token.node_id, + visit: token.visit, + parent_token_ids: Object.freeze([...token.parent_token_ids]), + via_edge_ids: Object.freeze([...token.via_edge_ids]), + status: token.status, + }); +} + +function tokenId(nodeId, visit) { + return 'token-' + nodeId + '-' + visit; +} + +function satisfiedIncoming(graph, state, nodeId) { + return graph.edges.filter((edge) => { + if (edge.to_node_id !== nodeId) return false; + if (edge.edge_kind !== 'loop') return classifyEdge(edge, state) === 'satisfied'; + return state.edge_decisions[edge.edge_id] === true + && state.loop_decision_visits[edge.edge_id] === state.visit_counts[edge.from_node_id] + && state.visit_counts[nodeId] > 0; + }); +} + +function syncTraversalTokens(graph, state, tokens) { + const evaluation = evaluateGraph(graph, state); + const ready = new Set([...evaluation.ready_node_ids, ...evaluation.deferred_ready_node_ids]); + const output = tokens.map((token) => freezeToken(token)); + for (const node of graph.nodes) { + if (!ready.has(node.node_id)) continue; + const visit = state.visit_counts[node.node_id] + 1; + const id = tokenId(node.node_id, visit); + if (output.some((token) => token.token_id === id)) continue; + const incoming = satisfiedIncoming(graph, state, node.node_id); + const parentIds = []; + for (const edge of incoming) { + const parent = [...output].reverse().find((token) => token.node_id === edge.from_node_id + && token.status === 'passed'); + if (parent && !parentIds.includes(parent.token_id)) parentIds.push(parent.token_id); + } + output.push(freezeToken({ + token_id: id, + node_id: node.node_id, + visit, + parent_token_ids: parentIds, + via_edge_ids: incoming.map((edge) => edge.edge_id), + status: 'pending', + })); + } + return Object.freeze(output); +} + +function deriveGraphRunStatus(graph, state) { + const evaluation = evaluateGraph(graph, state); + const statuses = Object.values(state.node_statuses); + if (evaluation.running_node_ids.length) return 'running'; + if (statuses.includes('failed')) return 'failed'; + if (statuses.includes('unknown')) return 'unknown'; + if (evaluation.complete) return statuses.every((status) => status === 'passed') ? 'passed' : 'blocked'; + if (statuses.includes('blocked')) return 'blocked'; + if (!evaluation.ready_node_ids.length && !evaluation.deferred_ready_node_ids.length + && evaluation.blocked_nodes.length) return 'blocked'; + return 'pending'; +} + +function validateIdArray(values, label, errors) { + if (!Array.isArray(values) || new Set(values).size !== values.length) { + errors.push(label + ' must be a unique array'); + return; + } + for (const value of values) { + if (typeof value !== 'string' || !ID_PATTERN.test(value)) errors.push(label + ' contains an invalid identifier'); + } +} + +function validateTraversalToken(graph, token, tokens, state, errors) { + if (!exactFields(token, TOKEN_FIELDS)) { + errors.push('traversal token fields must exactly match the allowlist'); + return; + } + if (typeof token.token_id !== 'string' || !ID_PATTERN.test(token.token_id)) errors.push('token_id is invalid'); + const node = graph.nodes.find((item) => item.node_id === token.node_id); + if (!node) errors.push('token node does not exist: ' + token.node_id); + if (!Number.isInteger(token.visit) || token.visit < 1 || token.visit > 100) errors.push('token visit is invalid'); + if (node && token.token_id !== tokenId(token.node_id, token.visit)) errors.push('token_id is not deterministic'); + validateIdArray(token.parent_token_ids, 'parent_token_ids', errors); + validateIdArray(token.via_edge_ids, 'via_edge_ids', errors); + const parentIds = Array.isArray(token.parent_token_ids) ? token.parent_token_ids : []; + const edgeIds = Array.isArray(token.via_edge_ids) ? token.via_edge_ids : []; + const parentTokens = parentIds.map((parentId) => tokens.find((item) => item.token_id === parentId)); + for (let index = 0; index < parentIds.length; index++) { + if (!parentTokens[index]) errors.push('parent token does not exist: ' + parentIds[index]); + else if (parentTokens[index].status !== 'passed') errors.push('parent token must be passed: ' + parentIds[index]); + if (parentIds[index] === token.token_id) errors.push('token cannot parent itself'); + } + const incomingEdges = []; + const expectedTokenVisit = node && state.node_statuses[token.node_id] === 'pending' + ? state.visit_counts[token.node_id] + 1 : Math.max(1, state.visit_counts[token.node_id]); + const historical = node ? token.visit < expectedTokenVisit : false; + for (const edgeId of edgeIds) { + const edge = graph.edges.find((item) => item.edge_id === edgeId && item.to_node_id === token.node_id); + if (!edge) errors.push('token edge does not enter its node: ' + edgeId); + else { + incomingEdges.push(edge); + if (!historical && edge.edge_kind !== 'loop' && classifyEdge(edge, state) !== 'satisfied') { + errors.push('token edge is not satisfied: ' + edgeId); + } + if (!historical && !TERMINAL_STATUSES.includes(token.status) && edge.edge_kind === 'loop') { + const parent = parentTokens.find((item) => item && item.node_id === edge.from_node_id); + if (state.edge_decisions[edgeId] !== true || !parent + || state.loop_decision_visits[edgeId] !== parent.visit) { + errors.push('token loop edge is not the selected reset edge: ' + edgeId); + } + } + } + } + const expectedParentNodes = [...new Set(incomingEdges.map((edge) => edge.from_node_id))].sort(); + const actualParentNodes = [...new Set(parentTokens.filter(Boolean).map((parent) => parent.node_id))].sort(); + if (JSON.stringify(expectedParentNodes) !== JSON.stringify(actualParentNodes)) { + errors.push('parent tokens must match traversal edges'); + } + if (!EXECUTION_STATUSES.includes(token.status)) errors.push('token status is invalid'); + if (node) { + if (token.visit > expectedTokenVisit) errors.push('token visit exceeds scheduler visit count'); + if (token.visit === expectedTokenVisit && token.status !== state.node_statuses[token.node_id]) { + errors.push('latest token status does not match scheduler state: ' + token.node_id); + } + if (token.visit < expectedTokenVisit && !TERMINAL_STATUSES.includes(token.status)) { + errors.push('historical token must be terminal: ' + token.token_id); + } + } +} + +function validateGraphRun(graph, run) { + const errors = []; + try { assertExecutableGraph(graph); } catch (error) { errors.push(error.message); return errors; } + if (!exactFields(run, RUN_FIELDS)) return ['graph run fields must exactly match the allowlist']; + if (run.run_version !== GRAPH_RUN_VERSION) errors.push('run_version must be ' + GRAPH_RUN_VERSION); + if (run.kind !== GRAPH_RUN_KIND) errors.push('kind must be ' + GRAPH_RUN_KIND); + if (typeof run.run_id !== 'string' || !ID_PATTERN.test(run.run_id)) errors.push('run_id is invalid'); + if (run.graph_id !== graph.graph_id) errors.push('run graph_id does not match graph'); + if (run.graph_digest !== sha256Digest(graph)) errors.push('graph_digest does not match graph'); + let stateValid = true; + try { assertValidGraphState(graph, run.scheduler_state); } catch (error) { + errors.push(error.message); + stateValid = false; + } + if (!Array.isArray(run.traversal_tokens)) errors.push('traversal_tokens must be an array'); + else if (stateValid) { + const ids = run.traversal_tokens.map((token) => token && token.token_id); + if (new Set(ids).size !== ids.length) errors.push('traversal token ids must be unique'); + run.traversal_tokens.forEach((token) => validateTraversalToken( + graph, token, run.traversal_tokens, run.scheduler_state, errors)); + } + if (!EXECUTION_STATUSES.includes(run.status)) errors.push('run status is invalid'); + else if (stateValid) { + try { + if (run.status !== deriveGraphRunStatus(graph, run.scheduler_state)) errors.push('run status does not match scheduler state'); + } catch (error) { errors.push(error.message); } + } + if (!canonicalTimestamp(run.created_at)) errors.push('created_at must be a canonical ISO timestamp'); + if (!canonicalTimestamp(run.updated_at)) errors.push('updated_at must be a canonical ISO timestamp'); + if (canonicalTimestamp(run.created_at) && canonicalTimestamp(run.updated_at) + && Date.parse(run.updated_at) < Date.parse(run.created_at)) errors.push('updated_at cannot precede created_at'); + if (Array.isArray(run.traversal_tokens) && stateValid) { + const tokenLimit = graph.nodes.reduce((sum, node) => sum + node.max_visits, 0); + if (run.traversal_tokens.length > tokenLimit) errors.push('traversal token count exceeds graph visit limits'); + const evaluation = evaluateGraph(graph, run.scheduler_state); + const ready = new Set([...evaluation.ready_node_ids, ...evaluation.deferred_ready_node_ids]); + for (const node of graph.nodes) { + const attempts = run.scheduler_state.attempt_counts[node.node_id]; + const status = run.scheduler_state.node_statuses[node.node_id]; + const token = [...run.traversal_tokens].reverse().find((item) => item.node_id === node.node_id); + const expectedVisit = status === 'pending' + ? run.scheduler_state.visit_counts[node.node_id] + 1 + : Math.max(1, run.scheduler_state.visit_counts[node.node_id]); + const currentToken = token && token.visit === expectedVisit ? token : null; + if (attempts > 0 && !token) errors.push('attempted node is missing a traversal token: ' + node.node_id); + if (ready.has(node.node_id) && !currentToken) errors.push('ready node is missing a traversal token: ' + node.node_id); + if (currentToken && status === 'pending' && !ready.has(node.node_id)) { + errors.push('pending token belongs to a node that is not ready: ' + node.node_id); + } + } + } + return errors; +} + +function assertValidGraphRun(graph, run) { + const errors = validateGraphRun(graph, run); + if (errors.length) throw new Error('invalid graph run: ' + errors.join('; ')); + return run; +} + +function buildRun(graph, input) { + const state = plainState(input.scheduler_state); + const run = Object.freeze({ + run_version: GRAPH_RUN_VERSION, + kind: GRAPH_RUN_KIND, + run_id: input.run_id, + graph_id: graph.graph_id, + graph_digest: sha256Digest(graph), + status: deriveGraphRunStatus(graph, state), + scheduler_state: state, + traversal_tokens: Object.freeze(input.traversal_tokens.map((token) => freezeToken(token))), + created_at: input.created_at, + updated_at: input.updated_at, + }); + assertValidGraphRun(graph, run); + return run; +} + +function createGraphRun(graph, runId, options = {}) { + assertValidOperationGraph(graph); + assertExecutableGraph(graph); + const now = options.now || new Date().toISOString(); + if (!canonicalTimestamp(now)) throw new Error('now must be a canonical ISO timestamp'); + const state = plainState(createGraphState(graph)); + return buildRun(graph, { + run_id: runId, + scheduler_state: state, + traversal_tokens: syncTraversalTokens(graph, state, []), + created_at: now, + updated_at: now, + }); +} + +function transitionGraphRun(graph, run, nodeId, toStatus, options = {}) { + assertValidGraphRun(graph, run); + const now = options.now || new Date().toISOString(); + if (!canonicalTimestamp(now)) throw new Error('now must be a canonical ISO timestamp'); + const state = plainState(transitionNode(graph, run.scheduler_state, nodeId, toStatus)); + const tokens = run.traversal_tokens.map((token) => ({ ...token })); + const visit = state.visit_counts[nodeId] || run.scheduler_state.visit_counts[nodeId] + 1; + const id = tokenId(nodeId, visit); + const index = tokens.findIndex((token) => token.token_id === id); + if (index < 0) throw new Error('node is missing a traversal token: ' + nodeId); + tokens[index] = { ...tokens[index], status: toStatus }; + return buildRun(graph, { + run_id: run.run_id, + scheduler_state: state, + traversal_tokens: syncTraversalTokens(graph, state, tokens), + created_at: run.created_at, + updated_at: now, + }); +} + +function decideGraphRunEdge(graph, run, edgeId, selected, options = {}) { + assertValidGraphRun(graph, run); + const now = options.now || new Date().toISOString(); + if (!canonicalTimestamp(now)) throw new Error('now must be a canonical ISO timestamp'); + const state = plainState(recordEdgeDecision(graph, run.scheduler_state, edgeId, selected)); + return buildRun(graph, { + run_id: run.run_id, + scheduler_state: state, + traversal_tokens: syncTraversalTokens(graph, state, run.traversal_tokens), + created_at: run.created_at, + updated_at: now, + }); +} + +module.exports = Object.freeze({ + GRAPH_RUN_KIND, + GRAPH_RUN_VERSION, + RUN_FIELDS, + TOKEN_FIELDS, + assertValidGraphRun, + createGraphRun, + decideGraphRunEdge, + deriveGraphRunStatus, + syncTraversalTokens, + transitionGraphRun, + validateGraphRun, +}); diff --git a/core/operations/graph-scheduler.js b/core/operations/graph-scheduler.js new file mode 100644 index 00000000..ce2061e7 --- /dev/null +++ b/core/operations/graph-scheduler.js @@ -0,0 +1,331 @@ +'use strict'; + +const { EXECUTION_STATUSES, TERMINAL_STATUSES } = require('./constants'); +const { assertValidOperationGraph } = require('./graph-contract'); +const { validateStatusTransition } = require('./transitions'); + +const STATE_VERSION = '0.1'; +const STATE_FIELDS = Object.freeze([ + 'state_version', 'graph_id', 'node_statuses', 'visit_counts', 'attempt_counts', + 'edge_decisions', 'loop_decision_visits', 'transition_count', 'total_attempts', +]); + +function cloneRecord(value) { + return Object.assign(Object.create(null), value); +} + +function exactKeys(value, expected) { + return value && typeof value === 'object' && !Array.isArray(value) + && JSON.stringify(Object.keys(value).sort()) === JSON.stringify([...expected].sort()); +} + +function assertExecutableGraph(graph) { + assertValidOperationGraph(graph); + return graph; +} + +function assertValidGraphState(graph, state) { + const errors = []; + if (!exactKeys(state, STATE_FIELDS)) throw new Error('graph state fields must exactly match the allowlist'); + if (state.state_version !== STATE_VERSION) errors.push('state_version must be ' + STATE_VERSION); + if (state.graph_id !== graph.graph_id) errors.push('state graph_id does not match graph'); + const nodeIds = graph.nodes.map((node) => node.node_id); + for (const field of ['node_statuses', 'visit_counts', 'attempt_counts']) { + if (!exactKeys(state[field], nodeIds)) errors.push(field + ' keys must exactly match graph nodes'); + } + if (errors.length === 0) { + for (const nodeId of nodeIds) { + if (!EXECUTION_STATUSES.includes(state.node_statuses[nodeId])) errors.push('invalid node status: ' + nodeId); + if (!Number.isInteger(state.visit_counts[nodeId]) || state.visit_counts[nodeId] < 0) errors.push('invalid visit count: ' + nodeId); + if (!Number.isInteger(state.attempt_counts[nodeId]) || state.attempt_counts[nodeId] < 0) errors.push('invalid attempt count: ' + nodeId); + const node = graph.nodes.find((item) => item.node_id === nodeId); + if (state.visit_counts[nodeId] > node.max_visits) errors.push('visit limit exceeded: ' + nodeId); + if (state.attempt_counts[nodeId] > node.max_attempts) errors.push('attempt limit exceeded: ' + nodeId); + } + } + if (!state.edge_decisions || typeof state.edge_decisions !== 'object' || Array.isArray(state.edge_decisions)) { + errors.push('edge_decisions must be an object'); + } else { + const decisionEdges = new Map(graph.edges.filter((edge) => edge.edge_kind === 'conditional' || edge.edge_kind === 'loop').map((edge) => [edge.edge_id, edge])); + for (const [edgeId, selected] of Object.entries(state.edge_decisions)) { + if (!decisionEdges.has(edgeId)) errors.push('edge decision does not reference a conditional or loop edge: ' + edgeId); + if (typeof selected !== 'boolean') errors.push('edge decision must be boolean: ' + edgeId); + } + } + if (!state.loop_decision_visits || typeof state.loop_decision_visits !== 'object' + || Array.isArray(state.loop_decision_visits)) { + errors.push('loop_decision_visits must be an object'); + } else { + const loopEdges = new Map(graph.edges.filter((edge) => edge.edge_kind === 'loop') + .map((edge) => [edge.edge_id, edge])); + for (const [edgeId, visit] of Object.entries(state.loop_decision_visits)) { + const edge = loopEdges.get(edgeId); + if (!edge) errors.push('loop decision visit does not reference a loop edge: ' + edgeId); + if (!Number.isInteger(visit) || visit < 1) { + errors.push('loop decision visit must be a positive integer: ' + edgeId); + } + if (edge && Number.isInteger(visit) && visit > state.visit_counts[edge.from_node_id]) { + errors.push('loop decision visit exceeds its source visit: ' + edgeId); + } + if (!Object.prototype.hasOwnProperty.call(state.edge_decisions, edgeId)) { + errors.push('loop decision visit requires an edge decision: ' + edgeId); + } + } + for (const edge of graph.edges.filter((item) => item.edge_kind === 'loop')) { + if (Object.prototype.hasOwnProperty.call(state.edge_decisions, edge.edge_id) + && !Object.prototype.hasOwnProperty.call(state.loop_decision_visits, edge.edge_id)) { + errors.push('loop edge decision requires a source visit: ' + edge.edge_id); + } + } + } + if (!Number.isInteger(state.transition_count) || state.transition_count < 0) errors.push('transition_count must be a non-negative integer'); + if (!Number.isInteger(state.total_attempts) || state.total_attempts < 0) errors.push('total_attempts must be a non-negative integer'); + if (Number.isInteger(state.total_attempts) && exactKeys(state.attempt_counts, nodeIds) + && Object.values(state.attempt_counts).reduce((sum, count) => sum + count, 0) !== state.total_attempts) { + errors.push('total_attempts must equal the sum of node attempt counts'); + } + if (exactKeys(state.node_statuses, nodeIds) + && Object.values(state.node_statuses).filter((status) => status === 'running').length > graph.limits.max_parallel) { + errors.push('parallelism limit exceeded'); + } + if (state.transition_count > graph.limits.max_transitions) errors.push('transition limit exceeded'); + if (state.total_attempts > graph.limits.max_total_attempts) errors.push('attempt limit exceeded'); + if (errors.length) throw new Error('invalid graph state: ' + errors.join('; ')); + return state; +} + +function createGraphState(graph) { + assertExecutableGraph(graph); + const nodeStatuses = Object.create(null); + const visitCounts = Object.create(null); + const attemptCounts = Object.create(null); + for (const node of graph.nodes) { + nodeStatuses[node.node_id] = 'pending'; + visitCounts[node.node_id] = 0; + attemptCounts[node.node_id] = 0; + } + return Object.freeze({ + state_version: STATE_VERSION, + graph_id: graph.graph_id, + node_statuses: Object.freeze(nodeStatuses), + visit_counts: Object.freeze(visitCounts), + attempt_counts: Object.freeze(attemptCounts), + edge_decisions: Object.freeze(Object.create(null)), + loop_decision_visits: Object.freeze(Object.create(null)), + transition_count: 0, + total_attempts: 0, + }); +} + +function classifyEdge(edge, state) { + const sourceStatus = state.node_statuses[edge.from_node_id]; + if (edge.edge_kind === 'success') { + if (sourceStatus === 'passed') return 'satisfied'; + return TERMINAL_STATUSES.includes(sourceStatus) ? 'inactive' : 'waiting'; + } + if (edge.edge_kind === 'failure') { + if (TERMINAL_STATUSES.includes(sourceStatus) && sourceStatus !== 'passed') return 'satisfied'; + return sourceStatus === 'passed' ? 'inactive' : 'waiting'; + } + if (sourceStatus !== 'passed') return TERMINAL_STATUSES.includes(sourceStatus) ? 'inactive' : 'waiting'; + if (edge.edge_kind === 'loop' + && state.loop_decision_visits[edge.edge_id] !== state.visit_counts[edge.from_node_id]) return 'waiting'; + if (!Object.prototype.hasOwnProperty.call(state.edge_decisions, edge.edge_id)) return 'waiting'; + return state.edge_decisions[edge.edge_id] ? 'satisfied' : 'inactive'; +} +function loopCycleRegion(graph, edge) { + const nonLoop = graph.edges.filter((item) => item.edge_kind !== 'loop'); + const forward = new Set([edge.to_node_id]); + const queue = [edge.to_node_id]; + while (queue.length) { + const current = queue.shift(); + for (const candidate of nonLoop.filter((item) => item.from_node_id === current)) { + if (!forward.has(candidate.to_node_id)) { + forward.add(candidate.to_node_id); + queue.push(candidate.to_node_id); + } + } + } + const reverse = new Set([edge.from_node_id]); + const reverseQueue = [edge.from_node_id]; + while (reverseQueue.length) { + const current = reverseQueue.shift(); + for (const candidate of nonLoop.filter((item) => item.to_node_id === current)) { + if (!reverse.has(candidate.from_node_id)) { + reverse.add(candidate.from_node_id); + reverseQueue.push(candidate.from_node_id); + } + } + } + return graph.nodes.map((node) => node.node_id) + .filter((nodeId) => forward.has(nodeId) && reverse.has(nodeId)); +} + + +function joinDisposition(incoming, join, state) { + const classifications = incoming.map((edge) => classifyEdge(edge, state)); + const satisfied = classifications.filter((value) => value === 'satisfied').length; + const waiting = classifications.filter((value) => value === 'waiting').length; + const policy = join ? join.policy : 'first_success'; + const threshold = policy === 'all' ? incoming.length : policy === 'quorum' ? join.threshold : 1; + if (satisfied >= threshold) return Object.freeze({ status: 'ready', classifications }); + if (satisfied + waiting < threshold) { + return Object.freeze({ + status: 'blocked', + classifications, + missing_input_status: join ? join.missing_input_status : 'blocked', + reason: 'join cannot reach ' + policy + ' threshold', + }); + } + return Object.freeze({ status: 'waiting', classifications }); +} + +function evaluateGraph(graph, state) { + assertValidOperationGraph(graph); + assertValidGraphState(graph, state); + const entryIds = new Set(graph.entry_node_ids); + const ready = []; + const waiting = []; + const blocked = []; + const running = []; + const terminal = []; + const awaitingLoopDecisions = graph.edges.filter((edge) => edge.edge_kind === 'loop' + && state.node_statuses[edge.from_node_id] === 'passed' + && state.loop_decision_visits[edge.edge_id] !== state.visit_counts[edge.from_node_id]) + .map((edge) => edge.edge_id); + + for (const node of graph.nodes) { + const nodeId = node.node_id; + const status = state.node_statuses[nodeId]; + if (status === 'running') { running.push(nodeId); continue; } + if (TERMINAL_STATUSES.includes(status)) { terminal.push(nodeId); continue; } + const incoming = graph.edges.filter((edge) => edge.to_node_id === nodeId && edge.edge_kind !== 'loop'); + if (entryIds.has(nodeId) && incoming.length === 0) { ready.push(nodeId); continue; } + const join = graph.joins.find((item) => item.node_id === nodeId); + const disposition = joinDisposition(incoming, join, state); + if (disposition.status === 'ready') ready.push(nodeId); + else if (disposition.status === 'blocked') blocked.push(Object.freeze({ + node_id: nodeId, status: disposition.missing_input_status, reason: disposition.reason, + })); + else waiting.push(nodeId); + } + + const capacity = Math.max(0, graph.limits.max_parallel - running.length); + return Object.freeze({ + ready_node_ids: Object.freeze(ready.slice(0, capacity)), + deferred_ready_node_ids: Object.freeze(ready.slice(capacity)), + waiting_node_ids: Object.freeze(waiting), + blocked_nodes: Object.freeze(blocked), + running_node_ids: Object.freeze(running), + terminal_node_ids: Object.freeze(terminal), + awaiting_loop_decision_edge_ids: Object.freeze(awaitingLoopDecisions), + complete: terminal.length === graph.nodes.length && awaitingLoopDecisions.length === 0, + }); +} + +function recordEdgeDecision(graph, state, edgeId, selected) { + assertValidOperationGraph(graph); + assertValidGraphState(graph, state); + const edge = graph.edges.find((item) => item.edge_id === edgeId); + if (!edge || (edge.edge_kind !== 'conditional' && edge.edge_kind !== 'loop')) { + throw new Error('edge decisions are only valid for conditional or loop edges'); + } + if (typeof selected !== 'boolean') throw new Error('edge decision must be boolean'); + if (state.node_statuses[edge.from_node_id] !== 'passed') throw new Error('edge source must pass before a decision is recorded'); + const decisions = cloneRecord(state.edge_decisions); + const loopDecisionVisits = cloneRecord(state.loop_decision_visits); + if (edge.edge_kind === 'loop' + && loopDecisionVisits[edgeId] === state.visit_counts[edge.from_node_id]) { + throw new Error('loop edge is already decided for this source visit'); + } + decisions[edgeId] = selected; + if (edge.edge_kind !== 'loop') { + return Object.freeze(Object.assign({}, state, { edge_decisions: Object.freeze(decisions) })); + } + if (state.transition_count + 1 > graph.limits.max_transitions) throw new Error('graph transition limit exceeded'); + const sourceVisit = state.visit_counts[edge.from_node_id]; + if (sourceVisit < 1) throw new Error('loop source has not been visited'); + loopDecisionVisits[edgeId] = sourceVisit; + if (!selected) { + return Object.freeze(Object.assign({}, state, { + edge_decisions: Object.freeze(decisions), + loop_decision_visits: Object.freeze(loopDecisionVisits), + transition_count: state.transition_count + 1, + })); + } + const region = loopCycleRegion(graph, edge); + if (!region.length || !region.includes(edge.from_node_id) || !region.includes(edge.to_node_id)) { + throw new Error('loop edge does not close a non-loop cycle'); + } + for (const nodeId of region) { + if (!TERMINAL_STATUSES.includes(state.node_statuses[nodeId])) { + throw new Error('loop cycle region must be terminal before reset: ' + nodeId); + } + const node = graph.nodes.find((item) => item.node_id === nodeId); + if (state.visit_counts[nodeId] >= node.max_visits) { + throw new Error('node visit limit exceeded: ' + nodeId); + } + } + const statuses = cloneRecord(state.node_statuses); + for (const nodeId of region) statuses[nodeId] = 'pending'; + for (const candidate of graph.edges) { + if (candidate.edge_kind === 'conditional' && region.includes(candidate.from_node_id)) { + delete decisions[candidate.edge_id]; + } + } + return Object.freeze(Object.assign({}, state, { + node_statuses: Object.freeze(statuses), + edge_decisions: Object.freeze(decisions), + loop_decision_visits: Object.freeze(loopDecisionVisits), + transition_count: state.transition_count + 1, + })); +} + +function transitionNode(graph, state, nodeId, toStatus) { + assertValidOperationGraph(graph); + assertValidGraphState(graph, state); + const node = graph.nodes.find((item) => item.node_id === nodeId); + if (!node) throw new Error('unknown graph node: ' + nodeId); + const fromStatus = state.node_statuses[nodeId]; + const transitionErrors = validateStatusTransition(fromStatus, toStatus); + if (transitionErrors.length) throw new Error(transitionErrors.join('; ')); + if (state.transition_count + 1 > graph.limits.max_transitions) throw new Error('graph transition limit exceeded'); + + let totalAttempts = state.total_attempts; + const attempts = cloneRecord(state.attempt_counts); + const visits = cloneRecord(state.visit_counts); + if (toStatus === 'running') { + if (fromStatus === 'blocked' && attempts[nodeId] === 0) { + throw new Error('blocked node cannot retry before its first attempt: ' + nodeId); + } + if (fromStatus === 'pending' && !evaluateGraph(graph, state).ready_node_ids.includes(nodeId)) { + throw new Error('node is not ready: ' + nodeId); + } + const runningCount = Object.values(state.node_statuses).filter((status) => status === 'running').length; + if (runningCount >= graph.limits.max_parallel) throw new Error('graph parallelism limit exceeded'); + if (attempts[nodeId] + 1 > node.max_attempts) throw new Error('node attempt limit exceeded: ' + nodeId); + if (fromStatus === 'pending' && visits[nodeId] + 1 > node.max_visits) throw new Error('node visit limit exceeded: ' + nodeId); + if (totalAttempts + 1 > graph.limits.max_total_attempts) throw new Error('graph total attempt limit exceeded'); + attempts[nodeId] += 1; + if (fromStatus === 'pending') visits[nodeId] += 1; + totalAttempts += 1; + } + const statuses = cloneRecord(state.node_statuses); + statuses[nodeId] = toStatus; + return Object.freeze({ + state_version: STATE_VERSION, + graph_id: state.graph_id, + node_statuses: Object.freeze(statuses), + visit_counts: Object.freeze(visits), + attempt_counts: Object.freeze(attempts), + edge_decisions: state.edge_decisions, + loop_decision_visits: state.loop_decision_visits, + transition_count: state.transition_count + 1, + total_attempts: totalAttempts, + }); +} + +module.exports = Object.freeze({ + STATE_VERSION, assertExecutableGraph, assertValidGraphState, classifyEdge, createGraphState, evaluateGraph, + loopCycleRegion, recordEdgeDecision, transitionNode, +}); diff --git a/core/operations/index.js b/core/operations/index.js index 2082de12..017da775 100644 --- a/core/operations/index.js +++ b/core/operations/index.js @@ -11,4 +11,10 @@ module.exports = Object.freeze({ ...require('./receipts'), ...require('./intents'), ...require('./conformance'), + ...require('./graph-contract'), + ...require('./graph-scheduler'), + ...require('./graph-run'), + ...require('./graph-effects'), + ...require('./graph-journal'), + ...require('./research-graph'), }); diff --git a/core/operations/journal.js b/core/operations/journal.js index 6415928b..a2067dec 100644 --- a/core/operations/journal.js +++ b/core/operations/journal.js @@ -13,7 +13,7 @@ const EFFECT_CLASSES = Object.freeze([ 'external-idempotent', 'external-nonrepeatable', ]); -const IDEMPOTENCY_STATES = Object.freeze(['pending', 'completed', 'unknown']); +const IDEMPOTENCY_STATES = Object.freeze(['pending', 'completed', 'unknown', 'retryable']); const JOURNAL_FIELDS = Object.freeze([ 'protocol_version', 'kind', 'sequence', 'recorded_at', 'run_id', 'attempt_id', 'idempotency_key', 'effect_class', 'state', 'payload_digest', 'evidence_digest', @@ -57,7 +57,9 @@ function validateJournalEntry(entry, options = {}) { if (entry.evidence_digest !== null && (typeof entry.evidence_digest !== 'string' || !DIGEST_PATTERN.test(entry.evidence_digest))) { errors.push('evidence_digest must be null or a sha256 digest'); } - if (entry.state === 'completed' && entry.evidence_digest === null) errors.push('completed checkpoints require evidence_digest'); + if (['completed', 'retryable'].includes(entry.state) && entry.evidence_digest === null) { + errors.push('completed and retryable checkpoints require evidence_digest'); + } if (entry.previous_hash !== null && (typeof entry.previous_hash !== 'string' || !DIGEST_PATTERN.test(entry.previous_hash))) { errors.push('previous_hash must be null or a sha256 digest'); } @@ -76,10 +78,39 @@ function entryFiles(journalDir) { .filter((name) => ENTRY_PATTERN.test(name)) .sort(); } +function validateJournalContinuation(previous, entry) { + const errors = []; + if (!previous) { + if (entry.state !== 'pending') errors.push('first idempotency checkpoint must be pending'); + return errors; + } + for (const field of ['run_id', 'attempt_id', 'effect_class', 'payload_digest']) { + if (entry[field] !== previous[field]) errors.push(field + ' cannot change for an idempotency key'); + } + if (Date.parse(entry.recorded_at) < Date.parse(previous.recorded_at)) { + errors.push('idempotency checkpoint timestamps must be monotonic'); + } + const allowed = { + pending: ['pending', 'completed', 'unknown', 'retryable'], + unknown: ['pending', 'completed', 'retryable'], + retryable: ['pending'], + completed: [], + }; + if (!allowed[previous.state].includes(entry.state)) { + errors.push('invalid idempotency state transition: ' + previous.state + ' -> ' + entry.state); + } + if (entry.state === 'pending' && previous.effect_class === 'external-nonrepeatable' + && previous.state !== 'retryable') { + errors.push('nonrepeatable effects require an evidenced retryable resolution before retry'); + } + return errors; +} + function readJournal(journalDir) { const files = entryFiles(journalDir); const entries = []; + const latest = new Map(); let previousHash = null; files.forEach((name, index) => { const match = name.match(ENTRY_PATTERN); @@ -97,6 +128,9 @@ function readJournal(journalDir) { }); if (errors.length) throw new JournalCorruptionError('INVALID_ENTRY', errors.join('; ')); entries.push(entry); + const continuationErrors = validateJournalContinuation(latest.get(entry.idempotency_key), entry); + if (continuationErrors.length) throw new JournalCorruptionError('INVALID_ENTRY', continuationErrors.join('; ')); + latest.set(entry.idempotency_key, entry); previousHash = entry.entry_hash; }); return Object.freeze({ @@ -162,6 +196,10 @@ function appendJournalEntry(journalDir, input, options = {}) { expectedSequence: journal.next_sequence, expectedPreviousHash: journal.head_hash, }); + const previous = [...journal.entries].reverse() + .find((item) => item.idempotency_key === entry.idempotency_key); + errors.push(...validateJournalContinuation(previous, entry)); + if (errors.length) throw new TypeError(`Invalid journal entry: ${errors.join('; ')}`); const target = path.join(journalDir, `${String(entry.sequence).padStart(8, '0')}.json`); atomicWrite(target, `${canonicalSerialize(entry)}\n`); diff --git a/core/operations/recovery.js b/core/operations/recovery.js index fab64737..44df30db 100644 --- a/core/operations/recovery.js +++ b/core/operations/recovery.js @@ -5,6 +5,9 @@ const { EFFECT_CLASSES, JournalCorruptionError, readJournal } = require('./journ function decisionFor(entry) { if (entry.state === 'completed') return { decision: 'skip', reason_code: 'ALREADY_COMPLETED' }; if (entry.effect_class === 'external-nonrepeatable') { + if (entry.state === 'retryable') { + return { decision: 'retry', reason_code: 'RETRY_AUTHORIZED_BY_EVIDENCE' }; + } return { decision: 'block', reason_code: 'AMBIGUOUS_NONREPEATABLE_EFFECT' }; } return { decision: 'retry', reason_code: entry.state === 'unknown' ? 'SAFE_RETRY_AFTER_UNKNOWN' : 'SAFE_RETRY_AFTER_PENDING' }; diff --git a/core/operations/research-graph.js b/core/operations/research-graph.js new file mode 100644 index 00000000..256c3c09 --- /dev/null +++ b/core/operations/research-graph.js @@ -0,0 +1,160 @@ +'use strict'; + +const { sha256Digest } = require('./canonical'); +const { assertValidOperationGraph } = require('./graph-contract'); +const { ID_PATTERN } = require('./validation'); + +const RESEARCH_GRAPH_TEMPLATE_VERSION = '0.1'; + +function canonicalTimestamp(value) { + return typeof value === 'string' && Number.isFinite(Date.parse(value)) + && new Date(value).toISOString() === value; +} + +function verifier(policy, evidenceTypes) { + return Object.freeze({ + required: policy !== 'none', + policy, + evidence_types: Object.freeze([...evidenceTypes]), + }); +} + +function researchNode(nodeId, nodeKind, executorProfile, policy, evidenceTypes, digests) { + return Object.freeze({ + node_id: nodeId, + step_id: nodeId, + node_kind: nodeKind, + input_schema_digest: digests.input, + output_schema_digest: digests.output, + executor_profile: executorProfile, + scope_digest: digests.scope, + timeout_ms: 900000, + max_attempts: 2, + max_visits: 1, + effect_class: 'pure', + verifier: verifier(policy, evidenceTypes), + }); +} + +function successEdge(edgeId, fromNodeId, toNodeId, dataDigest) { + return Object.freeze({ + edge_id: edgeId, + from_node_id: fromNodeId, + to_node_id: toNodeId, + edge_kind: 'success', + condition_digest: null, + data_contract_digest: dataDigest, + }); +} + +function validateAngles(angleIds) { + if (!Array.isArray(angleIds) || angleIds.length < 3 || angleIds.length > 5) { + throw new Error('research graph requires 3 to 5 angle ids'); + } + if (new Set(angleIds).size !== angleIds.length) throw new Error('research angle ids must be unique'); + for (const angleId of angleIds) { + if (typeof angleId !== 'string' || !ID_PATTERN.test(angleId) || angleId.length > 64) { + throw new Error('research angle ids must be opaque lowercase identifiers'); + } + } +} + +function createResearchFleetGraph(angleIds, options = {}) { + validateAngles(angleIds); + const createdAt = options.now || new Date().toISOString(); + if (!canonicalTimestamp(createdAt)) throw new Error('now must be a canonical ISO timestamp'); + const identityDigest = sha256Digest({ + template: 'research-fleet', + template_version: RESEARCH_GRAPH_TEMPLATE_VERSION, + angle_ids: angleIds, + }); + const graphId = options.graphId || 'research-fleet-' + identityDigest.slice('sha256:'.length, 'sha256:'.length + 12); + if (!ID_PATTERN.test(graphId)) throw new Error('graphId must be an opaque lowercase identifier'); + const digests = Object.freeze({ + input: sha256Digest({ schema: 'research-node-input', version: '0.1' }), + output: sha256Digest({ schema: 'research-node-output', version: '0.1' }), + scope: options.scopeDigest || sha256Digest({ graph_id: graphId, scope: 'research-fleet' }), + data: sha256Digest({ schema: 'research-node-handoff', version: '0.1' }), + }); + const scoutNodes = angleIds.map((angleId) => 'scout-' + angleId); + const nodes = [ + researchNode('scope', 'deterministic', 'local-deterministic', 'deterministic', ['artifact'], digests), + ...scoutNodes.map((nodeId) => + researchNode(nodeId, 'agent', 'research-scout', 'single', ['review'], digests)), + researchNode('reduce', 'deterministic', 'local-deterministic', 'deterministic', ['artifact'], digests), + researchNode('synthesize', 'agent', 'synthesis-agent', 'single', ['artifact', 'review'], digests), + researchNode('arbiter', 'gate', 'arbiter', 'arbiter', ['review'], digests), + ]; + const edges = [ + ...scoutNodes.map((nodeId) => successEdge('scope-to-' + nodeId, 'scope', nodeId, digests.data)), + ...scoutNodes.map((nodeId) => successEdge(nodeId + '-to-reduce', nodeId, 'reduce', digests.data)), + successEdge('reduce-to-synthesize', 'reduce', 'synthesize', digests.data), + successEdge('synthesize-to-arbiter', 'synthesize', 'arbiter', digests.data), + ]; + const nodeCount = nodes.length; + const graph = { + graph_version: '0.1', + kind: 'operation_graph_spec', + graph_id: graphId, + operation_spec_digest: options.operationSpecDigest || identityDigest, + entry_node_ids: ['scope'], + nodes, + edges, + joins: [{ node_id: 'reduce', policy: 'all', threshold: null, missing_input_status: 'blocked' }], + limits: { + max_transitions: nodeCount * 4, + max_parallel: angleIds.length, + max_total_attempts: nodeCount * 2, + }, + created_at: createdAt, + }; + assertValidOperationGraph(graph); + return Object.freeze(graph); +} + +function createResearchFleetOperationSpec(angleIds, options = {}) { + validateAngles(angleIds); + const createdAt = options.now || new Date().toISOString(); + if (!canonicalTimestamp(createdAt)) throw new Error('now must be a canonical ISO timestamp'); + const identityDigest = sha256Digest({ + template: 'research-fleet', + template_version: RESEARCH_GRAPH_TEMPLATE_VERSION, + angle_ids: angleIds, + }); + const suffix = identityDigest.slice('sha256:'.length, 'sha256:'.length + 12); + return Object.freeze({ + protocol_version: '0.1', + kind: 'operation_spec', + operation_id: 'research-operation-' + suffix, + title: 'Research graph cohort', + objective_digest: identityDigest, + step_ids: Object.freeze([ + 'scope', + ...angleIds.map((angleId) => 'scout-' + angleId), + 'reduce', + 'synthesize', + 'arbiter', + ]), + policy_digests: Object.freeze([sha256Digest({ + policy: 'research-graph-verification', + version: RESEARCH_GRAPH_TEMPLATE_VERSION, + })]), + created_at: createdAt, + }); +} + +function createResearchFleetBundle(angleIds, options = {}) { + const operation = createResearchFleetOperationSpec(angleIds, options); + const graph = createResearchFleetGraph(angleIds, { + ...options, + operationSpecDigest: sha256Digest(operation), + }); + return Object.freeze({ operation, graph }); +} + +module.exports = Object.freeze({ + RESEARCH_GRAPH_TEMPLATE_VERSION, + createResearchFleetGraph, + createResearchFleetBundle, + createResearchFleetOperationSpec, +}); diff --git a/docs/APP_CONTRACTS.md b/docs/APP_CONTRACTS.md new file mode 100644 index 00000000..bfbef314 --- /dev/null +++ b/docs/APP_CONTRACTS.md @@ -0,0 +1,52 @@ +# Citadel App Contracts v1 + +Citadel App contracts are the dependency-free boundary between the open-core Citadel engine, the local desktop supervisor, and Citadel-Studio. The browser-safe entrypoint is `@citadel/contracts/app`; desktop consumers must not import Citadel `core/` files or the root repository through a sibling `file:` dependency. + +## Version axes + +- `app_contract_version: 1` versions app entities and supervisor projections. +- Operations Protocol remains `protocol_version: "0.1"` for runtime-neutral execution evidence. +- The future supervisor transport uses its own API version and capability handshake. + +Changing one axis does not silently upgrade another. + +## Entities + +- `agent_profile`: persistent named identity and policy references. Instructions cross the public envelope by digest; local content remains in the supervisor-owned profile store. +- `agent_instance`: one supervised execution created from a profile and bound to an operation and workspace. It records the immutable profile revision and snapshot digest used at launch. +- `operation_definition`: editable app-level operation intent that projects deterministically into an Operations Protocol 0.1 `operation_spec`. +- `team`: reusable membership and coordination, handoff, and resource policies. +- `workspace_ref`: an opaque workspace identity. Renderer commands use the ID; only the trusted supervisor owns the ID-to-path mapping. +- `handoff`: durable collaboration transfer carrying outcome, decisions, blockers, artifacts, verification, and next-action digests. +- `supervisor_event`: ordered, replayable lifecycle projection with opaque payload digests. + +Runs, attempts, intents, evidence, and receipts continue using Operations Protocol rather than being duplicated here. + +Mutable app entities carry monotonically increasing revisions. Commands may use +those revisions for optimistic concurrency; lifecycle transition helpers bump +the revision automatically. + +## Security and privacy boundaries + +- Unknown kinds, fields, and versions fail closed. +- Public records contain opaque identifiers, references, and digests rather than raw repository paths, prompts, credentials, terminal output, or source. +- Process IDs, handles, environment variables, credentials, and native paths remain supervisor-private. +- Renderer code may import only `@citadel/contracts/app` and the typed client. It never receives arbitrary shell or filesystem capabilities. +- A Markdown `HANDOFF` may be ingested as legacy local payload, but it cannot manufacture accepted state or passed evidence. + +## Lifecycle rules + +Agent instances move through the explicit transition graph exported as `INSTANCE_TRANSITIONS`. Terminal states are immutable. Handoffs begin `pending` and resolve once to `accepted`, `rejected`, or `blocked`. Transition functions are non-mutating and revalidate the resulting record. + +`projectOperationDefinition()` is the only v1 app-to-execution projection. Its +output is validated by the canonical Operations Protocol validator in tests. + +## Development + +```text +node scripts/generate-app-contract-schema.js --write +node scripts/generate-app-contract-schema.js --check +node scripts/test-app-contracts.js +``` + +The generated JSON Schema required-field lists must exactly match the executable validator allowlists. diff --git a/docs/OPERATION_GRAPH.md b/docs/OPERATION_GRAPH.md new file mode 100644 index 00000000..c263cecf --- /dev/null +++ b/docs/OPERATION_GRAPH.md @@ -0,0 +1,94 @@ +# Operation Graph v0.1 + +Operation Graph is Citadel's experimental orchestration layer above Operations Protocol v0.1. It turns a reusable operation into a bounded map of typed work, routing, joins, and verification gates. It does not replace the protocol: every graph references an immutable `OperationSpec` digest, and every graph node maps to a protocol `step_id`. + +## Why this exists + +A loop lets an executor choose the next action while pursuing one goal. A graph lets Citadel own the repeatable control flow around those loops: what may run in parallel, what must join, which result unlocks the next node, and where independent verification is mandatory. + +The graph is valuable when the route is part of the product contract. One-off exploratory work should usually remain a bounded loop. + +## Contract + +`core/operations/graph-contract.js` validates an exact-field, privacy-safe envelope with: + +- typed nodes: `agent`, `deterministic`, `gate`, or `human` +- typed edges: `success`, `failure`, `conditional`, or `loop` +- explicit join policy: `all`, `quorum`, or `first_success` +- executor profile, scope digest, input/output schema digests, timeout, attempt, and visit limits per node +- verifier policy and required evidence types per node +- graph-wide transition, parallelism, and total-attempt limits +- reachability, reference integrity, explicit-cycle, and bounded-loop checks + +The JSON Schema is `packages/contracts/schemas/operation-graph-v0.1.json`. Graph content remains digest-addressed; prompts, customer data, and raw artifacts do not belong in the control-plane contract. + +## Scheduler + +`core/operations/graph-scheduler.js` is a deterministic, side-effect-free scheduler. Given a validated graph and a serializable state snapshot, it: + +1. classifies incoming edges as satisfied, waiting, or inactive +2. evaluates join thresholds +3. returns ready nodes in declared graph order, capped by `max_parallel` +4. enforces existing Operations Protocol status transitions +5. bounds retries, visits, total attempts, and transitions +6. validates state integrity before resume + +Blocked join results are reported, not silently converted into success. Callers decide when to persist a `blocked`, `failed`, or `unknown` protocol status and attach evidence. + +### Bounded loop semantics + +Scheduler v0.1 executes explicit loop edges only when the edge closes a non-loop path and every node in the cycle region permits another visit. A loop decision is bound to the source node's current visit. Selecting it resets only nodes that are both reachable from the target and able to reach the source; unrelated upstream and downstream work stays terminal. + +Historical traversal tokens are immutable. The new visit receives a token whose parents include the selected loop source, stale decisions cannot trigger another reset, and `max_visits`, `max_attempts`, `max_transitions`, and `max_total_attempts` remain hard stops. Node-local retry still uses `running -> blocked -> running` without creating a graph visit. + +## Durable graph runs + +`core/operations/graph-run.js` wraps scheduler state in a typed graph run and +assigns deterministic traversal tokens to ready nodes. Tokens record only node IDs, +visit numbers, satisfied inbound edges, and parent token IDs. + +`core/operations/graph-journal.js` persists control-plane snapshots in a separate +append-only hash chain. `core/operations/graph-effects.js` binds each traversal token +to one deterministic Operation Protocol attempt and idempotency key in the effect +journal. Recovery distinguishes safe execute, safe retry, completed-effect skip, +payload mismatch, corrupt proof, and ambiguous nonrepeatable effects. + +Nonrepeatable ambiguity can move again only after a reviewer records either completed +evidence or an evidenced `retryable` resolution. A completed effect checkpoint can +advance a still-running graph node without repeating the effect. Terminal graphs can +emit an unsigned, privacy-safe Operation Protocol proof bundle through the existing +receipt contract; callers may sign that receipt with the existing Ed25519 API. + +`scripts/operation-graph-runner.js` owns graph initialization and route decisions. +`scripts/operation-graph-effects.js` owns effect `start`, `complete`, `resolve`, +integrated `status`, and terminal `receipt`. Both CLIs contain all paths under the +declared project root. Standard Research mode creates no graph state. + + +## First proof + +`core/operations/fixtures/research-fleet.graph.json` models a real Research Fleet: + +```text +scope + -> scout-claims ----\ + -> scout-taxonomy --- all barrier -> reduce -> synthesize -> arbiter + -> scout-fit --------/ +``` + +The golden trace and local Research trial prove deterministic fan-out, barrier release, +bounded cycle reset, cross-journal crash reconciliation, nonrepeatable-effect review, +terminal evidence coverage, and receipt generation. Run them with: + +```powershell +node scripts/test-operation-graph.js +node scripts/test-operation-graph-run.js +node scripts/test-operation-graph-effects.js +``` + +## Promotion gates + +Operation Graph is still explicit opt-in. Runtime adapters should advertise graph and +effect-recovery capabilities before compilation targets them. Promote Research graph +mode only after real cohorts outperform the Markdown-only flow. Mission Control should +visualize these authoritative journals rather than inventing a second execution state. diff --git a/docs/SUPERVISOR_API.md b/docs/SUPERVISOR_API.md new file mode 100644 index 00000000..90ba9faf --- /dev/null +++ b/docs/SUPERVISOR_API.md @@ -0,0 +1,45 @@ +# Citadel Supervisor API v1 + +The Supervisor API is the local trust boundary between the sandboxed Citadel +App renderer and the singleton Electron main-process supervisor. Its +browser-safe client entrypoint is `@citadel/client/supervisor`. + +## Transport contract + +- Every request declares `apiVersion`, a unique request ID, kind, allowlisted + method, bounded JSON payload, and timestamp. +- Commands additionally carry an idempotency key and an optional expected + entity revision. +- Every response echoes the request ID and carries either a validated result or + a stable error code. Revision conflicts are explicit and retryable. +- Events are globally ordered by sequence, revisioned by subject, replayable, + and validated before a renderer listener receives them. + +The package includes a transport-agnostic client, dispatcher, and bounded event +log. Electron IPC, tests, or another local transport can provide the small +`request()` and `subscribe()` adapter without changing application code. + +## Native boundary + +Renderer payloads cannot contain raw paths, working directories, shell +commands, environment blocks, passwords, API keys, or tokens. Workspaces are +chosen through a native picker and represented to the renderer by opaque IDs. +Process handles, termination mechanics, worktree paths, credentials, and +ID-to-path mappings stay in the supervisor. + +The allowlisted method catalog covers workspaces, profiles, teams, operations, +instances, handoffs, event replay, handshake, and shutdown. Unknown methods and +fields fail closed. + +## Reliability + +- Idempotency outcomes are cached so a renderer retry cannot launch or mutate + the same command twice. +- Expected revisions prevent stale windows from overwriting newer state. +- Response IDs must match their request. +- Event subscribers receive only validated projections and can reconnect using + `events.replay` plus an `afterSequence` cursor. + +The in-package event log is a protocol primitive, not the final durable store. +The desktop supervisor must append events and lifecycle state to its persistent +store and Citadel journals before acknowledging durable mutations. diff --git a/packages/client/README.md b/packages/client/README.md index ff0a84f4..fcbf12b4 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -11,6 +11,8 @@ Initial scope: - local event normalization - sink interfaces for local-only and cloud delivery - transport-agnostic event submission primitives +- a browser-safe `@citadel/client/supervisor` entrypoint for versioned desktop + supervisor queries, idempotent commands, event replay, and subscriptions ## Source Inputs @@ -23,3 +25,8 @@ Initial implementation work should draw from: ## Boundary Rule This package should not contain hosted logic. It should provide a clean client-side integration surface that works with local-only sinks and future Cloud sinks. + +The supervisor entrypoint is deliberately self-contained. It rejects unknown +methods, oversized or deeply nested payloads, raw filesystem and shell fields, +environment blocks, and common secret-bearing fields before they cross preload +IPC. The legacy root entrypoint remains available for telemetry sinks. diff --git a/packages/client/package.json b/packages/client/package.json index 187b49d9..8353fd55 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -2,5 +2,9 @@ "name": "@citadel/client", "version": "0.1.0", "private": true, - "main": "./index.js" + "main": "./index.js", + "exports": { + ".": "./index.js", + "./supervisor": "./supervisor/index.js" + } } diff --git a/packages/client/supervisor/client.js b/packages/client/supervisor/client.js new file mode 100644 index 00000000..08437a98 --- /dev/null +++ b/packages/client/supervisor/client.js @@ -0,0 +1,58 @@ +'use strict'; + +const { REQUEST_KINDS, SUPERVISOR_API_VERSION } = require('./constants'); +const { assertValid, validateSupervisorEvent, validateSupervisorRequest, validateSupervisorResponse } = require('./validation'); + +let fallbackSequence = 0; + +function defaultId(prefix) { + const crypto = globalThis.crypto; + if (crypto && typeof crypto.randomUUID === 'function') return `${prefix}-${crypto.randomUUID().toLowerCase()}`; + fallbackSequence += 1; + return `${prefix}-${Date.now().toString(36)}-${fallbackSequence.toString(36)}`; +} + +function createSupervisorClient(transport, options = {}) { + if (!transport || typeof transport.request !== 'function') throw new TypeError('createSupervisorClient requires a transport.request function'); + const now = options.now || (() => new Date().toISOString()); + const createId = options.createId || defaultId; + + async function send(kind, method, payload = {}, requestOptions = {}) { + const request = { + apiVersion: SUPERVISOR_API_VERSION, + requestId: requestOptions.requestId || createId('request'), + kind, + method, + payload, + sentAt: now(), + }; + if (kind === REQUEST_KINDS.COMMAND) { + request.idempotencyKey = requestOptions.idempotencyKey || createId('command'); + request.expectedRevision = requestOptions.expectedRevision ?? null; + } + assertValid(validateSupervisorRequest(request), 'Invalid supervisor request'); + const response = await transport.request(Object.freeze(request)); + assertValid(validateSupervisorResponse(response), 'Invalid supervisor response'); + if (response.requestId !== request.requestId) throw new Error('Supervisor response requestId does not match the request'); + return response; + } + + function subscribe(listener, subscriptionOptions = {}) { + if (typeof transport.subscribe !== 'function') throw new TypeError('Supervisor transport does not support subscriptions'); + if (typeof listener !== 'function') throw new TypeError('subscribe requires a listener function'); + return transport.subscribe((event) => { + assertValid(validateSupervisorEvent(event), 'Invalid supervisor event'); + listener(event); + }, { afterSequence: subscriptionOptions.afterSequence ?? 0 }); + } + + return Object.freeze({ + handshake: () => send(REQUEST_KINDS.QUERY, 'system.handshake', {}), + query: (method, payload, requestOptions) => send(REQUEST_KINDS.QUERY, method, payload, requestOptions), + command: (method, payload, requestOptions) => send(REQUEST_KINDS.COMMAND, method, payload, requestOptions), + replay: (afterSequence = 0, limit = 500) => send(REQUEST_KINDS.QUERY, 'events.replay', { afterSequence, limit }), + subscribe, + }); +} + +module.exports = Object.freeze({ createSupervisorClient }); diff --git a/packages/client/supervisor/constants.js b/packages/client/supervisor/constants.js new file mode 100644 index 00000000..81099f36 --- /dev/null +++ b/packages/client/supervisor/constants.js @@ -0,0 +1,39 @@ +'use strict'; + +const SUPERVISOR_API_VERSION = 1; +const SUPPORTED_SUPERVISOR_API_VERSIONS = Object.freeze([SUPERVISOR_API_VERSION]); +const MAX_SUPERVISOR_PAYLOAD_BYTES = 64 * 1024; +const MAX_SUPERVISOR_PAYLOAD_DEPTH = 8; +const REQUEST_KINDS = Object.freeze({ QUERY: 'query', COMMAND: 'command' }); + +const QUERY_METHODS = Object.freeze([ + 'system.handshake', 'workspaces.list', 'workspaces.get', 'profiles.list', + 'profiles.get', 'teams.list', 'teams.get', 'operations.list', + 'operations.get', 'instances.list', 'instances.get', 'handoffs.list', + 'handoffs.get', 'events.replay', +]); + +const COMMAND_METHODS = Object.freeze([ + 'workspaces.choose', 'workspaces.openRecent', 'profiles.create', + 'profiles.update', 'profiles.archive', 'teams.create', 'teams.update', + 'teams.archive', 'operations.create', 'operations.update', + 'operations.launch', 'instances.pause', 'instances.resume', + 'instances.cancel', 'handoffs.create', 'handoffs.accept', + 'handoffs.reject', 'handoffs.block', 'system.shutdown', +]); + +const FORBIDDEN_PAYLOAD_KEYS = Object.freeze(new Set([ + 'path', 'filepath', 'workspacepath', 'directory', 'cwd', 'root', 'command', + 'shell', 'env', 'environment', 'secret', 'token', 'apikey', 'password', +])); + +module.exports = Object.freeze({ + COMMAND_METHODS, + FORBIDDEN_PAYLOAD_KEYS, + MAX_SUPERVISOR_PAYLOAD_BYTES, + MAX_SUPERVISOR_PAYLOAD_DEPTH, + QUERY_METHODS, + REQUEST_KINDS, + SUPERVISOR_API_VERSION, + SUPPORTED_SUPERVISOR_API_VERSIONS, +}); diff --git a/packages/client/supervisor/dispatcher.js b/packages/client/supervisor/dispatcher.js new file mode 100644 index 00000000..b501dd8e --- /dev/null +++ b/packages/client/supervisor/dispatcher.js @@ -0,0 +1,102 @@ +'use strict'; + +const { SUPERVISOR_API_VERSION } = require('./constants'); +const { + assertValid, isPlainObject, validateSupervisorRequest, validateSupervisorResponse, +} = require('./validation'); + +class SupervisorError extends Error { + constructor(code, message, retryable = false, revision = null) { + super(message); + this.name = 'SupervisorError'; + this.code = code; + this.retryable = retryable; + this.revision = revision; + } +} + +function createSupervisorDispatcher(options = {}) { + const handlers = options.handlers || {}; + if (!isPlainObject(handlers)) throw new TypeError('dispatcher handlers must be a plain object'); + const now = options.now || (() => new Date().toISOString()); + const getRevision = options.getRevision || null; + const cacheLimit = options.idempotencyCacheLimit || 1000; + const outcomes = new Map(); + + function response(requestId, outcome) { + const envelope = outcome.ok + ? { + apiVersion: SUPERVISOR_API_VERSION, + requestId, + ok: true, + result: outcome.result, + revision: outcome.revision, + completedAt: now(), + } + : { + apiVersion: SUPERVISOR_API_VERSION, + requestId, + ok: false, + error: outcome.error, + revision: outcome.revision, + completedAt: now(), + }; + assertValid(validateSupervisorResponse(envelope), 'Invalid supervisor response'); + return Object.freeze(envelope); + } + + function remember(key, outcome) { + outcomes.set(key, Object.freeze(outcome)); + if (outcomes.size > cacheLimit) outcomes.delete(outcomes.keys().next().value); + } + + async function dispatch(request, context = {}) { + assertValid(validateSupervisorRequest(request), 'Invalid supervisor request'); + if (request.kind === 'command' && outcomes.has(request.idempotencyKey)) { + return response(request.requestId, outcomes.get(request.idempotencyKey)); + } + + let outcome; + try { + const handler = handlers[request.method]; + if (typeof handler !== 'function') { + throw new SupervisorError('METHOD_UNAVAILABLE', `No supervisor handler is registered for ${request.method}`); + } + if (request.kind === 'command' && request.expectedRevision !== null && getRevision) { + const actual = await getRevision(request, context); + if (actual !== request.expectedRevision) { + throw new SupervisorError( + 'REVISION_CONFLICT', + `Expected revision ${request.expectedRevision}, received ${actual}`, + true, + Number.isSafeInteger(actual) && actual >= 0 ? actual : null, + ); + } + } + const handled = await handler(request.payload, Object.freeze({ request, context })); + if (!isPlainObject(handled) || !isPlainObject(handled.result) + || (handled.revision !== null && (!Number.isSafeInteger(handled.revision) || handled.revision < 0))) { + throw new SupervisorError('INVALID_HANDLER_RESULT', 'Supervisor handler returned an invalid result envelope'); + } + outcome = Object.freeze({ ok: true, result: handled.result, revision: handled.revision }); + } catch (error) { + const known = error instanceof SupervisorError; + outcome = Object.freeze({ + ok: false, + error: Object.freeze({ + code: known ? error.code : 'INTERNAL_ERROR', + message: known ? error.message : 'The supervisor could not complete the request', + retryable: known ? error.retryable : false, + }), + revision: known ? error.revision : null, + }); + } + + if (request.kind === 'command') remember(request.idempotencyKey, outcome); + return response(request.requestId, outcome); + } + + return Object.freeze({ dispatch }); +} + +module.exports = Object.freeze({ SupervisorError, createSupervisorDispatcher }); diff --git a/packages/client/supervisor/events.js b/packages/client/supervisor/events.js new file mode 100644 index 00000000..aef44107 --- /dev/null +++ b/packages/client/supervisor/events.js @@ -0,0 +1,52 @@ +'use strict'; + +const { SUPERVISOR_API_VERSION } = require('./constants'); +const { assertValid, isPlainObject, validateSupervisorEvent } = require('./validation'); + +function createSupervisorEventLog(options = {}) { + const capacity = options.capacity || 10000; + if (!Number.isSafeInteger(capacity) || capacity < 1) throw new TypeError('event log capacity must be a positive safe integer'); + const now = options.now || (() => new Date().toISOString()); + const createId = options.createId || ((sequence) => `event-${sequence}`); + const events = []; + const listeners = new Set(); + let sequence = options.initialSequence || 0; + + function append(input) { + if (!isPlainObject(input)) throw new TypeError('event input must be a plain object'); + sequence += 1; + const event = { + apiVersion: SUPERVISOR_API_VERSION, + sequence, + eventId: input.eventId || createId(sequence), + type: input.type, + subjectType: input.subjectType, + subjectId: input.subjectId, + revision: input.revision, + payload: input.payload || {}, + occurredAt: input.occurredAt || now(), + }; + assertValid(validateSupervisorEvent(event), 'Invalid supervisor event'); + const frozen = Object.freeze(event); + events.push(frozen); + if (events.length > capacity) events.shift(); + for (const listener of listeners) listener(frozen); + return frozen; + } + + function replay(afterSequence = 0, limit = 500) { + if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) throw new TypeError('afterSequence must be a non-negative safe integer'); + if (!Number.isSafeInteger(limit) || limit < 1 || limit > 5000) throw new TypeError('limit must be between 1 and 5000'); + return Object.freeze(events.filter((event) => event.sequence > afterSequence).slice(0, limit)); + } + + function subscribe(listener) { + if (typeof listener !== 'function') throw new TypeError('event subscription requires a listener'); + listeners.add(listener); + return () => listeners.delete(listener); + } + + return Object.freeze({ append, replay, subscribe, get sequence() { return sequence; } }); +} + +module.exports = Object.freeze({ createSupervisorEventLog }); diff --git a/packages/client/supervisor/index.js b/packages/client/supervisor/index.js new file mode 100644 index 00000000..9e37fe56 --- /dev/null +++ b/packages/client/supervisor/index.js @@ -0,0 +1,9 @@ +'use strict'; + +module.exports = Object.freeze({ + ...require('./constants'), + ...require('./validation'), + ...require('./client'), + ...require('./dispatcher'), + ...require('./events'), +}); diff --git a/packages/client/supervisor/validation.js b/packages/client/supervisor/validation.js new file mode 100644 index 00000000..73ffa107 --- /dev/null +++ b/packages/client/supervisor/validation.js @@ -0,0 +1,165 @@ +'use strict'; + +const { + COMMAND_METHODS, FORBIDDEN_PAYLOAD_KEYS, MAX_SUPERVISOR_PAYLOAD_BYTES, + MAX_SUPERVISOR_PAYLOAD_DEPTH, QUERY_METHODS, REQUEST_KINDS, + SUPPORTED_SUPERVISOR_API_VERSIONS, +} = require('./constants'); + +const ID_PATTERN = /^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$/; +const TYPE_PATTERN = /^[a-z][a-z0-9]*(?:[._:-][a-z0-9]+)*$/; + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + && Object.getPrototypeOf(value) === Object.prototype; +} + +function exactFields(value, expected, label, errors) { + if (!isPlainObject(value)) { + errors.push(`${label} must be a plain object`); + return; + } + const actual = Object.keys(value).sort(); + const wanted = [...expected].sort(); + const extras = actual.filter((key) => !wanted.includes(key)); + const missing = wanted.filter((key) => !actual.includes(key)); + if (extras.length) errors.push(`${label} has unknown fields: ${extras.join(', ')}`); + if (missing.length) errors.push(`${label} is missing fields: ${missing.join(', ')}`); +} + +function validateTimestamp(value, field, errors) { + if (typeof value !== 'string' || !value || Number.isNaN(Date.parse(value))) { + errors.push(`${field} must be an ISO-8601 timestamp`); + } +} + +function validateId(value, field, errors) { + if (typeof value !== 'string' || !ID_PATTERN.test(value)) { + errors.push(`${field} must be a stable lowercase identifier`); + } +} + +function validateJsonValue(value, field, errors, depth = 0, seen = new Set()) { + if (depth > MAX_SUPERVISOR_PAYLOAD_DEPTH) { + errors.push(`${field} exceeds maximum nesting depth`); + return; + } + if (value === null || typeof value === 'string' || typeof value === 'boolean') return; + if (typeof value === 'number') { + if (!Number.isFinite(value)) errors.push(`${field} must contain finite numbers`); + return; + } + if (typeof value !== 'object') { + errors.push(`${field} must contain plain JSON values`); + return; + } + if (seen.has(value)) { + errors.push(`${field} must not contain cycles`); + return; + } + seen.add(value); + if (Array.isArray(value)) { + value.forEach((item, index) => validateJsonValue(item, `${field}[${index}]`, errors, depth + 1, seen)); + } else if (isPlainObject(value)) { + for (const [key, item] of Object.entries(value)) { + if (FORBIDDEN_PAYLOAD_KEYS.has(key.toLowerCase())) { + errors.push(`${field}.${key} is a forbidden private or native field`); + } + validateJsonValue(item, `${field}.${key}`, errors, depth + 1, seen); + } + } else { + errors.push(`${field} must contain only arrays and plain objects`); + } + seen.delete(value); +} + +function validatePayload(payload, field, errors) { + if (!isPlainObject(payload)) { + errors.push(`${field} must be a plain object`); + return; + } + validateJsonValue(payload, field, errors); + try { + if (new TextEncoder().encode(JSON.stringify(payload)).byteLength > MAX_SUPERVISOR_PAYLOAD_BYTES) { + errors.push(`${field} exceeds ${MAX_SUPERVISOR_PAYLOAD_BYTES} bytes`); + } + } catch { + errors.push(`${field} must be serializable JSON`); + } +} + +function result(errors) { + return Object.freeze({ ok: errors.length === 0, errors: Object.freeze(errors) }); +} + +function validateSupervisorRequest(value) { + const errors = []; + if (!isPlainObject(value)) return result(['request must be a plain object']); + const common = ['apiVersion', 'requestId', 'kind', 'method', 'payload', 'sentAt']; + const fields = value.kind === REQUEST_KINDS.COMMAND + ? [...common, 'idempotencyKey', 'expectedRevision'] + : common; + exactFields(value, fields, 'request', errors); + if (!SUPPORTED_SUPERVISOR_API_VERSIONS.includes(value.apiVersion)) errors.push('request.apiVersion is unsupported'); + validateId(value.requestId, 'request.requestId', errors); + validateTimestamp(value.sentAt, 'request.sentAt', errors); + if (!Object.values(REQUEST_KINDS).includes(value.kind)) errors.push('request.kind is unsupported'); + const methods = value.kind === REQUEST_KINDS.COMMAND ? COMMAND_METHODS : QUERY_METHODS; + if (!methods.includes(value.method)) errors.push(`request.method is not allowed for ${value.kind || 'unknown'} requests`); + validatePayload(value.payload, 'request.payload', errors); + if (value.kind === REQUEST_KINDS.COMMAND) { + validateId(value.idempotencyKey, 'request.idempotencyKey', errors); + if (value.expectedRevision !== null + && (!Number.isSafeInteger(value.expectedRevision) || value.expectedRevision < 0)) { + errors.push('request.expectedRevision must be null or a non-negative safe integer'); + } + } + return result(errors); +} + +function validateSupervisorResponse(value) { + const errors = []; + if (!isPlainObject(value)) return result(['response must be a plain object']); + const fields = value.ok + ? ['apiVersion', 'requestId', 'ok', 'result', 'revision', 'completedAt'] + : ['apiVersion', 'requestId', 'ok', 'error', 'revision', 'completedAt']; + exactFields(value, fields, 'response', errors); + if (!SUPPORTED_SUPERVISOR_API_VERSIONS.includes(value.apiVersion)) errors.push('response.apiVersion is unsupported'); + validateId(value.requestId, 'response.requestId', errors); + validateTimestamp(value.completedAt, 'response.completedAt', errors); + if (typeof value.ok !== 'boolean') errors.push('response.ok must be boolean'); + if (value.revision !== null && (!Number.isSafeInteger(value.revision) || value.revision < 0)) errors.push('response.revision must be null or a non-negative safe integer'); + if (value.ok) { + validatePayload(value.result, 'response.result', errors); + } else { + exactFields(value.error, ['code', 'message', 'retryable'], 'response.error', errors); + if (isPlainObject(value.error)) { + if (typeof value.error.code !== 'string' || !/^[A-Z][A-Z0-9_]{0,63}$/.test(value.error.code)) errors.push('response.error.code is invalid'); + if (typeof value.error.message !== 'string' || !value.error.message) errors.push('response.error.message is required'); + if (typeof value.error.retryable !== 'boolean') errors.push('response.error.retryable must be boolean'); + } + } + return result(errors); +} + +function validateSupervisorEvent(value) { + const errors = []; + if (!isPlainObject(value)) return result(['event must be a plain object']); + exactFields(value, ['apiVersion', 'sequence', 'eventId', 'type', 'subjectType', 'subjectId', 'revision', 'payload', 'occurredAt'], 'event', errors); + if (!SUPPORTED_SUPERVISOR_API_VERSIONS.includes(value.apiVersion)) errors.push('event.apiVersion is unsupported'); + if (!Number.isSafeInteger(value.sequence) || value.sequence < 1) errors.push('event.sequence must be a positive safe integer'); + validateId(value.eventId, 'event.eventId', errors); + if (typeof value.type !== 'string' || !TYPE_PATTERN.test(value.type)) errors.push('event.type is invalid'); + if (typeof value.subjectType !== 'string' || !TYPE_PATTERN.test(value.subjectType)) errors.push('event.subjectType is invalid'); + validateId(value.subjectId, 'event.subjectId', errors); + if (!Number.isSafeInteger(value.revision) || value.revision < 0) errors.push('event.revision must be a non-negative safe integer'); + validatePayload(value.payload, 'event.payload', errors); + validateTimestamp(value.occurredAt, 'event.occurredAt', errors); + return result(errors); +} + +function assertValid(validation, label) { + if (!validation.ok) throw new TypeError(`${label}: ${validation.errors.join('; ')}`); +} + +module.exports = Object.freeze({ assertValid, isPlainObject, validateSupervisorEvent, validateSupervisorRequest, validateSupervisorResponse }); diff --git a/packages/contracts/app/constants.js b/packages/contracts/app/constants.js new file mode 100644 index 00000000..b1d275ed --- /dev/null +++ b/packages/contracts/app/constants.js @@ -0,0 +1,113 @@ +'use strict'; + +const APP_CONTRACT_VERSION = 1; +const SUPPORTED_APP_CONTRACT_VERSIONS = Object.freeze([APP_CONTRACT_VERSION]); + +const APP_CONTRACT_KINDS = Object.freeze({ + AGENT_PROFILE: 'agent_profile', + AGENT_INSTANCE: 'agent_instance', + OPERATION_DEFINITION: 'operation_definition', + TEAM: 'team', + WORKSPACE_REF: 'workspace_ref', + HANDOFF: 'handoff', + SUPERVISOR_EVENT: 'supervisor_event', +}); + +const AGENT_INSTANCE_STATUSES = Object.freeze([ + 'queued', + 'starting', + 'running', + 'pause-requested', + 'paused', + 'blocked', + 'completed', + 'failed', + 'cancelled', + 'lost', +]); + +const TERMINAL_AGENT_INSTANCE_STATUSES = Object.freeze([ + 'completed', + 'failed', + 'cancelled', + 'lost', +]); + +const HANDOFF_STATUSES = Object.freeze([ + 'pending', + 'accepted', + 'rejected', + 'blocked', +]); + +const SUPERVISOR_EVENT_TYPES = Object.freeze([ + 'instance-queued', + 'instance-started', + 'instance-output', + 'instance-paused', + 'instance-resumed', + 'instance-blocked', + 'instance-completed', + 'instance-failed', + 'instance-cancelled', + 'instance-lost', + 'handoff-created', + 'handoff-accepted', + 'handoff-rejected', + 'approval-required', + 'approval-resolved', + 'artifact-recorded', + 'recovery-started', + 'recovery-completed', +]); + +const FIELD_ALLOWLISTS = Object.freeze({ + [APP_CONTRACT_KINDS.AGENT_PROFILE]: Object.freeze([ + 'app_contract_version', 'kind', 'revision', 'profile_id', 'name', 'role', 'runtime_id', + 'model', 'instructions_digest', 'skill_ids', 'memory_policy_id', + 'permission_policy_id', 'resource_policy_id', 'created_at', 'updated_at', + ]), + [APP_CONTRACT_KINDS.AGENT_INSTANCE]: Object.freeze([ + 'app_contract_version', 'kind', 'revision', 'instance_id', 'profile_id', + 'profile_revision', 'profile_snapshot_digest', 'operation_id', + 'workspace_id', 'supervisor_id', 'status', 'process_ref', 'terminal_ref', + 'branch_ref', 'worktree_ref', 'budget_digest', 'started_at', 'updated_at', + 'completed_at', 'exit_code', 'failure_code', + ]), + [APP_CONTRACT_KINDS.TEAM]: Object.freeze([ + 'app_contract_version', 'kind', 'revision', 'team_id', 'name', 'member_profile_ids', + 'coordination_policy_id', 'handoff_policy_id', 'resource_policy_id', + 'created_at', 'updated_at', + ]), + [APP_CONTRACT_KINDS.WORKSPACE_REF]: Object.freeze([ + 'app_contract_version', 'kind', 'revision', 'workspace_id', 'name', 'root_digest', + 'instruction_digests', 'runtime_ids', 'editable', 'last_opened_at', + ]), + [APP_CONTRACT_KINDS.HANDOFF]: Object.freeze([ + 'app_contract_version', 'kind', 'revision', 'handoff_id', 'operation_id', + 'from_instance_id', 'to_profile_id', 'to_instance_id', 'status', + 'outcome_digest', 'decision_digests', 'blocker_codes', 'artifact_digests', + 'verification_digests', 'next_action_digest', 'created_at', 'resolved_at', + ]), + [APP_CONTRACT_KINDS.SUPERVISOR_EVENT]: Object.freeze([ + 'app_contract_version', 'kind', 'event_id', 'supervisor_id', 'operation_id', + 'instance_id', 'sequence', 'subject_revision', 'event_type', 'status', 'payload_digest', + 'recorded_at', + ]), + [APP_CONTRACT_KINDS.OPERATION_DEFINITION]: Object.freeze([ + 'app_contract_version', 'kind', 'revision', 'operation_id', 'workspace_id', + 'team_id', 'lead_profile_id', 'title', 'objective_digest', 'step_ids', + 'policy_digests', 'created_at', 'updated_at', + ]), +}); + +module.exports = Object.freeze({ + AGENT_INSTANCE_STATUSES, + APP_CONTRACT_KINDS, + APP_CONTRACT_VERSION, + FIELD_ALLOWLISTS, + HANDOFF_STATUSES, + SUPERVISOR_EVENT_TYPES, + SUPPORTED_APP_CONTRACT_VERSIONS, + TERMINAL_AGENT_INSTANCE_STATUSES, +}); diff --git a/packages/contracts/app/index.js b/packages/contracts/app/index.js new file mode 100644 index 00000000..509e7b69 --- /dev/null +++ b/packages/contracts/app/index.js @@ -0,0 +1,8 @@ +'use strict'; + +module.exports = Object.freeze({ + ...require('./constants'), + ...require('./validation'), + ...require('./transitions'), + ...require('./projections'), +}); diff --git a/packages/contracts/app/projections.js b/packages/contracts/app/projections.js new file mode 100644 index 00000000..2c5aa5b9 --- /dev/null +++ b/packages/contracts/app/projections.js @@ -0,0 +1,23 @@ +'use strict'; + +const { APP_CONTRACT_KINDS } = require('./constants'); +const { assertValidAppContract } = require('./validation'); + +function projectOperationDefinition(definition) { + assertValidAppContract(definition); + if (definition.kind !== APP_CONTRACT_KINDS.OPERATION_DEFINITION) { + throw new TypeError('projectOperationDefinition requires an operation_definition'); + } + return Object.freeze({ + protocol_version: '0.1', + kind: 'operation_spec', + operation_id: definition.operation_id, + title: definition.title, + objective_digest: definition.objective_digest, + step_ids: Object.freeze([...definition.step_ids]), + policy_digests: Object.freeze([...definition.policy_digests]), + created_at: definition.created_at, + }); +} + +module.exports = Object.freeze({ projectOperationDefinition }); diff --git a/packages/contracts/app/transitions.js b/packages/contracts/app/transitions.js new file mode 100644 index 00000000..d2d5e133 --- /dev/null +++ b/packages/contracts/app/transitions.js @@ -0,0 +1,58 @@ +'use strict'; + +const { assertValidAppContract } = require('./validation'); + +const INSTANCE_TRANSITIONS = Object.freeze({ + queued: Object.freeze(['starting', 'cancelled']), + starting: Object.freeze(['running', 'blocked', 'failed', 'cancelled', 'lost']), + running: Object.freeze(['pause-requested', 'blocked', 'completed', 'failed', 'cancelled', 'lost']), + 'pause-requested': Object.freeze(['paused', 'running', 'failed', 'cancelled', 'lost']), + paused: Object.freeze(['running', 'cancelled', 'lost']), + blocked: Object.freeze(['queued', 'running', 'failed', 'cancelled', 'lost']), + completed: Object.freeze([]), + failed: Object.freeze([]), + cancelled: Object.freeze([]), + lost: Object.freeze([]), +}); + +const HANDOFF_TRANSITIONS = Object.freeze({ + pending: Object.freeze(['accepted', 'rejected', 'blocked']), + accepted: Object.freeze([]), + rejected: Object.freeze([]), + blocked: Object.freeze([]), +}); + +function canTransition(graph, from, to) { + return Boolean(graph[from]?.includes(to)); +} + +function transitionAgentInstance(instance, status, patch = {}) { + assertValidAppContract(instance); + if (instance.kind !== 'agent_instance') throw new TypeError('transitionAgentInstance requires an agent_instance'); + if (!canTransition(INSTANCE_TRANSITIONS, instance.status, status)) { + throw new TypeError(`Invalid agent instance transition: ${instance.status} -> ${status}`); + } + const next = { ...instance, ...patch, status, revision: instance.revision + 1 }; + assertValidAppContract(next); + return Object.freeze(next); +} + +function transitionHandoff(handoff, status, patch = {}) { + assertValidAppContract(handoff); + if (handoff.kind !== 'handoff') throw new TypeError('transitionHandoff requires a handoff'); + if (!canTransition(HANDOFF_TRANSITIONS, handoff.status, status)) { + throw new TypeError(`Invalid handoff transition: ${handoff.status} -> ${status}`); + } + const next = { ...handoff, ...patch, status, revision: handoff.revision + 1 }; + assertValidAppContract(next); + return Object.freeze(next); +} + +module.exports = Object.freeze({ + HANDOFF_TRANSITIONS, + INSTANCE_TRANSITIONS, + canTransitionAgentInstance: (from, to) => canTransition(INSTANCE_TRANSITIONS, from, to), + canTransitionHandoff: (from, to) => canTransition(HANDOFF_TRANSITIONS, from, to), + transitionAgentInstance, + transitionHandoff, +}); diff --git a/packages/contracts/app/validation.js b/packages/contracts/app/validation.js new file mode 100644 index 00000000..9af2e33a --- /dev/null +++ b/packages/contracts/app/validation.js @@ -0,0 +1,303 @@ +'use strict'; + +const { + AGENT_INSTANCE_STATUSES, + APP_CONTRACT_KINDS, + APP_CONTRACT_VERSION, + FIELD_ALLOWLISTS, + HANDOFF_STATUSES, + SUPERVISOR_EVENT_TYPES, + TERMINAL_AGENT_INSTANCE_STATUSES, +} = require('./constants'); + +const ID_PATTERN = /^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$/; +const DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/; +const CODE_PATTERN = /^[A-Z][A-Z0-9_]{0,63}$/; +const REF_PATTERN = /^[a-z][a-z0-9]*(?:[-_.:/][a-z0-9]+)*$/; + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value) + && Object.getPrototypeOf(value) === Object.prototype; +} + +function exactFields(value, kind, errors) { + if (!isPlainObject(value)) { + errors.push(`${kind} must be a plain object`); + return false; + } + const expected = FIELD_ALLOWLISTS[kind]; + const actual = Object.keys(value).sort(); + if (!expected || JSON.stringify(actual) !== JSON.stringify([...expected].sort())) { + errors.push(`${kind} fields must exactly match the app contract allowlist`); + } + return true; +} + +function baseErrors(value, kind) { + const errors = []; + if (!exactFields(value, kind, errors)) return errors; + if (value.app_contract_version !== APP_CONTRACT_VERSION) { + errors.push(`app_contract_version must be ${APP_CONTRACT_VERSION}`); + } + if (value.kind !== kind) errors.push(`kind must be ${kind}`); + return errors; +} + +function checkId(value, label, errors, nullable = false) { + if (nullable && value === null) return; + if (typeof value !== 'string' || value.length > 128 || !ID_PATTERN.test(value)) { + errors.push(`${label} must be an opaque lowercase identifier`); + } +} + +function checkRef(value, label, errors, nullable = false) { + if (nullable && value === null) return; + if (typeof value !== 'string' || value.length > 256 || !REF_PATTERN.test(value)) { + errors.push(`${label} must be an opaque reference`); + } +} + +function checkDigest(value, label, errors, nullable = false) { + if (nullable && value === null) return; + if (typeof value !== 'string' || !DIGEST_PATTERN.test(value)) { + errors.push(`${label} must be a sha256 digest`); + } +} + +function checkTimestamp(value, label, errors, nullable = false) { + if (nullable && value === null) return; + if (typeof value !== 'string' || !Number.isFinite(Date.parse(value))) { + errors.push(`${label} must be an ISO timestamp`); + return; + } + if (new Date(value).toISOString() !== value) errors.push(`${label} must be a canonical ISO timestamp`); +} + +function checkRevision(value, label, errors) { + if (!Number.isSafeInteger(value) || value < 0) errors.push(`${label} must be a non-negative safe integer`); +} + +function checkLabel(value, label, errors, maximum = 160) { + if (typeof value !== 'string' || !value.trim() || value.length > maximum || /[\r\n]/.test(value)) { + errors.push(`${label} must be a bounded single-line label`); + } +} + +function checkUniqueArray(value, label, errors, checkEntry, options = {}) { + const minimum = options.minimum ?? 0; + const maximum = options.maximum ?? 256; + if (!Array.isArray(value) || value.length < minimum || value.length > maximum) { + errors.push(`${label} must contain ${minimum} to ${maximum} entries`); + return; + } + const seen = new Set(); + value.forEach((entry, index) => { + checkEntry(entry, `${label}[${index}]`, errors); + const key = JSON.stringify(entry); + if (seen.has(key)) errors.push(`${label} cannot contain duplicates`); + seen.add(key); + }); +} + +function checkOrderedTimestamps(value, start, update, complete, errors) { + checkTimestamp(value[start], start, errors, true); + checkTimestamp(value[update], update, errors); + checkTimestamp(value[complete], complete, errors, true); + if (value[start] && Date.parse(value[update]) < Date.parse(value[start])) { + errors.push(`${update} cannot precede ${start}`); + } + if (value[complete] && Date.parse(value[complete]) < Date.parse(value[update])) { + errors.push(`${complete} cannot precede ${update}`); + } +} + +function validateAgentProfile(value) { + const errors = baseErrors(value, APP_CONTRACT_KINDS.AGENT_PROFILE); + if (!isPlainObject(value)) return errors; + checkRevision(value.revision, 'revision', errors); + checkId(value.profile_id, 'profile_id', errors); + checkLabel(value.name, 'name', errors, 120); + checkLabel(value.role, 'role', errors, 160); + checkId(value.runtime_id, 'runtime_id', errors); + if (value.model !== null) checkLabel(value.model, 'model', errors, 160); + checkDigest(value.instructions_digest, 'instructions_digest', errors); + checkUniqueArray(value.skill_ids, 'skill_ids', errors, checkId, { maximum: 128 }); + checkId(value.memory_policy_id, 'memory_policy_id', errors); + checkId(value.permission_policy_id, 'permission_policy_id', errors); + checkId(value.resource_policy_id, 'resource_policy_id', errors); + checkTimestamp(value.created_at, 'created_at', errors); + checkTimestamp(value.updated_at, 'updated_at', errors); + if (value.created_at && value.updated_at && Date.parse(value.updated_at) < Date.parse(value.created_at)) { + errors.push('updated_at cannot precede created_at'); + } + return errors; +} + +function validateAgentInstance(value) { + const errors = baseErrors(value, APP_CONTRACT_KINDS.AGENT_INSTANCE); + if (!isPlainObject(value)) return errors; + checkRevision(value.revision, 'revision', errors); + checkId(value.instance_id, 'instance_id', errors); + checkId(value.profile_id, 'profile_id', errors); + checkRevision(value.profile_revision, 'profile_revision', errors); + checkDigest(value.profile_snapshot_digest, 'profile_snapshot_digest', errors); + checkId(value.operation_id, 'operation_id', errors); + checkId(value.workspace_id, 'workspace_id', errors); + checkId(value.supervisor_id, 'supervisor_id', errors); + if (!AGENT_INSTANCE_STATUSES.includes(value.status)) { + errors.push(`status must be one of ${AGENT_INSTANCE_STATUSES.join(', ')}`); + } + checkRef(value.process_ref, 'process_ref', errors, true); + checkRef(value.terminal_ref, 'terminal_ref', errors, true); + checkRef(value.branch_ref, 'branch_ref', errors, true); + checkRef(value.worktree_ref, 'worktree_ref', errors, true); + checkDigest(value.budget_digest, 'budget_digest', errors); + checkOrderedTimestamps(value, 'started_at', 'updated_at', 'completed_at', errors); + if (value.exit_code !== null && (!Number.isInteger(value.exit_code) || value.exit_code < -2147483648 || value.exit_code > 2147483647)) { + errors.push('exit_code must be null or a signed 32-bit integer'); + } + if (value.failure_code !== null && (typeof value.failure_code !== 'string' || !CODE_PATTERN.test(value.failure_code))) { + errors.push('failure_code must be null or a bounded uppercase code'); + } + const terminal = TERMINAL_AGENT_INSTANCE_STATUSES.includes(value.status); + if (terminal && value.completed_at === null) errors.push('terminal instances require completed_at'); + if (!terminal && value.completed_at !== null) errors.push('non-terminal instances prohibit completed_at'); + if (value.status === 'failed' && value.failure_code === null) errors.push('failed instances require failure_code'); + if (value.status !== 'failed' && value.failure_code !== null) errors.push('failure_code is only valid for failed instances'); + return errors; +} + +function validateTeam(value) { + const errors = baseErrors(value, APP_CONTRACT_KINDS.TEAM); + if (!isPlainObject(value)) return errors; + checkRevision(value.revision, 'revision', errors); + checkId(value.team_id, 'team_id', errors); + checkLabel(value.name, 'name', errors, 120); + checkUniqueArray(value.member_profile_ids, 'member_profile_ids', errors, checkId, { minimum: 1, maximum: 128 }); + checkId(value.coordination_policy_id, 'coordination_policy_id', errors); + checkId(value.handoff_policy_id, 'handoff_policy_id', errors); + checkId(value.resource_policy_id, 'resource_policy_id', errors); + checkTimestamp(value.created_at, 'created_at', errors); + checkTimestamp(value.updated_at, 'updated_at', errors); + return errors; +} + +function validateWorkspaceRef(value) { + const errors = baseErrors(value, APP_CONTRACT_KINDS.WORKSPACE_REF); + if (!isPlainObject(value)) return errors; + checkRevision(value.revision, 'revision', errors); + checkId(value.workspace_id, 'workspace_id', errors); + checkLabel(value.name, 'name', errors, 160); + checkDigest(value.root_digest, 'root_digest', errors); + checkUniqueArray(value.instruction_digests, 'instruction_digests', errors, checkDigest, { maximum: 128 }); + checkUniqueArray(value.runtime_ids, 'runtime_ids', errors, checkId, { maximum: 32 }); + if (typeof value.editable !== 'boolean') errors.push('editable must be boolean'); + checkTimestamp(value.last_opened_at, 'last_opened_at', errors); + return errors; +} + +function validateHandoff(value) { + const errors = baseErrors(value, APP_CONTRACT_KINDS.HANDOFF); + if (!isPlainObject(value)) return errors; + checkRevision(value.revision, 'revision', errors); + checkId(value.handoff_id, 'handoff_id', errors); + checkId(value.operation_id, 'operation_id', errors); + checkId(value.from_instance_id, 'from_instance_id', errors); + checkId(value.to_profile_id, 'to_profile_id', errors); + checkId(value.to_instance_id, 'to_instance_id', errors, true); + if (!HANDOFF_STATUSES.includes(value.status)) errors.push(`status must be one of ${HANDOFF_STATUSES.join(', ')}`); + checkDigest(value.outcome_digest, 'outcome_digest', errors); + checkUniqueArray(value.decision_digests, 'decision_digests', errors, checkDigest, { maximum: 256 }); + checkUniqueArray(value.blocker_codes, 'blocker_codes', errors, (entry, label, list) => { + if (typeof entry !== 'string' || !CODE_PATTERN.test(entry)) list.push(`${label} must be a bounded uppercase code`); + }, { maximum: 128 }); + checkUniqueArray(value.artifact_digests, 'artifact_digests', errors, checkDigest, { maximum: 1024 }); + checkUniqueArray(value.verification_digests, 'verification_digests', errors, checkDigest, { maximum: 1024 }); + checkDigest(value.next_action_digest, 'next_action_digest', errors); + checkTimestamp(value.created_at, 'created_at', errors); + checkTimestamp(value.resolved_at, 'resolved_at', errors, true); + if (value.status === 'pending' && value.resolved_at !== null) errors.push('pending handoffs prohibit resolved_at'); + if (value.status !== 'pending' && value.resolved_at === null) errors.push('resolved handoffs require resolved_at'); + return errors; +} + +function validateSupervisorEvent(value) { + const errors = baseErrors(value, APP_CONTRACT_KINDS.SUPERVISOR_EVENT); + if (!isPlainObject(value)) return errors; + checkId(value.event_id, 'event_id', errors); + checkId(value.supervisor_id, 'supervisor_id', errors); + checkId(value.operation_id, 'operation_id', errors, true); + checkId(value.instance_id, 'instance_id', errors, true); + if (!Number.isInteger(value.sequence) || value.sequence < 0 || value.sequence > Number.MAX_SAFE_INTEGER) { + errors.push('sequence must be a non-negative safe integer'); + } + checkRevision(value.subject_revision, 'subject_revision', errors); + if (!SUPERVISOR_EVENT_TYPES.includes(value.event_type)) { + errors.push(`event_type must be one of ${SUPERVISOR_EVENT_TYPES.join(', ')}`); + } + if (![...AGENT_INSTANCE_STATUSES, ...HANDOFF_STATUSES, 'unknown'].includes(value.status)) { + errors.push('status must be an agent, handoff, or unknown status'); + } + checkDigest(value.payload_digest, 'payload_digest', errors, true); + checkTimestamp(value.recorded_at, 'recorded_at', errors); + return errors; +} + +function validateOperationDefinition(value) { + const errors = baseErrors(value, APP_CONTRACT_KINDS.OPERATION_DEFINITION); + if (!isPlainObject(value)) return errors; + checkRevision(value.revision, 'revision', errors); + checkId(value.operation_id, 'operation_id', errors); + checkId(value.workspace_id, 'workspace_id', errors); + checkId(value.team_id, 'team_id', errors, true); + checkId(value.lead_profile_id, 'lead_profile_id', errors); + checkLabel(value.title, 'title', errors, 160); + checkDigest(value.objective_digest, 'objective_digest', errors); + checkUniqueArray(value.step_ids, 'step_ids', errors, checkId, { minimum: 1, maximum: 256 }); + checkUniqueArray(value.policy_digests, 'policy_digests', errors, checkDigest, { maximum: 256 }); + checkTimestamp(value.created_at, 'created_at', errors); + checkTimestamp(value.updated_at, 'updated_at', errors); + if (value.created_at && value.updated_at && Date.parse(value.updated_at) < Date.parse(value.created_at)) { + errors.push('updated_at cannot precede created_at'); + } + return errors; +} + +const VALIDATORS = Object.freeze({ + [APP_CONTRACT_KINDS.AGENT_PROFILE]: validateAgentProfile, + [APP_CONTRACT_KINDS.AGENT_INSTANCE]: validateAgentInstance, + [APP_CONTRACT_KINDS.OPERATION_DEFINITION]: validateOperationDefinition, + [APP_CONTRACT_KINDS.TEAM]: validateTeam, + [APP_CONTRACT_KINDS.WORKSPACE_REF]: validateWorkspaceRef, + [APP_CONTRACT_KINDS.HANDOFF]: validateHandoff, + [APP_CONTRACT_KINDS.SUPERVISOR_EVENT]: validateSupervisorEvent, +}); + +function validateAppContract(value) { + if (!isPlainObject(value)) return ['app contract must be a plain object']; + const validator = VALIDATORS[value.kind]; + if (!validator) return [`unknown app contract kind: ${value.kind || '(missing)'}`]; + return validator(value); +} + +function assertValidAppContract(value) { + const errors = validateAppContract(value); + if (errors.length) throw new TypeError(`Invalid ${value?.kind || 'app contract'}: ${errors.join('; ')}`); + return value; +} + +module.exports = Object.freeze({ + CODE_PATTERN, + DIGEST_PATTERN, + ID_PATTERN, + REF_PATTERN, + assertValidAppContract, + validateAgentInstance, + validateAgentProfile, + validateAppContract, + validateHandoff, + validateOperationDefinition, + validateSupervisorEvent, + validateTeam, + validateWorkspaceRef, +}); diff --git a/packages/contracts/index.js b/packages/contracts/index.js index c0f998c4..89cf510b 100644 --- a/packages/contracts/index.js +++ b/packages/contracts/index.js @@ -2,6 +2,7 @@ module.exports = Object.freeze({ ...require('../../core/contracts'), + app: require('./app'), operations: require('../../core/operations'), schemaVersion: require('../../core/telemetry/schema').SCHEMA_VERSION, }); diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 750bb1dc..a8c25e54 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -2,5 +2,10 @@ "name": "@citadel/contracts", "version": "0.1.0", "private": true, - "main": "./index.js" + "main": "./index.js", + "exports": { + ".": "./index.js", + "./app": "./app/index.js", + "./schemas/*": "./schemas/*" + } } diff --git a/packages/contracts/schemas/app-contracts-v1.json b/packages/contracts/schemas/app-contracts-v1.json new file mode 100644 index 00000000..250cc84b --- /dev/null +++ b/packages/contracts/schemas/app-contracts-v1.json @@ -0,0 +1,740 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "urn:citadel:app-contracts:1", + "title": "Citadel App Contracts v1", + "oneOf": [ + { + "$ref": "#/$defs/AgentProfile" + }, + { + "$ref": "#/$defs/AgentInstance" + }, + { + "$ref": "#/$defs/Team" + }, + { + "$ref": "#/$defs/WorkspaceRef" + }, + { + "$ref": "#/$defs/Handoff" + }, + { + "$ref": "#/$defs/SupervisorEvent" + }, + { + "$ref": "#/$defs/OperationDefinition" + } + ], + "$defs": { + "identifier": { + "type": "string", + "maxLength": 128, + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$" + }, + "digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "revision": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "opaqueRef": { + "type": "string", + "maxLength": 256, + "pattern": "^[a-z][a-z0-9]*(?:[-_.:/][a-z0-9]+)*$" + }, + "failureCode": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{0,63}$" + }, + "label120": { + "type": "string", + "minLength": 1, + "maxLength": 120, + "pattern": "^[^\\r\\n]+$" + }, + "label160": { + "type": "string", + "minLength": 1, + "maxLength": 160, + "pattern": "^[^\\r\\n]+$" + }, + "AgentProfile": { + "properties": { + "app_contract_version": { + "const": 1 + }, + "kind": { + "const": "agent_profile" + }, + "revision": { + "$ref": "#/$defs/revision" + }, + "profile_id": { + "$ref": "#/$defs/identifier" + }, + "name": { + "$ref": "#/$defs/label120" + }, + "role": { + "$ref": "#/$defs/label160" + }, + "runtime_id": { + "$ref": "#/$defs/identifier" + }, + "model": { + "oneOf": [ + { + "$ref": "#/$defs/label160" + }, + { + "type": "null" + } + ] + }, + "instructions_digest": { + "$ref": "#/$defs/digest" + }, + "skill_ids": { + "type": "array", + "minItems": 0, + "maxItems": 128, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "memory_policy_id": { + "$ref": "#/$defs/identifier" + }, + "permission_policy_id": { + "$ref": "#/$defs/identifier" + }, + "resource_policy_id": { + "$ref": "#/$defs/identifier" + }, + "created_at": { + "$ref": "#/$defs/timestamp" + }, + "updated_at": { + "$ref": "#/$defs/timestamp" + } + }, + "type": "object", + "additionalProperties": false, + "required": [ + "app_contract_version", + "kind", + "revision", + "profile_id", + "name", + "role", + "runtime_id", + "model", + "instructions_digest", + "skill_ids", + "memory_policy_id", + "permission_policy_id", + "resource_policy_id", + "created_at", + "updated_at" + ] + }, + "AgentInstance": { + "properties": { + "app_contract_version": { + "const": 1 + }, + "kind": { + "const": "agent_instance" + }, + "revision": { + "$ref": "#/$defs/revision" + }, + "instance_id": { + "$ref": "#/$defs/identifier" + }, + "profile_id": { + "$ref": "#/$defs/identifier" + }, + "profile_revision": { + "$ref": "#/$defs/revision" + }, + "profile_snapshot_digest": { + "$ref": "#/$defs/digest" + }, + "operation_id": { + "$ref": "#/$defs/identifier" + }, + "workspace_id": { + "$ref": "#/$defs/identifier" + }, + "supervisor_id": { + "$ref": "#/$defs/identifier" + }, + "status": { + "enum": [ + "queued", + "starting", + "running", + "pause-requested", + "paused", + "blocked", + "completed", + "failed", + "cancelled", + "lost" + ] + }, + "process_ref": { + "oneOf": [ + { + "$ref": "#/$defs/opaqueRef" + }, + { + "type": "null" + } + ] + }, + "terminal_ref": { + "oneOf": [ + { + "$ref": "#/$defs/opaqueRef" + }, + { + "type": "null" + } + ] + }, + "branch_ref": { + "oneOf": [ + { + "$ref": "#/$defs/opaqueRef" + }, + { + "type": "null" + } + ] + }, + "worktree_ref": { + "oneOf": [ + { + "$ref": "#/$defs/opaqueRef" + }, + { + "type": "null" + } + ] + }, + "budget_digest": { + "$ref": "#/$defs/digest" + }, + "started_at": { + "oneOf": [ + { + "$ref": "#/$defs/timestamp" + }, + { + "type": "null" + } + ] + }, + "updated_at": { + "$ref": "#/$defs/timestamp" + }, + "completed_at": { + "oneOf": [ + { + "$ref": "#/$defs/timestamp" + }, + { + "type": "null" + } + ] + }, + "exit_code": { + "oneOf": [ + { + "type": "integer", + "minimum": -2147483648, + "maximum": 2147483647 + }, + { + "type": "null" + } + ] + }, + "failure_code": { + "oneOf": [ + { + "$ref": "#/$defs/failureCode" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "additionalProperties": false, + "required": [ + "app_contract_version", + "kind", + "revision", + "instance_id", + "profile_id", + "profile_revision", + "profile_snapshot_digest", + "operation_id", + "workspace_id", + "supervisor_id", + "status", + "process_ref", + "terminal_ref", + "branch_ref", + "worktree_ref", + "budget_digest", + "started_at", + "updated_at", + "completed_at", + "exit_code", + "failure_code" + ] + }, + "Team": { + "properties": { + "app_contract_version": { + "const": 1 + }, + "kind": { + "const": "team" + }, + "revision": { + "$ref": "#/$defs/revision" + }, + "team_id": { + "$ref": "#/$defs/identifier" + }, + "name": { + "$ref": "#/$defs/label120" + }, + "member_profile_ids": { + "type": "array", + "minItems": 1, + "maxItems": 128, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "coordination_policy_id": { + "$ref": "#/$defs/identifier" + }, + "handoff_policy_id": { + "$ref": "#/$defs/identifier" + }, + "resource_policy_id": { + "$ref": "#/$defs/identifier" + }, + "created_at": { + "$ref": "#/$defs/timestamp" + }, + "updated_at": { + "$ref": "#/$defs/timestamp" + } + }, + "type": "object", + "additionalProperties": false, + "required": [ + "app_contract_version", + "kind", + "revision", + "team_id", + "name", + "member_profile_ids", + "coordination_policy_id", + "handoff_policy_id", + "resource_policy_id", + "created_at", + "updated_at" + ] + }, + "WorkspaceRef": { + "properties": { + "app_contract_version": { + "const": 1 + }, + "kind": { + "const": "workspace_ref" + }, + "revision": { + "$ref": "#/$defs/revision" + }, + "workspace_id": { + "$ref": "#/$defs/identifier" + }, + "name": { + "$ref": "#/$defs/label160" + }, + "root_digest": { + "$ref": "#/$defs/digest" + }, + "instruction_digests": { + "type": "array", + "minItems": 0, + "maxItems": 128, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/digest" + } + }, + "runtime_ids": { + "type": "array", + "minItems": 0, + "maxItems": 32, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "editable": { + "type": "boolean" + }, + "last_opened_at": { + "$ref": "#/$defs/timestamp" + } + }, + "type": "object", + "additionalProperties": false, + "required": [ + "app_contract_version", + "kind", + "revision", + "workspace_id", + "name", + "root_digest", + "instruction_digests", + "runtime_ids", + "editable", + "last_opened_at" + ] + }, + "Handoff": { + "properties": { + "app_contract_version": { + "const": 1 + }, + "kind": { + "const": "handoff" + }, + "revision": { + "$ref": "#/$defs/revision" + }, + "handoff_id": { + "$ref": "#/$defs/identifier" + }, + "operation_id": { + "$ref": "#/$defs/identifier" + }, + "from_instance_id": { + "$ref": "#/$defs/identifier" + }, + "to_profile_id": { + "$ref": "#/$defs/identifier" + }, + "to_instance_id": { + "oneOf": [ + { + "$ref": "#/$defs/identifier" + }, + { + "type": "null" + } + ] + }, + "status": { + "enum": [ + "pending", + "accepted", + "rejected", + "blocked" + ] + }, + "outcome_digest": { + "$ref": "#/$defs/digest" + }, + "decision_digests": { + "type": "array", + "minItems": 0, + "maxItems": 256, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/digest" + } + }, + "blocker_codes": { + "type": "array", + "minItems": 0, + "maxItems": 128, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/failureCode" + } + }, + "artifact_digests": { + "type": "array", + "minItems": 0, + "maxItems": 1024, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/digest" + } + }, + "verification_digests": { + "type": "array", + "minItems": 0, + "maxItems": 1024, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/digest" + } + }, + "next_action_digest": { + "$ref": "#/$defs/digest" + }, + "created_at": { + "$ref": "#/$defs/timestamp" + }, + "resolved_at": { + "oneOf": [ + { + "$ref": "#/$defs/timestamp" + }, + { + "type": "null" + } + ] + } + }, + "type": "object", + "additionalProperties": false, + "required": [ + "app_contract_version", + "kind", + "revision", + "handoff_id", + "operation_id", + "from_instance_id", + "to_profile_id", + "to_instance_id", + "status", + "outcome_digest", + "decision_digests", + "blocker_codes", + "artifact_digests", + "verification_digests", + "next_action_digest", + "created_at", + "resolved_at" + ] + }, + "SupervisorEvent": { + "properties": { + "app_contract_version": { + "const": 1 + }, + "kind": { + "const": "supervisor_event" + }, + "event_id": { + "$ref": "#/$defs/identifier" + }, + "supervisor_id": { + "$ref": "#/$defs/identifier" + }, + "operation_id": { + "oneOf": [ + { + "$ref": "#/$defs/identifier" + }, + { + "type": "null" + } + ] + }, + "instance_id": { + "oneOf": [ + { + "$ref": "#/$defs/identifier" + }, + { + "type": "null" + } + ] + }, + "sequence": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "subject_revision": { + "$ref": "#/$defs/revision" + }, + "event_type": { + "enum": [ + "instance-queued", + "instance-started", + "instance-output", + "instance-paused", + "instance-resumed", + "instance-blocked", + "instance-completed", + "instance-failed", + "instance-cancelled", + "instance-lost", + "handoff-created", + "handoff-accepted", + "handoff-rejected", + "approval-required", + "approval-resolved", + "artifact-recorded", + "recovery-started", + "recovery-completed" + ] + }, + "status": { + "enum": [ + "queued", + "starting", + "running", + "pause-requested", + "paused", + "blocked", + "completed", + "failed", + "cancelled", + "lost", + "pending", + "accepted", + "rejected", + "unknown" + ] + }, + "payload_digest": { + "oneOf": [ + { + "$ref": "#/$defs/digest" + }, + { + "type": "null" + } + ] + }, + "recorded_at": { + "$ref": "#/$defs/timestamp" + } + }, + "type": "object", + "additionalProperties": false, + "required": [ + "app_contract_version", + "kind", + "event_id", + "supervisor_id", + "operation_id", + "instance_id", + "sequence", + "subject_revision", + "event_type", + "status", + "payload_digest", + "recorded_at" + ] + }, + "OperationDefinition": { + "properties": { + "app_contract_version": { + "const": 1 + }, + "kind": { + "const": "operation_definition" + }, + "revision": { + "$ref": "#/$defs/revision" + }, + "operation_id": { + "$ref": "#/$defs/identifier" + }, + "workspace_id": { + "$ref": "#/$defs/identifier" + }, + "team_id": { + "oneOf": [ + { + "$ref": "#/$defs/identifier" + }, + { + "type": "null" + } + ] + }, + "lead_profile_id": { + "$ref": "#/$defs/identifier" + }, + "title": { + "$ref": "#/$defs/label160" + }, + "objective_digest": { + "$ref": "#/$defs/digest" + }, + "step_ids": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/identifier" + } + }, + "policy_digests": { + "type": "array", + "minItems": 0, + "maxItems": 256, + "uniqueItems": true, + "items": { + "$ref": "#/$defs/digest" + } + }, + "created_at": { + "$ref": "#/$defs/timestamp" + }, + "updated_at": { + "$ref": "#/$defs/timestamp" + } + }, + "type": "object", + "additionalProperties": false, + "required": [ + "app_contract_version", + "kind", + "revision", + "operation_id", + "workspace_id", + "team_id", + "lead_profile_id", + "title", + "objective_digest", + "step_ids", + "policy_digests", + "created_at", + "updated_at" + ] + } + } +} diff --git a/packages/contracts/schemas/operation-graph-journal-v0.1.json b/packages/contracts/schemas/operation-graph-journal-v0.1.json new file mode 100644 index 00000000..7333bd60 --- /dev/null +++ b/packages/contracts/schemas/operation-graph-journal-v0.1.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://citadel.dev/schemas/operation-graph-journal-v0.1.json", + "title": "Citadel Operation Graph Journal Entry v0.1", + "description": "Hash-chained durable checkpoint for one Operation Graph run.", + "type": "object", + "additionalProperties": false, + "required": [ + "journal_version", + "kind", + "sequence", + "recorded_at", + "event_type", + "run_id", + "graph_id", + "graph_digest", + "run_digest", + "run_snapshot", + "previous_hash", + "entry_hash" + ], + "properties": { + "journal_version": { + "const": "0.1" + }, + "kind": { + "const": "operation_graph_journal_entry" + }, + "sequence": { + "type": "integer", + "minimum": 1 + }, + "recorded_at": { + "type": "string", + "format": "date-time" + }, + "event_type": { + "enum": [ + "initialized", + "node_transition", + "edge_decision", + "checkpoint" + ] + }, + "run_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "graph_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "graph_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "run_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "run_snapshot": { + "$ref": "operation-graph-run-v0.1.json" + }, + "previous_hash": { + "anyOf": [ + { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + { + "type": "null" + } + ] + }, + "entry_hash": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } +} diff --git a/packages/contracts/schemas/operation-graph-run-v0.1.json b/packages/contracts/schemas/operation-graph-run-v0.1.json new file mode 100644 index 00000000..99b35c0e --- /dev/null +++ b/packages/contracts/schemas/operation-graph-run-v0.1.json @@ -0,0 +1,209 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://citadel.dev/schemas/operation-graph-run-v0.1.json", + "title": "Citadel Operation Graph Run v0.1", + "description": "Privacy-safe scheduler snapshot with deterministic traversal tokens.", + "type": "object", + "additionalProperties": false, + "required": [ + "run_version", + "kind", + "run_id", + "graph_id", + "graph_digest", + "status", + "scheduler_state", + "traversal_tokens", + "created_at", + "updated_at" + ], + "properties": { + "run_version": { + "const": "0.1" + }, + "kind": { + "const": "operation_graph_run" + }, + "run_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "graph_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "graph_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "status": { + "enum": [ + "pending", + "running", + "passed", + "failed", + "blocked", + "unknown" + ] + }, + "scheduler_state": { + "type": "object", + "additionalProperties": false, + "required": [ + "state_version", + "graph_id", + "node_statuses", + "visit_counts", + "attempt_counts", + "edge_decisions", + "loop_decision_visits", + "transition_count", + "total_attempts" + ], + "properties": { + "state_version": { + "const": "0.1" + }, + "graph_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "node_statuses": { + "type": "object", + "propertyNames": { + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$" + }, + "additionalProperties": { + "enum": [ + "pending", + "running", + "passed", + "failed", + "blocked", + "unknown" + ] + } + }, + "visit_counts": { + "type": "object", + "propertyNames": { + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$" + }, + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "attempt_counts": { + "type": "object", + "propertyNames": { + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$" + }, + "additionalProperties": { + "type": "integer", + "minimum": 0 + } + }, + "edge_decisions": { + "type": "object", + "propertyNames": { + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$" + }, + "additionalProperties": { + "type": "boolean" + } + }, + "loop_decision_visits": { + "type": "object", + "propertyNames": { + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$" + }, + "additionalProperties": { + "type": "integer", + "minimum": 1 + } + }, + "transition_count": { + "type": "integer", + "minimum": 0 + }, + "total_attempts": { + "type": "integer", + "minimum": 0 + } + } + }, + "traversal_tokens": { + "type": "array", + "maxItems": 25600, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "token_id", + "node_id", + "visit", + "parent_token_ids", + "via_edge_ids", + "status" + ], + "properties": { + "token_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "node_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "visit": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "parent_token_ids": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + } + }, + "via_edge_ids": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + } + }, + "status": { + "enum": [ + "pending", + "running", + "passed", + "failed", + "blocked", + "unknown" + ] + } + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/packages/contracts/schemas/operation-graph-v0.1.json b/packages/contracts/schemas/operation-graph-v0.1.json new file mode 100644 index 00000000..4c8d042f --- /dev/null +++ b/packages/contracts/schemas/operation-graph-v0.1.json @@ -0,0 +1,310 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://citadel.dev/schemas/operation-graph-v0.1.json", + "title": "Citadel Operation Graph v0.1", + "description": "Experimental bounded orchestration graph referencing an immutable Operations Protocol v0.1 specification.", + "type": "object", + "additionalProperties": false, + "required": [ + "graph_version", + "kind", + "graph_id", + "operation_spec_digest", + "entry_node_ids", + "nodes", + "edges", + "joins", + "limits", + "created_at" + ], + "properties": { + "graph_version": { + "const": "0.1" + }, + "kind": { + "const": "operation_graph_spec" + }, + "graph_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "operation_spec_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "entry_node_ids": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "uniqueItems": true, + "items": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + } + }, + "nodes": { + "type": "array", + "minItems": 1, + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "node_id", + "step_id", + "node_kind", + "input_schema_digest", + "output_schema_digest", + "executor_profile", + "scope_digest", + "timeout_ms", + "max_attempts", + "max_visits", + "effect_class", + "verifier" + ], + "properties": { + "node_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "step_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "node_kind": { + "enum": [ + "agent", + "deterministic", + "gate", + "human" + ] + }, + "input_schema_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "output_schema_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "executor_profile": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "scope_digest": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + "timeout_ms": { + "type": "integer", + "minimum": 1, + "maximum": 86400000 + }, + "max_attempts": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "max_visits": { + "type": "integer", + "minimum": 1, + "maximum": 100 + }, + "effect_class": { + "enum": [ + "pure", + "workspace-reversible", + "external-idempotent", + "external-nonrepeatable" + ] + }, + "verifier": { + "type": "object", + "additionalProperties": false, + "required": [ + "required", + "policy", + "evidence_types" + ], + "properties": { + "required": { + "type": "boolean" + }, + "policy": { + "enum": [ + "none", + "deterministic", + "single", + "arbiter" + ] + }, + "evidence_types": { + "type": "array", + "uniqueItems": true, + "maxItems": 8, + "items": { + "enum": [ + "artifact", + "command", + "deployment", + "diff", + "policy", + "review", + "test", + "other" + ] + } + } + } + } + } + } + }, + "edges": { + "type": "array", + "maxItems": 2048, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "edge_id", + "from_node_id", + "to_node_id", + "edge_kind", + "condition_digest", + "data_contract_digest" + ], + "properties": { + "edge_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "from_node_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "to_node_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "edge_kind": { + "enum": [ + "success", + "conditional", + "failure", + "loop" + ] + }, + "condition_digest": { + "anyOf": [ + { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + { + "type": "null" + } + ] + }, + "data_contract_digest": { + "anyOf": [ + { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + }, + { + "type": "null" + } + ] + } + } + } + }, + "joins": { + "type": "array", + "maxItems": 256, + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "node_id", + "policy", + "threshold", + "missing_input_status" + ], + "properties": { + "node_id": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$", + "maxLength": 128 + }, + "policy": { + "enum": [ + "all", + "quorum", + "first_success" + ] + }, + "threshold": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 256 + }, + { + "type": "null" + } + ] + }, + "missing_input_status": { + "enum": [ + "blocked", + "unknown", + "failed" + ] + } + } + } + }, + "limits": { + "type": "object", + "additionalProperties": false, + "required": [ + "max_transitions", + "max_parallel", + "max_total_attempts" + ], + "properties": { + "max_transitions": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + }, + "max_parallel": { + "type": "integer", + "minimum": 1, + "maximum": 64 + }, + "max_total_attempts": { + "type": "integer", + "minimum": 1, + "maximum": 100000 + } + } + }, + "created_at": { + "type": "string", + "format": "date-time" + } + } +} diff --git a/scripts/generate-app-contract-schema.js b/scripts/generate-app-contract-schema.js new file mode 100644 index 00000000..8ec1e9d9 --- /dev/null +++ b/scripts/generate-app-contract-schema.js @@ -0,0 +1,211 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const { + AGENT_INSTANCE_STATUSES, + APP_CONTRACT_KINDS, + APP_CONTRACT_VERSION, + FIELD_ALLOWLISTS, + HANDOFF_STATUSES, + SUPERVISOR_EVENT_TYPES, +} = require('../packages/contracts/app'); + +const output = path.join(__dirname, '..', 'packages', 'contracts', 'schemas', 'app-contracts-v1.json'); + +const ref = (name) => ({ $ref: `#/$defs/${name}` }); +const nullable = (schema) => ({ oneOf: [schema, { type: 'null' }] }); +const unique = (schema, maximum = 256, minimum = 0) => ({ + type: 'array', + minItems: minimum, + maxItems: maximum, + uniqueItems: true, + items: schema, +}); + +const common = { + app_contract_version: { const: APP_CONTRACT_VERSION }, +}; + +const definitions = { + AgentProfile: { + kind: APP_CONTRACT_KINDS.AGENT_PROFILE, + properties: { + ...common, + kind: { const: APP_CONTRACT_KINDS.AGENT_PROFILE }, + revision: ref('revision'), + profile_id: ref('identifier'), + name: ref('label120'), + role: ref('label160'), + runtime_id: ref('identifier'), + model: nullable(ref('label160')), + instructions_digest: ref('digest'), + skill_ids: unique(ref('identifier'), 128), + memory_policy_id: ref('identifier'), + permission_policy_id: ref('identifier'), + resource_policy_id: ref('identifier'), + created_at: ref('timestamp'), + updated_at: ref('timestamp'), + }, + }, + AgentInstance: { + kind: APP_CONTRACT_KINDS.AGENT_INSTANCE, + properties: { + ...common, + kind: { const: APP_CONTRACT_KINDS.AGENT_INSTANCE }, + revision: ref('revision'), + instance_id: ref('identifier'), + profile_id: ref('identifier'), + profile_revision: ref('revision'), + profile_snapshot_digest: ref('digest'), + operation_id: ref('identifier'), + workspace_id: ref('identifier'), + supervisor_id: ref('identifier'), + status: { enum: AGENT_INSTANCE_STATUSES }, + process_ref: nullable(ref('opaqueRef')), + terminal_ref: nullable(ref('opaqueRef')), + branch_ref: nullable(ref('opaqueRef')), + worktree_ref: nullable(ref('opaqueRef')), + budget_digest: ref('digest'), + started_at: nullable(ref('timestamp')), + updated_at: ref('timestamp'), + completed_at: nullable(ref('timestamp')), + exit_code: nullable({ type: 'integer', minimum: -2147483648, maximum: 2147483647 }), + failure_code: nullable(ref('failureCode')), + }, + }, + Team: { + kind: APP_CONTRACT_KINDS.TEAM, + properties: { + ...common, + kind: { const: APP_CONTRACT_KINDS.TEAM }, + revision: ref('revision'), + team_id: ref('identifier'), + name: ref('label120'), + member_profile_ids: unique(ref('identifier'), 128, 1), + coordination_policy_id: ref('identifier'), + handoff_policy_id: ref('identifier'), + resource_policy_id: ref('identifier'), + created_at: ref('timestamp'), + updated_at: ref('timestamp'), + }, + }, + WorkspaceRef: { + kind: APP_CONTRACT_KINDS.WORKSPACE_REF, + properties: { + ...common, + kind: { const: APP_CONTRACT_KINDS.WORKSPACE_REF }, + revision: ref('revision'), + workspace_id: ref('identifier'), + name: ref('label160'), + root_digest: ref('digest'), + instruction_digests: unique(ref('digest'), 128), + runtime_ids: unique(ref('identifier'), 32), + editable: { type: 'boolean' }, + last_opened_at: ref('timestamp'), + }, + }, + Handoff: { + kind: APP_CONTRACT_KINDS.HANDOFF, + properties: { + ...common, + kind: { const: APP_CONTRACT_KINDS.HANDOFF }, + revision: ref('revision'), + handoff_id: ref('identifier'), + operation_id: ref('identifier'), + from_instance_id: ref('identifier'), + to_profile_id: ref('identifier'), + to_instance_id: nullable(ref('identifier')), + status: { enum: HANDOFF_STATUSES }, + outcome_digest: ref('digest'), + decision_digests: unique(ref('digest'), 256), + blocker_codes: unique(ref('failureCode'), 128), + artifact_digests: unique(ref('digest'), 1024), + verification_digests: unique(ref('digest'), 1024), + next_action_digest: ref('digest'), + created_at: ref('timestamp'), + resolved_at: nullable(ref('timestamp')), + }, + }, + SupervisorEvent: { + kind: APP_CONTRACT_KINDS.SUPERVISOR_EVENT, + properties: { + ...common, + kind: { const: APP_CONTRACT_KINDS.SUPERVISOR_EVENT }, + event_id: ref('identifier'), + supervisor_id: ref('identifier'), + operation_id: nullable(ref('identifier')), + instance_id: nullable(ref('identifier')), + sequence: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + subject_revision: ref('revision'), + event_type: { enum: SUPERVISOR_EVENT_TYPES }, + status: { enum: [...new Set([...AGENT_INSTANCE_STATUSES, ...HANDOFF_STATUSES, 'unknown'])] }, + payload_digest: nullable(ref('digest')), + recorded_at: ref('timestamp'), + }, + }, + OperationDefinition: { + kind: APP_CONTRACT_KINDS.OPERATION_DEFINITION, + properties: { + ...common, + kind: { const: APP_CONTRACT_KINDS.OPERATION_DEFINITION }, + revision: ref('revision'), + operation_id: ref('identifier'), + workspace_id: ref('identifier'), + team_id: nullable(ref('identifier')), + lead_profile_id: ref('identifier'), + title: ref('label160'), + objective_digest: ref('digest'), + step_ids: unique(ref('identifier'), 256, 1), + policy_digests: unique(ref('digest'), 256), + created_at: ref('timestamp'), + updated_at: ref('timestamp'), + }, + }, +}; + +for (const definition of Object.values(definitions)) { + definition.type = 'object'; + definition.additionalProperties = false; + definition.required = [...FIELD_ALLOWLISTS[definition.kind]]; + delete definition.kind; +} + +const schema = { + $schema: 'https://json-schema.org/draft/2020-12/schema', + $id: 'urn:citadel:app-contracts:1', + title: 'Citadel App Contracts v1', + oneOf: Object.keys(definitions).map((name) => ref(name)), + $defs: { + identifier: { type: 'string', maxLength: 128, pattern: '^[a-z][a-z0-9]*(?:[-_.:][a-z0-9]+)*$' }, + digest: { type: 'string', pattern: '^sha256:[a-f0-9]{64}$' }, + timestamp: { type: 'string', format: 'date-time' }, + revision: { type: 'integer', minimum: 0, maximum: Number.MAX_SAFE_INTEGER }, + opaqueRef: { type: 'string', maxLength: 256, pattern: '^[a-z][a-z0-9]*(?:[-_.:/][a-z0-9]+)*$' }, + failureCode: { type: 'string', pattern: '^[A-Z][A-Z0-9_]{0,63}$' }, + label120: { type: 'string', minLength: 1, maxLength: 120, pattern: '^[^\\r\\n]+$' }, + label160: { type: 'string', minLength: 1, maxLength: 160, pattern: '^[^\\r\\n]+$' }, + ...definitions, + }, +}; + +const rendered = `${JSON.stringify(schema, null, 2)}\n`; +if (process.argv.includes('--check')) { + const current = fs.existsSync(output) ? fs.readFileSync(output, 'utf8') : ''; + if (current !== rendered) { + process.stderr.write('app contract schema is stale; run generate-app-contract-schema.js --write\n'); + process.exit(1); + } + process.stdout.write('app contract schema is current\n'); + process.exit(0); +} + +if (!process.argv.includes('--write')) { + process.stdout.write(rendered); + process.exit(0); +} + +fs.writeFileSync(output, rendered, 'utf8'); +process.stdout.write(`wrote ${path.relative(process.cwd(), output)}\n`); diff --git a/scripts/operation-graph-effects.js b/scripts/operation-graph-effects.js new file mode 100644 index 00000000..b1f1f406 --- /dev/null +++ b/scripts/operation-graph-effects.js @@ -0,0 +1,203 @@ +#!/usr/bin/env node + +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { + canonicalSerialize, + completeGraphNodeEffect, + createGraphProtocolProof, + planGraphExecutionRecovery, + readGraphRunJournal, + resolveGraphNodeEffect, + startGraphNodeEffect, +} = require('../core/operations'); +const { resolveProjectPath } = require('./operation-graph-runner'); + +const COMMANDS = Object.freeze(['status', 'start', 'complete', 'resolve', 'receipt']); +const VALUE_FLAGS = new Set([ + '--project-root', '--graph', '--journal', '--effects', '--operation', '--receipt', + '--node', '--payload-digest', '--evidence-digest', '--resolution', '--issuer', '--now', +]); + +function parseArgs(argv) { + const args = [...argv]; + const command = args.shift(); + if (!COMMANDS.includes(command)) { + throw new Error('command must be status, start, complete, resolve, or receipt'); + } + const options = { command }; + while (args.length) { + const flag = args.shift(); + if (!VALUE_FLAGS.has(flag)) throw new Error('unknown option: ' + flag); + if (!args.length || args[0].startsWith('--')) throw new Error('missing value for ' + flag); + const key = flag.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); + if (key in options) throw new Error('duplicate option: ' + flag); + options[key] = args.shift(); + } + return options; +} + +function loadJson(file, label) { + try { + return JSON.parse(fs.readFileSync(file, 'utf8')); + } catch (_error) { + throw new Error(label + ' must contain valid JSON'); + } +} + +function loadContext(options) { + const projectRoot = options.projectRoot || process.cwd(); + const graphPath = resolveProjectPath(projectRoot, options.graph, 'graph', { + mustExist: true, + mustBeFile: true, + }); + const journalDir = resolveProjectPath(projectRoot, options.journal, 'journal'); + const effectJournalDir = resolveProjectPath(projectRoot, options.effects, 'effects'); + const graph = loadJson(graphPath, 'graph'); + let run = null; + if (options.command !== 'status') { + const graphJournal = readGraphRunJournal(journalDir, graph); + if (!graphJournal.latest_run) throw new Error('graph run is not initialized'); + run = graphJournal.latest_run; + } + return Object.freeze({ + projectRoot: fs.realpathSync(path.resolve(projectRoot)), + graph, + journalDir, + effectJournalDir, + run, + }); +} + +function writeNewJson(target, value) { + fs.mkdirSync(path.dirname(target), { recursive: true }); + const temporary = path.join(path.dirname(target), + '.' + path.basename(target) + '.' + process.pid + '.' + crypto.randomBytes(8).toString('hex') + '.tmp'); + let descriptor; + try { + descriptor = fs.openSync(temporary, 'wx'); + fs.writeFileSync(descriptor, canonicalSerialize(value) + '\n', 'utf8'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + if (fs.existsSync(target)) throw new Error('receipt already exists'); + fs.renameSync(temporary, target); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + if (fs.existsSync(temporary)) fs.rmSync(temporary, { force: true }); + } +} + +function resultSummary(command, outcome) { + return Object.freeze({ + status: outcome.status, + command, + reason_code: outcome.reason_code, + execution: outcome.execution, + run_id: outcome.run.run_id, + graph_id: outcome.run.graph_id, + run_status: outcome.run.status, + node_id: outcome.binding.node.node_id, + attempt_id: outcome.binding.attempt_id, + idempotency_key: outcome.binding.idempotency_key, + transition_count: outcome.run.scheduler_state.transition_count, + total_attempts: outcome.run.scheduler_state.total_attempts, + }); +} + +function requireNodeEffectFlags(options) { + if (!options.node) throw new Error('node is required'); + if (!options.payloadDigest) throw new Error('payload-digest is required'); +} + +function execute(options) { + const context = loadContext(options); + if (options.command === 'status') { + const recovery = planGraphExecutionRecovery( + context.journalDir, context.effectJournalDir, context.graph); + return Object.freeze({ + status: recovery.status, + command: 'status', + reason_code: recovery.reason_code, + run_id: recovery.run && recovery.run.run_id, + graph_id: context.graph.graph_id, + run_status: recovery.run && recovery.run.status, + actions: recovery.actions, + }); + } + if (options.command === 'receipt') { + if (!options.operation || !options.receipt || !options.issuer) { + throw new Error('operation, receipt, and issuer are required'); + } + const operationPath = resolveProjectPath( + context.projectRoot, options.operation, 'operation', { mustExist: true, mustBeFile: true }); + const receiptPath = resolveProjectPath(context.projectRoot, options.receipt, 'receipt'); + const proof = createGraphProtocolProof({ + graph: context.graph, + run: context.run, + operation: loadJson(operationPath, 'operation'), + effectJournalDir: context.effectJournalDir, + issuedAt: options.now || new Date().toISOString(), + issuerId: options.issuer, + }); + writeNewJson(receiptPath, proof); + return Object.freeze({ + status: 'ok', + command: 'receipt', + reason_code: 'GRAPH_PROTOCOL_PROOF_WRITTEN', + run_id: context.run.run_id, + graph_id: context.graph.graph_id, + receipt_status: proof.receipt_envelope.receipt.status, + receipt_id: proof.receipt_envelope.receipt.receipt_id, + evidence_count: proof.evidence.length, + }); + } + + requireNodeEffectFlags(options); + const common = { + graph: context.graph, + run: context.run, + graphJournalDir: context.journalDir, + effectJournalDir: context.effectJournalDir, + nodeId: options.node, + payloadDigest: options.payloadDigest, + now: options.now, + }; + if (options.command === 'start') { + return resultSummary('start', startGraphNodeEffect(common)); + } + if (options.command === 'complete') { + if (!options.evidenceDigest) throw new Error('evidence-digest is required'); + return resultSummary('complete', completeGraphNodeEffect({ + ...common, + evidenceDigest: options.evidenceDigest, + })); + } + if (!options.resolution) throw new Error('resolution is required'); + return resultSummary('resolve', resolveGraphNodeEffect({ + ...common, + resolution: options.resolution, + evidenceDigest: options.evidenceDigest, + })); +} + +function main(argv = process.argv.slice(2)) { + try { + process.stdout.write(JSON.stringify(execute(parseArgs(argv))) + '\n'); + return 0; + } catch (error) { + process.stderr.write(JSON.stringify({ + status: 'blocked', + reason_code: error.code || 'GRAPH_EFFECT_RUNNER_ERROR', + message: error.message, + }) + '\n'); + return 1; + } +} + +if (require.main === module) process.exitCode = main(); + +module.exports = Object.freeze({ execute, main, parseArgs }); diff --git a/scripts/operation-graph-runner.js b/scripts/operation-graph-runner.js new file mode 100644 index 00000000..d7f6f9c4 --- /dev/null +++ b/scripts/operation-graph-runner.js @@ -0,0 +1,224 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('fs'); +const crypto = require('crypto'); +const path = require('path'); +const { + appendGraphRunSnapshot, + createGraphRun, + createResearchFleetBundle, + decideGraphRunEdge, + evaluateGraph, + planGraphRunRecovery, + readGraphRunJournal, + transitionGraphRun, +} = require('../core/operations'); + +const COMMANDS = Object.freeze(['init', 'research-init', 'status', 'transition', 'decide']); +const VALUE_FLAGS = new Set([ + '--project-root', '--graph', '--journal', '--run-id', '--node', '--status', + '--edge', '--selected', '--now', '--angles', '--operation', +]); + +function parseArgs(argv) { + const args = [...argv]; + const command = args.shift(); + if (!COMMANDS.includes(command)) throw new Error('command must be init, research-init, status, transition, or decide'); + const options = { command }; + while (args.length) { + const flag = args.shift(); + if (!VALUE_FLAGS.has(flag)) throw new Error('unknown option: ' + flag); + if (!args.length || args[0].startsWith('--')) throw new Error('missing value for ' + flag); + const key = flag.slice(2).replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()); + if (key in options) throw new Error('duplicate option: ' + flag); + options[key] = args.shift(); + } + return options; +} + +function isInside(root, target) { + const relative = path.relative(root, target); + return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative)); +} + +function assertNoSymlinkSegments(root, target) { + const relative = path.relative(root, target); + let current = root; + for (const segment of relative.split(path.sep).filter(Boolean)) { + current = path.join(current, segment); + if (!fs.existsSync(current)) continue; + if (fs.lstatSync(current).isSymbolicLink()) throw new Error('path cannot contain symbolic links'); + } +} + +function resolveProjectPath(projectRoot, input, label, options = {}) { + if (typeof input !== 'string' || input.length === 0) throw new Error(label + ' is required'); + const root = fs.realpathSync(path.resolve(projectRoot)); + const target = path.resolve(root, input); + if (!isInside(root, target) || (!options.allowRoot && target === root)) { + throw new Error(label + ' must stay inside project root'); + } + assertNoSymlinkSegments(root, target); + if (options.mustExist && !fs.existsSync(target)) throw new Error(label + ' does not exist'); + if (options.mustBeFile && (!fs.existsSync(target) || !fs.statSync(target).isFile())) { + throw new Error(label + ' must be a file'); + } + if (fs.existsSync(target)) { + const realTarget = fs.realpathSync(target); + if (!isInside(root, realTarget)) throw new Error(label + ' resolves outside project root'); + } + return target; +} + + +function writeNewGraphFile(graphPath, graph) { + fs.mkdirSync(path.dirname(graphPath), { recursive: true }); + const temporary = path.join(path.dirname(graphPath), + '.' + path.basename(graphPath) + '.' + process.pid + '.' + crypto.randomBytes(8).toString('hex') + '.tmp'); + let descriptor; + try { + descriptor = fs.openSync(temporary, 'wx'); + fs.writeFileSync(descriptor, JSON.stringify(graph, null, 2) + '\n', 'utf8'); + fs.fsyncSync(descriptor); + fs.closeSync(descriptor); + descriptor = undefined; + if (fs.existsSync(graphPath)) throw new Error('graph already exists'); + fs.renameSync(temporary, graphPath); + } finally { + if (descriptor !== undefined) fs.closeSync(descriptor); + if (fs.existsSync(temporary)) fs.rmSync(temporary, { force: true }); + } +} +function loadContext(options) { + const projectRoot = options.projectRoot || process.cwd(); + const graphPath = resolveProjectPath(projectRoot, options.graph, 'graph', { mustExist: true, mustBeFile: true }); + const journalDir = resolveProjectPath(projectRoot, options.journal, 'journal'); + let graph; + try { graph = JSON.parse(fs.readFileSync(graphPath, 'utf8')); } + catch (_error) { throw new Error('graph must contain valid JSON'); } + return { projectRoot: fs.realpathSync(path.resolve(projectRoot)), graphPath, journalDir, graph }; +} + +function latestRun(context) { + const journal = readGraphRunJournal(context.journalDir, context.graph); + if (!journal.latest_run) throw new Error('graph run is not initialized'); + return journal.latest_run; +} + +function runSummary(context, run, extra = {}) { + const evaluation = evaluateGraph(context.graph, run.scheduler_state); + return Object.freeze({ + status: 'ok', + command: extra.command, + reason_code: extra.reason_code || null, + run_id: run.run_id, + graph_id: run.graph_id, + run_status: run.status, + ready_node_ids: evaluation.ready_node_ids, + deferred_ready_node_ids: evaluation.deferred_ready_node_ids, + waiting_node_ids: evaluation.waiting_node_ids, + blocked_nodes: evaluation.blocked_nodes, + running_node_ids: evaluation.running_node_ids, + traversal_token_count: run.traversal_tokens.length, + transition_count: run.scheduler_state.transition_count, + total_attempts: run.scheduler_state.total_attempts, + }); +} + +function execute(options) { + if (options.command === 'research-init') { + if (!options.runId) throw new Error('run-id is required for research-init'); + if (!options.angles) throw new Error('angles is required for research-init'); + if (!options.operation) throw new Error('operation is required for research-init'); + const projectRoot = options.projectRoot || process.cwd(); + const graphPath = resolveProjectPath(projectRoot, options.graph, 'graph'); + const operationPath = resolveProjectPath(projectRoot, options.operation, 'operation'); + const journalDir = resolveProjectPath(projectRoot, options.journal, 'journal'); + if (fs.existsSync(graphPath)) throw new Error('graph already exists'); + if (fs.existsSync(operationPath)) throw new Error('operation already exists'); + const angleIds = options.angles.split(',').map((value) => value.trim()).filter(Boolean); + const { graph, operation } = createResearchFleetBundle(angleIds, { now: options.now }); + const context = { + projectRoot: fs.realpathSync(path.resolve(projectRoot)), graphPath, journalDir, graph, + }; + try { + writeNewGraphFile(graphPath, graph); + writeNewGraphFile(operationPath, operation); + const run = createGraphRun(graph, options.runId, { now: options.now }); + appendGraphRunSnapshot(journalDir, graph, run, 'initialized'); + return Object.freeze({ + ...runSummary(context, run, { command: 'research-init' }), + operation_id: operation.operation_id, + }); + } catch (error) { + fs.rmSync(graphPath, { force: true }); + fs.rmSync(operationPath, { force: true }); + throw error; + } + } + const context = loadContext(options); + if (options.command === 'init') { + if (!options.runId) throw new Error('run-id is required for init'); + const run = createGraphRun(context.graph, options.runId, { now: options.now }); + appendGraphRunSnapshot(context.journalDir, context.graph, run, 'initialized'); + return runSummary(context, run, { command: 'init' }); + } + if (options.command === 'status') { + const recovery = planGraphRunRecovery(context.journalDir, context.graph); + if (!recovery.run) return Object.freeze({ + status: recovery.status, + command: 'status', + reason_code: recovery.reason_code, + journal_status: recovery.journal_status, + run_id: null, + graph_id: context.graph.graph_id, + in_flight_node_ids: recovery.in_flight_node_ids, + }); + return Object.freeze({ + ...runSummary(context, recovery.run, { command: 'status', reason_code: recovery.reason_code }), + recovery_status: recovery.status, + journal_status: recovery.journal_status, + in_flight_node_ids: recovery.in_flight_node_ids, + }); + } + const current = latestRun(context); + if (options.runId && options.runId !== current.run_id) throw new Error('run-id does not match journal'); + if (options.command === 'transition') { + if (!options.node || !options.status) throw new Error('node and status are required for transition'); + const run = transitionGraphRun(context.graph, current, options.node, options.status, { now: options.now }); + appendGraphRunSnapshot(context.journalDir, context.graph, run, 'node_transition'); + return runSummary(context, run, { command: 'transition' }); + } + if (!options.edge || !['true', 'false'].includes(options.selected)) { + throw new Error('edge and selected=true|false are required for decide'); + } + const run = decideGraphRunEdge(context.graph, current, options.edge, options.selected === 'true', { now: options.now }); + appendGraphRunSnapshot(context.journalDir, context.graph, run, 'edge_decision'); + return runSummary(context, run, { command: 'decide' }); +} + +function main(argv = process.argv.slice(2)) { + try { + const result = execute(parseArgs(argv)); + process.stdout.write(JSON.stringify(result) + '\n'); + return 0; + } catch (error) { + process.stderr.write(JSON.stringify({ + status: 'blocked', + reason_code: 'GRAPH_RUNNER_ERROR', + message: error.message, + }) + '\n'); + return 1; + } +} + +if (require.main === module) process.exitCode = main(); + +module.exports = Object.freeze({ + execute, + main, + parseArgs, + resolveProjectPath, +}); diff --git a/scripts/test-all.js b/scripts/test-all.js index 98840411..7e9b9c24 100644 --- a/scripts/test-all.js +++ b/scripts/test-all.js @@ -26,6 +26,8 @@ const DEMO_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-demo.js'); const SECURITY_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-security.js'); const RUNTIME_CONTRACT_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-runtime-contracts.js'); const OPERATIONS_PROTOCOL_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-operations-protocol.js'); +const APP_CONTRACT_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-app-contracts.js'); +const SUPERVISOR_CLIENT_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-supervisor-client.js'); const HOOK_EVENT_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-hook-events.js'); const RUNTIME_REGISTRY_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-runtime-registry.js'); const RUNTIME_MATRIX_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-runtime-matrix.js'); @@ -101,6 +103,10 @@ const ECOSYSTEM_COMPAT_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-ecosystem- const PRODUCT_PROOF_REPORT_TEST = path.join(PLUGIN_ROOT, 'scripts', 'test-product-proof-report.js'); const UNLOCK_TESTS = Object.freeze([ ['Operations conformance', 'test-operations-conformance.js'], + ['Operation Graph', 'test-operation-graph.js'], + ['Operation Graph run', 'test-operation-graph-run.js'], + ['Operation Graph effects', 'test-operation-graph-effects.js'], + ['Operation Graph runner', 'test-operation-graph-runner.js'], ['Operation recovery', 'test-operation-recovery.js'], ['Operation receipts', 'test-operation-receipts.js'], ['Operation chaos', 'test-operation-chaos.js'], @@ -150,6 +156,8 @@ const hooksPassed = run('Hook Smoke Test', SMOKE_TEST); const securityPassed = run('Security Tests', SECURITY_TEST); const contractsPassed = run('Runtime Contract Tests', RUNTIME_CONTRACT_TEST); const operationsProtocolPassed = run('Operations Protocol Tests', OPERATIONS_PROTOCOL_TEST); +const appContractsPassed = run('App Contract Tests', APP_CONTRACT_TEST); +const supervisorClientPassed = run('Supervisor Client Tests', SUPERVISOR_CLIENT_TEST); const runtimeRegistryPassed = run('Runtime Registry Tests', RUNTIME_REGISTRY_TEST); const runtimeMatrixPassed = run('Runtime Matrix Tests', RUNTIME_MATRIX_TEST); const hookEventsPassed = run('Hook Event Tests', HOOK_EVENT_TEST); @@ -235,6 +243,8 @@ console.log(` Hook smoke test: ${hooksPassed ? 'PASS' : 'FAIL'}`); console.log(` Security tests: ${securityPassed ? 'PASS' : 'FAIL'}`); console.log(` Runtime contracts: ${contractsPassed ? 'PASS' : 'FAIL'}`); console.log(` Operations protocol: ${operationsProtocolPassed ? 'PASS' : 'FAIL'}`); +console.log(` App contracts: ${appContractsPassed ? 'PASS' : 'FAIL'}`); +console.log(` Supervisor client: ${supervisorClientPassed ? 'PASS' : 'FAIL'}`); console.log(` Runtime registry: ${runtimeRegistryPassed ? 'PASS' : 'FAIL'}`); console.log(` Runtime matrix: ${runtimeMatrixPassed ? 'PASS' : 'FAIL'}`); console.log(` Hook events: ${hookEventsPassed ? 'PASS' : 'FAIL'}`); @@ -315,7 +325,7 @@ for (const [label, passed] of unlockResults) { } console.log(''); -if (hooksPassed && securityPassed && contractsPassed && operationsProtocolPassed && runtimeRegistryPassed && runtimeMatrixPassed && hookEventsPassed && skillsPassed && demoPassed && telemetryPassed && telemetryIntegrityPassed && memoryBlockPassed && evidenceContractPassed && sandboxProviderPassed && skillPackagingPassed && mapSubstratePassed && deliveryPassed && deliveryPackagePassed && continueActionPassed && nextActionPassed && routePreviewPassed && loopsPassed && operatingProofPassed && usefulnessTrialPassed && operatorConsolePassed && operatorJourneyPassed && firstUseOperatorPassed && verificationPlanPassed && prReadyPassed && stackPlanPassed && deployStewardPassed && agentsMdOnlyStewardPassed && coordinationPassed && hookInstallerPassed && campaignPassed && discoveryPassed && discoveryWriterPassed && momentumPassed && momentumWatcherPassed && policyPassed && claudeRuntimePassed && codexRuntimePassed && codexNativeIntegrationPassed && codexOperationalImprovementPassed && installerPassed && cliPackagePassed && projectBootstrapPassed && compatFixturePassed && backwardCompatPassed && costTrackerPassed && dashboardPassed && docSyncPassed && fleetSessionPassed && worktreeReadinessPassed && postEditTypecheckPassed && routingSyncPassed && watchDedupPassed && teammateRebalancePassed && docSurfacesPassed && siteStoryPassed && telemetryOtlpPassed && stateHygienePassed && permissionAuditPassed && secretsLensPassed && dashboardWebPassed && dashboardPerfPassed && dashboardVisualPassed && noopDetectPassed && releaseIntegrityPassed && activationTelemetryPassed && activationCohortPassed && githubTrafficSnapshotPassed && goldenPathPassed && goldenPathMatrixPassed && productBenchmarkPassed && productProofCohortPassed && sarifCoordinatesPassed && ecosystemCompatPassed && productProofReportPassed && unlockSuitePassed) { +if (hooksPassed && securityPassed && contractsPassed && operationsProtocolPassed && appContractsPassed && supervisorClientPassed && runtimeRegistryPassed && runtimeMatrixPassed && hookEventsPassed && skillsPassed && demoPassed && telemetryPassed && telemetryIntegrityPassed && memoryBlockPassed && evidenceContractPassed && sandboxProviderPassed && skillPackagingPassed && mapSubstratePassed && deliveryPassed && deliveryPackagePassed && continueActionPassed && nextActionPassed && routePreviewPassed && loopsPassed && operatingProofPassed && usefulnessTrialPassed && operatorConsolePassed && operatorJourneyPassed && firstUseOperatorPassed && verificationPlanPassed && prReadyPassed && stackPlanPassed && deployStewardPassed && agentsMdOnlyStewardPassed && coordinationPassed && hookInstallerPassed && campaignPassed && discoveryPassed && discoveryWriterPassed && momentumPassed && momentumWatcherPassed && policyPassed && claudeRuntimePassed && codexRuntimePassed && codexNativeIntegrationPassed && codexOperationalImprovementPassed && installerPassed && cliPackagePassed && projectBootstrapPassed && compatFixturePassed && backwardCompatPassed && costTrackerPassed && dashboardPassed && docSyncPassed && fleetSessionPassed && worktreeReadinessPassed && postEditTypecheckPassed && routingSyncPassed && watchDedupPassed && teammateRebalancePassed && docSurfacesPassed && siteStoryPassed && telemetryOtlpPassed && stateHygienePassed && permissionAuditPassed && secretsLensPassed && dashboardWebPassed && dashboardPerfPassed && dashboardVisualPassed && noopDetectPassed && releaseIntegrityPassed && activationTelemetryPassed && activationCohortPassed && githubTrafficSnapshotPassed && goldenPathPassed && goldenPathMatrixPassed && productBenchmarkPassed && productProofCohortPassed && sarifCoordinatesPassed && ecosystemCompatPassed && productProofReportPassed && unlockSuitePassed) { console.log('All tests pass.\n'); console.log('Next steps:'); console.log(' node scripts/skill-bench.js --list see benchmark scenarios'); @@ -404,6 +414,8 @@ if (!hooksPassed) console.log('Hook smoke test failed. Fix hook issues before pr if (!securityPassed) console.log('Security tests failed. DO NOT SHIP - critical vulnerabilities present.'); if (!contractsPassed) console.log('Runtime contract tests failed. Fix the contract skeleton before proceeding.'); if (!operationsProtocolPassed) console.log('Operations protocol tests failed. Fix schemas, validation, transitions, or canonical identity before proceeding.'); +if (!appContractsPassed) console.log('App contract tests failed. Fix entity allowlists, lifecycle transitions, schema parity, or browser-safe packaging before proceeding.'); +if (!supervisorClientPassed) console.log('Supervisor client tests failed. Fix IPC envelopes, payload privacy, versioning, or event validation before proceeding.'); if (!runtimeRegistryPassed) console.log('Runtime registry tests failed. Fix runtime metadata and detection before proceeding.'); if (!runtimeMatrixPassed) console.log('Runtime matrix tests failed. Fix adapter levels or runtime tradeoff metadata before proceeding.'); if (!hookEventsPassed) console.log('Hook event tests failed. Fix event normalization before proceeding.'); diff --git a/scripts/test-app-contracts.js b/scripts/test-app-contracts.js new file mode 100644 index 00000000..29b78823 --- /dev/null +++ b/scripts/test-app-contracts.js @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const app = require('../packages/contracts/app'); +const coreCompatibility = require('../core/app-contracts'); + +const now = '2026-07-14T18:30:00.000Z'; +const later = '2026-07-14T18:31:00.000Z'; +const digest = (character) => `sha256:${character.repeat(64)}`; + +const fixtures = [ + { + app_contract_version: 1, kind: 'agent_profile', revision: 1, profile_id: 'profile-scout', name: 'Scout', + role: 'Repository investigator', runtime_id: 'claude-code', model: null, + instructions_digest: digest('a'), skill_ids: ['research'], memory_policy_id: 'memory-workspace', + permission_policy_id: 'permission-read-only', resource_policy_id: 'resource-standard', + created_at: now, updated_at: now, + }, + { + app_contract_version: 1, kind: 'agent_instance', revision: 1, instance_id: 'instance-scout-1', + profile_id: 'profile-scout', profile_revision: 1, profile_snapshot_digest: digest('4'), + operation_id: 'operation-alpha', workspace_id: 'workspace-citadel', + supervisor_id: 'supervisor-local', status: 'queued', process_ref: null, terminal_ref: null, + branch_ref: null, worktree_ref: null, budget_digest: digest('b'), started_at: null, + updated_at: now, completed_at: null, exit_code: null, failure_code: null, + }, + { + app_contract_version: 1, kind: 'team', revision: 1, team_id: 'team-foundry', name: 'Foundry Team', + member_profile_ids: ['profile-scout'], coordination_policy_id: 'coordination-sequential', + handoff_policy_id: 'handoff-explicit', resource_policy_id: 'resource-standard', + created_at: now, updated_at: now, + }, + { + app_contract_version: 1, kind: 'workspace_ref', revision: 1, workspace_id: 'workspace-citadel', name: 'Citadel', + root_digest: digest('c'), instruction_digests: [digest('d')], runtime_ids: ['claude-code', 'codex'], + editable: true, last_opened_at: now, + }, + { + app_contract_version: 1, kind: 'handoff', revision: 1, handoff_id: 'handoff-scout-mason', + operation_id: 'operation-alpha', from_instance_id: 'instance-scout-1', + to_profile_id: 'profile-mason', to_instance_id: null, status: 'pending', + outcome_digest: digest('e'), decision_digests: [digest('f')], blocker_codes: [], + artifact_digests: [digest('1')], verification_digests: [digest('2')], + next_action_digest: digest('3'), created_at: now, resolved_at: null, + }, + { + app_contract_version: 1, kind: 'supervisor_event', event_id: 'event-1', + supervisor_id: 'supervisor-local', operation_id: 'operation-alpha', instance_id: 'instance-scout-1', + sequence: 1, subject_revision: 1, event_type: 'instance-queued', status: 'queued', payload_digest: null, + recorded_at: now, + }, + { + app_contract_version: 1, kind: 'operation_definition', revision: 1, + operation_id: 'operation-alpha', workspace_id: 'workspace-citadel', team_id: 'team-foundry', + lead_profile_id: 'profile-scout', title: 'Build the Citadel App', objective_digest: digest('5'), + step_ids: ['step-contract', 'step-supervisor'], policy_digests: [digest('6')], + created_at: now, updated_at: now, + }, +]; + +for (const fixture of fixtures) { + assert.deepEqual(app.validateAppContract(fixture), [], `${fixture.kind} fixture should validate`); +} + +assert.strictEqual(coreCompatibility.APP_CONTRACT_VERSION, app.APP_CONTRACT_VERSION); +assert.match(app.validateAppContract({ ...fixtures[0], prompt: 'private prompt' }).join('; '), /allowlist/); +assert.match(app.validateAppContract({ ...fixtures[0], app_contract_version: 2 }).join('; '), /must be 1/); +assert.match(app.validateAppContract({ ...fixtures[0], kind: 'future_kind' }).join('; '), /unknown app contract kind/); + +const queued = fixtures[1]; +const starting = app.transitionAgentInstance(queued, 'starting', { + process_ref: 'process:100', started_at: now, updated_at: later, +}); +assert.equal(starting.status, 'starting'); +assert.equal(starting.revision, 2); +assert.equal(queued.status, 'queued', 'transitions must not mutate their input'); +assert.throws(() => app.transitionAgentInstance(starting, 'completed', { + completed_at: later, exit_code: 0, +}), /Invalid agent instance transition/); + +const pending = fixtures[4]; +const accepted = app.transitionHandoff(pending, 'accepted', { resolved_at: later }); +assert.equal(accepted.status, 'accepted'); +assert.equal(accepted.revision, 2); +assert.equal(pending.status, 'pending', 'handoff transitions must not mutate their input'); +assert.throws(() => app.transitionHandoff(accepted, 'rejected', { resolved_at: later }), /Invalid handoff transition/); + +const projectedOperation = app.projectOperationDefinition(fixtures[6]); +assert.deepEqual(require('../core/operations').validateOperationSpec(projectedOperation), []); +assert.equal(projectedOperation.protocol_version, '0.1'); +assert.equal(projectedOperation.operation_id, fixtures[6].operation_id); +assert.notStrictEqual(projectedOperation.step_ids, fixtures[6].step_ids, 'projection must copy arrays'); + +const schemaPath = path.join(__dirname, '..', 'packages', 'contracts', 'schemas', 'app-contracts-v1.json'); +const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8')); +const names = ['AgentProfile', 'AgentInstance', 'Team', 'WorkspaceRef', 'Handoff', 'SupervisorEvent', 'OperationDefinition']; +for (const name of names) { + const definition = schema.$defs[name]; + const kind = definition.properties.kind.const; + assert.deepEqual([...definition.required].sort(), [...app.FIELD_ALLOWLISTS[kind]].sort(), `${name} schema allowlist drift`); +} + +const appSource = fs.readdirSync(path.join(__dirname, '..', 'packages', 'contracts', 'app')) + .filter((file) => file.endsWith('.js')) + .map((file) => fs.readFileSync(path.join(__dirname, '..', 'packages', 'contracts', 'app', file), 'utf8')) + .join('\n'); +assert.doesNotMatch(appSource, /require\(['"](?:fs|path|child_process|node:|\.\.\/\.\.\/core)/, 'browser-safe app subpath cannot import Node or core'); + +console.log('app contract tests passed'); diff --git a/scripts/test-operation-graph-effects.js b/scripts/test-operation-graph-effects.js new file mode 100644 index 00000000..74bb672d --- /dev/null +++ b/scripts/test-operation-graph-effects.js @@ -0,0 +1,346 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const operations = require('../core/operations'); + +const BASE = Date.parse('2026-07-21T16:00:00.000Z'); +const times = Array.from({ length: 96 }, (_, index) => + new Date(BASE + index * 1000).toISOString()); + +function directories(prefix) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + return { + root, + graphJournalDir: path.join(root, 'graph-journal'), + effectJournalDir: path.join(root, 'effect-journal'), + }; +} + +function initialize(bundle, dirs, runId, now = times[0]) { + const run = operations.createGraphRun(bundle.graph, runId, { now }); + operations.appendGraphRunSnapshot(dirs.graphJournalDir, bundle.graph, run, 'initialized'); + return run; +} + +let passed = 0; +function test(name, fn) { + const dirs = directories('citadel-graph-effects-'); + try { + fn(dirs); + passed++; + process.stdout.write(' PASS ' + name + '\n'); + } finally { + fs.rmSync(dirs.root, { recursive: true, force: true }); + } +} + +test('a Research graph executes through effect checkpoints and emits a passed protocol receipt', (dirs) => { + const bundle = operations.createResearchFleetBundle( + ['claims', 'taxonomy', 'fit'], { now: times[0] }); + assert.equal(operations.sha256Digest(bundle.operation), bundle.graph.operation_spec_digest); + assert.doesNotThrow(() => operations.assertGraphOperationBinding(bundle.graph, bundle.operation)); + let run = initialize(bundle, dirs, 'run-research-effect-proof'); + assert.throws(() => operations.createGraphProtocolProof({ + graph: bundle.graph, run, operation: bundle.operation, + effectJournalDir: dirs.effectJournalDir, issuedAt: times[1], issuerId: 'issuer-local', + }), /terminal graph run/); + let clock = 1; + + while (run.status !== 'passed') { + const evaluation = operations.evaluateGraph(bundle.graph, run.scheduler_state); + assert(evaluation.ready_node_ids.length > 0, 'research graph should keep making progress'); + for (const nodeId of evaluation.ready_node_ids) { + const payloadDigest = operations.sha256Digest({ node_id: nodeId, input: 'redacted' }); + const started = operations.startGraphNodeEffect({ + graph: bundle.graph, + run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId, + payloadDigest, + now: times[clock++], + }); + assert.equal(started.status, 'ready'); + assert.equal(started.execution, 'execute'); + run = started.run; + const completed = operations.completeGraphNodeEffect({ + graph: bundle.graph, + run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId, + payloadDigest, + evidenceDigest: operations.sha256Digest({ node_id: nodeId, artifact: 'verified' }), + now: times[clock++], + }); + assert.equal(completed.status, 'completed'); + run = completed.run; + } + } + + const proof = operations.createGraphProtocolProof({ + graph: bundle.graph, + run, + operation: bundle.operation, + effectJournalDir: dirs.effectJournalDir, + issuedAt: times[clock], + issuerId: 'issuer-local', + }); + assert.equal(proof.receipt_envelope.receipt.status, 'passed'); + assert.equal(proof.evidence.length, bundle.graph.nodes.length); + assert.equal(proof.step_attempts.length, bundle.operation.step_ids.length); + assert.equal(operations.validateReceiptEnvelope(proof.receipt_envelope).length, 0); + const raw = JSON.stringify(proof); + assert(!raw.includes('input'), 'proof should retain digests, not input content'); + assert(!raw.includes('prompt')); +}); + +test('a completed effect checkpoint reconciles a graph crash without repeating work', (dirs) => { + const bundle = operations.createResearchFleetBundle( + ['claims', 'taxonomy', 'fit'], { now: times[0] }); + let run = initialize(bundle, dirs, 'run-crash-reconcile'); + const payloadDigest = operations.sha256Digest({ node: 'scope', payload: 1 }); + run = operations.startGraphNodeEffect({ + graph: bundle.graph, + run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', + payloadDigest, + now: times[1], + }).run; + const evidenceDigest = operations.sha256Digest({ node: 'scope', evidence: 1 }); + assert.throws(() => operations.completeGraphNodeEffect({ + graph: bundle.graph, + run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', + payloadDigest, + evidenceDigest, + now: times[2], + faultAt: 'after_effect_checkpoint', + }), /Injected fault/); + + const recovery = operations.planGraphExecutionRecovery( + dirs.graphJournalDir, dirs.effectJournalDir, bundle.graph); + assert.equal(recovery.status, 'ready'); + assert.deepEqual(recovery.actions.map((action) => action.decision), ['skip']); + const recoveredRun = operations.readGraphRunJournal( + dirs.graphJournalDir, bundle.graph).latest_run; + const reconciled = operations.startGraphNodeEffect({ + graph: bundle.graph, + run: recoveredRun, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', + payloadDigest, + now: times[3], + }); + assert.equal(reconciled.execution, 'skipped'); + assert.equal(reconciled.run.scheduler_state.node_statuses.scope, 'passed'); + assert.deepEqual(operations.readJournal(dirs.effectJournalDir).entries.map((entry) => entry.state), + ['pending', 'completed']); +}); + +test('nonrepeatable ambiguity blocks until an evidenced retry resolution', (dirs) => { + const base = operations.createResearchFleetBundle( + ['claims', 'taxonomy', 'fit'], { now: times[0] }); + const graph = JSON.parse(JSON.stringify(base.graph)); + graph.nodes.find((node) => node.node_id === 'scope').effect_class = 'external-nonrepeatable'; + const bundle = { graph, operation: base.operation }; + let run = initialize(bundle, dirs, 'run-nonrepeatable-proof'); + const payloadDigest = operations.sha256Digest({ node: 'scope', payload: 2 }); + run = operations.startGraphNodeEffect({ + graph, + run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', + payloadDigest, + now: times[1], + }).run; + + const pendingCheckpoint = operations.readJournal(dirs.effectJournalDir).entries[0]; + assert.throws(() => operations.appendJournalEntry(dirs.effectJournalDir, { + run_id: pendingCheckpoint.run_id, + attempt_id: pendingCheckpoint.attempt_id, + idempotency_key: pendingCheckpoint.idempotency_key, + effect_class: pendingCheckpoint.effect_class, + state: 'pending', + payload_digest: pendingCheckpoint.payload_digest, + evidence_digest: null, + }, { now: times[2] }), /retryable resolution/, + 'the journal itself must reject an unevidenced nonrepeatable retry'); + + const blocked = operations.startGraphNodeEffect({ + graph, + run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', + payloadDigest, + now: times[2], + }); + assert.equal(blocked.execution, 'blocked'); + assert.equal(blocked.reason_code, 'AMBIGUOUS_NONREPEATABLE_EFFECT'); + assert.equal(operations.planGraphExecutionRecovery( + dirs.graphJournalDir, dirs.effectJournalDir, graph).status, 'blocked'); + + const mismatch = operations.startGraphNodeEffect({ + graph, + run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', + payloadDigest: operations.sha256Digest({ different: true }), + now: times[2], + }); + assert.equal(mismatch.reason_code, 'PAYLOAD_DIGEST_MISMATCH'); + + const resolved = operations.resolveGraphNodeEffect({ + graph, + run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', + payloadDigest, + resolution: 'retryable', + evidenceDigest: operations.sha256Digest({ review: 'effect-not-observed' }), + now: times[3], + }); + assert.equal(resolved.execution, 'retry_authorized'); + assert.equal(resolved.run.scheduler_state.node_statuses.scope, 'blocked'); + + const retried = operations.startGraphNodeEffect({ + graph, + run: resolved.run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', + payloadDigest, + now: times[4], + }); + assert.equal(retried.execution, 'retry'); + const completed = operations.completeGraphNodeEffect({ + graph, + run: retried.run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', + payloadDigest, + evidenceDigest: operations.sha256Digest({ external_receipt: 'verified' }), + now: times[5], + }); + assert.equal(completed.run.scheduler_state.node_statuses.scope, 'passed'); + assert.deepEqual(operations.readJournal(dirs.effectJournalDir).entries.map((entry) => entry.state), + ['pending', 'retryable', 'pending', 'completed']); +}); + +test('corrupt effect journals block integrated recovery and operation mismatches fail closed', (dirs) => { + const bundle = operations.createResearchFleetBundle( + ['claims', 'taxonomy', 'fit'], { now: times[0] }); + let run = initialize(bundle, dirs, 'run-corrupt-effect-proof'); +test('reviewed completion resolves an ambiguous nonrepeatable node without rerunning it', (dirs) => { + const base = operations.createResearchFleetBundle( + ['claims', 'taxonomy', 'fit'], { now: times[0] }); + const graph = JSON.parse(JSON.stringify(base.graph)); + graph.nodes.find((node) => node.node_id === 'scope').effect_class = 'external-nonrepeatable'; + let run = initialize({ graph }, dirs, 'run-reviewed-completion'); + const payloadDigest = operations.sha256Digest({ node: 'scope', payload: 4 }); + run = operations.startGraphNodeEffect({ + graph, run, graphJournalDir: dirs.graphJournalDir, effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', payloadDigest, now: times[1], + }).run; + const resolved = operations.resolveGraphNodeEffect({ + graph, run, graphJournalDir: dirs.graphJournalDir, effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', payloadDigest, resolution: 'completed', + evidenceDigest: operations.sha256Digest({ review: 'effect-confirmed' }), now: times[2], + }); + assert.equal(resolved.execution, 'recorded'); + assert.equal(resolved.run.scheduler_state.node_statuses.scope, 'passed'); + assert.deepEqual(operations.readJournal(dirs.effectJournalDir).entries.map((entry) => entry.state), + ['pending', 'completed']); +}); + + const payloadDigest = operations.sha256Digest({ node: 'scope', payload: 3 }); + run = operations.startGraphNodeEffect({ + graph: bundle.graph, + run, + graphJournalDir: dirs.graphJournalDir, + effectJournalDir: dirs.effectJournalDir, + nodeId: 'scope', + payloadDigest, + now: times[1], + }).run; + const file = path.join(dirs.effectJournalDir, '00000001.json'); + const entry = JSON.parse(fs.readFileSync(file, 'utf8')); + entry.payload_digest = operations.sha256Digest({ tampered: true }); + fs.writeFileSync(file, JSON.stringify(entry)); + const recovery = operations.planGraphExecutionRecovery( + dirs.graphJournalDir, dirs.effectJournalDir, bundle.graph); + assert.equal(recovery.status, 'blocked'); + assert.equal(recovery.reason_code, 'EFFECT_JOURNAL_CORRUPT'); + + const wrongOperation = { ...bundle.operation, title: 'Changed binding' }; + assert.throws(() => operations.assertGraphOperationBinding(bundle.graph, wrongOperation), + /operation digest/); +}); + +test('the guarded CLI completes a local Research graph trial and writes its proof bundle', (dirs) => { + const graphRunner = path.join(__dirname, 'operation-graph-runner.js'); + const effectRunner = path.join(__dirname, 'operation-graph-effects.js'); + const invoke = (script, args) => JSON.parse(require('child_process').execFileSync( + process.execPath, [script, ...args], { cwd: dirs.root, encoding: 'utf8' })); + const graphPath = '.planning/research/cli-proof/operation-graph.json'; + const operationPath = '.planning/research/cli-proof/operation-spec.json'; + const graphJournal = '.planning/research/cli-proof/graph-journal'; + const effectJournal = '.planning/research/cli-proof/effect-journal'; + const receiptPath = '.planning/research/cli-proof/protocol-proof.json'; + invoke(graphRunner, [ + 'research-init', '--project-root', dirs.root, '--graph', graphPath, '--operation', operationPath, + '--journal', graphJournal, '--run-id', 'run-cli-effect-proof', + '--angles', 'claims,taxonomy,fit', '--now', times[0], + ]); + const graph = JSON.parse(fs.readFileSync(path.join(dirs.root, graphPath), 'utf8')); + let clock = 1; + while (true) { + const run = operations.readGraphRunJournal(path.join(dirs.root, graphJournal), graph).latest_run; + if (run.status === 'passed') break; + const ready = operations.evaluateGraph(graph, run.scheduler_state).ready_node_ids; + assert(ready.length > 0); + for (const nodeId of ready) { + const payload = operations.sha256Digest({ cli_node: nodeId, payload: clock }); + const evidence = operations.sha256Digest({ cli_node: nodeId, evidence: clock }); + const common = [ + '--project-root', dirs.root, '--graph', graphPath, '--journal', graphJournal, + '--effects', effectJournal, '--node', nodeId, '--payload-digest', payload, + ]; + assert.equal(invoke(effectRunner, ['start', ...common, '--now', times[clock++]]).status, 'ready'); + assert.equal(invoke(effectRunner, [ + 'complete', ...common, '--evidence-digest', evidence, '--now', times[clock++], + ]).status, 'completed'); + } + } + const status = invoke(effectRunner, [ + 'status', '--project-root', dirs.root, '--graph', graphPath, '--journal', graphJournal, + '--effects', effectJournal, + ]); + assert.equal(status.status, 'complete'); + const receipt = invoke(effectRunner, [ + 'receipt', '--project-root', dirs.root, '--graph', graphPath, '--journal', graphJournal, + '--effects', effectJournal, '--operation', operationPath, '--receipt', receiptPath, + '--issuer', 'issuer-local', '--now', times[clock], + ]); + assert.equal(receipt.receipt_status, 'passed'); + const proof = JSON.parse(fs.readFileSync(path.join(dirs.root, receiptPath), 'utf8')); + assert.equal(proof.receipt_envelope.receipt.status, 'passed'); + assert.equal(proof.evidence.length, graph.nodes.length); +}); + +process.stdout.write('Operation Graph effect tests: ' + passed + ' passed.\n'); diff --git a/scripts/test-operation-graph-run.js b/scripts/test-operation-graph-run.js new file mode 100644 index 00000000..a55ffa75 --- /dev/null +++ b/scripts/test-operation-graph-run.js @@ -0,0 +1,167 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const operations = require('../core/operations'); + +const graph = JSON.parse(fs.readFileSync(path.join( + __dirname, '..', 'core', 'operations', 'fixtures', 'research-fleet.graph.json'), 'utf8')); +const times = Array.from({ length: 32 }, (_, index) => + new Date(Date.parse('2026-07-21T12:00:00.000Z') + index * 1000).toISOString()); +const runSchema = JSON.parse(fs.readFileSync(path.join( + __dirname, '..', 'packages', 'contracts', 'schemas', 'operation-graph-run-v0.1.json'), 'utf8')); +const journalSchema = JSON.parse(fs.readFileSync(path.join( + __dirname, '..', 'packages', 'contracts', 'schemas', 'operation-graph-journal-v0.1.json'), 'utf8')); +assert.deepEqual(runSchema.required.slice().sort(), [...operations.RUN_FIELDS].sort()); +assert.deepEqual(journalSchema.required.slice().sort(), [...operations.GRAPH_JOURNAL_FIELDS].sort()); +assert.equal(runSchema.additionalProperties, false); +assert.equal(journalSchema.additionalProperties, false); +assert.equal(journalSchema.properties.run_snapshot.$ref, 'operation-graph-run-v0.1.json'); + + +let run = operations.createGraphRun(graph, 'run-research-proof', { now: times[0] }); +assert.deepEqual(operations.validateGraphRun(graph, run), []); +assert.equal(run.status, 'pending'); +assert.deepEqual(run.traversal_tokens.map((token) => token.token_id), ['token-scope-1']); +assert.deepEqual(run.traversal_tokens[0].parent_token_ids, []); + +run = operations.transitionGraphRun(graph, run, 'scope', 'running', { now: times[1] }); +assert.equal(run.status, 'running'); +run = operations.transitionGraphRun(graph, run, 'scope', 'passed', { now: times[2] }); +assert.equal(run.status, 'pending'); +assert.deepEqual(run.traversal_tokens.slice(1).map((token) => token.node_id), + ['scout-claims', 'scout-taxonomy', 'scout-fit']); +assert(run.traversal_tokens.slice(1).every((token) => + token.parent_token_ids[0] === 'token-scope-1')); + +let clock = 3; +for (const nodeId of ['scout-claims', 'scout-taxonomy', 'scout-fit']) { + run = operations.transitionGraphRun(graph, run, nodeId, 'running', { now: times[clock++] }); +} +for (const nodeId of ['scout-claims', 'scout-taxonomy', 'scout-fit']) { + run = operations.transitionGraphRun(graph, run, nodeId, 'passed', { now: times[clock++] }); +} +const reduceToken = run.traversal_tokens.find((token) => token.node_id === 'reduce'); +assert.deepEqual(reduceToken.parent_token_ids, [ + 'token-scout-claims-1', 'token-scout-taxonomy-1', 'token-scout-fit-1', +], 'barrier token should preserve every satisfied parent'); +assert.deepEqual(reduceToken.via_edge_ids, [ + 'claims-to-reduce', 'taxonomy-to-reduce', 'fit-to-reduce', +]); + +const tokenTamper = JSON.parse(JSON.stringify(run)); +tokenTamper.traversal_tokens.find((token) => token.node_id === 'reduce').parent_token_ids.push('token-missing-1'); +assert(operations.validateGraphRun(graph, tokenTamper).some((error) => error.includes('parent token does not exist'))); +const statusTamper = JSON.parse(JSON.stringify(run)); +statusTamper.status = 'passed'; +assert(operations.validateGraphRun(graph, statusTamper).includes('run status does not match scheduler state')); +const missingReadyToken = JSON.parse(JSON.stringify(run)); +missingReadyToken.traversal_tokens = missingReadyToken.traversal_tokens.filter((token) => token.node_id !== 'reduce'); +assert(operations.validateGraphRun(graph, missingReadyToken).includes('ready node is missing a traversal token: reduce')); +const malformedParents = JSON.parse(JSON.stringify(run)); +malformedParents.traversal_tokens.find((token) => token.node_id === 'reduce').parent_token_ids = {}; +assert.doesNotThrow(() => operations.validateGraphRun(graph, malformedParents)); +assert(operations.validateGraphRun(graph, malformedParents).includes('parent_token_ids must be a unique array')); +const fakeLineage = JSON.parse(JSON.stringify(run)); +fakeLineage.traversal_tokens.find((token) => token.node_id === 'reduce').parent_token_ids = ['token-scope-1']; +assert(operations.validateGraphRun(graph, fakeLineage).includes('parent tokens must match traversal edges')); +const malformedState = JSON.parse(JSON.stringify(run)); +delete malformedState.scheduler_state.node_statuses.reduce; +assert.doesNotThrow(() => operations.validateGraphRun(graph, malformedState)); +assert(operations.validateGraphRun(graph, malformedState).some((error) => error.includes('node_statuses keys'))); + + +for (const nodeId of ['reduce', 'synthesize', 'arbiter']) { + run = operations.transitionGraphRun(graph, run, nodeId, 'running', { now: times[clock++] }); + run = operations.transitionGraphRun(graph, run, nodeId, 'passed', { now: times[clock++] }); +} +assert.equal(run.status, 'passed'); +assert.equal(run.traversal_tokens.length, graph.nodes.length); +assert(Object.values(run.scheduler_state.node_statuses).every((status) => status === 'passed')); +const loopGraph = JSON.parse(JSON.stringify(graph)); +for (const nodeId of ['synthesize', 'arbiter']) { + loopGraph.nodes.find((node) => node.node_id === nodeId).max_visits = 2; +} +loopGraph.edges.push({ + edge_id: 'arbiter-to-synthesize', + from_node_id: 'arbiter', + to_node_id: 'synthesize', + edge_kind: 'loop', + condition_digest: 'sha256:' + 'f'.repeat(64), + data_contract_digest: 'sha256:' + 'e'.repeat(64), +}); +let loopRun = operations.createGraphRun(loopGraph, 'run-loop-proof', { now: times[0] }); +let loopClock = 1; +while (operations.evaluateGraph(loopGraph, loopRun.scheduler_state).ready_node_ids.length) { + const ready = operations.evaluateGraph(loopGraph, loopRun.scheduler_state).ready_node_ids; + for (const nodeId of ready) loopRun = operations.transitionGraphRun( + loopGraph, loopRun, nodeId, 'running', { now: times[loopClock++] }); + for (const nodeId of ready) loopRun = operations.transitionGraphRun( + loopGraph, loopRun, nodeId, 'passed', { now: times[loopClock++] }); +} +assert.equal(loopRun.status, 'pending', 'a terminal cycle source must await its loop decision'); +loopRun = operations.decideGraphRunEdge( + loopGraph, loopRun, 'arbiter-to-synthesize', true, { now: times[loopClock++] }); +const secondSynthesis = loopRun.traversal_tokens.find((token) => token.token_id === 'token-synthesize-2'); +assert(secondSynthesis.parent_token_ids.includes('token-arbiter-1')); +assert(secondSynthesis.parent_token_ids.includes('token-reduce-1')); +for (const nodeId of ['synthesize', 'arbiter']) { + loopRun = operations.transitionGraphRun(loopGraph, loopRun, nodeId, 'running', { now: times[loopClock++] }); + loopRun = operations.transitionGraphRun(loopGraph, loopRun, nodeId, 'passed', { now: times[loopClock++] }); +} +assert.equal(loopRun.status, 'pending'); +loopRun = operations.decideGraphRunEdge( + loopGraph, loopRun, 'arbiter-to-synthesize', false, { now: times[loopClock++] }); +assert.equal(loopRun.status, 'passed'); +assert.equal(loopRun.traversal_tokens.length, loopGraph.nodes.length + 2); +assert.deepEqual(operations.validateGraphRun(loopGraph, loopRun), []); + + +const journalDir = fs.mkdtempSync(path.join(os.tmpdir(), 'citadel-operation-graph-run-')); +try { + let journalRun = operations.createGraphRun(graph, 'run-journal-proof', { now: times[0] }); + operations.appendGraphRunSnapshot(journalDir, graph, journalRun, 'initialized'); + journalRun = operations.transitionGraphRun(graph, journalRun, 'scope', 'running', { now: times[1] }); + operations.appendGraphRunSnapshot(journalDir, graph, journalRun, 'node_transition'); + + let journal = operations.readGraphRunJournal(journalDir, graph); + assert.equal(journal.entries.length, 2); + assert.equal(journal.entries[1].previous_hash, journal.entries[0].entry_hash); + assert.equal(journal.latest_run.scheduler_state.node_statuses.scope, 'running'); + const inFlight = operations.planGraphRunRecovery(journalDir, graph); + assert.equal(inFlight.status, 'blocked'); + assert.equal(inFlight.reason_code, 'IN_FLIGHT_NODE_REQUIRES_EFFECT_RECOVERY'); + assert.deepEqual(inFlight.in_flight_node_ids, ['scope']); + + journalRun = operations.transitionGraphRun(graph, journalRun, 'scope', 'passed', { now: times[2] }); + operations.appendGraphRunSnapshot(journalDir, graph, journalRun, 'node_transition'); + const resumable = operations.planGraphRunRecovery(journalDir, graph); + assert.equal(resumable.status, 'ready'); + assert.equal(resumable.reason_code, 'GRAPH_RUN_RESUMABLE'); + assert.deepEqual(resumable.run.traversal_tokens.slice(1).map((token) => token.node_id), + ['scout-claims', 'scout-taxonomy', 'scout-fit']); + + fs.writeFileSync(path.join(journalDir, '.00000004.json.crash.tmp'), '{partial'); + journal = operations.readGraphRunJournal(journalDir, graph); + assert.equal(journal.entries.length, 3, 'temporary debris should be ignored'); + + const raw = fs.readFileSync(path.join(journalDir, '00000003.json'), 'utf8'); + assert(!raw.includes('prompt')); + assert(!raw.includes('artifact_body')); + const tampered = JSON.parse(raw); + tampered.run_snapshot.status = 'passed'; + fs.writeFileSync(path.join(journalDir, '00000003.json'), JSON.stringify(tampered)); + assert.throws(() => operations.readGraphRunJournal(journalDir, graph), operations.GraphJournalCorruptionError); + const corrupt = operations.planGraphRunRecovery(journalDir, graph); + assert.equal(corrupt.status, 'blocked'); + assert.equal(corrupt.reason_code, 'GRAPH_JOURNAL_CORRUPT'); +} finally { + fs.rmSync(journalDir, { recursive: true, force: true }); +} + +console.log('operation graph run tests passed'); diff --git a/scripts/test-operation-graph-runner.js b/scripts/test-operation-graph-runner.js new file mode 100644 index 00000000..9bc61298 --- /dev/null +++ b/scripts/test-operation-graph-runner.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const { execFileSync, spawnSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const runner = path.join(__dirname, 'operation-graph-runner.js'); +const fixture = path.join(__dirname, '..', 'core', 'operations', 'fixtures', 'research-fleet.graph.json'); +const root = fs.mkdtempSync(path.join(os.tmpdir(), 'citadel-graph-runner-')); + +function invoke(args) { + return JSON.parse(execFileSync(process.execPath, [runner, ...args], { + cwd: root, encoding: 'utf8', + })); +} + +try { + fs.copyFileSync(fixture, path.join(root, 'graph.json')); + const common = ['--project-root', root, '--graph', 'graph.json', '--journal', '.planning/graph-run']; + const initialized = invoke([ + 'init', ...common, '--run-id', 'run-cli-proof', '--now', '2026-07-21T13:00:00.000Z', + ]); + assert.equal(initialized.status, 'ok'); + assert.equal(initialized.run_status, 'pending'); + assert.deepEqual(initialized.ready_node_ids, ['scope']); + assert.equal(initialized.traversal_token_count, 1); + + const initialStatus = invoke(['status', ...common]); + assert.equal(initialStatus.recovery_status, 'ready'); + assert.equal(initialStatus.reason_code, 'GRAPH_RUN_RESUMABLE'); + + const running = invoke([ + 'transition', ...common, '--node', 'scope', '--status', 'running', + '--now', '2026-07-21T13:00:01.000Z', + ]); + assert.equal(running.run_status, 'running'); + const inFlight = invoke(['status', ...common]); + assert.equal(inFlight.recovery_status, 'blocked'); + assert.equal(inFlight.reason_code, 'IN_FLIGHT_NODE_REQUIRES_EFFECT_RECOVERY'); + assert.deepEqual(inFlight.in_flight_node_ids, ['scope']); + + const passed = invoke([ + 'transition', ...common, '--node', 'scope', '--status', 'passed', + '--now', '2026-07-21T13:00:02.000Z', + ]); + assert.equal(passed.run_status, 'pending'); + assert.deepEqual(passed.ready_node_ids, ['scout-claims', 'scout-taxonomy', 'scout-fit']); + assert.equal(passed.traversal_token_count, 4); + + const duplicate = spawnSync(process.execPath, [runner, + 'init', ...common, '--run-id', 'run-cli-proof', '--now', '2026-07-21T13:00:03.000Z', + ], { cwd: root, encoding: 'utf8' }); + assert.notEqual(duplicate.status, 0); + assert.equal(JSON.parse(duplicate.stderr).reason_code, 'GRAPH_RUNNER_ERROR'); + + const escaped = spawnSync(process.execPath, [runner, + 'init', '--project-root', root, '--graph', '..' + path.sep + 'outside.json', + '--journal', '.planning/escape', '--run-id', 'run-escape', + ], { cwd: root, encoding: 'utf8' }); + assert.notEqual(escaped.status, 0); + assert(JSON.parse(escaped.stderr).message.includes('inside project root')); + + const conditional = JSON.parse(fs.readFileSync(fixture, 'utf8')); + const edge = conditional.edges.find((item) => item.edge_id === 'scope-to-claims'); + edge.edge_kind = 'conditional'; + edge.condition_digest = 'sha256:' + 'f'.repeat(64); + fs.writeFileSync(path.join(root, 'conditional.json'), JSON.stringify(conditional)); + const conditionalCommon = [ + '--project-root', root, '--graph', 'conditional.json', '--journal', '.planning/conditional-run', + ]; + invoke(['init', ...conditionalCommon, '--run-id', 'run-conditional', '--now', '2026-07-21T14:00:00.000Z']); + invoke(['transition', ...conditionalCommon, '--node', 'scope', '--status', 'running', '--now', '2026-07-21T14:00:01.000Z']); + invoke(['transition', ...conditionalCommon, '--node', 'scope', '--status', 'passed', '--now', '2026-07-21T14:00:02.000Z']); + const decided = invoke([ + 'decide', ...conditionalCommon, '--edge', 'scope-to-claims', '--selected', 'true', + '--now', '2026-07-21T14:00:03.000Z', + ]); + assert(decided.ready_node_ids.includes('scout-claims')); + assert.equal(decided.traversal_token_count, 4); + + const researchGraph = '.planning/research/fleet-proof/operation-graph.json'; + const researchOperation = '.planning/research/fleet-proof/operation-spec.json'; + const researchJournal = '.planning/research/fleet-proof/graph-journal'; + const researchInitialized = invoke([ + 'research-init', '--project-root', root, '--graph', researchGraph, '--journal', researchJournal, + '--operation', researchOperation, + '--run-id', 'run-research-generated', '--angles', 'performance,migration,health,ecosystem', + '--now', '2026-07-21T15:00:00.000Z', + ]); + assert.equal(researchInitialized.command, 'research-init'); + assert.deepEqual(researchInitialized.ready_node_ids, ['scope']); + const generated = JSON.parse(fs.readFileSync(path.join(root, researchGraph), 'utf8')); + const generatedOperation = JSON.parse(fs.readFileSync(path.join(root, researchOperation), 'utf8')); + assert.equal(generated.operation_spec_digest, require('../core/operations').sha256Digest(generatedOperation)); + assert.deepEqual(generated.nodes.filter((node) => node.node_kind === 'agent' + && node.executor_profile === 'research-scout').map((node) => node.node_id), + ['scout-performance', 'scout-migration', 'scout-health', 'scout-ecosystem']); + assert.equal(generated.joins[0].policy, 'all'); + assert.equal(generated.limits.max_parallel, 4); + const generatedRaw = fs.readFileSync(path.join(root, researchGraph), 'utf8'); + assert(!generatedRaw.includes('question')); + assert(!generatedRaw.includes('prompt')); + assert(!fs.readdirSync(path.dirname(path.join(root, researchGraph))) + .some((name) => name.endsWith('.tmp')), 'successful graph creation should leave no temporary debris'); + const researchStatus = invoke([ + 'status', '--project-root', root, '--graph', researchGraph, '--journal', researchJournal, + ]); + assert.equal(researchStatus.recovery_status, 'ready'); + assert.equal(researchStatus.traversal_token_count, 1); + + const invalidResearchGraph = '.planning/research/invalid/operation-graph.json'; + const invalidResearchOperation = '.planning/research/invalid/operation-spec.json'; + const invalidResearch = spawnSync(process.execPath, [runner, + 'research-init', '--project-root', root, '--graph', invalidResearchGraph, + '--journal', '.planning/research/invalid/graph-journal', '--operation', invalidResearchOperation, + '--run-id', 'run-invalid-research', + '--angles', 'only,two', '--now', '2026-07-21T15:01:00.000Z', + ], { cwd: root, encoding: 'utf8' }); + assert.notEqual(invalidResearch.status, 0); + assert(!fs.existsSync(path.join(root, invalidResearchGraph)), 'failed initialization should not leave a graph'); + assert(!fs.existsSync(path.join(root, invalidResearchOperation)), + 'failed initialization should not leave an operation'); + + console.log('operation graph runner tests passed'); +} finally { + fs.rmSync(root, { recursive: true, force: true }); +} diff --git a/scripts/test-operation-graph.js b/scripts/test-operation-graph.js new file mode 100644 index 00000000..54ce8884 --- /dev/null +++ b/scripts/test-operation-graph.js @@ -0,0 +1,185 @@ +#!/usr/bin/env node + +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); + +const operations = require('../core/operations'); +const publicContracts = require('../packages/contracts'); + +const fixturePath = path.join(__dirname, '..', 'core', 'operations', 'fixtures', 'research-fleet.graph.json'); +const tracePath = path.join(__dirname, '..', 'core', 'operations', 'fixtures', 'research-fleet.trace.json'); +const schemaPath = path.join(__dirname, '..', 'packages', 'contracts', 'schemas', 'operation-graph-v0.1.json'); +const graph = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); +const golden = JSON.parse(fs.readFileSync(tracePath, 'utf8')); +const schema = JSON.parse(fs.readFileSync(schemaPath, 'utf8')); + +assert.deepEqual(operations.validateOperationGraph(graph), [], 'Research Fleet graph should validate'); +assert.equal(publicContracts.operations.GRAPH_VERSION, '0.1', 'graph contract should be public'); +assert.equal(publicContracts.operations.createGraphState, operations.createGraphState, 'scheduler should be public'); +assert.deepEqual( + schema.required.slice().sort(), + ['graph_version', 'kind', 'graph_id', 'operation_spec_digest', 'entry_node_ids', 'nodes', 'edges', 'joins', 'limits', 'created_at'].sort(), + 'schema should preserve the runtime top-level allowlist', +); +assert.equal(schema.additionalProperties, false, 'graph schema should reject unknown top-level fields'); + +const withPrivateData = JSON.parse(JSON.stringify(graph)); +withPrivateData.customer_prompt = 'must never enter the control-plane envelope'; +assert( + operations.validateOperationGraph(withPrivateData).some((error) => error.includes('allowlist')), + 'unknown graph fields should fail closed', +); + +const withImplicitCycle = JSON.parse(JSON.stringify(graph)); +withImplicitCycle.edges.push({ + edge_id: 'arbiter-to-scope', + from_node_id: 'arbiter', + to_node_id: 'scope', + edge_kind: 'success', + condition_digest: null, + data_contract_digest: 'sha256:' + 'e'.repeat(64), +}); +assert( + operations.validateOperationGraph(withImplicitCycle).includes('cycles must be expressed only with loop edges'), + 'implicit cycles should be rejected', +); + +const withUnboundedLoop = JSON.parse(JSON.stringify(graph)); +withUnboundedLoop.edges.push({ + edge_id: 'arbiter-to-synthesize', + from_node_id: 'arbiter', + to_node_id: 'synthesize', + edge_kind: 'loop', + condition_digest: 'sha256:' + 'f'.repeat(64), + data_contract_digest: 'sha256:' + 'e'.repeat(64), +}); +assert( + operations.validateOperationGraph(withUnboundedLoop).some((error) => error.includes('loop target must allow at least two visits')), + 'loop targets should require an explicit visit bound greater than one', +); + +const withPartiallyBoundedLoop = JSON.parse(JSON.stringify(withUnboundedLoop)); +withPartiallyBoundedLoop.nodes.find((node) => node.node_id === 'synthesize').max_visits = 2; +assert(operations.validateOperationGraph(withPartiallyBoundedLoop) + .some((error) => error.includes('loop cycle node must allow at least two visits: arbiter'))); + + +const withBoundedLoop = JSON.parse(JSON.stringify(withUnboundedLoop)); +for (const nodeId of ['synthesize', 'arbiter']) { + withBoundedLoop.nodes.find((node) => node.node_id === nodeId).max_visits = 2; +} +assert.deepEqual(operations.validateOperationGraph(withBoundedLoop), [], 'bounded loop should be contract-valid'); +let loopState = operations.createGraphState(withBoundedLoop); +for (const batch of golden.ready_batches) { + for (const nodeId of batch) loopState = operations.transitionNode(withBoundedLoop, loopState, nodeId, 'running'); + for (const nodeId of batch) loopState = operations.transitionNode(withBoundedLoop, loopState, nodeId, 'passed'); +} +assert.deepEqual(operations.evaluateGraph(withBoundedLoop, loopState).awaiting_loop_decision_edge_ids, + ['arbiter-to-synthesize']); +loopState = operations.recordEdgeDecision(withBoundedLoop, loopState, 'arbiter-to-synthesize', true); +assert.equal(loopState.node_statuses.synthesize, 'pending'); +assert.equal(loopState.node_statuses.arbiter, 'pending'); +assert.equal(loopState.node_statuses.reduce, 'passed', 'nodes outside the cycle region must not reset'); +for (const nodeId of ['synthesize', 'arbiter']) { + loopState = operations.transitionNode(withBoundedLoop, loopState, nodeId, 'running'); + loopState = operations.transitionNode(withBoundedLoop, loopState, nodeId, 'passed'); +} +assert.deepEqual(operations.evaluateGraph(withBoundedLoop, loopState).awaiting_loop_decision_edge_ids, + ['arbiter-to-synthesize']); +loopState = operations.recordEdgeDecision(withBoundedLoop, loopState, 'arbiter-to-synthesize', false); +assert.equal(operations.evaluateGraph(withBoundedLoop, loopState).complete, true); +assert.deepEqual([loopState.visit_counts.synthesize, loopState.visit_counts.arbiter], [2, 2]); +assert.throws(() => operations.recordEdgeDecision(withBoundedLoop, loopState, 'arbiter-to-synthesize', true), + /already decided/, 'one loop decision is allowed per source visit'); +let state = operations.createGraphState(graph); +const observedBatches = []; +for (const expectedBatch of golden.ready_batches) { + const evaluation = operations.evaluateGraph(graph, state); + observedBatches.push([...evaluation.ready_node_ids]); + assert.deepEqual(evaluation.ready_node_ids, expectedBatch, 'scheduler should follow the golden batch order'); + for (const nodeId of expectedBatch) state = operations.transitionNode(graph, state, nodeId, 'running'); + for (const nodeId of expectedBatch) state = operations.transitionNode(graph, state, nodeId, 'passed'); +} +assert.deepEqual(observedBatches, golden.ready_batches, 'ready batches should be deterministic'); +const completed = operations.evaluateGraph(graph, state); +assert.equal(completed.complete, true, 'golden graph should finish'); +assert.equal(state.transition_count, golden.final_transition_count, 'transition count should match the trace'); +assert.equal(state.total_attempts, golden.final_total_attempts, 'attempt count should match the trace'); +assert(Object.values(state.node_statuses).every((status) => status === golden.final_status)); + +let barrierState = operations.createGraphState(graph); +barrierState = operations.transitionNode(graph, barrierState, 'scope', 'running'); +barrierState = operations.transitionNode(graph, barrierState, 'scope', 'passed'); +for (const nodeId of ['scout-claims', 'scout-taxonomy']) { + barrierState = operations.transitionNode(graph, barrierState, nodeId, 'running'); + barrierState = operations.transitionNode(graph, barrierState, nodeId, 'passed'); +} +const beforeBarrier = operations.evaluateGraph(graph, barrierState); +assert(beforeBarrier.waiting_node_ids.includes('reduce'), 'all-join should wait for the final scout'); +assert.deepEqual(beforeBarrier.ready_node_ids, ['scout-fit'], 'remaining scout should be the only ready node'); + +let blockedState = operations.transitionNode(graph, barrierState, 'scout-fit', 'running'); +blockedState = operations.transitionNode(graph, blockedState, 'scout-fit', 'failed'); +const blockedEvaluation = operations.evaluateGraph(graph, blockedState); +assert.deepEqual(blockedEvaluation.blocked_nodes, [{ + node_id: 'reduce', + status: 'blocked', + reason: 'join cannot reach all threshold', +}], 'failed required input should fail the barrier closed'); + +let retryState = operations.createGraphState(graph); +retryState = operations.transitionNode(graph, retryState, 'scope', 'running'); +retryState = operations.transitionNode(graph, retryState, 'scope', 'blocked'); +retryState = operations.transitionNode(graph, retryState, 'scope', 'running'); +retryState = operations.transitionNode(graph, retryState, 'scope', 'blocked'); +assert.throws( + () => operations.transitionNode(graph, retryState, 'scope', 'running'), + /attempt limit exceeded/, + 'node retries should be bounded independently of visits', +); + +const conditionalGraph = JSON.parse(JSON.stringify(graph)); +const conditionalEdge = conditionalGraph.edges.find((edge) => edge.edge_id === 'scope-to-claims'); +conditionalEdge.edge_kind = 'conditional'; +conditionalEdge.condition_digest = 'sha256:' + 'f'.repeat(64); +assert.deepEqual(operations.validateOperationGraph(conditionalGraph), [], 'conditional graph should validate'); +let conditionalState = operations.createGraphState(conditionalGraph); +conditionalState = operations.transitionNode(conditionalGraph, conditionalState, 'scope', 'running'); +conditionalState = operations.transitionNode(conditionalGraph, conditionalState, 'scope', 'passed'); +const beforeDecision = operations.evaluateGraph(conditionalGraph, conditionalState); +assert(beforeDecision.waiting_node_ids.includes('scout-claims'), 'conditional target should wait for a route decision'); +const selectedState = operations.recordEdgeDecision(conditionalGraph, conditionalState, 'scope-to-claims', true); +assert(operations.evaluateGraph(conditionalGraph, selectedState).ready_node_ids.includes('scout-claims')); +const unselectedState = operations.recordEdgeDecision(conditionalGraph, conditionalState, 'scope-to-claims', false); +assert(operations.evaluateGraph(conditionalGraph, unselectedState).blocked_nodes.some((item) => item.node_id === 'scout-claims')); + +const cappedGraph = JSON.parse(JSON.stringify(graph)); +cappedGraph.limits.max_parallel = 2; +let cappedState = operations.createGraphState(cappedGraph); +cappedState = operations.transitionNode(cappedGraph, cappedState, 'scope', 'running'); +cappedState = operations.transitionNode(cappedGraph, cappedState, 'scope', 'passed'); +const cappedEvaluation = operations.evaluateGraph(cappedGraph, cappedState); +assert.deepEqual(cappedEvaluation.ready_node_ids, ['scout-claims', 'scout-taxonomy']); +assert.deepEqual(cappedEvaluation.deferred_ready_node_ids, ['scout-fit']); + +const transitionLimitedGraph = JSON.parse(JSON.stringify(graph)); +transitionLimitedGraph.limits.max_transitions = 1; +let transitionLimitedState = operations.createGraphState(transitionLimitedGraph); +transitionLimitedState = operations.transitionNode(transitionLimitedGraph, transitionLimitedState, 'scope', 'running'); +assert.throws( + () => operations.transitionNode(transitionLimitedGraph, transitionLimitedState, 'scope', 'passed'), + /transition limit exceeded/, + 'graph transitions should stop at the declared limit', +); + +const resumed = JSON.parse(JSON.stringify(state)); +assert.doesNotThrow(() => operations.assertValidGraphState(graph, resumed), 'serialized graph state should resume safely'); +const tamperedResume = JSON.parse(JSON.stringify(resumed)); +tamperedResume.total_attempts += 1; +assert.throws(() => operations.assertValidGraphState(graph, tamperedResume), /sum of node attempt counts/, + 'resume should reject inconsistent aggregate counters'); + +console.log('operation graph tests passed'); diff --git a/scripts/test-supervisor-client.js b/scripts/test-supervisor-client.js new file mode 100644 index 00000000..20af7000 --- /dev/null +++ b/scripts/test-supervisor-client.js @@ -0,0 +1,158 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const { + SUPERVISOR_API_VERSION, SupervisorError, createSupervisorClient, + createSupervisorDispatcher, createSupervisorEventLog, validateSupervisorEvent, + validateSupervisorRequest, +} = require('../packages/client/supervisor'); + +const timestamp = '2026-07-14T20:00:00.000Z'; +let id = 0; +const createId = (prefix) => `${prefix}-test-${++id}`; + +function success(request, result = {}) { + return { + apiVersion: SUPERVISOR_API_VERSION, + requestId: request.requestId, + ok: true, + result, + revision: 1, + completedAt: timestamp, + }; +} + +async function main() { + const requests = []; + let eventListener; + const client = createSupervisorClient({ + request(request) { + requests.push(request); + return Promise.resolve(success(request, { accepted: true })); + }, + subscribe(listener) { + eventListener = listener; + return () => {}; + }, + }, { now: () => timestamp, createId }); + + await client.handshake(); + assert.equal(requests[0].method, 'system.handshake'); + assert.equal(requests[0].kind, 'query'); + + await client.command('instances.pause', { instanceId: 'instance-one' }, { expectedRevision: 4 }); + assert.equal(requests[1].kind, 'command'); + assert.equal(requests[1].expectedRevision, 4); + assert.match(requests[1].idempotencyKey, /^command-/); + + await assert.rejects(client.query('instances.destroyEverything', {}), /method is not allowed/); + await assert.rejects( + client.command('operations.launch', { cwd: 'C:\\private', operationId: 'operation-one' }), + /forbidden private or native field/, + ); + await assert.rejects( + client.command('operations.launch', { command: 'powershell.exe' }), + /forbidden private or native field/, + ); + + let received; + client.subscribe((event) => { received = event; }); + const event = { + apiVersion: SUPERVISOR_API_VERSION, + sequence: 1, + eventId: 'event-one', + type: 'instance.updated', + subjectType: 'agent_instance', + subjectId: 'instance-one', + revision: 5, + payload: { status: 'running' }, + occurredAt: timestamp, + }; + eventListener(event); + assert.deepEqual(received, event); + assert.equal(validateSupervisorEvent({ ...event, payload: { token: 'nope' } }).ok, false); + + const oversizedRequest = { + apiVersion: SUPERVISOR_API_VERSION, + requestId: 'request-large', + kind: 'query', + method: 'instances.list', + payload: { text: 'x'.repeat(65 * 1024) }, + sentAt: timestamp, + }; + assert.equal(validateSupervisorRequest(oversizedRequest).ok, false); + + const mismatchClient = createSupervisorClient({ + request(request) { + return Promise.resolve({ ...success(request), requestId: 'request-wrong' }); + }, + }, { now: () => timestamp, createId }); + await assert.rejects(mismatchClient.handshake(), /does not match/); + + let dispatchCalls = 0; + const dispatcher = createSupervisorDispatcher({ + now: () => timestamp, + getRevision: () => 2, + handlers: { + 'system.handshake': () => ({ result: { apiVersion: 1 }, revision: null }), + 'instances.pause': () => { + dispatchCalls += 1; + return { result: { accepted: true }, revision: 3 }; + }, + 'operations.launch': () => { + throw new SupervisorError('POLICY_BLOCKED', 'Launch requires approval', false, 2); + }, + }, + }); + const dispatchClient = createSupervisorClient({ request: dispatcher.dispatch }, { now: () => timestamp, createId }); + const commandOptions = { idempotencyKey: 'command-idempotent', expectedRevision: 2 }; + const first = await dispatchClient.command('instances.pause', { instanceId: 'instance-one' }, commandOptions); + const replayed = await dispatchClient.command('instances.pause', { instanceId: 'instance-one' }, commandOptions); + assert.equal(first.ok, true); + assert.equal(replayed.ok, true); + assert.equal(dispatchCalls, 1, 'idempotency replay must not execute the handler twice'); + const conflict = await dispatchClient.command( + 'instances.pause', + { instanceId: 'instance-one' }, + { idempotencyKey: 'command-conflict', expectedRevision: 1 }, + ); + assert.equal(conflict.ok, false); + assert.equal(conflict.error.code, 'REVISION_CONFLICT'); + assert.equal(conflict.revision, 2); + const blocked = await dispatchClient.command( + 'operations.launch', + { operationId: 'operation-one' }, + { idempotencyKey: 'command-blocked', expectedRevision: 2 }, + ); + assert.equal(blocked.error.code, 'POLICY_BLOCKED'); + + const eventLog = createSupervisorEventLog({ now: () => timestamp }); + const observed = []; + const unsubscribe = eventLog.subscribe((entry) => observed.push(entry)); + eventLog.append({ + type: 'instance.updated', subjectType: 'agent_instance', subjectId: 'instance-one', + revision: 2, payload: { status: 'starting' }, + }); + eventLog.append({ + type: 'instance.updated', subjectType: 'agent_instance', subjectId: 'instance-one', + revision: 3, payload: { status: 'running' }, + }); + unsubscribe(); + assert.deepEqual(eventLog.replay(1).map((entry) => entry.sequence), [2]); + assert.equal(observed.length, 2); + + const source = fs.readdirSync(path.join(__dirname, '..', 'packages', 'client', 'supervisor')) + .filter((file) => file.endsWith('.js')) + .map((file) => fs.readFileSync(path.join(__dirname, '..', 'packages', 'client', 'supervisor', file), 'utf8')) + .join('\n'); + assert.doesNotMatch(source, /require\(['"](?:fs|path|child_process|node:)|\bBuffer\b/, 'supervisor client entrypoint must remain browser-safe'); + + console.log('supervisor client tests passed'); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/skills/research/SKILL.md b/skills/research/SKILL.md index 462dcf3f..954e6ecf 100644 --- a/skills/research/SKILL.md +++ b/skills/research/SKILL.md @@ -18,7 +18,7 @@ trigger_keywords: - parallel research - multi-angle research - compare options -last-updated: 2026-06-11 +last-updated: 2026-07-21 --- # /research — Focused Investigation @@ -141,6 +141,13 @@ Each angle must be: - Specific (one clear question per scout) - Answerable (3-6 sources should be sufficient) +#### Step P1b: INITIALIZE GRAPH (Explicit Opt-In) + +With explicit `--operation-graph`, assign 3-5 opaque angle IDs and initialize a graph plus bound operation: +`node .citadel/scripts/operation-graph-runner.js research-init --project-root . --graph .planning/research/fleet-{slug}/operation-graph.json --operation .planning/research/fleet-{slug}/operation-spec.json --journal .planning/research/fleet-{slug}/graph-journal --run-id research-{slug} --angles {id1,id2,id3}` +For each P2-P5 node, call `operation-graph-effects.js start` with the graph journal, `--effects .../effect-journal`, node ID, and payload digest before work; call `complete` with its evidence digest after verification. Keep prompts and findings outside graph state. +On resume use effect-runner `status`; resolve blocked nonrepeatable work with reviewed evidence. After the arbiter passes, write `receipt` with the bound operation. Without the flag, create no graph state. + ### Step P2: DEPLOY WAVE 1 Spawn one scout agent per angle using Fleet wave mechanics: