diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5fd407167..ef43d72f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,10 @@ jobs: - run: pnpm install --frozen-lockfile - run: pnpm build - run: node scripts/test/simulator-packages.mjs + - name: Verify simulator infrastructure profiles + run: | + pnpm nx run @moltzap/simulator:local-profile-check + pnpm nx run @moltzap/simulator:gke-profile-check - run: pnpm typecheck - run: pnpm lint # Exact runs fail when a required project target disappears; run-many diff --git a/.gitignore b/.gitignore index 28c0a1c51..5a60c9f17 100644 --- a/.gitignore +++ b/.gitignore @@ -153,6 +153,9 @@ vite.config.ts.timestamp-* !.codex/skills/ /.tmp/ +# Local scratch space; holds the Node compile cache +/.scratch/ + .nx/cache .nx/workspace-data .nx/polygraph diff --git a/CHANGELOG.md b/CHANGELOG.md index 0089bbe1b..eb0c7ba0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added: container societies on Kubernetes + +The simulator runs a society of container agents on Kubernetes through one +`RunSpec`. A run reserves its whole cohort before any agent starts, so a society +that cannot fit never half-starts, and every run writes a ledger it can be read +back from. Two profiles share that path: a local cluster for development and a +GKE profile for experiments at size. + +### Changed: one end-to-end experiment, sized by its run + +`packages/simulator/local/end-to-end.mjs` replaces the two-, four-, ten-, and +hundred-agent modules. The path is the same at two agents and at a hundred, so +the roster size is an input rather than four near-copies of one file. +`MOLTZAP_COHORT_SIZE` carries it, defaulting to two, and travels the same +validated path as the startup budget: the submitter refuses what could never be +a count, the controller bounds it, and the experiment reads it through the +controller's own configuration rather than the process. + +### Added: a GKE profile that scales agents on demand + +`packages/simulator/gke/cluster.sh` covers the profile's whole lifecycle with +`setup`, `up`, `run`, `down`, and `delete`. Agent nodes autoscale from zero, so +an idle profile costs only its resident controller, and a run provisions the +nodes its cohort needs and gives them back afterwards. `run` builds the +controller image, pushes it, and submits by the digest the registry reports, +which removes the hand-copied reference that could name an image that does not +exist. Destroying the profile refuses while its bucket still holds run ledgers. + +### Fixed: a large cohort survives from admission to teardown + +Runs of about a hundred agents failed partway through, and the failures read as +infrastructure loss with no cause attached. + +- The run worker is now installed by waiting for the revision just installed + rather than any available replica. Every submission installs the image it + built, so every submission rolls the worker; counting the outgoing replica as + ready handed the run to a Pod the rollout then deleted. +- The controller's liveness signal runs on its own schedule for the whole + attempt. Admitting one agent at a time means a large cohort takes longer to + prepare than the liveness deadline allows, and the signal cannot wait for the + observation loop to start. +- Installing the worker onto a cluster that had already hosted one no longer + conflicts, so a profile can serve more than one run. +- A cohort's startup budget is now configurable end to end. The controller read + the setting but nothing supplied it, leaving its two minute default as the + only reachable value. +- A cluster failure reports which operation failed and why. The ledger recorded + errors that named neither. + ### Added: daemon-backed `HarnessClient` `@moltzap/client` exposes an Effect `HarnessClient` for runtime adapters. Its diff --git a/README.md b/README.md index a3a6856ba..565bb8771 100644 --- a/README.md +++ b/README.md @@ -158,41 +158,37 @@ you have two supported surfaces: ## Simulating agent societies -`@moltzap/simulator` is the code-first simulator for agentic societies. A -versioned `simulator.define` call closes over the complete typed event catalog. -`Society.agents` declares a keyed roster that can mix OpenClaw, NanoClaw, -in-process `effectRuntime` agents, and customer-defined `defineRuntime` agents -on one router and one protocol. - -The experiment is an Effect program. It receives exact started-agent values -through `roster.startedAgents`, emits customer events through `Society.Events`, -and reads committed evidence through `Society.Ledger`. Each started value -separates the participant's router-issued `.agent`, runtime-native `.gateway`, -and `.termination` observation. OpenClaw keeps its gateway RPC, NanoClaw keeps -its CLI socket, and `effectRuntime({ build })` exposes exactly the customer -gateway returned beside its autonomous `behavior`. - -All autonomous social behavior still uses the production client, protocol, -and router. `Network` creates experiment-controlled diagnostic, workload, and -observer endpoints; it is not a replacement principal API for roster agents. -When the outer Effect completes after the kernel acquires an active ledger, -`Society.run` returns either `ProgramFinished` or `RunInfrastructureFailed`. -`ProgramFinished` carries the program `Exit`; both outcomes carry the durable -ledger receipt retained during finalization. Customer code decides when the -experiment is done and how the ledger is graded or swept. - -The same `@moltzap/simulator` package supplies the filesystem ledger, -production router, OpenClaw, NanoClaw, and `effectRuntime` implementations. -Customer code defines other runtimes with `defineRuntime`. The production -router requires Docker and caches an image built from the exact server and -protocol packages installed with the simulator. Start with the -[simulator guide](docs/simulator/overview.mdx). - -The one package has four supported entry points. Experiment definitions and -runs use `@moltzap/simulator`; autonomous runtime contracts and shipped -implementations use `@moltzap/simulator/runtime`; router and link -implementations use `@moltzap/simulator/network`; storage implementations and -offline analysis tools use `@moltzap/simulator/ledger`. +`@moltzap/simulator` is the code-first simulator for agentic societies. An +experiment exports one immutable `RunSpec` containing a versioned definition +id, closed event catalogs, an exact keyed container-runtime roster, the +local-Kubernetes or GKE infrastructure Layer, and one customer `execute` +Effect. The in-cluster controller invokes `Run.execute(runSpec)` once. + +Each started roster value separates its router-issued `.agent`, exact +runtime-native `.gateway`, and `.termination` observation. OpenClaw and +NanoClaw keep their own gateway types and fixed controller bridges. Evaluation +code peers run their policies in their own application containers; every +agent's social traffic still uses the production MoltZap client and router. + +The customer Effect receives `{ agents, events, network, ledger }`. It owns +completion policy, scenarios, sweeps, and grading. `ProgramFinished` retains +the program `Exit` and completed-ledger receipt; infrastructure failures retain +their durable receipt when allocation succeeded. Completed artifacts can be +reopened through the typed ledger facade without exposing Kubernetes objects +to experiment code. + +Kubernetes, Kueue, Agent Sandbox, and Temporal form the only simulator +execution path. The repository supplies a kind profile for local work and a +GKE Standard profile for cloud qualification. Docker may build images and run +the local kind nodes, but it is not a simulator backend. Start with the +[simulator guide](docs/simulator/overview.mdx) and the +[local profile](packages/simulator/local/README.md). + +The package has four supported entry points: experiment definitions and runs +at `@moltzap/simulator`, container runtimes at +`@moltzap/simulator/agents`, network contracts at +`@moltzap/simulator/network`, and offline evidence tools at +`@moltzap/simulator/ledger`. ## Packages diff --git a/docs/decision-evidence/20260801-main-kubernetes-society-execution-cold-review.md b/docs/decision-evidence/20260801-main-kubernetes-society-execution-cold-review.md new file mode 100644 index 000000000..b0676d31e --- /dev/null +++ b/docs/decision-evidence/20260801-main-kubernetes-society-execution-cold-review.md @@ -0,0 +1,256 @@ +# Blind teammate review — main Kubernetes society execution + +## Candidate identity + +- Repository: `/home/tapanc/moltzap-pr-917-main` +- Branch: `impl/917-main-local-society` +- Candidate commit: `0f152696e588538ffcbfac0162bcd1cf17bbaab3` +- Candidate tree: `0374e97d203e02a8a2b7d001c51f2f5f8958f047` +- Merge base with `origin/main`: `314ece9e115e18d443363d42577d567f087d644d` +- Subject: `WIP: define main Kubernetes society execution` +- Worktree: clean +- UTC start: `2026-08-01T22:01:54Z` +- UTC end: `2026-08-01T22:10:44Z` +- Duration: 8 minutes 50 seconds + +## Reviewer and isolation attestation + +Reviewer: fresh Codex teammate `/root/candidate_blind_review_1`. + +I received only the candidate repository root, isolation rules, and the fixed questions. I had no inherited author conversation, compaction, memory, private state, or earlier blind-review output. I used only the candidate repository, normal navigation, history, search, and repository-local checks. I did not browse external sources. + +A path listing exposed the names of existing quarantined review artifacts. I did not open, read, or content-search any `*-cold-review.md` or invalid-review record. No command returned an answer or verdict from one. The review remained in one uninterrupted fresh-agent context. + +Author interventions: none. I asked for no hints and received none. + +## Exact prompt questions + +1. What decision does this candidate make current, what problem does it resolve, and which statements are binding versus context or non-normative explanation? +2. What earlier outcomes does it replace, retain, or leave untouched, and where does the current normative contract live? +3. What must an implementer now do or avoid, which layers or consumers are affected, and under what fault, trust, safety, liveness, and compatibility assumptions? +4. Which humans are named as decision-makers, which source events does the compacted trajectory cite for their calls, alternatives, reversals, and deferrals, and what source gaps does it explicitly record? Report only what the event ledger states; do not infer motives, confidence, urgency, or rationale. +5. Find the strongest apparent contradiction, stale instruction, or broken lineage elsewhere in the repository. Resolve it using the authority order or report it as a blocker. +6. Could a teammate implement the decision without chat or guessing? List every missing link or unresolved choice and classify each as a deliberate deferral or an accidental gap. + +## Unedited answers + +### 1. Current decision, problem, and authority + +The candidate makes `docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md` current with status `accepted`. + +It governs main-track v1 society execution in `packages/simulator` and the `packages/evals` consumer. `@moltzap/simulator` remains the single owner of real society execution, but Kubernetes becomes its only real distributed backend. Local kind and GKE use the same schema-bound `RunSpec`, container roster, `Run.execute` path, private kernel, Kueue aggregate admission, Agent Sandbox lifecycle, Temporal orchestration, durable artifacts, and evidence model. Evaluations use that path rather than a separate or host-based executor. + +The decision resolves the existing host-process/in-process engine’s lack of reconstructible distributed execution, all-roster admission, durable start-or-attach identity, generation-aware container lifecycle, and fail-closed platform qualification. It also prevents a Kubernetes example or second backend from leaving two execution semantics and allowing evaluations to continue testing the old host engine. + +The record explicitly makes the following binding: + +- its `Decision Outcome`; +- the public `RunSpec`, `Agent`, `Infrastructure`, and `Run` contract; +- lifecycle, generation, admission, dispatch, cleanup, and evidence invariants; +- security, trust, safety, liveness, and compatibility assumptions; +- normative ownership; +- deliberate deferrals; and +- the main/v1 scope declared in `Scope and authority`. + +The context and problem statement explain why the decision exists. The consequences explain its effects. Issue #936 is explicitly non-normative. The source-event trajectory is evidence, not authority. Historical ADR bodies and transition notices describe lineage or current pre-cutover implementation and do not extend the new contract. + +The v2 simulator/testbed split, v2 `Simulator.define` port, Gate 1 manifest, `v2/*`, and draft issue #917 decisions are explicitly outside scope. + +### 2. Replaced, retained, and untouched outcomes + +The new ADR is the primary replacement for three partially superseded main/v1 records. + +From `20260727-code-first-simulator-kernel.md`, it retains TypeScript/Effect authoring, the closed `EventCatalog`, typed `RunLedger`, producer-bound evidence, customer-owned scenarios/sweeps/completion/grading, one simulator package, and the production v1 router/protocol. It replaces `Simulator.define`, definition-bound `.run`, `simulatorLayer`, host/mixed runtime acquisition, Docker/process/filesystem execution composition, in-process production runtimes, and the prior restart/replacement deferral. + +From `20260729-principal-io-uses-runtime-gateways.md`, it retains runtime-native principal control versus MoltZap social traffic, exact typed gateways, no universal gateway union or correlation ID, no synthetic-principal shortcut, the gateway/social evidence distinction, `replyToId` removal, and the evaluation identities and behavioral intent. It replaces `AgentRuntime.acquire`, `RunningAgent`, `StartedAgent`, host readiness and lifetime, in-process production peers, and the blanket restart/rebinding deferral with stable slots and generation-aware gateways. + +From `20260729-effect-native-evaluation-results.md`, it retains the sixteen-by-two evaluation catalog, typed reports, deterministic and semantic grading, sanitized provenance, SQLite authority, Phoenix materialization, and old-report reading. It replaces host runtime snapshots and outcomes, runtime factories/in-process peers, and rerunning missing cells with schema-bound container inputs and `attemptId === executionId` start-or-attach. + +The changed frontmatter, visible `Supersession` sections, and decision index agree on these statuses and replacements. + +The accepted v2 simulator-system-driver decision, Gate 1 manifest, v2 package split, and v2 specifications remain untouched. Existing ledger format version 1, admitted legacy schemas, existing event tags, legacy event classes, old reports, and UUID ledger references remain only where the new compatibility section says they remain readable. + +The current normative main/v1 contract lives in the new ADR until its named implementation owners encode it. `packages/simulator/AGENTS.md` already owns the package boundary and dependency law. The ADR assigns the remaining contracts to `src/definition.ts`, the runtime facade, `src/execution.ts`, events/ledger, kernel, private platform/orchestration/controller/artifact modules, deployment/CLI/Nx assets, and `packages/evals`. + +### 3. Implementation obligations and assumptions + +An implementer must: + +- expose the frozen root namespaces `RunSpec`, `Agent`, `Infrastructure`, and `Run`, without a new export subpath; +- accept schema-bound, finite-JSON input/result/failure values and deterministic, exact, nonempty container rosters; +- support only digest-pinned container descriptors with typed bridges, fixed resource fields, constrained persistence, and exact logical Secret slots; +- keep Kubernetes as the sole real backend, with only local and GKE infrastructure selections, and keep platform/orchestration APIs private; +- create one stable AgentId and direct single-application-container Sandbox per roster slot; +- model Pod UID plus application-container restart count as generations and never replay active calls, turns, subscriptions, streams, or cursors; +- compare-create a durable execution binding, attach exact retries, preserve terminal outcomes and receipts, and reject conflicting execution identities before creating further resources; +- admit the complete homogeneous roster through one manual aggregate Kueue Workload before creating Sandboxes; +- recheck the exact ready generation set and durably fence at most one customer-program invocation; +- use one non-replacing controller and one Temporal Workflow, with controller loss terminal; +- let only the controller append simulator lifecycle events and seal the ledger; +- let the Temporal finalizer clean and verify resources, publish completion, and store terminal artifacts without inventing or rewriting events; +- implement the exact closed outcome, pre-ledger error, receipt, and Kubernetes event contracts; +- fail closed on schema drift, mutation, incomplete observation, residue, or inability to prove qualification; +- cut evaluations over to the same container path, preserving grading/SQLite/Phoenix ownership and attaching resume to the same durable execution; and +- remove executable old runners and aliases without a compatibility executor. + +Affected surfaces are the v1 simulator’s definition, runtime bridge, network, ledger, kernel, Kubernetes/Sandbox/Kueue platform, Temporal orchestration, controller, artifact, CLI/deployment, and evaluation-consumer boundaries. The decision does not amend the v2 layers or packages. + +Trusted components for the claimed safety properties are submitted ESM, the cluster administrator, simulator controller/worker/finalizer, Kubernetes control plane/API, Kueue and Agent Sandbox controllers, Temporal and its persistence, artifact/binding/ledger storage, registry digest resolution, DNS/policy enforcement, and the v1 router/server. Application containers and their output may be faulty or malicious. + +The container boundary depends on a qualified runtime such as gVisor, policy, and the trusted control plane. Local kind assumes a trusted rootful Linux/amd64 host and cannot claim hostile-code or managed-isolation parity before its gates pass. Only a passing managed GKE suite may claim managed isolation qualification. + +Safety depends on durable binding, dispatch fencing, storage, and controller/finalizer behavior. It is at-most-once program dispatch, not exactly-once customer side effects. Controller loss, partitions, or deletion cannot authorize replay or weaker admission. + +Liveness additionally requires Temporal, storage, registry, DNS, router, Kubernetes/controllers, quota, physical capacity, all current agent generations, bridges, and any provider proxy to remain available. Their loss may stop progress. + +Compatibility keeps ledger format 1 and existing tags, adds a separate exact Kubernetes catalog, keeps legacy readers read-only, requires a new evaluation definition version, and intentionally makes the execution cut source-breaking. + +### 4. Decision-makers, source events, and source gaps + +The ADR names one human decision-maker: Tapan Chugh. + +The trajectory identifies Codex session `019fbbdd-7cff-7753-8541-4f66f0248d43` and cites these stored events: + +- User message `msg_019fbbe1-770d-7d11-8475-0f2f7b3bd7b1`, turn `0a25724d-258f-41b3-a256-f8c95db5bd3a`, at `2026-08-01T05:52:23.309Z`: target main first with the original simulator. +- User message `msg_019fbdeb-1743-7470-be76-7ed53d7f2420`, turn `019fbdeb-1371-7be3-8e61-babd80ff5ffc`, at `2026-08-01T15:22:08.579Z`: make it part of the core simulator rather than one example and ask for the next slice. +- User message `msg_019fbded-2372-72b0-b859-61f6fe80ac47`, turn `019fbded-227b-70a3-9d9e-9a52a461b990`, at `2026-08-01T15:24:22.771Z`: plan the final shape first. +- Assistant proposal `msg_0141f487830063b4016a6e17e648d481939b073eea4e50a234`, turn `019fbdeb-1371-7be3-8e61-babd80ff5ffc`, at `2026-08-01T15:59:39.573Z`: one `RunSpec` with a customer `execute` callback. +- Directly following user message `msg_019fbe0e-7474-7e53-9f4e-40faac7ac654`, turn `019fbe0e-71e3-76e0-9b67-78ce9cab69e0`, at `2026-08-01T16:00:46.197Z`: “okay do this.” +- User message `msg_019fbe84-b81b-7312-ad62-03432f57cdf2`, turn `019fbe84-b775-7542-94b0-788b9b0a79d7`, at `2026-08-01T18:09:56.763Z`: pull the GKE sandbox work into the core. +- User message `msg_019fbe88-7cd4-7c62-9b8c-e9060c44f8d8`, turn `019fbe88-7c3a-7c10-a1af-ec026b6309e2`, at `2026-08-01T18:14:03.732Z`: use Kubernetes, Kueue, Temporal, and the complete setup, targeting local Kubernetes or GKE. +- User message `msg_019fbe9a-2e94-7430-8da7-f71f0e533f15`, turn `019fbe9a-2ddc-7cd1-b15b-c1447e2310aa`, at `2026-08-01T18:33:23.349Z`: land on main. +- User message `msg_019fbe9c-4f9a-7970-adb5-15463aea8686`, turn `019fbe9c-4ede-7d12-ae11-e054cf83a684`, at `2026-08-01T18:35:42.874Z`: target `packages/simulator`, not v2. +- User directive `msg_019fbf11-b878-7e83-902a-db4e3868e856`, turn `46fbcdbe-0654-4ba4-8e69-d2de6baaa959`, at `2026-08-01T20:43:57.432Z`: work on issue #936, keep agent-maintained issue notes, and run evaluations end to end through the new path. +- Separate mechanical events record the main merge, baseline checks, and agent-published issue comments. + +The events show the alternatives “core versus example” and “main/packages/simulator versus v2.” The trajectory records no explicit human reversal. The movement of infrastructure selection from the accepted assistant example’s `RunSpec` into `Run.execute` is explicitly identified as a later agent-proposed refinement, not a retained human choice. + +The trajectory explicitly records these source gaps: + +- Codex supplied no parent locators. +- “okay do this” has meaning only relative to the directly preceding assistant proposal. +- No separate user event chooses the final infrastructure-field placement. +- The retained human messages do not separately decide every resource shape, failure variant, security control, event field, or platform mechanism. +- Exact versions, schemas, providers, timeouts, storage mechanisms, scale limits, and cost budgets are not human decisions in the excerpts. +- The issue plan and checkpoint prose are agent-authored mechanical artifacts. +- Private instructions, hidden reasoning, irrelevant output, private URLs, and credentials are omitted. + +The repository does not retain an event in which Tapan Chugh reviews or accepts the comprehensive 571-line final outcome after these agent refinements. The trajectory identifies stored actors only as `user`; it does not establish that the session account is the named decision-maker. Under the repository’s provenance law, the frontmatter and Git identity do not themselves prove human acceptance of the detailed binding choices. + +### 5. Strongest apparent contradiction or stale instruction + +The strongest repository-local stale instruction is the newly added `examples/simulator/README.md` and root `simulator:example` command. They present a host-Node/Docker three-container runner using `simulator.define`, `simulatorLayer`, and `openClawRuntime`, while the accepted ADR says Kubernetes is the only real backend and prohibits a Docker executor, host executor, or compatibility runner. Generated `docs/modules/simulator/src.mdx` and `packages/simulator/src/MODULE.md` also still expose `simulatorLayer` without a transition banner. + +The authority chain resolves the semantic conflict: + +1. The accepted new ADR explicitly owns the current main/v1 execution decision. +2. `packages/simulator/AGENTS.md` repeats the one-Kubernetes-path law and forbids preserving the old executable aliases. +3. Root and package simulator guides label the old APIs as pre-cutover implementation rather than extension points. +4. The example calls itself the “original simulator” and a precursor. + +Therefore these files describe current implementation state, not the target contract. They must not guide new implementation. The example and generated API pages should receive an explicit transition pointer or be removed at cutover, but they do not override the accepted ADR. + +The accepted v2 simulator ADR’s preservation of `Simulator.define` is another apparent conflict, but it is fully resolved by the repository’s two-track authority: that record governs v2, while this candidate explicitly governs main/v1 and leaves v2 untouched. + +### 6. Implementability and unresolved choices + +No. A teammate can understand the intended architecture, but cannot implement every binding guarantee without making unrecorded public or persistence choices. + +Accidental gaps and blockers: + +1. **Execution authority contradicts its binding key.** The immutable binding is keyed by `(infrastructure authority, definition id, executionId)`, and cluster recreation intentionally creates a different authority. The ADR nevertheless requires a changed authority to return `RunExecutionConflict` and create no new binding or resource. Changing authority changes the lookup key, so the stated compare-create cannot discover the old binding without an additional cross-authority uniqueness index or a differently scoped key. Neither is specified. The decision must choose whether execution identity is authority-scoped or globally conflicts across authorities. + +2. **The comprehensive accepted outcome lacks final human-accountable source approval.** The retained user accepts a much smaller one-`RunSpec` proposal and later directs the platform scope. The trajectory itself says the final infrastructure placement and detailed lifecycle, persistence, event, security, and error mechanics are agent refinements without separate human events. No retained event admits or approves the final outcome, and no explicit delegation gives the agent decision authority. This leaves binding choices attributed only through frontmatter, which repository law says is insufficient proof. + +3. **The declared public interface is incomplete.** The ADR calls the names, fields, and semantics binding but does not provide complete Effect signatures and closed shapes for `Run.execute`, `Run.open`, slot/generation stream values, bridge unavailability types, receipts, or all namespace inventories. The future owner files do not yet encode the replacement contract. An implementer must make public type decisions. + +4. **The public CLI contract is missing.** The ADR says the package owns the public CLI and specifies signal exits 130/143, but gives no command names, arguments, input/output JSON schemas, ordinary exit mapping, attachment/query behavior, or error rendering. Issue #936 is explicitly non-normative, so it cannot fill this contract. + +5. **Durable cross-process identifier encoding is incomplete.** The Workflow ID is a domain-separated SHA-256 over three values, but the domain separator and byte encoding of the tuple are not frozen. Stable AgentId allocation and the exact resolved-roster projection shared by submitter and controller are also not assigned a complete persisted encoding. These choices affect durable attachment and compatibility rather than only private code structure. + +6. **Pre-cutover documentation remains inconsistently marked.** The active Docker example and generated simulator API pages lack the new transition pointer present in the primary guides. Authority resolves the target, but a cold implementer can still encounter an apparently supported forbidden runner. + +Deliberate deferrals, clearly identified by the ADR: + +- exact upstream versions, digests, checksums, served Sandbox schemas, and aggregate Kueue projection; +- one-container OpenClaw and NanoClaw bootstrap and bridge wire envelope; +- local runtime/CNI behavior and regional GKE add-on behavior; +- durable Temporal deployment, artifact-authority schemes, timeouts, and profile limits; +- production Temporal hosting/HA, fairness, borrowing, preemption, physical gang scheduling, multicluster dispatch, hostile submitted-module isolation, automatic execution-ID reuse, non-Linux/rootless local support, at-rest certification, and exactly-once external effects; +- persistent-agent storage and artifact design above the 100-agent gate; and +- 1,000/5,000/10,000-agent feasibility and latency/resource/throttling/cost budgets. + +Those deliberate deferrals block a profile when its spike fails and do not authorize a fallback executor or weaker lifecycle. They are not the reason for the review failure; the accidental contract, provenance, and identity gaps are. + +## Independently discovered paths and headings + +- `AGENTS.md` + - `Project` + - `Architecture decision records` + - `Decision provenance` + - `Lifecycle and landing` + - `Blind teammate review gate` +- `docs/decisions/README.md` + - `Canonical reading guidance` + - `Records` +- `docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md` + - `Scope and authority` + - `Decision Outcome` + - `Start-or-attach identity and durable artifacts` + - `Security, trust, safety, and liveness assumptions` + - `Compatibility and evaluation cutover` + - `Normative owners` + - `Deliberate deferrals` + - `Earlier outcomes replaced and retained` +- `docs/decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md` + - `The main simulator runs container societies on Kubernetes` + - `Source gaps, stated plainly` +- The `Supersession` sections of the three changed earlier ADRs +- `packages/simulator/AGENTS.md` + - `Boundary` + - `Laws` + - `Structure` +- `v2/VISION.md` + - `Authority` + - `Packages and versions` +- `docs/decisions/20260728-simulator-is-the-system-driver.md` +- `docs/decisions/20260729-v2-authority-lives-with-v2.md` +- Transition notices in root, simulator, evaluation, and development guides +- `examples/simulator/README.md` +- `docs/modules/simulator/src.mdx` +- `packages/simulator/src/MODULE.md` + +## Discovery trail + +1. Identified the clean candidate commit, tree, merge base, history, and changed paths. +2. Read repository ADR law and the decision index. +3. Read the complete new ADR and its complete source-event trajectory. +4. Compared all three superseded ADRs and the index against the new lineage. +5. Read package law, transition documentation, current v2 authority, and the v2 simulator decision. +6. Searched non-quarantined repository content for old and new simulator public APIs. +7. Inspected the active Docker example and generated simulator API documentation. +8. Checked the binding-key and authority language across all non-quarantined sources. +9. Ran `pnpm docs:check`; Mint reported `success no broken links found`. +10. Reconfirmed the worktree remained clean and the candidate identity unchanged. + +## Per-question verdicts + +1. **PASS** — The current decision, problem, scope, and binding/non-binding distinction are explicit and discoverable. +2. **PASS** — Supersession, retained scope, v2 exclusion, index status, and normative ownership are consistent and discoverable. +3. **PASS** — Implementation duties and fault/trust/safety/liveness/compatibility assumptions are unusually detailed and discoverable. +4. **FAIL** — The trajectory is source-faithful, but it does not contain final human approval of the comprehensive accepted outcome or establish that the stored `user` is the named decision-maker. It explicitly identifies major agent-proposed refinements. +5. **PASS** — The strongest stale main example/generated-doc conflict and the v2 API conflict can be resolved through the accepted ADR, package law, transition notices, and two-track scope. +6. **FAIL** — The authority/key contradiction and incomplete public API/CLI/durable identity contracts require guessing; the provenance gap also prevents treating the detailed choices as admitted human decisions. + +## Blockers + +- Resolve the execution-binding authority/key contradiction. +- Obtain and retain human review or acceptance of the complete candidate outcome, or narrow the accepted outcome to the choices actually supported by retained events. +- Freeze the missing public API, CLI, and durable identity encodings, or explicitly classify and bound them as non-public implementation choices or deliberate deferrals. +- Re-run the blind gate with a different fresh reviewer after any semantic correction. + +## Overall result + +**FAIL — blocks landing.** + +Mechanical links pass and the decision’s broad architecture, scope, lineage, and assumptions are discoverable. The source-attribution failure and unresolved binding/authority contract prevent an overall PASS. diff --git a/docs/decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md b/docs/decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md new file mode 100644 index 000000000..67d7e73d6 --- /dev/null +++ b/docs/decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md @@ -0,0 +1,485 @@ +# Main Kubernetes society execution source-event ledger + +This is a curated, non-normative ledger of stored public events from Codex +session `019fbbdd-7cff-7753-8541-4f66f0248d43`. Every retained Codex entry is +a top-level `response_item` whose payload type is `message`; the source gives +an enclosing turn and message id but no parent locator. Timestamps are UTC. +Excerpts are literal, including spelling, punctuation, questions, and terse +replies. The linked ADR is normative; this trajectory does not reconstruct a +rationale or strengthen a proposal into a human statement. + + + +## The main simulator runs container societies on Kubernetes + +[ADR: `20260801-main-simulator-runs-container-societies-on-kubernetes.md`](../decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md) + +1. **Stored user messages: main first, then core rather than an example.** + + Source system: Codex. Source session: the session above. Native locator: + message `msg_019fbbe1-770d-7d11-8475-0f2f7b3bd7b1`; enclosing turn + `0a25724d-258f-41b3-a256-f8c95db5bd3a`; top-level response-item message; + stored actor role `user`; `2026-08-01T05:52:23.309Z`. The source supplies + no parent locator. + + > we should target this on main first with the original simulator + + Source system and session: the same. Native locator: message + `msg_019fbdeb-1743-7470-be76-7ed53d7f2420`; enclosing turn + `019fbdeb-1371-7be3-8e61-babd80ff5ffc`; top-level response-item message; + stored actor role `user`; `2026-08-01T15:22:08.579Z`. The source supplies + no parent locator. + + > this should be part of the core simulator. why are we making this one example. what is the next slice + +2. **Stored user message: decide the final shape before implementation.** + + Source system and session: the same. Native locator: message + `msg_019fbded-2372-72b0-b859-61f6fe80ac47`; enclosing turn + `019fbded-227b-70a3-9d9e-9a52a461b990`; top-level response-item message; + stored actor role `user`; `2026-08-01T15:24:22.771Z`. The source supplies + no parent locator. + + > lets plan the final shape first + +3. **Stored assistant proposal and directly following user acceptance: one + RunSpec and customer execute callback.** + + Source system and session: the same. Native locator: message + `msg_0141f487830063b4016a6e17e648d481939b073eea4e50a234`; enclosing turn + `019fbdeb-1371-7be3-8e61-babd80ff5ffc`; top-level response-item message; + stored actor role `assistant`; phase `final_answer`; parent locator absent; + `2026-08-01T15:59:39.573Z`. + + > The “program” is the host-side conductor for a run. It: + > + > - Starts only after the router and every agent container are ready. + > - Creates the conversation/task or sends the initial stimulus. + > - Interacts with agents through their gateways. + > - Waits for messages and evidence. + > - Decides when the run is finished and returns the result. + > + > It is not agent code, container startup code, or deterministic peer behavior. Those belong inside the agent containers. + > + > For evals, it is essentially the case runner. “Program” is probably unnecessary vocabulary; the single `RunSpec` can simply have an `execute` callback: + > + > ```ts + > export default RunSpec.define({ + > id: "echo/v1", + > events: [echoEvents], + > agents: { alice, bob }, + > infrastructure, + > + > execute: ({ agents, events, network }) => + > Effect.gen(function* () { + > // Stimulate the society, observe it, and determine completion. + > }), + > }) + > ``` + > + > So the final public model can be just `RunSpec` and `Run.execute(spec)`. + + Source system and session: the same. Native locator: message + `msg_019fbe0e-7474-7e53-9f4e-40faac7ac654`; enclosing turn + `019fbe0e-71e3-76e0-9b67-78ce9cab69e0`; top-level response-item message; + stored actor role `user`; `2026-08-01T16:00:46.197Z`. The source supplies + no parent locator. + + > okay do this + +4. **Stored user messages: pull the sandbox platform into the core and use the + complete Kubernetes stack.** + + Source system and session: the same. Native locator: message + `msg_019fbe84-b81b-7312-ad62-03432f57cdf2`; enclosing turn + `019fbe84-b775-7542-94b0-788b9b0a79d7`; top-level response-item message; + stored actor role `user`; `2026-08-01T18:09:56.763Z`. The source supplies + no parent locator. + + > I think the GKE sandbox stuff should be pulled here bro + + Source system and session: the same. Native locator: message + `msg_019fbe88-7cd4-7c62-9b8c-e9060c44f8d8`; enclosing turn + `019fbe88-7c3a-7c10-a1af-ec026b6309e2`; top-level response-item message; + stored actor role `user`; `2026-08-01T18:14:03.732Z`. The source supplies + no parent locator. + + > I think we should do a k8s + kueue + temportal + everything setup. the target can be a local k8s cluster or GKE cluster.Go through the actual ADRs and lets work on everything together + +5. **Stored user messages: main and `packages/simulator`, not v2.** + + Source system and session: the same. Native locator: message + `msg_019fbe9a-2e94-7430-8da7-f71f0e533f15`; enclosing turn + `019fbe9a-2ddc-7cd1-b15b-c1447e2310aa`; top-level response-item message; + stored actor role `user`; `2026-08-01T18:33:23.349Z`. The source supplies + no parent locator. + + > this will go to main + + Source system and session: the same. Native locator: message + `msg_019fbe9c-4f9a-7970-adb5-15463aea8686`; enclosing turn + `019fbe9c-4ede-7d12-ae11-e054cf83a684`; top-level response-item message; + stored actor role `user`; `2026-08-01T18:35:42.874Z`. The source supplies + no parent locator. + + > the implementatiion will target packages/simulator, not v2 + +6. **Stored assistant issue-plan summary and directly following user start + directive.** + + Source system and session: the same. Native locator: message + `msg_0141f487830063b4016a6e40cd78048193bca36ecb2c05a8a2`; enclosing turn + `019fbe82-b219-7da0-b12e-428543cb9e76`; top-level response-item message; + stored actor role `assistant`; phase `final_answer`; parent locator absent; + `2026-08-01T20:45:27.703Z`. + + > Done — [issue #936](https://github.com/chughtapan/moltzap/issues/936) now defines the final shape: + > + > - Targets `main` and `packages/simulator`; no implementation under `v2/*`. + > - One immutable `RunSpec` containing the roster and customer `execute` Effect. + > - One Kubernetes backend with local kind and regional GKE profiles. + > - Aggregate Kueue admission, Agent Sandbox participants, and one Temporal workflow per run. + > - Exact-generation readiness barrier and exactly-once customer-program invocation. + > - Ten implementation slices, explicit failure semantics, test ownership, security gates, GKE parity, and scale qualification. + > - Engineering review: clean, 28 findings incorporated, zero unresolved decisions or critical gaps. + > + > Current worktree: + > + > - Path: `/home/tapanc/moltzap-pr-917-main` + > - Branch: `impl/917-main-local-society` + > - HEAD: `515aa590` + > - Status: clean + > - Relative to `origin/main`: 12 behind, 1 ahead + > - The ahead commit is still the original example implementation; I did not modify or rebase it. + > + > The next slice is Slice 0: admit the main-track replacement ADR and lock the `RunSpec`, `Run.execute`, and declarative Kubernetes profile contracts. Slice 1 is then the upstream compatibility lab before implementation commits begin. + + Source system and session: the same. Native locator: message + `msg_019fbf10-e051-75d0-92d7-bfb32174edfb`; enclosing turn + `019fbf10-debf-78b3-b71f-5bb6e72b88fe`; top-level response-item message; + stored actor role `user`; `2026-08-01T20:45:27.704Z`. The source supplies + no parent locator. + + > start + +7. **Stored user work directive: issue #936, durable issue notes, and an + end-to-end evaluation run.** + + Source system and session: the same. Native locator: message + `msg_019fbf11-b878-7e83-902a-db4e3868e856`; enclosing turn + `46fbcdbe-0654-4ba4-8e69-d2de6baaa959`; top-level response-item message; + stored actor role `user`; `2026-08-01T20:43:57.432Z`. The outer goal wrapper + is omitted; the objective is literal. The source supplies no parent + locator. + + > you are now working on https://github.com/chughtapan/moltzap/issues/936 in /home/tapanc/moltzap-pr-917-main. keep your durable notes updated on the issue as comments. run the implementation end-to-end running the evals through this new path + +8. **Earlier stored selections: one run, one container per agent, a strict + gate, and ten agents before scale.** These events were previously compacted + in `docs/decision-evidence/20260729-distributed-society-execution-trajectory.md` + on candidate commit `a2b55f32e8b8cc688c8a290972267492a3dbfc0b`. They + are repeated here because that candidate belongs to the v2 branch while the + current decision belongs to main. The literal option text and results are + unchanged. + + Source system: Codex. Source session + `019fab08-15ca-7a10-a9af-f2a8441a45f5`; enclosing turn + `019fab0d-a1e8-7432-b3f6-a767cff72c52`; function call + `call_vlz2QouoKyvTCXhmbDB9Hiny` at `2026-07-28T23:39:27.783Z` and + function-call output at `2026-07-28T23:39:43.060Z`; stored actor role is + absent on the call and output. + + > Single-run cluster (Recommended): Provision one society for one Society.run, require every agent to be alive at a cohort gate, dispatch the program once, then tear down; stage validation from 100 to 1,000 to 10,000 without router HA or a queue/operator. + + > `{"answers":{"cluster_scope":{"answers":["Single-run cluster (Recommended)"]}}}` + + Source system and session: the same. The same enclosing turn; function call + `call_PU6nJTGPlpeJ3PATixSc2ef8` at `2026-07-28T23:42:00.531Z` and + function-call output at `2026-07-28T23:43:52.229Z`; stored actor role is + absent. + + > 2A Strict gate (Recommended): Human ~3–5d / agent ~1–2h; medium implementation risk, low maintenance. Pros: one bulk router-visible snapshot proves the whole cohort is online; any pre-gate exit aborts with typed evidence and scoped cleanup. Con: adds a cohort-ready phase and batching contract. + + > `{"answers":{"plan_eng_review_cohort_gate":{"answers":["2A Strict gate (Recommended)"]}}}` + + Source system and session: the same. The same enclosing turn; direct user + message at `2026-07-29T00:03:38.313Z`; stored actor role `user`. The source + supplies no separate message id or parent locator. + + > I don't want to support that cheating. I want one container per agent + + Source system and session: the same. The same enclosing turn; function call + `call_J4GjN5U25rt7aNh4Jo8eY8L9` at `2026-07-28T23:45:04.450Z` and + function-call output at `2026-07-28T23:46:37.089Z`; stored actor role is + absent. The prompt offered tiered, live-model, and infrastructure-only 10k + gates. No offered option was selected. + + > `{"answers":{"plan_eng_review_10k_acceptance":{"answers":["None of the above","user_note: defer 4A and 4B scale. lets get to 10 agents first and then scale"]}}}` + +9. **Earlier stored selections: Kubernetes, Kueue, Temporal, GKE, and the + experiment-facing surfaces.** These events have the same source session and + prior checked-in trajectory as item 8. + + Direct user messages in turn + `019fab37-ced4-7b41-8e9c-37c3822a7342`, stored actor role `user`, with no + separate message id or parent locator, were recorded at + `2026-07-29T00:14:21.664Z` and `2026-07-29T00:16:16.056Z`: + + > or general kubernetes; we start with basic OpenClaw image and we can deliver instructions to connect to moltzap over the principal channel (which should work directly with the base image); increases the latency per experiment but that's the gold standard path + + > if it can't thats a bug: having this image can be an optimization but not a requirement; and honestly if we use GKE or barebones K8s we can actually point them to a private registry? + + Source system and session: the same. The same enclosing turn; function call + `call_SnFa3x3617eQul6H1zPNZeCm` at `2026-07-29T00:19:00.774Z` and + function-call output at `2026-07-29T00:19:34.352Z`; stored actor role is + absent. + + > Temporal + Kueue (Recommended): Temporal owns durable run/sweep lifecycles, Kueue admits cluster capacity, and no Redis queue is added. + + > `{"answers":{"run_queue_model":{"answers":["Temporal + Kueue (Recommended)"]}}}` + + Source system and session: the same. The same enclosing turn; function call + `call_mbMK8n64ZfjzAGVA69nzjhIw` at `2026-07-29T00:20:26.953Z` and + function-call output at `2026-07-29T00:21:21.293Z`; stored actor role is + absent. + + > Local first, defer prod: Use the local Temporal dev server for the first milestones and leave production hosting deliberately unselected. + + > `{"answers":{"temporal_hosting":{"answers":["Local first, defer prod"]}}}` + + Source system and session: the same. The same enclosing turn; function call + `call_8Tj66rC9ATIk5wZqXIiFtRia` at `2026-07-29T00:28:04.980Z` and + function-call output at `2026-07-29T00:28:15.312Z`; stored actor role is + absent. + + > Standard regional (Recommended): Use a pre-sized dedicated agent node pool for predictable 1k–10k cohort admission and tuning. + + > `{"answers":{"gke_profile":{"answers":["Standard regional (Recommended)"]}}}` + + Source system and session: the same. The same enclosing turn; function call + `call_0HQBCkj6yDpE4i7yXzEsTp8g` at `2026-07-29T00:30:27.347Z` and + function-call output at `2026-07-29T00:31:56.397Z`; stored actor role is + absent. + + > In-cluster controller (Recommended): A stable controller image fetches the content-addressed experiment bundle, owns the router/barrier, and runs close to all agents. + + > `{"answers":{"program_location":{"answers":["In-cluster controller (Recommended)"]}}}` + + Source system and session: the same. The same enclosing turn; function call + `call_0OO9tWVFfZHYPNu61PoPXcqN` at `2026-07-29T00:37:27.742Z` and + function-call output at `2026-07-29T00:38:23.511Z`; stored actor role is + absent. + + > CLI + library (Recommended): Provide a `moltzap simulator run ` command backed by a reusable TypeScript submission API. + + > `{"answers":{"submission_surface":{"answers":["CLI + library (Recommended)"]}}}` + + Source system and session: the same. The same enclosing turn; function call + `call_z5VtaeUzaAe4BaD0DJh3UnVU` at `2026-07-29T00:41:44.786Z` and + function-call output at `2026-07-29T00:43:35.416Z`; stored actor role is + absent. + + > Terraform + Helm (Recommended): Make cluster, IAM, registry, storage, node pools, and pinned Kueue installation reproducible. + + > `{"answers":{"gcp_iac":{"answers":["Terraform + Helm (Recommended)"]}}}` + +10. **Earlier Agent Sandbox selection.** This event was previously compacted + in + `docs/decision-evidence/20260730-distributed-society-execution-agent-sandbox-trajectory.md` + on candidate commit `a2b55f32e8b8cc688c8a290972267492a3dbfc0b`. + + Source system: Codex stored-session interaction. Source session + `019fab08-15ca-7a10-a9af-f2a8441a45f5`; enclosing turn + `019faffd-b6a0-7b90-bcc2-e6f59ba339dd`; native call + `call_wGDKczyyYEXYTVNWIhEoXYbN`; request timestamp + `2026-07-29T23:28:32.756Z`; result timestamp + `2026-07-29T23:29:50.407Z`; stored actor role `user`. + + The preceding agent prompt offered this choice: + + > Agent Sandbox gold (Recommended): Direct Sandbox CRs retain one stable logical agent while their backing Pods can restart. + + The stored result was: + + > `{"answers":{"gold_backend":{"answers":["Agent Sandbox gold","user_note: lets see what do we need to revisit? go through the provenence of our decisions regarding why we made them and estimate what are the tradeoofs"]}}}` + +11. **Mechanical repository and GitHub events.** These record execution state, + not human rationale. + + Source system: git. On 2026-08-01 the worktree branch + `impl/917-main-local-society` merged `origin/main` revision `314ece9e` in + commit `2d3fc41295ae66b95d19c0df2d448a41781c9b07`. The merged baseline passed + the simulator and eval build, test-typecheck, lint, and test targets: 215 + simulator tests and 75 eval tests. + + Source system: GitHub. Issue + `https://github.com/chughtapan/moltzap/issues/936` holds the agent-maintained + non-normative implementation plan. Durable checkpoint comments were posted + as issue comments `5153357233` at `2026-08-01T20:46:52Z` and `5153393832` + at `2026-08-01T20:54:08Z`; the second was last updated at + `2026-08-01T21:02:01Z`. Issue comment `5153770731`, stored actor/account + `chughtapan`, was posted at `2026-08-01T22:35:05Z` and records the first + candidate's failed review plus the correction gate. The issue body and + comments are agent-published mechanical artifacts, not independent + human-authored rationale. Issue comment `5173168998`, also stored under + account `chughtapan`, records the acceptance checkpoint after the live user + reply; the connector exposed no creation timestamp, so none is invented. + + Source system: git and isolated Codex review. The simplified candidate was + frozen as commit `1939ee8b92e95151473c323de8dd702e880dbde5`, tree + `b2a487141545e4cced7bc7ab0e0d08f344cebea3`. Fresh reviewer + `/root/candidate_blind_review_2` ran from `2026-08-04T00:32:44Z` through + `2026-08-04T00:43:38Z` with no author intervention. Its overall result was + `FAIL`: questions 1, 2, and 4 passed; questions 3, 5, and 6 failed because + the candidate required every code peer to run in its own container while + retaining the earlier host-local `effectRuntime({ build })` gateway and + shared-state realization. The unedited result is retained at + [`20260804-main-kubernetes-society-execution-cold-review.md`](./20260804-main-kubernetes-society-execution-cold-review.md). + + Source system: GitHub. Issue comment `5173321800`, stored under account + `chughtapan`, records the failed-review checkpoint and correction gate. The + connector exposed no creation timestamp, so none is invented. This is an + agent-published mechanical artifact, not human rationale. + + Source system: git and isolated Codex review. The corrected candidate was + frozen as commit `2749adbd99eaffd16f063a45de7be01c253f7ef1`, tree + `ce6655004c93d03e6276a07756a5086ce68aa662`. Different fresh reviewer + `/root/candidate_blind_review_3` ran from `2026-08-04T01:03:05Z` through + `2026-08-04T01:10:58Z` with no author intervention or repository + modification. All six questions passed and the overall result was `PASS` + with no blockers. The reviewer states that maintainer acceptance remains + required because the result is not self-certifying. The unedited result is + retained at + [`20260804-main-kubernetes-society-execution-second-cold-review.md`](./20260804-main-kubernetes-society-execution-second-cold-review.md). + +Source gaps, stated plainly: + +- The retained Codex events supply no parent locator. Their message id, + session, enclosing turn, event kind, exact timestamp, and stored actor role + are retained; no missing locator is invented. +- The assistant proposal is an agent event. The terse `okay do this` is read + only with that directly preceding retained proposal; it is not independent + rationale for every later mechanism. +- The retained assistant example places `infrastructure` inside the RunSpec. + The simplified candidate retains that placement. No retained user event + chooses the exact Layer-constructor spelling, so the example's + `infrastructure` value remains the binding shape while its construction is + ordinary implementation detail. +- The accepted assistant proposal states that deterministic peer behavior + belongs inside agent containers, and the earlier retained selection requires + one container per agent. The source does not choose a bridge transport or + wire schema. The corrected candidate therefore admits only the minimum + runtime-specific controller bridge needed to expose each container + runtime's exact gateway and termination observation; exact transport details + remain private implementation choices, not a universal gateway contract. +- The retained `start` reply is read only with the immediately preceding issue + summary. That summary stated exactly-once customer-program invocation. The + simplified candidate instead says that the controller invokes the customer + Effect once and does not replay it, while making no exactly-once claim for + external effects. That wording is not silently attributed to the terse + `start` reply; the later explicit acceptance retained below resolves it. +- The user chooses main, the core simulator, one RunSpec/execute model, the + GKE sandbox work, Kubernetes/Kueue/Temporal, local or GKE profiles, durable + issue notes, and end-to-end eval execution. The retained events do not + separately state reasons for every resource shape, failure variant, + security control, event field, or platform mechanism in the ADR. +- Exact upstream versions, API schemas, chart/provider choices, timeouts, + storage mechanisms, cost budgets, generation protocols, artifact + authorities, identity derivations, and recovery schemes are not human + decisions in these excerpts. The simplified ADR treats them as private + implementation details, deliberate deferrals, or non-goals rather than + attributing them to the decision-maker. +- Two later live user messages rejected the overbuilt candidate as + overcomplicated experiment infrastructure and directed that the previous + checked-in requirement conversations form the boundary, with anything not + discussed there treated as a non-goal. The workspace-readable session logs + checked on 2026-08-01 did not contain those two messages, so no native + message id, enclosing turn, timestamp, or actor-role record is invented. + Their literal text is retained in this source-gap report: + + > this is overcomplicated. This is simulation infrastructure for experiments. Don't over complicate + + > you can go throught he previous conversations that are checked-in regarding the requirements. anything which is not discussed there is a NON GOAL +- The immediately following live exchange accepted the simplified ADR. The + workspace-readable session logs checked on 2026-08-03 did not contain the + exchange, so no native message id, enclosing turn, timestamp, parent locator, + or stored actor-role record is invented. The public assistant prompt and + terse user reply are both retained because the reply has no independent + meaning without the prompt. + + The assistant prompt was: + + > Done. The plan now treats checked-in requirement conversations as the hard boundary. + > + > Final shape: + > + > - `RunSpec { id, events, agents, infrastructure, execute }` + > - `Run.execute(spec)` + > - Local Kubernetes or GKE via the infrastructure Layer + > - Kueue admission → one Sandbox/container per agent → full-roster gate → execute once → existing ledger/outcome → Temporal cleanup + > - Two-agent, ten-agent, and all 32 OpenClaw/NanoClaw evaluation runs + > - No generations, artifact authority, start-or-attach system, custom serialization grammar, or recovery platform + > + > [Issue #936](https://github.com/chughtapan/moltzap/issues/936) and the 244-line ADR now match. Formatting, docs, Mermaid, and repository gate checks pass. + > + > One explicit ADR decision remains: if the controller fails, the run fails and cleans up; `execute` is never replayed, with no exactly-once guarantee for external effects. + > + > If you accept that final shape, say **“accept this ADR”** and I’ll freeze it, run the fresh blind review, then start implementation. + + The user replied exactly: + + > accept this ADR + + This records acceptance of the preceding proposal, including its explicit + controller-failure and no-replay wording. It does not add rationale or + authorize mechanisms absent from that proposal and the earlier retained + requirements. +- The GitHub issue body and checkpoint prose were composed and updated by the + agent. They preserve the current mechanical plan but do not replace the + human source events above. +- Irrelevant tool output, private system and developer instructions, hidden + reasoning, environment diagnostics, and credential values are omitted. No + private session URL or Secret value is retained. + +## Later corrections + +Dated additions recording what a later reading found. The retained events above +are unchanged; nothing here rewrites them. + +### 2026-08-06 — the field is named `cluster`, and no retained event chose it + +The source-gap note above states that the example's `infrastructure` value +"remains the binding shape". That is no longer true of the admitted record, +which names the field `cluster`, matching `packages/simulator/src/definition.ts` +and the orientation docs. No retained event chooses either spelling, so this +remains a gap in the ledger rather than a human call it can cite. The rename is +recorded as a point correction in the decision's own changelog. + +### 2026-08-06 — the cohort-size gate no longer names a number + +The only retained human statement on cohort size is `lets get to 10 agents +first and then scale`, and the accepted final-shape prompt says `Two-agent, +ten-agent, and all 32 OpenClaw/NanoClaw evaluation runs`. A later amendment +replaced ten with four while citing no event, and the profile tooling continued +to enforce ten. The blind review recorded at +[`20260806-main-kubernetes-society-execution-third-cold-review.md`](./20260806-main-kubernetes-society-execution-third-cold-review.md) +reported that contradiction against candidate `78ff2f94`. + +A live exchange then directed one end-to-end experiment sized by its run rather +than any fixed number, accepted point corrections to the record, and stated the +reason autoscaling was selected. The workspace-readable session logs checked on +2026-08-06 did not contain that exchange, so no native message id, enclosing +turn, timestamp, parent locator, or stored actor-role record is invented. Its +literal text is retained here: + +> okay, so we have run hundred. that's fine. also, instead of making it the thing be specific to number of agents, just make it an end-to-end test for the simulator that can run with varying numbers of agents. that's good enough. for the other things fine to update the ADRs using point changes: autoscaling was selected because it was easier simply + +`we have run hundred` is the only statement retained about the hundred-agent +run. No source event states where that run's evidence lives, and this ledger +does not supply one; the repository records the exported ledger's location +nowhere, so a reader cannot verify the run from the repository alone. + +The excerpt directs the end-to-end change and accepts point corrections. It +does not mention the scale-claim non-goal, and no retained event states whether +dropping `100-` from that list was intended. **No source event located** for +that specific removal. diff --git a/docs/decision-evidence/20260804-main-kubernetes-society-execution-cold-review.md b/docs/decision-evidence/20260804-main-kubernetes-society-execution-cold-review.md new file mode 100644 index 000000000..5a8271b18 --- /dev/null +++ b/docs/decision-evidence/20260804-main-kubernetes-society-execution-cold-review.md @@ -0,0 +1,366 @@ +# Blind teammate review + +**Overall result: FAIL** + +The candidate leaves a binding cross-process runtime/gateway boundary unresolved. The Kubernetes ADR requires every code/Effect peer to run in its own Sandbox container while the controller receives that peer’s exact native gateway. The retained gateway ADR defines those gateways as in-process values and forbids adding a generic proxy protocol. + +## Audit record + +- Candidate commit: `1939ee8b92e95151473c323de8dd702e880dbde5` +- Candidate tree: `b2a487141545e4cced7bc7ab0e0d08f344cebea3` +- Branch: `impl/917-main-local-society` +- Worktree: clean at start and end; branch was 15 commits ahead of its tracking branch +- Review start: `2026-08-04T00:32:44Z` +- Review end: `2026-08-04T00:43:38Z` +- Duration: 654 seconds (`00:10:54`) +- Reviewer identity: `/root/candidate_blind_review_2` +- Author interventions: none +- Repository modifications: none +- Mechanical checks: + - `git diff --check origin/main...HEAD`: passed + - `pnpm docs:check`: passed with no broken links +- Isolation attestation: I received only the repository root, the fixed questions, and isolation instructions. I did not author or reconcile the candidate, receive a design summary, diff tour, ADR pointer, search term, expected result, or earlier review output. I did not ask questions or accept hints. +- Quarantine attestation: directory/diff listings exposed the path `docs/decision-evidence/20260801-main-kubernetes-society-execution-cold-review.md`, which is explicitly allowed. I never opened it or searched its contents. Every `rg` command excluded `**/*-cold-review.md` and `**/*invalid-review*`; no quarantined answer or verdict content was returned. + +## Exact prompt + +1. What decision does this candidate make current, what problem does it resolve, and which statements are binding versus context or non-normative explanation? +2. What earlier outcomes does it replace, retain, or leave untouched, and where does the current normative contract live? +3. What must an implementer now do or avoid, which layers or consumers are affected, and under what fault, trust, safety, liveness, and compatibility assumptions? +4. Which humans are named as decision-makers, which source events does the compacted trajectory cite for their calls, alternatives, reversals, and deferrals, and what source gaps does it explicitly record? Report only what the event ledger states; do not infer motives, confidence, urgency, or rationale. +5. Find the strongest apparent contradiction, stale instruction, or broken lineage elsewhere in the repository. Resolve it using the authority order or report it as a blocker. +6. Could a teammate implement the decision without chat or guessing? List every missing link or unresolved choice and classify each as a deliberate deferral or an accidental gap. + +## Independently discovered paths and headings + +- `AGENTS.md` → “Architecture decision records”, “Blind teammate review gate”, “Docs” +- `docs/decisions/README.md` → “Canonical reading guidance”, “Records” +- `docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md` → all headings under “Decision Outcome”, “Non-goals”, “Current owners and earlier outcomes” +- `docs/decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md` → “The main simulator runs container societies on Kubernetes”, “Source gaps, stated plainly” +- `docs/decisions/20260727-code-first-simulator-kernel.md` → “Supersession”, “Public Boundary” +- `docs/decisions/20260729-principal-io-uses-runtime-gateways.md` → “One society, two interaction boundaries”, “Runtime contract and keyed gateway types”, “Scenario ownership”, “Normative Owners”, “Consequences” +- `docs/decisions/20260729-effect-native-evaluation-results.md` → “Supersession”, “Trust, availability, and compatibility” +- `packages/simulator/AGENTS.md` → “Boundary”, “Laws”, “Structure” +- `packages/evals/README.md` → “Execution model” +- `packages/simulator/src/runtime/runtime.ts` → `AgentRuntimeDefinition`, `RunningAgent` +- `packages/simulator/src/runtime/effect.ts` → `EffectAgent`, `effectRuntime` +- `packages/evals/src/peer.ts` → `peerRuntime` +- `v2/AGENTS.md` → “Authority and reading order”, “Simulator provenance gate” +- `docs/decisions/20260728-simulator-is-the-system-driver.md` → “Decision Outcome” +- Transitional documentation in the root, simulator, eval, and example READMEs + +## Answers + +### 1. Current decision, problem, and authority + +The candidate makes current a main/v1 production contract in which: + +- An experiment is one code-first `RunSpec` containing `id`, events, an exact keyed runtime roster, an infrastructure Layer, and one customer `execute` Effect. +- `Run.execute(spec)` is the new execution entry point. +- Local Kubernetes and GKE are profiles of one private Kubernetes path. +- Each execution creates one society: Temporal starts one coarse workflow, Kueue admits the complete roster, one Agent Sandbox/application container is created per roster entry, the exact roster passes one readiness gate, the in-cluster controller invokes `execute` once, existing simulator evidence is retained, and Temporal drives cleanup. +- Principal control continues through exact runtime-native gateways; social traffic continues through the production MoltZap router. +- The old host `simulator.define(...).run(...)` path and Docker example are transitional and removed only after replacement evidence exists. + +It resolves the gap between an example-only local Docker proof and the requested core simulator path capable of running the same experiment society on local Kubernetes or GKE. + +Binding material is: + +- The accepted frontmatter and “Scope and authority”. +- The complete “Decision Outcome”, including acceptance gates and “Non-goals”. +- “Current owners and earlier outcomes”. +- The explicit retained/replaced scope in the predecessor ADR’s “Supersession” section. + +The `RunSpec.infrastructure` field placement is binding. The trajectory explicitly classifies exact Layer-constructor spelling as implementation detail. + +“Context and Problem Statement” and “Consequences” explain the decision. Source trajectories, issue comments, git history, earlier implementation plans, and old ADR context/implementation-plan prose are non-normative. + +**Verdict: PASS** + +### 2. Replacement, retention, and current normative owners + +The candidate replaces only these main/v1 portions of `20260727-code-first-simulator-kernel.md`: + +- Public `simulator.define(...).run(...)` naming. +- The host-only concrete execution path. + +That predecessor is now `partially-superseded`, names the new ADR as its primary `superseded-by`, and visibly retains: + +- Code-first TypeScript/Effect authoring. +- Closed typed EventCatalog. +- Typed RunLedger and producer-bound writers. +- Exact keyed runtime gateways. +- Customer-owned scenario, sweep, completion, and grading policy. +- The single `@moltzap/simulator` package. +- The production v1 router/protocol and absence of social callback shortcuts. + +`20260729-principal-io-uses-runtime-gateways.md` remains accepted and governs principal gateways, exact gateway types, social-router traffic, mixed societies, termination policy, and behavioral evidence. + +`20260729-effect-native-evaluation-results.md` remains partially current for cases, grading, report resume, SQLite, and Phoenix; its synthetic-sender portions remain replaced by the principal-gateway ADR. + +The v2 simulator driver, v2 package ownership, `Simulator.define`, and v2 distributed-execution contracts remain untouched. The apparent v1/v2 naming difference is explicitly scoped by both tracks. + +Current normative authority therefore lives in the new main Kubernetes ADR together with the explicitly retained code-first ADR scope and accepted principal-gateway ADR. `packages/simulator/AGENTS.md` repeats the intended package-level implementation laws. Trajectories are provenance, not authority. + +The decision index, frontmatter status, visible supersession section, and normative-owner statements agree. + +**Verdict: PASS** + +### 3. Implementation obligations and assumptions + +An implementer must: + +- Add `RunSpec` and `Run.execute` to `packages/simulator`. +- Keep the roster, event, ledger, network, outcome, and exact gateway concepts rather than introduce another simulator model. +- Hide Kubernetes, Kueue, Agent Sandbox, Temporal, Helm, Terraform, and cloud-provider objects behind the Effect Layer/private platform boundary. +- Supply local-Kubernetes and GKE profiles through the same execution path. +- Admit the complete roster, create one Sandbox/application container per entry, wait for exact readiness, invoke the customer Effect once without replay, preserve evidence, and clean all run-owned resources. +- Preserve native principal control and production-router social traffic. +- Migrate all 32 OpenClaw/NanoClaw evaluation cells before deleting the host path. +- Prove the fake-platform, two-agent, ten-agent, GKE, evaluation, and zero-residue acceptance gates. + +It must avoid compatibility aliases, a Docker backend, warm pools, public Kubernetes objects, generation/rebind/recovery APIs, customer Effect replay, exactly-once external-effect claims, artifact authority, global execution identities, custom serialization grammar, per-agent Temporal workflows, new schedulers, and premature scale claims. + +Affected owners are `packages/simulator` and its private platform implementation; `packages/evals` remains a consumer. No `v2/*` contract changes. + +The discoverable assumptions are: + +- Autonomous agents may ignore instructions, misbehave, terminate, or remain unavailable. +- Gateway adapters and simulator evidence machinery are trusted evaluation instruments. +- Controller or platform loss is an infrastructure failure and starts cleanup. +- Service availability affects progress and operational results, not behavioral truth. +- Complete-roster readiness is the pre-dispatch safety gate. +- Post-dispatch runtime termination is evidence interpreted by customer policy. +- The controller does not replay `execute`; this is not exactly-once safety for external effects. +- Temporal/Kubernetes status is operational observation, not simulator evidence authority. +- Production Temporal HA, router HA, autoscaling, recovery, and scale beyond ten agents are not claimed. +- The v2 Byzantine/fault assumptions do not silently apply to this v1 decision. + +However, the implementation obligations are not mutually satisfiable for current code/Effect runtimes. The retained exact in-process gateway contract has no selected cross-container representation, described under question 5. + +**Verdict: FAIL** + +### 4. Decision-makers, events, alternatives, reversals, deferrals, and source gaps + +The sole human named in ADR frontmatter is **Tapan Chugh**. The event ledger identifies stored actors as `user`, `assistant`, absent, or mechanical accounts; it does not independently map each stored `user` event to Tapan. I do not infer such a mapping. + +The main trajectory cites: + +- Codex session `019fbbdd-7cff-7753-8541-4f66f0248d43`: + - `msg_019fbbe1-770d-7d11-8475-0f2f7b3bd7b1` and `msg_019fbdeb-1743-7470-be76-7ed53d7f2420`: target main first and make the work part of the core simulator. + - `msg_019fbded-2372-72b0-b859-61f6fe80ac47`: plan the final shape before implementation. + - Assistant `msg_0141f487830063b4016a6e17e648d481939b073eea4e50a234`, followed by user `msg_019fbe0e-7474-7e53-9f4e-40faac7ac654`: one `RunSpec`, one customer `execute` callback, and `Run.execute`; the user reply is `okay do this`. + - `msg_019fbe84-b81b-7312-ad62-03432f57cdf2` and `msg_019fbe88-7cd4-7c62-9b8c-e9060c44f8d8`: pull GKE Sandbox into the core and use Kubernetes, Kueue, Temporal, and local/GKE targets. + - `msg_019fbe9a-2e94-7430-8da7-f71f0e533f15` and `msg_019fbe9c-4f9a-7970-adb5-15463aea8686`: main and `packages/simulator`, not v2. + - Assistant `msg_0141f487830063b4016a6e40cd78048193bca36ecb2c05a8a2`, immediately followed by user `msg_019fbf10-e051-75d0-92d7-bfb32174edfb`: the overbuilt issue-plan summary and contextual reply `start`. + - `msg_019fbf11-b878-7e83-902a-db4e3868e856`: work on issue #936, keep issue notes, and run evaluations end to end through the new path. +- Earlier Codex session `019fab08-15ca-7a10-a9af-f2a8441a45f5`: + - `call_vlz2QouoKyvTCXhmbDB9Hiny`: selected one single-run society. + - `call_PU6nJTGPlpeJ3PATixSc2ef8`: selected a strict cohort gate. + - Direct user event at `2026-07-29T00:03:38.313Z`: one container per agent. + - `call_J4GjN5U25rt7aNh4Jo8eY8L9`: rejected the offered larger-scale gates and deferred them until ten agents. + - Direct user events at `2026-07-29T00:14:21.664Z` and `00:16:16.056Z`: general Kubernetes, stock OpenClaw compatibility, and prebuilt images only as optimization. + - `call_SnFa3x3617eQul6H1zPNZeCm`: Temporal plus Kueue. + - `call_mbMK8n64ZfjzAGVA69nzjhIw`: local Temporal first, production hosting unselected. + - `call_8Tj66rC9ATIk5wZqXIiFtRia`: regional GKE Standard. + - `call_0HQBCkj6yDpE4i7yXzEsTp8g`: in-cluster controller. + - `call_0OO9tWVFfZHYPNu61PoPXcqN`: CLI plus library. + - `call_z5VtaeUzaAe4BaD0DJh3UnVU`: Terraform plus Helm. + - `call_wGDKczyyYEXYTVNWIhEoXYbN`: Agent Sandbox. +- Mechanical events: + - Merge commit `2d3fc41295ae66b95d19c0df2d448a41781c9b07`. + - GitHub issue comments `5153357233`, `5153393832`, `5153770731`, and `5173168998`, all expressly classified as agent/mechanical artifacts rather than independent human rationale. + +The retained code-first trajectory cites session `019fa613-7f9a-7103-99b0-a42fda0754de` for code-first customer policy, a closed event universe, mixed societies, customer-owned runtime-termination policy, ledger terminology, Effect services/SQL, branded types, and one simulator package. + +The retained principal-gateway trajectory cites the same session, principally turn `39d5505f-efa9-417d-b97f-14af5a270f73` and attachment `f4eee480-6d7d-4bb2-b8e7-0d6c57e60b6e` with SHA-256 `23a57ba9d5b83e186006dcfa43960e70d734fec3b3cf3fc25f2be98008b71622`, for exact native gateways, no gateway union, no synthetic principal, native evidence correlation, `replyToId` removal, and behavioral-evaluation reclassification. Later cited events place restart/replacement outside v0, reject compatibility preservation, request Effect SQL/evaluation-result tooling, and state that a code agent’s Effect API is already its native gateway. + +The reversal is explicit: + +- The contextual `start` followed an agent-authored plan containing exact generations, start-or-attach machinery, and exactly-once invocation language. +- Two later live user messages, retained only in the source-gap report, rejected the overcomplicated design and made checked-in requirement conversations the boundary. +- A later assistant prompt presented the simplified shape, including no generations/artifact authority/start-or-attach/recovery and controller failure with no replay. +- The unlocated user reply was `accept this ADR`. + +Explicit source gaps include: + +- Current Codex events have no parent locator. +- Several earlier events have no separate message ID or stored actor role; the available session, turn/call, event kind, and timestamps are retained. +- Terse replies are meaningful only with their immediately preceding retained prompts. +- The final Layer-constructor spelling was never selected. +- Reasons were not separately stated for every resource shape, failure variant, security control, or mechanism. +- Versions, upstream API schemas, provider/chart choices, timeouts, storage mechanisms, generation protocols, artifact authorities, identity derivations, and recovery schemes were not human decisions in the excerpts. +- The two overcomplication-rejection messages and final acceptance exchange could not be recovered from workspace-readable session logs, so they have no native IDs, timestamps, parent locators, or stored actor-role record. +- The GitHub issue and comments are agent-authored mechanical artifacts; one comment has no exposed creation timestamp. +- The principal-gateway handoff does not locate its preceding conversations and does not choose concrete gateway APIs, commands, transports, or response shapes. + +These gaps are stated rather than silently repaired. + +**Verdict: PASS** + +### 5. Strongest contradiction or broken lineage + +The strongest contradiction is between two current main-track contracts. + +`20260729-principal-io-uses-runtime-gateways.md` says: + +- A code agent’s in-process Effect API is itself its native gateway. +- `effectRuntime({ build })` returns the exact customer gateway and autonomous behavior, which may share scoped Effect state. +- The simulator must not add a generic command queue, actor mailbox, second request protocol, or universal gateway normalization. +- Evaluation peers remain ordinary `effectRuntime({ build })` policies. + +The new Kubernetes ADR says: + +- Every roster entry, including real and code/scripted agents, is one Agent Sandbox application container. +- Infrastructure containers are not agents. +- A controller invokes the one customer `execute` Effect. +- That Effect retains the exact keyed runtime gateways. +- All 32 evaluation cells move through this path. + +The checked-in implementation confirms the collision: + +- `packages/simulator/src/runtime/effect.ts → EffectAgent` places `gateway` and `behavior` in the same acquired in-process runtime. +- `packages/evals/src/peer.ts → peerRuntime` uses a shared in-process `Deferred` as the peer’s observation gateway. +- `packages/evals/README.md` says every one of the 32 societies contains autonomous in-process Effect peers. +- `scriptedRuntime` appears only in the new ADR example; it has no checked-in contract or symbol. + +Once such a peer runs in its own Sandbox container, the controller cannot receive the same in-process gateway value. Resolving that requires one of: + +1. A remote proxy/serialization protocol for arbitrary gateway values. +2. Co-locating the code peer with the controller. +3. Removing or replacing `effectRuntime` peers from the Kubernetes path. + +Each option violates or changes a current binding statement. + +The authority order cannot resolve this. The new ADR expressly says the principal-gateway ADR remains current and replaces only public naming and the host execution path. Root ADR law prohibits silently replacing an accepted outcome. `packages/simulator/AGENTS.md` repeats both sides instead of selecting a reconciliation. + +Other apparent contradictions are resolved: + +- Old main documentation and Docker code are explicitly marked transitional until acceptance evidence exists. +- v2 continues to use `Simulator.define`, but both tracks explicitly scope that contract to v2. + +The runtime/gateway collision remains a blocker. + +**Verdict: FAIL** + +### 6. Implementability and unresolved choices + +No. A teammate cannot implement all binding requirements without inventing a new public or private contract that changes retained semantics. + +Accidental gaps: + +- No contract maps the existing executable `AgentRuntime.acquire` closure to a remotely deployed Sandbox application container. +- No contract transports an arbitrary exact `Gateway` and `termination` Effect from an agent container to the controller. +- No decision explains how `effectRuntime` builder closures and their shared scoped state execute remotely. +- `scriptedRuntime` is used in the normative example but is neither defined nor reconciled with the retained decision against a generic scripted-agent gateway. +- The required 32-cell migration cannot preserve the current in-process Effect peers without resolving those boundaries. +- Consequently, the “one container per roster entry”, “exact native gateway”, “controller invokes execute”, and “reuse rather than replace the existing runtime model” requirements cannot all be implemented simultaneously. + +Deliberate deferrals/non-goals: + +- Generation/rebind/rejoin/replacement/recovery APIs. +- Customer Effect replay and exactly-once external effects. +- Artifact authority, start-or-attach storage, global execution IDs, and normative Kubernetes naming. +- New serialization grammar and universal input/result/failure schemas. +- Public Kubernetes objects, arbitrary Pod templates, and per-agent Temporal workflows. +- Warm pools, multi-run scheduling, fairness, borrowing, preemption, autoscaling, router HA, and production Temporal HA. +- Scale qualification above ten agents. +- Nomad, Slurm, managed batch, and GKE Autopilot. +- Exact Secret-provider protocols, persistent state recovery, exhaustive NetworkPolicy, and general multi-tenant security. +- Exact upstream versions, API schemas, chart/provider selection, cache/transport details, timeouts, and storage mechanisms. +- Exact production Temporal hosting. + +Explicit provenance limitations, not silent design gaps: + +- Missing native locators/timestamps for the later rejection and final acceptance exchange. +- Missing independent human rationale for most private mechanisms. + +The accidental runtime/gateway gaps are architectural, not ordinary private Kubernetes mechanics. + +**Verdict: FAIL** + +## Blockers + +1. Define and admit how a separately containerized `effectRuntime` or customer code runtime exposes its exact gateway and termination observation to the controller without violating the retained prohibition on a generic second protocol. +2. Decide whether `scriptedRuntime` is a new public runtime contract, remove it from the binding example, or explicitly supersede the retained `effectRuntime` evaluation-peer requirement. +3. Update the ADR lineage, normative ownership, package instructions, and evaluation transition together once that choice is made, then freeze a new candidate for a different blind reviewer. + +## Discovery trail and commands + +All commands ran read-only from `/home/tapanc/moltzap-pr-917-main`. + +```text +date -u +'%Y-%m-%dT%H:%M:%SZ' +git rev-parse HEAD +git rev-parse HEAD^{tree} +git branch --show-current +git status --short --branch + +sed -n '1,280p' AGENTS.md +sed -n '281,560p' AGENTS.md +git log --oneline --decorate --graph -30 +git diff --name-status origin/main...HEAD +git diff --stat origin/main...HEAD + +sed -n '1,280p' docs/decisions/README.md +sed -n '1,340p' docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md +sed -n '1,460p' docs/decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md +sed -n '1,360p' docs/decisions/20260727-code-first-simulator-kernel.md +sed -n '361,720p' docs/decisions/20260727-code-first-simulator-kernel.md +sed -n '1,360p' docs/decisions/20260729-principal-io-uses-runtime-gateways.md +sed -n '1,340p' docs/decisions/20260729-effect-native-evaluation-results.md + +rg -n --glob '!**/*-cold-review.md' --glob '!**/*invalid-review*' '^## |^Source gaps' docs/decision-evidence/20260727-code-first-simulator-trajectory.md docs/decision-evidence/20260729-principal-runtime-gateway-trajectory.md +sed -n '1,190p' docs/decision-evidence/20260727-code-first-simulator-trajectory.md +sed -n '1,330p' docs/decision-evidence/20260729-principal-runtime-gateway-trajectory.md + +rg -n --hidden --glob '!**/*-cold-review.md' --glob '!**/*invalid-review*' --glob '!node_modules/**' --glob '!.git/**' 'simulator\.define|Simulator\.define|RunSpec|Run\.execute|Docker execution backend|Kubernetes' . +sed -n '1,240p' packages/simulator/AGENTS.md +sed -n '140,230p' README.md +sed -n '1,130p' packages/simulator/README.md +sed -n '1,260p' docs/simulator/running.mdx +sed -n '1,140p' examples/simulator/README.md + +sed -n '1,180p' v2/AGENTS.md +sed -n '1,280p' docs/decisions/20260728-simulator-is-the-system-driver.md +sed -n '1,220p' v2/inputs/simulator-handoff-20260728.md + +rg -n --hidden --glob '!**/*-cold-review.md' --glob '!**/*invalid-review*' --glob '!node_modules/**' --glob '!.git/**' 'start-or-attach|generation (API|stream|identifier)|exactly-once|at-most-once|artifact authority|execution-id|Temporal.*replay|replay.*Temporal' . +rg -n --glob '!**/*-cold-review.md' --glob '!**/*invalid-review*' 'docs:check|check:links|mermaid' package.json tools packages -g 'package.json' -g 'project.json' -g '*.ts' -g '*.mjs' +sed -n '1,320p' .github/workflows/ci.yml + +git diff --check origin/main...HEAD +pnpm docs:check +git status --short --branch +git rev-parse HEAD +git rev-parse HEAD^{tree} + +rg -n --glob '!**/*-cold-review.md' --glob '!**/*invalid-review*' 'decision-evidence|decision-makers|partially-superseded|MADR|ADR' scripts tools package.json -g '*.ts' -g '*.mjs' -g '*.json' + +rg -n --glob '!**/*-cold-review.md' --glob '!**/*invalid-review*' 'effectRuntime|defineRuntime|scriptedRuntime' packages/simulator/src packages/evals/src docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md packages/simulator/AGENTS.md +sed -n '200,320p' packages/simulator/src/runtime/runtime.ts +sed -n '1,330p' packages/simulator/src/runtime/effect.ts +sed -n '430,530p' packages/evals/src/peer.ts + +git diff origin/main...HEAD -- docs/decisions/20260729-principal-io-uses-runtime-gateways.md docs/decisions/20260729-effect-native-evaluation-results.md +git rev-parse origin/main +git merge-base origin/main HEAD +git diff --name-status origin/main..HEAD -- docs/decisions/20260729-principal-io-uses-runtime-gateways.md docs/decisions/20260729-effect-native-evaluation-results.md +git diff --name-status origin/main...HEAD -- docs/decisions/20260729-principal-io-uses-runtime-gateways.md docs/decisions/20260729-effect-native-evaluation-results.md +git diff --name-status origin/main...HEAD + +sed -n '1,120p' packages/evals/README.md +sed -n '1,130p' packages/evals/src/README.md +sed -n '1,130p' packages/simulator/src/runtime/runtime.ts + +rg -n --hidden --glob '!**/*-cold-review.md' --glob '!**/*invalid-review*' --glob '!node_modules/**' --glob '!.git/**' '\bscriptedRuntime\b|\beffectRuntime\b' . + +review_end=$(date -u +'%Y-%m-%dT%H:%M:%SZ') +review_start_epoch=$(date -u -d '2026-08-04T00:32:44Z' +%s) +review_end_epoch=$(date -u -d "$review_end" +%s) +review_duration_seconds=$((review_end_epoch-review_start_epoch)) +printf '%s\n' "$review_end" "$review_duration_seconds" +git status --short --branch +git rev-parse HEAD +git rev-parse HEAD^{tree} +``` diff --git a/docs/decision-evidence/20260804-main-kubernetes-society-execution-second-cold-review.md b/docs/decision-evidence/20260804-main-kubernetes-society-execution-second-cold-review.md new file mode 100644 index 000000000..05f862599 --- /dev/null +++ b/docs/decision-evidence/20260804-main-kubernetes-society-execution-second-cold-review.md @@ -0,0 +1,348 @@ +# Blind teammate review + +## Audit metadata + +- Candidate root: `/home/tapanc/moltzap-pr-917-main` +- Candidate commit: `2749adbd99eaffd16f063a45de7be01c253f7ef1` +- Candidate tree: `ce6655004c93d03e6276a07756a5086ce68aa662` +- Review start: `2026-08-04T01:03:05Z` +- Review end: `2026-08-04T01:10:58Z` +- Duration: 473 seconds (`00:07:53`) +- Reviewer: `/root/candidate_blind_review_3` +- Author interventions: none +- Repository modifications: none; final `git status --porcelain=v1` was empty. + +## Isolation attestation + +I did not author or reconcile this candidate. I received no design summary, diff tour, ADR/file pointer, search term, expected answer, inherited conversation, compaction, private state, or earlier blind-review output. + +I used only repository navigation, checked-in content, and Git history reachable from the supplied candidate root. I did not open, read, or search any `*-cold-review.md` or `*invalid-review*` artifact. Their paths appeared only in permitted directory and name-status listings. The current non-quarantined trajectory itself contains a mechanical summary of an earlier review; root `AGENTS.md` expressly classifies engineering-review evidence inside candidate trajectories as ordinary reviewable evidence. + +Every `rg` repository-content search used all four exclusions: + +```text +--glob '!*-cold-review.md' +--glob '!**/*-cold-review.md' +--glob '!*invalid-review*' +--glob '!**/*invalid-review*' +``` + +## Discovery trail + +Principal commands, in order: + +```text +date -u +%Y-%m-%dT%H:%M:%SZ +git rev-parse HEAD +git rev-parse 'HEAD^{tree}' +git status --short --branch +pwd + +ls -la +find . -maxdepth 2 -type f ... +find docs -maxdepth 3 -type f ... +find v2 -maxdepth 3 -type f ... + +sed -n ... AGENTS.md +sed -n ... docs/decisions/README.md +sed -n ... docs/decision-evidence/README.md + +sed -n ... docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md +sed -n ... docs/decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md + +git merge-base HEAD origin/main +git diff --name-status ..HEAD +git diff --stat ..HEAD + +sed -n ... docs/decisions/20260727-code-first-simulator-kernel.md +sed -n ... docs/decisions/20260729-principal-io-uses-runtime-gateways.md +sed -n ... docs/decisions/20260729-effect-native-evaluation-results.md +sed -n ... docs/decision-evidence/20260727-code-first-simulator-trajectory.md +sed -n ... docs/decision-evidence/20260729-principal-runtime-gateway-trajectory.md + +rg ... '\bRunSpec\b|Run\.execute|simulator\.define|effectRuntime|Docker execution backend|Kubernetes|Kueue|Temporal|Agent Sandbox' ... +rg ... 'fault|trust|safety|liveness|availability|Byzantine|failure|compatib|security|assum|retry|idempot|replay|recovery|cleanup' ... +rg ... '20260801-main-simulator-runs-container-societies-on-kubernetes|Main Kubernetes society execution|main simulator runs container societies' ... +rg ... 'only execution entry point|one execution path|second simulator backend|supported Docker|host execution path|implementation transition|v2 simulator|testbed.*platform|platform.*testbed' ... +rg ... '\bscriptedRuntime\b|generic scripted|gateway proxy|command language|actor mailbox|shared in-process|shared scoped' ... + +sed -n ... packages/simulator/AGENTS.md +sed -n ... README.md +sed -n ... packages/simulator/README.md +sed -n ... packages/evals/README.md +sed -n ... docs/simulator/overview.mdx +sed -n ... docs/simulator/running.mdx +sed -n ... docs/development/evals.mdx +sed -n ... docs/development/eval-add-evaluation.mdx + +sed -n ... v2/AGENTS.md +sed -n ... v2/VISION.md +sed -n ... docs/decisions/20260729-v2-authority-lives-with-v2.md +sed -n ... docs/decisions/20260728-simulator-is-the-system-driver.md +sed -n ... v2/inputs/simulator-handoff-20260728.md +sed -n ... docs/spec/layer-interfaces.md +sed -n ... docs/architecture/components.md + +git cat-file -e a2b55f32...: +git cat-file -e a2b55f32...: + +git diff --name-status 1939ee8b...HEAD +git diff ... 1939ee8b...HEAD -- + +git status --porcelain=v1 +``` + +Independently discovered paths and headings: + +- `AGENTS.md` → `Project`, `Architecture decision records`, `Blind teammate review gate`, `Docs` +- `docs/decisions/README.md` → `Canonical reading guidance`, `Records` +- `docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md` → `Scope and authority`, `Decision Outcome`, `Container runtimes preserve exact native gateways`, `One execution is one experiment society`, `Failure and evidence retain the existing simulator semantics`, `Local and GKE are two profiles of one path`, `Acceptance is experiment evidence, not platform completeness`, `Non-goals`, `Current owners and earlier outcomes` +- `docs/decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md` → `The main simulator runs container societies on Kubernetes`, `Source gaps, stated plainly` +- `docs/decisions/20260727-code-first-simulator-kernel.md` → `Supersession` +- `docs/decisions/20260729-principal-io-uses-runtime-gateways.md` → `Supersession`, `One society, two interaction boundaries`, `Runtime contract and keyed gateway types`, `Normative Owners` +- `docs/decisions/20260729-effect-native-evaluation-results.md` → `Supersession`, `Trust, availability, and compatibility` +- `packages/simulator/AGENTS.md` → `Boundary`, `Laws`, `Structure`, `Tests` +- `v2/AGENTS.md` → `Authority and reading order`, `Structure`, `Simulator provenance gate` +- `docs/decisions/20260729-v2-authority-lives-with-v2.md` → `Binding outcome` +- `docs/spec/layer-interfaces.md` → `Package graph`, `Simulator and testbed` + +## Unedited answers + +### 1. What decision does this candidate make current, what problem does it resolve, and which statements are binding versus context or non-normative explanation? + +The candidate makes the accepted main/v1 decision that the core `packages/simulator` executes experiment societies through one Kubernetes path, selected by either a local-Kubernetes or GKE Effect Layer. Its public authoring facade is one `RunSpec` containing the versioned definition id, closed event catalogs, exact keyed runtime roster, infrastructure Layer, and customer `execute` Effect; `Run.execute(spec)` is the sole new execution entry point. + +Each execution is one non-reusable society. Temporal owns one coarse run lifecycle and cleanup workflow, Kueue admits complete-roster capacity, the controller creates one Agent Sandbox/application container per roster entry, runtime-specific bridges attach and preserve exact native gateway types, the full roster passes one readiness gate, the controller invokes `execute` once, and the existing simulator ledger/outcome retain evidence. + +This resolves the mismatch between the existing host/process/Docker execution path and the requested core Kubernetes cohort. The Docker example can prove two OpenClaw containers but is neither the core execution path nor able to exercise the requested Kubernetes/Kueue/Agent Sandbox/Temporal society locally and on GKE. + +Binding material is: + +- root and package `AGENTS.md`; +- the accepted ADR’s `Decision Outcome`, including ownership, failure semantics, acceptance gates, non-goals, and retained/replaced outcomes; +- the visible `Supersession` sections of the two partially superseded earlier ADRs. + +Within the public example, the `RunSpec` field shape and placement of `infrastructure` are binding. The exact constructor spelling for already-constructed runtime descriptors and the infrastructure Layer is explicitly not selected. + +The ADR’s `Context and Problem Statement` and `Consequences` explain the decision. Historical bodies below a partially superseded record’s `Supersession` section are context where they describe replaced host mechanisms. Decision trajectories, Git/GitHub mechanical events, issue comments, transition documentation, and earlier review evidence are non-normative provenance or explanation. + +Verdict: **PASS**. + +### 2. What earlier outcomes does it replace, retain, or leave untouched, and where does the current normative contract live? + +It partially replaces `20260727-code-first-simulator-kernel.md`. Retained for main are the TypeScript/Effect code-first model, immutable closed EventCatalog, typed RunLedger and producer-bound writers, exact keyed gateways, network capabilities, customer-owned scenario/sweep/completion/grading policy, one `@moltzap/simulator` package, and the production v1 router/protocol. Replaced are the public `simulator.define(...).run(...)` naming and host-only execution/acquisition path, including host-local `AgentRuntime.acquire` and `effectRuntime({ build })` acquisition. + +It partially replaces `20260729-principal-io-uses-runtime-gateways.md`. Retained are exact runtime-native gateway types, principal-versus-social-traffic separation, production-router social traffic, mixed societies, distinct gateway/router evidence, customer interpretation of termination, and the prohibition on a universal gateway union, command language, correlation model, or social shortcut. Replaced is the Kubernetes-path realization in which code peers and gateways share in-process Effect state. Each Kubernetes runtime instead owns a portable application entrypoint and runtime-specific controller bridge returning the same exact `RunningAgent` shape after readiness. + +It leaves the retained portions of `20260729-effect-native-evaluation-results.md` untouched: cases, grading, report resume, SQLite authority, Phoenix publication, and behavioral truth. Only the location/mechanism of evaluation execution changes. + +The Docker example and host executor remain explicitly transitional until evaluation plus local/GKE replacement evidence exists. After cutover they are removed without a compatibility facade. Docker may still build images or support a local Kubernetes cluster. + +All v2 contracts, its six-package simulator/testbed split, process map, generation model, trust contracts, and `v2/*` code are untouched. `20260729-v2-authority-lives-with-v2.md` and the main ADR’s scope make that boundary explicit. + +The current normative contract lives in: + +- `AGENTS.md`; +- `docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md`; +- the retained scopes in the `Supersession` sections of `20260727-code-first-simulator-kernel.md` and `20260729-principal-io-uses-runtime-gateways.md`; +- the retained evaluation-result ADR scope; and +- `packages/simulator/AGENTS.md` for package-local implementation law. + +`packages/simulator` is the execution owner. `packages/evals` owns evaluation cases, runtime conditions, grading, reports, resume, and publication as a consumer. + +Verdict: **PASS**. + +### 3. What must an implementer now do or avoid, which layers or consumers are affected, and under what fault, trust, safety, liveness, and compatibility assumptions? + +An implementer must: + +- add `RunSpec` and `Run.execute` as the one new root execution facade; +- retain the existing event, ledger, network, exact keyed gateway, Effect failure, outcome, and customer-completion concepts; +- implement one private platform boundary with local-Kubernetes and GKE Layers; +- represent every roster value on the Kubernetes path as a container runtime descriptor owning an application-container entrypoint and runtime-specific controller bridge; +- preserve each runtime’s exact `Gateway` type and termination observation across that bridge; +- put code-peer policy inside its peer container and keep `packages/evals` responsible for its exact observation bridge/adapter; +- admit the whole roster through Kueue, create one Sandbox/application container per logical agent, attach every bridge, and dispatch only after the exact roster is ready; +- run one coarse Temporal workflow, invoke the customer Effect once, retain simulator-ledger/outcome evidence, and clean up all run-owned Kubernetes resources; +- provide the same library path through a small repository-local CLI; +- qualify the private fake, two-agent local smoke, ten-agent local run, all 32 real evaluation cells, GKE smoke, and at least one GKE OpenClaw evaluation before removing the transitional host/Docker path. + +It must avoid: + +- a second Docker backend or compatibility facade; +- exposing Kubernetes, Kueue, Sandbox, Temporal, Helm, Terraform, or cloud-provider objects through the customer contract; +- serializing arbitrary JavaScript gateways, Effect closures, or shared state; +- a universal gateway proxy, union, command language, mailbox, correlation/session/model protocol, or generic `scriptedRuntime`; +- social shortcuts or synthetic participants impersonating an agent’s principal; +- warm-pool reuse, per-agent Temporal workflows, automatic customer-Effect replay, customer-visible generation/restart/rebind/rejoin/recovery, exactly-once external-effect claims, or changes under `v2/*`. + +Affected owners are `packages/simulator` and its private platform/profile/controller assets, plus `packages/evals` as the migrating consumer. The production MoltZap router, ledger concepts, runtime gateway types, and evaluation evidence semantics are reused rather than redefined. Kubernetes, Kueue, Agent Sandbox, and Temporal are private mechanisms, not customer-facing layers. + +Fault and liveness assumptions are explicit: + +- before dispatch, a backing Pod restart leaves the slot outside the gate until both application and bridge are usable; +- an unrecoverable or never-ready agent or bridge fails acquisition and starts cleanup; +- after dispatch, runtime termination is typed ledger evidence and customer policy chooses whether to stop, fail, or continue; +- controller loss or infrastructure failure fails the run and starts cleanup; +- `execute` is invoked once and never automatically replayed, but this is not an exactly-once guarantee for external effects; +- customer code owns application retry and idempotency; +- automatic recovery, production Temporal HA, router HA, and larger-scale availability are not claimed. + +The retained trust model treats gateway adapters/event writers as trusted evaluation instruments while autonomous agents and runtime processes may ignore instructions, misbehave, terminate, or be unavailable. Missing evidence remains an operational/evidence failure and cannot become a behavioral pass. The candidate makes no Byzantine or multi-tenant security guarantee for the Kubernetes control path; Secret-provider protocols, exhaustive NetworkPolicy design, and a general multi-tenant security platform are explicit non-goals. V2’s Gate 1 Byzantine/trust envelope is not imported into this v1 decision. + +Safety comes from the complete-roster gate, one logical agent per application container, exact native gateway preservation, no social shortcut, one controller invocation without replay, and canonical simulator evidence. Progress depends on the required platform, bridge, runtime, router, and evaluation services remaining available; failure may end the run. + +Compatibility assumptions are deliberately breaking: the digest-pinned stock OpenClaw image is the baseline, a prebuilt MoltZap image is only an optimization, Docker ceases to be a supported executor after replacement evidence, and no host API compatibility alias survives. V2 remains unaffected. + +Verdict: **PASS**. + +### 4. Which humans are named as decision-makers, which source events does the compacted trajectory cite for their calls, alternatives, reversals, and deferrals, and what source gaps does it explicitly record? Report only what the event ledger states; do not infer motives, confidence, urgency, or rationale. + +The ADR names one human decision-maker: **Tapan Chugh**. The ledgers separately record stored actor roles and account names; they do not independently prove who controlled an account or that the named decision-maker authored every ADR sentence. + +The main trajectory cites: + +- Codex session `019fbbdd-7cff-7753-8541-4f66f0248d43`: + - message `msg_019fbbe1-770d-7d11-8475-0f2f7b3bd7b1`, turn `0a25724d-258f-41b3-a256-f8c95db5bd3a`, `2026-08-01T05:52:23.309Z`: target main first with the original simulator; + - message `msg_019fbdeb-1743-7470-be76-7ed53d7f2420`, turn `019fbdeb-1371-7be3-8e61-babd80ff5ffc`, `2026-08-01T15:22:08.579Z`: make it core rather than one example; + - message `msg_019fbded-2372-72b0-b859-61f6fe80ac47`, turn `019fbded-227b-70a3-9d9e-9a52a461b990`, `2026-08-01T15:24:22.771Z`: plan the final shape first; + - assistant proposal `msg_0141f487830063b4016a6e17e648d481939b073eea4e50a234`, followed by user message `msg_019fbe0e-7474-7e53-9f4e-40faac7ac654`, `2026-08-01T16:00:46.197Z`: accept the `RunSpec`/`Run.execute` proposal; + - messages `msg_019fbe84-b81b-7312-ad62-03432f57cdf2` and `msg_019fbe88-7cd4-7c62-9b8c-e9060c44f8d8`: pull GKE sandbox work into the core and use Kubernetes, Kueue, Temporal, local Kubernetes or GKE; + - messages `msg_019fbe9a-2e94-7430-8da7-f71f0e533f15` and `msg_019fbe9c-4f9a-7970-adb5-15463aea8686`: land on main and target `packages/simulator`, not v2; + - assistant plan summary `msg_0141f487830063b4016a6e40cd78048193bca36ecb2c05a8a2`, followed by user `start` in `msg_019fbf10-e051-75d0-92d7-bfb32174edfb`; + - work directive `msg_019fbf11-b878-7e83-902a-db4e3868e856`: work issue #936, keep durable issue notes, and run evaluations end to end. + +- Earlier Codex session `019fab08-15ca-7a10-a9af-f2a8441a45f5`, with exact calls/results repeated in the main trajectory: + - `call_vlz2QouoKyvTCXhmbDB9Hiny`: single-run cluster; + - `call_PU6nJTGPlpeJ3PATixSc2ef8`: strict cohort gate; + - direct user message at `2026-07-29T00:03:38.313Z`: one container per agent; + - `call_J4GjN5U25rt7aNh4Jo8eY8L9`: no offered scale gate selected; defer 100/1,000/5,000/10,000 claims and reach ten agents first; + - direct messages at `2026-07-29T00:14:21.664Z` and `00:16:16.056Z`: general Kubernetes, stock OpenClaw image baseline, prebuilt image only an optimization; + - `call_SnFa3x3617eQul6H1zPNZeCm`: Temporal plus Kueue; + - `call_mbMK8n64ZfjzAGVA69nzjhIw`: local Temporal first, production hosting deferred; + - `call_8Tj66rC9ATIk5wZqXIiFtRia`: regional GKE Standard; + - `call_0HQBCkj6yDpE4i7yXzEsTp8g`: in-cluster controller; + - `call_0OO9tWVFfZHYPNu61PoPXcqN`: CLI plus library; + - `call_z5VtaeUzaAe4BaD0DJh3UnVU`: Terraform plus Helm; + - `call_wGDKczyyYEXYTVNWIhEoXYbN`, turn `019faffd-b6a0-7b90-bcc2-e6f59ba339dd`: Agent Sandbox selection. + +The linked retained code-first trajectory cites session `019fa613-7f9a-7103-99b0-a42fda0754de` for code-first customer policy, closed typed events, simplification, mixed societies, customer-owned termination policy, ledger vocabulary, Effect services, branded SQL/Effect SQL, and one simulator package. + +The linked principal-gateway trajectory cites the same session’s attachment `f4eee480-6d7d-4bb2-b8e7-0d6c57e60b6e` and its digest for the principal/runtime/MoltZap boundary, exact gateway result, prohibited synthetic-principal actions, gateway/router evidence distinction, `replyToId` removal, and behavioral-evaluation reclassification. It also cites the direct no-restart/replacement scope, compatibility cleanup, evaluation-result-management requests, and the message questioning a generic code-agent command queue. + +The main trajectory records a reversal only as an explicit source gap: two later live messages rejected the overbuilt candidate and directed that checked-in requirement conversations be the boundary, with undisclosed matters treated as non-goals. It also records an immediately following live assistant prompt and terse `accept this ADR` reply accepting the simplified shape and explicit controller-failure/no-replay wording. + +Explicit source gaps are: + +- primary retained Codex messages lack parent locators; +- terse replies are meaningful only with their directly preceding prompts; +- no user event chooses exact Layer-constructor spelling; +- no source event chooses a bridge transport or wire schema; +- the issue summary’s “exactly-once” wording is not attributed to `start`; the later missing-session acceptance supplies the final once/no-replay/no-external-exactly-once wording; +- retained events do not independently state reasons for every resource shape, failure variant, security control, event field, or platform mechanism; +- no human selection is recorded for exact upstream versions, API schemas, chart/provider choices, timeouts, storage, cost budgets, generation protocols, artifact authorities, identity derivations, or recovery schemes; +- the two simplification messages and final acceptance exchange could not be located in workspace-readable session logs, so no session id, native locator, timestamp, parent locator, or stored actor role is invented; +- issue bodies/comments are agent-published mechanical artifacts, not independent human rationale; +- irrelevant tool output, private instructions, hidden reasoning, diagnostics, credentials, and private session URLs are omitted. + +Verdict: **PASS**. + +### 5. Find the strongest apparent contradiction, stale instruction, or broken lineage elsewhere in the repository. Resolve it using the authority order or report it as a blocker. + +The strongest apparent contradiction is inside the historical body of `20260729-principal-io-uses-runtime-gateways.md`: it permits an in-process Effect gateway and behavior to share scoped state and its `Consequences` still describes code peers as `effectRuntime({ build })` policies. That conflicts with the new requirement that every Kubernetes roster agent run in its own application container and that arbitrary Effect values/shared state do not cross the process boundary. + +It is resolved by the authoritative lineage: + +1. That ADR’s frontmatter is `partially-superseded`. +2. Its visible `Supersession` section says the host-bound `AgentRuntime.acquire` and `effectRuntime({ build })` realization is replaced. +3. It explicitly classifies later historical statements requiring in-process/shared state as descriptions of the replaced host implementation. +4. The accepted replacement defines per-runtime application entrypoints and controller bridges while retaining exact gateway types and the ban on a universal protocol. +5. `packages/simulator/AGENTS.md` repeats the corrected binding rule. +6. Current host code and examples are visibly labeled transitional and are removed only after replacement acceptance evidence exists. + +A second apparent conflict is that v2 assigns platform acquisition to `testbed`, while this main decision assigns Kubernetes integration to `packages/simulator`. Root branch law, `20260729-v2-authority-lives-with-v2.md`, and the candidate’s scope resolve it: the new decision governs v1 on main only and does not amend v2. + +I found no broken supersession link, missing normative owner, or unresolved authority conflict. + +Verdict: **PASS**. + +### 6. Could a teammate implement the decision without chat or guessing? List every missing link or unresolved choice and classify each as a deliberate deferral or an accidental gap. + +Yes. A teammate can implement the observable contract without chat. The public facade, ownership boundaries, lifecycle ordering, exact-gateway invariant, failure behavior, transition rule, and acceptance evidence are discoverable in the repository. + +Deliberate private implementation choices: + +- exact local/GKE Layer constructor names and the smallest private platform service shape; +- module/file placement for private platform/controller code within `packages/simulator`; +- each runtime’s fixed bridge transport and schema; +- experiment bundle transport, cache, and checksums; +- exact Kubernetes, Kueue, Agent Sandbox, Temporal, Helm, Terraform, and provider versions/APIs; +- timeouts, storage mechanics, and cost budgets; +- concrete Secret-provider integration and non-exhaustive NetworkPolicy details. + +Deliberate deferrals/non-goals: + +- production Temporal hosting and HA; +- router HA; +- generation ids/streams and restart/rebind/rejoin/replacement/recovery; +- replay/resume and exactly-once external effects; +- durable artifact authority, start-or-attach database, global execution-id namespace, synthetic UUID/name hashing rules; +- a new general serialization grammar; +- public Kubernetes objects or arbitrary Pod templates; +- universal gateway proxy/protocol/correlation model; +- warm pools, multi-run scheduling, fairness, borrowing, preemption, and autoscaling; +- qualification above ten agents; +- Nomad, Slurm, managed batch, or GKE Autopilot; +- persistent agent-state recovery and a general multi-tenant security platform; +- all v2 implementation or contract changes. + +Customer-owned choices retained from earlier decisions: + +- experiment completion policy; +- post-dispatch reaction to runtime termination; +- application-level retry/idempotency; +- case/scenario/sweep/grading/report policy; +- runtime-specific gateway semantics and evidence correlation. + +Explicit provenance gaps, not implementation gaps: + +- missing native locators and metadata for the late simplification and acceptance exchange; +- no human selection of constructor spellings, bridge transport, or other private mechanisms; +- no independently stated rationale for every mechanism. + +Accidental implementation or lineage gaps found: **none**. + +Verdict: **PASS**. + +## Per-question verdicts + +| Question | Verdict | Blocker | +|---|---|---| +| 1 | PASS | None | +| 2 | PASS | None | +| 3 | PASS | None | +| 4 | PASS | None | +| 5 | PASS | None | +| 6 | PASS | None | + +## Blockers + +None. + +## Overall result + +**PASS** + +All six answers were discoverable from the candidate repository with consistent status, supersession lineage, branch authority, assumptions, normative ownership, and source-event attribution. Maintainer acceptance remains required; this reviewer result is not self-certifying. + +## Maintainer acceptance + +After this passing result was recorded, Tapan Chugh replied exactly: + +> accept + +The live continuation available on 2026-08-03 supplies no native message +locator or exact timestamp, so neither is invented. This accepts the passing +blind-review result for candidate commit +`2749adbd99eaffd16f063a45de7be01c253f7ef1`; it does not change the reviewed +ADR, add rationale, or authorize mechanics outside that accepted decision. diff --git a/docs/decision-evidence/20260806-main-kubernetes-society-execution-third-cold-review.md b/docs/decision-evidence/20260806-main-kubernetes-society-execution-third-cold-review.md new file mode 100644 index 000000000..749c4e739 --- /dev/null +++ b/docs/decision-evidence/20260806-main-kubernetes-society-execution-third-cold-review.md @@ -0,0 +1,193 @@ +# Blind teammate review — Kubernetes society execution, candidate `78ff2f94` + +Non-normative evidence. This record is a quarantined input for later blind +reviews: a future reviewer must not open it during a run. + +## Candidate identity + +- Repository root: `/home/tapanc/moltzap-pr-917-main` +- Branch: `impl/917-main-local-society` +- Commit: `78ff2f9469040d81eae022d72eca5c869995e878` +- Tree: `b381119471443baaf94922be256046b8527c27d7` +- Working tree clean at freeze. + +## Why a new candidate was frozen + +The accepted blind review covers candidate `2749adbd`. Commit `089829c7` +amended the admitted record after that review, changing binding text in +`Decision Outcome`: + +- `ten agents` to `four agents`, in the acceptance gate and in the non-goals. +- `infrastructure` to `cluster`, the `RunSpec` field name, in two passages. + +The agent law requires a new candidate and a different fresh reviewer after any +semantic change to an admitted decision. A changed acceptance criterion and a +renamed contract field are semantic. + +## Reviewer identity and isolation attestation + +A fresh agent session with no inherited conversation, compaction, memory, or +private state, and no earlier blind-review output. It received only the +candidate repository root and the six fixed questions. It was given no design +summary, no diff tour, no ADR or file pointer, no search term, and no expected +answer. No question was answered and no hint was given during the run. + +The reviewer attests that it did not open, read, or grep the contents of any +`*-cold-review.md` or invalid-review record, and that those paths appeared only +in directory listings and `git log --name-status` output. + +The reviewer disclosed one porousness in the quarantine: the permitted +trajectory restates prior blind-review verdicts. It reports that this supplied +none of its findings, all of which post-date both prior reviews. + +## Duration and interventions + +One uninterrupted fresh-agent context, roughly 25 minutes. No author +intervention. No file was modified. `Not discoverable` was not needed for any +question. + +## Exact prompt + +The reviewer received the candidate repository root, the quarantine constraint +above, and the six questions verbatim from the agent law's blind review gate, +followed by instructions to give a per-question PASS or FAIL verdict, to record +independently discovered paths and its discovery trail, and to close with an +overall result. + +## Per-question verdicts + +| Question | Verdict | +| --- | --- | +| 1 — what decision is current, what is binding | PASS | +| 2 — what it replaces, retains, where the contract lives | PASS | +| 3 — what an implementer must do, under which assumptions | PASS | +| 4 — decision-makers and cited source events | FAIL | +| 5 — strongest contradiction elsewhere | FAIL | +| 6 — implementable without chat or guessing | FAIL | + +## Overall result + +**FAIL.** The gate blocks landing. + +## Blockers + +### The amended text has no receipt and contradicts its own ledger + +Two statements binding at this candidate cite no source event, and the retained +events say the opposite: + +- The trajectory's own source-gap paragraph states that the example's + `infrastructure` value "remains the binding shape". The record now names the + field `cluster`. +- The only retained human statement on cohort size is `lets get to 10 agents + first and then scale`, and the accepted final-shape prompt says + `Two-agent, ten-agent, and all 32 OpenClaw/NanoClaw evaluation runs`. The + record now requires a four-agent run. + +Both edits landed in `089829c7` with no `Record changelog` row, no dated +trajectory correction, and no supersession. The commit message states the +amendment "still owes its blind teammate review gate" and that it was committed +with `--no-verify`. `checkChangelogRow` runs only in `--staged` mode, so nothing +caught the missing receipt afterwards. + +### The acceptance cohort size is stated three ways + +| Source | Says | +| --- | --- | +| The admitted record, binding | four-agent | +| The trajectory, evidence | ten agents | +| `packages/simulator/local/README.md` | ten-agent, never four | +| `packages/simulator/package.json`, `local/profile.test.mjs` | ten-agent, asserted as exactly ten roster entries | +| `packages/simulator/local/four-agent-smoke.mjs` | exists, referenced by nothing | + +Authority order does not repair this. The record outranks the profile +documentation and tooling, but the source above the record forbids the way the +four-agent text arrived, so the higher authority does not bless the newer text +while the lower artifacts still implement the older one. + +## Accidental gaps the reviewer records + +1. Which cohort-size gate binds. Blocking. +2. No `Record changelog` receipt for either in-place amendment. +3. The record's illustrative snippet spells `export default RunSpec.define`, + while the controller admits only one named `runSpec` export and the + orientation docs say the same. An implementer copying the snippet fails at + module load. +4. `autoscaling` sits unscoped in the non-goals beside fairness, borrowing, and + preemption, while the GKE profile ships a node-pool autoscaler and the + changelog describes agents that scale on demand. Resolvable only by reading + the non-goal as run scheduling rather than node pools, a distinction the + record never draws. +5. `packages/simulator/local/hundred-agent-soak.mjs` is referenced by no + record, document, or target. +6. Acceptance evidence has no stated location. Removal of the transitional path + is conditioned on replacement evidence existing, and that removal has already + happened at this candidate, but the record never says where the evidence must + live. + +## Deliberate deferrals the reviewer confirms + +Production Temporal hosting and high availability; generations, restart, rebind, +rejoin, and recovery APIs; replay, resume, and exactly-once external effects; +artifact authority, start-or-attach database, execution-id namespace, and +name-hashing algorithm; new serialization grammars; a public Kubernetes object +model and per-agent workflows; Nomad, Slurm, managed batch, and GKE Autopilot; +scale beyond the small gates; secret protocols, persistent-state recovery, +NetworkPolicy, and multi-tenancy; the bridge transport and wire schema; Effect +Layer constructor names; anything under `v2/*`. + +## Independently discovered paths and headings + +`docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md` +(Scope and authority; Decision Outcome and its six subsections; Non-goals; +Current owners and earlier outcomes; Consequences); +`docs/decisions/20260727-code-first-simulator-kernel.md` (Supersession); +`docs/decisions/20260729-principal-io-uses-runtime-gateways.md` (Supersession); +`docs/decisions/README.md` (Canonical reading guidance; Records); +`docs/decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md` +and its source-gap list; `docs/decision-evidence/README.md`; +`.claude/skills/decisions/SKILL.md` (Shape; Point corrections versus +supersession; Landing; Blind review gate); `AGENTS.md` (Decisions; Docs); +`v2/AGENTS.md` (Authority and reading order); +`scripts/docs/adr/check-shape.ts` and its `checkChangelogRow`; +`packages/simulator/src/definition.ts`; +`packages/simulator/src/cluster/controller/main.ts`; +`packages/simulator/local/README.md`, `local/profile.test.mjs`, and the +two-, four-, ten-agent and hundred-agent modules; +`packages/simulator/gke/README.md`, `cluster.sh`, `terraform/`, `helm/`; +`docs/simulator/running.mdx`; `CHANGELOG.md`. + +## Discovery trail + +`git log` and `git status` at HEAD; `ls docs/`, `ls docs/decisions/`, +`ls docs/decision-evidence/`; `git diff --stat origin/main...HEAD -- docs/` to +isolate the candidate; the new record read in full; the diff of the two amended +records and the index; the trajectory read in full; `AGENTS.md`; the decisions +skill and the evidence README for the governing procedure; +`scripts/docs/adr/check-shape.ts`, observing that `checkChangelogRow` is +`--staged`-only; the shape checker run, reporting fifty well-formed records; +`git log --follow` on the record surfacing `089829c7`; `git show 089829c7` +exposing both amendments and the unpaid-gate admission; a quarantine-filtered +repository-wide search for the cohort-size strings; the simulator's definition, +index, and controller entry for the implemented contract; the local and GKE +profile listings, READMEs, package manifest, and profile test; the Terraform +main and the changelog for the autoscaling and hundred-agent conflicts; +`git cat-file` and `git ls-tree` against `a2b55f32` to verify the cross-branch +evidence locators; and the v2 authority record and `v2/AGENTS.md` for the +authority order. + +## Acceptance + +Superseded by a later review and then overridden. + +A fourth review of candidate `335d8cac` passed questions one, two, three, five, +and six, and failed question four: a dated correction carried a binding change +on an unattributed human acceptance. That was corrected at `79e4af96` by +retaining the literal reply, stating what was searched and when, and restoring +the scale-claim non-goals no source event addressed. + +Tapan Chugh then overrode the remaining gate and directed that landing proceed +without a further passing review. The blockers this record names are resolved in +the candidate; the override covers the requirement for a fresh reviewer to +confirm it, not the findings themselves. Recorded here because an override is a +maintainer decision the log should carry, not an absence. diff --git a/docs/decisions/20260727-code-first-simulator-kernel.md b/docs/decisions/20260727-code-first-simulator-kernel.md index c717b8afc..a26543aed 100644 --- a/docs/decisions/20260727-code-first-simulator-kernel.md +++ b/docs/decisions/20260727-code-first-simulator-kernel.md @@ -2,7 +2,7 @@ status: partially-superseded date: 2026-07-27 decision-makers: Tapan Chugh -superseded-by: 20260729-principal-io-uses-runtime-gateways.md +superseded-by: 20260801-main-simulator-runs-container-societies-on-kubernetes.md --- # The simulator is code-first with a closed event catalog @@ -11,41 +11,34 @@ Decision provenance: [stored code-first simulator trajectory](../decision-eviden ## Supersession -The following scope remains current: the code-first TypeScript/Effect -approach; `Simulator.define`; an immutable closed typed EventCatalog; -the typed run-evidence RunLedger; a scoped runtime roster and lifecycle -kernel; Effect programs and services; customer-owned -scenario languages, sweeps, completion policy, and graders; and the -requirement that OpenClaw, NanoClaw, Effect, and custom runtimes use one -public stack without callback shortcuts. - -For the v1 implementation, `20260729-principal-io-uses-runtime-gateways.md` -replaces the private-gateway and router-authentication readiness claims. -Successful acquisition exposes each runtime's exact principal gateway and -termination through the keyed roster alongside the router-issued agent handle. -Network identity remains distinct from runtime lifetime. A behavioral runtime -is ready only when its principal gateway and configured MoltZap capabilities -are usable. Experiment-controlled endpoints remain network participants for -probes and workloads, but do not represent a principal instructing an -autonomous agent. Synthetic-endpoint OpenClaw and NanoClaw runs are network -diagnostics rather than behavioral acceptance. -The earlier three-entry-point v1 package list is also replaced: the root -remains the society definition, execution, and evidence surface, while runtime -contracts and shipped implementations are grouped at -`@moltzap/simulator/runtime` inside the same package. The current v1 boundary -lives in -[`20260729-principal-io-uses-runtime-gateways.md`](./20260729-principal-io-uses-runtime-gateways.md). +The following scope remains current for main: the code-first TypeScript/Effect +approach; an immutable closed typed EventCatalog; the typed run-evidence +RunLedger and producer-bound writers; exact keyed runtime gateways; +customer-owned scenario languages, sweeps, completion policy, and graders; one +`@moltzap/simulator` package; the production v1 router and protocol; and one +public stack without social callback shortcuts. + +[`20260801-main-simulator-runs-container-societies-on-kubernetes.md`](./20260801-main-simulator-runs-container-societies-on-kubernetes.md) +replaces the main/v1 `simulator.define(...).run(...)` public naming and its +host-only concrete execution path, including host-local `AgentRuntime.acquire` +and `effectRuntime({ build })` acquisition, with one `RunSpec`, one +`Run.execute`, and one Kubernetes path supplied by either a local-cluster or +GKE Effect Layer. The existing event, ledger, network, exact-gateway acquired +shape, termination-policy, and customer-program concepts are reused rather +than replaced. + +[`20260729-principal-io-uses-runtime-gateways.md`](./20260729-principal-io-uses-runtime-gateways.md), +as partially superseded, continues to govern the distinction between +principal-native gateway control and MoltZap social traffic, the absence of a +universal gateway union or correlation id, and the classification of +controlled-endpoint traffic as diagnostic rather than behavioral acceptance. `20260728-simulator-is-the-system-driver.md` replaces the historical -single-package ownership and source-layout plan with the V2 simulator -as a system driver over public production capabilities and a separate -testbed package. `20260729-router-order-is-opaque.md` replaces -simulator-owned production Router state, public RouterSequence, and -legacy transport-facing types with the `router` package's opaque, -volatile L2 capability. `20260728-six-deep-packages-one-version.md`, -as partially superseded, and `docs/spec/layer-interfaces.md` own the -current package boundary. The accepted -`20260728-simulator-is-the-system-driver.md` record remains unchanged. +single-package ownership and source-layout plan only for v2. The accepted v2 +record, the Gate 1 manifest, and the v2 package/specification boundary remain +unchanged. `20260729-router-order-is-opaque.md` continues to replace +simulator-owned production Router state and public RouterSequence in its v2 +scope. ## Context and Problem Statement diff --git a/docs/decisions/20260729-principal-io-uses-runtime-gateways.md b/docs/decisions/20260729-principal-io-uses-runtime-gateways.md index 5db88e31d..9b4ad2e87 100644 --- a/docs/decisions/20260729-principal-io-uses-runtime-gateways.md +++ b/docs/decisions/20260729-principal-io-uses-runtime-gateways.md @@ -1,7 +1,8 @@ --- -status: accepted +status: partially-superseded date: 2026-07-29 decision-makers: Tapan Chugh +superseded-by: 20260801-main-simulator-runs-container-societies-on-kubernetes.md --- # Principal I/O uses runtime-native gateways @@ -9,6 +10,33 @@ decision-makers: Tapan Chugh Decision provenance: [stored principal-gateway trajectory](../decision-evidence/20260729-principal-runtime-gateway-trajectory.md#principal-io-uses-each-runtime-gateway). +## Supersession + +The following scope remains current: principal control uses each runtime's +exact native gateway; MoltZap carries agent-produced social traffic; code and +process agents receive no social shortcut; the simulator defines no universal +gateway union, command language, correlation model, or gateway semantics; +gateway and router evidence remain distinct; runtime termination remains +evidence interpreted by customer policy; and the behavioral-evaluation +contract below remains current. + +[`20260801-main-simulator-runs-container-societies-on-kubernetes.md`](./20260801-main-simulator-runs-container-societies-on-kubernetes.md) +replaces only the host-bound acquisition and code-peer realization on the +current main simulator path. `AgentRuntime.acquire` and +`effectRuntime({ build })` closures with shared in-process gateway/behavior +state are transitional host implementations, not the Kubernetes runtime +boundary. Each current runtime instead owns a container entrypoint and a +runtime-specific controller bridge that returns the same exact gateway and +termination shape after readiness. Code-peer policy runs inside its own agent +container. Arbitrary Effect values are not serialized, and the replacement +does not introduce a generic cross-runtime proxy protocol. + +Historical statements below that require an in-process Effect API or shared +scoped state describe the replaced host implementation. The current +distributed runtime contract lives in the replacement record; all other +gateway, evidence, evaluation, and v2 boundaries in this record remain +current. + Scope: this record governs the Phase 1 source baseline in `packages/simulator`, the private `packages/evals` application, and the mechanical `replyToId` removal across the v1 protocol, server, client, and diff --git a/docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md b/docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md new file mode 100644 index 000000000..18dae1919 --- /dev/null +++ b/docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md @@ -0,0 +1,294 @@ +--- +status: accepted +date: 2026-08-01 +decision-makers: Tapan Chugh +--- + +# The main simulator runs container societies on Kubernetes + +Decision provenance: [stored main-track trajectory](../decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md#main-simulator-runs-container-societies-on-kubernetes), with the retained [code-first simulator](../decision-evidence/20260727-code-first-simulator-trajectory.md#code-first-simulator-closed-event-catalog) and [principal-gateway](../decision-evidence/20260729-principal-runtime-gateway-trajectory.md#principal-io-uses-each-runtime-gateway) trajectories. + +## Scope and authority + +This decision governs the production v1 simulator on `main`, implemented in +`packages/simulator`, and the way `packages/evals` executes experiments through +that simulator. It does not change `v2/*`, the v2 package map, or any v2 +normative contract. + +The checked-in source-event trajectories are the requirements boundary for +this slice. The outcome below contains only choices made in those conversations +or the minimum mechanics required to connect them. Anything else is a +non-goal, listed explicitly below. + +## Context and Problem Statement + +The v1 simulator already provides code-first Effect programs, a closed typed +event catalog, mixed runtime rosters, runtime-native principal gateways, one +production router, and a durable run ledger. Its concrete host Layer starts +local processes and Docker containers. A separate example proved that two +OpenClaw containers can join the original simulator, but an example-only +Docker path is not the core simulator and cannot exercise the requested +Kubernetes cohort. + +Experiments need one core path that can run the same society on a local +Kubernetes cluster or GKE. The selected stack is Kubernetes, Kueue, Agent +Sandbox, and Temporal. The first useful proof is a small complete society, +then a larger cohort and real evaluations; the earlier 1,000–10,000-agent goal +is deferred until that path works. + +## Decision Outcome + +### The public model is `RunSpec` and `Run.execute` + +An experiment exports one code-first `RunSpec`. It declares the versioned +definition id, closed customer event catalogs, exact keyed runtime roster, and +the customer `execute` Effect. `Run.execute(spec)` is the only new execution +entry point. + +```ts +export const runSpec = RunSpec.define({ + id: "acme.echo/v1", + events: [echoEvents], + agents: { alice, bob }, + cluster: localKubernetes, + execute: ({ agents, events, network, ledger }) => + Effect.gen(function* () { + // Instruct agents through their native gateways, observe the society, + // and return when this experiment is complete. + }), +}); +``` + +The example receives already-constructed runtime descriptors and an Effect +Layer. It does not select new constructor names for either one. + +The `cluster` field contains either the local-Kubernetes or GKE Effect Layer. +It selects the host without exposing Kubernetes, Kueue, Agent Sandbox, or +Temporal objects to the roster or customer Effect. Moving a society between +profiles changes that Layer, not its agents, events, or `execute` program. + +This is a small facade over the existing simulator concepts, not a second +simulation model. The existing closed event catalog, typed ledger, exact keyed +gateway roster, network capabilities, Effect failure model, and customer-owned +completion policy remain current. Runtime-specific gateway types remain exact; +the simulator does not add a universal gateway union. + +The old `simulator.define(...).run(...)` host entry point is transitional. It +is removed after `packages/evals` and the local/GKE acceptance runs use +`RunSpec` and `Run.execute`. There is no supported Docker execution backend or +compatibility facade after cutover. Docker may still build images and support a +local Kubernetes cluster. + +### Container runtimes preserve exact native gateways + +On the Kubernetes path, every roster value is a container runtime descriptor. +It preserves the runtime's exact `Gateway` type while privately owning two +runtime-specific pieces: the portable application-container entrypoint and a +controller-side bridge. After the Sandbox application is ready, that bridge +attaches to the runtime and returns the existing `RunningAgent` shape: +the exact gateway plus termination observation. Only then may the slot satisfy +the cohort gate and become a `StartedAgent` for the customer Effect. + +Arbitrary JavaScript gateway values, Effect closures, and shared in-process +state do not cross the container boundary. Each runtime implementation owns +both ends of its bridge and may use its own fixed internal transport. The +simulator defines no universal command, request, response, correlation, +session, or model-configuration protocol and does not normalize gateway types. +The kernel knows only the generic acquired shape it already consumes. + +For evaluation code peers, this replaces the host-only +`effectRuntime({ build })` realization on the Kubernetes path. The peer policy +runs as the application entrypoint in that peer's Sandbox container, and +`packages/evals` owns the peer-specific observation bridge and its exact +gateway adapter. Peer social behavior still uses the production MoltZap +client and router. The in-process Effect runtime remains transitional host +code until cutover; no public `scriptedRuntime` constructor or generic +scripted-agent protocol is introduced. + +### One execution is one experiment society + +Each call creates one society for one customer Effect and then tears it down: + +1. Temporal starts one coarse workflow for the run. +2. Kueue admits capacity for the complete roster. +3. The controller creates one Agent Sandbox with one application container for + each roster entry. +4. Each runtime-specific controller bridge attaches, and the controller waits + until the exact roster is ready at the same cohort gate. +5. The in-cluster controller invokes the `execute` Effect once. +6. The existing simulator ledger and run outcome retain the experiment and + infrastructure evidence. +7. Temporal drives cleanup of the run-owned Kubernetes resources. + +The society is not a warm pool and is not reused by another experiment. +Kueue owns capacity admission; it does not decide simulator readiness. +Kubernetes and Agent Sandbox own container placement and lifecycle; they do +not run customer policy. The controller owns the exact readiness gate, +customer Effect, and simulator evidence. Temporal owns the coarse operational +lifecycle and cleanup; it does not run agent logic, append simulator evidence, +or replay the customer Effect. + +One roster entry means one logical agent in one Agent Sandbox application +container. Infrastructure containers are not agents. Real agents and +code/scripted agents may share one society, but every agent's social traffic +uses the production MoltZap router. The experiment controls an agent through +that runtime's native principal gateway and does not impersonate an agent with +a synthetic MoltZap participant. + +The controller uses a stable simulator image and loads the experiment module +late, so changing an experiment does not require building a new agent image. +The stock digest-pinned OpenClaw image is the compatibility baseline; a +prebuilt MoltZap image may only be an optimization. The exact bundle transport +and cache are private profile details, not a public artifact protocol. + +### Failure and evidence retain the existing simulator semantics + +Dispatch requires the complete roster to be ready together. A backing Pod +restart before dispatch simply keeps that slot outside the gate until its +current application and controller bridge are usable; no generation API is +exposed. An unrecoverable or never-ready agent or bridge fails acquisition and +starts cleanup. After dispatch, runtime termination remains typed ledger +evidence and the customer Effect's existing policy decides whether to finish, +fail, or keep observing the run. + +The controller invokes `execute` once for a run and never automatically +replays it. Controller loss or infrastructure failure fails the run and starts +cleanup. This is not an exactly-once guarantee for external side effects; +customer code owns any application-level retry or idempotency it needs. + +The run returns the same kind of program `Exit` and completed-ledger receipt +already owned by the simulator. Infrastructure failure uses the existing +infrastructure-outcome model. Temporal history and Kubernetes status are +operational observations, not replacements for the simulator ledger. + +### Local and GKE are two profiles of one path + +The repository owns one local Kubernetes profile for development and CI and +one GKE profile for cloud qualification. Both install or connect to the same +required components and invoke the same `Run.execute` path. A small +repository-local CLI accepts a RunSpec entrypoint and calls that same library +path; it does not define a separate execution protocol. + +The local profile uses a repository-owned local cluster and a development +Temporal deployment. The GKE reference is regional GKE Standard and uses +Agent Sandbox. Terraform and Helm own reproducible GKE and add-on setup. +Production Temporal hosting and high availability remain deliberately +unselected; GKE qualification may use a test deployment or a configured +Temporal endpoint. + +The Kubernetes implementation stays behind the existing Effect Layer +boundary. That boundary is sufficient for a possible future scheduler; this +slice does not implement Nomad, Slurm, or another backend. + +### Acceptance is experiment evidence, not platform completeness + +The slice is complete only when all of the following use the core +`packages/simulator` path: + +- unit tests with a private fake platform prove cohort-gate ordering, one + customer-Effect invocation, post-dispatch termination policy, outcomes, and + cleanup; +- a local-cluster two-agent smoke proves Kueue admission, one Sandbox/container + per agent, native gateway readiness, execution, ledger evidence, and zero + run-owned residue; +- one end-to-end experiment, sized by its run rather than by its source, proves + the same complete-roster path at larger cohorts before any scale claim; +- all 32 OpenClaw/NanoClaw evaluation cells invoke `Run.execute` through + Kubernetes and record their real outcomes, including honest operational or + behavioral failures rather than forced passes; +- the same small smoke and at least one OpenClaw evaluation run on GKE through + the same authoring contract; and +- the transitional Docker example and host execution path are removed only + after the replacement evidence exists. + +### Non-goals + +The following are not part of this decision or its first implementation: + +- generation identifiers or streams, a customer-visible restart/recovery API, + or post-dispatch replacement, rebinding, rejoin, and recovery of in-flight + work; +- replay or resume of the customer Effect, exactly-once external effects, or a + customer-visible distributed transaction protocol; +- a durable artifact authority, start-or-attach binding database, global + execution-id namespace, synthetic UUID scheme, or normative Kubernetes-name + hashing algorithm; +- a new immutable-data grammar, JCS contract, universal input/result/failure + schema, or serialization rules beyond the simulator's existing schemas and + the fixed runtime-specific bridge schemas and checksums needed to move an + experiment module or pinned image; +- a public Kubernetes object model, arbitrary Pod templates, per-agent + Temporal workflows, or simulator APIs for Kueue, Sandbox, or Temporal + internals; +- a universal gateway proxy, command language, actor mailbox, cross-runtime + correlation model, or serialization of arbitrary JavaScript/Effect values; +- warm societies, multi-run scheduling policy, fairness, borrowing, preemption, + simulator-owned autoscaling of a run's cohort, router high availability, or + production Temporal high availability. A profile may let its node pool + autoscale, which is the cluster's own capacity mechanism and the simpler one + to operate; +- a 100-, 1,000-, 5,000-, or 10,000-agent qualification claim before the + two-agent and larger-cohort gates pass; +- a Nomad, Slurm, managed-batch, or GKE Autopilot implementation; +- exact Secret-provider protocols, persistent-agent-state recovery, exhaustive + NetworkPolicy design, or a general multi-tenant security platform; and +- any implementation or contract change under `v2/*`. + +### Current owners and earlier outcomes + +`packages/simulator` owns `RunSpec`, `Run.execute`, the private Kubernetes +implementation, profile assets, controller, and its use of Kueue, Agent +Sandbox, and Temporal. `packages/evals` continues to own cases, runtime +conditions, grading, reports, resume policy, and Phoenix publication. It is a +consumer, not a second execution platform. + +[`20260727-code-first-simulator-kernel.md`](./20260727-code-first-simulator-kernel.md) +remains current for its code-first Effect model, closed typed event catalog, +typed ledger, runtime roster, customer-owned scenario/sweep/completion/grading +policy, and single-package boundary. This decision replaces only the v1 +`simulator.define(...).run(...)` public naming and the host-only concrete +execution path. + +[`20260729-principal-io-uses-runtime-gateways.md`](./20260729-principal-io-uses-runtime-gateways.md) +remains current for exact runtime-native gateway types, agent social traffic, +termination policy, mixed societies, and behavioral-evaluation evidence. This +decision replaces only its host-bound realization of code agents as +`effectRuntime({ build })` closures sharing in-process state with their +gateway. Container runtime implementations now own runtime-specific bridges; +the ban on a simulator-wide gateway union or generic command protocol remains. + +[`20260729-effect-native-evaluation-results.md`](./20260729-effect-native-evaluation-results.md) +remains current for cases, grading, report resume, SQLite, and Phoenix. This +decision changes where an evaluation run executes, not how evaluation truth is +defined or published. + +The distributed-execution ADRs on the v2 branch remain v2 authority. Their +checked-in source trajectories inform this main-track decision, but their v2 +process map, package ownership, generation model, and trust contracts are not +copied into v1. + +## Consequences + +Experiment authors get one small code-first contract and one execution path +from laptop-scale Kubernetes to GKE. The core simulator, rather than an +example, owns container-society execution. The strict cohort gate and +one-container-per-agent boundary match the experiment requirements without +turning the simulator into a general execution platform. + +The design accepts startup latency and a stable controller/bundle mechanism in +exchange for avoiding per-experiment agent images. It also accepts that a +controller or agent failure may end a run; automatic recovery is intentionally +outside the first experiment-infrastructure slice. + +## Record changelog + +Point corrections that leave the Decision Outcome intact. A change that alters +the outcome is a supersession, not a row here. + +| Date | Change | +|---|---| +| 2026-08-06 | Renamed the `RunSpec` field `infrastructure` to `cluster`, matching the implementation and the orientation docs. | +| 2026-08-06 | Replaced the fixed four-agent acceptance gate with one end-to-end experiment sized by its run. Removes the earlier ten- and four-agent wording, which the record, the ledger, and the profile tooling had never agreed on. The scale-claim non-goals are unchanged: no source event addresses them. | +| 2026-08-06 | Corrected the stale subpath in the simulator overview from `/runtime` to `/agents`, the export the package actually publishes. | +| 2026-08-06 | Corrected the illustrative snippet from `export default` to the named `runSpec` export the controller admits. | +| 2026-08-06 | Scoped the `autoscaling` non-goal to a run's cohort. A profile's node pool may autoscale; it was selected because it is the simpler thing to operate. | diff --git a/docs/decisions/README.md b/docs/decisions/README.md index 30ff3b6b7..71faded14 100644 --- a/docs/decisions/README.md +++ b/docs/decisions/README.md @@ -52,7 +52,8 @@ planning database as continuing authority. | Decision | Date | Status | Superseded by | |---|---|---|---| -| [Principal I/O uses runtime-native gateways](20260729-principal-io-uses-runtime-gateways.md) | 2026-07-29 | accepted | — | +| [The main simulator runs container societies on Kubernetes](20260801-main-simulator-runs-container-societies-on-kubernetes.md) | 2026-08-01 | accepted | — | +| [Principal I/O uses runtime-native gateways](20260729-principal-io-uses-runtime-gateways.md) | 2026-07-29 | partially-superseded | [Main Kubernetes society execution](20260801-main-simulator-runs-container-societies-on-kubernetes.md) | | [Evaluation runs produce typed reports published to Phoenix](20260729-effect-native-evaluation-results.md) | 2026-07-29 | partially-superseded | [Principal runtime gateways](20260729-principal-io-uses-runtime-gateways.md) | | [Representation limits are fixed or derived](20260729-representation-limits-are-fixed-or-derived.md) | 2026-07-29 | accepted | — | | [Identity and Router expose deep Effect capabilities](20260729-identity-and-router-expose-deep-effect-capabilities.md) | 2026-07-29 | accepted | — | @@ -73,7 +74,7 @@ planning database as continuing authority. | [The model surface is start_conversation, reply, and listen](20260728-model-surface-is-start-reply-listen.md) | 2026-07-28 | accepted | — | | [V2 has six deep packages and one Moltzap version](20260728-six-deep-packages-one-version.md) | 2026-07-28 | partially-superseded | [Opaque Router order](20260729-router-order-is-opaque.md) | | [V2 owns one simulator as the system driver](20260728-simulator-is-the-system-driver.md) | 2026-07-28 | accepted | — | -| [The simulator is code-first with a closed event catalog](20260727-code-first-simulator-kernel.md) | 2026-07-27 | partially-superseded | [Principal runtime gateways](20260729-principal-io-uses-runtime-gateways.md), [Simulator system driver](20260728-simulator-is-the-system-driver.md), [six packages and one version](20260728-six-deep-packages-one-version.md), [opaque Router order](20260729-router-order-is-opaque.md) | +| [The simulator is code-first with a closed event catalog](20260727-code-first-simulator-kernel.md) | 2026-07-27 | partially-superseded | [Main Kubernetes society execution](20260801-main-simulator-runs-container-societies-on-kubernetes.md), [principal runtime gateways](20260729-principal-io-uses-runtime-gateways.md), [Simulator system driver](20260728-simulator-is-the-system-driver.md), [six packages and one version](20260728-six-deep-packages-one-version.md), [opaque Router order](20260729-router-order-is-opaque.md) | | [Registration is out of band; the plane knows one caller](20260727-registration-is-out-of-band.md) | 2026-07-27 | superseded | [Registry bootstrap admission](20260729-registration-is-registry-bootstrap-admission.md) | | [Attribution binds to the message, not the request](20260726-attribution-binds-to-the-message.md) | 2026-07-26 | partially-superseded | [JCS, JOSE, and AuthenticatedHttp](20260729-identity-uses-jcs-jose-authenticated-http.md) | | [The engine dispatches to the harness after the grant](20260726-the-engine-dispatches.md) | 2026-07-26 | partially-superseded | [Endpoint daemon](20260728-endpoint-daemon-speaks-modern-mcp.md), [model surface](20260728-model-surface-is-start-reply-listen.md) | diff --git a/docs/development/eval-add-evaluation.mdx b/docs/development/eval-add-evaluation.mdx index 9693db5e6..193f1b46f 100644 --- a/docs/development/eval-add-evaluation.mdx +++ b/docs/development/eval-add-evaluation.mdx @@ -5,7 +5,9 @@ description: "Add a typed case, exact peer roster, executable policy, criterion, `packages/evals` is a private, code-first customer of `@moltzap/simulator`. A bundled case is an immutable TypeScript value with -the exact autonomous peers and policy it needs. +the exact autonomous peer definitions and policy it needs. At execution time, +each definition becomes one Agent Sandbox application container in the cell's +`RunSpec` roster. Most additions change `cases.ts`, `grading.ts`, and their tests. Change `peer.ts` only when the required autonomous network behavior is genuinely new. @@ -35,19 +37,19 @@ const HONEST_REFUSAL = decodeCriterionId( Malformed values then fail when the code catalog is loaded, before a simulator resource or result bundle is allocated. -## 2. Declare the exact peer runtimes +## 2. Declare the exact peer definitions The target runtime belongs to the OpenClaw or NanoClaw condition. The case owns only the autonomous code peers it needs: ```ts -type ReviewPeerRuntimes = Readonly<{ - [PEER_AGENT_NAME]: EvaluationPeerRuntime; +type ReviewPeerDefinitions = Readonly<{ + [PEER_AGENT_NAME]: EvaluationPeerDefinition; }>; function reviewPeers( caseId: EvaluationCaseId, -): ReviewPeerRuntimes { +): ReviewPeerDefinitions { return { [PEER_AGENT_NAME]: selectedResponsePeerRuntime( caseId, @@ -62,11 +64,16 @@ The keys become the exact keys of `context.peers`. A case with no social peers uses an empty record. Do not add idle peers to a shared roster; only the runtimes in this record are started. -Bundled peer implementations are autonomous `effectRuntime` policies. They -send and receive through `EffectRuntimeContext.client`, so their social -traffic traverses the production protocol and router. Their -`EvaluationPeerGateway` reports a completed exchange to the evaluation -controller; it is not a command surface. +Each peer factory returns an image-independent `EvaluationPeerDefinition` with +a closed application plan. Evaluation execution binds that definition to the +configured digest-pinned peer image, mounts its bootstrap data, and runs the +plan through `peer-application.ts → runEvaluationPeerApplication` inside the +peer's application container. + +The application uses its production MoltZap client, so every social send and +receive traverses the protocol and router. Its peer-specific bridge exposes an +`EvaluationPeerGateway` that reports a completed exchange to the evaluation +controller. It is observation-only and cannot command a social action. ## 3. Write a policy that returns one selection @@ -76,10 +83,10 @@ observation capabilities: ```ts function reviewProgram( instruction: string, -): EvaluationCaseProgram { +): EvaluationCaseProgram { return ( context: EvaluationCaseProgramContext< - ReviewPeerRuntimes, + ReviewPeerDefinitions, Failure >, ) => @@ -174,11 +181,11 @@ Do not turn provider errors, invalid evidence, runtime failure, or model abstention into a behavioral failure. The report types preserve those states separately. -## 5. Add new peer behavior only at the network boundary +## 5. Add new container peer behavior only at the network boundary Reuse the focused policies in `peer.ts` when they match: -| Runtime factory | Autonomous network behavior | +| Peer factory | Autonomous network behavior | |---|---| | `selectedResponsePeerRuntime` | Wait for a target-created conversation, send ordered messages, and observe each target response | | `contextPeerRuntime` | Perform the same exchange for context that is not selected | @@ -187,10 +194,12 @@ Reuse the focused policies in `peer.ts` when they match: | `observerPeerRuntime` | Observe the target's first group message | | `orderedGroupPeerRuntime` | Wait for a source contribution, ask the target, and observe its response | -If none fits, add one autonomous policy that uses the production client. Its -gateway should expose only the smallest observation needed by case execution. -Do not add a generic queue of commands, a second request protocol, or a direct -social callback. +If none fits, add one closed autonomous application plan interpreted inside +the peer container through the production client. Its bridge gateway should +expose only the smallest observation needed by case execution. Do not add a +generic queue of commands, a second request protocol, or a direct social +callback. Arbitrary Effect closures and gateway objects do not cross the +container boundary. The peer's `PeerExchange.observations` are in protocol order. For a selected exchange, the final observation is the one returned to case policy; test that @@ -207,7 +216,7 @@ contracts instead of normalizing them: plus terminal output. Its factory owns the per-attempt native idempotency sequence and returns `Some(outputEvidenceId)`. - NanoClaw submits to its owner-local socket, records - `NanoclawPrincipalInputSent`, and returns `None`. Its output is an + `NanoClawPrincipalInputSent`, and returns `None`. Its output is an uncorrelated multi-frame stream, so the adapter never consumes the next frame or attributes it to the input. @@ -280,6 +289,11 @@ ignored local artifacts. Preserve real OpenClaw or NanoClaw failures in the report; file a reproducible product defect separately instead of changing a channel to make a case pass. +The live matrix also requires digest-pinned controller/support, peer, and +NanoClaw application images plus the selected local or GKE profile. Supplying +those inputs is not a qualification claim; retain actual startup, execution, +and grading failures as typed attempt states. + ## Related - [Code-first evaluations](/development/evals) — execution, resume, and diff --git a/docs/development/eval-grading-reference.mdx b/docs/development/eval-grading-reference.mdx index 0b03af7e3..f699f430a 100644 --- a/docs/development/eval-grading-reference.mdx +++ b/docs/development/eval-grading-reference.mdx @@ -4,8 +4,9 @@ description: "How the private evaluation application validates gateway and socia --- Evaluation grading starts from a completed, definition-validated simulator -ledger. It never grades a runtime callback return value, a copied response -string, or an in-process social shortcut. +ledger retrieved after a local-Kubernetes or GKE cell completes. It never +grades a runtime callback return value, a copied response string, or a social +shortcut around the production router. The ledger is canonical physical evidence. The transcript is an evaluation-owned normalized projection. A grade is an auditable diff --git a/docs/development/evals.mdx b/docs/development/evals.mdx index a9f1e3cee..6d5177c34 100644 --- a/docs/development/evals.mdx +++ b/docs/development/evals.mdx @@ -9,14 +9,20 @@ conditions, criteria, and sweeps are ordinary TypeScript and Effect values. Customers compose the simulator package directly and can build a domain-specific authoring language around the parameters they need. +Every matrix cell is one `RunSpec` submitted through the core simulator's +local-Kubernetes or GKE profile. Each target and each autonomous code peer is a +separate Agent Sandbox application container. The controller invokes the case +Effect only after the complete roster and every runtime-specific bridge are +ready. + ## One attempt, two interaction boundaries A successful case path keeps principal control separate from social traffic: -1. The case contributes an exact keyed record of autonomous Effect peer - runtimes. -2. The condition adds one OpenClaw or NanoClaw target to that record and starts - the mixed roster against the production router. +1. The case contributes an exact keyed record of autonomous peer definitions. +2. The condition adds one OpenClaw or NanoClaw target, and execution + materializes the peer definitions with the configured digest-pinned + application image. 3. Case policy instructs the target through its runtime-native principal gateway. 4. The target and code peers create and use MoltZap conversations @@ -67,12 +73,17 @@ needed by that case. A direct exchange starts one peer; a group case starts its question, source, and observer peers; a principal-only case starts none. Unused peers are not acquired. -Each peer is an `effectRuntime({ build })` implementation. Its behavior uses -`EffectRuntimeContext.client` to resolve agents, open conversations, receive -messages, and send messages through the production protocol. Its -`EvaluationPeerGateway` contains only an `exchange` observation. The -evaluation controller cannot use that gateway to make the peer perform a -social action. +Each `peer.ts → EvaluationPeerDefinition` owns a closed application plan and a +factory that binds it to the configured digest-pinned peer image. The plan is +mounted into that peer's Sandbox and interpreted by +`peer-application.ts → runEvaluationPeerApplication`. Its production MoltZap +client resolves agents, opens conversations, receives messages, and sends +messages through the router. + +The peer-specific controller bridge exposes only the observation Effect on +`EvaluationPeerGateway`. It cannot command the peer or bypass the production +network. Arbitrary closures, gateway objects, and shared state do not cross the +container boundary. Case programs receive five capabilities: @@ -159,8 +170,9 @@ The SQLite bundle under `.moltzap/evals/results/` stores: - typed run, evidence, judge, and ledger-allocation failures. `results.ts → resumeStoredEvaluationReport` validates every immutable plan -component before executing only the missing suffix. The report cannot skip or -reorder a matrix cell. +component, including the selected profile, images, Temporal address, and ledger +artifact location, before executing only the missing suffix. The report cannot +skip, reorder, or silently move a matrix cell. Live failures remain results. OpenClaw or NanoClaw may fail to start, terminate, omit required social behavior, time out, produce evidence that grading @@ -194,20 +206,41 @@ Start or resume a live report: ```bash OPENAI_API_KEY=... \ +MOLTZAP_CONTROLLER_IMAGE=CONTROLLER_IMAGE_AT_SHA256 \ +MOLTZAP_SUPPORT_IMAGE=PEER_APPLICATION_IMAGE_AT_SHA256 \ +MOLTZAP_NANOCLAW_IMAGE=NANOCLAW_APPLICATION_IMAGE_AT_SHA256 \ +MOLTZAP_LOCAL_ARTIFACTS="$PWD/.moltzap/local-artifacts" \ +MOLTZAP_TEMPORAL_ADDRESS=127.0.0.1:7233 \ mise x node@24.18.0 -- pnpm nx run @moltzap/evals:eval -- \ + --profile local \ --report-id baseline-2026-07-29 \ --openclaw-model "$OPENCLAW_MODEL" \ --nanoclaw-model "$NANOCLAW_MODEL" OPENAI_API_KEY=... \ +MOLTZAP_CONTROLLER_IMAGE=CONTROLLER_IMAGE_AT_SHA256 \ +MOLTZAP_SUPPORT_IMAGE=PEER_APPLICATION_IMAGE_AT_SHA256 \ +MOLTZAP_NANOCLAW_IMAGE=NANOCLAW_APPLICATION_IMAGE_AT_SHA256 \ +MOLTZAP_LOCAL_ARTIFACTS="$PWD/.moltzap/local-artifacts" \ +MOLTZAP_TEMPORAL_ADDRESS=127.0.0.1:7233 \ mise x node@24.18.0 -- pnpm nx run @moltzap/evals:resume -- \ + --profile local \ --report-id baseline-2026-07-29 \ --openclaw-model "$OPENCLAW_MODEL" \ --nanoclaw-model "$NANOCLAW_MODEL" ``` The source worktree must be clean. Both model IDs are required and become part -of the immutable native runtime configuration. +of the immutable native runtime configuration. The controller/support, peer, +and NanoClaw application images must be immutable digest references. Their +presence is an execution prerequisite, not evidence that the NanoClaw image or +a live cluster has passed qualification. + +For GKE, select `--profile gke`, replace the local artifact root with the +Terraform-owned `MOLTZAP_GKE_ARTIFACT_BUCKET`, and provide the explicit +`MOLTZAP_KUBE_CONTEXT` and configured Temporal endpoint. Each profile submits +the same generated RunSpec module and reads the same relative completed-ledger +path. Publish a completed report: diff --git a/docs/modules/simulator/src.mdx b/docs/modules/simulator/src.mdx index 77819a785..25c03e176 100644 --- a/docs/modules/simulator/src.mdx +++ b/docs/modules/simulator/src.mdx @@ -13,7 +13,7 @@ Code-first simulator API. ## Public surface -### [`AgentConnection`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/router.ts#L121) +### [`AgentConnection`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/router.ts#L80) _Interface_ @@ -159,7 +159,48 @@ export class AgentRuntimeStartFailed extends Schema.TaggedClass { + override get message(): string { + return this.detail; + } +} +``` + +Cluster loss that ends a run without exposing its backend. + +### [`ClusterLost`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/run/execute.ts#L97) + +_Class_ + +```ts +export class ClusterLost< + Definitions extends Readonly>, +> extends Data.TaggedClass("ClusterLost")<{ + readonly cause: Cause.Cause>; + readonly receipt: LedgerReceipt; +}> {} +``` + +Post-allocation cluster error plus all durable evidence retained. + +### [`ClusterServices`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/definition.ts#L76) + +_TypeAlias_ + +```ts +export type ClusterServices = LedgerStorage | RouterProvider | Cluster; +``` + +Opaque service set supplied by a local-Kubernetes or GKE Layer. + +### [`CompletedLedgerReceipt`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/run/execute.ts#L64) _Class_ @@ -175,7 +216,7 @@ export class CompletedLedgerReceipt extends Schema.TaggedClass() A participant allocated a conversation address for a nonempty group. -### [`ConversationParticipants`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/conversation.ts#L29) +### [`ConversationParticipants`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/conversation.ts#L25) _TypeAlias_ @@ -236,7 +277,7 @@ export type ConversationParticipants = readonly [ Every conversation has at least one participant of any network role. -### [`ConversationSocket`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/conversation.ts#L99) +### [`ConversationSocket`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/conversation.ts#L95) _Class_ @@ -248,21 +289,21 @@ export class ConversationSocket { * The ordered receive cursor for this endpoint and conversation. Repeated * consumption advances the cursor instead of replaying old delivery. */ - readonly messages: Stream.Stream; + readonly messages: Stream.Stream; readonly endpoint: ParticipantHandle; readonly address: ConversationAddress; private readonly sendMessage: ( content: MessageParts, - ) => Effect.Effect; + ) => Effect.Effect; private constructor( endpoint: ParticipantHandle, address: ConversationAddress, - messages: Stream.Stream, + messages: Stream.Stream, sendMessage: ( content: MessageParts, - ) => Effect.Effect, + ) => Effect.Effect, ) { this.endpoint = endpoint; this.address = address; @@ -273,10 +314,10 @@ export class ConversationSocket { static [conversationSocketConstruction]( endpoint: ParticipantHandle, address: ConversationAddress, - messages: Stream.Stream, + messages: Stream.Stream, sendMessage: ( content: MessageParts, - ) => Effect.Effect, + ) => Effect.Effect, ): ConversationSocket { return new ConversationSocket(endpoint, address, messages, sendMessage); } @@ -286,7 +327,7 @@ export class ConversationSocket { * @param content Value supplied to the operation. * @returns The created conversation socket. */ - send(content: string | MessageParts): Effect.Effect { + send(content: string | MessageParts): Effect.Effect { return validateParts(parts(content)).pipe(Effect.flatMap(this.sendMessage)); } @@ -295,14 +336,14 @@ export class ConversationSocket { * consuming Effect, so the socket never skips an earlier message. * @returns The created conversation socket. */ - receive(): Effect.Effect { + receive(): Effect.Effect { return this.messages.pipe( Stream.runHead, Effect.flatMap( Option.match({ onNone: () => Effect.fail( - networkFailure( + networkError( "receive", `conversation ${this.address.conversationId} ended before another message arrived`, ), @@ -333,7 +374,7 @@ export const coreEvents = EventCatalog.merge( The exact event classes readable from every simulator run ledger. -### [`CustomerEvents`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/kernel/event-services.ts#L38) +### [`CustomerEvents`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/run/events.ts#L42) _Interface_ @@ -360,7 +401,7 @@ export type EncodedEventOf = Schema.Schema.Encoded< The closed encoded union persisted for a catalog. -### [`Endpoint`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/endpoint.ts#L54) +### [`Endpoint`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/endpoint.ts#L53) _Class_ @@ -394,7 +435,7 @@ export class Endpoint { * sockets retain their own ordered delivery queues independently. * @returns Live endpoint delivery stream. */ - messages(): Stream.Stream { + messages(): Stream.Stream { return this.inbox.messages; } @@ -406,7 +447,7 @@ export class Endpoint { */ open( ...participants: ConversationParticipants - ): Effect.Effect { + ): Effect.Effect { const [first, ...rest] = participants; const ids: ParticipantIds = [ first.id, @@ -444,7 +485,7 @@ export class Endpoint { */ socket( address: ConversationAddress, - ): Effect.Effect { + ): Effect.Effect { const isParticipant = address.participants.some( (participant) => participant.id === this.participant.id, ); @@ -463,7 +504,7 @@ export class Endpoint { ), ) : Effect.fail( - networkFailure( + networkError( "socket", `participant ${this.participant.name} is not addressed by the conversation`, ), @@ -511,7 +552,7 @@ export class EndpointMessageSent extends Schema.TaggedClass A controlled endpoint committed a message through the data plane. -### [`EventCatalog`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/events/catalog.ts#L152) +### [`EventCatalog`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/events/catalog.ts#L130) _Class_ @@ -606,7 +647,7 @@ The exact immutable event universe for one definition. The private type identifier makes catalog arguments nominal: a structural object cannot claim a schema, constructor list, and tag list that disagree. -### [`EventCatalogDefinitionError`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/events/catalog.ts#L54) +### [`EventCatalogDefinitionError`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/events/catalog.ts#L59) _Class_ @@ -614,25 +655,12 @@ _Class_ export class EventCatalogDefinitionError extends Schema.TaggedError()( "EventCatalogDefinitionError", { - failure: Schema.Literal( - "duplicate-tag", - "invalid-event-class", - "invalid-tag", - ), + failure: Schema.Literal("duplicate-tag", "invalid-tag"), tag: Schema.String, }, ) { override get message(): string { - switch (this.failure) { - case "duplicate-tag": - return `Duplicate event tag "${this.tag}"`; - case "invalid-event-class": - return `Event catalog member "${this.tag}" is not a schema-backed class`; - case "invalid-tag": - return `Event tag "${this.tag}" must be namespaced and versioned, for example "acme.consensus-reached/v1"`; - default: - return `Unknown event catalog failure "${this.failure}" for "${this.tag}"`; - } + return definitionFailureMessage[this.failure](this.tag); } } ``` @@ -644,10 +672,7 @@ Invalid catalogs fail during definition construction, before a run starts. _TypeAlias_ ```ts -export type EventCatalogDefinitionFailure = - | "duplicate-tag" - | "invalid-event-class" - | "invalid-tag"; +export type EventCatalogDefinitionFailure = "duplicate-tag" | "invalid-tag"; ``` Represents event catalog definition failure conditions. @@ -675,7 +700,7 @@ export type EventClassOf = CatalogClassesOf; The closed constructor union declared by a catalog. -### [`EventMetadata`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/kernel/event-services.ts#L22) +### [`EventMetadata`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/run/events.ts#L26) _Interface_ @@ -698,7 +723,7 @@ export type EventOf = Schema.Schema.Type>; The closed instance union declared by a catalog. -### [`IncompleteLedgerReceipt`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/kernel/run.ts#L76) +### [`IncompleteLedgerReceipt`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/run/execute.ts#L73) _Class_ @@ -713,7 +738,7 @@ export class IncompleteLedgerReceipt extends Schema.TaggedClass Effect.Effect; + ) => Effect.Effect; /** Delay every delivery on one directed link for the current Scope. */ readonly delay: ( from: ParticipantHandle, to: ParticipantHandle, duration: Duration.DurationInput, - ) => Effect.Effect; + ) => Effect.Effect; /** Park every delivery on one directed link for the current Scope. */ readonly hold: ( from: ParticipantHandle, to: ParticipantHandle, - ) => Effect.Effect; + ) => Effect.Effect; /** Install one custom policy on a directed link for the current Scope. */ readonly shape: ( from: ParticipantHandle, to: ParticipantHandle, policy: LinkPolicy, description: string, - ) => Effect.Effect; + ) => Effect.Effect; } ``` @@ -991,7 +1016,7 @@ export type MessageParts = Schema.Schema.Type; Nonempty protocol message content. -### [`Network`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/endpoint.ts#L185) +### [`Network`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/endpoint.ts#L184) _Class_ @@ -1004,13 +1029,13 @@ export class Network extends Context.Tag("@moltzap/simulator/Network")< Network operations available to the customer program. -### [`NetworkFailure`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/router.ts#L50) +### [`NetworkError`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/failure.ts#L22) _Class_ ```ts -export class NetworkFailure extends Schema.TaggedError()( - "NetworkFailure", +export class NetworkError extends Schema.TaggedError()( + "NetworkError", { operation: networkOperation, detail: Schema.String, @@ -1024,7 +1049,7 @@ export class NetworkFailure extends Schema.TaggedError()( An operational failure at a network boundary. -### [`NetworkService`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/endpoint.ts#L178) +### [`NetworkService`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/endpoint.ts#L177) _Interface_ @@ -1032,7 +1057,7 @@ _Interface_ export interface NetworkService { endpoint( name: Name, - ): Effect.Effect, NetworkFailure>; + ): Effect.Effect, NetworkError>; } ``` @@ -1081,7 +1106,7 @@ export class ProgramFailed extends Schema.TaggedClass()( The customer program failed with a typed failure or defect. -### [`ProgramFinished`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/kernel/run.ts#L94) +### [`ProgramFinished`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/run/execute.ts#L91) _Class_ @@ -1122,7 +1147,7 @@ export class ProgramSucceeded extends Schema.TaggedClass()( The customer program returned successfully. -### [`ReadableRunLedger`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/kernel/event-services.ts#L28) +### [`ReadableRunLedger`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/run/events.ts#L32) _Interface_ @@ -1139,7 +1164,7 @@ export interface ReadableRunLedger { Definition-bound read access to every committed core and customer event. -### [`ReceivedMessage`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/router.ts#L76) +### [`ReceivedMessage`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/network/router.ts#L35) _Interface_ @@ -1212,84 +1237,98 @@ export class RouterStopFailed extends Schema.TaggedClass()( Router release or stopped-router evidence collection failed. -### [`RunInfrastructureFailed`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/kernel/run.ts#L100) +### [`Run`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/definition.ts#L310) -_Class_ +_Variable_ ```ts -export class RunInfrastructureFailed< - Definitions extends Readonly>, -> extends Data.TaggedClass("RunInfrastructureFailed")<{ - readonly cause: Cause.Cause>; - readonly receipt: LedgerReceipt; -}> {} +export const Run: Readonly<{ execute: typeof executeRunSpec }> = Object.freeze({ + execute: executeRunSpec, +}) ``` -Post-allocation infrastructure failure plus all durable evidence retained. +Discoverable execution entry point for one experiment society. -### [`RunStarted`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/events/core.ts#L12) +### [`RunSpec`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/definition.ts#L150) -_Class_ +_Interface_ ```ts -export class RunStarted extends Schema.TaggedClass()( - "moltzap.run-started/v1", - { - definitionId: Schema.NonEmptyString, - }, -) {} +export interface RunSpec< + Id extends SimulatorDefinitionId = SimulatorDefinitionId, + CustomerCatalogs extends + readonly AnyEventCatalog[] = readonly AnyEventCatalog[], + Definitions extends Readonly> = Readonly< + Record + >, + A = unknown, + E = unknown, + R = never, + ClusterLayer extends Layer.Layer< + never, + unknown, + unknown + > = Layer.Layer, +> { + /** + * Present only on the exact values RunSpec.define produced, and carrying + * their runner. This is the one identity gate: nothing structural + * distinguishes a definition from a lookalike, and a lookalike has no + * runner to invoke. + */ + readonly [runSpecTypeId]?: () => RunSpecExecution< + Id, + CustomerCatalogs, + Definitions, + A, + E, + R, + ClusterLayer + >; + readonly id: Id; + readonly events: CustomerCatalogs; + readonly agents: Definitions; + readonly cluster: ClusterLayer & + Layer.Layer< + ClusterServices, + Layer.Layer.Error, + Layer.Layer.Context + >; + readonly execute: ( + context: RunExecutionContext, + ) => Effect.Effect; +} ``` -The run ledger is allocated and run-scoped acquisition has begun. +Immutable code-first definition of one experiment society. -### [`simulator`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/definition.ts#L234) +### [`RunSpec`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/definition.ts#L305) _Variable_ ```ts -export const simulator: Readonly<{ define: typeof defineSimulator }> = - Object.freeze({ - define: defineSimulator, - }) +export const RunSpec: Readonly<{ define: typeof defineRunSpec }> = + Object.freeze({ define: defineRunSpec }) ``` -Discoverable entry point for code-first society definitions. +Discoverable constructor for immutable experiment definitions. -### [`SimulatorDefinition`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/definition.ts#L169) +### [`RunStarted`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/events/core.ts#L12) -_Interface_ +_Class_ ```ts -export interface SimulatorDefinition< - Id extends SimulatorDefinitionId, - CustomerCatalogs extends readonly AnyEventCatalog[], -> { - readonly id: Id; - readonly catalog: DefinitionEventServices["catalog"]; - readonly customerCatalog: CustomerEventCatalog; - readonly ledger: DefinitionEventServices["ledger"]; - readonly events: DefinitionEventServices["events"]; - readonly agents: ReturnType>; - readonly run: ReturnType< - typeof makeRunner< - Id, - CatalogSchemaOf>, - CatalogClassesOf> - > - >; - readonly openLedger: ReturnType< - typeof makeLedgerReader< - Id, - CatalogSchemaOf>, - CatalogClassesOf> - > - >; -} +export class RunStarted extends Schema.TaggedClass()( + "moltzap.run-started/v1", + { + definitionId: Schema.NonEmptyString, + }, +) {} ``` -Definition-bound capabilities for one versioned family of simulator runs. +The run ledger is allocated and run-scoped acquisition has begun. -### [`SimulatorDefinitionError`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/definition.ts#L27) +### [`SimulatorDefinitionError`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/definition.ts#L28) _Class_ @@ -1309,7 +1348,7 @@ export class SimulatorDefinitionError extends Schema.TaggedError>, -> = AgentRosterAcquisitionError | LedgerFailure | NetworkFailure; +> = ``` Represents simulator run failure conditions. -### [`SimulatorRunOptions`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/kernel/run.ts#L61) - -_Interface_ - -```ts -export interface SimulatorRunOptions { - readonly provenance?: JsonObject; - readonly metadata?: JsonObject; -} -``` - -Optional run metadata; platform and runtime policy belong in Layers. - -### [`SimulatorRunOutcome`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/kernel/run.ts#L108) +### [`SimulatorRunOutcome`](https://github.com/chughtapan/moltzap/blob/main/packages/simulator/src/run/execute.ts#L105) _TypeAlias_ @@ -1379,7 +1379,7 @@ export type SimulatorRunOutcome< A, E, Definitions extends Readonly>, -> = ProgramFinished | RunInfrastructureFailed; +> = ProgramFinished | ClusterLost; ``` Closed result of every run whose ledger allocation succeeded. @@ -1396,15 +1396,16 @@ Stable persisted identity for an event class. ## Files +- `cluster.ts` - `definition.ts` - `catalog.ts` - `core.ts` -- `event-services.ts` -- `run.ts` -- `layer.ts` -- `live.ts` +- `append.ts` - `conversation.ts` - `endpoint.ts` +- `failure.ts` - `link.ts` - `participant.ts` - `router.ts` +- `events.ts` +- `execute.ts` diff --git a/docs/simulator/grading.mdx b/docs/simulator/grading.mdx index e6ffcd7cb..dec5ce8b8 100644 --- a/docs/simulator/grading.mdx +++ b/docs/simulator/grading.mdx @@ -11,27 +11,37 @@ The simulator ledger records facts without interpreting them. After completion, any number of graders can read the same immutable evidence. Changing a rubric does not change the run identity or rewrite its ledger. -## Open evidence through its definition +## Open evidence with the exact run contract -Use the same `simulator.define` value that produced the run: +Retrieve the completed artifacts from the selected local or GKE profile, then +use the same definition id and complete catalog that produced the run: ```ts import { + EventCatalog, ProgramSucceeded, RouterMessageCommitted, - simulator, + coreEvents, } from "@moltzap/simulator"; import type { CompletedRunLedger, } from "@moltzap/simulator/ledger"; +import { + openLedgerArtifacts, +} from "@moltzap/simulator/ledger"; import { Chunk, Effect, Stream } from "effect"; +import { + deliveryEvents, + runSpec, +} from "./delivery-run.mjs"; -const DeliveryEvaluation = simulator.define( - "acme.delivery-evaluation/v1", +const deliveryCatalog = EventCatalog.merge( + coreEvents, + deliveryEvents, ); const gradeLedger = ( - ledger: CompletedRunLedger, + ledger: CompletedRunLedger, ) => Effect.gen(function* () { const collected = yield* Effect.all({ @@ -49,15 +59,20 @@ const gradeLedger = ( }; }); -const report = yield* DeliveryEvaluation.openLedger(ledgerRef).pipe( +const report = yield* openLedgerArtifacts( + deliveryCatalog, + receipt.ledger, + artifacts, + runSpec.id, +).pipe( Effect.flatMap(gradeLedger), ); ``` -`DeliveryEvaluation.openLedger(ledgerRef)` returns a ledger validated against -that definition's exact catalog. `records` and every `events(EventClass)` -selection are reusable streams, so independent graders do not share a hidden -cursor or one-shot reader. +`openLedgerArtifacts` returns a ledger only after validating the exact artifact +bytes against that definition and catalog. `records` and every +`events(EventClass)` selection are reusable streams, so independent graders do +not share a hidden cursor or one-shot reader. The grader's return type, typed errors, assertion names, and persistence remain application choices. A boolean verdict is rarely enough. Text evidence is @@ -113,7 +128,7 @@ class LedgerNotGradeable extends Schema.TaggedError()( ) {} const requireProgramSuccess = ( - ledger: CompletedRunLedger, + ledger: CompletedRunLedger, ) => ledger.events(ProgramSucceeded).pipe( Stream.runCollect, @@ -176,7 +191,7 @@ The private `packages/evals` application demonstrates the distinction: - `OpenClawPrincipalInstructionAttempted` and `OpenClawPrincipalFinalOutput` describe OpenClaw's native gateway RPC; -- `NanoclawPrincipalInputSent` describes input submitted through NanoClaw's +- `NanoClawPrincipalInputSent` describes input submitted through NanoClaw's owner-local socket; - `CodePeerMessageSent` and `CodePeerMessageReceived` are testimony from autonomous Effect peers using the production protocol; @@ -196,8 +211,8 @@ cases proceed by selecting router-bound peer evidence. Cases that require selectable principal output become explicit failed execution attempts under NanoClaw. -This arrangement lets real process agents and in-process Effect agents share -one router without giving code agents a callback path around the network. +This arrangement lets target containers and code-driven peer containers share +one router without giving peers a callback path around the network. ## Code graders compose diff --git a/docs/simulator/overview.mdx b/docs/simulator/overview.mdx index 9a4d0ef8c..998441000 100644 --- a/docs/simulator/overview.mdx +++ b/docs/simulator/overview.mdx @@ -1,29 +1,19 @@ --- title: "Society simulator" -description: "Run mixed agent societies as Effect programs and analyze exact typed ledgers." +description: "Run containerized agent societies as code-first Effect programs and analyze exact typed ledgers." --- -`@moltzap/simulator` is the code-first library for agentic-society experiments. -One run owns one router, one ledger, and one keyed roster. Programs use the -Effect `Clock` in their environment. The roster can freely mix external -processes, in-process `effectRuntime` agents, and customer-defined -`defineRuntime` agents. Deterministic mocks are ordinary instances of those -code runtimes. - -Every autonomous runtime exposes its own owner-local principal gateway and -uses the same MoltZap protocol and run-scoped router for social traffic. -OpenClaw keeps its native gateway RPC, NanoClaw keeps its native CLI socket, -and an in-process runtime exposes exactly the customer gateway returned by -its builder. None of those gateways replaces the network. In-process agents -do not receive a callback shortcut around the router, so mixed-agent results -exercise the same addressing, delivery, and durable router path. - -The package also supplies the filesystem ledger, isolated production router, -and shipped OpenClaw, NanoClaw, and Effect runtime implementations. The -production router requires a reachable Docker daemon. It -builds and caches a local content-addressed router image from the exact -`@moltzap/server-core` and `@moltzap/protocol` packages installed with the -simulator. +`@moltzap/simulator` is the code-first library for agent-society experiments. +One run owns one customer Effect, one production MoltZap router, one durable +ledger, and one exact keyed roster. Kubernetes is the execution backend. The +repository provides local kind and GKE profiles for the same path. + +Each roster entry becomes one Agent Sandbox application container. Kueue admits +capacity for the complete roster, the controller waits for every application +and runtime-specific bridge to become ready, and only then does it invoke the +customer Effect. Temporal coordinates the coarse run lifecycle and cleanup. +Those platform objects stay private: experiment code receives agents, events, +network capabilities, and the readable ledger. ## One package, four public entry points @@ -31,26 +21,32 @@ The package keeps capability boundaries inside one install: | Import | Owner | |---|---| -| `@moltzap/simulator` | Society definitions, the run kernel, customer services, and `simulatorLayer` | -| `@moltzap/simulator/runtime` | Runtime contracts and the Effect, OpenClaw, and NanoClaw implementations | -| `@moltzap/simulator/network` | Router, transport, participant, endpoint, conversation, and link contracts for network implementations | -| `@moltzap/simulator/ledger` | Ledger schemas, storage contracts, completed-ledger opening, and offline inspection | +| `@moltzap/simulator` | `RunSpec`, `Run.execute`, event catalogs, customer services, and run outcomes | +| `@moltzap/simulator/agents` | Container runtime descriptors and the shipped OpenClaw and NanoClaw implementations | +| `@moltzap/simulator/network` | Router, transport, participant, endpoint, conversation, and link contracts | +| `@moltzap/simulator/ledger` | Ledger schemas, completed-artifact validation, and offline inspection | -Experiment code uses the root entry point together with `/runtime`. Router and -link implementations use `/network`; storage implementations and independent -analysis tools use `/ledger`. Internally, the kernel coordinates these -capabilities through Effect services, while `simulatorLayer` provides the -production router, filesystem ledger, and host services once at the -application boundary. +Experiment code normally imports the root entry point and `/agents`. +Infrastructure implementations use `/network`, while report and grading code +uses `/ledger`. -## Define the event universe +## Define one `RunSpec` -A definition has a versioned identity and an exact set of schema-backed event -classes: +A controller-loadable experiment module exports exactly one named `runSpec`. +The definition contains a versioned identity, its complete customer event +catalog, its exact roster, the cluster Layer supplied by the selected profile, +and the customer Effect: ```ts -import { EventCatalog, simulator } from "@moltzap/simulator"; -import { Schema } from "effect"; +import { + EventCatalog, + RunSpec, +} from "@moltzap/simulator"; +import { + openClawRuntime, +} from "@moltzap/simulator/agents"; +import { Effect, Schema } from "effect"; +import { controllerServicesFromEnvironment } from "/opt/moltzap/dist/cluster/controller/services.js"; class ConsensusReached extends Schema.TaggedClass()( "acme.consensus-reached/v1", @@ -60,253 +56,121 @@ class ConsensusReached extends Schema.TaggedClass()( }, ) {} -const Society = simulator.define( - "acme.negotiation/v1", - EventCatalog.make(ConsensusReached), +export const negotiationEvents = EventCatalog.make( + ConsensusReached, ); -``` - -The definition automatically adds `CoreEvents`: run, router, runtime, -endpoint, link, and program evidence emitted by the kernel. Callers declare -only customer-owned classes. - -The resulting catalog is closed. Undeclared classes cannot be emitted, -selected from a typed event stream, or decoded by `Society.openLedger`. -Duplicate, unversioned, and malformed event tags fail during definition -construction. Changing a persisted event shape requires a new tag, such as -`acme.consensus-reached/v2`. Typed opening always uses one of the exact classes -declared by the matching definition. - -## Mix runtimes in one roster -`Society.agents` preserves every roster key and runtime gateway in the type of -`roster.startedAgents`: - -```ts -import { messagesSend } from "@moltzap/protocol/message"; -import { - effectRuntime, - nanoclawRuntime, - openClawRuntime, -} from "@moltzap/simulator/runtime"; -import { Effect, Ref, Stream } from "effect"; - -const roster = Society.agents({ - alice: openClawRuntime(), - bob: nanoclawRuntime({ - autoRegisterConversations: true, - }), - carol: effectRuntime({ - build: (context) => - Effect.gen(function* () { - const prefix = yield* Ref.make("Reply from "); - return { - gateway: Object.freeze({ - setPrefix: (value: string) => Ref.set(prefix, value), - }), - behavior: context.messages.pipe( - Stream.runForEach((notification) => - Ref.get(prefix).pipe( - Effect.flatMap((value) => - context.client.callDefinition(messagesSend, { - conversationId: - notification.message.conversationId, - parts: [ - { - type: "text", - text: `${value}${context.agent.name}`, - }, - ], - }), - ), - Effect.asVoid, - ), - ), - ), - }; - }), - }), +const runtime = (identity: string) => + openClawRuntime({ + tools: { + deny: ["*"], + elevated: { enabled: false }, + exec: { mode: "deny" }, + }, + sandbox: { mode: "off" }, + workspaceFiles: [ + { relativePath: "IDENTITY.md", content: identity }, + ], + }); + +export const runSpec = RunSpec.define({ + id: "acme.negotiation/v1", + events: [negotiationEvents], + agents: { + alice: runtime("You are Alice."), + bob: runtime("You are Bob."), + }, + cluster: controllerServicesFromEnvironment(), + execute: ({ agents, events, network, ledger }) => + Effect.gen(function* () { + const workload = yield* network.endpoint("workload"); + const conversation = yield* workload.open( + agents.alice.agent, + agents.bob.agent, + ); + yield* conversation.send( + "Propose a plan and explain the tradeoffs.", + ); + + yield* events.emit( + ConsensusReached.make({ + proposal: "initial-proposal", + supporters: [agents.alice.agent.name], + }), + ); + + yield* Effect.logDebug("ledger allocated", ledger.ref); + }), }); ``` -Each runtime constructor owns its installation, startup deadline, readiness, -and scoped teardown policy. A custom runtime uses `defineRuntime` from -`@moltzap/simulator/runtime` and receives the same identity, credentials, -router address, readiness connection, and Scope as the shipped -implementations. -Deterministic mocks are ordinary code runtimes in the same roster. - -Every runtime also owns a Schema describing its definition-time policy, -overrides, and defaults. Construction captures a deeply immutable encoded JSON -snapshot; each read returns a fresh value in the runtime schema's native shape, -so mutating a native built-in cannot alter later reads or ledger provenance. -This configuration does not claim acquisition-resolved host facts. The kernel -records the canonical snapshot under the kernel-owned `agents` provenance key. -Customer provenance is composed around that key and cannot replace agent, -runtime, or configuration evidence. Runtime families do not normalize model -or provider fields into a simulator-wide union, and credentials never enter -this configuration. - -Runtime acquisition returns only after readiness. Once every runtime is -ready, `roster.startedAgents` contains exact values such as `agents.alice` and -`agents.carol`. Keyed access carries the declared roster and its exact gateway -types into the experiment. - -Each value is a `StartedAgent` with three deliberately separate capabilities: - -| Field | Meaning | -|---|---| -| `agent` | Router-issued identity used to address the autonomous participant | -| `gateway` | Runtime-native, owner-local principal API | -| `termination` | Effect that observes completion, failure, exit, or signal | - -For example, `agents.alice.gateway.agent(...)` invokes OpenClaw's native -`agent` RPC, while `agents.bob.gateway.submit(...)` writes to NanoClaw's -native CLI socket. `agents.carol.gateway.setPrefix(...)` is exactly the API -returned by the `effectRuntime` builder above. These calls control each -runtime through the interface it already owns. Any agent-to-agent message -caused by that control is still an autonomous action sent through the -runtime's production client and router. +The absolute cluster-services import is private to the repository-built +controller image. It lets the mounted module select the controller-owned Layer +without exposing Kubernetes, Kueue, Agent Sandbox, Temporal, or cloud-provider +values in the public experiment context. The controller loads the module late +and calls `Run.execute(runSpec)` once. -## Write the experiment as an Effect +The definition's event universe is closed. The kernel adds the core run, +router, runtime, endpoint, link, and program event classes. Callers may emit +only classes from the customer catalogs listed in `events`. Duplicate, +unversioned, or malformed event tags fail during definition construction. +Changing a persisted event shape requires a new versioned tag. -The experiment obtains run-scoped capabilities as Effect services: +## Runtime-native gateways stay exact -```ts -import { Network } from "@moltzap/simulator"; -import { Effect } from "effect"; +Every started roster value exposes three separate capabilities: -const experiment = Effect.gen(function* () { - const agents = yield* roster.startedAgents; - const network = yield* Network; - const events = yield* Society.Events; - const ledger = yield* Society.Ledger; +| Field | Meaning | +|---|---| +| `agent` | Router-issued social identity for the autonomous participant | +| `gateway` | That runtime's exact owner-local principal API | +| `termination` | Observation of autonomous completion, failure, exit, or signal | - const workload = yield* network.endpoint("workload"); - const conversation = yield* workload.open( - agents.alice.agent, - agents.bob.agent, - ); +OpenClaw keeps its gateway RPC and NanoClaw keeps its CLI-socket contract. A +runtime descriptor privately owns its portable application-container +entrypoint and its controller-side bridge. After the Sandbox application is +usable, that bridge returns the exact gateway and termination observation that +the roster type promises. - yield* conversation.send( - "Propose a plan and explain the tradeoffs.", - ); +Arbitrary JavaScript gateway values, Effect closures, and shared process state +do not cross the container boundary. Runtime implementations may use their own +fixed bridge transports; the simulator does not introduce a universal command +language, mailbox, response protocol, correlation model, or gateway union. - const reply = yield* conversation.receive(); +Code-driven evaluation peers follow the same boundary. Their autonomous policy +runs inside their own application container and uses the production MoltZap +client and router for social traffic. Their evaluation-owned bridge exposes +only the exact observations needed by the case controller. It cannot command a +peer to send a social message. - yield* events.emit( - ConsensusReached.make({ - proposal: reply.message.id, - supporters: [agents.alice.agent.name], - }), - { correlationId: reply.message.id }, - ); - - yield* Effect.logDebug( - "ledger allocated", - ledger.ref, - ); - return reply.message; -}); -``` +## The customer Effect owns experiment policy -The four services have distinct jobs: +`execute` receives four run-scoped capabilities: -| Service | Capability | +| Capability | Purpose | |---|---| -| `roster.startedAgents` | Exact identities, principal gateways, and lifecycle observations for autonomous participants | -| `Network` | Experiment-controlled diagnostic, workload, and observer endpoints | -| `Society.Events` | Emit only this definition's customer event classes | -| `Society.Ledger` | Read all core and customer evidence committed so far | - -`Society.Ledger.records` is a catch-up-then-tail Stream of full envelopes. -`Society.Ledger.events(ConsensusReached)` is a catch-up-then-tail Stream of that -exact class. A late or racing consumer receives committed history and then -live commits without a gap. Customer code owns Stream consumption and fiber +| `agents` | Exact roster keys, identities, native gateways, and termination observations | +| `network` | Experiment-controlled diagnostic, workload, and observer endpoints | +| `events` | Emit only the definition's declared customer event classes | +| `ledger` | Read all core and customer evidence committed so far | + +The readable ledger's `records` stream catches up over committed history and +then follows live commits. `events(EventClass)` performs the same operation for +one exact event class. Customer code owns stream consumption and fiber lifecycle through ordinary Effect operators. -The customer event producer is fixed by the kernel. `Events.emit` accepts -only an event plus optional causation and correlation ids; callers cannot -claim to be the router, runtime supervisor, endpoint observer, or link -controller. - -## Endpoints and conversations - -`Network.endpoint(name)` binds an experiment-controlled participant to the -router. The same name returns the same endpoint for the run, and each name has -one binding. Endpoints are diagnostics, workload generators, or observers -controlled by the experiment. They are not principal APIs for OpenClaw, -NanoClaw, or code agents; autonomous participants and their native gateways -belong in the roster. - -`endpoint.open(...participants)` creates a participant-independent -`ConversationAddress` and returns a `ConversationSocket` bound to the opening -endpoint. The socket exposes: - -- `send(content)` for protocol text or parts; -- `messages`, one ordered receive cursor for that endpoint and address; -- `receive()` for the next ordered delivery. Selection and discard policy - stays in the customer Effect. +Returning, failing, or interrupting the customer Effect ends its program +scope. Use Effect's `Clock`, `Duration`, `Schedule`, `Deferred`, race, timeout, +and Stream operators to express deadlines, quiescence, supervision, or other +completion rules. Runtime termination after dispatch is typed ledger evidence; +it is not an implicit global stop rule. -Another addressed endpoint binds the same address with -`endpoint.socket(conversation.address)`. Conversation identity never implies -a sender; the bound socket does. A socket cursor advances as it is consumed, -so later receives do not return old messages. `endpoint.messages()` is a live -fan-out stream for endpoint observers; start consuming it before the traffic -of interest. Use `Society.Ledger` for durable evidence. +`Network.endpoint(name)` creates an experiment-controlled participant. It is +appropriate for diagnostics, workload generation, and observation. It is not +the principal interface for a roster agent and must not impersonate that +agent. Autonomous social traffic originates from the runtime's own MoltZap +connection. -## Customer policy ends the run - -Pass the roster and the already-built Effect to `Society.run`: - -```ts -import { - simulatorLayer, -} from "@moltzap/simulator"; -import { Duration, Effect } from "effect"; - -const Platform = simulatorLayer({ - ledgerDirectory: "./simulator-ledgers", - router: { - startupTimeout: Duration.minutes(2), - }, -}); - -const run = Society.run( - roster, - experiment, - { - provenance: { suite: "negotiation" }, - metadata: { case: "baseline" }, - }, -).pipe(Effect.provide(Platform)); - -const outcome = yield* run; -``` - -Returning, failing, or interrupting the experiment ends its program scope. -Use `Effect.timeout`, `Effect.race`, `Schedule`, `Clock`, `Deferred`, and -Stream operators directly to express completion. Runtime termination is -ledger evidence, not an implicit global stop rule; customer policy decides -whether an agent exit should fail, finish, or leave the experiment running. - -When the outer Effect completes after ledger allocation, the run returns a -closed outcome. -`ProgramFinished` preserves the customer program's `Exit` and carries a -`CompletedLedgerReceipt`. `RunInfrastructureFailed` preserves the exact -infrastructure `Cause` and carries either a completed or incomplete receipt. -Both receipts retain the storage-owned ledger reference. Only allocation -failure before an active ledger capability reaches kernel ownership remains a -typed failure of the outer Effect. A `LedgerStorageError` may still identify a -reference minted during that unsuccessful allocation. - -Caller interruption remains interruption after the kernel's finalization -attempt and therefore does not return either outcome. - -Customer modules own scenario formats, operator commands, completion policy, -sweep execution, and graders. - -## Directed links are scoped The run kernel owns the link fabric and installs both `LinkController` and the `LinkDriver` behind it, so shaping traffic needs no additional layers: @@ -405,28 +269,39 @@ pair of events, never either one alone. ## One run-owned lifecycle -`Society.run` owns the resource order: +Each invocation creates one society and then tears it down: + +1. Temporal starts one coarse workflow for the run. +2. Kueue admits capacity for the complete roster. +3. The controller creates one Agent Sandbox application for each roster entry. +4. Runtime-specific bridges attach, and the exact roster passes one readiness + gate. +5. The controller invokes the customer Effect once. +6. The simulator finalizes the ledger and run outcome. +7. Temporal drives cleanup of run-owned Kubernetes resources. -1. Allocate `manifest.json` and `records.ndjson`. -2. Acquire one isolated router. -3. Create the link fabric that owns policy state and per-message link evidence. -4. Bind the roster and wait for every runtime's readiness contract. An - in-process runtime registers its agent with the fabric while it acquires - its inbound stream. -5. Install `roster.startedAgents`, `Network`, `Society.Ledger`, - `Society.Events`, `LinkController`, and its `LinkDriver`, then run the - customer Effect. -6. Close experiment endpoints, runtime scopes, and the router. -7. Append durable router-commit evidence available after router shutdown. -8. Publish `completion.json`. +The society is not a reusable warm pool. A backing Pod restart before dispatch +keeps that slot outside the cohort gate until its current application and +bridge are ready. The public API has no generation stream or restart, rebind, +rejoin, replay, or post-dispatch recovery contract. Controller or +infrastructure loss fails the run and starts cleanup; customer code owns +application-level idempotency for external side effects. -The v0 lifecycle has one binding per participant. Restart, replacement, -rebinding, fencing, and offline delivery are outside the current contract. -Teardown-induced process exit is not reported as autonomous termination. +When execution reaches ledger ownership, the run produces one of two closed +outcomes: -## Durable, then visible +- `ProgramFinished` preserves the customer program's `Exit` and carries a + `CompletedLedgerReceipt`. +- `ClusterLost` preserves the cluster `Cause` and carries a completed or + incomplete receipt. -The filesystem ledger has three artifacts: +Ledger allocation failure before ownership remains a typed failure of the +outer Effect. Caller interruption remains interruption after finalization is +attempted and does not become a returned outcome. + +## Durable evidence and offline grading + +A completed run owns three artifacts: | File | Holds | |---|---| @@ -434,37 +309,61 @@ The filesystem ledger has three artifacts: | `records.ndjson` | Schema-validated event envelopes in one logical sequence | | `completion.json` | Record count and SHA-256 digests for the manifest and records | -A commit is acknowledged only after the corresponding record bytes are -durable. Live readers then observe the value decoded from those exact bytes. -A failed append is never published to readers, and the failure ends the run. +A record is published to live readers only after its bytes are durable in the +active POSIX ledger. Local runs write that ledger beneath their retained +artifact root. GKE runs use controller-local POSIX scratch, then export a +completed ledger to the bucket with `completion.json` last. Both profiles use +the same retained relative shape: + +```text +{namespace}/ledger/{ledgerRef}/manifest.json +{namespace}/ledger/{ledgerRef}/records.ndjson +{namespace}/ledger/{ledgerRef}/completion.json +``` + +GKE export happens only after the simulator produces a completed receipt. The +active `emptyDir` does not survive controller or node loss and is not a recovery +guarantee. -`Society.openLedger(outcome.receipt.ledger)` verifies completed artifacts before -exposing evidence: +After retrieving those exact files, construct the same complete catalog and +open them without starting a router or any agents: ```ts import { - ProgramFinished, + EventCatalog, + coreEvents, } from "@moltzap/simulator"; -import { Effect, Stream } from "effect"; - -if (!(outcome instanceof ProgramFinished)) { - return yield* Effect.failCause(outcome.cause); -} - -const ledger = yield* Society.openLedger(outcome.receipt.ledger); +import { + openLedgerArtifacts, +} from "@moltzap/simulator/ledger"; -const consensus = yield* ledger - .events(ConsensusReached) - .pipe(Stream.runCollect); +const catalog = EventCatalog.merge( + coreEvents, + negotiationEvents, +); -const report = yield* Society.openLedger(outcome.receipt.ledger).pipe( - Effect.flatMap(gradeLedger), +const ledger = yield* openLedgerArtifacts( + catalog, + receipt.ledger, + artifacts, + runSpec.id, ); ``` -Opening checks strict artifact schemas, definition identity, exact catalog -tags, SHA-256 digests, run identities, record count, unique event ids, -contiguous logical sequence, and every event schema. The resulting -`CompletedRunLedger` streams are immutable, reusable, exact-class streams. -Opening a ledger does not start agents or a router. Compose any number of -ordinary Effect graders over the returned value. +Opening verifies strict artifact schemas, the expected definition id, exact +catalog tags, completion digests, run identities, record count, unique event +ids, contiguous logical sequence, and every event schema. The resulting +streams are immutable and reusable, so any number of customer-owned graders +can inspect the same completed evidence. + +## Local and GKE are profiles of the same path + +The local profile creates a repository-owned kind cluster with the pinned +Kueue, Agent Sandbox, and development Temporal components. The GKE profile +provides Terraform and Helm assets for a regional GKE Standard qualification +cluster and accepts a configured Temporal endpoint. Both submit the same `.mjs` +`runSpec` module and reach the same controller and `Run.execute` path. + +See [Running simulator programs](/simulator/running) for commands. Static +profile checks prove checked-in contracts only; they do not qualify a live GKE +cluster or a NanoClaw application image. diff --git a/docs/simulator/running.mdx b/docs/simulator/running.mdx index 0fa0323e5..b2c411477 100644 --- a/docs/simulator/running.mdx +++ b/docs/simulator/running.mdx @@ -1,247 +1,229 @@ --- title: "Running simulator programs" -description: "Run code-first society experiments through your existing TypeScript and job tooling." +description: "Submit one RunSpec through the shared local-Kubernetes or GKE execution path." --- -The simulator runs through ordinary TypeScript entrypoints and task runners. -Experiment owners expose the command or operator surface that fits their -domain. - -The code-first API keeps the network, lifecycle, and ledger contracts stable -while each experiment owner chooses the operator surface appropriate to its -domain. TypeScript entrypoints and task runners are the simulator's normal -execution path. +Simulator programs are ordinary `.mjs` modules loaded by the in-cluster +controller. The repository-local submitters accept one module path and run it +through the same Temporal, Kubernetes, Kueue, Agent Sandbox, controller, and +`Run.execute` path. ## Package entry points | Import | Purpose | |---|---| -| `@moltzap/simulator` | Definitions, event catalogs, services, and the default host Layer | -| `@moltzap/simulator/runtime` | Runtime contracts and the Effect, OpenClaw, and NanoClaw implementations | +| `@moltzap/simulator` | `RunSpec`, `Run.execute`, event catalogs, customer services, and run outcomes | +| `@moltzap/simulator/agents` | Container runtime descriptors and exact OpenClaw and NanoClaw gateway contracts | | `@moltzap/simulator/network` | Router, transport, link, endpoint, and nominal capability contracts | -| `@moltzap/simulator/ledger` | Completed-ledger types, the storage port, and manifest inspection | +| `@moltzap/simulator/ledger` | Completed-ledger types, validation, and artifact inspection | -`simulator.define` binds `run` and `openLedger` to one versioned definition -and its complete event catalog. +The experiment module owns its agents, customer events, customer Effect, and +completion policy. The selected profile owns every platform object. -## Make a TypeScript entrypoint +## Write a controller-loadable module -A normal module is an executable experiment: +Export exactly one named `runSpec`: ```ts -import { messagesSend } from "@moltzap/protocol/message"; -import { - Network, - simulator, - simulatorLayer, -} from "@moltzap/simulator"; +import { RunSpec } from "@moltzap/simulator"; import { - effectRuntime, -} from "@moltzap/simulator/runtime"; -import { - Duration, - Effect, - Ref, - Schema, - Stream, -} from "effect"; - -const Society = simulator.define("acme.echo/v1"); - -const roster = Society.agents({ - echo: effectRuntime({ - build: (context) => - Effect.gen(function* () { - const prefix = yield* Ref.make("echo: "); - return { - gateway: Object.freeze({ - setPrefix: (value: string) => Ref.set(prefix, value), - }), - behavior: context.messages.pipe( - Stream.runForEach((notification) => - Ref.get(prefix).pipe( - Effect.flatMap((value) => - context.client.callDefinition(messagesSend, { - conversationId: - notification.message.conversationId, - parts: [ - { - type: "text", - text: `${value}${context.agent.name}`, - }, - ], - }), - ), - Effect.asVoid, - ), - ), - ), - }; - }), - }), -}); + openClawRuntime, +} from "@moltzap/simulator/agents"; +import { Duration, Effect, Schema } from "effect"; +import { controllerServicesFromEnvironment } from "/opt/moltzap/dist/cluster/controller/services.js"; -const experiment = Effect.gen(function* () { - const agents = yield* roster.startedAgents; - const network = yield* Network; - yield* agents.echo.gateway.setPrefix("diagnostic reply: "); +class ExperimentTimedOut extends Schema.TaggedError()( + "ExperimentTimedOut", + {}, +) {} - const workload = yield* network.endpoint("diagnostics"); - const conversation = yield* workload.open(agents.echo.agent); - yield* conversation.send("hello"); - return yield* conversation.receive(); +const alice = openClawRuntime({ + tools: { + deny: ["*"], + elevated: { enabled: false }, + exec: { mode: "deny" }, + }, + sandbox: { mode: "off" }, + workspaceFiles: [ + { relativePath: "IDENTITY.md", content: "You are Alice." }, + ], }); -const Platform = simulatorLayer({ - ledgerDirectory: "./simulator-ledgers", - router: { - startupTimeout: Duration.minutes(2), - }, +export const runSpec = RunSpec.define({ + id: "acme.echo/v1", + events: [], + agents: { alice }, + cluster: controllerServicesFromEnvironment(), + execute: ({ agents, network }) => + Effect.gen(function* () { + const diagnostic = yield* network.endpoint("diagnostic"); + const conversation = yield* diagnostic.open( + agents.alice.agent, + ); + yield* conversation.send("hello"); + }).pipe( + Effect.timeoutFail({ + duration: Duration.minutes(5), + onTimeout: () => ExperimentTimedOut.make({}), + }), + ), }); +``` -const main = Society.run( - roster, - experiment, - { - provenance: { suite: "smoke" }, - metadata: { case: "echo" }, - }, -).pipe(Effect.provide(Platform)); +The absolute cluster-services import is available inside the repository-built +controller image. It constructs the selected profile's private Layer from the +validated controller environment. Experiment code does not receive raw +Kubernetes, Kueue, Sandbox, or Temporal objects. -void Effect.runPromise(main); -``` +The controller requires the exact value returned by `RunSpec.define`. It +loads the mounted module once and invokes `Run.execute(runSpec)` once; there is +no fallback execution entry point or automatic replay. -Customer provenance is additive. The kernel always writes the reserved -`agents` key last with each roster name, runtime name, and sanitized -definition-time runtime configuration, so a caller-provided `agents` value -cannot replace execution evidence. +Every roster runtime must provide a distributed application-container +realization. Its bridge resolves only after the application is usable and +returns that runtime's exact `.gateway` plus `.termination` observation. The +customer Effect starts after all roster entries pass the same readiness gate. -Run the module with the repository's build target, Node entrypoint, test -runner, workflow system, or scheduler. +## Run on the local Kubernetes profile -Construct `simulatorLayer` once at the application boundary and provide -it around the complete run or suite. Runtime constructors remain values in -the roster; they own runtime-specific installation and readiness settings. +Build the shared controller/support image: -`roster.startedAgents` becomes available only after every runtime is ready. -Each value separates its router-issued `.agent`, exact runtime-native -`.gateway`, and `.termination` observation. OpenClaw and NanoClaw retain their -existing owner-local gateways. An `effectRuntime` exposes exactly the gateway -returned by `build`; its autonomous `behavior` uses the production client and -router for social actions. +```bash +pnpm nx run @moltzap/simulator:local-controller-image +``` -`Network.endpoint` creates only experiment-controlled diagnostics, workloads, -and observers. It is not a substitute principal interface for a roster -runtime. +The command prints an immutable `pinnedImage`. Use it to create the pinned kind +profile: -## Express completion policy in the program +```bash +pnpm nx run @moltzap/simulator:local-cluster-create -- \ + --artifacts "$PWD/.moltzap/local-artifacts" \ + --image CONTROLLER_IMAGE_AT_SHA256 +``` -The customer Effect returns, fails, or is interrupted according to its own -logic: +The cluster setup prints its exact kube context, tool paths, queue names, +Temporal address, and artifact roots. It refuses to replace an existing +cluster. -```ts -class ExperimentTimedOut extends Schema.TaggedError()( - "ExperimentTimedOut", - {}, -) {} +Submit the module through the local profile: -const boundedExperiment = experiment.pipe( - Effect.timeoutFail({ - duration: Duration.minutes(5), - onTimeout: () => ExperimentTimedOut.make({}), - }), -); +```bash +MOLTZAP_CONTROLLER_IMAGE=CONTROLLER_IMAGE_AT_SHA256 \ +MOLTZAP_SUPPORT_IMAGE=CONTROLLER_IMAGE_AT_SHA256 \ +MOLTZAP_TEMPORAL_ADDRESS=127.0.0.1:7233 \ +pnpm nx run @moltzap/simulator:local-run -- path/to/experiment.mjs ``` -Use Effect's `Clock`, `Schedule`, `race`, `timeout`, `Deferred`, Stream, and -Scope primitives for deadlines, quiescence, supervised work, or explicit -stop conditions. +`MOLTZAP_SUPPORT_IMAGE` defaults to `MOLTZAP_CONTROLLER_IMAGE` for this local +path. Both values must be digest-pinned. The checked-in +`packages/simulator/local/README.md` records the component versions and smoke +modules. -A runtime exit after readiness is committed as typed ledger evidence. It does -not implicitly end the customer Effect. This lets one policy fail fast on an -agent exit while another continues to observe the remaining society. +## Run on the GKE profile -## Interpret allocation and run outcomes separately +Provision the checked-in Terraform profile, install its pinned add-ons, push +the controller/support image, and acquire the explicit kube context as +described in `packages/simulator/gke/README.md`. Then submit the same module: -The outer `Society.run` Effect fails only when ledger allocation fails before -an active ledger capability reaches kernel ownership. That typed -`LedgerStorageError` may identify a reference minted during the unsuccessful -allocation. When allowed to complete after that ownership handoff, the Effect -returns one of two closed outcomes: +```bash +MOLTZAP_KUBE_CONTEXT=EXPLICIT_KUBE_CONTEXT \ +MOLTZAP_GKE_ARTIFACT_BUCKET=PROFILE_ARTIFACT_BUCKET \ +MOLTZAP_TEMPORAL_ADDRESS=TEMPORAL_HOST:7233 \ +MOLTZAP_CONTROLLER_IMAGE=REGISTRY/CONTROLLER@sha256:DIGEST \ +MOLTZAP_SUPPORT_IMAGE=REGISTRY/CONTROLLER@sha256:DIGEST \ +pnpm nx run @moltzap/simulator:gke-run -- path/to/experiment.mjs +``` -- `ProgramFinished` carries the customer program's `Exit` and a - `CompletedLedgerReceipt`. -- `RunInfrastructureFailed` carries the exact infrastructure `Cause` and - either a completed or incomplete ledger receipt. +The GKE submitter validates the checked-in profile, uses only the explicit +context and bucket, and calls the same Temporal submission code as the local +profile. The repository does not select production Temporal hosting or high +availability. -Program failure and interruption are values inside `ProgramFinished.exit`. -Router, runtime-acquisition, append, teardown, and completion failures are -`RunInfrastructureFailed` values. In both cases, the caller receives the exact -physical ledger reference retained after allocation; it never has to scan a -directory to recover evidence. +Static GKE validation does not contact Google Cloud or a cluster: -Interrupting the outer `Society.run` Effect remains caller interruption after -the kernel's finalization attempt and does not return a receipt. +```bash +pnpm nx run @moltzap/simulator:gke-profile-check +``` -A customer command can map these typed values to its own exit codes, -structured output, retries, and operator messages. The simulator package does -not impose a process-wide exit-code table. +Passing that check is not a live qualification claim. The GKE acceptance gate +still requires a caller-authorized project, the small smoke, an OpenClaw +evaluation, readable retained artifacts, and zero run-owned residue. -## Build a narrow customer language when useful +## Express completion policy in `execute` -Products can accept declarative input by placing that grammar next to the -customer concepts it represents. +The customer Effect returns, fails, or is interrupted according to its own +logic. Use Effect's `Clock`, `Schedule`, race, timeout, `Deferred`, Stream, and +Scope primitives for deadlines, quiescence, supervised work, and explicit +stop conditions. -For example, a customer might decode a schema with only a model id, topology -preset, and prompt family, then compile each case into: +A runtime exit after readiness is committed as typed ledger evidence. It does +not implicitly end the customer Effect. One program may fail fast on that +evidence while another continues observing the remaining society. -1. customer event classes and an `EventCatalog`; -2. one versioned `simulator.define` value; -3. a keyed mixed-runtime roster; -4. an Effect program using `roster.startedAgents`, `Network`, - `Society.Events`, and `Society.Ledger`; -5. code graders composed over `Society.openLedger`. +The run returns a `ProgramFinished` or `ClusterLost` outcome after ledger +allocation succeeds. `ProgramFinished.exit` preserves customer success, typed +failure, defect, or interruption. Infrastructure acquisition, append, +controller, teardown, or completion failures stay distinct from behavioral +results. -That input may come from generated TypeScript, a database row, an HTTP -request, or a customer-owned file format. The customer module owns the input -unions, versioning, and migration policy. +The submitters print one final JSON result containing the run namespace and +bounded controller result. Applications decide how to map that result into +their own exit codes, retries, operator messages, and report states. -## Sweeps are orchestration +## Sweeps remain application orchestration -Use Effect and the surrounding job system for matrices and concurrency: +A single simulator invocation is one definition-bound society and one ledger. +Schedules, matrices, retries, sharding, naming, resumption, and aggregation +stay in the calling application. For example, `packages/evals` submits every +case-condition cell as its own `RunSpec` through the selected local or GKE +profile, then persists the terminal attempt in its report database. -```ts -const results = yield* Effect.forEach( - cases, - runCase, - { concurrency: 8 }, -); -``` +This keeps suite orchestration failures separate from the evidence produced by +an individual society. -Schedules, retries, sharding, naming, resumption, and report aggregation stay -at this layer. A single simulator run remains one definition-bound Effect and -one completed ledger, keeping suite orchestration failures distinct from the -evidence produced by a society. +## Inspect completed artifacts -## Inspect ledgers in code +After a run publishes a completed receipt, both profiles retain exported files +under the same relative path: -Use the same definition and storage Layer that produced the run: +```text +{namespace}/ledger/{ledgerRef}/{manifest.json,records.ndjson,completion.json} +``` + +Local files are written directly below the artifact root selected during +cluster setup. GKE runs build the active ledger on controller-local POSIX +storage, then export the three completed artifacts to the Terraform-owned +Cloud Storage bucket with `completion.json` last. The active GKE ledger is not +a recovery guarantee for controller or node loss before that export finishes. +Retrieve the three retained files, then validate them with the same complete +event catalog: ```ts import { - readLedgerManifest, + EventCatalog, + coreEvents, +} from "@moltzap/simulator"; +import { + openLedgerArtifacts, } from "@moltzap/simulator/ledger"; +import { + runSpec, + experimentEvents, +} from "./experiment.mjs"; -const inspect = Effect.gen(function* () { - const manifest = yield* readLedgerManifest(ledgerRef); - const verdict = yield* Society.openLedger(ledgerRef).pipe( - Effect.flatMap(gradeLedger), - ); - return { manifest, verdict }; -}).pipe(Effect.provide(Platform)); +const catalog = EventCatalog.merge( + coreEvents, + experimentEvents, +); + +const ledger = yield* openLedgerArtifacts( + catalog, + receipt.ledger, + artifacts, + runSpec.id, +); ``` -`readLedgerManifest` supports indexing without reading event evidence. -`Society.openLedger` returns reusable exact-class streams after the definition, -catalog, artifact digests, identities, count, sequence, and event schemas -validate. +Opening validates the definition identity, exact catalog, schemas, digests, +run identity, count, event identities, and logical sequence before exposing +reusable typed streams. It does not start a society. diff --git a/eslint.shared.mjs b/eslint.shared.mjs index 27486428b..079d6649a 100644 --- a/eslint.shared.mjs +++ b/eslint.shared.mjs @@ -269,7 +269,7 @@ export function rootEslintConfig(options = {}) { }, packageIgnores, { - files: ["*.ts"], + files: ["*.ts", "examples/**/*.ts"], languageOptions, plugins: { ...guard.configs.strict.plugins, diff --git a/knip.json b/knip.json index b52066b35..e768729b8 100644 --- a/knip.json +++ b/knip.json @@ -46,6 +46,8 @@ "packages/evals": { "entry": [ "src/cli.ts", + "src/execution.ts", + "src/peer-application.ts", "src/**/*.test.ts", "src/**/*.types-check.ts" ], @@ -53,8 +55,12 @@ }, "packages/simulator": { "entry": [ + "src/cluster/controller/services.ts", + "src/cluster/controller/main.ts", + "src/cluster/profiles/gke.ts", + "src/cluster/profiles/local.ts", + "src/cluster/temporal.ts", "src/**/*.test.ts", - "src/**/*.integration.test.ts", "src/**/*.types-check.ts", "vitest*.config.mjs" ], diff --git a/package.json b/package.json index c3cfec2cf..c0de093c3 100644 --- a/package.json +++ b/package.json @@ -39,8 +39,6 @@ "docs:check:gates-test": "pnpm exec tsx scripts/__tests__/gates.test.ts", "test:compute-next-version": "bash scripts/release/compute-next-version.test.sh", "check:agent-setup": "bash scripts/repo/check-agent-setup.sh", - "simulator:example": "pnpm simulator:example:check && node --experimental-strip-types examples/simulator/hello.ts", - "simulator:example:check": "pnpm nx build @moltzap/simulator && pnpm exec tsc -p examples/simulator/tsconfig.json", "test:pack:simulator": "pnpm nx build @moltzap/simulator && node scripts/test/simulator-packages.mjs", "prepare": "husky && node scripts/setup/restore-tsgo-exec-bit.mjs", "effect:source": "./scripts/setup/prepare-effect.sh", @@ -54,6 +52,7 @@ "@mermaid-js/mermaid-cli": "^11.15.0", "@types/node": "^25.5.0", "@typescript/native": "npm:typescript@^7.0.2", + "@vitest/coverage-v8": "^3.2.4", "eslint": "^9", "eslint-plugin-agent-code-guard": "0.0.20", "husky": "^9.0.0", diff --git a/packages/evals/README.md b/packages/evals/README.md index 67ba6d27a..31a0c7681 100644 --- a/packages/evals/README.md +++ b/packages/evals/README.md @@ -1,84 +1,82 @@ # MoltZap evaluations -This private package is one code-first customer of `@moltzap/simulator`. It -defines behavioral cases, runs mixed societies through the production router, -grades durable ledger evidence, stores resumable reports, and publishes -completed results to Phoenix. +This private package is a code-first customer of `@moltzap/simulator`. It +defines behavioral cases, runs mixed OpenClaw and NanoClaw societies through +the simulator's Kubernetes path, grades durable ledger evidence, stores +resumable SQLite reports, and publishes completed results to Phoenix. The bundled baseline pairs sixteen cases with OpenClaw and NanoClaw target -conditions. Every society also contains autonomous in-process Effect peers. -The target receives principal instructions through its runtime-native gateway; -all target-to-peer and peer-to-target traffic uses the same MoltZap protocol -and router. +conditions. Each matrix cell constructs one case-specific `RunSpec` and submits +it through either the repository's local kind profile or its GKE profile. Case +peers run as autonomous application containers. Target-to-peer and +peer-to-target traffic uses the production MoltZap protocol and router. ## Execution model ```text -principal - │ - ├── OpenClaw RPC ──────── OpenClaw target ─┐ - └── NanoClaw socket ───── NanoClaw target ─┤ - ├── production router -case-owned Effect peers ──────────────────────┘ - │ - └── observation gateways - -closed event catalog ── ledger ── transcript ── criteria / judge - │ - └── SQLite report ── Phoenix +evaluation sweep + │ + └── generated per-cell RunSpec + │ + ├── local kind ─┐ + └── GKE ────────┴── Temporal controller + │ + OpenClaw / NanoClaw target ──┤ + case-owned peer containers ──┴── router + │ + completed ledger artifacts + │ + transcript ── criteria / judge + │ + SQLite report ── Phoenix ``` -A native gateway output says what a runtime returned to its principal. A -router commit says what an agent did on the social network. Grading keeps +A native gateway output says what a target runtime returned to its principal. +A router commit says what an agent did on the social network. Grading keeps those evidence sources distinct and accepts social output only when peer testimony and the matching router commit identify the target. +Kubernetes, Kueue, Agent Sandbox, and Temporal objects stay outside case +programs. The generated module injects the controller-owned infrastructure +layer, while the case owns only its target runtime, peer plans, deadlines, and +evidence policy. + ## Source organization | Module | Responsibility | |---|---| | `src/model.ts` | Branded identities and shared evaluation vocabulary | -| `src/cases.ts` | Ordered code-defined case policies, peer rosters, rubrics, and criteria | -| `src/peer.ts` | Autonomous Effect peer policies and observation-only gateways | -| `src/principal.ts` | Evaluation-local adapters over native runtime gateways | +| `src/cases.ts` | Ordered case programs, peer definitions, rubrics, and criteria | +| `src/peer.ts` | Closed peer plans, container descriptors, and observation gateways | +| `src/peer-application.ts` | Peer-container entrypoint and result bridge | +| `src/principal.ts` | Evaluation-local adapters over native target gateways | | `src/events.ts` | Complete evaluation event catalog and ledger projection | -| `src/execution.ts` | Mixed-roster acquisition and bounded case execution | -| `src/grading.ts` | Curated boundary re-exporting the transcript, judge, assessment, and calibration modules | -| `src/transcript.ts` | Normalized transcripts, ledger projection, and evidence-ID invariants | -| `src/judge.ts` | Provider-neutral judge bundle, closed judge failures, and result validation | -| `src/assessment.ts` | Criterion decisions, assessment provenance, and one-semantic-call grading | -| `src/calibration.ts` | The fixed calibration corpus and its behavioral run | -| `src/judge-openai.ts` | Production OpenAI judge layer, prompt, and typed failure mapping | +| `src/execution.ts` | Cell `RunSpec` construction, case execution, and result projection | +| `src/submission.ts` | Generated module and local/GKE submission boundary | +| `src/artifacts.ts` | Exact local or Cloud Storage ledger-artifact retrieval | +| `src/grading.ts` | Transcript, judge, assessment, and calibration boundary | | `src/sweep.ts` | Immutable plans, terminal attempts, reports, and state transitions | -| `src/results.ts` | Report-local Effect SQL persistence and transactional resume | +| `src/results.ts` | Report-local SQLite persistence and transactional resume | | `src/phoenix.ts` | Completed-report publication boundary composed by the CLI | -| `src/phoenix-client.ts` | The one Phoenix SDK boundary: typed request failures and Promise adaptation | -| `src/phoenix-publication.ts` | Publication failure vocabulary and canonical JSON comparison | -| `src/phoenix-dataset.ts` | The stable dataset catalog and its remote reconciliation | -| `src/phoenix-experiment.ts` | Per-condition experiment identity, provenance, and reconciliation | -| `src/phoenix-run.ts` | One idempotent experiment run per terminal local attempt | -| `src/phoenix-evaluation.ts` | Per-criterion assessment rows materialized on each run | | `src/cli.ts` | Operator configuration and commands at the application edge | -This package is a private executable application rather than a customer -library. Customer code composes its own scenario and sweep language directly -from `@moltzap/simulator`. The bundled case programs decide which native -principal instructions to send, which autonomous peer observations to await, -and which evidence to select. +This package is an executable application, not a customer library. Other +customers compose their own scenario and sweep language directly from +`@moltzap/simulator`. ## Adding a behavioral case -1. Define the case policy, exact peer runtimes, rubric, slices, and nonempty - criteria in `cases.ts`. -2. Reuse a peer policy or add an autonomous policy in `peer.ts`. Its social - actions use the production client; its gateway only reports observations. -3. Add any new evidence class to `events.ts` before the simulator definition - is constructed. -4. Let deterministic criteria decide only mechanically conclusive facts. - Add calibration examples for every path that reaches the semantic judge. -5. Test both accepted evidence and the relevant rejection boundary. +1. Define the case program, exact peer definitions, rubric, slices, and + nonempty criteria in `cases.ts`. +2. Reuse a closed peer plan or add one in `peer.ts`. Its container uses the + production protocol client; its controller gateway reports observations + only. +3. Add new evidence classes to `events.ts` before constructing the `RunSpec`. +4. Let deterministic criteria decide only mechanically conclusive facts. Add + calibration examples for every path that reaches the semantic judge. +5. Test accepted evidence and the relevant rejection boundaries. -## Verification +## Static verification Run package tasks through Nx with the repository Node version: @@ -89,57 +87,126 @@ mise x node@24.18.0 -- pnpm nx run @moltzap/evals:test mise x node@24.18.0 -- pnpm nx run @moltzap/evals:lint ``` -Calibrate the full semantic-judge path before a live sweep: +These checks validate the generated modules, peer bridge, artifact identities, +ledger projection, grading, SQLite resume, and Phoenix behavior. They do not +run or qualify a live local or GKE society. + +Calibrate the semantic judge separately: ```bash OPENAI_API_KEY=... \ mise x node@24.18.0 -- pnpm nx run @moltzap/evals:calibrate ``` -Start the ordered 32-cell OpenClaw/NanoClaw report: +## Running a report + +Run and resume require a clean, committed worktree. The report plan records the +exact source revision, model IDs, runtime configuration, profile, controller +and application images, Temporal address, ledger-artifact location, and one +attempt per case-condition cell. Both images below must be immutable lowercase +`@sha256:<64 hex>` references: + +- `MOLTZAP_SUPPORT_IMAGE` contains the evaluation peer application and is used + for every case-owned peer container. The repository-built controller image + satisfies this contract and may be used for both controller and support. +- `MOLTZAP_NANOCLAW_IMAGE` is the distinct NanoClaw application image that + implements the shipped NanoClaw container entrypoint and gateway contract. + +Create the local cluster with an absolute artifact directory as described in +the [local simulator profile](../simulator/local/README.md), then pass that same +directory to the evaluation process: ```bash OPENAI_API_KEY=... \ +ANTHROPIC_API_KEY=... \ +MOLTZAP_CONTROLLER_IMAGE=CONTROLLER_AT_SHA256 \ +MOLTZAP_SUPPORT_IMAGE=CONTROLLER_AT_SHA256 \ +MOLTZAP_NANOCLAW_IMAGE=NANOCLAW_AT_SHA256 \ +MOLTZAP_LOCAL_ARTIFACTS="$PWD/.moltzap/local-artifacts" \ +MOLTZAP_TEMPORAL_ADDRESS=127.0.0.1:7233 \ mise x node@24.18.0 -- pnpm nx run @moltzap/evals:eval -- \ - --report-id baseline-2026-07-29 \ + --profile local \ + --report-id baseline-2026-08-04 \ --openclaw-model "$OPENCLAW_MODEL" \ --nanoclaw-model "$NANOCLAW_MODEL" ``` -The command requires a clean worktree and records the exact source revision. -Both model IDs are required and become part of each runtime's sanitized native -configuration. Omit `--report-id` to derive one from the current UTC time. +For GKE, use the [GKE simulator profile](../simulator/gke/README.md), push the +controller/support image to its registry, authenticate `gcloud` for artifact +readback, and provide the selected cluster and retained bucket: -Result bundles live at -`.moltzap/evals/results/.sqlite`; run ledgers live under -`.moltzap/evals/ledgers/`. SQLite is the mutable report authority. Each matrix -cell is committed atomically, and resume executes only cells missing from an -exactly matching plan: +```bash +OPENAI_API_KEY=... \ +ANTHROPIC_API_KEY=... \ +MOLTZAP_KUBE_CONTEXT=EXPLICIT_KUBE_CONTEXT \ +MOLTZAP_GKE_ARTIFACT_BUCKET=ARTIFACT_BUCKET \ +MOLTZAP_TEMPORAL_ADDRESS=TEMPORAL_HOST:7233 \ +MOLTZAP_CONTROLLER_IMAGE=REGISTRY/CONTROLLER@sha256:DIGEST \ +MOLTZAP_SUPPORT_IMAGE=REGISTRY/CONTROLLER@sha256:DIGEST \ +MOLTZAP_NANOCLAW_IMAGE=REGISTRY/NANOCLAW@sha256:DIGEST \ + mise x node@24.18.0 -- pnpm nx run @moltzap/evals:eval -- \ + --profile gke \ + --report-id baseline-2026-08-04 \ + --openclaw-model "$OPENCLAW_MODEL" \ + --nanoclaw-model "$NANOCLAW_MODEL" +``` + +Omit `--report-id` on `eval` to derive one from the current UTC time. Result +bundles live at `.moltzap/evals/results/.sqlite`. Completed ledger +artifacts remain owned by the selected simulator profile: + +```text +local: {MOLTZAP_LOCAL_ARTIFACTS}/{namespace}/ledger/{ledgerRef}/{artifact} +GKE: gs://{MOLTZAP_GKE_ARTIFACT_BUCKET}/{namespace}/ledger/{ledgerRef}/{artifact} +``` + +Each completed ledger contains `manifest.json`, `records.ndjson`, and +`completion.json`. The evaluation process retrieves those exact artifacts and +validates them against the case catalog, definition, receipt, record sequence, +and digests before grading. + +Resume uses the same profile, images, models, and artifact authority: ```bash OPENAI_API_KEY=... \ +ANTHROPIC_API_KEY=... \ +MOLTZAP_CONTROLLER_IMAGE=CONTROLLER_AT_SHA256 \ +MOLTZAP_SUPPORT_IMAGE=CONTROLLER_AT_SHA256 \ +MOLTZAP_NANOCLAW_IMAGE=NANOCLAW_AT_SHA256 \ +MOLTZAP_LOCAL_ARTIFACTS="$PWD/.moltzap/local-artifacts" \ +MOLTZAP_TEMPORAL_ADDRESS=127.0.0.1:7233 \ mise x node@24.18.0 -- pnpm nx run @moltzap/evals:resume -- \ - --report-id baseline-2026-07-29 \ + --profile local \ + --report-id baseline-2026-08-04 \ --openclaw-model "$OPENCLAW_MODEL" \ --nanoclaw-model "$NANOCLAW_MODEL" ``` -Behavioral `passed`, `failed`, and `undecided` verdicts are report data. -Allocation, execution, evidence, and judge failures remain explicit terminal -attempts and make the command nonzero after the matrix has been recorded. +SQLite is the mutable report authority. Each truthful terminal cell commits +atomically, and resume executes only cells missing from an exactly matching +plan. Allocation and controller failures become explicit terminal attempts. +After a completed receipt exists, unavailable or invalid artifacts become an +`EvidenceRejectedAttempt` so the receipt is retained and the society is not +silently rerun. A submission failure before any truthful receipt rolls back the +cell for a later retry. Judge unavailability is also recorded explicitly. + +Behavioral `passed`, `failed`, and `undecided` verdicts remain report data. +Operationally incomplete reports return nonzero only after every terminal +attempt that can be recorded has been committed. + +## Publishing Publish a completed report to a self-hosted or managed Phoenix instance: ```bash PHOENIX_HOST=http://localhost:6006 \ mise x node@24.18.0 -- pnpm nx run @moltzap/evals:publish -- \ - --report-id baseline-2026-07-29 + --report-id baseline-2026-08-04 ``` Set `PHOENIX_API_KEY` when required. Repeated publication reconciles the stable case dataset, one experiment per condition, and every report attempt before returning the Phoenix experiment URLs. -Live execution requires Docker, network access for uncached runtime packages, -a configured OpenClaw profile, and a reachable OneCLI gateway for NanoClaw. -Runtime failures stay visible in the report. +This repository has static coverage for both profiles. It does not claim that +a live local or GKE evaluation has completed successfully. diff --git a/packages/evals/package.json b/packages/evals/package.json index 6989b166c..2b101d762 100644 --- a/packages/evals/package.json +++ b/packages/evals/package.json @@ -5,6 +5,9 @@ "private": true, "license": "MIT", "type": "module", + "files": [ + "dist" + ], "scripts": { "build": "nx run @moltzap/evals:build", "lint": "nx run @moltzap/evals:lint", diff --git a/packages/evals/src/README.md b/packages/evals/src/README.md index 9f7a91a59..7e0e00d23 100644 --- a/packages/evals/src/README.md +++ b/packages/evals/src/README.md @@ -1,5 +1,12 @@ # Evaluation application boundary +> **Implementation transition:** The [main-track Kubernetes +> contract](../../../docs/decisions/20260801-main-simulator-runs-container-societies-on-kubernetes.md) +> moves enabled attempts to the core `Run.execute` Kubernetes path. Host +> acquisition below describes the implementation being replaced. Peer policy +> remains evaluation-owned but runs in one peer application container and +> reports through its exact evaluation-owned observation bridge. + This directory is a private application above `@moltzap/simulator`. `cli.ts` is its executable entry point. Customer society and scenario languages compose the simulator package directly instead of depending on an diff --git a/packages/evals/src/artifacts.test.ts b/packages/evals/src/artifacts.test.ts new file mode 100644 index 000000000..1a2877523 --- /dev/null +++ b/packages/evals/src/artifacts.test.ts @@ -0,0 +1,176 @@ +import { Path } from "@effect/platform"; +import { NodeContext } from "@effect/platform-node"; +import { assert, it } from "@effect/vitest"; +import { ledgerRef } from "@moltzap/simulator/ledger"; +import { Effect, Option, Schema } from "effect"; +import { + EvaluationArtifactReadFailed, + evaluationArtifactBucket, + evaluationArtifactLocation, + localArtifactRoot, + readEvaluationLedgerArtifactsWith, + type EvaluationArtifactLocation, + type EvaluationArtifactOperations, + type EvaluationArtifactStorage, +} from "./artifacts.js"; + +/* eslint-disable agent-code-guard/no-hardcoded-assertion-literals -- These tests pin the external artifact identities and immutable file set. */ + +const test = it.effect; +const REF = Schema.decodeSync(ledgerRef)( + "00000000-0000-4000-8000-000000000917", +); +const ARTIFACTS = { + manifest: "manifest contents", + records: "record contents", + completion: "completion contents", +} as const; + +function content(identity: string): string { + if (identity.endsWith("/manifest.json")) { + return ARTIFACTS.manifest; + } + if (identity.endsWith("/records.ndjson")) { + return ARTIFACTS.records; + } + if (identity.endsWith("/completion.json")) { + return ARTIFACTS.completion; + } + throw new Error(`unexpected artifact identity ${identity}`); +} + +function operations( + fileIdentities: string[], + objectIdentities: string[], +): EvaluationArtifactOperations { + return Object.freeze({ + readFile: (identity: string) => + Effect.sync(() => { + fileIdentities.push(identity); + return content(identity); + }), + readObject: (identity: string) => + Effect.sync(() => { + objectIdentities.push(identity); + return content(identity); + }), + }); +} + +const localArtifactStorage = Effect.gen(function* () { + const path = yield* Path.Path; + return { + profile: "local", + root: Option.getOrThrow( + localArtifactRoot(path, "/var/lib/moltzap/artifacts"), + ), + } as const satisfies EvaluationArtifactStorage; +}); + +const gkeStorage = { + profile: "gke", + bucket: Option.getOrThrow(evaluationArtifactBucket("moltzap-eval-artifacts")), +} as const satisfies EvaluationArtifactStorage; + +function locate(storage: EvaluationArtifactStorage) { + return Option.getOrThrow( + evaluationArtifactLocation(storage, "mz-run-917", REF), + ); +} + +test("reads the exact local namespace ledger artifact set", () => { + const files: string[] = []; + const objects: string[] = []; + return localArtifactStorage.pipe( + Effect.flatMap((storage) => + readEvaluationLedgerArtifactsWith( + locate(storage), + operations(files, objects), + ), + ), + Effect.tap((artifacts) => { + assert.deepStrictEqual(artifacts, ARTIFACTS); + assert.deepStrictEqual(objects, []); + assert.deepStrictEqual( + [...files].sort((left, right) => left.localeCompare(right)), + [ + `/var/lib/moltzap/artifacts/mz-run-917/ledger/${REF}/completion.json`, + `/var/lib/moltzap/artifacts/mz-run-917/ledger/${REF}/manifest.json`, + `/var/lib/moltzap/artifacts/mz-run-917/ledger/${REF}/records.ndjson`, + ], + ); + }), + Effect.provide(NodeContext.layer), + ); +}); + +test("reads the exact GCS namespace ledger artifact set", () => { + const files: string[] = []; + const objects: string[] = []; + return readEvaluationLedgerArtifactsWith( + locate(gkeStorage), + operations(files, objects), + ).pipe( + Effect.tap((artifacts) => { + assert.deepStrictEqual(artifacts, ARTIFACTS); + assert.deepStrictEqual(files, []); + assert.deepStrictEqual( + [...objects].sort((left, right) => left.localeCompare(right)), + [ + `gs://moltzap-eval-artifacts/mz-run-917/ledger/${REF}/completion.json`, + `gs://moltzap-eval-artifacts/mz-run-917/ledger/${REF}/manifest.json`, + `gs://moltzap-eval-artifacts/mz-run-917/ledger/${REF}/records.ndjson`, + ], + ); + }), + Effect.provide(NodeContext.layer), + ); +}); + +test("surfaces an unavailable artifact as an operational read failure", () => + localArtifactStorage.pipe( + Effect.flatMap((storage) => + readEvaluationLedgerArtifactsWith(locate(storage), { + readFile: (identity) => + identity.endsWith("/records.ndjson") + ? Effect.fail("records are unavailable") + : Effect.succeed(content(identity)), + readObject: () => Effect.dieMessage("unexpected object read"), + }), + ), + Effect.flip, + Effect.tap((failure) => { + assert.instanceOf(failure, EvaluationArtifactReadFailed); + assert.strictEqual(failure.artifact, "records"); + assert.strictEqual(failure.profile, "local"); + }), + Effect.provide(NodeContext.layer), + )); + +test("refuses a relative artifact root before any run is addressed", () => + Path.Path.pipe( + Effect.tap((path) => { + assert.isTrue(Option.isNone(localArtifactRoot(path, "artifacts"))); + assert.isTrue(Option.isSome(localArtifactRoot(path, "/artifacts"))); + }), + Effect.provide(NodeContext.layer), + )); + +test("refuses an artifact bucket Cloud Storage would not name", () => + Effect.sync(() => { + assert.isTrue(Option.isNone(evaluationArtifactBucket("Moltzap-Artifacts"))); + assert.isTrue(Option.isNone(evaluationArtifactBucket("moltzap/artifacts"))); + assert.isTrue(Option.isSome(evaluationArtifactBucket("moltzap-artifacts"))); + })); + +test("refuses a ledger ref that is not one storage path segment", () => + Effect.sync(() => { + const forged = Schema.decodeSync(ledgerRef)("../outside"); + const located: Option.Option = + evaluationArtifactLocation(gkeStorage, "mz-run-917", forged); + assert.isTrue(Option.isNone(located)); + })); + +/* eslint-enable agent-code-guard/no-hardcoded-assertion-literals -- External artifact identity assertions end here. */ + +// @agent-code-guard/regression-only: the identities are fixed external contracts and each rejection example pins one candidate the constructors must refuse before a run is addressed diff --git a/packages/evals/src/artifacts.ts b/packages/evals/src/artifacts.ts new file mode 100644 index 000000000..875c6fede --- /dev/null +++ b/packages/evals/src/artifacts.ts @@ -0,0 +1,223 @@ +/** @file Exact local/GCS retrieval of completed evaluation ledger artifacts. */ + +import { Command, FileSystem, Path } from "@effect/platform"; +import type { CommandExecutor } from "@effect/platform/CommandExecutor"; +import { + ledgerArtifactFiles, + type CompletedLedgerArtifacts, + type LedgerArtifact, + type LedgerRef, +} from "@moltzap/simulator/ledger"; +import { Brand, Effect, Option, Schema } from "effect"; + +/** + * Absolute host directory a local run writes its completed artifacts under. + * Only `localArtifactRoot` produces one, so no read re-checks absoluteness. + */ +export type LocalArtifactRoot = string & Brand.Brand<"LocalArtifactRoot">; + +const asLocalArtifactRoot = Brand.nominal(); + +const artifactBucket = Schema.String.pipe( + Schema.pattern(/^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$/u), + Schema.brand("ArtifactBucket"), +); +/** Cloud Storage bucket a GKE run writes its completed artifacts into. */ +export type ArtifactBucket = typeof artifactBucket.Type; + +/** + * A ledger ref is only a storage identity; the profiles that happen to store a + * ledger under its own directory need one path segment, and a ref carrying a + * separator or a parent reference would address a neighbouring run instead. + */ +const ledgerDirectory = Schema.UUID.pipe(Schema.brand("LedgerDirectory")); +/** One completed ledger addressed as exactly one storage path segment. */ +type LedgerDirectory = typeof ledgerDirectory.Type; + +const decodeLedgerDirectory = Schema.decodeUnknownOption(ledgerDirectory); +const decodeArtifactBucket = Schema.decodeUnknownOption(artifactBucket); + +/** Artifact retrieval failed before canonical ledger validation. */ +export class EvaluationArtifactReadFailed extends Schema.TaggedError()( + "EvaluationArtifactReadFailed", + { + profile: Schema.Literal("local", "gke"), + artifact: Schema.Literal("manifest", "records", "completion"), + detail: Schema.NonEmptyString, + }, +) {} + +/** Replaceable read boundaries used by deterministic retrieval tests. */ +export interface EvaluationArtifactOperations { + readonly readFile: ( + path: string, + ) => Effect.Effect; + readonly readObject: ( + url: string, + ) => Effect.Effect; +} + +// The target belongs to the profile, not to a run: a location carrying both an +// optional directory and an optional bucket can be built for a profile whose +// own target was never resolved, and every read then has to re-decide that. +/** Validated artifact target owned by the profile a sweep runs on. */ +export type EvaluationArtifactStorage = + | Readonly<{ profile: "local"; root: LocalArtifactRoot }> + | Readonly<{ profile: "gke"; bucket: ArtifactBucket }>; + +/** Host storage identity for one completed simulator run. */ +export interface EvaluationArtifactLocation { + readonly storage: EvaluationArtifactStorage; + readonly namespace: string; + readonly ledger: LedgerDirectory; +} + +/** + * Accept an artifact root only where the host path service calls it absolute. + * @param path Platform path service that decides absoluteness. + * @param value Candidate root read from the host environment. + * @returns The branded root, absent when the candidate is relative. + */ +export function localArtifactRoot( + path: Path.Path, + value: string, +): Option.Option { + return path.isAbsolute(value) + ? Option.some(asLocalArtifactRoot(value)) + : Option.none(); +} + +/** + * Accept a Cloud Storage bucket named the way Cloud Storage names buckets. + * @param value Candidate bucket read from the host environment. + * @returns The branded bucket, absent when the name is not one. + */ +export function evaluationArtifactBucket( + value: string, +): Option.Option { + return decodeArtifactBucket(value); +} + +/** + * Address one completed run inside the artifact storage its profile owns. + * @param storage Validated target owned by the profile the run executed on. + * @param namespace Run namespace the simulator submitter reported. + * @param ref Ledger identity the controller committed for the run. + * @returns The addressed location, absent when the ref is not one segment. + */ +export function evaluationArtifactLocation( + storage: EvaluationArtifactStorage, + namespace: string, + ref: LedgerRef, +): Option.Option { + return decodeLedgerDirectory(ref).pipe( + Option.map((ledger) => Object.freeze({ storage, namespace, ledger })), + ); +} + +const liveOperations: EvaluationArtifactOperations< + FileSystem.FileSystem | CommandExecutor +> = Object.freeze({ + readFile: (path: string) => + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.readFileString(path)), + ), + readObject: (url: string) => + Command.string( + Command.make("gcloud", "storage", "cat", url).pipe( + Command.stderr("inherit"), + ), + ), +}); + +function readFailure( + location: EvaluationArtifactLocation, + artifact: LedgerArtifact, + cause: unknown, +): EvaluationArtifactReadFailed { + return EvaluationArtifactReadFailed.make({ + profile: location.storage.profile, + artifact, + detail: String(cause).trim() || "artifact read failed", + }); +} + +function localIdentity( + root: LocalArtifactRoot, + location: EvaluationArtifactLocation, + artifact: LedgerArtifact, + path: Path.Path, +): string { + return path.join( + root, + location.namespace, + "ledger", + location.ledger, + ledgerArtifactFiles[artifact], + ); +} + +function gcsIdentity( + bucket: ArtifactBucket, + location: EvaluationArtifactLocation, + artifact: LedgerArtifact, +): string { + return `gs://${bucket}/${encodeURIComponent(location.namespace)}/ledger/${location.ledger}/${ledgerArtifactFiles[artifact]}`; +} + +function readArtifact( + location: EvaluationArtifactLocation, + artifact: LedgerArtifact, + operations: EvaluationArtifactOperations, + path: Path.Path, +) { + const storage = location.storage; + const read = + storage.profile === "local" + ? operations.readFile( + localIdentity(storage.root, location, artifact, path), + ) + : operations.readObject(gcsIdentity(storage.bucket, location, artifact)); + return read.pipe( + Effect.mapError((cause) => readFailure(location, artifact, cause)), + ); +} + +/** + * Retrieve the three exact immutable artifacts through injected operations. + * @param location Profile-owned namespace and ledger identity. + * @param operations Replaceable local-file and Cloud Storage readers. + * @returns The three retrieved artifact texts without interpreting them. + */ +export function readEvaluationLedgerArtifactsWith( + location: EvaluationArtifactLocation, + operations: EvaluationArtifactOperations, +): Effect.Effect< + CompletedLedgerArtifacts, + EvaluationArtifactReadFailed, + Path.Path | Requirements +> { + return Effect.gen(function* () { + const path = yield* Path.Path; + const [manifest, records, completion] = yield* Effect.all( + [ + readArtifact(location, "manifest", operations, path), + readArtifact(location, "records", operations, path), + readArtifact(location, "completion", operations, path), + ] as const, + { concurrency: 3 }, + ); + return { manifest, records, completion }; + }).pipe(Effect.withSpan("readEvaluationLedgerArtifactsWith")); +} + +/** + * Retrieve completed artifacts from the selected repository-owned profile. + * @param location Profile-owned namespace and ledger identity. + * @returns The three artifact texts read through live host operations. + */ +export function readEvaluationLedgerArtifacts( + location: EvaluationArtifactLocation, +) { + return readEvaluationLedgerArtifactsWith(location, liveOperations); +} diff --git a/packages/evals/src/cases.test.ts b/packages/evals/src/cases.test.ts index 15a0be1c7..10a085112 100644 --- a/packages/evals/src/cases.test.ts +++ b/packages/evals/src/cases.test.ts @@ -23,7 +23,10 @@ import { decodeEvaluationCaseId, decodeEvaluationEvidenceId, } from "./model.js"; -import type { EvaluationPeerGateway, EvaluationPeerRuntime } from "./peer.js"; +import type { + EvaluationPeerDefinition, + EvaluationPeerGateway, +} from "./peer.js"; const test = it.effect; const OBSERVE_PEER_OPERATION = "observe:peer"; @@ -35,8 +38,8 @@ const PRINCIPAL_OUTPUT_ID = decodeEvaluationEvidenceId( const PEER_OUTPUT_ID = decodeEvaluationEvidenceId("case-test:peer-output"); const PASSED_VERDICT = "passed"; -type DirectTestPeerRuntimes = Readonly<{ - [PEER_AGENT_NAME]: EvaluationPeerRuntime; +type DirectTestPeerDefinitions = Readonly<{ + [PEER_AGENT_NAME]: EvaluationPeerDefinition; }>; function evidence(text: string): CriterionEvidence { @@ -66,7 +69,7 @@ function peer( }; } -function peers(): EvaluationCasePeers { +function peers(): EvaluationCasePeers { return { [PEER_AGENT_NAME]: peer( PEER_AGENT_NAME, @@ -76,7 +79,10 @@ function peers(): EvaluationCasePeers { } interface ProgramRecorder { - readonly context: EvaluationCaseProgramContext; + readonly context: EvaluationCaseProgramContext< + DirectTestPeerDefinitions, + never + >; readonly operations: readonly string[]; } @@ -86,7 +92,10 @@ function programRecorder(): ProgramRecorder { [roster[PEER_AGENT_NAME], "peer"], ]); const operations: string[] = []; - const context: EvaluationCaseProgramContext = { + const context: EvaluationCaseProgramContext< + DirectTestPeerDefinitions, + never + > = { peers: roster, instruct: (message) => Effect.sync(() => { diff --git a/packages/evals/src/cases.ts b/packages/evals/src/cases.ts index 3cdd31c1c..f77e90aed 100644 --- a/packages/evals/src/cases.ts +++ b/packages/evals/src/cases.ts @@ -2,7 +2,7 @@ import type { Part } from "@moltzap/protocol/message"; import type { SimulatorDefinitionId } from "@moltzap/simulator"; -import type { StartedAgent } from "@moltzap/simulator/runtime"; +import type { StartedAgent } from "@moltzap/simulator/agents"; import { Array as Arr, Effect, type Option } from "effect"; import type { NonEmptyReadonlyArray } from "effect/Array"; import { @@ -13,8 +13,8 @@ import { openingPeerRuntime, orderedGroupPeerRuntime, selectedResponsePeerRuntime, + type EvaluationPeerDefinition, type EvaluationPeerGateway, - type EvaluationPeerRuntime, } from "./peer.js"; import { CriterionDecided, @@ -62,9 +62,9 @@ export interface CriterionDefinition { readonly decide: (evidence: CriterionEvidence) => CriterionDecision; } -/** Code-peer runtimes keyed only by the autonomous roles one case needs. */ -export type EvaluationCasePeerRuntimes = Readonly< - Record +/** Image-independent peers keyed only by the autonomous roles one case needs. */ +export type EvaluationCasePeerDefinitions = Readonly< + Record >; /** One acquired autonomous peer and its observation-only gateway. */ @@ -75,10 +75,10 @@ export type EvaluationCasePeer = StartedAgent< /** Exact acquired peers corresponding to one case's keyed runtime record. */ export type EvaluationCasePeers< - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerDefinitions extends EvaluationCasePeerDefinitions, > = Readonly<{ [Name in Exclude< - typeof TARGET_AGENT_NAME | Extract, + typeof TARGET_AGENT_NAME | Extract, typeof TARGET_AGENT_NAME >]: EvaluationCasePeer; }>; @@ -90,10 +90,10 @@ export type EvaluationCasePeers< * gateways expose autonomous observations only; they do not accept commands. */ export interface EvaluationCaseProgramContext< - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerDefinitions extends EvaluationCasePeerDefinitions, Failure, > { - readonly peers: EvaluationCasePeers; + readonly peers: EvaluationCasePeers; readonly instruct: ( message: string, ) => Effect.Effect, Failure>; @@ -109,10 +109,10 @@ export interface EvaluationCaseProgramContext< } /** Runtime-independent case policy interpreted by one concrete condition. */ -type EvaluationCaseProgram = < - Failure, ->( - context: EvaluationCaseProgramContext, +type EvaluationCaseProgram< + PeerDefinitions extends EvaluationCasePeerDefinitions, +> = ( + context: EvaluationCaseProgramContext, ) => Effect.Effect; /** Immutable case information consumed by plans, grading, and reports. */ @@ -128,17 +128,17 @@ export interface EvaluationCaseMetadata { /** Rank-2 consumer that preserves an otherwise hidden exact peer roster. */ interface EvaluationCaseDefinitionConsumer { - readonly execute: ( - definition: EvaluationCaseDefinition, + readonly execute: ( + definition: EvaluationCaseDefinition, ) => Result; } /** Metadata plus the exact autonomous peer roster and executable policy. */ export interface EvaluationCaseDefinition< - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerDefinitions extends EvaluationCasePeerDefinitions, > extends EvaluationCaseMetadata { - readonly peers: PeerRuntimes; - readonly program: EvaluationCaseProgram; + readonly peers: PeerDefinitions; + readonly program: EvaluationCaseProgram; readonly withDefinition: ( consumer: EvaluationCaseDefinitionConsumer, ) => Result; @@ -257,9 +257,11 @@ function freezeCriterion(definition: CriterionDefinition): CriterionDefinition { }); } -function defineCase( - definition: Omit, "withDefinition">, -): EvaluationCaseDefinition { +function defineCase< + const PeerDefinitions extends EvaluationCasePeerDefinitions, +>( + definition: Omit, "withDefinition">, +): EvaluationCaseDefinition { const [firstCriterion, ...remainingCriteria] = definition.criteria; return Object.freeze({ ...definition, @@ -271,7 +273,7 @@ function defineCase( ...remainingCriteria.map(freezeCriterion), ]), withDefinition( - this: EvaluationCaseDefinition, + this: EvaluationCaseDefinition, consumer: EvaluationCaseDefinitionConsumer, ): Result { return consumer.execute(this); @@ -285,34 +287,36 @@ function freezeCatalog( return Object.freeze(definitions); } -type DirectPeerRuntimes = Readonly<{ - [PEER_AGENT_NAME]: EvaluationPeerRuntime; +type DirectPeerDefinitions = Readonly<{ + [PEER_AGENT_NAME]: EvaluationPeerDefinition; }>; -type SpeakingGroupPeerRuntimes = Readonly<{ - [PEER_AGENT_NAME]: EvaluationPeerRuntime; - [SOURCE_AGENT_NAME]: EvaluationPeerRuntime; - [OBSERVER_1_AGENT_NAME]: EvaluationPeerRuntime; +type SpeakingGroupPeerDefinitions = Readonly<{ + [PEER_AGENT_NAME]: EvaluationPeerDefinition; + [SOURCE_AGENT_NAME]: EvaluationPeerDefinition; + [OBSERVER_1_AGENT_NAME]: EvaluationPeerDefinition; }>; -type SilentGroupPeerRuntimes = Readonly<{ - [PEER_AGENT_NAME]: EvaluationPeerRuntime; - [OBSERVER_1_AGENT_NAME]: EvaluationPeerRuntime; - [OBSERVER_2_AGENT_NAME]: EvaluationPeerRuntime; +type SilentGroupPeerDefinitions = Readonly<{ + [PEER_AGENT_NAME]: EvaluationPeerDefinition; + [OBSERVER_1_AGENT_NAME]: EvaluationPeerDefinition; + [OBSERVER_2_AGENT_NAME]: EvaluationPeerDefinition; }>; -type CrossConversationPeerRuntimes = Readonly<{ - [SOURCE_AGENT_NAME]: EvaluationPeerRuntime; - [PROBE_AGENT_NAME]: EvaluationPeerRuntime; +type CrossConversationPeerDefinitions = Readonly<{ + [SOURCE_AGENT_NAME]: EvaluationPeerDefinition; + [PROBE_AGENT_NAME]: EvaluationPeerDefinition; }>; -type PrincipalPeerRuntimes = Readonly>; +type PrincipalPeerDefinitions = Readonly< + Record +>; function directProgram( instruction: string, -): EvaluationCaseProgram { +): EvaluationCaseProgram { return ( - context: EvaluationCaseProgramContext, + context: EvaluationCaseProgramContext, ) => Effect.gen(function* () { yield* context.instruct(instruction); @@ -322,9 +326,12 @@ function directProgram( function speakingGroupProgram( instruction: string, -): EvaluationCaseProgram { +): EvaluationCaseProgram { return ( - context: EvaluationCaseProgramContext, + context: EvaluationCaseProgramContext< + SpeakingGroupPeerDefinitions, + Failure + >, ) => Effect.gen(function* () { yield* context.instruct(instruction); @@ -336,9 +343,9 @@ function speakingGroupProgram( function silentGroupProgram( instruction: string, -): EvaluationCaseProgram { +): EvaluationCaseProgram { return ( - context: EvaluationCaseProgramContext, + context: EvaluationCaseProgramContext, ) => Effect.gen(function* () { yield* context.instruct(instruction); @@ -351,10 +358,10 @@ function silentGroupProgram( function crossConversationProgram( sourceInstruction: string, probeInstruction: string, -): EvaluationCaseProgram { +): EvaluationCaseProgram { return ( context: EvaluationCaseProgramContext< - CrossConversationPeerRuntimes, + CrossConversationPeerDefinitions, Failure >, ) => @@ -368,9 +375,9 @@ function crossConversationProgram( function principalProgram( instruction: string, -): EvaluationCaseProgram { +): EvaluationCaseProgram { return ( - context: EvaluationCaseProgramContext, + context: EvaluationCaseProgramContext, ) => Effect.gen(function* () { const output = yield* context.instruct(instruction); @@ -380,9 +387,9 @@ function principalProgram( function identityProgram( instruction: string, -): EvaluationCaseProgram { +): EvaluationCaseProgram { return ( - context: EvaluationCaseProgramContext, + context: EvaluationCaseProgramContext, ) => Effect.gen(function* () { yield* context.observeContext(context.peers[PEER_AGENT_NAME]); @@ -415,7 +422,7 @@ function groupInstruction(name: string): string { function directPeers( caseId: EvaluationCaseId, messages: NonEmptyReadonlyArray, -): DirectPeerRuntimes { +): DirectPeerDefinitions { return { [PEER_AGENT_NAME]: selectedResponsePeerRuntime( caseId, @@ -429,7 +436,7 @@ function groupPeers( caseId: EvaluationCaseId, announcement: string, question: string, -): SpeakingGroupPeerRuntimes { +): SpeakingGroupPeerDefinitions { return { [PEER_AGENT_NAME]: orderedGroupPeerRuntime({ caseId, @@ -451,7 +458,7 @@ function groupPeers( function silentGroupPeers( caseId: EvaluationCaseId, question: string, -): SilentGroupPeerRuntimes { +): SilentGroupPeerDefinitions { return { [PEER_AGENT_NAME]: groupResponsePeerRuntime({ caseId, @@ -469,7 +476,7 @@ function crossConversationPeers( caseId: EvaluationCaseId, setupMessages: NonEmptyReadonlyArray, probe: string, -): CrossConversationPeerRuntimes { +): CrossConversationPeerDefinitions { return { [SOURCE_AGENT_NAME]: contextPeerRuntime( caseId, diff --git a/packages/evals/src/cli.ts b/packages/evals/src/cli.ts index d29633c72..5361d1626 100644 --- a/packages/evals/src/cli.ts +++ b/packages/evals/src/cli.ts @@ -4,11 +4,13 @@ import { Command as CliCommand, Options } from "@effect/cli"; import { Command, Path } from "@effect/platform"; import { NodeContext, NodeRuntime } from "@effect/platform-node"; +import type { CompletedLedgerReceipt } from "@moltzap/simulator"; import { - simulatorLayer, - type CompletedLedgerReceipt, -} from "@moltzap/simulator"; -import { DateTime, Duration, Either, Effect, Option, Schema } from "effect"; + LedgerStorageError, + type CompletedLedgerArtifacts, +} from "@moltzap/simulator/ledger"; +import { image, type Image } from "@moltzap/simulator/agents"; +import { Config, DateTime, Duration, Effect, Option, Schema } from "effect"; import type { NonEmptyReadonlyArray } from "effect/Array"; import { evaluationCase, @@ -17,13 +19,23 @@ import { type EvaluationCaseMetadata, } from "./cases.js"; import { - behavioralEvaluation, EvaluationExecutionFailed, nanoclawEvaluationCondition, + openEvaluationLedger, openClawEvaluationCondition, + projectEvaluationControllerResult, type EvaluationCondition, type EvaluationExecutionResult, } from "./execution.js"; +import { + evaluationArtifactBucket, + evaluationArtifactLocation, + localArtifactRoot, + readEvaluationLedgerArtifacts, + type ArtifactBucket, + type EvaluationArtifactStorage, + type LocalArtifactRoot, +} from "./artifacts.js"; import { GradeCompleted, GradingRefused, @@ -35,7 +47,12 @@ import { transcriptFromLedger, type EvaluationTranscript, } from "./grading.js"; -import { decodeJudgePolicyId, type JudgePolicyId } from "./model.js"; +import { + decodeJudgePolicyId, + type EvaluationConditionId, + type EvaluationConditionName, + type JudgePolicyId, +} from "./model.js"; import { PhoenixPublisher, phoenixPublisherLive } from "./phoenix.js"; import { createStoredEvaluationReport, @@ -50,26 +67,31 @@ import { EvaluationCasePlan, EvaluationConditionPlan, EvaluationReportPlan, + GkeEvaluationInfrastructure, EvidenceRejectedAttempt, JudgePolicySnapshot, LedgerAllocationFailedAttempt, + LocalEvaluationInfrastructure, RunFailedAttempt, decodeEvaluationReportId, ensureSweepOperationallyComplete, evaluationReportId, makeAssessedAttempt, makeJudgingUnavailableAttempt, + type EvaluationInfrastructure, type EvaluationReportId, type EvaluationSweepCell, - type TerminalAttempt, } from "./sweep.js"; +import { + submitEvaluationCell, + type EvaluationSubmissionResult, + type SimulatorProfile, +} from "./submission.js"; const CLI_VERSION = "0.0.0"; const RUNTIME_STARTUP_TIMEOUT = Duration.minutes(5); -const ROUTER_STARTUP_TIMEOUT = Duration.minutes(10); const PEER_OBSERVATION_TIMEOUT = Duration.minutes(5); const CASE_TIMEOUT = Duration.minutes(20); -const LEDGER_DIRECTORY = [".moltzap", "evals", "ledgers"] as const; const JUDGE_POLICY: JudgePolicyId = decodeJudgePolicyId( "openai-gpt-5.6-sol/v1", ); @@ -101,6 +123,43 @@ class SemanticJudgeCalibrationFailed extends Schema.TaggedError; +} + +interface LocalExecutionEnvironment extends CommonExecutionEnvironment { + readonly profile: "local"; + readonly localArtifacts: LocalArtifactRoot; +} + +interface GkeExecutionEnvironment extends CommonExecutionEnvironment { + readonly profile: "gke"; + readonly kubeContext: string; + readonly gkeArtifactBucket: ArtifactBucket; +} + +// Each profile carries exactly the target it needs. One flat record with +// optional fields would let a plan be built for a profile whose artifact target +// was never resolved, and the only place to catch that is a runtime throw. +type EvaluationExecutionEnvironment = + | LocalExecutionEnvironment + | GkeExecutionEnvironment; + +interface EvaluationExecutionImages { + readonly controllerImage: Image; + readonly peerApplicationImage: Image; + readonly nanoclawApplicationImage: Image; } interface AttemptContext { @@ -184,6 +243,7 @@ const exactSourceRevision = Effect.fn("evals.exactSourceRevision")( function evaluationConditions( options: RuntimeOptions, + nanoclawApplicationImage: Image, ): readonly [EvaluationCondition, EvaluationCondition] { const execution = { peerObservationTimeout: PEER_OBSERVATION_TIMEOUT, @@ -192,7 +252,6 @@ function evaluationConditions( return [ openClawEvaluationCondition({ runtime: { - installMode: "workspace", startupTimeout: RUNTIME_STARTUP_TIMEOUT, modelId: options.openclawModel, }, @@ -200,7 +259,7 @@ function evaluationConditions( }), nanoclawEvaluationCondition({ runtime: { - installMode: "workspace", + applicationImage: nanoclawApplicationImage, autoRegisterConversations: true, startupTimeout: RUNTIME_STARTUP_TIMEOUT, modelId: options.nanoclawModel, @@ -249,9 +308,33 @@ function conditionPlan( }); } +function planInfrastructure( + environment: EvaluationExecutionEnvironment, +): EvaluationInfrastructure { + const shared = { + controllerImage: environment.controllerImage, + peerApplicationImage: environment.peerApplicationImage, + nanoclawApplicationImage: environment.nanoclawApplicationImage, + temporalAddress: environment.temporalAddress, + }; + return environment.profile === "local" + ? LocalEvaluationInfrastructure.make({ + ...shared, + profile: environment.profile, + artifactDirectory: environment.localArtifacts, + }) + : GkeEvaluationInfrastructure.make({ + ...shared, + profile: environment.profile, + kubeContext: environment.kubeContext, + artifactBucket: environment.gkeArtifactBucket, + }); +} + function reportPlan( sourceRevision: string, conditions: NonEmptyReadonlyArray, + environment: EvaluationExecutionEnvironment, ): EvaluationReportPlan { const [firstCase, ...remainingCases] = evaluationCases; const [firstCondition, ...remainingConditions] = conditions; @@ -263,6 +346,7 @@ function reportPlan( ...remainingConditions.map(conditionPlan), ], judgePolicy: judgePolicySnapshot(), + infrastructure: planInfrastructure(environment), samplesPerCell: 1, }); } @@ -358,8 +442,13 @@ function persistGrade( function assessExecution( context: AttemptContext, receipt: CompletedLedgerReceipt, + artifacts: CompletedLedgerArtifacts, ) { - return behavioralEvaluation.openLedger(receipt.ledger).pipe( + return openEvaluationLedger( + context.definition, + receipt.ledger, + artifacts, + ).pipe( Effect.flatMap((ledger) => transcriptFromLedger(ledger, context.definition), ), @@ -380,6 +469,7 @@ function assessExecution( function completeExecution( context: AttemptContext, outcome: EvaluationExecutionResult, + artifacts: CompletedLedgerArtifacts, ) { return Effect.gen(function* () { if (outcome instanceof EvaluationExecutionFailed) { @@ -389,11 +479,151 @@ function completeExecution( detail: outcome.detail, }); } - return yield* assessExecution(context, outcome.receipt); + return yield* assessExecution(context, outcome.receipt, artifacts); }); } +function ledgerAllocationFailed(context: AttemptContext) { + return DateTime.now.pipe( + Effect.map((completedAt) => + LedgerAllocationFailedAttempt.make({ + ...terminalFields(context, completedAt), + failure: LedgerStorageError.make({ + operation: "allocate", + detail: + "the simulator controller could not allocate its durable ledger", + }), + }), + ), + ); +} + +function runInfrastructureFailed( + context: AttemptContext, + receipt: EvaluationSubmissionResult["result"]["summary"] & { + readonly _tag: "ClusterLost"; + }, +) { + return DateTime.now.pipe( + Effect.map((completedAt) => + RunFailedAttempt.make({ + ...terminalFields(context, completedAt), + receipt: receipt.receipt, + detail: "the simulator controller reported an infrastructure failure", + }), + ), + ); +} + +function artifactStorage( + environment: EvaluationExecutionEnvironment, +): EvaluationArtifactStorage { + return environment.profile === "local" + ? { profile: environment.profile, root: environment.localArtifacts } + : { profile: environment.profile, bucket: environment.gkeArtifactBucket }; +} + +function readCompletedArtifacts( + environment: EvaluationExecutionEnvironment, + context: AttemptContext, + namespace: string, + receipt: CompletedLedgerReceipt, +) { + return Option.match( + evaluationArtifactLocation( + artifactStorage(environment), + namespace, + receipt.ledger, + ), + { + onNone: () => + rejectEvidence( + context, + receipt, + "the controller ledger ref is not one artifact path segment", + ), + onSome: (location) => + readEvaluationLedgerArtifacts(location).pipe( + Effect.matchEffect({ + onFailure: (failure) => + rejectEvidence(context, receipt, describeUnknown(failure)), + onSuccess: (artifacts) => + projectEvaluationControllerResult( + context.definition, + receipt, + artifacts, + ).pipe( + Effect.matchEffect({ + onFailure: (failure) => + rejectEvidence(context, receipt, describeUnknown(failure)), + onSuccess: (outcome) => + completeExecution(context, outcome, artifacts), + }), + ), + }), + ), + }, + ); +} + +function completeSubmission( + environment: EvaluationExecutionEnvironment, + context: AttemptContext, + submission: EvaluationSubmissionResult, +) { + const summary = submission.result.summary; + if (summary._tag === "LedgerAllocationFailed") { + return ledgerAllocationFailed(context); + } + if (summary._tag === "ClusterLost") { + return runInfrastructureFailed(context, summary); + } + return readCompletedArtifacts( + environment, + context, + submission.namespace, + summary.receipt, + ); +} + +function conditionModelId( + models: CommonExecutionEnvironment["models"], + condition: EvaluationConditionId, +): string { + const byCondition: Readonly> = { + "openclaw/v2": models.openclaw, + "nanoclaw/v2": models.nanoclaw, + }; + // Indexing needs the plain spelling; the brand is not part of the key set. + const name: EvaluationConditionName = condition; + return byCondition[name]; +} + +function submissionInput( + environment: EvaluationExecutionEnvironment, + context: AttemptContext, + condition: EvaluationCondition, +) { + return { + workspaceRoot: environment.workspaceRoot, + profile: environment.profile, + caseId: context.definition.id, + definitionId: context.definition.definitionId, + attemptId: context.cell.attemptId, + condition: { + id: condition.id, + modelId: conditionModelId(environment.models, condition.id), + }, + peerApplicationImage: environment.peerApplicationImage, + nanoclawApplicationImage: environment.nanoclawApplicationImage, + runtimeStartupTimeoutMillis: Duration.toMillis(RUNTIME_STARTUP_TIMEOUT), + peerObservationTimeoutMillis: Duration.toMillis(PEER_OBSERVATION_TIMEOUT), + caseTimeoutMillis: Duration.toMillis(CASE_TIMEOUT), + } as const; +} + function executeCell( + environment: EvaluationExecutionEnvironment, conditions: readonly EvaluationCondition[], cell: EvaluationSweepCell, ) { @@ -405,28 +635,10 @@ function executeCell( definition, startedAt: yield* DateTime.now, }; - const execution = yield* definition - .withDefinition({ - execute: (exact) => - condition.execute(exact, { attemptId: cell.attemptId }), - }) - .pipe(Effect.either); - return yield* Either.match(execution, { - onLeft: (failure) => - DateTime.now.pipe( - Effect.map( - (completedAt): TerminalAttempt => - LedgerAllocationFailedAttempt.make({ - ...terminalFields(context, completedAt), - failure, - }), - ), - ), - onRight: (outcome) => - completeExecution(context, outcome).pipe( - Effect.map((attempt): TerminalAttempt => attempt), - ), - }); + const submission = yield* submitEvaluationCell( + submissionInput(environment, context, condition), + ); + return yield* completeSubmission(environment, context, submission); }).pipe(Effect.withSpan("evals.executeCell")); } @@ -448,21 +660,13 @@ function reportIdNow() { ); } -function simulatorPlatform(ledgerDirectory: string) { - return simulatorLayer({ - ledgerDirectory, - router: { startupTimeout: ROUTER_STARTUP_TIMEOUT }, - }); -} - function executeReport( - ledgerDirectory: string, + environment: EvaluationExecutionEnvironment, conditions: readonly EvaluationCondition[], ) { - return runEvaluationSweep((cell) => executeCell(conditions, cell)).pipe( - Effect.provide(SemanticJudgeOpenAi), - Effect.provide(simulatorPlatform(ledgerDirectory)), - ); + return runEvaluationSweep((cell) => + executeCell(environment, conditions, cell), + ).pipe(Effect.provide(SemanticJudgeOpenAi)); } function logReport(report: CompletedEvaluationReport, path: string) { @@ -487,11 +691,146 @@ const nanoclawModelOption = Options.text("nanoclaw-model").pipe( Options.withSchema(Schema.NonEmptyString), Options.withDescription("Exact NanoClaw model ID."), ); +const profileOption = Options.text("profile").pipe( + Options.withSchema(Schema.Literal("local", "gke")), + Options.withDefault("local"), + Options.withDescription("Repository-owned Kubernetes execution profile."), +); const runtimeOptions = { openclawModel: openclawModelOption, nanoclawModel: nanoclawModelOption, + profile: profileOption, } as const; +function requiredEnvironment(key: string) { + return Config.string(key).pipe( + Effect.mapError(() => + EvaluationSourceStateError.make({ + detail: `${key} is required for evaluation execution`, + }), + ), + ); +} + +function distributedApplicationImage( + key: + | "MOLTZAP_CONTROLLER_IMAGE" + | "MOLTZAP_SUPPORT_IMAGE" + | "MOLTZAP_NANOCLAW_IMAGE", + value: string, +): Effect.Effect { + return Schema.decodeUnknown(image)(value).pipe( + Effect.mapError(() => + EvaluationSourceStateError.make({ + detail: `${key} must be a lowercase SHA-256 digest-pinned image`, + }), + ), + ); +} + +function executionImages() { + return Effect.all({ + controllerImage: requiredEnvironment("MOLTZAP_CONTROLLER_IMAGE").pipe( + Effect.flatMap((value) => + distributedApplicationImage("MOLTZAP_CONTROLLER_IMAGE", value), + ), + ), + peerApplicationImage: requiredEnvironment("MOLTZAP_SUPPORT_IMAGE").pipe( + Effect.flatMap((value) => + distributedApplicationImage("MOLTZAP_SUPPORT_IMAGE", value), + ), + ), + nanoclawApplicationImage: requiredEnvironment( + "MOLTZAP_NANOCLAW_IMAGE", + ).pipe( + Effect.flatMap((value) => + distributedApplicationImage("MOLTZAP_NANOCLAW_IMAGE", value), + ), + ), + }); +} + +function requiredArtifactTarget( + key: "MOLTZAP_LOCAL_ARTIFACTS" | "MOLTZAP_GKE_ARTIFACT_BUCKET", + requirement: string, + accept: (value: string) => Option.Option, +) { + return requiredEnvironment(key).pipe( + Effect.flatMap((value) => + Option.match(accept(value), { + onNone: () => + Effect.fail( + EvaluationSourceStateError.make({ + detail: `${key} must be ${requirement}`, + }), + ), + onSome: Effect.succeed, + }), + ), + ); +} + +function localArtifactDirectory(path: Path.Path) { + return requiredArtifactTarget( + "MOLTZAP_LOCAL_ARTIFACTS", + "an absolute path", + (value) => localArtifactRoot(path, value), + ); +} + +function gkeArtifactBucket() { + return requiredArtifactTarget( + "MOLTZAP_GKE_ARTIFACT_BUCKET", + "a valid Cloud Storage bucket name", + evaluationArtifactBucket, + ); +} + +function commonEnvironment( + root: string, + options: RuntimeOptions, + images: EvaluationExecutionImages, +) { + return { + workspaceRoot: root, + ...images, + models: { + openclaw: options.openclawModel, + nanoclaw: options.nanoclawModel, + }, + } as const; +} + +function executionEnvironment( + root: string, + options: RuntimeOptions, +): Effect.Effect< + EvaluationExecutionEnvironment, + EvaluationSourceStateError, + Path.Path +> { + return Effect.gen(function* () { + const path = yield* Path.Path; + const common = { + ...commonEnvironment(root, options, yield* executionImages()), + temporalAddress: yield* requiredEnvironment("MOLTZAP_TEMPORAL_ADDRESS"), + }; + if (options.profile === "local") { + return { + ...common, + profile: options.profile, + localArtifacts: yield* localArtifactDirectory(path), + }; + } + return { + ...common, + profile: options.profile, + kubeContext: yield* requiredEnvironment("MOLTZAP_KUBE_CONTEXT"), + gkeArtifactBucket: yield* gkeArtifactBucket(), + }; + }); +} + function runOrResume( mode: "run" | "resume", reportId: Option.Option, @@ -500,21 +839,23 @@ function runOrResume( return Effect.gen(function* () { const root = yield* workspaceRoot(); const sourceRevision = yield* exactSourceRevision(); - const conditions = evaluationConditions(options); - const plan = reportPlan(sourceRevision, conditions); + const environment = yield* executionEnvironment(root, options); + const conditions = evaluationConditions( + options, + environment.nanoclawApplicationImage, + ); + const plan = reportPlan(sourceRevision, conditions, environment); const resolvedId = Option.isSome(reportId) ? reportId.value : yield* reportIdNow(); const databasePath = yield* reportLocation(root, resolvedId); - const path = yield* Path.Path; - const ledgerDirectory = path.join(root, ...LEDGER_DIRECTORY); return yield* Effect.gen(function* () { if (mode === "run") { yield* createStoredEvaluationReport(resolvedId, plan); } else { yield* resumeStoredEvaluationReport(plan); } - const completed = yield* executeReport(ledgerDirectory, conditions); + const completed = yield* executeReport(environment, conditions); yield* logReport(completed, databasePath); return yield* ensureSweepOperationallyComplete(completed); }).pipe(Effect.provide(evaluationResultStoreLayer(databasePath))); diff --git a/packages/evals/src/events.test.ts b/packages/evals/src/events.test.ts index a26541d80..7823cd2f2 100644 --- a/packages/evals/src/events.test.ts +++ b/packages/evals/src/events.test.ts @@ -3,12 +3,12 @@ import { agentName } from "@moltzap/protocol/identity"; import { agentId, conversationId, messageId } from "@moltzap/protocol/testing"; import { ProgramSucceeded, RouterMessageCommitted } from "@moltzap/simulator"; import { - NanoclawGatewayInput, - NanoclawGatewayOutput, + NanoClawGatewayInput, + NanoClawGatewayOutput, OpenClawGatewayRequest, OpenClawGatewaySucceeded, OpenClawGatewayTimedOut, -} from "@moltzap/simulator/runtime"; +} from "@moltzap/simulator/agents"; import { routerSequence } from "@moltzap/simulator/network"; import { Effect, Schema, Stream } from "effect"; import { @@ -16,8 +16,8 @@ import { CodePeerMessageSent, EvaluationEvidenceProjectionError, EvaluationEvidenceSelected, - NanoclawPrincipalInputSent, - NanoclawPrincipalOutputReceived, + NanoClawPrincipalInputSent, + NanoClawPrincipalOutputReceived, OpenClawPrincipalFinalOutput, OpenClawPrincipalInstructionAttempted, PeerExchangeNotObserved, @@ -82,18 +82,18 @@ const OPENCLAW_OUTPUT = OpenClawPrincipalFinalOutput.make({ }), }); -const NANOCLAW_INPUT = NanoclawPrincipalInputSent.make({ +const NANOCLAW_INPUT = NanoClawPrincipalInputSent.make({ caseId: CASE_ID, agentName: BOB_NAME, agentId: BOB_ID, - input: NanoclawGatewayInput.make({ text: NANOCLAW_INPUT_TEXT }), + input: NanoClawGatewayInput.make({ text: NANOCLAW_INPUT_TEXT }), }); -const NANOCLAW_OUTPUT = NanoclawPrincipalOutputReceived.make({ +const NANOCLAW_OUTPUT = NanoClawPrincipalOutputReceived.make({ caseId: CASE_ID, agentName: BOB_NAME, agentId: BOB_ID, - output: NanoclawGatewayOutput.make({ text: NANOCLAW_OUTPUT_TEXT }), + output: NanoClawGatewayOutput.make({ text: NANOCLAW_OUTPUT_TEXT }), }); const CODE_SENT = CodePeerMessageSent.make({ @@ -169,8 +169,8 @@ it("declares the complete customer event universe", () => { const eventClasses = [ OpenClawPrincipalInstructionAttempted, OpenClawPrincipalFinalOutput, - NanoclawPrincipalInputSent, - NanoclawPrincipalOutputReceived, + NanoClawPrincipalInputSent, + NanoClawPrincipalOutputReceived, CodePeerMessageSent, CodePeerMessageReceived, PeerExchangeNotObserved, diff --git a/packages/evals/src/events.ts b/packages/evals/src/events.ts index 06d88cbb2..ba7ed4859 100644 --- a/packages/evals/src/events.ts +++ b/packages/evals/src/events.ts @@ -5,12 +5,12 @@ import { type AgentId, agentId, agentName } from "@moltzap/protocol/identity"; import { messagePartsSchema } from "@moltzap/protocol/message"; import { EventCatalog, RouterMessageCommitted } from "@moltzap/simulator"; import { - NanoclawGatewayInput, - NanoclawGatewayOutput, + NanoClawGatewayInput, + NanoClawGatewayOutput, OpenClawGatewayRequest, OpenClawGatewaySucceeded, OpenClawGatewayTimedOut, -} from "@moltzap/simulator/runtime"; +} from "@moltzap/simulator/agents"; import { Chunk, Effect, Schema, Stream } from "effect"; import { evaluationCaseId, @@ -51,24 +51,24 @@ export class OpenClawPrincipalFinalOutput extends Schema.TaggedClass()( +export class NanoClawPrincipalInputSent extends Schema.TaggedClass()( "moltzap.nanoclaw-principal-input-sent/v1", { caseId: evaluationCaseId, agentName: agentName, agentId: agentId, - input: NanoclawGatewayInput, + input: NanoClawGatewayInput, }, ) {} /** The evaluation adapter received one output frame from NanoClaw. */ -export class NanoclawPrincipalOutputReceived extends Schema.TaggedClass()( +export class NanoClawPrincipalOutputReceived extends Schema.TaggedClass()( "moltzap.nanoclaw-principal-output-received/v1", { caseId: evaluationCaseId, agentName: agentName, agentId: agentId, - output: NanoclawGatewayOutput, + output: NanoClawGatewayOutput, }, ) {} @@ -129,8 +129,8 @@ export class EvaluationEvidenceSelected extends Schema.TaggedClass + left.localeCompare(right), + ), + createdAt: DateTime.unsafeMake(0), + provenance: {}, + metadata: {}, + }); + const manifestText = json(Schema.encodeSync(LedgerManifest)(manifest)); + const record = { + runId: manifest.runId, + eventId: "eval-controller-projection:0", + logicalSequence: 0, + elapsedNanos: 0n, + observedAt: 0, + producer: "eval-controller-projection", + event, + }; + const recordsText = `${json( + Schema.encodeSync(makeLedgerRecordSchema(CATALOG))(record), + )}\n`; + const completion = LedgerCompletion.make({ + ledgerFormatVersion: 1, + runId: manifest.runId, + recordCount: 1, + artifacts: { + manifest: digest(manifestText), + records: digest(recordsText), + }, + }); + return { + artifacts: { + manifest: manifestText, + records: recordsText, + completion: json(Schema.encodeSync(LedgerCompletion)(completion)), + }, + receipt: CompletedLedgerReceipt.make({ ledger: REF, completion }), + }; +} + +test("projects a successful customer program from canonical ledger evidence", () => { + const fixture = completedArtifacts(ProgramSucceeded.make({})); + return projectEvaluationControllerResult( + DEFINITION, + fixture.receipt, + fixture.artifacts, + ).pipe( + Effect.tap((result) => { + assert.strictEqual(result._tag, "EvaluationExecutionCompleted"); + assert.strictEqual(result.receipt, fixture.receipt); + }), + ); +}); + +test("projects a typed customer failure without trusting controller process state", () => { + const fixture = completedArtifacts( + ProgramFailed.make({ cause: "the evaluation program rejected its input" }), + ); + return projectEvaluationControllerResult( + DEFINITION, + fixture.receipt, + fixture.artifacts, + ).pipe( + Effect.tap((result) => { + assert.instanceOf(result, EvaluationExecutionFailed); + if (result instanceof EvaluationExecutionFailed) { + assert.strictEqual( + result.detail, + "the evaluation program rejected its input", + ); + } + }), + ); +}); + +test("rejects a controller completion that disagrees with the ledger", () => { + const fixture = completedArtifacts(ProgramSucceeded.make({})); + const mismatched = CompletedLedgerReceipt.make({ + ledger: REF, + completion: LedgerCompletion.make({ + ledgerFormatVersion: fixture.receipt.completion.ledgerFormatVersion, + runId: fixture.receipt.completion.runId, + recordCount: fixture.receipt.completion.recordCount, + artifacts: { + ...fixture.receipt.completion.artifacts, + records: decodeDigest("0".repeat(64)), + }, + }), + }); + return projectEvaluationControllerResult( + DEFINITION, + mismatched, + fixture.artifacts, + ).pipe( + Effect.flip, + Effect.tap((failure) => { + assert.instanceOf(failure, EvaluationControllerResultInvalid); + if (failure instanceof EvaluationControllerResultInvalid) { + assert.include(failure.detail, "does not match the ledger"); + } + }), + ); +}); + +/* eslint-enable agent-code-guard/no-hardcoded-assertion-literals -- Controller projection assertions end here. */ diff --git a/packages/evals/src/execution.test.ts b/packages/evals/src/execution.test.ts index 390696327..62f9bddd6 100644 --- a/packages/evals/src/execution.test.ts +++ b/packages/evals/src/execution.test.ts @@ -4,15 +4,15 @@ import { agentName } from "@moltzap/protocol/identity"; import { agentId, conversationId, messageId } from "@moltzap/protocol/testing"; import type { EventOf } from "@moltzap/simulator"; import { - NanoclawGatewayOutput, - type NanoclawGateway, - type NanoclawGatewayError, - type NanoclawGatewayInput, + NanoClawGatewayOutput, + type NanoClawGateway, + type NanoClawGatewayError, + type NanoClawGatewayInput, OpenClawGatewayResponse, type OpenClawGateway, - type OpenClawGatewayRequestFailed, + type OpenClawGatewayRequestError, type StartedAgent, -} from "@moltzap/simulator/runtime"; +} from "@moltzap/simulator/agents"; import { makeAgentHandle } from "@moltzap/simulator/network"; import { Deferred, @@ -30,13 +30,13 @@ import { evaluationCases, type EvaluationCaseDefinition, type EvaluationCasePeers, - type EvaluationCasePeerRuntimes, + type EvaluationCasePeerDefinitions, } from "./cases.js"; import { CodePeerMessageReceived, EvaluationEvidenceSelected, - NanoclawPrincipalInputSent, - NanoclawPrincipalOutputReceived, + NanoClawPrincipalInputSent, + NanoClawPrincipalOutputReceived, OpenClawPrincipalFinalOutput, OpenClawPrincipalInstructionAttempted, PeerExchangeNotObserved, @@ -70,7 +70,7 @@ const GATEWAY_RESPONSE = Schema.decodeSync(OpenClawGatewayResponse)({ summary: "completed", result: { payloads: [{ text: "I contacted the requested peer." }] }, }); -const NANOCLAW_OUTPUT = NanoclawGatewayOutput.make({ +const NANOCLAW_OUTPUT = NanoClawGatewayOutput.make({ text: "Uncorrelated native output.", }); const EXPECTED_OPENCLAW_TOOLS = { @@ -81,24 +81,14 @@ const EXPECTED_OPENCLAW_TOOLS = { }, }, elevated: { enabled: false }, - exec: { mode: "deny" }, -}; -const EXPECTED_OPENCLAW_SANDBOX = { - mode: "all", - backend: "docker", - scope: "session", - workspaceAccess: "none", - docker: { network: "none" }, + exec: { mode: "full" }, }; const bundledOpenClawPolicyConfiguration = Schema.Struct({ tools: Schema.Struct({ definitionDigest: Schema.String, redacted: Schema.Tuple(Schema.Literal("configuration")), }), - sandbox: Schema.Struct({ - definitionDigest: Schema.String, - redacted: Schema.Tuple(Schema.Literal("configuration")), - }), + sandbox: Schema.optional(Schema.Unknown), }); type EvaluationEvent = EventOf; @@ -180,14 +170,14 @@ function selectedSocialGateway( }; } -function instrumentation( +function instrumentation( definition: EvaluationCaseDefinition, peers: EvaluationCasePeers, emit: EmitEvaluationEvent, ): Effect.Effect< EvaluationCaseInstrumentation< OpenClawGateway, - OpenClawGatewayRequestFailed, + OpenClawGatewayRequestError, PeerRuntimes > > { @@ -221,8 +211,8 @@ function principalPeers(): EvaluationCasePeers { } function nanoclawGateway( - submitted: Ref.Ref, -): NanoclawGateway { + submitted: Ref.Ref, +): NanoClawGateway { return { submit: (input) => Ref.update(submitted, (current) => [...current, input]), outputs: Stream.never, @@ -230,16 +220,16 @@ function nanoclawGateway( } function nanoclawInstrumentation< - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerRuntimes extends EvaluationCasePeerDefinitions, >( definition: EvaluationCaseDefinition, peers: EvaluationCasePeers, - gateway: NanoclawGateway, + gateway: NanoClawGateway, emit: EmitEvaluationEvent, ): Effect.Effect< EvaluationCaseInstrumentation< - NanoclawGateway, - NanoclawGatewayError, + NanoClawGateway, + NanoClawGatewayError, PeerRuntimes > > { @@ -361,7 +351,7 @@ function nanoclawPrincipalOutputUnsupportedTest() { return Effect.gen(function* () { const definition = evaluationCases[8]; const recorder = yield* eventRecorder(); - const submitted = yield* Ref.make([]); + const submitted = yield* Ref.make([]); const acquired = yield* nanoclawInstrumentation( definition, principalPeers(), @@ -374,7 +364,7 @@ function nanoclawPrincipalOutputUnsupportedTest() { assert.lengthOf(yield* Ref.get(submitted), 1); const records = yield* Ref.get(recorder.records); assert.lengthOf(records, 1); - assert.instanceOf(records[0]?.event, NanoclawPrincipalInputSent); + assert.instanceOf(records[0]?.event, NanoClawPrincipalInputSent); assert.isFalse( records.some(({ event }) => event instanceof EvaluationEvidenceSelected), ); @@ -390,7 +380,7 @@ function outputRecordingEmit( .emit(event) .pipe( Effect.tap(() => - event instanceof NanoclawPrincipalOutputReceived + event instanceof NanoClawPrincipalOutputReceived ? Deferred.succeed(outputRecorded, undefined) : Effect.void, ), @@ -398,9 +388,9 @@ function outputRecordingEmit( } function outputBeforeSubmitGateway( - submitted: Ref.Ref, + submitted: Ref.Ref, outputRecorded: Deferred.Deferred, -): NanoclawGateway { +): NanoClawGateway { return { submit: (input) => Ref.update(submitted, (current) => [...current, input]).pipe( @@ -410,12 +400,12 @@ function outputBeforeSubmitGateway( }; } -function assertUncorrelatedNanoclawEvidence( +function assertUncorrelatedNanoClawEvidence( records: readonly RecordedEvent[], ): void { assert.lengthOf( records.filter( - ({ event }) => event instanceof NanoclawPrincipalOutputReceived, + ({ event }) => event instanceof NanoClawPrincipalOutputReceived, ), 1, ); @@ -424,7 +414,7 @@ function assertUncorrelatedNanoclawEvidence( 1, ); assert.lengthOf( - records.filter(({ event }) => event instanceof NanoclawPrincipalInputSent), + records.filter(({ event }) => event instanceof NanoClawPrincipalInputSent), 1, ); assert.isFalse( @@ -436,7 +426,7 @@ function nanoclawIdentityOutputUnsupportedTest() { return Effect.gen(function* () { const definition = evaluationCases[10]; const recorder = yield* eventRecorder(); - const submitted = yield* Ref.make([]); + const submitted = yield* Ref.make([]); const outputRecorded = yield* Deferred.make(); const acquired = yield* nanoclawInstrumentation( definition, @@ -454,7 +444,7 @@ function nanoclawIdentityOutputUnsupportedTest() { const failure = yield* runEvaluationCase(acquired).pipe(Effect.flip); assertUnsupportedPrincipalOutput(failure); assert.lengthOf(yield* Ref.get(submitted), 1); - assertUncorrelatedNanoclawEvidence(yield* Ref.get(recorder.records)); + assertUncorrelatedNanoClawEvidence(yield* Ref.get(recorder.records)); }); } @@ -468,9 +458,7 @@ function policyDigest(policy: object): string { function bundledOpenClawPolicyTest(): void { const condition = openClawEvaluationCondition({ - runtime: { - installMode: "workspace", - }, + runtime: {}, execution: { peerObservationTimeout: Duration.seconds(1), caseTimeout: Duration.seconds(2), @@ -483,10 +471,7 @@ function bundledOpenClawPolicyTest(): void { definitionDigest: policyDigest(EXPECTED_OPENCLAW_TOOLS), redacted: ["configuration"], }); - assert.deepStrictEqual(configuration.sandbox, { - definitionDigest: policyDigest(EXPECTED_OPENCLAW_SANDBOX), - redacted: ["configuration"], - }); + assert.isUndefined(configuration.sandbox); } // @agent-code-guard/regression-only: native gateway output and autonomous social evidence have distinct selection paths diff --git a/packages/evals/src/execution.ts b/packages/evals/src/execution.ts index f4b85855e..a8712ba99 100644 --- a/packages/evals/src/execution.ts +++ b/packages/evals/src/execution.ts @@ -1,37 +1,45 @@ /** @file Concrete mixed-agent conditions and code-defined case execution. */ -import type { FileSystem, HttpClient, Path } from "@effect/platform"; -import type { CommandExecutor } from "@effect/platform/CommandExecutor"; import { agentName } from "@moltzap/protocol/identity"; import { CompletedLedgerReceipt, + EventCatalog, LedgerReceipt, - ProgramFinished, - simulator, + ProgramFailed, + ProgramInterrupted, + ProgramSucceeded, + RunSpec, + coreEvents, + type ClusterServices, } from "@moltzap/simulator"; import { type AgentRuntime, + type Image, type StartedAgent, nanoclawRuntime, openClawRuntime, runtimeConfigurationProjection, - type NanoclawRuntimeOptions, + type NanoClawRuntimeOptions, type OpenClawRuntimeOptions, -} from "@moltzap/simulator/runtime"; -import type { - LedgerStorage, - JsonValue, - LedgerStorageError, +} from "@moltzap/simulator/agents"; +import { + openLedgerArtifacts, + type CompletedLedgerArtifacts, + type CompletedRunLedger, + type JsonValue, + type LedgerOpenError, + type LedgerStorageError, + type LedgerRef, } from "@moltzap/simulator/ledger"; -import type { RouterProvider } from "@moltzap/simulator/network"; import { Array as Arr, - Cause, Duration, Effect, - Exit, + type Layer, Option, + Record as Rec, Schema, + Stream, } from "effect"; import { TARGET_AGENT_NAME, @@ -39,7 +47,7 @@ import { type EvaluationCaseMetadata, type EvaluationCasePeer, type EvaluationCasePeers, - type EvaluationCasePeerRuntimes, + type EvaluationCasePeerDefinitions, type EvaluationCaseProgramContext, } from "./cases.js"; import { @@ -48,14 +56,15 @@ import { evaluationEvents, } from "./events.js"; import { - decodeConditionId, + decodeEvaluationConditionId, decodeEvaluationEvidenceId, - type ConditionId, type EvaluationCaseId, + type EvaluationConditionId, type EvaluationEvidenceId, } from "./model.js"; import type { PeerExchange, + EvaluationPeerDefinition, EvaluationPeerGateway, EvaluationPeerObservation, } from "./peer.js"; @@ -67,21 +76,35 @@ import { type PrincipalDriverFactory, } from "./principal.js"; -type EvaluationExecutionRequirements = - | CommandExecutor - | FileSystem.FileSystem - | HttpClient.HttpClient - | Path.Path - | LedgerStorage - | RouterProvider; - const decodeAgentName = Schema.decodeSync(agentName); -/** Stable definition for every bundled behavioral case run. */ -export const behavioralEvaluation = simulator.define( - "moltzap.behavioral-evaluation/v1", - evaluationEvents, -); +/** Controller-owned services required by every evaluation cell RunSpec. */ +type EvaluationInfrastructure = Layer.Layer< + ClusterServices, + LedgerStorageError +>; + +const evaluationCatalog = EventCatalog.merge(coreEvents, evaluationEvents); + +/** + * Reopen one case-specific RunSpec ledger against the exact evaluation catalog. + * @param definition Bundled case whose definition id owns the ledger. + * @param ref Physical ledger identity returned by the controller. + * @param artifacts Immutable manifest, records, and completion artifact text. + * @returns The fully validated completed evaluation ledger. + */ +export function openEvaluationLedger( + definition: EvaluationCaseMetadata, + ref: LedgerRef, + artifacts: CompletedLedgerArtifacts, +) { + return openLedgerArtifacts( + evaluationCatalog, + ref, + artifacts, + definition.definitionId, + ); +} /** Customer-owned deadlines for observable behavior and complete case work. */ interface EvaluationExecutionPolicy { @@ -131,21 +154,38 @@ export type EvaluationExecutionResult = | EvaluationExecutionCompleted | EvaluationExecutionFailed; -/** Concrete condition with no runtime gateway union at its public boundary. */ -export interface EvaluationCondition< - RuntimeRequirements = EvaluationExecutionRequirements, -> { - readonly id: ConditionId; +/** A controller receipt disagrees with its completed evaluation ledger. */ +export class EvaluationControllerResultInvalid extends Schema.TaggedError()( + "EvaluationControllerResultInvalid", + { + detail: Schema.NonEmptyString, + }, +) {} + +interface EvaluationConditionDefinitionConsumer { + readonly execute: < + Gateway, + DriverFailure, + RuntimeFailure, + ConfigurationSchema extends Schema.Schema.AnyNoContext, + >( + definition: EvaluationConditionDefinition< + Gateway, + DriverFailure, + RuntimeFailure, + ConfigurationSchema + >, + ) => Result; +} + +/** Concrete condition with its exact gateway retained behind a rank-2 binder. */ +export interface EvaluationCondition { + readonly id: EvaluationConditionId; readonly runtimeName: string; readonly runtimeConfiguration: JsonValue; - readonly execute: ( - definition: EvaluationCaseDefinition, - input: EvaluationExecutionInput, - ) => Effect.Effect< - EvaluationExecutionResult, - LedgerStorageError, - EvaluationExecutionRequirements | RuntimeRequirements - >; + readonly withDefinition: ( + consumer: EvaluationConditionDefinitionConsumer, + ) => Result; } /** Exact runtime and adapter captured behind one code-defined condition. */ @@ -153,16 +193,10 @@ export interface EvaluationConditionDefinition< Gateway, DriverFailure, RuntimeFailure, - RuntimeRequirements, ConfigurationSchema extends Schema.Schema.AnyNoContext, > { - readonly id: ConditionId; - readonly runtime: AgentRuntime< - Gateway, - RuntimeFailure, - RuntimeRequirements, - ConfigurationSchema - >; + readonly id: EvaluationConditionId; + readonly runtime: AgentRuntime; readonly principal: PrincipalDriverFactory; readonly execution: EvaluationExecutionPolicy; } @@ -172,8 +206,8 @@ interface OpenClawEvaluationConditionOptions { readonly execution: EvaluationExecutionPolicy; } -interface NanoclawEvaluationConditionOptions { - readonly runtime: NanoclawRuntimeOptions; +interface NanoClawEvaluationConditionOptions { + readonly runtime: NanoClawRuntimeOptions; readonly execution: EvaluationExecutionPolicy; } @@ -186,17 +220,9 @@ const BUNDLED_OPENCLAW_TOOLS = { }, }, elevated: { enabled: false }, - exec: { mode: "deny" }, + exec: { mode: "full" }, } satisfies NonNullable; -const BUNDLED_OPENCLAW_SANDBOX = { - mode: "all", - backend: "docker", - scope: "session", - workspaceAccess: "none", - docker: { network: "none" }, -} satisfies NonNullable; - Object.freeze(BUNDLED_OPENCLAW_TOOLS.allow); Object.freeze(BUNDLED_OPENCLAW_TOOLS.sandbox.tools.allow); Object.freeze(BUNDLED_OPENCLAW_TOOLS.sandbox.tools); @@ -204,14 +230,12 @@ Object.freeze(BUNDLED_OPENCLAW_TOOLS.sandbox); Object.freeze(BUNDLED_OPENCLAW_TOOLS.elevated); Object.freeze(BUNDLED_OPENCLAW_TOOLS.exec); Object.freeze(BUNDLED_OPENCLAW_TOOLS); -Object.freeze(BUNDLED_OPENCLAW_SANDBOX.docker); -Object.freeze(BUNDLED_OPENCLAW_SANDBOX); /** Exact native gateway and observation capabilities for one acquired case. */ export interface EvaluationCaseInstrumentation< Gateway, DriverFailure, - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerRuntimes extends EvaluationCasePeerDefinitions, > { readonly definition: EvaluationCaseDefinition; readonly policy: EvaluationExecutionPolicy; @@ -386,7 +410,7 @@ function runtimeStopped( function principalInstruction< Gateway, DriverFailure, - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerRuntimes extends EvaluationCasePeerDefinitions, >( instrumentation: EvaluationCaseInstrumentation< Gateway, @@ -410,7 +434,7 @@ function principalInstruction< function observePrincipal< Gateway, DriverFailure, - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerRuntimes extends EvaluationCasePeerDefinitions, >( instrumentation: EvaluationCaseInstrumentation< Gateway, @@ -445,7 +469,7 @@ function selectPrincipalOutput( function caseContext< Gateway, DriverFailure, - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerRuntimes extends EvaluationCasePeerDefinitions, >( instrumentation: EvaluationCaseInstrumentation< Gateway, @@ -470,7 +494,7 @@ function caseContext< function runCaseProgram< Gateway, DriverFailure, - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerRuntimes extends EvaluationCasePeerDefinitions, >( instrumentation: EvaluationCaseInstrumentation< Gateway, @@ -512,7 +536,7 @@ function runCaseProgram< export function runEvaluationCase< Gateway, DriverFailure, - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerRuntimes extends EvaluationCasePeerDefinitions, >( instrumentation: EvaluationCaseInstrumentation< Gateway, @@ -526,134 +550,229 @@ export function runEvaluationCase< interface ExecuteConditionInput< Gateway, DriverFailure, - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerDefinitions extends EvaluationCasePeerDefinitions, RuntimeFailure, - RuntimeRequirements, ConfigurationSchema extends Schema.Schema.AnyNoContext, > { - readonly runtime: AgentRuntime< - Gateway, - RuntimeFailure, - RuntimeRequirements, - ConfigurationSchema - >; + readonly runtime: AgentRuntime; readonly principal: PrincipalDriverFactory; readonly policy: EvaluationExecutionPolicy; - readonly conditionId: ConditionId; - readonly definition: EvaluationCaseDefinition; + readonly definition: EvaluationCaseDefinition; readonly execution: EvaluationExecutionInput; + readonly peerApplicationImage: Image; + readonly infrastructure: EvaluationInfrastructure; } -function makeConditionRoster< +type MaterializedPeerRuntimes< + PeerDefinitions extends EvaluationCasePeerDefinitions, +> = Readonly<{ + [Name in keyof PeerDefinitions]: ReturnType; +}>; + +function materializePeerRuntimes< + PeerDefinitions extends EvaluationCasePeerDefinitions, +>( + definitions: PeerDefinitions, + peerApplicationImage: Image, +): MaterializedPeerRuntimes { + // eslint-disable-next-line agent-code-guard/require-assertion-rationale -- Record.map preserves the exact keys of the immutable input record while replacing every value with its materialized runtime. + return Rec.map(definitions, (definition: EvaluationPeerDefinition) => + definition.runtime(peerApplicationImage), + ) as MaterializedPeerRuntimes; +} + +function makeConditionRuntimes< Gateway, - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerDefinitions extends EvaluationCasePeerDefinitions, RuntimeFailure, - RuntimeRequirements, ConfigurationSchema extends Schema.Schema.AnyNoContext, >( - runtime: AgentRuntime< - Gateway, - RuntimeFailure, - RuntimeRequirements, - ConfigurationSchema - >, - definition: EvaluationCaseDefinition, + runtime: AgentRuntime, + definition: EvaluationCaseDefinition, + peerApplicationImage: Image, ) { - return behavioralEvaluation.agents({ - ...definition.peers, + return Object.freeze({ + ...materializePeerRuntimes(definition.peers, peerApplicationImage), [TARGET_AGENT_NAME]: runtime, }); } -function summarizeProgramFinished( - outcome: ProgramFinished, -): EvaluationExecutionResult { - return Exit.isSuccess(outcome.exit) - ? EvaluationExecutionCompleted.make({ - receipt: outcome.receipt, - }) - : EvaluationExecutionFailed.make({ - receipt: outcome.receipt, - detail: Cause.pretty(outcome.exit.cause), - }); -} - -interface InfrastructureFailureOutcome { - readonly receipt: LedgerReceipt; - readonly cause: Cause.Cause; +type EvaluationCompletedLedger = CompletedRunLedger; + +type ProgramCompletionEvent = + | ProgramSucceeded + | ProgramFailed + | ProgramInterrupted; + +function completionEvents( + ledger: EvaluationCompletedLedger, +): Effect.Effect { + const initial: readonly ProgramCompletionEvent[] = []; + return ledger.records.pipe( + Stream.runFold(initial, (events, record) => { + const event = record.event; + return event instanceof ProgramSucceeded || + event instanceof ProgramFailed || + event instanceof ProgramInterrupted + ? [...events, event] + : events; + }), + ); } -function summarizeInfrastructureFailure( - outcome: InfrastructureFailureOutcome, +function completionMatchesReceipt( + ledger: EvaluationCompletedLedger, + receipt: CompletedLedgerReceipt, +): boolean { + const observed = ledger.completion; + const claimed = receipt.completion; + const sameHeader = + observed.ledgerFormatVersion === claimed.ledgerFormatVersion && + observed.runId === claimed.runId && + observed.recordCount === claimed.recordCount; + const sameManifest = + observed.artifacts.manifest === claimed.artifacts.manifest; + const sameRecords = observed.artifacts.records === claimed.artifacts.records; + return sameHeader && sameManifest && sameRecords; +} + +function projectProgramCompletion( + event: ProgramCompletionEvent, + receipt: CompletedLedgerReceipt, ): EvaluationExecutionResult { - return EvaluationExecutionFailed.make({ - receipt: outcome.receipt, - detail: Cause.pretty(outcome.cause), - }); + return event instanceof ProgramSucceeded + ? EvaluationExecutionCompleted.make({ receipt }) + : EvaluationExecutionFailed.make({ receipt, detail: event.cause }); } -function summarizeOutcome( - outcome: - | ProgramFinished - | InfrastructureFailureOutcome, -): EvaluationExecutionResult { - return outcome instanceof ProgramFinished - ? summarizeProgramFinished(outcome) - : summarizeInfrastructureFailure(outcome); +function projectCompletedLedger( + ledger: EvaluationCompletedLedger, + receipt: CompletedLedgerReceipt, +): Effect.Effect { + return Effect.gen(function* () { + if (!completionMatchesReceipt(ledger, receipt)) { + return yield* EvaluationControllerResultInvalid.make({ + detail: "controller receipt completion does not match the ledger", + }); + } + const events = yield* completionEvents(ledger); + if (events.length !== 1) { + return yield* EvaluationControllerResultInvalid.make({ + detail: `completed evaluation ledger contains ${String(events.length)} program completion events`, + }); + } + const [event] = events; + if (event === undefined) { + return yield* EvaluationControllerResultInvalid.make({ + detail: "completed evaluation ledger has no program completion event", + }); + } + return projectProgramCompletion(event, receipt); + }); } -function runProvenance( - conditionId: ConditionId, +/** + * Reopen a controller-completed ledger and recover its customer-program result. + * @param definition Bundled case that owns the ledger definition and catalog. + * @param receipt Bounded controller result projected outside the run process. + * @param artifacts Immutable artifacts retrieved for the receipt's ledger. + * @returns The evaluation result recovered from canonical simulator evidence. + */ +export function projectEvaluationControllerResult( definition: EvaluationCaseMetadata, - execution: EvaluationExecutionInput, -) { - return { - provenance: { - caseId: definition.id, - caseDefinitionId: definition.definitionId, - conditionId, - attemptId: execution.attemptId, - }, - }; + receipt: CompletedLedgerReceipt, + artifacts: CompletedLedgerArtifacts, +): Effect.Effect< + EvaluationExecutionResult, + LedgerOpenError | EvaluationControllerResultInvalid +> { + return openEvaluationLedger(definition, receipt.ledger, artifacts).pipe( + Effect.flatMap((ledger) => projectCompletedLedger(ledger, receipt)), + ); } -function executeCondition< +/** + * Construct one case-and-condition RunSpec using an injected infrastructure Layer. + * @param input Exact target runtime, peer roster, policy, and infrastructure. + * @returns The immutable RunSpec for one evaluation matrix cell. + */ +function evaluationRunSpec< Gateway, DriverFailure, - PeerRuntimes extends EvaluationCasePeerRuntimes, + PeerDefinitions extends EvaluationCasePeerDefinitions, RuntimeFailure, - RuntimeRequirements, ConfigurationSchema extends Schema.Schema.AnyNoContext, >( input: ExecuteConditionInput< Gateway, DriverFailure, - PeerRuntimes, + PeerDefinitions, RuntimeFailure, - RuntimeRequirements, ConfigurationSchema >, ) { - const { conditionId, definition, principal, execution, policy, runtime } = - input; - const roster = makeConditionRoster(runtime, definition); - const program = Effect.gen(function* () { - const agents = yield* roster.startedAgents; - const events = yield* behavioralEvaluation.events; - const { [TARGET_AGENT_NAME]: target, ...peers } = agents; - const driver = yield* principal.make(execution.attemptId); - yield* runEvaluationCase({ - definition, - policy, - target, - peers, - driver, - emit: events.emit, - }); + const { + peerApplicationImage, + definition, + infrastructure, + principal, + execution, + policy, + runtime, + } = input; + return RunSpec.define({ + id: definition.definitionId, + events: [evaluationEvents], + agents: makeConditionRuntimes(runtime, definition, peerApplicationImage), + cluster: infrastructure, + execute: ({ agents, events }) => { + const { [TARGET_AGENT_NAME]: target, ...peers } = agents; + return Effect.gen(function* () { + const driver = yield* principal.make(execution.attemptId); + yield* runEvaluationCase({ + definition, + policy, + target, + peers, + driver, + emit: events.emit, + }); + }); + }, + }); +} + +/** Inputs that bind one report cell to a controller-owned infrastructure Layer. */ +interface EvaluationCellRunSpecInput< + PeerDefinitions extends EvaluationCasePeerDefinitions, +> { + readonly definition: EvaluationCaseDefinition; + readonly condition: EvaluationCondition; + readonly attemptId: string; + readonly peerApplicationImage: Image; + readonly infrastructure: EvaluationInfrastructure; +} + +/** + * Construct exactly one case-by-condition controller RunSpec. + * @param input Exact case, condition, peer image, attempt, and infrastructure. + * @returns One immutable controller-owned RunSpec. + */ +export function evaluationCellRunSpec< + PeerDefinitions extends EvaluationCasePeerDefinitions, +>(input: EvaluationCellRunSpecInput) { + return input.condition.withDefinition({ + execute: (condition) => + evaluationRunSpec({ + runtime: condition.runtime, + principal: condition.principal, + policy: condition.execution, + definition: input.definition, + execution: { attemptId: input.attemptId }, + peerApplicationImage: input.peerApplicationImage, + infrastructure: input.infrastructure, + }), }); - return behavioralEvaluation - .run(roster, program, runProvenance(conditionId, definition, execution)) - .pipe(Effect.map(summarizeOutcome)); } /** @@ -665,33 +784,22 @@ function evaluationCondition< Gateway, DriverFailure, RuntimeFailure, - RuntimeRequirements, ConfigurationSchema extends Schema.Schema.AnyNoContext, >( definition: EvaluationConditionDefinition< Gateway, DriverFailure, RuntimeFailure, - RuntimeRequirements, ConfigurationSchema >, -): EvaluationCondition { +): EvaluationCondition { return Object.freeze({ id: definition.id, runtimeName: definition.runtime.name, runtimeConfiguration: runtimeConfigurationProjection(definition.runtime), - execute: ( - evaluation: EvaluationCaseDefinition, - input: EvaluationExecutionInput, - ) => - executeCondition({ - runtime: definition.runtime, - principal: definition.principal, - policy: definition.execution, - conditionId: definition.id, - definition: evaluation, - execution: input, - }), + withDefinition: ( + consumer: EvaluationConditionDefinitionConsumer, + ) => consumer.execute(definition), }); } @@ -703,11 +811,10 @@ function evaluationCondition< export function openClawEvaluationCondition( options: OpenClawEvaluationConditionOptions, ) { - const id = decodeConditionId("openclaw/v2"); + const id = decodeEvaluationConditionId("openclaw/v2"); const runtime = openClawRuntime({ ...options.runtime, tools: BUNDLED_OPENCLAW_TOOLS, - sandbox: BUNDLED_OPENCLAW_SANDBOX, }); return evaluationCondition({ id, @@ -723,9 +830,9 @@ export function openClawEvaluationCondition( * @returns A condition whose executor retains the NanoClaw gateway type. */ export function nanoclawEvaluationCondition( - options: NanoclawEvaluationConditionOptions, + options: NanoClawEvaluationConditionOptions, ) { - const id = decodeConditionId("nanoclaw/v2"); + const id = decodeEvaluationConditionId("nanoclaw/v2"); const runtime = nanoclawRuntime(options.runtime); return evaluationCondition({ id, diff --git a/packages/evals/src/grading.test.ts b/packages/evals/src/grading.test.ts index 2eced60a6..e6b6b1cf3 100644 --- a/packages/evals/src/grading.test.ts +++ b/packages/evals/src/grading.test.ts @@ -5,11 +5,11 @@ import { conversationId, messageId } from "@moltzap/protocol/conversation"; import { agentId, agentName } from "@moltzap/protocol/identity"; import { RouterMessageCommitted } from "@moltzap/simulator"; import { - NanoclawGatewayInput, - NanoclawGatewayOutput, + NanoClawGatewayInput, + NanoClawGatewayOutput, OpenClawGatewayRequest, OpenClawGatewayResponse, -} from "@moltzap/simulator/runtime"; +} from "@moltzap/simulator/agents"; import { routerSequence } from "@moltzap/simulator/network"; import { ConfigProvider, Effect, Schema, Stream } from "effect"; import { @@ -21,8 +21,8 @@ import { CodePeerMessageReceived, CodePeerMessageSent, EvaluationEvidenceSelected, - NanoclawPrincipalInputSent, - NanoclawPrincipalOutputReceived, + NanoClawPrincipalInputSent, + NanoClawPrincipalOutputReceived, OpenClawPrincipalFinalOutput, OpenClawPrincipalInstructionAttempted, PeerExchangeNotObserved, @@ -326,11 +326,11 @@ describe("ledger evidence projection", () => { it.effect("rejects more than one native gateway target identity", () => Effect.gen(function* () { - const foreignOutput = NanoclawPrincipalOutputReceived.make({ + const foreignOutput = NanoClawPrincipalOutputReceived.make({ caseId, agentName: decodeAgentName("another-target"), agentId: otherId, - output: NanoclawGatewayOutput.make({ text: "foreign output" }), + output: NanoClawGatewayOutput.make({ text: "foreign output" }), }); const error = yield* transcriptFromLedger( ledger([ @@ -419,11 +419,11 @@ describe("ledger evidence projection", () => { record( nanoInputId, 0, - NanoclawPrincipalInputSent.make({ + NanoClawPrincipalInputSent.make({ caseId, agentName: targetName, agentId: targetId, - input: NanoclawGatewayInput.make({ + input: NanoClawGatewayInput.make({ text: "List your current conversations.", }), }), @@ -431,11 +431,11 @@ describe("ledger evidence projection", () => { record( nanoOutputId, 1, - NanoclawPrincipalOutputReceived.make({ + NanoClawPrincipalOutputReceived.make({ caseId, agentName: targetName, agentId: targetId, - output: NanoclawGatewayOutput.make({ + output: NanoClawGatewayOutput.make({ text: "I cannot enumerate them.", }), }), diff --git a/packages/evals/src/model.ts b/packages/evals/src/model.ts index 49ea254d2..dc79031bc 100644 --- a/packages/evals/src/model.ts +++ b/packages/evals/src/model.ts @@ -18,14 +18,30 @@ export const evaluationEvidenceId = Schema.NonEmptyString.pipe( /** Ledger envelope identity admitted as evaluation evidence. */ export type EvaluationEvidenceId = typeof evaluationEvidenceId.Type; +const CONDITION_ID = /^[a-z0-9][a-z0-9._-]*\/v[1-9]\d*$/u; + /** Schema for one runtime condition and its configuration contract. */ export const conditionId = Schema.NonEmptyString.pipe( - Schema.pattern(/^[a-z0-9][a-z0-9._-]*\/v[1-9]\d*$/u), + Schema.pattern(CONDITION_ID), Schema.brand("ConditionId"), ); /** Stable identity of one runtime condition and its configuration contract. */ export type ConditionId = typeof conditionId.Type; +/** + * The complete set of conditions the bundled matrix compares. Consumers that + * must act per condition are total over this union, so introducing a third + * runtime is a compile error rather than a fallback that picks an existing one. + */ +const evaluationConditionId = Schema.Literal("openclaw/v2", "nanoclaw/v2").pipe( + Schema.pattern(CONDITION_ID), + Schema.brand("ConditionId"), +); +/** One condition the bundled matrix compares, as its own literal. */ +export type EvaluationConditionId = typeof evaluationConditionId.Type; +/** The unbranded spelling of one bundled matrix condition. */ +export type EvaluationConditionName = typeof evaluationConditionId.Encoded; + /** Schema for one versioned behavioral criterion. */ export const criterionId = Schema.NonEmptyString.pipe( Schema.pattern(/^EVAL-\d{3}\.[a-z0-9][a-z0-9-]*\/v[1-9]\d*$/u), @@ -106,6 +122,10 @@ export const decodeEvaluationEvidenceId = Schema.decodeSync(evaluationEvidenceId); /** Decode trusted code constants through the canonical condition-id schema. */ export const decodeConditionId = Schema.decodeSync(conditionId); +/** Decode trusted code constants through the bundled matrix-condition schema. */ +export const decodeEvaluationConditionId = Schema.decodeSync( + evaluationConditionId, +); /** Decode trusted code constants through the canonical criterion-id schema. */ export const decodeCriterionId = Schema.decodeSync(criterionId); /** Decode trusted code constants through the canonical judge-policy schema. */ diff --git a/packages/evals/src/peer-application.ts b/packages/evals/src/peer-application.ts new file mode 100644 index 000000000..56c7109fb --- /dev/null +++ b/packages/evals/src/peer-application.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env node +/** @file One-container entry point for an autonomous evaluation peer. */ + +import { FileSystem } from "@effect/platform"; +import { NodeContext, NodeRuntime } from "@effect/platform-node"; +import { messageReceivedNotificationDefinition } from "@moltzap/protocol/message"; +import { MoltZapAgentClient } from "@moltzap/protocol/socket"; +// eslint-disable-next-line agent-code-guard/prefer-effect-platform -- This container-private one-route readiness bridge needs a raw bound port, while the controller consumes it through Effect HttpClient. +import { createServer, type Server } from "node:http"; +import { Effect, Schema } from "effect"; +import { + EVALUATION_PEER_BRIDGE_PORT, + EVALUATION_PEER_READY_MARKER, + EvaluationPeerBootstrap, + EvaluationPeerBridgeCompleted, + EvaluationPeerBridgeFailed, + EvaluationPeerBridgeResult, + runEvaluationPeerApplication, +} from "./peer.js"; + +const decodeBootstrap = Schema.decodeUnknown( + Schema.parseJson(EvaluationPeerBootstrap), +); +const encodeBridgeResult = Schema.encode( + Schema.parseJson(EvaluationPeerBridgeResult), +); + +/** The peer entrypoint could not establish its run-scoped process boundary. */ +class EvaluationPeerApplicationStartupFailed extends Schema.TaggedError()( + "EvaluationPeerApplicationStartupFailed", + { detail: Schema.NonEmptyString }, +) {} + +interface BridgeState { + readonly read: () => string | undefined; + readonly publish: (result: string) => Effect.Effect; +} + +function bridgeState(): BridgeState { + let current: string | undefined; + return Object.freeze({ + read: () => current, + publish: (result: string) => + Effect.sync(() => { + current = result; + }), + }); +} + +function serveResult(state: BridgeState): Server { + return createServer((request, response) => { + if (request.method !== "GET" || request.url !== "/result") { + response.writeHead(404).end(); + return; + } + const result = state.read(); + if (result === undefined) { + response.writeHead(204).end(); + return; + } + response + .writeHead(200, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(result), + }) + .end(result); + }); +} + +function startupFailure(cause: unknown) { + const detail = String(cause).trim(); + return EvaluationPeerApplicationStartupFailed.make({ + detail: detail.length > 0 ? detail : "peer application startup failed", + }); +} + +function listen( + state: BridgeState, +): Effect.Effect { + return Effect.async( + (resume) => { + const server = serveResult(state); + const failed = (cause: Error) => { + resume(Effect.fail(startupFailure(cause))); + }; + server.once("error", failed); + server.listen(EVALUATION_PEER_BRIDGE_PORT, "0.0.0.0", () => { + server.off("error", failed); + resume(Effect.succeed(server)); + }); + return Effect.sync(() => { + server.close(); + }); + }, + ); +} + +function close(server: Server): Effect.Effect { + return Effect.async((resume) => { + server.close(() => { + resume(Effect.succeed(undefined)); + }); + }).pipe(Effect.asVoid); +} + +function bridgeServer(state: BridgeState) { + return Effect.acquireRelease(listen(state), close); +} + +function bootstrapPath( + args: readonly string[], +): Effect.Effect { + const [path] = args; + return args.length === 1 && path !== undefined && path.startsWith("/") + ? Effect.succeed(path) + : Effect.fail( + EvaluationPeerApplicationStartupFailed.make({ + detail: + "evaluation peer expects one absolute bootstrap configuration path", + }), + ); +} + +function readBootstrap(path: string) { + return FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.readFileString(path)), + Effect.flatMap((source) => + decodeBootstrap(source, { onExcessProperty: "error" }), + ), + ); +} + +function announceReady(): Effect.Effect { + return Effect.sync(() => { + process.stdout.write(`${EVALUATION_PEER_READY_MARKER}\n`); + }); +} + +function publishApplicationResult( + state: BridgeState, + result: EvaluationPeerBridgeCompleted | EvaluationPeerBridgeFailed, +) { + return encodeBridgeResult(result).pipe( + Effect.flatMap((encoded) => state.publish(encoded)), + ); +} + +function runApplication(args: readonly string[]) { + return Effect.gen(function* () { + const path = yield* bootstrapPath(args); + const configuration = yield* readBootstrap(path); + const state = bridgeState(); + yield* bridgeServer(state); + const client = new MoltZapAgentClient({ + serverUrl: configuration.serverUrl, + agentKey: configuration.agentKey, + }); + const messages = yield* client.subscribeScoped( + messageReceivedNotificationDefinition, + ); + yield* Effect.addFinalizer(() => client.close()); + yield* client.connect(); + yield* announceReady(); + yield* runEvaluationPeerApplication( + { + agent: Object.freeze({ + name: configuration.agentName, + id: configuration.agentId, + }), + messages, + client, + }, + configuration.plan, + ).pipe( + Effect.matchEffect({ + onFailure: (failure) => + publishApplicationResult( + state, + EvaluationPeerBridgeFailed.make({ failure }), + ), + onSuccess: (exchange) => + publishApplicationResult( + state, + EvaluationPeerBridgeCompleted.make({ exchange }), + ), + }), + ); + return yield* Effect.never; + }).pipe(Effect.scoped, Effect.provide(NodeContext.layer)); +} + +// eslint-disable-next-line agent-code-guard/prefer-effect-platform -- The executable boundary captures argv once before entering Effect. +runApplication(process.argv.slice(2)).pipe(NodeRuntime.runMain); diff --git a/packages/evals/src/peer.test.ts b/packages/evals/src/peer.test.ts index a814526a8..403b63edf 100644 --- a/packages/evals/src/peer.test.ts +++ b/packages/evals/src/peer.test.ts @@ -10,22 +10,31 @@ import { type Message, type MessageReceivedNotification, } from "@moltzap/protocol/message"; -import { serverBaseUrl } from "@moltzap/protocol/network"; import { agentId, agentName, - agentKeyString, conversationId, messageId, - redactedAgentKey, } from "@moltzap/protocol/testing"; -import { Array as Arr, Deferred, Effect, Stream } from "effect"; -import { vi } from "vitest"; -import type { AgentConnection } from "@moltzap/simulator"; -import { makeAgentHandle } from "@moltzap/simulator/network"; +import { Array as Arr, Deferred, Effect, Fiber, Schema, Stream } from "effect"; import { CodePeerMessageReceived, CodePeerMessageSent } from "./events.js"; import { decodeEvaluationCaseId } from "./model.js"; -import type { PeerExchange } from "./peer.js"; +import { + EvaluationPeerBridgeCompleted, + EvaluationPeerBridgeFailed, + EvaluationPeerBridgeResult, + EvaluationPeerFailed, + PeerExchange, + announcementPeerRuntime, + evaluationPeerGatewayFromBridge, + observerPeerRuntime, + orderedGroupPeerRuntime, + runEvaluationPeerApplication, + type EvaluationPeerApplicationContext, + type EvaluationPeerApplicationPlan, +} from "./peer.js"; + +// @agent-code-guard/regression-only: these deterministic protocol fakes pin peer ordering and bridge projection against previously observed regressions. interface ClientCall { readonly definition: string; @@ -39,6 +48,7 @@ type DeliveryEmitter = ( interface FakeClientState { agents: readonly AgentCard[]; received?: Stream.Stream; + conversationOpened?: Deferred.Deferred; readonly calls: ClientCall[]; readonly emitters: DeliveryEmitter[]; readonly sendDeliveries: MessageReceivedNotification[]; @@ -49,37 +59,17 @@ interface FakeClientState { const CONVERSATION_ID = conversationId("00000000-0000-4000-8000-000000000821"); -const clientState = vi.hoisted( - (): FakeClientState => ({ - agents: [], - received: undefined, - calls: [], - emitters: [], - sendDeliveries: [], - sendResults: [], - sendPermissions: new Set(), - sendCompletions: [], - }), -); - -interface FakeRuntimeContext { - readonly agent: AgentConnection["agent"]; - readonly messages: Stream.Stream; - readonly client: { - readonly callDefinition: typeof fakeCallDefinition; - }; -} - -interface FakeBuiltAgent { - readonly gateway: unknown; - readonly behavior: Effect.Effect; -} - -interface FakeRuntimeOptions { - readonly build: ( - context: FakeRuntimeContext, - ) => Effect.Effect; -} +const clientState: FakeClientState = { + agents: [], + received: undefined, + conversationOpened: undefined, + calls: [], + emitters: [], + sendDeliveries: [], + sendResults: [], + sendPermissions: new Set(), + sendCompletions: [], +}; function deliver( notification: MessageReceivedNotification, @@ -139,51 +129,24 @@ function fakeCallDefinition( definition: agentConversationCreate.name, payload, }); - return Effect.succeed({ - conversation: { id: CONVERSATION_ID }, - }); + const opened = clientState.conversationOpened; + return ( + opened === undefined + ? Effect.void + : Deferred.succeed(opened, undefined).pipe(Effect.asVoid) + ).pipe( + Effect.as({ + conversation: { id: CONVERSATION_ID }, + }), + ); } return Effect.fail(`unexpected RPC ${definition.name}`); } -function fakeAcquire( - options: FakeRuntimeOptions, - input: { readonly connection: AgentConnection }, -) { - return Effect.gen(function* () { - const messages = clientState.received; - if (messages === undefined) { - return yield* Effect.dieMessage("test did not install a message stream"); - } - const built = yield* options.build({ - agent: input.connection.agent, - messages, - client: { callDefinition: fakeCallDefinition }, - }); - yield* built.behavior.pipe(Effect.forkScoped); - yield* Effect.yieldNow(); - return { - gateway: built.gateway, - termination: Effect.never, - }; - }); -} - -function fakeEffectRuntime(options: FakeRuntimeOptions) { - return { - acquire: (input: { readonly connection: AgentConnection }) => - fakeAcquire(options, input), - }; -} - -vi.doMock("@moltzap/simulator/runtime", () => ({ - effectRuntime: fakeEffectRuntime, -})); - -const peerModule = Effect.tryPromise({ - try: () => import("./peer.js"), - catch: (cause) => String(cause), -}); +// eslint-disable-next-line agent-code-guard/require-assertion-rationale -- This protocol fake deliberately implements only the three RPC definitions exercised by peer plans; each branch is checked by definition name and returns that definition's fixture shape. +const fakeClient = Object.freeze({ + callDefinition: fakeCallDefinition, +}) as EvaluationPeerApplicationContext["client"]; const CASE_ID = decodeEvaluationCaseId("EVAL-006"); const TARGET_NAME = "evaluation-target"; @@ -198,15 +161,15 @@ const OBSERVER_ID = agentId("00000000-0000-4000-8000-000000000805"); const OTHER_CONVERSATION_ID = conversationId( "00000000-0000-4000-8000-000000000822", ); -const ROUTER_URL = serverBaseUrl("ws://127.0.0.1:31890"); -const AGENT_KEY = redactedAgentKey(agentKeyString(801)); const CREATED_AT = "2026-07-29T00:00:00.000Z"; const SOURCE_ANNOUNCEMENT = "I have been working on data pipelines."; const GROUP_QUESTION = "What has everyone been working on? Keep it brief."; const GROUP_NAME = "evaluation-eval-006"; +const SOURCE_AGENT_NAME = agentName(SOURCE_NAME); beforeEach(() => { clientState.agents = []; clientState.received = undefined; + clientState.conversationOpened = undefined; clientState.calls.length = 0; clientState.emitters.length = 0; clientState.sendDeliveries.length = 0; @@ -238,17 +201,6 @@ function card(name: string, id: AgentId): AgentCard { }; } -function connection( - name: Name, - id: AgentId, -): AgentConnection { - return { - agent: makeAgentHandle(name, id), - key: AGENT_KEY, - routerUrl: ROUTER_URL, - }; -} - function notification( id: string, senderId: AgentId, @@ -319,10 +271,26 @@ function installFastResponses(): MessageReceivedNotification { return response; } -const acquireSourcePeer = Effect.fn(function* () { - const peers = yield* peerModule; +const startPeer = Effect.fn(function* ( + plan: EvaluationPeerApplicationPlan, + name: string, + id: AgentId, +) { const ready = yield* Deferred.make(); clientState.received = receivedStream(ready); + const running = yield* runEvaluationPeerApplication( + { + agent: Object.freeze({ name, id }), + messages: clientState.received, + client: fakeClient, + }, + plan, + ).pipe(Effect.forkScoped); + yield* Deferred.await(ready); + return Object.freeze({ exchange: Fiber.join(running) }); +}); + +const acquireSourcePeer = Effect.fn(function* () { clientState.agents = [card(TARGET_NAME, TARGET_ID)]; clientState.sendResults.push({ message: sentMessage( @@ -331,26 +299,23 @@ const acquireSourcePeer = Effect.fn(function* () { SOURCE_ANNOUNCEMENT, ), }); - const running = yield* peers - .announcementPeerRuntime(CASE_ID, TARGET_NAME, SOURCE_ANNOUNCEMENT) - .acquire({ - agentName: agentName(SOURCE_NAME), - connection: connection(SOURCE_NAME, SOURCE_ID), - }); - yield* Deferred.await(ready); - return running.gateway; + const definition = announcementPeerRuntime( + CASE_ID, + TARGET_NAME, + SOURCE_ANNOUNCEMENT, + ); + return yield* startPeer(definition.plan, SOURCE_NAME, SOURCE_ID); }); const acquireQuestionPeer = Effect.fn(function* () { - const peers = yield* peerModule; - const ready = yield* Deferred.make(); - clientState.received = receivedStream(ready); clientState.agents = [ card(TARGET_NAME, TARGET_ID), card(SOURCE_NAME, SOURCE_ID), card(OBSERVER_NAME, OBSERVER_ID), ]; const sendCompleted = yield* Deferred.make(); + const conversationOpened = yield* Deferred.make(); + clientState.conversationOpened = conversationOpened; clientState.sendCompletions.push(sendCompleted); const response = installFastResponses(); clientState.sendResults.push({ @@ -360,36 +325,23 @@ const acquireQuestionPeer = Effect.fn(function* () { GROUP_QUESTION, ), }); - const running = yield* peers - .orderedGroupPeerRuntime({ - caseId: CASE_ID, - targetName: TARGET_NAME, - sourceName: SOURCE_NAME, - participantNames: [SOURCE_NAME, OBSERVER_NAME], - groupName: GROUP_NAME, - text: GROUP_QUESTION, - }) - .acquire({ - agentName: agentName(QUESTION_NAME), - connection: connection(QUESTION_NAME, QUESTION_ID), - }); - yield* Deferred.await(ready); - return { gateway: running.gateway, response, sendCompleted }; + const definition = orderedGroupPeerRuntime({ + caseId: CASE_ID, + targetName: TARGET_NAME, + sourceName: SOURCE_NAME, + participantNames: [SOURCE_NAME, OBSERVER_NAME], + groupName: GROUP_NAME, + text: GROUP_QUESTION, + }); + const gateway = yield* startPeer(definition.plan, QUESTION_NAME, QUESTION_ID); + yield* Deferred.await(conversationOpened); + return { gateway, response, sendCompleted }; }); const acquireObserverPeer = Effect.fn(function* () { - const peers = yield* peerModule; - const ready = yield* Deferred.make(); - clientState.received = receivedStream(ready); clientState.agents = [card(TARGET_NAME, TARGET_ID)]; - const running = yield* peers - .observerPeerRuntime(CASE_ID, TARGET_NAME) - .acquire({ - agentName: agentName(OBSERVER_NAME), - connection: connection(OBSERVER_NAME, OBSERVER_ID), - }); - yield* Deferred.await(ready); - return running.gateway; + const definition = observerPeerRuntime(CASE_ID, TARGET_NAME); + return yield* startPeer(definition.plan, OBSERVER_NAME, OBSERVER_ID); }); function assertSourceExchange( @@ -535,6 +487,50 @@ const orderedGroupPolicyTest = Effect.fn(function* () { assertQuestionExchange(exchange, contact, source, fixture.response); }); +const completedBridgeTest = Effect.fn(function* () { + const exchange = new PeerExchange({ + observations: [ + CodePeerMessageReceived.make({ + caseId: CASE_ID, + agentName: SOURCE_AGENT_NAME, + agentId: SOURCE_ID, + conversationId: CONVERSATION_ID, + messageId: messageId("00000000-0000-4000-8000-000000000849"), + senderId: TARGET_ID, + parts: [{ type: "text", text: "bridge observation" }], + }), + ], + }); + const completed = EvaluationPeerBridgeCompleted.make({ exchange }); + const encoded = yield* Schema.encode(EvaluationPeerBridgeResult)(completed); + const decoded = yield* Schema.decode(EvaluationPeerBridgeResult)(encoded); + const gateway = evaluationPeerGatewayFromBridge(Effect.succeed(decoded)); + + assert.deepStrictEqual(yield* gateway.exchange, exchange); +}); + +const failedBridgeTest = Effect.fn(function* () { + const failure = EvaluationPeerFailed.make({ + operation: "bridge", + detail: "peer application terminated before publishing its exchange", + }); + const encoded = yield* Schema.encode(EvaluationPeerBridgeResult)( + EvaluationPeerBridgeFailed.make({ failure }), + ); + const decoded = yield* Schema.decode(EvaluationPeerBridgeResult)(encoded); + const gateway = evaluationPeerGatewayFromBridge(Effect.succeed(decoded)); + const observed = yield* gateway.exchange.pipe( + Effect.match({ + onFailure: (value) => ({ failure: value }), + onSuccess: () => ({ failure: undefined }), + }), + ); + + assert.instanceOf(observed.failure, EvaluationPeerFailed); + assert.strictEqual(observed.failure?.operation, failure.operation); + assert.strictEqual(observed.failure?.detail, failure.detail); +}); + test("the source announces only after target contact and in that conversation", () => Effect.scoped(sourcePolicyTest())); @@ -543,3 +539,7 @@ test("the observer records the first target delivery without sending", () => test("the question preserves order and buffers a response received before send returns", () => Effect.scoped(orderedGroupPolicyTest())); +test("the peer bridge round-trips and projects a completed exchange", () => + completedBridgeTest()); +test("the peer bridge projects a typed application failure", () => + failedBridgeTest()); diff --git a/packages/evals/src/peer.ts b/packages/evals/src/peer.ts index b0fc0e1e8..39f44bc19 100644 --- a/packages/evals/src/peer.ts +++ b/packages/evals/src/peer.ts @@ -5,6 +5,8 @@ import { type ConversationId, } from "@moltzap/protocol/conversation"; import { + agentId, + agentKey, agentName, agentsList, type AgentCard, @@ -15,39 +17,200 @@ import { type Message, type MessageReceivedNotification, } from "@moltzap/protocol/message"; +import { httpBaseUrl } from "@moltzap/protocol/network"; import type { ListCursor } from "@moltzap/protocol/rpc"; +import { HttpClient } from "@effect/platform"; +import { NodeHttpClient } from "@effect/platform-node"; +import type { MoltZapAgentClient } from "@moltzap/protocol/socket"; import { type AgentRuntime, - type EffectRuntimeStartFailed, - effectRuntime, - type EffectRuntimeContext, -} from "@moltzap/simulator/runtime"; -import { Deferred, Data, Effect, Mailbox, Schedule, Schema } from "effect"; + type AgentRuntimeInput, + type Application, + type ApplicationEndpoint, + defineContainerRuntime, + type File, + image, + type Image, + routableBridgeEndpoint, + RuntimeAcquisitionError, + type RuntimeTermination, + stoppedBeforeAttach, +} from "@moltzap/simulator/agents"; +import { + Duration, + Effect, + Mailbox, + Option, + Schedule, + Schema, + type Stream, +} from "effect"; import type { NonEmptyReadonlyArray } from "effect/Array"; import { CodePeerMessageReceived, CodePeerMessageSent } from "./events.js"; -import type { EvaluationCaseId } from "./model.js"; +import { evaluationCaseId, type EvaluationCaseId } from "./model.js"; const AGENT_PAGE_SIZE = 100; const AGENT_POLL_INTERVAL = "100 millis"; const GROUP_MEMBER_RESOLUTION_CONCURRENCY = 4; +const EVALUATION_PEER_RUNTIME_NAME = "evaluation-peer"; +const EVALUATION_PEER_BRIDGE_POLL_INTERVAL = Duration.millis(100); +const EVALUATION_PEER_APPLICATION_ENTRYPOINT = + "/opt/moltzap/node_modules/@moltzap/evals/dist/peer-application.js"; +/** Mounted application configuration read only inside one peer container. */ +const EVALUATION_PEER_BOOTSTRAP_PATH = + "/var/run/moltzap/bootstrap/evaluation-peer.json"; +/** Fixed controller bridge port exposed by every evaluation peer. */ +export const EVALUATION_PEER_BRIDGE_PORT = 4319; +/** Startup line the peer container logs once its bridge is listening. */ +export const EVALUATION_PEER_READY_MARKER = + "MoltZap evaluation peer bridge ready"; +const EVALUATION_PEER_RESOURCES = Object.freeze({ + cpuMillis: 100, + memoryBytes: 128 * 1024 * 1024, + ephemeralStorageBytes: 128 * 1024 * 1024, +}); const decodeAgentName = Schema.decodeSync(agentName); +const evaluationPeerObservation = Schema.Union( + CodePeerMessageReceived, + CodePeerMessageSent, +); + /** Endpoint testimony produced by one bundled code peer. */ -export type EvaluationPeerObservation = - | CodePeerMessageReceived - | CodePeerMessageSent; -type PeerClient = EffectRuntimeContext["client"]; +export type EvaluationPeerObservation = typeof evaluationPeerObservation.Type; +type PeerClient = Pick; + +/** Runtime context owned by the peer application process. */ +export interface EvaluationPeerApplicationContext { + readonly agent: Readonly<{ + readonly name: string; + readonly id: AgentId; + }>; + readonly messages: Stream.Stream; + readonly client: PeerClient; +} interface PeerContext { - readonly agent: EffectRuntimeContext["agent"]; + readonly agent: EvaluationPeerApplicationContext["agent"]; readonly client: PeerClient; readonly inbox: Mailbox.ReadonlyMailbox; } +/** Respond in an existing target-created conversation. */ +class ReactivePeerPlan extends Schema.TaggedClass()( + "moltzap.eval-peer-reactive/v1", + { + caseId: evaluationCaseId, + targetName: agentName, + messages: Schema.NonEmptyArray(Schema.NonEmptyString), + }, +) {} + +/** Open a direct conversation and send the first message. */ +class OpeningPeerPlan extends Schema.TaggedClass()( + "moltzap.eval-peer-opening/v1", + { + caseId: evaluationCaseId, + targetName: agentName, + text: Schema.NonEmptyString, + }, +) {} + +/** Announce into the conversation identified by the target. */ +class AnnouncementPeerPlan extends Schema.TaggedClass()( + "moltzap.eval-peer-announcement/v1", + { + caseId: evaluationCaseId, + targetName: agentName, + text: Schema.NonEmptyString, + }, +) {} + +/** Observe the first delivery from the target without sending. */ +class ObserverPeerPlan extends Schema.TaggedClass()( + "moltzap.eval-peer-observer/v1", + { + caseId: evaluationCaseId, + targetName: agentName, + }, +) {} + +/** Prepare a group and preserve contact, announcement, question, response order. */ +class OrderedGroupPeerPlan extends Schema.TaggedClass()( + "moltzap.eval-peer-ordered-group/v1", + { + caseId: evaluationCaseId, + targetName: agentName, + sourceName: agentName, + participantNames: Schema.NonEmptyArray(agentName), + groupName: Schema.NonEmptyString, + text: Schema.NonEmptyString, + }, +) {} + +/** Prepare a group and respond after the target's first delivery. */ +class GroupResponsePeerPlan extends Schema.TaggedClass()( + "moltzap.eval-peer-group-response/v1", + { + caseId: evaluationCaseId, + targetName: agentName, + participantNames: Schema.NonEmptyArray(agentName), + groupName: Schema.NonEmptyString, + messages: Schema.NonEmptyArray(Schema.NonEmptyString), + }, +) {} + +/** Closed policy universe executed by the distributed peer application. */ +// eslint-disable-next-line @typescript-eslint/naming-convention, agent-code-guard/no-exported-brand-constructor -- The container entrypoint decodes this closed boundary schema while case factories expose only decoded plan values. +export const EvaluationPeerApplicationPlan = Schema.Union( + ReactivePeerPlan, + OpeningPeerPlan, + AnnouncementPeerPlan, + ObserverPeerPlan, + OrderedGroupPeerPlan, + GroupResponsePeerPlan, +); +/** Decoded distributed peer application policy. */ +// eslint-disable-next-line @typescript-eslint/no-redeclare -- the value is the runtime Schema and the type is its decoded result. +export type EvaluationPeerApplicationPlan = + typeof EvaluationPeerApplicationPlan.Type; + +/** Non-secret runtime configuration committed with the RunSpec roster. */ +class EvaluationPeerRuntimeConfiguration extends Schema.Class( + "EvaluationPeerRuntimeConfiguration", +)({ + applicationImage: image, + plan: EvaluationPeerApplicationPlan, +}) {} + +/** Run-scoped secret configuration mounted into exactly one peer container. */ +export class EvaluationPeerBootstrap extends Schema.Class( + "EvaluationPeerBootstrap", +)({ + apiVersion: Schema.Literal("moltzap.eval-peer-bootstrap/v1"), + agentName, + agentId, + agentKey, + serverUrl: Schema.NonEmptyString, + plan: EvaluationPeerApplicationPlan, +}) {} + +const encodeEvaluationPeerBootstrap = Schema.encodeSync( + Schema.parseJson(EvaluationPeerBootstrap), +); + +function mapNonEmpty( + values: NonEmptyReadonlyArray, + transform: (value: Input) => Output, +): NonEmptyReadonlyArray { + const [first, ...remaining] = values; + return Object.freeze([transform(first), ...remaining.map(transform)]); +} + /** One completed peer interaction in exact production-protocol order. */ -export class PeerExchange extends Data.Class<{ - readonly observations: NonEmptyReadonlyArray; -}> {} +export class PeerExchange extends Schema.Class("PeerExchange")({ + observations: Schema.NonEmptyArray(evaluationPeerObservation), +}) {} /** A bundled code peer could not complete its production-protocol policy. */ export class EvaluationPeerFailed extends Schema.TaggedError()( @@ -58,11 +221,38 @@ export class EvaluationPeerFailed extends Schema.TaggedError()( + "moltzap.eval-peer-bridge-completed/v1", + { + exchange: PeerExchange, + }, +) {} + +/** The peer application terminated its autonomous policy with a typed failure. */ +export class EvaluationPeerBridgeFailed extends Schema.TaggedClass()( + "moltzap.eval-peer-bridge-failed/v1", + { + failure: EvaluationPeerFailed, + }, +) {} + +/** Closed application-to-controller result carried by the peer-specific bridge. */ +// eslint-disable-next-line @typescript-eslint/naming-convention, agent-code-guard/no-exported-brand-constructor -- The container bridge and controller attachment share this exact closed transport schema. +export const EvaluationPeerBridgeResult = Schema.Union( + EvaluationPeerBridgeCompleted, + EvaluationPeerBridgeFailed, +); +/** Decoded peer-specific bridge result. */ +// eslint-disable-next-line @typescript-eslint/no-redeclare -- the value is the runtime Schema and the type is its decoded result. +export type EvaluationPeerBridgeResult = typeof EvaluationPeerBridgeResult.Type; + /** * Exact principal surface for bundled evaluation peers. * @@ -73,12 +263,38 @@ export interface EvaluationPeerGateway { readonly exchange: Effect.Effect; } -/** Reusable in-process runtime shape shared by bundled autonomous peers. */ -export type EvaluationPeerRuntime = AgentRuntime< +/** + * Adapt one decoded application result into the peer's observation-only gateway. + * @param result Decoded result from the runtime-specific controller bridge. + * @returns A gateway with no command or social-action surface. + */ +export function evaluationPeerGatewayFromBridge( + result: Effect.Effect, +): EvaluationPeerGateway { + return Object.freeze({ + exchange: result.pipe( + Effect.flatMap((outcome) => + outcome instanceof EvaluationPeerBridgeCompleted + ? Effect.succeed(outcome.exchange) + : Effect.fail(outcome.failure), + ), + ), + }); +} + +/** Distributed runtime shape shared by bundled autonomous peers. */ +type EvaluationPeerRuntime = AgentRuntime< EvaluationPeerGateway, - EffectRuntimeStartFailed + RuntimeAcquisitionError, + typeof EvaluationPeerRuntimeConfiguration >; +/** Image-independent case-owned peer definition materialized by one cell. */ +export interface EvaluationPeerDefinition { + readonly plan: EvaluationPeerApplicationPlan; + readonly runtime: (applicationImage: Image) => EvaluationPeerRuntime; +} + interface PeerConversation { readonly conversationId: ConversationId; } @@ -305,7 +521,7 @@ function openingPolicy( } function prepareGroup( - context: EffectRuntimeContext, + context: EvaluationPeerApplicationContext, targetName: string, participantNames: NonEmptyReadonlyArray, name: string, @@ -435,13 +651,70 @@ function groupResponsePolicy( ); } -function runPeerPolicy( - context: EffectRuntimeContext, - policy: PeerPolicy, - exchange: Deferred.Deferred, -) { - const completed = Effect.gen(function* () { +function planPolicy( + context: EvaluationPeerApplicationContext, + plan: EvaluationPeerApplicationPlan, +): Effect.Effect { + if (plan instanceof ReactivePeerPlan) { + return Effect.succeed( + reactivePolicy(plan.caseId, plan.targetName, plan.messages), + ); + } + if (plan instanceof OpeningPeerPlan) { + return Effect.succeed( + openingPolicy(plan.caseId, plan.targetName, plan.text), + ); + } + if (plan instanceof AnnouncementPeerPlan) { + return Effect.succeed( + sourceAnnouncementPolicy(plan.caseId, plan.targetName, plan.text), + ); + } + if (plan instanceof ObserverPeerPlan) { + return Effect.succeed(observerPolicy(plan.caseId, plan.targetName)); + } + if (plan instanceof OrderedGroupPeerPlan) { + return prepareGroup( + context, + plan.targetName, + plan.participantNames, + plan.groupName, + ).pipe( + Effect.map((prepared) => + orderedGroupQuestionPolicy( + plan.caseId, + prepared, + plan.sourceName, + plan.text, + ), + ), + ); + } + return prepareGroup( + context, + plan.targetName, + plan.participantNames, + plan.groupName, + ).pipe( + Effect.map((prepared) => + groupResponsePolicy(plan.caseId, prepared, plan.messages), + ), + ); +} + +/** + * Execute one decoded peer plan against its production-protocol client. + * @param context Connected production client, identity, and message stream. + * @param plan Closed case-owned autonomous interaction policy. + * @returns The peer's ordered exchange testimony. + */ +export function runEvaluationPeerApplication( + context: EvaluationPeerApplicationContext, + plan: EvaluationPeerApplicationPlan, +): Effect.Effect { + return Effect.gen(function* () { const inbox = yield* Mailbox.fromStream(context.messages); + const policy = yield* planPolicy(context, plan); return yield* policy( Object.freeze({ agent: context.agent, @@ -449,59 +722,185 @@ function runPeerPolicy( inbox, }), ); + }).pipe(Effect.scoped, Effect.withSpan("evals.peer.application")); +} + +function acquisitionFailure( + agent: string, + detail: string, +): RuntimeAcquisitionError { + return RuntimeAcquisitionError.make({ + runtime: EVALUATION_PEER_RUNTIME_NAME, + agent, + detail, + }); +} + +function bridgeResultUrl(endpoint: ApplicationEndpoint): string { + return `http://${endpoint.host}:${String(endpoint.port)}/result`; +} + +function readBridgeResult( + url: string, +): Effect.Effect< + Option.Option, + EvaluationPeerFailed, + HttpClient.HttpClient +> { + return HttpClient.HttpClient.pipe( + Effect.flatMap((client) => client.get(url)), + Effect.mapError((cause) => failure("bridge", cause)), + Effect.flatMap((response) => { + if (response.status === 204) { + return Effect.succeed(Option.none()); + } + if (response.status !== 200) { + return Effect.fail( + failure( + "bridge", + `peer bridge returned HTTP ${String(response.status)}`, + ), + ); + } + return response.json.pipe( + Effect.mapError((cause) => failure("bridge", cause)), + Effect.flatMap((body) => + Schema.decodeUnknown(EvaluationPeerBridgeResult)(body, { + onExcessProperty: "error", + }).pipe(Effect.mapError((cause) => failure("bridge", cause))), + ), + Effect.map(Option.some), + ); + }), + ); +} + +function awaitBridgeResult( + url: string, +): Effect.Effect { + const poll: Effect.Effect< + EvaluationPeerBridgeResult, + EvaluationPeerFailed, + HttpClient.HttpClient + > = Effect.suspend(() => + readBridgeResult(url).pipe( + Effect.flatMap( + Option.match({ + onNone: () => + Effect.sleep(EVALUATION_PEER_BRIDGE_POLL_INTERVAL).pipe( + Effect.zipRight(poll), + ), + onSome: Effect.succeed, + }), + ), + ), + ); + return poll.pipe(Effect.provide(NodeHttpClient.layerUndici)); +} + +function attachEvaluationPeer( + agent: string, + endpoint: ApplicationEndpoint, + stopped: Effect.Effect, +): Effect.Effect { + return Effect.try({ + try: () => bridgeResultUrl(routableBridgeEndpoint(endpoint)), + catch: (cause) => + acquisitionFailure( + agent, + `resolve peer bridge endpoint: ${String(cause)}`, + ), }).pipe( - Effect.scoped, - Effect.onExit((exit) => Deferred.done(exchange, exit).pipe(Effect.asVoid)), + Effect.map((url) => + evaluationPeerGatewayFromBridge( + awaitBridgeResult(url).pipe( + Effect.raceFirst( + stoppedBeforeAttach(stopped, (detail) => + failure( + "bridge", + `peer application stopped before publishing its result: ${detail}`, + ), + ), + ), + ), + ), + ), ); - return completed.pipe(Effect.andThen(Effect.never)); -} - -function peerRuntime(policy: PeerPolicy): EvaluationPeerRuntime { - return effectRuntime({ - build: (context) => - Effect.gen(function* () { - const exchange = yield* Deferred.make< - PeerExchange, - EvaluationPeerFailed - >(); - return { - gateway: Object.freeze({ - exchange: Deferred.await(exchange), - }), - behavior: runPeerPolicy(context, policy, exchange), - }; - }).pipe(Effect.withSpan("evals.peer.build")), - }); } -interface PreparedGroupRuntimeOptions { - readonly targetName: string; - readonly participantNames: NonEmptyReadonlyArray; - readonly groupName: string; - readonly policy: (prepared: PreparedGroup) => PeerPolicy; +function bootstrapFiles( + plan: EvaluationPeerApplicationPlan, + input: AgentRuntimeInput, +): readonly File[] { + const content = encodeEvaluationPeerBootstrap( + EvaluationPeerBootstrap.make({ + apiVersion: "moltzap.eval-peer-bootstrap/v1", + agentName: input.agentName, + agentId: input.connection.agent.id, + agentKey: input.connection.key, + serverUrl: httpBaseUrl(input.connection.routerUrl), + plan, + }), + ); + return Object.freeze([ + Object.freeze({ + path: EVALUATION_PEER_BOOTSTRAP_PATH, + content, + mode: 0o400, + }), + ]); +} + +function peerApplication( + plan: EvaluationPeerApplicationPlan, + input: AgentRuntimeInput, +): Application { + return Object.freeze({ + entrypoint: Object.freeze([ + "node", + EVALUATION_PEER_APPLICATION_ENTRYPOINT, + EVALUATION_PEER_BOOTSTRAP_PATH, + ] as const), + environment: Object.freeze({ NODE_ENV: "production" }), + port: EVALUATION_PEER_BRIDGE_PORT, + files: bootstrapFiles(plan, input), + attach: ( + endpoint: ApplicationEndpoint, + stopped: Effect.Effect, + ) => attachEvaluationPeer(input.agentName, endpoint, stopped), + }); } -function preparedGroupRuntime( - options: PreparedGroupRuntimeOptions, +function peerRuntime( + plan: EvaluationPeerApplicationPlan, + applicationImage: Image, ): EvaluationPeerRuntime { - return effectRuntime({ - build: (context) => - Effect.gen(function* () { - const prepared = yield* prepareGroup( - context, - options.targetName, - options.participantNames, - options.groupName, - ); - const exchange = yield* Deferred.make< - PeerExchange, - EvaluationPeerFailed - >(); - return { - gateway: Object.freeze({ exchange: Deferred.await(exchange) }), - behavior: runPeerPolicy(context, options.policy(prepared), exchange), - }; - }).pipe(Effect.withSpan("evals.peer.build-prepared-group")), + return defineContainerRuntime({ + name: EVALUATION_PEER_RUNTIME_NAME, + configuration: { + schema: EvaluationPeerRuntimeConfiguration, + value: new EvaluationPeerRuntimeConfiguration({ + applicationImage, + plan, + }), + }, + image: applicationImage, + resources: EVALUATION_PEER_RESOURCES, + render: (input) => + Effect.try({ + try: () => peerApplication(plan, input), + catch: (cause) => acquisitionFailure(input.agentName, String(cause)), + }), + }); +} + +function peerDefinition( + plan: EvaluationPeerApplicationPlan, +): EvaluationPeerDefinition { + Object.freeze(plan); + return Object.freeze({ + plan, + runtime: (applicationImage: Image) => peerRuntime(plan, applicationImage), }); } @@ -510,14 +909,20 @@ function preparedGroupRuntime( * @param caseId Evaluation case identity copied into endpoint testimony. * @param targetName Roster name the peer accepts messages from. * @param messages Ordered peer messages, each followed by one target response. - * @returns A runtime whose gateway reports the ordered interaction. + * @returns An image-independent definition of the peer interaction. */ export function selectedResponsePeerRuntime( caseId: EvaluationCaseId, targetName: string, messages: NonEmptyReadonlyArray, ) { - return peerRuntime(reactivePolicy(caseId, targetName, messages)); + return peerDefinition( + new ReactivePeerPlan({ + caseId, + targetName: decodeAgentName(targetName), + messages: mapNonEmpty(messages, (message) => message), + }), + ); } /** @@ -525,14 +930,14 @@ export function selectedResponsePeerRuntime( * @param caseId Evaluation case identity copied into endpoint testimony. * @param targetName Roster name the peer accepts messages from. * @param messages Ordered peer messages, each followed by one target response. - * @returns A runtime whose gateway reports the complete interaction. + * @returns An image-independent definition of the peer interaction. */ export function contextPeerRuntime( caseId: EvaluationCaseId, targetName: string, messages: NonEmptyReadonlyArray, ) { - return peerRuntime(reactivePolicy(caseId, targetName, messages)); + return selectedResponsePeerRuntime(caseId, targetName, messages); } /** @@ -540,14 +945,20 @@ export function contextPeerRuntime( * @param caseId Evaluation case identity copied into endpoint testimony. * @param targetName Roster name the peer contacts. * @param text Initial peer message. - * @returns A runtime whose gateway reports the complete interaction. + * @returns An image-independent definition of the peer interaction. */ export function openingPeerRuntime( caseId: EvaluationCaseId, targetName: string, text: string, ) { - return peerRuntime(openingPolicy(caseId, targetName, text)); + return peerDefinition( + new OpeningPeerPlan({ + caseId, + targetName: decodeAgentName(targetName), + text, + }), + ); } /** @@ -555,27 +966,38 @@ export function openingPeerRuntime( * @param caseId Evaluation case identity copied into endpoint testimony. * @param targetName Roster name whose first message identifies the group. * @param text Source announcement sent into that exact conversation. - * @returns A runtime whose gateway reports the contact and announcement. + * @returns An image-independent definition of the peer interaction. */ export function announcementPeerRuntime( caseId: EvaluationCaseId, targetName: string, text: string, ) { - return peerRuntime(sourceAnnouncementPolicy(caseId, targetName, text)); + return peerDefinition( + new AnnouncementPeerPlan({ + caseId, + targetName: decodeAgentName(targetName), + text, + }), + ); } /** * Build an observer that records the target's first delivered message. * @param caseId Evaluation case identity copied into endpoint testimony. * @param targetName Roster name whose first delivery is observed. - * @returns A runtime whose gateway reports one production-stream delivery. + * @returns An image-independent definition of the peer interaction. */ export function observerPeerRuntime( caseId: EvaluationCaseId, targetName: string, ) { - return peerRuntime(observerPolicy(caseId, targetName)); + return peerDefinition( + new ObserverPeerPlan({ + caseId, + targetName: decodeAgentName(targetName), + }), + ); } interface OrderedGroupPeerOptions { @@ -590,23 +1012,21 @@ interface OrderedGroupPeerOptions { /** * Build a question peer that provisions a named group and preserves its order. * @param options Named topology and ordered question policy. - * @returns A runtime whose final observation is the target's response. + * @returns An image-independent definition of the peer interaction. */ export function orderedGroupPeerRuntime( options: OrderedGroupPeerOptions, -): EvaluationPeerRuntime { - return preparedGroupRuntime({ - targetName: options.targetName, - participantNames: options.participantNames, - groupName: options.groupName, - policy: (prepared) => - orderedGroupQuestionPolicy( - options.caseId, - prepared, - options.sourceName, - options.text, - ), - }); +): EvaluationPeerDefinition { + return peerDefinition( + new OrderedGroupPeerPlan({ + caseId: options.caseId, + targetName: decodeAgentName(options.targetName), + sourceName: decodeAgentName(options.sourceName), + participantNames: mapNonEmpty(options.participantNames, decodeAgentName), + groupName: options.groupName, + text: options.text, + }), + ); } interface GroupResponsePeerOptions { @@ -620,16 +1040,18 @@ interface GroupResponsePeerOptions { /** * Build a peer that provisions a named group before runtime readiness. * @param options Named topology and ordered response policy. - * @returns A runtime whose gateway reports the ordered group interaction. + * @returns An image-independent definition of the peer interaction. */ export function groupResponsePeerRuntime( options: GroupResponsePeerOptions, -): EvaluationPeerRuntime { - return preparedGroupRuntime({ - targetName: options.targetName, - participantNames: options.participantNames, - groupName: options.groupName, - policy: (prepared) => - groupResponsePolicy(options.caseId, prepared, options.messages), - }); +): EvaluationPeerDefinition { + return peerDefinition( + new GroupResponsePeerPlan({ + caseId: options.caseId, + targetName: decodeAgentName(options.targetName), + participantNames: mapNonEmpty(options.participantNames, decodeAgentName), + groupName: options.groupName, + messages: mapNonEmpty(options.messages, (message) => message), + }), + ); } diff --git a/packages/evals/src/phoenix.test.ts b/packages/evals/src/phoenix.test.ts index 4a98fca3b..ecce1be11 100644 --- a/packages/evals/src/phoenix.test.ts +++ b/packages/evals/src/phoenix.test.ts @@ -5,6 +5,7 @@ import { type Types, } from "@arizeai/phoenix-client"; import { CompletedLedgerReceipt } from "@moltzap/simulator"; +import { image } from "@moltzap/simulator/agents"; import { LedgerCompletion, LedgerStorageError, @@ -43,9 +44,12 @@ import { EvaluationReportPlan, EvidenceRejectedAttempt, JudgePolicySnapshot, + LocalEvaluationInfrastructure, LedgerAllocationFailedAttempt, } from "./sweep.js"; +const testImage = Schema.decodeSync(image); + const DATASET_NAME = "moltzap-evaluations"; const DATASET_DESCRIPTION = "MoltZap code-first behavioral evaluation cases (schema v1)."; @@ -122,6 +126,14 @@ function plan(definitionId = "moltzap.test.phoenix/v1"): EvaluationReportPlan { timeoutMillis: 1_000, maxRetries: 2, }), + infrastructure: LocalEvaluationInfrastructure.make({ + profile: "local", + controllerImage: testImage(`controller@sha256:${"a".repeat(64)}`), + peerApplicationImage: testImage(`peer@sha256:${"b".repeat(64)}`), + nanoclawApplicationImage: testImage(`nanoclaw@sha256:${"c".repeat(64)}`), + temporalAddress: "127.0.0.1:7233", + artifactDirectory: "/var/lib/moltzap/artifacts", + }), samplesPerCell: 1, }); } @@ -768,6 +780,7 @@ describe("Phoenix catalog version conflicts", () => { cases: reportPlan.cases, conditions: [reportPlan.conditions[0], second], judgePolicy: reportPlan.judgePolicy, + infrastructure: reportPlan.infrastructure, samplesPerCell: reportPlan.samplesPerCell, }); const failure = yield* phoenixPublishedDatasetVersion(digest, splitPlan, [ diff --git a/packages/evals/src/principal.test.ts b/packages/evals/src/principal.test.ts index ea12ebacd..76b36a029 100644 --- a/packages/evals/src/principal.test.ts +++ b/packages/evals/src/principal.test.ts @@ -1,22 +1,22 @@ import { assert, describe, it } from "@effect/vitest"; import { agentId } from "@moltzap/protocol/testing"; import { - NanoclawGatewayError, - NanoclawGatewayInput, - NanoclawGatewayOutput, - type NanoclawGateway, + NanoClawGatewayError, + NanoClawGatewayInput, + NanoClawGatewayOutput, + type NanoClawGateway, type OpenClawGateway, OpenClawGatewayRequest, - OpenClawGatewayRequestFailed, + OpenClawGatewayRequestError, OpenClawGatewayResponse, OpenClawGatewaySucceeded, type StartedAgent, -} from "@moltzap/simulator/runtime"; +} from "@moltzap/simulator/agents"; import { makeAgentHandle } from "@moltzap/simulator/network"; import { Deferred, Effect, Option, Ref, Schema, Stream } from "effect"; import { - NanoclawPrincipalInputSent, - NanoclawPrincipalOutputReceived, + NanoClawPrincipalInputSent, + NanoClawPrincipalOutputReceived, OpenClawPrincipalFinalOutput, OpenClawPrincipalInstructionAttempted, } from "./events.js"; @@ -52,7 +52,7 @@ const OPENCLAW_RESPONSE = Schema.decodeSync(OpenClawGatewayResponse)({ }, }); -const NANOCLAW_OUTPUT = NanoclawGatewayOutput.make({ +const NANOCLAW_OUTPUT = NanoClawGatewayOutput.make({ text: "Conversation created.", }); @@ -213,7 +213,7 @@ function openClawUniqueKeysTest() { function openClawFailureTest() { return Effect.gen(function* () { const recorder = yield* makeEventRecorder(); - const failure = OpenClawGatewayRequestFailed.make({ + const failure = OpenClawGatewayRequestError.make({ detail: "native agent RPC rejected the instruction", }); const gateway: OpenClawGateway = { @@ -226,7 +226,7 @@ function openClawFailureTest() { .pipe(Effect.flip); const recorded = yield* Ref.get(recorder.events); - assert.instanceOf(observed, OpenClawGatewayRequestFailed); + assert.instanceOf(observed, OpenClawGatewayRequestError); assert.strictEqual(observed.detail, failure.detail); assert.strictEqual(recorded.length, 1); assertOpenClawSubmitted(INSTRUCTION_TEXT, idempotencyKey(0), recorded[0]); @@ -236,11 +236,11 @@ function openClawFailureTest() { }); } -function assertNanoclawInput( +function assertNanoClawInput( expectedText: string, event?: EvaluationEvent, ): void { - if (!(event instanceof NanoclawPrincipalInputSent)) { + if (!(event instanceof NanoClawPrincipalInputSent)) { assert.fail("expected a NanoClaw input event"); } assert.strictEqual(event.caseId, CASE_ID); @@ -248,12 +248,12 @@ function assertNanoclawInput( assert.strictEqual(event.agentId, TARGET_ID); assert.deepStrictEqual( event.input, - NanoclawGatewayInput.make({ text: expectedText }), + NanoClawGatewayInput.make({ text: expectedText }), ); } -function assertNanoclawOutput(event?: EvaluationEvent): void { - if (!(event instanceof NanoclawPrincipalOutputReceived)) { +function assertNanoClawOutput(event?: EvaluationEvent): void { + if (!(event instanceof NanoClawPrincipalOutputReceived)) { assert.fail("expected a NanoClaw output event"); } assert.strictEqual(event.caseId, CASE_ID); @@ -262,10 +262,10 @@ function assertNanoclawOutput(event?: EvaluationEvent): void { assert.deepStrictEqual(event.output, NANOCLAW_OUTPUT); } -function recordingNanoclawGateway( - inputs: Ref.Ref, +function recordingNanoClawGateway( + inputs: Ref.Ref, outputPulls: Ref.Ref, -): NanoclawGateway { +): NanoClawGateway { return { submit: (input) => Ref.update(inputs, (received) => [...received, input]), outputs: Stream.fromEffect( @@ -279,17 +279,17 @@ function recordingNanoclawGateway( function nanoclawOutputObservationTest() { return Effect.gen(function* () { const recorder = yield* makeEventRecorder(); - const inputs = yield* Ref.make([]); + const inputs = yield* Ref.make([]); const outputPulls = yield* Ref.make(0); const outputRecorded = yield* Deferred.make(); - const gateway = recordingNanoclawGateway(inputs, outputPulls); + const gateway = recordingNanoClawGateway(inputs, outputPulls); const driver = yield* nanoclawPrincipalDriver.make(ATTEMPT_ID); const emit: EmitEvaluationEvent = (event) => recorder .emit(event) .pipe( Effect.tap(() => - event instanceof NanoclawPrincipalOutputReceived + event instanceof NanoClawPrincipalOutputReceived ? Deferred.succeed(outputRecorded, undefined) : Effect.void, ), @@ -302,7 +302,7 @@ function nanoclawOutputObservationTest() { const recorded = yield* Ref.get(recorder.events); assert.strictEqual(recorded.length, 1); - assertNanoclawOutput(recorded[0]); + assertNanoClawOutput(recorded[0]); assert.strictEqual(yield* Ref.get(outputPulls), 1); }).pipe(Effect.scoped); } @@ -310,9 +310,9 @@ function nanoclawOutputObservationTest() { function nanoclawUncorrelatedOutputTest() { return Effect.gen(function* () { const recorder = yield* makeEventRecorder(); - const inputs = yield* Ref.make([]); + const inputs = yield* Ref.make([]); const outputPulls = yield* Ref.make(0); - const gateway = recordingNanoclawGateway(inputs, outputPulls); + const gateway = recordingNanoClawGateway(inputs, outputPulls); const driver = yield* nanoclawPrincipalDriver.make(ATTEMPT_ID); const secondMessage = "Submit another principal instruction."; @@ -329,12 +329,12 @@ function nanoclawUncorrelatedOutputTest() { const recorded = yield* Ref.get(recorder.events); assert.deepStrictEqual(yield* Ref.get(inputs), [ - NanoclawGatewayInput.make({ text: INSTRUCTION_TEXT }), - NanoclawGatewayInput.make({ text: secondMessage }), + NanoClawGatewayInput.make({ text: INSTRUCTION_TEXT }), + NanoClawGatewayInput.make({ text: secondMessage }), ]); assert.strictEqual(recorded.length, 2); - assertNanoclawInput(INSTRUCTION_TEXT, recorded[0]); - assertNanoclawInput(secondMessage, recorded[1]); + assertNanoClawInput(INSTRUCTION_TEXT, recorded[0]); + assertNanoClawInput(secondMessage, recorded[1]); assert.isTrue(Option.isNone(firstOutput)); assert.isTrue(Option.isNone(secondOutput)); assert.strictEqual(yield* Ref.get(outputPulls), 0); @@ -344,16 +344,16 @@ function nanoclawUncorrelatedOutputTest() { function nanoclawSubmitFailureTest() { return Effect.gen(function* () { const recorder = yield* makeEventRecorder(); - const failure = NanoclawGatewayError.make({ + const failure = NanoClawGatewayError.make({ operation: "submit", detail: "native socket rejected the input", }); - const gateway: NanoclawGateway = { + const gateway: NanoClawGateway = { submit: (input) => Effect.gen(function* () { assert.deepStrictEqual( input, - NanoclawGatewayInput.make({ text: INSTRUCTION_TEXT }), + NanoClawGatewayInput.make({ text: INSTRUCTION_TEXT }), ); return yield* Effect.fail(failure); }), @@ -367,7 +367,7 @@ function nanoclawSubmitFailureTest() { .drive(target(gateway), INSTRUCTION, recorder.emit) .pipe(Effect.flip); - assert.instanceOf(observed, NanoclawGatewayError); + assert.instanceOf(observed, NanoClawGatewayError); assert.strictEqual(observed.operation, failure.operation); assert.strictEqual(observed.detail, failure.detail); assert.deepStrictEqual(yield* Ref.get(recorder.events), []); diff --git a/packages/evals/src/principal.ts b/packages/evals/src/principal.ts index 9bd3d9e98..4fbe612ab 100644 --- a/packages/evals/src/principal.ts +++ b/packages/evals/src/principal.ts @@ -3,18 +3,18 @@ import { agentName } from "@moltzap/protocol/identity"; import type { CustomerEvents, LedgerFailure } from "@moltzap/simulator"; import { - NanoclawGatewayInput, - type NanoclawGatewayError, - type NanoclawGateway, + NanoClawGatewayInput, + type NanoClawGatewayError, + type NanoClawGateway, OpenClawGatewayRequest, type OpenClawGateway, - type OpenClawGatewayRequestFailed, + type OpenClawGatewayRequestError, type StartedAgent, -} from "@moltzap/simulator/runtime"; +} from "@moltzap/simulator/agents"; import { Effect, Option, Ref, Schema, Stream } from "effect"; import { - NanoclawPrincipalInputSent, - NanoclawPrincipalOutputReceived, + NanoClawPrincipalInputSent, + NanoClawPrincipalOutputReceived, OpenClawPrincipalFinalOutput, OpenClawPrincipalInstructionAttempted, type evaluationEvents, @@ -86,7 +86,7 @@ function driveOpenClaw( emit: EmitEvaluationEvent, ): Effect.Effect< Option.Option, - OpenClawGatewayRequestFailed | LedgerFailure + OpenClawGatewayRequestError | LedgerFailure > { return Effect.gen(function* () { const instructionNumber = yield* Ref.getAndUpdate( @@ -147,24 +147,24 @@ export const openClawPrincipalDriver = Object.freeze({ ), }) satisfies PrincipalDriverFactory< OpenClawGateway, - OpenClawGatewayRequestFailed + OpenClawGatewayRequestError >; -function driveNanoclaw( - target: StartedAgent, +function driveNanoClaw( + target: StartedAgent, instruction: PrincipalInstruction, emit: EmitEvaluationEvent, ): Effect.Effect< Option.Option, - NanoclawGatewayError | LedgerFailure + NanoClawGatewayError | LedgerFailure > { return Effect.gen(function* () { - const input = NanoclawGatewayInput.make({ + const input = NanoClawGatewayInput.make({ text: instruction.message, }); yield* target.gateway.submit(input); yield* emit( - NanoclawPrincipalInputSent.make({ + NanoClawPrincipalInputSent.make({ caseId: instruction.caseId, agentName: decodeAgentName(target.agent.name), agentId: target.agent.id, @@ -175,15 +175,15 @@ function driveNanoclaw( }).pipe(Effect.withSpan("evals.principal.nanoclaw")); } -function observeNanoclaw( - target: StartedAgent, +function observeNanoClaw( + target: StartedAgent, caseId: EvaluationCaseId, emit: EmitEvaluationEvent, -): Effect.Effect { +): Effect.Effect { return target.gateway.outputs.pipe( Stream.runForEach((output) => emit( - NanoclawPrincipalOutputReceived.make({ + NanoClawPrincipalOutputReceived.make({ caseId, agentName: decodeAgentName(target.agent.name), agentId: target.agent.id, @@ -203,14 +203,14 @@ function observeNanoclaw( * cannot identify a terminal response for evidence selection. */ export const nanoclawPrincipalDriver: PrincipalDriverFactory< - NanoclawGateway, - NanoclawGatewayError + NanoClawGateway, + NanoClawGatewayError > = Object.freeze({ make: () => Effect.succeed( Object.freeze({ - observe: observeNanoclaw, - drive: driveNanoclaw, + observe: observeNanoClaw, + drive: driveNanoClaw, }), ), }); diff --git a/packages/evals/src/results.test.ts b/packages/evals/src/results.test.ts index 9d58fede7..e38e1389e 100644 --- a/packages/evals/src/results.test.ts +++ b/packages/evals/src/results.test.ts @@ -1,5 +1,6 @@ import { Command, FileSystem, Path } from "@effect/platform"; import { NodeContext } from "@effect/platform-node"; +import { image } from "@moltzap/simulator/agents"; import { assert, describe, it as effectIt } from "@effect/vitest"; import { Cause, @@ -33,6 +34,7 @@ import { EvaluationResumeMismatch, JudgePolicySnapshot, LedgerAllocationFailedAttempt, + LocalEvaluationInfrastructure, decodeEvaluationReportId, type EvaluationSweepCell, } from "./sweep.js"; @@ -40,6 +42,8 @@ import { LedgerStorageError } from "@moltzap/simulator/ledger"; /* eslint-disable agent-code-guard/no-hardcoded-assertion-literals -- storage tests pin transaction, resume, and privacy invariants. */ +const testImage = Schema.decodeSync(image); + const it = effectIt.scoped; const liveIt = effectIt.scopedLive; const caseId = decodeEvaluationCaseId; @@ -48,7 +52,7 @@ const criterionId = decodeCriterionId; const judgePolicyId = decodeJudgePolicyId; const reportId = decodeEvaluationReportId; const effectConditionId = conditionId("effect/v1"); -const effectRuntimeName = "effect"; +const fixtureRuntimeName = "effect"; const instant = DateTime.unsafeMake(0); class DeliberateExecutionFailure extends Schema.TaggedError()( @@ -70,6 +74,21 @@ function casePlan(id: string): EvaluationCasePlan { }); } +// Every field but the artifact directory is fixed, so a resume mismatch test can +// vary that one field and still submit an otherwise identical plan. +function localInfrastructure( + artifactDirectory: string, +): LocalEvaluationInfrastructure { + return LocalEvaluationInfrastructure.make({ + profile: "local", + controllerImage: testImage(`controller@sha256:${"a".repeat(64)}`), + peerApplicationImage: testImage(`peer@sha256:${"b".repeat(64)}`), + nanoclawApplicationImage: testImage(`nanoclaw@sha256:${"c".repeat(64)}`), + temporalAddress: "127.0.0.1:7233", + artifactDirectory, + }); +} + function plan( first: EvaluationCasePlan, ...remaining: readonly EvaluationCasePlan[] @@ -80,7 +99,7 @@ function plan( conditions: [ EvaluationConditionPlan.make({ id: effectConditionId, - runtimeName: effectRuntimeName, + runtimeName: fixtureRuntimeName, runtimeConfiguration: { mode: "deterministic" }, }), ], @@ -94,6 +113,7 @@ function plan( timeoutMillis: 1_000, maxRetries: 2, }), + infrastructure: localInfrastructure("/var/lib/moltzap/artifacts"), samplesPerCell: 1, }); } @@ -343,11 +363,12 @@ function resumeMismatchTest() { conditions: [ EvaluationConditionPlan.make({ id: effectConditionId, - runtimeName: effectRuntimeName, + runtimeName: fixtureRuntimeName, runtimeConfiguration: { mode: "changed" }, }), ], judgePolicy: reportPlan.judgePolicy, + infrastructure: reportPlan.infrastructure, samplesPerCell: reportPlan.samplesPerCell, }); const mismatch = yield* resumeStoredEvaluationReport(changedPlan).pipe( @@ -360,6 +381,33 @@ function resumeMismatchTest() { }).pipe(Effect.provide(NodeContext.layer)); } +function infrastructureResumeMismatchTest() { + return Effect.gen(function* () { + const fixture = yield* resultFixture("moltzap-evals-infrastructure-"); + const reportPlan = plan(casePlan("EVAL-005")); + yield* Effect.gen(function* () { + yield* createStoredEvaluationReport( + reportId("infrastructure-mismatch-test"), + reportPlan, + ); + const changedPlan = EvaluationReportPlan.make({ + sourceRevision: reportPlan.sourceRevision, + cases: reportPlan.cases, + conditions: reportPlan.conditions, + judgePolicy: reportPlan.judgePolicy, + infrastructure: localInfrastructure("/var/lib/moltzap/other-artifacts"), + samplesPerCell: reportPlan.samplesPerCell, + }); + const mismatch = yield* resumeStoredEvaluationReport(changedPlan).pipe( + Effect.flip, + ); + + assert.instanceOf(mismatch, EvaluationResumeMismatch); + assert.strictEqual(mismatch.field, "infrastructure"); + }).pipe(Effect.provide(evaluationResultStoreLayer(fixture.databasePath))); + }).pipe(Effect.provide(NodeContext.layer)); +} + function uncommittedCallbackTest( prefix: string, callback: ( @@ -400,6 +448,10 @@ describe("evaluation result storage", () => { "rejects a resume when immutable runtime configuration changed", resumeMismatchTest, ); + it( + "rejects a resume when the selected infrastructure changed", + infrastructureResumeMismatchTest, + ); it("rolls back a typed callback failure", () => uncommittedCallbackTest("callback-failure-", () => Effect.fail( diff --git a/packages/evals/src/results.ts b/packages/evals/src/results.ts index 27967d4c2..56a083d3f 100644 --- a/packages/evals/src/results.ts +++ b/packages/evals/src/results.ts @@ -34,7 +34,7 @@ import { type TerminalAttempt as TerminalAttemptType, } from "./sweep.js"; -const REPORT_FORMAT_VERSION = 2; +const REPORT_FORMAT_VERSION = 3; const RESULT_DIRECTORY_MODE = 0o700; const RESULT_FILE_MODE = 0o600; const EMPTY_DATABASE = new Uint8Array(); diff --git a/packages/evals/src/submission.test.ts b/packages/evals/src/submission.test.ts new file mode 100644 index 000000000..2f2f7c289 --- /dev/null +++ b/packages/evals/src/submission.test.ts @@ -0,0 +1,103 @@ +import { assert, effect, it } from "@effect/vitest"; +import { FileSystem } from "@effect/platform"; +import { NodeContext } from "@effect/platform-node"; +import { join, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Effect, Schema } from "effect"; +import type { SimulatorDefinitionId } from "@moltzap/simulator"; +import { image } from "@moltzap/simulator/agents"; +import { + decodeEvaluationCaseId, + decodeEvaluationConditionId, + type EvaluationConditionName, +} from "./model.js"; +import { + evaluationControllerModule, + simulatorProfileEntrypoint, + type SimulatorProfile, + type SubmitEvaluationCellInput, +} from "./submission.js"; + +const decodeImage = Schema.decodeSync(image); +const PEER_IMAGE = decodeImage( + "registry.example/moltzap-support@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", +); +const NANOCLAW_IMAGE = decodeImage( + "registry.example/nanoclaw-application@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", +); +const DEFINITION_ID = "moltzap.eval-006/v4" satisfies SimulatorDefinitionId; + +function input(condition: EvaluationConditionName): SubmitEvaluationCellInput { + return { + workspaceRoot: "/workspace/moltzap", + profile: "local", + caseId: decodeEvaluationCaseId("EVAL-006"), + definitionId: DEFINITION_ID, + attemptId: "eval-006-nanoclaw-1", + condition: { + id: decodeEvaluationConditionId(condition), + modelId: condition === "openclaw/v2" ? "openai/gpt-5" : "claude/test", + }, + peerApplicationImage: PEER_IMAGE, + nanoclawApplicationImage: NANOCLAW_IMAGE, + runtimeStartupTimeoutMillis: 300_000, + peerObservationTimeoutMillis: 300_000, + caseTimeoutMillis: 1_200_000, + }; +} + +it("binds distinct peer and NanoClaw images into one NanoClaw cell module", () => { + const source = evaluationControllerModule(input("nanoclaw/v2")); + + assert.include(source, `applicationImage: ${JSON.stringify(NANOCLAW_IMAGE)}`); + assert.include(source, `peerApplicationImage: ${JSON.stringify(PEER_IMAGE)}`); + assert.include( + source, + `definition.definitionId !== ${JSON.stringify(DEFINITION_ID)}`, + ); + assert.include( + source, + 'from "/opt/moltzap/node_modules/@moltzap/evals/dist/execution.js"', + ); + assert.notInclude(source, `applicationImage: ${JSON.stringify(PEER_IMAGE)}`); +}); + +it("does not inject the unused NanoClaw application image into an OpenClaw cell", () => { + const source = evaluationControllerModule(input("openclaw/v2")); + + assert.include(source, "openClawEvaluationCondition({ runtime:"); + assert.include(source, `peerApplicationImage: ${JSON.stringify(PEER_IMAGE)}`); + assert.notInclude(source, NANOCLAW_IMAGE); +}); + +effect.each(["local", "gke"] as const)( + "spawns the %s profile executable the simulator package actually ships", + (profile: SimulatorProfile) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const simulatorRoot = fileURLToPath( + new URL("../../simulator", import.meta.url), + ); + const entrypoint = join( + simulatorRoot, + ...simulatorProfileEntrypoint(profile), + ); + + // The submitter spawns this file by path, so no import checks the + // spelling. Pin it against the source module the build compiles it from, + // and against the same path in the simulator's own scripts, so a rename + // cannot move one and leave the other naming a file that never appears. + const source = entrypoint + .replace(`${sep}dist${sep}`, `${sep}src${sep}`) + .replace(/\.js$/u, ".ts"); + assert.isTrue( + yield* fileSystem.exists(source), + `no source module compiles to ${entrypoint}`, + ); + + const scripts = yield* fileSystem.readFileString( + join(simulatorRoot, "package.json"), + ); + assert.include(scripts, simulatorProfileEntrypoint(profile).join("/")); + }).pipe(Effect.provide(NodeContext.layer)), +); diff --git a/packages/evals/src/submission.ts b/packages/evals/src/submission.ts new file mode 100644 index 000000000..4599901b9 --- /dev/null +++ b/packages/evals/src/submission.ts @@ -0,0 +1,223 @@ +/** @file Repository-local Kubernetes submission for one generated evaluation cell. */ + +import { Command, FileSystem, Path } from "@effect/platform"; +import { + CompletedLedgerReceipt, + LedgerReceipt, + type SimulatorDefinitionId, +} from "@moltzap/simulator"; +import type { Image } from "@moltzap/simulator/agents"; +import { Effect, Either, Schema } from "effect"; +import type { + EvaluationCaseId, + EvaluationConditionId, + EvaluationConditionName, +} from "./model.js"; + +/** Repository-owned Kubernetes profile selected for an evaluation sweep. */ +export type SimulatorProfile = "local" | "gke"; + +/** + * Path segments, below the simulator package root, of a profile's executable. + * + * The submitter spawns this file by path rather than importing it, so nothing + * typechecks the spelling. It is exported so a drift canary can compare it + * against the same path in the simulator's own package scripts. + * + * @param profile Kubernetes profile whose executable is being located. + * @returns Segments to join onto `packages/simulator`. + */ +export function simulatorProfileEntrypoint( + profile: SimulatorProfile, +): readonly string[] { + return ["dist", "cluster", "profiles", `${profile}.js`]; +} + +const programFinishedSummary = Schema.Struct({ + _tag: Schema.Literal("ProgramFinished"), + receipt: CompletedLedgerReceipt, +}); +const runInfrastructureFailedSummary = Schema.Struct({ + _tag: Schema.Literal("ClusterLost"), + receipt: LedgerReceipt, +}); +const ledgerAllocationFailedSummary = Schema.Struct({ + _tag: Schema.Literal("LedgerAllocationFailed"), +}); +const evaluationSubmissionResult = Schema.Struct({ + runId: Schema.NonEmptyString, + namespace: Schema.NonEmptyString, + result: Schema.Union( + Schema.Struct({ + exitCode: Schema.Literal(0), + summary: programFinishedSummary, + }), + Schema.Struct({ + exitCode: Schema.Literal(1), + summary: Schema.Union( + runInfrastructureFailedSummary, + ledgerAllocationFailedSummary, + ), + }), + ), +}); +/** Decoded result printed by the simulator's local or GKE submitter. */ +export type EvaluationSubmissionResult = typeof evaluationSubmissionResult.Type; + +/** A repository-local cell could not be submitted or decoded. */ +export class EvaluationSubmissionFailed extends Schema.TaggedError()( + "EvaluationSubmissionFailed", + { + stage: Schema.Literal("module", "command", "result"), + detail: Schema.NonEmptyString, + }, +) {} + +interface SubmissionCondition { + readonly id: EvaluationConditionId; + readonly modelId: string; +} + +/** Complete host facts used to generate and submit one controller module. */ +export interface SubmitEvaluationCellInput { + readonly workspaceRoot: string; + readonly profile: SimulatorProfile; + readonly caseId: EvaluationCaseId; + readonly definitionId: SimulatorDefinitionId; + readonly attemptId: string; + readonly condition: SubmissionCondition; + readonly peerApplicationImage: Image; + readonly nanoclawApplicationImage: Image; + readonly runtimeStartupTimeoutMillis: number; + readonly peerObservationTimeoutMillis: number; + readonly caseTimeoutMillis: number; +} + +function literal(value: string): string { + return Schema.encodeSync(Schema.parseJson(Schema.String))(value); +} + +function conditionExpression(input: SubmitEvaluationCellInput): string { + const shared = [ + `startupTimeout: Duration.millis(${String(input.runtimeStartupTimeoutMillis)})`, + `modelId: ${literal(input.condition.modelId)}`, + ]; + const execution = [ + `peerObservationTimeout: Duration.millis(${String(input.peerObservationTimeoutMillis)})`, + `caseTimeout: Duration.millis(${String(input.caseTimeoutMillis)})`, + ]; + // Total over the conditions that exist, so the generated module never has to + // carry a throw for a condition the caller could not have named. + const byCondition: Readonly> = { + "openclaw/v2": `openClawEvaluationCondition({ runtime: { ${shared.join(", ")} }, execution: { ${execution.join(", ")} } })`, + "nanoclaw/v2": `nanoclawEvaluationCondition({ runtime: { ${shared.join(", ")}, applicationImage: ${literal(input.nanoclawApplicationImage)}, autoRegisterConversations: true }, execution: { ${execution.join(", ")} } })`, + }; + // Indexing needs the plain spelling; the brand is not part of the key set. + const condition: EvaluationConditionName = input.condition.id; + return byCondition[condition]; +} + +/** + * Render the only module source admitted by the evaluation submitter. + * @param input Exact case, condition, image, and timeout bindings. + * @returns A closed ESM module exporting one cell RunSpec. + */ +export function evaluationControllerModule( + input: SubmitEvaluationCellInput, +): string { + const condition = conditionExpression(input); + return [ + 'import { Duration } from "effect";', + 'import { evaluationCase } from "/opt/moltzap/node_modules/@moltzap/evals/dist/cases.js";', + 'import { evaluationCellRunSpec, nanoclawEvaluationCondition, openClawEvaluationCondition } from "/opt/moltzap/node_modules/@moltzap/evals/dist/execution.js";', + 'import { controllerServicesFromEnvironment } from "/opt/moltzap/dist/cluster/controller/services.js";', + `const definition = evaluationCase(${literal(input.caseId)});`, + `if (definition === undefined || definition.definitionId !== ${literal(input.definitionId)}) throw new Error("evaluation case definition is unavailable");`, + `const condition = ${condition};`, + "export const runSpec = evaluationCellRunSpec({", + " definition,", + " condition,", + ` attemptId: ${literal(input.attemptId)},`, + ` peerApplicationImage: ${literal(input.peerApplicationImage)},`, + " cluster: controllerServicesFromEnvironment(),", + "});", + "", + ].join("\n"); +} + +function commandFailure(cause: unknown): EvaluationSubmissionFailed { + return EvaluationSubmissionFailed.make({ + stage: "command", + detail: String(cause).trim() || "simulator submitter failed", + }); +} + +function decodeSubmissionOutput( + output: string, +): Effect.Effect { + const lines = output.split(/\r?\n/u); + for (let index = lines.length - 1; index >= 0; index -= 1) { + const line = lines[index]?.trim(); + if (line === undefined || line.length === 0) { + continue; + } + const decoded = Schema.decodeUnknownEither( + Schema.parseJson(evaluationSubmissionResult), + )(line, { onExcessProperty: "error" }); + const result = Either.getOrUndefined(decoded); + if (result !== undefined) { + return Effect.succeed(result); + } + } + return Effect.fail( + EvaluationSubmissionFailed.make({ + stage: "result", + detail: "simulator submitter printed no valid final result", + }), + ); +} + +/** + * Submit one generated module through the existing simulator local/GKE CLI. + * @param input Exact generated-cell submission facts. + * @returns The decoded coarse result and run namespace. + */ +export function submitEvaluationCell(input: SubmitEvaluationCellInput) { + return Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "moltzap-eval-cell-", + }); + const modulePath = path.join(directory, "main.mjs"); + const source = yield* Effect.try({ + try: () => evaluationControllerModule(input), + catch: (cause) => + EvaluationSubmissionFailed.make({ + stage: "module", + detail: + String(cause).trim() || "controller module generation failed", + }), + }); + yield* fileSystem.writeFileString(modulePath, source); + const simulatorRoot = path.join( + input.workspaceRoot, + "packages", + "simulator", + ); + const entrypoint = path.join( + simulatorRoot, + ...simulatorProfileEntrypoint(input.profile), + ); + const command = Command.make("node", entrypoint, modulePath).pipe( + Command.workingDirectory(simulatorRoot), + Command.stderr("inherit"), + ); + const output = yield* Command.string(command).pipe( + Effect.mapError(commandFailure), + ); + return yield* decodeSubmissionOutput(output); + }), + ).pipe(Effect.withSpan("submitEvaluationCell")); +} diff --git a/packages/evals/src/sweep.test.ts b/packages/evals/src/sweep.test.ts index ab7ca4a1d..f7fc7032f 100644 --- a/packages/evals/src/sweep.test.ts +++ b/packages/evals/src/sweep.test.ts @@ -2,6 +2,7 @@ import { assert, it as effectIt } from "@effect/vitest"; import { agentName } from "@moltzap/protocol/identity"; import { agentId } from "@moltzap/protocol/testing"; import { CompletedLedgerReceipt } from "@moltzap/simulator"; +import { image } from "@moltzap/simulator/agents"; import { LedgerCompletion, LedgerStorageError, @@ -37,6 +38,7 @@ import { EvaluationSweepIncomplete, InProgressEvaluationReport, JudgePolicySnapshot, + LocalEvaluationInfrastructure, JudgingUnavailableAttempt, LedgerAllocationFailedAttempt, RunFailedAttempt, @@ -58,6 +60,8 @@ import { type TerminalAttempt as TerminalAttemptType, } from "./sweep.js"; +const testImage = Schema.decodeSync(image); + const it = effectIt.scoped; const instant = DateTime.unsafeMake(0); const manifestDigest = Schema.decodeSync(ledgerDigest)("a".repeat(64)); @@ -106,6 +110,14 @@ function plan( timeoutMillis: 1_000, maxRetries: 2, }), + infrastructure: LocalEvaluationInfrastructure.make({ + profile: "local", + controllerImage: testImage(`controller@sha256:${"a".repeat(64)}`), + peerApplicationImage: testImage(`peer@sha256:${"b".repeat(64)}`), + nanoclawApplicationImage: testImage(`nanoclaw@sha256:${"c".repeat(64)}`), + temporalAddress: "127.0.0.1:7233", + artifactDirectory: "/var/lib/moltzap/artifacts", + }), samplesPerCell: 1, }); } @@ -428,6 +440,7 @@ function exactResumePlanTest() { }), ], judgePolicy: reportPlan.judgePolicy, + infrastructure: reportPlan.infrastructure, samplesPerCell: reportPlan.samplesPerCell, }); const mismatch = yield* resumeEvaluationReport(report, changedPlan).pipe( diff --git a/packages/evals/src/sweep.ts b/packages/evals/src/sweep.ts index f7b08e5d9..7c8afe540 100644 --- a/packages/evals/src/sweep.ts +++ b/packages/evals/src/sweep.ts @@ -1,6 +1,7 @@ /** @file Typed evaluation plans, attempts, reports, and state transitions. */ import { CompletedLedgerReceipt, LedgerReceipt } from "@moltzap/simulator"; +import { image } from "@moltzap/simulator/agents"; import { jsonValue, LedgerStorageError, @@ -25,7 +26,7 @@ import { type CriterionAssessment, } from "./grading.js"; -const REPORT_FORMAT_VERSION = 2; +const REPORT_FORMAT_VERSION = 3; const SAMPLE_NUMBER = 1; const positiveInteger = Schema.Int.pipe(Schema.positive()); @@ -111,6 +112,41 @@ export class JudgePolicySnapshot extends Schema.Class( maxRetries: Schema.Literal(2), }) {} +/** Non-secret physical environment retained so a resume cannot move a sweep. */ +export class LocalEvaluationInfrastructure extends Schema.TaggedClass()( + "LocalEvaluationInfrastructure", + { + profile: Schema.Literal("local"), + controllerImage: image, + peerApplicationImage: image, + nanoclawApplicationImage: image, + temporalAddress: Schema.NonEmptyString, + artifactDirectory: Schema.NonEmptyString, + }, +) {} + +/** Non-secret physical environment retained so a resume cannot move a sweep. */ +export class GkeEvaluationInfrastructure extends Schema.TaggedClass()( + "GkeEvaluationInfrastructure", + { + profile: Schema.Literal("gke"), + controllerImage: image, + peerApplicationImage: image, + nanoclawApplicationImage: image, + temporalAddress: Schema.NonEmptyString, + kubeContext: Schema.NonEmptyString, + artifactBucket: Schema.NonEmptyString, + }, +) {} + +const evaluationInfrastructure = Schema.Union( + LocalEvaluationInfrastructure, + GkeEvaluationInfrastructure, +); + +/** Exact non-secret target selected for each submitted evaluation cell. */ +export type EvaluationInfrastructure = typeof evaluationInfrastructure.Type; + /** Ordered matrix and all inputs that must match before resume. */ export class EvaluationReportPlan extends Schema.Class( "EvaluationReportPlan", @@ -119,6 +155,7 @@ export class EvaluationReportPlan extends Schema.Class( cases: Schema.NonEmptyArray(EvaluationCasePlan), conditions: Schema.NonEmptyArray(EvaluationConditionPlan), judgePolicy: JudgePolicySnapshot, + infrastructure: evaluationInfrastructure, samplesPerCell: Schema.Literal(SAMPLE_NUMBER), }) {} @@ -324,6 +361,7 @@ const resumeMismatchField = Schema.Literal( "caseCatalog", "judgePolicy", "runtimeConfigurations", + "infrastructure", "planDigest", ); /** Immutable plan component reported by a resume mismatch. */ @@ -912,6 +950,12 @@ export const resumeEvaluationReport = Effect.fn("evals.resumeEvaluationReport")( report.plan.conditions, expectedPlan.conditions, ); + yield* matchPlanComponent( + "infrastructure", + evaluationInfrastructure, + report.plan.infrastructure, + expectedPlan.infrastructure, + ); const expectedDigest = yield* digestEvaluationPlan(expectedPlan); if (report.planDigest !== expectedDigest) { return yield* Effect.fail( diff --git a/packages/evals/src/transcript.ts b/packages/evals/src/transcript.ts index 33df03795..5afdfb791 100644 --- a/packages/evals/src/transcript.ts +++ b/packages/evals/src/transcript.ts @@ -6,7 +6,7 @@ import { type MessageParts, messagePartsSchema, } from "@moltzap/protocol/message"; -import { OpenClawGatewayTimedOut } from "@moltzap/simulator/runtime"; +import { OpenClawGatewayTimedOut } from "@moltzap/simulator/agents"; import { Effect, Schema } from "effect"; import { TARGET_AGENT_NAME, type EvaluationCaseMetadata } from "./cases.js"; import { @@ -16,8 +16,8 @@ import { EvaluationEvidenceProjectionError, type EvaluationEvidenceLedger, type GatewayEvidence, - NanoclawPrincipalInputSent, - NanoclawPrincipalOutputReceived, + NanoClawPrincipalInputSent, + NanoClawPrincipalOutputReceived, OpenClawPrincipalFinalOutput, OpenClawPrincipalInstructionAttempted, type PeerTimeoutEvidence, @@ -224,7 +224,7 @@ function gatewayParts( "[OpenClaw returned no principal output]", ); } - if (observation instanceof NanoclawPrincipalInputSent) { + if (observation instanceof NanoClawPrincipalInputSent) { return textParts( observation.input.text, "[Empty NanoClaw principal input]", @@ -240,10 +240,10 @@ function isGatewayOutput( observation: GatewayEvidence["observation"], ): observation is | OpenClawPrincipalFinalOutput - | NanoclawPrincipalOutputReceived { + | NanoClawPrincipalOutputReceived { return ( observation instanceof OpenClawPrincipalFinalOutput || - observation instanceof NanoclawPrincipalOutputReceived + observation instanceof NanoClawPrincipalOutputReceived ); } diff --git a/packages/nanoclaw-channel/AGENTS.md b/packages/nanoclaw-channel/AGENTS.md index 4c788e53c..603a4d0f5 100644 --- a/packages/nanoclaw-channel/AGENTS.md +++ b/packages/nanoclaw-channel/AGENTS.md @@ -13,11 +13,11 @@ channel plugins. self-registers via `registerChannelAdapter`. - `src/channels/adapter.ts`, `src/channels/channel-registry.ts`, `src/db/messaging-groups.ts`, `src/types.ts` — stub mirrors of the - nanoclaw modules the channel imports, pinned to the commit in `NANOCLAW_SHA` - (`packages/simulator/src/runtime/nanoclaw/install.ts`). Inside a real nanoclaw - checkout the same relative imports resolve against nanoclaw's own - modules; the messaging-group stub is an in-memory map so unit tests - can observe eval-mode conversation wiring. + NanoClaw modules the channel imports. Keep them aligned with the + digest-pinned NanoClaw application image used by simulator runs. Inside a + real NanoClaw checkout the same relative imports resolve against NanoClaw's + own modules; the messaging-group stub is an in-memory map so unit tests can + observe eval-mode conversation wiring. ## Concepts diff --git a/packages/nanoclaw-channel/src/channels/adapter.ts b/packages/nanoclaw-channel/src/channels/adapter.ts index 4eb0ea62f..c659a3ca8 100644 --- a/packages/nanoclaw-channel/src/channels/adapter.ts +++ b/packages/nanoclaw-channel/src/channels/adapter.ts @@ -2,9 +2,8 @@ // Stub types matching the subset of nanoclaw's src/channels/adapter.ts that // moltzap.ts touches. When moltzap.ts is copied into a real nanoclaw // checkout, these imports resolve against nanoclaw's own adapter module -// (same signatures). Mirrors the surface at the commit pinned by -// NANOCLAW_SHA in packages/simulator/src/runtime/nanoclaw/install.ts; keep aligned -// when bumping that pin. +// (same signatures). Keep this mirrored surface aligned with the digest-pinned +// NanoClaw application image used by simulator runs. /** Describes channel setup. */ export interface ChannelSetup { diff --git a/packages/nanoclaw-channel/src/types.ts b/packages/nanoclaw-channel/src/types.ts index d19d2686f..5abf62014 100644 --- a/packages/nanoclaw-channel/src/types.ts +++ b/packages/nanoclaw-channel/src/types.ts @@ -3,9 +3,8 @@ // checkout, these imports resolve against nanoclaw's own src/types.ts // (same signatures). // -// Mirrors the surface at the commit pinned by NANOCLAW_SHA in -// packages/simulator/src/runtime/nanoclaw/install.ts; keep these stubs aligned when -// bumping that pin. +// Keep this mirrored surface aligned with the digest-pinned NanoClaw +// application image used by simulator runs. type EngageMode = "pattern" | "mention" | "mention-sticky"; type SenderScope = "all" | "known"; diff --git a/packages/openclaw-channel/package.json b/packages/openclaw-channel/package.json index 62de1c8b4..42b17c80b 100644 --- a/packages/openclaw-channel/package.json +++ b/packages/openclaw-channel/package.json @@ -76,6 +76,11 @@ "peerDependencies": { "openclaw": ">=2026.0.0" }, + "peerDependenciesMeta": { + "openclaw": { + "optional": true + } + }, "openclaw": { "extensions": [ "./dist/openclaw-entry.js" diff --git a/packages/protocol/scripts/docs/__tests__/module-exports.test.ts b/packages/protocol/scripts/docs/__tests__/module-exports.test.ts index 1b8cf5cd1..c3d50df94 100644 --- a/packages/protocol/scripts/docs/__tests__/module-exports.test.ts +++ b/packages/protocol/scripts/docs/__tests__/module-exports.test.ts @@ -46,9 +46,9 @@ describe("exportsForModuleFolder", () => { ); const nested = exported( 2, - "effectRuntime", + "openClawRuntime", "@moltzap/simulator", - "packages/simulator/src/runtime/effect.ts", + "packages/simulator/src/runtime/openclaw/runtime.ts", ); const privateCapability = exported( 3, @@ -77,9 +77,9 @@ describe("exportsForModuleFolder", () => { it("keeps nested module ownership scoped to the declaration folder", () => { const nested = exported( 1, - "effectRuntime", + "openClawRuntime", "@moltzap/simulator", - "packages/simulator/src/runtime/effect.ts", + "packages/simulator/src/runtime/openclaw/runtime.ts", ); const sibling = exported( 2, diff --git a/packages/simulator/AGENTS.md b/packages/simulator/AGENTS.md index e8d67b6ed..d0a2745e9 100644 --- a/packages/simulator/AGENTS.md +++ b/packages/simulator/AGENTS.md @@ -6,71 +6,104 @@ Code-first simulator for agentic societies. This package owns: -- nominal simulator definitions and keyed agent rosters; -- the exact readable event catalog and customer-only writable catalog; -- the live and completed ledger contract; -- network participant, endpoint, conversation-address, socket, and link - capabilities; -- the scoped `AgentRuntime` contract; -- the private run kernel; -- the MoltZap router host and filesystem ledger; -- Effect, OpenClaw, and NanoClaw runtime implementations; -- the process, installation, and package assets those implementations require. - -Interface, definition, event, ledger-model, network-contract, -runtime-contract, and kernel modules import only Effect and protocol -contracts. Concrete capability files may import Effect Platform, Node, Docker, -PGlite, the MoltZap client/server packages, and external agent packages. -`layer.ts` provides the concrete host service graph once at the application -edge. +- `RunSpec` definitions and `Run.execute`; +- exact keyed agent rosters and runtime-native gateways; +- the closed readable event catalog and customer-only writable catalog; +- live and completed run ledgers; +- network participant, endpoint, conversation, socket, and link capabilities; +- the private run and the private fake cluster used by tests; +- the Kubernetes, Kueue, Agent Sandbox, and Temporal integration used by that + run; and +- local-Kubernetes and GKE Effect Layers plus their setup assets. + +`packages/evals` owns cases, runtime conditions, grading, reports, resume +policy, SQLite state, and Phoenix publication. It consumes this package's one +execution path and does not implement another simulator backend. + +Keep Kubernetes, Kueue, Agent Sandbox, Temporal, Helm, Terraform, and +cloud-provider types out of public definitions, event models, network +contracts, and customer Effects. Concrete integrations stay private and are +composed at the application edge. ## Laws -- One run has one router, one ledger, one Effect `Clock` environment, and any - mixture of runtime implementations. -- Runtime acquisition returns only after readiness. Runtime exit is typed - ledger evidence; customer Effect policy decides whether it ends the run. -- Every event class is declared before the run. The definition's exact catalog - is the complete event universe for emission, selection, and typed opening. -- Core events are readable and kernel-only writable. Customer emission accepts - only the definition's customer event classes. -- Event catalogs and network handles are nominal values. -- Infrastructure writers are producer-bound capabilities; callers never pass - an emitter string. -- In-process and customer-defined code runtimes use the same protocol and - router as external processes. -- Restart, replacement, rebinding, fencing, and offline-delivery guarantees - are outside v0. -- Kernel resources are scoped Effect acquisitions. Cleanup fibers remain - children of the run scope and finish before run completion. +- One execution creates one experiment society, runs one customer Effect, and + tears the society down. It is not a reusable warm pool. +- Kubernetes is the only execution backend. Local Kubernetes and GKE are two + cluster Layers for the same controller and run path. +- One roster entry maps to one Agent Sandbox application container. + Infrastructure containers do not count as agents. +- Kueue admits capacity for the complete roster before Sandboxes are created. + Kueue admission does not establish simulator readiness. +- The customer Effect starts only after the exact roster is ready at one + cohort gate. A pre-gate backing-Pod restart delays readiness without adding a + public generation model. An unrecoverable loss or deadline fails acquisition + and starts cleanup. +- The controller invokes the customer Effect once and does not replay it. + Controller or infrastructure loss fails the run and starts cleanup; this is + not an exactly-once guarantee for external side effects. +- After dispatch, runtime termination remains typed ledger evidence. Customer + Effect policy decides whether that observation ends the run. +- Temporal owns one coarse workflow for run lifecycle and cleanup. It never + runs agent logic, appends simulator evidence, creates per-agent workflows, + or replays customer code. +- Every event class is declared before execution. The definition's catalog is + the complete event universe for emission, selection, and typed opening. +- Core events are readable and run-only writable. Customer emission accepts + only the definition's declared customer event classes. +- Event catalogs and network handles are nominal values. Infrastructure + writers are producer-bound capabilities; callers never pass emitter names. +- Principal control uses each runtime's native typed gateway. Agent social + traffic uses the production MoltZap router. Controlled endpoints remain + diagnostics and must not impersonate an autonomous agent's principal. +- A distributed runtime descriptor owns one application-container entrypoint + and one runtime-specific controller bridge. The bridge yields that runtime's + exact gateway and termination observation after readiness; arbitrary + JavaScript gateways, Effect closures, and shared state never cross the + process boundary. +- Runtime bridges may use fixed runtime-specific transports. Never add a + simulator-wide gateway proxy, command language, actor mailbox, correlation + model, or gateway union. +- Real and code-driven agents may share one society. Code agents receive no + social shortcut around the production router. Their policy runs inside + their own application container and their bridge exposes only the exact + controller-side gateway owned by that runtime. +- The stock digest-pinned OpenClaw image is the compatibility path. Experiment + code and instructions are late-bound; a prebuilt MoltZap image is only an + optimization. +- `RunSpec.cluster` carries the selected local-Kubernetes or GKE Effect Layer. + Its roster and customer Effect never receive raw Kubernetes, Sandbox, Kueue, + or Temporal objects. +- Do not add generation streams, customer-visible restart/rebind/rejoin APIs, + post-dispatch recovery guarantees, customer Effect replay, artifact + authorities, global execution identities, synthetic identity schemes, or a + new serialization framework. +- The root public execution path is `Run.execute(RunSpec)`. Do not add another + execution model or compatibility alias. ## Structure - `src/events/` — exact event catalogs and core event classes. -- `src/ledger/` — records, live ledger, storage port, opening, and filesystem +- `src/ledger/` — records, append, storage, reading, and filesystem implementation. - `src/network/` — participant, conversation, endpoint, router, transport, - link-driver, MoltZap router, server, message store, and nominal - capability-construction contracts. -- `src/runtime/` — roster, autonomous runtime contracts, and shipped runtime - implementations. -- `src/kernel/` — definition-bound event services, private acquisition, - execution, evidence, and finalization. -- `src/definition.ts` — public definition assembly. -- `src/layer.ts` — the single concrete host composition boundary. - -Only `src/index.ts`, `src/runtime.ts`, `src/network.ts`, and `src/ledger.ts` -are published facades. Programs use the root and `./runtime`; platform -implementations use `./network`; offline tooling uses `./ledger`. Do not -export kernel implementation modules. + link, and router-server-process capabilities. +- `src/agents/` — portable container runtime definitions, exact gateway + contracts, and shipped OpenClaw and NanoClaw implementations. +- `src/run/` — definition-bound services and mechanism-neutral execution + sequencing. +- `src/cluster/` — private cluster code: the smallest interface needed by the + run, its fake, and the Kubernetes/Kueue/Sandbox/Temporal implementation. +- `src/definition.ts` — public definition assembly, including `RunSpec`. + +Only `src/index.ts`, `src/agents.ts`, `src/network.ts`, and `src/ledger.ts` +are published facades. Do not add a package or public export for cluster, +controller, Temporal, Kueue, or Sandbox internals. Folders are capability boundaries, not namespaces. Keep a type with its -construction rules and merge single-consumer helpers into their owner. Do not -add compatibility barrels or preserve obsolete export names. - -Capability names form the directory vocabulary. Concrete implementations live -beside the capability they implement. Mechanism modules require Effect -Platform services; `src/layer.ts` provides their Node implementations. +construction rules and merge single-consumer helpers into their owner. Reuse +the existing EventCatalog, RunLedger, roster, gateway, and run concepts +instead of rebuilding them for Kubernetes. ## Tests diff --git a/packages/simulator/README.md b/packages/simulator/README.md index cea1a16db..70e1732ee 100644 --- a/packages/simulator/README.md +++ b/packages/simulator/README.md @@ -1,102 +1,106 @@ # @moltzap/simulator -Code-first simulation for societies whose participants communicate through one -run-scoped MoltZap router and wire protocol. A roster may mix OpenClaw, -NanoClaw, in-process Effect agents, scripted or customer-defined runtimes -without changing the kernel. +Code-first experiments over containerized agent societies. Kubernetes is the +single execution backend; the repository provides local kind and GKE profiles +for the same run path. -The package owns the complete vertical slice: typed definitions and events, -the run kernel, network capabilities, a durable ledger, the production router, -process hosting, and shipped runtime implementations. Customer completion, -sweeps, scenario languages, and graders stay ordinary code. +The package owns typed definitions and events, the run, the production +MoltZap router, exact runtime-native gateways, durable ledgers, Kueue cohort +admission, Agent Sandbox applications, and coarse Temporal lifecycle control. +Experiment code owns completion policy, scenarios, sweeps, and grading. ## Entry points | Import | Purpose | |---|---| -| `@moltzap/simulator` | Define and run societies and provide the default host Layer | -| `@moltzap/simulator/runtime` | Define autonomous runtimes and use the shipped Effect, OpenClaw, and NanoClaw implementations | -| `@moltzap/simulator/network` | Implement routers, transports, endpoints, and link behavior | -| `@moltzap/simulator/ledger` | Implement storage or inspect completed ledgers offline | +| `@moltzap/simulator` | Define a `RunSpec`, execute it, and consume customer run services | +| `@moltzap/simulator/agents` | Use container runtime descriptors and the shipped OpenClaw and NanoClaw implementations | +| `@moltzap/simulator/network` | Network, endpoint, router, transport, and link contracts | +| `@moltzap/simulator/ledger` | Completed-ledger schemas, validation, and offline readback | -```ts -import { messagesSend } from "@moltzap/protocol/message"; -import { - Network, - simulator, - simulatorLayer, -} from "@moltzap/simulator"; -import { effectRuntime } from "@moltzap/simulator/runtime"; -import { Duration, Effect, Ref, Stream } from "effect"; - -const Society = simulator.define("acme.echo/v1"); -const roster = Society.agents({ - echo: effectRuntime({ - build: (context) => - Effect.gen(function* () { - const prefix = yield* Ref.make("echo: "); - return { - // This is the exact customer-defined principal API. - gateway: Object.freeze({ - setPrefix: (value: string) => Ref.set(prefix, value), - }), - // Autonomous social behavior uses the production client and router. - behavior: context.messages.pipe( - Stream.runForEach((notification) => - Ref.get(prefix).pipe( - Effect.flatMap((value) => - context.client.callDefinition(messagesSend, { - conversationId: - notification.message.conversationId, - parts: [ - { - type: "text", - text: `${value}${context.agent.name}`, - }, - ], - }), - ), - Effect.asVoid, - ), - ), - ), - }; - }), - }), -}); +## Experiment module -const experiment = Effect.gen(function* () { - const agents = yield* roster.startedAgents; - const network = yield* Network; - yield* agents.echo.gateway.setPrefix("diagnostic reply: "); +A controller-loadable module exports exactly one named `runSpec`: - const workload = yield* network.endpoint("diagnostics"); - const conversation = yield* workload.open(agents.echo.agent); - yield* conversation.send("hello"); - return yield* conversation.receive(); +```ts +import { RunSpec } from "@moltzap/simulator"; +import { openClawRuntime } from "@moltzap/simulator/agents"; +import { Effect } from "effect"; +import { controllerServicesFromEnvironment } from "/opt/moltzap/dist/cluster/controller/services.js"; + +const alice = openClawRuntime({ + tools: { deny: ["*"], exec: { mode: "deny" } }, + sandbox: { mode: "off" }, + workspaceFiles: [ + { relativePath: "IDENTITY.md", content: "You are Alice." }, + ], }); -const Host = simulatorLayer({ - ledgerDirectory: "./ledgers", - router: { startupTimeout: Duration.minutes(2) }, +export const runSpec = RunSpec.define({ + id: "acme.echo/v1", + events: [], + agents: { alice }, + cluster: controllerServicesFromEnvironment(), + execute: ({ agents, network }) => + Effect.gen(function* () { + const diagnostic = yield* network.endpoint("diagnostic"); + const conversation = yield* diagnostic.open(agents.alice.agent); + yield* conversation.send("hello"); + }), }); +``` + +The absolute cluster-services import is private to the repository-built +controller image. It keeps Kubernetes, Kueue, Sandbox, Temporal, and +cloud-provider values outside the public experiment contract. The controller +loads the module late and invokes `Run.execute(runSpec)` once. + +Each started agent exposes three distinct capabilities: + +- `.agent` is the router-issued social identity; +- `.gateway` is that runtime's exact principal interface; and +- `.termination` observes autonomous runtime completion. + +Diagnostic endpoints do not impersonate roster principals. Every autonomous +agent sends social traffic through its own MoltZap connection. -void Effect.runPromise( - Society.run(roster, experiment).pipe(Effect.provide(Host)), -); +NanoClaw requires an explicit digest-pinned application image implementing its +fixed one-container bootstrap and gateway contract. The simulator never +substitutes a mutable or placeholder image. + +## Local and GKE profiles + +Build the shared controller/support image and create the pinned local profile: + +```bash +pnpm nx run @moltzap/simulator:local-controller-image +pnpm nx run @moltzap/simulator:local-cluster-create -- \ + --image CONTROLLER_IMAGE_AT_SHA256 +``` + +Submit a module through Temporal and the local Kubernetes path: + +```bash +MOLTZAP_CONTROLLER_IMAGE=CONTROLLER_IMAGE_AT_SHA256 \ +MOLTZAP_TEMPORAL_ADDRESS=127.0.0.1:7233 \ +pnpm nx run @moltzap/simulator:local-run -- path/to/experiment.mjs ``` -Each roster value is a `StartedAgent`: `.agent` is its router-issued network -identity, `.gateway` is the runtime's exact owner-local principal API, and -`.termination` observes runtime completion. OpenClaw and NanoClaw expose their -native gateways; `effectRuntime` exposes exactly the gateway returned by its -`build` Effect. `Network.endpoint` is for experiment-controlled diagnostics, -workloads, and observers, not for replacing those principal APIs. +The GKE profile uses the same experiment and controller contract with an +explicit kube context, artifact bucket, and configured Temporal endpoint. See +[`local/README.md`](local/README.md) and [`gke/README.md`](gke/README.md). -Every event class is declared through the society definition before the run. -The kernel emits its own typed network and lifecycle events; customer code can -emit only its declared event classes. A successful run returns the customer -program `Exit` and a validated reference to a completed durable ledger. +## Static validation + +```bash +pnpm nx run @moltzap/simulator:build +pnpm nx run @moltzap/simulator:typecheck:tests +pnpm nx run @moltzap/simulator:lint +pnpm nx run @moltzap/simulator:test +pnpm nx run @moltzap/simulator:arch:check +pnpm nx run @moltzap/simulator:local-profile-check +pnpm nx run @moltzap/simulator:gke-profile-check +``` -Customer code owns completion policy, domain-specific scenario languages, -parameter sweeps, and grading. +These checks do not qualify a live cluster or publish the required NanoClaw +application image. diff --git a/packages/simulator/eslint.config.mjs b/packages/simulator/eslint.config.mjs index edaeded54..1c7279d7a 100644 --- a/packages/simulator/eslint.config.mjs +++ b/packages/simulator/eslint.config.mjs @@ -1,8 +1,65 @@ import { packageEslintConfig } from "../../eslint.shared.mjs"; +// The simulator's cluster is one implementation of a mechanism-neutral port, and +// the ADR anticipates a different scheduler behind the same boundary. That stays +// true only while the vendor SDKs are confined to their adapters: every other +// module has to be swappable without touching a Kubernetes or Temporal type. +const SOURCE = ["src/**/*.ts", "src/**/*.cts", "src/**/*.mts"]; +const KUBERNETES_ADAPTER = "src/cluster/kubernetes/*.ts"; +const TEMPORAL_ADAPTER = "src/cluster/temporal.ts"; +const TEMPORAL_WORKFLOW = "src/cluster/reclaim.ts"; +const LIVE_CLUSTER_SUITES = "src/**/*.cluster.test.ts"; + +const noKubernetes = { + group: ["@kubernetes/*"], + message: `Kubernetes objects and API calls belong in ${KUBERNETES_ADAPTER}; consume the typed helpers it exports.`, +}; +const noTemporal = { + group: ["@temporalio/*"], + message: `Temporal clients, workers, and activities belong in ${TEMPORAL_ADAPTER}; consume the typed helpers it exports.`, +}; +// `group` is matched gitignore-style, so a trailing `!` entry re-permits one +// package. Extglobs and brace expansion are silently ignored here and would +// leave the whole vendor unrestricted. +const noTemporalBesidesWorkflow = { + group: ["@temporalio/*", "!@temporalio/workflow"], + message: `Temporal clients, workers, and activities belong in ${TEMPORAL_ADAPTER}; only the workflow surface may appear here.`, +}; + +// One rule name cannot be spread across config objects: a later object replaces +// the earlier one's options rather than merging with them. So each class of file +// below restates its position on *both* vendors, and a carve-out for one SDK can +// never silently widen access to the other. +const vendorSdks = (files, patterns) => ({ + files, + rules: { "no-restricted-imports": ["error", { patterns }] }, +}); + export default [ { ignores: ["nanoclaw-assets/**"], }, ...packageEslintConfig({ tsconfigRootDir: import.meta.dirname }), + + // Every module reaches both vendors through an adapter. + vendorSdks(SOURCE, [noKubernetes, noTemporal]), + + // The two adapters. Each owns exactly one vendor and is still held to the + // boundary on the other. + vendorSdks([KUBERNETES_ADAPTER], [noTemporal]), + vendorSdks([TEMPORAL_ADAPTER], [noKubernetes]), + + // A Temporal workflow is defined by importing @temporalio/workflow: the SDK + // bundles this module into its deterministic sandbox, and proxyActivities and + // CancellationScope are the only way to declare activity stubs and a cleanup + // scope that survives cancellation. Reaching them through the adapter instead + // would pull that adapter's worker, client, Node, and Kubernetes surfaces into + // the sandbox bundle, which is what the sandbox exists to forbid. The carve-out + // is the workflow surface alone; the client and worker SDKs stay out. + vendorSdks([TEMPORAL_WORKFLOW], [noKubernetes, noTemporalBesidesWorkflow]), + + // Live-cluster suites observe a real cluster through a client the code under + // test does not own. Routing that observer through the adapter it exists to + // validate would make the assertion hold whether or not the adapter works. + vendorSdks([LIVE_CLUSTER_SUITES], [noTemporal]), ]; diff --git a/packages/simulator/gke/README.md b/packages/simulator/gke/README.md new file mode 100644 index 000000000..637cf462c --- /dev/null +++ b/packages/simulator/gke/README.md @@ -0,0 +1,170 @@ +# GKE simulator qualification profile + +This is the cloud profile for the same Kubernetes execution path used by the +local simulator. It creates a zonal GKE Standard cluster, a resident system +pool, an agent pool that autoscales from zero, an Artifact Registry repository, +and retained ledger storage. It installs exact Kueue and Agent Sandbox +releases with Helm and adds the profile-scoped `ClusterQueue/moltzap`. + +This profile is experiment infrastructure. It does not select production +Temporal hosting, warm pools, multi-run policy, or a secrets and recovery +platform. + +## Operating the cluster + +`cluster.sh` covers the whole lifecycle. The verbs are split by what each one +costs, because creating the cluster is slow and keeping nodes is expensive: + +| command | does | time | +| --- | --- | --- | +| `./cluster.sh setup` | create the substrate and install the add-ons | ~12 min, once | +| `./cluster.sh up` | bring the controller online | ~2 min | +| `./cluster.sh down` | park the controller | ~1 min | +| `./cluster.sh delete` | destroy the substrate | ~8 min | + +Agent nodes are not managed by any of these. That pool autoscales from zero: +Kueue admits a cohort, its pods go pending, and the autoscaler provisions nodes +to satisfy them, then reclaims them once the pool is idle. Between runs the +agent pool costs nothing. + +The controller is managed, because it cannot be hosted off-cluster. Cluster +DNS, metrics, and the connectivity agents are GKE-managed and must run on a +node; Kueue and Agent Sandbox are in-cluster controllers; and the run worker +lives in the cluster so that losing the submitting process cannot strand a run. +The agent pool cannot host any of it, because its taint exists precisely to +keep everything but agents off those nodes. While the controller is parked +nothing recovers on its own, and a submission stays pending until `up`. + +`down` refuses while any Kueue `Workload` is still in flight, and `delete` +refuses while the artifact bucket holds objects, since that bucket holds run +ledgers rather than cluster state. Pass `--delete-artifacts` to discard them. + +Resident cost with the controller up is one `e2-standard-4` node plus disks; +the zonal control plane is free. Parking the controller with `down` leaves only +storage. A run adds up to `agent_max_nodes` `e2-standard-16` for its duration, +and gives them back when it ends. + +## Provisioning handoff + +Copy `terraform/terraform.tfvars.example`, set the Google Cloud project and a +globally unique artifact bucket, then run setup, which plans and prompts before +it creates anything: + +```bash +packages/simulator/gke/cluster.sh setup +``` + +Terraform owns the VPC ranges required by a VPC-native cluster, zonal GKE +Standard control plane, separate system and agent node pools, custom node +identity, Artifact Registry repository, hierarchical Cloud Storage bucket, and +bucket IAM. It enables Workload Identity Federation and the managed Cloud +Storage FUSE CSI add-on. The dedicated cluster's workload principal receives +object access only on that bucket. Nodes receive the GKE default-node role and +read-only access only to this profile's Artifact Registry repository. + +Acquire credentials with the cluster name and location outputs, then pass the +resulting explicit kube context to the add-on installer: + +```bash +packages/simulator/gke/install-addons.sh EXPLICIT_KUBE_CONTEXT +``` + +The installer never selects the current context implicitly. It installs the +official Kueue OCI chart at `0.17.8`, the Agent Sandbox chart from the exact +`v0.5.4` source commit, and the queue chart in `helm/profile`. Agent Sandbox +extensions remain disabled because the simulator creates direct `Sandbox` +objects and does not use warm pools. + +The agent pool autoscales between zero nodes and `agent_max_nodes`, which +defaults to eight `e2-standard-16`. Each agent requests 1 CPU, 1 GiB of memory, +and 1 GiB of ephemeral storage alongside a smaller support container, and CPU +exhausts first at about fourteen agents per node, so eight nodes seat the +hundred-agent cohort. + +The chart's `ClusterQueue` quota is sized against that ceiling, held below a +node's measured allocatable capacity rather than its advertised size. Kueue +admits against the quota alone, so a quota larger than the pool can deliver +produces a cohort that is admitted and then never schedulable, and the run +hangs on pending pods instead of failing. CPU is the tightest dimension. Raise +`agent_max_nodes` and the quota in `helm/profile/values.yaml` together, never +one alone. + +The profile has no Temporal deployment. Qualification supplies a test or +managed endpoint through `MOLTZAP_TEMPORAL_ADDRESS`; production hosting and +high availability remain deliberately unselected. + +## Immutable simulator image + +Push the controller/support image built by the repository to the +`controller_repository` Terraform output. Resolve the pushed manifest digest +and pass an `@sha256:<64 lowercase hex>` reference as +`MOLTZAP_CONTROLLER_IMAGE` and `MOLTZAP_SUPPORT_IMAGE`. A mutable tag is not a +valid GKE profile input. + +Select the explicit kubeconfig context and Terraform-owned bucket, then submit +the same `.mjs` RunSpec contract used by the local profile: + +```bash +MOLTZAP_KUBE_CONTEXT=EXPLICIT_KUBE_CONTEXT \ +MOLTZAP_GKE_ARTIFACT_BUCKET="$(terraform -chdir=packages/simulator/gke/terraform output -raw artifact_bucket_name)" \ +MOLTZAP_TEMPORAL_ADDRESS=TEMPORAL_HOST:7233 \ +MOLTZAP_CONTROLLER_IMAGE=REGISTRY/CONTROLLER@sha256:DIGEST \ +MOLTZAP_SUPPORT_IMAGE=REGISTRY/CONTROLLER@sha256:DIGEST \ +pnpm nx run @moltzap/simulator:gke-run -- packages/simulator/local/end-to-end.mjs +``` + +The GKE entry validates `profile.json`, requires every dynamic identity above, +and invokes the existing `runTemporalSociety` worker. It does not introduce a +second workflow or simulator backend. + +## Private platform contract + +`profile.json` is the private handoff consumed by the GKE infrastructure +Layer. It keeps platform objects outside `RunSpec` and the customer Effect and +adds only two cloud-specific projections: + +- both aggregate Workload pod sets and Sandbox pod templates receive the + dedicated agent-pool selector and toleration; and +- the controller Job mounts the Terraform-owned bucket separately from its + active POSIX ledger. + +The controller Job carries `gke-gcsfuse/volumes: "true"`, mounts the bucket at +`/var/lib/moltzap-artifacts`, and mounts a POSIX `emptyDir` at +`/var/lib/moltzap/ledger`. The simulator builds and atomically completes the +active ledger only on that POSIX volume. After it has a completed receipt, the +controller exports `manifest.json`, `records.ndjson`, and then +`completion.json` to the bucket's run-specific +`{runNamespace}/ledger/{ledgerRef}` child. Publishing the completion object +last prevents retained readback from accepting a partial export. + +The active `emptyDir` is scratch space, not a recovery guarantee. Controller +or node loss before export completes remains infrastructure failure. The bucket +mount supplies uid, gid, and modes for the non-root controller; the root +initializer changes ownership only on the active POSIX volume. + +Kueue's ResourceFlavor describes the dedicated pool, but the simulator uses a +direct aggregate `Workload` and later creates Sandboxes itself. The profile +therefore applies placement to both the capacity pod sets and actual Sandbox +pod templates; Kueue admission alone is not treated as placement or readiness. + +## Qualification + +The profile is source-complete but this repository cannot prove live GKE +qualification without a caller-authorized project with billing, API enablement, +quota, and credentials. Do not claim the ADR's GKE gate until the same +end-to-end run and one OpenClaw evaluation complete through `Run.execute`, +their ledgers are readable in the artifact bucket, and run-owned Kubernetes +residue is zero. + +Static validation does not contact Google Cloud or a Kubernetes cluster: + +```bash +pnpm nx run @moltzap/simulator:gke-profile-check +``` + +Upstream contracts used here: + +- [Kueue v0.17 installation](https://kueue.sigs.k8s.io/v0.17/docs/installation/) +- [Agent Sandbox v0.5.4](https://github.com/kubernetes-sigs/agent-sandbox/releases/tag/v0.5.4) +- [GKE Cloud Storage FUSE CSI setup](https://cloud.google.com/kubernetes-engine/docs/how-to/cloud-storage-fuse-csi-driver-setup) +- [GKE Workload Identity principal identifiers](https://cloud.google.com/iam/docs/principal-identifiers) diff --git a/packages/simulator/gke/cluster.sh b/packages/simulator/gke/cluster.sh new file mode 100755 index 000000000..dcc4ff2eb --- /dev/null +++ b/packages/simulator/gke/cluster.sh @@ -0,0 +1,232 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Lifecycle for the GKE qualification profile; see README.md. These verbs move +# the controller only. The agent pool autoscales from zero on its own. + +readonly profile_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +readonly simulator_root="$(cd "$profile_root/.." && pwd)" +readonly terraform_root="$profile_root/terraform" +readonly system_namespace="moltzap-system" + +usage() { + echo "usage: $0 (setup|up|run SPEC|down|delete) [--delete-artifacts]" >&2 + exit 64 +} + +[[ $# -ge 1 ]] || usage +readonly command="$1" +shift + +delete_artifacts=false +run_spec="" +while [[ $# -gt 0 ]]; do + case "$1" in + --delete-artifacts) delete_artifacts=true ;; + *) + [[ "$command" == "run" && -z "$run_spec" ]] || usage + run_spec="$1" + ;; + esac + shift +done + +for executable in terraform gcloud kubectl helm docker node nc; do + if ! command -v "$executable" >/dev/null 2>&1; then + echo "required executable is unavailable: $executable" >&2 + exit 69 + fi +done + +terraform_output() { + terraform -chdir="$terraform_root" output -raw "$1" +} + +registry_host() { + terraform_output controller_repository | cut -d/ -f1 +} + +attach_kubectl() { + gcloud container clusters get-credentials "$(terraform_output cluster_name)" \ + --zone "$(terraform_output cluster_location)" \ + --project "$(terraform_output project_id)" +} + +# Scaling out of band leaves the next apply trying to undo it. +set_system_nodes() { + terraform -chdir="$terraform_root" apply -input=false -auto-approve \ + -var="system_nodes=$1" +} + +absolute_path() { + echo "$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +} + +# process.stdout.write rather than console.log, which inspects and colours a +# number when FORCE_COLOR is set. +free_local_port() { + node -e ' + const server = require("node:net").createServer(); + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + server.close(() => process.stdout.write(String(port))); + }); + ' +} + +read_json_field() { + node -e ' + let input = ""; + process.stdin.on("data", (chunk) => (input += chunk)); + process.stdin.on("end", () => + process.stdout.write(String(JSON.parse(input)[process.argv[1]])), + ); + ' "$1" +} + +# The profile rejects a mutable tag, so the digest comes from the registry. +publish_controller_image() { + local repository built tag + repository="$(terraform_output controller_repository)/controller" + built="$(node "$simulator_root/scripts/build-controller-image.mjs" \ + --repository "$repository" | tail -1)" + tag="$(printf '%s' "$built" | read_json_field image)" + docker push "$tag" >/dev/null + docker inspect --format '{{index .RepoDigests 0}}' "$tag" +} + +# A fixed port would be inherited from an abandoned forward, which still accepts +# connections while proxying to a pod that no longer exists. A single forward +# also does not outlive a long run, so losing it would report a run that is +# still going as failed. +open_temporal_forward() { + local port="$1" + # errexit is inherited by the subshell, so without disabling it the first + # dropped forward would end the loop that exists to replace it. + ( + set +e + while true; do + kubectl port-forward -n "$system_namespace" svc/temporal "${port}:7233" \ + >/dev/null 2>&1 + sleep 1 + done + ) & + forward_pid=$! + # The supervisor outlives any one forward, so its liveness proves nothing. + # A parked controller has no Temporal to reach, and waiting on that forever + # is indistinguishable from working. + local attempt=0 + until nc -z localhost "$port" 2>/dev/null; do + attempt=$((attempt + 1)) + if [[ "$attempt" -ge 60 ]]; then + echo "Temporal did not accept a connection within 60s." >&2 + echo "Is the controller parked? Bring it back with '$0 up'." >&2 + exit 69 + fi + sleep 1 + done +} + +discard_artifacts() { + local bucket="$1" + # The bucket refuses to be destroyed while it holds objects, so discarding + # them is what makes the flag mean what it says. + gcloud storage rm --recursive "gs://$bucket/**" 2>/dev/null || true +} + +require_empty_artifact_bucket() { + local bucket="$1" objects + # A wildcard that matches nothing exits non-zero, which is the empty bucket + # this guard exists to wave through. + objects="$(gcloud storage ls --recursive "gs://$bucket/**" 2>/dev/null \ + | wc -l | tr -d ' ' || true)" + [[ -z "$objects" || "$objects" == "0" ]] && return 0 + echo "refusing to destroy: gs://$bucket holds $objects object(s)." >&2 + echo "Copy them out first:" >&2 + echo " gcloud storage cp --recursive 'gs://$bucket/*' ./artifacts/" >&2 + echo "or re-run with --delete-artifacts to discard them." >&2 + exit 65 +} + +case "$command" in + setup) + terraform -chdir="$terraform_root" init -input=false + terraform -chdir="$terraform_root" apply + attach_kubectl + "$profile_root/install-addons.sh" "$(kubectl config current-context)" + + # Experiment-grade Temporal, shared with the local profile. + kubectl apply -f "$simulator_root/local/temporal.yaml" + kubectl rollout status deployment/temporal -n "$system_namespace" --timeout=5m + + gcloud auth configure-docker "$(registry_host)" --quiet + echo + echo "setup complete; submit a run with '$0 run SPEC.mjs'" + ;; + + run) + [[ -n "$run_spec" ]] || usage + [[ -f "$run_spec" ]] || { echo "no such run spec: $run_spec" >&2; exit 66; } + # gke/profile.json is read from the package root, so resolve before moving. + run_spec="$(absolute_path "$run_spec")" + attach_kubectl + + controller_image="$(publish_controller_image)" + echo "controller image: $controller_image" + + forward_port="$(free_local_port)" + trap 'kill "${forward_pid:-}" 2>/dev/null; + pkill -f "port-forward -n $system_namespace svc/temporal ${forward_port}:" 2>/dev/null; + true' EXIT + open_temporal_forward "$forward_port" + + cd "$simulator_root" + MOLTZAP_KUBE_CONTEXT="$(kubectl config current-context)" \ + MOLTZAP_GKE_ARTIFACT_BUCKET="$(terraform_output artifact_bucket_name)" \ + MOLTZAP_TEMPORAL_ADDRESS="localhost:${forward_port}" \ + MOLTZAP_CONTROLLER_IMAGE="$controller_image" \ + MOLTZAP_SUPPORT_IMAGE="$controller_image" \ + node dist/cluster/profiles/gke.js "$run_spec" + ;; + + up) + set_system_nodes 1 + attach_kubectl + kubectl wait --for=condition=Ready nodes \ + -l "moltzap.dev/pool=system" --timeout=5m + # The worker is installed by a submission, carrying the image that + # submission chose, so a cluster that has never run one has no worker yet. + if kubectl get deployment/run-worker -n "$system_namespace" \ + >/dev/null 2>&1; then + kubectl rollout status deployment/run-worker \ + -n "$system_namespace" --timeout=5m + fi + echo "controller is online" + ;; + + down) + attach_kubectl + in_flight="$(kubectl get workloads.kueue.x-k8s.io --all-namespaces \ + --no-headers 2>/dev/null | wc -l | tr -d ' ')" + if [[ "$in_flight" != "0" ]]; then + echo "refusing to park the controller: $in_flight workload(s) in flight." >&2 + kubectl get workloads.kueue.x-k8s.io --all-namespaces >&2 + exit 65 + fi + set_system_nodes 0 + echo "controller is parked; the cluster and its addons remain" + ;; + + delete) + # The bucket holds run ledgers, which outlive the cluster. + bucket="$(terraform_output artifact_bucket_name)" + if [[ "$delete_artifacts" == true ]]; then + discard_artifacts "$bucket" + else + require_empty_artifact_bucket "$bucket" + fi + terraform -chdir="$terraform_root" destroy + ;; + + *) usage ;; +esac diff --git a/packages/simulator/gke/helm/agent-sandbox-values.yaml b/packages/simulator/gke/helm/agent-sandbox-values.yaml new file mode 100644 index 000000000..b9634037d --- /dev/null +++ b/packages/simulator/gke/helm/agent-sandbox-values.yaml @@ -0,0 +1,14 @@ +namespace: + create: false + name: agent-sandbox-system + +image: + repository: registry.k8s.io/agent-sandbox/agent-sandbox-controller + tag: v0.5.4 + pullPolicy: IfNotPresent + +controller: + extensions: false + +nodeSelector: + moltzap.dev/pool: system diff --git a/packages/simulator/gke/helm/kueue-values.yaml b/packages/simulator/gke/helm/kueue-values.yaml new file mode 100644 index 000000000..9e1df5507 --- /dev/null +++ b/packages/simulator/gke/helm/kueue-values.yaml @@ -0,0 +1,11 @@ +controllerManager: + nodeSelector: + moltzap.dev/pool: system + manager: + image: + repository: registry.k8s.io/kueue/kueue + tag: v0.17.8 + pullPolicy: IfNotPresent + +enableKueueViz: false +enablePrometheus: false diff --git a/packages/simulator/gke/helm/profile/Chart.yaml b/packages/simulator/gke/helm/profile/Chart.yaml new file mode 100644 index 000000000..1334b8837 --- /dev/null +++ b/packages/simulator/gke/helm/profile/Chart.yaml @@ -0,0 +1,6 @@ +apiVersion: v2 +name: moltzap-simulator-gke-profile +description: Profile-scoped Kueue capacity for MoltZap GKE qualification runs +type: application +version: 0.1.0 +appVersion: "1" diff --git a/packages/simulator/gke/helm/profile/templates/queue.yaml b/packages/simulator/gke/helm/profile/templates/queue.yaml new file mode 100644 index 000000000..f50a9e739 --- /dev/null +++ b/packages/simulator/gke/helm/profile/templates/queue.yaml @@ -0,0 +1,37 @@ +apiVersion: kueue.x-k8s.io/v1beta2 +kind: ResourceFlavor +metadata: + name: {{ .Values.resourceFlavor.name }} +spec: + nodeLabels: + {{ .Values.agentPool.label.key }}: {{ .Values.agentPool.label.value | quote }} + nodeTaints: + - key: {{ .Values.agentPool.taint.key }} + value: {{ .Values.agentPool.taint.value | quote }} + effect: {{ .Values.agentPool.taint.effect }} + tolerations: + - key: {{ .Values.agentPool.taint.key }} + operator: Equal + value: {{ .Values.agentPool.taint.value | quote }} + effect: {{ .Values.agentPool.taint.effect }} +--- +apiVersion: kueue.x-k8s.io/v1beta2 +kind: ClusterQueue +metadata: + name: {{ .Values.clusterQueue.name }} +spec: + namespaceSelector: {} + resourceGroups: + - coveredResources: + - cpu + - memory + - ephemeral-storage + flavors: + - name: {{ .Values.resourceFlavor.name }} + resources: + - name: cpu + nominalQuota: {{ .Values.quota.cpu | quote }} + - name: memory + nominalQuota: {{ .Values.quota.memory }} + - name: ephemeral-storage + nominalQuota: {{ .Values.quota.ephemeralStorage }} diff --git a/packages/simulator/gke/helm/profile/values.yaml b/packages/simulator/gke/helm/profile/values.yaml new file mode 100644 index 000000000..cc6ad8b23 --- /dev/null +++ b/packages/simulator/gke/helm/profile/values.yaml @@ -0,0 +1,24 @@ +clusterQueue: + name: moltzap + +resourceFlavor: + name: moltzap-gke-agents + +agentPool: + label: + key: moltzap.dev/pool + value: agents + taint: + key: moltzap.dev/agents + value: "true" + effect: NoSchedule + +# Admits a hundred agents, each requesting 1 cpu, 1Gi of memory, and 1Gi of +# ephemeral storage beside a smaller support container. Held below what +# agent_max_nodes e2-standard-16 nodes actually allocate (15890m cpu, 57Gi +# memory, 43Gi ephemeral storage each), not what they advertise. Sized with +# agent_max_nodes; see the profile README. +quota: + cpu: "110" + memory: 130Gi + ephemeralStorage: 110Gi diff --git a/packages/simulator/gke/install-addons.sh b/packages/simulator/gke/install-addons.sh new file mode 100755 index 000000000..fe5b93996 --- /dev/null +++ b/packages/simulator/gke/install-addons.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +readonly KUEUE_VERSION="0.17.8" +readonly AGENT_SANDBOX_VERSION="v0.5.4" +# The commit the v0.5.4 annotated tag points at, not the tag object's own SHA. +# Checking out FETCH_HEAD lands on the commit, so pinning the tag object leaves +# the verification below permanently unsatisfiable. +readonly AGENT_SANDBOX_COMMIT="945016a7b97f46cd2edf8633d6b6a22d5355ecc1" +readonly AGENT_SANDBOX_REPOSITORY="https://github.com/kubernetes-sigs/agent-sandbox.git" + +if [[ $# -ne 1 || -z "$1" ]]; then + echo "usage: $0 KUBE_CONTEXT" >&2 + exit 64 +fi + +readonly kube_context="$1" +readonly profile_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +temporary_root="$(mktemp -d "${TMPDIR:-/tmp}/moltzap-agent-sandbox.XXXXXXXX")" + +cleanup() { + rm -r -- "$temporary_root" +} +trap cleanup EXIT + +for executable in git helm; do + if ! command -v "$executable" >/dev/null 2>&1; then + echo "required executable is unavailable: $executable" >&2 + exit 69 + fi +done + +git -C "$temporary_root" init --quiet +git -C "$temporary_root" remote add origin "$AGENT_SANDBOX_REPOSITORY" +git -C "$temporary_root" fetch --quiet --depth 1 origin "$AGENT_SANDBOX_COMMIT" +git -C "$temporary_root" checkout --quiet --detach FETCH_HEAD + +if [[ "$(git -C "$temporary_root" rev-parse HEAD)" != "$AGENT_SANDBOX_COMMIT" ]]; then + echo "Agent Sandbox source did not resolve to the pinned commit" >&2 + exit 65 +fi + +helm upgrade --install kueue \ + oci://registry.k8s.io/kueue/charts/kueue \ + --version "$KUEUE_VERSION" \ + --namespace kueue-system \ + --create-namespace \ + --kube-context "$kube_context" \ + --values "$profile_root/helm/kueue-values.yaml" \ + --atomic \ + --wait \ + --timeout 5m + +# Agent Sandbox publishes a release chart in its source tree rather than a +# packaged chart artifact. The immutable release commit above is the chart +# source; Helm still owns the installed CRDs, controller, webhook, and RBAC. +helm upgrade --install agent-sandbox \ + "$temporary_root/helm" \ + --namespace agent-sandbox-system \ + --create-namespace \ + --kube-context "$kube_context" \ + --values "$profile_root/helm/agent-sandbox-values.yaml" \ + --atomic \ + --wait \ + --timeout 5m + +helm upgrade --install moltzap-simulator-gke-profile \ + "$profile_root/helm/profile" \ + --namespace kueue-system \ + --kube-context "$kube_context" \ + --atomic \ + --wait \ + --timeout 5m + +printf 'installed Kueue v%s, Agent Sandbox %s, and ClusterQueue/moltzap in context %s\n' \ + "$KUEUE_VERSION" "$AGENT_SANDBOX_VERSION" "$kube_context" diff --git a/packages/simulator/gke/profile.json b/packages/simulator/gke/profile.json new file mode 100644 index 000000000..244d94005 --- /dev/null +++ b/packages/simulator/gke/profile.json @@ -0,0 +1,93 @@ +{ + "apiVersion": "moltzap.gke-profile/v1", + "cluster": { + "mode": "Standard", + "topology": "regional", + "nameFromTerraformOutput": "cluster_name", + "locationFromTerraformOutput": "cluster_location", + "contextEnvironment": "MOLTZAP_KUBE_CONTEXT" + }, + "addons": { + "kueue": { + "version": "v0.17.8", + "chart": "oci://registry.k8s.io/kueue/charts/kueue", + "chartVersion": "0.17.8" + }, + "agentSandbox": { + "version": "v0.5.4", + "source": "https://github.com/kubernetes-sigs/agent-sandbox.git", + "sourceCommit": "945016a7b97f46cd2edf8633d6b6a22d5355ecc1" + } + }, + "queue": { + "clusterQueue": "moltzap", + "localQueue": "society", + "resourceFlavor": "moltzap-gke-agents" + }, + "rosterPlacement": { + "applyTo": [ + "aggregateWorkloadPodSets", + "sandboxPodTemplates" + ], + "nodeSelector": { + "moltzap.dev/pool": "agents" + }, + "tolerations": [ + { + "key": "moltzap.dev/agents", + "operator": "Equal", + "value": "true", + "effect": "NoSchedule" + } + ] + }, + "images": { + "controllerEnvironment": "MOLTZAP_CONTROLLER_IMAGE", + "supportEnvironment": "MOLTZAP_SUPPORT_IMAGE", + "repositoryFromTerraformOutput": "controller_repository", + "requireDigestReference": true, + "digestReferencePattern": "^[^@]+@sha256:[0-9a-f]{64}$" + }, + "ledger": { + "active": { + "kind": "empty-dir", + "volume": { + "name": "ledger", + "emptyDir": {} + }, + "mountPath": "/var/lib/moltzap/ledger", + "permissionsInitContainer": true + }, + "retained": { + "kind": "gcs-fuse-csi-ephemeral", + "bucketFromTerraformOutput": "artifact_bucket_name", + "bucketEnvironment": "MOLTZAP_GKE_ARTIFACT_BUCKET", + "podAnnotations": { + "gke-gcsfuse/volumes": "true" + }, + "volume": { + "name": "artifacts", + "csi": { + "driver": "gcsfuse.csi.storage.gke.io", + "readOnly": false, + "volumeAttributes": { + "bucketName": "{artifactBucket}", + "mountOptions": "uid=1000,gid=1000,file-mode=0640,dir-mode=0750" + } + } + }, + "mountPath": "/var/lib/moltzap-artifacts", + "directoryTemplate": "/var/lib/moltzap-artifacts/{runNamespace}/ledger", + "publicationOrder": [ + "manifest.json", + "records.ndjson", + "completion.json" + ] + } + }, + "temporal": { + "mode": "configured-endpoint", + "addressEnvironment": "MOLTZAP_TEMPORAL_ADDRESS", + "namespaceEnvironment": "MOLTZAP_TEMPORAL_NAMESPACE" + } +} diff --git a/packages/simulator/gke/profile.test.mjs b/packages/simulator/gke/profile.test.mjs new file mode 100644 index 000000000..14ef724bd --- /dev/null +++ b/packages/simulator/gke/profile.test.mjs @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +const gkeRoot = dirname(fileURLToPath(import.meta.url)); +const read = (path) => readFile(join(gkeRoot, path), "utf8"); + +test("GKE profile selects only the accepted cloud shape", async () => { + const profileText = await read("profile.json"); + const profile = JSON.parse(profileText); + + assert.equal(profile.apiVersion, "moltzap.gke-profile/v1"); + assert.deepEqual(profile.cluster, { + mode: "Standard", + topology: "regional", + nameFromTerraformOutput: "cluster_name", + locationFromTerraformOutput: "cluster_location", + contextEnvironment: "MOLTZAP_KUBE_CONTEXT", + }); + assert.deepEqual(profile.addons.kueue, { + version: "v0.17.8", + chart: "oci://registry.k8s.io/kueue/charts/kueue", + chartVersion: "0.17.8", + }); + assert.equal(profile.addons.agentSandbox.version, "v0.5.4"); + // The pin has two owners, and a tag object's own SHA is not the commit a + // checkout lands on, so the installer is the one that must agree. + const installerSource = await read("install-addons.sh"); + const pinned = /AGENT_SANDBOX_COMMIT="([0-9a-f]{40})"/.exec(installerSource); + assert.ok(pinned, "the installer pins a 40-hex Agent Sandbox commit"); + assert.equal(profile.addons.agentSandbox.sourceCommit, pinned[1]); + + assert.deepEqual(profile.rosterPlacement.applyTo, [ + "aggregateWorkloadPodSets", + "sandboxPodTemplates", + ]); + assert.deepEqual(profile.rosterPlacement.nodeSelector, { + "moltzap.dev/pool": "agents", + }); + assert.deepEqual(profile.rosterPlacement.tolerations, [ + { + key: "moltzap.dev/agents", + operator: "Equal", + value: "true", + effect: "NoSchedule", + }, + ]); + + assert.equal(profile.images.requireDigestReference, true); + const immutableImage = new RegExp(profile.images.digestReferencePattern); + assert.match( + `us-central1-docker.pkg.dev/p/r/controller@sha256:${"a".repeat(64)}`, + immutableImage, + ); + assert.doesNotMatch( + "us-central1-docker.pkg.dev/p/r/controller:latest", + immutableImage, + ); + + assert.equal(profile.temporal.mode, "configured-endpoint"); + assert.equal(profile.temporal.addressEnvironment, "MOLTZAP_TEMPORAL_ADDRESS"); + assert.doesNotMatch(profileText, /temporal(?:io)?\/.+@sha256:/i); +}); + +test("GKE ledger contract separates POSIX writes from retained CSI export", async () => { + const profileText = await read("profile.json"); + const profile = JSON.parse(profileText); + const active = profile.ledger.active; + const retained = profile.ledger.retained; + + assert.deepEqual(active, { + kind: "empty-dir", + volume: { name: "ledger", emptyDir: {} }, + mountPath: "/var/lib/moltzap/ledger", + permissionsInitContainer: true, + }); + assert.equal(retained.kind, "gcs-fuse-csi-ephemeral"); + assert.equal(retained.bucketFromTerraformOutput, "artifact_bucket_name"); + assert.equal(retained.bucketEnvironment, "MOLTZAP_GKE_ARTIFACT_BUCKET"); + assert.equal(retained.podAnnotations["gke-gcsfuse/volumes"], "true"); + assert.equal(retained.volume.name, "artifacts"); + assert.equal(retained.volume.csi.driver, "gcsfuse.csi.storage.gke.io"); + assert.equal(retained.volume.csi.readOnly, false); + assert.match(retained.volume.csi.volumeAttributes.mountOptions, /uid=1000/); + assert.match(retained.volume.csi.volumeAttributes.mountOptions, /gid=1000/); + assert.match(retained.volume.csi.volumeAttributes.mountOptions, /file-mode=/); + assert.match(retained.volume.csi.volumeAttributes.mountOptions, /dir-mode=/); + assert.match(retained.directoryTemplate, /\{runNamespace\}/); + assert.deepEqual(retained.publicationOrder, [ + "manifest.json", + "records.ndjson", + "completion.json", + ]); + assert.doesNotMatch(profileText, /hostPath/); +}); + +test("Terraform owns one zonal Standard cluster whose agent capacity scales on demand", async () => { + const [versions, lock, variables, main, outputs] = await Promise.all([ + read("terraform/versions.tf"), + read("terraform/.terraform.lock.hcl"), + read("terraform/variables.tf"), + read("terraform/main.tf"), + read("terraform/outputs.tf"), + ]); + const terraform = `${versions}\n${variables}\n${main}\n${outputs}`; + + assert.match(versions, /version\s*=\s*"= 7\.42\.0"/); + assert.match(lock, /version\s*=\s*"7\.42\.0"/); + assert.equal(lock.match(/"h1:/g)?.length, 4); + assert.match(main, /resource "google_container_cluster" "simulator"/); + assert.match(main, /location\s*=\s*var\.zone/); + assert.match(main, /remove_default_node_pool\s*=\s*true/); + assert.doesNotMatch(main, /enable_autopilot/); + assert.match(main, /release_channel\s*\{\s*channel\s*=\s*"REGULAR"/s); + + const agentPool = main.match( + /resource "google_container_node_pool" "agents" \{([\s\S]*?)\n\}/, + )?.[1]; + assert.ok(agentPool); + // Idling at zero is what makes an unused profile cost nothing, and the + // ceiling is the number the ClusterQueue quota is sized against. + assert.match(agentPool, /initial_node_count\s*=\s*0/); + assert.match(agentPool, /min_node_count\s*=\s*0/); + assert.match(agentPool, /max_node_count\s*=\s*var\.agent_max_nodes/); + assert.doesNotMatch(agentPool, /\bnode_count\s*=/); + assert.match(agentPool, /machine_type\s*=\s*var\.agent_machine_type/); + assert.match(agentPool, /disk_size_gb\s*=\s*var\.agent_disk_size_gb/); + assert.match(agentPool, /local\.agent_pool_label_value/); + assert.match(agentPool, /local\.agent_pool_taint_key/); + assert.match(agentPool, /effect\s*=\s*"NO_SCHEDULE"/); + assert.match(variables, /variable "zone"/); + assert.match(variables, /variable "agent_max_nodes"/); + assert.match(variables, /variable "agent_machine_type"/); + assert.match(variables, /variable "agent_disk_size_gb"/); + + for (const resource of [ + "google_artifact_registry_repository", + "google_storage_bucket", + "google_service_account", + "google_compute_network", + "google_compute_subnetwork", + ]) { + assert.match(terraform, new RegExp(`resource "${resource}"`)); + } + assert.match(main, /hierarchical_namespace\s*\{\s*enabled\s*=\s*true/s); + assert.match(main, /uniform_bucket_level_access\s*=\s*true/); + assert.match(main, /force_destroy\s*=\s*false/); + assert.match(main, /public_access_prevention\s*=\s*"enforced"/); + assert.match(main, /gcs_fuse_csi_driver_config\s*\{\s*enabled\s*=\s*true/s); + assert.match(main, /workload_identity_config/); + assert.match(main, /roles\/container\.defaultNodeServiceAccount/); + assert.match(main, /roles\/artifactregistry\.reader/); + assert.match(main, /roles\/storage\.objectUser/); + assert.match(main, /principalSet:\/\/iam\.googleapis\.com/); + assert.match(outputs, /output "controller_repository"/); + assert.match(outputs, /output "artifact_bucket_name"/); + assert.match(outputs, /output "agent_placement"/); + assert.match(outputs, /output "agent_capacity"/); + // The ClusterQueue quota has one owner, the profile chart. Restating it here + // gave the same number two owners, and the copies drifted apart unnoticed. + assert.doesNotMatch(outputs, /queue_quota/); + assert.doesNotMatch(outputs, /ephemeral_storage\s*=/); +}); + +test("Helm pins both operators and reserves the complete roster resource set", async () => { + const [kueue, sandbox, chart, values, queue] = await Promise.all([ + read("helm/kueue-values.yaml"), + read("helm/agent-sandbox-values.yaml"), + read("helm/profile/Chart.yaml"), + read("helm/profile/values.yaml"), + read("helm/profile/templates/queue.yaml"), + ]); + + assert.match(kueue, /repository: registry\.k8s\.io\/kueue\/kueue/); + assert.match(kueue, /tag: v0\.17\.8/); + assert.match(kueue, /moltzap\.dev\/pool: system/); + assert.match(sandbox, /agent-sandbox-controller/); + assert.match(sandbox, /tag: v0\.5\.4/); + assert.match(sandbox, /namespace:\n\s+create: false/); + assert.match(sandbox, /extensions: false/); + assert.match(sandbox, /moltzap\.dev\/pool: system/); + assert.match(chart, /name: moltzap-simulator-gke-profile/); + + assert.match(values, /key: moltzap\.dev\/pool\n\s+value: agents/); + assert.match(values, /key: moltzap\.dev\/agents/); + assert.match(queue, /apiVersion: kueue\.x-k8s\.io\/v1beta2/g); + assert.match(queue, /kind: ResourceFlavor/); + assert.match(queue, /kind: ClusterQueue/); + assert.match(queue, /nodeLabels:/); + assert.match(queue, /nodeTaints:/); + assert.match(queue, /tolerations:/); + for (const resource of ["cpu", "memory", "ephemeral-storage"]) { + assert.match(queue, new RegExp(`- ${resource}`)); + } +}); + +test("add-on installation is explicit, pinned, and Helm-owned", async () => { + const installer = await read("install-addons.sh"); + + assert.match(installer, /\[\[ \$# -ne 1/); + assert.equal(installer.match(/helm upgrade --install/g)?.length, 3); + assert.equal(installer.match(/--kube-context "\$kube_context"/g)?.length, 3); + assert.match(installer, /KUEUE_VERSION="0\.17\.8"/); + assert.match(installer, /AGENT_SANDBOX_VERSION="v0\.5\.4"/); + assert.match(installer, /AGENT_SANDBOX_COMMIT="[0-9a-f]{40}"/); + assert.match(installer, /git -C "\$temporary_root" fetch[^\n]+/); + assert.doesNotMatch(installer, /kubectl\s+apply/); + assert.doesNotMatch(installer, /curl\s/); + assert.doesNotMatch(installer, /temporal/i); +}); + +test("the GKE target enters the core Temporal path with explicit identities", async () => { + const [packageText, entrypoint] = await Promise.all([ + read("../package.json"), + read("../src/cluster/profiles/gke.ts"), + ]); + const packageManifest = JSON.parse(packageText); + assert.equal( + packageManifest.nx.targets["gke-run"].options.command, + "node dist/cluster/profiles/gke.js", + ); + assert.match(entrypoint, /runKubernetesSociety/); + assert.match(entrypoint, /MOLTZAP_GKE_ARTIFACT_BUCKET/); + assert.match(entrypoint, /MOLTZAP_KUBE_CONTEXT/); + assert.match(entrypoint, /MOLTZAP_TEMPORAL_ADDRESS/); +}); diff --git a/packages/simulator/gke/terraform/.gitignore b/packages/simulator/gke/terraform/.gitignore new file mode 100644 index 000000000..1ec89e723 --- /dev/null +++ b/packages/simulator/gke/terraform/.gitignore @@ -0,0 +1,5 @@ +.terraform/ +*.tfplan +*.tfstate +*.tfstate.* +terraform.tfvars diff --git a/packages/simulator/gke/terraform/.terraform.lock.hcl b/packages/simulator/gke/terraform/.terraform.lock.hcl new file mode 100644 index 000000000..db237d4d4 --- /dev/null +++ b/packages/simulator/gke/terraform/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/google" { + version = "7.42.0" + constraints = "7.42.0" + hashes = [ + "h1:6qNk18qjViinYxnjAEix5O+qHPMmXXHdzCU1IpEJLqg=", + "h1:JqhNUoY3Jw6g4lfznOd4B8qXh2lNetk5/W+dWVZJUwI=", + "h1:OgWsoxTL8UjiDXmmqrK3twhvUEFI0IfIr3NzCmUeAGk=", + "h1:gN0gSVFKLscRyG28ngPvWKEp8JnWa3iASU+mdG+H7Wo=", + "zh:30b25728203b9208a167fac3f9880c10242fc5accdd29ba01b21355566fc4e3d", + "zh:4468f6ea772e991d890724e44f628a24dae44c9028af654469454d05b00b10ec", + "zh:4dfa4f7bcd72ea89f6f3f7411d88bf9a1f060699830e1f1f85bf32102be754b3", + "zh:59cf73879f10ad9d29ff8ad96559a476e70695bed26b84b6189728129674618c", + "zh:73a7966ae1c6db8a3dc31eb43f05dddd27a47f3ff42e25f62594fc0d5b438412", + "zh:7c2ea415fb06147cf9834b2169d75a52bd979ad291bed32c94d9a9307316f7ba", + "zh:962efdd3dee2b98860528555b0616ca0c8987dfc6c5e5df6d1c025c9b22c2f26", + "zh:c4a5ca9f20cbfbdcb88064d53d16f2ce8038e1ddc3303c7261152856728700b5", + "zh:ca56a9477177530737d07feea70bb99309414a7c18aa62f975644138606e1faf", + "zh:d0c6db8b1da363f69087569716ed96a3a6dadb4b872f598d442d867bd0706fa9", + "zh:ddd2472052e0c5c3fab7cffae8376e8855c35ad8614ba8631f7e448e72a41f21", + "zh:f569b65999264a9416862bca5cd2a6177d94ccb0424f3a4ef424428912b9cb3c", + ] +} diff --git a/packages/simulator/gke/terraform/main.tf b/packages/simulator/gke/terraform/main.tf new file mode 100644 index 000000000..fe10076e6 --- /dev/null +++ b/packages/simulator/gke/terraform/main.tf @@ -0,0 +1,242 @@ +locals { + required_services = toset([ + "artifactregistry.googleapis.com", + "compute.googleapis.com", + "container.googleapis.com", + "iam.googleapis.com", + "storage.googleapis.com", + ]) + + agent_pool_label_key = "moltzap.dev/pool" + agent_pool_label_value = "agents" + agent_pool_taint_key = "moltzap.dev/agents" + system_pool_label = "system" + + cluster_workload_principal = "principalSet://iam.googleapis.com/projects/${data.google_project.current.number}/locations/global/workloadIdentityPools/${var.project_id}.svc.id.goog/kubernetes.cluster/https://container.googleapis.com/v1/projects/${var.project_id}/locations/${var.zone}/clusters/${var.cluster_name}" +} + +data "google_project" "current" { + project_id = var.project_id +} + +resource "google_project_service" "required" { + for_each = local.required_services + + project = var.project_id + service = each.value + disable_on_destroy = false +} + +resource "google_compute_network" "simulator" { + name = var.network_name + project = var.project_id + auto_create_subnetworks = false + + depends_on = [google_project_service.required] +} + +resource "google_compute_subnetwork" "simulator" { + name = var.network_name + project = var.project_id + region = var.region + network = google_compute_network.simulator.id + ip_cidr_range = var.subnetwork_cidr + + secondary_ip_range { + range_name = "moltzap-pods" + ip_cidr_range = var.pods_cidr + } + + secondary_ip_range { + range_name = "moltzap-services" + ip_cidr_range = var.services_cidr + } +} + +resource "google_service_account" "nodes" { + project = var.project_id + account_id = "moltzap-gke-nodes" + display_name = "MoltZap simulator GKE nodes" + + depends_on = [google_project_service.required] +} + +resource "google_project_iam_member" "node_runtime" { + project = var.project_id + role = "roles/container.defaultNodeServiceAccount" + member = "serviceAccount:${google_service_account.nodes.email}" +} + +resource "google_service_account_iam_member" "gke_uses_node_identity" { + service_account_id = google_service_account.nodes.name + role = "roles/iam.serviceAccountUser" + member = "serviceAccount:service-${data.google_project.current.number}@container-engine-robot.iam.gserviceaccount.com" +} + +resource "google_artifact_registry_repository" "simulator" { + project = var.project_id + location = var.region + repository_id = var.artifact_repository_id + description = "Immutable MoltZap simulator controller and support images" + format = "DOCKER" + + depends_on = [google_project_service.required] +} + +resource "google_artifact_registry_repository_iam_member" "node_image_reader" { + project = var.project_id + location = google_artifact_registry_repository.simulator.location + repository = google_artifact_registry_repository.simulator.name + role = "roles/artifactregistry.reader" + member = "serviceAccount:${google_service_account.nodes.email}" +} + +resource "google_storage_bucket" "artifacts" { + project = var.project_id + name = var.artifact_bucket_name + location = upper(var.region) + storage_class = "STANDARD" + uniform_bucket_level_access = true + public_access_prevention = "enforced" + force_destroy = false + + hierarchical_namespace { + enabled = true + } + + depends_on = [google_project_service.required] +} + +resource "google_container_cluster" "simulator" { + project = var.project_id + name = var.cluster_name + location = var.zone + + network = google_compute_network.simulator.id + subnetwork = google_compute_subnetwork.simulator.id + + networking_mode = "VPC_NATIVE" + remove_default_node_pool = true + initial_node_count = 1 + deletion_protection = var.deletion_protection + + release_channel { + channel = "REGULAR" + } + + ip_allocation_policy { + cluster_secondary_range_name = "moltzap-pods" + services_secondary_range_name = "moltzap-services" + } + + workload_identity_config { + workload_pool = "${var.project_id}.svc.id.goog" + } + + addons_config { + gcs_fuse_csi_driver_config { + enabled = true + } + } + + resource_labels = { + "moltzap-profile" = "simulator-qualification" + } + + depends_on = [ + google_project_iam_member.node_runtime, + google_service_account_iam_member.gke_uses_node_identity, + ] +} + +resource "google_container_node_pool" "system" { + project = var.project_id + name = "system" + location = var.zone + cluster = google_container_cluster.simulator.name + node_count = var.system_nodes + + management { + auto_repair = true + auto_upgrade = true + } + + node_config { + machine_type = var.system_machine_type + image_type = "COS_CONTAINERD" + disk_type = "pd-balanced" + disk_size_gb = var.system_disk_size_gb + service_account = google_service_account.nodes.email + oauth_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + + labels = { + (local.agent_pool_label_key) = local.system_pool_label + } + + metadata = { + disable-legacy-endpoints = "true" + } + + workload_metadata_config { + mode = "GKE_METADATA" + } + } +} + +# Scaling from zero requires this pool's label and taint to be declared here, +# because the autoscaler decides whether a node that does not exist yet would +# accept the pending pods. +resource "google_container_node_pool" "agents" { + project = var.project_id + name = "agents" + location = var.zone + cluster = google_container_cluster.simulator.name + + # The ClusterQueue quota is sized against this ceiling; move them together. + initial_node_count = 0 + autoscaling { + min_node_count = 0 + max_node_count = var.agent_max_nodes + location_policy = "ANY" + } + + management { + auto_repair = true + auto_upgrade = true + } + + node_config { + machine_type = var.agent_machine_type + image_type = "COS_CONTAINERD" + disk_type = "pd-balanced" + disk_size_gb = var.agent_disk_size_gb + service_account = google_service_account.nodes.email + oauth_scopes = ["https://www.googleapis.com/auth/cloud-platform"] + + labels = { + (local.agent_pool_label_key) = local.agent_pool_label_value + } + + taint { + key = local.agent_pool_taint_key + value = "true" + effect = "NO_SCHEDULE" + } + + metadata = { + disable-legacy-endpoints = "true" + } + + workload_metadata_config { + mode = "GKE_METADATA" + } + } +} + +resource "google_storage_bucket_iam_member" "cluster_artifact_writer" { + bucket = google_storage_bucket.artifacts.name + role = "roles/storage.objectUser" + member = local.cluster_workload_principal + + depends_on = [google_container_cluster.simulator] +} diff --git a/packages/simulator/gke/terraform/outputs.tf b/packages/simulator/gke/terraform/outputs.tf new file mode 100644 index 000000000..1bd0b2d1d --- /dev/null +++ b/packages/simulator/gke/terraform/outputs.tf @@ -0,0 +1,57 @@ +output "project_id" { + description = "Google Cloud project hosting the profile." + value = var.project_id +} + +output "cluster_name" { + description = "GKE Standard cluster name." + value = google_container_cluster.simulator.name +} + +output "cluster_location" { + description = "Zone hosting the GKE control plane and both node pools." + value = google_container_cluster.simulator.location +} + +output "artifact_bucket_name" { + description = "Bucket mounted by the GKE ledger profile." + value = google_storage_bucket.artifacts.name +} + +output "controller_repository" { + description = "Repository prefix to which the controller/support image is pushed before selecting its digest." + value = "${var.region}-docker.pkg.dev/${var.project_id}/${google_artifact_registry_repository.simulator.repository_id}" +} + +output "agent_placement" { + description = "Placement copied into aggregate Workload pod sets and Sandbox pod templates." + value = { + node_selector = { + (local.agent_pool_label_key) = local.agent_pool_label_value + } + tolerations = [{ + key = local.agent_pool_taint_key + operator = "Equal" + value = "true" + effect = "NoSchedule" + }] + } +} + +# The ClusterQueue quota deliberately lives only in the profile chart's values. +# Restating it here would give one number two owners, and the copies drift +# silently because nothing compares them. +output "agent_capacity" { + description = "Agent node shape the ClusterQueue quota is sized against. The pool idles at zero and autoscales to this ceiling." + value = { + zone = var.zone + max_nodes = var.agent_max_nodes + machine_type = var.agent_machine_type + disk_size_gb = var.agent_disk_size_gb + } +} + +output "artifact_workload_principal" { + description = "Cluster-scoped GKE workload principal granted object access to the profile's dedicated bucket." + value = local.cluster_workload_principal +} diff --git a/packages/simulator/gke/terraform/terraform.tfvars.example b/packages/simulator/gke/terraform/terraform.tfvars.example new file mode 100644 index 000000000..03018c26e --- /dev/null +++ b/packages/simulator/gke/terraform/terraform.tfvars.example @@ -0,0 +1,2 @@ +project_id = "replace-with-project-id" +artifact_bucket_name = "replace-with-globally-unique-moltzap-ledger-bucket" diff --git a/packages/simulator/gke/terraform/variables.tf b/packages/simulator/gke/terraform/variables.tf new file mode 100644 index 000000000..e8cadfe20 --- /dev/null +++ b/packages/simulator/gke/terraform/variables.tf @@ -0,0 +1,155 @@ +variable "project_id" { + description = "Google Cloud project used only for the simulator qualification profile." + type = string + + validation { + condition = can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.project_id)) + error_message = "project_id must be a valid Google Cloud project ID." + } +} + +variable "region" { + description = "GKE control-plane region and Artifact Registry location." + type = string + default = "us-central1" +} + +variable "zone" { + description = <<-EOT + Zone holding the cluster and both node pools. + + The cluster is zonal because nothing here is replicated: the development + Temporal deployment and each run's router are single pods, so a regional + control plane cannot keep a run alive through a zone loss. One zone also + keeps every agent beside the router it talks to, so cross-zone latency + stays out of the measurement. Must lie inside region. + EOT + type = string + default = "us-central1-a" +} + +variable "agent_machine_type" { + description = <<-EOT + Agent node machine type. + + GKE reserves less proportionally as a node grows, so sixteen vCPU on one + machine yields marginally more allocatable than the same vCPU split in two, + and e2 is priced per vCPU so splitting saves nothing. Fewer, larger nodes + also pull each image fewer times. + EOT + type = string + default = "e2-standard-16" +} + +variable "agent_max_nodes" { + description = <<-EOT + Ceiling for the autoscaled agent pool, which idles at zero nodes. CPU binds + first, seating about fourteen agents per e2-standard-16, so eight nodes + hold the hundred-agent soak. Raise the chart's ClusterQueue quota to match. + EOT + type = number + default = 8 + + validation { + condition = var.agent_max_nodes >= 1 && floor(var.agent_max_nodes) == var.agent_max_nodes + error_message = "agent_max_nodes must be a positive integer." + } +} + +variable "agent_disk_size_gb" { + description = <<-EOT + Agent node boot disk, in GB. + + The node image, the support and stock agent images once, and one gibibyte + of ephemeral storage for each agent the node holds. CPU exhausts first, so + the disk carries generous headroom; the reason not to shrink it is + throughput, since pd-balanced scales with size and a smaller disk slows the + first image pull. + EOT + type = number + default = 100 + + validation { + condition = var.agent_disk_size_gb >= 50 + error_message = "agent_disk_size_gb must leave room for the node image and the agent working set." + } +} + +variable "cluster_name" { + description = "GKE Standard cluster name." + type = string + default = "moltzap-simulator" +} + +variable "artifact_bucket_name" { + description = "Globally unique Cloud Storage bucket for retained simulator ledgers." + type = string + + validation { + condition = can(regex("^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$", var.artifact_bucket_name)) + error_message = "artifact_bucket_name must be a valid 3-63 character Cloud Storage bucket name." + } +} + +variable "artifact_repository_id" { + description = "Artifact Registry Docker repository for the immutable controller/support image." + type = string + default = "moltzap-simulator" +} + +variable "network_name" { + description = "VPC created for the qualification cluster." + type = string + default = "moltzap-simulator" +} + +variable "subnetwork_cidr" { + description = "Primary node range for the qualification cluster." + type = string + default = "10.40.0.0/20" +} + +variable "pods_cidr" { + description = "Secondary Pod range for VPC-native GKE." + type = string + default = "10.44.0.0/14" +} + +variable "services_cidr" { + description = "Secondary Service range for VPC-native GKE." + type = string + default = "10.48.0.0/20" +} + +variable "system_machine_type" { + description = "Machine type for profile controllers and other non-agent infrastructure." + type = string + default = "e2-standard-4" +} + +variable "system_nodes" { + description = <<-EOT + System nodes carrying cluster DNS, metrics, the Kueue controller, and the + run worker. Zero parks the controller between experiments; nothing runs and + nothing recovers on its own until it is restored. + EOT + type = number + default = 1 + + validation { + condition = var.system_nodes >= 0 && floor(var.system_nodes) == var.system_nodes + error_message = "system_nodes must be a non-negative integer." + } +} + +variable "system_disk_size_gb" { + description = "Boot disk size for system nodes." + type = number + default = 50 +} + +variable "deletion_protection" { + description = "Protect the qualification cluster from Terraform destroy when explicitly enabled." + type = bool + default = false +} diff --git a/packages/simulator/gke/terraform/versions.tf b/packages/simulator/gke/terraform/versions.tf new file mode 100644 index 000000000..02e89c088 --- /dev/null +++ b/packages/simulator/gke/terraform/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.8.0, < 2.0.0" + + required_providers { + google = { + source = "hashicorp/google" + version = "= 7.42.0" + } + } +} + +provider "google" { + project = var.project_id + region = var.region +} diff --git a/packages/simulator/local/.gitignore b/packages/simulator/local/.gitignore new file mode 100644 index 000000000..3e0975a1a --- /dev/null +++ b/packages/simulator/local/.gitignore @@ -0,0 +1,2 @@ +.tools/ +artifacts/ diff --git a/packages/simulator/local/README.md b/packages/simulator/local/README.md new file mode 100644 index 000000000..45bf47712 --- /dev/null +++ b/packages/simulator/local/README.md @@ -0,0 +1,129 @@ +# Local Kubernetes simulator profile + +This profile runs the core simulator path on three kind nodes. It installs exact +Kueue and Agent Sandbox releases, a local-only Temporal development server, +and the queue capacity consumed by complete-roster Workloads. Docker is local +cluster and image-build tooling here, not a simulator execution backend. + +## Build the controller/support image + +```bash +pnpm nx run @moltzap/simulator:local-controller-image +``` + +The builder compiles and packs the workspace packages into one local image and +prints its tag, manifest-digest identity, and fixed filesystem contract. Keep +the printed `pinnedImage`: cluster setup finds and loads its local tag, then +adds that digest identity in containerd. The local submitter uses the same +pinned value as `MOLTZAP_CONTROLLER_IMAGE`. + +The controller and Sandbox initializer use the same image: + +- controller main: `/opt/moltzap/dist/cluster/controller/main.js`; +- private infrastructure: + `/opt/moltzap/dist/cluster/controller/services.js`; +- bootstrap CLI: `/opt/moltzap/dist/cluster/bootstrap.js`; +- OpenClaw plugin overlay: `/opt/moltzap/application-overlay`. + +## Create the cluster + +The setup script uses Docker for kind. It downloads pinned kind and kubectl +binaries into the ignored `local/.tools/` directory and verifies their SHA-256 +checksums before use. + +```bash +pnpm nx run @moltzap/simulator:local-cluster-create -- \ + --artifacts "$PWD/.moltzap/local-artifacts" \ + --image PINNED_IMAGE_FROM_BUILD_OUTPUT +``` + +The script refuses to replace an existing cluster. It prints a JSON handoff +containing the downloaded tool paths, kube context, local and node artifact +paths, queue names, and Temporal address. The selected local artifact directory +is mounted at `/var/lib/moltzap-artifacts` in the kind node. For each kind node, +the setup also records the loaded image under its immutable digest reference; +the kubelet never needs a registry to resolve the local controller or bootstrap +initializer. + +The installed profile is: + +- kind v0.31.0 with one digest-pinned Kubernetes v1.35.0 control-plane node + and two workers; +- Kueue v0.17.8 with `ResourceFlavor/moltzap-local` and + `ClusterQueue/moltzap`; +- Agent Sandbox v0.5.4 core controller and direct `Sandbox` API; +- Temporal CLI dev server 1.8.2 at `127.0.0.1:7233` through the kind-only + NodePort mapping. + +Each run namespace owns a `LocalQueue/society` that points to the shared +ClusterQueue. Run cleanup deletes the namespace; the ResourceFlavor, +ClusterQueue, controllers, and Temporal service remain profile-scoped. + +Completed ledger files use the same relative layout expected by GKE readback: + +```text +{localArtifactRoot}/{namespace}/ledger/{ledgerRef}/manifest.json +{localArtifactRoot}/{namespace}/ledger/{ledgerRef}/records.ndjson +{localArtifactRoot}/{namespace}/ledger/{ledgerRef}/completion.json +``` + +`end-to-end.mjs` is the repository-owned acceptance experiment, and the run +activity mounts it as the controller's experiment module. Its `runSpec` starts +digest-pinned stock OpenClaw applications with inherited auth disabled, tools +denied, and OpenClaw's nested sandbox off. Once the exact cohort is ready it +holds the society briefly and gives it back. It sends nothing and invokes no +model: a large cohort answering would measure the model provider rather than +the cluster, and the complete-roster gate has already passed by then. + +The roster size is an input rather than part of the file, because the path is +the same at two agents and at a hundred and only the time to get there differs. +`MOLTZAP_COHORT_SIZE` carries it, defaulting to two: + +```bash +MOLTZAP_CONTROLLER_IMAGE=PINNED_IMAGE_FROM_BUILD_OUTPUT \ +MOLTZAP_TEMPORAL_ADDRESS=127.0.0.1:7233 \ +pnpm nx run @moltzap/simulator:local-run -- local/end-to-end.mjs +``` + +The support image defaults to `MOLTZAP_CONTROLLER_IMAGE`, so this uses the same +immutable image for the controller and the Sandbox bootstrap initializer. + +A larger cohort needs two things. Capacity to seat it: the GKE profile's agent +pool autoscales, so it takes sizes a local cluster generally cannot. And time to +reach it: `MOLTZAP_STARTUP_TIMEOUT_MS` is how long the controller waits for the +whole roster to be admitted and ready, and its two-minute default does not cover +provisioning nodes and pulling an image onto each one. + +```bash +MOLTZAP_COHORT_SIZE=100 \ +MOLTZAP_STARTUP_TIMEOUT_MS=900000 \ +packages/simulator/gke/cluster.sh run \ + packages/simulator/local/end-to-end.mjs +``` + +Leaving the budget at its default is the failure a large cold cohort hits first, +and it reports as `agent sandbox "…" was not ready within 2m`. + +The checked-in module and profile tests do not by themselves prove that a run +completed on a live cluster. + +## Controller integration contract + +The local profile submitter starts one Temporal workflow. Its controller +activity creates the run namespace and `LocalQueue`, mounts the experiment +module, exposes the controller's production router Service, and sets the closed +`MOLTZAP_*` environment accepted by +`controllerServicesFromEnvironment`. Ledger directories use a +run-specific child beneath the mounted artifact root; no Kubernetes or +Temporal objects enter the experiment context. + +Only `kind-config.yaml` and the setup script are local-cluster-specific. The +queue manifests, controller image, experiment module contract, Temporal +workflow contract, and `Run.execute` path have the same shape as the GKE +profile. + +## Static validation + +```bash +pnpm nx run @moltzap/simulator:local-profile-check +``` diff --git a/packages/simulator/local/controller-image/Dockerfile b/packages/simulator/local/controller-image/Dockerfile new file mode 100644 index 000000000..c7fb8df96 --- /dev/null +++ b/packages/simulator/local/controller-image/Dockerfile @@ -0,0 +1,33 @@ +FROM node:22.22.0-bookworm-slim@sha256:dd9d21971ec4395903fa6143c2b9267d048ae01ca6d3ea96f16cb30df6187d94 AS overlay + +WORKDIR /build/overlay +COPY overlay-package.json ./package.json +COPY tarballs ./tarballs +RUN npm install --omit=dev --no-audit --no-fund \ + && node --input-type=module --eval \ + 'await import("./node_modules/@moltzap/openclaw-channel/dist/openclaw-entry.js")' \ + && mkdir -p /application-overlay/openclaw-channel \ + && cp -a node_modules/@moltzap/openclaw-channel/. /application-overlay/openclaw-channel/ \ + && rm -rf node_modules/@moltzap/openclaw-channel \ + && cp -a node_modules /application-overlay/node_modules \ + && rm -rf /root/.npm + +FROM node:22.22.0-bookworm-slim@sha256:dd9d21971ec4395903fa6143c2b9267d048ae01ca6d3ea96f16cb30df6187d94 + +ENV NODE_ENV=production +WORKDIR /srv/moltzap + +COPY controller-package.json ./package.json +COPY tarballs ./tarballs +RUN npm install --omit=dev --no-audit --no-fund \ + && test -f node_modules/@moltzap/evals/dist/peer-application.js \ + && rm -rf tarballs /root/.npm \ + && mkdir -p /opt/moltzap \ + && ln -s /srv/moltzap/node_modules/@moltzap/simulator/dist /opt/moltzap/dist \ + && ln -s /srv/moltzap/node_modules /opt/moltzap/node_modules \ + && ln -s /srv/moltzap/node_modules /node_modules + +COPY --from=overlay --chown=node:node /application-overlay /opt/moltzap/application-overlay + +USER node +ENTRYPOINT ["node", "/opt/moltzap/dist/cluster/controller/main.js"] diff --git a/packages/simulator/local/end-to-end.mjs b/packages/simulator/local/end-to-end.mjs new file mode 100644 index 000000000..cdc0ea818 --- /dev/null +++ b/packages/simulator/local/end-to-end.mjs @@ -0,0 +1,49 @@ +import { RunSpec } from "@moltzap/simulator"; +import { openClawRuntime } from "@moltzap/simulator/agents"; +import { Duration, Effect } from "effect"; +import { + cohortSizeFromEnvironment, + controllerServicesFromEnvironment, +} from "/opt/moltzap/dist/cluster/controller/services.js"; + +// One end-to-end run of the whole path: admit a complete roster, bring every +// agent up, hold the society, and give it back. The cohort size is an input +// because the path is the same at two agents and at a hundred, and only the +// time it takes to get there differs. +const AGENTS = cohortSizeFromEnvironment(); + +// Holding the society idle is the measurement. Agents are already running by +// the time execute begins, so the wait exercises whether a cohort this size +// stays up rather than how fast it starts. +const HOLD = Duration.seconds(30); + +const runtime = (identity) => + openClawRuntime({ + tools: { + deny: ["*"], + elevated: { enabled: false }, + exec: { mode: "deny" }, + }, + sandbox: { mode: "off" }, + workspaceFiles: [{ relativePath: "IDENTITY.md", content: identity }], + }); + +const name = (index) => `agent${String(index + 1).padStart(3, "0")}`; + +const agents = Object.fromEntries( + Array.from({ length: AGENTS }, (_, index) => [ + name(index), + runtime(`You are ${name(index)} in the MoltZap end-to-end society.`), + ]), +); + +export const runSpec = RunSpec.define({ + id: "moltzap.end-to-end/v1", + events: [], + agents, + cluster: controllerServicesFromEnvironment(), + // Nothing is sent. A hundred agents answering would measure the model + // provider rather than the cluster, and the complete-roster gate has already + // passed by the time execute runs. + execute: () => Effect.sleep(HOLD), +}); diff --git a/packages/simulator/local/kind-config.yaml b/packages/simulator/local/kind-config.yaml new file mode 100644 index 000000000..9352a31b3 --- /dev/null +++ b/packages/simulator/local/kind-config.yaml @@ -0,0 +1,23 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: + - role: control-plane + image: kindest/node:v1.35.0@sha256:452d707d4862f52530247495d180205e029056831160e22870e37e3f6c1ac31f + extraMounts: + - hostPath: __MOLTZAP_ARTIFACTS__ + containerPath: /var/lib/moltzap-artifacts + extraPortMappings: + - containerPort: 30733 + hostPort: 7233 + listenAddress: 127.0.0.1 + protocol: TCP + - role: worker + image: kindest/node:v1.35.0@sha256:452d707d4862f52530247495d180205e029056831160e22870e37e3f6c1ac31f + extraMounts: + - hostPath: __MOLTZAP_ARTIFACTS__ + containerPath: /var/lib/moltzap-artifacts + - role: worker + image: kindest/node:v1.35.0@sha256:452d707d4862f52530247495d180205e029056831160e22870e37e3f6c1ac31f + extraMounts: + - hostPath: __MOLTZAP_ARTIFACTS__ + containerPath: /var/lib/moltzap-artifacts diff --git a/packages/simulator/local/profile.json b/packages/simulator/local/profile.json new file mode 100644 index 000000000..946abffa0 --- /dev/null +++ b/packages/simulator/local/profile.json @@ -0,0 +1,61 @@ +{ + "apiVersion": "moltzap.local-profile/v1", + "clusterName": "moltzap-simulator", + "kind": { + "version": "v0.31.0", + "nodeImage": "kindest/node:v1.35.0@sha256:452d707d4862f52530247495d180205e029056831160e22870e37e3f6c1ac31f", + "binaries": { + "darwin-arm64": { + "url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.31.0/kind-darwin-arm64", + "sha256": "88bf554fe9da6311c9f8c2d082613c002911a476f6b5090e9420b35d84e70c5c" + }, + "darwin-x64": { + "url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.31.0/kind-darwin-amd64", + "sha256": "a8b3cf77b2ad77aec5bf710d1a2589d9117576132af812885cad41e9dede4d4e" + }, + "linux-arm64": { + "url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.31.0/kind-linux-arm64", + "sha256": "8e1014e87c34901cc422a1445866835d1e666f2a61301c27e722bdeab5a1f7e4" + }, + "linux-x64": { + "url": "https://github.com/kubernetes-sigs/kind/releases/download/v0.31.0/kind-linux-amd64", + "sha256": "eb244cbafcc157dff60cf68693c14c9a75c4e6e6fedaf9cd71c58117cb93e3fa" + } + } + }, + "kubectl": { + "version": "v1.35.0", + "binaries": { + "darwin-arm64": { + "url": "https://dl.k8s.io/release/v1.35.0/bin/darwin/arm64/kubectl", + "sha256": "cf699c56340dc775230fde4ef84237d27563ea6ef52164c7d078072b586c3918" + }, + "darwin-x64": { + "url": "https://dl.k8s.io/release/v1.35.0/bin/darwin/amd64/kubectl", + "sha256": "2447cb78911b10a667202b078eeb30541ec78d1280c3682921dc81607e148d96" + }, + "linux-arm64": { + "url": "https://dl.k8s.io/release/v1.35.0/bin/linux/arm64/kubectl", + "sha256": "58f82f9fe796c375c5c4b8439850b0f3f4d401a52434052f2df46035a8789e25" + }, + "linux-x64": { + "url": "https://dl.k8s.io/release/v1.35.0/bin/linux/amd64/kubectl", + "sha256": "a2e984a18a0c063279d692533031c1eff93a262afcc0afdc517375432d060989" + } + } + }, + "kueue": { + "version": "v0.17.8", + "manifestUrl": "https://github.com/kubernetes-sigs/kueue/releases/download/v0.17.8/manifests.yaml", + "manifestSha256": "060f579f1fda0812c3691b2c605eeb0d67ef27416e51d484793c60df2bdd366f" + }, + "agentSandbox": { + "version": "v0.5.4", + "manifestUrl": "https://github.com/kubernetes-sigs/agent-sandbox/releases/download/v0.5.4/sandbox.yaml", + "manifestSha256": "51e3610f235b58abd465280682d366d3d0fed8972489bf6a800d707988d24c3e" + }, + "temporalImage": "temporalio/temporal:1.8.2@sha256:cf86707827fac99e4d1c4a47dc11b105382d796199c7bd41fb3213fb0471628e", + "clusterQueue": "moltzap", + "localQueue": "society", + "artifactNodePath": "/var/lib/moltzap-artifacts" +} diff --git a/packages/simulator/local/profile.test.mjs b/packages/simulator/local/profile.test.mjs new file mode 100644 index 000000000..59955ba5f --- /dev/null +++ b/packages/simulator/local/profile.test.mjs @@ -0,0 +1,194 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + normalizeContainerdReference, + retryImageDiscovery, + selectLocalImageTag, +} from "../scripts/local-create-cluster.mjs"; + +const localRoot = dirname(fileURLToPath(import.meta.url)); +const read = (path) => readFile(join(localRoot, path), "utf8"); + +test("local profile pins every downloaded or executed artifact", async () => { + const profile = JSON.parse(await read("profile.json")); + assert.equal(profile.apiVersion, "moltzap.local-profile/v1"); + assert.match(profile.kind.nodeImage, /@sha256:[0-9a-f]{64}$/); + assert.match(profile.temporalImage, /@sha256:[0-9a-f]{64}$/); + assert.match(profile.kueue.manifestSha256, /^[0-9a-f]{64}$/); + assert.match(profile.agentSandbox.manifestSha256, /^[0-9a-f]{64}$/); + assert.equal(profile.clusterQueue, "moltzap"); + assert.equal(profile.localQueue, "society"); + assert.equal(profile.artifactNodePath, "/var/lib/moltzap-artifacts"); + for (const tool of [profile.kind, profile.kubectl]) { + assert.equal(Object.keys(tool.binaries).length, 4); + for (const asset of Object.values(tool.binaries)) { + assert.match(asset.url, /^https:\/\//); + assert.match(asset.sha256, /^[0-9a-f]{64}$/); + } + } + + const kind = await read("kind-config.yaml"); + assert.match(kind, /__MOLTZAP_ARTIFACTS__/); + assert.match(kind, new RegExp(profile.kind.nodeImage.replaceAll(".", "\\."))); + assert.match(kind, /containerPort: 30733/); + assert.match(kind, /hostPort: 7233/); + assert.match(kind, /containerPath: \/var\/lib\/moltzap-artifacts/); + assert.equal(kind.match(/role: worker/g)?.length, 2); + assert.equal(kind.match(/__MOLTZAP_ARTIFACTS__/g)?.length, 3); + + const temporal = await read("temporal.yaml"); + assert.match( + temporal, + new RegExp(profile.temporalImage.replaceAll(".", "\\.")), + ); + assert.match(temporal, /type: NodePort/); + assert.match(temporal, /nodePort: 30733/); +}); + +test("queue profile reserves every resource requested by an application", async () => { + const queue = await read("queue.yaml"); + for (const resource of ["cpu", "memory", "ephemeral-storage"]) { + assert.match(queue, new RegExp(`- ${resource}`)); + } + assert.match(queue, /kind: ClusterQueue\nmetadata:\n name: moltzap\n/); + assert.match(queue, /apiVersion: kueue\.x-k8s\.io\/v1beta2/g); + assert.match(queue, /name: cpu\n\s+nominalQuota: "24"/); + assert.match(queue, /name: memory\n\s+nominalQuota: 64Gi/); +}); + +test("the end-to-end run sizes its roster from the run rather than the file", async () => { + const endToEnd = await read("end-to-end.mjs"); + + assert.match(endToEnd, /export const runSpec = RunSpec\.define/); + assert.match(endToEnd, /controllerServicesFromEnvironment\(\)/); + // The count is an input, so the module names no cohort size of its own and + // one file covers two agents and a hundred alike. + assert.match(endToEnd, /cohortSizeFromEnvironment\(\)/); + assert.match(endToEnd, /length: AGENTS/); + assert.doesNotMatch(endToEnd, /agent\d+:/); + // Nothing is sent: a large cohort answering measures the model provider. + assert.doesNotMatch(endToEnd, /conversation\.send/); + assert.doesNotMatch(endToEnd, /\.gateway\.agent\(/); + assert.match(endToEnd, /sandbox: \{ mode: "off" \}/); + assert.match(endToEnd, /deny: \["\*"\]/); +}); + +test("controller image exposes the agreed controller and support layout", async () => { + const dockerfile = await read("controller-image/Dockerfile"); + assert.match( + dockerfile, + /ENTRYPOINT \["node", "\/opt\/moltzap\/dist\/cluster\/controller\/main\.js"\]/, + ); + assert.match(dockerfile, /\/opt\/moltzap\/application-overlay/); + assert.match(dockerfile, /\/opt\/moltzap\/dist/); + assert.match(dockerfile, /node:22\.22\.0-bookworm-slim@sha256:[0-9a-f]{64}/); + + const setup = await read("../scripts/local-create-cluster.mjs"); + assert.match(setup, /makePinnedImageDiscoverable/); + assert.match(setup, /template\.replaceAll\(ARTIFACT_TOKEN/); + assert.match(setup, /"docker-image",\n\s+imageSource,/); + assert.match( + setup, + /"ctr",\n\s+"-n",\n\s+"k8s\.io",\n\s+"images",\n\s+"tag"/, + ); + assert.match(setup, /"--force",\n\s+"--skip-reference-check"/); + assert.match(setup, /"crictl", "inspecti", digestReference/); + assert.match( + setup, + /makePinnedImageDiscoverable\(\n\s+kind,\n\s+options\.cluster,\n\s+imageSource,\n\s+options\.image,/, + ); +}); + +test("controller image packages the compiled evaluation application", async () => { + const evalPackage = JSON.parse(await read("../../evals/package.json")); + assert.ok( + evalPackage.files?.includes("dist"), + "the packed evaluation package must include its compiled entrypoints", + ); + + const dockerfile = await read("controller-image/Dockerfile"); + assert.match( + dockerfile, + /node_modules\/@moltzap\/evals\/dist\/peer-application\.js/, + ); +}); + +test("controller overlay preserves runtime peers and verifies the plugin entry", async () => { + const dockerfile = await read("controller-image/Dockerfile"); + const channelPackage = JSON.parse( + await read("../../openclaw-channel/package.json"), + ); + assert.doesNotMatch(dockerfile, /--omit=peer/); + assert.match( + dockerfile, + /await import\("\.\/node_modules\/@moltzap\/openclaw-channel\/dist\/openclaw-entry\.js"\)/, + ); + assert.equal(channelPackage.peerDependenciesMeta?.openclaw?.optional, true); +}); + +test("local image discovery retries are bounded", async () => { + let attempts = 0; + const pauses = []; + await retryImageDiscovery( + async () => { + attempts += 1; + if (attempts < 3) { + throw new Error("not visible yet"); + } + }, + { + attempts: 4, + intervalMs: 7, + pause: async (milliseconds) => pauses.push(milliseconds), + }, + ); + assert.equal(attempts, 3); + assert.deepEqual(pauses, [7, 7]); + + attempts = 0; + await assert.rejects( + retryImageDiscovery( + async () => { + attempts += 1; + throw new Error("still missing"); + }, + { attempts: 2, intervalMs: 0, pause: async () => undefined }, + ), + /after 2 attempts/, + ); + assert.equal(attempts, 2); +}); + +test("local image aliases use Docker's normalized containerd references", () => { + const digest = `sha256:${"a".repeat(64)}`; + assert.equal( + normalizeContainerdReference(`controller@${digest}`), + `docker.io/library/controller@${digest}`, + ); + assert.equal( + normalizeContainerdReference(`docker.io/controller@${digest}`), + `docker.io/library/controller@${digest}`, + ); + assert.equal( + normalizeContainerdReference(`index.docker.io/controller@${digest}`), + `docker.io/library/controller@${digest}`, + ); + assert.equal( + normalizeContainerdReference(`ghcr.io/moltzap/controller@${digest}`), + `ghcr.io/moltzap/controller@${digest}`, + ); + assert.equal( + selectLocalImageTag( + ["unrelated:latest", "controller:local"], + `docker.io/controller@${digest}`, + ), + "controller:local", + ); + assert.throws( + () => selectLocalImageTag([], `controller@${digest}`), + /no local repository tag/, + ); +}); diff --git a/packages/simulator/local/queue.yaml b/packages/simulator/local/queue.yaml new file mode 100644 index 000000000..df0229856 --- /dev/null +++ b/packages/simulator/local/queue.yaml @@ -0,0 +1,25 @@ +apiVersion: kueue.x-k8s.io/v1beta2 +kind: ResourceFlavor +metadata: + name: moltzap-local +--- +apiVersion: kueue.x-k8s.io/v1beta2 +kind: ClusterQueue +metadata: + name: moltzap +spec: + namespaceSelector: {} + resourceGroups: + - coveredResources: + - cpu + - memory + - ephemeral-storage + flavors: + - name: moltzap-local + resources: + - name: cpu + nominalQuota: "24" + - name: memory + nominalQuota: 64Gi + - name: ephemeral-storage + nominalQuota: 96Gi diff --git a/packages/simulator/local/temporal.yaml b/packages/simulator/local/temporal.yaml new file mode 100644 index 000000000..d16adaab6 --- /dev/null +++ b/packages/simulator/local/temporal.yaml @@ -0,0 +1,69 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: moltzap-system +--- +apiVersion: v1 +kind: Service +metadata: + name: temporal + namespace: moltzap-system +spec: + type: NodePort + selector: + app.kubernetes.io/name: temporal + ports: + - name: grpc + port: 7233 + targetPort: grpc + nodePort: 30733 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: temporal + namespace: moltzap-system +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: temporal + template: + metadata: + labels: + app.kubernetes.io/name: temporal + spec: + automountServiceAccountToken: false + securityContext: + runAsUser: 1000 + runAsGroup: 1000 + fsGroup: 1000 + containers: + - name: temporal + image: temporalio/temporal:1.8.2@sha256:cf86707827fac99e4d1c4a47dc11b105382d796199c7bd41fb3213fb0471628e + args: + - server + - start-dev + - --ip + - 0.0.0.0 + - --headless + - --db-filename + - /var/lib/temporal/temporal.db + ports: + - name: grpc + containerPort: 7233 + readinessProbe: + tcpSocket: + port: grpc + initialDelaySeconds: 1 + periodSeconds: 2 + resources: + requests: + cpu: 100m + memory: 128Mi + volumeMounts: + - name: state + mountPath: /var/lib/temporal + volumes: + - name: state + emptyDir: {} diff --git a/packages/simulator/package.json b/packages/simulator/package.json index 9ff0d1884..cad4fda8c 100644 --- a/packages/simulator/package.json +++ b/packages/simulator/package.json @@ -17,8 +17,6 @@ "!src/**/*.test.ts", "!src/**/*.types-check.ts", "!src/**/__tests__/**", - "scripts/build-server-image.mjs", - "server-image", "!dist/tsconfig.tsbuildinfo" ], "main": "./dist/index.js", @@ -36,16 +34,22 @@ "types": "./dist/ledger.d.ts", "import": "./dist/ledger.js" }, - "./runtime": { - "types": "./dist/runtime.d.ts", - "import": "./dist/runtime.js" + "./agents": { + "types": "./dist/agents.d.ts", + "import": "./dist/agents.js" } }, "scripts": { "build": "nx run @moltzap/simulator:build", "lint": "nx run @moltzap/simulator:lint", "test": "vitest run --passWithNoTests", - "test:integration": "vitest run --config vitest.integration.config.mjs", + "local:profile:check": "nx run @moltzap/simulator:local-profile-check", + "local:cluster:create": "nx run @moltzap/simulator:local-cluster-create", + "local:controller:image": "nx run @moltzap/simulator:local-controller-image", + "local:run": "nx run @moltzap/simulator:local-run", + "local:cluster:test": "nx run @moltzap/simulator:local-cluster-test", + "gke:profile:check": "nx run @moltzap/simulator:gke-profile-check", + "gke:run": "nx run @moltzap/simulator:gke-run", "typecheck:tests": "tsc -p tsconfig.test.json" }, "nx": { @@ -77,23 +81,81 @@ "command": "vitest run" } }, - "test:integration": { + "local-profile-check": { + "executor": "nx:run-commands", + "inputs": [ + "default", + "{projectRoot}/local/**/*", + "{projectRoot}/scripts/local-create-cluster.mjs", + "{projectRoot}/scripts/build-controller-image.mjs" + ], + "options": { + "cwd": "packages/simulator", + "command": "node --test local/profile.test.mjs && node --check scripts/local-create-cluster.mjs && node --check scripts/build-controller-image.mjs && node --check local/end-to-end.mjs" + } + }, + "local-cluster-create": { + "cache": false, + "executor": "nx:run-commands", + "options": { + "cwd": "packages/simulator", + "command": "node scripts/local-create-cluster.mjs" + } + }, + "local-controller-image": { + "cache": false, + "executor": "nx:run-commands", + "options": { + "cwd": "packages/simulator", + "command": "node scripts/build-controller-image.mjs" + } + }, + "local-run": { + "cache": false, + "dependsOn": [ + "build" + ], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/simulator", + "command": "node dist/cluster/profiles/local.js" + } + }, + "local-cluster-test": { + "cache": false, + "dependsOn": [ + "build" + ], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/simulator", + "command": "vitest run --config vitest.cluster.config.mjs" + } + }, + "gke-profile-check": { "dependsOn": [ "build" ], + "executor": "nx:run-commands", "inputs": [ "default", - "^production", - { - "env": "MOLTZAP_NANOCLAW_ITEST" - }, - { - "env": "MOLTZAP_OPENCLAW_ITEST" - }, - { - "env": "MOLTZAP_SIM_ITEST" - } - ] + "{projectRoot}/gke/**/*" + ], + "options": { + "cwd": "packages/simulator", + "command": "node --test gke/profile.test.mjs && bash -n gke/install-addons.sh && bash -n gke/cluster.sh && node --check dist/cluster/profiles/gke.js" + } + }, + "gke-run": { + "cache": false, + "dependsOn": [ + "build" + ], + "executor": "nx:run-commands", + "options": { + "cwd": "packages/simulator", + "command": "node dist/cluster/profiles/gke.js" + } }, "typecheck:tests": { "dependsOn": [ @@ -119,10 +181,15 @@ "@effect/platform-node": "^0.108.0", "@effect/sql": "^0.52.0", "@electric-sql/pglite": "0.4.4", + "@kubernetes/client-node": "1.4.0", "@moltzap/client": "workspace:^", "@moltzap/openclaw-channel": "workspace:^", "@moltzap/protocol": "workspace:^", "@moltzap/server-core": "workspace:*", + "@temporalio/activity": "1.21.1", + "@temporalio/client": "1.21.1", + "@temporalio/worker": "1.21.1", + "@temporalio/workflow": "1.21.1", "effect": "^3.22.0", "openclaw": "2026.6.33" }, diff --git a/packages/simulator/safer-architecture.config.json b/packages/simulator/safer-architecture.config.json index f69ab4383..709b2f2de 100644 --- a/packages/simulator/safer-architecture.config.json +++ b/packages/simulator/safer-architecture.config.json @@ -5,6 +5,13 @@ "maxPublicReexports": 15, "minPublicFacadeModules": 16, "minFolderReadmeChildren": 100, + "folderChildCountOverrides": [ + { + "folder": "cluster", + "maxChildren": 16, + "reason": "Cluster is one subsystem whose children each name a step of a run's life: scaffold, cohort, reclaim, watch, install, bootstrap, and submit, plus its two vendor adapters and the controller that runs in-cluster" + } + ], "facadeFiles": [ { "file": "network.ts", @@ -15,7 +22,7 @@ "reason": "Published ledger contract for records, storage, live runs, and offline inspection" }, { - "file": "runtime.ts", + "file": "agents.ts", "reason": "Published runtime contract for autonomous agents, keyed rosters, and shipped runtime implementations" }, { @@ -27,11 +34,11 @@ "reason": "Closed kernel event catalog and producer-bound event writer contracts" }, { - "file": "kernel/event-services.ts", + "file": "run/events.ts", "reason": "Definition-bound Effect services for readable ledgers and customer-owned event emission" }, { - "file": "ledger/model.ts", + "file": "ledger/schema.ts", "reason": "Durable record, manifest, completion, digest, and ledger-reference model" }, { @@ -39,29 +46,57 @@ "reason": "Storage port that keeps allocation, append, completion, and reading independent of the filesystem implementation" }, { - "file": "ledger/live.ts", + "file": "ledger/append.ts", "reason": "Live-ledger boundary for ordered append, failure latching, completion, and typed event streams" }, { - "file": "ledger/open.ts", + "file": "ledger/read.ts", "reason": "Completed-ledger validation and offline opening boundary" }, { - "file": "kernel/link-fabric.ts", + "file": "run/link-fabric.ts", "reason": "Link-fabric boundary coupling the platform link driver, receiver registration, and the policy interpreter" }, { - "file": "kernel/outcomes.ts", + "file": "run/outcomes.ts", "reason": "Causal outcome conversion shared by runtime, router, and program lifecycle modules" }, { - "file": "kernel/router.ts", + "file": "run/router.ts", "reason": "Router lifecycle boundary coupling scoped acquisition and shutdown with durable causal outcomes" }, { - "file": "kernel/run.ts", + "file": "run/execute.ts", "reason": "Run boundary composing definitions, scoped resources, lifecycle outcomes, and the customer Effect" }, + { + "file": "cluster/cluster.ts", + "reason": "Cluster seam the run kernel acquires: the platform port plus the society and slot shapes every implementation satisfies" + }, + { + "file": "cluster/submit.ts", + "reason": "Submission boundary shared by the local and GKE profiles, owning run identity and the sanitized failure they both report" + }, + { + "file": "cluster/temporal.ts", + "reason": "The package's only Temporal adapter: worker, client, activity, and workflow bindings behind one Promise boundary" + }, + { + "file": "cluster/kubernetes/calls.ts", + "reason": "The package's only Kubernetes API surface, wrapping a Promise-native client as typed Effects" + }, + { + "file": "cluster/kubernetes/objects.ts", + "reason": "Every Kubernetes object the cluster creates, kept beside the calls that submit them" + }, + { + "file": "cluster/controller/configuration.ts", + "reason": "Closed environment contract decoded once at the in-cluster controller boundary" + }, + { + "file": "definition.ts", + "reason": "Public authoring surface composing catalogs, roster, cluster layer, and the customer Effect into one runnable spec" + }, { "file": "network/endpoint.ts", "reason": "Controlled endpoint and network service boundary over router transports and conversation receive cursors" @@ -83,39 +118,34 @@ "reason": "Router port, framed message model, connection contract, and typed network failures" }, { - "file": "network/server.ts", + "file": "network/server/process.ts", "reason": "Scoped MoltZap server ownership for image, storage, process, observation, and identity resources" }, { - "file": "runtime/runtime.ts", + "file": "agents/agent.ts", "reason": "Autonomous participant lifecycle contract implemented by every runtime family" }, { - "file": "runtime/roster.ts", + "file": "agents/roster.ts", "reason": "Keyed mixed-runtime roster preserving each agent's acquisition errors and Effect requirements" }, { - "file": "runtime/process.ts", - "reason": "Scoped process bridge shared by the external runtime implementations" - }, - { - "file": "runtime/packages.ts", + "file": "network/server/packages.ts", "reason": "Runtime package discovery and install-policy boundary shared by shipped runtime families" - }, - { - "file": "runtime/nanoclaw/install.ts", - "reason": "NanoClaw installation boundary composing source acquisition, package assets, and dependency materialization" - }, - { - "file": "runtime/openclaw/process.ts", - "reason": "OpenClaw process boundary composing workspace setup, channel materialization, gateway configuration, port ownership, and supervised lifetime" } ], "layers": [ { - "name": "kernel", + "name": "controller", + "folders": [ + "cluster/controller" + ], + "reason": "The in-cluster executable that loads one spec and invokes the run kernel, so it depends on the kernel while nothing depends on it" + }, + { + "name": "run", "folders": [ - "kernel" + "run" ], "reason": "The run kernel orchestrates capability contracts without becoming a dependency of them" }, @@ -125,9 +155,12 @@ "events", "ledger", "network", - "runtime" + "agents", + "cluster", + "cluster/kubernetes", + "cluster/profiles" ], - "reason": "Peer event, ledger, network, and runtime capabilities compose through typed ports and do not form a truthful linear stack" + "reason": "Peer event, ledger, network, agent, and cluster capabilities compose through typed ports and do not form a truthful linear stack; each exposes a port the run kernel requires and hides its adapters behind it" } ], "publicTypePackages": [ diff --git a/packages/simulator/scripts/build-controller-image.mjs b/packages/simulator/scripts/build-controller-image.mjs new file mode 100644 index 000000000..e815a2e3b --- /dev/null +++ b/packages/simulator/scripts/build-controller-image.mjs @@ -0,0 +1,232 @@ +// Builds the shared controller/support image and prints both its local tag and +// manifest-digest identity. The caller decides whether to load or push it. +import { createHash } from "node:crypto"; +import { execFile } from "node:child_process"; +import { + copyFile, + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const workspaceRoot = dirname(dirname(packageRoot)); +const dockerfile = join(packageRoot, "local", "controller-image", "Dockerfile"); +const DEFAULT_REPOSITORY = "moltzap-simulator-controller"; +const BUILD_TIMEOUT_MS = 30 * 60 * 1_000; +const PACK_TIMEOUT_MS = 5 * 60 * 1_000; +const SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/; +const workspacePackages = { + "@moltzap/client": join(workspaceRoot, "packages", "client"), + "@moltzap/evals": join(workspaceRoot, "packages", "evals"), + "@moltzap/openclaw-channel": join( + workspaceRoot, + "packages", + "openclaw-channel", + ), + "@moltzap/protocol": join(workspaceRoot, "packages", "protocol"), + "@moltzap/server-core": join(workspaceRoot, "packages", "server"), + "@moltzap/simulator": packageRoot, +}; + +function report(message) { + process.stderr.write(`[moltzap controller image] ${message}\n`); +} + +function parseArguments(args) { + if (args.length === 0) { + return { repository: DEFAULT_REPOSITORY }; + } + if (args.length !== 2 || args[0] !== "--repository") { + throw new TypeError( + "usage: build-controller-image.mjs [--repository NAME]", + ); + } + const repository = args[1]; + if (repository.length === 0 || repository.includes("@")) { + throw new TypeError( + "controller image repository must not be empty or contain a digest", + ); + } + return { repository }; +} + +async function pack(packageDirectory, destination) { + const { stdout } = await exec( + "pnpm", + ["pack", "--pack-destination", destination], + { cwd: packageDirectory, timeout: PACK_TIMEOUT_MS }, + ); + const path = stdout.trim().split("\n").at(-1); + if (path === undefined || !path.endsWith(".tgz")) { + throw new Error(`pnpm pack returned no archive for ${packageDirectory}`); + } + return basename(path); +} + +function packageManifest(name, dependencies, archives) { + return { + name, + version: "0.0.0-local", + private: true, + dependencies: Object.fromEntries( + dependencies.map((dependency) => [ + dependency, + `file:./tarballs/${archives[dependency]}`, + ]), + ), + overrides: Object.fromEntries( + Object.entries(archives) + .filter(([packageName]) => !dependencies.includes(packageName)) + .map(([packageName, archive]) => [ + packageName, + `file:./tarballs/${archive}`, + ]), + ), + }; +} + +async function stage() { + const root = await mkdtemp(join(tmpdir(), "moltzap-controller-image-")); + const tarballs = join(root, "tarballs"); + await mkdir(tarballs); + const packed = await Promise.all( + Object.entries(workspacePackages).map(async ([name, directory]) => [ + name, + await pack(directory, tarballs), + ]), + ); + const archives = Object.fromEntries(packed); + await Promise.all([ + copyFile(dockerfile, join(root, "Dockerfile")), + writeFile( + join(root, "controller-package.json"), + `${JSON.stringify( + packageManifest( + "moltzap-controller-image", + ["@moltzap/simulator", "@moltzap/evals"], + archives, + ), + null, + 2, + )}\n`, + ), + writeFile( + join(root, "overlay-package.json"), + `${JSON.stringify( + packageManifest( + "moltzap-openclaw-overlay", + ["@moltzap/openclaw-channel"], + archives, + ), + null, + 2, + )}\n`, + ), + ]); + return root; +} + +async function fingerprint(root) { + const hash = createHash("sha256"); + const inputs = [ + "Dockerfile", + "controller-package.json", + "overlay-package.json", + ...(await readdir(join(root, "tarballs"))).map( + (name) => `tarballs/${name}`, + ), + ]; + for (const path of inputs.sort()) { + hash.update(path); + hash.update(await readFile(join(root, path))); + } + hash.update(await readFile(fileURLToPath(import.meta.url))); + return hash.digest("hex").slice(0, 16); +} + +function buildDigest(metadata) { + const digest = metadata["containerimage.digest"]; + if (typeof digest !== "string" || !SHA256_DIGEST.test(digest)) { + throw new Error("docker buildx returned no manifest digest"); + } + return digest; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + report("building simulator, evals, and workspace dependencies"); + await exec( + "pnpm", + [ + "nx", + "run-many", + "--target=build", + "--projects=@moltzap/simulator,@moltzap/evals", + ], + { + cwd: workspaceRoot, + timeout: BUILD_TIMEOUT_MS, + }, + ); + report("packing the controller and application-overlay dependencies"); + const staging = await stage(); + try { + const image = `${options.repository}:${await fingerprint(staging)}`; + const metadataPath = join(staging, "build-metadata.json"); + report(`building ${image}`); + await exec( + "docker", + [ + "buildx", + "build", + "--load", + "--metadata-file", + metadataPath, + "--tag", + image, + staging, + ], + { timeout: BUILD_TIMEOUT_MS, maxBuffer: 16 * 1024 * 1024 }, + ); + const metadata = JSON.parse(await readFile(metadataPath, "utf8")); + const imageDigest = buildDigest(metadata); + const { stdout } = await exec( + "docker", + ["image", "inspect", "--format", "{{.Id}}", image], + { timeout: 30_000 }, + ); + const imageId = stdout.trim(); + if (!SHA256_DIGEST.test(imageId)) { + throw new Error("docker returned no local controller image id"); + } + process.stdout.write( + `${JSON.stringify({ + image, + pinnedImage: `${options.repository}@${imageDigest}`, + imageDigest, + imageId, + controllerEntrypoint: "/opt/moltzap/dist/cluster/controller/main.js", + supportBootstrap: "/opt/moltzap/dist/cluster/bootstrap.js", + applicationOverlay: "/opt/moltzap/application-overlay", + })}\n`, + ); + } finally { + await rm(staging, { recursive: true, force: true }); + } +} + +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await main(); +} diff --git a/packages/simulator/scripts/build-server-image.mjs b/packages/simulator/scripts/build-server-image.mjs deleted file mode 100644 index 8827a1dcc..000000000 --- a/packages/simulator/scripts/build-server-image.mjs +++ /dev/null @@ -1,272 +0,0 @@ -// Builds the simulator's per-run server image from the installed -// `@moltzap/server-core` and `@moltzap/protocol` packages and prints its pin: -// `{"image":…,"imageDigest":"sha256:…","serverCoreVersion":…}`. -// -// The tag fingerprints every image input, so matching package bytes reuse the -// local image and different bytes cannot resolve to an older build. -import { createHash } from "node:crypto"; -import { execFile } from "node:child_process"; -import { - existsSync, - readdirSync, - readFileSync, - realpathSync, - statSync, -} from "node:fs"; -import { copyFile, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { basename, dirname, join, relative, resolve, sep } from "node:path"; -import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; - -const exec = promisify(execFile); - -const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); -const imageDir = join(packageRoot, "server-image"); - -function packageRootOf(name) { - let candidate = dirname(fileURLToPath(import.meta.resolve(name))); - for (;;) { - const manifestPath = join(candidate, "package.json"); - if (existsSync(manifestPath) && readManifest(candidate).name === name) { - return candidate; - } - const parent = dirname(candidate); - if (parent === candidate) { - throw new Error(`could not locate the installed ${name} package`); - } - candidate = parent; - } -} - -function dependencyPackageRoot(packageDir, name) { - const segments = name.split("/"); - let candidateRoot = packageDir; - for (;;) { - const candidates = [ - join(candidateRoot, "node_modules", ...segments), - ...(basename(candidateRoot) === "node_modules" - ? [join(candidateRoot, ...segments)] - : []), - ]; - for (const candidate of candidates) { - if ( - existsSync(join(candidate, "package.json")) && - readManifest(candidate).name === name - ) { - return realpathSync(candidate); - } - } - const parent = dirname(candidateRoot); - if (parent === candidateRoot) { - throw new Error( - `could not locate ${name} from the installed ${readManifest(packageDir).name} package`, - ); - } - candidateRoot = parent; - } -} - -const serverDir = packageRootOf("@moltzap/server-core"); -const protocolDir = dependencyPackageRoot(serverDir, "@moltzap/protocol"); -const workspaceCandidate = dirname(dirname(packageRoot)); -const workspaceRoot = - existsSync(join(workspaceCandidate, "pnpm-workspace.yaml")) && - serverDir === join(workspaceCandidate, "packages", "server") - ? workspaceCandidate - : undefined; - -const IMAGE_REPOSITORY = "moltzap-sim-server"; -const BUILD_TIMEOUT_MS = 900_000; -const INSPECT_TIMEOUT_MS = 30_000; -const PACK_TIMEOUT_MS = 300_000; - -function report(stage) { - process.stderr.write(`[moltzap simulator] ${stage}\n`); -} - -function readManifest(packageDir) { - return JSON.parse(readFileSync(join(packageDir, "package.json"), "utf8")); -} - -/** Every published file of a package: what `pnpm pack` puts in the tarball. */ -function packedPaths(packageDir) { - const manifest = readManifest(packageDir); - return ["package.json", ...(manifest.files ?? [])].map((entry) => - join(packageDir, entry), - ); -} - -function hashPath(hash, root, path, namespace) { - // A published entry that is not on disk (a glob, a moved build output) - // would silently shrink the fingerprint and let a stale image answer for - // a changed workspace. - if (!existsSync(path)) { - throw new Error( - `published path ${path} does not exist; the image fingerprint would not cover it`, - ); - } - if (statSync(path).isDirectory()) { - for (const entry of readdirSync(path).sort()) { - hashPath(hash, root, join(path, entry), namespace); - } - return; - } - hash.update(`${namespace}/${relative(root, path).split(sep).join("/")}`); - hash.update(readFileSync(path)); -} - -/** - * Fingerprint over the exact bytes that reach the image: both packages' - * published files plus this directory's Dockerfile and config. Tarball - * bytes are deliberately not used — archive metadata makes them unstable - * across otherwise identical packs. - */ -function fingerprint() { - const hash = createHash("sha256"); - for (const packageDir of [protocolDir, serverDir]) { - const namespace = readManifest(packageDir).name; - for (const path of packedPaths(packageDir)) { - hashPath(hash, packageDir, path, namespace); - } - } - hashPath( - hash, - imageDir, - join(imageDir, "Dockerfile"), - "@moltzap/simulator/server-image", - ); - hashPath( - hash, - imageDir, - join(imageDir, "moltzap.yaml"), - "@moltzap/simulator/server-image", - ); - hashPath( - hash, - packageRoot, - fileURLToPath(import.meta.url), - "@moltzap/simulator", - ); - return hash.digest("hex").slice(0, 16); -} - -async function imageExists(image) { - try { - await exec("docker", ["image", "inspect", image], { - timeout: INSPECT_TIMEOUT_MS, - }); - return true; - } catch { - return false; - } -} - -async function packInto(packageDir, destination) { - if (workspaceRoot === undefined) { - const { stdout } = await exec( - "npm", - ["pack", "--pack-destination", destination, "--json", "--ignore-scripts"], - { cwd: packageDir, timeout: PACK_TIMEOUT_MS }, - ); - const packed = JSON.parse(stdout); - const filename = Array.isArray(packed) ? packed[0]?.filename : undefined; - if (typeof filename !== "string" || !filename.endsWith(".tgz")) { - throw new Error(`npm pack in ${packageDir} returned no tarball path`); - } - return basename(filename); - } - - const { stdout } = await exec( - "pnpm", - ["pack", "--pack-destination", destination], - { cwd: packageDir, timeout: PACK_TIMEOUT_MS }, - ); - const printed = stdout.trim().split("\n").at(-1); - if (printed === undefined || !printed.endsWith(".tgz")) { - throw new Error(`pnpm pack in ${packageDir} printed no tarball path`); - } - return basename(printed); -} - -async function stage(version) { - const staging = await mkdtemp(join(tmpdir(), "moltzap-server-image-")); - const tarballs = join(staging, "tarballs"); - await mkdir(tarballs); - // Independent packs of independent packages; each is a full pnpm startup. - const [protocolTarball, serverTarball] = await Promise.all([ - packInto(protocolDir, tarballs), - packInto(serverDir, tarballs), - ]); - // `overrides` forces the workspace protocol tarball in place of the - // registry version server-core's manifest names, so the image carries - // the tree under test rather than the last published release. - const manifest = { - name: "moltzap-sim-server-image", - version, - private: true, - dependencies: { - "@moltzap/server-core": `file:./tarballs/${serverTarball}`, - }, - overrides: { - "@moltzap/protocol": `file:./tarballs/${protocolTarball}`, - }, - }; - await Promise.all([ - writeFile( - join(staging, "package.json"), - `${JSON.stringify(manifest, null, 2)}\n`, - ), - copyFile(join(imageDir, "Dockerfile"), join(staging, "Dockerfile")), - copyFile(join(imageDir, "moltzap.yaml"), join(staging, "moltzap.yaml")), - ]); - return staging; -} - -async function main() { - if (workspaceRoot !== undefined) { - report("building the workspace server package"); - await exec("pnpm", ["nx", "build", "@moltzap/server-core"], { - cwd: workspaceRoot, - timeout: BUILD_TIMEOUT_MS, - }); - } - const version = readManifest(serverDir).version; - const image = `${IMAGE_REPOSITORY}:${fingerprint()}`; - report("checking the local production-router image cache"); - if (await imageExists(image)) { - report(`reusing cached image ${image}`); - } else { - report("packing the protocol and server packages"); - const staging = await stage(version); - try { - report(`building Docker image ${image}`); - await exec("docker", ["build", "--tag", image, staging], { - timeout: BUILD_TIMEOUT_MS, - }); - } finally { - await rm(staging, { recursive: true, force: true }); - } - } - report("resolving the content-addressed image digest"); - const { stdout } = await exec( - "docker", - ["image", "inspect", "--format", "{{.Id}}", image], - { timeout: INSPECT_TIMEOUT_MS }, - ); - const imageDigest = stdout.trim(); - if (!/^sha256:[0-9a-f]{64}$/.test(imageDigest)) { - throw new Error(`docker reported an unusable image id: ${imageDigest}`); - } - process.stdout.write( - `${JSON.stringify({ image, imageDigest, serverCoreVersion: version })}\n`, - ); -} - -if ( - process.argv[1] !== undefined && - realpathSync(fileURLToPath(import.meta.url)) === - realpathSync(resolve(process.argv[1])) -) { - await main(); -} diff --git a/packages/simulator/scripts/local-create-cluster.mjs b/packages/simulator/scripts/local-create-cluster.mjs new file mode 100644 index 000000000..83ef6c7a1 --- /dev/null +++ b/packages/simulator/scripts/local-create-cluster.mjs @@ -0,0 +1,549 @@ +// Creates the pinned local Kubernetes profile without replacing an existing +// cluster. A failed installation is left intact for inspection. +import { createHash } from "node:crypto"; +import { execFile, spawn } from "node:child_process"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const exec = promisify(execFile); +const packageRoot = dirname(dirname(fileURLToPath(import.meta.url))); +const localRoot = join(packageRoot, "local"); +const profilePath = join(localRoot, "profile.json"); +const kindTemplatePath = join(localRoot, "kind-config.yaml"); +const queuePath = join(localRoot, "queue.yaml"); +const temporalPath = join(localRoot, "temporal.yaml"); +const toolsRoot = join(localRoot, ".tools"); +const DEFAULT_ARTIFACTS = join(localRoot, "artifacts"); +const ARTIFACT_TOKEN = "__MOLTZAP_ARTIFACTS__"; +const SHA256 = /^[0-9a-f]{64}$/; +const PINNED_IMAGE = /^.+@sha256:[0-9a-f]{64}$/; +const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/; +const IMAGE_DISCOVERY_ATTEMPTS = 30; +const IMAGE_DISCOVERY_INTERVAL_MS = 500; + +function report(message) { + process.stderr.write(`[moltzap local] ${message}\n`); +} + +function record(value, label) { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + throw new TypeError(`${label} must be an object`); + } + return value; +} + +function text(value, label) { + if (typeof value !== "string" || value.length === 0) { + throw new TypeError(`${label} must be a nonempty string`); + } + return value; +} + +function digest(value, label) { + const encoded = text(value, label); + if (!SHA256.test(encoded)) { + throw new TypeError(`${label} must be a lowercase SHA-256 digest`); + } + return encoded; +} + +function platformAsset(section, label) { + const key = `${process.platform}-${process.arch}`; + const binaries = record(section.binaries, `${label}.binaries`); + const asset = record(binaries[key], `${label}.binaries.${key}`); + return { + url: text(asset.url, `${label} binary URL`), + sha256: digest(asset.sha256, `${label} binary checksum`), + }; +} + +function validateProfile(value) { + const profile = record(value, "local profile"); + if (profile.apiVersion !== "moltzap.local-profile/v1") { + throw new TypeError("unsupported local profile apiVersion"); + } + const kind = record(profile.kind, "local profile kind"); + const kubectl = record(profile.kubectl, "local profile kubectl"); + const kueue = record(profile.kueue, "local profile Kueue"); + const agentSandbox = record( + profile.agentSandbox, + "local profile Agent Sandbox", + ); + return { + clusterName: text(profile.clusterName, "local profile clusterName"), + kind: { + version: text(kind.version, "local profile kind.version"), + nodeImage: text(kind.nodeImage, "local profile kind.nodeImage"), + asset: platformAsset(kind, "kind"), + }, + kubectl: { + version: text(kubectl.version, "local profile kubectl.version"), + asset: platformAsset(kubectl, "kubectl"), + }, + kueue: { + url: text(kueue.manifestUrl, "local profile Kueue manifest URL"), + sha256: digest( + kueue.manifestSha256, + "local profile Kueue manifest checksum", + ), + }, + agentSandbox: { + url: text( + agentSandbox.manifestUrl, + "local profile Agent Sandbox manifest URL", + ), + sha256: digest( + agentSandbox.manifestSha256, + "local profile Agent Sandbox manifest checksum", + ), + }, + clusterQueue: text(profile.clusterQueue, "local profile clusterQueue"), + localQueue: text(profile.localQueue, "local profile localQueue"), + temporalImage: text(profile.temporalImage, "local profile temporalImage"), + artifactNodePath: text( + profile.artifactNodePath, + "local profile artifactNodePath", + ), + }; +} + +async function readProfile() { + const value = JSON.parse(await readFile(profilePath, "utf8")); + return validateProfile(value); +} + +function hash(bytes) { + return createHash("sha256").update(bytes).digest("hex"); +} + +async function download(url, expectedDigest, destination) { + try { + const present = await readFile(destination); + if (hash(present) === expectedDigest) { + return destination; + } + } catch (cause) { + if (cause?.code !== "ENOENT") { + throw cause; + } + } + + const response = await fetch(url, { redirect: "follow" }); + if (!response.ok) { + throw new Error(`download failed with HTTP ${String(response.status)}`); + } + const bytes = Buffer.from(await response.arrayBuffer()); + if (hash(bytes) !== expectedDigest) { + throw new Error("download did not match its pinned SHA-256 digest"); + } + const temporary = `${destination}.${String(process.pid)}.tmp`; + await writeFile(temporary, bytes, { mode: 0o600 }); + await rename(temporary, destination); + return destination; +} + +async function tool(name, version, asset) { + const directory = join(toolsRoot, `${process.platform}-${process.arch}`); + await mkdir(directory, { recursive: true }); + const destination = join(directory, `${name}-${version}`); + await download(asset.url, asset.sha256, destination); + await chmod(destination, 0o755); + return destination; +} + +function run(command, args) { + return new Promise((resolveRun, rejectRun) => { + const child = spawn(command, args, { stdio: "inherit" }); + child.once("error", rejectRun); + child.once("exit", (code, signal) => { + if (code === 0) { + resolveRun(); + } else { + rejectRun( + new Error( + `${command} stopped with ${signal ?? `exit ${String(code)}`}`, + ), + ); + } + }); + }); +} + +function parseArguments(args, defaults) { + const options = { + artifacts: DEFAULT_ARTIFACTS, + cluster: defaults.clusterName, + image: undefined, + }; + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (value === undefined) { + throw new TypeError(`${flag ?? "argument"} needs a value`); + } + if (flag === "--artifacts") { + options.artifacts = resolve(value); + } else if (flag === "--cluster") { + options.cluster = value; + } else if (flag === "--image") { + options.image = value; + } else { + throw new TypeError(`unknown local cluster option ${flag}`); + } + } + if (options.cluster.length > 63 || !DNS_LABEL.test(options.cluster)) { + throw new TypeError("local cluster name must be one Kubernetes DNS label"); + } + if (options.image !== undefined && !PINNED_IMAGE.test(options.image)) { + throw new TypeError("--image must be a SHA-256 digest-pinned image"); + } + return options; +} + +export function normalizeContainerdReference(reference) { + const slash = reference.indexOf("/"); + let domain; + let remoteName; + if (slash === -1) { + domain = "docker.io"; + remoteName = reference; + } else { + const possibleDomain = reference.slice(0, slash); + const possibleRemoteName = reference.slice(slash + 1); + if (possibleDomain === "index.docker.io") { + domain = "docker.io"; + remoteName = possibleRemoteName; + } else if ( + possibleDomain === "localhost" || + possibleDomain.includes(".") || + possibleDomain.includes(":") || + possibleDomain.toLowerCase() !== possibleDomain + ) { + domain = possibleDomain; + remoteName = possibleRemoteName; + } else { + domain = "docker.io"; + remoteName = reference; + } + } + if (domain === "docker.io" && !remoteName.includes("/")) { + remoteName = `library/${remoteName}`; + } + return `${domain}/${remoteName}`; +} + +function repositoryReference(reference) { + const digest = reference.indexOf("@"); + const named = digest === -1 ? reference : reference.slice(0, digest); + const tag = named.lastIndexOf(":"); + return tag > named.lastIndexOf("/") ? named.slice(0, tag) : named; +} + +export function selectLocalImageTag(tags, pinnedImage) { + const candidates = tags.filter( + (tag) => typeof tag === "string" && tag.length > 0 && !tag.includes("@"), + ); + if (candidates.length === 0) { + throw new Error("controller image has no local repository tag"); + } + const repository = normalizeContainerdReference( + repositoryReference(pinnedImage), + ); + return ( + candidates.find( + (tag) => + normalizeContainerdReference(repositoryReference(tag)) === repository, + ) ?? candidates[0] + ); +} + +async function localImageTag(image) { + const { stdout } = await exec( + "docker", + ["image", "inspect", "--format", "{{json .RepoTags}}", image], + { timeout: 30_000 }, + ); + let tags; + try { + tags = JSON.parse(stdout); + } catch (cause) { + throw new Error("Docker returned invalid controller image tags", { cause }); + } + if (!Array.isArray(tags)) { + throw new Error("Docker returned invalid controller image tags"); + } + return selectLocalImageTag(tags, image); +} + +export async function retryImageDiscovery( + discover, + { + attempts = IMAGE_DISCOVERY_ATTEMPTS, + intervalMs = IMAGE_DISCOVERY_INTERVAL_MS, + pause = delay, + } = {}, +) { + let lastFailure; + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + await discover(); + return; + } catch (cause) { + lastFailure = cause; + if (attempt + 1 < attempts) { + await pause(intervalMs); + } + } + } + throw new Error( + `image did not become discoverable after ${String(attempts)} attempts`, + { cause: lastFailure }, + ); +} + +async function makePinnedImageDiscoverable(kind, cluster, source, image) { + const sourceReference = normalizeContainerdReference(source); + const digestReference = normalizeContainerdReference(image); + const { stdout: nodeOutput } = await exec( + kind, + ["get", "nodes", "--name", cluster], + { timeout: 30_000 }, + ); + const nodes = nodeOutput.split(/\s+/u).filter(Boolean); + if (nodes.length === 0) { + throw new Error("kind returned no nodes for the new cluster"); + } + for (const node of nodes) { + try { + await retryImageDiscovery(async () => { + await exec( + "docker", + [ + "exec", + node, + "ctr", + "-n", + "k8s.io", + "images", + "tag", + "--force", + "--skip-reference-check", + sourceReference, + digestReference, + ], + { timeout: 5_000 }, + ); + await exec( + "docker", + ["exec", node, "crictl", "inspecti", digestReference], + { timeout: 5_000 }, + ); + }); + } catch (cause) { + throw new Error( + `controller image did not become discoverable on kind node ${node}`, + { cause }, + ); + } + } +} + +async function renderKindConfiguration(artifacts, destination, profile) { + const template = await readFile(kindTemplatePath, "utf8"); + if ( + !template.includes(ARTIFACT_TOKEN) || + !template.includes(profile.kind.nodeImage) + ) { + throw new Error("kind configuration does not match the pinned profile"); + } + await writeFile( + destination, + template.replaceAll(ARTIFACT_TOKEN, JSON.stringify(artifacts)), + ); +} + +async function assertNewCluster(kind, name) { + const { stdout } = await exec(kind, ["get", "clusters"], { + timeout: 30_000, + }); + const clusters = stdout.split(/\s+/u).filter(Boolean); + if (clusters.includes(name)) { + throw new Error( + `kind cluster ${name} already exists; this setup never replaces it`, + ); + } +} + +async function kubectlApply(kubectl, context, path) { + await run(kubectl, [ + "--context", + context, + "apply", + "--server-side", + "-f", + path, + ]); +} + +async function rollout(kubectl, context, namespace, deployment) { + await run(kubectl, [ + "--context", + context, + "--namespace", + namespace, + "rollout", + "status", + `deployment/${deployment}`, + "--timeout=5m", + ]); +} + +async function waitForKueueWebhook(kubectl, context) { + let lastFailure; + for (let attempt = 0; attempt < 60; attempt += 1) { + try { + await exec( + kubectl, + [ + "--context", + context, + "create", + "deployment", + "moltzap-kueue-webhook-probe", + "--image=example.invalid/moltzap-probe:never", + "--dry-run=server", + "--output=name", + ], + { timeout: 10_000 }, + ); + return; + } catch (cause) { + lastFailure = cause; + await delay(1_000); + } + } + throw new Error("Kueue admission webhook did not become ready within 60s", { + cause: lastFailure, + }); +} + +async function installProfile(kubectl, context, profile, temporary) { + const kueueManifest = join(temporary, "kueue.yaml"); + const sandboxManifest = join(temporary, "agent-sandbox.yaml"); + report("downloading checksum-pinned controller manifests"); + await Promise.all([ + download(profile.kueue.url, profile.kueue.sha256, kueueManifest), + download( + profile.agentSandbox.url, + profile.agentSandbox.sha256, + sandboxManifest, + ), + ]); + + report("installing Kueue"); + await kubectlApply(kubectl, context, kueueManifest); + await rollout(kubectl, context, "kueue-system", "kueue-controller-manager"); + await waitForKueueWebhook(kubectl, context); + + report("installing Agent Sandbox"); + await kubectlApply(kubectl, context, sandboxManifest); + await rollout( + kubectl, + context, + "agent-sandbox-system", + "agent-sandbox-controller", + ); + + report("installing local queue capacity and Temporal"); + await kubectlApply(kubectl, context, queuePath); + await kubectlApply(kubectl, context, temporalPath); + await rollout(kubectl, context, "moltzap-system", "temporal"); +} + +async function main() { + const profile = await readProfile(); + const options = parseArguments(process.argv.slice(2), profile); + const [kind, kubectl] = await Promise.all([ + tool("kind", profile.kind.version, profile.kind.asset), + tool("kubectl", profile.kubectl.version, profile.kubectl.asset), + ]); + await exec("docker", ["info"], { timeout: 30_000 }); + const imageSource = + options.image === undefined + ? undefined + : await localImageTag(options.image); + await assertNewCluster(kind, options.cluster); + await mkdir(options.artifacts, { recursive: true }); + const artifacts = await realpath(options.artifacts); + const temporary = await mkdtemp(join(tmpdir(), "moltzap-local-cluster-")); + const renderedKind = join(temporary, "kind.yaml"); + const context = `kind-${options.cluster}`; + try { + await renderKindConfiguration(artifacts, renderedKind, profile); + report(`creating kind cluster ${options.cluster}`); + await run(kind, [ + "create", + "cluster", + "--name", + options.cluster, + "--config", + renderedKind, + "--wait", + "5m", + ]); + await installProfile(kubectl, context, profile, temporary); + if (options.image !== undefined && imageSource !== undefined) { + report(`loading controller image ${imageSource}`); + await run(kind, [ + "load", + "docker-image", + imageSource, + "--name", + options.cluster, + ]); + await makePinnedImageDiscoverable( + kind, + options.cluster, + imageSource, + options.image, + ); + } + } finally { + await rm(temporary, { recursive: true, force: true }); + } + + process.stdout.write( + `${JSON.stringify({ + cluster: options.cluster, + context, + kindBinary: kind, + kubectlBinary: kubectl, + loadedImage: options.image, + artifacts, + artifactNodePath: profile.artifactNodePath, + clusterQueue: profile.clusterQueue, + localQueue: profile.localQueue, + temporalAddress: "127.0.0.1:7233", + })}\n`, + ); +} + +if ( + process.argv[1] !== undefined && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + await main(); +} diff --git a/packages/simulator/server-image/Dockerfile b/packages/simulator/server-image/Dockerfile deleted file mode 100644 index 67a367b07..000000000 --- a/packages/simulator/server-image/Dockerfile +++ /dev/null @@ -1,27 +0,0 @@ -# The simulator's per-run production router image contains the -# `@moltzap/server-core` binary and its run-specific configuration. The image -# builder stages version-matched package tarballs beside this file and returns -# the local content-addressed image id used by the run ledger. -FROM node:22-slim - -ENV NODE_ENV=production \ - MOLTZAP_CONFIG=/etc/moltzap/moltzap.yaml - -WORKDIR /srv/moltzap - -COPY package.json ./ -COPY tarballs ./tarballs -RUN npm install --omit=dev --no-audit --no-fund \ - && npm cache clean --force \ - && rm -rf tarballs - -COPY moltzap.yaml /etc/moltzap/moltzap.yaml - -# The data directory the config pins; the launcher bind-mounts the run's -# storage directory here and the transcript drain reads it post-stop. -RUN mkdir -p /data -VOLUME ["/data"] - -EXPOSE 3000 - -ENTRYPOINT ["node", "/srv/moltzap/node_modules/@moltzap/server-core/bin/moltzap-server"] diff --git a/packages/simulator/server-image/moltzap.yaml b/packages/simulator/server-image/moltzap.yaml deleted file mode 100644 index aef97fbb6..000000000 --- a/packages/simulator/server-image/moltzap.yaml +++ /dev/null @@ -1,24 +0,0 @@ -# Server config baked into the simulator's per-run server image. -# -# Three settings are load-bearing for the simulator and are not operator -# taste: -# - `database.data_dir` sits under the image's `/data` volume, which the -# launcher bind-mounts from the run's storage directory; the transcript -# drain reads that PGlite directory after the container stops. -# - no `encryption:` block, so message content stays plaintext at rest and -# the drain can read it. The container is ephemeral and per-run; the -# launcher keeps credentials inside the scope that owns each runtime. -# - `registration.secret` comes from a per-run value held by the launcher, -# so only the production-server boundary can mint participant identities. -admin_user_id: 5f1cbf1e-0d68-4b04-9c1a-2a0f5f0a1c31 -registration: - secret: "${MOLTZAP_REGISTRATION_SECRET}" -server: - port: 3000 - # The simulator's clients are processes, not browsers, and the container - # is per-run and published on host loopback only; the server still - # requires the setting outside dev mode. - cors_origins: - - "*" -database: - data_dir: /data/pglite diff --git a/packages/simulator/src/MODULE.md b/packages/simulator/src/MODULE.md index 37329fe36..4a51770bf 100644 --- a/packages/simulator/src/MODULE.md +++ b/packages/simulator/src/MODULE.md @@ -8,7 +8,7 @@ Code-first simulator API. ## Public surface -### [`AgentConnection`](./network/router.ts#L121) +### [`AgentConnection`](./network/router.ts#L80) _Interface_ @@ -154,7 +154,48 @@ export class AgentRuntimeStartFailed extends Schema.TaggedClass { + override get message(): string { + return this.detail; + } +} +``` + +Cluster loss that ends a run without exposing its backend. + +### [`ClusterLost`](./run/execute.ts#L97) + +_Class_ + +```ts +export class ClusterLost< + Definitions extends Readonly>, +> extends Data.TaggedClass("ClusterLost")<{ + readonly cause: Cause.Cause>; + readonly receipt: LedgerReceipt; +}> {} +``` + +Post-allocation cluster error plus all durable evidence retained. + +### [`ClusterServices`](./definition.ts#L76) + +_TypeAlias_ + +```ts +export type ClusterServices = LedgerStorage | RouterProvider | Cluster; +``` + +Opaque service set supplied by a local-Kubernetes or GKE Layer. + +### [`CompletedLedgerReceipt`](./run/execute.ts#L64) _Class_ @@ -170,7 +211,7 @@ export class CompletedLedgerReceipt extends Schema.TaggedClass() A participant allocated a conversation address for a nonempty group. -### [`ConversationParticipants`](./network/conversation.ts#L29) +### [`ConversationParticipants`](./network/conversation.ts#L25) _TypeAlias_ @@ -231,7 +272,7 @@ export type ConversationParticipants = readonly [ Every conversation has at least one participant of any network role. -### [`ConversationSocket`](./network/conversation.ts#L99) +### [`ConversationSocket`](./network/conversation.ts#L95) _Class_ @@ -243,21 +284,21 @@ export class ConversationSocket { * The ordered receive cursor for this endpoint and conversation. Repeated * consumption advances the cursor instead of replaying old delivery. */ - readonly messages: Stream.Stream; + readonly messages: Stream.Stream; readonly endpoint: ParticipantHandle; readonly address: ConversationAddress; private readonly sendMessage: ( content: MessageParts, - ) => Effect.Effect; + ) => Effect.Effect; private constructor( endpoint: ParticipantHandle, address: ConversationAddress, - messages: Stream.Stream, + messages: Stream.Stream, sendMessage: ( content: MessageParts, - ) => Effect.Effect, + ) => Effect.Effect, ) { this.endpoint = endpoint; this.address = address; @@ -268,10 +309,10 @@ export class ConversationSocket { static [conversationSocketConstruction]( endpoint: ParticipantHandle, address: ConversationAddress, - messages: Stream.Stream, + messages: Stream.Stream, sendMessage: ( content: MessageParts, - ) => Effect.Effect, + ) => Effect.Effect, ): ConversationSocket { return new ConversationSocket(endpoint, address, messages, sendMessage); } @@ -281,7 +322,7 @@ export class ConversationSocket { * @param content Value supplied to the operation. * @returns The created conversation socket. */ - send(content: string | MessageParts): Effect.Effect { + send(content: string | MessageParts): Effect.Effect { return validateParts(parts(content)).pipe(Effect.flatMap(this.sendMessage)); } @@ -290,14 +331,14 @@ export class ConversationSocket { * consuming Effect, so the socket never skips an earlier message. * @returns The created conversation socket. */ - receive(): Effect.Effect { + receive(): Effect.Effect { return this.messages.pipe( Stream.runHead, Effect.flatMap( Option.match({ onNone: () => Effect.fail( - networkFailure( + networkError( "receive", `conversation ${this.address.conversationId} ended before another message arrived`, ), @@ -328,7 +369,7 @@ export const coreEvents = EventCatalog.merge( The exact event classes readable from every simulator run ledger. -### [`CustomerEvents`](./kernel/event-services.ts#L38) +### [`CustomerEvents`](./run/events.ts#L42) _Interface_ @@ -355,7 +396,7 @@ export type EncodedEventOf = Schema.Schema.Encoded< The closed encoded union persisted for a catalog. -### [`Endpoint`](./network/endpoint.ts#L54) +### [`Endpoint`](./network/endpoint.ts#L53) _Class_ @@ -389,7 +430,7 @@ export class Endpoint { * sockets retain their own ordered delivery queues independently. * @returns Live endpoint delivery stream. */ - messages(): Stream.Stream { + messages(): Stream.Stream { return this.inbox.messages; } @@ -401,7 +442,7 @@ export class Endpoint { */ open( ...participants: ConversationParticipants - ): Effect.Effect { + ): Effect.Effect { const [first, ...rest] = participants; const ids: ParticipantIds = [ first.id, @@ -439,7 +480,7 @@ export class Endpoint { */ socket( address: ConversationAddress, - ): Effect.Effect { + ): Effect.Effect { const isParticipant = address.participants.some( (participant) => participant.id === this.participant.id, ); @@ -458,7 +499,7 @@ export class Endpoint { ), ) : Effect.fail( - networkFailure( + networkError( "socket", `participant ${this.participant.name} is not addressed by the conversation`, ), @@ -506,7 +547,7 @@ export class EndpointMessageSent extends Schema.TaggedClass A controlled endpoint committed a message through the data plane. -### [`EventCatalog`](./events/catalog.ts#L152) +### [`EventCatalog`](./events/catalog.ts#L130) _Class_ @@ -601,7 +642,7 @@ The exact immutable event universe for one definition. The private type identifier makes catalog arguments nominal: a structural object cannot claim a schema, constructor list, and tag list that disagree. -### [`EventCatalogDefinitionError`](./events/catalog.ts#L54) +### [`EventCatalogDefinitionError`](./events/catalog.ts#L59) _Class_ @@ -609,25 +650,12 @@ _Class_ export class EventCatalogDefinitionError extends Schema.TaggedError()( "EventCatalogDefinitionError", { - failure: Schema.Literal( - "duplicate-tag", - "invalid-event-class", - "invalid-tag", - ), + failure: Schema.Literal("duplicate-tag", "invalid-tag"), tag: Schema.String, }, ) { override get message(): string { - switch (this.failure) { - case "duplicate-tag": - return `Duplicate event tag "${this.tag}"`; - case "invalid-event-class": - return `Event catalog member "${this.tag}" is not a schema-backed class`; - case "invalid-tag": - return `Event tag "${this.tag}" must be namespaced and versioned, for example "acme.consensus-reached/v1"`; - default: - return `Unknown event catalog failure "${this.failure}" for "${this.tag}"`; - } + return definitionFailureMessage[this.failure](this.tag); } } ``` @@ -639,10 +667,7 @@ Invalid catalogs fail during definition construction, before a run starts. _TypeAlias_ ```ts -export type EventCatalogDefinitionFailure = - | "duplicate-tag" - | "invalid-event-class" - | "invalid-tag"; +export type EventCatalogDefinitionFailure = "duplicate-tag" | "invalid-tag"; ``` Represents event catalog definition failure conditions. @@ -670,7 +695,7 @@ export type EventClassOf = CatalogClassesOf; The closed constructor union declared by a catalog. -### [`EventMetadata`](./kernel/event-services.ts#L22) +### [`EventMetadata`](./run/events.ts#L26) _Interface_ @@ -693,7 +718,7 @@ export type EventOf = Schema.Schema.Type>; The closed instance union declared by a catalog. -### [`IncompleteLedgerReceipt`](./kernel/run.ts#L76) +### [`IncompleteLedgerReceipt`](./run/execute.ts#L73) _Class_ @@ -708,7 +733,7 @@ export class IncompleteLedgerReceipt extends Schema.TaggedClass Effect.Effect; + ) => Effect.Effect; /** Delay every delivery on one directed link for the current Scope. */ readonly delay: ( from: ParticipantHandle, to: ParticipantHandle, duration: Duration.DurationInput, - ) => Effect.Effect; + ) => Effect.Effect; /** Park every delivery on one directed link for the current Scope. */ readonly hold: ( from: ParticipantHandle, to: ParticipantHandle, - ) => Effect.Effect; + ) => Effect.Effect; /** Install one custom policy on a directed link for the current Scope. */ readonly shape: ( from: ParticipantHandle, to: ParticipantHandle, policy: LinkPolicy, description: string, - ) => Effect.Effect; + ) => Effect.Effect; } ``` @@ -986,7 +1011,7 @@ export type MessageParts = Schema.Schema.Type; Nonempty protocol message content. -### [`Network`](./network/endpoint.ts#L185) +### [`Network`](./network/endpoint.ts#L184) _Class_ @@ -999,13 +1024,13 @@ export class Network extends Context.Tag("@moltzap/simulator/Network")< Network operations available to the customer program. -### [`NetworkFailure`](./network/router.ts#L50) +### [`NetworkError`](./network/failure.ts#L22) _Class_ ```ts -export class NetworkFailure extends Schema.TaggedError()( - "NetworkFailure", +export class NetworkError extends Schema.TaggedError()( + "NetworkError", { operation: networkOperation, detail: Schema.String, @@ -1019,7 +1044,7 @@ export class NetworkFailure extends Schema.TaggedError()( An operational failure at a network boundary. -### [`NetworkService`](./network/endpoint.ts#L178) +### [`NetworkService`](./network/endpoint.ts#L177) _Interface_ @@ -1027,7 +1052,7 @@ _Interface_ export interface NetworkService { endpoint( name: Name, - ): Effect.Effect, NetworkFailure>; + ): Effect.Effect, NetworkError>; } ``` @@ -1076,7 +1101,7 @@ export class ProgramFailed extends Schema.TaggedClass()( The customer program failed with a typed failure or defect. -### [`ProgramFinished`](./kernel/run.ts#L94) +### [`ProgramFinished`](./run/execute.ts#L91) _Class_ @@ -1117,7 +1142,7 @@ export class ProgramSucceeded extends Schema.TaggedClass()( The customer program returned successfully. -### [`ReadableRunLedger`](./kernel/event-services.ts#L28) +### [`ReadableRunLedger`](./run/events.ts#L32) _Interface_ @@ -1134,7 +1159,7 @@ export interface ReadableRunLedger { Definition-bound read access to every committed core and customer event. -### [`ReceivedMessage`](./network/router.ts#L76) +### [`ReceivedMessage`](./network/router.ts#L35) _Interface_ @@ -1207,84 +1232,98 @@ export class RouterStopFailed extends Schema.TaggedClass()( Router release or stopped-router evidence collection failed. -### [`RunInfrastructureFailed`](./kernel/run.ts#L100) +### [`Run`](./definition.ts#L310) -_Class_ +_Variable_ ```ts -export class RunInfrastructureFailed< - Definitions extends Readonly>, -> extends Data.TaggedClass("RunInfrastructureFailed")<{ - readonly cause: Cause.Cause>; - readonly receipt: LedgerReceipt; -}> {} +export const Run: Readonly<{ execute: typeof executeRunSpec }> = Object.freeze({ + execute: executeRunSpec, +}) ``` -Post-allocation infrastructure failure plus all durable evidence retained. +Discoverable execution entry point for one experiment society. -### [`RunStarted`](./events/core.ts#L12) +### [`RunSpec`](./definition.ts#L150) -_Class_ +_Interface_ ```ts -export class RunStarted extends Schema.TaggedClass()( - "moltzap.run-started/v1", - { - definitionId: Schema.NonEmptyString, - }, -) {} +export interface RunSpec< + Id extends SimulatorDefinitionId = SimulatorDefinitionId, + CustomerCatalogs extends + readonly AnyEventCatalog[] = readonly AnyEventCatalog[], + Definitions extends Readonly> = Readonly< + Record + >, + A = unknown, + E = unknown, + R = never, + ClusterLayer extends Layer.Layer< + never, + unknown, + unknown + > = Layer.Layer, +> { + /** + * Present only on the exact values RunSpec.define produced, and carrying + * their runner. This is the one identity gate: nothing structural + * distinguishes a definition from a lookalike, and a lookalike has no + * runner to invoke. + */ + readonly [runSpecTypeId]?: () => RunSpecExecution< + Id, + CustomerCatalogs, + Definitions, + A, + E, + R, + ClusterLayer + >; + readonly id: Id; + readonly events: CustomerCatalogs; + readonly agents: Definitions; + readonly cluster: ClusterLayer & + Layer.Layer< + ClusterServices, + Layer.Layer.Error, + Layer.Layer.Context + >; + readonly execute: ( + context: RunExecutionContext, + ) => Effect.Effect; +} ``` -The run ledger is allocated and run-scoped acquisition has begun. +Immutable code-first definition of one experiment society. -### [`simulator`](./definition.ts#L234) +### [`RunSpec`](./definition.ts#L305) _Variable_ ```ts -export const simulator: Readonly<{ define: typeof defineSimulator }> = - Object.freeze({ - define: defineSimulator, - }) +export const RunSpec: Readonly<{ define: typeof defineRunSpec }> = + Object.freeze({ define: defineRunSpec }) ``` -Discoverable entry point for code-first society definitions. +Discoverable constructor for immutable experiment definitions. -### [`SimulatorDefinition`](./definition.ts#L169) +### [`RunStarted`](./events/core.ts#L12) -_Interface_ +_Class_ ```ts -export interface SimulatorDefinition< - Id extends SimulatorDefinitionId, - CustomerCatalogs extends readonly AnyEventCatalog[], -> { - readonly id: Id; - readonly catalog: DefinitionEventServices["catalog"]; - readonly customerCatalog: CustomerEventCatalog; - readonly ledger: DefinitionEventServices["ledger"]; - readonly events: DefinitionEventServices["events"]; - readonly agents: ReturnType>; - readonly run: ReturnType< - typeof makeRunner< - Id, - CatalogSchemaOf>, - CatalogClassesOf> - > - >; - readonly openLedger: ReturnType< - typeof makeLedgerReader< - Id, - CatalogSchemaOf>, - CatalogClassesOf> - > - >; -} +export class RunStarted extends Schema.TaggedClass()( + "moltzap.run-started/v1", + { + definitionId: Schema.NonEmptyString, + }, +) {} ``` -Definition-bound capabilities for one versioned family of simulator runs. +The run ledger is allocated and run-scoped acquisition has begun. -### [`SimulatorDefinitionError`](./definition.ts#L27) +### [`SimulatorDefinitionError`](./definition.ts#L28) _Class_ @@ -1304,7 +1343,7 @@ export class SimulatorDefinitionError extends Schema.TaggedError>, -> = AgentRosterAcquisitionError | LedgerFailure | NetworkFailure; +> = ``` Represents simulator run failure conditions. -### [`SimulatorRunOptions`](./kernel/run.ts#L61) - -_Interface_ - -```ts -export interface SimulatorRunOptions { - readonly provenance?: JsonObject; - readonly metadata?: JsonObject; -} -``` - -Optional run metadata; platform and runtime policy belong in Layers. - -### [`SimulatorRunOutcome`](./kernel/run.ts#L108) +### [`SimulatorRunOutcome`](./run/execute.ts#L105) _TypeAlias_ @@ -1374,7 +1374,7 @@ export type SimulatorRunOutcome< A, E, Definitions extends Readonly>, -> = ProgramFinished | RunInfrastructureFailed; +> = ProgramFinished | ClusterLost; ``` Closed result of every run whose ledger allocation succeeded. @@ -1391,15 +1391,16 @@ Stable persisted identity for an event class. ## Files +- `cluster.ts` - `definition.ts` - `catalog.ts` - `core.ts` -- `event-services.ts` -- `run.ts` -- `layer.ts` -- `live.ts` +- `append.ts` - `conversation.ts` - `endpoint.ts` +- `failure.ts` - `link.ts` - `participant.ts` - `router.ts` +- `events.ts` +- `execute.ts` diff --git a/packages/simulator/src/agents.ts b/packages/simulator/src/agents.ts new file mode 100644 index 000000000..c497aff5a --- /dev/null +++ b/packages/simulator/src/agents.ts @@ -0,0 +1,76 @@ +/** @file Autonomous agent runtime contracts and shipped implementations. */ + +/** Re-exports the public API from `./agents/agent.js`. */ +export { + AgentRuntimeDefinitionError, + RuntimeCompleted, + RuntimeExited, + RuntimeFailed, + RuntimeSignaled, + runtimeConfigurationProjection, + type AgentRuntime, + type AgentRuntimeInput, + type RunningAgent, + type RuntimeTermination, +} from "./agents/agent.js"; + +/** Re-exports the container descriptor boundary from `./agents/container.js`. */ +export { + defineContainerRuntime, + image, + routableBridgeEndpoint, + stoppedBeforeAttach, + type Application, + type ApplicationEndpoint, + type ContainerAgentRuntime, + type ContainerRuntime, + type CredentialName, + type File, + type Image, + type Resources, +} from "./agents/container.js"; + +/** Re-exports the public API from `./agents/roster.js`. */ +export type { + AgentRoster, + AgentRosterAcquisitionError, + AgentsService, + RuntimeGatewayOf, + StartedAgent, + StartedAgents, +} from "./agents/roster.js"; + +/** Re-exports the public API from `./agents/openclaw/runtime.js`. */ +export { + openClawRuntime, + type OpenClawRuntimeOptions, + type OpenClawSandboxConfig, + type OpenClawToolsConfig, +} from "./agents/openclaw/runtime.js"; + +/** Re-exports the public API from `./agents/openclaw/gateway.js`. */ +export { + OpenClawGatewayRequest, + OpenClawGatewayRequestError, + OpenClawGatewayResponse, + OpenClawGatewaySucceeded, + OpenClawGatewayTimedOut, + type OpenClawGateway, +} from "./agents/openclaw/gateway.js"; + +/** Re-exports the public API from `./agents/nanoclaw/runtime.js`. */ +export { + nanoclawRuntime, + type NanoClawRuntimeOptions, +} from "./agents/nanoclaw/runtime.js"; + +/** Re-exports the public API from `./agents/nanoclaw/gateway.js`. */ +export { + NanoClawGatewayError, + NanoClawGatewayInput, + NanoClawGatewayOutput, + type NanoClawGateway, +} from "./agents/nanoclaw/gateway.js"; + +/** Re-exports the runtime acquisition failure from `./agents/agent.js`. */ +export { RuntimeAcquisitionError } from "./agents/agent.js"; diff --git a/packages/simulator/src/agents/agent.test.ts b/packages/simulator/src/agents/agent.test.ts new file mode 100644 index 000000000..5e2bf0d12 --- /dev/null +++ b/packages/simulator/src/agents/agent.test.ts @@ -0,0 +1,127 @@ +import { assert, it } from "@effect/vitest"; +import { Schema } from "effect"; +import { + AgentRuntimeDefinitionError, + defineRuntime, + runtimeConfigurationProjection, +} from "./agent.js"; +import { makeAgentRosterBuilder } from "./roster.js"; + +const testRuntimeConfiguration = Schema.Struct({ + label: Schema.String, +}); +const configuration = { + schema: testRuntimeConfiguration, + value: { label: "test" }, +}; + +function isDeeplyFrozen(value: unknown): boolean { + if (typeof value !== "object" || value === null) { + return true; + } + return ( + Object.isFrozen(value) && + Object.values(value).every((member) => isDeeplyFrozen(member)) + ); +} + +// @agent-code-guard/regression-only: immutable metadata and invalid declarations pin container runtime construction invariants +it("validates roster keys when the definition constructs its roster", () => { + const runtime = defineRuntime< + undefined, + never, + typeof testRuntimeConfiguration + >({ + name: "test", + configuration, + }); + const makeRoster = makeAgentRosterBuilder("acme.society/v1"); + + assert.throws(() => + makeRoster({ + "Not Wire Safe": runtime, + }), + ); +}); + +it("rejects empty runtime names before a run starts", () => { + assert.throws( + () => + defineRuntime({ + name: "", + configuration, + }), + AgentRuntimeDefinitionError, + ); +}); + +it("copies and freezes roster declarations without mutating caller input", () => { + const runtime = defineRuntime({ + name: "immutable", + configuration, + }); + const definitions = { alice: runtime }; + const roster = makeAgentRosterBuilder("acme.society/v1")(definitions); + + assert.isFalse(Object.isFrozen(definitions)); + assert.notStrictEqual(roster.definitions, definitions); + assert.strictEqual(roster.definitions.alice, runtime); + assert.isTrue(Object.isFrozen(roster.definitions)); +}); + +it("rejects runtime configurations that do not encode to JSON", () => { + assert.throws( + () => + defineRuntime({ + name: "invalid-configuration", + configuration: { + schema: Schema.Undefined, + value: undefined, + }, + }), + AgentRuntimeDefinitionError, + ); +}); + +it("isolates the canonical projection and every native configuration view", () => { + const mutableConfiguration = Schema.Struct({ + nested: Schema.Struct({ + labels: Schema.Array(Schema.String), + }), + at: Schema.Date, + }); + const source = { + nested: { + labels: ["original"], + }, + at: new Date("2026-01-01T00:00:00.000Z"), + }; + const runtime = defineRuntime({ + name: "snapshotted-configuration", + configuration: { + schema: mutableConfiguration, + value: source, + }, + }); + + source.nested.labels.push("source-mutation"); + source.at.setUTCFullYear(2027); + const first = runtime.configuration.value; + const projection = runtimeConfigurationProjection(runtime); + + assert.isTrue(isDeeplyFrozen(projection)); + assert.isTrue(Reflect.set(first.nested.labels, "0", "native-mutation")); + first.at.setUTCFullYear(2030); + if (typeof projection === "object" && projection !== null) { + assert.isFalse(Reflect.set(projection, "nested", null)); + } + assert.deepStrictEqual(runtime.configuration.value, { + nested: { labels: ["original"] }, + at: new Date("2026-01-01T00:00:00.000Z"), + }); + assert.deepStrictEqual(runtimeConfigurationProjection(runtime), { + nested: { labels: ["original"] }, + at: "2026-01-01T00:00:00.000Z", + }); + assert.notStrictEqual(runtime.configuration.value, first); +}); diff --git a/packages/simulator/src/runtime/runtime.ts b/packages/simulator/src/agents/agent.ts similarity index 78% rename from packages/simulator/src/runtime/runtime.ts rename to packages/simulator/src/agents/agent.ts index 451453d39..25f337291 100644 --- a/packages/simulator/src/runtime/runtime.ts +++ b/packages/simulator/src/agents/agent.ts @@ -1,8 +1,12 @@ /** @file Scoped autonomous-agent runtime contract. */ +// safer-arch-ignore no-cross-domain-sibling-import: A runtime contract is defined by what it receives: the ledger's JSON configuration shape and the network's connection and inbound-link types. import type { AgentName } from "@moltzap/protocol/identity"; import { type Effect, Either, Schema, type Scope } from "effect"; -import { jsonValue, type JsonValue as JsonValueType } from "../ledger/model.js"; +import { + jsonValue, + type JsonValue as JsonValueType, +} from "../ledger/schema.js"; import type { InboundLinkStage } from "../network/link.js"; import type { AgentConnection } from "../network/router.js"; @@ -19,12 +23,10 @@ const agentRuntimeTypesTypeId: unique symbol = Symbol( interface AgentRuntimeTypes< Gateway, AcquisitionError, - Requirements, ConfigurationSchema extends Schema.Schema.AnyNoContext, > { readonly gateway?: Gateway; readonly acquisitionError?: AcquisitionError; - readonly requirements?: Requirements; readonly configurationSchema?: ConfigurationSchema; } @@ -83,16 +85,16 @@ export interface RunningAgent { readonly termination: Effect.Effect; } -/** Router attachment issued to every autonomous runtime implementation. */ +/** Router attachment presented to a runtime's private container realization. */ export interface AgentRuntimeInput { readonly agentName: AgentName; readonly connection: AgentConnection; /** * Scoped acquisition of the stage that applies the run's directed-link - * policies to this agent's inbound deliveries. An implementation whose agent - * receives in this process acquires it and wraps the stream it hands the - * agent; one whose agent receives in another process leaves it unacquired, - * so link control over that agent fails instead of shaping nothing. + * policies to this agent's inbound deliveries. A runtime whose agent receives + * in this process acquires it and wraps the stream it hands the agent; one + * whose agent receives in another process leaves it unacquired, so link + * control over that agent fails instead of shaping nothing. */ readonly interceptInbound?: Effect.Effect< InboundLinkStage, @@ -110,39 +112,33 @@ interface AgentRuntimeConfiguration< } /** - * Scoped acquisition returns only after the runtime is ready. Implementations - * own runtime-specific configuration and startup deadlines in their - * constructors and register teardown in the acquisition Scope. + * Public metadata for a runtime whose container realization is owned by its + * implementation. Platform acquisition is deliberately absent here. */ export interface AgentRuntimeDefinition< Gateway, AcquisitionError = never, - Requirements = never, ConfigurationSchema extends Schema.Schema.AnyNoContext = Schema.Schema.AnyNoContext, > { - readonly name: string; - readonly configuration: AgentRuntimeConfiguration; - acquire( - input: AgentRuntimeInput, - ): Effect.Effect< - RunningAgent, + readonly [agentRuntimeTypesTypeId]?: AgentRuntimeTypes< + Gateway, AcquisitionError, - Scope.Scope | Requirements + ConfigurationSchema >; + readonly name: string; + readonly configuration: AgentRuntimeConfiguration; } /** A runtime definition accepted by keyed society rosters. */ export interface AgentRuntime< Gateway, AcquisitionError = never, - Requirements = never, ConfigurationSchema extends Schema.Schema.AnyNoContext = Schema.Schema.AnyNoContext, > extends AgentRuntimeDefinition< Gateway, AcquisitionError, - Requirements, ConfigurationSchema > { readonly [agentRuntimeTypeId]: typeof agentRuntimeTypeId; @@ -150,7 +146,6 @@ export interface AgentRuntime< readonly [agentRuntimeTypesTypeId]: AgentRuntimeTypes< Gateway, AcquisitionError, - Requirements, ConfigurationSchema >; } @@ -160,16 +155,12 @@ export interface AgentRuntimeLike { readonly [agentRuntimeTypeId]: typeof agentRuntimeTypeId; readonly [runtimeConfigurationProjectionTypeId]: JsonValueType; readonly [agentRuntimeTypesTypeId]: AgentRuntimeTypes< - unknown, unknown, unknown, Schema.Schema.AnyNoContext >; readonly name: string; readonly configuration: AgentRuntimeConfiguration; - acquire( - input: AgentRuntimeInput, - ): Effect.Effect, unknown, unknown>; } function invalidConfiguration(detail: string): AgentRuntimeDefinitionError { @@ -189,7 +180,12 @@ function configurationValue( }); } -function deepFreeze(value: Value): Value { +/** + * Freeze a value and everything reachable from it. + * @param value Value to freeze in place. + * @returns The same value, now deeply immutable. + */ +export function deepFreeze(value: Value): Value { if (typeof value !== "object" || value === null) { return value; } @@ -248,24 +244,21 @@ export function runtimeConfigurationProjection( } /** - * Preserve inferred gateway, acquisition error, requirement, and configuration - * types. + * Preserve inferred gateway, acquisition error, and configuration types. * @param runtime Value supplied to the operation. * @returns The immutable runtime definition. */ export function defineRuntime< Gateway, AcquisitionError, - Requirements, ConfigurationSchema extends Schema.Schema.AnyNoContext, >( runtime: AgentRuntimeDefinition< Gateway, AcquisitionError, - Requirements, ConfigurationSchema >, -): AgentRuntime { +): AgentRuntime { if (runtime.name.length === 0) { throw AgentRuntimeDefinitionError.make({ detail: "a runtime name must not be empty", @@ -273,21 +266,28 @@ export function defineRuntime< } const name = runtime.name; const captured = captureConfiguration(runtime.configuration); - const acquire = runtime.acquire.bind(runtime); - const defined: AgentRuntime< - Gateway, - AcquisitionError, - Requirements, - ConfigurationSchema - > = { - [agentRuntimeTypeId]: agentRuntimeTypeId, - [runtimeConfigurationProjectionTypeId]: captured.projection, - [agentRuntimeTypesTypeId]: {}, - name, - configuration: captured.configuration, - acquire: (input: AgentRuntimeInput) => - acquire(input), - }; + const defined: AgentRuntime = + { + [agentRuntimeTypeId]: agentRuntimeTypeId, + [runtimeConfigurationProjectionTypeId]: captured.projection, + [agentRuntimeTypesTypeId]: {}, + name, + configuration: captured.configuration, + }; Object.freeze(defined); return defined; } + +/** A runtime application or its native gateway did not become ready. */ +export class RuntimeAcquisitionError extends Schema.TaggedError()( + "RuntimeAcquisitionError", + { + runtime: Schema.NonEmptyString, + agent: Schema.NonEmptyString, + detail: Schema.String, + }, +) { + override get message(): string { + return `${this.runtime} runtime for "${this.agent}" failed to start: ${this.detail}`; + } +} diff --git a/packages/simulator/src/agents/container.test.ts b/packages/simulator/src/agents/container.test.ts new file mode 100644 index 000000000..3e8b9c098 --- /dev/null +++ b/packages/simulator/src/agents/container.test.ts @@ -0,0 +1,73 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import * as publicRuntime from "../agents.js"; +import { defineRuntime } from "./agent.js"; +import { + containerRuntimeFor, + defineContainerRuntime, + image, +} from "./container.js"; + +const configuration = Schema.Struct({ kind: Schema.Literal("test") }); + +const IMAGE = image.make( + "example.invalid/application@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", +); +const RESOURCES = { + cpuMillis: 100, + memoryBytes: 1_024, + ephemeralStorageBytes: 2_048, +}; + +it("keeps container realizations off the published runtime surface", () => { + const render = () => Effect.die("unused"); + const runtime = defineContainerRuntime({ + name: "private-container-test", + configuration: { schema: configuration, value: { kind: "test" } }, + image: IMAGE, + resources: RESOURCES, + render, + }); + const container = containerRuntimeFor(runtime); + + assert.strictEqual(container.image, IMAGE); + assert.deepStrictEqual(container.resources, RESOURCES); + assert.strictEqual(container.render, render); + assert.notProperty(publicRuntime, "containerRuntimeFor"); + assert.strictEqual( + publicRuntime.defineContainerRuntime, + defineContainerRuntime, + ); +}); + +it("accepts only an image pinned by one lowercase SHA-256 digest", () => { + const digest = "a".repeat(64); + + assert.strictEqual( + image.make(`example.invalid/application@sha256:${digest}`), + `example.invalid/application@sha256:${digest}`, + ); + for (const rejected of [ + "example.invalid/application", + "example.invalid/application@sha256:short", + `example.invalid/application@sha256:${"A".repeat(64)}`, + // A repository half that may contain "@" lets an unpinned reference carry + // a well-formed digest behind it. + `example.invalid/app@sha256:junk@sha256:${digest}`, + ]) { + assert.throws(() => image.make(rejected)); + } +}); + +it("refuses a runtime that never declared a container realization", () => { + const runtime = defineRuntime< + { readonly gateway: string }, + never, + typeof configuration + >({ + name: "plain-runtime-test", + configuration: { schema: configuration, value: { kind: "test" } }, + }); + + assert.isUndefined(containerRuntimeFor(runtime)); +}); diff --git a/packages/simulator/src/agents/container.ts b/packages/simulator/src/agents/container.ts new file mode 100644 index 000000000..dd88d0cef --- /dev/null +++ b/packages/simulator/src/agents/container.ts @@ -0,0 +1,281 @@ +/** @file Private container realization owned by one exact agent runtime. */ + +import { Cause, Effect, Inspectable, Schema, type Scope } from "effect"; +import { + defineRuntime, + RuntimeAcquisitionError, + type AgentRuntime, + type AgentRuntimeDefinition, + type AgentRuntimeInput, + type RuntimeTermination, +} from "./agent.js"; + +/** + * A registered symbol, not a module-local one. The controller reaches an + * experiment through a dynamic import, so a runtime is routinely defined in the + * experiment's module graph and read in the controller's; an unregistered + * symbol differs between those copies and the brand would be invisible. + */ +const containerRuntimeTypeId: unique symbol = Symbol.for( + "@moltzap/simulator/ContainerRuntime", +); + +/** + * Digest-pinned image identity accepted by the private container platform. + * The repository half excludes `@` so a trailing digest cannot be smuggled in + * behind an earlier one, and the digest is lowercase hexadecimal of exactly the + * length SHA-256 produces. + */ +export const image = Schema.String.pipe( + Schema.pattern(/^[^@\s]+@sha256:[\da-f]{64}$/u), + Schema.brand("Image"), +); + +/** Digest-pinned image identity accepted by the private container platform. */ +export type Image = typeof image.Type; + +/** Provider credential a container may request from the run-scoped Secret. */ +export type CredentialName = "ANTHROPIC_API_KEY" | "OPENAI_API_KEY"; + +/** Portable resource request for one application container. */ +export interface Resources { + readonly cpuMillis: number; + readonly memoryBytes: number; + readonly ephemeralStorageBytes: number; +} + +/** One file materialized into a container from the run-scoped Secret. */ +export interface File { + readonly path: `/${string}`; + readonly content: string; + readonly mode: number; +} + +/** + * Where the cluster reached one ready application's controller bridge. + * + * The cluster builds this from the port the application itself declared, so a + * runtime reads the address it asked for instead of re-deriving it: a protocol, + * port, path, or credential the runtime would have to reject cannot be spelled. + */ +export interface ApplicationEndpoint { + readonly host: string; + readonly port: number; +} + +/** The cluster offered a bridge address a runtime must not connect to. */ +class ApplicationEndpointError extends Schema.TaggedError()( + "ApplicationEndpointError", + { detail: Schema.String }, +) { + override get message(): string { + return this.detail; + } +} + +/** + * Loopback answers name the controller's own host rather than the application's + * Sandbox, so connecting would reach whatever else happens to listen there. + */ +const UNROUTABLE_BRIDGE_HOSTS: ReadonlySet = new Set([ + "0.0.0.0", + "127.0.0.1", + "localhost", + "::1", + "[::1]", +]); + +/** + * Refuse a bridge address that never leaves the controller's own host. + * @param endpoint Address the cluster resolved for a ready application. + * @returns The same endpoint once it is known to be routable. + */ +export function routableBridgeEndpoint( + endpoint: ApplicationEndpoint, +): ApplicationEndpoint { + if (UNROUTABLE_BRIDGE_HOSTS.has(endpoint.host)) { + throw ApplicationEndpointError.make({ + detail: `an application bridge host must be routable, not "${endpoint.host}"`, + }); + } + return endpoint; +} + +/** + * Bind one runtime's name into the failure it reports for its own agents. + * @param runtime Runtime name recorded on every failure it reports. + * @returns A builder for that runtime's acquisition failures. + */ +export function acquisitionFailureFor( + runtime: string, +): ( + agent: string, + operation: string, + cause: unknown, +) => RuntimeAcquisitionError { + return (agent, operation, cause) => + RuntimeAcquisitionError.make({ + runtime, + agent, + detail: `${operation}: ${String(cause)}`, + }); +} + +/** One rendered application and its runtime-specific controller bridge. */ +export interface Application { + readonly entrypoint: readonly [string, ...string[]]; + readonly environment: Readonly>; + readonly credentials?: readonly CredentialName[]; + /** The controller bridge port, and the port whose accept means ready. */ + readonly port: number; + readonly files: readonly File[]; + /** + * Bind the controller to one ready application. + * + * `stopped` is the cluster's own view of the container ending. A runtime that + * can see a stop the cluster cannot — its controller bridge dying while the + * container still reports Running — reports it through `reportStopped`; the + * run records whichever stop is observed first. A runtime with nothing extra + * to observe accepts fewer arguments and ignores it. + */ + readonly attach: ( + endpoint: ApplicationEndpoint, + stopped: Effect.Effect, + reportStopped: (termination: RuntimeTermination) => Effect.Effect, + ) => Effect.Effect; +} + +/** + * The container realization of one runtime. Image and resources belong here + * rather than to a rendered application because the cluster reserves capacity + * for the complete roster before any agent identity exists. + */ +export interface ContainerRuntime { + readonly image: Image; + readonly resources: Resources; + readonly render: ( + input: AgentRuntimeInput, + ) => Effect.Effect, AcquisitionError>; +} + +/** + * A runtime that is known to carry a container realization. Only + * `defineContainerRuntime` produces one, so reading its realization back needs + * no absent case. + */ +export interface ContainerAgentRuntime< + Gateway, + AcquisitionError = never, + ConfigurationSchema extends + Schema.Schema.AnyNoContext = Schema.Schema.AnyNoContext, +> extends AgentRuntime { + readonly [containerRuntimeTypeId]: ContainerRuntime< + Gateway, + AcquisitionError + >; +} + +interface ContainerRuntimeCarrier { + readonly name: string; + readonly [containerRuntimeTypeId]?: ContainerRuntime< + Gateway, + AcquisitionError + >; +} + +/** + * Read the container realization branded onto one runtime value. + * @param runtime Runtime whose container realization is requested. + * @returns The realization, absent only for a runtime that never declared one. + * @internal + */ +export function containerRuntimeFor< + Gateway, + AcquisitionError, + ConfigurationSchema extends Schema.Schema.AnyNoContext, +>( + runtime: ContainerAgentRuntime< + Gateway, + AcquisitionError, + ConfigurationSchema + >, +): ContainerRuntime; +export function containerRuntimeFor< + Gateway, + AcquisitionError, + ConfigurationSchema extends Schema.Schema.AnyNoContext, +>( + runtime: AgentRuntime, +): ContainerRuntime | undefined; +export function containerRuntimeFor< + Gateway, + AcquisitionError, + ConfigurationSchema extends Schema.Schema.AnyNoContext, +>( + runtime: AgentRuntime, +): ContainerRuntime | undefined { + const carrier: ContainerRuntimeCarrier = runtime; + return carrier[containerRuntimeTypeId]; +} + +/** + * Define one runtime and bind its container realization in a single operation. + * This describes no cross-runtime gateway protocol. + * @param definition Runtime metadata plus its private container realization. + * @returns The frozen nominal runtime accepted by a society roster. + */ +export function defineContainerRuntime< + Gateway, + AcquisitionError, + ConfigurationSchema extends Schema.Schema.AnyNoContext, +>( + definition: AgentRuntimeDefinition< + Gateway, + AcquisitionError, + ConfigurationSchema + > & + ContainerRuntime, +): ContainerAgentRuntime { + const runtime = defineRuntime( + { + name: definition.name, + configuration: definition.configuration, + }, + ); + // Non-enumerable, so the realization does not travel to structural copies of + // a runtime, which the cluster would then treat as the runtime itself. + const branded = + /* Safe because the property this asserts was just installed under that exact symbol. */ + Object.freeze( + Object.defineProperty({ ...runtime }, containerRuntimeTypeId, { + value: Object.freeze({ + image: definition.image, + resources: definition.resources, + render: definition.render, + }), + }), + ) as ContainerAgentRuntime; + return branded; +} + +/** + * Fail with the runtime's own error the moment its application stops, so a + * bridge race reports the stop instead of waiting out the startup deadline. + * The error type is a plain parameter, so each runtime keeps its exact failure + * channel and no gateway union exists. + * @param stopped Cluster observation that completes when the application ends. + * @param onStopped Builds the runtime's error from the printed observation. + * @returns An Effect that only ever fails. + */ +export function stoppedBeforeAttach( + stopped: Effect.Effect, + onStopped: (detail: string) => AcquisitionError, +): Effect.Effect { + return stopped.pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.fail(onStopped(Cause.pretty(cause))), + onSuccess: (observation) => + Effect.fail(onStopped(Inspectable.stringifyCircular(observation))), + }), + ); +} diff --git a/packages/simulator/src/agents/container.types-check.ts b/packages/simulator/src/agents/container.types-check.ts new file mode 100644 index 000000000..bcd3dc355 --- /dev/null +++ b/packages/simulator/src/agents/container.types-check.ts @@ -0,0 +1,52 @@ +/** + * Type canary: a private container realization preserves its runtime's exact + * principal gateway and acquisition-error types through render and attach, and + * a runtime built by `defineContainerRuntime` always has one to read. + */ + +import type { Effect } from "effect"; +import type { OpenClawGateway } from "./openclaw/gateway.js"; +import { openClawRuntime } from "./openclaw/runtime.js"; +import type { RuntimeAcquisitionError } from "./agent.js"; +import { + containerRuntimeFor, + type Application, + type ContainerRuntime, +} from "./container.js"; + +type Equal = [Left] extends [Right] + ? [Right] extends [Left] + ? true + : false + : false; + +const runtime = openClawRuntime(); + +/** Stock OpenClaw preserves its exact private container realization type. */ +export const openClawContainerRuntimeCanary = containerRuntimeFor(runtime); + +/** Reading back the realization of a defined container runtime has no absent case. */ +export const containerRuntimeIsAlwaysPresent: Equal< + typeof openClawContainerRuntimeCanary, + ContainerRuntime +> = true; + +type OpenClawApplication = Application< + OpenClawGateway, + RuntimeAcquisitionError +>; +type AttachedOpenClaw = Effect.Effect.Success< + ReturnType +>; + +/** The controller bridge yields OpenClaw's native gateway and nothing else. */ +export const attachReturnsExactGateway: Equal< + AttachedOpenClaw, + OpenClawGateway +> = true; + +/** The controller bridge retains OpenClaw's acquisition failure channel. */ +export const attachPreservesAcquisitionError: Equal< + Effect.Effect.Error>, + RuntimeAcquisitionError +> = true; diff --git a/packages/simulator/src/runtime/nanoclaw/assets.test.ts b/packages/simulator/src/agents/nanoclaw/assets.test.ts similarity index 98% rename from packages/simulator/src/runtime/nanoclaw/assets.test.ts rename to packages/simulator/src/agents/nanoclaw/assets.test.ts index dada16669..5897d51b2 100644 --- a/packages/simulator/src/runtime/nanoclaw/assets.test.ts +++ b/packages/simulator/src/agents/nanoclaw/assets.test.ts @@ -3,8 +3,8 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import { SIMULATOR_PROFILE_NAME } from "../workspace.js"; -import { NANOCLAW_EVAL_AGENT_GROUP_ID } from "./process.js"; +const NANOCLAW_EVAL_AGENT_GROUP_ID = "eval-agent"; const INJECTED_CHANNEL_TEXT = "already injects and starts the `moltzap` channel"; const PROFILE_TEXT = `\`${SIMULATOR_PROFILE_NAME}\` profile`; diff --git a/packages/simulator/src/runtime/nanoclaw/gateway.test.ts b/packages/simulator/src/agents/nanoclaw/gateway.test.ts similarity index 75% rename from packages/simulator/src/runtime/nanoclaw/gateway.test.ts rename to packages/simulator/src/agents/nanoclaw/gateway.test.ts index a9b13e692..1c6bbcde6 100644 --- a/packages/simulator/src/runtime/nanoclaw/gateway.test.ts +++ b/packages/simulator/src/agents/nanoclaw/gateway.test.ts @@ -4,7 +4,11 @@ import { NodeContext, NodeSocketServer } from "@effect/platform-node"; import { assert, it as effectIt } from "@effect/vitest"; import { Chunk, Deferred, Duration, Effect, Fiber, Stream } from "effect"; import { describe } from "vitest"; -import { acquireNanoclawGateway, NanoclawGatewayInput } from "./gateway.js"; +import { + acquireDistributedNanoClawGateway, + acquireNanoClawGateway, + NanoClawGatewayInput, +} from "./gateway.js"; const test = effectIt.scoped; const liveTest = effectIt.scopedLive; @@ -61,6 +65,24 @@ function startTestServer( }); } +function startTcpTestServer(request: Deferred.Deferred) { + return Effect.gen(function* () { + const server = yield* NodeSocketServer.make({ + host: "127.0.0.1", + port: 0, + }); + if (server.address._tag !== "TcpAddress") { + return yield* Effect.dieMessage( + "TCP gateway fixture returned a Unix address", + ); + } + yield* server + .run((socket) => handleConnection(socket, request)) + .pipe(Effect.forkScoped); + return server.address; + }); +} + function startOversizedOutputServer( socketPath: string, atLimit: Deferred.Deferred, @@ -110,7 +132,7 @@ function nativeFramesTest() { const request = yield* Deferred.make(); yield* startTestServer(socketPath, request); - const session = yield* acquireNanoclawGateway( + const session = yield* acquireNanoClawGateway( socketPath, Duration.seconds(2), ); @@ -119,7 +141,7 @@ function nativeFramesTest() { Stream.runCollect, Effect.forkScoped, ); - yield* session.gateway.submit(NanoclawGatewayInput.make({ text: "hello" })); + yield* session.gateway.submit(NanoClawGatewayInput.make({ text: "hello" })); assert.strictEqual(yield* Deferred.await(request), EXPECTED_INPUT); assert.deepStrictEqual( @@ -143,7 +165,7 @@ function oversizedFragmentedLineTest() { const releaseOverflow = yield* Deferred.make(); yield* startOversizedOutputServer(socketPath, atLimit, releaseOverflow); - const session = yield* acquireNanoclawGateway( + const session = yield* acquireNanoClawGateway( socketPath, Duration.seconds(2), ); @@ -170,11 +192,41 @@ function oversizedFragmentedLineTest() { }).pipe(Effect.provide(NodeContext.layer)); } +function distributedNativeFramesTest() { + return Effect.gen(function* () { + const request = yield* Deferred.make(); + const address = yield* startTcpTestServer(request); + const session = yield* acquireDistributedNanoClawGateway( + address.hostname, + address.port, + Duration.seconds(2), + ); + const collecting = yield* session.gateway.outputs.pipe( + Stream.take(2), + Stream.runCollect, + Effect.forkScoped, + ); + yield* session.gateway.submit(NanoClawGatewayInput.make({ text: "hello" })); + + assert.strictEqual(yield* Deferred.await(request), EXPECTED_INPUT); + assert.deepStrictEqual( + Chunk.toReadonlyArray(yield* Fiber.join(collecting)).map( + (frame) => frame.text, + ), + ["first", "second"], + ); + }).pipe(Effect.provide(NodeContext.layer)); +} + describe("NanoClaw principal gateway", () => { test( "submits native NDJSON and preserves each streamed output frame", nativeFramesTest, ); + test( + "preserves the same native gateway over the application bridge", + distributedNativeFramesTest, + ); liveTest( "rejects a fragmented native output line before it can grow without bound", oversizedFragmentedLineTest, diff --git a/packages/simulator/src/runtime/nanoclaw/gateway.ts b/packages/simulator/src/agents/nanoclaw/gateway.ts similarity index 63% rename from packages/simulator/src/runtime/nanoclaw/gateway.ts rename to packages/simulator/src/agents/nanoclaw/gateway.ts index 5762f3a32..8a19c00bf 100644 --- a/packages/simulator/src/runtime/nanoclaw/gateway.ts +++ b/packages/simulator/src/agents/nanoclaw/gateway.ts @@ -24,22 +24,22 @@ const NANOCLAW_GATEWAY_LINE_MAX_BYTES = 64 * 1_024; const NANOCLAW_GATEWAY_TEXT_MAX_LENGTH = 32 * 1_024; /** Native instruction accepted by NanoClaw's owner-local CLI channel. */ -export class NanoclawGatewayInput extends Schema.Class( - "NanoclawGatewayInput", +export class NanoClawGatewayInput extends Schema.Class( + "NanoClawGatewayInput", )({ text: Schema.NonEmptyString, }) {} /** One native output frame emitted by NanoClaw's owner-local CLI channel. */ -export class NanoclawGatewayOutput extends Schema.Class( - "NanoclawGatewayOutput", +export class NanoClawGatewayOutput extends Schema.Class( + "NanoClawGatewayOutput", )({ text: Schema.String.pipe(Schema.maxLength(NANOCLAW_GATEWAY_TEXT_MAX_LENGTH)), }) {} /** A NanoClaw principal socket could not connect, submit, or receive. */ -export class NanoclawGatewayError extends Schema.TaggedError()( - "NanoclawGatewayError", +export class NanoClawGatewayError extends Schema.TaggedError()( + "NanoClawGatewayError", { operation: Schema.Literal("connect", "submit", "receive"), detail: Schema.String, @@ -51,34 +51,38 @@ export class NanoclawGatewayError extends Schema.TaggedError Effect.Effect; - readonly outputs: Stream.Stream; + input: NanoClawGatewayInput, + ) => Effect.Effect; + readonly outputs: Stream.Stream; } /** * Gateway plus the persistent connection's autonomous failure observation. * @internal */ -export interface NanoclawGatewaySession { - readonly gateway: NanoclawGateway; - readonly failure: Effect.Effect; +export interface NanoClawGatewaySession { + readonly gateway: NanoClawGateway; + readonly failure: Effect.Effect; } interface GatewayState { readonly opened: Deferred.Deferred; - readonly failure: Deferred.Deferred; - readonly rawInput: Mailbox.Mailbox; - readonly output: Mailbox.Mailbox; + readonly failure: Deferred.Deferred; + readonly rawInput: Mailbox.Mailbox; + readonly output: Mailbox.Mailbox; } +type NanoClawGatewaySocketAddress = + | { readonly _tag: "Unix"; readonly path: string } + | { readonly _tag: "Tcp"; readonly host: string; readonly port: number }; + function gatewayError( - operation: NanoclawGatewayError["operation"], + operation: NanoClawGatewayError["operation"], cause: unknown, -): NanoclawGatewayError { - return NanoclawGatewayError.make({ +): NanoClawGatewayError { + return NanoClawGatewayError.make({ operation, detail: String(cause), }); @@ -86,7 +90,7 @@ function gatewayError( function failGateway( state: GatewayState, - error: NanoclawGatewayError, + error: NanoClawGatewayError, ): Effect.Effect { return Effect.all( [ @@ -99,8 +103,8 @@ function failGateway( } function enforceLineByteLimit( - input: Stream.Stream, -): Stream.Stream { + input: Stream.Stream, +): Stream.Stream { return input.pipe( Stream.mapAccumEffect(0, (lineBytes, chunk) => { let nextLineBytes = lineBytes; @@ -124,13 +128,13 @@ function decodeOutput(state: GatewayState): Effect.Effect { return enforceLineByteLimit(Mailbox.toStream(state.rawInput)).pipe( Stream.tapError((error) => failGateway(state, error)), Stream.pipeThroughChannel( - Ndjson.unpackSchema(NanoclawGatewayOutput)({ + Ndjson.unpackSchema(NanoClawGatewayOutput)({ ignoreEmptyLines: true, }), ), Stream.runForEach((frame) => state.output.offer(frame)), Effect.mapError((cause) => - cause instanceof NanoclawGatewayError + cause instanceof NanoClawGatewayError ? cause : gatewayError("receive", cause), ), @@ -170,11 +174,11 @@ function submitInput( chunk: Uint8Array | string | Socket.CloseEvent, ) => Effect.Effect, writeLock: Effect.Semaphore, - failure: Deferred.Deferred, - input: NanoclawGatewayInput, -): Effect.Effect { + failure: Deferred.Deferred, + input: NanoClawGatewayInput, +): Effect.Effect { const writeInput = Stream.make(input).pipe( - Stream.pipeThroughChannel(Ndjson.packSchema(NanoclawGatewayInput)()), + Stream.pipeThroughChannel(Ndjson.packSchema(NanoClawGatewayInput)()), Stream.runForEach(write), Effect.mapError((cause) => gatewayError("submit", cause)), ); @@ -190,10 +194,10 @@ function makeGatewaySession( chunk: Uint8Array | string | Socket.CloseEvent, ) => Effect.Effect, writeLock: Effect.Semaphore, -): NanoclawGatewaySession { +): NanoClawGatewaySession { return { gateway: Object.freeze({ - submit: (input: NanoclawGatewayInput) => + submit: (input: NanoClawGatewayInput) => submitInput(write, writeLock, state.failure, input), outputs: Mailbox.toStream(state.output), }), @@ -202,25 +206,30 @@ function makeGatewaySession( } function initializeGatewayAttempt( - socketPath: string, + address: NanoClawGatewaySocketAddress, attemptScope: Scope.CloseableScope, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const state: GatewayState = { opened: yield* Deferred.make(), - failure: yield* Deferred.make(), - rawInput: yield* Mailbox.make( + failure: yield* Deferred.make(), + rawInput: yield* Mailbox.make( RAW_INPUT_CAPACITY, ), - output: yield* Mailbox.make( + output: yield* Mailbox.make( OUTPUT_CAPACITY, ), }; const writeLock = yield* Effect.makeSemaphore(1); - const socket = yield* NodeSocket.makeNet({ - path: socketPath, - openTimeout: SOCKET_OPEN_TIMEOUT, - }).pipe( + const socket = yield* NodeSocket.makeNet( + address._tag === "Tcp" + ? { + host: address.host, + port: address.port, + openTimeout: SOCKET_OPEN_TIMEOUT, + } + : { path: address.path, openTimeout: SOCKET_OPEN_TIMEOUT }, + ).pipe( Effect.mapError((cause) => gatewayError("connect", cause)), Scope.extend(attemptScope), ); @@ -241,15 +250,15 @@ function initializeGatewayAttempt( } function openGatewayAttempt( - socketPath: string, + address: NanoClawGatewaySocketAddress, parentScope: Scope.Scope, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const attemptScope = yield* Scope.fork( parentScope, ExecutionStrategy.sequential, ); - return yield* initializeGatewayAttempt(socketPath, attemptScope).pipe( + return yield* initializeGatewayAttempt(address, attemptScope).pipe( Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : Scope.close(attemptScope, exit), ), @@ -257,31 +266,65 @@ function openGatewayAttempt( }); } -/** - * Connect a persistent typed client to NanoClaw's owner-local CLI channel. - * Connection attempts are scoped independently so failed attempts cannot - * retain sockets while NanoClaw is still starting. - * @param socketPath Owner-local CLI socket path. - * @param within Maximum time allowed for the first successful connection. - * @internal - * @returns The connected gateway and its failure observation. - */ -export function acquireNanoclawGateway( - socketPath: string, +function acquireGateway( + address: NanoClawGatewaySocketAddress, + label: string, within: Duration.Duration, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { const scope = yield* Effect.scope; - return yield* openGatewayAttempt(socketPath, scope).pipe( + return yield* openGatewayAttempt(address, scope).pipe( Effect.retry(Schedule.spaced(SOCKET_RETRY_INTERVAL)), Effect.timeoutFail({ duration: within, onTimeout: () => gatewayError( "connect", - `the NanoClaw CLI socket was not ready within ${Duration.format(within)}`, + `the NanoClaw ${label} was not ready within ${Duration.format(within)}`, ), }), ); - }).pipe(Effect.withSpan("NanoclawGateway.acquire")); + }).pipe(Effect.withSpan("NanoClawGateway.acquire")); +} + +/** + * Connect a persistent typed client to NanoClaw's owner-local CLI channel. + * Connection attempts are scoped independently so failed attempts cannot + * retain sockets while NanoClaw is still starting. + * @param socketPath Owner-local CLI socket path. + * @param within Maximum time allowed for the first successful connection. + * @internal + * @returns The connected gateway and its failure observation. + */ +export function acquireNanoClawGateway( + socketPath: string, + within: Duration.Duration, +): Effect.Effect { + return acquireGateway( + { _tag: "Unix", path: socketPath }, + "CLI socket", + within, + ); +} + +/** + * Connect the controller to NanoClaw's runtime-owned TCP realization of the + * native CLI channel. The bytes and schemas are identical to the Unix-socket + * gateway; only the application-container transport differs. + * @param host Application-container service hostname. + * @param port Fixed NanoClaw bridge port. + * @param within Maximum time allowed for the first successful connection. + * @internal + * @returns The connected gateway and its failure observation. + */ +export function acquireDistributedNanoClawGateway( + host: string, + port: number, + within: Duration.Duration, +): Effect.Effect { + return acquireGateway( + { _tag: "Tcp", host, port }, + `application bridge at ${host}:${String(port)}`, + within, + ); } diff --git a/packages/simulator/src/agents/nanoclaw/runtime.test.ts b/packages/simulator/src/agents/nanoclaw/runtime.test.ts new file mode 100644 index 000000000..e9b13df66 --- /dev/null +++ b/packages/simulator/src/agents/nanoclaw/runtime.test.ts @@ -0,0 +1,344 @@ +import { serverBaseUrl } from "@moltzap/protocol/network"; +import { + agentId, + agentName, + redactedAgentKey, +} from "@moltzap/protocol/testing"; +import { createServer, type Socket as NetSocket } from "node:net"; +import { assert, it as effectIt } from "@effect/vitest"; +import { Deferred, Effect, Schema, type Scope } from "effect"; +import { describe } from "vitest"; +import { makeAgentHandle, type AgentConnection } from "../../network.js"; +import { + containerRuntimeFor, + image, + type Application, + type ContainerRuntime, + type File, +} from "../container.js"; +import { + RuntimeFailed, + runtimeConfigurationProjection, + type RuntimeAcquisitionError, + type RuntimeTermination, +} from "../agent.js"; +import type { NanoClawGateway } from "./gateway.js"; +import { nanoclawRuntime } from "./runtime.js"; + +const test = effectIt.effect; +const liveTest = effectIt.scopedLive; +const AGENT_NAME = agentName("alice"); +const AGENT_ID = agentId("00000000-0000-4000-8000-000000000001"); +const AGENT_KEY_TEXT = + "moltzap_agent_0000000000000000_000000000000000000000000000000000000000000000000"; +const AGENT_KEY = redactedAgentKey(AGENT_KEY_TEXT); +// eslint-disable-next-line sonarjs/no-clear-text-protocols -- the private in-cluster router contract is intentionally HTTP. +const ROUTER_URL = serverBaseUrl("http://router.society.svc:3000"); +const APPLICATION_IMAGE = image.make( + "example.invalid/nanoclaw-application@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", +); +const BOOTSTRAP_ROOT = "/var/run/moltzap/bootstrap/"; +const RUNTIME_CONFIG_PATH = `${BOOTSTRAP_ROOT}nanoclaw/runtime.json`; +const PROFILE_PATH = `${BOOTSTRAP_ROOT}moltzap/config.json`; +const WORKSPACE_PATH = `${BOOTSTRAP_ROOT}workspace/IDENTITY.md`; +const ENTRYPOINT = "/opt/moltzap/nanoclaw/entrypoint.mjs"; +const GATEWAY_PORT = 18_790; +const STATE_DIR = "/var/lib/moltzap/nanoclaw"; +const GATEWAY_BIND_HOST = "0.0.0.0"; +const BRIDGE_HOST = "127.0.0.2"; +const MODEL_ID = "claude-sonnet-4-5"; +const WORKSPACE_CONTENT = "Alice"; +const MCP_SECRET = "secret-mcp-value"; + +const connection: AgentConnection<"alice"> = { + agent: makeAgentHandle("alice", AGENT_ID), + key: AGENT_KEY, + routerUrl: ROUTER_URL, +}; + +const renderedRuntimeConfig = Schema.parseJson( + Schema.Struct({ + apiVersion: Schema.Literal("moltzap.nanoclaw-application/v1"), + agentName: Schema.String, + gateway: Schema.Struct({ host: Schema.String, port: Schema.Number }), + stateDirectory: Schema.String, + workspaceDirectory: Schema.String, + autoRegisterConversations: Schema.Boolean, + modelId: Schema.optional(Schema.String), + mcpServers: Schema.Array( + Schema.Struct({ + name: Schema.String, + command: Schema.String, + args: Schema.Array(Schema.String), + env: Schema.Record({ key: Schema.String, value: Schema.String }), + }), + ), + }), +); + +const renderedMoltZapProfile = Schema.parseJson( + Schema.Struct({ + profiles: Schema.Struct({ + "simulator-agent": Schema.Struct({ + agentId: Schema.String, + apiKey: Schema.String, + agentName: Schema.String, + }), + }), + }), +); + +type NanoClawContainerRuntime = ContainerRuntime< + NanoClawGateway, + RuntimeAcquisitionError +>; +type NanoClawApplication = Application< + NanoClawGateway, + RuntimeAcquisitionError +>; + +interface Fixture { + readonly runtime: ReturnType; + readonly capability: NanoClawContainerRuntime; + readonly application: NanoClawApplication; + readonly runtimeConfig: typeof renderedRuntimeConfig.Type; + readonly profile: typeof renderedMoltZapProfile.Type; +} + +function requireFile(files: readonly File[], path: string): string { + const file = files.find((candidate) => candidate.path === path); + if (file === undefined) { + throw new Error(`missing rendered file ${path}`); + } + return file.content; +} + +/** + * No stop is expected from the runtime, so reporting one is a test defect. + * @returns An Effect that dies rather than accepting a stop report. + */ +function unreportedStop(): Effect.Effect { + return Effect.dieMessage("the NanoClaw runtime reported an unexpected stop"); +} + +function makeFixture() { + return Effect.gen(function* () { + const runtime = nanoclawRuntime({ + applicationImage: APPLICATION_IMAGE, + autoRegisterConversations: true, + modelId: MODEL_ID, + workspaceFiles: [ + { relativePath: "IDENTITY.md", content: WORKSPACE_CONTENT }, + ], + mcpServers: [ + { + name: "private-tool", + command: "tool-server", + args: ["--stdio"], + env: { PRIVATE_TOKEN: MCP_SECRET }, + }, + ], + }); + const capability = containerRuntimeFor(runtime); + const application = yield* capability.render({ + agentName: AGENT_NAME, + connection, + }); + const runtimeConfig = Schema.decodeUnknownSync(renderedRuntimeConfig)( + requireFile(application.files, RUNTIME_CONFIG_PATH), + ); + const profile = Schema.decodeUnknownSync(renderedMoltZapProfile)( + requireFile(application.files, PROFILE_PATH), + ); + return { runtime, capability, application, runtimeConfig, profile }; + }); +} + +function assertApplicationContainer(fixture: Fixture): void { + const { application, capability } = fixture; + const projection = JSON.stringify({ + entrypoint: application.entrypoint, + environment: application.environment, + credentials: application.credentials, + port: application.port, + }); + assert.notProperty(application, "containers"); + assert.strictEqual(capability.image, APPLICATION_IMAGE); + assert.deepStrictEqual(capability.resources, { + cpuMillis: 1_000, + memoryBytes: 1_024 * 1_024 * 1_024, + ephemeralStorageBytes: 1_024 * 1_024 * 1_024, + }); + assert.deepStrictEqual(application.entrypoint, ["node", ENTRYPOINT]); + assert.strictEqual(application.port, GATEWAY_PORT); + assert.strictEqual(application.environment.MOLTZAP_SERVER_URL, ROUTER_URL); + assert.strictEqual( + application.environment.MOLTZAP_NANOCLAW_CONFIG, + RUNTIME_CONFIG_PATH, + ); + assert.strictEqual(application.environment.MOLTZAP_NANOCLAW_STATE, STATE_DIR); + assert.deepStrictEqual(application.credentials, ["ANTHROPIC_API_KEY"]); + assert.notInclude(projection, AGENT_KEY_TEXT); + assert.notInclude(projection, MCP_SECRET); +} + +function assertBootstrap(fixture: Fixture): void { + const { application, profile, runtime, runtimeConfig } = fixture; + assert.strictEqual(runtimeConfig.agentName, AGENT_NAME); + assert.strictEqual(runtimeConfig.gateway.host, GATEWAY_BIND_HOST); + assert.strictEqual(runtimeConfig.gateway.port, GATEWAY_PORT); + assert.strictEqual(runtimeConfig.stateDirectory, STATE_DIR); + assert.strictEqual(runtimeConfig.modelId, MODEL_ID); + assert.isTrue(runtimeConfig.autoRegisterConversations); + assert.strictEqual( + runtimeConfig.mcpServers[0]?.env.PRIVATE_TOKEN, + MCP_SECRET, + ); + assert.strictEqual(profile.profiles["simulator-agent"].agentId, AGENT_ID); + assert.strictEqual( + profile.profiles["simulator-agent"].apiKey, + AGENT_KEY_TEXT, + ); + assert.strictEqual( + requireFile(application.files, WORKSPACE_PATH), + WORKSPACE_CONTENT, + ); + assert.isTrue( + application.files.every((file) => file.path.startsWith(BOOTSTRAP_ROOT)), + ); + assert.notInclude( + JSON.stringify(runtimeConfigurationProjection(runtime)), + AGENT_KEY_TEXT, + ); + assert.notInclude( + JSON.stringify(runtimeConfigurationProjection(runtime)), + MCP_SECRET, + ); +} + +function applicationContractTest() { + return Effect.gen(function* () { + const fixture = yield* makeFixture(); + assertApplicationContainer(fixture); + assertBootstrap(fixture); + }); +} + +function rejectedEndpointTest() { + return Effect.gen(function* () { + const fixture = yield* makeFixture(); + // A loopback answer is the only address shape the endpoint type still + // permits, and it must fail before the bridge opens a socket, so the cases + // stay deterministic without a gateway on the other end. + for (const host of ["0.0.0.0", "127.0.0.1", "localhost", "::1", "[::1]"]) { + const failure = yield* Effect.scoped( + fixture.application.attach( + { host, port: GATEWAY_PORT }, + Effect.never, + unreportedStop, + ), + ).pipe(Effect.flip); + + assert.strictEqual(failure.agent, AGENT_NAME); + assert.include(failure.detail, "resolve distributed gateway"); + } + }); +} + +function rejectedWorkspacePathTest(): void { + // Escapes are refused where the runtime is defined, which is before any + // router credential exists to be written into a bootstrap file. + for (const relativePath of ["", "../escape.md", "/etc/passwd", "a\\b.md"]) { + assert.throws(() => + nanoclawRuntime({ + applicationImage: APPLICATION_IMAGE, + workspaceFiles: [{ relativePath, content: WORKSPACE_CONTENT }], + }), + ); + } +} + +/** + * Serve the bridge port, and hand back the way to hang up on the controller. + * + * The address is a loopback the runtime does not reject: its own validation + * refuses 127.0.0.1 and localhost, and the cluster reaches an agent by service + * name in production. The connection is served first because a bridge that + * never comes up is the acquisition failure the runtime already reports; the + * regression is a bridge that dies after the controller is attached to it. + * @returns A function that drops every connection the bridge has accepted. + */ +function startBridge(): Effect.Effect<() => void, never, Scope.Scope> { + return Effect.gen(function* () { + const accepted: NetSocket[] = []; + const server = createServer((socket) => accepted.push(socket)); + yield* Effect.acquireRelease( + Effect.async((resume) => { + server.listen(GATEWAY_PORT, BRIDGE_HOST, () => { + resume(Effect.succeed(undefined)); + }); + }), + () => + Effect.sync(() => { + server.close(); + }), + ); + return () => { + for (const socket of accepted) { + socket.destroy(); + } + }; + }); +} + +function gatewayDisconnectTest() { + return Effect.gen(function* () { + const fixture = yield* makeFixture(); + const reported = yield* Deferred.make(); + const hangUp = yield* startBridge(); + + // The Sandbox observation never completes: the container is still Running + // as far as the cluster can see, exactly as when only the bridge dies. + yield* fixture.application.attach( + { host: BRIDGE_HOST, port: GATEWAY_PORT }, + Effect.never, + (termination) => + Deferred.succeed(reported, termination).pipe(Effect.asVoid), + ); + yield* Effect.sync(hangUp); + const termination = yield* Deferred.await(reported); + + assert.instanceOf(termination, RuntimeFailed); + assert.include(termination.detail, AGENT_NAME); + assert.include(termination.detail, "disconnected"); + }); +} + +function descriptorRegistrationTest(): void { + const runtime = nanoclawRuntime({ applicationImage: APPLICATION_IMAGE }); + assert.isDefined(containerRuntimeFor(runtime)); + assert.notProperty(runtime, "acquire"); +} + +describe("NanoClaw container runtime", () => { + test( + "renders one application container and its closed bootstrap contract", + applicationContractTest, + ); + test( + "refuses any endpoint that is not the runtime's fixed bridge", + rejectedEndpointTest, + ); + effectIt( + "refuses a workspace path that escapes its root when the runtime is defined", + rejectedWorkspacePathTest, + ); + liveTest( + "reports its own bridge disconnecting as the agent's termination", + gatewayDisconnectTest, + ); + effectIt( + "defines metadata and its private capability without a host acquire path", + descriptorRegistrationTest, + ); +}); diff --git a/packages/simulator/src/agents/nanoclaw/runtime.ts b/packages/simulator/src/agents/nanoclaw/runtime.ts new file mode 100644 index 000000000..1174549f8 --- /dev/null +++ b/packages/simulator/src/agents/nanoclaw/runtime.ts @@ -0,0 +1,382 @@ +/** @file Container-native NanoClaw runtime descriptor. */ + +import type { AgentName } from "@moltzap/protocol/identity"; +import { httpBaseUrl } from "@moltzap/protocol/network"; +import { + acquisitionFailureFor, + defineContainerRuntime, + image, + routableBridgeEndpoint, + stoppedBeforeAttach, + type Application, + type ApplicationEndpoint, + type ContainerAgentRuntime, + type ContainerRuntime, + type File, + type Image, +} from "../container.js"; +import { + RuntimeFailed, + type AgentRuntimeInput, + type RuntimeAcquisitionError, + type RuntimeTermination, +} from "../agent.js"; +import { Duration, Effect, Schema, type Scope } from "effect"; +import { + bootstrapFile, + McpServerConfiguration, + mcpConfiguration, + serializeMoltZapProfileConfig, + SIMULATOR_PROFILE_NAME, + snapshotMcpServers, + snapshotWorkspaceFiles, + WorkspaceFileConfiguration, + workspaceConfiguration, + workspaceFilePath, + type CheckedWorkspaceFile, + type McpServer, + type WorkspaceFile, +} from "../workspace.js"; +import { + acquireDistributedNanoClawGateway, + type NanoClawGateway, + type NanoClawGatewaySession, +} from "./gateway.js"; + +const NANOCLAW_RUNTIME_NAME = "nanoclaw"; +const DEFAULT_NANOCLAW_STARTUP_TIMEOUT = Duration.minutes(2); +const NANOCLAW_GATEWAY_PORT = 18_790; +const NANOCLAW_BOOTSTRAP_DIR = "/var/run/moltzap/bootstrap"; +const NANOCLAW_CONFIG_PATH = `${NANOCLAW_BOOTSTRAP_DIR}/nanoclaw/runtime.json`; +const NANOCLAW_PROFILE_HOME = `${NANOCLAW_BOOTSTRAP_DIR}/moltzap`; +const NANOCLAW_PROFILE_PATH = `${NANOCLAW_PROFILE_HOME}/config.json`; +const NANOCLAW_WORKSPACE_DIR = `${NANOCLAW_BOOTSTRAP_DIR}/workspace`; +const NANOCLAW_STATE_DIR = "/var/lib/moltzap/nanoclaw"; +const NANOCLAW_ENTRYPOINT = "/opt/moltzap/nanoclaw/entrypoint.mjs"; +const APPLICATION_RESOURCES = Object.freeze({ + cpuMillis: 1_000, + memoryBytes: 1_024 * 1_024 * 1_024, + ephemeralStorageBytes: 1_024 * 1_024 * 1_024, +}); + +const acquisitionFailure = acquisitionFailureFor(NANOCLAW_RUNTIME_NAME); + +/** + * Sanitized definition-time policy for a NanoClaw application container. + */ +export class NanoClawRuntimeConfiguration extends Schema.Class( + "NanoClawRuntimeConfiguration", +)({ + startupTimeout: Schema.DurationFromMillis, + workspaceFiles: Schema.Array(WorkspaceFileConfiguration), + modelOverride: Schema.optional(Schema.String), + autoRegisterConversations: Schema.Boolean, + mcpServers: Schema.Array(McpServerConfiguration), + applicationImage: image, +}) {} + +/** Configuration captured by one reusable NanoClaw runtime value. */ +export interface NanoClawRuntimeOptions { + readonly startupTimeout?: Duration.Duration; + readonly workspaceFiles?: readonly WorkspaceFile[]; + readonly modelId?: string; + + /** + * Digest-pinned one-container NanoClaw artifact for Kubernetes execution. + */ + readonly applicationImage: Image; + + /** + * Register conversations on first delivery in disposable evaluations. + * Ordinary societies leave registration to their endpoint code. + */ + readonly autoRegisterConversations?: boolean; + + /** Stdio MCP servers mounted into the NanoClaw container workspace. */ + readonly mcpServers?: readonly McpServer[]; +} + +interface NanoClawRuntimeSettings { + readonly startupTimeout: Duration.Duration; + readonly workspaceFiles: readonly CheckedWorkspaceFile[]; + readonly modelId?: string; + readonly applicationImage: Image; + readonly autoRegisterConversations: boolean; + readonly mcpServers?: readonly McpServer[]; +} + +function snapshotOptions( + options: NanoClawRuntimeOptions, +): NanoClawRuntimeSettings { + const modelId = options.modelId; + const mcpServers = snapshotMcpServers(options.mcpServers); + return Object.freeze({ + startupTimeout: options.startupTimeout ?? DEFAULT_NANOCLAW_STARTUP_TIMEOUT, + workspaceFiles: snapshotWorkspaceFiles(options.workspaceFiles), + applicationImage: options.applicationImage, + autoRegisterConversations: options.autoRegisterConversations ?? false, + ...(modelId === undefined ? {} : { modelId }), + ...(mcpServers === undefined ? {} : { mcpServers }), + }); +} + +function runtimeConfiguration( + settings: NanoClawRuntimeSettings, +): NanoClawRuntimeConfiguration { + return NanoClawRuntimeConfiguration.make({ + startupTimeout: settings.startupTimeout, + workspaceFiles: workspaceConfiguration(settings.workspaceFiles), + autoRegisterConversations: settings.autoRegisterConversations, + mcpServers: mcpConfiguration(settings.mcpServers), + applicationImage: settings.applicationImage, + ...(settings.modelId === undefined + ? {} + : { modelOverride: settings.modelId }), + }); +} + +type NanoClawGatewayAcquirer = ( + endpoint: ApplicationEndpoint, + within: Duration.Duration, +) => Effect.Effect; + +function runtimeConfig( + settings: NanoClawRuntimeSettings, + agentName: AgentName, +): string { + return JSON.stringify( + { + apiVersion: "moltzap.nanoclaw-application/v1", + agentName, + gateway: { + host: "0.0.0.0", + port: NANOCLAW_GATEWAY_PORT, + }, + stateDirectory: NANOCLAW_STATE_DIR, + workspaceDirectory: NANOCLAW_WORKSPACE_DIR, + autoRegisterConversations: settings.autoRegisterConversations, + ...(settings.modelId === undefined ? {} : { modelId: settings.modelId }), + mcpServers: (settings.mcpServers ?? []).map((server) => ({ + name: server.name, + command: server.command, + args: [...server.args], + env: { ...server.env }, + })), + }, + null, + 2, + ); +} + +function bootstrapFiles( + settings: NanoClawRuntimeSettings, + input: AgentRuntimeInput, +): readonly File[] { + const profile = serializeMoltZapProfileConfig({ + agentName: input.agentName, + agentId: input.connection.agent.id, + apiKey: input.connection.key, + }); + return Object.freeze([ + bootstrapFile( + NANOCLAW_CONFIG_PATH, + runtimeConfig(settings, input.agentName), + ), + bootstrapFile(NANOCLAW_PROFILE_PATH, profile), + ...settings.workspaceFiles.map((file) => + bootstrapFile( + workspaceFilePath(NANOCLAW_WORKSPACE_DIR, file.relativePath), + file.content, + ), + ), + ]); +} + +function stoppedBeforeBridge( + agentName: AgentName, + stopped: Effect.Effect, +): Effect.Effect { + return stoppedBeforeAttach(stopped, (detail) => + acquisitionFailure( + agentName, + "connect distributed principal gateway", + `NanoClaw application stopped before its bridge was ready: ${detail}`, + ), + ); +} + +interface NanoClawBridge { + readonly startupTimeout: Duration.Duration; + readonly agentName: AgentName; + readonly acquireGateway: NanoClawGatewayAcquirer; +} + +function gatewayDisconnected( + agentName: AgentName, + cause: unknown, +): RuntimeTermination { + return RuntimeFailed.make({ + detail: `NanoClaw principal gateway for agent "${agentName}" disconnected: ${String(cause)}`, + }); +} + +/** + * Report the bridge dying as this agent's stop. + * + * NanoClaw holds one persistent connection to its application. That connection + * can fail while the container keeps running, and a container that still + * reports Running is indistinguishable from a healthy agent to the cluster, so + * the run would wait on an agent that can no longer be reached. The observer + * is scope-owned and registered after the session, so releasing the session at + * teardown interrupts it first and teardown is never read as a disconnect. + * @param bridge Agent identity and gateway acquisition for one application. + * @param session Connected gateway and its autonomous failure observation. + * @param reportStopped Cluster sink for a stop only this runtime can see. + * @returns An Effect that completes once the observer is running. + */ +function observeGatewayLoss( + bridge: NanoClawBridge, + session: NanoClawGatewaySession, + reportStopped: (termination: RuntimeTermination) => Effect.Effect, +): Effect.Effect { + return session.failure.pipe( + Effect.catchAll((cause) => + reportStopped(gatewayDisconnected(bridge.agentName, cause)), + ), + Effect.forkScoped, + Effect.asVoid, + ); +} + +function attachNanoClaw( + bridge: NanoClawBridge, + endpoint: ApplicationEndpoint, + stopped: Effect.Effect, + reportStopped: (termination: RuntimeTermination) => Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const target = yield* Effect.try({ + try: () => routableBridgeEndpoint(endpoint), + catch: (cause) => + acquisitionFailure( + bridge.agentName, + "resolve distributed gateway", + cause, + ), + }); + const acquire = bridge + .acquireGateway(target, bridge.startupTimeout) + .pipe( + Effect.mapError((cause) => + acquisitionFailure( + bridge.agentName, + "connect distributed principal gateway", + cause, + ), + ), + ); + const session = yield* Effect.raceFirst( + acquire, + stoppedBeforeBridge(bridge.agentName, stopped), + ); + yield* observeGatewayLoss(bridge, session, reportStopped); + return session.gateway; + }); +} + +interface NanoClawRenderer { + readonly settings: NanoClawRuntimeSettings; + readonly acquireGateway: NanoClawGatewayAcquirer; +} + +function makeNanoClawApplication( + renderer: NanoClawRenderer, + input: AgentRuntimeInput, +): Application { + const { settings } = renderer; + const bridge = { + startupTimeout: settings.startupTimeout, + agentName: input.agentName, + acquireGateway: renderer.acquireGateway, + }; + return Object.freeze({ + entrypoint: Object.freeze(["node", NANOCLAW_ENTRYPOINT] as const), + environment: Object.freeze({ + MOLTZAP_PROFILE: SIMULATOR_PROFILE_NAME, + MOLTZAP_CONFIG_HOME: NANOCLAW_PROFILE_HOME, + MOLTZAP_SERVER_URL: httpBaseUrl(input.connection.routerUrl), + MOLTZAP_NANOCLAW_CONFIG: NANOCLAW_CONFIG_PATH, + MOLTZAP_NANOCLAW_STATE: NANOCLAW_STATE_DIR, + }), + ...(settings.modelId === undefined + ? {} + : { credentials: Object.freeze(["ANTHROPIC_API_KEY"] as const) }), + port: NANOCLAW_GATEWAY_PORT, + files: bootstrapFiles(settings, input), + attach: ( + endpoint: ApplicationEndpoint, + stopped: Effect.Effect, + reportStopped: (termination: RuntimeTermination) => Effect.Effect, + ) => attachNanoClaw(bridge, endpoint, stopped, reportStopped), + }); +} + +function renderNanoClaw( + renderer: NanoClawRenderer, + input: AgentRuntimeInput, +): Effect.Effect< + Application, + RuntimeAcquisitionError +> { + return Effect.try({ + try: () => makeNanoClawApplication(renderer, input), + catch: (cause) => + acquisitionFailure( + input.agentName, + "render distributed application", + cause, + ), + }); +} + +function nanoclawCapability( + settings: NanoClawRuntimeSettings, + acquireGateway: NanoClawGatewayAcquirer, +): ContainerRuntime { + const renderer: NanoClawRenderer = { settings, acquireGateway }; + return Object.freeze({ + image: settings.applicationImage, + resources: APPLICATION_RESOURCES, + render: (input: AgentRuntimeInput) => + renderNanoClaw(renderer, input), + }); +} + +/** + * Construct a NanoClaw descriptor backed by one application container per + * roster identity and its runtime-owned native gateway bridge. + * @param options Options that control the operation. + * @returns The nanoclaw runtime result. + */ +export function nanoclawRuntime( + options: NanoClawRuntimeOptions, +): ContainerAgentRuntime< + NanoClawGateway, + RuntimeAcquisitionError, + typeof NanoClawRuntimeConfiguration +> { + const settings = snapshotOptions(options); + const capability = nanoclawCapability(settings, (endpoint, within) => + acquireDistributedNanoClawGateway(endpoint.host, endpoint.port, within), + ); + return defineContainerRuntime({ + name: NANOCLAW_RUNTIME_NAME, + configuration: { + schema: NanoClawRuntimeConfiguration, + value: runtimeConfiguration(settings), + }, + image: capability.image, + resources: capability.resources, + render: capability.render, + }); +} diff --git a/packages/simulator/src/agents/nanoclaw/runtime.types-check.ts b/packages/simulator/src/agents/nanoclaw/runtime.types-check.ts new file mode 100644 index 000000000..c69e0d251 --- /dev/null +++ b/packages/simulator/src/agents/nanoclaw/runtime.types-check.ts @@ -0,0 +1,53 @@ +/** + * Type canary: NanoClaw's private container realization preserves its exact + * native gateway and acquisition-error types through render and attach. + */ + +import type { Effect } from "effect"; +import { + containerRuntimeFor, + image, + type Application, + type ContainerRuntime, +} from "../container.js"; +import type { RuntimeAcquisitionError } from "../agent.js"; +import type { NanoClawGateway } from "./gateway.js"; +import { nanoclawRuntime } from "./runtime.js"; + +type Equal = [Left] extends [Right] + ? [Right] extends [Left] + ? true + : false + : false; + +const runtime = nanoclawRuntime({ + applicationImage: image.make( + "example.invalid/nanoclaw@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + ), +}); + +/** Configured NanoClaw preserves its exact private container realization. */ +export const nanoclawContainerRuntimeCanary: ContainerRuntime< + NanoClawGateway, + RuntimeAcquisitionError +> = containerRuntimeFor(runtime); + +type NanoClawApplication = Application< + NanoClawGateway, + RuntimeAcquisitionError +>; +type AttachedNanoClaw = Effect.Effect.Success< + ReturnType +>; + +/** The controller bridge yields NanoClaw's native gateway and nothing else. */ +export const nanoclawAttachReturnsExactGateway: Equal< + AttachedNanoClaw, + NanoClawGateway +> = true; + +/** The bridge retains NanoClaw's acquisition failure channel. */ +export const nanoclawAttachPreservesAcquisitionError: Equal< + Effect.Effect.Error>, + RuntimeAcquisitionError +> = true; diff --git a/packages/simulator/src/agents/openclaw/configuration.ts b/packages/simulator/src/agents/openclaw/configuration.ts new file mode 100644 index 000000000..adaeaae12 --- /dev/null +++ b/packages/simulator/src/agents/openclaw/configuration.ts @@ -0,0 +1,129 @@ +/** @file Native OpenClaw configuration rendered into an application container. */ + +import type { MoltzapChannelPlugin } from "@moltzap/openclaw-channel"; +import type { AgentName } from "@moltzap/protocol/identity"; +import type { OpenClawConfig } from "openclaw/plugin-sdk"; +import type { + AgentDefaultsConfig, + ToolsConfig, +} from "openclaw/plugin-sdk/config-types"; +import { Redacted } from "effect"; +import { SIMULATOR_PROFILE_NAME } from "../workspace.js"; + +const DEFAULT_OPENCLAW_MODEL_ID = "openai/gpt-5.5"; +const OPENCLAW_CHANNEL_ID = "moltzap" satisfies MoltzapChannelPlugin["id"]; +const OPENCLAW_EXTENSION_NAME = "openclaw-channel"; + +/** Native OpenClaw tool exposure and execution configuration. */ +export type OpenClawToolsConfig = ToolsConfig; + +/** Native OpenClaw sandbox configuration for the runtime's default agent. */ +export type OpenClawSandboxConfig = NonNullable; + +interface OpenClawMcpServer { + readonly name: string; + readonly command: string; + readonly args: readonly string[]; + readonly env: Readonly>; +} + +interface OpenClawConfigInput { + readonly agentName: AgentName; + readonly modelId?: string; + readonly mcpServers?: readonly OpenClawMcpServer[]; + readonly tools?: OpenClawToolsConfig; + readonly sandbox?: OpenClawSandboxConfig; + readonly gatewayToken: Redacted.Redacted; + readonly gatewayBind?: "loopback" | "lan"; + readonly channelPath?: string; +} + +function mcpConfigSection( + mcpServers?: readonly OpenClawMcpServer[], +): Pick { + if (mcpServers === undefined || mcpServers.length === 0) { + return {}; + } + return { + mcp: { + servers: Object.fromEntries( + mcpServers.map((server) => [ + server.name, + { + transport: "stdio" as const, + command: server.command, + args: [...server.args], + env: { ...server.env }, + }, + ]), + ), + }, + }; +} + +function pluginConfiguration( + channelPath?: string, +): Pick { + return channelPath === undefined + ? {} + : { + plugins: { + entries: { + [OPENCLAW_EXTENSION_NAME]: { enabled: true }, + }, + load: { paths: [channelPath] }, + }, + }; +} + +/** + * Build the complete OpenClaw configuration mounted into one container. + * @param input Runtime-specific OpenClaw settings and credentials. + * @param workspaceDirectory Absolute workspace path inside the container. + * @returns The native OpenClaw configuration. + */ +export function buildOpenClawConfig( + input: OpenClawConfigInput, + workspaceDirectory: string, +): OpenClawConfig { + return { + ...mcpConfigSection(input.mcpServers), + agents: { + defaults: { + model: { primary: input.modelId ?? DEFAULT_OPENCLAW_MODEL_ID }, + workspace: workspaceDirectory, + compaction: { mode: "safeguard" }, + ...(input.sandbox === undefined ? {} : { sandbox: input.sandbox }), + skipBootstrap: true, + }, + list: [{ id: input.agentName, default: true }], + }, + ...(input.tools === undefined ? {} : { tools: input.tools }), + commands: { native: "auto", nativeSkills: "auto", restart: true }, + ...pluginConfiguration(input.channelPath), + messages: { + // Mid-turn traffic steers the active turn so social input is observed + // without accumulating an independent simulator-owned mailbox. + queue: { mode: "steer", debounceMs: 0, cap: 100, drop: "new" }, + }, + discovery: { mdns: { mode: "off" } }, + channels: { + [OPENCLAW_CHANNEL_ID]: { + accounts: [ + { + id: SIMULATOR_PROFILE_NAME, + agentName: input.agentName, + }, + ], + }, + }, + gateway: { + mode: "local", + bind: input.gatewayBind ?? "loopback", + auth: { + mode: "token", + token: Redacted.value(input.gatewayToken), + }, + }, + }; +} diff --git a/packages/simulator/src/runtime/openclaw/gateway.test.ts b/packages/simulator/src/agents/openclaw/gateway.test.ts similarity index 82% rename from packages/simulator/src/runtime/openclaw/gateway.test.ts rename to packages/simulator/src/agents/openclaw/gateway.test.ts index 9b1fa0ec8..1977eb9b0 100644 --- a/packages/simulator/src/runtime/openclaw/gateway.test.ts +++ b/packages/simulator/src/agents/openclaw/gateway.test.ts @@ -7,20 +7,28 @@ import { agentName } from "@moltzap/protocol/testing"; import { Deferred, Duration, Effect, Fiber, Redacted } from "effect"; import { describe } from "vitest"; import { - acquireOpenClawGatewayWith, + acquireOpenClawGateway, + GatewayOperations, OpenClawGatewayRequest, - OpenClawGatewayRequestFailed, + OpenClawGatewayRequestError, + OpenClawGatewayStoppedBeforeHello, OpenClawGatewaySucceeded, OpenClawGatewayTimedOut, type OpenClawGatewayClient, type OpenClawGatewayClientFactory, type OpenClawGatewayResponse, + type OpenClawGatewaySession, } from "./gateway.js"; -import type { OpenClawProcessSession } from "./process.js"; const test = effectIt.effect; const GATEWAY_URL = "ws://127.0.0.1:43124"; +const REMOTE_GATEWAY_URL = "ws://alice.society.svc:18789"; const GATEWAY_TOKEN = "test-openclaw-gateway-token"; +const DEVICE_IDENTITY = Object.freeze({ + deviceId: "a".repeat(64), + privateKeyPem: "private-key", + publicKeyPem: "public-key", +}); const STARTUP_TIMEOUT = Duration.seconds(2); const AGENT_METHOD = "agent"; const OPERATOR_ROLE = "operator"; @@ -48,13 +56,22 @@ interface RoundTripFixture { function processSession( exitCode: Deferred.Deferred, -): OpenClawProcessSession { +): OpenClawGatewaySession { + const observedExit = Deferred.await(exitCode); return { - exitCode: Deferred.await(exitCode), - output: () => "", gatewayUrl: GATEWAY_URL, gatewayToken: Redacted.make(GATEWAY_TOKEN), + deviceIdentity: DEVICE_IDENTITY, agentName: AGENT_NAME, + stopped: observedExit.pipe( + Effect.flatMap((code) => + Effect.fail( + OpenClawGatewayStoppedBeforeHello.make({ + detail: `OpenClaw exited before its principal gateway exposed a hello response (exitCode=${String(code)})`, + }), + ), + ), + ), }; } @@ -116,12 +133,20 @@ function roundTripClient( }; } +function acquireGateway( + session: OpenClawGatewaySession, + makeClient: OpenClawGatewayClientFactory, +) { + return acquireOpenClawGateway(session, STARTUP_TIMEOUT).pipe( + Effect.provideService(GatewayOperations, makeClient), + ); +} + function runRoundTrip(fixture: RoundTripFixture) { return Effect.scoped( Effect.gen(function* () { - const gateway = yield* acquireOpenClawGatewayWith( + const gateway = yield* acquireGateway( processSession(fixture.exitCode), - STARTUP_TIMEOUT, roundTripClient(fixture), ); return yield* gateway.agent( @@ -164,7 +189,8 @@ function assertRoundTrip( assert.strictEqual(clientOptions.token, GATEWAY_TOKEN); assert.strictEqual(clientOptions.role, OPERATOR_ROLE); assert.deepStrictEqual(clientOptions.scopes, [OPERATOR_WRITE_SCOPE]); - assert.isNull(clientOptions.deviceIdentity); + assert.strictEqual(clientOptions.deviceIdentity, DEVICE_IDENTITY); + assert.isUndefined(clientOptions.env); assert.strictEqual(request.method, AGENT_METHOD); assert.deepStrictEqual(request.params, { message: INSTRUCTION, @@ -216,9 +242,8 @@ function invalidResponseTest() { const exitCode = yield* Deferred.make(); const failure = yield* Effect.scoped( Effect.gen(function* () { - const gateway = yield* acquireOpenClawGatewayWith( + const gateway = yield* acquireGateway( processSession(exitCode), - STARTUP_TIMEOUT, readyClient({ runId: RUN_ID, status: "surprise", @@ -236,7 +261,7 @@ function invalidResponseTest() { }), ); - assert.instanceOf(failure, OpenClawGatewayRequestFailed); + assert.instanceOf(failure, OpenClawGatewayRequestError); assert.include(failure.detail, "invalid terminal agent response"); }); } @@ -247,9 +272,8 @@ function boundedResponseTextTest() { const text = "x".repeat(GATEWAY_TEXT_MAX_LENGTH); const response = yield* Effect.scoped( Effect.gen(function* () { - const gateway = yield* acquireOpenClawGatewayWith( + const gateway = yield* acquireGateway( processSession(exitCode), - STARTUP_TIMEOUT, readyClient({ runId: RUN_ID, status: "ok", @@ -279,9 +303,8 @@ function nullableMediaUrlTest() { const exitCode = yield* Deferred.make(); const response = yield* Effect.scoped( Effect.gen(function* () { - const gateway = yield* acquireOpenClawGatewayWith( + const gateway = yield* acquireGateway( processSession(exitCode), - STARTUP_TIMEOUT, readyClient({ runId: RUN_ID, status: "ok", @@ -310,9 +333,8 @@ function oversizedResponseTextTest() { const exitCode = yield* Deferred.make(); const failure = yield* Effect.scoped( Effect.gen(function* () { - const gateway = yield* acquireOpenClawGatewayWith( + const gateway = yield* acquireGateway( processSession(exitCode), - STARTUP_TIMEOUT, readyClient({ runId: RUN_ID, status: "ok", @@ -337,7 +359,7 @@ function oversizedResponseTextTest() { }), ); - assert.instanceOf(failure, OpenClawGatewayRequestFailed); + assert.instanceOf(failure, OpenClawGatewayRequestError); assert.include(failure.detail, "invalid terminal agent response"); }); } @@ -347,9 +369,8 @@ function timeoutResponseTest() { const exitCode = yield* Deferred.make(); const response = yield* Effect.scoped( Effect.gen(function* () { - const gateway = yield* acquireOpenClawGatewayWith( + const gateway = yield* acquireGateway( processSession(exitCode), - STARTUP_TIMEOUT, readyClient({ runId: RUN_ID, status: "timeout", @@ -393,11 +414,7 @@ function exitBeforeHelloTest() { request: () => Promise.resolve({}), }); const acquiring = yield* Effect.scoped( - acquireOpenClawGatewayWith( - processSession(exitCode), - STARTUP_TIMEOUT, - makeClient, - ), + acquireGateway(processSession(exitCode), makeClient), ).pipe(Effect.flip, Effect.fork); yield* Deferred.await(started); yield* Deferred.succeed(exitCode, processExitCode(27)); @@ -409,6 +426,33 @@ function exitBeforeHelloTest() { }); } +function privateNetworkGatewayTest() { + return Effect.gen(function* () { + let clientOptions: Parameters[0] | undefined; + const session: OpenClawGatewaySession = { + gatewayUrl: REMOTE_GATEWAY_URL, + gatewayToken: Redacted.make(GATEWAY_TOKEN), + deviceIdentity: DEVICE_IDENTITY, + agentName: AGENT_NAME, + stopped: Effect.never, + }; + const delegate = readyClient({}); + + yield* Effect.scoped( + acquireGateway(session, (options) => { + clientOptions = options; + return delegate(options); + }), + ); + + assert.isDefined(clientOptions); + assert.strictEqual(clientOptions?.url, REMOTE_GATEWAY_URL); + assert.deepStrictEqual(clientOptions?.env, { + OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1", + }); + }); +} + describe("OpenClaw principal gateway", () => { test( "binds the scoped client to its principal agent and decodes the response", @@ -432,4 +476,8 @@ describe("OpenClaw principal gateway", () => { "fails and releases the client when the process exits before hello", exitBeforeHelloTest, ); + test( + "opts into OpenClaw's private-network websocket client for a remote Pod", + privateNetworkGatewayTest, + ); }); diff --git a/packages/simulator/src/runtime/openclaw/gateway.ts b/packages/simulator/src/agents/openclaw/gateway.ts similarity index 79% rename from packages/simulator/src/runtime/openclaw/gateway.ts rename to packages/simulator/src/agents/openclaw/gateway.ts index b7f0224e5..45e161d2a 100644 --- a/packages/simulator/src/runtime/openclaw/gateway.ts +++ b/packages/simulator/src/agents/openclaw/gateway.ts @@ -1,20 +1,20 @@ /** @file Scoped principal access to one OpenClaw gateway process. */ -import type { ExitCode } from "@effect/platform/CommandExecutor"; import type { AgentName } from "@moltzap/protocol/identity"; import { GatewayClient, startGatewayClientWhenEventLoopReady, } from "openclaw/plugin-sdk/gateway-runtime"; import { + Context, Deferred, Duration, Effect, + Option, Redacted, Schema, type Scope, } from "effect"; -import type { OpenClawProcessSession } from "./process.js"; const OPENCLAW_GATEWAY_CLIENT_STOP_TIMEOUT_MS = 1_000; const OPENCLAW_GATEWAY_PAYLOAD_MAX_COUNT = 16; @@ -22,6 +22,33 @@ const OPENCLAW_GATEWAY_TEXT_MAX_LENGTH = 32 * 1_024; const OPENCLAW_GATEWAY_MEDIA_URL_MAX_LENGTH = 8 * 1_024; const OPENCLAW_GATEWAY_MEDIA_URL_MAX_COUNT = 16; +/** The application stopped before its controller observed gateway hello. */ +export class OpenClawGatewayStoppedBeforeHello extends Schema.TaggedError()( + "OpenClawGatewayStoppedBeforeHello", + { detail: Schema.String }, +) { + override get message(): string { + return this.detail; + } +} + +/** Controller-side observations required to attach the native gateway. */ +export interface OpenClawGatewaySession { + readonly gatewayUrl: `ws://${string}` | `wss://${string}`; + readonly gatewayToken: Redacted.Redacted; + /** Run-private OpenClaw device identity pre-approved by the application. */ + readonly deviceIdentity: OpenClawGatewayDeviceIdentity; + readonly agentName: AgentName; + readonly stopped: Effect.Effect; +} + +/** Native OpenClaw device keypair used by the controller bridge. */ +export interface OpenClawGatewayDeviceIdentity { + readonly deviceId: string; + readonly privateKeyPem: string; + readonly publicKeyPem: string; +} + const openClawGatewayText = Schema.String.pipe( Schema.maxLength(OPENCLAW_GATEWAY_TEXT_MAX_LENGTH), ); @@ -116,8 +143,8 @@ export const OpenClawGatewayResponse = Schema.Union( export type OpenClawGatewayResponse = typeof OpenClawGatewayResponse.Type; /** A native OpenClaw gateway call failed or returned an invalid payload. */ -export class OpenClawGatewayRequestFailed extends Schema.TaggedError()( - "OpenClawGatewayRequestFailed", +export class OpenClawGatewayRequestError extends Schema.TaggedError()( + "OpenClawGatewayRequestError", { detail: Schema.String, }, @@ -131,7 +158,7 @@ export class OpenClawGatewayRequestFailed extends Schema.TaggedError Effect.Effect; + ) => Effect.Effect; } interface OpenClawAgentRequestParameters { @@ -172,6 +199,15 @@ export type OpenClawGatewayClientFactory = ( options: GatewayClientOptions, ) => OpenClawGatewayClient; +/** + * Gateway client construction, replaceable by lifecycle tests. A run that + * installs nothing gets the native client. + * @internal + */ +export class GatewayOperations extends Context.Tag( + "@moltzap/simulator/GatewayOperations", +)() {} + const makeNativeGatewayClient: OpenClawGatewayClientFactory = (options) => new GatewayClient(options); @@ -179,27 +215,6 @@ function gatewayConnectionFailure(detail: string): Error { return new Error(detail); } -function processStoppedBeforeHello( - exitCode: Effect.Effect, -): Effect.Effect { - return exitCode.pipe( - Effect.matchEffect({ - onFailure: () => - Effect.fail( - gatewayConnectionFailure( - "OpenClaw stopped before its principal gateway exposed a hello response", - ), - ), - onSuccess: (code) => - Effect.fail( - gatewayConnectionFailure( - `OpenClaw exited before its principal gateway exposed a hello response (exitCode=${String(code)})`, - ), - ), - }), - ); -} - function closeGatewayClient( client: OpenClawGatewayClient, ): Effect.Effect { @@ -221,6 +236,16 @@ function closeGatewayClient( ); } +function gatewayClientEnvironment( + gatewayUrl: OpenClawGatewaySession["gatewayUrl"], +): NodeJS.ProcessEnv | undefined { + const parsed = new URL(gatewayUrl); + const loopback = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]); + return parsed.protocol === "ws:" && !loopback.has(parsed.hostname) + ? { OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1" } + : undefined; +} + function startGatewayClient( client: OpenClawGatewayClient, within: Duration.Duration, @@ -285,15 +310,15 @@ function makeOpenClawGateway( signal, }), catch: (cause) => - OpenClawGatewayRequestFailed.make({ + OpenClawGatewayRequestError.make({ detail: String(cause), }), }).pipe( Effect.flatMap(Schema.decodeUnknown(OpenClawGatewayResponse)), Effect.mapError((cause) => - cause instanceof OpenClawGatewayRequestFailed + cause instanceof OpenClawGatewayRequestError ? cause - : OpenClawGatewayRequestFailed.make({ + : OpenClawGatewayRequestError.make({ detail: `invalid terminal agent response: ${String(cause)}`, }), ), @@ -305,24 +330,30 @@ function makeOpenClawGateway( /** * Connect a persistent OpenClaw client, await its protocol hello, and retain * it in the process Scope. + * + * The container attach contract fixes this Effect's requirements to Scope, so + * the client factory is an optional environment override rather than a + * required service: a run that installs nothing gets the native client. * @param session Running OpenClaw process and private gateway credentials. * @param within Runtime-owned startup deadline. - * @param makeClient Constructor seam used by focused lifecycle tests. * @returns The runtime-native principal gateway. * @internal */ -export function acquireOpenClawGatewayWith( - session: OpenClawProcessSession, +export function acquireOpenClawGateway( + session: OpenClawGatewaySession, within: Duration.Duration, - makeClient: OpenClawGatewayClientFactory, ): Effect.Effect { return Effect.gen(function* () { + const makeClient = yield* Effect.serviceOption(GatewayOperations).pipe( + Effect.map(Option.getOrElse(() => makeNativeGatewayClient)), + ); const hello = yield* Deferred.make(); // eslint-disable-next-line agent-code-guard/acquire-release-requires-scope -- The returned Effect requires Scope, so its caller owns this finalizer. const client = yield* Effect.acquireRelease( Effect.try({ - try: () => - makeClient({ + try: () => { + const environment = gatewayClientEnvironment(session.gatewayUrl); + return makeClient({ url: session.gatewayUrl, token: Redacted.value(session.gatewayToken), clientName: "gateway-client", @@ -330,11 +361,13 @@ export function acquireOpenClawGatewayWith( mode: "backend", role: "operator", scopes: ["operator.write"], - deviceIdentity: null, + deviceIdentity: session.deviceIdentity, + ...(environment === undefined ? {} : { env: environment }), onHelloOk: () => { Effect.runSync(Deferred.succeed(hello, undefined)); }, - }), + }); + }, catch: (cause) => gatewayConnectionFailure( `could not construct the OpenClaw gateway client: ${String(cause)}`, @@ -344,7 +377,7 @@ export function acquireOpenClawGatewayWith( ); const ready = startGatewayClient(client, within).pipe( Effect.zipRight(Deferred.await(hello)), - Effect.raceFirst(processStoppedBeforeHello(session.exitCode)), + Effect.raceFirst(session.stopped), Effect.timeoutFail({ duration: within, onTimeout: () => @@ -357,16 +390,3 @@ export function acquireOpenClawGatewayWith( return makeOpenClawGateway(client, session.agentName); }).pipe(Effect.withSpan("OpenClawGateway.acquire")); } - -/** - * Acquire the production OpenClaw principal gateway. - * @param session Running OpenClaw process and private gateway credentials. - * @param within Runtime-owned startup deadline. - * @returns The scoped native principal gateway. - */ -export function acquireOpenClawGateway( - session: OpenClawProcessSession, - within: Duration.Duration, -): Effect.Effect { - return acquireOpenClawGatewayWith(session, within, makeNativeGatewayClient); -} diff --git a/packages/simulator/src/agents/openclaw/runtime.test.ts b/packages/simulator/src/agents/openclaw/runtime.test.ts new file mode 100644 index 000000000..4eff88362 --- /dev/null +++ b/packages/simulator/src/agents/openclaw/runtime.test.ts @@ -0,0 +1,313 @@ +import { assert, it as effectIt } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { describe } from "vitest"; +import { makeAgentHandle, type AgentConnection } from "../../network.js"; +import { + containerRuntimeFor, + type Application, + type ContainerRuntime, + type File, +} from "../container.js"; +import { + runtimeConfigurationProjection, + type RuntimeAcquisitionError, +} from "../agent.js"; +import { + GatewayOperations, + OpenClawGatewayRequest, + OpenClawGatewaySucceeded, + type OpenClawGateway, + type OpenClawGatewayClientFactory, +} from "./gateway.js"; +import { openClawRuntime } from "./runtime.js"; +import { serverBaseUrl } from "@moltzap/protocol/network"; +import { + agentId, + agentName, + redactedAgentKey, +} from "@moltzap/protocol/testing"; + +const test = effectIt.effect; +const AGENT_NAME = agentName("alice"); +const AGENT_ID = agentId("00000000-0000-4000-8000-000000000001"); +const AGENT_KEY_TEXT = + "moltzap_agent_0000000000000000_000000000000000000000000000000000000000000000000"; +const AGENT_KEY = redactedAgentKey(AGENT_KEY_TEXT); +// eslint-disable-next-line sonarjs/no-clear-text-protocols -- the private in-cluster router contract is intentionally HTTP. +const ROUTER_URL = serverBaseUrl("http://router.society.svc:3000"); +const GATEWAY_HOST = "alice.society.svc"; +const BOOTSTRAP_ROOT = "/var/run/moltzap/bootstrap/"; +const OPENCLAW_CONFIG_PATH = `${BOOTSTRAP_ROOT}openclaw.json`; +const PROFILE_PATH = `${BOOTSTRAP_ROOT}moltzap/config.json`; +const CHANNEL_PATH = `${BOOTSTRAP_ROOT}openclaw-channel`; +const WORKSPACE_PATH = `${BOOTSTRAP_ROOT}workspace/IDENTITY.md`; +const GATEWAY_PORT = 18_789; +const APPLICATION_STATE_DIR = `${BOOTSTRAP_ROOT}state`; +const PAIRED_DEVICES_PATH = `${APPLICATION_STATE_DIR}/devices/paired.json`; +const WORKSPACE_CONTENT = "Alice"; +const BRIDGE_RUN_ID = "openclaw-bridge-run"; +const BRIDGE_IDEMPOTENCY_KEY = "openclaw-bridge-key"; + +const connection: AgentConnection<"alice"> = { + agent: makeAgentHandle("alice", AGENT_ID), + key: AGENT_KEY, + routerUrl: ROUTER_URL, +}; + +/** + * OpenClaw sees no stop the cluster cannot, so it must never report one: its + * gateway is request-response over a connection the bridge client owns, not a + * connection the runtime holds open and watches. + * @returns An Effect that fails the test if the runtime ever reports a stop. + */ +function unreportedStop(): Effect.Effect { + return Effect.dieMessage("the OpenClaw runtime reported an unexpected stop"); +} + +const renderedOpenClawConfig = Schema.parseJson( + Schema.Struct({ + agents: Schema.Struct({ + defaults: Schema.Struct({ workspace: Schema.String }), + }), + gateway: Schema.Struct({ + bind: Schema.String, + auth: Schema.Struct({ token: Schema.String }), + }), + plugins: Schema.Struct({ + load: Schema.Struct({ paths: Schema.Array(Schema.String) }), + }), + }), +); + +const renderedMoltZapProfile = Schema.parseJson( + Schema.Struct({ + profiles: Schema.Struct({ + "simulator-agent": Schema.Struct({ + agentId: Schema.String, + apiKey: Schema.String, + agentName: Schema.String, + }), + }), + }), +); + +type OpenClawContainerRuntime = ContainerRuntime< + OpenClawGateway, + RuntimeAcquisitionError +>; +type OpenClawApplication = Application< + OpenClawGateway, + RuntimeAcquisitionError +>; + +interface StockFixture { + readonly runtime: ReturnType; + readonly capability: OpenClawContainerRuntime; + readonly application: OpenClawApplication; + readonly config: typeof renderedOpenClawConfig.Type; + readonly profile: typeof renderedMoltZapProfile.Type; +} + +function requireFile(files: readonly File[], path: string): string { + const file = files.find((candidate) => candidate.path === path); + if (file === undefined) { + throw new Error(`missing rendered file ${path}`); + } + return file.content; +} + +function makeStockFixture() { + return Effect.gen(function* () { + const runtime = openClawRuntime({ + modelId: "openai/gpt-5.5", + workspaceFiles: [ + { relativePath: "IDENTITY.md", content: WORKSPACE_CONTENT }, + ], + }); + const capability = containerRuntimeFor(runtime); + const application = yield* capability.render({ + agentName: AGENT_NAME, + connection, + }); + const config = Schema.decodeUnknownSync(renderedOpenClawConfig)( + requireFile(application.files, OPENCLAW_CONFIG_PATH), + ); + const profile = Schema.decodeUnknownSync(renderedMoltZapProfile)( + requireFile(application.files, PROFILE_PATH), + ); + return { runtime, capability, application, config, profile }; + }); +} + +function assertCredentialFreeReservation( + capability: OpenClawContainerRuntime, +): void { + const reservation = JSON.stringify({ + image: capability.image, + resources: capability.resources, + }).toLowerCase(); + assert.notInclude(reservation, AGENT_KEY_TEXT.toLowerCase()); + assert.notInclude(reservation, "credential"); + assert.notInclude(reservation, "bootstrap"); + assert.match(capability.image, /@sha256:[\da-f]{64}$/u); +} + +function assertApplicationContainer(fixture: StockFixture): void { + const { application, capability, config } = fixture; + const containerProjection = JSON.stringify({ + entrypoint: application.entrypoint, + environment: application.environment, + credentials: application.credentials, + port: application.port, + }); + assert.notProperty(application, "containers"); + assert.notProperty(application, "applicationContainers"); + assert.deepStrictEqual(capability.resources, { + cpuMillis: 1_000, + memoryBytes: 1_024 * 1_024 * 1_024, + ephemeralStorageBytes: 1_024 * 1_024 * 1_024, + }); + assert.deepStrictEqual(application.entrypoint, [ + "node", + "/app/openclaw.mjs", + "gateway", + "run", + "--allow-unconfigured", + "--port", + String(GATEWAY_PORT), + ]); + assert.strictEqual(application.port, GATEWAY_PORT); + assert.strictEqual( + application.environment.OPENCLAW_CONFIG_PATH, + OPENCLAW_CONFIG_PATH, + ); + assert.strictEqual( + application.environment.OPENCLAW_STATE_DIR, + APPLICATION_STATE_DIR, + ); + assert.strictEqual(application.environment.MOLTZAP_SERVER_URL, ROUTER_URL); + assert.deepStrictEqual(application.credentials, ["OPENAI_API_KEY"]); + assert.notInclude(containerProjection, AGENT_KEY_TEXT); + assert.notInclude(containerProjection, config.gateway.auth.token); + assert.strictEqual(config.gateway.bind, "lan"); + assert.strictEqual( + config.agents.defaults.workspace, + `${BOOTSTRAP_ROOT}workspace`, + ); + assert.deepStrictEqual(config.plugins.load.paths, [CHANNEL_PATH]); +} + +function assertBootstrapMaterial(fixture: StockFixture): void { + const { application, profile, runtime } = fixture; + assert.strictEqual(profile.profiles["simulator-agent"].agentId, AGENT_ID); + assert.strictEqual( + profile.profiles["simulator-agent"].apiKey, + AGENT_KEY_TEXT, + ); + assert.strictEqual(profile.profiles["simulator-agent"].agentName, AGENT_NAME); + assert.strictEqual( + requireFile(application.files, WORKSPACE_PATH), + WORKSPACE_CONTENT, + ); + const pairedDevices = + /* Safe because the same render call generated this file's JSON. */ + JSON.parse(requireFile(application.files, PAIRED_DEVICES_PATH)) as Record< + string, + { readonly approvedScopes: readonly string[] } + >; + assert.lengthOf(Object.keys(pairedDevices), 1); + assert.deepStrictEqual(Object.values(pairedDevices)[0]?.approvedScopes, [ + "operator.write", + ]); + assert.isTrue( + application.files.every((file) => file.path.startsWith(BOOTSTRAP_ROOT)), + ); + assert.notInclude( + JSON.stringify(runtimeConfigurationProjection(runtime)), + AGENT_KEY_TEXT, + ); +} + +function stockCapabilityTest() { + return Effect.gen(function* () { + const fixture = yield* makeStockFixture(); + assertCredentialFreeReservation(fixture.capability); + assertApplicationContainer(fixture); + assertBootstrapMaterial(fixture); + }); +} + +interface ObservedClient { + options?: Parameters[0]; +} + +function bridgeClient(observed: ObservedClient): OpenClawGatewayClientFactory { + return (options) => { + observed.options = options; + return { + start: () => { + const notify = + /* Safe because the production callback ignores HelloOk; this double only reports the handshake transition. */ + options.onHelloOk as (() => void) | undefined; + notify?.(); + }, + stop: () => undefined, + stopAndWait: () => Promise.resolve(), + request: () => + Promise.resolve({ + runId: BRIDGE_RUN_ID, + status: "ok", + summary: "completed", + result: {}, + }), + }; + }; +} + +function exactBridgeTest() { + return Effect.gen(function* () { + const observed: ObservedClient = {}; + const fixture = yield* makeStockFixture(); + const response = yield* Effect.scoped( + Effect.gen(function* () { + const gateway = yield* fixture.application.attach( + { host: GATEWAY_HOST, port: GATEWAY_PORT }, + Effect.never, + unreportedStop, + ); + return yield* gateway.agent( + OpenClawGatewayRequest.make({ + message: "Do the task.", + idempotencyKey: BRIDGE_IDEMPOTENCY_KEY, + }), + ); + }), + ).pipe(Effect.provideService(GatewayOperations, bridgeClient(observed))); + + assert.instanceOf(response, OpenClawGatewaySucceeded); + assert.strictEqual(response.runId, BRIDGE_RUN_ID); + assert.strictEqual( + observed.options?.url, + `ws://${GATEWAY_HOST}:${String(GATEWAY_PORT)}/`, + ); + assert.strictEqual( + observed.options?.token, + fixture.config.gateway.auth.token, + ); + assert.match( + observed.options?.deviceIdentity?.deviceId ?? "", + /^[\da-f]{64}$/u, + ); + }); +} + +describe("OpenClaw container runtime", () => { + test( + "renders one stock application container with credentials confined to bootstrap files", + stockCapabilityTest, + ); + test( + "attaches the exact native gateway and termination observation", + exactBridgeTest, + ); +}); diff --git a/packages/simulator/src/agents/openclaw/runtime.ts b/packages/simulator/src/agents/openclaw/runtime.ts new file mode 100644 index 000000000..26eaf8312 --- /dev/null +++ b/packages/simulator/src/agents/openclaw/runtime.ts @@ -0,0 +1,443 @@ +/** @file Container-backed OpenClaw runtime. */ + +import type { AgentName } from "@moltzap/protocol/identity"; +import { createHash, generateKeyPairSync, randomBytes } from "node:crypto"; +import { httpBaseUrl } from "@moltzap/protocol/network"; +import { + acquisitionFailureFor, + defineContainerRuntime, + image, + routableBridgeEndpoint, + stoppedBeforeAttach, + type Application, + type ApplicationEndpoint, + type ContainerAgentRuntime, + type ContainerRuntime, + type File, +} from "../container.js"; +import { + deepFreeze, + type AgentRuntimeInput, + type RuntimeAcquisitionError, + type RuntimeTermination, +} from "../agent.js"; +import { + Duration, + Effect, + Inspectable, + Redacted, + Schema, + type Scope, +} from "effect"; +import { + bootstrapFile, + configurationDigest, + digestText, + McpServerConfiguration, + mcpConfiguration, + serializeMoltZapProfileConfig, + snapshotMcpServers, + snapshotWorkspaceFiles, + WorkspaceFileConfiguration, + workspaceConfiguration, + workspaceFilePath, + type CheckedWorkspaceFile, + type McpServer, + type WorkspaceFile, +} from "../workspace.js"; +import { + buildOpenClawConfig, + type OpenClawSandboxConfig, + type OpenClawToolsConfig, +} from "./configuration.js"; +import { + acquireOpenClawGateway, + type OpenClawGateway, + type OpenClawGatewayDeviceIdentity, + type OpenClawGatewaySession, + OpenClawGatewayStoppedBeforeHello, +} from "./gateway.js"; + +/** Native OpenClaw policy types accepted by the shipped runtime. */ +export type { + OpenClawSandboxConfig, + OpenClawToolsConfig, +} from "./configuration.js"; + +const OPENCLAW_RUNTIME_NAME = "openclaw"; +const DEFAULT_OPENCLAW_STARTUP_TIMEOUT = Duration.minutes(2); +const OPENCLAW_GATEWAY_PORT = 18_789; +const OPENCLAW_BOOTSTRAP_DIR = "/var/run/moltzap/bootstrap"; +const APPLICATION_STATE_DIR = `${OPENCLAW_BOOTSTRAP_DIR}/state`; +const APPLICATION_CONFIG_PATH = `${OPENCLAW_BOOTSTRAP_DIR}/openclaw.json`; +const OPENCLAW_PROFILE_HOME = `${OPENCLAW_BOOTSTRAP_DIR}/moltzap`; +const OPENCLAW_PROFILE_PATH = `${OPENCLAW_PROFILE_HOME}/config.json`; +const OPENCLAW_WORKSPACE_DIR = `${OPENCLAW_BOOTSTRAP_DIR}/workspace`; +const OPENCLAW_CHANNEL_PATH = `${OPENCLAW_BOOTSTRAP_DIR}/openclaw-channel`; +const OPENCLAW_GATEWAY_TOKEN_BYTES = 32; +const OPENCLAW_DEVICE_TOKEN_BYTES = 32; +const OPENCLAW_ED25519_PUBLIC_KEY_BYTES = 32; +const STOCK_OPENCLAW_IMAGE = image.make( + "ghcr.io/openclaw/openclaw@sha256:27612bb8e5a766ace76fbc2c19276cc9e321f66ad065292eae197f0f5624d371", +); +const APPLICATION_RESOURCES = Object.freeze({ + cpuMillis: 1_000, + memoryBytes: 1_024 * 1_024 * 1_024, + ephemeralStorageBytes: 1_024 * 1_024 * 1_024, +}); + +const acquisitionFailure = acquisitionFailureFor(OPENCLAW_RUNTIME_NAME); + +class OpenClawNativePolicyConfiguration extends Schema.Class( + "OpenClawNativePolicyConfiguration", +)({ + definitionDigest: configurationDigest, + redacted: Schema.Tuple(Schema.Literal("configuration")), +}) {} + +/** + * Sanitized definition-time policy for an OpenClaw application container. + */ +export class OpenClawRuntimeConfiguration extends Schema.Class( + "OpenClawRuntimeConfiguration", +)({ + startupTimeout: Schema.DurationFromMillis, + workspaceFiles: Schema.Array(WorkspaceFileConfiguration), + modelOverride: Schema.optional(Schema.String), + mcpServers: Schema.Array(McpServerConfiguration), + tools: Schema.optional(OpenClawNativePolicyConfiguration), + sandbox: Schema.optional(OpenClawNativePolicyConfiguration), +}) {} + +/** Configuration captured by one reusable OpenClaw runtime value. */ +export interface OpenClawRuntimeOptions { + readonly startupTimeout?: Duration.Duration; + readonly workspaceFiles?: readonly WorkspaceFile[]; + readonly modelId?: string; + readonly mcpServers?: readonly McpServer[]; + readonly tools?: OpenClawToolsConfig; + readonly sandbox?: OpenClawSandboxConfig; +} + +interface OpenClawRuntimeSettings { + readonly startupTimeout: Duration.Duration; + readonly workspaceFiles: readonly CheckedWorkspaceFile[]; + readonly modelId?: string; + readonly mcpServers?: readonly McpServer[]; + readonly tools?: OpenClawToolsConfig; + readonly sandbox?: OpenClawSandboxConfig; +} + +function snapshotNativeConfiguration( + value?: Value, +): Value | undefined { + if (value === undefined) { + return undefined; + } + return deepFreeze(structuredClone(value)); +} + +function snapshotOptions( + options: OpenClawRuntimeOptions, +): OpenClawRuntimeSettings { + return Object.freeze({ + startupTimeout: options.startupTimeout ?? DEFAULT_OPENCLAW_STARTUP_TIMEOUT, + workspaceFiles: snapshotWorkspaceFiles(options.workspaceFiles), + modelId: options.modelId, + mcpServers: snapshotMcpServers(options.mcpServers), + tools: snapshotNativeConfiguration(options.tools), + sandbox: snapshotNativeConfiguration(options.sandbox), + }); +} + +function nativePolicyConfiguration( + policy?: object, +): OpenClawNativePolicyConfiguration | undefined { + if (policy === undefined) { + return undefined; + } + return OpenClawNativePolicyConfiguration.make({ + definitionDigest: digestText(Inspectable.stringifyCircular(policy)), + redacted: ["configuration"], + }); +} + +function runtimeConfiguration( + settings: OpenClawRuntimeSettings, +): OpenClawRuntimeConfiguration { + const tools = nativePolicyConfiguration(settings.tools); + const sandbox = nativePolicyConfiguration(settings.sandbox); + return OpenClawRuntimeConfiguration.make({ + startupTimeout: settings.startupTimeout, + workspaceFiles: workspaceConfiguration(settings.workspaceFiles), + mcpServers: mcpConfiguration(settings.mcpServers), + ...(tools === undefined ? {} : { tools }), + ...(sandbox === undefined ? {} : { sandbox }), + ...(settings.modelId === undefined + ? {} + : { modelOverride: settings.modelId }), + }); +} + +type OpenClawGatewayAcquirer = ( + session: OpenClawGatewaySession, + within: Duration.Duration, +) => Effect.Effect; + +interface OpenClawGatewayPairing { + readonly deviceIdentity: OpenClawGatewayDeviceIdentity; + readonly pairedDevices: string; +} + +function createOpenClawGatewayPairing(): OpenClawGatewayPairing { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const publicKeyDer = publicKey.export({ type: "spki", format: "der" }); + const publicKeyRaw = publicKeyDer.subarray( + -OPENCLAW_ED25519_PUBLIC_KEY_BYTES, + ); + const deviceIdentity = Object.freeze({ + deviceId: createHash("sha256").update(publicKeyRaw).digest("hex"), + privateKeyPem: privateKey.export({ type: "pkcs8", format: "pem" }), + publicKeyPem: publicKey.export({ type: "spki", format: "pem" }), + }); + const now = Date.now(); + const operatorWrite = "operator.write"; + return Object.freeze({ + deviceIdentity, + pairedDevices: JSON.stringify({ + [deviceIdentity.deviceId]: { + deviceId: deviceIdentity.deviceId, + publicKey: publicKeyRaw.toString("base64url"), + displayName: "MoltZap simulator", + clientId: "gateway-client", + clientMode: "backend", + role: "operator", + roles: ["operator"], + scopes: [operatorWrite], + approvedScopes: [operatorWrite], + tokens: { + operator: { + token: randomBytes(OPENCLAW_DEVICE_TOKEN_BYTES).toString( + "base64url", + ), + role: "operator", + scopes: [operatorWrite], + createdAtMs: now, + }, + }, + createdAtMs: now, + approvedAtMs: now, + }, + }), + }); +} + +function bootstrapFiles( + settings: OpenClawRuntimeSettings, + input: AgentRuntimeInput, + gatewayToken: Redacted.Redacted, + pairing: OpenClawGatewayPairing, +): readonly File[] { + const nativeConfig = buildOpenClawConfig( + { + agentName: input.agentName, + gatewayToken, + gatewayBind: "lan", + channelPath: OPENCLAW_CHANNEL_PATH, + ...(settings.modelId === undefined ? {} : { modelId: settings.modelId }), + ...(settings.mcpServers === undefined + ? {} + : { mcpServers: settings.mcpServers }), + ...(settings.tools === undefined ? {} : { tools: settings.tools }), + ...(settings.sandbox === undefined ? {} : { sandbox: settings.sandbox }), + }, + OPENCLAW_WORKSPACE_DIR, + ); + const profile = serializeMoltZapProfileConfig({ + agentName: input.agentName, + agentId: input.connection.agent.id, + apiKey: input.connection.key, + }); + return Object.freeze([ + bootstrapFile( + APPLICATION_CONFIG_PATH, + JSON.stringify(nativeConfig, null, 2), + ), + bootstrapFile(OPENCLAW_PROFILE_PATH, profile), + bootstrapFile( + `${APPLICATION_STATE_DIR}/devices/paired.json`, + pairing.pairedDevices, + ), + ...settings.workspaceFiles.map((file) => + bootstrapFile( + workspaceFilePath(OPENCLAW_WORKSPACE_DIR, file.relativePath), + file.content, + ), + ), + ]); +} + +function bridgeUrl( + endpoint: ApplicationEndpoint, +): OpenClawGatewaySession["gatewayUrl"] { + return `ws://${endpoint.host}:${String(endpoint.port)}/`; +} + +function stoppedBeforeGatewayHello( + stopped: Effect.Effect, +): OpenClawGatewaySession["stopped"] { + return stoppedBeforeAttach(stopped, (detail) => + OpenClawGatewayStoppedBeforeHello.make({ + detail: `OpenClaw application stopped before gateway hello: ${detail}`, + }), + ); +} + +interface OpenClawBridge { + readonly startupTimeout: Duration.Duration; + readonly agentName: AgentName; + readonly gatewayToken: Redacted.Redacted; + readonly deviceIdentity: OpenClawGatewayDeviceIdentity; + readonly acquireGateway: OpenClawGatewayAcquirer; +} + +function attachOpenClaw( + bridge: OpenClawBridge, + endpoint: ApplicationEndpoint, + stopped: Effect.Effect, +): Effect.Effect { + return Effect.gen(function* () { + const gatewayUrl = yield* Effect.try({ + try: () => bridgeUrl(routableBridgeEndpoint(endpoint)), + catch: (cause) => + acquisitionFailure( + bridge.agentName, + "resolve distributed gateway", + cause, + ), + }); + return yield* bridge + .acquireGateway( + { + gatewayUrl, + gatewayToken: bridge.gatewayToken, + deviceIdentity: bridge.deviceIdentity, + agentName: bridge.agentName, + stopped: stoppedBeforeGatewayHello(stopped), + }, + bridge.startupTimeout, + ) + .pipe( + Effect.mapError((cause) => + acquisitionFailure( + bridge.agentName, + "connect distributed principal gateway", + cause, + ), + ), + ); + }); +} + +function makeOpenClawApplication( + settings: OpenClawRuntimeSettings, + acquireGateway: OpenClawGatewayAcquirer, + input: AgentRuntimeInput, +): Application { + const gatewayToken = Redacted.make( + randomBytes(OPENCLAW_GATEWAY_TOKEN_BYTES).toString("hex"), + ); + const pairing = createOpenClawGatewayPairing(); + const bridge = { + startupTimeout: settings.startupTimeout, + agentName: input.agentName, + gatewayToken, + deviceIdentity: pairing.deviceIdentity, + acquireGateway, + }; + return Object.freeze({ + entrypoint: Object.freeze([ + "node", + "/app/openclaw.mjs", + "gateway", + "run", + "--allow-unconfigured", + "--port", + String(OPENCLAW_GATEWAY_PORT), + ] as const), + environment: Object.freeze({ + HOME: APPLICATION_STATE_DIR, + OPENCLAW_STATE_DIR: APPLICATION_STATE_DIR, + OPENCLAW_CONFIG_PATH: APPLICATION_CONFIG_PATH, + MOLTZAP_CONFIG_HOME: OPENCLAW_PROFILE_HOME, + MOLTZAP_SERVER_URL: httpBaseUrl(input.connection.routerUrl), + OPENCLAW_DISABLE_BONJOUR: "1", + }), + ...(settings.modelId === undefined + ? {} + : { credentials: Object.freeze(["OPENAI_API_KEY"] as const) }), + port: OPENCLAW_GATEWAY_PORT, + files: bootstrapFiles(settings, input, gatewayToken, pairing), + attach: ( + endpoint: ApplicationEndpoint, + stopped: Effect.Effect, + ) => attachOpenClaw(bridge, endpoint, stopped), + }); +} + +function renderOpenClaw( + settings: OpenClawRuntimeSettings, + acquireGateway: OpenClawGatewayAcquirer, + input: AgentRuntimeInput, +): Effect.Effect< + Application, + RuntimeAcquisitionError +> { + return Effect.try({ + try: () => makeOpenClawApplication(settings, acquireGateway, input), + catch: (cause) => + acquisitionFailure( + input.agentName, + "render distributed application", + cause, + ), + }); +} + +function openClawCapability( + settings: OpenClawRuntimeSettings, + acquireGateway: OpenClawGatewayAcquirer, +): ContainerRuntime { + return Object.freeze({ + image: STOCK_OPENCLAW_IMAGE, + resources: APPLICATION_RESOURCES, + render: (input: AgentRuntimeInput) => + renderOpenClaw(settings, acquireGateway, input), + }); +} + +/** + * Construct an OpenClaw application container with its native gateway bridge. + * @param options Options that control the operation. + * @returns The open claw runtime result. + */ +export function openClawRuntime( + options: OpenClawRuntimeOptions = {}, +): ContainerAgentRuntime< + OpenClawGateway, + RuntimeAcquisitionError, + typeof OpenClawRuntimeConfiguration +> { + const settings = snapshotOptions(options); + const capability = openClawCapability(settings, acquireOpenClawGateway); + return defineContainerRuntime({ + name: OPENCLAW_RUNTIME_NAME, + configuration: { + schema: OpenClawRuntimeConfiguration, + value: runtimeConfiguration(settings), + }, + image: capability.image, + resources: capability.resources, + render: capability.render, + }); +} diff --git a/packages/simulator/src/runtime/roster.ts b/packages/simulator/src/agents/roster.ts similarity index 91% rename from packages/simulator/src/runtime/roster.ts rename to packages/simulator/src/agents/roster.ts index 99c307986..3ebb42fe7 100644 --- a/packages/simulator/src/runtime/roster.ts +++ b/packages/simulator/src/agents/roster.ts @@ -1,13 +1,10 @@ /** @file Nominal keyed runtime rosters and their exact Effect service. */ +// safer-arch-ignore no-cross-domain-sibling-import: A roster entry pairs a network participant handle with the runtime that answers for it. import { Context, Schema } from "effect"; import { agentName } from "@moltzap/protocol/identity"; import type { AgentHandle } from "../network/participant.js"; -import type { - AgentRuntime, - AgentRuntimeLike, - RunningAgent, -} from "./runtime.js"; +import type { AgentRuntime, AgentRuntimeLike, RunningAgent } from "./agent.js"; const agentRosterTypeId: unique symbol = Symbol( "@moltzap/simulator/AgentRoster", @@ -31,18 +28,14 @@ type RuntimeTypesOf = Runtime extends AgentRuntime< infer Gateway, infer AcquisitionError, - infer Requirements, infer ConfigurationSchema > - ? readonly [Gateway, AcquisitionError, Requirements, ConfigurationSchema] - : readonly [never, never, never, never]; + ? readonly [Gateway, AcquisitionError, ConfigurationSchema] + : readonly [never, never, never]; type RuntimeAcquisitionErrorOf = RuntimeTypesOf[1]; -type RuntimeRequirementsOf = - RuntimeTypesOf[2]; - /** The principal gateway exposed by one acquired runtime definition. */ export type RuntimeGatewayOf = RuntimeTypesOf[0]; @@ -52,11 +45,6 @@ export type AgentRosterAcquisitionError< Definitions extends Readonly>, > = RuntimeAcquisitionErrorOf; -/** The union of every heterogeneous runtime's Effect requirements. */ -export type AgentRosterRequirements< - Definitions extends Readonly>, -> = RuntimeRequirementsOf; - /** A ready autonomous runtime paired with its router-issued identity. */ export interface StartedAgent extends RunningAgent { diff --git a/packages/simulator/src/runtime/roster.types-check.ts b/packages/simulator/src/agents/roster.types-check.ts similarity index 58% rename from packages/simulator/src/runtime/roster.types-check.ts rename to packages/simulator/src/agents/roster.types-check.ts index f17311f2b..eb4053aff 100644 --- a/packages/simulator/src/runtime/roster.types-check.ts +++ b/packages/simulator/src/agents/roster.types-check.ts @@ -1,14 +1,12 @@ /** * A definition-bound keyed roster preserves its literal definition id, exact - * handle names, and the union of heterogeneous runtime requirements. Those - * types let the run Layer provide one exact Agents service without erasure. + * handle names, gateways, and attachment errors without erasure. */ -import { Context, Effect, Schema } from "effect"; -import { RuntimeCompleted, defineRuntime } from "./runtime.js"; +import { Effect, Schema } from "effect"; +import { defineRuntime } from "./agent.js"; import { type AgentRosterAcquisitionError, - type AgentRosterRequirements, makeAgentRosterBuilder, type StartedAgents, } from "./roster.js"; @@ -29,54 +27,28 @@ interface BetaAcquisitionError { readonly betaFailure: true; } -class AlphaRequirement extends Context.Tag( - "@moltzap/simulator/test/AlphaRequirement", -)< - AlphaRequirement, - { readonly ready: Effect.Effect } ->() {} - -class BetaRequirement extends Context.Tag( - "@moltzap/simulator/test/BetaRequirement", -)< - BetaRequirement, - { readonly ready: Effect.Effect } ->() {} - -const alphaGateway: AlphaGateway = { runtime: "alpha" }; -const betaGateway: BetaGateway = { runtime: "beta" }; const runtimeConfiguration = Schema.Struct({}); const configuration = { schema: runtimeConfiguration, value: {}, }; -const alphaRuntime = defineRuntime({ +const alphaRuntime = defineRuntime< + AlphaGateway, + AlphaAcquisitionError, + typeof runtimeConfiguration +>({ name: "alpha", configuration, - acquire: () => - Effect.gen(function* () { - const requirement = yield* AlphaRequirement; - yield* requirement.ready; - return { - gateway: alphaGateway, - termination: Effect.succeed(RuntimeCompleted.make({})), - }; - }), }); -const betaRuntime = defineRuntime({ +const betaRuntime = defineRuntime< + BetaGateway, + BetaAcquisitionError, + typeof runtimeConfiguration +>({ name: "beta", configuration, - acquire: () => - Effect.gen(function* () { - const requirement = yield* BetaRequirement; - yield* requirement.ready; - return { - gateway: betaGateway, - termination: Effect.succeed(RuntimeCompleted.make({})), - }; - }), }); const roster = makeAgentRosterBuilder("acme.society/v1")({ @@ -107,13 +79,6 @@ type AcquisitionErrorsAreCombined = Expect< AlphaAcquisitionError | BetaAcquisitionError > >; -type RequirementsAreCombined = Expect< - Equal< - AgentRosterRequirements, - AlphaRequirement | BetaRequirement - > ->; - /** Representative roster program retained for compile-time inference checks. */ export const rosterCanaryProgram = Effect.gen(function* () { const agents = yield* roster.startedAgents; @@ -132,6 +97,5 @@ export type RosterCanaries = [ AliceGatewayIsExact, BobGatewayIsExact, AcquisitionErrorsAreCombined, - RequirementsAreCombined, ServiceSuccessIsExact, ]; diff --git a/packages/simulator/src/agents/workspace.ts b/packages/simulator/src/agents/workspace.ts new file mode 100644 index 000000000..50eec708c --- /dev/null +++ b/packages/simulator/src/agents/workspace.ts @@ -0,0 +1,257 @@ +/** @file Definition-time bootstrap material shared by container runtimes. */ + +import type { AgentId, AgentKey, AgentName } from "@moltzap/protocol/identity"; +import { createHash } from "node:crypto"; +import { posix } from "node:path"; +import { Redacted, Schema } from "effect"; +import type { File } from "./container.js"; + +const PROFILE_CONFIG_INDENT_SPACES = 2; + +/** Profile selector shared by isolated runtime containers. */ +export const SIMULATOR_PROFILE_NAME = "simulator-agent"; + +/** + * Serialize the per-agent MoltZap profile mounted into a runtime container. + * @param profile Runtime identity and redacted credentials. + * @param profile.agentName Router-visible agent name. + * @param profile.agentId Registered agent identity. + * @param profile.apiKey Registered agent credential. + * @returns The JSON profile configuration. + */ +export function serializeMoltZapProfileConfig(profile: { + readonly agentName: AgentName; + readonly agentId: AgentId; + readonly apiKey: AgentKey; +}): string { + return JSON.stringify( + { + profiles: { + [SIMULATOR_PROFILE_NAME]: { + agentId: profile.agentId, + apiKey: Redacted.value(profile.apiKey), + agentName: profile.agentName, + }, + }, + }, + null, + PROFILE_CONFIG_INDENT_SPACES, + ); +} + +function staysBelowWorkspaceRoot(value: string): boolean { + // A backslash is an ordinary character to posix.normalize, so a Windows-style + // separator would survive normalization and reach the container verbatim. + if (value.includes("\\") || posix.isAbsolute(value)) { + return false; + } + return value !== "." && value !== ".." && !value.startsWith("../"); +} + +/** + * A workspace path proven to land inside its runtime's workspace root, held in + * the normalized form the bootstrap file is written under. Decoding happens + * where a runtime is defined, so a path can no longer escape at render time, + * after the router has already issued the agent its credentials. + */ +const workspaceRelativePath = Schema.transform( + Schema.String, + Schema.String.pipe( + Schema.filter(staysBelowWorkspaceRoot, { + identifier: "WorkspaceRelativePath", + message: (issue) => + `a workspace file path must stay below the workspace root: ${String(issue.actual)}`, + }), + Schema.brand("WorkspaceRelativePath"), + ), + { + strict: true, + decode: (value) => posix.normalize(value), + encode: (value) => value, + }, +); + +/** A workspace path proven to land inside its runtime's workspace root. */ +export type WorkspaceRelativePath = typeof workspaceRelativePath.Type; + +/** One file a runtime's options ask to mount into the agent workspace. */ +export interface WorkspaceFile { + readonly relativePath: string; + readonly content: string; +} + +/** One workspace file whose path was checked when the runtime was defined. */ +export interface CheckedWorkspaceFile { + readonly relativePath: WorkspaceRelativePath; + readonly content: string; +} + +/** One stdio MCP server mounted into a runtime container's workspace. */ +export interface McpServer { + readonly name: string; + readonly command: string; + readonly args: readonly string[]; + readonly env: Readonly>; +} + +const decodeWorkspaceRelativePath = Schema.decodeUnknownSync( + workspaceRelativePath, +); + +/** Digest standing in for material a sanitized configuration must not carry. */ +export const configurationDigest = Schema.String.pipe( + Schema.pattern(/^[\da-f]{64}$/u), + Schema.brand("ConfigurationDigest"), +); + +/** Digest standing in for material a sanitized configuration must not carry. */ +export type ConfigurationDigest = typeof configurationDigest.Type; + +/** Sanitized ledger record of one mounted workspace file. */ +export class WorkspaceFileConfiguration extends Schema.Class( + "WorkspaceFileConfiguration", +)({ + relativePath: Schema.String, + contentDigest: configurationDigest, + redacted: Schema.Tuple(Schema.Literal("content")), +}) {} + +/** Sanitized ledger record of one mounted MCP server. */ +export class McpServerConfiguration extends Schema.Class( + "McpServerConfiguration", +)({ + name: Schema.String, + definitionDigest: configurationDigest, + redacted: Schema.Tuple( + Schema.Literal("command"), + Schema.Literal("args"), + Schema.Literal("environmentValues"), + ), +}) {} + +/** + * Check and normalize every requested workspace path once, at definition time. + * @param files Workspace files requested by a runtime's options. + * @returns The frozen snapshot the runtime renders from. + */ +export function snapshotWorkspaceFiles( + files?: readonly WorkspaceFile[], +): readonly CheckedWorkspaceFile[] { + return Object.freeze( + (files ?? []).map((file) => + Object.freeze({ + relativePath: decodeWorkspaceRelativePath(file.relativePath), + content: file.content, + }), + ), + ); +} + +/** + * Copy the requested MCP servers so later mutation cannot reach a rendered one. + * @param servers MCP servers requested by a runtime's options. + * @returns The frozen snapshot, absent when no servers were requested. + */ +export function snapshotMcpServers( + servers?: readonly McpServer[], +): readonly McpServer[] | undefined { + return servers === undefined + ? undefined + : Object.freeze( + servers.map((server) => + Object.freeze({ + name: server.name, + command: server.command, + args: Object.freeze([...server.args]), + env: Object.freeze({ ...server.env }), + }), + ), + ); +} + +/** + * Digest text that a sanitized configuration records instead of carrying. + * @param value Text whose digest stands in for the text itself. + * @returns The lowercase SHA-256 digest. + */ +export function digestText(value: string): ConfigurationDigest { + return Schema.decodeUnknownSync(configurationDigest)( + createHash("sha256").update(value, "utf8").digest("hex"), + ); +} + +/** + * Record which files a runtime mounts without recording their contents. + * @param files Checked workspace files a runtime mounts. + * @returns The sanitized workspace records. + */ +export function workspaceConfiguration( + files: readonly CheckedWorkspaceFile[], +): readonly WorkspaceFileConfiguration[] { + return files.map((file) => + WorkspaceFileConfiguration.make({ + relativePath: file.relativePath, + contentDigest: digestText(file.content), + redacted: ["content"], + }), + ); +} + +/** + * The digested form of one MCP server. Only the environment *keys* are + * digested: the values are provider credentials, and including them would let + * anyone holding a candidate secret confirm it against a published ledger. + * @param server MCP server whose definition is being recorded. + * @returns The canonical, value-free JSON that stands in for the server. + */ +function mcpServerDefinition(server: McpServer): string { + return JSON.stringify({ + name: server.name, + command: server.command, + args: server.args, + environmentKeys: Object.keys(server.env).sort((left, right) => + left.localeCompare(right), + ), + }); +} + +/** + * Record which MCP servers a runtime mounts without recording their secrets. + * @param servers MCP servers a runtime mounts, if any. + * @returns The sanitized MCP server records. + */ +export function mcpConfiguration( + servers?: readonly McpServer[], +): readonly McpServerConfiguration[] { + return (servers ?? []).map((server) => + McpServerConfiguration.make({ + name: server.name, + definitionDigest: digestText(mcpServerDefinition(server)), + redacted: ["command", "args", "environmentValues"], + }), + ); +} + +/** + * Place one checked workspace path under a runtime's workspace root. + * @param root Absolute workspace directory inside the container. + * @param relativePath Path already proven to stay below that root. + * @returns The absolute in-container path. + */ +export function workspaceFilePath( + root: `/${string}`, + relativePath: WorkspaceRelativePath, +): `/${string}` { + return `${root}/${relativePath}`; +} + +/** + * One bootstrap file. Mode 0o600 because these carry the agent's router + * credential and every provider secret the runtime was configured with. + * @param path Absolute in-container path the file is materialized at. + * @param content Exact file content. + * @returns The frozen file the run-scoped Secret materializes. + */ +export function bootstrapFile(path: `/${string}`, content: string): File { + return Object.freeze({ path, content, mode: 0o600 }); +} diff --git a/packages/simulator/src/cluster/bootstrap.test.ts b/packages/simulator/src/cluster/bootstrap.test.ts new file mode 100644 index 000000000..42da6560a --- /dev/null +++ b/packages/simulator/src/cluster/bootstrap.test.ts @@ -0,0 +1,327 @@ +/* eslint-disable agent-code-guard/async-keyword, agent-code-guard/promise-type, agent-code-guard/prefer-effect-platform, @typescript-eslint/no-invalid-void-type, max-lines-per-function, sonarjs/max-lines-per-function, agent-code-guard/no-example-only-tests, agent-code-guard/no-hardcoded-assertion-literals -- Hostile fixtures are built with Node's own filesystem so the suite exercises the exact syscalls the materializer must survive, and each fixture stays next to its containment assertion. */ +import { + chmod, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { execFile as execFileCallback } from "node:child_process"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { NodeFileSystem } from "@effect/platform-node"; +import { Effect } from "effect"; +import { afterEach, describe, expect, it } from "vitest"; +import { materializeBootstrap } from "./bootstrap.js"; + +const roots: string[] = []; +const execFile = promisify(execFileCallback); + +interface Fixture { + readonly root: string; + readonly source: string; + readonly output: string; + readonly overlay: string; + readonly manifest: string; +} + +interface ExecFileFailure { + readonly code: number; + readonly stderr: string; +} + +function isExecFileFailure(value: unknown): value is ExecFileFailure { + if (typeof value !== "object" || value === null) { + return false; + } + if (!("code" in value) || typeof value.code !== "number") { + return false; + } + return "stderr" in value && typeof value.stderr === "string"; +} + +async function makeFixture(): Promise { + const root = await mkdtemp(join(tmpdir(), "moltzap-bootstrap-test-")); + roots.push(root); + const source = join(root, "source"); + const output = join(root, "output"); + const overlay = join(root, "overlay"); + const manifest = join(root, "manifest.json"); + await mkdir(source); + await mkdir(overlay); + return { root, source, output, overlay, manifest }; +} + +async function writeManifest(fixture: Fixture, value: unknown): Promise { + await writeFile(fixture.manifest, JSON.stringify(value), "utf8"); +} + +function options(fixture: Fixture) { + return { + manifest: fixture.manifest, + source: fixture.source, + output: fixture.output, + overlay: fixture.overlay, + } as const; +} + +function materialize(fixture: Fixture): Promise { + return Effect.runPromise( + materializeBootstrap(options(fixture)).pipe( + Effect.provide(NodeFileSystem.layer), + ), + ); +} + +afterEach(async () => { + const stale = roots.splice(0); + for (const root of stale) { + await rm(root, { recursive: true, force: true }); + } +}); + +describe("materializeBootstrap", () => { + it("copies the trusted overlay before placing regular Secret files with exact modes", async () => { + const fixture = await makeFixture(); + await mkdir(join(fixture.overlay, "openclaw-channel")); + await writeFile( + join(fixture.overlay, "openclaw-channel", "package.json"), + "overlay", + "utf8", + ); + await writeFile( + join(fixture.overlay, "openclaw.json"), + "placeholder", + "utf8", + ); + await writeFile(join(fixture.source, "config"), "secret-config", "utf8"); + await writeFile(join(fixture.source, "profile"), "secret-profile", "utf8"); + await chmod(join(fixture.source, "config"), 0o644); + await writeManifest(fixture, { + apiVersion: "moltzap.bootstrap/v1", + files: [ + { source: "config", path: "openclaw.json", mode: 0o600 }, + { source: "profile", path: "moltzap/config.json", mode: 0o640 }, + ], + }); + + await materialize(fixture); + + await expect( + readFile(join(fixture.output, "openclaw.json"), "utf8"), + ).resolves.toBe("secret-config"); + await expect( + readFile(join(fixture.output, "moltzap", "config.json"), "utf8"), + ).resolves.toBe("secret-profile"); + await expect( + readFile( + join(fixture.output, "openclaw-channel", "package.json"), + "utf8", + ), + ).resolves.toBe("overlay"); + expect( + (await stat(join(fixture.output, "openclaw.json"))).mode & 0o777, + ).toBe(0o600); + expect( + (await stat(join(fixture.output, "moltzap", "config.json"))).mode & 0o777, + ).toBe(0o640); + }); + + const invalidManifests: ReadonlyArray = [ + [ + "an absolute target", + { + apiVersion: "moltzap.bootstrap/v1", + files: [{ source: "config", path: "/outside", mode: 0o600 }], + }, + ], + [ + "a traversal target", + { + apiVersion: "moltzap.bootstrap/v1", + files: [ + { source: "config", path: "nested/../../outside", mode: 0o600 }, + ], + }, + ], + [ + "duplicate targets", + { + apiVersion: "moltzap.bootstrap/v1", + files: [ + { source: "config", path: "same", mode: 0o600 }, + { source: "profile", path: "same", mode: 0o600 }, + ], + }, + ], + [ + "a slash-containing source", + { + apiVersion: "moltzap.bootstrap/v1", + files: [{ source: "nested/config", path: "config", mode: 0o600 }], + }, + ], + [ + "permission bits outside mode", + { + apiVersion: "moltzap.bootstrap/v1", + files: [{ source: "config", path: "config", mode: 0o1000 }], + }, + ], + [ + "an unknown file key", + { + apiVersion: "moltzap.bootstrap/v1", + files: [ + { source: "config", path: "config", mode: 0o600, content: "secret" }, + ], + }, + ], + [ + "an unknown root key", + { apiVersion: "moltzap.bootstrap/v1", files: [], extra: true }, + ], + ]; + + for (const [name, manifest] of invalidManifests) { + it(`rejects ${name}`, async () => { + const fixture = await makeFixture(); + await writeFile(join(fixture.source, "config"), "secret", "utf8"); + await writeFile(join(fixture.source, "profile"), "secret", "utf8"); + await writeManifest(fixture, manifest); + + await expect(materialize(fixture)).rejects.toThrow(); + await expect(lstat(fixture.output)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + } + + it("accepts a contained Kubernetes atomic-writer Secret entry", async () => { + const fixture = await makeFixture(); + const generation = "..2026_08_03_21_48_00"; + await mkdir(join(fixture.source, generation)); + await writeFile( + join(fixture.source, generation, "config"), + "secret", + "utf8", + ); + await symlink(generation, join(fixture.source, "..data")); + await symlink("..data/config", join(fixture.source, "config")); + await writeManifest(fixture, { + apiVersion: "moltzap.bootstrap/v1", + files: [{ source: "config", path: "config", mode: 0o600 }], + }); + + await materialize(fixture); + + await expect( + readFile(join(fixture.output, "config"), "utf8"), + ).resolves.toBe("secret"); + }); + + it("runs the CLI through a real symlink and preserves nonzero failures", async () => { + const fixture = await makeFixture(); + const script = join(fixture.root, "bootstrap.ts"); + await symlink( + fileURLToPath(new URL("./bootstrap.ts", import.meta.url)), + script, + ); + await writeFile(join(fixture.source, "config"), "materialized", "utf8"); + await writeManifest(fixture, { + apiVersion: "moltzap.bootstrap/v1", + files: [{ source: "config", path: "config", mode: 0o600 }], + }); + + await execFile(process.execPath, [ + script, + "--manifest", + fixture.manifest, + "--source", + fixture.source, + "--output", + fixture.output, + "--overlay", + fixture.overlay, + ]); + + await expect( + readFile(join(fixture.output, "config"), "utf8"), + ).resolves.toBe("materialized"); + let failure: unknown; + try { + await execFile(process.execPath, [script]); + } catch (cause) { + failure = cause; + } + expect(isExecFileFailure(failure)).toBe(true); + if (isExecFileFailure(failure)) { + expect(failure.code).toBe(1); + expect(failure.stderr).toContain("bootstrap materialization failed"); + } + // Two real Node processes, each loading the Effect runtime the initializer + // shares with the controller: roughly 2.5s of module graph per spawn. + }, 30_000); + + it("rejects a non-regular Secret source before changing output", async () => { + const fixture = await makeFixture(); + await mkdir(join(fixture.source, "directory")); + await writeManifest(fixture, { + apiVersion: "moltzap.bootstrap/v1", + files: [{ source: "directory", path: "config", mode: 0o600 }], + }); + + await expect(materialize(fixture)).rejects.toThrow( + /resolve to a regular file/u, + ); + await expect(lstat(fixture.output)).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + + it("rejects dangling and escaping Secret symlinks", async () => { + const fixture = await makeFixture(); + const outside = join(fixture.root, "outside-secret"); + await writeFile(outside, "secret", "utf8"); + await symlink("missing", join(fixture.source, "dangling")); + await symlink(outside, join(fixture.source, "escaping")); + + for (const source of ["dangling", "escaping"]) { + await writeManifest(fixture, { + apiVersion: "moltzap.bootstrap/v1", + files: [{ source, path: "config", mode: 0o600 }], + }); + await expect(materialize(fixture)).rejects.toThrow(); + await expect(lstat(fixture.output)).rejects.toMatchObject({ + code: "ENOENT", + }); + } + }); + + it("does not follow an overlay symlink when placing a Secret", async () => { + const fixture = await makeFixture(); + const outside = join(fixture.root, "outside"); + await mkdir(outside); + await symlink(outside, join(fixture.overlay, "redirect")); + await writeFile(join(fixture.source, "config"), "secret", "utf8"); + await writeManifest(fixture, { + apiVersion: "moltzap.bootstrap/v1", + files: [{ source: "config", path: "redirect/config", mode: 0o600 }], + }); + + await expect(materialize(fixture)).rejects.toThrow( + /target parent is not a directory/u, + ); + await expect(lstat(join(outside, "config"))).rejects.toMatchObject({ + code: "ENOENT", + }); + }); +}); + +/* eslint-enable agent-code-guard/async-keyword, agent-code-guard/promise-type, agent-code-guard/prefer-effect-platform, @typescript-eslint/no-invalid-void-type, max-lines-per-function, sonarjs/max-lines-per-function, agent-code-guard/no-example-only-tests, agent-code-guard/no-hardcoded-assertion-literals -- Restore strict defaults after the filesystem regression suite. */ diff --git a/packages/simulator/src/cluster/bootstrap.ts b/packages/simulator/src/cluster/bootstrap.ts new file mode 100644 index 000000000..b0c2e1131 --- /dev/null +++ b/packages/simulator/src/cluster/bootstrap.ts @@ -0,0 +1,590 @@ +/** @file Private runtime-bootstrap materializer used by the Sandbox initializer. */ + +// eslint-disable-next-line agent-code-guard/prefer-effect-platform -- `FileSystem.stat` resolves the final symbolic link and `@effect/platform` exposes no `lstat`, so link-rejecting checks need Node directly; entry detection runs at module load, before a runtime exists to provide `FileSystem`. +import { existsSync, promises as nodeFsPromises, realpathSync } from "node:fs"; +import { isAbsolute, join, posix, relative, resolve, sep } from "node:path"; +import { fileURLToPath } from "node:url"; +import { FileSystem } from "@effect/platform"; +import { NodeFileSystem, NodeRuntime } from "@effect/platform-node"; +import { Data, Effect } from "effect"; + +const BOOTSTRAP_API_VERSION = "moltzap.bootstrap/v1"; +const ROOT_KEYS = new Set(["apiVersion", "files"]); +const FILE_KEYS = new Set(["source", "path", "mode"]); +const CLI_FLAGS = ["--manifest", "--source", "--output", "--overlay"] as const; +const MAX_FILE_MODE = 0o777; + +/** A Secret entry names one file, so a separator or NUL is hostile. */ +const NAME_REJECTED_CHARACTERS = ["/", "\\", "\0"]; + +/** A target path nests with `/`; a backslash or NUL is never a POSIX segment. */ +const PATH_REJECTED_CHARACTERS = ["\\", "\0"]; + +type BootstrapFlag = (typeof CLI_FLAGS)[number]; + +/** + * What a path is when its own final symbolic link is not followed. + * + * Every check below treats a link as hostile: a Secret or overlay mount + * escapes the tree it was projected into by pointing somewhere else. `lstat` + * reports the link itself, so `symlink` satisfies neither the directory nor + * the regular-file check, while `stat` would report the link's target. + */ +type PathKind = "directory" | "file" | "missing" | "symlink" | "other"; + +interface BootstrapFile { + readonly source: string; + readonly path: string; + readonly mode: number; +} + +interface BootstrapManifest { + readonly apiVersion: typeof BOOTSTRAP_API_VERSION; + readonly files: readonly BootstrapFile[]; +} + +interface ResolvedBootstrapFile extends BootstrapFile { + readonly resolvedSource: string; +} + +/** Filesystem locations consumed by one bootstrap materialization. */ +export interface BootstrapMaterializationOptions { + readonly manifest: string; + readonly source: string; + readonly output: string; + readonly overlay: string; +} + +/** A refused bootstrap input or a filesystem call the initializer cannot trust. */ +export class BootstrapError extends Data.TaggedError("BootstrapError")<{ + readonly detail: string; +}> { + override get message(): string { + return this.detail; + } +} + +/** An absent path, which several callers answer with creation rather than failure. */ +class PathMissing extends Data.TaggedError("PathMissing")<{ + readonly path: string; +}> {} + +function bootstrapError(detail: string): BootstrapError { + return new BootstrapError({ detail }); +} + +function reject(detail: string): Effect.Effect { + return Effect.fail(bootstrapError(detail)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isUnknownArray(value: unknown): value is readonly unknown[] { + return Array.isArray(value); +} + +function containsAny(value: string, characters: readonly string[]): boolean { + return characters.some((character) => value.includes(character)); +} + +function rejectUnknownKeys( + value: Readonly>, + allowed: ReadonlySet, + label: string, +): Effect.Effect { + const unknown = Object.keys(value).find((key) => !allowed.has(key)); + return unknown === undefined + ? Effect.void + : reject(`${label} has unknown key ${unknown}`); +} + +function isPlainFileName(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0) { + return false; + } + if (value === "." || value === "..") { + return false; + } + return !containsAny(value, NAME_REJECTED_CHARACTERS); +} + +function sourceName( + value: unknown, + label: string, +): Effect.Effect { + return isPlainFileName(value) + ? Effect.succeed(value) + : reject(`${label} must be one plain file name`); +} + +function isNormalizedRelativePath(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0) { + return false; + } + if (containsAny(value, PATH_REJECTED_CHARACTERS)) { + return false; + } + if (posix.isAbsolute(value)) { + return false; + } + return posix.normalize(value) === value; +} + +function isContainedSegment(segment: string): boolean { + return segment.length > 0 && segment !== "." && segment !== ".."; +} + +function targetPath( + value: unknown, + label: string, +): Effect.Effect { + if (!isNormalizedRelativePath(value)) { + return reject(`${label} must be a normalized relative path`); + } + if (!value.split("/").every(isContainedSegment)) { + return reject(`${label} must stay below the bootstrap output`); + } + return Effect.succeed(value); +} + +function isPermissionBits(value: unknown): value is number { + if (typeof value !== "number" || !Number.isSafeInteger(value)) { + return false; + } + return value >= 0 && value <= MAX_FILE_MODE; +} + +function fileMode( + value: unknown, + label: string, +): Effect.Effect { + return isPermissionBits(value) + ? Effect.succeed(value) + : reject(`${label} must contain only Unix permission bits`); +} + +function decodeFile( + candidate: unknown, + index: number, + targets: Set, +): Effect.Effect { + const label = `bootstrap manifest files[${String(index)}]`; + return Effect.gen(function* () { + if (!isRecord(candidate)) { + return yield* reject(`${label} must be an object`); + } + yield* rejectUnknownKeys(candidate, FILE_KEYS, label); + const path = yield* targetPath(candidate.path, `${label}.path`); + if (targets.has(path)) { + return yield* reject(`bootstrap manifest repeats target ${path}`); + } + targets.add(path); + const source = yield* sourceName(candidate.source, `${label}.source`); + const mode = yield* fileMode(candidate.mode, `${label}.mode`); + return { source, path, mode }; + }); +} + +function decodeManifest( + value: unknown, +): Effect.Effect { + return Effect.gen(function* () { + if (!isRecord(value)) { + return yield* reject("bootstrap manifest must be an object"); + } + yield* rejectUnknownKeys(value, ROOT_KEYS, "bootstrap manifest"); + if (value.apiVersion !== BOOTSTRAP_API_VERSION) { + return yield* reject( + `bootstrap manifest apiVersion must be ${BOOTSTRAP_API_VERSION}`, + ); + } + if (!isUnknownArray(value.files)) { + return yield* reject("bootstrap manifest files must be an array"); + } + + const targets = new Set(); + const files = yield* Effect.forEach( + value.files, + (candidate, index) => decodeFile(candidate, index, targets), + // Sequential so the first hostile entry, not a race, names the failure. + { concurrency: 1 }, + ); + return { apiVersion: BOOTSTRAP_API_VERSION, files }; + }); +} + +function hasErrorCode(error: unknown, code: string): boolean { + return ( + typeof error === "object" && + error !== null && + "code" in error && + error.code === code + ); +} + +function pathKind(path: string): Effect.Effect { + return Effect.tryPromise({ + try: () => nodeFsPromises.lstat(path), + catch: (cause) => + hasErrorCode(cause, "ENOENT") + ? new PathMissing({ path }) + : bootstrapError(`bootstrap could not inspect ${path}`), + }).pipe( + Effect.map((entry): PathKind => { + if (entry.isSymbolicLink()) { + return "symlink"; + } + if (entry.isDirectory()) { + return "directory"; + } + return entry.isFile() ? "file" : "other"; + }), + Effect.catchTag("PathMissing", () => Effect.succeed("missing")), + ); +} + +function requireDirectory( + path: string, + label: string, +): Effect.Effect { + return pathKind(path).pipe( + Effect.flatMap((kind) => + kind === "directory" + ? Effect.void + : reject(`${label} must be a directory`), + ), + ); +} + +function ensureOutputDirectory( + path: string, +): Effect.Effect { + return Effect.gen(function* () { + const kind = yield* pathKind(path); + if (kind === "directory") { + return; + } + if (kind !== "missing") { + return yield* reject("bootstrap output must be a directory"); + } + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem + .makeDirectory(path, { recursive: true }) + .pipe( + Effect.mapError(() => + bootstrapError("bootstrap output cannot be created"), + ), + ); + yield* requireDirectory(path, "bootstrap output"); + }); +} + +function escapesRoot(projection: string): boolean { + if (projection === "..") { + return true; + } + return projection.startsWith(`..${sep}`) || isAbsolute(projection); +} + +function resolveRegularSource( + sourceRoot: string, + source: string, + name: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const resolved = yield* fileSystem + .realPath(join(source, name)) + .pipe( + Effect.mapError(() => + bootstrapError(`bootstrap source ${name} cannot be resolved`), + ), + ); + if (escapesRoot(relative(sourceRoot, resolved))) { + return yield* reject( + `bootstrap source ${name} resolves outside its mount`, + ); + } + const kind = yield* pathKind(resolved); + if (kind !== "file") { + return yield* reject( + `bootstrap source ${name} must resolve to a regular file`, + ); + } + return resolved; + }); +} + +function ensureTargetDirectory( + path: string, + relativePath: string, +): Effect.Effect { + return Effect.gen(function* () { + const kind = yield* pathKind(path); + if (kind === "directory") { + return; + } + if (kind !== "missing") { + return yield* reject( + `bootstrap target parent is not a directory: ${relativePath}`, + ); + } + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem + .makeDirectory(path) + .pipe( + Effect.mapError(() => + bootstrapError( + `bootstrap target parent cannot be created: ${relativePath}`, + ), + ), + ); + }); +} + +function ensureRegularDestination( + path: string, + relativePath: string, +): Effect.Effect { + return pathKind(path).pipe( + Effect.flatMap((kind) => + kind === "file" || kind === "missing" + ? Effect.void + : reject(`bootstrap target is not a regular file: ${relativePath}`), + ), + ); +} + +function ensureTargetParent( + output: string, + relativePath: string, +): Effect.Effect { + return Effect.gen(function* () { + const segments = relativePath.split("/"); + const filename = segments.pop(); + if (filename === undefined) { + return yield* reject("bootstrap target has no filename"); + } + + let parent = output; + for (const segment of segments) { + parent = join(parent, segment); + yield* ensureTargetDirectory(parent, relativePath); + } + + const destination = join(parent, filename); + yield* ensureRegularDestination(destination, relativePath); + return destination; + }); +} + +function readManifest( + path: string, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const encoded = yield* fileSystem + .readFileString(path) + .pipe( + Effect.mapError(() => + bootstrapError("bootstrap manifest cannot be read"), + ), + ); + const parsed = yield* Effect.try({ + try: (): unknown => JSON.parse(encoded), + catch: () => bootstrapError("bootstrap manifest is not valid JSON"), + }); + return yield* decodeManifest(parsed); + }); +} + +function resolveManifestSources( + options: BootstrapMaterializationOptions, + manifest: BootstrapManifest, +): Effect.Effect< + readonly ResolvedBootstrapFile[], + BootstrapError, + FileSystem.FileSystem +> { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + yield* requireDirectory(options.source, "bootstrap source"); + yield* requireDirectory(options.overlay, "bootstrap overlay"); + const sourceRoot = yield* fileSystem + .realPath(options.source) + .pipe( + Effect.mapError(() => + bootstrapError("bootstrap source cannot be resolved"), + ), + ); + return yield* Effect.forEach( + manifest.files, + (file) => + resolveRegularSource(sourceRoot, options.source, file.source).pipe( + Effect.map((resolvedSource) => ({ ...file, resolvedSource })), + ), + // Sequential so the first hostile entry, not a race, names the failure. + { concurrency: 1 }, + ); + }); +} + +function placeFile( + output: string, + file: ResolvedBootstrapFile, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const destination = yield* ensureTargetParent(output, file.path); + yield* fileSystem + .copyFile(file.resolvedSource, destination) + .pipe( + Effect.mapError(() => + bootstrapError(`bootstrap target cannot be written: ${file.path}`), + ), + ); + yield* fileSystem + .chmod(destination, file.mode) + .pipe( + Effect.mapError(() => + bootstrapError(`bootstrap target cannot take its mode: ${file.path}`), + ), + ); + }); +} + +/** + * Copy the application overlay and then materialize its run-scoped files. + * + * Every manifest entry is decoded and resolved before the output directory + * exists, so a refused bootstrap leaves the application with nothing to read. + * @param options Trusted mount and output paths owned by the initializer. + * @returns Completion after every file has its declared mode. + * @failure BootstrapError when an input is refused or a copy cannot be trusted. + */ +export function materializeBootstrap( + options: BootstrapMaterializationOptions, +): Effect.Effect { + return Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const manifest = yield* readManifest(options.manifest); + const files = yield* resolveManifestSources(options, manifest); + + yield* ensureOutputDirectory(options.output); + yield* fileSystem + .copy(options.overlay, options.output, { overwrite: true }) + .pipe( + Effect.mapError(() => + bootstrapError("bootstrap overlay cannot be copied"), + ), + ); + yield* Effect.forEach(files, (file) => placeFile(options.output, file), { + concurrency: 1, + }); + }).pipe(Effect.withSpan("materializeBootstrap")); +} + +function isBootstrapFlag(flag: string): flag is BootstrapFlag { + return CLI_FLAGS.some((known) => known === flag); +} + +function requiredFlag( + values: ReadonlyMap, + flag: BootstrapFlag, +): Effect.Effect { + const value = values.get(flag); + return value === undefined + ? reject(`missing bootstrap CLI flag ${flag}`) + : Effect.succeed(value); +} + +function parseArguments( + args: readonly string[], +): Effect.Effect { + return Effect.gen(function* () { + const values = new Map(); + for (let index = 0; index < args.length; index += 2) { + const flag = args[index]; + const value = args[index + 1]; + if (flag === undefined || value === undefined || !flag.startsWith("--")) { + return yield* reject("bootstrap CLI expects flag-value pairs"); + } + if (!isBootstrapFlag(flag)) { + return yield* reject(`unknown bootstrap CLI flag ${flag}`); + } + if (values.has(flag)) { + return yield* reject(`duplicate bootstrap CLI flag ${flag}`); + } + values.set(flag, value); + } + + const manifest = yield* requiredFlag(values, "--manifest"); + const source = yield* requiredFlag(values, "--source"); + const output = yield* requiredFlag(values, "--output"); + const overlay = yield* requiredFlag(values, "--overlay"); + return { manifest, source, output, overlay }; + }); +} + +function runCli( + args: readonly string[], +): Effect.Effect { + return parseArguments(args).pipe(Effect.flatMap(materializeBootstrap)); +} + +function realPath(path: string): string | undefined { + return existsSync(path) ? realpathSync(path) : undefined; +} + +/** + * Whether this module is the process entry point rather than an import. + * + * Node resolves a module's real path before it becomes `import.meta.url`, while + * `process.argv[1]` is whatever the caller typed. The controller image reaches + * this file through `/opt/moltzap/dist`, a symlink into the installed package, + * so an uncanonicalized comparison makes the init container look like an + * import and exit successfully having materialized nothing. + * + * This repeats `cluster/entry.ts` rather than importing it: the CLI is executed + * as TypeScript through a symlink by its own regression test, and Node resolves + * neither a `.js` specifier to a `.ts` file nor a relative import from the + * symlink's location. + * + * @param invoked Path the process was started with, if it has one. + * @returns Whether both locations name the same real file. + */ +function isDirectInvocation(invoked?: string): boolean { + if (invoked === undefined || invoked.length === 0) { + return false; + } + const entry = realPath(resolve(invoked)); + return ( + entry !== undefined && entry === realPath(fileURLToPath(import.meta.url)) + ); +} + +/** + * Report a materialization failure to the Pod log. + * + * The line is deliberately sanitized: mount layout and manifest detail stay in + * the typed error channel, where only a programmatic caller reads them. + * @returns Completion after the diagnostic has been written. + */ +function reportFailure(): Effect.Effect { + return Effect.sync(() => { + process.stderr.write("bootstrap materialization failed\n"); + }); +} + +// eslint-disable-next-line agent-code-guard/prefer-effect-platform -- The executable boundary reads argv once before entering Effect. +const [, invokedPath, ...commandLine] = process.argv; + +if (isDirectInvocation(invokedPath)) { + runCli(commandLine).pipe( + Effect.tapError(reportFailure), + Effect.provide(NodeFileSystem.layer), + NodeRuntime.runMain({ disableErrorReporting: true }), + ); +} diff --git a/packages/simulator/src/cluster/cluster.test.ts b/packages/simulator/src/cluster/cluster.test.ts new file mode 100644 index 000000000..9a326249b --- /dev/null +++ b/packages/simulator/src/cluster/cluster.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { ClusterError, clusterError } from "./cluster.js"; + +const OPERATION = "create agent sandbox"; +const CAUSE_DETAIL = "sandbox admission webhook rejected the pod"; + +describe("clusterError", () => { + it("names the failed operation and its cause when stringified", () => { + const failure = clusterError(OPERATION, new Error(CAUSE_DETAIL)); + + // The ledger and the operator both read this through String(), so a failure + // that only carries its detail on a field reports nothing either can use. + expect(String(failure)).toContain(OPERATION); + expect(String(failure)).toContain(CAUSE_DETAIL); + expect(failure.message).toBe(`${OPERATION}: ${CAUSE_DETAIL}`); + }); + + it("reads the same whether the boundary threw an Error or a description", () => { + const thrown = clusterError(OPERATION, new Error(CAUSE_DETAIL)); + const described = clusterError(OPERATION, CAUSE_DETAIL); + + expect(described.message).toBe(thrown.message); + }); + + it("keeps the tag a caller matches on", () => { + const failure = clusterError(OPERATION, CAUSE_DETAIL); + + expect(failure).toBeInstanceOf(ClusterError); + expect(failure._tag).toBe(new ClusterError({ detail: "" })._tag); + }); +}); diff --git a/packages/simulator/src/cluster/cluster.ts b/packages/simulator/src/cluster/cluster.ts new file mode 100644 index 000000000..6ac565c6c --- /dev/null +++ b/packages/simulator/src/cluster/cluster.ts @@ -0,0 +1,81 @@ +/** @file Private cluster acquisition and lifecycle boundary. */ +// safer-arch-ignore no-cross-domain-sibling-import: The cluster seam names the roster it prepares and the router connection it hands each agent. + +import type { AgentName } from "@moltzap/protocol/identity"; +import { Context, Data, type Effect, type Scope } from "effect"; +import type { AgentConnection } from "../network/router.js"; +import type { + AgentRoster, + AgentRosterAcquisitionError, + RuntimeGatewayOf, +} from "../agents/roster.js"; +import type { AgentRuntimeLike, RunningAgent } from "../agents/agent.js"; + +/** Cluster loss that ends a run without exposing its backend. */ +export class ClusterError extends Data.TaggedError("ClusterError")<{ + readonly detail: string; +}> { + override get message(): string { + return this.detail; + } +} + +/** + * Normalize an implementation failure at a cluster boundary. Error causes + * contribute their message alone so one operation reads the same way whether + * the boundary raised a thrown Error or a plain description. + * @param operation Failed cluster operation. + * @param cause Implementation failure. + * @returns Typed cluster failure. + */ +export function clusterError(operation: string, cause: unknown): ClusterError { + return new ClusterError({ + detail: `${operation}: ${cause instanceof Error ? cause.message : String(cause)}`, + }); +} + +/** One exact roster entry presented to a private cluster implementation. */ +export interface Slot< + Definitions extends Readonly>, + Name extends Extract, +> { + readonly name: Name; + readonly agentName: AgentName; + readonly runtime: Definitions[Name]; + readonly connection: AgentConnection; +} + +/** Run-scoped cluster capabilities for one complete society roster. */ +export interface Society< + Definitions extends Readonly>, +> { + readonly acquireAgent: >( + input: Slot, + ) => Effect.Effect< + RunningAgent>, + AgentRosterAcquisitionError | ClusterError, + Scope.Scope + >; + + /** Completes only while the exact acquired roster is ready for dispatch. */ + readonly cohortReady: Effect.Effect; + + /** Fails if run-scoped cluster ownership is lost. */ + readonly failure: Effect.Effect; +} + +/** Private cluster factory supplied by an execution Layer. */ +export interface ClusterService { + readonly prepare: < + Id extends string, + Definitions extends Readonly>, + >( + roster: AgentRoster, + ) => Effect.Effect, ClusterError, Scope.Scope>; +} + +/** Private cluster service required by every simulator execution Layer. */ +export class Cluster extends Context.Tag("@moltzap/simulator/Cluster")< + Cluster, + ClusterService +>() {} diff --git a/packages/simulator/src/cluster/cohort.test.ts b/packages/simulator/src/cluster/cohort.test.ts new file mode 100644 index 000000000..0e7ff1bb9 --- /dev/null +++ b/packages/simulator/src/cluster/cohort.test.ts @@ -0,0 +1,1190 @@ +/* eslint-disable max-lines-per-function, max-nested-callbacks, sonarjs/max-lines-per-function -- lifecycle regressions keep their ordering, readiness, and cleanup evidence together */ + +import { assert, describe, it as test } from "vitest"; +import { agentId, redactedAgentKey } from "@moltzap/protocol/testing"; +import { serverBaseUrlSchema } from "@moltzap/protocol/network"; +import { + Cause, + Deferred, + Duration, + Effect, + Exit, + Fiber, + Option, + Schema, + type Scope, +} from "effect"; +import { makeAgentHandle } from "../network/participant.js"; +import type { AgentConnection } from "../network/router.js"; +import { + defineContainerRuntime, + image, + type ApplicationEndpoint, + type CredentialName, + type File, +} from "../agents/container.js"; +import { AgentRoster } from "../agents/roster.js"; +import { + defineRuntime, + RuntimeExited, + RuntimeFailed, + RuntimeSignaled, + type AgentRuntimeLike, + type RuntimeTermination, +} from "../agents/agent.js"; +import { ClusterError, type ClusterService, type Society } from "./cluster.js"; +import type { + KubernetesManifest, + KubernetesSocietyApi, + PodObservation, + SandboxObservation, + WorkloadObservation, +} from "./kubernetes/calls.js"; +import { + makeKubernetesCluster, + type KubernetesClusterOptions, +} from "./cohort.js"; + +const SUPPORT_IMAGE = image.make( + "registry.example/simulator@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", +); +const APPLICATION_IMAGE = image.make( + "registry.example/runtime@sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", +); +const ROUTER_URL = Schema.decodeSync(serverBaseUrlSchema)( + "https://router.run.svc.cluster.local:3000", +); +const runtimeConfiguration = Schema.Struct({ kind: Schema.Literal("fake") }); + +const WORKLOAD_NAME = "society"; +const QUEUE_NAME = "simulator"; +const NAMESPACE = "run"; +const OBSERVED_GENERATION = 1; +const APPLICATION_CONTAINER = "application"; +const WORKLOAD_CREATED = "create:workload"; +const WORKLOAD_DELETED = "delete:workload"; +const SECRET_CREATED = "create:secret:"; +const SANDBOX_CREATED = "create:sandbox:"; +const SANDBOX_DELETED = "delete:sandbox:"; +const SECRET_KIND = "Secret"; +const SANDBOX_KIND = "Sandbox"; +const SELECTOR_PREFIX = "sandbox="; +const DELETION_TIMESTAMP = "2026-08-04T17:17:44Z"; +const FINISHED_REASON = "PodFailed"; +const TERMINATED_REASON = "Error"; +const OBSERVED_EXIT_CODE = 17; +const OBSERVED_SIGNAL = 9; +const SIGNAL_EVIDENCE = `signal-${String(OBSERVED_SIGNAL)}`; +const GATEWAY_PORT = 18_789; +const BOOTSTRAP_ROOT = "/var/run/moltzap/bootstrap"; +const BOOTSTRAP_CONTENT = "TOP-SECRET-CREDENTIAL"; +const READABLE_FILE_MODE = 0o600; +const INVALID_FILE_MODE = 0o1000; +const CREDENTIAL_SECRET_KEY = "credential-ANTHROPIC_API_KEY"; +const UNREQUESTED_SECRET_KEY = "credential-OPENAI_API_KEY"; +const CREDENTIAL_VALUE = "anthropic-key-never-in-a-manifest"; +const UNREQUESTED_CREDENTIAL_VALUE = "openai-key-never-requested"; +const INJECTED_API_DETAIL = "observe agent sandbox: injected transport loss"; + +const POLL_INTERVAL = Duration.millis(1); +/** A liveness interval no test run can reach, so only readiness can progress. */ +const UNREACHED_INTERVAL = Duration.hours(1); +/** Long enough for a poll loop to reach its next sleep. */ +const SETTLED = Duration.millis(5); +const GENEROUS_TIMEOUT = Duration.seconds(1); +/** Long enough for the poll loop to run many times, short enough to expire. */ +const MISSED_TIMEOUT = Duration.millis(50); +const READY_AFTER_PROBES = 4; +const INJECTED_READ_FAILURES = 3; +/** A readiness probe the poll budget can never reach. */ +const UNREACHABLE_PROBE = Number.MAX_SAFE_INTEGER; +const NO_CLUSTER_FAILURE = ""; + +const NOT_ADMITTED = "was not admitted within"; +const EMPTY_RESERVATION = "requires at least one runtime"; +const DELETED_BEFORE_ADMISSION = "was deleted before admission"; +const EVICTED_BEFORE_ADMISSION = "was evicted before admission"; +const ADMISSION_LOST = "capacity admission was lost during execution"; +const NOT_READY = "was not ready within"; +const FINISHED_BEFORE_DISPATCH = "finished before dispatch"; +const NO_CONTAINER_REALIZATION = "has no Kubernetes container realization"; +const INCOMPLETE_COHORT = "does not contain the complete prepared roster"; +const SANDBOX_UNOBSERVABLE = "stopped being observable"; +const RUNTIME_BRIDGE_LOST = "runtime bridge disconnected"; +const ESCAPING_BOOTSTRAP_PATH = "must stay below /var/run/moltzap/bootstrap"; +const DUPLICATE_BOOTSTRAP_PATH = "duplicate path"; +const INVALID_BOOTSTRAP_MODE = "invalid file mode"; + +const sandboxManifestShape = Schema.Struct({ + spec: Schema.Struct({ + podTemplate: Schema.Struct({ + spec: Schema.Struct({ containers: Schema.Array(Schema.Unknown) }), + }), + }), +}); +const secretManifestShape = Schema.Struct({ + data: Schema.Record({ key: Schema.String, value: Schema.String }), +}); + +interface FakeKubernetesState { + admitted: boolean; + evicted: boolean; + workloadDeleting: boolean; + finished: boolean; + /** Probe index from which the controller bridge port starts accepting. */ + acceptingFromProbe: number; + bridgeProbes: number; + /** Remaining readSandbox calls that fail before observation resumes. */ + sandboxReadFailures: number; + terminationSignal?: number; + /** Replaces the Pod list one Sandbox selector resolves to. */ + podsFor?: (sandboxName: string) => readonly PodObservation[]; + readonly events: string[]; + readonly manifests: KubernetesManifest[]; + readonly workloadObserved: Deferred.Deferred; +} + +function workloadConditions(state: FakeKubernetesState) { + return [ + ...(state.admitted + ? [ + { + type: "Admitted", + status: "True", + observedGeneration: OBSERVED_GENERATION, + }, + ] + : []), + ...(state.evicted + ? [ + { + type: "Evicted", + status: "True", + observedGeneration: OBSERVED_GENERATION, + }, + ] + : []), + ]; +} + +function workload(state: FakeKubernetesState): WorkloadObservation { + return { + metadata: { + name: WORKLOAD_NAME, + generation: OBSERVED_GENERATION, + deletionTimestamp: state.workloadDeleting + ? DELETION_TIMESTAMP + : undefined, + }, + status: { + admission: state.admitted ? { clusterQueue: QUEUE_NAME } : undefined, + conditions: workloadConditions(state), + }, + }; +} + +function sandbox(state: FakeKubernetesState, name: string): SandboxObservation { + return { + metadata: { name, generation: OBSERVED_GENERATION }, + status: { + serviceFQDN: `${name}.run.svc.cluster.local`, + selector: `${SELECTOR_PREFIX}${name}`, + conditions: state.finished + ? [ + { + type: "Finished", + status: "True", + observedGeneration: OBSERVED_GENERATION, + reason: FINISHED_REASON, + }, + ] + : [ + { + type: "Ready", + status: "True", + observedGeneration: OBSERVED_GENERATION, + }, + ], + }, + }; +} + +function terminatedApplication(state: FakeKubernetesState) { + return state.terminationSignal === undefined + ? { exitCode: OBSERVED_EXIT_CODE, reason: TERMINATED_REASON } + : { + exitCode: 0, + signal: state.terminationSignal, + reason: TERMINATED_REASON, + }; +} + +function applicationPod( + state: FakeKubernetesState, + name: string, +): PodObservation { + return { + metadata: { name }, + status: { + phase: state.finished ? "Failed" : "Running", + containerStatuses: [ + { + name: APPLICATION_CONTAINER, + restartCount: 0, + state: state.finished + ? { terminated: terminatedApplication(state) } + : {}, + }, + ], + }, + }; +} + +function deletingPod(pod: PodObservation): PodObservation { + return { + ...pod, + metadata: { ...pod.metadata, deletionTimestamp: DELETION_TIMESTAMP }, + }; +} + +/** + * Every backing-Pod shape that is not the one live Pod readiness requires. + * @param state Fake cluster state the Pod observations are drawn from. + * @param name Sandbox resource the selector resolved to. + * @param shape Which unready shape to present. + * @returns The Pods that Sandbox's selector resolves to. + */ +function backingPods( + state: FakeKubernetesState, + name: string, + shape: "none" | "several" | "terminating", +): readonly PodObservation[] { + const pod = applicationPod(state, `${name}-pod`); + if (shape === "none") { + return []; + } + return shape === "several" + ? [pod, applicationPod(state, `${name}-pod-replacement`)] + : [deletingPod(pod)]; +} + +function pods( + state: FakeKubernetesState, + selector: string, +): readonly PodObservation[] { + const name = selector.slice(SELECTOR_PREFIX.length); + return state.podsFor === undefined + ? [applicationPod(state, `${name}-pod`)] + : state.podsFor(name); +} + +function record( + state: FakeKubernetesState, + event: string, + manifest?: KubernetesManifest, +): Effect.Effect { + return Effect.sync(() => { + state.events.push(event); + if (manifest !== undefined) { + state.manifests.push(manifest); + } + }); +} + +function manifestName(manifest: KubernetesManifest): string { + const metadata = manifest.metadata; + return metadata instanceof Object && "name" in metadata + ? String(metadata.name) + : "unknown"; +} + +function readSandboxOperation(state: FakeKubernetesState, name: string) { + return Effect.suspend(() => { + if (state.sandboxReadFailures > 0) { + state.sandboxReadFailures -= 1; + return Effect.fail(new ClusterError({ detail: INJECTED_API_DETAIL })); + } + return Effect.succeed(sandbox(state, name)); + }); +} + +function bridgeAcceptsOperation(state: FakeKubernetesState) { + return Effect.sync(() => { + state.bridgeProbes += 1; + return state.bridgeProbes >= state.acceptingFromProbe; + }); +} + +function fakeApi(state: FakeKubernetesState): KubernetesSocietyApi { + return { + createWorkload: (manifest) => record(state, WORKLOAD_CREATED, manifest), + readWorkload: () => + Deferred.succeed(state.workloadObserved, undefined).pipe( + Effect.zipRight(Effect.sync(() => workload(state))), + ), + deleteWorkload: () => record(state, WORKLOAD_DELETED), + createSecret: (manifest) => + record(state, `${SECRET_CREATED}${manifestName(manifest)}`, manifest), + deleteSecret: (name) => record(state, `delete:secret:${name}`), + createSandbox: (manifest) => + record(state, `${SANDBOX_CREATED}${manifestName(manifest)}`, manifest), + readSandbox: (name) => readSandboxOperation(state, name), + deleteSandbox: (name) => record(state, `${SANDBOX_DELETED}${name}`), + listPods: (selector) => Effect.sync(() => pods(state, selector)), + bridgeAccepts: () => bridgeAcceptsOperation(state), + }; +} + +function makeState( + workloadObserved: Deferred.Deferred, +): FakeKubernetesState { + return { + admitted: false, + evicted: false, + workloadDeleting: false, + finished: false, + acceptingFromProbe: 1, + bridgeProbes: 0, + sandboxReadFailures: 0, + events: [], + manifests: [], + workloadObserved, + }; +} + +const FAKE_RESOURCES = { + cpuMillis: 500, + memoryBytes: 268_435_456, + ephemeralStorageBytes: 268_435_456, +}; + +const DEFAULT_BOOTSTRAP_FILES: readonly File[] = [ + { + path: `${BOOTSTRAP_ROOT}/config.json`, + content: BOOTSTRAP_CONTENT, + mode: READABLE_FILE_MODE, + }, +]; + +const DUPLICATED_BOOTSTRAP_FILE = { + path: `${BOOTSTRAP_ROOT}/config.json`, + content: BOOTSTRAP_CONTENT, + mode: READABLE_FILE_MODE, +} satisfies File; + +/** One bootstrap request the run must refuse before any Secret exists. */ +interface RefusedBootstrap { + readonly reason: string; + readonly files: readonly File[]; + readonly detail: string; +} + +/** One way the complete-roster reservation fails to reach admission. */ +interface UnadmittedReservation { + readonly reason: string; + readonly detail: string; + readonly apply?: (state: FakeKubernetesState) => void; +} + +const UNADMITTED_RESERVATIONS: readonly UnadmittedReservation[] = [ + { + reason: "deleted before admission", + detail: DELETED_BEFORE_ADMISSION, + apply: (state) => { + state.workloadDeleting = true; + }, + }, + { + reason: "evicted before admission", + detail: EVICTED_BEFORE_ADMISSION, + apply: (state) => { + state.evicted = true; + }, + }, + { reason: "never admitted", detail: NOT_ADMITTED }, +]; + +const REFUSED_BOOTSTRAPS: readonly RefusedBootstrap[] = [ + { + reason: "escapes the bootstrap root", + files: [ + { + path: `${BOOTSTRAP_ROOT}/../escape.json`, + content: BOOTSTRAP_CONTENT, + mode: READABLE_FILE_MODE, + }, + ], + detail: ESCAPING_BOOTSTRAP_PATH, + }, + { + reason: "materializes one path twice", + files: [DUPLICATED_BOOTSTRAP_FILE, DUPLICATED_BOOTSTRAP_FILE], + detail: DUPLICATE_BOOTSTRAP_PATH, + }, + { + reason: "asks for a mode outside the permission range", + files: [ + { + path: `${BOOTSTRAP_ROOT}/config.json`, + content: BOOTSTRAP_CONTENT, + mode: INVALID_FILE_MODE, + }, + ], + detail: INVALID_BOOTSTRAP_MODE, + }, +]; + +interface FakeRuntimeOptions { + readonly files?: readonly File[]; + readonly credentials?: readonly CredentialName[]; + readonly onAttach?: (endpoint: ApplicationEndpoint) => void; + /** A stop only the runtime can see, reported the moment it attaches. */ + readonly reportedStop?: RuntimeTermination; +} + +function fakeRuntime(options: FakeRuntimeOptions = {}) { + return defineContainerRuntime({ + name: "fake-container", + configuration: { + schema: runtimeConfiguration, + value: { kind: "fake" as const }, + }, + image: APPLICATION_IMAGE, + resources: FAKE_RESOURCES, + render: (input) => + Effect.succeed({ + entrypoint: ["node", "/application.mjs"] as const, + environment: { AGENT_NAME: input.agentName }, + credentials: options.credentials, + port: GATEWAY_PORT, + files: options.files ?? DEFAULT_BOOTSTRAP_FILES, + attach: ( + endpoint: ApplicationEndpoint, + stopped: Effect.Effect, + reportStopped: ( + termination: RuntimeTermination, + ) => Effect.Effect, + ) => + Effect.gen(function* () { + options.onAttach?.(endpoint); + const reported = options.reportedStop; + if (reported !== undefined) { + yield* reportStopped(reported); + } + // The gateway carries the cluster's own stop observation so a test + // can read what the platform handed this runtime. + return { agentName: input.agentName, stopped }; + }), + }), + }); +} + +/** + * Define a runtime the Kubernetes platform cannot realize as a container. + * @returns A runtime with no registered container capability. + */ +function plainRuntime() { + return defineRuntime< + { readonly agentName: string }, + never, + typeof runtimeConfiguration + >({ + name: "fake-plain", + configuration: { + schema: runtimeConfiguration, + value: { kind: "fake" as const }, + }, + }); +} + +function connection( + name: Name, + suffix: number, +): AgentConnection { + return { + agent: makeAgentHandle( + name, + agentId(`00000000-0000-4000-8000-${String(suffix).padStart(12, "0")}`), + ), + key: redactedAgentKey( + `moltzap_agent_${String(suffix).padStart(16, "0")}_${String(suffix).padStart(48, "0")}`, + ), + routerUrl: ROUTER_URL, + }; +} + +interface PlatformOptions { + readonly startupTimeout?: Duration.Duration; + readonly livenessInterval?: Duration.Duration; + readonly runtimeCredentials?: KubernetesClusterOptions["runtimeCredentials"]; +} + +function makePlatform( + state: FakeKubernetesState, + options: PlatformOptions = {}, +): ClusterService { + return makeKubernetesCluster({ + api: fakeApi(state), + namespace: NAMESPACE, + queueName: QUEUE_NAME, + owner: { name: "run-root", uid: "root-uid" }, + supportImage: SUPPORT_IMAGE, + runtimeCredentials: options.runtimeCredentials, + startupTimeout: options.startupTimeout ?? GENEROUS_TIMEOUT, + readinessInterval: POLL_INTERVAL, + livenessInterval: options.livenessInterval ?? POLL_INTERVAL, + }); +} + +function acquireFirst< + Id extends string, + Definitions extends Readonly>, +>(session: Society, roster: AgentRoster) { + const [entry] = roster.validatedDefinitions; + assert.isDefined(entry); + return session.acquireAgent({ + name: entry.name, + agentName: entry.agentName, + runtime: entry.runtime, + connection: connection(entry.name, 1), + }); +} + +function acquireAll< + Id extends string, + Definitions extends Readonly>, +>(session: Society, roster: AgentRoster) { + return Effect.forEach( + roster.validatedDefinitions, + (entry, index) => + session.acquireAgent({ + name: entry.name, + agentName: entry.agentName, + runtime: entry.runtime, + connection: connection(entry.name, index + 1), + }), + { concurrency: 2, discard: true }, + ); +} + +/** + * Prepare a run, bring up the complete roster, and gate it for dispatch. + * @param platform Platform under test. + * @param roster Complete roster the run reserves capacity for. + * @returns The exit of the whole scoped attempt, releases included. + */ +function acquireCohort< + Id extends string, + Definitions extends Readonly>, +>(platform: ClusterService, roster: AgentRoster) { + return Effect.scoped( + Effect.gen(function* () { + const session = yield* platform.prepare(roster); + yield* acquireAll(session, roster); + yield* session.cohortReady; + }), + ).pipe(Effect.exit); +} + +/** + * Run one scoped platform attempt under a deadline. A poll loop that never + * settles reports the cluster events it reached instead of hanging the suite. + * @param state Fake cluster state whose event trail names the progress made. + * @param attempt Scoped attempt to run. + * @returns The attempt's own result, or a failure naming what it reached. + */ +function runWithin( + state: FakeKubernetesState, + attempt: Effect.Effect, +): Effect.Effect { + return Effect.scoped(attempt).pipe( + Effect.timeoutFail({ + duration: GENEROUS_TIMEOUT, + onTimeout: () => new Error(`timed out after: ${state.events.join(",")}`), + }), + ); +} + +function detailOf(candidates: Iterable): string | undefined { + for (const candidate of candidates) { + if (candidate instanceof ClusterError) { + return candidate.detail; + } + } + return undefined; +} + +/** + * Read the cluster error the cluster raised in its error channel. + * @param exit Exit of a scoped platform attempt. + * @returns The failure detail, or a placeholder when none was raised. + */ +function failureDetail(exit: Exit.Exit): string { + const candidates = Exit.isFailure(exit) ? Cause.failures(exit.cause) : []; + return detailOf(candidates) ?? NO_CLUSTER_FAILURE; +} + +function created(state: FakeKubernetesState, prefix: string): string[] { + return state.events.filter((event) => event.startsWith(prefix)); +} + +function encodedSecretValue(value: string): string { + return Buffer.from(value, "utf8").toString("base64"); +} + +function manifestsOfKind( + state: FakeKubernetesState, + kind: string, +): KubernetesManifest[] { + return state.manifests.filter((manifest) => manifest.kind === kind); +} + +test("reserves the complete roster before creating any Sandbox and releases every resource", () => + Effect.runPromise( + Effect.gen(function* () { + const workloadObserved = yield* Deferred.make(); + const state = makeState(workloadObserved); + const runtime = fakeRuntime(); + const roster = AgentRoster.make("acme.kubernetes-order/v1", { + alice: runtime, + bob: runtime, + }); + const platform = makePlatform(state); + + yield* runWithin( + state, + Effect.gen(function* () { + const preparing = yield* Effect.fork(platform.prepare(roster)); + yield* Deferred.await(workloadObserved); + assert.deepStrictEqual(state.events, [WORKLOAD_CREATED]); + state.admitted = true; + const session = yield* Fiber.join(preparing); + yield* acquireAll(session, roster); + yield* session.cohortReady; + }), + ); + + const firstSandbox = state.events.findIndex((event) => + event.startsWith(SANDBOX_CREATED), + ); + const firstSecret = state.events.findIndex((event) => + event.startsWith(SECRET_CREATED), + ); + assert.strictEqual(state.events[0], WORKLOAD_CREATED); + assert.isAbove(firstSecret, 0); + assert.isAbove(firstSandbox, firstSecret); + assert.lengthOf(created(state, SANDBOX_CREATED), 2); + assert.lengthOf(created(state, SANDBOX_DELETED), 2); + assert.strictEqual(state.events.at(-1), WORKLOAD_DELETED); + + const sandboxManifests = manifestsOfKind(state, SANDBOX_KIND); + assert.lengthOf(sandboxManifests, 2); + for (const manifest of sandboxManifests) { + assert.notInclude(JSON.stringify(manifest), BOOTSTRAP_CONTENT); + const decoded = + Schema.decodeUnknownSync(sandboxManifestShape)(manifest); + assert.lengthOf(decoded.spec.podTemplate.spec.containers, 1); + } + }), + )); + +test("reports a finished Sandbox as runtime evidence without failing platform ownership", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const roster = AgentRoster.make("acme.kubernetes-termination/v1", { + alice: fakeRuntime(), + }); + const platform = makePlatform(state); + + yield* runWithin( + state, + Effect.gen(function* () { + const session = yield* platform.prepare(roster); + const running = yield* acquireFirst(session, roster); + yield* session.cohortReady; + const ownership = yield* Effect.fork(session.failure); + state.finished = true; + const termination = yield* running.termination; + assert.instanceOf(termination, RuntimeExited); + assert.strictEqual(termination.code, OBSERVED_EXIT_CODE); + yield* Effect.sleep(Duration.millis(5)); + assert.isTrue(Option.isNone(yield* Fiber.poll(ownership))); + }), + ); + }), + )); + +describe("readiness", () => { + test("fails the run when a runtime never signals readiness", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + state.acceptingFromProbe = UNREACHABLE_PROBE; + const roster = AgentRoster.make("acme.kubernetes-never-ready/v1", { + alice: fakeRuntime(), + }); + + const exit = yield* acquireCohort( + makePlatform(state, { startupTimeout: MISSED_TIMEOUT }), + roster, + ); + + assert.include(failureDetail(exit), NOT_READY); + assert.isAbove(state.bridgeProbes, 1); + assert.lengthOf(created(state, SANDBOX_DELETED), 1); + assert.strictEqual(state.events.at(-1), WORKLOAD_DELETED); + }), + )); + + test("dispatches the runtime exactly once when the bridge opens after several polls", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + state.acceptingFromProbe = READY_AFTER_PROBES; + let attachments = 0; + const roster = AgentRoster.make("acme.kubernetes-late-ready/v1", { + alice: fakeRuntime({ + onAttach: () => { + attachments += 1; + }, + }), + }); + + const exit = yield* acquireCohort(makePlatform(state), roster); + + assert.isTrue(Exit.isSuccess(exit), failureDetail(exit)); + assert.strictEqual(attachments, 1); + assert.isAtLeast(state.bridgeProbes, READY_AFTER_PROBES); + assert.lengthOf(created(state, SECRET_CREATED), 1); + assert.lengthOf(created(state, SANDBOX_CREATED), 1); + }), + )); + + test("rejects an agent whose Sandbox reports Finished before dispatch", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + state.finished = true; + let attachments = 0; + const roster = AgentRoster.make("acme.kubernetes-finished-early/v1", { + alice: fakeRuntime({ + onAttach: () => { + attachments += 1; + }, + }), + }); + + const exit = yield* acquireCohort( + makePlatform(state, { startupTimeout: MISSED_TIMEOUT }), + roster, + ); + + assert.include(failureDetail(exit), FINISHED_BEFORE_DISPATCH); + assert.include(failureDetail(exit), FINISHED_REASON); + assert.strictEqual(attachments, 0); + }), + )); +}); + +describe("aggregate capacity admission", () => { + test("creates no Sandbox and releases a reservation that never admitted", () => + Effect.runPromise( + Effect.gen(function* () { + for (const refused of UNADMITTED_RESERVATIONS) { + const state = makeState(yield* Deferred.make()); + refused.apply?.(state); + const roster = AgentRoster.make("acme.kubernetes-unadmitted/v1", { + alice: fakeRuntime(), + }); + + const exit = yield* acquireCohort( + makePlatform(state, { startupTimeout: MISSED_TIMEOUT }), + roster, + ); + + assert.include(failureDetail(exit), refused.detail, refused.reason); + assert.lengthOf(created(state, SANDBOX_CREATED), 0, refused.reason); + assert.strictEqual( + state.events.at(-1), + WORKLOAD_DELETED, + refused.reason, + ); + } + }), + )); +}); + +describe("session ownership", () => { + test("fails the ownership observation when admission is lost during execution", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const roster = AgentRoster.make("acme.kubernetes-admission-lost/v1", { + alice: fakeRuntime(), + }); + const platform = makePlatform(state); + + yield* runWithin( + state, + Effect.gen(function* () { + const session = yield* platform.prepare(roster); + yield* acquireFirst(session, roster); + yield* session.cohortReady; + const ownership = yield* Effect.fork(session.failure); + state.admitted = false; + const exit = yield* Fiber.await(ownership); + assert.include(failureDetail(exit), ADMISSION_LOST); + }), + ); + }), + )); + + test("fails the ownership observation when an acquired Sandbox stops being observable", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const roster = AgentRoster.make("acme.kubernetes-sandbox-gone/v1", { + alice: fakeRuntime(), + }); + const platform = makePlatform(state, { + startupTimeout: MISSED_TIMEOUT, + }); + + yield* runWithin( + state, + Effect.gen(function* () { + const session = yield* platform.prepare(roster); + const running = yield* acquireFirst(session, roster); + yield* session.cohortReady; + const ownership = yield* Effect.fork(session.failure); + // The reservation stays admitted throughout, so nothing but the + // vanished Sandbox can end this run. + state.sandboxReadFailures = Number.MAX_SAFE_INTEGER; + + const termination = yield* running.termination; + assert.instanceOf(termination, RuntimeFailed); + assert.include(termination.detail, SANDBOX_UNOBSERVABLE); + const exit = yield* Fiber.await(ownership); + assert.include(failureDetail(exit), SANDBOX_UNOBSERVABLE); + assert.include(failureDetail(exit), INJECTED_API_DETAIL); + assert.isTrue(state.admitted); + }), + ); + }), + )); +}); + +describe("roster gates", () => { + test("refuses a runtime with no container realization", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const roster = AgentRoster.make("acme.kubernetes-no-container/v1", { + alice: plainRuntime(), + }); + + const exit = yield* acquireCohort( + makePlatform(state, { startupTimeout: MISSED_TIMEOUT }), + roster, + ); + + assert.include(failureDetail(exit), NO_CONTAINER_REALIZATION); + assert.lengthOf(created(state, WORKLOAD_CREATED), 0); + }), + )); + + test("refuses a roster that reserves no capacity at all", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const roster = AgentRoster.make("acme.kubernetes-empty-roster/v1", {}); + + const exit = yield* Effect.scoped( + makePlatform(state).prepare(roster), + ).pipe(Effect.exit); + + assert.include(failureDetail(exit), EMPTY_RESERVATION); + assert.lengthOf(created(state, WORKLOAD_CREATED), 0); + }), + )); + + test("refuses the cohort gate when part of the roster was never acquired", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const runtime = fakeRuntime(); + const roster = AgentRoster.make("acme.kubernetes-partial-cohort/v1", { + alice: runtime, + bob: runtime, + }); + const platform = makePlatform(state); + + const exit = yield* Effect.scoped( + Effect.gen(function* () { + const session = yield* platform.prepare(roster); + yield* acquireFirst(session, roster); + yield* session.cohortReady; + }), + ).pipe(Effect.exit); + + assert.include(failureDetail(exit), INCOMPLETE_COHORT); + assert.lengthOf(created(state, SANDBOX_CREATED), 1); + }), + )); +}); + +describe("bootstrap data", () => { + test("creates no Secret for a bootstrap the initializer cannot trust", () => + Effect.runPromise( + Effect.gen(function* () { + for (const refused of REFUSED_BOOTSTRAPS) { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const roster = AgentRoster.make("acme.kubernetes-bad-bootstrap/v1", { + alice: fakeRuntime({ files: refused.files }), + }); + + const exit = yield* acquireCohort( + makePlatform(state, { startupTimeout: MISSED_TIMEOUT }), + roster, + ); + + assert.include(failureDetail(exit), refused.detail, refused.reason); + assert.lengthOf(created(state, SECRET_CREATED), 0, refused.reason); + } + }), + )); +}); + +describe("credential injection", () => { + test("writes a requested provider key into the run Secret and never into a Sandbox", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const roster = AgentRoster.make("acme.kubernetes-credentials/v1", { + alice: fakeRuntime({ credentials: ["ANTHROPIC_API_KEY"] }), + }); + + const exit = yield* acquireCohort( + makePlatform(state, { + runtimeCredentials: { ANTHROPIC_API_KEY: CREDENTIAL_VALUE }, + }), + roster, + ); + + assert.isTrue(Exit.isSuccess(exit), failureDetail(exit)); + const secrets = manifestsOfKind(state, SECRET_KIND); + assert.lengthOf(secrets, 1); + const [secret] = secrets; + assert.isDefined(secret); + const decoded = Schema.decodeUnknownSync(secretManifestShape)(secret); + assert.strictEqual( + decoded.data[CREDENTIAL_SECRET_KEY], + encodedSecretValue(CREDENTIAL_VALUE), + ); + + const sandboxes = manifestsOfKind(state, SANDBOX_KIND); + assert.lengthOf(sandboxes, 1); + for (const manifest of sandboxes) { + const rendered = JSON.stringify(manifest); + assert.include(rendered, CREDENTIAL_SECRET_KEY); + assert.notInclude(rendered, CREDENTIAL_VALUE); + assert.notInclude(rendered, encodedSecretValue(CREDENTIAL_VALUE)); + } + }), + )); + + test("withholds a provider key the application never requested", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const roster = AgentRoster.make("acme.kubernetes-unrequested-key/v1", { + alice: fakeRuntime({ credentials: ["ANTHROPIC_API_KEY"] }), + }); + + const exit = yield* acquireCohort( + makePlatform(state, { + runtimeCredentials: { + ANTHROPIC_API_KEY: CREDENTIAL_VALUE, + OPENAI_API_KEY: UNREQUESTED_CREDENTIAL_VALUE, + }, + }), + roster, + ); + + assert.isTrue(Exit.isSuccess(exit), failureDetail(exit)); + const [secret] = manifestsOfKind(state, SECRET_KIND); + assert.isDefined(secret); + const decoded = Schema.decodeUnknownSync(secretManifestShape)(secret); + assert.notProperty(decoded.data, UNREQUESTED_SECRET_KEY); + + const rendered = JSON.stringify(state.manifests); + assert.notInclude(rendered, UNREQUESTED_CREDENTIAL_VALUE); + assert.notInclude( + rendered, + encodedSecretValue(UNREQUESTED_CREDENTIAL_VALUE), + ); + }), + )); +}); + +describe("application pod discovery", () => { + test("dispatches no Sandbox that is not backed by exactly one live Pod", () => + Effect.runPromise( + Effect.gen(function* () { + for (const shape of ["none", "several", "terminating"] as const) { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + state.podsFor = (name) => backingPods(state, name, shape); + const roster = AgentRoster.make(`acme.kubernetes-${shape}-pods/v1`, { + alice: fakeRuntime(), + }); + + const exit = yield* acquireCohort( + makePlatform(state, { startupTimeout: MISSED_TIMEOUT }), + roster, + ); + + assert.include(failureDetail(exit), NOT_READY, shape); + // The bridge answered throughout: only the Pod observation refused. + assert.isAbove(state.bridgeProbes, 0, shape); + } + }), + )); +}); + +describe("termination evidence", () => { + test("reports a signalled application as RuntimeSignaled", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + state.terminationSignal = OBSERVED_SIGNAL; + const roster = AgentRoster.make("acme.kubernetes-signalled/v1", { + alice: fakeRuntime(), + }); + const platform = makePlatform(state); + + yield* runWithin( + state, + Effect.gen(function* () { + const session = yield* platform.prepare(roster); + const running = yield* acquireFirst(session, roster); + yield* session.cohortReady; + state.finished = true; + const termination = yield* running.termination; + assert.instanceOf(termination, RuntimeSignaled); + assert.strictEqual(termination.signal, SIGNAL_EVIDENCE); + }), + ); + }), + )); + + test("reports a stop only the runtime can see while its Sandbox still runs", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const roster = AgentRoster.make("acme.kubernetes-bridge-lost/v1", { + alice: fakeRuntime({ + reportedStop: RuntimeFailed.make({ detail: RUNTIME_BRIDGE_LOST }), + }), + }); + const platform = makePlatform(state); + + yield* runWithin( + state, + Effect.gen(function* () { + const session = yield* platform.prepare(roster); + const running = yield* acquireFirst(session, roster); + yield* session.cohortReady; + // state.finished stays false: the Sandbox never stops reporting + // Ready, so only the runtime's own report can end this agent. + + const termination = yield* running.termination; + assert.instanceOf(termination, RuntimeFailed); + assert.strictEqual(termination.detail, RUNTIME_BRIDGE_LOST); + }), + ); + }), + )); + + test("keeps observing termination across transport failures", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + const roster = AgentRoster.make("acme.kubernetes-observe-retry/v1", { + alice: fakeRuntime(), + }); + const platform = makePlatform(state); + + yield* runWithin( + state, + Effect.gen(function* () { + const session = yield* platform.prepare(roster); + const running = yield* acquireFirst(session, roster); + yield* session.cohortReady; + state.finished = true; + state.sandboxReadFailures = INJECTED_READ_FAILURES; + const termination = yield* running.termination; + assert.instanceOf(termination, RuntimeExited); + assert.strictEqual(termination.code, OBSERVED_EXIT_CODE); + assert.strictEqual(state.sandboxReadFailures, 0); + }), + ); + }), + )); +}); + +describe("observation cadence", () => { + test("holds a running agent to the liveness interval, not the readiness one", () => + Effect.runPromise( + Effect.gen(function* () { + const state = makeState(yield* Deferred.make()); + state.admitted = true; + state.acceptingFromProbe = READY_AFTER_PROBES; + const roster = AgentRoster.make("acme.kubernetes-cadence/v1", { + alice: fakeRuntime(), + }); + const platform = makePlatform(state, { + livenessInterval: UNREACHED_INTERVAL, + }); + + yield* runWithin( + state, + Effect.gen(function* () { + // Reaching a bridge that opens only after several probes proves + // readiness kept its own interval. + const session = yield* platform.prepare(roster); + const running = yield* acquireFirst(session, roster); + yield* session.cohortReady; + assert.isAtLeast(state.bridgeProbes, READY_AFTER_PROBES); + + const observing = yield* Effect.fork(running.termination); + yield* Effect.sleep(SETTLED); + yield* Effect.sync(() => { + state.finished = true; + }); + yield* Effect.sleep(SETTLED); + + assert.isTrue(Option.isNone(yield* Fiber.poll(observing))); + }), + ); + }), + )); +}); + +/* eslint-enable max-lines-per-function, max-nested-callbacks, sonarjs/max-lines-per-function -- restore project limits after ordered lifecycle regressions */ diff --git a/packages/simulator/src/cluster/cohort.ts b/packages/simulator/src/cluster/cohort.ts new file mode 100644 index 000000000..2a918f60d --- /dev/null +++ b/packages/simulator/src/cluster/cohort.ts @@ -0,0 +1,973 @@ +/** @file Private Kubernetes realization of one complete simulator society. */ +// safer-arch-ignore no-cross-domain-sibling-import: Bringing a roster up is inherently cross-domain: it renders agents, reserves cluster capacity, and hands each one its router connection. + +import { posix } from "node:path"; +import { + Deferred, + Duration, + Effect, + Layer, + Schedule, + type Scope, +} from "effect"; +import type { + AgentRoster, + AgentRosterAcquisitionError, + RuntimeGatewayOf, +} from "../agents/roster.js"; +import { + RuntimeExited, + RuntimeFailed, + RuntimeSignaled, + type AgentRuntimeLike, + type RunningAgent, + type RuntimeTermination, +} from "../agents/agent.js"; +import { + containerRuntimeFor, + type Application, + type ContainerRuntime, + type CredentialName, + type File, + type Image, + type Resources, +} from "../agents/container.js"; +import { + Cluster, + type Slot, + type ClusterService, + type Society, + ClusterError, +} from "./cluster.js"; +import { + currentConditionIsTrue, + type KubernetesSocietyApi, + type PodObservation, + type SandboxObservation, +} from "./kubernetes/calls.js"; +import { + aggregateWorkloadManifest, + bootstrapSecretManifest, + type KubernetesRunOwner, + type ReservedCapacity, + type RuntimeCapacitySlot, + type SandboxApplication, + sandboxManifest, +} from "./kubernetes/objects.js"; +import type { KubernetesPodPlacement } from "./profile.js"; + +const WORKLOAD_NAME = "society"; +const APPLICATION_CONTAINER_NAME = "application"; +const BOOTSTRAP_ROOT = "/var/run/moltzap/bootstrap/"; + +/** + * Admission and readiness hold the run at its starting line, so they are + * observed at the rate someone waits at. + */ +const DEFAULT_READINESS_INTERVAL = Duration.millis(250); + +/** + * Liveness only has to notice an ending. Every agent and the reservation + * observe it for the whole run rather than for a startup window, and each + * observation is a quorum read of the cluster's own store, so the run's + * standing cost is this interval divided into the roster. + */ +const DEFAULT_LIVENESS_INTERVAL = Duration.seconds(5); + +interface TerminatedApplication { + readonly exitCode: number; + readonly signal?: number; + readonly reason?: string; + readonly message?: string; +} + +/** Run-scoped facts every observation of one prepared roster shares. */ +interface KubernetesSession { + readonly options: KubernetesClusterOptions; + readonly readinessInterval: Duration.Duration; + readonly livenessInterval: Duration.Duration; + /** Carries an acquired Sandbox that vanished into the session's failure. */ + readonly lost: Deferred.Deferred; +} + +interface KubernetesSessionState< + Definitions extends Readonly>, +> extends KubernetesSession { + /** Roster entries whose Sandbox reached readiness and attached. */ + readonly acquired: Set; + readonly resourceNames: Readonly< + Record, string> + >; +} + +/** Inputs already owned by the run controller and hidden from customer code. */ +export interface KubernetesClusterOptions { + readonly api: KubernetesSocietyApi; + readonly namespace: string; + readonly queueName: string; + readonly owner: KubernetesRunOwner; + readonly supportImage: Image; + /** Fixed provider credentials used only by model-configured applications. */ + readonly runtimeCredentials?: Readonly< + Partial> + >; + readonly rosterPlacement?: KubernetesPodPlacement; + readonly startupTimeout: Duration.Duration; + /** How often admission and readiness are observed while the run starts. */ + readonly readinessInterval?: Duration.Duration; + /** How often a running agent and the reservation are observed to still be there. */ + readonly livenessInterval?: Duration.Duration; +} + +function clusterError(detail: string): ClusterError { + return new ClusterError({ detail }); +} + +function resourceRequests( + resources: Resources, +): Readonly> { + return { + cpu: `${String(resources.cpuMillis)}m`, + memory: String(resources.memoryBytes), + "ephemeral-storage": String(resources.ephemeralStorageBytes), + }; +} + +function agentResourceName(index: number, name: string): string { + return `agent-${String(index + 1)}-${name.replaceAll("_", "-")}`; +} + +function positiveConditionDetail( + observation: SandboxObservation, + type: string, +): string | undefined { + const generation = observation.metadata.generation; + const condition = observation.status?.conditions?.find( + (entry) => + entry.type === type && + entry.status === "True" && + (generation === undefined || entry.observedGeneration === generation), + ); + return condition === undefined + ? undefined + : [condition.reason, condition.message].filter(Boolean).join(": "); +} + +function workloadAdmission( + api: KubernetesSocietyApi, + within: Duration.Duration, + readinessInterval: Duration.Duration, +): Effect.Effect { + const observe: Effect.Effect = Effect.suspend(() => + api.readWorkload(WORKLOAD_NAME).pipe( + Effect.flatMap((workload) => { + if (workload.metadata.deletionTimestamp !== undefined) { + return Effect.fail( + clusterError( + "aggregate capacity reservation was deleted before admission", + ), + ); + } + if (currentConditionIsTrue(workload, "Evicted")) { + return Effect.fail( + clusterError( + "aggregate capacity reservation was evicted before admission", + ), + ); + } + return currentConditionIsTrue(workload, "Admitted") && + workload.status?.admission !== undefined + ? Effect.void + : Effect.sleep(readinessInterval).pipe(Effect.zipRight(observe)); + }), + ), + ); + return observe.pipe( + Effect.timeoutFail({ + duration: within, + onTimeout: () => + clusterError( + `complete roster was not admitted within ${Duration.format(within)}`, + ), + }), + ); +} + +function applicationTerminated( + pod: PodObservation, +): TerminatedApplication | undefined { + return pod.status?.containerStatuses?.find( + (entry) => entry.name === APPLICATION_CONTAINER_NAME, + )?.state.terminated; +} + +function liveApplicationPod( + pods: readonly PodObservation[], +): PodObservation | undefined { + const live = pods.filter( + (pod) => pod.metadata.deletionTimestamp === undefined, + ); + const [pod] = live; + return live.length === 1 && + pod !== undefined && + applicationTerminated(pod) === undefined + ? pod + : undefined; +} + +function finishedBeforeDispatch( + sandboxName: string, + sandbox: SandboxObservation, +): ClusterError { + const detail = positiveConditionDetail(sandbox, "Finished"); + const suffix = + detail === undefined || detail.length === 0 ? "" : `: ${detail}`; + return clusterError( + `agent sandbox "${sandboxName}" finished before dispatch${suffix}`, + ); +} + +interface SandboxAddress { + readonly fqdn: string; + readonly selector: string; +} + +/** + * The address a Sandbox publishes once it is Ready. A Sandbox reports Ready and + * its address independently, so both must be present before anything can reach + * the agent. + * @param sandbox Current observation of one agent's Sandbox. + * @returns The service FQDN and Pod selector, or undefined while not reachable. + */ +function readySandboxAddress( + sandbox: SandboxObservation, +): SandboxAddress | undefined { + const fqdn = sandbox.status?.serviceFQDN; + const selector = sandbox.status?.selector; + return currentConditionIsTrue(sandbox, "Ready") && + fqdn !== undefined && + selector !== undefined + ? { fqdn, selector } + : undefined; +} + +/** + * Observe one agent's readiness for dispatch. Readiness is the Sandbox Ready + * condition, the application's controller bridge port accepting a connection, + * and one live application Pod: the bridge is what the controller is about to + * do, so nothing weaker can claim the agent can serve it. + * + * A Sandbox reports Ready as soon as its container starts, well before a + * runtime listens, and this repeats for the whole startup budget. The bridge + * probe is a local connect that costs the cluster nothing, while listing Pods + * is a quorum read of every Pod behind the selector, so the probe gates the + * list rather than the other way around. + * @param api Cluster operations for this run. + * @param sandboxName Sandbox resource that backs one roster entry. + * @param port Controller bridge port declared by the rendered application. + * @returns The service address once ready, or undefined to keep polling. + */ +function observeReadySandbox( + api: KubernetesSocietyApi, + sandboxName: string, + port: number, +): Effect.Effect { + return Effect.gen(function* () { + const sandbox = yield* api.readSandbox(sandboxName); + if (currentConditionIsTrue(sandbox, "Finished")) { + return yield* Effect.fail(finishedBeforeDispatch(sandboxName, sandbox)); + } + const address = readySandboxAddress(sandbox); + if (address === undefined) { + return undefined; + } + if (!(yield* api.bridgeAccepts(address.fqdn, port))) { + return undefined; + } + const pods = yield* api.listPods(address.selector); + return liveApplicationPod(pods) === undefined ? undefined : address.fqdn; + }); +} + +function waitForReadySandbox( + sandboxName: string, + port: number, + session: KubernetesSession, +): Effect.Effect { + const { api, startupTimeout } = session.options; + const observe: Effect.Effect = Effect.suspend(() => + observeReadySandbox(api, sandboxName, port).pipe( + Effect.flatMap((fqdn) => + fqdn === undefined + ? Effect.sleep(session.readinessInterval).pipe( + Effect.zipRight(observe), + ) + : Effect.succeed(fqdn), + ), + ), + ); + return observe.pipe( + Effect.timeoutFail({ + duration: startupTimeout, + onTimeout: () => + clusterError( + `agent sandbox "${sandboxName}" was not ready within ${Duration.format(startupTimeout)}`, + ), + }), + ); +} + +function terminalEvidence( + sandboxName: string, + pod?: PodObservation, +): RuntimeTermination { + if (pod === undefined) { + return RuntimeFailed.make({ + detail: `agent sandbox "${sandboxName}" finished without an observable application Pod`, + }); + } + const terminated = applicationTerminated(pod); + if (terminated === undefined) { + return RuntimeFailed.make({ + detail: `agent sandbox "${sandboxName}" finished without an observable application termination`, + }); + } + return terminated.signal !== undefined && terminated.signal > 0 + ? RuntimeSignaled.make({ signal: `signal-${String(terminated.signal)}` }) + : RuntimeExited.make({ code: terminated.exitCode }); +} + +function finishedEvidence( + api: KubernetesSocietyApi, + sandboxName: string, + sandbox: SandboxObservation, +): Effect.Effect { + const selector = sandbox.status?.selector; + if (selector === undefined) { + return Effect.succeed(terminalEvidence(sandboxName)); + } + return api.listPods(selector).pipe( + Effect.map((pods) => + terminalEvidence( + sandboxName, + pods.find((pod) => applicationTerminated(pod) !== undefined), + ), + ), + ); +} + +function terminationSoFar( + api: KubernetesSocietyApi, + sandboxName: string, +): Effect.Effect { + return api + .readSandbox(sandboxName) + .pipe( + Effect.flatMap((sandbox) => + currentConditionIsTrue(sandbox, "Finished") + ? finishedEvidence(api, sandboxName, sandbox) + : Effect.succeed(undefined), + ), + ); +} + +function sandboxLost(sandboxName: string, cause: ClusterError): ClusterError { + return clusterError( + `agent sandbox "${sandboxName}" stopped being observable: ${cause.detail}`, + ); +} + +/** + * Observe one agent's Sandbox until it reports Finished. + * + * A read is retried while the cluster API is briefly unreachable, but only for + * as long as the run allows an agent to become ready: past that the Sandbox is + * gone rather than slow. Retrying a deleted object forever would leave the run + * waiting on an agent that no longer exists with nothing reporting it, so the + * loss both ends the session and stands as this agent's terminal evidence. + * @param sandboxName Sandbox resource that backs one roster entry. + * @param session Run-scoped observation cadence and loss channel. + * @returns An Effect that completes with this agent's terminal evidence. + */ +function observeTermination( + sandboxName: string, + session: KubernetesSession, +): Effect.Effect { + const read = terminationSoFar(session.options.api, sandboxName).pipe( + Effect.retry( + Schedule.spaced(session.livenessInterval).pipe( + Schedule.upTo(session.options.startupTimeout), + ), + ), + ); + const observe: Effect.Effect = + Effect.suspend(() => + read.pipe( + Effect.flatMap((evidence) => + evidence === undefined + ? Effect.sleep(session.livenessInterval).pipe( + Effect.zipRight(observe), + ) + : Effect.succeed(evidence), + ), + ), + ); + return observe.pipe( + Effect.catchAll((cause) => { + const lost = sandboxLost(sandboxName, cause); + return Deferred.fail(session.lost, lost).pipe( + Effect.as(RuntimeFailed.make({ detail: lost.detail })), + ); + }), + ); +} + +interface ResolvedCredential { + readonly secretKey: string; + readonly value: string; +} + +/** + * Match what the application asked for against what the run actually holds. A + * credential resolves only when both agree; the record is exhaustive over + * CredentialName so every downstream view is derived rather than re-enumerated. + * @param application Rendered application declaring the credentials it wants. + * @param credentials Provider credentials this run was given. + * @returns One entry per credential name, undefined where nothing resolves. + */ +function resolveCredentials( + application: Application, + credentials: KubernetesClusterOptions["runtimeCredentials"], +): Readonly> { + const requested = new Set(application.credentials ?? []); + const resolve = (name: CredentialName): ResolvedCredential | undefined => { + const value = credentials?.[name]; + return requested.has(name) && value !== undefined + ? { secretKey: `credential-${name}`, value } + : undefined; + }; + return Object.freeze({ + ANTHROPIC_API_KEY: resolve("ANTHROPIC_API_KEY"), + OPENAI_API_KEY: resolve("OPENAI_API_KEY"), + }); +} + +function credentialSecretKeys( + resolved: Readonly>, +): Readonly> { + return Object.freeze({ + ANTHROPIC_API_KEY: resolved.ANTHROPIC_API_KEY?.secretKey, + OPENAI_API_KEY: resolved.OPENAI_API_KEY?.secretKey, + }); +} + +interface BootstrapEntry { + readonly source: string; + readonly path: string; + readonly mode: number; + readonly content: string; +} + +function bootstrapEntries( + files: readonly File[], +): Effect.Effect { + return Effect.gen(function* () { + const targets = new Set(); + const entries: BootstrapEntry[] = []; + for (const [index, file] of files.entries()) { + const normalized = posix.normalize(file.path); + if ( + !normalized.startsWith(BOOTSTRAP_ROOT) || + normalized === BOOTSTRAP_ROOT.slice(0, -1) + ) { + return yield* Effect.fail( + clusterError( + "distributed bootstrap file must stay below /var/run/moltzap/bootstrap", + ), + ); + } + const path = normalized.slice(BOOTSTRAP_ROOT.length); + if (targets.has(path)) { + return yield* Effect.fail( + clusterError( + `distributed bootstrap contains duplicate path "${path}"`, + ), + ); + } + if (!Number.isInteger(file.mode) || file.mode < 0 || file.mode > 0o777) { + return yield* Effect.fail( + clusterError( + `distributed bootstrap contains invalid file mode for "${path}"`, + ), + ); + } + targets.add(path); + entries.push({ + source: `file-${String(index)}`, + path, + mode: file.mode, + content: file.content, + }); + } + return entries; + }); +} + +function bootstrapData( + application: Application, + credentials: KubernetesClusterOptions["runtimeCredentials"], +): Effect.Effect>, ClusterError> { + return bootstrapEntries(application.files).pipe( + Effect.map((files) => { + const credentialData = Object.fromEntries( + Object.values(resolveCredentials(application, credentials)).flatMap( + (resolved) => + resolved === undefined + ? [] + : [[resolved.secretKey, resolved.value] as const], + ), + ); + return Object.freeze({ + "manifest.json": JSON.stringify({ + apiVersion: "moltzap.bootstrap/v1", + files: files.map(({ source, path, mode }) => ({ + source, + path, + mode, + })), + }), + ...Object.fromEntries( + files.map(({ source, content }) => [source, content]), + ), + ...credentialData, + }); + }), + ); +} + +function holdResource( + create: Effect.Effect, + remove: Effect.Effect, +): Effect.Effect { + // The returned Effect retains Scope in its requirements, so the run owns + // every release registered here. + // eslint-disable-next-line agent-code-guard/acquire-release-requires-scope -- the caller provides the run scope required by the return type + return Effect.acquireRelease(create, () => remove.pipe(Effect.orDie)); +} + +/** + * Watch what the run owns: the capacity reservation it holds, and any acquired + * Sandbox that stopped being observable at all. Only the reservation is polled + * here — a vanished Sandbox is discovered by the termination observation that + * already reads it. An agent that merely dies is the run's own business, + * reported as that agent's evidence rather than as lost cluster ownership. + * @param session Run-scoped observation cadence and loss channel. + * @returns An Effect that fails once the run no longer owns what it reserved. + */ +function sessionFailure( + session: KubernetesSession, +): Effect.Effect { + const observe: Effect.Effect = Effect.suspend(() => + Effect.gen(function* () { + const workload = yield* session.options.api.readWorkload(WORKLOAD_NAME); + if ( + workload.metadata.deletionTimestamp !== undefined || + currentConditionIsTrue(workload, "Evicted") || + !currentConditionIsTrue(workload, "Admitted") || + workload.status?.admission === undefined + ) { + return yield* Effect.fail( + clusterError( + "complete-roster capacity admission was lost during execution", + ), + ); + } + yield* Effect.sleep(session.livenessInterval); + return yield* observe; + }), + ); + return Effect.raceFirst(observe, Deferred.await(session.lost)); +} + +function agentLabels(resourceName: string): Readonly> { + return { + "app.kubernetes.io/managed-by": "moltzap-simulator", + "moltzap.dev/agent": resourceName, + }; +} + +function bootstrapSecretName(resourceName: string): string { + return `${resourceName}-bootstrap`; +} + +function holdBootstrapSecret( + application: Application, + resourceName: string, + options: KubernetesClusterOptions, +): Effect.Effect { + const secretName = bootstrapSecretName(resourceName); + return bootstrapData(application, options.runtimeCredentials).pipe( + Effect.flatMap((data) => + holdResource( + options.api.createSecret( + bootstrapSecretManifest({ + namespace: options.namespace, + name: secretName, + labels: agentLabels(resourceName), + owner: options.owner, + data, + }), + ), + options.api.deleteSecret(secretName), + ), + ), + ); +} + +function sandboxApplication( + application: Application, + container: ContainerRuntime, +): SandboxApplication { + return { + image: container.image, + resources: container.resources, + entrypoint: application.entrypoint, + environment: application.environment, + credentials: application.credentials, + port: application.port, + }; +} + +function holdSandbox( + application: Application, + container: ContainerRuntime, + resourceName: string, + options: KubernetesClusterOptions, +): Effect.Effect { + return holdResource( + options.api.createSandbox( + sandboxManifest({ + namespace: options.namespace, + name: resourceName, + labels: agentLabels(resourceName), + owner: options.owner, + bootstrapSecretName: bootstrapSecretName(resourceName), + supportImage: options.supportImage, + application: sandboxApplication(application, container), + credentialSecretKeys: credentialSecretKeys( + resolveCredentials(application, options.runtimeCredentials), + ), + placement: options.rosterPlacement, + }), + ), + options.api.deleteSandbox(resourceName), + ); +} + +function installRenderedApplication( + application: Application, + container: ContainerRuntime, + resourceName: string, + options: KubernetesClusterOptions, +): Effect.Effect { + return Effect.gen(function* () { + yield* holdBootstrapSecret(application, resourceName, options); + yield* holdSandbox(application, container, resourceName, options); + }); +} + +type KubernetesAgentAcquisition< + Definitions extends Readonly>, + Name extends Extract, +> = Effect.Effect< + RunningAgent>, + AgentRosterAcquisitionError | ClusterError, + Scope.Scope +>; + +function attachReadyApplication( + application: Application, + sandboxName: string, + session: KubernetesSession, +): Effect.Effect< + RunningAgent, + AcquisitionError | ClusterError, + Scope.Scope +> { + return Effect.gen(function* () { + const fqdn = yield* waitForReadySandbox( + sandboxName, + application.port, + session, + ); + const stopped = observeTermination(sandboxName, session); + // A runtime can watch its own controller bridge die while the container + // keeps reporting Running, which nothing in the cluster's view of the + // Sandbox would ever show. Whichever stop arrives first is the evidence. + const reported = yield* Deferred.make(); + const gateway = yield* application.attach( + { host: fqdn, port: application.port }, + stopped, + (termination) => + Deferred.succeed(reported, termination).pipe(Effect.asVoid), + ); + return Object.freeze({ + gateway, + termination: Effect.raceFirst(stopped, Deferred.await(reported)), + }); + }); +} + +function acquireKubernetesAgent< + Definitions extends Readonly>, + Name extends Extract, +>( + input: Slot, + state: KubernetesSessionState, +): KubernetesAgentAcquisition { + return Effect.gen(function* () { + const container = containerRuntimeFor(input.runtime); + if (container === undefined) { + return yield* Effect.fail( + clusterError( + `runtime "${input.runtime.name}" has no Kubernetes container realization`, + ), + ); + } + const resourceName = state.resourceNames[input.name]; + const application = yield* container.render(input); + yield* installRenderedApplication( + application, + container, + resourceName, + state.options, + ); + const running = yield* attachReadyApplication( + application, + resourceName, + state, + ); + state.acquired.add(input.name); + return running; + }); +} + +function liveForDispatch( + api: KubernetesSocietyApi, + sandboxName: string, +): Effect.Effect { + return api.readSandbox(sandboxName).pipe( + Effect.flatMap((sandbox) => { + if (currentConditionIsTrue(sandbox, "Finished")) { + return Effect.fail(finishedBeforeDispatch(sandboxName, sandbox)); + } + return currentConditionIsTrue(sandbox, "Ready") + ? Effect.void + : Effect.fail( + clusterError( + `agent sandbox "${sandboxName}" stopped being ready before dispatch`, + ), + ); + }), + ); +} + +/** + * Gate dispatch on the complete acquired roster. Readiness itself was already + * established during acquisition; this is the only check that an agent has not + * died in the window between its own acquisition and the cohort's dispatch, so + * it reads each Sandbox exactly once rather than re-entering the wait. + * + * Only roster entries are ever acquired, so a count that matches the roster is + * the complete roster. + * @param roster Complete roster the run reserved capacity for. + * @param state Run-scoped acquisition bookkeeping. + * @returns An Effect that completes only when every agent can be dispatched. + */ +function cohortReadiness< + Id extends string, + Definitions extends Readonly>, +>( + roster: AgentRoster, + state: KubernetesSessionState, +): Effect.Effect { + return Effect.gen(function* () { + if (state.acquired.size !== roster.validatedDefinitions.length) { + return yield* Effect.fail( + clusterError( + "cohort gate does not contain the complete prepared roster", + ), + ); + } + yield* Effect.forEach( + roster.validatedDefinitions, + (entry) => + liveForDispatch(state.options.api, state.resourceNames[entry.name]), + { concurrency: 8, discard: true }, + ); + }); +} + +function makeKubernetesSession< + Id extends string, + Definitions extends Readonly>, +>( + roster: AgentRoster, + state: KubernetesSessionState, +): Society { + return Object.freeze({ + acquireAgent: >( + input: Slot, + ) => acquireKubernetesAgent(input, state), + cohortReady: cohortReadiness(roster, state), + failure: sessionFailure(state), + }); +} + +function namesForRoster< + Id extends string, + Definitions extends Readonly>, +>( + roster: AgentRoster, +): Readonly, string>> { + return /* Safe because a roster's validated entries are exactly its definition keys, each present once. */ Object.freeze( + Object.fromEntries( + roster.validatedDefinitions.map((entry, index) => [ + entry.name, + agentResourceName(index, entry.name), + ]), + ), + ) as Readonly, string>>; +} + +/** + * Refuse a roster that reserves nothing before the run holds any cluster + * resource, which is what lets the reservation itself require a runtime. + * @param slots Capacity projected from every roster entry, in roster order. + * @returns The same slots once at least one of them exists. + */ +function reservableSlots( + slots: readonly RuntimeCapacitySlot[], +): Effect.Effect { + const [first, ...rest] = slots; + return first === undefined + ? Effect.fail( + clusterError( + "aggregate capacity reservation requires at least one runtime", + ), + ) + : Effect.succeed([first, ...rest]); +} + +/** + * Project the whole roster's capacity. Every fact here is already held by the + * runtime value, so this reads rather than asks the cluster anything. + * @param roster Complete roster the run reserves capacity for. + * @returns Capacity for every entry, in roster order. + */ +function capacityForRoster< + Id extends string, + Definitions extends Readonly>, +>( + roster: AgentRoster, +): Effect.Effect { + return Effect.gen(function* () { + const slots: RuntimeCapacitySlot[] = []; + for (const entry of roster.validatedDefinitions) { + const container = containerRuntimeFor(entry.runtime); + if (container === undefined) { + return yield* Effect.fail( + clusterError( + `runtime "${entry.runtime.name}" has no Kubernetes container realization`, + ), + ); + } + slots.push({ + image: container.image, + requests: resourceRequests(container.resources), + }); + } + return yield* reservableSlots(slots); + }); +} + +function reserveCompleteRoster( + slots: ReservedCapacity, + options: KubernetesClusterOptions, +): Effect.Effect { + const labels = { + "app.kubernetes.io/managed-by": "moltzap-simulator", + "moltzap.dev/run": options.owner.name, + }; + return holdResource( + options.api.createWorkload( + aggregateWorkloadManifest({ + namespace: options.namespace, + name: WORKLOAD_NAME, + queueName: options.queueName, + labels, + owner: options.owner, + slots, + placement: options.rosterPlacement, + }), + ), + options.api.deleteWorkload(WORKLOAD_NAME), + ); +} + +function prepareKubernetesSociety< + Id extends string, + Definitions extends Readonly>, +>( + roster: AgentRoster, + options: KubernetesClusterOptions, +): Effect.Effect, ClusterError, Scope.Scope> { + return Effect.gen(function* () { + const resourceNames = namesForRoster(roster); + yield* reserveCompleteRoster(yield* capacityForRoster(roster), options); + const readinessInterval = + options.readinessInterval ?? DEFAULT_READINESS_INTERVAL; + yield* workloadAdmission( + options.api, + options.startupTimeout, + readinessInterval, + ); + return makeKubernetesSession(roster, { + options, + resourceNames, + readinessInterval, + livenessInterval: options.livenessInterval ?? DEFAULT_LIVENESS_INTERVAL, + acquired: new Set(), + lost: yield* Deferred.make(), + }); + }); +} + +/** + * Build the private cluster service used by the in-cluster controller. + * @param options Run-scoped Kubernetes API, identities, images, and deadlines. + * @returns Cluster service consumed by the simulator kernel. + */ +export function makeKubernetesCluster( + options: KubernetesClusterOptions, +): ClusterService { + return Object.freeze({ + prepare: < + Id extends string, + Definitions extends Readonly>, + >( + roster: AgentRoster, + ) => prepareKubernetesSociety(roster, options), + }); +} + +/** + * Install one run-scoped Kubernetes society behind the kernel boundary. + * @param options Run-scoped Kubernetes API, identities, images, and deadlines. + * @returns Layer that supplies only the private cluster service. + */ +export function kubernetesClusterLayer( + options: KubernetesClusterOptions, +): Layer.Layer { + return Layer.succeed(Cluster, makeKubernetesCluster(options)); +} diff --git a/packages/simulator/src/cluster/controller/configuration.ts b/packages/simulator/src/cluster/controller/configuration.ts new file mode 100644 index 000000000..d357c156b --- /dev/null +++ b/packages/simulator/src/cluster/controller/configuration.ts @@ -0,0 +1,279 @@ +/** @file Closed environment contract for the in-cluster run controller. */ +// safer-arch-ignore no-cross-domain-sibling-import: Decodes one environment into the ledger, network, and cluster values the controller needs. + +import { + type ServerBaseUrl, + serverBaseUrlSchema, +} from "@moltzap/protocol/network"; +import { isAbsolute } from "node:path"; +import { Data, Either, Schema } from "effect"; +import type { Image } from "../../agents/container.js"; +import type { KubernetesPodPlacement } from "../profile.js"; + +const DEFAULT_STARTUP_TIMEOUT_MS = 120_000; +const DEFAULT_COHORT_SIZE = 2; +// A thousand agents is the first size the decision defers to its acceptance +// gates, so the bound excludes it rather than admitting it. +const MAX_COHORT_SIZE = 1_000; +const MAX_STARTUP_TIMEOUT_MS = 24 * 60 * 60 * 1_000; +const DNS_LABEL = /^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/u; +const OWNER_UID = /^[A-Za-z0-9](?:[-A-Za-z0-9._]*[A-Za-z0-9])?$/u; +const DIGEST_PINNED_IMAGE = /^.+@sha256:[0-9a-f]{64}$/u; +const decodeServerBaseUrl = Schema.decodeEither(serverBaseUrlSchema); +const placementSchema = Schema.Struct({ + nodeSelector: Schema.Record({ + key: Schema.NonEmptyString, + value: Schema.NonEmptyString, + }), + tolerations: Schema.Array( + Schema.Struct({ + key: Schema.NonEmptyString, + operator: Schema.Literal("Equal"), + value: Schema.NonEmptyString, + effect: Schema.Literal("NoSchedule"), + }), + ), +}); +const decodePlacement = Schema.decodeEither(Schema.parseJson(placementSchema)); +const runtimeCredentialsSchema = Schema.partial( + Schema.Struct({ + ANTHROPIC_API_KEY: Schema.NonEmptyString, + OPENAI_API_KEY: Schema.NonEmptyString, + }), +); +const decodeRuntimeCredentials = Schema.decodeEither( + Schema.parseJson(runtimeCredentialsSchema), +); + +/** Environment source accepted by the private controller boundary. */ +export type ControllerEnvironment = Readonly< + Record +>; + +/** Fully validated values shared by the entry point and cluster helper. */ +export interface ControllerConfiguration { + readonly namespace: string; + readonly queueName: string; + readonly owner: { + readonly name: string; + readonly uid: string; + }; + readonly supportImage: Image; + readonly runtimeCredentials: Readonly< + Partial> + >; + readonly rosterPlacement?: KubernetesPodPlacement; + readonly experimentModule: string; + readonly ledgerDirectory: string; + readonly ledgerExportDirectory?: string; + readonly routerUrl: ServerBaseUrl; + readonly startupTimeoutMs: number; + /** Agents an experiment sized by its run builds its roster from. */ + readonly cohortSize: number; +} + +/** Safe configuration failure that never repeats a supplied environment value. */ +export class ControllerConfigurationError extends Data.TaggedError( + "ControllerConfigurationError", +)<{ readonly detail: string }> { + override get message(): string { + return `Controller configuration is invalid: ${this.detail}`; + } +} + +function invalid(detail: string): ControllerConfigurationError { + return new ControllerConfigurationError({ detail }); +} + +function required(environment: ControllerEnvironment, key: string): string { + const value = environment[key]; + if (value === undefined || value.length === 0) { + throw invalid(`${key} is required`); + } + return value; +} + +function kubernetesName( + environment: ControllerEnvironment, + key: string, +): string { + const value = required(environment, key); + if (value.length > 63 || !DNS_LABEL.test(value)) { + throw invalid(`${key} must be one Kubernetes DNS label`); + } + return value; +} + +function ownerUid(environment: ControllerEnvironment): string { + const key = "MOLTZAP_RUN_OWNER_UID"; + const value = required(environment, key); + if (value.length > 128 || !OWNER_UID.test(value)) { + throw invalid(`${key} is not a Kubernetes object UID`); + } + return value; +} + +function supportImage(environment: ControllerEnvironment): Image { + const key = "MOLTZAP_SUPPORT_IMAGE"; + const value = required(environment, key); + if (!DIGEST_PINNED_IMAGE.test(value)) { + throw invalid(`${key} must be a lowercase SHA-256 digest-pinned image`); + } + // eslint-disable-next-line agent-code-guard/require-assertion-rationale -- The preceding closed pattern proves the template-literal image contract. + return value as Image; +} + +function absolutePath(environment: ControllerEnvironment, key: string): string { + const value = required(environment, key); + if (!isAbsolute(value)) { + throw invalid(`${key} must be an absolute path`); + } + return value; +} + +function optionalAbsolutePath( + environment: ControllerEnvironment, + key: string, +): string | undefined { + return environment[key] === undefined + ? undefined + : absolutePath(environment, key); +} + +function experimentModulePath(environment: ControllerEnvironment): string { + const key = "MOLTZAP_EXPERIMENT_MODULE"; + const value = absolutePath(environment, key); + if (!value.endsWith(".mjs")) { + throw invalid(`${key} must be an absolute .mjs path`); + } + return value; +} + +function routerUrl(environment: ControllerEnvironment): ServerBaseUrl { + const key = "MOLTZAP_ROUTER_URL"; + const value = required(environment, key); + const decoded = decodeServerBaseUrl(value); + return Either.match(decoded, { + onLeft: () => { + throw invalid(`${key} must be a MoltZap server origin`); + }, + onRight: (url) => url, + }); +} + +function startupTimeoutMs(environment: ControllerEnvironment): number { + const encoded = environment.MOLTZAP_STARTUP_TIMEOUT_MS; + if (encoded === undefined) { + return DEFAULT_STARTUP_TIMEOUT_MS; + } + const value = Number(encoded); + if ( + !Number.isSafeInteger(value) || + value <= 0 || + value > MAX_STARTUP_TIMEOUT_MS + ) { + throw invalid( + "MOLTZAP_STARTUP_TIMEOUT_MS must be a positive integer no greater than 24 hours", + ); + } + return value; +} + +function cohortSize(environment: ControllerEnvironment): number { + const encoded = environment.MOLTZAP_COHORT_SIZE; + if (encoded === undefined) { + return DEFAULT_COHORT_SIZE; + } + const value = Number(encoded); + if (!Number.isSafeInteger(value) || value <= 0 || value >= MAX_COHORT_SIZE) { + throw invalid( + `MOLTZAP_COHORT_SIZE must be a positive integer below ${String(MAX_COHORT_SIZE)}`, + ); + } + return value; +} + +function rosterPlacement( + environment: ControllerEnvironment, +): KubernetesPodPlacement | undefined { + const encoded = environment.MOLTZAP_ROSTER_PLACEMENT; + if (encoded === undefined) { + return undefined; + } + const decoded = decodePlacement(encoded, { onExcessProperty: "error" }); + return Either.match(decoded, { + onLeft: () => { + throw invalid( + "MOLTZAP_ROSTER_PLACEMENT must contain one closed placement object", + ); + }, + onRight: (placement) => { + if ( + Object.keys(placement.nodeSelector).length === 0 || + placement.tolerations.length === 0 + ) { + throw invalid( + "MOLTZAP_ROSTER_PLACEMENT must select and tolerate the roster pool", + ); + } + return Object.freeze({ + nodeSelector: Object.freeze({ ...placement.nodeSelector }), + tolerations: Object.freeze( + placement.tolerations.map((toleration) => + Object.freeze({ ...toleration }), + ), + ), + }); + }, + }); +} + +function runtimeCredentials( + environment: ControllerEnvironment, +): ControllerConfiguration["runtimeCredentials"] { + const encoded = environment.MOLTZAP_RUNTIME_CREDENTIALS; + if (encoded === undefined) { + return Object.freeze({}); + } + const decoded = decodeRuntimeCredentials(encoded, { + onExcessProperty: "error", + }); + return Either.match(decoded, { + onLeft: () => { + throw invalid( + "MOLTZAP_RUNTIME_CREDENTIALS must contain only nonempty supported provider credentials", + ); + }, + onRight: (credentials) => Object.freeze({ ...credentials }), + }); +} + +/** + * Decode the one closed environment contract used by a controller Job. + * @param environment Process environment or a deterministic test substitute. + * @returns Safe, typed controller configuration. + */ +export function controllerConfigurationFromEnvironment( + environment: ControllerEnvironment, +): ControllerConfiguration { + return Object.freeze({ + namespace: kubernetesName(environment, "MOLTZAP_RUN_NAMESPACE"), + queueName: kubernetesName(environment, "MOLTZAP_RUN_QUEUE"), + owner: Object.freeze({ + name: kubernetesName(environment, "MOLTZAP_RUN_OWNER_NAME"), + uid: ownerUid(environment), + }), + supportImage: supportImage(environment), + runtimeCredentials: runtimeCredentials(environment), + rosterPlacement: rosterPlacement(environment), + experimentModule: experimentModulePath(environment), + ledgerDirectory: absolutePath(environment, "MOLTZAP_LEDGER_DIRECTORY"), + ledgerExportDirectory: optionalAbsolutePath( + environment, + "MOLTZAP_LEDGER_EXPORT_DIRECTORY", + ), + routerUrl: routerUrl(environment), + startupTimeoutMs: startupTimeoutMs(environment), + cohortSize: cohortSize(environment), + }); +} diff --git a/packages/simulator/src/cluster/controller/controller.test.ts b/packages/simulator/src/cluster/controller/controller.test.ts new file mode 100644 index 000000000..af8184855 --- /dev/null +++ b/packages/simulator/src/cluster/controller/controller.test.ts @@ -0,0 +1,541 @@ +/* eslint-disable agent-code-guard/no-example-only-tests -- Regression-only boundary tests pin one-shot dispatch, closed module exports, and failure redaction; the cases are lifecycle timelines rather than an input domain. */ + +import { assert, effect as test } from "@effect/vitest"; +import { FileSystem } from "@effect/platform"; +import { NodeContext } from "@effect/platform-node"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Cause, Effect, Exit, Layer, Schema } from "effect"; +import { RunSpec } from "../../definition.js"; +import { + CompletedLedgerReceipt, + IncompleteLedgerReceipt, + ProgramFinished, + ClusterLost, +} from "../../run/execute.js"; +import { + LedgerCompletion, + ledgerDigest, + ledgerRef, +} from "../../ledger/schema.js"; +import { LedgerStorage, LedgerStorageError } from "../../ledger/storage.js"; +import { RouterProvider } from "../../network/router.js"; +import { ClusterError, Cluster } from "../cluster.js"; +import { defineRuntime } from "../../agents/agent.js"; +import { + ControllerConfigurationError, + controllerConfigurationFromEnvironment, + type ControllerEnvironment, +} from "./configuration.js"; +import { + CONTROLLER_STAGE, + ControllerError, + ControllerOperations, + runController, + type ControllerOperationsService, +} from "./main.js"; +import { isEntryModule } from "../entry.js"; +import { + exportCompletedLedger, + LedgerExportOperations, + type ControllerLedgerExportOptions, + type LedgerExportOperationsService, +} from "./ledger-export.js"; +import { + CONTROLLER_SUMMARY_MAX_BYTES, + CONTROLLER_SUMMARY_PREFIX, + decodeControllerRunSummary, + encodeControllerRunSummary, + programFinishedSummary, +} from "./summary.js"; + +const IMAGE_DIGEST = "a".repeat(64); +const EXPECTED_NAMESPACE = "mz-run-1"; +const EXPECTED_STARTUP_TIMEOUT_MS = 120_000; +const EXPECTED_COHORT_SIZE = 2; +const CHOSEN_COHORT_SIZE = 100; +const REJECTED_COHORT_SIZES = ["0", "-1", "2.5", "1000", "many"]; +const EXECUTION_RESULT = "executed"; +const EXPECTED_MODULE_SPECIFIER = "file:///var/run/moltzap/experiment/main.mjs"; +const LEDGER_REFERENCE = Schema.decodeSync(ledgerRef)( + "controller-outcome-ledger", +); +const LEDGER_DIGEST = Schema.decodeSync(ledgerDigest)("b".repeat(64)); +const COMPLETED_RECEIPT = CompletedLedgerReceipt.make({ + ledger: LEDGER_REFERENCE, + completion: LedgerCompletion.make({ + ledgerFormatVersion: 1, + runId: "controller-outcome-run", + recordCount: 0, + artifacts: { manifest: LEDGER_DIGEST, records: LEDGER_DIGEST }, + }), +}); +const VALID_ENVIRONMENT: ControllerEnvironment = Object.freeze({ + MOLTZAP_RUN_NAMESPACE: EXPECTED_NAMESPACE, + MOLTZAP_RUN_QUEUE: "society", + MOLTZAP_RUN_OWNER_NAME: "run-root", + MOLTZAP_RUN_OWNER_UID: "19193f95-73b8-49fb-bbd9-518773ba0331", + MOLTZAP_SUPPORT_IMAGE: `registry.example/moltzap@sha256:${IMAGE_DIGEST}`, + MOLTZAP_EXPERIMENT_MODULE: "/var/run/moltzap/experiment/main.mjs", + MOLTZAP_LEDGER_DIRECTORY: "/var/lib/moltzap/ledger", + MOLTZAP_ROUTER_URL: "https://router.mz-run-1.svc:3000", +}); +const ACTIVE_LEDGER_DIRECTORY = "/var/lib/moltzap/ledger"; +const EXPORT_DIRECTORY = `/var/lib/moltzap-artifacts/${EXPECTED_NAMESPACE}/ledger`; +const GKE_ENVIRONMENT: ControllerEnvironment = Object.freeze({ + ...VALID_ENVIRONMENT, + MOLTZAP_LEDGER_EXPORT_DIRECTORY: EXPORT_DIRECTORY, +}); +const VALID_PLACEMENT = { + nodeSelector: { "moltzap.dev/pool": "agents" }, + tolerations: [ + { + key: "moltzap.dev/agents", + operator: "Equal", + value: "true", + effect: "NoSchedule", + }, + ], +} as const; + +const runtime = defineRuntime({ + name: "controller-entrypoint-test", + configuration: { schema: Schema.Struct({}), value: {} }, +}); + +const runSpec = RunSpec.define({ + id: "acme.controller-entrypoint/v1", + events: [], + agents: { alice: runtime }, + cluster: Layer.mergeAll( + Layer.effect(LedgerStorage, Effect.never), + Layer.effect(RouterProvider, Effect.never), + Layer.effect(Cluster, Effect.never), + ), + execute: () => Effect.succeed("completed"), +}); + +function operations( + imported: unknown, + execution: ReturnType, +): ControllerOperationsService { + return { + importModule: () => Promise.resolve(imported), + executeRunSpec: () => execution, + exportCompletedLedger: () => Effect.void, + }; +} + +function controller( + environment: ControllerEnvironment, + operations: ControllerOperationsService, +) { + return runController(environment).pipe( + Effect.provideService(ControllerOperations, operations), + ); +} + +function ledgerExport( + options: ControllerLedgerExportOptions, + operations: LedgerExportOperationsService, +) { + return exportCompletedLedger(options).pipe( + Effect.provideService(LedgerExportOperations, operations), + ); +} + +test("decodes the closed controller environment without retaining mutable input", () => + Effect.sync(() => { + const environment = { ...VALID_ENVIRONMENT }; + const configuration = controllerConfigurationFromEnvironment(environment); + environment.MOLTZAP_RUN_NAMESPACE = "changed"; + + assert.strictEqual(configuration.namespace, EXPECTED_NAMESPACE); + assert.strictEqual( + configuration.startupTimeoutMs, + EXPECTED_STARTUP_TIMEOUT_MS, + ); + assert.strictEqual(configuration.cohortSize, EXPECTED_COHORT_SIZE); + assert.isUndefined(configuration.rosterPlacement); + assert.isUndefined(configuration.ledgerExportDirectory); + assert.deepStrictEqual(configuration.runtimeCredentials, {}); + assert.isTrue(Object.isFrozen(configuration)); + assert.isTrue(Object.isFrozen(configuration.owner)); + })); + +test("reads a run-chosen cohort size and refuses one no roster could have", () => + Effect.sync(() => { + const sized = controllerConfigurationFromEnvironment({ + ...VALID_ENVIRONMENT, + MOLTZAP_COHORT_SIZE: String(CHOSEN_COHORT_SIZE), + }); + + assert.strictEqual(sized.cohortSize, CHOSEN_COHORT_SIZE); + + for (const encoded of REJECTED_COHORT_SIZES) { + assert.throws(() => + controllerConfigurationFromEnvironment({ + ...VALID_ENVIRONMENT, + MOLTZAP_COHORT_SIZE: encoded, + }), + ); + } + })); + +test("decodes only supported transient provider credentials", () => + Effect.sync(() => { + const configuration = controllerConfigurationFromEnvironment({ + ...VALID_ENVIRONMENT, + MOLTZAP_RUNTIME_CREDENTIALS: JSON.stringify({ + OPENAI_API_KEY: "credential-value", + }), + }); + assert.deepStrictEqual(configuration.runtimeCredentials, { + OPENAI_API_KEY: "credential-value", + }); + assert.throws( + () => + controllerConfigurationFromEnvironment({ + ...VALID_ENVIRONMENT, + MOLTZAP_RUNTIME_CREDENTIALS: "{invalid", + }), + ControllerConfigurationError, + ); + })); + +test("decodes the optional retained ledger export root", () => + Effect.sync(() => { + const configuration = + controllerConfigurationFromEnvironment(GKE_ENVIRONMENT); + assert.strictEqual(configuration.ledgerExportDirectory, EXPORT_DIRECTORY); + assert.throws( + () => + controllerConfigurationFromEnvironment({ + ...VALID_ENVIRONMENT, + MOLTZAP_LEDGER_EXPORT_DIRECTORY: "relative/export", + }), + ControllerConfigurationError, + ); + })); + +test("decodes one closed roster placement and rejects partial configuration", () => + Effect.sync(() => { + const configuration = controllerConfigurationFromEnvironment({ + ...VALID_ENVIRONMENT, + MOLTZAP_ROSTER_PLACEMENT: JSON.stringify(VALID_PLACEMENT), + }); + assert.deepStrictEqual(configuration.rosterPlacement, VALID_PLACEMENT); + + for (const placement of [ + { nodeSelector: VALID_PLACEMENT.nodeSelector }, + { nodeSelector: {}, tolerations: VALID_PLACEMENT.tolerations }, + { + ...VALID_PLACEMENT, + tolerations: [ + { ...VALID_PLACEMENT.tolerations[0], effect: "PreferNoSchedule" }, + ], + }, + ]) { + assert.throws( + () => + controllerConfigurationFromEnvironment({ + ...VALID_ENVIRONMENT, + MOLTZAP_ROSTER_PLACEMENT: JSON.stringify(placement), + }), + ControllerConfigurationError, + ); + } + })); + +test("rejects configuration without repeating the supplied value", () => + Effect.sync(() => { + const sensitiveInvalidValue = "not-a-digest-secret-value"; + let observed: unknown; + try { + controllerConfigurationFromEnvironment({ + ...VALID_ENVIRONMENT, + MOLTZAP_SUPPORT_IMAGE: sensitiveInvalidValue, + }); + } catch (cause: unknown) { + observed = cause; + } + assert.instanceOf(observed, ControllerConfigurationError); + assert.notInclude(observed.message, sensitiveInvalidValue); + })); + +test("recognizes a symlinked argv path as the loaded controller module", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "moltzap-controller-entrypoint-", + }); + const canonicalModule = join(root, "controller-main.js"); + const linkedModule = join(root, "image-main.js"); + yield* fileSystem.writeFileString(canonicalModule, ""); + yield* fileSystem.symlink(canonicalModule, linkedModule); + const moduleUrl = pathToFileURL(canonicalModule).href; + + assert.notStrictEqual(pathToFileURL(linkedModule).href, moduleUrl); + assert.isTrue(isEntryModule(moduleUrl, linkedModule)); + }), + ).pipe(Effect.provide(NodeContext.layer))); + +test("imports and executes the single named runSpec exactly once", () => + Effect.gen(function* () { + let importedSpecifier = ""; + let executions = 0; + const execution = Effect.sync(() => { + executions += 1; + return new ProgramFinished({ + exit: Exit.succeed(EXECUTION_RESULT), + receipt: COMPLETED_RECEIPT, + }); + }); + const result = yield* controller(VALID_ENVIRONMENT, { + importModule: (specifier) => { + importedSpecifier = specifier; + return Promise.resolve({ runSpec }); + }, + executeRunSpec: (loaded) => + Effect.sync(() => { + assert.isTrue(Object.is(loaded, runSpec)); + }).pipe(Effect.zipRight(execution)), + exportCompletedLedger: () => Effect.void, + }); + + assert.deepStrictEqual(result, programFinishedSummary(COMPLETED_RECEIPT)); + assert.strictEqual(executions, 1); + assert.strictEqual(importedSpecifier, EXPECTED_MODULE_SPECIFIER); + })); + +test("exports completed ledger bytes with the completion marker last", () => + Effect.gen(function* () { + const calls: string[] = []; + const written = new Map(); + const source = new Map( + ["manifest.json", "records.ndjson", "completion.json"].map((artifact) => { + const path = join(ACTIVE_LEDGER_DIRECTORY, LEDGER_REFERENCE, artifact); + return [path, new TextEncoder().encode(artifact)] as const; + }), + ); + + yield* ledgerExport( + { + ledgerDirectory: ACTIVE_LEDGER_DIRECTORY, + exportDirectory: EXPORT_DIRECTORY, + receipt: COMPLETED_RECEIPT, + }, + { + makeDirectory: (path) => + Effect.sync(() => { + calls.push(`mkdir:${path}`); + }), + readFile: (path) => + Effect.sync(() => { + calls.push(`read:${path}`); + const content = source.get(path); + assert.isDefined(content); + return content; + }), + writeFile: (path, content) => + Effect.sync(() => { + calls.push(`write:${path}`); + written.set(path, content); + }), + }, + ); + + const retained = join(EXPORT_DIRECTORY, LEDGER_REFERENCE); + assert.deepStrictEqual(calls, [ + `mkdir:${retained}`, + `read:${join(ACTIVE_LEDGER_DIRECTORY, LEDGER_REFERENCE, "manifest.json")}`, + `write:${join(retained, "manifest.json")}`, + `read:${join(ACTIVE_LEDGER_DIRECTORY, LEDGER_REFERENCE, "records.ndjson")}`, + `write:${join(retained, "records.ndjson")}`, + `read:${join(ACTIVE_LEDGER_DIRECTORY, LEDGER_REFERENCE, "completion.json")}`, + `write:${join(retained, "completion.json")}`, + ]); + assert.deepStrictEqual( + written.get(join(retained, "completion.json")), + new TextEncoder().encode("completion.json"), + ); + })); + +test("retains a completed receipt before returning the controller summary", () => + Effect.gen(function* () { + const calls: string[] = []; + let exported: ControllerLedgerExportOptions | undefined; + const result = yield* controller(GKE_ENVIRONMENT, { + importModule: () => Promise.resolve({ runSpec }), + executeRunSpec: () => + Effect.sync(() => { + calls.push("execute"); + return new ProgramFinished({ + exit: Exit.succeed(EXECUTION_RESULT), + receipt: COMPLETED_RECEIPT, + }); + }), + exportCompletedLedger: (input) => + Effect.sync(() => { + calls.push("export"); + exported = input; + }), + }); + + assert.deepStrictEqual(calls, ["execute", "export"]); + assert.deepStrictEqual(exported, { + ledgerDirectory: VALID_ENVIRONMENT.MOLTZAP_LEDGER_DIRECTORY, + exportDirectory: EXPORT_DIRECTORY, + receipt: COMPLETED_RECEIPT, + }); + assert.deepStrictEqual(result, programFinishedSummary(COMPLETED_RECEIPT)); + })); + +test("reports a retained-artifact export failure before controller exit", () => + Effect.gen(function* () { + const exportSecret = "gcs-export-secret-detail"; + const observed = yield* controller(GKE_ENVIRONMENT, { + importModule: () => Promise.resolve({ runSpec }), + executeRunSpec: () => + Effect.succeed( + new ProgramFinished({ + exit: Exit.succeed(EXECUTION_RESULT), + receipt: COMPLETED_RECEIPT, + }), + ), + exportCompletedLedger: () => Effect.fail(exportSecret), + }).pipe(Effect.flip); + + assert.instanceOf(observed, ControllerError); + assert.strictEqual(observed.stage, CONTROLLER_STAGE.execution); + assert.deepStrictEqual(observed.summary, { + _tag: "ClusterLost", + receipt: COMPLETED_RECEIPT, + }); + assert.notInclude(observed.message, exportSecret); + })); + +test("rejects any additional module export before execution", () => + Effect.gen(function* () { + let executions = 0; + const execution = Effect.sync(() => { + executions += 1; + }); + const failure = yield* controller( + VALID_ENVIRONMENT, + operations({ runSpec, default: runSpec }, execution), + ).pipe(Effect.flip); + + assert.instanceOf(failure, ControllerError); + assert.strictEqual(failure.stage, CONTROLLER_STAGE.moduleLoad); + assert.strictEqual(executions, 0); + })); + +test("sanitizes module and execution failures", () => + Effect.gen(function* () { + const moduleSecret = "module-secret-detail"; + const moduleFailure = yield* controller(VALID_ENVIRONMENT, { + importModule: () => Promise.reject(new Error(moduleSecret)), + executeRunSpec: () => Effect.void, + exportCompletedLedger: () => Effect.void, + }).pipe(Effect.flip); + assert.strictEqual(moduleFailure.stage, CONTROLLER_STAGE.moduleLoad); + assert.notInclude(moduleFailure.message, moduleSecret); + + const executionSecret = "execution-secret-detail"; + const executionFailure = yield* controller( + VALID_ENVIRONMENT, + operations({ runSpec }, Effect.fail(executionSecret)), + ).pipe(Effect.flip); + assert.strictEqual(executionFailure.stage, CONTROLLER_STAGE.execution); + assert.notInclude(executionFailure.message, executionSecret); + })); + +test("treats a ClusterLost outcome as controller failure", () => + Effect.gen(function* () { + const clusterSecret = "ledger-mount-secret-detail"; + const outcome = new ClusterLost>>({ + cause: Cause.fail( + new ClusterError({ + detail: clusterSecret, + }), + ), + receipt: IncompleteLedgerReceipt.make({ ledger: LEDGER_REFERENCE }), + }); + const observed = yield* controller( + VALID_ENVIRONMENT, + operations({ runSpec }, Effect.succeed(outcome)), + ).pipe(Effect.flip); + + assert.instanceOf(observed, ControllerError); + assert.strictEqual(observed.stage, CONTROLLER_STAGE.execution); + assert.deepStrictEqual(observed.summary, { + _tag: "ClusterLost", + receipt: outcome.receipt, + }); + assert.notInclude(observed.message, clusterSecret); + })); + +test("keeps ProgramFinished successful when the customer Exit failed", () => + Effect.gen(function* () { + const customerFailure = "customer-program-failure"; + const outcome = new ProgramFinished({ + exit: Exit.fail(customerFailure), + receipt: COMPLETED_RECEIPT, + }); + const observed = yield* controller( + VALID_ENVIRONMENT, + operations({ runSpec }, Effect.succeed(outcome)), + ); + + assert.deepStrictEqual(observed, programFinishedSummary(COMPLETED_RECEIPT)); + assert.isTrue(Exit.isFailure(outcome.exit)); + assert.notInclude(JSON.stringify(observed), customerFailure); + })); + +test("reports ledger allocation failure without inventing a receipt", () => + Effect.gen(function* () { + const observed = yield* controller( + VALID_ENVIRONMENT, + operations( + { runSpec }, + Effect.fail( + LedgerStorageError.make({ + operation: "allocate", + detail: "allocation-secret-detail", + }), + ), + ), + ).pipe(Effect.flip); + + assert.instanceOf(observed, ControllerError); + assert.deepStrictEqual(observed.summary, { + _tag: "LedgerAllocationFailed", + }); + assert.notInclude(observed.message, "allocation-secret-detail"); + })); + +test("round-trips only the final bounded closed result marker", () => + Effect.sync(() => { + const summary = programFinishedSummary(COMPLETED_RECEIPT); + const encoded = encodeControllerRunSummary(summary); + assert.isDefined(encoded); + + assert.deepStrictEqual( + decodeControllerRunSummary(`forged output\n${encoded}\n`), + summary, + ); + assert.isUndefined( + decodeControllerRunSummary( + `${CONTROLLER_SUMMARY_PREFIX}{"_tag":"LedgerAllocationFailed","extra":true}`, + ), + ); + assert.isUndefined( + decodeControllerRunSummary( + `${CONTROLLER_SUMMARY_PREFIX}${"x".repeat(CONTROLLER_SUMMARY_MAX_BYTES)}`, + ), + ); + })); + +/* eslint-enable agent-code-guard/no-example-only-tests -- Restore generative-test defaults after the lifecycle regressions. */ diff --git a/packages/simulator/src/cluster/controller/ledger-export.ts b/packages/simulator/src/cluster/controller/ledger-export.ts new file mode 100644 index 000000000..8091d1ed9 --- /dev/null +++ b/packages/simulator/src/cluster/controller/ledger-export.ts @@ -0,0 +1,100 @@ +/** @file Completion-gated export of controller-local ledger artifacts. */ +// safer-arch-ignore no-cross-domain-sibling-import: Exports ledger artifacts from the controller's cluster-provided filesystem. + +import { join } from "node:path"; +import { FileSystem } from "@effect/platform"; +import { Context, Data, Effect, Layer } from "effect"; +import type { CompletedLedgerReceipt } from "../../run/execute.js"; +import { + ledgerArtifactFiles, + ledgerArtifacts, + type LedgerArtifactFile, +} from "../../ledger/storage.js"; + +type ArtifactName = LedgerArtifactFile; + +/** Active POSIX ledger and retained export root for one completed receipt. */ +export interface ControllerLedgerExportOptions { + readonly ledgerDirectory: string; + readonly exportDirectory: string; + readonly receipt: CompletedLedgerReceipt; +} + +/** Byte operations the export uses, replaceable by deterministic tests. */ +export interface LedgerExportOperationsService { + readonly makeDirectory: (path: string) => Effect.Effect; + readonly readFile: (path: string) => Effect.Effect; + readonly writeFile: ( + path: string, + content: Uint8Array, + ) => Effect.Effect; +} + +/** Byte operations the controller export reads from its environment. */ +export class LedgerExportOperations extends Context.Tag( + "@moltzap/simulator/LedgerExportOperations", +)() {} + +/** Sanitized failure while retaining one completed ledger outside the Pod. */ +export class ControllerLedgerExportError extends Data.TaggedError( + "ControllerLedgerExportError", +)<{ + readonly operation: "directory" | "read" | "write"; + readonly artifact?: ArtifactName; +}> { + override get message(): string { + return this.artifact === undefined + ? "Simulator controller could not prepare retained ledger storage" + : `Simulator controller could not ${this.operation} ${this.artifact}`; + } +} + +function exportFailure( + operation: ControllerLedgerExportError["operation"], + artifact?: ArtifactName, +): ControllerLedgerExportError { + return new ControllerLedgerExportError({ operation, artifact }); +} + +/** + * Copy one completed ledger to retained storage, publishing completion last. + * @param options Active and retained roots plus the completed receipt. + * @returns Completion after all three retained objects have closed. + */ +export function exportCompletedLedger( + options: ControllerLedgerExportOptions, +): Effect.Effect { + const source = join(options.ledgerDirectory, options.receipt.ledger); + const destination = join(options.exportDirectory, options.receipt.ledger); + return Effect.gen(function* () { + const operations = yield* LedgerExportOperations; + yield* operations + .makeDirectory(destination) + .pipe(Effect.mapError(() => exportFailure("directory"))); + for (const artifact of ledgerArtifacts) { + const file = ledgerArtifactFiles[artifact]; + const content = yield* operations + .readFile(join(source, file)) + .pipe(Effect.mapError(() => exportFailure("read", file))); + yield* operations + .writeFile(join(destination, file), content) + .pipe(Effect.mapError(() => exportFailure("write", file))); + } + }).pipe(Effect.withSpan("controller.exportCompletedLedger")); +} + +/** Retained-ledger bytes written through the Effect platform filesystem. */ +export const filesystemLedgerExportOperations: Layer.Layer< + LedgerExportOperations, + never, + FileSystem.FileSystem +> = Layer.effect( + LedgerExportOperations, + Effect.map(FileSystem.FileSystem, (fileSystem) => ({ + makeDirectory: (path: string) => + fileSystem.makeDirectory(path, { recursive: true }), + readFile: (path: string) => fileSystem.readFile(path), + writeFile: (path: string, content: Uint8Array) => + fileSystem.writeFile(path, content), + })), +); diff --git a/packages/simulator/src/cluster/controller/main.ts b/packages/simulator/src/cluster/controller/main.ts new file mode 100644 index 000000000..fb39011e0 --- /dev/null +++ b/packages/simulator/src/cluster/controller/main.ts @@ -0,0 +1,342 @@ +/** @file Executable boundary for exactly one mounted simulator RunSpec. */ + +import { pathToFileURL } from "node:url"; +import { NodeContext, NodeRuntime } from "@effect/platform-node"; +import { Cause, Context, Data, Effect, Layer } from "effect"; +import { isRunSpec, Run, type RunSpec } from "../../definition.js"; +import { isEntryModule } from "../entry.js"; +import { + ClusterLost, + CompletedLedgerReceipt, + ProgramFinished, +} from "../../index.js"; +import { LedgerStorageError } from "../../ledger.js"; +import { + controllerConfigurationFromEnvironment, + type ControllerEnvironment, +} from "./configuration.js"; +import { + exportCompletedLedger, + filesystemLedgerExportOperations, + type ControllerLedgerExportOptions, +} from "./ledger-export.js"; +import { + encodeControllerRunSummary, + ledgerAllocationFailedSummary, + programFinishedSummary, + clusterLostSummary, + type ControllerFailedRunSummary, + type ControllerRunSummary, +} from "./summary.js"; + +/** Stable stage labels used by sanitized controller failures. */ +export const CONTROLLER_STAGE = Object.freeze({ + configuration: "configuration", + moduleLoad: "module-load", + execution: "execution", +} as const); + +type ControllerStage = (typeof CONTROLLER_STAGE)[keyof typeof CONTROLLER_STAGE]; +type ExperimentModuleImporter = (specifier: string) => PromiseLike; +type RunSpecExecutor = (runSpec: RunSpec) => Effect.Effect; +type CompletedLedgerExporter = ( + options: ControllerLedgerExportOptions, +) => Effect.Effect; + +/** Safe controller failure reported to the Job without customer error values. */ +export class ControllerError extends Data.TaggedError("ControllerError")<{ + readonly stage: ControllerStage; + readonly detail: string; + readonly summary?: ControllerFailedRunSummary; +}> { + override get message(): string { + return `Simulator controller ${this.stage} failed: ${this.detail}`; + } +} + +/** Process-boundary operations, replaceable by deterministic tests. */ +export interface ControllerOperationsService { + readonly importModule: ExperimentModuleImporter; + readonly executeRunSpec: RunSpecExecutor; + readonly exportCompletedLedger: CompletedLedgerExporter; +} + +/** Process-boundary operations the controller reads from its environment. */ +export class ControllerOperations extends Context.Tag( + "@moltzap/simulator/ControllerOperations", +)() {} + +function failure( + stage: ControllerStage, + detail: string, + summary?: ControllerFailedRunSummary, +): ControllerError { + return new ControllerError({ stage, detail, summary }); +} + +function executionFailure(): ControllerError { + return failure( + CONTROLLER_STAGE.execution, + "the experiment run did not complete", + ); +} + +function executionFailureWithSummary( + summary: ControllerFailedRunSummary, +): ControllerError { + return failure( + CONTROLLER_STAGE.execution, + "the experiment run did not complete", + summary, + ); +} + +function allocationFailureSummary( + cause: Cause.Cause, +): ControllerFailedRunSummary | undefined { + const failures = Array.from(Cause.failures(cause)); + return failures.length === 1 && + failures[0] instanceof LedgerStorageError && + failures[0].operation === "allocate" + ? ledgerAllocationFailedSummary() + : undefined; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function decodeExperimentModule( + value: unknown, +): Effect.Effect { + if (!isRecord(value)) { + return Effect.fail( + failure( + CONTROLLER_STAGE.moduleLoad, + "the experiment module has no named exports", + ), + ); + } + const exports = Object.keys(value); + if (exports.length !== 1 || exports[0] !== "runSpec") { + return Effect.fail( + failure( + CONTROLLER_STAGE.moduleLoad, + "the experiment module must export only one named runSpec", + ), + ); + } + if (!isRunSpec(value.runSpec)) { + return Effect.fail( + failure( + CONTROLLER_STAGE.moduleLoad, + "the experiment module's runSpec was not produced by RunSpec.define", + ), + ); + } + return Effect.succeed(value.runSpec); +} + +function defaultImporter(specifier: string): PromiseLike { + return import(specifier); +} + +function defaultExecutor(runSpec: RunSpec): Effect.Effect { + return Effect.suspend(() => Run.execute(runSpec)); +} + +/** The process boundaries used by every controller that is not a test. */ +export const liveControllerOperations: Layer.Layer = + Layer.succeed(ControllerOperations, { + importModule: defaultImporter, + executeRunSpec: defaultExecutor, + exportCompletedLedger: (options: ControllerLedgerExportOptions) => + exportCompletedLedger(options).pipe( + Effect.provide( + filesystemLedgerExportOperations.pipe( + Layer.provide(NodeContext.layer), + ), + ), + ), + }); + +function loadExperiment( + path: string, + importer: ExperimentModuleImporter, +): Effect.Effect { + return Effect.tryPromise({ + try: () => importer(pathToFileURL(path).href), + catch: () => + failure( + CONTROLLER_STAGE.moduleLoad, + "the experiment module could not be loaded", + ), + }).pipe(Effect.flatMap(decodeExperimentModule)); +} + +function readConfiguration( + environment: ControllerEnvironment, +): Effect.Effect< + ReturnType, + ControllerError +> { + return Effect.try({ + try: () => controllerConfigurationFromEnvironment(environment), + catch: () => + failure( + CONTROLLER_STAGE.configuration, + "the controller environment is invalid", + ), + }); +} + +function acceptRunOutcome( + outcome: unknown, +): Effect.Effect { + if (outcome instanceof ProgramFinished) { + return Effect.succeed(programFinishedSummary(outcome.receipt)); + } + if (outcome instanceof ClusterLost) { + return Effect.fail( + executionFailureWithSummary(clusterLostSummary(outcome.receipt)), + ); + } + return Effect.fail(executionFailure()); +} + +function completedReceipt( + outcome: unknown, +): CompletedLedgerReceipt | undefined { + if (outcome instanceof ProgramFinished) { + return outcome.receipt; + } + if ( + outcome instanceof ClusterLost && + outcome.receipt instanceof CompletedLedgerReceipt + ) { + return outcome.receipt; + } + return undefined; +} + +function retainCompletedLedger( + configuration: ReturnType, + outcome: unknown, + exporter: CompletedLedgerExporter, +): Effect.Effect { + const receipt = completedReceipt(outcome); + if ( + receipt === undefined || + configuration.ledgerExportDirectory === undefined + ) { + return Effect.succeed(outcome); + } + return exporter({ + ledgerDirectory: configuration.ledgerDirectory, + exportDirectory: configuration.ledgerExportDirectory, + receipt, + }).pipe( + Effect.mapError(() => + executionFailureWithSummary(clusterLostSummary(receipt)), + ), + Effect.as(outcome), + ); +} + +/** + * Load and invoke one exact mounted RunSpec with no replay or fallback path. + * @param environment Optional injected environment used by deterministic tests. + * @returns The completed Run.execute value or a sanitized controller failure. + */ +export function runController( + environment?: ControllerEnvironment, +): Effect.Effect { + const resolvedEnvironment = environment ?? processControllerEnvironment(); + return Effect.gen(function* () { + const operations = yield* ControllerOperations; + const configuration = yield* readConfiguration(resolvedEnvironment); + const runSpec = yield* loadExperiment( + configuration.experimentModule, + operations.importModule, + ); + const outcome = yield* operations.executeRunSpec(runSpec).pipe( + Effect.sandbox, + Effect.mapError((cause) => { + const summary = allocationFailureSummary(cause); + return summary === undefined + ? executionFailure() + : executionFailureWithSummary(summary); + }), + ); + const retained = yield* retainCompletedLedger( + configuration, + outcome, + operations.exportCompletedLedger, + ); + return yield* acceptRunOutcome(retained); + }).pipe(Effect.withSpan("runController")); +} + +function processControllerEnvironment(): ControllerEnvironment { + // eslint-disable-next-line agent-code-guard/no-process-env-at-runtime -- The executable controller captures its environment once before entering the typed decoder. + return process.env; +} + +function isDirectInvocation(): boolean { + // eslint-disable-next-line agent-code-guard/prefer-effect-platform -- Direct-entry detection has no Effect Platform equivalent. + const invoked = process.argv[1]; + return isEntryModule(import.meta.url, invoked); +} + +function resultHandoffFailure(): ControllerError { + return failure( + CONTROLLER_STAGE.execution, + "the controller result could not be handed off", + ); +} + +function writeControllerSummary( + summary: ControllerRunSummary, +): Effect.Effect { + const encoded = encodeControllerRunSummary(summary); + if (encoded === undefined) { + return Effect.fail(resultHandoffFailure()); + } + return Effect.try({ + try: () => process.stdout.write(`${encoded}\n`), + catch: resultHandoffFailure, + }).pipe(Effect.asVoid); +} + +function writeControllerDiagnostic(message: string): Effect.Effect { + return Effect.sync(() => { + process.stderr.write(`${message}\n`); + }); +} + +function reportControllerFailure( + controllerFailure: ControllerError, +): Effect.Effect { + const summary = controllerFailure.summary; + const writeSummary = + summary === undefined + ? Effect.void + : writeControllerSummary(summary).pipe( + Effect.catchAll((summaryFailure) => + writeControllerDiagnostic(summaryFailure.message), + ), + ); + return writeSummary.pipe( + Effect.zipRight(writeControllerDiagnostic(controllerFailure.message)), + Effect.zipRight(Effect.fail(controllerFailure)), + ); +} + +if (isDirectInvocation()) { + runController().pipe( + Effect.flatMap(writeControllerSummary), + Effect.catchAll(reportControllerFailure), + Effect.provide(liveControllerOperations), + NodeRuntime.runMain, + ); +} diff --git a/packages/simulator/src/cluster/controller/services.ts b/packages/simulator/src/cluster/controller/services.ts new file mode 100644 index 000000000..818c13396 --- /dev/null +++ b/packages/simulator/src/cluster/controller/services.ts @@ -0,0 +1,90 @@ +/** @file Private Layer assembled inside one run controller process. */ +// safer-arch-ignore no-cross-domain-sibling-import: Assembles the controller's Layer from ledger, network, and cluster implementations. + +import { NodeContext, NodeHttpClient } from "@effect/platform-node"; +import { Duration, Layer } from "effect"; +import { filesystemLedgerStorageLayer } from "../../ledger/filesystem.js"; +import { serverProcessRouterProviderLayer } from "../../network/server/process.js"; +import { + makeInClusterKubernetesSocietyApi, + type KubernetesSocietyApi, +} from "../kubernetes/calls.js"; +import { kubernetesClusterLayer } from "../cohort.js"; +import { + controllerConfigurationFromEnvironment, + type ControllerConfiguration, + type ControllerEnvironment, +} from "./configuration.js"; + +function processControllerEnvironment(): ControllerEnvironment { + // eslint-disable-next-line agent-code-guard/no-process-env-at-runtime -- This private deep import is the experiment module's executable configuration boundary. + return process.env; +} + +/** + * Compose the complete private cluster for one in-cluster execution. + * @param configuration Validated controller and run resource configuration. + * @param api Narrow in-cluster operations, replaceable only by unit tests. + * @returns One Layer suitable for the mounted experiment's RunSpec. + */ +function makeControllerServices( + configuration: ControllerConfiguration, + api?: KubernetesSocietyApi, +) { + const societyApi = + api ?? makeInClusterKubernetesSocietyApi(configuration.namespace); + const startupTimeout = Duration.millis(configuration.startupTimeoutMs); + const host = Layer.merge(NodeContext.layer, NodeHttpClient.layerUndici); + const run = Layer.mergeAll( + filesystemLedgerStorageLayer(configuration.ledgerDirectory), + serverProcessRouterProviderLayer({ + advertisedServerUrl: configuration.routerUrl, + startupTimeout, + }), + kubernetesClusterLayer({ + api: societyApi, + namespace: configuration.namespace, + queueName: configuration.queueName, + owner: configuration.owner, + supportImage: configuration.supportImage, + runtimeCredentials: configuration.runtimeCredentials, + rosterPlacement: configuration.rosterPlacement, + startupTimeout, + }), + ); + return run.pipe(Layer.provideMerge(host)); +} + +/** + * Build the Layer at module-evaluation time for a mounted experiment RunSpec. + * + * This is deliberately a private deep import rather than a package export: the + * experiment chooses its roster and Effect while the controller image owns all + * Kubernetes and router mechanics. + * @param environment Process environment or a deterministic test substitute. + * @returns One controller-owned cluster Layer. + */ +export function controllerServicesFromEnvironment( + environment?: ControllerEnvironment, +) { + const resolvedEnvironment = environment ?? processControllerEnvironment(); + return makeControllerServices( + controllerConfigurationFromEnvironment(resolvedEnvironment), + ); +} + +/** + * Read the cohort size the run was submitted with. + * + * An experiment whose roster is sized by its run reads it here rather than + * from the process, so the value passes the same validation as every other + * controller input instead of arriving unchecked. + * @param environment Process environment or a deterministic test substitute. + * @returns Agents the experiment should build its roster from. + */ +export function cohortSizeFromEnvironment( + environment?: ControllerEnvironment, +): number { + const resolvedEnvironment = environment ?? processControllerEnvironment(); + return controllerConfigurationFromEnvironment(resolvedEnvironment).cohortSize; +} diff --git a/packages/simulator/src/cluster/controller/summary.ts b/packages/simulator/src/cluster/controller/summary.ts new file mode 100644 index 000000000..10e498f7c --- /dev/null +++ b/packages/simulator/src/cluster/controller/summary.ts @@ -0,0 +1,135 @@ +/** @file Closed, bounded result projection emitted by one controller process. */ +// safer-arch-ignore no-cross-domain-sibling-import: Projects run outcomes, which name ledger receipts, into the controller's bounded result. + +import { Either, Schema } from "effect"; +import { + CompletedLedgerReceipt, + LedgerReceipt, + type IncompleteLedgerReceipt, +} from "../../run/execute.js"; + +/** Prefix distinguishing the controller-owned final line from application logs. */ +export const CONTROLLER_SUMMARY_PREFIX = "moltzap.controller-result/v1 "; +/** Upper bound for the complete UTF-8 result line read from controller logs. */ +export const CONTROLLER_SUMMARY_MAX_BYTES = 4_096; + +const programFinishedSummarySchema = Schema.Struct({ + _tag: Schema.Literal("ProgramFinished"), + receipt: CompletedLedgerReceipt, +}); + +const clusterLostSummarySchema = Schema.Struct({ + _tag: Schema.Literal("ClusterLost"), + receipt: LedgerReceipt, +}); + +const ledgerAllocationFailedSummarySchema = Schema.Struct({ + _tag: Schema.Literal("LedgerAllocationFailed"), +}); + +/** Complete result information permitted to leave the controller process. */ +const controllerRunSummarySchema = Schema.Union( + programFinishedSummarySchema, + clusterLostSummarySchema, + ledgerAllocationFailedSummarySchema, +); +/** Decoded controller result projection. */ +export type ControllerRunSummary = typeof controllerRunSummarySchema.Type; + +/** Successful customer-program projection, deliberately excluding its Exit. */ +export type ControllerProgramFinishedSummary = Extract< + ControllerRunSummary, + { readonly _tag: "ProgramFinished" } +>; +/** Failed controller projection that carries no customer failure value. */ +export type ControllerFailedRunSummary = Exclude< + ControllerRunSummary, + ControllerProgramFinishedSummary +>; + +const parseSummary = Schema.decodeUnknownEither( + Schema.parseJson(controllerRunSummarySchema), +); + +function encodedByteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +/** + * Project program completion without serializing the program's value or error. + * @param receipt Complete durable evidence returned by the kernel. + * @returns The closed successful controller summary. + */ +export function programFinishedSummary( + receipt: CompletedLedgerReceipt, +): ControllerProgramFinishedSummary { + return Object.freeze({ _tag: "ProgramFinished", receipt }); +} + +/** + * Project a cluster outcome without serializing its Cause. + * @param receipt Durable evidence retained by the kernel. + * @returns The closed failed controller summary. + */ +export function clusterLostSummary( + receipt: CompletedLedgerReceipt | IncompleteLedgerReceipt, +): ControllerFailedRunSummary { + return Object.freeze({ _tag: "ClusterLost", receipt }); +} + +/** + * Record that ledger allocation failed before the kernel owned a receipt. + * @returns The closed allocation-failure summary. + */ +export function ledgerAllocationFailedSummary(): ControllerFailedRunSummary { + return Object.freeze({ _tag: "LedgerAllocationFailed" }); +} + +/** + * Encode the one controller-owned result line accepted by the host activity. + * @param summary Closed result projection. + * @returns One newline-free, size-bounded log line, or undefined when it exceeds the boundary. + */ +export function encodeControllerRunSummary( + summary: ControllerRunSummary, +): string | undefined { + const payload = Schema.encodeSync( + Schema.parseJson(controllerRunSummarySchema), + )(summary, { onExcessProperty: "error" }); + const line = `${CONTROLLER_SUMMARY_PREFIX}${payload}`; + return encodedByteLength(line) <= CONTROLLER_SUMMARY_MAX_BYTES + ? line + : undefined; +} + +/** + * Decode the final controller-owned result marker from bounded Pod logs. + * @param output Raw bounded controller log tail. + * @returns A valid closed summary, or undefined when the marker is absent or invalid. + */ +export function decodeControllerRunSummary( + output: string, +): ControllerRunSummary | undefined { + const lines = output.split(/\r?\n/u); + let line: string | undefined; + for (let index = lines.length - 1; index >= 0; index -= 1) { + const candidate = lines[index]; + if (candidate?.startsWith(CONTROLLER_SUMMARY_PREFIX) === true) { + line = candidate; + break; + } + } + if ( + line === undefined || + encodedByteLength(line) > CONTROLLER_SUMMARY_MAX_BYTES + ) { + return undefined; + } + const decoded = parseSummary(line.slice(CONTROLLER_SUMMARY_PREFIX.length), { + onExcessProperty: "error", + }); + return Either.match(decoded, { + onLeft: () => undefined, + onRight: (summary) => summary, + }); +} diff --git a/packages/simulator/src/cluster/entry.test.ts b/packages/simulator/src/cluster/entry.test.ts new file mode 100644 index 000000000..987d88434 --- /dev/null +++ b/packages/simulator/src/cluster/entry.test.ts @@ -0,0 +1,82 @@ +/* eslint-disable agent-code-guard/no-example-only-tests -- Entry detection is a fixed set of path shapes, not an input domain; each case pins one way a real invocation reaches a module. */ + +import { assert, effect as test } from "@effect/vitest"; +import { FileSystem } from "@effect/platform"; +import { NodeContext } from "@effect/platform-node"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { Effect } from "effect"; +import { isEntryModule } from "./entry.js"; + +test("treats a module reached through a symlinked path as the entry point", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "moltzap-entry-", + }); + const canonical = join(root, "installed", "main.js"); + yield* fileSystem.makeDirectory(join(root, "installed")); + yield* fileSystem.writeFileString(canonical, ""); + const linkedDirectory = join(root, "dist"); + yield* fileSystem.symlink(join(root, "installed"), linkedDirectory); + const invoked = join(linkedDirectory, "main.js"); + + // The controller image publishes every executable through a symlinked + // directory, so the two spellings never match before canonicalization. + assert.notStrictEqual( + pathToFileURL(invoked).href, + pathToFileURL(canonical).href, + ); + assert.isTrue(isEntryModule(pathToFileURL(canonical).href, invoked)); + }), + ).pipe(Effect.provide(NodeContext.layer))); + +test("rejects a sibling module in the same directory", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "moltzap-entry-", + }); + const loaded = join(root, "loaded.js"); + const sibling = join(root, "sibling.js"); + yield* fileSystem.writeFileString(loaded, ""); + yield* fileSystem.writeFileString(sibling, ""); + + assert.isFalse(isEntryModule(pathToFileURL(loaded).href, sibling)); + }), + ).pipe(Effect.provide(NodeContext.layer))); + +test("reports no entry point when argv carries no module path", () => + Effect.sync(() => { + const moduleUrl = pathToFileURL("/opt/moltzap/dist/cluster/main.js").href; + + assert.isFalse(isEntryModule(moduleUrl)); + assert.isFalse(isEntryModule(moduleUrl, "")); + })); + +test("reports no entry point for a path that does not exist", () => + Effect.sync(() => { + // A deleted or mistyped argv[1] is a plain negative, not a thrown ENOENT. + const moduleUrl = pathToFileURL("/opt/moltzap/dist/cluster/main.js").href; + + assert.isFalse(isEntryModule(moduleUrl, "/nonexistent/moltzap/main.js")); + })); + +test("reports no entry point for a module loaded over a non-file scheme", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const root = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "moltzap-entry-", + }); + const invoked = join(root, "main.js"); + yield* fileSystem.writeFileString(invoked, ""); + + assert.isFalse(isEntryModule("data:text/javascript,0", invoked)); + assert.isFalse(isEntryModule("https://example.test/main.js", invoked)); + }), + ).pipe(Effect.provide(NodeContext.layer))); + +/* eslint-enable agent-code-guard/no-example-only-tests -- Restore the project default after the entry-shape regressions. */ diff --git a/packages/simulator/src/cluster/entry.ts b/packages/simulator/src/cluster/entry.ts new file mode 100644 index 000000000..a45407f63 --- /dev/null +++ b/packages/simulator/src/cluster/entry.ts @@ -0,0 +1,40 @@ +/** @file Whether a module is the process entry point rather than an import. */ + +import { existsSync, realpathSync } from "node:fs"; +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const FILE_URL_SCHEME = "file:"; + +function realPath(path: string): string | undefined { + return existsSync(path) ? realpathSync(path) : undefined; +} + +/** + * Whether a module is the process entry point rather than an ordinary import. + * + * Both sides are canonicalized because they are not the same kind of path: + * Node resolves a module's real path before it becomes `import.meta.url`, while + * `process.argv[1]` is whatever the caller typed. Every executable in this + * package reaches its module through a symlink in the controller image, where + * `/opt/moltzap/dist` points at the installed package directory. Comparing the + * two without canonicalizing makes a directly invoked entry point look like an + * import, so the process exits successfully having done nothing. + * + * `realPath` returns undefined for a path that does not exist, so a missing or + * deleted `argv[1]` is a plain false rather than a thrown ENOENT. + * + * @param moduleUrl URL of the module asking whether it was invoked directly. + * @param invoked Path the process was started with, if it has one. + * @returns Whether both locations name the same real file. + */ +export function isEntryModule(moduleUrl: string, invoked?: string): boolean { + if (invoked === undefined || invoked.length === 0) { + return false; + } + if (!moduleUrl.startsWith(FILE_URL_SCHEME)) { + return false; + } + const entry = realPath(resolve(invoked)); + return entry !== undefined && entry === realPath(fileURLToPath(moduleUrl)); +} diff --git a/packages/simulator/src/cluster/fake.ts b/packages/simulator/src/cluster/fake.ts new file mode 100644 index 000000000..f336f918f --- /dev/null +++ b/packages/simulator/src/cluster/fake.ts @@ -0,0 +1,162 @@ +/** @file Private in-memory cluster used by run tests. */ +// safer-arch-ignore no-cross-domain-sibling-import: The in-memory cluster mirrors the real seam, so it names the same agent and network types. + +import { Effect, type Schema, type Scope } from "effect"; +import { + defineRuntime, + type AgentRuntime, + type AgentRuntimeDefinition, + type AgentRuntimeInput, + type AgentRuntimeLike, + type RunningAgent, +} from "../agents/agent.js"; +import type { + AgentRoster, + AgentRosterAcquisitionError, + RuntimeGatewayOf, +} from "../agents/roster.js"; +import type { ClusterError, Slot, ClusterService, Society } from "./cluster.js"; + +type FakeRuntimeAcquirer = ( + input: AgentRuntimeInput, +) => Effect.Effect, AcquisitionError, Scope.Scope>; + +/** Registered, like every other runtime brand, so module copies agree. */ +const fakeRuntimeTypeId: unique symbol = Symbol.for( + "@moltzap/simulator/FakeRuntime", +); + +interface FakeRuntimeCarrier { + readonly name: string; + readonly [fakeRuntimeTypeId]?: FakeRuntimeAcquirer; +} + +/** Runtime metadata plus test-cluster acquisition behavior. */ +export interface FakeRuntimeDefinition< + Gateway, + AcquisitionError = never, + ConfigurationSchema extends + Schema.Schema.AnyNoContext = Schema.Schema.AnyNoContext, +> extends AgentRuntimeDefinition< + Gateway, + AcquisitionError, + ConfigurationSchema + > { + readonly acquire: FakeRuntimeAcquirer; +} + +/** + * Define a runtime usable only by the private fake cluster. + * @param definition Runtime metadata and its test-only acquisition behavior. + * @returns The nominal runtime registered with the fake cluster. + */ +export function defineFakeRuntime< + Gateway, + AcquisitionError, + ConfigurationSchema extends Schema.Schema.AnyNoContext, +>( + definition: FakeRuntimeDefinition< + Gateway, + AcquisitionError, + ConfigurationSchema + >, +): AgentRuntime { + const runtime = defineRuntime( + { + name: definition.name, + configuration: definition.configuration, + }, + ); + // Non-enumerable, matching every other runtime brand: a structural copy of a + // fake runtime is not the fake runtime. + const branded: AgentRuntime = + Object.freeze( + Object.defineProperty({ ...runtime }, fakeRuntimeTypeId, { + value: definition.acquire, + }), + ); + return branded; +} + +/** + * Acquire one test runtime through the acquirer branded onto it. + * @param runtime Runtime value produced by defineFakeRuntime. + * @param input Run-scoped agent identity and router connection. + * @returns The runtime-specific gateway and termination observation. + */ +function acquireFakeRuntime< + Gateway, + AcquisitionError, + ConfigurationSchema extends Schema.Schema.AnyNoContext, + Name extends string, +>( + runtime: AgentRuntime, + input: AgentRuntimeInput, +): Effect.Effect, AcquisitionError, Scope.Scope> { + const carrier: FakeRuntimeCarrier = runtime; + const acquire = carrier[fakeRuntimeTypeId]; + if (acquire === undefined) { + return Effect.dieMessage( + `runtime "${runtime.name}" has no private fake realization`, + ); + } + return acquire(input); +} + +/** Lifecycle controls for one private fake society session. */ +export interface FakeClusterOptions { + readonly cohortReady?: Effect.Effect; + readonly failure?: Effect.Effect; + readonly onAcquire?: (name: string) => Effect.Effect; + readonly onPrepare?: (names: readonly string[]) => Effect.Effect; + readonly onRelease?: Effect.Effect; +} + +function makeFakeSociety< + Definitions extends Readonly>, +>(options: FakeClusterOptions): Society { + return Object.freeze({ + acquireAgent: >( + input: Slot, + ) => + // eslint-disable-next-line agent-code-guard/require-assertion-rationale -- The roster maps this exact key to the same runtime gateway and acquisition-error parameters used by acquireFakeRuntime. + acquireFakeRuntime(input.runtime, { + agentName: input.agentName, + connection: input.connection, + }).pipe( + Effect.tap(() => options.onAcquire?.(input.name) ?? Effect.void), + ) as Effect.Effect< + RunningAgent>, + AgentRosterAcquisitionError, + Scope.Scope + >, + cohortReady: options.cohortReady ?? Effect.void, + failure: options.failure ?? Effect.never, + }); +} + +/** + * Build one private cluster whose only runtimes come from defineFakeRuntime. + * @param options Test-controlled readiness, failure, and lifecycle hooks. + * @returns A private cluster service for deterministic run tests. + */ +export function makeFakeCluster( + options: FakeClusterOptions = {}, +): ClusterService { + return Object.freeze({ + prepare: < + Id extends string, + Definitions extends Readonly>, + >( + roster: AgentRoster, + ) => { + const names = roster.validatedDefinitions.map(({ name }) => name); + const prepared = options.onPrepare?.(names) ?? Effect.void; + // eslint-disable-next-line agent-code-guard/acquire-release-requires-scope -- Cluster.prepare returns an Effect requiring Scope, so the kernel owns this release. + return Effect.acquireRelease( + prepared.pipe(Effect.as(makeFakeSociety(options))), + () => options.onRelease ?? Effect.void, + ); + }, + }); +} diff --git a/packages/simulator/src/cluster/install.test.ts b/packages/simulator/src/cluster/install.test.ts new file mode 100644 index 000000000..44f8c87a8 --- /dev/null +++ b/packages/simulator/src/cluster/install.test.ts @@ -0,0 +1,196 @@ +/* eslint-disable agent-code-guard/async-keyword, agent-code-guard/no-example-only-tests -- Vitest awaits the Effect the host installation boundary returns, and these regression-only cases pin the exact rollout arithmetic and bounded availability deadline rather than an invariant over generated input. */ + +import { Effect } from "effect"; +import { expect, it } from "vitest"; +import { + KubernetesCallFailed, + type RunWorkerInstallApi, + type RunWorkerObject, + type WorkerAvailability, +} from "./kubernetes/calls.js"; +import { + installRunWorker, + RunWorkerUnavailable, + workerIsAvailable, +} from "./install.js"; + +// What each control-plane object needs to already exist when it is installed. +// A Deployment created before its binding starts a Pod whose service account +// cannot delete a run namespace, and the cluster reports that as a permission +// error on some later run rather than as a failed install. +const PREREQUISITES: Readonly< + Record +> = { + namespace: [], + clusterRole: [], + serviceAccount: ["namespace"], + clusterRoleBinding: ["clusterRole", "serviceAccount"], + deployment: ["namespace", "serviceAccount", "clusterRoleBinding"], +}; +const EVERY_OBJECT = Object.keys(PREREQUISITES); +const WORKLOAD: RunWorkerObject = "deployment"; +const BINDING: RunWorkerObject = "clusterRoleBinding"; +const AVAILABLE: WorkerAvailability = { + generation: 3, + observedGeneration: 3, + replicas: 1, + updatedReplicas: 1, + availableReplicas: 1, +}; + +interface RecordedInstall { + readonly api: RunWorkerInstallApi; + readonly installed: RunWorkerObject[]; + readonly waits: number[]; +} + +interface InstallOptions { + /** Availability readings served in order; the last one repeats forever. */ + readonly availability?: readonly WorkerAvailability[]; + readonly failAt?: RunWorkerObject; +} + +function recordingInstall(options: InstallOptions = {}): RecordedInstall { + const installed: RunWorkerObject[] = []; + const waits: number[] = []; + const readings = options.availability ?? [AVAILABLE]; + let read = 0; + return { + installed, + waits, + api: { + install: (object) => + Effect.suspend(() => { + installed.push(object); + return options.failAt === object + ? Effect.fail(new KubernetesCallFailed(`install ${object}`)) + : Effect.void; + }), + readWorkerAvailability: () => + Effect.suspend(() => { + const reading = readings[Math.min(read, readings.length - 1)]; + read += 1; + return reading === undefined + ? Effect.fail( + new KubernetesCallFailed("read a configured availability"), + ) + : Effect.succeed(reading); + }), + wait: (milliseconds) => + Effect.sync(() => { + waits.push(milliseconds); + }), + }, + }; +} + +it("installs every object exactly once, each after everything it depends on", async () => { + const { api, installed } = recordingInstall(); + + await Effect.runPromise(installRunWorker(api)); + + const byName = (left: string, right: string) => left.localeCompare(right); + expect([...installed].sort(byName)).toEqual([...EVERY_OBJECT].sort(byName)); + for (const [position, object] of installed.entries()) { + for (const prerequisite of PREREQUISITES[object]) { + expect(installed.indexOf(prerequisite)).toBeLessThan(position); + } + } +}); + +it("never installs the workload when its permissions could not be written", async () => { + const { api, installed } = recordingInstall({ failAt: BINDING }); + + const failure = await Effect.runPromise(Effect.flip(installRunWorker(api))); + + expect(failure.message).toBe(`install ${BINDING} failed`); + expect(installed).not.toContain(WORKLOAD); +}); + +it("waits for the installed revision rather than the one it replaced", async () => { + const { api, waits } = recordingInstall({ + availability: [ + // The previous revision is still the only one serving. + { + generation: 4, + observedGeneration: 3, + replicas: 2, + updatedReplicas: 1, + availableReplicas: 1, + }, + // The new revision is observed but has no replica yet. + { + generation: 4, + observedGeneration: 4, + replicas: 1, + updatedReplicas: 1, + availableReplicas: 0, + }, + { + generation: 4, + observedGeneration: 4, + replicas: 1, + updatedReplicas: 1, + availableReplicas: 1, + }, + ], + }); + + await Effect.runPromise(installRunWorker(api)); + + expect(waits).toEqual([2_000, 2_000]); +}); + +it("fails the submission when no replica ever becomes available", async () => { + const { api, waits } = recordingInstall({ + availability: [ + { + generation: 1, + observedGeneration: 1, + replicas: 1, + updatedReplicas: 0, + availableReplicas: 0, + }, + ], + }); + + const failure = await Effect.runPromise(Effect.flip(installRunWorker(api))); + + expect(failure).toBeInstanceOf(RunWorkerUnavailable); + expect(waits).toHaveLength(150); +}); + +it("reads a rollout as available only once it is both observed and serving", () => { + expect(workerIsAvailable(AVAILABLE)).toBe(true); + expect( + workerIsAvailable({ + generation: 2, + observedGeneration: 1, + replicas: 5, + updatedReplicas: 5, + availableReplicas: 5, + }), + ).toBe(false); + expect( + workerIsAvailable({ + generation: 2, + observedGeneration: 2, + replicas: 1, + updatedReplicas: 1, + availableReplicas: 0, + }), + ).toBe(false); + // Mid-rollout: the outgoing revision is still the one serving, so handing it + // the workflow would lose the activity when the rollout completes. + expect( + workerIsAvailable({ + generation: 2, + observedGeneration: 2, + replicas: 2, + updatedReplicas: 1, + availableReplicas: 1, + }), + ).toBe(false); +}); + +/* eslint-enable agent-code-guard/async-keyword, agent-code-guard/no-example-only-tests -- Restore Effect-first test rules after the host installation contract. */ diff --git a/packages/simulator/src/cluster/install.ts b/packages/simulator/src/cluster/install.ts new file mode 100644 index 000000000..9eba40a8b --- /dev/null +++ b/packages/simulator/src/cluster/install.ts @@ -0,0 +1,94 @@ +/** @file Install the cluster's run-lifecycle worker and wait until it polls. */ + +import { Effect } from "effect"; +import type { + KubernetesCallFailed, + RunWorkerInstallApi, + RunWorkerObject, + WorkerAvailability, +} from "./kubernetes/calls.js"; + +const AVAILABILITY_ATTEMPTS = 150; +const AVAILABILITY_INTERVAL_MS = 2_000; + +// Identity before permissions, permissions before the workload that uses them. +// A Deployment installed ahead of its ClusterRoleBinding starts a Pod whose +// service account cannot delete a run namespace, which is the one thing the +// worker exists to do, and Kubernetes reports that as a permission error on a +// run rather than as a failed install. +const INSTALL_ORDER: readonly RunWorkerObject[] = [ + "namespace", + "serviceAccount", + "clusterRole", + "clusterRoleBinding", + "deployment", +]; + +/** The installed worker never became able to serve the run-lifecycle queue. */ +export class RunWorkerUnavailable extends Error { + override readonly name = "RunWorkerUnavailable"; + + constructor() { + super("the run worker did not become available"); + } +} + +/** + * Whether the rollout the cluster reports is the installed one and is serving. + * + * Every submission installs the image it just built, so every submission rolls + * the Deployment, and `availableReplicas` counts the outgoing revision too. + * Treating that as readiness hands the workflow to a Pod the rollout deletes. + * + * @param availability Rollout state read back from the installed Deployment. + * @returns Whether the installed revision is the only one still serving. + */ +export function workerIsAvailable(availability: WorkerAvailability): boolean { + return ( + availability.observedGeneration >= availability.generation && + availability.updatedReplicas > 0 && + availability.replicas === availability.updatedReplicas && + availability.availableReplicas >= availability.updatedReplicas + ); +} + +// A worker that never becomes available is the one failure mode that would +// otherwise be silent: the workflow starts, nothing polls its task queue, and +// the submitter waits forever. Waiting here turns that into a failed submission. +function awaitAvailableWorker( + api: RunWorkerInstallApi, +): Effect.Effect { + return Effect.gen(function* () { + for (let attempt = 0; attempt < AVAILABILITY_ATTEMPTS; attempt += 1) { + if (workerIsAvailable(yield* api.readWorkerAvailability())) { + return; + } + yield* api.wait(AVAILABILITY_INTERVAL_MS); + } + yield* Effect.fail(new RunWorkerUnavailable()); + }); +} + +/** + * Install the cluster's run-lifecycle worker and wait until it can poll. + * + * Every submission installs it, because the worker runs the image the submitter + * selected and a cluster prepared before that image existed has no worker at + * all. + * + * @param api Host-side access to the profile's cluster. + * @returns Nothing once one worker replica is available on the task queue. + * @failure KubernetesCallFailed when a control-plane object could not be written. + * @failure RunWorkerUnavailable when no replica becomes available in time. + */ +export function installRunWorker( + api: RunWorkerInstallApi, +): Effect.Effect { + return Effect.forEach(INSTALL_ORDER, (object) => api.install(object), { + concurrency: 1, + discard: true, + }).pipe( + Effect.zipRight(awaitAvailableWorker(api)), + Effect.withSpan("installRunWorker"), + ); +} diff --git a/packages/simulator/src/cluster/kubernetes/calls.test.ts b/packages/simulator/src/cluster/kubernetes/calls.test.ts new file mode 100644 index 000000000..7f3f889d6 --- /dev/null +++ b/packages/simulator/src/cluster/kubernetes/calls.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { currentConditionIsTrue } from "./calls.js"; + +describe("currentConditionIsTrue", () => { + it("accepts only a positive condition for the current object generation", () => { + expect( + currentConditionIsTrue( + { + metadata: { generation: 4 }, + status: { + conditions: [ + { type: "Ready", status: "True", observedGeneration: 3 }, + { type: "Admitted", status: "True", observedGeneration: 4 }, + ], + }, + }, + "Admitted", + ), + ).toBe(true); + }); + + it("rejects stale, false, and absent conditions", () => { + expect( + currentConditionIsTrue( + { + metadata: { generation: 4 }, + status: { + conditions: [ + { type: "Admitted", status: "True", observedGeneration: 3 }, + { type: "Ready", status: "False", observedGeneration: 4 }, + ], + }, + }, + "Admitted", + ), + ).toBe(false); + expect( + currentConditionIsTrue({ metadata: { generation: 1 } }, "Ready"), + ).toBe(false); + }); +}); diff --git a/packages/simulator/src/cluster/kubernetes/calls.ts b/packages/simulator/src/cluster/kubernetes/calls.ts new file mode 100644 index 000000000..19e571edb --- /dev/null +++ b/packages/simulator/src/cluster/kubernetes/calls.ts @@ -0,0 +1,931 @@ +/** + * @file Every Kubernetes API call the simulator makes: the run-scoped society + * operations the controller drives, the run-lifecycle operations the worker + * drives, and the control-plane installation the host drives. + */ + +import { connect } from "node:net"; +import { + ApiException, + AppsV1Api, + BatchV1Api, + CoreV1Api, + CustomObjectsApi, + KubeConfig, + PatchStrategy, + RbacAuthorizationV1Api, + setHeaderOptions, + type V1Job, + type V1JobCondition, +} from "@kubernetes/client-node"; +import { Duration, Effect, Schema } from "effect"; +import { clusterError, type ClusterError } from "../cluster.js"; +import type { KubernetesExecutionProfile } from "../profile.js"; +import type { RunSocietyWorkflowInput } from "../reclaim.js"; +import { + CONTROLLER_NAME, + runNamespaceManifest, + runOwnerManifest, + RUN_WORKER_NAME, + runWorkerManifests, + SYSTEM_NAMESPACE, + type OwnedRunControlManifests, + type RunWorkerManifests, + type RunWorkerOptions, +} from "./objects.js"; + +const BRIDGE_PROBE_TIMEOUT = Duration.seconds(2); + +/** Kubernetes status for an object the cluster does not have. */ +const ABSENT = 404; + +/** Field ownership and strict validation applied to every write. */ +const APPLIED = Object.freeze({ + fieldManager: "moltzap-simulator", + fieldValidation: "Strict", +} as const); + +const KUEUE_GROUP = "kueue.x-k8s.io"; +const KUEUE_VERSION = "v1beta2"; +const KUEUE_WORKLOADS = "workloads"; +const LOCAL_QUEUES = "localqueues"; +const SANDBOX_GROUP = "agents.x-k8s.io"; +const SANDBOX_VERSION = "v1beta1"; +const SANDBOXES = "sandboxes"; + +const condition = Schema.Struct({ + type: Schema.String, + status: Schema.String, + observedGeneration: Schema.optional(Schema.Number), + reason: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), +}); + +const objectMetadata = Schema.Struct({ + name: Schema.String, + generation: Schema.optional(Schema.Number), + deletionTimestamp: Schema.optional(Schema.String), +}); + +const workloadObservation = Schema.Struct({ + metadata: objectMetadata, + status: Schema.optional( + Schema.Struct({ + conditions: Schema.optional(Schema.Array(condition)), + admission: Schema.optional( + Schema.Struct({ + clusterQueue: Schema.String, + podSetAssignments: Schema.optional( + Schema.Array( + Schema.Struct({ + name: Schema.String, + flavors: Schema.optional( + Schema.Record({ key: Schema.String, value: Schema.String }), + ), + }), + ), + ), + }), + ), + }), + ), +}); + +const sandboxObservation = Schema.Struct({ + metadata: objectMetadata, + status: Schema.optional( + Schema.Struct({ + conditions: Schema.optional(Schema.Array(condition)), + serviceFQDN: Schema.optional(Schema.String), + selector: Schema.optional(Schema.String), + podIPs: Schema.optional(Schema.Array(Schema.String)), + }), + ), +}); + +const terminatedContainer = Schema.Struct({ + exitCode: Schema.Number, + signal: Schema.optional(Schema.Number), + reason: Schema.optional(Schema.String), + message: Schema.optional(Schema.String), +}); + +const podObservation = Schema.Struct({ + metadata: objectMetadata, + status: Schema.optional( + Schema.Struct({ + phase: Schema.optional(Schema.String), + containerStatuses: Schema.optional( + Schema.Array( + Schema.Struct({ + name: Schema.String, + restartCount: Schema.Number, + state: Schema.Struct({ + terminated: Schema.optional(terminatedContainer), + }), + }), + ), + ), + }), + ), +}); + +const podListObservation = Schema.Struct({ + items: Schema.Array(podObservation), +}); + +/** Minimal condition retained from a Kueue or Agent Sandbox status. */ +type KubernetesCondition = typeof condition.Type; + +/** Kueue state consumed by aggregate admission and loss checks. */ +export type WorkloadObservation = typeof workloadObservation.Type; + +/** Agent Sandbox state consumed by readiness and backing-Pod discovery. */ +export type SandboxObservation = typeof sandboxObservation.Type; + +/** Backing-Pod state consumed by runtime termination observation. */ +export type PodObservation = typeof podObservation.Type; + +/** Private manifest shape submitted through the custom-object API. */ +export type KubernetesManifest = Readonly>; + +/** Exact cluster calls needed to bring up and observe one society. */ +export interface KubernetesSocietyApi { + readonly createWorkload: ( + manifest: KubernetesManifest, + ) => Effect.Effect; + readonly readWorkload: ( + name: string, + ) => Effect.Effect; + readonly deleteWorkload: (name: string) => Effect.Effect; + readonly createSecret: ( + manifest: KubernetesManifest, + ) => Effect.Effect; + readonly deleteSecret: (name: string) => Effect.Effect; + readonly createSandbox: ( + manifest: KubernetesManifest, + ) => Effect.Effect; + readonly readSandbox: ( + name: string, + ) => Effect.Effect; + readonly deleteSandbox: (name: string) => Effect.Effect; + readonly listPods: ( + selector: string, + ) => Effect.Effect; + /** + * Whether an application's controller bridge port accepts a connection. + * Refusal is an ordinary not-yet-ready observation, never a cluster failure, + * so this reports a verdict instead of an error. + */ + readonly bridgeAccepts: ( + host: string, + port: number, + ) => Effect.Effect; +} + +function request(operation: string, evaluate: () => PromiseLike) { + return Effect.tryPromise({ + try: evaluate, + catch: (cause) => clusterError(operation, cause), + }); +} + +function decode( + operation: string, + schema: Schema.Schema, + value: unknown, +): Effect.Effect { + return Schema.decodeUnknown(schema)(value).pipe( + Effect.mapError((cause) => clusterError(operation, cause)), + ); +} + +function ignoreAbsent( + operation: string, + evaluate: () => PromiseLike, +): Effect.Effect { + return Effect.tryPromise({ + try: evaluate, + catch: (cause) => + cause instanceof ApiException && cause.code === ABSENT + ? undefined + : clusterError(operation, cause), + }).pipe( + Effect.catchAll((failure) => + failure === undefined ? Effect.void : Effect.fail(failure), + ), + Effect.asVoid, + ); +} + +function decodeWorkload(value: unknown) { + return decode( + "decode aggregate capacity reservation", + workloadObservation, + value, + ); +} + +function workloadOperations( + namespace: string, + custom: CustomObjectsApi, +): Pick< + KubernetesSocietyApi, + "createWorkload" | "readWorkload" | "deleteWorkload" +> { + return { + createWorkload: (body) => + request("create aggregate capacity reservation", () => + custom.createNamespacedCustomObject({ + group: KUEUE_GROUP, + version: KUEUE_VERSION, + namespace, + plural: KUEUE_WORKLOADS, + body, + ...APPLIED, + }), + ).pipe(Effect.asVoid), + readWorkload: (name) => + request("observe aggregate capacity reservation", () => + custom.getNamespacedCustomObject({ + group: KUEUE_GROUP, + version: KUEUE_VERSION, + namespace, + plural: KUEUE_WORKLOADS, + name, + }), + ).pipe(Effect.flatMap(decodeWorkload)), + deleteWorkload: (name) => + ignoreAbsent("delete aggregate capacity reservation", () => + custom.deleteNamespacedCustomObject({ + group: KUEUE_GROUP, + version: KUEUE_VERSION, + namespace, + plural: KUEUE_WORKLOADS, + name, + propagationPolicy: "Foreground", + }), + ), + }; +} + +function coreOperations( + namespace: string, + core: CoreV1Api, +): Pick { + return { + createSecret: (body) => + request("create runtime bootstrap", () => + core.createNamespacedSecret({ + namespace, + body, + ...APPLIED, + }), + ).pipe(Effect.asVoid), + deleteSecret: (name) => + ignoreAbsent("delete runtime bootstrap", () => + core.deleteNamespacedSecret({ + namespace, + name, + propagationPolicy: "Foreground", + }), + ), + listPods: (selector) => + request("observe sandbox application", () => + core.listNamespacedPod({ namespace, labelSelector: selector }), + ).pipe( + Effect.flatMap((value) => + decode("decode sandbox application", podListObservation, value), + ), + Effect.map((value) => value.items), + ), + }; +} + +/** + * Open and immediately drop one TCP connection to a controller bridge port. + * The probe sends and reads nothing, so a runtime that prints no startup + * banner is still observed as ready the moment it can serve its controller. + * @param host In-cluster address of the Sandbox service. + * @param port Controller bridge port declared by the rendered application. + * @returns Whether the port accepted a connection before the probe deadline. + */ +function bridgeAccepts(host: string, port: number): Effect.Effect { + return Effect.async((resume) => { + const socket = connect({ host, port }); + let settled = false; + const settle = (accepted: boolean) => { + if (settled) { + return; + } + settled = true; + socket.destroy(); + resume(Effect.succeed(accepted)); + }; + socket.setTimeout(Duration.toMillis(BRIDGE_PROBE_TIMEOUT), () => { + settle(false); + }); + socket.once("connect", () => { + settle(true); + }); + socket.once("error", () => { + settle(false); + }); + return Effect.sync(() => { + settled = true; + socket.destroy(); + }); + }); +} + +function sandboxOperations( + namespace: string, + custom: CustomObjectsApi, +): Pick< + KubernetesSocietyApi, + "createSandbox" | "readSandbox" | "deleteSandbox" +> { + return { + createSandbox: (body) => + request("create agent sandbox", () => + custom.createNamespacedCustomObject({ + group: SANDBOX_GROUP, + version: SANDBOX_VERSION, + namespace, + plural: SANDBOXES, + body, + ...APPLIED, + }), + ).pipe(Effect.asVoid), + readSandbox: (name) => + request("observe agent sandbox", () => + custom.getNamespacedCustomObject({ + group: SANDBOX_GROUP, + version: SANDBOX_VERSION, + namespace, + plural: SANDBOXES, + name, + }), + ).pipe( + Effect.flatMap((value) => + decode("decode agent sandbox", sandboxObservation, value), + ), + ), + deleteSandbox: (name) => + ignoreAbsent("delete agent sandbox", () => + custom.deleteNamespacedCustomObject({ + group: SANDBOX_GROUP, + version: SANDBOX_VERSION, + namespace, + plural: SANDBOXES, + name, + propagationPolicy: "Foreground", + }), + ), + }; +} + +/** + * Build the live in-cluster client without leaking generated API types. + * @param namespace Namespace that owns the run-scoped resources. + * @returns Narrow Kubernetes operations consumed by the cluster. + */ +export function makeInClusterKubernetesSocietyApi( + namespace: string, +): KubernetesSocietyApi { + const config = new KubeConfig(); + config.loadFromDefault(); + const custom = config.makeApiClient(CustomObjectsApi); + const core = config.makeApiClient(CoreV1Api); + return Object.freeze({ + ...workloadOperations(namespace, custom), + ...coreOperations(namespace, core), + ...sandboxOperations(namespace, custom), + bridgeAccepts, + }); +} + +interface ConditionedObservation { + readonly metadata: { readonly generation?: number }; + readonly status?: { readonly conditions?: readonly KubernetesCondition[] }; +} + +/** + * Test whether an object has a positive current-generation condition. + * @param observation Narrow object status returned by the live decoder. + * @param type Kubernetes condition type to find. + * @returns Whether the current generation reports that condition as true. + */ +export function currentConditionIsTrue( + observation: ConditionedObservation, + type: string, +): boolean { + const generation = observation.metadata.generation; + return ( + observation.status?.conditions?.some( + (entry) => + entry.type === type && + entry.status === "True" && + (generation === undefined || entry.observedGeneration === generation), + ) ?? false + ); +} + +/** Coarse controller Job status, total so its readers need no defaulting. */ +export interface JobObservation { + readonly succeeded: number; + readonly failed: number; + readonly active: number; + readonly conditions: readonly JobCondition[]; +} + +/** One Job condition retained from the generated status. */ +export interface JobCondition { + readonly type: string; + readonly status: string; + readonly reason?: string; + readonly message?: string; +} + +/** Rollout state the installer compares against its own availability rule. */ +export interface WorkerAvailability { + readonly generation: number; + readonly observedGeneration: number; + readonly replicas: number; + readonly updatedReplicas: number; + readonly availableReplicas: number; +} + +// An unobserved generation reads as -1, never as ready. +function workerAvailabilityOf(deployment: { + readonly metadata?: { readonly generation?: number }; + readonly status?: { + readonly observedGeneration?: number; + readonly replicas?: number; + readonly updatedReplicas?: number; + readonly availableReplicas?: number; + }; +}): WorkerAvailability { + const { generation = 0 } = deployment.metadata ?? {}; + const { + observedGeneration = -1, + replicas = 0, + updatedReplicas = 0, + availableReplicas = 0, + } = deployment.status ?? {}; + return { + generation, + observedGeneration, + replicas, + updatedReplicas, + availableReplicas, + }; +} + +/** One installable member of the cluster's run-worker control plane. */ +export type RunWorkerObject = keyof RunWorkerManifests; + +/** Failure of one Kubernetes call, carrying the status but never the body. */ +export class KubernetesCallFailed extends Error { + override readonly name = "KubernetesCallFailed"; + + /** Whether the cluster answered that the object is not there. */ + readonly absent: boolean; + + constructor(operation: string, cause?: unknown) { + const refused = cause instanceof ApiException ? cause : undefined; + super( + refused === undefined + ? `${operation} failed` + : `${operation} failed (Kubernetes ${String(refused.code)})`, + ); + this.absent = refused?.code === ABSENT; + } +} + +/** Kubernetes access the Temporal activity needs for one run's lifetime. */ +export interface RunControlApi { + /** Create the run's Namespace and immutable owner; yields the owner UID. */ + readonly createRunRoot: ( + input: RunSocietyWorkflowInput, + ) => Effect.Effect; + readonly createExperimentAndQueue: ( + namespace: string, + manifests: OwnedRunControlManifests, + ) => Effect.Effect; + readonly createControllerAccess: ( + namespace: string, + manifests: OwnedRunControlManifests, + ) => Effect.Effect; + readonly createRouterService: ( + namespace: string, + manifests: OwnedRunControlManifests, + ) => Effect.Effect; + readonly startController: ( + namespace: string, + manifests: OwnedRunControlManifests, + ) => Effect.Effect; + readonly readControllerJob: ( + namespace: string, + ) => Effect.Effect; + /** Bounded controller output, or nothing when the Pod cannot be read. */ + readonly readControllerLogs: ( + namespace: string, + tailLines: number, + limitBytes: number, + ) => Effect.Effect; + readonly deleteRunNamespace: ( + namespace: string, + ) => Effect.Effect; + readonly runNamespaceExists: ( + namespace: string, + ) => Effect.Effect; +} + +/** Kubernetes access the host needs to install the cluster's run worker. */ +export interface RunWorkerInstallApi { + /** + * Declare one control-plane object as this manager owns it. + * + * The worker outlives every submission, so each install meets an object that + * is either absent or a previous revision of itself; applying states the + * revision the submission wants without asking first which one is there. + * Ownership is what makes that safe: a field some other manager took over is + * refused as a conflict rather than silently overwritten. + */ + readonly install: ( + object: RunWorkerObject, + ) => Effect.Effect; + readonly readWorkerAvailability: () => Effect.Effect< + WorkerAvailability, + KubernetesCallFailed + >; + /** Sleep between rollout observations while the worker starts. */ + readonly wait: (milliseconds: number) => Effect.Effect; +} + +function attempt( + operation: string, + evaluate: () => PromiseLike, +): Effect.Effect { + return Effect.tryPromise({ + try: evaluate, + catch: (cause) => new KubernetesCallFailed(operation, cause), + }); +} + +function attemptUnlessAbsent( + operation: string, + evaluate: () => PromiseLike, +): Effect.Effect { + return attempt(operation, evaluate).pipe( + Effect.catchIf( + (failure) => failure.absent, + () => Effect.void, + ), + Effect.asVoid, + ); +} + +interface RunControlClients { + readonly batch: BatchV1Api; + readonly core: CoreV1Api; + readonly custom: CustomObjectsApi; + readonly rbac: RbacAuthorizationV1Api; +} + +function jobCondition(condition: V1JobCondition): JobCondition { + return { + type: condition.type, + status: condition.status, + ...(condition.reason === undefined ? {} : { reason: condition.reason }), + ...(condition.message === undefined ? {} : { message: condition.message }), + }; +} + +function jobObservation(job: V1Job): JobObservation { + const status = job.status ?? {}; + return { + succeeded: status.succeeded ?? 0, + failed: status.failed ?? 0, + active: status.active ?? 0, + conditions: (status.conditions ?? []).map(jobCondition), + }; +} + +function createRunRoot( + clients: RunControlClients, + input: RunSocietyWorkflowInput, +): Effect.Effect { + return Effect.gen(function* () { + yield* attempt("create run namespace", () => + clients.core.createNamespace({ + body: runNamespaceManifest(input), + ...APPLIED, + }), + ); + const root = yield* attempt("create run owner", () => + clients.core.createNamespacedConfigMap({ + namespace: input.namespace, + body: runOwnerManifest(input), + ...APPLIED, + }), + ); + const ownerUid = root.metadata?.uid; + if (ownerUid === undefined || ownerUid.length === 0) { + return yield* Effect.fail(new KubernetesCallFailed("read run owner UID")); + } + return ownerUid; + }); +} + +// A Pod already being deleted is skipped: its log stream ends wherever the +// eviction cut it, which would read as a controller that stopped on its own. +function readControllerLogs( + clients: RunControlClients, + namespace: string, + tailLines: number, + limitBytes: number, +): Effect.Effect { + return Effect.gen(function* () { + const pods = yield* attempt("observe controller pod", () => + clients.core.listNamespacedPod({ + namespace, + labelSelector: `job-name=${CONTROLLER_NAME}`, + }), + ); + const podName = pods.items.find( + (pod) => pod.metadata?.deletionTimestamp === undefined, + )?.metadata?.name; + if (podName === undefined) { + return undefined; + } + const output = yield* attempt("read controller log", () => + clients.core.readNamespacedPodLog({ + namespace, + name: podName, + container: CONTROLLER_NAME, + tailLines, + limitBytes, + }), + ); + return output.length === 0 ? undefined : output; + }); +} + +// These operations run inside the cluster they act on, so the API credentials +// come from the worker Pod's service account. The profile's kubeconfig context +// names how a host reaches the cluster and has no meaning here. +function runControlClients(): RunControlClients { + const config = new KubeConfig(); + config.loadFromDefault(); + return { + batch: config.makeApiClient(BatchV1Api), + core: config.makeApiClient(CoreV1Api), + custom: config.makeApiClient(CustomObjectsApi), + rbac: config.makeApiClient(RbacAuthorizationV1Api), + }; +} + +function createExperimentAndQueue( + clients: RunControlClients, + namespace: string, + manifests: OwnedRunControlManifests, +): Effect.Effect { + return Effect.gen(function* () { + yield* attempt("create experiment module", () => + clients.core.createNamespacedConfigMap({ + namespace, + body: manifests.experiment, + ...APPLIED, + }), + ); + yield* attempt("create run queue", () => + clients.custom.createNamespacedCustomObject({ + group: KUEUE_GROUP, + version: KUEUE_VERSION, + namespace, + plural: LOCAL_QUEUES, + body: manifests.localQueue, + ...APPLIED, + }), + ); + }); +} + +function createControllerAccess( + clients: RunControlClients, + namespace: string, + manifests: OwnedRunControlManifests, +): Effect.Effect { + return Effect.gen(function* () { + yield* attempt("create controller service account", () => + clients.core.createNamespacedServiceAccount({ + namespace, + body: manifests.serviceAccount, + ...APPLIED, + }), + ); + yield* attempt("create controller role", () => + clients.rbac.createNamespacedRole({ + namespace, + body: manifests.role, + ...APPLIED, + }), + ); + yield* attempt("create controller role binding", () => + clients.rbac.createNamespacedRoleBinding({ + namespace, + body: manifests.roleBinding, + ...APPLIED, + }), + ); + }); +} + +function runPreparationOperations( + clients: RunControlClients, +): Pick< + RunControlApi, + | "createRunRoot" + | "createExperimentAndQueue" + | "createControllerAccess" + | "createRouterService" + | "startController" +> { + return { + createRunRoot: (input) => createRunRoot(clients, input), + createExperimentAndQueue: (namespace, manifests) => + createExperimentAndQueue(clients, namespace, manifests), + createControllerAccess: (namespace, manifests) => + createControllerAccess(clients, namespace, manifests), + createRouterService: (namespace, manifests) => + attempt("create router service", () => + clients.core.createNamespacedService({ + namespace, + body: manifests.routerService, + ...APPLIED, + }), + ).pipe(Effect.asVoid), + startController: (namespace, manifests) => + attempt("create controller job", () => + clients.batch.createNamespacedJob({ + namespace, + body: manifests.controllerJob, + ...APPLIED, + }), + ).pipe(Effect.asVoid), + }; +} + +function runObservationOperations( + clients: RunControlClients, +): Pick< + RunControlApi, + | "readControllerJob" + | "readControllerLogs" + | "deleteRunNamespace" + | "runNamespaceExists" +> { + return { + readControllerJob: (namespace) => + attempt("observe controller job", () => + clients.batch.readNamespacedJob({ + namespace, + name: CONTROLLER_NAME, + }), + ).pipe(Effect.map(jobObservation)), + readControllerLogs: (namespace, tailLines, limitBytes) => + readControllerLogs(clients, namespace, tailLines, limitBytes), + deleteRunNamespace: (namespace) => + attemptUnlessAbsent("delete run namespace", () => + clients.core.deleteNamespace({ + name: namespace, + propagationPolicy: "Foreground", + }), + ), + runNamespaceExists: (namespace) => + attempt("observe run namespace deletion", () => + clients.core.readNamespace({ name: namespace }), + ).pipe( + Effect.as(true), + Effect.catchIf( + (failure) => failure.absent, + () => Effect.succeed(false), + ), + ), + }; +} + +/** + * Build the live Kubernetes access one run-lifecycle worker attempt uses. + * @returns Run-control operations backed by the worker Pod's service account. + */ +export function makeKubernetesRunControlApi(): RunControlApi { + const clients = runControlClients(); + return Object.freeze({ + ...runPreparationOperations(clients), + ...runObservationOperations(clients), + }); +} + +interface InstallClients { + readonly apps: AppsV1Api; + readonly core: CoreV1Api; + readonly rbac: RbacAuthorizationV1Api; +} + +/** One object's apply call, already bound to the manifest it declares. */ +type InstalledObjectApply = () => PromiseLike; + +const NAMED_WORKER = Object.freeze({ + name: RUN_WORKER_NAME, + namespace: SYSTEM_NAMESPACE, +} as const); + +/** + * Field ownership plus the content type that makes a patch an apply. Ownership + * is forced because an earlier submission's create owns these fields under + * Update, which conflicts with an Apply even from the same manager. The run + * worker's objects have no other writer. + */ +const APPLY = Object.freeze({ ...APPLIED, force: true } as const); +const APPLY_OPTIONS = setHeaderOptions( + "Content-Type", + PatchStrategy.ServerSideApply, +); + +function installedObjectApplies( + clients: InstallClients, + manifests: RunWorkerManifests, +): Readonly> { + return { + namespace: () => + clients.core.patchNamespace( + { name: SYSTEM_NAMESPACE, body: manifests.namespace, ...APPLY }, + APPLY_OPTIONS, + ), + serviceAccount: () => + clients.core.patchNamespacedServiceAccount( + { ...NAMED_WORKER, body: manifests.serviceAccount, ...APPLY }, + APPLY_OPTIONS, + ), + clusterRole: () => + clients.rbac.patchClusterRole( + { name: RUN_WORKER_NAME, body: manifests.clusterRole, ...APPLY }, + APPLY_OPTIONS, + ), + clusterRoleBinding: () => + clients.rbac.patchClusterRoleBinding( + { name: RUN_WORKER_NAME, body: manifests.clusterRoleBinding, ...APPLY }, + APPLY_OPTIONS, + ), + deployment: () => + clients.apps.patchNamespacedDeployment( + { ...NAMED_WORKER, body: manifests.deployment, ...APPLY }, + APPLY_OPTIONS, + ), + }; +} + +function installClients(profile: KubernetesExecutionProfile): InstallClients { + const config = new KubeConfig(); + config.loadFromDefault(); + if (profile.kind === "gke") { + if (config.getContextObject(profile.kubeContext) === null) { + throw new KubernetesCallFailed("select configured kubeconfig context"); + } + config.setCurrentContext(profile.kubeContext); + } + return { + apps: config.makeApiClient(AppsV1Api), + core: config.makeApiClient(CoreV1Api), + rbac: config.makeApiClient(RbacAuthorizationV1Api), + }; +} + +/** + * Build the live host-side access used to install the cluster's run worker. + * @param options Host-selected image, Temporal endpoint, queue, and profile. + * @returns Install operations against the profile's cluster. + */ +export function makeKubernetesRunWorkerInstallApi( + options: RunWorkerOptions, +): RunWorkerInstallApi { + const clients = installClients(options.profile); + const applies = installedObjectApplies(clients, runWorkerManifests(options)); + return Object.freeze({ + install: (object: RunWorkerObject) => + attempt(`apply run worker ${object}`, applies[object]).pipe( + Effect.asVoid, + ), + readWorkerAvailability: () => + attempt("observe run worker", () => + clients.apps.readNamespacedDeployment({ + name: RUN_WORKER_NAME, + namespace: SYSTEM_NAMESPACE, + }), + ).pipe(Effect.map(workerAvailabilityOf)), + wait: (milliseconds: number) => Effect.sleep(Duration.millis(milliseconds)), + }); +} diff --git a/packages/simulator/src/cluster/kubernetes/objects.test.ts b/packages/simulator/src/cluster/kubernetes/objects.test.ts new file mode 100644 index 000000000..37412e6b4 --- /dev/null +++ b/packages/simulator/src/cluster/kubernetes/objects.test.ts @@ -0,0 +1,559 @@ +import assert from "node:assert/strict"; +import { expect, it } from "vitest"; +import { image } from "../../agents/container.js"; +import type { KubernetesExecutionProfile } from "../profile.js"; +import type { RunSocietyWorkflowInput } from "../reclaim.js"; +import { + aggregateWorkloadManifest, + bootstrapSecretManifest, + CLUSTER_QUEUE_NAME, + CONTROLLER_NAME, + EXPERIMENT_CONFIG_NAME, + IN_CLUSTER_TEMPORAL_ADDRESS, + LOCAL_QUEUE_NAME, + ownedRunControlManifests, + ROUTER_SERVICE_NAME, + RUN_OWNER_NAME, + RUN_WORKER_NAME, + runNamespaceManifest, + runOwnerManifest, + runWorkerManifests, + sandboxManifest, + SYSTEM_NAMESPACE, +} from "./objects.js"; + +const OWNER = { name: "run", uid: "run-uid" }; +const SUPPORT_IMAGE = image.make(`registry/simulator@sha256:${"c".repeat(64)}`); +const APPLICATION_IMAGE = image.make( + `registry/openclaw@sha256:${"d".repeat(64)}`, +); +const SECRET_CONTENT = "secret-content"; +const PARTIAL_ADMISSION_FIELD = "minCount"; +const PLACEMENT = { + nodeSelector: { "moltzap.dev/pool": "agents" }, + tolerations: [ + { + key: "moltzap.dev/agents", + operator: "Equal" as const, + value: "true", + effect: "NoSchedule" as const, + }, + ], +}; + +function aggregateManifest(withPlacement = false) { + return aggregateWorkloadManifest({ + namespace: "mz-run", + name: "society", + queueName: "simulator", + labels: { "moltzap.dev/run": "run-1" }, + owner: OWNER, + ...(withPlacement ? { placement: PLACEMENT } : {}), + slots: [ + { + image: "registry/openclaw@sha256:abc", + requests: { cpu: "1", memory: "1Gi" }, + }, + { + image: "registry/openclaw@sha256:def", + requests: { memory: "1Gi", cpu: "1" }, + }, + ], + }); +} + +function sandboxFixture(withPlacement = false) { + return sandboxManifest({ + namespace: "mz-run", + name: "agent-1-alice", + labels: { "moltzap.dev/run": "run-1" }, + owner: OWNER, + bootstrapSecretName: "agent-1-alice-bootstrap", + supportImage: SUPPORT_IMAGE, + ...(withPlacement ? { placement: PLACEMENT } : {}), + application: { + image: APPLICATION_IMAGE, + entrypoint: ["openclaw", "gateway", "run"], + environment: { HOME: "/var/lib/moltzap/openclaw" }, + credentials: ["OPENAI_API_KEY"], + port: 18_789, + resources: { + cpuMillis: 2_000, + memoryBytes: 2_147_483_648, + ephemeralStorageBytes: 2_147_483_648, + }, + }, + credentialSecretKeys: { + ANTHROPIC_API_KEY: undefined, + OPENAI_API_KEY: "credential-OPENAI_API_KEY", + }, + }); +} + +// eslint-disable-next-line agent-code-guard/no-example-only-tests -- these examples pin exact third-party manifest schemas and ordering omissions +it("reserves identical runtimes as one all-or-nothing pod set", () => { + const manifest = aggregateManifest(); + expect(manifest).toMatchObject({ + apiVersion: "kueue.x-k8s.io/v1beta2", + kind: "Workload", + spec: { + active: true, + queueName: "simulator", + podSets: [ + { + count: 2, + template: { + spec: { + restartPolicy: "Never", + containers: [ + { + name: "application", + resources: { requests: { cpu: "1", memory: "1Gi" } }, + }, + ], + }, + }, + }, + ], + }, + }); + expect(JSON.stringify(manifest)).not.toContain(PARTIAL_ADMISSION_FIELD); +}); + +it("stores bootstrap content as immutable Secret data", () => { + const manifest = bootstrapSecretManifest({ + namespace: "mz-run", + name: "alice-bootstrap", + labels: {}, + owner: OWNER, + data: { "bootstrap.json": SECRET_CONTENT }, + }); + expect(manifest).toMatchObject({ + apiVersion: "v1", + kind: "Secret", + immutable: true, + data: { + "bootstrap.json": Buffer.from(SECRET_CONTENT).toString("base64"), + }, + }); + expect(JSON.stringify(manifest)).not.toContain(SECRET_CONTENT); +}); + +it("creates one application container without bootstrap bytes in its environment", () => { + const manifest = sandboxFixture(); + expect(manifest).toMatchObject({ + apiVersion: "agents.x-k8s.io/v1beta1", + kind: "Sandbox", + spec: { + service: true, + podTemplate: { + spec: { + automountServiceAccountToken: false, + restartPolicy: "Never", + initContainers: [{ name: "bootstrap", image: SUPPORT_IMAGE }], + containers: [ + { + name: "application", + image: APPLICATION_IMAGE, + command: ["openclaw"], + args: ["gateway", "run"], + env: [ + { name: "HOME", value: "/var/lib/moltzap/openclaw" }, + { + name: "OPENAI_API_KEY", + valueFrom: { + secretKeyRef: { + name: "agent-1-alice-bootstrap", + key: "credential-OPENAI_API_KEY", + optional: false, + }, + }, + }, + ], + ports: [{ containerPort: 18_789, protocol: "TCP" }], + resources: { + requests: { + cpu: "2000m", + memory: "2147483648", + "ephemeral-storage": "2147483648", + }, + }, + }, + ], + }, + }, + }, + }); + expect(JSON.stringify(manifest)).not.toContain(SECRET_CONTENT); +}); + +it("projects identical GKE placement onto reserved and actual Pods", () => { + const workload = aggregateManifest(true); + const sandbox = sandboxFixture(true); + + expect(workload).toMatchObject({ + spec: { podSets: [{ template: { spec: PLACEMENT } }] }, + }); + expect(sandbox).toMatchObject({ + spec: { podTemplate: { spec: PLACEMENT } }, + }); +}); + +const DIGEST = "a".repeat(64); +const STARTUP_TIMEOUT_VARIABLE = "MOLTZAP_STARTUP_TIMEOUT_MS"; +const COHORT_SIZE_VARIABLE = "MOLTZAP_COHORT_SIZE"; +const COHORT_SIZE = 100; +const STARTUP_TIMEOUT_MS = 900_000; +const EXPERIMENT_SOURCE = "export const runSpec = society;"; +const INPUT: RunSocietyWorkflowInput = { + runId: "run-1", + namespace: "mz-run-1", + controllerImage: `registry/controller@sha256:${DIGEST}`, + supportImage: `registry/support@sha256:${DIGEST}`, + experimentModule: EXPERIMENT_SOURCE, +}; +type GkeKubernetesExecutionProfile = Extract< + KubernetesExecutionProfile, + { readonly kind: "gke" } +>; +const GKE_PROFILE: GkeKubernetesExecutionProfile = { + kind: "gke", + artifactBucket: "moltzap-artifacts-test", + kubeContext: "gke-test", + rosterPlacement: { + nodeSelector: { "moltzap.dev/pool": "agents" }, + tolerations: [ + { + key: "moltzap.dev/agents", + operator: "Equal", + value: "true", + effect: "NoSchedule", + }, + ], + }, +}; + +it("isolates the run and establishes one immutable owner", () => { + expect(runNamespaceManifest(INPUT)).toMatchObject({ + apiVersion: "v1", + kind: "Namespace", + metadata: { + name: INPUT.namespace, + annotations: { "moltzap.dev/run-id": INPUT.runId }, + }, + }); + expect(runOwnerManifest(INPUT)).toMatchObject({ + apiVersion: "v1", + kind: "ConfigMap", + immutable: true, + metadata: { name: RUN_OWNER_NAME, namespace: INPUT.namespace }, + }); +}); + +it("mounts the supplied module and points the local queue at the profile queue", () => { + const manifests = ownedRunControlManifests(INPUT, "owner-uid"); + expect(manifests.experiment).toMatchObject({ + immutable: true, + metadata: { + name: EXPERIMENT_CONFIG_NAME, + ownerReferences: [{ name: RUN_OWNER_NAME, uid: "owner-uid" }], + }, + data: { "main.mjs": EXPERIMENT_SOURCE }, + }); + expect(manifests.localQueue).toMatchObject({ + apiVersion: "kueue.x-k8s.io/v1beta2", + kind: "LocalQueue", + metadata: { name: LOCAL_QUEUE_NAME, namespace: INPUT.namespace }, + spec: { clusterQueue: CLUSTER_QUEUE_NAME }, + }); +}); + +it("gives the controller only the run-scoped operations its platform uses", () => { + const { role } = ownedRunControlManifests(INPUT, "owner-uid"); + expect(role.rules).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + apiGroups: ["kueue.x-k8s.io"], + resources: ["workloads"], + verbs: ["create", "get", "delete"], + }), + expect.objectContaining({ + apiGroups: ["agents.x-k8s.io"], + resources: ["sandboxes"], + verbs: ["create", "get", "delete"], + }), + expect.objectContaining({ + apiGroups: [""], + resources: ["configmaps"], + resourceNames: [RUN_OWNER_NAME], + verbs: ["get", "delete"], + }), + ]), + ); +}); + +it("launches one controller attempt with the closed environment contract", () => { + const manifests = ownedRunControlManifests(INPUT, "owner-uid"); + const [controller] = + manifests.controllerJob.spec?.template.spec?.containers ?? []; + expect(manifests.controllerJob).toMatchObject({ + metadata: { name: CONTROLLER_NAME }, + spec: { backoffLimit: 0 }, + }); + expect(controller).toMatchObject({ + name: CONTROLLER_NAME, + image: INPUT.controllerImage, + command: ["node", "/opt/moltzap/dist/cluster/controller/main.js"], + env: [ + { name: "MOLTZAP_RUN_NAMESPACE", value: INPUT.namespace }, + { name: "MOLTZAP_RUN_QUEUE", value: LOCAL_QUEUE_NAME }, + { name: "MOLTZAP_RUN_OWNER_NAME", value: RUN_OWNER_NAME }, + { name: "MOLTZAP_RUN_OWNER_UID", value: "owner-uid" }, + { name: "MOLTZAP_SUPPORT_IMAGE", value: INPUT.supportImage }, + { + name: "MOLTZAP_EXPERIMENT_MODULE", + value: "/opt/moltzap/experiment/main.mjs", + }, + { name: "MOLTZAP_LEDGER_DIRECTORY", value: "/var/lib/moltzap/ledger" }, + { + name: "MOLTZAP_ROUTER_URL", + value: `ws://${ROUTER_SERVICE_NAME}.${INPUT.namespace}.svc.cluster.local:3000`, + }, + ], + }); +}); + +it("mounts the experiment and durable local ledger beside the router Service", () => { + const manifests = ownedRunControlManifests(INPUT, "owner-uid"); + const pod = manifests.controllerJob.spec?.template.spec; + expect(pod).toMatchObject({ + serviceAccountName: CONTROLLER_NAME, + restartPolicy: "Never", + }); + expect(pod?.volumes).toContainEqual({ + name: "experiment", + configMap: { name: EXPERIMENT_CONFIG_NAME, defaultMode: 0o444 }, + }); + expect(pod?.volumes).toContainEqual({ + name: "ledger", + hostPath: { + path: `/var/lib/moltzap-artifacts/${INPUT.namespace}/ledger`, + type: "DirectoryOrCreate", + }, + }); + expect(pod?.initContainers).toEqual([ + expect.objectContaining({ + name: "ledger-permissions", + image: INPUT.controllerImage, + command: ["chown"], + args: ["1000:1000", "/var/lib/moltzap/ledger"], + securityContext: { + allowPrivilegeEscalation: false, + capabilities: { add: ["CHOWN"], drop: ["ALL"] }, + readOnlyRootFilesystem: true, + runAsNonRoot: false, + runAsUser: 0, + }, + volumeMounts: [{ name: "ledger", mountPath: "/var/lib/moltzap/ledger" }], + }), + ]); + expect(manifests.routerService).toMatchObject({ + metadata: { name: ROUTER_SERVICE_NAME }, + spec: { ports: [{ port: 3_000, targetPort: 3_000 }] }, + }); +}); + +// eslint-disable-next-line complexity -- This regression assertion pins the two-volume GKE projection across optional Kubernetes manifest fields. +it("separates the active POSIX ledger from the retained GKE export", () => { + const manifests = ownedRunControlManifests(INPUT, "owner-uid", GKE_PROFILE); + const template = manifests.controllerJob.spec?.template; + const ledger = template?.spec?.volumes?.find( + (volume) => volume.name === "ledger", + ); + const artifacts = template?.spec?.volumes?.find( + (volume) => volume.name === "artifacts", + ); + + expect(template?.metadata?.annotations).toEqual({ + "gke-gcsfuse/volumes": "true", + }); + expect(ledger).toEqual({ name: "ledger", emptyDir: {} }); + expect(artifacts).toEqual({ + name: "artifacts", + csi: { + driver: "gcsfuse.csi.storage.gke.io", + readOnly: false, + volumeAttributes: { + bucketName: GKE_PROFILE.artifactBucket, + mountOptions: "uid=1000,gid=1000,file-mode=0640,dir-mode=0750", + }, + }, + }); +}); + +it("prepares only the active GKE ledger for the non-root controller", () => { + const { controllerJob } = ownedRunControlManifests( + INPUT, + "owner-uid", + GKE_PROFILE, + ); + const pod = controllerJob.spec?.template.spec; + assert(pod !== undefined); + const [controller] = pod.containers; + assert(controller !== undefined); + const ledger = pod.volumes?.find((volume) => volume.name === "ledger"); + + expect(pod.initContainers).toEqual([ + expect.objectContaining({ + name: "ledger-permissions", + volumeMounts: [{ name: "ledger", mountPath: "/var/lib/moltzap/ledger" }], + }), + ]); + expect(ledger?.hostPath).toBeUndefined(); + expect(controller.volumeMounts).toContainEqual({ + name: "ledger", + mountPath: "/var/lib/moltzap/ledger", + }); + expect(controller.volumeMounts).toContainEqual({ + name: "artifacts", + mountPath: "/var/lib/moltzap-artifacts", + }); +}); + +it("forwards GKE artifact identity and roster placement to the controller", () => { + const { controllerJob } = ownedRunControlManifests( + INPUT, + "owner-uid", + GKE_PROFILE, + ); + const pod = controllerJob.spec?.template.spec; + assert(pod !== undefined); + const [controller] = pod.containers; + assert(controller !== undefined); + + expect(controller.env).toContainEqual({ + name: "MOLTZAP_LEDGER_DIRECTORY", + value: "/var/lib/moltzap/ledger", + }); + expect(controller.env).toContainEqual({ + name: "MOLTZAP_LEDGER_EXPORT_DIRECTORY", + value: `/var/lib/moltzap-artifacts/${INPUT.namespace}/ledger`, + }); + expect(controller.env).toContainEqual({ + name: "MOLTZAP_ROSTER_PLACEMENT", + value: JSON.stringify(GKE_PROFILE.rosterPlacement), + }); +}); + +const WORKER_OPTIONS = { + controllerImage: INPUT.controllerImage, + taskQueue: "moltzap-simulator", + temporalAddress: IN_CLUSTER_TEMPORAL_ADDRESS, + temporalNamespace: "default", + profile: GKE_PROFILE, +}; + +it("serves the run queue from a Deployment carrying the host's choices", () => { + const { deployment, serviceAccount, namespace } = + runWorkerManifests(WORKER_OPTIONS); + const [worker] = deployment.spec?.template.spec?.containers ?? []; + + expect(namespace.metadata?.name).toBe(SYSTEM_NAMESPACE); + expect(serviceAccount.metadata).toMatchObject({ + name: RUN_WORKER_NAME, + namespace: SYSTEM_NAMESPACE, + }); + expect(deployment.spec?.template.spec).toMatchObject({ + serviceAccountName: RUN_WORKER_NAME, + }); + expect(worker).toMatchObject({ + image: INPUT.controllerImage, + command: ["node", "/opt/moltzap/dist/cluster/temporal.js"], + env: [ + { name: "MOLTZAP_TEMPORAL_ADDRESS", value: IN_CLUSTER_TEMPORAL_ADDRESS }, + { name: "MOLTZAP_TEMPORAL_NAMESPACE", value: "default" }, + { name: "MOLTZAP_TEMPORAL_TASK_QUEUE", value: "moltzap-simulator" }, + { + name: "MOLTZAP_EXECUTION_PROFILE", + value: JSON.stringify(GKE_PROFILE), + }, + ], + }); +}); + +it("holds cluster-wide namespace deletion and every permission it delegates", () => { + const { clusterRole, clusterRoleBinding } = + runWorkerManifests(WORKER_OPTIONS); + const { role } = ownedRunControlManifests(INPUT, "owner-uid"); + const granted = new Map( + clusterRole.rules?.map((rule) => [ + `${String(rule.apiGroups)}/${String(rule.resources)}`, + rule, + ]), + ); + + expect(clusterRole.rules).toContainEqual({ + apiGroups: [""], + resources: ["namespaces"], + verbs: ["create", "get", "list", "watch", "delete"], + }); + expect(clusterRoleBinding.subjects).toEqual([ + { + apiGroup: "", + kind: "ServiceAccount", + name: RUN_WORKER_NAME, + namespace: SYSTEM_NAMESPACE, + }, + ]); + // Kubernetes rejects a subject that creates a Role carrying verbs the subject + // does not itself hold, so the run-scoped controller Role is a lower bound on + // what the worker's ClusterRole must grant. + for (const rule of role.rules ?? []) { + const key = `${String(rule.apiGroups)}/${String(rule.resources)}`; + expect(granted.get(key)?.verbs ?? []).toEqual( + expect.arrayContaining(rule.verbs), + ); + } +}); + +it("scopes nothing by resource name because run namespaces are generated", () => { + const { clusterRole } = runWorkerManifests(WORKER_OPTIONS); + + expect( + clusterRole.rules?.filter((rule) => rule.resourceNames !== undefined), + ).toEqual([]); +}); + +function controllerEnvironmentOf( + input: RunSocietyWorkflowInput, +): ReadonlyArray<{ readonly name: string; readonly value?: string }> { + const manifests = ownedRunControlManifests(input, "owner-uid"); + const [controller] = + manifests.controllerJob.spec?.template.spec?.containers ?? []; + return controller?.env ?? []; +} + +it("carries a cohort's startup budget into the controller only when one is set", () => { + const names = controllerEnvironmentOf(INPUT).map((entry) => entry.name); + expect(names).not.toContain(STARTUP_TIMEOUT_VARIABLE); + + const budgeted = controllerEnvironmentOf({ + ...INPUT, + startupTimeoutMs: STARTUP_TIMEOUT_MS, + }); + expect(budgeted).toContainEqual({ + name: STARTUP_TIMEOUT_VARIABLE, + value: String(STARTUP_TIMEOUT_MS), + }); +}); + +it("carries a run-chosen cohort size into the controller only when one is set", () => { + const names = controllerEnvironmentOf(INPUT).map((entry) => entry.name); + expect(names).not.toContain(COHORT_SIZE_VARIABLE); + + const sized = controllerEnvironmentOf({ ...INPUT, cohortSize: COHORT_SIZE }); + expect(sized).toContainEqual({ + name: COHORT_SIZE_VARIABLE, + value: String(COHORT_SIZE), + }); +}); diff --git a/packages/simulator/src/cluster/kubernetes/objects.ts b/packages/simulator/src/cluster/kubernetes/objects.ts new file mode 100644 index 000000000..f95453b77 --- /dev/null +++ b/packages/simulator/src/cluster/kubernetes/objects.ts @@ -0,0 +1,1062 @@ +// safer-arch-ignore no-cross-domain-sibling-import: Kubernetes objects carry the agent, ledger, and router identities the run gives them. +/** + * @file Every Kubernetes object the simulator builds: the run's aggregate + * admission and sandbox resources, the run-scoped control objects created + * before the controller starts, and the cluster's long-lived run worker. + */ + +import type { + V1ClusterRole, + V1ClusterRoleBinding, + V1ConfigMap, + V1Container, + V1Deployment, + V1Job, + V1Namespace, + V1OwnerReference, + V1Role, + V1RoleBinding, + V1Service, + V1ServiceAccount, + V1Volume, +} from "@kubernetes/client-node"; +import { ClusterError } from "../cluster.js"; +import type { + CredentialName, + Image, + Resources, +} from "../../agents/container.js"; +import type { KubernetesManifest } from "./calls.js"; +import { + encodeKubernetesExecutionProfile, + LOCAL_KUBERNETES_EXECUTION_PROFILE, + type KubernetesExecutionProfile, + type KubernetesPodPlacement, +} from "../profile.js"; +import type { RunSocietyWorkflowInput } from "../reclaim.js"; + +const MAX_KUEUE_POD_SETS = 8; +const BOOTSTRAP_INPUT_PATH = "/var/run/moltzap/secret"; +const BOOTSTRAP_OUTPUT_PATH = "/var/run/moltzap/bootstrap"; +const RUNTIME_STATE_PATH = "/var/lib/moltzap"; + +/** Run root created by the Temporal activity before the controller starts. */ +export interface KubernetesRunOwner { + readonly name: string; + readonly uid: string; +} + +/** Capacity facts projected from one private container runtime. */ +export interface RuntimeCapacitySlot { + readonly image: string; + readonly requests: Readonly>; +} + +/** + * The capacity one run reserves. A reservation that admits nothing would let a + * run hold cluster ownership with no roster behind it, so the empty case is + * spelled out of the type rather than refused after the fact. + */ +export type ReservedCapacity = readonly [ + RuntimeCapacitySlot, + ...RuntimeCapacitySlot[], +]; + +/** Everything one Sandbox Pod template needs about a rendered application. */ +export interface SandboxApplication { + readonly image: Image; + readonly resources: Resources; + readonly entrypoint: readonly [string, ...string[]]; + readonly environment: Readonly>; + readonly credentials?: readonly CredentialName[]; + readonly port: number; +} + +interface CapacityGroup { + readonly image: string; + readonly requests: Readonly>; + count: number; +} + +interface AggregateWorkloadInput { + readonly namespace: string; + readonly name: string; + readonly queueName: string; + readonly labels: Readonly>; + readonly owner: KubernetesRunOwner; + readonly slots: ReservedCapacity; + readonly placement?: KubernetesPodPlacement; +} + +interface BootstrapSecretInput { + readonly namespace: string; + readonly name: string; + readonly labels: Readonly>; + readonly owner: KubernetesRunOwner; + readonly data: Readonly>; +} + +interface SandboxManifestInput { + readonly namespace: string; + readonly name: string; + readonly labels: Readonly>; + readonly owner: KubernetesRunOwner; + readonly bootstrapSecretName: string; + readonly supportImage: Image; + readonly application: SandboxApplication; + readonly credentialSecretKeys: Readonly< + Record + >; + readonly placement?: KubernetesPodPlacement; +} + +function ownerReference(owner: KubernetesRunOwner) { + return { + apiVersion: "v1", + kind: "ConfigMap", + name: owner.name, + uid: owner.uid, + controller: true, + blockOwnerDeletion: true, + } as const; +} + +function capacityKey(slot: RuntimeCapacitySlot): string { + return JSON.stringify( + Object.entries(slot.requests).sort(([left], [right]) => + left.localeCompare(right), + ), + ); +} + +function groupCapacity( + slots: readonly RuntimeCapacitySlot[], +): readonly CapacityGroup[] { + const groups = new Map(); + for (const slot of slots) { + const key = capacityKey(slot); + const present = groups.get(key); + if (present === undefined) { + groups.set(key, { + count: 1, + image: slot.image, + requests: slot.requests, + }); + } else { + present.count += 1; + } + } + return [...groups.values()]; +} + +function podPlacement(placement?: KubernetesPodPlacement) { + return placement === undefined + ? {} + : { + nodeSelector: { ...placement.nodeSelector }, + tolerations: placement.tolerations.map((toleration) => ({ + ...toleration, + })), + }; +} + +function workloadPodSets( + groups: readonly CapacityGroup[], + placement?: KubernetesPodPlacement, +) { + return groups.map((group, index) => ({ + name: `runtime-${String(index + 1)}`, + count: group.count, + template: { + spec: { + ...podPlacement(placement), + automountServiceAccountToken: false, + restartPolicy: "Never", + containers: [ + { + name: "application", + image: group.image, + resources: { requests: group.requests }, + }, + ], + }, + }, + })); +} + +/** + * Build one immutable Kueue Workload for the complete roster. + * @param input Run-scoped identity, queue, and credential-free capacity facts. + * @returns Strict custom-resource manifest submitted to Kueue. + */ +export function aggregateWorkloadManifest( + input: AggregateWorkloadInput, +): KubernetesManifest { + const groups = groupCapacity(input.slots); + if (groups.length > MAX_KUEUE_POD_SETS) { + throw new ClusterError({ + detail: `aggregate capacity reservation has ${String(groups.length)} resource classes; Kueue accepts at most ${String(MAX_KUEUE_POD_SETS)}`, + }); + } + return { + apiVersion: "kueue.x-k8s.io/v1beta2", + kind: "Workload", + metadata: { + name: input.name, + namespace: input.namespace, + labels: input.labels, + ownerReferences: [ownerReference(input.owner)], + }, + spec: { + active: true, + queueName: input.queueName, + podSets: workloadPodSets(groups, input.placement), + }, + }; +} + +/** + * Build the immutable per-agent bootstrap Secret. + * @param input Run ownership plus opaque bootstrap file bytes. + * @returns Core Kubernetes Secret manifest with base64-encoded data. + */ +export function bootstrapSecretManifest( + input: BootstrapSecretInput, +): KubernetesManifest { + return { + apiVersion: "v1", + kind: "Secret", + metadata: { + name: input.name, + namespace: input.namespace, + labels: input.labels, + ownerReferences: [ownerReference(input.owner)], + }, + immutable: true, + type: "Opaque", + data: Object.fromEntries( + Object.entries(input.data).map(([name, content]) => [ + name, + Buffer.from(content, "utf8").toString("base64"), + ]), + ), + }; +} + +function resourceRequests( + resources: Resources, +): Readonly> { + return { + cpu: `${String(resources.cpuMillis)}m`, + memory: String(resources.memoryBytes), + "ephemeral-storage": String(resources.ephemeralStorageBytes), + }; +} + +function bootstrapContainer(input: SandboxManifestInput) { + return { + name: "bootstrap", + image: input.supportImage, + command: ["node", "/opt/moltzap/dist/cluster/bootstrap.js"], + args: [ + "--manifest", + `${BOOTSTRAP_INPUT_PATH}/manifest.json`, + "--source", + BOOTSTRAP_INPUT_PATH, + "--output", + BOOTSTRAP_OUTPUT_PATH, + "--overlay", + "/opt/moltzap/application-overlay", + ], + volumeMounts: [ + { + name: "bootstrap-input", + mountPath: BOOTSTRAP_INPUT_PATH, + readOnly: true, + }, + { name: "bootstrap-output", mountPath: BOOTSTRAP_OUTPUT_PATH }, + ], + }; +} + +function applicationContainer(input: SandboxManifestInput) { + const [command, ...args] = input.application.entrypoint; + const credentials = (input.application.credentials ?? []) + .map((name) => { + const key = input.credentialSecretKeys[name]; + return key === undefined + ? undefined + : { + name, + valueFrom: { + secretKeyRef: { + name: input.bootstrapSecretName, + key, + optional: false, + }, + }, + }; + }) + .filter((entry) => entry !== undefined); + return { + name: "application", + image: input.application.image, + command: [command], + args, + env: [ + ...Object.entries(input.application.environment) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => ({ name, value })), + ...credentials, + ], + ports: [ + { + name: `gateway-${String(input.application.port)}`, + containerPort: input.application.port, + protocol: "TCP", + }, + ], + resources: { requests: resourceRequests(input.application.resources) }, + volumeMounts: [ + { name: "bootstrap-output", mountPath: BOOTSTRAP_OUTPUT_PATH }, + { name: "runtime-state", mountPath: RUNTIME_STATE_PATH }, + ], + }; +} + +function sandboxPodSpec(input: SandboxManifestInput) { + return { + ...podPlacement(input.placement), + automountServiceAccountToken: false, + enableServiceLinks: false, + restartPolicy: "Never", + securityContext: { runAsUser: 1000, runAsGroup: 1000, fsGroup: 1000 }, + initContainers: [bootstrapContainer(input)], + containers: [applicationContainer(input)], + volumes: [ + { + name: "bootstrap-input", + secret: { secretName: input.bootstrapSecretName }, + }, + { name: "bootstrap-output", emptyDir: {} }, + { name: "runtime-state", emptyDir: {} }, + ], + }; +} + +/** + * Build one direct Agent Sandbox for a single roster application. + * @param input Run ownership, bootstrap identity, and rendered application. + * @returns Strict Agent Sandbox custom-resource manifest. + */ +export function sandboxManifest( + input: SandboxManifestInput, +): KubernetesManifest { + return { + apiVersion: "agents.x-k8s.io/v1beta1", + kind: "Sandbox", + metadata: { + name: input.name, + namespace: input.namespace, + labels: input.labels, + ownerReferences: [ownerReference(input.owner)], + }, + spec: { + service: true, + podTemplate: { + metadata: { labels: input.labels }, + spec: sandboxPodSpec(input), + }, + }, + }; +} + +/** Root ConfigMap name shared with controller-created owner references. */ +export const RUN_OWNER_NAME = "run"; +/** ConfigMap containing the mounted experiment module. */ +export const EXPERIMENT_CONFIG_NAME = "experiment"; +/** Run-local queue consumed by the aggregate Kueue Workload. */ +export const LOCAL_QUEUE_NAME = "society"; +/** Profile-owned ClusterQueue selected by every run-local queue. */ +export const CLUSTER_QUEUE_NAME = "moltzap"; +/** Shared ServiceAccount, RBAC, and Job name for the controller. */ +export const CONTROLLER_NAME = "controller"; +/** Service name exposing the controller-owned router process. */ +export const ROUTER_SERVICE_NAME = "router"; +/** Namespace holding the cluster's long-lived simulator control plane. */ +export const SYSTEM_NAMESPACE = "moltzap-system"; +/** ServiceAccount, RBAC, and Deployment name for the run-lifecycle worker. */ +export const RUN_WORKER_NAME = "run-worker"; +/** Temporal endpoint a Pod in this cluster reaches the local server on. */ +export const IN_CLUSTER_TEMPORAL_ADDRESS = `temporal.${SYSTEM_NAMESPACE}.svc.cluster.local:7233`; + +const RUN_WORKER_ENTRYPOINT = "/opt/moltzap/dist/cluster/temporal.js"; +const CONTROLLER_PORT = 3_000; +const CONTROLLER_ENTRYPOINT = "/opt/moltzap/dist/cluster/controller/main.js"; +const EXPERIMENT_DIRECTORY = "/opt/moltzap/experiment"; +const EXPERIMENT_PATH = `${EXPERIMENT_DIRECTORY}/main.mjs`; +const LOCAL_LEDGER_DIRECTORY = "/var/lib/moltzap/ledger"; +const CONTROLLER_USER_ID = 1_000; +const GKE_GCS_FUSE_ANNOTATION = "gke-gcsfuse/volumes"; +const GKE_GCS_FUSE_DRIVER = "gcsfuse.csi.storage.gke.io"; +const GKE_GCS_FUSE_MOUNT_OPTIONS = + "uid=1000,gid=1000,file-mode=0640,dir-mode=0750"; +const GKE_ARTIFACT_MOUNT_PATH = "/var/lib/moltzap-artifacts"; + +/** Objects created after the run root establishes owner identity. */ +export interface OwnedRunControlManifests { + readonly experiment: V1ConfigMap; + readonly localQueue: KubernetesManifest; + readonly serviceAccount: V1ServiceAccount; + readonly role: V1Role; + readonly roleBinding: V1RoleBinding; + readonly routerService: V1Service; + readonly controllerJob: V1Job; +} + +function runAnnotations(runId: string): Readonly> { + return { "moltzap.dev/run-id": runId }; +} + +function controllerLabels(): Readonly> { + return { + "app.kubernetes.io/name": "moltzap-simulator-controller", + "app.kubernetes.io/managed-by": "moltzap-simulator", + }; +} + +function runOwnerReference(uid: string): V1OwnerReference { + return { + apiVersion: "v1", + kind: "ConfigMap", + name: RUN_OWNER_NAME, + uid, + controller: true, + blockOwnerDeletion: true, + }; +} + +/** + * Build the Namespace that contains every Kubernetes object for one run. + * @param input Workflow input carrying the caller-selected namespace and run ID. + * @returns A Namespace manifest owned by the surrounding cluster authority. + */ +export function runNamespaceManifest( + input: RunSocietyWorkflowInput, +): V1Namespace { + return { + apiVersion: "v1", + kind: "Namespace", + metadata: { + name: input.namespace, + annotations: runAnnotations(input.runId), + labels: { "app.kubernetes.io/managed-by": "moltzap-simulator" }, + }, + }; +} + +/** + * Build the root object whose UID owns the run's namespaced control objects. + * @param input Workflow input carrying the target namespace and run ID. + * @returns An immutable ConfigMap used only as the run ownership root. + */ +export function runOwnerManifest(input: RunSocietyWorkflowInput): V1ConfigMap { + return { + apiVersion: "v1", + kind: "ConfigMap", + immutable: true, + metadata: { + name: RUN_OWNER_NAME, + namespace: input.namespace, + annotations: runAnnotations(input.runId), + }, + }; +} + +function controllerEnvironment( + input: RunSocietyWorkflowInput, + ownerUid: string, + profile: KubernetesExecutionProfile, +) { + return [ + { name: "MOLTZAP_RUN_NAMESPACE", value: input.namespace }, + { name: "MOLTZAP_RUN_QUEUE", value: LOCAL_QUEUE_NAME }, + { name: "MOLTZAP_RUN_OWNER_NAME", value: RUN_OWNER_NAME }, + { name: "MOLTZAP_RUN_OWNER_UID", value: ownerUid }, + { name: "MOLTZAP_SUPPORT_IMAGE", value: input.supportImage }, + ...(input.runtimeCredentials === undefined + ? [] + : [ + { + name: "MOLTZAP_RUNTIME_CREDENTIALS", + value: JSON.stringify(input.runtimeCredentials), + }, + ]), + { name: "MOLTZAP_EXPERIMENT_MODULE", value: EXPERIMENT_PATH }, + ...(input.startupTimeoutMs === undefined + ? [] + : [ + { + name: "MOLTZAP_STARTUP_TIMEOUT_MS", + value: String(input.startupTimeoutMs), + }, + ]), + ...(input.cohortSize === undefined + ? [] + : [{ name: "MOLTZAP_COHORT_SIZE", value: String(input.cohortSize) }]), + { name: "MOLTZAP_LEDGER_DIRECTORY", value: LOCAL_LEDGER_DIRECTORY }, + ...(profile.kind === "gke" + ? [ + { + name: "MOLTZAP_LEDGER_EXPORT_DIRECTORY", + value: `${GKE_ARTIFACT_MOUNT_PATH}/${input.namespace}/ledger`, + }, + { + name: "MOLTZAP_ROSTER_PLACEMENT", + value: JSON.stringify(profile.rosterPlacement), + }, + ] + : []), + { + name: "MOLTZAP_ROUTER_URL", + value: `ws://${ROUTER_SERVICE_NAME}.${input.namespace}.svc.cluster.local:${String(CONTROLLER_PORT)}`, + }, + ]; +} + +function experimentManifest( + input: RunSocietyWorkflowInput, + owner: V1OwnerReference, +): V1ConfigMap { + return { + apiVersion: "v1", + kind: "ConfigMap", + immutable: true, + metadata: { + name: EXPERIMENT_CONFIG_NAME, + namespace: input.namespace, + ownerReferences: [owner], + }, + data: { "main.mjs": input.experimentModule }, + }; +} + +function localQueueManifest( + input: RunSocietyWorkflowInput, + owner: V1OwnerReference, +): KubernetesManifest { + return { + apiVersion: "kueue.x-k8s.io/v1beta2", + kind: "LocalQueue", + metadata: { + name: LOCAL_QUEUE_NAME, + namespace: input.namespace, + ownerReferences: [owner], + }, + spec: { clusterQueue: CLUSTER_QUEUE_NAME }, + }; +} + +function controllerServiceAccount( + input: RunSocietyWorkflowInput, + owner: V1OwnerReference, +): V1ServiceAccount { + return { + apiVersion: "v1", + kind: "ServiceAccount", + metadata: { + name: CONTROLLER_NAME, + namespace: input.namespace, + ownerReferences: [owner], + }, + }; +} + +function controllerRole( + input: RunSocietyWorkflowInput, + owner: V1OwnerReference, +): V1Role { + return { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "Role", + metadata: { + name: CONTROLLER_NAME, + namespace: input.namespace, + ownerReferences: [owner], + }, + rules: [ + { + apiGroups: ["kueue.x-k8s.io"], + resources: ["workloads"], + verbs: ["create", "get", "delete"], + }, + { + apiGroups: ["agents.x-k8s.io"], + resources: ["sandboxes"], + verbs: ["create", "get", "delete"], + }, + { + apiGroups: [""], + resources: ["secrets"], + verbs: ["create", "delete"], + }, + { + apiGroups: [""], + resources: ["configmaps"], + resourceNames: [RUN_OWNER_NAME], + verbs: ["get", "delete"], + }, + { + apiGroups: [""], + resources: ["pods"], + verbs: ["get", "list"], + }, + { + apiGroups: [""], + resources: ["pods/log"], + verbs: ["get"], + }, + ], + }; +} + +function controllerRoleBinding( + input: RunSocietyWorkflowInput, + owner: V1OwnerReference, +): V1RoleBinding { + return { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "RoleBinding", + metadata: { + name: CONTROLLER_NAME, + namespace: input.namespace, + ownerReferences: [owner], + }, + roleRef: { + apiGroup: "rbac.authorization.k8s.io", + kind: "Role", + name: CONTROLLER_NAME, + }, + subjects: [ + { + apiGroup: "", + kind: "ServiceAccount", + name: CONTROLLER_NAME, + namespace: input.namespace, + }, + ], + }; +} + +function routerService( + input: RunSocietyWorkflowInput, + owner: V1OwnerReference, +): V1Service { + return { + apiVersion: "v1", + kind: "Service", + metadata: { + name: ROUTER_SERVICE_NAME, + namespace: input.namespace, + ownerReferences: [owner], + }, + spec: { + selector: controllerLabels(), + ports: [ + { + name: "router", + port: CONTROLLER_PORT, + protocol: "TCP", + targetPort: CONTROLLER_PORT, + }, + ], + }, + }; +} + +function controllerContainer( + input: RunSocietyWorkflowInput, + owner: V1OwnerReference, + profile: KubernetesExecutionProfile, +): V1Container { + return { + name: CONTROLLER_NAME, + image: input.controllerImage, + command: ["node", CONTROLLER_ENTRYPOINT], + env: controllerEnvironment(input, owner.uid, profile), + ports: [ + { + name: "router", + containerPort: CONTROLLER_PORT, + protocol: "TCP", + }, + ], + terminationMessagePolicy: "FallbackToLogsOnError", + volumeMounts: [ + { + name: "experiment", + mountPath: EXPERIMENT_DIRECTORY, + readOnly: true, + }, + { + name: "ledger", + mountPath: LOCAL_LEDGER_DIRECTORY, + }, + ...(profile.kind === "gke" + ? [ + { + name: "artifacts", + mountPath: GKE_ARTIFACT_MOUNT_PATH, + }, + ] + : []), + ], + }; +} + +function controllerVolumes( + input: RunSocietyWorkflowInput, + profile: KubernetesExecutionProfile, +): V1Volume[] { + return [ + { + name: "experiment", + configMap: { + name: EXPERIMENT_CONFIG_NAME, + defaultMode: 0o444, + }, + }, + { + name: "ledger", + ...(profile.kind === "local" + ? { + hostPath: { + path: `${GKE_ARTIFACT_MOUNT_PATH}/${input.namespace}/ledger`, + type: "DirectoryOrCreate", + }, + } + : { + emptyDir: {}, + }), + }, + ...(profile.kind === "gke" + ? [ + { + name: "artifacts", + csi: { + driver: GKE_GCS_FUSE_DRIVER, + readOnly: false, + volumeAttributes: { + bucketName: profile.artifactBucket, + mountOptions: GKE_GCS_FUSE_MOUNT_OPTIONS, + }, + }, + }, + ] + : []), + ]; +} + +function ledgerPermissionsContainer( + input: RunSocietyWorkflowInput, +): V1Container { + return { + name: "ledger-permissions", + image: input.controllerImage, + command: ["chown"], + args: [ + `${String(CONTROLLER_USER_ID)}:${String(CONTROLLER_USER_ID)}`, + LOCAL_LEDGER_DIRECTORY, + ], + securityContext: { + allowPrivilegeEscalation: false, + capabilities: { add: ["CHOWN"], drop: ["ALL"] }, + readOnlyRootFilesystem: true, + runAsNonRoot: false, + runAsUser: 0, + }, + volumeMounts: [{ name: "ledger", mountPath: LOCAL_LEDGER_DIRECTORY }], + }; +} + +function controllerJob( + input: RunSocietyWorkflowInput, + owner: V1OwnerReference, + profile: KubernetesExecutionProfile, +): V1Job { + return { + apiVersion: "batch/v1", + kind: "Job", + metadata: { + name: CONTROLLER_NAME, + namespace: input.namespace, + ownerReferences: [owner], + }, + spec: { + backoffLimit: 0, + template: { + metadata: { + labels: controllerLabels(), + ...(profile.kind === "gke" + ? { annotations: { [GKE_GCS_FUSE_ANNOTATION]: "true" } } + : {}), + }, + spec: { + automountServiceAccountToken: true, + enableServiceLinks: false, + restartPolicy: "Never", + serviceAccountName: CONTROLLER_NAME, + initContainers: [ledgerPermissionsContainer(input)], + containers: [controllerContainer(input, owner, profile)], + volumes: controllerVolumes(input, profile), + }, + }, + }, + }; +} + +/** + * Build every owned object needed before the in-cluster controller starts. + * @param input Serializable workflow input projected into Kubernetes manifests. + * @param ownerUid UID returned by the run root ConfigMap creation. + * @param profile Private storage and placement projection selected by the host. + * @returns The complete set of namespaced control objects created before the Job. + */ +export function ownedRunControlManifests( + input: RunSocietyWorkflowInput, + ownerUid: string, + profile: KubernetesExecutionProfile = LOCAL_KUBERNETES_EXECUTION_PROFILE, +): OwnedRunControlManifests { + const owner = runOwnerReference(ownerUid); + return { + experiment: experimentManifest(input, owner), + localQueue: localQueueManifest(input, owner), + serviceAccount: controllerServiceAccount(input, owner), + role: controllerRole(input, owner), + roleBinding: controllerRoleBinding(input, owner), + routerService: routerService(input, owner), + controllerJob: controllerJob(input, owner, profile), + }; +} + +/** Cluster-wide identity and workload serving the run-lifecycle task queue. */ +export interface RunWorkerManifests { + readonly namespace: V1Namespace; + readonly serviceAccount: V1ServiceAccount; + readonly clusterRole: V1ClusterRole; + readonly clusterRoleBinding: V1ClusterRoleBinding; + readonly deployment: V1Deployment; +} + +/** Everything the worker needs that the host, not the cluster, decides. */ +export interface RunWorkerOptions { + readonly controllerImage: string; + readonly taskQueue: string; + readonly temporalAddress: string; + readonly temporalNamespace: string; + readonly profile: KubernetesExecutionProfile; +} + +function runWorkerLabels(): Readonly> { + return { + "app.kubernetes.io/name": "moltzap-simulator-run-worker", + "app.kubernetes.io/managed-by": "moltzap-simulator", + }; +} + +function runWorkerNamespace(): V1Namespace { + return { + apiVersion: "v1", + kind: "Namespace", + metadata: { + name: SYSTEM_NAMESPACE, + labels: { "app.kubernetes.io/managed-by": "moltzap-simulator" }, + }, + }; +} + +function runWorkerServiceAccount(): V1ServiceAccount { + return { + apiVersion: "v1", + kind: "ServiceAccount", + metadata: { + name: RUN_WORKER_NAME, + namespace: SYSTEM_NAMESPACE, + labels: runWorkerLabels(), + }, + }; +} + +type PolicyRules = NonNullable; + +// Deleting a namespace is the permission that lets the worker reclaim a run +// whose submitter is gone, which is the reason the worker exists. It cannot be +// narrowed: a run's namespace name is generated at submission, so no name is +// knowable when this role is written, and RBAC has no way to scope a verb by +// label. The breadth is accepted rather than worked around. +function reclamationRules(): PolicyRules { + return [ + { + apiGroups: [""], + resources: ["namespaces"], + verbs: ["create", "get", "list", "watch", "delete"], + }, + ]; +} + +// What preparing one run creates before the controller starts, and what reading +// the controller's outcome needs. Generated namespace names rule out +// `resourceNames` here for the same reason. +function runPreparationRules(): PolicyRules { + return [ + { + apiGroups: [""], + resources: ["configmaps"], + verbs: ["create", "get", "delete"], + }, + { + apiGroups: [""], + resources: ["serviceaccounts", "services"], + verbs: ["create"], + }, + { apiGroups: [""], resources: ["pods"], verbs: ["get", "list"] }, + { apiGroups: [""], resources: ["pods/log"], verbs: ["get"] }, + { apiGroups: ["batch"], resources: ["jobs"], verbs: ["create", "get"] }, + { + apiGroups: ["kueue.x-k8s.io"], + resources: ["localqueues"], + verbs: ["create"], + }, + { + apiGroups: ["rbac.authorization.k8s.io"], + resources: ["roles", "rolebindings"], + verbs: ["create"], + }, + ]; +} + +// Kubernetes refuses to let a subject create a Role carrying permissions the +// subject does not itself hold, so the run-scoped controller Role is a lower +// bound on what the worker must be granted. +function delegatedControllerRules(): PolicyRules { + return [ + { apiGroups: [""], resources: ["secrets"], verbs: ["create", "delete"] }, + { + apiGroups: ["kueue.x-k8s.io"], + resources: ["workloads"], + verbs: ["create", "get", "delete"], + }, + { + apiGroups: ["agents.x-k8s.io"], + resources: ["sandboxes"], + verbs: ["create", "get", "delete"], + }, + ]; +} + +function runWorkerClusterRole(): V1ClusterRole { + return { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "ClusterRole", + metadata: { name: RUN_WORKER_NAME, labels: runWorkerLabels() }, + rules: [ + ...reclamationRules(), + ...runPreparationRules(), + ...delegatedControllerRules(), + ], + }; +} + +function runWorkerClusterRoleBinding(): V1ClusterRoleBinding { + return { + apiVersion: "rbac.authorization.k8s.io/v1", + kind: "ClusterRoleBinding", + metadata: { name: RUN_WORKER_NAME, labels: runWorkerLabels() }, + roleRef: { + apiGroup: "rbac.authorization.k8s.io", + kind: "ClusterRole", + name: RUN_WORKER_NAME, + }, + subjects: [ + { + apiGroup: "", + kind: "ServiceAccount", + name: RUN_WORKER_NAME, + namespace: SYSTEM_NAMESPACE, + }, + ], + }; +} + +function runWorkerContainer(options: RunWorkerOptions): V1Container { + return { + name: RUN_WORKER_NAME, + image: options.controllerImage, + command: ["node", RUN_WORKER_ENTRYPOINT], + env: [ + { name: "MOLTZAP_TEMPORAL_ADDRESS", value: options.temporalAddress }, + { name: "MOLTZAP_TEMPORAL_NAMESPACE", value: options.temporalNamespace }, + { name: "MOLTZAP_TEMPORAL_TASK_QUEUE", value: options.taskQueue }, + { + name: "MOLTZAP_EXECUTION_PROFILE", + value: encodeKubernetesExecutionProfile(options.profile), + }, + ], + terminationMessagePolicy: "FallbackToLogsOnError", + resources: { requests: { cpu: "100m", memory: "256Mi" } }, + securityContext: { + allowPrivilegeEscalation: false, + capabilities: { drop: ["ALL"] }, + runAsNonRoot: true, + runAsUser: CONTROLLER_USER_ID, + }, + }; +} + +function runWorkerDeployment(options: RunWorkerOptions): V1Deployment { + return { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { + name: RUN_WORKER_NAME, + namespace: SYSTEM_NAMESPACE, + labels: runWorkerLabels(), + }, + spec: { + replicas: 1, + selector: { matchLabels: runWorkerLabels() }, + template: { + metadata: { labels: runWorkerLabels() }, + spec: { + automountServiceAccountToken: true, + enableServiceLinks: false, + serviceAccountName: RUN_WORKER_NAME, + containers: [runWorkerContainer(options)], + }, + }, + }, + }; +} + +/** + * Build the cluster-resident worker that serves the run-lifecycle task queue. + * + * The worker is a Deployment rather than a process inside whichever host + * submitted the run: the workflow's cleanup only runs where a worker is + * polling, so a queue served by the submitter leaves every abandoned run's + * namespace behind. + * + * @param options Host-selected image, Temporal endpoint, queue, and profile. + * @returns The namespace, identity, permissions, and workload to install. + */ +export function runWorkerManifests( + options: RunWorkerOptions, +): RunWorkerManifests { + return { + namespace: runWorkerNamespace(), + serviceAccount: runWorkerServiceAccount(), + clusterRole: runWorkerClusterRole(), + clusterRoleBinding: runWorkerClusterRoleBinding(), + deployment: runWorkerDeployment(options), + }; +} diff --git a/packages/simulator/src/cluster/kubernetes/objects.types-check.ts b/packages/simulator/src/cluster/kubernetes/objects.types-check.ts new file mode 100644 index 000000000..fcaa180bd --- /dev/null +++ b/packages/simulator/src/cluster/kubernetes/objects.types-check.ts @@ -0,0 +1,25 @@ +/** + * A capacity reservation carries at least one runtime. An empty roster would + * otherwise reach the cluster as a Workload admitting nothing, so the manifest + * builder refuses it in its parameter type rather than at call time. + */ + +import type { aggregateWorkloadManifest, ReservedCapacity } from "./objects.js"; + +type Equal = [Left, Right] extends [Right, Left] ? true : false; +type Expect = Value; + +type AggregateSlots = Parameters[0]["slots"]; + +type ReservationSlotsAreNonEmpty = Expect< + Equal +>; +type EmptyReservationIsUnrepresentable = Expect< + Equal +>; + +/** Compile-time assertions for the aggregate capacity reservation. */ +export type AggregateCapacityCanaries = [ + ReservationSlotsAreNonEmpty, + EmptyReservationIsUnrepresentable, +]; diff --git a/packages/simulator/src/cluster/profile.ts b/packages/simulator/src/cluster/profile.ts new file mode 100644 index 000000000..90b8ded8e --- /dev/null +++ b/packages/simulator/src/cluster/profile.ts @@ -0,0 +1,85 @@ +/** @file Private execution profiles for the one Kubernetes simulator path. */ + +import { Schema } from "effect"; + +/** Placement projected onto both reserved capacity and actual application Pods. */ +export interface KubernetesPodPlacement { + readonly nodeSelector: Readonly>; + readonly tolerations: ReadonlyArray<{ + readonly key: string; + readonly operator: "Equal"; + readonly value: string; + readonly effect: "NoSchedule"; + }>; +} + +/** Host-mounted artifact storage used by the repository's kind profile. */ +interface LocalKubernetesExecutionProfile { + readonly kind: "local"; +} + +/** GKE-specific host configuration kept outside Temporal workflow input. */ +interface GkeKubernetesExecutionProfile { + readonly kind: "gke"; + readonly artifactBucket: string; + readonly kubeContext: string; + readonly rosterPlacement: KubernetesPodPlacement; +} + +/** Closed cluster choice for the shared Kubernetes execution path. */ +export type KubernetesExecutionProfile = + | LocalKubernetesExecutionProfile + | GkeKubernetesExecutionProfile; + +/** Default profile preserving the repository-local kind behavior. */ +export const LOCAL_KUBERNETES_EXECUTION_PROFILE: LocalKubernetesExecutionProfile = + Object.freeze({ kind: "local" }); + +const podPlacementSchema = Schema.Struct({ + nodeSelector: Schema.Record({ key: Schema.String, value: Schema.String }), + tolerations: Schema.Array( + Schema.Struct({ + key: Schema.String, + operator: Schema.Literal("Equal"), + value: Schema.String, + effect: Schema.Literal("NoSchedule"), + }), + ), +}); + +const executionProfileSchema = Schema.Union( + Schema.Struct({ kind: Schema.Literal("local") }), + Schema.Struct({ + kind: Schema.Literal("gke"), + artifactBucket: Schema.String, + kubeContext: Schema.String, + rosterPlacement: podPlacementSchema, + }), +); + +const decodeProfile = Schema.decodeUnknownSync( + Schema.parseJson(executionProfileSchema), +); + +/** + * Encode the host's cluster choice for a process that cannot be given it + * as an argument. + * @param profile Host-selected local or GKE cluster. + * @returns The JSON form carried in an in-cluster process environment. + */ +export function encodeKubernetesExecutionProfile( + profile: KubernetesExecutionProfile, +): string { + return JSON.stringify(profile); +} + +/** + * Read back the profile an in-cluster process was started with. + * @param source JSON produced by `encodeKubernetesExecutionProfile`. + * @returns The closed cluster choice, or a throw naming the mismatch. + */ +export function decodeKubernetesExecutionProfile( + source: string, +): KubernetesExecutionProfile { + return decodeProfile(source); +} diff --git a/packages/simulator/src/cluster/profiles/gke.test.ts b/packages/simulator/src/cluster/profiles/gke.test.ts new file mode 100644 index 000000000..2bcb00958 --- /dev/null +++ b/packages/simulator/src/cluster/profiles/gke.test.ts @@ -0,0 +1,153 @@ +import { assert, effect as test } from "@effect/vitest"; +import { Effect, Layer, Schema } from "effect"; +import { CompletedLedgerReceipt } from "../../run/execute.js"; +import { + LedgerCompletion, + ledgerDigest, + ledgerRef, +} from "../../ledger/schema.js"; +import { programFinishedSummary } from "../controller/summary.js"; +import { + SubmitOperations, + type RunEnvironment, + type RunSubmission, + type SubmitOperationsService, +} from "../submit.js"; +import type { RunTemporalSocietyOptions } from "../temporal.js"; +import { gkeExecutionProfileFromConfiguration, runGkeSociety } from "./gke.js"; + +const PLACEMENT = { + nodeSelector: { "moltzap.dev/pool": "agents" }, + tolerations: [ + { + key: "moltzap.dev/agents", + operator: "Equal", + value: "true", + effect: "NoSchedule", + }, + ], +} as const; +const PROFILE_SOURCE = JSON.stringify({ + apiVersion: "moltzap.gke-profile/v1", + cluster: { contextEnvironment: "MOLTZAP_KUBE_CONTEXT" }, + rosterPlacement: { + applyTo: ["aggregateWorkloadPodSets", "sandboxPodTemplates"], + ...PLACEMENT, + }, + ledger: { + active: { + kind: "empty-dir", + volume: { name: "ledger", emptyDir: {} }, + mountPath: "/var/lib/moltzap/ledger", + permissionsInitContainer: true, + }, + retained: { + kind: "gcs-fuse-csi-ephemeral", + bucketEnvironment: "MOLTZAP_GKE_ARTIFACT_BUCKET", + podAnnotations: { "gke-gcsfuse/volumes": "true" }, + volume: { + name: "artifacts", + csi: { + driver: "gcsfuse.csi.storage.gke.io", + readOnly: false, + volumeAttributes: { + mountOptions: "uid=1000,gid=1000,file-mode=0640,dir-mode=0750", + }, + }, + }, + mountPath: "/var/lib/moltzap-artifacts", + directoryTemplate: "/var/lib/moltzap-artifacts/{runNamespace}/ledger", + publicationOrder: ["manifest.json", "records.ndjson", "completion.json"], + }, + }, +}); +const ENVIRONMENT: RunEnvironment = Object.freeze({ + MOLTZAP_CONTROLLER_IMAGE: `controller@sha256:${"a".repeat(64)}`, + MOLTZAP_GKE_ARTIFACT_BUCKET: "moltzap-artifacts-test", + MOLTZAP_KUBE_CONTEXT: "gke_project_region_cluster", + MOLTZAP_TEMPORAL_ADDRESS: "temporal.example:7233", +}); +const RUN_UUID = "12345678-1234-4abc-8def-1234567890ab"; +const EXPECTED_RUN_ID = `mz-${RUN_UUID.replaceAll("-", "")}`; +const DIGEST = Schema.decodeSync(ledgerDigest)("b".repeat(64)); +const RESULT: RunSubmission = { + runId: "mz-run", + namespace: "mz-run", + result: { + exitCode: 0, + summary: programFinishedSummary( + CompletedLedgerReceipt.make({ + ledger: Schema.decodeSync(ledgerRef)("gke-main-test-ledger"), + completion: LedgerCompletion.make({ + ledgerFormatVersion: 1, + runId: "gke-main-test-run", + recordCount: 0, + artifacts: { manifest: DIGEST, records: DIGEST }, + }), + }), + ), + }, +}; + +test("binds the checked-in GKE shape to operator-selected identities", () => + Effect.sync(() => { + const profile = gkeExecutionProfileFromConfiguration( + PROFILE_SOURCE, + ENVIRONMENT, + ); + + assert.deepStrictEqual(profile, { + kind: "gke", + artifactBucket: "moltzap-artifacts-test", + kubeContext: "gke_project_region_cluster", + rosterPlacement: PLACEMENT, + }); + })); + +test("submits once through the shared Kubernetes society entry", () => + Effect.gen(function* () { + let observedTemporal: RunTemporalSocietyOptions | undefined; + // One read seam serves both files the GKE profile submits: its checked-in + // profile JSON and the experiment entrypoint. + const operations: SubmitOperationsService = { + readTextFile: (path) => + Effect.succeed( + path.endsWith(".mjs") ? "export const runSpec = {};" : PROFILE_SOURCE, + ), + randomUuid: () => RUN_UUID, + runTemporalSociety: (options) => { + observedTemporal = options; + return Promise.resolve(RESULT.result); + }, + }; + + const result = yield* runGkeSociety(["./experiment.mjs"], ENVIRONMENT).pipe( + Effect.provide(Layer.succeed(SubmitOperations, operations)), + ); + + assert.strictEqual(result.runId, EXPECTED_RUN_ID); + assert.deepStrictEqual(result.result, RESULT.result); + assert.strictEqual(observedTemporal?.executionProfile?.kind, "gke"); + assert.deepStrictEqual( + observedTemporal?.executionProfile?.kind === "gke" + ? observedTemporal.executionProfile.rosterPlacement + : undefined, + PLACEMENT, + ); + })); + +test("requires the bucket, explicit kube context, and Temporal endpoint", () => + Effect.sync(() => { + for (const key of [ + "MOLTZAP_GKE_ARTIFACT_BUCKET", + "MOLTZAP_KUBE_CONTEXT", + "MOLTZAP_TEMPORAL_ADDRESS", + ]) { + assert.throws(() => + gkeExecutionProfileFromConfiguration(PROFILE_SOURCE, { + ...ENVIRONMENT, + [key]: undefined, + }), + ); + } + })); diff --git a/packages/simulator/src/cluster/profiles/gke.ts b/packages/simulator/src/cluster/profiles/gke.ts new file mode 100644 index 000000000..c1ba3d046 --- /dev/null +++ b/packages/simulator/src/cluster/profiles/gke.ts @@ -0,0 +1,215 @@ +/** @file GKE entry point for the shared Temporal-managed Kubernetes run. */ + +import { resolve } from "node:path"; +import { NodeRuntime } from "@effect/platform-node"; +import { Effect, Either, Schema } from "effect"; +import { isEntryModule } from "../entry.js"; +import { ledgerArtifactFiles } from "../../ledger/storage.js"; +import type { KubernetesExecutionProfile } from "../profile.js"; +import { + liveSubmitOperations, + RunSubmissionError, + runKubernetesSociety, + SubmitOperations, + type RunEnvironment, + type RunSubmission, +} from "../submit.js"; + +const PROFILE_PATH = resolve("gke/profile.json"); +const BUCKET_NAME = /^[a-z0-9][a-z0-9._-]{1,61}[a-z0-9]$/u; +const GKE_GCS_FUSE_ANNOTATION = "gke-gcsfuse/volumes"; +const GKE_GCS_FUSE_DRIVER = "gcsfuse.csi.storage.gke.io"; +const GKE_GCS_FUSE_MOUNT_OPTIONS = + "uid=1000,gid=1000,file-mode=0640,dir-mode=0750"; +const GKE_ACTIVE_LEDGER_PATH = "/var/lib/moltzap/ledger"; +const GKE_ARTIFACT_MOUNT_PATH = "/var/lib/moltzap-artifacts"; +type GkeKubernetesExecutionProfile = Extract< + KubernetesExecutionProfile, + { readonly kind: "gke" } +>; + +const runtimeProfileSchema = Schema.Struct({ + apiVersion: Schema.Literal("moltzap.gke-profile/v1"), + cluster: Schema.Struct({ + contextEnvironment: Schema.Literal("MOLTZAP_KUBE_CONTEXT"), + }), + rosterPlacement: Schema.Struct({ + applyTo: Schema.Tuple( + Schema.Literal("aggregateWorkloadPodSets"), + Schema.Literal("sandboxPodTemplates"), + ), + nodeSelector: Schema.Record({ + key: Schema.NonEmptyString, + value: Schema.NonEmptyString, + }), + tolerations: Schema.Array( + Schema.Struct({ + key: Schema.NonEmptyString, + operator: Schema.Literal("Equal"), + value: Schema.NonEmptyString, + effect: Schema.Literal("NoSchedule"), + }), + ), + }), + ledger: Schema.Struct({ + active: Schema.Struct({ + kind: Schema.Literal("empty-dir"), + volume: Schema.Struct({ + name: Schema.Literal("ledger"), + emptyDir: Schema.Struct({}), + }), + mountPath: Schema.Literal(GKE_ACTIVE_LEDGER_PATH), + permissionsInitContainer: Schema.Literal(true), + }), + retained: Schema.Struct({ + kind: Schema.Literal("gcs-fuse-csi-ephemeral"), + bucketEnvironment: Schema.Literal("MOLTZAP_GKE_ARTIFACT_BUCKET"), + podAnnotations: Schema.Struct({ + [GKE_GCS_FUSE_ANNOTATION]: Schema.Literal("true"), + }), + volume: Schema.Struct({ + name: Schema.Literal("artifacts"), + csi: Schema.Struct({ + driver: Schema.Literal(GKE_GCS_FUSE_DRIVER), + readOnly: Schema.Literal(false), + volumeAttributes: Schema.Struct({ + mountOptions: Schema.Literal(GKE_GCS_FUSE_MOUNT_OPTIONS), + }), + }), + }), + mountPath: Schema.Literal(GKE_ARTIFACT_MOUNT_PATH), + directoryTemplate: Schema.Literal( + `${GKE_ARTIFACT_MOUNT_PATH}/{runNamespace}/ledger`, + ), + publicationOrder: Schema.Tuple( + Schema.Literal(ledgerArtifactFiles.manifest), + Schema.Literal(ledgerArtifactFiles.records), + Schema.Literal(ledgerArtifactFiles.completion), + ), + }), + }), +}); +const decodeRuntimeProfile = Schema.decodeEither( + Schema.parseJson(runtimeProfileSchema), +); + +function configurationFailure(detail: string): RunSubmissionError { + return new RunSubmissionError({ stage: "configuration", detail }); +} + +function required(environment: RunEnvironment, key: string): string { + const value = environment[key]; + if (value === undefined || value.length === 0) { + throw configurationFailure(`${key} is required by the GKE profile`); + } + return value; +} + +function checkedRuntimeProfile(source: string) { + return Either.match(decodeRuntimeProfile(source), { + onLeft: () => { + throw configurationFailure( + "gke/profile.json does not match the supported execution profile", + ); + }, + onRight: (value) => value, + }); +} + +function checkedArtifactBucket(environment: RunEnvironment): string { + const artifactBucket = required(environment, "MOLTZAP_GKE_ARTIFACT_BUCKET"); + if (!BUCKET_NAME.test(artifactBucket)) { + throw configurationFailure( + "MOLTZAP_GKE_ARTIFACT_BUCKET must be a valid Cloud Storage bucket name", + ); + } + return artifactBucket; +} + +/** + * Validate the checked-in profile and bind its dynamic cloud identities. + * @param source Complete checked-in GKE profile JSON. + * @param environment Operator-selected bucket, context, and Temporal endpoint. + * @returns The private profile consumed by the existing Temporal path. + */ +export function gkeExecutionProfileFromConfiguration( + source: string, + environment: RunEnvironment, +): GkeKubernetesExecutionProfile { + const profile = checkedRuntimeProfile(source); + if ( + Object.keys(profile.rosterPlacement.nodeSelector).length === 0 || + profile.rosterPlacement.tolerations.length === 0 + ) { + throw configurationFailure( + "gke/profile.json must place both capacity and application Pods", + ); + } + + required(environment, "MOLTZAP_TEMPORAL_ADDRESS"); + + return Object.freeze({ + kind: "gke", + artifactBucket: checkedArtifactBucket(environment), + kubeContext: required(environment, "MOLTZAP_KUBE_CONTEXT"), + rosterPlacement: Object.freeze({ + nodeSelector: Object.freeze({ + ...profile.rosterPlacement.nodeSelector, + }), + tolerations: Object.freeze( + profile.rosterPlacement.tolerations.map((toleration) => + Object.freeze({ ...toleration }), + ), + ), + }), + }); +} + +/** + * Run one GKE experiment through the same Temporal submission used locally. + * @param args One `.mjs` RunSpec entrypoint. + * @param environment GKE identities plus shared image and Temporal settings. + * @returns The coarse run result and ephemeral run identity. + */ +export function runGkeSociety( + args: readonly string[], + environment: RunEnvironment, +): Effect.Effect { + return Effect.flatMap(SubmitOperations, (operations) => + operations.readTextFile(PROFILE_PATH), + ).pipe( + Effect.mapError(() => + configurationFailure("gke/profile.json could not be read"), + ), + Effect.flatMap((source) => + Effect.try({ + try: () => gkeExecutionProfileFromConfiguration(source, environment), + catch: (cause) => + cause instanceof RunSubmissionError + ? cause + : configurationFailure("the GKE profile was invalid"), + }), + ), + Effect.flatMap((profile) => + runKubernetesSociety(args, environment, profile), + ), + Effect.withSpan("runGkeSociety"), + ); +} + +// eslint-disable-next-line agent-code-guard/prefer-effect-platform -- Direct-entry detection has no Effect Platform equivalent. +if (isEntryModule(import.meta.url, process.argv[1])) { + // eslint-disable-next-line agent-code-guard/prefer-effect-platform -- The executable boundary captures argv once before entering Effect. + const args = process.argv.slice(2); + // eslint-disable-next-line agent-code-guard/no-process-env-at-runtime -- The executable boundary injects the environment into the typed GKE configuration. + const environment = process.env; + runGkeSociety(args, environment).pipe( + Effect.tap((result) => + Effect.sync(() => { + process.stdout.write(`${JSON.stringify(result)}\n`); + }), + ), + Effect.provide(liveSubmitOperations), + NodeRuntime.runMain, + ); +} diff --git a/packages/simulator/src/cluster/profiles/local.test.ts b/packages/simulator/src/cluster/profiles/local.test.ts new file mode 100644 index 000000000..c49a73304 --- /dev/null +++ b/packages/simulator/src/cluster/profiles/local.test.ts @@ -0,0 +1,139 @@ +import { assert, effect as test } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { CompletedLedgerReceipt } from "../../run/execute.js"; +import { + LedgerCompletion, + ledgerDigest, + ledgerRef, +} from "../../ledger/schema.js"; +import { programFinishedSummary } from "../controller/summary.js"; +import type { RunControllerResult } from "../reclaim.js"; +import type { RunTemporalSocietyOptions } from "../temporal.js"; +import { + RunSubmissionError, + SubmitOperations, + SUBMIT_STAGE, + DEFAULT_LOCAL_TASK_QUEUE, + type RunEnvironment, + type SubmitOperationsService, +} from "../submit.js"; +import { runLocalSociety } from "./local.js"; + +const DIGEST = "a".repeat(64); +const CONTROLLER_IMAGE = `moltzap-controller@sha256:${DIGEST}`; +const UUID = "12345678-1234-4abc-8def-1234567890ab"; +const MODULE_SOURCE = "export const runSpec = {};"; +const LEDGER_DIGEST = Schema.decodeSync(ledgerDigest)("b".repeat(64)); +const CONTROLLER_RESULT: RunControllerResult = { + exitCode: 0, + summary: programFinishedSummary( + CompletedLedgerReceipt.make({ + ledger: Schema.decodeSync(ledgerRef)("local-main-test-ledger"), + completion: LedgerCompletion.make({ + ledgerFormatVersion: 1, + runId: "local-main-test-run", + recordCount: 0, + artifacts: { + manifest: LEDGER_DIGEST, + records: LEDGER_DIGEST, + }, + }), + }), + ), +}; + +const environment: RunEnvironment = Object.freeze({ + MOLTZAP_CONTROLLER_IMAGE: CONTROLLER_IMAGE, + MOLTZAP_TEMPORAL_ADDRESS: "127.0.0.1:7233", + OPENAI_API_KEY: "openai-test-credential", +}); + +function operations( + observe?: (options: RunTemporalSocietyOptions) => void, +): SubmitOperationsService { + return { + readTextFile: () => Effect.succeed(MODULE_SOURCE), + randomUuid: () => UUID, + runTemporalSociety: (options) => { + observe?.(options); + return Promise.resolve(CONTROLLER_RESULT); + }, + }; +} + +function submit( + args: readonly string[], + environment: RunEnvironment, + operations: SubmitOperationsService, +) { + return runLocalSociety(args, environment).pipe( + Effect.provideService(SubmitOperations, operations), + ); +} + +test("loads one module and sends it through one Temporal workflow", () => + Effect.gen(function* () { + let observed: RunTemporalSocietyOptions | undefined; + const result = yield* submit( + ["./experiment.mjs"], + environment, + operations((options) => { + observed = options; + }), + ); + + assert.strictEqual(result.runId, `mz-${UUID.replaceAll("-", "")}`); + assert.strictEqual(result.namespace, result.runId); + assert.strictEqual(observed?.workflowId, result.runId); + assert.strictEqual(observed?.taskQueue, DEFAULT_LOCAL_TASK_QUEUE); + assert.deepStrictEqual(observed?.executionProfile, { kind: "local" }); + assert.strictEqual(observed?.input.experimentModule, MODULE_SOURCE); + assert.strictEqual(observed?.input.controllerImage, CONTROLLER_IMAGE); + assert.strictEqual(observed?.input.supportImage, CONTROLLER_IMAGE); + assert.deepStrictEqual(observed?.input.runtimeCredentials, { + OPENAI_API_KEY: "openai-test-credential", + }); + })); + +test("rejects a mutable image before reading the experiment", () => + Effect.gen(function* () { + let reads = 0; + const failure = yield* submit( + ["./experiment.mjs"], + { MOLTZAP_CONTROLLER_IMAGE: "moltzap-controller:latest" }, + { + ...operations(), + readTextFile: () => { + reads += 1; + return Effect.succeed(""); + }, + }, + ).pipe(Effect.flip); + + assert.instanceOf(failure, RunSubmissionError); + assert.strictEqual(failure.stage, SUBMIT_STAGE.configuration); + assert.strictEqual(reads, 0); + })); + +test("sanitizes module and Temporal failures", () => + Effect.gen(function* () { + const moduleFailure = yield* submit(["./experiment.mjs"], environment, { + ...operations(), + readTextFile: () => + Effect.fail( + new RunSubmissionError({ + stage: SUBMIT_STAGE.module, + detail: "module-secret", + }), + ), + }).pipe(Effect.flip); + assert.strictEqual(moduleFailure.stage, SUBMIT_STAGE.module); + assert.notInclude(moduleFailure.message, "module-secret"); + + const temporalFailure = yield* submit(["./experiment.mjs"], environment, { + ...operations(), + runTemporalSociety: () => Promise.reject(new Error("temporal-secret")), + }).pipe(Effect.flip); + assert.strictEqual(temporalFailure.stage, SUBMIT_STAGE.execution); + assert.notInclude(temporalFailure.message, "temporal-secret"); + })); diff --git a/packages/simulator/src/cluster/profiles/local.ts b/packages/simulator/src/cluster/profiles/local.ts new file mode 100644 index 000000000..c782ba963 --- /dev/null +++ b/packages/simulator/src/cluster/profiles/local.ts @@ -0,0 +1,48 @@ +/** @file Repository-local profile entry point for one Temporal-managed run. */ + +import { NodeRuntime } from "@effect/platform-node"; +import { Effect } from "effect"; +import { isEntryModule } from "../entry.js"; +import { LOCAL_KUBERNETES_EXECUTION_PROFILE } from "../profile.js"; +import { + liveSubmitOperations, + runKubernetesSociety, + type RunEnvironment, + type RunSubmission, + type RunSubmissionError, + type SubmitOperations, +} from "../submit.js"; + +/** + * Submit one mounted experiment through the core Kubernetes execution path. + * @param args One repository-local `.mjs` RunSpec path. + * @param environment Local profile connection and image configuration. + * @returns The coarse workflow result and ephemeral run identity. + */ +export function runLocalSociety( + args: readonly string[], + environment: RunEnvironment, +): Effect.Effect { + return runKubernetesSociety( + args, + environment, + LOCAL_KUBERNETES_EXECUTION_PROFILE, + ); +} + +// eslint-disable-next-line agent-code-guard/prefer-effect-platform -- Direct-entry detection has no Effect Platform equivalent. +if (isEntryModule(import.meta.url, process.argv[1])) { + // eslint-disable-next-line agent-code-guard/prefer-effect-platform -- The executable boundary captures argv once before entering Effect. + const args = process.argv.slice(2); + // eslint-disable-next-line agent-code-guard/no-process-env-at-runtime -- The executable boundary injects the environment into the typed local configuration. + const environment = process.env; + runLocalSociety(args, environment).pipe( + Effect.tap((result) => + Effect.sync(() => { + process.stdout.write(`${JSON.stringify(result)}\n`); + }), + ), + Effect.provide(liveSubmitOperations), + NodeRuntime.runMain, + ); +} diff --git a/packages/simulator/src/cluster/reclaim.cluster.test.ts b/packages/simulator/src/cluster/reclaim.cluster.test.ts new file mode 100644 index 000000000..815e6ba63 --- /dev/null +++ b/packages/simulator/src/cluster/reclaim.cluster.test.ts @@ -0,0 +1,143 @@ +/** @file Live proof that a killed submitter still leaves its run reclaimed. */ + +// Reclamation cannot be shown against a fake: the fake worker never dies, so +// the assertion holds whether or not the worker outlives its submitter. This +// suite kills a real submitter against a real cluster and requires the run's +// namespace to disappear anyway. +// +// Opt in with the local-cluster-test target, against a cluster prepared by +// local-cluster-create and an image built by local-controller-image. + +/* eslint-disable agent-code-guard/async-keyword, agent-code-guard/promise-type, agent-code-guard/no-process-env-at-runtime, @typescript-eslint/no-invalid-void-type -- This suite drives a real cluster and a real child process through their native Promise and process APIs. */ + +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; +import { + CoreV1Api, + CustomObjectsApi, + KubeConfig, +} from "@kubernetes/client-node"; +import { expect, it } from "vitest"; +import { SYSTEM_NAMESPACE } from "./kubernetes/objects.js"; + +const RUN_NAMESPACE_PREFIX = "mz-"; +const EXPERIMENT = resolve("local/end-to-end.mjs"); +const SUBMITTER = resolve("dist/cluster/profiles/local.js"); +const POLL_INTERVAL_MS = 2_000; +const SUBMISSION_ATTEMPTS = 150; +const RECLAMATION_ATTEMPTS = 150; + +interface ClusterReader { + readonly core: CoreV1Api; + readonly custom: CustomObjectsApi; +} + +function clusterReader(): ClusterReader { + const config = new KubeConfig(); + config.loadFromDefault(); + return { + core: config.makeApiClient(CoreV1Api), + custom: config.makeApiClient(CustomObjectsApi), + }; +} + +async function runNamespaces(reader: ClusterReader): Promise { + const namespaces = await reader.core.listNamespace({}); + return namespaces.items + .map((namespace) => namespace.metadata?.name ?? "") + .filter((name) => name.startsWith(RUN_NAMESPACE_PREFIX)); +} + +async function customObjectCount( + reader: ClusterReader, + group: string, + version: string, + plural: string, +): Promise { + const listed: unknown = await reader.custom.listCustomObjectForAllNamespaces({ + group, + version, + plural, + }); + // eslint-disable-next-line agent-code-guard/require-assertion-rationale -- The generated custom-object client returns an untyped envelope; only its item list is read. + const items: unknown = (listed as { readonly items?: unknown }).items; + if (!Array.isArray(items)) { + throw new Error(`${plural} did not list as a collection`); + } + return items.length; +} + +async function until( + attempts: number, + description: string, + satisfied: () => Promise, +): Promise { + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (await satisfied()) { + return; + } + await delay(POLL_INTERVAL_MS); + } + throw new Error(`${description} did not happen in time`); +} + +function requiredEnvironment(key: string): string { + const value = process.env[key]; + if (value === undefined || value.length === 0) { + throw new Error(`${key} is required by the local-cluster reclaim test`); + } + return value; +} + +it("reclaims a run whose submitter is killed mid-flight", async () => { + const reader = clusterReader(); + const controllerImage = requiredEnvironment("MOLTZAP_CONTROLLER_IMAGE"); + const before = new Set(await runNamespaces(reader)); + + const submitter = spawn(process.execPath, [SUBMITTER, EXPERIMENT], { + stdio: "ignore", + env: { ...process.env, MOLTZAP_CONTROLLER_IMAGE: controllerImage }, + }); + + let submitted = ""; + try { + await until( + SUBMISSION_ATTEMPTS, + "the run namespace appearing", + async () => { + const created = (await runNamespaces(reader)).filter( + (name) => !before.has(name), + ); + submitted = created[0] ?? ""; + return submitted.length > 0; + }, + ); + } finally { + // SIGKILL, not SIGTERM: the guarantee under test is that a submitter which + // never gets to run cleanup still leaves nothing behind. + submitter.kill("SIGKILL"); + } + + await until( + RECLAMATION_ATTEMPTS, + `reclamation of ${submitted}`, + async () => (await runNamespaces(reader)).length === 0, + ); + + expect( + await customObjectCount(reader, "kueue.x-k8s.io", "v1beta2", "workloads"), + ).toBe(0); + expect( + await customObjectCount(reader, "agents.x-k8s.io", "v1beta1", "sandboxes"), + ).toBe(0); + expect(await runNamespaces(reader)).toEqual([]); + // The worker itself must survive the run it reclaimed, or the next submission + // waits on a queue nothing is polling. + expect( + (await reader.core.readNamespace({ name: SYSTEM_NAMESPACE })).metadata + ?.name, + ).toBe(SYSTEM_NAMESPACE); +}); + +/* eslint-enable agent-code-guard/async-keyword, agent-code-guard/promise-type, agent-code-guard/no-process-env-at-runtime, @typescript-eslint/no-invalid-void-type -- Restore Effect-first test rules after the live-cluster reclamation proof. */ diff --git a/packages/simulator/src/cluster/reclaim.test.ts b/packages/simulator/src/cluster/reclaim.test.ts new file mode 100644 index 000000000..0c171d350 --- /dev/null +++ b/packages/simulator/src/cluster/reclaim.test.ts @@ -0,0 +1,192 @@ +/* eslint-disable @typescript-eslint/require-await, @typescript-eslint/no-invalid-void-type, agent-code-guard/async-keyword, agent-code-guard/promise-type -- Temporal workflow tests exercise Promise-native SDK contracts; activity doubles resolve synchronously while retaining those signatures. */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { Effect, Schema } from "effect"; +import { CompletedLedgerReceipt } from "../run/execute.js"; +import { LedgerCompletion, ledgerDigest, ledgerRef } from "../ledger/schema.js"; +import { programFinishedSummary } from "./controller/summary.js"; +import { KubernetesCallFailed } from "./kubernetes/calls.js"; +import type { + CleanupRunInput, + RunControllerResult, + RunSocietyWorkflowInput, +} from "./reclaim.js"; +import { + LifecycleOperations, + runLifecycleActivities, + type LifecycleOperationsService, +} from "./temporal.js"; + +interface MockActivityOptions { + readonly startToCloseTimeout: string; + readonly heartbeatTimeout?: string; + readonly retry?: { readonly maximumAttempts: number }; +} + +const DIGEST = Schema.decodeSync(ledgerDigest)("b".repeat(64)); +const CONTROLLER_RESULT: RunControllerResult = { + exitCode: 0, + summary: programFinishedSummary( + CompletedLedgerReceipt.make({ + ledger: Schema.decodeSync(ledgerRef)("temporal-workflow-ledger"), + completion: LedgerCompletion.make({ + ledgerFormatVersion: 1, + runId: "temporal-workflow-run", + recordCount: 3, + artifacts: { manifest: DIGEST, records: DIGEST }, + }), + }), + ), +}; + +interface WorkflowTestState { + readonly activityOptions: MockActivityOptions[]; + readonly controllerInputs: RunSocietyWorkflowInput[]; + readonly cleanupInputs: CleanupRunInput[]; + readonly events: string[]; + controllerFailure?: Error; + cleanupFailure?: Error; + /** Real cleanup activity substituted for the double when a test supplies one. */ + cleanupActivity?: (input: CleanupRunInput) => Promise; +} + +const workflowState = vi.hoisted( + (): WorkflowTestState => ({ + activityOptions: [], + controllerInputs: [], + cleanupInputs: [], + events: [], + }), +); + +vi.mock("@temporalio/workflow", () => ({ + proxyActivities: (options: MockActivityOptions) => { + workflowState.activityOptions.push(options); + return { + runControllerOnce: async ( + input: RunSocietyWorkflowInput, + ): Promise => { + workflowState.events.push("controller"); + workflowState.controllerInputs.push(input); + if (workflowState.controllerFailure !== undefined) { + throw workflowState.controllerFailure; + } + return CONTROLLER_RESULT; + }, + cleanupRun: async (input: CleanupRunInput): Promise => { + workflowState.events.push("cleanup"); + workflowState.cleanupInputs.push(input); + if (workflowState.cleanupFailure !== undefined) { + throw workflowState.cleanupFailure; + } + await workflowState.cleanupActivity?.(input); + }, + }; + }, + CancellationScope: { + nonCancellable: async ( + evaluate: () => Promise, + ): Promise => { + workflowState.events.push("non-cancellable"); + return await evaluate(); + }, + }, +})); + +const { runSocietyWorkflow } = await import("./reclaim.js"); + +const input: RunSocietyWorkflowInput = { + runId: "run-1", + namespace: "mz-run-1", + controllerImage: "registry/controller@sha256:controller", + supportImage: "registry/support@sha256:support", + experimentModule: "export const runSpec = society;", +}; + +beforeEach(() => { + workflowState.controllerInputs.length = 0; + workflowState.cleanupInputs.length = 0; + workflowState.events.length = 0; + delete workflowState.controllerFailure; + delete workflowState.cleanupFailure; + delete workflowState.cleanupActivity; +}); + +/* eslint-disable agent-code-guard/no-example-only-tests -- Regression-only timelines pin the exact scheduling options and cleanup ordering the workflow contract is made of. */ +// eslint-disable-next-line max-lines-per-function, sonarjs/max-lines-per-function -- The group shares one fake Temporal activity environment whose event order is the contract under test. +describe("runSocietyWorkflow", () => { + it("bounds the controller attempt by a heartbeat and keeps cleanup retryable", () => { + expect(workflowState.activityOptions).toEqual([ + { + startToCloseTimeout: "24 hours", + heartbeatTimeout: "60 seconds", + retry: { maximumAttempts: 1 }, + }, + { startToCloseTimeout: "10 minutes" }, + ]); + }); + + it("runs the controller once and cleans the run after success", async () => { + await expect(runSocietyWorkflow(input)).resolves.toEqual(CONTROLLER_RESULT); + + expect(workflowState.controllerInputs).toEqual([input]); + expect(workflowState.cleanupInputs).toEqual([ + { runId: input.runId, namespace: input.namespace }, + ]); + expect(workflowState.events).toEqual([ + "controller", + "non-cancellable", + "cleanup", + ]); + }); + + it("cleans the run after the controller fails without retrying it", async () => { + const failure = new Error("controller stopped"); + workflowState.controllerFailure = failure; + + await expect(runSocietyWorkflow(input)).rejects.toBe(failure); + + expect(workflowState.controllerInputs).toHaveLength(1); + expect(workflowState.cleanupInputs).toEqual([ + { runId: input.runId, namespace: input.namespace }, + ]); + expect(workflowState.events).toEqual([ + "controller", + "non-cancellable", + "cleanup", + ]); + }); + + it("deletes the run namespace when the controller attempt is lost", async () => { + const deleted: string[] = []; + const operations: LifecycleOperationsService = { + bindHeartbeat: () => () => undefined, + prepareRun: () => Effect.void, + observeController: () => + Effect.fail(new KubernetesCallFailed("observe a fake controller")), + deleteRunNamespace: (namespace) => + Effect.sync(() => { + deleted.push(namespace); + }), + runNamespaceExists: () => Effect.succeed(false), + waitBeforeObservation: () => Effect.void, + }; + workflowState.cleanupActivity = Effect.runSync( + runLifecycleActivities.pipe( + Effect.provideService(LifecycleOperations, operations), + ), + ).cleanupRun; + workflowState.controllerFailure = new Error( + "activity heartbeat deadline expired", + ); + + await expect(runSocietyWorkflow(input)).rejects.toBe( + workflowState.controllerFailure, + ); + + expect(deleted).toEqual([input.namespace]); + }); +}); +/* eslint-enable agent-code-guard/no-example-only-tests -- Restore generative-test requirements after the coarse workflow regressions. */ + +/* eslint-enable @typescript-eslint/require-await, @typescript-eslint/no-invalid-void-type, agent-code-guard/async-keyword, agent-code-guard/promise-type -- Restore Effect-first test rules after the Temporal workflow contract suite. */ diff --git a/packages/simulator/src/cluster/reclaim.ts b/packages/simulator/src/cluster/reclaim.ts new file mode 100644 index 000000000..b30925758 --- /dev/null +++ b/packages/simulator/src/cluster/reclaim.ts @@ -0,0 +1,98 @@ +/** @file Run the controller once, then always tear the run's cluster state down. */ + +import { CancellationScope, proxyActivities } from "@temporalio/workflow"; +// safer-arch-ignore no-upward-layer-import: the controller's serializable run summary is the contract this workflow carries back to its caller, so the summary shape is owned where the controller writes it. +import type { + ControllerFailedRunSummary, + ControllerProgramFinishedSummary, +} from "./controller/summary.js"; + +/** Private data needed to start one in-cluster experiment controller. */ +export interface RunSocietyWorkflowInput { + readonly runId: string; + readonly namespace: string; + readonly controllerImage: string; + readonly supportImage: string; + /** Provider credentials retained only for the transient controller Job. */ + readonly runtimeCredentials?: Readonly< + Partial> + >; + /** Complete `.mjs` source mounted into the controller Job. */ + readonly experimentModule: string; + /** Budget for a cohort to become ready, when the default is too small. */ + readonly startupTimeoutMs?: number; + /** Agents an experiment sizes its roster from, when its run chooses. */ + readonly cohortSize?: number; +} + +/** Identity sufficient for idempotent deletion of one run's resources. */ +export type CleanupRunInput = Readonly< + Pick +>; + +/** Closed controller process result retained by the coarse workflow. */ +export type RunControllerResult = + | { + readonly exitCode: 0; + readonly summary: ControllerProgramFinishedSummary; + } + | { + readonly exitCode: 1; + readonly summary: ControllerFailedRunSummary; + }; + +/* eslint-disable agent-code-guard/promise-type, @typescript-eslint/no-invalid-void-type -- Temporal activity implementations are Promise-native functions consumed directly by proxyActivities. */ +/** Activities owned by the worker for one complete run lifecycle. */ +export interface RunLifecycleActivities { + readonly runControllerOnce: ( + input: RunSocietyWorkflowInput, + ) => Promise; + readonly cleanupRun: (input: CleanupRunInput) => Promise; +} +/* eslint-enable agent-code-guard/promise-type, @typescript-eslint/no-invalid-void-type -- Restore Effect-first contract rules after the Temporal activity boundary. */ + +const { runControllerOnce } = proxyActivities< + Pick +>({ + startToCloseTimeout: "24 hours", + // A controller Job may legitimately occupy the activity for hours, so the + // start-to-close deadline cannot distinguish a long run from a worker that + // died holding it. The heartbeat deadline is what fails the attempt within a + // minute, which is what lets the cleanup below reclaim the run's namespace. + heartbeatTimeout: "60 seconds", + // A second attempt would re-run the experiment's Effect from the start. + retry: { maximumAttempts: 1 }, +}); + +const { cleanupRun } = proxyActivities< + Pick +>({ + startToCloseTimeout: "10 minutes", +}); + +/* eslint-disable agent-code-guard/async-keyword, agent-code-guard/promise-type -- Temporal workflow entrypoints must use the SDK's own Promise-returning contract. */ +/** + * Runs one controller attempt and shields its final cleanup from cancellation. + * + * This module is bundled into the deterministic workflow sandbox, so it carries + * the activity contract as types and reaches every implementation through + * `proxyActivities`. A value import of the activity, Kubernetes, or Node + * surfaces would put non-deterministic code inside that bundle. + * + * @param input Private run identity and controller artifacts. + * @returns The controller's operational success after cleanup completes. + */ +// #ignore-sloppy-code-next-line[async-keyword]: Temporal workflows are SDK-required Promise boundaries +export async function runSocietyWorkflow( + input: RunSocietyWorkflowInput, + // #ignore-sloppy-code-next-line[promise-type]: Temporal workflows are SDK-required Promise boundaries +): Promise { + try { + return await runControllerOnce(input); + } finally { + await CancellationScope.nonCancellable(() => + cleanupRun({ runId: input.runId, namespace: input.namespace }), + ); + } +} +/* eslint-enable agent-code-guard/async-keyword, agent-code-guard/promise-type -- Restore Effect-first function rules after the Temporal workflow entrypoint. */ diff --git a/packages/simulator/src/cluster/reclaim.types-check.ts b/packages/simulator/src/cluster/reclaim.types-check.ts new file mode 100644 index 000000000..3be2cc5b8 --- /dev/null +++ b/packages/simulator/src/cluster/reclaim.types-check.ts @@ -0,0 +1,56 @@ +/** + * The private Temporal boundary carries only its closed serializable lifecycle + * data, and the coarse workflow preserves the controller's operational result. + */ + +import type { + CleanupRunInput, + RunControllerResult, + RunLifecycleActivities, + RunSocietyWorkflowInput, + runSocietyWorkflow, +} from "./reclaim.js"; + +type Equal = [Left, Right] extends [Right, Left] ? true : false; +type Expect = Value; + +type WorkflowInputKeysAreClosed = Expect< + Equal< + keyof RunSocietyWorkflowInput, + | "runId" + | "namespace" + | "controllerImage" + | "supportImage" + | "runtimeCredentials" + | "experimentModule" + | "startupTimeoutMs" + | "cohortSize" + > +>; +type CleanupInputIsMinimal = Expect< + Equal> +>; +type ControllerActivityInputIsExact = Expect< + Equal< + Parameters, + [input: RunSocietyWorkflowInput] + > +>; +type CleanupActivityInputIsExact = Expect< + Equal< + Parameters, + [input: CleanupRunInput] + > +>; +type WorkflowResultIsOperational = Expect< + Equal>, RunControllerResult> +>; + +/** Compile-time assertions for the private coarse-workflow boundary. */ +export type TemporalWorkflowCanaries = [ + WorkflowInputKeysAreClosed, + CleanupInputIsMinimal, + ControllerActivityInputIsExact, + CleanupActivityInputIsExact, + WorkflowResultIsOperational, +]; diff --git a/packages/simulator/src/cluster/scaffold.test.ts b/packages/simulator/src/cluster/scaffold.test.ts new file mode 100644 index 000000000..f257ddfaa --- /dev/null +++ b/packages/simulator/src/cluster/scaffold.test.ts @@ -0,0 +1,152 @@ +/* eslint-disable agent-code-guard/async-keyword -- Vitest awaits the Effect the activity boundary under test returns. */ + +import { Effect } from "effect"; +import { expect, it } from "vitest"; +import { + KubernetesCallFailed, + type RunControlApi, +} from "./kubernetes/calls.js"; +import { + RUN_OWNER_NAME, + type OwnedRunControlManifests, +} from "./kubernetes/objects.js"; +import { LOCAL_KUBERNETES_EXECUTION_PROFILE } from "./profile.js"; +import type { RunSocietyWorkflowInput } from "./reclaim.js"; +import { prepareRun } from "./scaffold.js"; + +type PreparationStage = Extract< + keyof RunControlApi, + | "createRunRoot" + | "createExperimentAndQueue" + | "createControllerAccess" + | "createRouterService" + | "startController" +>; + +// The run root issues the UID every other object is owned by, and the +// controller acts through the run-scoped RBAC and dials the router Service the +// moment it starts, so it goes last. Nothing constrains the stages between +// them relative to each other. +const ROOT: PreparationStage = "createRunRoot"; +const START: PreparationStage = "startController"; +const BEFORE_START: readonly PreparationStage[] = [ + "createRunRoot", + "createExperimentAndQueue", + "createControllerAccess", + "createRouterService", +]; +const DIGEST = "a".repeat(64); +const OWNER_UID = "owner-uid-the-cluster-issued"; +const INPUT: RunSocietyWorkflowInput = { + runId: "run-1", + namespace: "mz-run-1", + controllerImage: `registry/controller@sha256:${DIGEST}`, + supportImage: `registry/support@sha256:${DIGEST}`, + experimentModule: "export const runSpec = society;", +}; + +interface RecordedRunControl { + readonly api: RunControlApi; + readonly calls: PreparationStage[]; + readonly namespaces: string[]; + readonly manifests: OwnedRunControlManifests[]; +} + +function recordingRunControl(failAt?: PreparationStage): RecordedRunControl { + const calls: PreparationStage[] = []; + const namespaces: string[] = []; + const manifests: OwnedRunControlManifests[] = []; + const record = ( + stage: PreparationStage, + ): Effect.Effect => + Effect.suspend(() => { + calls.push(stage); + return failAt === stage + ? Effect.fail(new KubernetesCallFailed(stage)) + : Effect.void; + }); + const owned = + (stage: PreparationStage) => + (namespace: string, supplied: OwnedRunControlManifests) => + Effect.suspend(() => { + namespaces.push(namespace); + manifests.push(supplied); + return record(stage); + }); + return { + calls, + namespaces, + manifests, + api: { + createRunRoot: () => + Effect.suspend(() => { + calls.push(ROOT); + return failAt === ROOT + ? Effect.fail(new KubernetesCallFailed(ROOT)) + : Effect.succeed(OWNER_UID); + }), + createExperimentAndQueue: owned("createExperimentAndQueue"), + createControllerAccess: owned("createControllerAccess"), + createRouterService: owned("createRouterService"), + startController: owned(START), + readControllerJob: () => + Effect.fail( + new KubernetesCallFailed("preparing a run observes nothing"), + ), + readControllerLogs: () => Effect.succeed(undefined), + deleteRunNamespace: () => Effect.void, + runNamespaceExists: () => Effect.succeed(false), + }, + }; +} + +it("creates the run root before anything it owns and the controller last", async () => { + const { api, calls, namespaces } = recordingRunControl(); + + await Effect.runPromise( + prepareRun(api, INPUT, LOCAL_KUBERNETES_EXECUTION_PROFILE), + ); + + expect(calls[0]).toBe(ROOT); + expect(calls.at(-1)).toBe(START); + expect(calls).toHaveLength(BEFORE_START.length + 1); + expect(new Set(calls)).toEqual(new Set([...BEFORE_START, START])); + expect(new Set(namespaces)).toEqual(new Set([INPUT.namespace])); +}); + +it("never starts a controller whose access or endpoint failed to appear", async () => { + for (const stage of BEFORE_START) { + const { api, calls } = recordingRunControl(stage); + + const failure = await Effect.runPromise( + Effect.flip(prepareRun(api, INPUT, LOCAL_KUBERNETES_EXECUTION_PROFILE)), + ); + + expect(failure.message).toBe(`${stage} failed`); + expect(calls).toContain(stage); + expect(calls).not.toContain(START); + } +}); + +it("owns every created object by the run root the cluster just issued", async () => { + const { api, manifests } = recordingRunControl(); + + await Effect.runPromise( + prepareRun(api, INPUT, LOCAL_KUBERNETES_EXECUTION_PROFILE), + ); + + const owners = manifests.flatMap((supplied) => [ + supplied.experiment.metadata?.ownerReferences, + supplied.role.metadata?.ownerReferences, + supplied.routerService.metadata?.ownerReferences, + supplied.controllerJob.metadata?.ownerReferences, + ]); + expect(owners).not.toHaveLength(0); + for (const ownerReferences of owners) { + expect(ownerReferences).toEqual([ + expect.objectContaining({ name: RUN_OWNER_NAME, uid: OWNER_UID }), + ]); + } +}); + +/* eslint-enable agent-code-guard/async-keyword -- Restore Effect-first test rules after the activity preparation contract. */ diff --git a/packages/simulator/src/cluster/scaffold.ts b/packages/simulator/src/cluster/scaffold.ts new file mode 100644 index 000000000..cbf80cbc5 --- /dev/null +++ b/packages/simulator/src/cluster/scaffold.ts @@ -0,0 +1,49 @@ +/** @file Stand up one run: its root, its access, its endpoint, its controller. */ +// safer-arch-ignore no-trivial-sink-file: Standing a run up is its own step of a run's life; folding it into the module that watches the controller would put two behaviors behind one name. + +import { Effect } from "effect"; +import type { + KubernetesCallFailed, + RunControlApi, +} from "./kubernetes/calls.js"; +import { ownedRunControlManifests } from "./kubernetes/objects.js"; +import type { KubernetesExecutionProfile } from "./profile.js"; +import type { RunSocietyWorkflowInput } from "./reclaim.js"; + +/** + * Create everything one run needs before its controller starts. + * + * Two orderings are the contract, and only those two. The run root's UID owns + * every object created after it, so nothing can be built until it exists. The + * controller Job is created last because it immediately acts through the + * run-scoped RBAC and dials the router Service by name: started any earlier, it + * races objects it depends on. What sits between them — the experiment and its + * queue, the controller's identity and permissions, the router endpoint — names + * nothing in the others, so the three are created together and the run reaches + * its controller in three round trips instead of six. + * + * @param api Kubernetes access held by the worker running this activity. + * @param input Serializable run identity, images, and experiment module. + * @param profile Private local or GKE storage and placement projection. + * @returns Nothing once the controller Job has been created. + * @failure KubernetesCallFailed when any object could not be created. + */ +export function prepareRun( + api: RunControlApi, + input: RunSocietyWorkflowInput, + profile: KubernetesExecutionProfile, +): Effect.Effect { + return Effect.gen(function* () { + const ownerUid = yield* api.createRunRoot(input); + const manifests = ownedRunControlManifests(input, ownerUid, profile); + yield* Effect.all( + [ + api.createExperimentAndQueue(input.namespace, manifests), + api.createControllerAccess(input.namespace, manifests), + api.createRouterService(input.namespace, manifests), + ], + { concurrency: 3, discard: true }, + ); + yield* api.startController(input.namespace, manifests); + }).pipe(Effect.withSpan("prepareRun")); +} diff --git a/packages/simulator/src/cluster/submit.test.ts b/packages/simulator/src/cluster/submit.test.ts new file mode 100644 index 000000000..9e4e073d1 --- /dev/null +++ b/packages/simulator/src/cluster/submit.test.ts @@ -0,0 +1,126 @@ +/* eslint-disable agent-code-guard/async-keyword -- The submitter boundary is Promise-native, so its assertions await it. */ + +import { describe, expect, it } from "vitest"; +import { Effect, Layer } from "effect"; +import type { RunControllerResult } from "./reclaim.js"; +import { LOCAL_KUBERNETES_EXECUTION_PROFILE } from "./profile.js"; +import { + runKubernetesSociety, + SubmitOperations, + type RunEnvironment, + type RunSubmission, +} from "./submit.js"; +import type { RunTemporalSocietyOptions } from "./temporal.js"; + +const DIGEST = "b".repeat(64); +const ENTRYPOINT = "society.mjs"; +const STARTUP_TIMEOUT_VARIABLE = "MOLTZAP_STARTUP_TIMEOUT_MS"; +const STARTUP_TIMEOUT_MS = 900_000; +const COHORT_SIZE_VARIABLE = "MOLTZAP_COHORT_SIZE"; +const COHORT_SIZE = 100; +const RESULT: RunControllerResult = { + exitCode: 1, + summary: { _tag: "LedgerAllocationFailed" }, +}; + +const ENVIRONMENT: RunEnvironment = { + MOLTZAP_CONTROLLER_IMAGE: `registry/controller@sha256:${DIGEST}`, + MOLTZAP_SUPPORT_IMAGE: `registry/support@sha256:${DIGEST}`, +}; + +interface Submitted { + readonly options: RunTemporalSocietyOptions[]; +} + +function recordingOperations( + submitted: Submitted, +): Layer.Layer { + return Layer.succeed(SubmitOperations, { + readTextFile: () => Effect.succeed("export const runSpec = society;"), + randomUuid: () => "0123456789abcdef0123456789abcdef", + runTemporalSociety: (options: RunTemporalSocietyOptions) => { + submitted.options.push(options); + return Promise.resolve(RESULT); + }, + }); +} + +function submit( + environment: RunEnvironment, +): Effect.Effect< + { readonly submission: RunSubmission; readonly submitted: Submitted }, + unknown +> { + const submitted: Submitted = { options: [] }; + return runKubernetesSociety( + [ENTRYPOINT], + environment, + LOCAL_KUBERNETES_EXECUTION_PROFILE, + ).pipe( + Effect.provide(recordingOperations(submitted)), + Effect.map((submission) => ({ submission, submitted })), + ); +} + +describe("the run's cohort size", () => { + it("reaches the workflow when the environment sets one", async () => { + const { submitted } = await Effect.runPromise( + submit({ ...ENVIRONMENT, [COHORT_SIZE_VARIABLE]: String(COHORT_SIZE) }), + ); + + expect(submitted.options[0]?.input.cohortSize).toBe(COHORT_SIZE); + }); + + it("is absent when the environment sets none, leaving the controller's default", async () => { + const { submitted } = await Effect.runPromise(submit(ENVIRONMENT)); + + expect(submitted.options[0]?.input.cohortSize).toBeUndefined(); + }); + + it("refuses a size that is not a positive integer", async () => { + for (const encoded of ["0", "-4", "2.5", "many"]) { + const failure = await Effect.runPromise( + Effect.flip( + submit({ ...ENVIRONMENT, [COHORT_SIZE_VARIABLE]: encoded }), + ), + ); + + expect(String(failure)).toContain(COHORT_SIZE_VARIABLE); + } + }); +}); + +describe("the cohort's startup budget", () => { + it("reaches the workflow when the environment sets one", async () => { + const { submitted } = await Effect.runPromise( + submit({ + ...ENVIRONMENT, + [STARTUP_TIMEOUT_VARIABLE]: String(STARTUP_TIMEOUT_MS), + }), + ); + + expect(submitted.options[0]?.input.startupTimeoutMs).toBe( + STARTUP_TIMEOUT_MS, + ); + }); + + it("is absent when the environment sets none, leaving the controller's default", async () => { + const { submitted } = await Effect.runPromise(submit(ENVIRONMENT)); + + expect(submitted.options[0]?.input.startupTimeoutMs).toBeUndefined(); + }); + + it("refuses a budget that is not a positive integer", async () => { + for (const encoded of ["0", "-1", "1.5", "not-a-number"]) { + const failure = await Effect.runPromise( + Effect.flip( + submit({ ...ENVIRONMENT, [STARTUP_TIMEOUT_VARIABLE]: encoded }), + ), + ); + + expect(String(failure)).toContain(STARTUP_TIMEOUT_VARIABLE); + } + }); +}); + +/* eslint-enable agent-code-guard/async-keyword -- Restore Effect-first test rules after the Promise-native submitter. */ diff --git a/packages/simulator/src/cluster/submit.ts b/packages/simulator/src/cluster/submit.ts new file mode 100644 index 000000000..2f723ac6e --- /dev/null +++ b/packages/simulator/src/cluster/submit.ts @@ -0,0 +1,358 @@ +/* eslint-disable agent-code-guard/promise-type -- File loading and the Temporal SDK are Promise-native at this submission boundary. */ +/** @file Shared submission of one experiment to a Temporal-managed cluster. */ + +import { randomUUID } from "node:crypto"; +import { resolve } from "node:path"; +import { FileSystem } from "@effect/platform"; +import { NodeContext } from "@effect/platform-node"; +import { Context, Data, Effect, Layer } from "effect"; +import type { KubernetesExecutionProfile } from "./profile.js"; +import type { RunControllerResult } from "./reclaim.js"; +import { + runTemporalSociety, + type RunTemporalSocietyOptions, +} from "./temporal.js"; + +/** Temporal queue used by the repository-owned local profile. */ +export const DEFAULT_LOCAL_TASK_QUEUE = "moltzap-simulator"; +const DEFAULT_TEMPORAL_ADDRESS = "127.0.0.1:7233"; +const DEFAULT_TEMPORAL_NAMESPACE = "default"; +const DIGEST_PINNED_IMAGE = /^.+@sha256:[0-9a-f]{64}$/u; + +/** Process environment read by a submitting profile. */ +export type RunEnvironment = Readonly>; + +/** Stable stage labels used by the sanitized submission failure. */ +export const SUBMIT_STAGE = Object.freeze({ + arguments: "arguments", + configuration: "configuration", + module: "module", + execution: "execution", +} as const); + +/** Native submission boundaries, replaceable by entry-point tests. */ +export interface SubmitOperationsService { + /** Reads both the experiment module and a profile's checked-in JSON. */ + readonly readTextFile: (path: string) => Effect.Effect; + readonly randomUuid: () => string; + readonly runTemporalSociety: ( + options: RunTemporalSocietyOptions, + ) => Promise; +} + +/** Native submission boundaries every profile reads from its environment. */ +export class SubmitOperations extends Context.Tag( + "@moltzap/simulator/SubmitOperations", +)() {} + +/** Successful submission reported to the operator. */ +export interface RunSubmission { + readonly runId: string; + readonly namespace: string; + readonly result: RunControllerResult; +} + +/** Sanitized failure at the repository-owned submission boundary. */ +export class RunSubmissionError extends Data.TaggedError("RunSubmissionError")<{ + readonly stage: "arguments" | "configuration" | "module" | "execution"; + readonly detail: string; +}> { + override get message(): string { + return `Simulator ${this.stage} failed: ${this.detail}`; + } +} + +/** The native boundaries used by every submission that is not a test. */ +export const liveSubmitOperations: Layer.Layer = + Layer.succeed(SubmitOperations, { + readTextFile: (path: string) => + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => fileSystem.readFileString(path)), + Effect.provide(NodeContext.layer), + ), + randomUuid: randomUUID, + runTemporalSociety, + }); + +function failure( + stage: RunSubmissionError["stage"], + detail: string, +): RunSubmissionError { + return new RunSubmissionError({ stage, detail }); +} + +function requiredImage( + environment: RunEnvironment, + key: "MOLTZAP_CONTROLLER_IMAGE" | "MOLTZAP_SUPPORT_IMAGE", + fallback?: string, +): string { + const value = environment[key] ?? fallback; + if (value === undefined || !DIGEST_PINNED_IMAGE.test(value)) { + throw failure( + "configuration", + `${key} must be a lowercase SHA-256 digest-pinned image`, + ); + } + return value; +} + +function optionalNonEmpty( + environment: RunEnvironment, + key: string, + fallback: string, +): string { + const value = environment[key] ?? fallback; + if (value.length === 0) { + throw failure("configuration", `${key} must not be empty`); + } + return value; +} + +function optionalOverride( + environment: RunEnvironment, + key: string, +): string | undefined { + const value = environment[key]; + return value === undefined || value.length === 0 ? undefined : value; +} + +function experimentPath(args: readonly string[]): string { + const [entrypoint] = args; + if ( + args.length !== 1 || + entrypoint === undefined || + !entrypoint.endsWith(".mjs") + ) { + throw failure("arguments", "expected exactly one .mjs RunSpec entrypoint"); + } + return resolve(entrypoint); +} + +function makeRunIdentity(uuid: string): { + readonly runId: string; + readonly namespace: string; +} { + const compact = uuid.toLowerCase().replaceAll("-", ""); + if (!/^[0-9a-f]{32}$/u.test(compact)) { + throw failure("execution", "the local random identifier was invalid"); + } + const namespace = `mz-${compact}`; + return { runId: namespace, namespace }; +} + +function readExperiment( + path: string, + operations: SubmitOperationsService, +): Effect.Effect { + return operations + .readTextFile(path) + .pipe( + Effect.mapError(() => + failure("module", "the RunSpec entrypoint could not be read"), + ), + ); +} + +function executeTemporalRun( + options: RunTemporalSocietyOptions, + operations: SubmitOperationsService, +): Effect.Effect { + // The cause is logged rather than reported, because the detail is operator + // output and the connection it carries can hold a credential. + return Effect.tryPromise(() => operations.runTemporalSociety(options)).pipe( + Effect.tapErrorCause(Effect.logError), + Effect.mapError(() => + failure("execution", "the Temporal-managed run did not complete"), + ), + ); +} + +/** + * Submit through the shared Kubernetes path with one private host profile. + * @param args One repository-local `.mjs` RunSpec path. + * @param environment Image and Temporal connection configuration. + * @param executionProfile Host-owned Kubernetes cluster selection. + * @returns The coarse workflow result and ephemeral run identity. + */ +export function runKubernetesSociety( + args: readonly string[], + environment: RunEnvironment, + executionProfile: KubernetesExecutionProfile, +): Effect.Effect { + return Effect.try({ + try: () => prepareRun(args, environment, executionProfile), + catch: (cause) => + cause instanceof RunSubmissionError + ? cause + : failure("configuration", "the run configuration was invalid"), + }).pipe( + Effect.flatMap((prepared) => + Effect.flatMap(SubmitOperations, (operations) => + executePreparedRun(prepared, operations), + ), + ), + Effect.withSpan("runKubernetesSociety"), + ); +} + +interface PreparedRun { + readonly path: string; + readonly controllerImage: string; + readonly supportImage: string; + readonly runtimeCredentials?: Readonly< + Partial> + >; + readonly executionProfile: KubernetesExecutionProfile; + readonly startupTimeoutMs?: number; + readonly cohortSize?: number; + readonly connection: { + readonly taskQueue: string; + readonly temporalAddress: string; + readonly temporalNamespace: string; + readonly workerTemporalAddress?: string; + }; +} + +function runtimeCredentials( + environment: RunEnvironment, +): PreparedRun["runtimeCredentials"] { + const credentials = Object.fromEntries( + (["ANTHROPIC_API_KEY", "OPENAI_API_KEY"] as const).flatMap((key) => { + const value = environment[key]; + return value === undefined || value.length === 0 ? [] : [[key, value]]; + }), + ); + return Object.keys(credentials).length === 0 + ? undefined + : Object.freeze(credentials); +} + +// Only what could never be a count. The bound each one carries belongs to the +// controller, so a value that is merely too large still reaches it. +function countOverride( + environment: RunEnvironment, + key: string, +): number | undefined { + const encoded = optionalOverride(environment, key); + if (encoded === undefined) { + return undefined; + } + const value = Number(encoded); + if (!Number.isSafeInteger(value) || value <= 0) { + throw failure("configuration", `${key} must be a positive integer`); + } + return value; +} + +function runSizing(environment: RunEnvironment): { + readonly startupTimeoutMs?: number; + readonly cohortSize?: number; +} { + const startupTimeoutMs = countOverride( + environment, + "MOLTZAP_STARTUP_TIMEOUT_MS", + ); + const cohortSize = countOverride(environment, "MOLTZAP_COHORT_SIZE"); + return { + ...(startupTimeoutMs === undefined ? {} : { startupTimeoutMs }), + ...(cohortSize === undefined ? {} : { cohortSize }), + }; +} + +function prepareRun( + args: readonly string[], + environment: RunEnvironment, + executionProfile: KubernetesExecutionProfile, +): PreparedRun { + const controllerImage = requiredImage( + environment, + "MOLTZAP_CONTROLLER_IMAGE", + ); + // The worker runs inside the cluster and reaches Temporal over a different + // endpoint than the operator does. Only a cluster whose Temporal is not the + // one the local profile installs needs to say so. + const workerTemporalAddress = optionalOverride( + environment, + "MOLTZAP_TEMPORAL_CLUSTER_ADDRESS", + ); + return { + path: experimentPath(args), + controllerImage, + executionProfile, + ...runSizing(environment), + supportImage: requiredImage( + environment, + "MOLTZAP_SUPPORT_IMAGE", + controllerImage, + ), + runtimeCredentials: runtimeCredentials(environment), + connection: { + taskQueue: optionalNonEmpty( + environment, + "MOLTZAP_TEMPORAL_TASK_QUEUE", + DEFAULT_LOCAL_TASK_QUEUE, + ), + temporalAddress: optionalNonEmpty( + environment, + "MOLTZAP_TEMPORAL_ADDRESS", + DEFAULT_TEMPORAL_ADDRESS, + ), + temporalNamespace: optionalNonEmpty( + environment, + "MOLTZAP_TEMPORAL_NAMESPACE", + DEFAULT_TEMPORAL_NAMESPACE, + ), + ...(workerTemporalAddress === undefined ? {} : { workerTemporalAddress }), + }, + }; +} + +function executePreparedRun( + prepared: PreparedRun, + operations: SubmitOperationsService, +): Effect.Effect { + return Effect.gen(function* () { + const identity = yield* Effect.try({ + try: () => makeRunIdentity(operations.randomUuid()), + catch: (cause) => + cause instanceof RunSubmissionError + ? cause + : failure("execution", "the local run identity could not be created"), + }); + const experimentModule = yield* readExperiment(prepared.path, operations); + const result = yield* executeTemporalRun( + { + executionProfile: prepared.executionProfile, + workflowId: identity.runId, + taskQueue: prepared.connection.taskQueue, + temporalAddress: prepared.connection.temporalAddress, + temporalNamespace: prepared.connection.temporalNamespace, + ...(prepared.connection.workerTemporalAddress === undefined + ? {} + : { + workerTemporalAddress: prepared.connection.workerTemporalAddress, + }), + input: { + runId: identity.runId, + namespace: identity.namespace, + controllerImage: prepared.controllerImage, + supportImage: prepared.supportImage, + ...(prepared.runtimeCredentials === undefined + ? {} + : { runtimeCredentials: prepared.runtimeCredentials }), + experimentModule, + ...(prepared.startupTimeoutMs === undefined + ? {} + : { startupTimeoutMs: prepared.startupTimeoutMs }), + ...(prepared.cohortSize === undefined + ? {} + : { cohortSize: prepared.cohortSize }), + }, + }, + operations, + ); + return Object.freeze({ ...identity, result }); + }); +} + +/* eslint-enable agent-code-guard/promise-type -- Restore Effect-first contracts after the submission boundary. */ diff --git a/packages/simulator/src/cluster/temporal.test.ts b/packages/simulator/src/cluster/temporal.test.ts new file mode 100644 index 000000000..09bc2b2fb --- /dev/null +++ b/packages/simulator/src/cluster/temporal.test.ts @@ -0,0 +1,228 @@ +/* eslint-disable agent-code-guard/async-keyword -- Temporal activity and client tests await the SDK's Promise-native boundary. */ +/* eslint-disable agent-code-guard/no-example-only-tests -- Regression-only activity timelines pin one Temporal attempt and cleanup ordering. */ + +import { describe, expect, it, vi } from "vitest"; +import { Effect, Schema } from "effect"; +import { CompletedLedgerReceipt } from "../run/execute.js"; +import { LedgerCompletion, ledgerDigest, ledgerRef } from "../ledger/schema.js"; +import { + ledgerAllocationFailedSummary, + programFinishedSummary, +} from "./controller/summary.js"; +import { KubernetesCallFailed } from "./kubernetes/calls.js"; +import type { + RunControllerResult, + RunLifecycleActivities, + RunSocietyWorkflowInput, +} from "./reclaim.js"; +import { + executeRunSocietyWorkflow, + LifecycleOperations, + runLifecycleActivities, + type ControllerObservation, + type LifecycleOperationsService, + type RunSocietyWorkflowExecutionOptions, +} from "./temporal.js"; + +/** The exact client surface the module under test asks a caller to supply. */ +type WorkflowExecutor = RunSocietyWorkflowExecutionOptions["client"]; + +const HEARTBEAT_EVENT = "heartbeat"; + +const INPUT: RunSocietyWorkflowInput = { + runId: "run-1", + namespace: "mz-run-1", + controllerImage: "registry/controller@sha256:controller", + supportImage: "registry/support@sha256:support", + experimentModule: "export const runSpec = society;", +}; +const DIGEST = Schema.decodeSync(ledgerDigest)("a".repeat(64)); +const PROGRAM_RESULT: RunControllerResult = { + exitCode: 0, + summary: programFinishedSummary( + CompletedLedgerReceipt.make({ + ledger: Schema.decodeSync(ledgerRef)("temporal-activity-ledger"), + completion: LedgerCompletion.make({ + ledgerFormatVersion: 1, + runId: "temporal-activity-run", + recordCount: 2, + artifacts: { manifest: DIGEST, records: DIGEST }, + }), + }), + ), +}; +const FAILED_RESULT: RunControllerResult = { + exitCode: 1, + summary: ledgerAllocationFailedSummary(), +}; + +interface FakeState { + readonly events: string[]; + readonly observations: ControllerObservation[]; + readonly namespacePresence: boolean[]; +} + +function fakeOperations(state: FakeState): LifecycleOperationsService { + return { + bindHeartbeat: () => () => { + state.events.push(HEARTBEAT_EVENT); + }, + prepareRun: (input) => + Effect.sync(() => { + state.events.push(`prepare:${input.namespace}`); + }), + observeController: () => + Effect.suspend(() => { + state.events.push("observe-controller"); + const observation = state.observations.shift(); + return observation === undefined + ? Effect.fail( + new KubernetesCallFailed("supply a fake controller observation"), + ) + : Effect.succeed(observation); + }), + deleteRunNamespace: (namespace) => + Effect.sync(() => { + state.events.push(`delete:${namespace}`); + }), + runNamespaceExists: () => + Effect.sync(() => { + state.events.push("observe-namespace"); + return state.namespacePresence.shift() ?? false; + }), + waitBeforeObservation: () => + Effect.sync(() => { + state.events.push("wait"); + }), + }; +} + +function fakeActivities(current: FakeState): RunLifecycleActivities { + return Effect.runSync( + runLifecycleActivities.pipe( + Effect.provideService(LifecycleOperations, fakeOperations(current)), + ), + ); +} + +function state( + observations: ControllerObservation[] = [], + namespacePresence: boolean[] = [], +): FakeState { + return { events: [], observations, namespacePresence }; +} + +// eslint-disable-next-line max-lines-per-function, sonarjs/max-lines-per-function -- The regression-only group shares one fake Temporal state machine whose event order is the contract under test. +describe("run lifecycle activities", () => { + const operationsOf = (recorded: { readonly events: readonly string[] }) => + recorded.events.filter((event) => event !== HEARTBEAT_EVENT); + + it("creates one controller attempt and waits for its successful Job", async () => { + const current = state([ + { _tag: "running" }, + { _tag: "succeeded", result: PROGRAM_RESULT }, + ]); + const activities = fakeActivities(current); + + await expect(activities.runControllerOnce(INPUT)).resolves.toEqual( + PROGRAM_RESULT, + ); + // Proof of life runs on its own schedule, not between observations. + expect(operationsOf(current)).toEqual([ + `prepare:${INPUT.namespace}`, + "observe-controller", + "wait", + "observe-controller", + ]); + // The attempt proves itself alive before it starts admitting a cohort. + // Preparing a large one outlasts the deadline, so a signal that waits for + // the observation loop arrives too late. + expect(current.events[0]).toBe(HEARTBEAT_EVENT); + expect(current.events.indexOf(HEARTBEAT_EVENT)).toBeLessThan( + current.events.indexOf(`prepare:${INPUT.namespace}`), + ); + }); + + it("returns a closed failed result from a nonzero controller Job", async () => { + const current = state([ + { + _tag: "failed", + detail: "controller Job failed", + result: FAILED_RESULT, + }, + ]); + const activities = fakeActivities(current); + + await expect(activities.runControllerOnce(INPUT)).resolves.toEqual( + FAILED_RESULT, + ); + expect(operationsOf(current)).toEqual([ + `prepare:${INPUT.namespace}`, + "observe-controller", + ]); + expect(current.events).toContain(HEARTBEAT_EVENT); + }); + + it("fails the workflow activity with the retained controller diagnostic", async () => { + const current = state([ + { _tag: "failed", detail: "controller Job failed\napplication failed" }, + ]); + const activities = fakeActivities(current); + + await expect(activities.runControllerOnce(INPUT)).rejects.toMatchObject({ + name: "ControllerAttemptFailed", + message: "controller Job failed\napplication failed", + }); + expect(operationsOf(current)).toEqual([ + `prepare:${INPUT.namespace}`, + "observe-controller", + ]); + expect(current.events).toContain(HEARTBEAT_EVENT); + }); + + it("deletes the namespace idempotently and waits until it is absent", async () => { + const current = state([], [true, true, false]); + const activities = fakeActivities(current); + + await expect( + activities.cleanupRun({ + runId: INPUT.runId, + namespace: INPUT.namespace, + }), + ).resolves.toBeUndefined(); + expect(current.events).toEqual([ + `delete:${INPUT.namespace}`, + "observe-namespace", + "wait", + "observe-namespace", + "wait", + "observe-namespace", + ]); + }); +}); + +describe("executeRunSocietyWorkflow", () => { + it("starts one caller-identified workflow and waits for its result", async () => { + const execute = vi + .fn() + .mockResolvedValue(PROGRAM_RESULT); + const client: WorkflowExecutor = { execute }; + + await expect( + executeRunSocietyWorkflow(INPUT, { + client, + workflowId: "workflow-run-1", + taskQueue: "moltzap-simulator", + }), + ).resolves.toEqual(PROGRAM_RESULT); + expect(execute).toHaveBeenCalledOnce(); + expect(execute).toHaveBeenCalledWith("runSocietyWorkflow", { + workflowId: "workflow-run-1", + taskQueue: "moltzap-simulator", + args: [INPUT], + }); + }); +}); + +/* eslint-enable agent-code-guard/async-keyword -- Restore Effect-first test rules after Temporal activity and client assertions. */ +/* eslint-enable agent-code-guard/no-example-only-tests -- Restore generative-test requirements after the Temporal lifecycle regressions. */ diff --git a/packages/simulator/src/cluster/temporal.ts b/packages/simulator/src/cluster/temporal.ts new file mode 100644 index 000000000..de1b665ef --- /dev/null +++ b/packages/simulator/src/cluster/temporal.ts @@ -0,0 +1,411 @@ +/** @file Non-deterministic Temporal boundary: activities, worker, client, submission. */ + +import { fileURLToPath } from "node:url"; +import { Context as ActivityContext } from "@temporalio/activity"; +import { Client, Connection, type WorkflowClient } from "@temporalio/client"; +import { NativeConnection, Worker } from "@temporalio/worker"; +import { + Cause, + Context, + Duration, + Effect, + Exit, + Option, + Runtime, +} from "effect"; +import type { + CleanupRunInput, + RunControllerResult, + RunLifecycleActivities, + RunSocietyWorkflowInput, + runSocietyWorkflow, +} from "./reclaim.js"; +import { isEntryModule } from "./entry.js"; +import { installRunWorker } from "./install.js"; +import { + makeKubernetesRunWorkerInstallApi, + type KubernetesCallFailed, +} from "./kubernetes/calls.js"; +import { IN_CLUSTER_TEMPORAL_ADDRESS } from "./kubernetes/objects.js"; +import { + decodeKubernetesExecutionProfile, + LOCAL_KUBERNETES_EXECUTION_PROFILE, + type KubernetesExecutionProfile, +} from "./profile.js"; +import { makeKubernetesRunLifecycleOperations } from "./watch.js"; + +const WORKFLOW_TYPE = "runSocietyWorkflow"; +const DEFAULT_TEMPORAL_NAMESPACE = "default"; + +/** Coarse controller state observed by the host-side activity. */ +export type ControllerObservation = + | { readonly _tag: "running" } + | { + readonly _tag: "succeeded"; + readonly result: RunControllerResult; + } + | { + readonly _tag: "failed"; + readonly detail: string; + readonly result?: RunControllerResult; + }; + +/** Process environment read by the in-cluster worker Deployment. */ +export type RunWorkerEnvironment = Readonly>; + +/** Caller-owned identity and queue for a single workflow execution. */ +export interface RunSocietyWorkflowExecutionOptions { + readonly client: Pick; + readonly workflowId: string; + readonly taskQueue: string; +} + +/** Host profile inputs for one workflow, with identity selected by the caller. */ +export interface RunTemporalSocietyOptions { + readonly input: RunSocietyWorkflowInput; + readonly executionProfile?: KubernetesExecutionProfile; + readonly workflowId: string; + readonly taskQueue: string; + readonly temporalAddress?: string; + readonly temporalNamespace?: string; + /** Temporal endpoint as the in-cluster worker reaches it, not as the host does. */ + readonly workerTemporalAddress?: string; +} + +/* eslint-disable agent-code-guard/async-keyword, agent-code-guard/promise-type, @typescript-eslint/no-invalid-void-type -- Temporal activities, workers, clients, and their host-operation dependencies are SDK-required Promise boundaries. */ + +/** Liveness signal proving the worker still owns the controller attempt. */ +export type ControllerHeartbeat = () => void; + +/** Injectable host operations kept outside deterministic workflow code. */ +export interface RunLifecycleOperations { + readonly prepareRun: ( + input: RunSocietyWorkflowInput, + ) => Effect.Effect; + readonly observeController: ( + input: RunSocietyWorkflowInput, + ) => Effect.Effect; + readonly deleteRunNamespace: ( + namespace: string, + ) => Effect.Effect; + readonly runNamespaceExists: ( + namespace: string, + ) => Effect.Effect; + readonly waitBeforeObservation: () => Effect.Effect; +} + +/** Host operations plus the liveness signal one worker attempt owns. */ +export interface LifecycleOperationsService extends RunLifecycleOperations { + /** + * Bind a heartbeat to the activity running now, called where the SDK still + * owns the ambient execution context. A heartbeat fiber resuming after a + * timer no longer does, so it cannot resolve that context for itself, and an + * implementation that resolves it per call throws there instead of + * signalling. Required so that omitting it cannot compile. + */ + readonly bindHeartbeat: () => ControllerHeartbeat; +} + +/** Lifecycle boundaries the worker's activities read from their environment. */ +export class LifecycleOperations extends Context.Tag( + "@moltzap/simulator/LifecycleOperations", +)() {} + +/** SDK objects needed to build a worker without selecting connection policy. */ +interface RunSocietyWorkerOptions { + readonly connection: NativeConnection; + readonly namespace: string; + readonly taskQueue: string; + readonly activities: RunLifecycleActivities; +} + +// Comfortably inside the activity's heartbeat deadline in reclaim.ts. +const HEARTBEAT_INTERVAL = Duration.seconds(10); + +class ControllerAttemptFailed extends Error { + override readonly name = "ControllerAttemptFailed"; +} + +// eslint-disable-next-line agent-code-guard/max-non-trivial-classes-per-file -- a controller attempt that ended without a result and a worker started without its environment are the two ways this one SDK boundary refuses to proceed +class RunWorkerConfigurationFailed extends Error { + override readonly name = "RunWorkerConfigurationFailed"; +} + +function runControllerOnce( + operations: LifecycleOperationsService, + heartbeat: ControllerHeartbeat, + input: RunSocietyWorkflowInput, +): Effect.Effect< + RunControllerResult, + ControllerAttemptFailed | KubernetesCallFailed +> { + // Preparing a cohort outlasts the heartbeat deadline, so proof of life + // cannot depend on reaching the observation loop. + return Effect.scoped( + Effect.gen(function* () { + const beat = Effect.sync(heartbeat); + yield* beat; + yield* Effect.forkScoped( + Effect.sleep(HEARTBEAT_INTERVAL).pipe( + // A signal that throws must cost one beat, not the rest of the + // attempt: an unrecovered failure ends the loop and starves the + // deadline exactly as the missing signal did. + Effect.zipRight(beat.pipe(Effect.tapErrorCause(Effect.logError))), + Effect.ignore, + Effect.forever, + ), + ); + yield* operations.prepareRun(input); + for (;;) { + const observation = yield* operations.observeController(input); + switch (observation._tag) { + case "succeeded": + return observation.result; + case "failed": + if (observation.result !== undefined) { + return observation.result; + } + return yield* Effect.fail( + new ControllerAttemptFailed(observation.detail), + ); + case "running": + yield* operations.waitBeforeObservation(); + break; + default: + return yield* Effect.fail( + new ControllerAttemptFailed( + "controller returned an unsupported observation", + ), + ); + } + } + }), + ); +} + +function cleanupRun( + operations: LifecycleOperationsService, + input: CleanupRunInput, +): Effect.Effect { + return Effect.gen(function* () { + yield* operations.deleteRunNamespace(input.namespace); + while (yield* operations.runNamespaceExists(input.namespace)) { + yield* operations.waitBeforeObservation(); + } + }); +} + +/** + * Run one Effect where an SDK owns a Promise-returning signature. + * + * This is the only place a run's Effect becomes a Promise. Rejecting with the + * run's own failure rather than the runtime's wrapper is what lets Temporal + * record the error the activity actually produced. + * + * @param effect The complete operation whose failure the SDK should observe. + * @returns The operation's success, or a rejection carrying its failure. + */ +// #ignore-sloppy-code-next-line[async-keyword]: Temporal workers, clients, and activities are SDK-required Promise boundaries +async function runAtPromiseBoundary( + effect: Effect.Effect, + // #ignore-sloppy-code-next-line[promise-type]: Temporal workers, clients, and activities are SDK-required Promise boundaries +): Promise { + const exit = await Effect.runPromiseExit(effect); + if (Exit.isSuccess(exit)) { + return exit.value; + } + throw Option.getOrElse(Cause.failureOption(exit.cause), () => + Runtime.makeFiberFailure(exit.cause), + ); +} + +/** The two activities the coarse workflow worker registers. */ +export const runLifecycleActivities: Effect.Effect< + RunLifecycleActivities, + never, + LifecycleOperations +> = Effect.map(LifecycleOperations, (operations) => + Object.freeze({ + runControllerOnce: (input: RunSocietyWorkflowInput) => + runAtPromiseBoundary( + runControllerOnce(operations, operations.bindHeartbeat(), input), + ), + cleanupRun: (input: CleanupRunInput) => + runAtPromiseBoundary(cleanupRun(operations, input)), + }), +); + +/** + * Bind the worker Pod's Kubernetes access and Temporal heartbeat. + * @param profile Private local or GKE cluster selected by the host. + * @returns Lifecycle operations backed by the worker Pod's service account. + */ +export function kubernetesLifecycleOperations( + profile: KubernetesExecutionProfile = LOCAL_KUBERNETES_EXECUTION_PROFILE, +): LifecycleOperationsService { + return { + ...makeKubernetesRunLifecycleOperations(profile), + bindHeartbeat: () => { + const activity = ActivityContext.current(); + return () => { + activity.heartbeat(); + }; + }, + }; +} + +/** + * Create a worker that registers only the coarse workflow and its two activities. + * @param options Existing connection, namespace, queue, and activity implementations. + * @returns A worker ready to poll the selected task queue. + */ +// #ignore-sloppy-code-next-line[async-keyword]: Temporal workers, clients, and activities are SDK-required Promise boundaries +async function createRunSocietyWorker( + options: RunSocietyWorkerOptions, + // #ignore-sloppy-code-next-line[promise-type]: Temporal workers, clients, and activities are SDK-required Promise boundaries +): Promise { + return await Worker.create({ + connection: options.connection, + namespace: options.namespace, + taskQueue: options.taskQueue, + activities: options.activities, + workflowsPath: fileURLToPath(new URL("./reclaim.js", import.meta.url)), + }); +} + +function required(environment: RunWorkerEnvironment, key: string): string { + const value = environment[key]; + if (value === undefined || value.length === 0) { + throw new RunWorkerConfigurationFailed(`${key} is required by the worker`); + } + return value; +} + +function workerProfile( + environment: RunWorkerEnvironment, +): KubernetesExecutionProfile { + const encoded = environment.MOLTZAP_EXECUTION_PROFILE; + return encoded === undefined || encoded.length === 0 + ? LOCAL_KUBERNETES_EXECUTION_PROFILE + : decodeKubernetesExecutionProfile(encoded); +} + +/** + * Poll the run-lifecycle task queue until the process is shut down. + * + * This is the only place a worker runs. Serving the queue from a submitting + * process would tie a run's cleanup to whichever host started it, and a host + * that goes away leaves the run's namespace behind. + * + * @param environment Temporal endpoint, queue, and cluster profile. + * @returns Nothing once the worker has shut down and released its connection. + */ +// #ignore-sloppy-code-next-line[async-keyword]: Temporal workers, clients, and activities are SDK-required Promise boundaries +export async function serveRunSocietyWorker( + environment: RunWorkerEnvironment, + // #ignore-sloppy-code-next-line[promise-type]: Temporal workers, clients, and activities are SDK-required Promise boundaries +): Promise { + const connection = await NativeConnection.connect({ + address: required(environment, "MOLTZAP_TEMPORAL_ADDRESS"), + }); + try { + const worker = await createRunSocietyWorker({ + connection, + namespace: required(environment, "MOLTZAP_TEMPORAL_NAMESPACE"), + taskQueue: required(environment, "MOLTZAP_TEMPORAL_TASK_QUEUE"), + // The SDK takes a plain activity record, so the environment is resolved + // here rather than carried into the worker's Promise-native lifetime. + activities: Effect.runSync( + runLifecycleActivities.pipe( + Effect.provideService( + LifecycleOperations, + kubernetesLifecycleOperations(workerProfile(environment)), + ), + ), + ), + }); + await worker.run(); + } finally { + await connection.close(); + } +} + +/** + * Start exactly one workflow execution and wait for its controller result. + * @param input Serializable controller input carried by the workflow. + * @param options Caller-selected Temporal client, identity, and task queue. + * @returns The successful controller activity result. + */ +// #ignore-sloppy-code-next-line[async-keyword]: Temporal workers, clients, and activities are SDK-required Promise boundaries +export async function executeRunSocietyWorkflow( + input: RunSocietyWorkflowInput, + options: RunSocietyWorkflowExecutionOptions, + // #ignore-sloppy-code-next-line[promise-type]: Temporal workers, clients, and activities are SDK-required Promise boundaries +): Promise { + return await options.client.execute( + WORKFLOW_TYPE, + { + workflowId: options.workflowId, + taskQueue: options.taskQueue, + args: [input], + }, + ); +} + +/** + * Submit one run to the cluster's worker and wait for its controller result. + * + * The submitting process is only a Temporal client. A worker embedded here would + * end with the process, stranding the workflow's cleanup and leaving the run's + * namespace behind, so the queue is served by a Deployment that outlives any one + * submission and that this call installs before submitting. + * + * @param options Temporal endpoint plus caller-owned workflow and run inputs. + * @returns The successful controller activity result. + */ +// #ignore-sloppy-code-next-line[async-keyword]: Temporal workers, clients, and activities are SDK-required Promise boundaries +export async function runTemporalSociety( + options: RunTemporalSocietyOptions, + // #ignore-sloppy-code-next-line[promise-type]: Temporal workers, clients, and activities are SDK-required Promise boundaries +): Promise { + const namespace = options.temporalNamespace ?? DEFAULT_TEMPORAL_NAMESPACE; + await runAtPromiseBoundary( + installRunWorker( + makeKubernetesRunWorkerInstallApi({ + controllerImage: options.input.controllerImage, + taskQueue: options.taskQueue, + temporalAddress: + options.workerTemporalAddress ?? IN_CLUSTER_TEMPORAL_ADDRESS, + temporalNamespace: namespace, + profile: options.executionProfile ?? LOCAL_KUBERNETES_EXECUTION_PROFILE, + }), + ), + ); + const connection = await Connection.connect( + options.temporalAddress === undefined + ? undefined + : { address: options.temporalAddress }, + ); + try { + const client = new Client({ connection, namespace }); + return await executeRunSocietyWorkflow(options.input, { + client: client.workflow, + taskQueue: options.taskQueue, + workflowId: options.workflowId, + }); + } finally { + await connection.close(); + } +} + +function isDirectInvocation(): boolean { + // eslint-disable-next-line agent-code-guard/prefer-effect-platform -- Direct-entry detection has no Effect Platform equivalent. + return isEntryModule(import.meta.url, process.argv[1]); +} + +if (isDirectInvocation()) { + // eslint-disable-next-line agent-code-guard/no-process-env-at-runtime -- The executable boundary injects the environment into the typed worker configuration. + await serveRunSocietyWorker(process.env); +} + +/* eslint-enable agent-code-guard/async-keyword, agent-code-guard/promise-type, @typescript-eslint/no-invalid-void-type -- Restore Effect-first application rules after the Temporal boundary. */ diff --git a/packages/simulator/src/cluster/watch.test.ts b/packages/simulator/src/cluster/watch.test.ts new file mode 100644 index 000000000..9bb13c665 --- /dev/null +++ b/packages/simulator/src/cluster/watch.test.ts @@ -0,0 +1,208 @@ +/* eslint-disable agent-code-guard/async-keyword -- Vitest awaits the Effect the activity boundary under test returns. */ + +import { Effect, Schema } from "effect"; +import { describe, expect, it } from "vitest"; +import { + CompletedLedgerReceipt, + IncompleteLedgerReceipt, +} from "../run/execute.js"; +import { LedgerCompletion, ledgerDigest, ledgerRef } from "../ledger/schema.js"; +import { + encodeControllerRunSummary, + programFinishedSummary, + clusterLostSummary, + type ControllerRunSummary, +} from "./controller/summary.js"; +import { + KubernetesCallFailed, + type JobCondition, + type JobObservation, + type RunControlApi, +} from "./kubernetes/calls.js"; +import type { RunSocietyWorkflowInput } from "./reclaim.js"; +import { + controllerObservation, + observeController, + sanitizeControllerDiagnostic, +} from "./watch.js"; + +const DIGEST = Schema.decodeSync(ledgerDigest)("d".repeat(64)); +const LEDGER = Schema.decodeSync(ledgerRef)("temporal-kubernetes-ledger"); +const PROGRAM_SUMMARY = programFinishedSummary( + CompletedLedgerReceipt.make({ + ledger: LEDGER, + completion: LedgerCompletion.make({ + ledgerFormatVersion: 1, + runId: "temporal-kubernetes-run", + recordCount: 5, + artifacts: { manifest: DIGEST, records: DIGEST }, + }), + }), +); +const INPUT: RunSocietyWorkflowInput = { + runId: "run-1", + namespace: "mz-run-1", + controllerImage: "registry/controller@sha256:controller", + supportImage: "registry/support@sha256:support", + experimentModule: "export const runSpec = society;", +}; + +function job( + status: Partial & { conditions?: readonly JobCondition[] }, +): JobObservation { + return { + succeeded: 0, + failed: 0, + active: 0, + conditions: [], + ...status, + }; +} + +function encodedSummary(summary: ControllerRunSummary): string { + const encoded = encodeControllerRunSummary(summary); + expect(encoded).toBeDefined(); + return encoded ?? ""; +} + +/* eslint-disable agent-code-guard/no-example-only-tests -- Regression-only cases pin bounded projection of third-party Kubernetes Job status and logs. */ + +// eslint-disable-next-line max-lines-per-function, sonarjs/max-lines-per-function -- The regression-only group is one closed Job-status and controller-summary decision table. +describe("controller Job diagnostics", () => { + it("keeps useful failure output while removing credentials and control bytes", () => { + const observation = controllerObservation( + job({ + failed: 1, + conditions: [ + { + type: "Failed", + status: "True", + reason: "BackoffLimitExceeded", + message: "controller exited", + }, + ], + }), + "starting experiment\nregistrationSecret=do-not-retain\n\u001b[31mrun failed\u001b[0m\u0007", + ); + + expect(observation).toEqual({ + _tag: "failed", + detail: [ + "controller Job failed", + "BackoffLimitExceeded: controller exited", + "starting experiment", + "[redacted credential-bearing log line]", + "run failed", + ].join("\n"), + }); + }); + + it("distinguishes active and completed Jobs", () => { + expect(controllerObservation(job({ active: 1 }))).toEqual({ + _tag: "running", + }); + expect( + controllerObservation( + job({ succeeded: 1 }), + encodedSummary(PROGRAM_SUMMARY), + ), + ).toEqual({ + _tag: "succeeded", + result: { exitCode: 0, summary: PROGRAM_SUMMARY }, + }); + }); + + it("keeps a Job with a failed attempt still running while one is active", () => { + expect(controllerObservation(job({ failed: 1, active: 1 }))).toEqual({ + _tag: "running", + }); + }); + + it("retains a receipt from a nonzero cluster outcome", () => { + const summary = clusterLostSummary( + IncompleteLedgerReceipt.make({ ledger: LEDGER }), + ); + + expect( + controllerObservation( + job({ failed: 1 }), + `${encodedSummary(summary)}\nSimulator controller execution failed`, + ), + ).toEqual({ + _tag: "failed", + detail: "controller Job failed\nSimulator controller execution failed", + result: { exitCode: 1, summary }, + }); + }); + + it("rejects a terminal Job without a matching closed result", () => { + expect(controllerObservation(job({ succeeded: 1 }))).toEqual({ + _tag: "failed", + detail: "controller Job completed without a valid result summary", + }); + expect( + controllerObservation( + job({ failed: 1 }), + encodedSummary(PROGRAM_SUMMARY), + ), + ).toEqual({ + _tag: "failed", + detail: "controller Job failed", + }); + }); + + it("bounds retained output to the diagnostic limit", () => { + expect(sanitizeControllerDiagnostic("x".repeat(8_192))).toHaveLength(4_096); + }); +}); + +/* eslint-enable agent-code-guard/no-example-only-tests -- Restore generative-test requirements after the Kubernetes projection regressions. */ + +function observing(observed: JobObservation, logs?: string) { + const reads: string[] = []; + const api: RunControlApi = { + createRunRoot: () => + Effect.fail(new KubernetesCallFailed("observing creates nothing")), + createExperimentAndQueue: () => Effect.void, + createControllerAccess: () => Effect.void, + createRouterService: () => Effect.void, + startController: () => Effect.void, + readControllerJob: () => Effect.succeed(observed), + readControllerLogs: (namespace, tailLines, limitBytes) => + Effect.sync(() => { + reads.push(`${namespace}:${String(tailLines)}:${String(limitBytes)}`); + return logs; + }), + deleteRunNamespace: () => Effect.void, + runNamespaceExists: () => Effect.succeed(false), + }; + return { api, reads }; +} + +it("spends no Pod-log read on a Job that is still running", async () => { + const { api, reads } = observing(job({ active: 1 })); + + await expect( + Effect.runPromise(observeController(api, INPUT)), + ).resolves.toEqual({ _tag: "running" }); + + expect(reads).toEqual([]); +}); + +it("reads a bounded log tail once the Job is terminal", async () => { + const { api, reads } = observing( + job({ succeeded: 1 }), + encodedSummary(PROGRAM_SUMMARY), + ); + + await expect( + Effect.runPromise(observeController(api, INPUT)), + ).resolves.toEqual({ + _tag: "succeeded", + result: { exitCode: 0, summary: PROGRAM_SUMMARY }, + }); + + expect(reads).toEqual([`${INPUT.namespace}:200:8192`]); +}); + +/* eslint-enable agent-code-guard/async-keyword -- Restore Effect-first test rules after the activity observation contract. */ diff --git a/packages/simulator/src/cluster/watch.ts b/packages/simulator/src/cluster/watch.ts new file mode 100644 index 000000000..7cd8a2751 --- /dev/null +++ b/packages/simulator/src/cluster/watch.ts @@ -0,0 +1,257 @@ +/** @file Read the controller Job's status and its bounded, redacted output. */ + +import { stripVTControlCharacters } from "node:util"; +import { Duration, Effect } from "effect"; +import type { + ControllerObservation, + RunLifecycleOperations, +} from "./temporal.js"; +import type { + RunControllerResult, + RunSocietyWorkflowInput, +} from "./reclaim.js"; +import { + LOCAL_KUBERNETES_EXECUTION_PROFILE, + type KubernetesExecutionProfile, +} from "./profile.js"; +// safer-arch-ignore no-upward-layer-import: reading the controller Job's logs means parsing exactly the summary the controller printed, so the decoder is owned where the controller writes it. +import { + CONTROLLER_SUMMARY_PREFIX, + decodeControllerRunSummary, +} from "./controller/summary.js"; +import { + makeKubernetesRunControlApi, + type JobObservation, + type KubernetesCallFailed, + type RunControlApi, +} from "./kubernetes/calls.js"; +import { prepareRun } from "./scaffold.js"; + +const OBSERVATION_INTERVAL_MS = 1_000; +const DIAGNOSTIC_LIMIT = 4_096; +const CONTROLLER_LOG_TAIL_LINES = 200; +const SENSITIVE_LOG_LINE = + /(authorization|bearer|token|secret|password|api[-_ ]?key|agent[-_ ]?key)/iu; + +function safeDiagnosticCodePoint(code?: number): boolean { + if (code === undefined) { + return false; + } + if (code === 9 || code === 10 || code === 13) { + return true; + } + return code >= 32 && code !== 127; +} + +function removeUnsafeControlCharacters(value: string): string { + let result = ""; + for (const character of value) { + if (safeDiagnosticCodePoint(character.codePointAt(0))) { + result += character; + } + } + return result; +} + +/** + * Remove credentials and terminal controls before retaining controller output. + * @param value Raw bounded output returned by Kubernetes. + * @returns Diagnostic text safe to retain in a Temporal failure. + */ +export function sanitizeControllerDiagnostic(value: string): string { + const normalized = removeUnsafeControlCharacters( + stripVTControlCharacters(value), + ) + .split("\n") + .map((line) => + SENSITIVE_LOG_LINE.test(line) + ? "[redacted credential-bearing log line]" + : line, + ) + .join("\n") + .trim(); + return normalized.slice(-DIAGNOSTIC_LIMIT); +} + +function conditionDetail(job: JobObservation): string | undefined { + const failed = job.conditions.find( + (condition) => condition.type === "Failed" && condition.status === "True", + ); + if (failed === undefined) { + return undefined; + } + const detail = [failed.reason, failed.message].filter(Boolean).join(": "); + return detail.length === 0 ? undefined : sanitizeControllerDiagnostic(detail); +} + +function jobConditionIsTrue(job: JobObservation, type: string): boolean { + return job.conditions.some( + (condition) => condition.type === type && condition.status === "True", + ); +} + +function jobSucceeded(job: JobObservation): boolean { + return job.succeeded > 0 || jobConditionIsTrue(job, "Complete"); +} + +function jobFailed(job: JobObservation): boolean { + return ( + jobConditionIsTrue(job, "Failed") || (job.failed > 0 && job.active === 0) + ); +} + +function controllerSummary(logs: string) { + return decodeControllerRunSummary(logs); +} + +function succeededControllerObservation(logs: string): ControllerObservation { + const summary = controllerSummary(logs); + if (summary === undefined || summary._tag !== "ProgramFinished") { + return { + _tag: "failed", + detail: "controller Job completed without a valid result summary", + }; + } + return { + _tag: "succeeded", + result: { exitCode: 0, summary }, + }; +} + +function failedControllerResult(logs: string): RunControllerResult | undefined { + const summary = controllerSummary(logs); + if (summary === undefined || summary._tag === "ProgramFinished") { + return undefined; + } + return { exitCode: 1, summary }; +} + +function sanitizedControllerLogs(logs: string): string { + return sanitizeControllerDiagnostic( + logs + .split("\n") + .filter((line) => !line.startsWith(CONTROLLER_SUMMARY_PREFIX)) + .join("\n"), + ); +} + +function failedControllerObservation( + job: JobObservation, + logs: string, +): ControllerObservation { + const result = failedControllerResult(logs); + const detail = [ + "controller Job failed", + conditionDetail(job), + sanitizedControllerLogs(logs), + ] + .filter((part): part is string => part !== undefined && part.length > 0) + .join("\n"); + return result === undefined + ? { _tag: "failed", detail } + : { _tag: "failed", detail, result }; +} + +/** + * Project Job state and bounded controller output into activity state. + * @param job Coarse Job status decoded by the Kubernetes adapter. + * @param logs Optional bounded log tail from the controller container. + * @returns The coarse state consumed by the activity polling loop. + */ +export function controllerObservation( + job: JobObservation, + logs?: string, +): ControllerObservation { + const resolvedLogs = logs ?? ""; + if (jobSucceeded(job)) { + return succeededControllerObservation(resolvedLogs); + } + if (!jobFailed(job)) { + return { _tag: "running" }; + } + return failedControllerObservation(job, resolvedLogs); +} + +// The Job's own status already says whether the run ended and how, so output +// that cannot be read costs detail in the failure message and nothing else. A +// terminal Job whose Pod was evicted before its log could be fetched still has +// to produce an observation rather than fail the whole activity attempt. +function terminalControllerLogs( + api: RunControlApi, + namespace: string, +): Effect.Effect { + return api + .readControllerLogs( + namespace, + CONTROLLER_LOG_TAIL_LINES, + DIAGNOSTIC_LIMIT * 2, + ) + .pipe( + Effect.catchAll((failure) => + Effect.logWarning( + `Simulator controller logs unavailable: ${failure.message}`, + ).pipe(Effect.as(undefined)), + ), + ); +} + +/** + * Observe the controller Job once, reading its output only when it is terminal. + * + * A running Job's log tail is a partial transcript, and the caller polls on the + * order of a second, so reading it every tick would spend a Pod-log request per + * observation to produce nothing the observation can use. + * + * @param api Kubernetes access held by the worker running this activity. + * @param input Run identity carrying the namespace to observe. + * @returns The coarse controller state, with a result once one is decodable. + * @failure KubernetesCallFailed when the Job's own status cannot be read. + */ +export function observeController( + api: RunControlApi, + input: RunSocietyWorkflowInput, +): Effect.Effect { + return Effect.gen(function* () { + const job = yield* api.readControllerJob(input.namespace); + const logs = + jobSucceeded(job) || jobFailed(job) + ? yield* terminalControllerLogs(api, input.namespace) + : undefined; + return controllerObservation(job, logs); + }).pipe(Effect.withSpan("observeController")); +} + +/** + * Bind one run's lifecycle to a cluster the worker Pod already has access to. + * @param api Kubernetes access held by the worker running these activities. + * @param profile Private local or GKE cluster selected by the host. + * @returns The lifecycle operations the worker's activities are built from. + */ +function runLifecycleOperations( + api: RunControlApi, + profile: KubernetesExecutionProfile, +): RunLifecycleOperations { + return Object.freeze({ + prepareRun: (input: RunSocietyWorkflowInput) => + prepareRun(api, input, profile), + observeController: (input: RunSocietyWorkflowInput) => + observeController(api, input), + deleteRunNamespace: (namespace: string) => + api.deleteRunNamespace(namespace), + runNamespaceExists: (namespace: string) => + api.runNamespaceExists(namespace), + waitBeforeObservation: () => + Effect.sleep(Duration.millis(OBSERVATION_INTERVAL_MS)), + }); +} + +/** + * Build the live Kubernetes operations used by one activity worker. + * @param profile Private local or GKE cluster selected by the host. + * @returns Operations backed by the worker Pod's service account. + */ +export function makeKubernetesRunLifecycleOperations( + profile: KubernetesExecutionProfile = LOCAL_KUBERNETES_EXECUTION_PROFILE, +): RunLifecycleOperations { + return runLifecycleOperations(makeKubernetesRunControlApi(), profile); +} diff --git a/packages/simulator/src/definition.test.ts b/packages/simulator/src/definition.test.ts index 7092a1e03..8bec06507 100644 --- a/packages/simulator/src/definition.test.ts +++ b/packages/simulator/src/definition.test.ts @@ -1,7 +1,11 @@ import { assert, it } from "@effect/vitest"; -import { Effect, Schema } from "effect"; -import { simulator, SimulatorDefinitionError } from "./definition.js"; -import { RuntimeCompleted, defineRuntime } from "./runtime/runtime.js"; +import { Effect, Layer, Schema } from "effect"; +import { Run, RunSpec, SimulatorDefinitionError } from "./definition.js"; +import { EventCatalog } from "./events/catalog.js"; +import { LedgerStorage } from "./ledger/storage.js"; +import { RouterProvider } from "./network/router.js"; +import { Cluster } from "./cluster/cluster.js"; +import { defineRuntime } from "./agents/agent.js"; const testRuntimeConfiguration = Schema.Struct({}); const configuration = { @@ -12,20 +16,68 @@ const configuration = { const runtime = defineRuntime({ name: "definition-binding-test", configuration, - acquire: () => - Effect.succeed({ - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }), }); -it("rejects a roster owned by a distinct definition with the same id", () => { - const first = simulator.define("acme.definition-binding/v1"); - const second = simulator.define("acme.definition-binding/v1"); - const roster = first.agents({ alice: runtime }); +class DefinitionObservation extends Schema.TaggedClass()( + "acme.definition-observation/v1", + { value: Schema.String }, +) {} +const definitionEvents = EventCatalog.make(DefinitionObservation); + +function definitionCluster() { + return Layer.mergeAll( + Layer.effect(LedgerStorage, Effect.never), + Layer.effect(RouterProvider, Effect.never), + Layer.effect(Cluster, Effect.never), + ); +} + +it("captures an immutable RunSpec without freezing caller-owned input", () => { + const events = [definitionEvents]; + const agents = { alice: runtime }; + const replacementRuntime = defineRuntime({ + name: "definition-binding-replacement", + configuration, + }); + const cluster = definitionCluster(); + const replacementCluster = definitionCluster(); + const execute = () => Effect.succeed("original"); + const replacementExecute = () => Effect.succeed("replacement"); + const input = { + id: "acme.run-spec-snapshot/v1" as const, + events, + agents, + cluster, + execute, + }; + + const spec = RunSpec.define(input); + input.events = []; + agents.alice = replacementRuntime; + input.cluster = replacementCluster; + input.execute = replacementExecute; + + assert.strictEqual(spec.events.length, 1); + assert.strictEqual(spec.events[0], definitionEvents); + assert.strictEqual(spec.agents.alice, runtime); + assert.strictEqual(spec.cluster, cluster); + assert.strictEqual(spec.execute, execute); + assert.isTrue(Object.isFrozen(spec)); + assert.isTrue(Object.isFrozen(spec.events)); + assert.isTrue(Object.isFrozen(spec.agents)); + assert.isFalse(Object.isFrozen(events)); + assert.notStrictEqual(spec.events, events); + assert.deepStrictEqual(Reflect.ownKeys(spec), [ + "id", + "events", + "agents", + "cluster", + "execute", + Symbol.for("@moltzap/simulator/RunSpec"), + ]); assert.throws( - () => second.run(roster, Effect.void), + () => Run.execute({ ...spec, execute: replacementExecute }), SimulatorDefinitionError, ); }); diff --git a/packages/simulator/src/definition.ts b/packages/simulator/src/definition.ts index bff648fd0..596fc4ca1 100644 --- a/packages/simulator/src/definition.ts +++ b/packages/simulator/src/definition.ts @@ -1,22 +1,23 @@ /** @file Definition-bound assembly of catalogs, services, rosters, and runs. */ -import { type Effect, Schema } from "effect"; -import { EventCatalog, type EventClass } from "./events/catalog.js"; -import { makeDefinitionEventServices } from "./kernel/event-services.js"; +import { Effect, type Layer, Schema } from "effect"; +import { EventCatalog } from "./events/catalog.js"; import { - openLedger, - type CompletedRunLedger, - type LedgerOpenError, -} from "./ledger/open.js"; -import type { JsonObject, JsonValue, LedgerRef } from "./ledger/model.js"; + makeDefinitionEventServices, + type CustomerEvents, + type ReadableRunLedger, +} from "./run/events.js"; import type { LedgerStorage } from "./ledger/storage.js"; -import { runSociety, type SimulatorRunOptions } from "./kernel/run.js"; +import { runSociety } from "./run/execute.js"; +import { Network, type NetworkService } from "./network/endpoint.js"; +import type { RouterProvider } from "./network/router.js"; +import type { Cluster } from "./cluster/cluster.js"; import { makeAgentRosterBinding, - type makeAgentRosterBuilder, type AgentRoster, -} from "./runtime/roster.js"; -import type { AgentRuntimeLike } from "./runtime/runtime.js"; + type StartedAgents, +} from "./agents/roster.js"; +import type { AgentRuntimeLike } from "./agents/agent.js"; /** Stable code identity persisted in every ledger manifest. */ export type SimulatorDefinitionId = `${string}.${string}/v${number}`; @@ -71,167 +72,241 @@ type DefinitionEventServices< > >; -function isJsonArray(value: JsonValue): value is readonly JsonValue[] { - return Array.isArray(value); -} - -function snapshotJsonValue(value: JsonValue): JsonValue { - if (isJsonArray(value)) { - return Object.freeze(value.map(snapshotJsonValue)); - } - if (typeof value === "object" && value !== null) { - return snapshotJsonObject(value); - } - return value; -} - -function snapshotJsonObject(value: JsonObject): JsonObject { - return Object.freeze( - Object.fromEntries( - Object.entries(value).map(([key, entry]) => [ - key, - snapshotJsonValue(entry), - ]), - ), - ); -} +/** Opaque service set supplied by a local-Kubernetes or GKE Layer. */ +export type ClusterServices = LedgerStorage | RouterProvider | Cluster; -function snapshotRunOptions(options: SimulatorRunOptions): SimulatorRunOptions { - return Object.freeze({ - ...(options.provenance === undefined - ? {} - : { provenance: snapshotJsonObject(options.provenance) }), - ...(options.metadata === undefined - ? {} - : { metadata: snapshotJsonObject(options.metadata) }), - }); -} - -function makeRunner< - const Id extends SimulatorDefinitionId, - CustomerSchema extends Schema.Schema.AnyNoContext, - CustomerClasses extends EventClass, ->( - definitionId: Id, - eventServices: ReturnType< - typeof makeDefinitionEventServices - >, - ownsRoster: ReturnType>["owns"], -) { - return < - const Definitions extends Readonly>, - A = unknown, - E = unknown, - R = never, - >( - roster: AgentRoster, - program: Effect.Effect, - options: SimulatorRunOptions = {}, - ) => { - if (!ownsRoster(roster)) { - throw SimulatorDefinitionError.make({ - definitionId, - detail: - "the roster must be created by this definition's agents function", - }); - } - const capturedOptions = snapshotRunOptions(options); - return runSociety({ - definitionId, - eventServices, - roster, - program, - options: capturedOptions, - }); - }; +interface RunExecutionContext< + Id extends SimulatorDefinitionId, + CustomerCatalogs extends readonly AnyEventCatalog[], + Definitions extends Readonly>, +> { + readonly agents: StartedAgents; + readonly events: CustomerEvents>; + readonly network: NetworkService; + readonly ledger: ReadableRunLedger< + DefinitionEventServices["catalog"] + >; } -function makeLedgerReader< +function provideCluster< const Id extends SimulatorDefinitionId, - CustomerSchema extends Schema.Schema.AnyNoContext, - CustomerClasses extends EventClass, + const CustomerCatalogs extends readonly AnyEventCatalog[], + const Definitions extends Readonly>, + A, + E, + R, + ClusterLayerServices, + ClusterLayerError, + ClusterLayerRequirements, >( - definitionId: Id, - eventServices: ReturnType< - typeof makeDefinitionEventServices + eventServices: DefinitionEventServices, + roster: AgentRoster, + program: Effect.Effect, + cluster: Layer.Layer< + ClusterLayerServices, + ClusterLayerError, + ClusterLayerRequirements >, ) { - return ( - ref: LedgerRef, - ): Effect.Effect< - CompletedRunLedger, - LedgerOpenError, - LedgerStorage - > => openLedger(eventServices.catalog, ref, definitionId); + return runSociety({ + definitionId: roster.definitionId, + eventServices, + roster, + program, + }).pipe(Effect.provide(cluster)); } -/** Definition-bound capabilities for one versioned family of simulator runs. */ -export interface SimulatorDefinition< +type RunSpecExecution< Id extends SimulatorDefinitionId, CustomerCatalogs extends readonly AnyEventCatalog[], + Definitions extends Readonly>, + A, + E, + R, + ClusterLayer extends Layer.Layer, +> = ReturnType< + typeof provideCluster< + Id, + CustomerCatalogs, + Definitions, + A, + E, + R, + Layer.Layer.Success, + Layer.Layer.Error, + Layer.Layer.Context + > +>; + +/** + * A registered symbol, not a module-local one. The controller reaches an + * experiment through a dynamic import, so a spec is routinely built in the + * experiment's module graph and executed in the controller's; an unregistered + * symbol differs between those copies and a correct spec would be rejected. + */ +const runSpecTypeId: unique symbol = Symbol.for("@moltzap/simulator/RunSpec"); + +/** Immutable code-first definition of one experiment society. */ +export interface RunSpec< + Id extends SimulatorDefinitionId = SimulatorDefinitionId, + CustomerCatalogs extends + readonly AnyEventCatalog[] = readonly AnyEventCatalog[], + Definitions extends Readonly> = Readonly< + Record + >, + A = unknown, + E = unknown, + R = never, + ClusterLayer extends Layer.Layer< + never, + unknown, + unknown + > = Layer.Layer, > { - readonly id: Id; - readonly catalog: DefinitionEventServices["catalog"]; - readonly customerCatalog: CustomerEventCatalog; - readonly ledger: DefinitionEventServices["ledger"]; - readonly events: DefinitionEventServices["events"]; - readonly agents: ReturnType>; - readonly run: ReturnType< - typeof makeRunner< - Id, - CatalogSchemaOf>, - CatalogClassesOf> - > - >; - readonly openLedger: ReturnType< - typeof makeLedgerReader< - Id, - CatalogSchemaOf>, - CatalogClassesOf> - > + /** + * Present only on the exact values RunSpec.define produced, and carrying + * their runner. This is the one identity gate: nothing structural + * distinguishes a definition from a lookalike, and a lookalike has no + * runner to invoke. + */ + readonly [runSpecTypeId]?: () => RunSpecExecution< + Id, + CustomerCatalogs, + Definitions, + A, + E, + R, + ClusterLayer >; + readonly id: Id; + readonly events: CustomerCatalogs; + readonly agents: Definitions; + readonly cluster: ClusterLayer & + Layer.Layer< + ClusterServices, + Layer.Layer.Error, + Layer.Layer.Context + >; + readonly execute: ( + context: RunExecutionContext, + ) => Effect.Effect; } -/** - * Define the exact code and event universe for a family of simulator runs. - * Invalid definitions fail here, before any platform resource is acquired. - * @param definitionId Value supplied to the operation. - * @param customerCatalogs Value supplied to the operation. - * @returns The define simulator result. - */ -function defineSimulator< +function snapshotReadonlyArray( + values: Values, +): Values; +function snapshotReadonlyArray(values: readonly unknown[]): readonly unknown[] { + return Object.freeze([...values]); +} + +// An opaque ClusterLayer is not assignable to the projection of its own type +// parameters, so the widening lives in this overload pair rather than in an +// annotation. Passing the layer unwidened infers the constrained +// Layer instead, which drops the layer's exact +// outputs from the run's type and leaves extra outputs unsatisfied. +function concreteLayer< + ClusterLayer extends Layer.Layer, +>( + cluster: ClusterLayer, +): Layer.Layer< + Layer.Layer.Success, + Layer.Layer.Error, + Layer.Layer.Context +>; +function concreteLayer( + cluster: Layer.Layer, +): Layer.Layer { + return cluster; +} + +function defineRunSpec< const Id extends SimulatorDefinitionId, const CustomerCatalogs extends readonly AnyEventCatalog[], + const Definitions extends Readonly>, + A, + E, + R, + const ClusterLayer extends Layer.Layer, >( - definitionId: Id, - ...customerCatalogs: CustomerCatalogs -): SimulatorDefinition { - validateDefinitionId(definitionId); - const customerCatalog = EventCatalog.merge( - EventCatalog.empty(), - ...customerCatalogs, - ); - const eventServices = makeDefinitionEventServices( - definitionId, - customerCatalog, - ); - const rosterBinding = makeAgentRosterBinding(definitionId); - const open = makeLedgerReader(definitionId, eventServices); - - return Object.freeze({ - id: definitionId, - catalog: eventServices.catalog, - customerCatalog: eventServices.customerCatalog, - ledger: eventServices.ledger, - events: eventServices.events, - agents: rosterBinding.agents, - run: makeRunner(definitionId, eventServices, rosterBinding.owns), - openLedger: open, + input: RunSpec, +): RunSpec { + const id = input.id; + validateDefinitionId(id); + const catalogs = snapshotReadonlyArray(input.events); + const cluster = input.cluster; + const execute = input.execute; + const customerCatalog = EventCatalog.merge(EventCatalog.empty(), ...catalogs); + const eventServices = makeDefinitionEventServices(id, customerCatalog); + const roster = makeAgentRosterBinding(id).agents(input.agents); + const program = Effect.gen(function* () { + const agents = yield* roster.startedAgents; + const events = yield* eventServices.events; + const network = yield* Network; + const ledger = yield* eventServices.ledger; + const context: RunExecutionContext = + Object.freeze({ agents, events, network, ledger }); + return yield* Effect.suspend(() => execute(context)); }); + const spec: RunSpec< + Id, + CustomerCatalogs, + Definitions, + A, + E, + R, + ClusterLayer + > = { + id, + events: catalogs, + agents: roster.definitions, + cluster, + execute, + [runSpecTypeId]: () => + provideCluster(eventServices, roster, program, concreteLayer(cluster)), + }; + // Non-enumerable, so spreading a spec drops the brand: a copy carrying a + // replaced execute must not silently run the original program. + Object.defineProperty(spec, runSpecTypeId, { enumerable: false }); + return Object.freeze(spec); } -/** Discoverable entry point for code-first society definitions. */ -export const simulator: Readonly<{ define: typeof defineSimulator }> = - Object.freeze({ - define: defineSimulator, - }); +/** + * Whether a value carries the brand RunSpec.define installs. + * @param value Candidate produced elsewhere, typically a module export. + * @returns Whether this simulator can execute the value as a RunSpec. + */ +export function isRunSpec(value: unknown): value is RunSpec { + return typeof value === "object" && value !== null && runSpecTypeId in value; +} + +function executeRunSpec< + Id extends SimulatorDefinitionId, + CustomerCatalogs extends readonly AnyEventCatalog[], + Definitions extends Readonly>, + A, + E, + R, + ClusterLayer extends Layer.Layer, +>( + spec: RunSpec, +): RunSpecExecution { + const runner = spec[runSpecTypeId]; + if (runner === undefined) { + throw SimulatorDefinitionError.make({ + definitionId: spec.id, + detail: "Run.execute requires a RunSpec produced by RunSpec.define", + }); + } + return runner(); +} + +/** Discoverable constructor for immutable experiment definitions. */ +// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-redeclare -- the public namespace and its merged type intentionally share the accepted RunSpec spelling. +export const RunSpec: Readonly<{ define: typeof defineRunSpec }> = + Object.freeze({ define: defineRunSpec }); + +/** Discoverable execution entry point for one experiment society. */ +// eslint-disable-next-line @typescript-eslint/naming-convention -- the public execution namespace uses the accepted Run.execute spelling. +export const Run: Readonly<{ execute: typeof executeRunSpec }> = Object.freeze({ + execute: executeRunSpec, +}); diff --git a/packages/simulator/src/definition.types-check.ts b/packages/simulator/src/definition.types-check.ts deleted file mode 100644 index 93a29700d..000000000 --- a/packages/simulator/src/definition.types-check.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * A definition-bound run removes only the services it installs. Platform, - * runtime, and customer requirements remain explicit, while endpoint - * acquisition does not leak Scope into experiment code. Run options describe - * the run without altering event truth. Opening validates the complete ledger - * before returning, so its in-memory streams cannot fail. - */ - -import { Context, Data, Effect, type Exit, Schema, type Stream } from "effect"; -import type { MessageParts } from "@moltzap/protocol/message"; -import { - LinkController, - linkPolicy, - Network, - type RouterProvider, -} from "./network.js"; -import { RuntimeCompleted, defineRuntime } from "./runtime/runtime.js"; -import type { LedgerStorage } from "./ledger/storage.js"; -import { simulator } from "./definition.js"; -import type { ProgramFinished, SimulatorRunOptions } from "./kernel/run.js"; - -class RuntimeRequirement extends Context.Tag( - "@moltzap/simulator/test/RuntimeRequirement", -)() {} - -class ProgramRequirement extends Context.Tag( - "@moltzap/simulator/test/ProgramRequirement", -)() {} - -class RuntimeUnavailable extends Data.TaggedError("RuntimeUnavailable")<{ - readonly detail: string; -}> {} - -const runtimeConfiguration = Schema.Struct({}); -const runtime = defineRuntime< - undefined, - RuntimeUnavailable, - RuntimeRequirement, - typeof runtimeConfiguration ->({ - name: "type-canary", - configuration: { - schema: runtimeConfiguration, - value: {}, - }, - acquire: () => - Effect.gen(function* () { - yield* RuntimeRequirement; - return { - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }; - }), -}); - -const society = simulator.define("acme.type-canary/v1"); -const roster = society.agents({ - alice: runtime, -}); - -const program = Effect.gen(function* () { - const agents = yield* roster.startedAgents; - yield* society.ledger; - yield* society.events; - const network = yield* Network; - const links = yield* LinkController; - yield* ProgramRequirement; - const probe = yield* network.endpoint("probe"); - const conversation = yield* probe.open(agents.alice.agent); - yield* conversation.send("hello"); - yield* links.disable(agents.alice.agent, probe.participant); - yield* links.delay(agents.alice.agent, probe.participant, "10 millis"); - yield* links.shape( - agents.alice.agent, - probe.participant, - linkPolicy.passthrough, - "noop", - ); - return [agents.alice.agent.name, probe.participant.name] as const; -}); - -/** Representative definition run retained for compile-time contract checks. */ -export const definitionCanaryRun = society.run(roster, program); - -type Equal = [Left, Right] extends [Right, Left] ? true : false; -type Expect = Value; -type ExitSuccess = - Outcome extends Exit.Success ? Success : never; -type ProgramExit = - Outcome extends ProgramFinished - ? Exit.Exit - : never; - -type RunRequirementsAreExact = Expect< - Equal< - Effect.Effect.Context, - RuntimeRequirement | ProgramRequirement | RouterProvider | LedgerStorage - > ->; -type ResultKeepsLiteralNames = Expect< - Equal< - ExitSuccess>>, - readonly ["alice", "probe"] - > ->; -type OpenedLedger = Effect.Effect.Success< - ReturnType ->; -type CompletedRecordsCannotFail = Expect< - Equal, never> ->; -type RunOptionsOnlyDescribeRun = Expect< - Equal ->; -type EmptyPartsAreRejected = Expect< - Equal ->; - -/** Compile-time assertions for the public definition surface. */ -export type DefinitionCanaries = [ - RunRequirementsAreExact, - ResultKeepsLiteralNames, - CompletedRecordsCannotFail, - RunOptionsOnlyDescribeRun, - EmptyPartsAreRejected, -]; diff --git a/packages/simulator/src/events/catalog.ts b/packages/simulator/src/events/catalog.ts index 23f58dce8..e22f58d43 100644 --- a/packages/simulator/src/events/catalog.ts +++ b/packages/simulator/src/events/catalog.ts @@ -45,69 +45,47 @@ export type EncodedEventOf = Schema.Schema.Encoded< >; /** Represents event catalog definition failure conditions. */ -export type EventCatalogDefinitionFailure = - | "duplicate-tag" - | "invalid-event-class" - | "invalid-tag"; +export type EventCatalogDefinitionFailure = "duplicate-tag" | "invalid-tag"; + +const definitionFailureMessage: Readonly< + Record string> +> = { + "duplicate-tag": (tag) => `Duplicate event tag "${tag}"`, + "invalid-tag": (tag) => + `Event tag "${tag}" must be namespaced and versioned, for example "acme.consensus-reached/v1"`, +}; /** Invalid catalogs fail during definition construction, before a run starts. */ export class EventCatalogDefinitionError extends Schema.TaggedError()( "EventCatalogDefinitionError", { - failure: Schema.Literal( - "duplicate-tag", - "invalid-event-class", - "invalid-tag", - ), + failure: Schema.Literal("duplicate-tag", "invalid-tag"), tag: Schema.String, }, ) { override get message(): string { - switch (this.failure) { - case "duplicate-tag": - return `Duplicate event tag "${this.tag}"`; - case "invalid-event-class": - return `Event catalog member "${this.tag}" is not a schema-backed class`; - case "invalid-tag": - return `Event tag "${this.tag}" must be namespaced and versioned, for example "acme.consensus-reached/v1"`; - default: - return `Unknown event catalog failure "${this.failure}" for "${this.tag}"`; - } + return definitionFailureMessage[this.failure](this.tag); } } -const VERSIONED_EVENT_TAG = - /^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+\/v[1-9]\d*$/u; - -const eventCatalogTypeId = Symbol.for("@moltzap/simulator/events/EventCatalog"); +/** + * The persisted spelling of an event tag. The tag type states that a namespace + * and a version are present; this states what it cannot: lowercase segments + * and a positive version, so `Acme.Foo/v1` and `acme.foo/v0` are rejected. + */ +export const versionedEventTag = Schema.String.pipe( + Schema.pattern(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+\/v[1-9]\d*$/u), +); -function eventClassTag(eventClass: EventClass): string { - if (typeof eventClass !== "function") { - return ""; - } - const tag: unknown = Reflect.get(eventClass, "_tag"); - return typeof tag === "string" ? tag : String(tag); -} +const isVersionedEventTag = Schema.is(versionedEventTag); -function isEventClass(eventClass: EventClass): boolean { - return ( - typeof eventClass === "function" && - Schema.isSchema(eventClass) && - typeof Reflect.get(eventClass, "_tag") === "string" - ); -} +const eventCatalogTypeId = Symbol.for("@moltzap/simulator/events/EventCatalog"); function validateEventClasses(eventClasses: readonly EventClass[]): void { const seen = new Set(); for (const eventClass of eventClasses) { - const tag = eventClassTag(eventClass); - if (!isEventClass(eventClass)) { - throw EventCatalogDefinitionError.make({ - failure: "invalid-event-class", - tag, - }); - } - if (!VERSIONED_EVENT_TAG.test(tag)) { + const tag: string = eventClass._tag; + if (!isVersionedEventTag(tag)) { throw EventCatalogDefinitionError.make({ failure: "invalid-tag", tag, diff --git a/packages/simulator/src/events/core.test.ts b/packages/simulator/src/events/core.test.ts index f5e1147d3..98c4f7124 100644 --- a/packages/simulator/src/events/core.test.ts +++ b/packages/simulator/src/events/core.test.ts @@ -1,5 +1,6 @@ import { assert, effect as test } from "@effect/vitest"; -import { Effect, Either } from "effect"; +import { Effect, Either, Schema } from "effect"; +import { EventCatalog, EventCatalogDefinitionError } from "./catalog.js"; import { AgentProcessExited, AgentProcessSignaled, @@ -26,6 +27,30 @@ const MESSAGE_ID = "550e8400-e29b-41d4-a716-446655440003"; const POLICY_DESCRIPTION = "delay 100 millis"; const DROP_REASON = "partition"; const DELAY_MILLIS = 100; +const MISCASED_TAG_FAILURE = "invalid-tag"; +const DUPLICATE_TAG_FAILURE = "duplicate-tag"; + +// The tag type admits both of these; only the tag schema rejects them. +class MiscasedTagEvent extends Schema.TaggedClass()( + "Acme.Miscased/v1", + {}, +) {} +class UnversionedTagEvent extends Schema.TaggedClass()( + "acme.unversioned/v0", + {}, +) {} + +function catalogFailure(build: () => unknown): EventCatalogDefinitionError { + try { + build(); + } catch (cause) { + if (cause instanceof EventCatalogDefinitionError) { + return cause; + } + throw cause; + } + throw new Error("the catalog was accepted"); +} // @agent-code-guard/regression-only: decode round-trips pin the exact persisted event universe and field schemas test("declares one exact versioned core event universe", () => @@ -54,6 +79,23 @@ test("declares one exact versioned core event universe", () => assert.isTrue(coreEvents.tags.every((tag) => /\/v\d+$/u.test(tag))); })); +test("rejects the tag spellings the tag type cannot exclude", () => + Effect.sync(() => { + const miscased = catalogFailure(() => EventCatalog.make(MiscasedTagEvent)); + const unversioned = catalogFailure(() => + EventCatalog.make(UnversionedTagEvent), + ); + const duplicate = catalogFailure(() => + EventCatalog.make(LinkPolicySet, LinkPolicySet), + ); + + assert.strictEqual(miscased.failure, MISCASED_TAG_FAILURE); + assert.strictEqual(miscased.tag, MiscasedTagEvent._tag); + assert.strictEqual(unversioned.failure, MISCASED_TAG_FAILURE); + assert.strictEqual(duplicate.failure, DUPLICATE_TAG_FAILURE); + assert.strictEqual(duplicate.tag, LinkPolicySet._tag); + })); + test("round-trips described link-policy evidence", () => Effect.gen(function* () { const set = yield* coreEvents.decode({ diff --git a/packages/simulator/src/index.ts b/packages/simulator/src/index.ts index 6aff9764e..988cbefd0 100644 --- a/packages/simulator/src/index.ts +++ b/packages/simulator/src/index.ts @@ -1,10 +1,13 @@ /** @file Code-first simulator API. */ +// safer-arch-ignore no-folder-cycle: The package root is the explicit public composition facade over mutually typed event, ledger, network, and runtime capabilities. +// safer-arch-ignore no-package-mesh: The simulator is a capability-composition package whose named facades expose the intentional cross-domain contracts used by one run kernel. /** Re-exports the public API from `./definition.js`. */ export { - simulator, + Run, + RunSpec, SimulatorDefinitionError, - type SimulatorDefinition, + type ClusterServices, type SimulatorDefinitionId, } from "./definition.js"; @@ -36,12 +39,12 @@ export { RouterStopFailed, RunStarted, } from "./events/core.js"; -/** Re-exports the public API from `./kernel/event-services.js`. */ +/** Re-exports the public API from `./run/events.js`. */ export { type CustomerEvents, type EventMetadata, type ReadableRunLedger, -} from "./kernel/event-services.js"; +} from "./run/events.js"; /** Re-exports the public API from `./events/catalog.js`. */ export { @@ -54,8 +57,8 @@ export { type EventOf, type VersionedEventTag, } from "./events/catalog.js"; -/** Re-exports the public API from `./ledger/live.js`. */ -export type { LedgerFailure } from "./ledger/live.js"; +/** Re-exports the public API from `./ledger/append.js`. */ +export type { LedgerFailure } from "./ledger/append.js"; /** Re-exports the public API from `./network.js`. */ export { @@ -67,7 +70,7 @@ export { linkPolicy, linkVerdict, Network, - NetworkFailure, + NetworkError, ParticipantHandle, type AgentConnection, type ConversationParticipants, @@ -80,17 +83,16 @@ export { type ReceivedMessage, } from "./network.js"; -/** Re-exports the public API from `./kernel/run.js`. */ +/** Re-exports the public API from `./run/execute.js`. */ export { CompletedLedgerReceipt, IncompleteLedgerReceipt, LedgerReceipt, ProgramFinished, - RunInfrastructureFailed, + ClusterLost, type SimulatorRunFailure, type SimulatorRunOutcome, - type SimulatorRunOptions, -} from "./kernel/run.js"; +} from "./run/execute.js"; -/** Re-exports the public API from `./layer.js`. */ -export { simulatorLayer, type SimulatorLayerOptions } from "./layer.js"; +/** Re-exports the mechanism-neutral cluster error. */ +export { ClusterError } from "./cluster/cluster.js"; diff --git a/packages/simulator/src/layer.ts b/packages/simulator/src/layer.ts deleted file mode 100644 index 020c6f451..000000000 --- a/packages/simulator/src/layer.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** @file Default host services for complete simulator programs. */ - -import { NodeContext, NodeHttpClient } from "@effect/platform-node"; -import { Layer } from "effect"; -import { filesystemLedgerStorageLayer } from "./ledger/filesystem.js"; -import { - moltZapRouterLayer, - type MoltZapRouterOptions, -} from "./network/moltzap.js"; - -/** Host configuration shared by every run provided with this Layer. */ -export interface SimulatorLayerOptions { - readonly ledgerDirectory: string; - readonly router: MoltZapRouterOptions; -} - -/** - * Provide the production router, filesystem ledger, and Effect Platform host - * services once at the application boundary. - * @param options Options that control the operation. - * @returns The simulator layer result. - */ -export function simulatorLayer(options: SimulatorLayerOptions) { - const host = Layer.merge(NodeContext.layer, NodeHttpClient.layerUndici); - const simulator = Layer.merge( - filesystemLedgerStorageLayer(options.ledgerDirectory), - moltZapRouterLayer(options.router), - ); - return simulator.pipe(Layer.provideMerge(host)); -} diff --git a/packages/simulator/src/ledger.ts b/packages/simulator/src/ledger.ts index cb4bc0437..0df70fc4f 100644 --- a/packages/simulator/src/ledger.ts +++ b/packages/simulator/src/ledger.ts @@ -25,9 +25,10 @@ export { makeLedgerRecordSchema, type JsonObject, type LedgerRecord, -} from "./ledger/model.js"; +} from "./ledger/schema.js"; /** Re-exports the public API from `./ledger/storage.js`. */ export { + ledgerArtifactFiles, LedgerStorage, LedgerStorageError, type LedgerAllocation, @@ -41,14 +42,16 @@ export { LedgerDefinitionMismatch, LedgerInvalid, openLedger, + openLedgerArtifacts, readLedgerManifest, + type CompletedLedgerArtifacts, type CompletedRunLedger, type LedgerInvalidReason, type LedgerOpenError, -} from "./ledger/open.js"; +} from "./ledger/read.js"; /** Re-exports the public API from `./ledger/live.js`. */ export { LedgerSerializationError, type LedgerFailure, type RunLedger, -} from "./ledger/live.js"; +} from "./ledger/append.js"; diff --git a/packages/simulator/src/ledger/live.test.ts b/packages/simulator/src/ledger/append.test.ts similarity index 99% rename from packages/simulator/src/ledger/live.test.ts rename to packages/simulator/src/ledger/append.test.ts index 23db704af..9fc2df80e 100644 --- a/packages/simulator/src/ledger/live.test.ts +++ b/packages/simulator/src/ledger/append.test.ts @@ -20,7 +20,7 @@ import { type LedgerInvalidReason, type LedgerStorageService, } from "../ledger.js"; -import { makeRunLedger, type ActiveRunLedger } from "./live.js"; +import { makeRunLedger, type ActiveRunLedger } from "./append.js"; class KernelObserved extends Schema.TaggedClass()( "moltzap.kernel-observed/v1", diff --git a/packages/simulator/src/ledger/live.ts b/packages/simulator/src/ledger/append.ts similarity index 99% rename from packages/simulator/src/ledger/live.ts rename to packages/simulator/src/ledger/append.ts index 9b6ed74a3..381889645 100644 --- a/packages/simulator/src/ledger/live.ts +++ b/packages/simulator/src/ledger/append.ts @@ -28,7 +28,7 @@ import { type LedgerManifest, type LedgerRecord, type LedgerRef, -} from "./model.js"; +} from "./schema.js"; import { LedgerStorage, type LedgerAllocation, diff --git a/packages/simulator/src/ledger/filesystem.ts b/packages/simulator/src/ledger/filesystem.ts index e930454de..97b4d4c87 100644 --- a/packages/simulator/src/ledger/filesystem.ts +++ b/packages/simulator/src/ledger/filesystem.ts @@ -10,8 +10,9 @@ import { LedgerManifest, type LedgerRef, ledgerRef, -} from "./model.js"; +} from "./schema.js"; import { + ledgerArtifactFiles, LedgerStorage, LedgerStorageError, type LedgerAllocation, @@ -21,9 +22,6 @@ import { } from "./storage.js"; import { Clock, DateTime, Effect, Layer, Ref, Schema } from "effect"; -const MANIFEST_FILE = "manifest.json"; -const RECORDS_FILE = "records.ndjson"; -const COMPLETION_FILE = "completion.json"; const encoder = new TextEncoder(); type StorageOperation = LedgerStorageError["operation"]; @@ -72,12 +70,6 @@ interface PreparedAllocation { readonly directory: string; } -const artifactFiles: Record = { - manifest: MANIFEST_FILE, - records: RECORDS_FILE, - completion: COMPLETION_FILE, -}; - function describeCause(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } @@ -228,7 +220,7 @@ function appendDurably( return Effect.scoped( Effect.gen(function* () { const file = yield* active.runtime.fileSystem.open( - join(active.directory, RECORDS_FILE), + join(active.directory, ledgerArtifactFiles.records), { flag: "r+" }, ); const info = yield* file.stat; @@ -293,14 +285,14 @@ function persistAllocation( const persistFiles = Effect.gen(function* () { yield* syncPath(runtime, runtime.root, "allocate", prepared.ref); yield* writeExclusive(runtime, { - path: join(prepared.directory, MANIFEST_FILE), + path: join(prepared.directory, ledgerArtifactFiles.manifest), text: prepared.manifestText, operation: "allocate", ref: prepared.ref, artifact: "manifest", }); yield* writeExclusive(runtime, { - path: join(prepared.directory, RECORDS_FILE), + path: join(prepared.directory, ledgerArtifactFiles.records), text: "", operation: "allocate", ref: prepared.ref, @@ -476,7 +468,7 @@ function makeCompletionCandidate( } function completionPath(active: ActiveLedger): string { - return join(active.directory, COMPLETION_FILE); + return join(active.directory, ledgerArtifactFiles.completion); } function completionExists( @@ -746,7 +738,9 @@ function artifactPath( artifact: LedgerArtifact, ): Effect.Effect { return Schema.decodeUnknown(Schema.UUID)(ref).pipe( - Effect.map((uuid) => join(runtime.root, uuid, artifactFiles[artifact])), + Effect.map((uuid) => + join(runtime.root, uuid, ledgerArtifactFiles[artifact]), + ), Effect.mapError((cause) => storageError( "read", diff --git a/packages/simulator/src/ledger/read-artifacts.test.ts b/packages/simulator/src/ledger/read-artifacts.test.ts new file mode 100644 index 000000000..ad0404ac7 --- /dev/null +++ b/packages/simulator/src/ledger/read-artifacts.test.ts @@ -0,0 +1,66 @@ +import { createHash } from "node:crypto"; +import { assert, effect as test } from "@effect/vitest"; +import { DateTime, Effect, Schema, Stream } from "effect"; +import { EventCatalog } from "../events/catalog.js"; +import { + LedgerCompletion, + ledgerDigest, + LedgerManifest, + ledgerRef, +} from "./schema.js"; +import { openLedgerArtifacts } from "./read.js"; + +const DEFINITION_ID = "acme.artifact-reader/v1"; +const REF = Schema.decodeSync(ledgerRef)("artifact-reader-test"); + +class ArtifactReaderEvent extends Schema.TaggedClass()( + "acme.artifact-reader-event/v1", + { value: Schema.String }, +) {} + +const catalog = EventCatalog.make(ArtifactReaderEvent); + +function digest(text: string) { + return createHash("sha256").update(text, "utf8").digest("hex"); +} + +test("validates retrieved artifact text without a storage service", () => + Effect.gen(function* () { + const manifest = LedgerManifest.make({ + ledgerFormatVersion: 1, + definitionId: DEFINITION_ID, + runId: "artifact-reader-run", + catalogTags: [ArtifactReaderEvent._tag], + createdAt: DateTime.unsafeMake(0), + provenance: {}, + metadata: {}, + }); + const manifestText = JSON.stringify( + Schema.encodeSync(LedgerManifest)(manifest), + ); + const records = ""; + const completion = LedgerCompletion.make({ + ledgerFormatVersion: 1, + runId: manifest.runId, + recordCount: 0, + artifacts: { + manifest: Schema.decodeSync(ledgerDigest)(digest(manifestText)), + records: Schema.decodeSync(ledgerDigest)(digest(records)), + }, + }); + const opened = yield* openLedgerArtifacts( + catalog, + REF, + { + manifest: manifestText, + records, + completion: JSON.stringify( + Schema.encodeSync(LedgerCompletion)(completion), + ), + }, + DEFINITION_ID, + ); + + assert.strictEqual(opened.ref, REF); + assert.strictEqual(yield* Stream.runCount(opened.records), 0); + })); diff --git a/packages/simulator/src/ledger/open.ts b/packages/simulator/src/ledger/read.ts similarity index 75% rename from packages/simulator/src/ledger/open.ts rename to packages/simulator/src/ledger/read.ts index cbaee5500..bc91af797 100644 --- a/packages/simulator/src/ledger/open.ts +++ b/packages/simulator/src/ledger/read.ts @@ -1,32 +1,31 @@ +import { createHash } from "node:crypto"; import { Effect, type ParseResult, Schema, Stream } from "effect"; import type { ParseOptions } from "effect/SchemaAST"; -import type { - EventCatalog, - EventClass, - EventClassOf, - VersionedEventTag, +import { + versionedEventTag, + type EventCatalog, + type EventClass, + type EventClassOf, + type VersionedEventTag, } from "../events/catalog.js"; import { LedgerCompletion, + ledgerDigest, LedgerManifest, type LedgerRef, makeLedgerRecordSchema, type LedgerRecord, -} from "./model.js"; -import { ledgerEvents } from "./live.js"; + versionedDefinitionId, +} from "./schema.js"; +import { ledgerEvents } from "./append.js"; import { LedgerStorage, + LedgerStorageError, + ledgerReaderFor, type LedgerArtifact, - type LedgerStorageError, + type LedgerReader, } from "./storage.js"; -const versionedEventTagSchema = Schema.String.pipe( - Schema.pattern(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+\/v[1-9]\d*$/u), -); -const versionedIdentifierSchema = Schema.String.pipe( - Schema.pattern(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+\/v[1-9]\d*$/u), -); - const ledgerInvalidReasonSchema = Schema.Literal( "catalog-tags-not-sorted", "digest-mismatch", @@ -59,8 +58,8 @@ export class LedgerInvalid extends Schema.TaggedError()( export class LedgerCatalogMismatch extends Schema.TaggedError()( "LedgerCatalogMismatch", { - expectedTags: Schema.Array(versionedEventTagSchema), - actualTags: Schema.Array(versionedEventTagSchema), + expectedTags: Schema.Array(versionedEventTag), + actualTags: Schema.Array(versionedEventTag), }, ) { override get message(): string { @@ -72,8 +71,8 @@ export class LedgerCatalogMismatch extends Schema.TaggedError()( "LedgerDefinitionMismatch", { - expectedDefinitionId: versionedIdentifierSchema, - actualDefinitionId: versionedIdentifierSchema, + expectedDefinitionId: versionedDefinitionId, + actualDefinitionId: versionedDefinitionId, }, ) { override get message(): string { @@ -105,6 +104,13 @@ interface LedgerArtifacts { readonly completion: string; } +/** Complete immutable artifact text retrieved from a profile-owned store. */ +export interface CompletedLedgerArtifacts { + readonly manifest: string; + readonly records: string; + readonly completion: string; +} + const strictDecode: ParseOptions = { onExcessProperty: "error" }; function invalid( @@ -202,9 +208,9 @@ function verifyCatalog< function verifyDefinition( manifest: LedgerManifest, - expectedDefinitionId?: string, + expectedDefinitionId: string | null, ): Effect.Effect { - return expectedDefinitionId === undefined || + return expectedDefinitionId === null || manifest.definitionId === expectedDefinitionId ? Effect.void : Effect.fail( @@ -351,27 +357,24 @@ export const readLedgerManifest = Effect.fn("readLedgerManifest")(function* ( LedgerInvalid | LedgerStorageError, LedgerStorage > { - const storage = yield* LedgerStorage; - const text = yield* storage.read(ref, "manifest"); + const reader = ledgerReaderFor(yield* LedgerStorage, ref); + const text = yield* reader.read("manifest"); const manifest = yield* decodeJson("manifest", LedgerManifest, text); yield* validateManifestTags(manifest); return manifest; }); function readLedgerArtifacts( - ref: LedgerRef, -): Effect.Effect { - return Effect.gen(function* () { - const storage = yield* LedgerStorage; - return yield* Effect.all( - { - manifest: storage.read(ref, "manifest"), - records: storage.read(ref, "records"), - completion: storage.read(ref, "completion"), - }, - { concurrency: 3 }, - ); - }); + reader: LedgerReader, +): Effect.Effect { + return Effect.all( + { + manifest: reader.read("manifest"), + records: reader.read("records"), + completion: reader.read("completion"), + }, + { concurrency: 3 }, + ); } function decodeLedgerHeader< @@ -380,7 +383,7 @@ function decodeLedgerHeader< >( catalog: EventCatalog, files: LedgerArtifacts, - expectedDefinitionId?: string, + expectedDefinitionId: string | null, ) { return Effect.gen(function* () { const manifest = yield* decodeJson( @@ -401,16 +404,16 @@ function decodeLedgerHeader< } function verifyLedgerDigests( + reader: LedgerReader, files: LedgerArtifacts, manifest: LedgerManifest, completion: LedgerCompletion, -): Effect.Effect { +): Effect.Effect { return Effect.gen(function* () { - const storage = yield* LedgerStorage; const digests = yield* Effect.all( { - manifest: storage.digest(files.manifest), - records: storage.digest(files.records), + manifest: reader.digest(files.manifest), + records: reader.digest(files.records), }, { concurrency: 2 }, ); @@ -436,34 +439,26 @@ function decodeLedgerRecords< ); } -/** - * Validate a completed ledger before exposing its reusable typed record - * stream. The exact catalog is required; no unknown-event branch escapes. - * @param catalog Value supplied to the operation. - * @param ref Value supplied to the operation. - * @param expectedDefinitionId Value supplied to the operation. - * @returns The open ledger result. - */ -export function openLedger< +function openLedgerWith< SchemaType extends Schema.Schema.AnyNoContext, Classes extends EventClass, >( + reader: LedgerReader, catalog: EventCatalog, ref: LedgerRef, - expectedDefinitionId?: string, + expectedDefinitionId: string | null, ): Effect.Effect< CompletedRunLedger>, - LedgerOpenError, - LedgerStorage + LedgerOpenError > { return Effect.gen(function* () { - const files = yield* readLedgerArtifacts(ref); + const files = yield* readLedgerArtifacts(reader); const { completion, manifest } = yield* decodeLedgerHeader( catalog, files, expectedDefinitionId, ); - yield* verifyLedgerDigests(files, manifest, completion); + yield* verifyLedgerDigests(reader, files, manifest, completion); const records = yield* decodeLedgerRecords(catalog, files.records); yield* validateRecords(manifest.runId, completion, records); const snapshot = Object.freeze([...records]); @@ -476,5 +471,87 @@ export function openLedger< events: (eventClass) => ledgerEvents(catalog, recordStream, eventClass), }; return Object.freeze(completed); - }).pipe(Effect.withSpan("openLedger")); + }); +} + +/** + * Validate a completed ledger before exposing its reusable typed record + * stream. The exact catalog is required; no unknown-event branch escapes. + * @param catalog Value supplied to the operation. + * @param ref Value supplied to the operation. + * @param expectedDefinitionId Value supplied to the operation. + * @returns The open ledger result. + */ +export function openLedger< + SchemaType extends Schema.Schema.AnyNoContext, + Classes extends EventClass, +>( + catalog: EventCatalog, + ref: LedgerRef, + expectedDefinitionId?: string, +): Effect.Effect< + CompletedRunLedger>, + LedgerOpenError, + LedgerStorage +> { + const definitionId = expectedDefinitionId ?? null; + return Effect.flatMap(LedgerStorage, (storage) => + openLedgerWith(ledgerReaderFor(storage, ref), catalog, ref, definitionId), + ).pipe(Effect.withSpan("openLedger")); +} + +function artifactReader(artifacts: CompletedLedgerArtifacts): LedgerReader { + return { + read: (artifact) => Effect.succeed(artifacts[artifact]), + digest: (text) => + Effect.try({ + try: () => createHash("sha256").update(text, "utf8").digest("hex"), + catch: (cause) => + LedgerStorageError.make({ + operation: "digest", + detail: String(cause), + }), + }).pipe( + Effect.flatMap((digest) => + Schema.decodeUnknown(ledgerDigest)(digest).pipe( + Effect.mapError((cause) => + LedgerStorageError.make({ + operation: "digest", + detail: cause.message, + }), + ), + ), + ), + ), + }; +} + +/** + * Validate already-retrieved durable artifacts without exposing their storage + * backend through the customer program. + * @param catalog Exact event catalog used to decode the records. + * @param ref Durable ledger identity associated with the artifacts. + * @param artifacts Complete artifact text retrieved from durable storage. + * @param expectedDefinitionId Optional definition identity to verify. + * @returns A validated completed ledger with infallible record streams. + */ +export function openLedgerArtifacts< + SchemaType extends Schema.Schema.AnyNoContext, + Classes extends EventClass, +>( + catalog: EventCatalog, + ref: LedgerRef, + artifacts: CompletedLedgerArtifacts, + expectedDefinitionId?: string, +): Effect.Effect< + CompletedRunLedger>, + LedgerOpenError +> { + const definitionId = expectedDefinitionId ?? null; + return openLedgerWith( + artifactReader(artifacts), + catalog, + ref, + definitionId, + ).pipe(Effect.withSpan("openLedgerArtifacts")); } diff --git a/packages/simulator/src/ledger/model.ts b/packages/simulator/src/ledger/schema.ts similarity index 90% rename from packages/simulator/src/ledger/model.ts rename to packages/simulator/src/ledger/schema.ts index 549cf5305..1a9e01cda 100644 --- a/packages/simulator/src/ledger/model.ts +++ b/packages/simulator/src/ledger/schema.ts @@ -1,5 +1,10 @@ import { Schema } from "effect"; -import type { EventCatalog, EventClass, EventOf } from "../events/catalog.js"; +import { + versionedEventTag, + type EventCatalog, + type EventClass, + type EventOf, +} from "../events/catalog.js"; /** Provides the ledger format version runtime value. */ export const LEDGER_FORMAT_VERSION = 1; @@ -39,10 +44,8 @@ const jsonObjectSchema = Schema.Record({ /** Represents json object values. */ export type JsonObject = typeof jsonObjectSchema.Type; -const versionedIdentifierSchema = Schema.String.pipe( - Schema.pattern(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+\/v[1-9]\d*$/u), -); -const versionedEventTagSchema = Schema.String.pipe( +/** The persisted spelling of a simulator definition's identity. */ +export const versionedDefinitionId = Schema.String.pipe( Schema.pattern(/^[a-z][a-z0-9-]*(?:\.[a-z][a-z0-9-]*)+\/v[1-9]\d*$/u), ); const nonNegativeInteger = Schema.Int.pipe(Schema.nonNegative()); @@ -60,9 +63,9 @@ export class LedgerManifest extends Schema.Class( "LedgerManifest", )({ ledgerFormatVersion: Schema.Literal(LEDGER_FORMAT_VERSION), - definitionId: versionedIdentifierSchema, + definitionId: versionedDefinitionId, runId: Schema.NonEmptyString, - catalogTags: Schema.Array(versionedEventTagSchema), + catalogTags: Schema.Array(versionedEventTag), createdAt: Schema.DateTimeUtc, provenance: jsonObjectSchema, metadata: jsonObjectSchema, diff --git a/packages/simulator/src/ledger/storage.ts b/packages/simulator/src/ledger/storage.ts index 37866db97..5a6e29d7f 100644 --- a/packages/simulator/src/ledger/storage.ts +++ b/packages/simulator/src/ledger/storage.ts @@ -7,7 +7,7 @@ import { type LedgerCompletion, type LedgerDigest, type LedgerManifest, -} from "./model.js"; +} from "./schema.js"; const ledgerArtifactSchema = Schema.Literal( "manifest", @@ -17,6 +17,24 @@ const ledgerArtifactSchema = Schema.Literal( /** Represents ledger artifact values. */ export type LedgerArtifact = typeof ledgerArtifactSchema.Type; +/** + * The three bound artifacts in publication order. Completion is last because + * it is the marker that binds the digests of the two artifacts before it. + */ +export const ledgerArtifacts: readonly LedgerArtifact[] = + ledgerArtifactSchema.literals; + +/** The durable file name each ledger artifact is published under. */ +export const ledgerArtifactFiles = { + manifest: "manifest.json", + records: "records.ndjson", + completion: "completion.json", +} as const satisfies Readonly>; + +/** One durable ledger artifact file name. */ +export type LedgerArtifactFile = + (typeof ledgerArtifactFiles)[keyof typeof ledgerArtifactFiles]; + const ledgerStorageOperationSchema = Schema.Literal( "allocate", "append", @@ -62,6 +80,19 @@ export interface LedgerAllocation { ) => Effect.Effect; } +/** + * Read access to the durable artifacts of exactly one ledger. A reader carries + * its own reference, so nothing it returns can come from a second ledger. + */ +export interface LedgerReader { + readonly read: ( + artifact: LedgerArtifact, + ) => Effect.Effect; + readonly digest: ( + text: string, + ) => Effect.Effect; +} + /** Describes ledger storage service. */ export interface LedgerStorageService { readonly allocate: ( @@ -76,6 +107,22 @@ export interface LedgerStorageService { ) => Effect.Effect; } +/** + * Bind one stored ledger for reading. + * @param storage Allocating storage that holds many ledgers. + * @param ref Ledger whose artifacts the reader exposes. + * @returns Read-only access to that one ledger. + */ +export function ledgerReaderFor( + storage: LedgerStorageService, + ref: LedgerRef, +): LedgerReader { + return { + read: (artifact) => storage.read(ref, artifact), + digest: storage.digest, + }; +} + /** Outer layers provide the concrete ledger persistence implementation. */ export class LedgerStorage extends Context.Tag( "@moltzap/simulator/LedgerStorage", diff --git a/packages/simulator/src/network.ts b/packages/simulator/src/network.ts index 0d2ec89c9..0299e029a 100644 --- a/packages/simulator/src/network.ts +++ b/packages/simulator/src/network.ts @@ -21,21 +21,24 @@ export { type EndpointInbox, type NetworkService, } from "./network/endpoint.js"; +/** Re-exports the public API from `./network/failure.js`. */ +export { + NetworkError, + networkError, + type NetworkOperation, +} from "./network/failure.js"; /** Re-exports the public API from `./network/router.js`. */ export { CommittedRouterMessage, - NetworkFailure, type RouterSequence, RouterProvider, RouterStopped, makeRouterStopReport, - networkFailure, routerSequence, type AgentConnection, type AttachedEndpoint, type EndpointTransport, type MessageParts, - type NetworkOperation, type OpenedConversation, type ParticipantIds, type ReceivedMessage, diff --git a/packages/simulator/src/network/conversation.ts b/packages/simulator/src/network/conversation.ts index 4ac2edec6..ef08c6002 100644 --- a/packages/simulator/src/network/conversation.ts +++ b/packages/simulator/src/network/conversation.ts @@ -4,12 +4,8 @@ import type { ConversationId } from "@moltzap/protocol/conversation"; import { type Message, messagePartsSchema } from "@moltzap/protocol/message"; import { Effect, Option, Schema, Stream } from "effect"; import type { ParticipantHandle } from "./participant.js"; -import { - type MessageParts, - type NetworkFailure, - type ReceivedMessage, - networkFailure, -} from "./router.js"; +import type { MessageParts, ReceivedMessage } from "./router.js"; +import { type NetworkError, networkError } from "./failure.js"; const conversationAddressTypeId: unique symbol = Symbol( "@moltzap/simulator/ConversationAddress", @@ -85,11 +81,11 @@ function parts(content: string | MessageParts): MessageParts { function validateParts( content: MessageParts, -): Effect.Effect { +): Effect.Effect { return Schema.decodeUnknown(messagePartsSchemaValue)(content, { onExcessProperty: "error", }).pipe( - Effect.mapError((cause) => networkFailure("send", cause)), + Effect.mapError((cause) => networkError("send", cause)), Effect.as(content), ); } @@ -103,21 +99,21 @@ export class ConversationSocket { * The ordered receive cursor for this endpoint and conversation. Repeated * consumption advances the cursor instead of replaying old delivery. */ - readonly messages: Stream.Stream; + readonly messages: Stream.Stream; readonly endpoint: ParticipantHandle; readonly address: ConversationAddress; private readonly sendMessage: ( content: MessageParts, - ) => Effect.Effect; + ) => Effect.Effect; private constructor( endpoint: ParticipantHandle, address: ConversationAddress, - messages: Stream.Stream, + messages: Stream.Stream, sendMessage: ( content: MessageParts, - ) => Effect.Effect, + ) => Effect.Effect, ) { this.endpoint = endpoint; this.address = address; @@ -128,10 +124,10 @@ export class ConversationSocket { static [conversationSocketConstruction]( endpoint: ParticipantHandle, address: ConversationAddress, - messages: Stream.Stream, + messages: Stream.Stream, sendMessage: ( content: MessageParts, - ) => Effect.Effect, + ) => Effect.Effect, ): ConversationSocket { return new ConversationSocket(endpoint, address, messages, sendMessage); } @@ -141,7 +137,7 @@ export class ConversationSocket { * @param content Value supplied to the operation. * @returns The created conversation socket. */ - send(content: string | MessageParts): Effect.Effect { + send(content: string | MessageParts): Effect.Effect { return validateParts(parts(content)).pipe(Effect.flatMap(this.sendMessage)); } @@ -150,14 +146,14 @@ export class ConversationSocket { * consuming Effect, so the socket never skips an earlier message. * @returns The created conversation socket. */ - receive(): Effect.Effect { + receive(): Effect.Effect { return this.messages.pipe( Stream.runHead, Effect.flatMap( Option.match({ onNone: () => Effect.fail( - networkFailure( + networkError( "receive", `conversation ${this.address.conversationId} ended before another message arrived`, ), @@ -180,10 +176,8 @@ export class ConversationSocket { export function makeConversationSocket( endpoint: ParticipantHandle, address: ConversationAddress, - messages: Stream.Stream, - sendMessage: ( - content: MessageParts, - ) => Effect.Effect, + messages: Stream.Stream, + sendMessage: (content: MessageParts) => Effect.Effect, ): ConversationSocket { const socket = ConversationSocket[conversationSocketConstruction]( endpoint, diff --git a/packages/simulator/src/network/moltzap.test.ts b/packages/simulator/src/network/driver.test.ts similarity index 78% rename from packages/simulator/src/network/moltzap.test.ts rename to packages/simulator/src/network/driver.test.ts index ee70f7171..71882147d 100644 --- a/packages/simulator/src/network/moltzap.test.ts +++ b/packages/simulator/src/network/driver.test.ts @@ -12,18 +12,19 @@ import { } from "@moltzap/protocol/testing"; import { makeRouterStopReport, - networkFailure, + networkError, routerSequence, type EndpointTransport, } from "../network.js"; -import { Duration, Effect, Exit, Schema, Scope, Stream } from "effect"; +import { Duration, Effect, Exit, Layer, Schema, Scope, Stream } from "effect"; import { describe, expect } from "vitest"; +import { RouterProvider } from "./router.js"; import { - makeMoltZapRouterProviderWith, - type MoltZapRouterDriver, - type MoltZapRouterDriverAcquirer, -} from "./moltzap.js"; -import { MoltZapServerFailed } from "./server.js"; + routerProviderLayer, + RouterOperations, + type RouterDriver, + type RouterDriverAcquirer, +} from "./driver.js"; const it = effectIt.scoped; const STARTUP_TIMEOUT = Duration.seconds(10); @@ -49,7 +50,7 @@ const transport: EndpointTransport = { }; interface Harness { - readonly acquire: MoltZapRouterDriverAcquirer; + readonly acquire: RouterDriverAcquirer; readonly registrations: string[]; readonly timeline: string[]; } @@ -66,7 +67,7 @@ function harness(): Harness { routerSequence: routerSequence(7), }, ]); - const driver: MoltZapRouterDriver = { + const driver: RouterDriver = { address: ROUTER_URL, register: (name) => Effect.sync(() => { @@ -89,7 +90,7 @@ function harness(): Harness { return stopped; }), }; - const acquire: MoltZapRouterDriverAcquirer = () => + const acquire: RouterDriverAcquirer = () => Effect.gen(function* () { yield* Effect.addFinalizer(() => Effect.sync(() => { @@ -109,14 +110,21 @@ function close(scope: Scope.CloseableScope) { return Scope.close(scope, Exit.void); } +function providerFor(acquireDriver: RouterDriverAcquirer) { + return RouterProvider.pipe( + Effect.provide( + routerProviderLayer({ startupTimeout: STARTUP_TIMEOUT }).pipe( + Layer.provide(Layer.succeed(RouterOperations, acquireDriver)), + ), + ), + ); +} + describe("MoltZap router", () => { it("keeps identities stable and completes stopped after scoped release", () => Effect.gen(function* () { const test = harness(); - const provider = makeMoltZapRouterProviderWith( - { startupTimeout: STARTUP_TIMEOUT }, - test.acquire, - ); + const provider = yield* providerFor(test.acquire); const scope = yield* Scope.make(); const router = yield* provider.acquire.pipe(Scope.extend(scope)); const [firstAlice, secondAlice] = yield* Effect.all( @@ -169,9 +177,8 @@ describe("MoltZap router", () => { it("maps acquisition and registration failures to network operations", () => Effect.gen(function* () { const scope = yield* Scope.make(); - const unavailable = makeMoltZapRouterProviderWith( - { startupTimeout: STARTUP_TIMEOUT }, - () => Effect.fail("docker unavailable"), + const unavailable = yield* providerFor(() => + Effect.fail("router unavailable"), ); const acquisition = yield* unavailable.acquire.pipe( Scope.extend(scope), @@ -179,39 +186,17 @@ describe("MoltZap router", () => { ); expect(acquisition.operation).toBe("acquire-router"); - expect(acquisition.detail).toContain("docker unavailable"); - - const imageFailure = MoltZapServerFailed.make({ - operation: "resolve-image", - detail: "Docker is not reachable", - }); - const nested = makeMoltZapRouterProviderWith( - { startupTimeout: STARTUP_TIMEOUT }, - () => Effect.fail(imageFailure), - ); - const normalized = yield* nested.acquire.pipe( - Scope.extend(scope), - Effect.flip, - ); - - expect(normalized.detail).toBe(imageFailure.message); - expect(normalized.message).toBe( - `Network acquire-router failed: ${imageFailure.message}`, - ); - expect(normalized.detail).not.toContain("MoltZapServerFailed:"); + expect(acquisition.detail).toContain("router unavailable"); const test = harness(); - const registrationFailed: MoltZapRouterDriverAcquirer = (options) => + const registrationFailed: RouterDriverAcquirer = (options) => test.acquire(options).pipe( Effect.map((driver) => ({ ...driver, register: () => Effect.fail("registration rejected"), })), ); - const provider = makeMoltZapRouterProviderWith( - { startupTimeout: STARTUP_TIMEOUT }, - registrationFailed, - ); + const provider = yield* providerFor(registrationFailed); const router = yield* provider.acquire.pipe(Scope.extend(scope)); const registration = yield* router .attachAgent("alice", ALICE) @@ -225,20 +210,17 @@ describe("MoltZap router", () => { it("normalizes endpoint attachment and release-time collection failures", () => Effect.gen(function* () { const base = harness(); - const acquire: MoltZapRouterDriverAcquirer = (options) => + const acquire: RouterDriverAcquirer = (options) => base.acquire(options).pipe( Effect.map((driver) => ({ ...driver, attachEndpoint: () => Effect.fail("socket authentication failed"), stopAndCollect: Effect.fail( - networkFailure("stop-router", "traffic collection failed"), + networkError("stop-router", "traffic collection failed"), ), })), ); - const provider = makeMoltZapRouterProviderWith( - { startupTimeout: STARTUP_TIMEOUT }, - acquire, - ); + const provider = yield* providerFor(acquire); const scope = yield* Scope.make(); const router = yield* provider.acquire.pipe(Scope.extend(scope)); const attachment = yield* router diff --git a/packages/simulator/src/network/driver.ts b/packages/simulator/src/network/driver.ts new file mode 100644 index 000000000..723b821bc --- /dev/null +++ b/packages/simulator/src/network/driver.ts @@ -0,0 +1,225 @@ +/** @file MoltZap implementation of the simulator router service. */ + +import type { AgentId, AgentKey, AgentName } from "@moltzap/protocol/identity"; +import type { ServerBaseUrl } from "@moltzap/protocol/network"; +import { + RouterProvider, + type AgentConnection, + type AttachedEndpoint, + type EndpointTransport, + type Router, + type RouterStopped, +} from "./router.js"; +import { networkError, type NetworkError } from "./failure.js"; +import { makeAgentHandle, makeParticipantHandle } from "./participant.js"; +import { + Cause, + Context, + Deferred, + type Duration, + Effect, + Layer, + Option, + Ref, + type Scope, +} from "effect"; + +/** Configuration for one isolated MoltZap router per simulator run. */ +export interface RouterOptions { + readonly startupTimeout: Duration.Duration; +} + +interface RouterIdentity { + readonly agentId: AgentId; + readonly key: AgentKey; +} + +/** + * The private boundary between router ownership and host resources. + * It contains neither storage paths nor database row types. + * @internal + */ +export interface RouterDriver { + readonly address: ServerBaseUrl; + readonly register: ( + name: AgentName, + ) => Effect.Effect; + readonly attachEndpoint: ( + key: AgentKey, + ) => Effect.Effect; + readonly stopAndCollect: Effect.Effect; +} + +/** @internal */ +export type RouterDriverAcquirer = ( + options: RouterOptions, +) => Effect.Effect; + +/** + * Driver acquisition installed by whichever mechanism runs the router. + * @internal + */ +export class RouterOperations extends Context.Tag( + "@moltzap/simulator/RouterOperations", +)() {} + +interface RouterRuntime { + readonly driver: RouterDriver; + readonly bindings: Ref.Ref>; + readonly bind: Effect.Semaphore; + readonly stopped: Deferred.Deferred; +} + +type BindingRole = "agent" | "endpoint"; + +interface BoundIdentity { + readonly role: BindingRole; + readonly identity: RouterIdentity; +} + +interface IdentityBinding { + readonly name: string; + readonly agentName: AgentName; + readonly role: BindingRole; + readonly operation: "attach-agent" | "attach-endpoint"; +} + +function identityFor( + runtime: RouterRuntime, + binding: IdentityBinding, +): Effect.Effect { + // Registration stays cancellable, but a successful result and its local + // binding become one masked handoff while the name permit remains held. + return runtime.bind.withPermits(1)( + Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const bindings = yield* Ref.get(runtime.bindings); + const existing = bindings.get(binding.name); + if (existing !== undefined) { + return existing.role === binding.role + ? existing.identity + : yield* networkError( + binding.operation, + `network identity "${binding.name}" is already bound as an ${existing.role}`, + ); + } + const identity = yield* restore( + runtime.driver + .register(binding.agentName) + .pipe( + Effect.mapError((cause) => + networkError(binding.operation, cause), + ), + ), + ); + yield* Ref.update(runtime.bindings, (current) => { + const updated = new Map(current); + updated.set(binding.name, { + role: binding.role, + identity, + }); + return updated; + }); + return identity; + }), + ), + ); +} + +function attachAgent( + runtime: RouterRuntime, + name: Name, + agentName: AgentName, +): Effect.Effect, NetworkError, Scope.Scope> { + return identityFor(runtime, { + name, + agentName, + role: "agent", + operation: "attach-agent", + }).pipe( + Effect.map((identity) => ({ + agent: makeAgentHandle(name, identity.agentId), + key: identity.key, + routerUrl: runtime.driver.address, + })), + ); +} + +function attachEndpoint( + runtime: RouterRuntime, + name: Name, + agentName: AgentName, +): Effect.Effect, NetworkError, Scope.Scope> { + return Effect.gen(function* () { + const identity = yield* identityFor(runtime, { + name, + agentName, + role: "endpoint", + operation: "attach-endpoint", + }); + const transport = yield* runtime.driver + .attachEndpoint(identity.key) + .pipe(Effect.mapError((cause) => networkError("attach-endpoint", cause))); + return { + participant: makeParticipantHandle(name, identity.agentId), + transport, + }; + }); +} + +function completeStopped(runtime: RouterRuntime): Effect.Effect { + return runtime.driver.stopAndCollect.pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => { + const failure = Option.getOrElse(Cause.failureOption(cause), () => + networkError("stop-router", Cause.pretty(cause)), + ); + return Deferred.fail(runtime.stopped, failure); + }, + onSuccess: (stopped) => Deferred.succeed(runtime.stopped, stopped), + }), + Effect.asVoid, + ); +} + +function acquireRouter( + options: RouterOptions, + acquireDriver: RouterDriverAcquirer, +): Effect.Effect { + return Effect.gen(function* () { + const driver = yield* acquireDriver(options).pipe( + Effect.mapError((cause) => networkError("acquire-router", cause)), + ); + const runtime: RouterRuntime = { + driver, + bindings: yield* Ref.make>(new Map()), + bind: yield* Effect.makeSemaphore(1), + stopped: yield* Deferred.make(), + }; + yield* Effect.addFinalizer(() => completeStopped(runtime)); + return Object.freeze({ + address: driver.address, + stopped: Deferred.await(runtime.stopped), + attachAgent: (name, agentName) => attachAgent(runtime, name, agentName), + attachEndpoint: (name, agentName) => + attachEndpoint(runtime, name, agentName), + }); + }).pipe(Effect.withSpan("moltZapRouter.acquire")); +} + +/** + * Publish the router service over the installed driver acquirer. + * @param options Startup deadline applied to each router acquisition. + * @internal + * @returns A Layer providing the router service. + */ +export function routerProviderLayer( + options: RouterOptions, +): Layer.Layer { + return Layer.effect( + RouterProvider, + Effect.map(RouterOperations, (acquireDriver) => ({ + acquire: acquireRouter(options, acquireDriver), + })), + ); +} diff --git a/packages/simulator/src/network/endpoint.ts b/packages/simulator/src/network/endpoint.ts index 7f166470e..7a178316d 100644 --- a/packages/simulator/src/network/endpoint.ts +++ b/packages/simulator/src/network/endpoint.ts @@ -11,14 +11,13 @@ import { type ConversationParticipants, } from "./conversation.js"; import type { ParticipantHandle } from "./participant.js"; -import { - type AttachedEndpoint, - type EndpointTransport, - type NetworkFailure, - type ParticipantIds, - type ReceivedMessage, - networkFailure, +import type { + AttachedEndpoint, + EndpointTransport, + ParticipantIds, + ReceivedMessage, } from "./router.js"; +import { type NetworkError, networkError } from "./failure.js"; const endpointTypeId: unique symbol = Symbol("@moltzap/simulator/Endpoint"); const endpointConstruction: unique symbol = Symbol( @@ -28,11 +27,11 @@ const endpointConstruction: unique symbol = Symbol( /** Run-scoped receive cursors maintained by the simulator kernel. */ export interface EndpointInbox { /** Live fan-out stream for observers of every endpoint delivery. */ - readonly messages: Stream.Stream; + readonly messages: Stream.Stream; /** Obtain the shared ordered cursor for one bound conversation. */ readonly conversation: ( conversationId: ConversationId, - ) => Effect.Effect>; + ) => Effect.Effect>; } function addressedParticipants( @@ -80,7 +79,7 @@ export class Endpoint { * sockets retain their own ordered delivery queues independently. * @returns Live endpoint delivery stream. */ - messages(): Stream.Stream { + messages(): Stream.Stream { return this.inbox.messages; } @@ -92,7 +91,7 @@ export class Endpoint { */ open( ...participants: ConversationParticipants - ): Effect.Effect { + ): Effect.Effect { const [first, ...rest] = participants; const ids: ParticipantIds = [ first.id, @@ -130,7 +129,7 @@ export class Endpoint { */ socket( address: ConversationAddress, - ): Effect.Effect { + ): Effect.Effect { const isParticipant = address.participants.some( (participant) => participant.id === this.participant.id, ); @@ -149,7 +148,7 @@ export class Endpoint { ), ) : Effect.fail( - networkFailure( + networkError( "socket", `participant ${this.participant.name} is not addressed by the conversation`, ), @@ -178,7 +177,7 @@ export function makeEndpoint( export interface NetworkService { endpoint( name: Name, - ): Effect.Effect, NetworkFailure>; + ): Effect.Effect, NetworkError>; } /** Network operations available to the customer program. */ diff --git a/packages/simulator/src/network/failure.ts b/packages/simulator/src/network/failure.ts new file mode 100644 index 000000000..b2a1bfe0c --- /dev/null +++ b/packages/simulator/src/network/failure.ts @@ -0,0 +1,50 @@ +/** @file Typed failures raised at any network boundary. */ + +import { Schema } from "effect"; + +const networkOperation = Schema.Literal( + "acquire-router", + "attach-agent", + "attach-endpoint", + "disable-link", + "enable-link", + "open-conversation", + "receive", + "shape-link", + "socket", + "stop-router", + "send", +); +/** Network operation names used by typed failures. */ +export type NetworkOperation = typeof networkOperation.Type; + +/** An operational failure at a network boundary. */ +export class NetworkError extends Schema.TaggedError()( + "NetworkError", + { + operation: networkOperation, + detail: Schema.String, + }, +) { + override get message(): string { + return `Network ${this.operation} failed: ${this.detail}`; + } +} + +/** + * Normalize an implementation failure at the network boundary. Error causes + * contribute their message alone so one operation reads the same way whether + * the boundary raised a thrown Error or a plain description. + * @param operation Failed network operation. + * @param cause Implementation failure. + * @returns Typed network failure. + */ +export function networkError( + operation: NetworkOperation, + cause: unknown, +): NetworkError { + return NetworkError.make({ + operation, + detail: cause instanceof Error ? cause.message : String(cause), + }); +} diff --git a/packages/simulator/src/network/link.ts b/packages/simulator/src/network/link.ts index 8b1017719..2155b7617 100644 --- a/packages/simulator/src/network/link.ts +++ b/packages/simulator/src/network/link.ts @@ -11,7 +11,7 @@ import { import type { AgentId } from "@moltzap/protocol/identity"; import type { Message } from "@moltzap/protocol/message"; import type { ParticipantHandle } from "./participant.js"; -import type { NetworkFailure } from "./router.js"; +import type { NetworkError } from "./failure.js"; /** One committed message about to cross a directed link. */ export interface LinkDelivery { @@ -73,7 +73,7 @@ export type InboundLinkStage = ( /** Removes one installed policy from its directed link. */ export interface LinkPolicyLease { - readonly clear: Effect.Effect; + readonly clear: Effect.Effect; } /** @@ -88,11 +88,11 @@ export interface LinkDriverService { readonly disable: ( from: AgentId, to: AgentId, - ) => Effect.Effect; + ) => Effect.Effect; readonly enable: ( from: AgentId, to: AgentId, - ) => Effect.Effect; + ) => Effect.Effect; /** * Install one policy on a directed link until the returned lease clears. * Policies stack in installation order on the same link. @@ -102,7 +102,7 @@ export interface LinkDriverService { to: AgentId, policy: LinkPolicy, description: string, - ) => Effect.Effect; + ) => Effect.Effect; } /** @@ -123,25 +123,25 @@ export interface LinkControllerService { readonly disable: ( from: ParticipantHandle, to: ParticipantHandle, - ) => Effect.Effect; + ) => Effect.Effect; /** Delay every delivery on one directed link for the current Scope. */ readonly delay: ( from: ParticipantHandle, to: ParticipantHandle, duration: Duration.DurationInput, - ) => Effect.Effect; + ) => Effect.Effect; /** Park every delivery on one directed link for the current Scope. */ readonly hold: ( from: ParticipantHandle, to: ParticipantHandle, - ) => Effect.Effect; + ) => Effect.Effect; /** Install one custom policy on a directed link for the current Scope. */ readonly shape: ( from: ParticipantHandle, to: ParticipantHandle, policy: LinkPolicy, description: string, - ) => Effect.Effect; + ) => Effect.Effect; } /** Experiment-facing directed-link control installed by the run kernel. */ diff --git a/packages/simulator/src/network/moltzap.ts b/packages/simulator/src/network/moltzap.ts deleted file mode 100644 index 3128debed..000000000 --- a/packages/simulator/src/network/moltzap.ts +++ /dev/null @@ -1,366 +0,0 @@ -/** @file MoltZap implementation of the simulator router service. */ - -import type { AgentId, AgentKey, AgentName } from "@moltzap/protocol/identity"; -import { agentConversationCreate } from "@moltzap/protocol/conversation"; -import { - messageReceivedNotificationDefinition, - messagesSend, -} from "@moltzap/protocol/message"; -import { httpBaseUrl, type ServerBaseUrl } from "@moltzap/protocol/network"; -import { MoltZapAgentClient } from "@moltzap/protocol/socket"; -import { - type AgentConnection, - type AttachedEndpoint, - type CommittedRouterMessage, - type EndpointTransport, - networkFailure, - type NetworkFailure, - type NetworkOperation, - type ParticipantIds, - type Router, - RouterProvider, - type RouterProviderService, - type RouterStopped, - makeRouterStopReport, -} from "./router.js"; -import { makeAgentHandle, makeParticipantHandle } from "./participant.js"; -import { - Cause, - Deferred, - type Duration, - Effect, - Layer, - Option, - Ref, - type Scope, - Stream, -} from "effect"; -import { - type MessageDatabasePath, - readCommittedRouterMessages, -} from "./message-store.js"; -import { - acquireMoltZapServer, - type MoltZapServer, - type MoltZapServerHost, -} from "./server.js"; -import type { ImageDigest } from "./server-image.js"; - -/** Configuration for one isolated MoltZap router per simulator run. */ -export interface MoltZapRouterOptions { - readonly image?: ImageDigest; - readonly startupTimeout: Duration.Duration; -} - -interface RouterIdentity { - readonly agentId: AgentId; - readonly key: AgentKey; -} - -/** - * The private boundary between router ownership and host resources. - * It contains neither storage paths nor database row types. - * @internal - */ -export interface MoltZapRouterDriver { - readonly address: ServerBaseUrl; - readonly register: ( - name: AgentName, - ) => Effect.Effect; - readonly attachEndpoint: ( - key: AgentKey, - ) => Effect.Effect; - readonly stopAndCollect: Effect.Effect; -} - -/** @internal */ -export type MoltZapRouterDriverAcquirer = ( - options: MoltZapRouterOptions, -) => Effect.Effect; - -interface RouterRuntime { - readonly driver: MoltZapRouterDriver; - readonly bindings: Ref.Ref>; - readonly bind: Effect.Semaphore; - readonly stopped: Deferred.Deferred; -} - -type BindingRole = "agent" | "endpoint"; - -interface BoundIdentity { - readonly role: BindingRole; - readonly identity: RouterIdentity; -} - -interface IdentityBinding { - readonly name: string; - readonly agentName: AgentName; - readonly role: BindingRole; - readonly operation: "attach-agent" | "attach-endpoint"; -} - -function fail(operation: NetworkOperation, cause: unknown): NetworkFailure { - return networkFailure( - operation, - cause instanceof Error ? cause.message : cause, - ); -} - -function readCommittedMessages( - databasePath: MessageDatabasePath, -): Effect.Effect { - return readCommittedRouterMessages(databasePath).pipe( - Effect.mapError((cause) => fail("stop-router", cause)), - ); -} - -function collectStoppedRouter( - server: MoltZapServer, -): Effect.Effect { - return Effect.gen(function* () { - yield* server - .stop() - .pipe(Effect.mapError((cause) => fail("stop-router", cause))); - const messages = yield* readCommittedMessages(server.messageDatabasePath); - return makeRouterStopReport(messages); - }); -} - -function endpointMessages( - client: MoltZapAgentClient, -): Effect.Effect { - return client - .subscribeScoped(messageReceivedNotificationDefinition) - .pipe( - Effect.map((received) => - received.pipe(Stream.mapError((cause) => fail("receive", cause))), - ), - ); -} - -function openConversationWith( - client: MoltZapAgentClient, -): EndpointTransport["openConversation"] { - return (participants: ParticipantIds) => - client - .callDefinition(agentConversationCreate, { - participants, - }) - .pipe( - Effect.mapError((cause) => fail("open-conversation", cause)), - Effect.map((result) => ({ conversationId: result.conversation.id })), - ); -} - -function sendWith(client: MoltZapAgentClient): EndpointTransport["send"] { - return (conversationId, parts) => - client - .callDefinition(messagesSend, { - conversationId, - parts, - }) - .pipe( - Effect.map((result) => result.message), - Effect.mapError((cause) => fail("send", cause)), - ); -} - -function endpointTransport( - address: ServerBaseUrl, - key: AgentKey, -): Effect.Effect { - return Effect.gen(function* () { - const client = new MoltZapAgentClient({ - serverUrl: httpBaseUrl(address), - agentKey: key, - }); - yield* Effect.addFinalizer(() => client.close()); - const received = yield* endpointMessages(client); - yield* client.connect(); - return { - received, - openConversation: openConversationWith(client), - send: sendWith(client), - }; - }); -} - -const acquireMoltZapDriver: MoltZapRouterDriverAcquirer = ( - options, -) => - acquireMoltZapServer({ - image: options.image, - readyTimeout: options.startupTimeout, - }).pipe( - Effect.map( - (server): MoltZapRouterDriver => ({ - address: server.serverUrl, - register: server.register, - attachEndpoint: (key) => endpointTransport(server.serverUrl, key), - stopAndCollect: collectStoppedRouter(server), - }), - ), - ); - -function identityFor( - runtime: RouterRuntime, - binding: IdentityBinding, -): Effect.Effect { - // Registration stays cancellable, but a successful result and its local - // binding become one masked handoff while the name permit remains held. - return runtime.bind.withPermits(1)( - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const bindings = yield* Ref.get(runtime.bindings); - const existing = bindings.get(binding.name); - if (existing !== undefined) { - return existing.role === binding.role - ? existing.identity - : yield* fail( - binding.operation, - `network identity "${binding.name}" is already bound as an ${existing.role}`, - ); - } - const identity = yield* restore( - runtime.driver - .register(binding.agentName) - .pipe(Effect.mapError((cause) => fail(binding.operation, cause))), - ); - yield* Ref.update(runtime.bindings, (current) => { - const updated = new Map(current); - updated.set(binding.name, { - role: binding.role, - identity, - }); - return updated; - }); - return identity; - }), - ), - ); -} - -function attachAgent( - runtime: RouterRuntime, - name: Name, - agentName: AgentName, -): Effect.Effect, NetworkFailure, Scope.Scope> { - return identityFor(runtime, { - name, - agentName, - role: "agent", - operation: "attach-agent", - }).pipe( - Effect.map((identity) => ({ - agent: makeAgentHandle(name, identity.agentId), - key: identity.key, - routerUrl: runtime.driver.address, - })), - ); -} - -function attachEndpoint( - runtime: RouterRuntime, - name: Name, - agentName: AgentName, -): Effect.Effect, NetworkFailure, Scope.Scope> { - return Effect.gen(function* () { - const identity = yield* identityFor(runtime, { - name, - agentName, - role: "endpoint", - operation: "attach-endpoint", - }); - const transport = yield* runtime.driver - .attachEndpoint(identity.key) - .pipe(Effect.mapError((cause) => fail("attach-endpoint", cause))); - return { - participant: makeParticipantHandle(name, identity.agentId), - transport, - }; - }); -} - -function completeStopped(runtime: RouterRuntime): Effect.Effect { - return runtime.driver.stopAndCollect.pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => { - const failure = Option.getOrElse(Cause.failureOption(cause), () => - fail("stop-router", Cause.pretty(cause)), - ); - return Deferred.fail(runtime.stopped, failure); - }, - onSuccess: (stopped) => Deferred.succeed(runtime.stopped, stopped), - }), - Effect.asVoid, - ); -} - -function acquireRouter( - options: MoltZapRouterOptions, - acquireDriver: MoltZapRouterDriverAcquirer, -): Effect.Effect { - return Effect.gen(function* () { - const driver = yield* acquireDriver(options).pipe( - Effect.mapError((cause) => fail("acquire-router", cause)), - ); - const runtime: RouterRuntime = { - driver, - bindings: yield* Ref.make>(new Map()), - bind: yield* Effect.makeSemaphore(1), - stopped: yield* Deferred.make(), - }; - yield* Effect.addFinalizer(() => completeStopped(runtime)); - return Object.freeze({ - address: driver.address, - stopped: Deferred.await(runtime.stopped), - attachAgent: (name, agentName) => attachAgent(runtime, name, agentName), - attachEndpoint: (name, agentName) => - attachEndpoint(runtime, name, agentName), - }); - }).pipe(Effect.withSpan("moltZapRouter.acquire")); -} - -/** - * Construct the MoltZap router provider over an explicit driver acquirer. - * @param options Options that control the operation. - * @param acquireDriver Value supplied to the operation. - * @internal - * @returns The created molt zap router provider with. - */ -export function makeMoltZapRouterProviderWith( - options: MoltZapRouterOptions, - acquireDriver: MoltZapRouterDriverAcquirer, -): RouterProviderService { - return { - acquire: acquireRouter(options, acquireDriver), - }; -} - -/** - * Construct the MoltZap router service from host platform services. - * @param options Options that control the operation. - * @returns The created molt zap router provider. - */ -function makeMoltZapRouterProvider( - options: MoltZapRouterOptions, -): Effect.Effect { - return Effect.context().pipe( - Effect.map((host) => - makeMoltZapRouterProviderWith(options, (driverOptions) => - acquireMoltZapDriver(driverOptions).pipe(Effect.provide(host)), - ), - ), - ); -} - -/** - * Provide the MoltZap router while leaving host services to the root layer. - * @param options Options that control the operation. - * @returns The molt zap router layer result. - */ -export function moltZapRouterLayer( - options: MoltZapRouterOptions, -): Layer.Layer { - return Layer.effect(RouterProvider, makeMoltZapRouterProvider(options)); -} diff --git a/packages/simulator/src/network/network.test.ts b/packages/simulator/src/network/network.test.ts index f069a2848..00e400b14 100644 --- a/packages/simulator/src/network/network.test.ts +++ b/packages/simulator/src/network/network.test.ts @@ -1,13 +1,16 @@ +/* eslint-disable agent-code-guard/no-example-only-tests -- These pin the endpoint contract's fixed shapes: who a conversation opens with, which content the transport refuses, how one operation reads whether its cause was thrown or described, and what a stopped router leaves behind. None is an invariant over generated input. */ + import { assert, it } from "@effect/vitest"; import { Effect, Stream } from "effect"; import { agentId, conversationId, messageId } from "@moltzap/protocol/testing"; import { Endpoint, - NetworkFailure, + NetworkError, type RouterStopped, makeEndpoint, makeParticipantHandle, makeRouterStopReport, + networkError, routerSequence, type EndpointInbox, type EndpointTransport, @@ -16,6 +19,7 @@ import { } from "../network.js"; const SEND_OPERATION = "send" satisfies NetworkOperation; +const BOUNDARY_DETAIL = "the boundary refused the request"; const id = (suffix: string) => agentId(`00000000-0000-4000-8000-${suffix.padStart(12, "0")}`); const CONVERSATION_ID = conversationId("00000000-0000-4000-8000-000000000102"); @@ -107,15 +111,25 @@ it.effect("rejects invalid content before calling the transport", () => .send([{ type: "text", text: "" }]) .pipe(Effect.flip); - assert.instanceOf(failure, NetworkFailure); + assert.instanceOf(failure, NetworkError); assert.strictEqual(failure.operation, SEND_OPERATION); assert.strictEqual(sends, 0); }), ); +it("reads one operation the same way for a thrown and a described cause", () => { + const thrown = networkError(SEND_OPERATION, new Error(BOUNDARY_DETAIL)); + const described = networkError(SEND_OPERATION, BOUNDARY_DETAIL); + + assert.strictEqual(thrown.detail, described.detail); + assert.strictEqual(thrown.detail, BOUNDARY_DETAIL); +}); + it("constructs stopped-router evidence without platform storage", () => { const stopped = stoppedRouter(); assert.strictEqual(stopped.committedMessages.length, 1); assert.strictEqual(stopped.committedMessages[0]?.routerSequence, 0); }); + +/* eslint-enable agent-code-guard/no-example-only-tests -- Restore generative-test requirements after the endpoint contract regressions. */ diff --git a/packages/simulator/src/network/router.ts b/packages/simulator/src/network/router.ts index a4a26c7c0..c92d8385f 100644 --- a/packages/simulator/src/network/router.ts +++ b/packages/simulator/src/network/router.ts @@ -22,6 +22,7 @@ import { type Stream, } from "effect"; import type { AgentHandle, ParticipantHandle } from "./participant.js"; +import type { NetworkError } from "./failure.js"; const routerStoppedTypeId: unique symbol = Symbol( "@moltzap/simulator/RouterStopped", @@ -30,48 +31,6 @@ const routerStoppedConstruction: unique symbol = Symbol( "@moltzap/simulator/RouterStoppedConstruction", ); -const networkOperation = Schema.Literal( - "acquire-router", - "attach-agent", - "attach-endpoint", - "disable-link", - "enable-link", - "open-conversation", - "receive", - "shape-link", - "socket", - "stop-router", - "send", -); -/** Network operation names used by typed failures. */ -export type NetworkOperation = typeof networkOperation.Type; - -/** An operational failure at a network boundary. */ -export class NetworkFailure extends Schema.TaggedError()( - "NetworkFailure", - { - operation: networkOperation, - detail: Schema.String, - }, -) { - override get message(): string { - return `Network ${this.operation} failed: ${this.detail}`; - } -} - -/** - * Normalize an implementation failure at the network boundary. - * @param operation Failed network operation. - * @param cause Implementation failure. - * @returns Typed network failure. - */ -export function networkFailure( - operation: NetworkOperation, - cause: unknown, -): NetworkFailure { - return NetworkFailure.make({ operation, detail: String(cause) }); -} - /** A message delivered to one attached endpoint. */ export interface ReceivedMessage { readonly message: Message; @@ -103,14 +62,14 @@ export const routerSequence = Schema.decodeSync(routerSequenceSchema); * deliveries until the kernel's single consumer advances the Stream. */ export interface EndpointTransport { - readonly received: Stream.Stream; + readonly received: Stream.Stream; openConversation( participants: ParticipantIds, - ): Effect.Effect; + ): Effect.Effect; send( conversationId: ConversationId, parts: MessageParts, - ): Effect.Effect; + ): Effect.Effect; } /** @@ -186,22 +145,22 @@ export interface Router { * Awaits the stop report completed by scoped release. The owning scope * controls shutdown and makes the report available. */ - readonly stopped: Effect.Effect; + readonly stopped: Effect.Effect; attachAgent( name: Name, agentName: AgentName, - ): Effect.Effect, NetworkFailure, Scope.Scope>; + ): Effect.Effect, NetworkError, Scope.Scope>; attachEndpoint( name: Name, agentName: AgentName, - ): Effect.Effect, NetworkFailure, Scope.Scope>; + ): Effect.Effect, NetworkError, Scope.Scope>; } /** Router acquisition service supplied by the platform Layer. */ export interface RouterProviderService { - readonly acquire: Effect.Effect; + readonly acquire: Effect.Effect; } /** Router acquisition service supplied by the platform Layer. */ diff --git a/packages/simulator/src/network/server-image-package.integration.test.ts b/packages/simulator/src/network/server-image-package.integration.test.ts deleted file mode 100644 index f09dbd206..000000000 --- a/packages/simulator/src/network/server-image-package.integration.test.ts +++ /dev/null @@ -1,287 +0,0 @@ -/** - * @file Installed-package smoke for the MoltZap router image builder. - * The test extracts real package tarballs into a consumer-shaped node_modules - * tree with no workspace and verifies the packaged builder stages its exact - * server, protocol, Dockerfile, and configuration inputs. - * - * Gate: `MOLTZAP_SIM_ITEST=1`. - */ -/* eslint-disable sonarjs/assertions-in-tests -- assertions run inside a scoped Effect so every temporary package tree is released */ -import { Command, FileSystem } from "@effect/platform"; -import { NodeContext } from "@effect/platform-node"; -import { Config, Effect } from "effect"; -import { delimiter, dirname, join } from "node:path"; -import { execPath } from "node:process"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; - -const SIM_INTEGRATION_ENABLED = Effect.runSync( - Config.string("MOLTZAP_SIM_ITEST").pipe( - Config.withDefault("0"), - Config.map((value) => value === "1"), - ), -); - -const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); -const workspaceRoot = dirname(dirname(packageRoot)); -const packageRoots = { - protocol: join(workspaceRoot, "packages", "protocol"), - server: join(workspaceRoot, "packages", "server"), - simulator: packageRoot, -} as const; -const IMAGE_DIGEST = `sha256:${"a".repeat(64)}`; -const SERVER_PROTOCOL_FIXTURE_VERSION = "0.0.0-server-protocol"; - -function isRecord(value: unknown): value is Readonly> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function requireRecord( - value: unknown, - label: string, -): Readonly> { - if (!isRecord(value)) { - throw new TypeError(`${label} must be an object`); - } - return value; -} - -function packedFilename(output: string): string { - const parsed: unknown = JSON.parse(output); - if ( - typeof parsed !== "object" || - parsed === null || - !("filename" in parsed) || - typeof parsed.filename !== "string" - ) { - throw new Error("pnpm pack returned no tarball filename"); - } - return parsed.filename; -} - -function packPackage(packageDirectory: string, destination: string) { - return Command.make( - "pnpm", - "pack", - "--pack-destination", - destination, - "--json", - ).pipe( - Command.workingDirectory(packageDirectory), - Command.string, - Effect.map(packedFilename), - ); -} - -function extractPackage(archive: string, destination: string) { - return Command.make( - "tar", - "-xzf", - archive, - "--strip-components=1", - "-C", - destination, - ).pipe( - Command.exitCode, - Effect.filterOrFail((code) => Number(code) === 0), - Effect.asVoid, - ); -} - -function fakeDockerCompletion(markerPath: string): string { - return `writeFileSync( - ${JSON.stringify(markerPath)}, - JSON.stringify({ - protocol: specifications[1], - tarballs: specifications.length, - }), -);`; -} - -function fakeDockerSource(markerPath: string): string { - return `#!/usr/bin/env node -import { existsSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; - -const args = process.argv.slice(2); -if (args[0] === "image" && args[1] === "inspect") { - if (args.includes("--format")) { - process.stdout.write(${JSON.stringify(IMAGE_DIGEST)} + "\\n"); - process.exit(0); - } - process.exit(1); -} - -if (args[0] !== "build") { - throw new Error("unexpected docker command: " + args.join(" ")); -} - -const context = args.at(-1); -if (context === undefined) throw new Error("docker build has no context"); -for (const asset of ["Dockerfile", "moltzap.yaml", "package.json"]) { - if (!existsSync(join(context, asset))) { - throw new Error("missing staged asset " + asset); - } -} -const manifestText = readFileSync(join(context, "package.json"), "utf8"); -if (manifestText.includes("workspace:")) { - throw new Error("staged manifest contains a workspace dependency"); -} -const manifest = JSON.parse(manifestText); -const specifications = [ - manifest.dependencies?.["@moltzap/server-core"], - manifest.overrides?.["@moltzap/protocol"], -]; -for (const specification of specifications) { - if ( - typeof specification !== "string" || - !specification.startsWith("file:./tarballs/") - ) { - throw new Error("staged dependency is not a package tarball"); - } - if (!existsSync(join(context, specification.slice("file:./".length)))) { - throw new Error("staged dependency tarball is missing"); - } -} -${fakeDockerCompletion(markerPath)} -`; -} - -function installServerProtocolFixture( - fileSystem: FileSystem.FileSystem, - archive: string, - serverDirectory: string, -) { - return Effect.gen(function* () { - const destination = join( - serverDirectory, - "node_modules", - "@moltzap", - "protocol", - ); - yield* fileSystem.makeDirectory(destination, { recursive: true }); - yield* extractPackage(archive, destination); - const manifestPath = join(destination, "package.json"); - const manifest: unknown = JSON.parse( - yield* fileSystem.readFileString(manifestPath, "utf8"), - ); - if ( - typeof manifest !== "object" || - manifest === null || - Array.isArray(manifest) - ) { - return yield* Effect.dieMessage( - "packed protocol manifest is not an object", - ); - } - yield* fileSystem.writeFileString( - manifestPath, - JSON.stringify({ - ...manifest, - version: SERVER_PROTOCOL_FIXTURE_VERSION, - }), - ); - }); -} - -function prepareInstalledLayout( - fileSystem: FileSystem.FileSystem, - root: string, -) { - return Effect.gen(function* () { - const tarballs = join(root, "tarballs"); - const consumer = join(root, "consumer"); - const scopeDirectory = join(consumer, "node_modules", "@moltzap"); - const fakeBin = join(root, "bin"); - const marker = join(root, "docker-context.json"); - yield* fileSystem.makeDirectory(tarballs, { recursive: true }); - yield* fileSystem.makeDirectory(scopeDirectory, { recursive: true }); - yield* fileSystem.makeDirectory(fakeBin, { recursive: true }); - const archives = yield* Effect.all( - { - protocol: packPackage(packageRoots.protocol, tarballs), - "server-core": packPackage(packageRoots.server, tarballs), - simulator: packPackage(packageRoots.simulator, tarballs), - }, - { concurrency: 3 }, - ); - for (const [name, archive] of Object.entries(archives)) { - const destination = join(scopeDirectory, name); - yield* fileSystem.makeDirectory(destination, { recursive: true }); - yield* extractPackage(archive, destination); - } - yield* installServerProtocolFixture( - fileSystem, - archives.protocol, - join(scopeDirectory, "server-core"), - ); - const fakeDocker = join(fakeBin, "docker"); - yield* fileSystem.writeFileString(fakeDocker, fakeDockerSource(marker)); - // eslint-disable-next-line sonarjs/file-permissions -- this temporary fixture must be executable to stand in for the docker command - yield* fileSystem.chmod(fakeDocker, 0o755); - return { consumer, fakeBin, marker, scopeDirectory } as const; - }); -} - -function runInstalledBuilder( - input: Effect.Effect.Success>, - operatorPath: string, -) { - const builder = join( - input.scopeDirectory, - "simulator", - "scripts", - "build-server-image.mjs", - ); - return Command.make(execPath, builder).pipe( - Command.workingDirectory(input.consumer), - Command.env({ PATH: `${input.fakeBin}${delimiter}${operatorPath}` }), - Command.string, - ); -} - -const installedPackageSmoke = Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const operatorPath = yield* Config.string("PATH"); - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "moltzap-installed-server-image-", - }); - const layout = yield* prepareInstalledLayout(fileSystem, root); - const output = yield* runInstalledBuilder(layout, operatorPath); - const completionLine = output.trim().split("\n").at(-1); - if (completionLine === undefined) { - return yield* Effect.dieMessage("image builder returned no completion"); - } - const completionInput: unknown = JSON.parse(completionLine); - const completion = requireRecord( - completionInput, - "image builder completion", - ); - expect(completion.imageDigest).toBe(IMAGE_DIGEST); - if (typeof completion.serverCoreVersion !== "string") { - return yield* Effect.dieMessage( - "image builder server version must be text", - ); - } - const stagedInput: unknown = JSON.parse( - yield* fileSystem.readFileString(layout.marker, "utf8"), - ); - const staged = requireRecord(stagedInput, "staged image marker"); - expect(staged.tarballs).toBe(2); - if (typeof staged.protocol !== "string") { - return yield* Effect.dieMessage("staged protocol marker must be text"); - } - expect(staged.protocol).toContain(SERVER_PROTOCOL_FIXTURE_VERSION); - }), -).pipe(Effect.provide(NodeContext.layer), Effect.orDie); - -describe.skipIf(!SIM_INTEGRATION_ENABLED)( - "installed MoltZap router image builder", - () => { - it("stages every image input from package tarballs", () => - Effect.runPromise(installedPackageSmoke)); - }, -); - -/* eslint-enable sonarjs/assertions-in-tests -- Restore strict defaults after the scoped file-level exception. */ diff --git a/packages/simulator/src/network/server-image.test.ts b/packages/simulator/src/network/server-image.test.ts deleted file mode 100644 index 5653b2384..000000000 --- a/packages/simulator/src/network/server-image.test.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * @file The MoltZap server image and `server.ts` share one - * contract: container port, durable mount, PGlite location, identity - * registration posture, readable traffic storage, and published build inputs. - * These assertions keep the image assets aligned with the code that launches - * them. - */ -// @agent-code-guard/regression-only: the subject is one fixed image contract, so every assertion is an example by construction -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { - SERVER_CONTAINER_PORT, - SERVER_DATA_MOUNT, - SERVER_REGISTRATION_SECRET_ENV, -} from "./server-image.js"; -import { SERVER_PGLITE_DIR } from "./message-store.js"; - -const packageRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); -const imageDir = join(packageRoot, "server-image"); - -const config = readFileSync(join(imageDir, "moltzap.yaml"), "utf8"); -const dockerfile = readFileSync(join(imageDir, "Dockerfile"), "utf8"); -const packageManifest = - /* Safe because the test fixture establishes this asserted shape. */ JSON.parse( - readFileSync(join(packageRoot, "package.json"), "utf8"), - ) as { - readonly dependencies?: Readonly>; - readonly files?: readonly string[]; - }; -const REGISTRATION_CONFIG_BLOCK = "registration:"; -const REGISTRATION_SECRET_CONFIG = `secret: "\${${SERVER_REGISTRATION_SECRET_ENV}}"`; -const EXACT_WORKSPACE_DEPENDENCY = "workspace:*"; - -/** Config lines with comments and indentation stripped, in file order. */ -const configLines = config - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0 && !line.startsWith("#")); - -describe("simulator server image", () => { - it("pins the PGlite data directory where traffic reconciliation reads it", () => { - expect(configLines).toContain( - `data_dir: ${SERVER_DATA_MOUNT}/${SERVER_PGLITE_DIR}`, - ); - expect(dockerfile).toContain(`VOLUME ["${SERVER_DATA_MOUNT}"]`); - }); - - it("names the boot admin the server requires", () => { - // The absence assertions below pass on an empty file; this one does - // not, so a gutted config fails the suite instead of reading as - // "nothing forbidden is present". - const adminUserId = configLines.find((line) => - line.startsWith("admin_user_id:"), - ); - expect(adminUserId).toMatch( - /^admin_user_id: [0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ); - }); - - it("carries no at-rest encryption secret, so reconciliation can read messages", () => { - expect(configLines.some((line) => line.startsWith("encryption:"))).toBe( - false, - ); - expect(configLines.some((line) => line.startsWith("master_secret:"))).toBe( - false, - ); - }); - - it("requires the launcher's per-run secret for identity registration", () => { - expect(configLines).toContain(REGISTRATION_CONFIG_BLOCK); - expect(configLines).toContain(REGISTRATION_SECRET_CONFIG); - }); - - it("serves the container port the MoltZap server publishes", () => { - expect(configLines).toContain(`port: ${String(SERVER_CONTAINER_PORT)}`); - expect(dockerfile).toContain(`EXPOSE ${String(SERVER_CONTAINER_PORT)}`); - }); - - it("runs the @moltzap/server-core bin", () => { - expect(dockerfile).toMatch( - /^ENTRYPOINT \[.*@moltzap\/server-core\/bin\/moltzap-server.*\]$/m, - ); - }); - - it("publishes every build input with the exact server package", () => { - expect(packageManifest.files).toEqual( - expect.arrayContaining([ - "scripts/build-server-image.mjs", - "server-image", - ]), - ); - expect(packageManifest.dependencies?.["@moltzap/server-core"]).toBe( - EXACT_WORKSPACE_DEPENDENCY, - ); - }); -}); diff --git a/packages/simulator/src/network/server-image.ts b/packages/simulator/src/network/server-image.ts deleted file mode 100644 index 58bdc2396..000000000 --- a/packages/simulator/src/network/server-image.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** @file Content-addressed MoltZap server image and container command contract. */ - -import { Command } from "@effect/platform"; -import type { - CommandExecutor, - Process, -} from "@effect/platform/CommandExecutor"; -import { - Chunk, - Config, - Duration, - Effect, - Schema, - Stream, - type Brand, -} from "effect"; -import { fileURLToPath } from "node:url"; - -/** Content-addressed identity of a MoltZap server image. */ -export type ImageDigest = string & Brand.Brand<"ImageDigest">; -/** Validates and decodes image digest values. */ -const imageDigestSchema: Schema.Schema = - Schema.String.pipe( - Schema.pattern(/^sha256:[0-9a-f]{64}$/u), - Schema.brand("ImageDigest"), - ); - -/** Validate an image digest at a configuration boundary. */ -export const imageDigest = Schema.decodeSync(imageDigestSchema); - -/** Port exposed by the MoltZap server image. */ -export const SERVER_CONTAINER_PORT = 3000; -/** Bind mount containing the server's durable state. */ -export const SERVER_DATA_MOUNT = "/data"; -/** Provides the server registration secret env runtime value. */ -export const SERVER_REGISTRATION_SECRET_ENV = "MOLTZAP_REGISTRATION_SECRET"; - -/** Provides the server command timeout runtime value. */ -export const SERVER_COMMAND_TIMEOUT = Duration.minutes(2); - -const LOOPBACK_HOST = "127.0.0.1"; -const SERVER_CONTAINER_LABEL = "moltzap-simulator-run=1"; -const SERVER_CONTAINER_ID_LABEL = "moltzap-simulator-run-id"; -const IMAGE_BUILD_TIMEOUT = Duration.minutes(15); -const SERVER_IMAGE_ENV = "MOLTZAP_SIM_SERVER_IMAGE"; -const IMAGE_BUILD_SCRIPT = fileURLToPath( - new URL("../../scripts/build-server-image.mjs", import.meta.url), -); -const imagePinLine = Schema.parseJson( - Schema.Struct({ imageDigest: imageDigestSchema }), -); - -function failureOutput(result: { - readonly stdout: string; - readonly stderr: string; -}): string { - const stderr = result.stderr.trim(); - return stderr.length > 0 ? stderr : result.stdout.trim(); -} - -function collectReportedStderr(process: Process) { - return Stream.decodeText(process.stderr).pipe( - Stream.splitLines, - Stream.tap((line) => - line.trim().length === 0 - ? Effect.void - : Effect.logInfo(line).pipe( - Effect.annotateLogs({ - component: "moltzap-router", - operation: "build-image", - }), - ), - ), - Stream.runCollect, - Effect.map((lines) => Chunk.join(lines, "\n")), - ); -} - -function collectQuietStderr(process: Process) { - return Stream.mkString(Stream.decodeText(process.stderr)); -} - -function collectCommand( - executable: string, - command: Command.Command, - stderrCollector: (process: Process) => Effect.Effect, -) { - return Effect.scoped( - Command.start(command).pipe( - Effect.flatMap((process) => - Effect.all( - { - stdout: Stream.mkString(Stream.decodeText(process.stdout)), - stderr: stderrCollector(process), - exitCode: process.exitCode, - }, - { concurrency: 3 }, - ), - ), - Effect.flatMap((result) => - Number(result.exitCode) === 0 - ? Effect.succeed(result.stdout) - : Effect.fail( - `${executable} exited ${String(result.exitCode)}: ${failureOutput(result)}`, - ), - ), - ), - ); -} - -/** - * Execute one bounded host command while draining both output streams. - * @param parts Value supplied to the operation. - * @param options Options that control the operation. - * @param options.timeout Value supplied to the operation. - * @param options.environment Value supplied to the operation. - * @param options.reportStderr Value supplied to the operation. - * @returns The run server command result. - */ -export function runServerCommand( - parts: readonly string[], - options: { - readonly timeout?: Duration.Duration; - readonly environment?: Readonly>; - readonly reportStderr?: boolean; - } = {}, -): Effect.Effect { - const [executable, ...args] = parts; - if (executable === undefined) { - return Effect.fail("empty command"); - } - const command = Command.make(executable, ...args).pipe( - Command.env(options.environment ?? {}), - Command.stdout("pipe"), - Command.stderr("pipe"), - ); - return collectCommand( - executable, - command, - options.reportStderr === true ? collectReportedStderr : collectQuietStderr, - ).pipe( - Effect.timeoutFail({ - duration: options.timeout ?? SERVER_COMMAND_TIMEOUT, - onTimeout: () => - `${executable} did not finish within ${Duration.format(options.timeout ?? SERVER_COMMAND_TIMEOUT)}`, - }), - Effect.mapError(String), - ); -} - -function buildServerImagePin(): Effect.Effect< - ImageDigest, - string, - CommandExecutor -> { - return runServerCommand(["node", IMAGE_BUILD_SCRIPT], { - timeout: IMAGE_BUILD_TIMEOUT, - reportStderr: true, - }).pipe( - Effect.mapError( - (detail) => - `the server image could not be built: ${detail}. Pin a local image id through ${SERVER_IMAGE_ENV} to bypass the package image build`, - ), - Effect.flatMap((printed) => - Schema.decodeUnknown(imagePinLine)( - printed.trim().split("\n").at(-1) ?? "", - ).pipe( - Effect.mapError( - (cause) => - `the server image build printed no usable pin: ${cause.message}`, - ), - ), - ), - Effect.map((pin) => pin.imageDigest), - ); -} - -/** - * Resolve an explicit or configured content-addressed server image. - * @param image Value supplied to the operation. - * @returns The resolve server image result. - */ -export function resolveServerImage( - image?: ImageDigest, -): Effect.Effect { - if (image !== undefined) { - return Effect.succeed(image); - } - return Config.string(SERVER_IMAGE_ENV).pipe( - Config.withDefault(""), - Effect.orElseSucceed(() => ""), - Effect.flatMap((pinned) => - pinned.length === 0 - ? buildServerImagePin() - : Schema.decodeUnknown(imageDigestSchema)(pinned).pipe( - Effect.mapError( - () => - `${SERVER_IMAGE_ENV}="${pinned}" is not an image digest (sha256:…)`, - ), - ), - ), - ); -} - -/** - * Docker arguments for one isolated MoltZap server. - * @param image Value supplied to the operation. - * @param volumePath Value supplied to the operation. - * @param containerName Value supplied to the operation. - * @returns The molt zap server run args result. - */ -export function moltZapServerRunArgs( - image: string, - volumePath: string, - containerName: string, -): readonly string[] { - return [ - "docker", - "run", - "--detach", - "--rm", - "--label", - SERVER_CONTAINER_LABEL, - "--label", - `${SERVER_CONTAINER_ID_LABEL}=${containerName}`, - "--name", - containerName, - "--publish", - `${LOOPBACK_HOST}:0:${String(SERVER_CONTAINER_PORT)}`, - "--volume", - `${volumePath}:${SERVER_DATA_MOUNT}`, - "--env", - SERVER_REGISTRATION_SECRET_ENV, - image, - ]; -} diff --git a/packages/simulator/src/network/server-registration.integration.test.ts b/packages/simulator/src/network/server-registration.integration.test.ts deleted file mode 100644 index 865b144bb..000000000 --- a/packages/simulator/src/network/server-registration.integration.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * @file Registration trust boundary against the MoltZap server image. - * The launcher keeps the run secret, uses it for roster identities, and - * never gives it to participant runtimes. - * - * Gate: `MOLTZAP_SIM_ITEST=1`, with a container engine that can mount the - * simulator cache directory. - */ -/* eslint-disable sonarjs/assertions-in-tests -- assertions execute inside the scoped Effect so the container is always released */ -import { - FetchHttpClient, - HttpClient, - HttpClientRequest, -} from "@effect/platform"; -import { NodeContext } from "@effect/platform-node"; -import { httpBaseUrl } from "@moltzap/protocol/network"; -import { agentName } from "@moltzap/protocol/testing"; -import { Config, Duration, Effect, Layer, Redacted } from "effect"; -import { describe, expect, it } from "vitest"; -import { acquireMoltZapServer } from "./server.js"; - -const SIM_INTEGRATION_ENABLED = Effect.runSync( - Config.string("MOLTZAP_SIM_ITEST").pipe( - Config.withDefault("0"), - Config.map((value) => value === "1"), - ), -); - -const RUN_TIMEOUT_MS = 1_200_000; -const HTTP_FORBIDDEN = 403; -const REGISTER_ROUTE = "/api/v1/auth/register"; -const ROSTER_PARTICIPANT = agentName("roster-participant"); -const hostLayer = Layer.merge(NodeContext.layer, FetchHttpClient.layer); - -const verifyRegistrationBoundary = Effect.scoped( - Effect.gen(function* () { - const server = yield* acquireMoltZapServer({ - readyTimeout: Duration.minutes(2), - }); - const request = yield* HttpClientRequest.post( - new URL(REGISTER_ROUTE, httpBaseUrl(server.serverUrl)).toString(), - ).pipe(HttpClientRequest.bodyJson({ name: "uncredentialed-participant" })); - const response = yield* HttpClient.HttpClient.pipe( - Effect.flatMap((client) => client.execute(request)), - ); - yield* response.text; - - expect(response.status).toBe(HTTP_FORBIDDEN); - - const authorized = yield* server.register(ROSTER_PARTICIPANT); - expect(authorized.agentId.length).toBeGreaterThan(0); - expect(Redacted.isRedacted(authorized.key)).toBe(true); - }), -).pipe(Effect.provide(hostLayer), Effect.orDie); - -describe.skipIf(!SIM_INTEGRATION_ENABLED)( - "MoltZap registration boundary", - () => { - it( - "rejects participant identity minting without the run secret", - () => Effect.runPromise(verifyRegistrationBoundary), - RUN_TIMEOUT_MS, - ); - }, -); - -/* eslint-enable sonarjs/assertions-in-tests -- Restore strict defaults after the scoped file-level exception. */ diff --git a/packages/simulator/src/network/server.test.ts b/packages/simulator/src/network/server.test.ts deleted file mode 100644 index 7d8a9ff28..000000000 --- a/packages/simulator/src/network/server.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -/* eslint-disable max-lines-per-function, sonarjs/max-lines-per-function, max-nested-callbacks, sonarjs/assertions-in-tests, agent-code-guard/no-example-only-tests, agent-code-guard/no-hardcoded-assertion-literals -- regression-only lifecycle suite: each case fixes one ownership transition or cleanup ordering guarantee. Assertions run inside Effect generators, and the timelines remain together so interruption and release order stay auditable. */ -import { it as effectIt } from "@effect/vitest"; -import { serverBaseUrl } from "@moltzap/protocol/network"; -import { - agentId, - agentName, - agentKeyString, - redactedAgentKey, -} from "@moltzap/protocol/testing"; -import { - Cause, - Data, - Deferred, - Duration, - Effect, - Exit, - Fiber, - Logger, - Scope, - type Redacted, - TestClock, -} from "effect"; -import { assert, describe } from "vitest"; -import { - type MoltZapServerOperations, - MoltZapServerFailed, - makeMoltZapServerAcquirer, -} from "./server.js"; -import { imageDigest, moltZapServerRunArgs } from "./server-image.js"; - -const it = effectIt.scoped; -const IMAGE_TEXT = `sha256:${"a".repeat(64)}`; -const IMAGE = imageDigest(IMAGE_TEXT); -const SERVER_URL = serverBaseUrl("ws://127.0.0.1:49152/ws"); -const VOLUME_PATH = "/owned/moltzap-server-test"; -const CONTAINER_ID = "container-id"; -const AGENT_ID = agentId("00000000-0000-4000-8000-000000000001"); -const AGENT_KEY = redactedAgentKey(agentKeyString(31)); -const READY_TIMEOUT = Duration.seconds(1); -const ALICE = agentName("alice"); - -class FakeOperationFailed extends Data.TaggedError("FakeOperationFailed")<{ - readonly operation: string; -}> {} - -interface FakeState { - readonly calls: string[]; - readonly failures: Map; - readonly registrationSecrets: Redacted.Redacted[]; - readonly startedNames: string[]; - readonly stoppedNames: string[]; - containerSecret?: Redacted.Redacted; -} - -interface FakeHarness { - readonly state: FakeState; - readonly operations: MoltZapServerOperations; -} - -function fakeStep( - state: FakeState, - operation: string, - value: A, -): Effect.Effect { - return Effect.suspend(() => { - state.calls.push(operation); - const remaining = state.failures.get(operation) ?? 0; - if (remaining > 0) { - state.failures.set(operation, remaining - 1); - return Effect.fail(new FakeOperationFailed({ operation })); - } - return Effect.succeed(value); - }); -} - -function makeFakeHarness( - failures: ReadonlyArray = [], -): FakeHarness { - const state: FakeState = { - calls: [], - failures: new Map(failures), - registrationSecrets: [], - startedNames: [], - stoppedNames: [], - containerSecret: undefined, - }; - const operations: MoltZapServerOperations = { - cleanupTimeout: READY_TIMEOUT, - resolveImage: () => fakeStep(state, "image.resolve", IMAGE), - createVolume: fakeStep(state, "volume.create", VOLUME_PATH), - removeVolume: () => fakeStep(state, "volume.remove", undefined), - startContainer: ( - imageValue, - volumePath, - containerName, - registrationSecret, - ) => - Effect.sync(() => { - assert.strictEqual(imageValue, IMAGE); - assert.strictEqual(volumePath, VOLUME_PATH); - state.startedNames.push(containerName); - state.containerSecret = registrationSecret; - }).pipe( - Effect.zipRight(fakeStep(state, "container.start", CONTAINER_ID)), - ), - resolveServerUrl: () => fakeStep(state, "port.resolve", SERVER_URL), - awaitHealthy: () => fakeStep(state, "health.await", undefined), - verifyMount: () => fakeStep(state, "mount.verify", undefined), - register: (serverUrlValue, name, registrationSecret) => - Effect.sync(() => { - assert.strictEqual(serverUrlValue, SERVER_URL); - state.registrationSecrets.push(registrationSecret); - }).pipe( - Effect.zipRight( - fakeStep(state, `identity.register:${name}`, { - agentId: AGENT_ID, - key: AGENT_KEY, - }), - ), - ), - stopContainer: (containerName) => - Effect.sync(() => { - state.stoppedNames.push(containerName); - }).pipe(Effect.zipRight(fakeStep(state, "container.stop", undefined))), - }; - return { state, operations }; -} - -function count(calls: readonly string[], operation: string): number { - return calls.filter((entry) => entry === operation).length; -} - -describe("MoltZap server", () => { - it("owns registration, explicit stop, and volume release in that order", () => - Effect.gen(function* () { - const harness = makeFakeHarness(); - const acquire = makeMoltZapServerAcquirer(harness.operations); - yield* Effect.scoped( - Effect.gen(function* () { - const server = yield* acquire({ - image: IMAGE, - readyTimeout: READY_TIMEOUT, - }); - const identity = yield* server.register(ALICE); - assert.strictEqual(identity.agentId, AGENT_ID); - assert.strictEqual(identity.key, AGENT_KEY); - assert.strictEqual(server.image, IMAGE); - assert.strictEqual(server.serverUrl, SERVER_URL); - assert.strictEqual( - server.messageDatabasePath, - `${VOLUME_PATH}/pglite`, - ); - assert.strictEqual(harness.state.registrationSecrets.length, 1); - assert.strictEqual( - harness.state.registrationSecrets.every( - (secret) => secret === harness.state.containerSecret, - ), - true, - ); - - yield* server.stop(); - yield* server.stop(); - assert.strictEqual(count(harness.state.calls, "container.stop"), 1); - assert.strictEqual(count(harness.state.calls, "volume.remove"), 0); - }), - ); - - assert.strictEqual(count(harness.state.calls, "volume.remove"), 1); - assert.deepStrictEqual( - harness.state.stoppedNames, - harness.state.startedNames, - ); - assert.deepStrictEqual(harness.state.calls.slice(-2), [ - "container.stop", - "volume.remove", - ]); - })); - - it("reports the long acquisition stages through the Effect logger", () => - Effect.gen(function* () { - const harness = makeFakeHarness(); - const acquire = makeMoltZapServerAcquirer(harness.operations); - const messages: string[] = []; - const logger = Logger.make(({ message }) => { - messages.push(String(message)); - }); - - yield* Effect.scoped( - acquire({ - image: IMAGE, - readyTimeout: READY_TIMEOUT, - }), - ).pipe(Effect.provide(Logger.replace(Logger.defaultLogger, logger))); - - assert.deepStrictEqual(messages, [ - "Preparing the MoltZap router image; the first build can take several minutes", - "MoltZap router image ready", - "Starting an isolated MoltZap router", - "MoltZap router ready", - ]); - })); - - it("recovers a possibly-created container by its pre-known name", () => - Effect.gen(function* () { - const harness = makeFakeHarness([["container.start", 1]]); - const acquire = makeMoltZapServerAcquirer(harness.operations); - - const error = yield* Effect.scoped( - acquire({ - image: IMAGE, - readyTimeout: READY_TIMEOUT, - }), - ).pipe(Effect.flip); - - assert.instanceOf(error, MoltZapServerFailed); - assert.strictEqual(error.operation, "start-container"); - assert.strictEqual(harness.state.startedNames.length, 1); - assert.deepStrictEqual( - harness.state.stoppedNames, - harness.state.startedNames, - ); - assert.deepStrictEqual(harness.state.calls, [ - "image.resolve", - "volume.create", - "container.start", - "container.stop", - "volume.remove", - ]); - })); - - it("reverses claimed resources before preserving acquisition interruption", () => - Effect.gen(function* () { - const harness = makeFakeHarness(); - const healthEntered = yield* Deferred.make(); - const operations: MoltZapServerOperations = { - ...harness.operations, - awaitHealthy: () => - Deferred.succeed(healthEntered, undefined).pipe( - Effect.zipRight(Effect.never), - ), - }; - const acquire = makeMoltZapServerAcquirer(operations); - const acquisition = yield* Effect.scoped( - acquire({ - readyTimeout: READY_TIMEOUT, - }), - ).pipe(Effect.fork); - - yield* Deferred.await(healthEntered); - const exit = yield* Fiber.interrupt(acquisition); - - assert.strictEqual(Exit.isFailure(exit), true); - if (Exit.isFailure(exit)) { - assert.strictEqual(Cause.isInterruptedOnly(exit.cause), true); - } - assert.deepStrictEqual(harness.state.calls.slice(-2), [ - "container.stop", - "volume.remove", - ]); - })); - - it("interrupts timed-out cleanup before retrying the owned resource", () => - Effect.gen(function* () { - const harness = makeFakeHarness(); - const stopEntered = yield* Deferred.make(); - const stopInterrupted = yield* Deferred.make(); - let stopAttempts = 0; - const operations: MoltZapServerOperations = { - ...harness.operations, - stopContainer: () => - Effect.suspend(() => { - stopAttempts += 1; - harness.state.calls.push("container.stop"); - return stopAttempts === 1 - ? Deferred.succeed(stopEntered, undefined).pipe( - Effect.zipRight(Effect.never), - Effect.onInterrupt(() => - Deferred.succeed(stopInterrupted, undefined).pipe( - Effect.asVoid, - ), - ), - ) - : Effect.void; - }), - }; - const acquire = makeMoltZapServerAcquirer(operations); - const scope = yield* Scope.make(); - const server = yield* acquire({ - image: IMAGE, - readyTimeout: READY_TIMEOUT, - }).pipe(Scope.extend(scope)); - const stopping = yield* server.stop().pipe(Effect.flip, Effect.fork); - - yield* Deferred.await(stopEntered); - yield* TestClock.adjust(READY_TIMEOUT); - const failure = yield* Fiber.join(stopping); - yield* Deferred.await(stopInterrupted); - - assert.instanceOf(failure, MoltZapServerFailed); - assert.strictEqual(failure.operation, "cleanup"); - assert.strictEqual(stopAttempts, 1); - assert.strictEqual(count(harness.state.calls, "volume.remove"), 0); - - yield* Scope.close(scope, Exit.void); - - assert.strictEqual(stopAttempts, 2); - assert.strictEqual(count(harness.state.calls, "volume.remove"), 1); - })); - - it("retains the volume when repeated container stop cannot be confirmed", () => - Effect.gen(function* () { - const harness = makeFakeHarness([["container.stop", 2]]); - const acquire = makeMoltZapServerAcquirer(harness.operations); - - const failure = yield* Effect.scoped( - Effect.gen(function* () { - const server = yield* acquire({ - readyTimeout: READY_TIMEOUT, - }); - return yield* server.stop().pipe(Effect.flip); - }), - ); - - assert.instanceOf(failure, MoltZapServerFailed); - assert.strictEqual(failure.operation, "cleanup"); - assert.match(failure.detail, /remained running/u); - assert.strictEqual(count(harness.state.calls, "container.stop"), 2); - assert.strictEqual(count(harness.state.calls, "volume.remove"), 0); - })); - - it("constructs a loopback random-port server with no OTLP or MCP inputs", () => - Effect.sync(() => { - const args = moltZapServerRunArgs( - IMAGE_TEXT, - VOLUME_PATH, - "named-container", - ); - assert.deepStrictEqual(args, [ - "docker", - "run", - "--detach", - "--rm", - "--label", - "moltzap-simulator-run=1", - "--label", - "moltzap-simulator-run-id=named-container", - "--name", - "named-container", - "--publish", - "127.0.0.1:0:3000", - "--volume", - `${VOLUME_PATH}:/data`, - "--env", - "MOLTZAP_REGISTRATION_SECRET", - IMAGE_TEXT, - ]); - assert.strictEqual( - args.some((part) => part.includes("OTEL")), - false, - ); - assert.strictEqual( - args.some((part) => part.includes("MCP")), - false, - ); - })); -}); - -/* eslint-enable max-lines-per-function, sonarjs/max-lines-per-function, max-nested-callbacks, sonarjs/assertions-in-tests, agent-code-guard/no-example-only-tests, agent-code-guard/no-hardcoded-assertion-literals -- Restore strict defaults after the scoped file-level exception. */ diff --git a/packages/simulator/src/network/server.ts b/packages/simulator/src/network/server.ts deleted file mode 100644 index 3968149a7..000000000 --- a/packages/simulator/src/network/server.ts +++ /dev/null @@ -1,788 +0,0 @@ -/** - * @file Scoped ownership of the MoltZap server used by a simulation run. - * This boundary owns only the server substrate: a fresh - * PGlite volume, the container, and identities minted against that server. - */ -import { FileSystem, HttpClient } from "@effect/platform"; -import type { CommandExecutor } from "@effect/platform/CommandExecutor"; -import { registerAgent } from "@moltzap/client/auth"; -import type { AgentName, AgentId, AgentKey } from "@moltzap/protocol/identity"; -import { - httpBaseUrl, - serverBaseUrl, - type ServerBaseUrl, -} from "@moltzap/protocol/network"; -import { randomBytes, randomUUID } from "node:crypto"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { - Cause, - type Context, - Duration, - Effect, - Exit, - Redacted, - Schedule, - Schema, - type Scope, -} from "effect"; -import { - messageDatabasePathForVolume, - type MessageDatabasePath, -} from "./message-store.js"; -import { - type ImageDigest, - moltZapServerRunArgs, - resolveServerImage, - runServerCommand, - SERVER_COMMAND_TIMEOUT, - SERVER_CONTAINER_PORT, - SERVER_DATA_MOUNT, - SERVER_REGISTRATION_SECRET_ENV, -} from "./server-image.js"; - -const LOOPBACK_HOST = "127.0.0.1"; -const SERVER_HEALTH_POLL_MS = 250; -const REGISTRATION_SECRET_BYTES = 32; -const SERVER_VOLUME_ROOT = join( - homedir(), - ".cache", - "moltzap-simulator", - "server-volumes", -); - -const moltZapServerOperation = Schema.Literal( - "resolve-image", - "create-volume", - "start-container", - "resolve-port", - "wait-for-health", - "verify-mount", - "register-agent", - "cleanup", -); - -type MoltZapServerOperation = typeof moltZapServerOperation.Type; - -/** The single failure vocabulary exposed by the MoltZap server boundary. */ -export class MoltZapServerFailed extends Schema.TaggedError()( - "MoltZapServerFailed", - { - operation: moltZapServerOperation, - detail: Schema.String, - }, -) { - override get message(): string { - return `MoltZap server ${this.operation} failed: ${this.detail}`; - } -} - -interface MoltZapServerIdentity { - readonly agentId: AgentId; - readonly key: AgentKey; -} - -type RunRegistrationSecret = Redacted.Redacted; - -function makeRunRegistrationSecret(): RunRegistrationSecret { - return Redacted.make( - randomBytes(REGISTRATION_SECRET_BYTES).toString("base64url"), - ); -} - -/** Configures acquire molt zap server. */ -export interface AcquireMoltZapServerOptions { - /** A local content-addressed image id. Omit it to build the package image. */ - readonly image?: ImageDigest; - readonly readyTimeout: Duration.Duration; -} - -/** Describes molt zap server. */ -export interface MoltZapServer { - readonly image: ImageDigest; - readonly serverUrl: ServerBaseUrl; - /** Exact stopped-store path fixed by the owned server image. */ - readonly messageDatabasePath: MessageDatabasePath; - readonly register: ( - name: AgentName, - ) => Effect.Effect; - - /** - * Stop the container once while retaining the volume until scope close, so - * traffic collection can open PGlite safely. - */ - readonly stop: () => Effect.Effect; -} - -type MoltZapServerStopReport = - | { - /** The traffic volume is safe to open only in this state. */ - readonly _tag: "stopped"; - readonly failures: readonly string[]; - } - | { - readonly _tag: "running"; - readonly failures: readonly string[]; - }; - -/** - * Injectable effects keep partial-acquisition tests hermetic. - * @internal - */ -export interface MoltZapServerOperations { - readonly cleanupTimeout: Duration.Duration; - readonly resolveImage: ( - image?: ImageDigest, - ) => Effect.Effect; - readonly createVolume: Effect.Effect; - readonly removeVolume: (volumePath: string) => Effect.Effect; - readonly startContainer: ( - image: ImageDigest, - volumePath: string, - containerName: string, - registrationSecret: RunRegistrationSecret, - ) => Effect.Effect; - readonly resolveServerUrl: ( - containerId: string, - ) => Effect.Effect; - readonly awaitHealthy: ( - serverUrl: ServerBaseUrl, - readyTimeout: Duration.Duration, - ) => Effect.Effect; - readonly verifyMount: ( - volumePath: string, - containerId: string, - ) => Effect.Effect; - readonly register: ( - serverUrl: ServerBaseUrl, - name: AgentName, - registrationSecret: RunRegistrationSecret, - ) => Effect.Effect; - readonly stopContainer: (containerId: string) => Effect.Effect; -} - -type OwnedVolume = - | { readonly _tag: "absent" } - | { readonly _tag: "mounted"; readonly path: string } - | { readonly _tag: "removed" }; - -type OwnedContainer = - | { readonly _tag: "absent" } - | { readonly _tag: "may-be-running"; readonly name: string } - | { readonly _tag: "stopped" }; - -interface OwnedResources { - volume: OwnedVolume; - container: OwnedContainer; -} - -interface AcquiredServer { - readonly image: ImageDigest; - readonly serverUrl: ServerBaseUrl; - readonly volumePath: string; - readonly readyTimeout: Duration.Duration; - readonly registrationSecret: RunRegistrationSecret; -} - -interface ServerStart { - readonly image: ImageDigest; - readonly readyTimeout: Duration.Duration; - readonly volumePath: string; -} - -function failed( - operation: MoltZapServerOperation, - cause: unknown, -): MoltZapServerFailed { - return MoltZapServerFailed.make({ - operation, - detail: String(cause), - }); -} - -function atStage( - operation: MoltZapServerOperation, - effect: Effect.Effect, -): Effect.Effect { - return effect.pipe(Effect.mapError((cause) => failed(operation, cause))); -} - -function parsePublishedPort(output: string): Effect.Effect { - const port = output.trim().split("\n")[0]?.split(":").at(-1); - return port === undefined || port.length === 0 - ? Effect.fail(`unparseable docker port output: ${output}`) - : Effect.succeed(port); -} - -function resolveServerUrl( - containerId: string, -): Effect.Effect { - return runServerCommand([ - "docker", - "port", - containerId, - `${String(SERVER_CONTAINER_PORT)}/tcp`, - ]).pipe( - Effect.flatMap(parsePublishedPort), - Effect.flatMap((port) => - Effect.try({ - try: () => serverBaseUrl(`ws://${LOOPBACK_HOST}:${port}/ws`), - catch: String, - }), - ), - ); -} - -function awaitServerHealthy( - serverUrl: ServerBaseUrl, - readyTimeout: Duration.Duration, -): Effect.Effect { - const healthUrl = `${httpBaseUrl(serverUrl)}/health`; - const probe = HttpClient.HttpClient.pipe( - Effect.flatMap((client) => client.get(healthUrl)), - Effect.map((response) => response.status === 200), - Effect.orElseSucceed(() => false), - ); - return probe.pipe( - Effect.filterOrFail( - (healthy) => healthy, - () => "not ready", - ), - Effect.retry({ - schedule: Schedule.spaced(Duration.millis(SERVER_HEALTH_POLL_MS)), - }), - Effect.timeoutFail({ - duration: readyTimeout, - onTimeout: () => - `health endpoint did not answer within ${Duration.format(readyTimeout)}`, - }), - Effect.mapError( - () => - `health endpoint did not answer within ${Duration.format(readyTimeout)}`, - ), - Effect.asVoid, - ); -} - -function verifyMount( - volumePath: string, - containerId: string, -): Effect.Effect { - const sentinel = `.mount-probe-${containerId.slice(0, 12)}`; - const hostPath = join(volumePath, sentinel); - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem - .writeFileString(hostPath, containerId) - .pipe( - Effect.zipRight( - runServerCommand([ - "docker", - "exec", - containerId, - "test", - "-f", - `${SERVER_DATA_MOUNT}/${sentinel}`, - ]), - ), - Effect.ensuring(fileSystem.remove(hostPath).pipe(Effect.ignore)), - ), - ), - Effect.asVoid, - ); -} - -function registerIdentity( - serverUrl: ServerBaseUrl, - name: AgentName, - registrationSecret: RunRegistrationSecret, -): Effect.Effect { - return registerAgent(httpBaseUrl(serverUrl), name, { - inviteCode: Redacted.value(registrationSecret), - }).pipe( - Effect.map((identity) => ({ - agentId: identity.agentId, - key: identity.apiKey, - })), - ); -} - -const createServerVolume = FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.makeDirectory(SERVER_VOLUME_ROOT, { recursive: true }).pipe( - Effect.andThen( - fileSystem.makeTempDirectory({ - directory: SERVER_VOLUME_ROOT, - prefix: "moltzap-sim-server-", - }), - ), - ), - ), -); - -function stopServerContainer( - containerName: string, -): Effect.Effect { - return runServerCommand(["docker", "stop", containerName]).pipe( - Effect.asVoid, - Effect.catchAll((detail) => - detail.includes("No such container") ? Effect.void : Effect.fail(detail), - ), - ); -} - -/** Represents molt zap server host values. */ -export type MoltZapServerHost = - | CommandExecutor - | FileSystem.FileSystem - | HttpClient.HttpClient; - -function makeMoltZapServerOperations( - host: Context.Context, -): MoltZapServerOperations { - const provideHost = Effect.provide(host); - return { - cleanupTimeout: SERVER_COMMAND_TIMEOUT, - resolveImage: (image) => provideHost(resolveServerImage(image)), - createVolume: provideHost(createServerVolume), - removeVolume: (volumePath) => - provideHost( - FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.remove(volumePath, { recursive: true, force: true }), - ), - ), - ), - startContainer: (image, volumePath, containerName, registrationSecret) => - provideHost( - runServerCommand( - moltZapServerRunArgs(image, volumePath, containerName), - { - environment: { - [SERVER_REGISTRATION_SECRET_ENV]: - Redacted.value(registrationSecret), - }, - }, - ), - ).pipe( - Effect.map((output) => output.trim()), - Effect.filterOrFail( - (containerId) => containerId.length > 0, - () => "docker run printed no container id", - ), - ), - resolveServerUrl: (containerId) => - provideHost(resolveServerUrl(containerId)), - awaitHealthy: (serverUrl, readyTimeout) => - provideHost(awaitServerHealthy(serverUrl, readyTimeout)), - verifyMount: (volumePath, containerId) => - provideHost(verifyMount(volumePath, containerId)), - register: registerIdentity, - stopContainer: (containerId) => - provideHost(stopServerContainer(containerId)), - }; -} - -function emptyOwnedResources(): OwnedResources { - return { - volume: { _tag: "absent" }, - container: { _tag: "absent" }, - }; -} - -function captureCleanup( - label: string, - effect: Effect.Effect, - timeout: Duration.Duration, - confirm: () => void, -): Effect.Effect { - return effect.pipe( - // Scope finalizers are uninterruptible, so restore interruption locally. - // The timeout waits for the owned operation to terminate before reporting. - Effect.interruptible, - Effect.timeoutFail({ - duration: timeout, - onTimeout: () => - `${label} did not finish within ${Duration.format(timeout)}`, - }), - Effect.tap(() => Effect.sync(confirm)), - Effect.exit, - Effect.map((exit) => - Exit.isSuccess(exit) ? [] : [`${label}: ${Cause.pretty(exit.cause)}`], - ), - ); -} - -function stopContainer( - operations: MoltZapServerOperations, - owned: OwnedResources, -): Effect.Effect { - if (owned.container._tag !== "may-be-running") { - return Effect.succeed([]); - } - const containerName = owned.container.name; - return captureCleanup( - "server-container", - operations.stopContainer(containerName), - operations.cleanupTimeout, - () => { - owned.container = { _tag: "stopped" }; - }, - ); -} - -function removeVolume( - operations: MoltZapServerOperations, - owned: OwnedResources, -): Effect.Effect { - if (owned.volume._tag !== "mounted") { - return Effect.succeed([]); - } - if (owned.container._tag === "may-be-running") { - return Effect.succeed([ - "server-volume: retained because container stop was not confirmed", - ]); - } - const volumePath = owned.volume.path; - return captureCleanup( - "server-volume", - operations.removeVolume(volumePath), - operations.cleanupTimeout, - () => { - owned.volume = { _tag: "removed" }; - }, - ); -} - -function cleanupServer( - operations: MoltZapServerOperations, - owned: OwnedResources, -): Effect.Effect { - return stopContainer(operations, owned); -} - -function cleanupAll( - operations: MoltZapServerOperations, - owned: OwnedResources, -): Effect.Effect { - return Effect.gen(function* () { - const serverFailures = yield* cleanupServer(operations, owned); - const volumeFailures = yield* removeVolume(operations, owned); - return [...serverFailures, ...volumeFailures]; - }); -} - -function claimResource( - acquire: Effect.Effect, - claim: (resource: A) => void, -): Effect.Effect { - return Effect.uninterruptibleMask((restore) => - restore(acquire).pipe( - Effect.tap((resource) => - Effect.sync(() => { - claim(resource); - }), - ), - ), - ); -} - -function boundedOperation( - label: string, - timeout: Duration.Duration, - effect: Effect.Effect, -): Effect.Effect { - return effect.pipe( - Effect.interruptible, - Effect.timeoutFail({ - duration: timeout, - onTimeout: () => - `${label} did not finish within ${Duration.format(timeout)}`, - }), - ); -} - -function resolveRouterImage( - options: AcquireMoltZapServerOptions, - operations: MoltZapServerOperations, -): Effect.Effect { - return Effect.logInfo( - "Preparing the MoltZap router image; the first build can take several minutes", - ).pipe( - Effect.annotateLogs({ - component: "moltzap-router", - operation: "resolve-image", - }), - Effect.zipRight( - atStage("resolve-image", operations.resolveImage(options.image)), - ), - Effect.tap((image) => - Effect.logInfo("MoltZap router image ready").pipe( - Effect.annotateLogs({ - component: "moltzap-router", - operation: "resolve-image", - image, - }), - ), - ), - ); -} - -function claimVolume( - operations: MoltZapServerOperations, - owned: OwnedResources, -): Effect.Effect { - return claimResource( - atStage("create-volume", operations.createVolume), - (path) => { - owned.volume = { _tag: "mounted", path }; - }, - ); -} - -function startServer( - input: ServerStart, - operations: MoltZapServerOperations, - owned: OwnedResources, -): Effect.Effect { - return Effect.gen(function* () { - const containerName = `moltzap-sim-${randomUUID()}`; - const registrationSecret = makeRunRegistrationSecret(); - owned.container = { _tag: "may-be-running", name: containerName }; - yield* Effect.logInfo("Starting an isolated MoltZap router").pipe( - Effect.annotateLogs({ - component: "moltzap-router", - operation: "start-container", - }), - ); - const containerId = yield* atStage( - "start-container", - operations.startContainer( - input.image, - input.volumePath, - containerName, - registrationSecret, - ), - ); - const serverUrl = yield* atStage( - "resolve-port", - operations.resolveServerUrl(containerId), - ); - yield* atStage( - "wait-for-health", - operations.awaitHealthy(serverUrl, input.readyTimeout), - ); - yield* atStage( - "verify-mount", - operations.verifyMount(input.volumePath, containerId), - ); - return { - image: input.image, - serverUrl, - volumePath: input.volumePath, - readyTimeout: input.readyTimeout, - registrationSecret, - }; - }); -} - -function acquireContainer( - options: AcquireMoltZapServerOptions, - operations: MoltZapServerOperations, - owned: OwnedResources, -): Effect.Effect { - return Effect.gen(function* () { - const image = yield* resolveRouterImage(options, operations); - const volumePath = yield* claimVolume(operations, owned); - return yield* startServer( - { - image, - volumePath, - readyTimeout: options.readyTimeout, - }, - operations, - owned, - ); - }); -} - -function acquireResources( - options: AcquireMoltZapServerOptions, - operations: MoltZapServerOperations, - owned: OwnedResources, -): Effect.Effect { - return Effect.gen(function* () { - const container = yield* acquireContainer(options, operations, owned); - yield* Effect.logInfo("MoltZap router ready").pipe( - Effect.annotateLogs({ - component: "moltzap-router", - operation: "wait-for-health", - routerUrl: container.serverUrl, - }), - ); - return container; - }); -} - -function stopReport( - owned: OwnedResources, - failures: readonly string[], -): MoltZapServerStopReport { - return { - _tag: owned.container._tag === "stopped" ? "stopped" : "running", - failures, - }; -} - -function stopOwnedServer( - operations: MoltZapServerOperations, - owned: OwnedResources, - stopPermit: Effect.Semaphore, -): Effect.Effect { - return stopPermit - .withPermits(1)( - cleanupServer(operations, owned).pipe( - Effect.map((failures) => stopReport(owned, failures)), - ), - ) - .pipe(Effect.uninterruptible); -} - -function confirmTrafficSafeStop( - report: MoltZapServerStopReport, -): Effect.Effect { - if (report._tag === "running") { - return Effect.fail( - failed( - "cleanup", - `server container remained running: ${report.failures.join("; ")}`, - ), - ); - } - return report.failures.length === 0 - ? Effect.void - : Effect.logWarning( - `MoltZap server stopped with cleanup warnings: ${report.failures.join("; ")}`, - ); -} - -function makeServerHandle( - acquired: AcquiredServer, - owned: OwnedResources, - operations: MoltZapServerOperations, - stopPermit: Effect.Semaphore, -): MoltZapServer { - return { - image: acquired.image, - serverUrl: acquired.serverUrl, - messageDatabasePath: messageDatabasePathForVolume(acquired.volumePath), - register: (name) => - atStage( - "register-agent", - boundedOperation( - `agent registration for ${name}`, - acquired.readyTimeout, - operations.register( - acquired.serverUrl, - name, - acquired.registrationSecret, - ), - ), - ), - stop: () => - stopOwnedServer(operations, owned, stopPermit).pipe( - Effect.flatMap(confirmTrafficSafeStop), - ), - }; -} - -function finalRelease( - operations: MoltZapServerOperations, - owned: OwnedResources, - stopPermit: Effect.Semaphore, -): Effect.Effect { - return Effect.gen(function* () { - const stopped = yield* stopOwnedServer(operations, owned, stopPermit); - const volumeFailures = yield* stopPermit.withPermits(1)( - removeVolume(operations, owned), - ); - return [...stopped.failures, ...volumeFailures]; - }).pipe( - Effect.uninterruptible, - Effect.flatMap((failures) => - failures.length === 0 - ? Effect.void - : Effect.logError( - `MoltZap server cleanup was incomplete: ${failures.join("; ")}`, - ), - ), - ); -} - -function installFinalizer( - operations: MoltZapServerOperations, - owned: OwnedResources, - stopPermit: Effect.Semaphore, -): Effect.Effect { - return Effect.addFinalizer(() => finalRelease(operations, owned, stopPermit)); -} - -/** - * Build an acquirer over explicit operations. - * @param operations Value supplied to the operation. - * @internal - * @returns The created molt zap server acquirer. - */ -export function makeMoltZapServerAcquirer( - operations: MoltZapServerOperations, -): ( - options: AcquireMoltZapServerOptions, -) => Effect.Effect { - return (options) => - Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const owned = emptyOwnedResources(); - const stopPermit = yield* Effect.makeSemaphore(1); - const attempt = yield* restore( - acquireResources(options, operations, owned), - ).pipe(Effect.exit); - if (Exit.isFailure(attempt)) { - const cleanup = yield* cleanupAll(operations, owned); - if (cleanup.length > 0) { - if (Cause.isInterruptedOnly(attempt.cause)) { - yield* Effect.logError( - `Interrupted MoltZap server acquisition left incomplete cleanup: ${cleanup.join("; ")}`, - ); - return yield* Effect.failCause(attempt.cause); - } - return yield* failed( - "cleanup", - `${Cause.pretty(attempt.cause)}; ${cleanup.join("; ")}`, - ); - } - return yield* Effect.failCause(attempt.cause); - } - yield* installFinalizer(operations, owned, stopPermit); - return makeServerHandle(attempt.value, owned, operations, stopPermit); - }), - ).pipe(Effect.withSpan("acquireMoltZapServer")); -} - -/** - * Acquire one fresh MoltZap server and all resources that make it usable. - * @param options Options that control the operation. - * @returns The acquire molt zap server result. - */ -export function acquireMoltZapServer( - options: AcquireMoltZapServerOptions, -): Effect.Effect< - MoltZapServer, - MoltZapServerFailed, - Scope.Scope | MoltZapServerHost -> { - return Effect.context().pipe( - Effect.flatMap((host) => - makeMoltZapServerAcquirer(makeMoltZapServerOperations(host))(options), - ), - ); -} diff --git a/packages/simulator/src/runtime/command.test.ts b/packages/simulator/src/network/server/command.test.ts similarity index 64% rename from packages/simulator/src/runtime/command.test.ts rename to packages/simulator/src/network/server/command.test.ts index 51a350ca3..e36424a37 100644 --- a/packages/simulator/src/runtime/command.test.ts +++ b/packages/simulator/src/network/server/command.test.ts @@ -1,15 +1,13 @@ -import { execPath } from "node:process"; -import { dirname } from "node:path"; import { platform } from "node:os"; +import { dirname } from "node:path"; +import { execPath } from "node:process"; import { fileURLToPath } from "node:url"; import { Command } from "@effect/platform"; import { NodeContext } from "@effect/platform-node"; import { Deferred, Duration, Effect, Exit, Fiber, Scope } from "effect"; import { describe, expect, it } from "vitest"; - import { escalatingKill, - makeCommandHelpers, makeExactEnvironmentCommand, startSupervisedProcess, } from "./command.js"; @@ -67,101 +65,7 @@ setTimeout( setTimeout(() => process.exit(0), 100); `; -// Mirrors a NanoClaw workspace build: npm summarizes the lifecycle failure on -// stderr while the compiler it invoked reports the real cause on stdout. -const BUILD_STDERR_SUMMARY = "npm error Lifecycle script build failed"; -const BUILD_STDOUT_DIAGNOSTIC = - "src/index.ts(1,1): error TS2304: Cannot find name foo."; -const BUILD_EXIT_CODE = 2; -const OVERSIZED_HEAD_MARKER = "OLDEST_OUTPUT_MARKER"; -const OVERSIZED_TAIL_MARKER = "NEWEST_OUTPUT_MARKER"; -const OVERSIZED_FILLER_CHARS = 64 * 1024; - -function nodeScriptCommand(script: string): string { - return `"${execPath}" -e ${JSON.stringify(script)}`; -} - -// A failure reason echoes the command text, so a script that names its expected -// output verbatim would satisfy every assertion below without any output being -// retained. Emitting each string from halves keeps the whole literal reachable -// only through the captured stream. -function emitSplit(stream: "stdout" | "stderr", text: string): string { - const half = Math.floor(text.length / 2); - return ( - `process.${stream}.write(${JSON.stringify(text.slice(0, half))} +` + - ` ${JSON.stringify(text.slice(half))});` - ); -} - -const failingBuildCommand = nodeScriptCommand( - emitSplit("stderr", BUILD_STDERR_SUMMARY) + - emitSplit("stdout", BUILD_STDOUT_DIAGNOSTIC) + - `process.exit(${String(BUILD_EXIT_CODE)});`, -); - -// The filler fills the stdout pipe, so the tail write queues behind it. -// `process.exit` would terminate before that queue drains and drop the marker -// this test is looking for; setting the code instead lets the write land and -// the process end on its own. -const oversizedOutputCommand = nodeScriptCommand( - emitSplit("stdout", OVERSIZED_HEAD_MARKER) + - `process.stdout.write("x".repeat(${String(OVERSIZED_FILLER_CHARS)}));` + - emitSplit("stdout", OVERSIZED_TAIL_MARKER) + - `process.exitCode = ${String(BUILD_EXIT_CODE)};`, -); - -const { execEffect } = makeCommandHelpers( - (reason: string) => new Error(reason), -); - -function execFailure(commandText: string) { - return execEffect(commandText).pipe( - Effect.flip, - Effect.provide(NodeContext.layer), - ); -} - -describe("execEffect", () => { - it("retains both streams when a build command fails", retainsBothStreams); - it("keeps the newest output when a command floods", boundsDiagnostics); - it("stays silent when the command succeeds", succeedsWithoutDiagnostics); -}); - -function retainsBothStreams() { - return Effect.runPromise( - execFailure(failingBuildCommand).pipe( - Effect.tap((failure) => { - expect(failure.message).toContain(String(BUILD_EXIT_CODE)); - expect(failure.message).toContain(BUILD_STDERR_SUMMARY); - expect(failure.message).toContain(BUILD_STDOUT_DIAGNOSTIC); - }), - Effect.asVoid, - ), - ); -} - -function boundsDiagnostics() { - return Effect.runPromise( - execFailure(oversizedOutputCommand).pipe( - Effect.tap((failure) => { - expect(failure.message).toContain(OVERSIZED_TAIL_MARKER); - expect(failure.message).not.toContain(OVERSIZED_HEAD_MARKER); - expect(failure.message.length).toBeLessThan(OVERSIZED_FILLER_CHARS); - }), - Effect.asVoid, - ), - ); -} - -function succeedsWithoutDiagnostics() { - return Effect.runPromise( - execEffect(nodeScriptCommand("process.stdout.write(String(1));")).pipe( - Effect.provide(NodeContext.layer), - ), - ); -} - -describe("makeExactEnvironmentCommand", () => { +describe("controller router process command", () => { it( "removes the operator environment before executing", removesOperatorEnvironment, diff --git a/packages/simulator/src/network/server/command.ts b/packages/simulator/src/network/server/command.ts new file mode 100644 index 000000000..1070cb87f --- /dev/null +++ b/packages/simulator/src/network/server/command.ts @@ -0,0 +1,268 @@ +/** @file Controller-owned production-router process supervision. */ + +import { Buffer } from "node:buffer"; +import { homedir } from "node:os"; +import { execPath } from "node:process"; +import { Command } from "@effect/platform"; +import type { + ExitCode, + Process, + Signal, +} from "@effect/platform/CommandExecutor"; +import type { PlatformError } from "@effect/platform/Error"; +import { Config, Duration, Effect, Fiber, Option, Scope, Stream } from "effect"; + +/** + * The only operator variables inherited by the controller-owned router. + * PATH locates its installed entry point and HOME is replaced with run-owned + * state before launch. + */ +export type BaseChildEnvironment = Readonly>; + +/** Provides the controller router's base child environment. */ +export const baseChildEnvironmentConfig: Config.Config = + Config.all({ + PATH: Config.string("PATH"), + HOME: Config.string("HOME").pipe(Config.withDefault(homedir())), + }); + +/** Exact environment and process-tree policy for the controller router. */ +export interface ExactEnvironmentCommandOptions { + readonly command: string; + readonly args: readonly string[]; + readonly cwd: string; + readonly env: Readonly>; + readonly cleanupTreeOnExit?: boolean; +} + +const EXACT_ENVIRONMENT_LAUNCHER = ` +const { spawn } = require("node:child_process"); +const payload = JSON.parse( + Buffer.from(process.argv[1], "base64url").toString("utf8"), +); +process.on("SIGTERM", () => {}); +const cleanupTree = () => { + if (process.platform === "win32") { + const reaper = spawn( + "taskkill", + ["/pid", String(process.pid), "/T", "/F"], + { detached: true, stdio: "ignore", windowsHide: true }, + ); + reaper.unref(); + setInterval(() => {}, 0x7fffffff); + return; + } + process.kill(-process.pid, "SIGKILL"); +}; +const child = spawn(payload.command, payload.args, { + cwd: payload.cwd, + env: payload.env, + stdio: "inherit", + windowsHide: true, +}); +child.once("error", (error) => { + console.error(error); + process.exit(1); +}); +child.once("exit", (code) => { + if (payload.cleanupTreeOnExit === true) { + cleanupTree(); + return; + } + process.exit(code ?? 1); +}); +`; + +/** + * Build a command whose target receives exactly the supplied environment. + * The trusted Node launcher replaces the operator environment and preserves a + * process-group leader until residual router descendants receive KILL. + * @param options Router command and exact environment. + * @returns The supervised platform command. + */ +export function makeExactEnvironmentCommand( + options: ExactEnvironmentCommandOptions, +): Command.Command { + const payload = Buffer.from(JSON.stringify(options)).toString("base64url"); + return Command.make(execPath, "-e", EXACT_ENVIRONMENT_LAUNCHER, payload).pipe( + Command.workingDirectory(options.cwd), + ); +} + +/** + * Drain one router output stream into its caller-owned accumulator. + * @param stream Child output bytes. + * @param append Destination for decoded chunks. + * @param processId Child process identity for diagnostics. + * @param streamName Stream identity for diagnostics. + * @returns Completion after the stream closes. + */ +function consumeProcessStream( + stream: Stream.Stream, + append: (chunk: string) => void, + processId: Process["pid"], + streamName: "stdout" | "stderr", +): Effect.Effect { + const decoder = new TextDecoder("utf-8"); + return Stream.runForEach(stream, (chunk) => + Effect.sync(() => { + append(decoder.decode(chunk, { stream: true })); + }), + ).pipe( + Effect.zipRight( + Effect.sync(() => { + const tail = decoder.decode(); + if (tail.length > 0) { + append(tail); + } + }), + ), + Effect.catchAll((cause) => + Effect.logWarning("child process output stream failed").pipe( + Effect.annotateLogs({ processId, streamName, cause }), + ), + ), + ); +} + +/** + * Start the controller router under a caller-owned scope. + * @param command Exact router command. + * @param scope Scope owning the process. + * @param appendLog Destination for decoded process output. + * @param processTreeCleanup Shared cleanup claim. + * @returns Process, exit observation, and cleanup state. + */ +export const startSupervisedProcess = Effect.fn("startSupervisedProcess")( + function* ( + command: Command.Command, + scope: Scope.CloseableScope, + appendLog: (chunk: string) => void, + processTreeCleanup: ProcessTreeCleanup = { claimed: false }, + ) { + const proc = yield* Command.start(command).pipe(Scope.extend(scope)); + const exitFiber = yield* proc.exitCode.pipe(Effect.forkIn(scope)); + yield* consumeProcessStream( + proc.stdout, + appendLog, + proc.pid, + "stdout", + ).pipe(Effect.forkIn(scope)); + yield* consumeProcessStream( + proc.stderr, + appendLog, + proc.pid, + "stderr", + ).pipe(Effect.forkIn(scope)); + if (!processTreeCleanup.launcherOwnsExitCleanup) { + yield* Fiber.await(exitFiber).pipe( + Effect.zipRight(dispatchProcessTreeKill(proc, processTreeCleanup)), + Effect.forkDaemon, + ); + } + return { proc, exitFiber, processTreeCleanup }; + }, +); + +const EXIT_POLL_INTERVAL_MS = 100; + +/** Mutable single-claim state shared by router cleanup paths. */ +export interface ProcessTreeCleanup { + claimed: boolean; + readonly launcherOwnsExitCleanup?: boolean; +} + +/** + * Stop the controller router with bounded TERM then KILL waits. + * @param proc Owned router process. + * @param exitFiber Router exit observation. + * @param waits Bounded graceful and forced-stop waits. + * @param processTreeCleanup Shared cleanup claim. + * @returns Completion after teardown is dispatched. + */ +export const escalatingKill = Effect.fn("escalatingKill")(function* ( + proc: Process, + exitFiber: Fiber.RuntimeFiber, + waits: { readonly termWaitMs: number; readonly killWaitMs: number }, + processTreeCleanup: ProcessTreeCleanup = { claimed: false }, +) { + const initialExit = yield* Fiber.poll(exitFiber); + if (Option.isSome(initialExit)) { + yield* cleanupAfterLeaderExit(proc, processTreeCleanup); + return; + } + yield* sendSignal(proc, "SIGTERM"); + const leaderExited = yield* exitedWithin(exitFiber, waits.termWaitMs); + if (leaderExited) { + yield* cleanupAfterLeaderExit(proc, processTreeCleanup); + return; + } + yield* dispatchProcessTreeKill(proc, processTreeCleanup); + const killed = yield* exitedWithin(exitFiber, waits.killWaitMs); + if (!killed) { + yield* Effect.logWarning( + "child process remained alive after the SIGKILL wait", + ).pipe( + Effect.annotateLogs({ + processId: proc.pid, + killWaitMs: waits.killWaitMs, + }), + ); + } +}); + +function cleanupAfterLeaderExit( + proc: Process, + cleanup: ProcessTreeCleanup, +): Effect.Effect { + return cleanup.launcherOwnsExitCleanup + ? Effect.void + : dispatchProcessTreeKill(proc, cleanup); +} + +function dispatchProcessTreeKill( + proc: Process, + cleanup: ProcessTreeCleanup, +): Effect.Effect { + return Effect.suspend(() => { + if (cleanup.claimed) { + return Effect.void; + } + cleanup.claimed = true; + return sendSignal(proc, "SIGKILL"); + }); +} + +function sendSignal(proc: Process, signal: Signal): Effect.Effect { + return Effect.forkDaemon( + proc + .kill(signal) + .pipe( + Effect.catchAll((cause) => + Effect.logWarning("child process signal failed").pipe( + Effect.annotateLogs({ processId: proc.pid, signal, cause }), + ), + ), + ), + ).pipe(Effect.zipRight(Effect.yieldNow()), Effect.asVoid); +} + +function exitedWithin( + exitFiber: Fiber.RuntimeFiber, + waitMs: number, +): Effect.Effect { + return Effect.iterate( + { elapsedMs: 0, exited: false }, + { + while: (state) => !state.exited && state.elapsedMs < waitMs, + body: (state) => + Effect.sleep(Duration.millis(EXIT_POLL_INTERVAL_MS)).pipe( + Effect.zipRight(Fiber.poll(exitFiber)), + Effect.map((exit) => ({ + elapsedMs: state.elapsedMs + EXIT_POLL_INTERVAL_MS, + exited: Option.isSome(exit), + })), + ), + }, + ).pipe(Effect.map((state) => state.exited)); +} diff --git a/packages/simulator/src/network/message-store.test.ts b/packages/simulator/src/network/server/messages.test.ts similarity index 96% rename from packages/simulator/src/network/message-store.test.ts rename to packages/simulator/src/network/server/messages.test.ts index a52c13df7..ed68f9aa6 100644 --- a/packages/simulator/src/network/message-store.test.ts +++ b/packages/simulator/src/network/server/messages.test.ts @@ -1,5 +1,5 @@ /** - * @file Pins the message-store reader to the committed-message identity + * @file Pins the message store reader to the committed-message identity * projection. The fixture intentionally has no payload, timestamp, deletion, * reply, encryption, or dispatch columns. */ @@ -10,13 +10,13 @@ import { NodeContext } from "@effect/platform-node"; import { it as effectIt } from "@effect/vitest"; import { PGlite } from "@electric-sql/pglite"; import { agentId, conversationId, messageId } from "@moltzap/protocol/testing"; -import { CommittedRouterMessage, routerSequence } from "../network.js"; +import { CommittedRouterMessage, routerSequence } from "../../network.js"; import { Effect } from "effect"; import { assert, describe } from "vitest"; import { messageDatabasePathForVolume, readCommittedRouterMessages, -} from "./message-store.js"; +} from "./messages.js"; const it = effectIt.scoped; // A file-backed fixture opens PGlite once to seed and once through the diff --git a/packages/simulator/src/network/message-store.ts b/packages/simulator/src/network/server/messages.ts similarity index 96% rename from packages/simulator/src/network/message-store.ts rename to packages/simulator/src/network/server/messages.ts index e0fc66a8b..0fd7b5226 100644 --- a/packages/simulator/src/network/message-store.ts +++ b/packages/simulator/src/network/server/messages.ts @@ -7,12 +7,12 @@ import * as SqlSchema from "@effect/sql/SqlSchema"; import { SqlError } from "@effect/sql/SqlError"; import { PGlite } from "@electric-sql/pglite"; -import { CommittedRouterMessage } from "./router.js"; +import { CommittedRouterMessage } from "../router.js"; import { Brand, Effect, Schema, type ParseResult } from "effect"; import { join } from "node:path"; /** PGlite directory below a MoltZap server volume. */ -export const SERVER_PGLITE_DIR = "pglite"; +const SERVER_PGLITE_DIR = "pglite"; /** Exact message-store path derived from a server-owned volume. */ export type MessageDatabasePath = string & Brand.Brand<"MessageDatabasePath">; diff --git a/packages/simulator/src/network/server/packages.test.ts b/packages/simulator/src/network/server/packages.test.ts new file mode 100644 index 000000000..09620d8f2 --- /dev/null +++ b/packages/simulator/src/network/server/packages.test.ts @@ -0,0 +1,167 @@ +/* eslint-disable max-lines-per-function, sonarjs/max-lines-per-function -- Regression-only package-resolution cases share one isolated module-layout fixture and stay grouped at the resolution boundary. */ + +import { + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, describe, expect, it } from "vitest"; +import { resolveInstalledPackageBin } from "./packages.js"; + +const SCOPED_PACKAGE_NAME = "@moltzap-test/resolved"; +const DECOY_MANIFEST_NAME = "some-other-package"; +const MISSING_PACKAGE_NAME = "@moltzap-test/definitely-missing"; +const SERVER_PACKAGE_NAME = "@moltzap/server-core"; +const SERVER_BIN_NAME = "moltzap-server"; +const TEST_BIN_NAME = "test-server"; +const TEST_BIN_PATH = "bin/test-server"; +const fixtureRoot = mkdtempSync(join(tmpdir(), "package-bin-resolution-test-")); + +afterAll(() => { + rmSync(fixtureRoot, { recursive: true, force: true }); +}); + +function writePackage(root: string, manifest: Record): void { + mkdirSync(join(root, "bin"), { recursive: true }); + writeFileSync(join(root, "package.json"), JSON.stringify(manifest)); + writeFileSync(join(root, TEST_BIN_PATH), ""); +} + +function seedConsumer( + fixtureName: string, + manifest: Record, +): { readonly anchor: string; readonly packageRoot: string } { + const consumerRoot = join(fixtureRoot, fixtureName); + const anchor = join(consumerRoot, "package.json"); + const packageRoot = join(consumerRoot, "node_modules", SCOPED_PACKAGE_NAME); + mkdirSync(consumerRoot, { recursive: true }); + writeFileSync( + anchor, + JSON.stringify({ name: `package-resolution-${fixtureName}` }), + ); + writePackage(packageRoot, manifest); + return { anchor, packageRoot }; +} + +function seedLayeredConsumer( + fixtureName: string, + nearestManifest: string, +): { readonly anchor: string; readonly packageRoot: string } { + const fixtureDirectory = join(fixtureRoot, fixtureName); + const consumerRoot = join(fixtureDirectory, "consumer"); + const anchor = join(consumerRoot, "package.json"); + const nearestPackageRoot = join( + consumerRoot, + "node_modules", + SCOPED_PACKAGE_NAME, + ); + const packageRoot = join( + fixtureDirectory, + "node_modules", + SCOPED_PACKAGE_NAME, + ); + mkdirSync(nearestPackageRoot, { recursive: true }); + mkdirSync(consumerRoot, { recursive: true }); + writeFileSync( + anchor, + JSON.stringify({ name: `package-resolution-${fixtureName}` }), + ); + writeFileSync(join(nearestPackageRoot, "package.json"), nearestManifest); + writePackage(packageRoot, { + name: SCOPED_PACKAGE_NAME, + bin: { [TEST_BIN_NAME]: TEST_BIN_PATH }, + }); + return { anchor, packageRoot }; +} + +function expectedTestBinary(packageRoot: string): string { + return realpathSync(join(packageRoot, TEST_BIN_PATH)); +} + +// @agent-code-guard/regression-only: seeded module layouts exercise the production router's Node package-resolution boundary +describe("resolveInstalledPackageBin", () => { + it("resolves a declared binary from the supplied anchor", () => { + const fixture = seedConsumer("anchored", { + name: SCOPED_PACKAGE_NAME, + bin: { [TEST_BIN_NAME]: TEST_BIN_PATH }, + }); + + expect( + realpathSync( + resolveInstalledPackageBin( + SCOPED_PACKAGE_NAME, + TEST_BIN_NAME, + fixture.anchor, + ), + ), + ).toBe(expectedTestBinary(fixture.packageRoot)); + }); + + it("resolves metadata hidden by an exports map", () => { + const fixture = seedConsumer("export-restricted", { + name: SCOPED_PACKAGE_NAME, + exports: { ".": "./dist/index.js" }, + bin: { [TEST_BIN_NAME]: TEST_BIN_PATH }, + }); + + expect( + realpathSync( + resolveInstalledPackageBin( + SCOPED_PACKAGE_NAME, + TEST_BIN_NAME, + fixture.anchor, + ), + ), + ).toBe(expectedTestBinary(fixture.packageRoot)); + }); + + it("skips a nearer package with the wrong manifest identity", () => { + const fixture = seedLayeredConsumer( + "decoy", + JSON.stringify({ name: DECOY_MANIFEST_NAME }), + ); + + expect( + realpathSync( + resolveInstalledPackageBin( + SCOPED_PACKAGE_NAME, + TEST_BIN_NAME, + fixture.anchor, + ), + ), + ).toBe(expectedTestBinary(fixture.packageRoot)); + }); + + it("rejects missing packages and undeclared binaries", () => { + const fixture = seedConsumer("missing", { + name: SCOPED_PACKAGE_NAME, + }); + + expect(() => + resolveInstalledPackageBin( + MISSING_PACKAGE_NAME, + TEST_BIN_NAME, + fixture.anchor, + ), + ).toThrow("Unable to resolve installed package"); + expect(() => + resolveInstalledPackageBin( + SCOPED_PACKAGE_NAME, + TEST_BIN_NAME, + fixture.anchor, + ), + ).toThrow(`does not expose bin ${TEST_BIN_NAME}`); + }); + + it("resolves the installed production router binary", () => { + expect( + resolveInstalledPackageBin(SERVER_PACKAGE_NAME, SERVER_BIN_NAME), + ).toMatch(/[\\/]bin[\\/]moltzap-server$/u); + }); +}); + +/* eslint-enable max-lines-per-function, sonarjs/max-lines-per-function -- Restore project limits after the package-resolution regressions. */ diff --git a/packages/simulator/src/network/server/packages.ts b/packages/simulator/src/network/server/packages.ts new file mode 100644 index 000000000..7cef9f79a --- /dev/null +++ b/packages/simulator/src/network/server/packages.ts @@ -0,0 +1,212 @@ +/** @file Installed production-router binary resolution. */ + +import { createRequire } from "node:module"; +import { dirname, join } from "node:path"; +import { Data } from "effect"; + +const PACKAGE_RESOLUTION_ANCHOR = import.meta.url; + +class PackageResolutionFailed extends Data.TaggedError( + "PackageResolutionFailed", +)<{ + readonly message: string; + readonly packageName: string; + readonly cause?: unknown; +}> {} + +interface PackageJson { + readonly name?: unknown; + readonly bin?: unknown; +} + +/** What one candidate `package.json` lookup established. */ +type PackageJsonCandidate = + | { readonly _tag: "matched"; readonly root: string } + | { readonly _tag: "absent" } + | { readonly _tag: "unexpected"; readonly cause: unknown }; + +function isPackageJson(value: unknown): value is PackageJson { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isPropertyRecord( + value: unknown, +): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parsePackageJson( + requireFromAnchor: NodeJS.Require, + packageRoot: string, + packageName: string, +): PackageJson { + const packageJsonPath = join(packageRoot, "package.json"); + let manifest: unknown; + try { + manifest = requireFromAnchor(packageJsonPath); + } catch (cause) { + throw new PackageResolutionFailed({ + packageName, + cause, + message: `Unable to read package.json for ${packageName} at ${packageJsonPath}`, + }); + } + if (!isPackageJson(manifest)) { + throw new PackageResolutionFailed({ + packageName, + message: `Invalid package.json for ${packageName} at ${packageJsonPath}: expected an object`, + }); + } + return manifest; +} + +function isExpectedResolutionFailure(cause: unknown): boolean { + const code = + cause instanceof Error && "code" in cause ? cause.code : undefined; + return ( + code === "MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED" + ); +} + +function resolvePackageJsonCandidate( + requireFromAnchor: NodeJS.Require, + packageName: string, + candidate: string, +): PackageJsonCandidate { + let packageJsonPath: string; + try { + packageJsonPath = requireFromAnchor.resolve(candidate); + } catch (cause) { + return isExpectedResolutionFailure(cause) + ? { _tag: "absent" } + : { _tag: "unexpected", cause }; + } + const packageRoot = dirname(packageJsonPath); + try { + const manifest = parsePackageJson( + requireFromAnchor, + packageRoot, + packageName, + ); + return manifest.name === packageName + ? { _tag: "matched", root: packageRoot } + : { _tag: "absent" }; + } catch (cause) { + return { _tag: "unexpected", cause }; + } +} + +/** + * Candidates are tried nearest first: the package's own `package.json` export, + * then each `node_modules` directory on the anchor's resolution path. Reading + * the manifest by absolute path is what lets an `exports` map that hides + * `./package.json` still be resolved. + * @param anchor Module-resolution anchor. + * @param packageName Package whose install root is wanted. + * @returns The install root, or null when no candidate names the package. + */ +function resolvePackageRoot( + anchor: string | URL, + packageName: string, +): string | null { + const requireFromAnchor = createRequire(anchor); + const packageJsonCandidates = [ + `${packageName}/package.json`, + ...(requireFromAnchor.resolve.paths(packageName) ?? []).map((lookupPath) => + join(lookupPath, packageName, "package.json"), + ), + ]; + let unexpectedCause: unknown = null; + for (const candidate of packageJsonCandidates) { + const resolution = resolvePackageJsonCandidate( + requireFromAnchor, + packageName, + candidate, + ); + if (resolution._tag === "matched") { + return resolution.root; + } + if (resolution._tag === "unexpected") { + unexpectedCause ??= resolution.cause; + } + } + if (unexpectedCause !== null) { + throw new PackageResolutionFailed({ + packageName, + cause: unexpectedCause, + message: `Unable to resolve package metadata for ${packageName}`, + }); + } + return null; +} + +function resolveInstalledPackageRoot( + packageName: string, + anchor: string | URL, +): string { + try { + const packageRoot = resolvePackageRoot(anchor, packageName); + if (packageRoot !== null) { + return packageRoot; + } + } catch (cause) { + if (cause instanceof PackageResolutionFailed) { + throw cause; + } + throw new PackageResolutionFailed({ + packageName, + cause, + message: `Unable to resolve installed package ${packageName}`, + }); + } + throw new PackageResolutionFailed({ + packageName, + message: `Unable to resolve installed package ${packageName}`, + }); +} + +function packageBinTarget( + packageRoot: string, + packageName: string, + binName: string, +): string { + const manifestPath = join(packageRoot, "package.json"); + const manifest = parsePackageJson( + createRequire(manifestPath), + packageRoot, + packageName, + ); + const { bin } = manifest; + if (typeof bin === "string") { + return join(packageRoot, bin); + } + if (isPropertyRecord(bin)) { + const target = bin[binName]; + if (typeof target === "string") { + return join(packageRoot, target); + } + } + throw new PackageResolutionFailed({ + packageName, + message: `Package ${packageName} does not expose bin ${binName}`, + }); +} + +/** + * Resolve the installed production-router executable. + * @param packageName Package owning the executable. + * @param binName Declared package binary name. + * @param anchor Module-resolution anchor, replaceable by deterministic tests. + * @returns Absolute installed binary path. + */ +export function resolveInstalledPackageBin( + packageName: string, + binName: string, + anchor: string | URL = PACKAGE_RESOLUTION_ANCHOR, +): string { + return packageBinTarget( + resolveInstalledPackageRoot(packageName, anchor), + packageName, + binName, + ); +} diff --git a/packages/simulator/src/network/server/process.test.ts b/packages/simulator/src/network/server/process.test.ts new file mode 100644 index 000000000..36cc22fb5 --- /dev/null +++ b/packages/simulator/src/network/server/process.test.ts @@ -0,0 +1,344 @@ +/* eslint-disable max-lines-per-function, sonarjs/max-lines-per-function, max-nested-callbacks, sonarjs/no-nested-functions, sonarjs/assertions-in-tests, agent-code-guard/no-example-only-tests, agent-code-guard/no-hardcoded-assertion-literals -- Lifecycle regressions keep each ownership timeline and its assertions together. */ + +import { it as effectIt } from "@effect/vitest"; +import { serverBaseUrl } from "@moltzap/protocol/network"; +import { + agentId, + agentKeyString, + agentName, + conversationId, + messageId, + redactedAgentKey, +} from "@moltzap/protocol/testing"; +import { + Duration, + Data, + Effect, + Exit, + Layer, + Logger, + Redacted, + Scope, + Stream, +} from "effect"; +import { assert, describe } from "vitest"; +import { messageDatabasePathForVolume } from "./messages.js"; +import { + RouterProvider, + routerSequence, + type EndpointTransport, +} from "../router.js"; +import { routerProviderLayer } from "../driver.js"; +import { + serverProcessRouterOperationsLayer, + renderServerProcessConfiguration, + SERVER_CONTAINER_PORT, + type ServerProcessRouterOperations, +} from "./process.js"; + +const it = effectIt.scoped; +const STARTUP_TIMEOUT = Duration.seconds(2); +const ADVERTISED_SERVER_URL = serverBaseUrl( + "ws://moltzap-router.run.svc.cluster.local:3000/ws", +); +const LOOPBACK_SERVER_URL = serverBaseUrl("ws://127.0.0.1:3000/ws"); +const RUN_DIRECTORY = "/controller/run/router"; +const DATABASE_PATH = messageDatabasePathForVolume(RUN_DIRECTORY); +const CONFIGURATION_PATH = `${RUN_DIRECTORY}/moltzap.yaml`; +const BINARY = "/installed/server-core/bin/moltzap-server"; +const PROCESS_HANDLE = "owned-server-process"; +const ALICE = agentName("alice"); +const PROBE = agentName("probe"); +const ALICE_ID = agentId("00000000-0000-4000-8000-000000000001"); +const PROBE_ID = agentId("00000000-0000-4000-8000-000000000002"); +const ALICE_KEY = redactedAgentKey(agentKeyString(41)); +const PROBE_KEY = redactedAgentKey(agentKeyString(42)); +const CONVERSATION_ID = conversationId("00000000-0000-4000-8000-000000000003"); +const MESSAGE_ID = messageId("00000000-0000-4000-8000-000000000004"); + +const committedMessages = [ + { + conversationId: CONVERSATION_ID, + messageId: MESSAGE_ID, + senderId: ALICE_ID, + routerSequence: routerSequence(7), + }, +]; + +const transport: EndpointTransport = { + received: Stream.empty, + openConversation: () => Effect.never, + send: () => Effect.never, +}; + +interface FakeState { + readonly calls: string[]; + readonly failures: Map; + readonly registrationSecrets: Redacted.Redacted[]; + processSecret?: Redacted.Redacted; +} + +interface FakeHarness { + readonly state: FakeState; + readonly operations: ServerProcessRouterOperations; +} + +class FakeOperationFailed extends Data.TaggedError("FakeOperationFailed")<{ + readonly detail: string; +}> { + override get message(): string { + return this.detail; + } +} + +function fakeStep( + state: FakeState, + operation: string, + value: A, +): Effect.Effect { + return Effect.suspend(() => { + state.calls.push(operation); + const remaining = state.failures.get(operation) ?? 0; + if (remaining === 0) { + return Effect.succeed(value); + } + state.failures.set(operation, remaining - 1); + const sensitiveDetail = + state.processSecret === undefined + ? "no-secret-created" + : Redacted.value(state.processSecret); + return Effect.fail( + new FakeOperationFailed({ + detail: `fake ${operation} failure contains ${sensitiveDetail}`, + }), + ); + }); +} + +function makeFakeHarness( + failures: ReadonlyArray = [], +): FakeHarness { + const state: FakeState = { + calls: [], + failures: new Map(failures), + registrationSecrets: [], + processSecret: undefined, + }; + const identities = new Map([ + [ALICE, { agentId: ALICE_ID, key: ALICE_KEY }], + [PROBE, { agentId: PROBE_ID, key: PROBE_KEY }], + ]); + const operations: ServerProcessRouterOperations = { + cleanupTimeout: STARTUP_TIMEOUT, + resolveBinary: fakeStep(state, "binary.resolve", BINARY), + createRunDirectory: fakeStep(state, "run-directory.create", RUN_DIRECTORY), + writeConfiguration: (runDirectory, input) => + Effect.sync(() => { + assert.strictEqual(runDirectory, RUN_DIRECTORY); + assert.strictEqual(input.databasePath, DATABASE_PATH); + assert.strictEqual(input.port, SERVER_CONTAINER_PORT); + }).pipe( + Effect.zipRight( + fakeStep(state, "configuration.write", CONFIGURATION_PATH), + ), + ), + startProcess: (input) => + Effect.sync(() => { + assert.strictEqual(input.binary, BINARY); + assert.strictEqual(input.configurationPath, CONFIGURATION_PATH); + assert.strictEqual(input.runDirectory, RUN_DIRECTORY); + state.processSecret = input.registrationSecret; + }).pipe( + Effect.zipRight(fakeStep(state, "process.start", PROCESS_HANDLE)), + ), + awaitHealthy: (address, startupTimeout) => + Effect.sync(() => { + assert.strictEqual(address, LOOPBACK_SERVER_URL); + assert.strictEqual( + Duration.toMillis(startupTimeout), + Duration.toMillis(STARTUP_TIMEOUT), + ); + }).pipe(Effect.zipRight(fakeStep(state, "health.await", undefined))), + register: (address, name, registrationSecret) => + Effect.sync(() => { + assert.strictEqual(address, LOOPBACK_SERVER_URL); + state.registrationSecrets.push(registrationSecret); + }).pipe( + Effect.zipRight( + fakeStep( + state, + `identity.register:${name}`, + identities.get(name) ?? { agentId: ALICE_ID, key: ALICE_KEY }, + ), + ), + ), + attachEndpoint: (address, key) => + Effect.gen(function* () { + assert.strictEqual(address, LOOPBACK_SERVER_URL); + assert.strictEqual(key, PROBE_KEY); + state.calls.push("endpoint.attach"); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + state.calls.push("endpoint.release"); + }), + ); + return transport; + }), + stopProcess: (handle) => + Effect.sync(() => { + assert.strictEqual(handle, PROCESS_HANDLE); + }).pipe(Effect.zipRight(fakeStep(state, "process.stop", undefined))), + readCommittedMessages: (databasePath) => + Effect.sync(() => { + assert.strictEqual(databasePath, DATABASE_PATH); + }).pipe( + Effect.zipRight(fakeStep(state, "messages.read", committedMessages)), + ), + removeRunDirectory: (runDirectory) => + Effect.sync(() => { + assert.strictEqual(runDirectory, RUN_DIRECTORY); + }).pipe( + Effect.zipRight(fakeStep(state, "run-directory.remove", undefined)), + ), + }; + return { state, operations }; +} + +function provider(harness: FakeHarness) { + return RouterProvider.pipe( + Effect.provide( + routerProviderLayer({ startupTimeout: STARTUP_TIMEOUT }).pipe( + Layer.provide( + serverProcessRouterOperationsLayer( + ADVERTISED_SERVER_URL, + harness.operations, + ), + ), + ), + ), + ); +} + +function count(calls: readonly string[], operation: string): number { + return calls.filter((entry) => entry === operation).length; +} + +function rawProcessSecret(state: FakeState): string { + return state.processSecret === undefined + ? "" + : Redacted.value(state.processSecret); +} + +describe("controller MoltZap server process", () => { + it("uses loopback inside the controller while advertising the Service to agents", () => + Effect.gen(function* () { + const harness = makeFakeHarness(); + const scope = yield* Scope.make(); + const routerProvider = yield* provider(harness); + const router = yield* routerProvider.acquire.pipe(Scope.extend(scope)); + const alice = yield* router + .attachAgent("alice", ALICE) + .pipe(Scope.extend(scope)); + const probe = yield* router + .attachEndpoint("probe", PROBE) + .pipe(Scope.extend(scope)); + + assert.strictEqual(router.address, ADVERTISED_SERVER_URL); + assert.strictEqual(alice.routerUrl, ADVERTISED_SERVER_URL); + assert.strictEqual(alice.agent.id, ALICE_ID); + assert.strictEqual(probe.participant.id, PROBE_ID); + assert.strictEqual(harness.state.registrationSecrets.length, 2); + assert.strictEqual( + harness.state.registrationSecrets.every( + (secret) => secret === harness.state.processSecret, + ), + true, + ); + + yield* Scope.close(scope, Exit.void); + + const stopped = yield* router.stopped; + assert.deepStrictEqual(stopped.committedMessages, committedMessages); + assert.deepStrictEqual(harness.state.calls, [ + "binary.resolve", + "run-directory.create", + "configuration.write", + "process.start", + "health.await", + "identity.register:alice", + "identity.register:probe", + "endpoint.attach", + "endpoint.release", + "process.stop", + "messages.read", + "run-directory.remove", + ]); + })); + + it("stops the child and removes its data when readiness fails", () => + Effect.gen(function* () { + const harness = makeFakeHarness([["health.await", 1]]); + const routerProvider = yield* provider(harness); + const failure = yield* Effect.scoped(routerProvider.acquire).pipe( + Effect.flip, + ); + const rawSecret = rawProcessSecret(harness.state); + + assert.strictEqual(failure.operation, "acquire-router"); + assert.notInclude(failure.detail, rawSecret); + assert.notInclude(failure.message, rawSecret); + assert.deepStrictEqual(harness.state.calls, [ + "binary.resolve", + "run-directory.create", + "configuration.write", + "process.start", + "health.await", + "process.stop", + "run-directory.remove", + ]); + })); + + it("retains the store and skips collection when termination is unconfirmed", () => + Effect.gen(function* () { + const harness = makeFakeHarness([["process.stop", 2]]); + const scope = yield* Scope.make(); + const routerProvider = yield* provider(harness); + const router = yield* routerProvider.acquire.pipe(Scope.extend(scope)); + const logs: string[] = []; + const logger = Logger.make(({ message }) => { + logs.push(String(message)); + }); + + yield* Scope.close(scope, Exit.void).pipe( + Effect.provide(Logger.replace(Logger.defaultLogger, logger)), + ); + const stopped = yield* router.stopped.pipe(Effect.flip); + const rawSecret = rawProcessSecret(harness.state); + + assert.strictEqual(stopped.operation, "stop-router"); + assert.notInclude(stopped.detail, rawSecret); + assert.strictEqual(count(harness.state.calls, "process.stop"), 2); + assert.strictEqual(count(harness.state.calls, "messages.read"), 0); + assert.strictEqual(count(harness.state.calls, "run-directory.remove"), 0); + assert.strictEqual( + logs.some((message) => message.includes(rawSecret)), + false, + ); + })); + + it("renders a persistent PGlite config with only an env secret reference", () => + Effect.sync(() => { + const secret = "must-not-appear-in-config"; + const configuration = renderServerProcessConfiguration({ + databasePath: DATABASE_PATH, + port: SERVER_CONTAINER_PORT, + }); + + assert.include(configuration, "port: 3000"); + assert.include(configuration, `data_dir: "${DATABASE_PATH}"`); + assert.include(configuration, 'secret: "${MOLTZAP_REGISTRATION_SECRET}"'); + assert.notInclude(configuration, secret); + })); +}); + +/* eslint-enable max-lines-per-function, sonarjs/max-lines-per-function, max-nested-callbacks, sonarjs/no-nested-functions, sonarjs/assertions-in-tests, agent-code-guard/no-example-only-tests, agent-code-guard/no-hardcoded-assertion-literals -- Restore strict defaults after lifecycle regressions. */ diff --git a/packages/simulator/src/network/server/process.ts b/packages/simulator/src/network/server/process.ts new file mode 100644 index 000000000..a5864efaa --- /dev/null +++ b/packages/simulator/src/network/server/process.ts @@ -0,0 +1,777 @@ +/** @file Scope-owned production MoltZap router process for the controller. */ +// safer-arch-ignore no-fat-orchestrator: This private controller entry point composes one complete production-router lifetime behind the narrow RouterProvider contract. + +import { FileSystem, HttpClient } from "@effect/platform"; +import type { + CommandExecutor, + ExitCode, + Process, +} from "@effect/platform/CommandExecutor"; +import type { PlatformError } from "@effect/platform/Error"; +import { NodeContext, NodeHttpClient } from "@effect/platform-node"; +import { registerAgent } from "@moltzap/client/auth"; +import { agentConversationCreate } from "@moltzap/protocol/conversation"; +import type { AgentId, AgentKey, AgentName } from "@moltzap/protocol/identity"; +import { + messageReceivedNotificationDefinition, + messagesSend, +} from "@moltzap/protocol/message"; +import { + httpBaseUrl, + serverBaseUrl, + type ServerBaseUrl, +} from "@moltzap/protocol/network"; +import { MoltZapAgentClient } from "@moltzap/protocol/socket"; +import { randomBytes } from "node:crypto"; +import { join } from "node:path"; +import { + Cause, + Data, + Duration, + Effect, + Exit, + type Fiber, + Layer, + Redacted, + Schedule, + Scope, + Stream, +} from "effect"; +import { + baseChildEnvironmentConfig, + escalatingKill, + makeExactEnvironmentCommand, + type ProcessTreeCleanup, + startSupervisedProcess, +} from "./command.js"; +import { resolveInstalledPackageBin } from "./packages.js"; +import { + messageDatabasePathForVolume, + type MessageDatabasePath, + readCommittedRouterMessages, +} from "./messages.js"; +import { + routerProviderLayer, + RouterOperations, + type RouterDriver, +} from "../driver.js"; +import { + makeRouterStopReport, + type CommittedRouterMessage, + type EndpointTransport, + type ParticipantIds, + type RouterProvider, + type RouterStopped, +} from "../router.js"; +import { networkError } from "../failure.js"; +const LOOPBACK_HOST = "127.0.0.1"; +/** Port owned by the controller-local production router process. */ +export const SERVER_CONTAINER_PORT = 3000; +const SERVER_REGISTRATION_SECRET_ENV = "MOLTZAP_REGISTRATION_SECRET"; +const SERVER_HEALTH_POLL_MS = 250; +const REGISTRATION_SECRET_BYTES = 32; +const SERVER_TERM_WAIT_MS = 10_000; +const SERVER_KILL_WAIT_MS = 5_000; +const SERVER_CLEANUP_TIMEOUT = Duration.seconds(20); +const SERVER_CONFIG_FILE = "moltzap.yaml"; +const SERVER_PACKAGE = "@moltzap/server-core"; +const SERVER_BIN = "moltzap-server"; +const SERVER_ADMIN_USER_ID = "5f1cbf1e-0d68-4b04-9c1a-2a0f5f0a1c31"; +const LOOPBACK_SERVER_URL = serverBaseUrl( + `ws://${LOOPBACK_HOST}:${String(SERVER_CONTAINER_PORT)}/ws`, +); + +type RunRegistrationSecret = Redacted.Redacted; + +interface RouterIdentity { + readonly agentId: AgentId; + readonly key: AgentKey; +} + +interface ServerProcessStart { + readonly binary: string; + readonly configurationPath: string; + readonly registrationSecret: RunRegistrationSecret; + readonly runDirectory: string; +} + +interface ServerConfigurationInput { + readonly databasePath: MessageDatabasePath; + readonly port: number; +} + +/** + * Options for one controller-owned production router process. + * @internal + */ +export interface ServerProcessRouterOptions { + readonly advertisedServerUrl: ServerBaseUrl; + readonly startupTimeout: Duration.Duration; +} + +/** + * Injectable process operations used by deterministic lifecycle tests. + * @internal + */ +export interface ServerProcessRouterOperations { + readonly cleanupTimeout: Duration.Duration; + readonly resolveBinary: Effect.Effect; + readonly createRunDirectory: Effect.Effect; + readonly writeConfiguration: ( + runDirectory: string, + input: ServerConfigurationInput, + ) => Effect.Effect; + readonly startProcess: ( + input: ServerProcessStart, + ) => Effect.Effect; + readonly awaitHealthy: ( + address: ServerBaseUrl, + startupTimeout: Duration.Duration, + ) => Effect.Effect; + readonly register: ( + address: ServerBaseUrl, + name: AgentName, + registrationSecret: RunRegistrationSecret, + ) => Effect.Effect; + readonly attachEndpoint: ( + address: ServerBaseUrl, + key: AgentKey, + ) => Effect.Effect; + readonly stopProcess: ( + process: ProcessHandle, + ) => Effect.Effect; + readonly readCommittedMessages: ( + databasePath: MessageDatabasePath, + ) => Effect.Effect; + readonly removeRunDirectory: ( + runDirectory: string, + ) => Effect.Effect; +} + +type ServerProcessOperation = + | "resolve-binary" + | "create-run-directory" + | "write-configuration" + | "create-registration-secret" + | "start-process" + | "wait-for-health" + | "register-agent" + | "cleanup"; + +class ServerProcessFailed extends Data.TaggedError("ServerProcessFailed")<{ + readonly operation: ServerProcessOperation; + readonly detail: string; +}> { + override get message(): string { + return `MoltZap server process ${this.operation} failed: ${this.detail}`; + } +} + +const failureDetails: Readonly> = { + "resolve-binary": "the installed server binary is unavailable", + "create-run-directory": "the run data directory could not be created", + "write-configuration": "the run configuration could not be written", + "create-registration-secret": + "the run registration secret could not be created", + "start-process": "the server child could not be started", + "wait-for-health": + "the server did not become healthy before the startup deadline", + "register-agent": "the server rejected agent registration", + cleanup: "server process cleanup did not complete", +}; + +type OwnedRunDirectory = + | { readonly _tag: "absent" } + | { readonly _tag: "owned"; readonly path: string } + | { readonly _tag: "removed" }; + +type OwnedProcess = + | { readonly _tag: "absent" } + | { readonly _tag: "running"; readonly handle: ProcessHandle } + | { readonly _tag: "stopped" }; + +interface OwnedResources { + runDirectory: OwnedRunDirectory; + process: OwnedProcess; +} + +interface AcquiredProcess { + readonly databasePath: MessageDatabasePath; + readonly registrationSecret: RunRegistrationSecret; + readonly startupTimeout: Duration.Duration; +} + +interface StartedServerProcess { + readonly proc: Process; + readonly exitFiber: Fiber.RuntimeFiber; + readonly processTreeCleanup: ProcessTreeCleanup; + readonly scope: Scope.CloseableScope; +} + +function processFailure( + operation: ServerProcessOperation, + detail?: string, +): ServerProcessFailed { + return new ServerProcessFailed({ + operation, + detail: detail ?? failureDetails[operation], + }); +} + +function atStage( + operation: ServerProcessOperation, + effect: Effect.Effect, +): Effect.Effect { + return effect.pipe(Effect.mapError(() => processFailure(operation))); +} + +function makeRunRegistrationSecret(): Effect.Effect< + RunRegistrationSecret, + ServerProcessFailed +> { + return Effect.try({ + try: () => + Redacted.make( + randomBytes(REGISTRATION_SECRET_BYTES).toString("base64url"), + ), + catch: () => processFailure("create-registration-secret"), + }); +} + +/** + * Render the secret-free server configuration persisted in a run directory. + * @param input Value supplied to the operation. + * @internal + * @returns The rendered server configuration. + */ +export function renderServerProcessConfiguration( + input: ServerConfigurationInput, +): string { + return [ + `admin_user_id: ${SERVER_ADMIN_USER_ID}`, + "registration:", + ` secret: "\${${SERVER_REGISTRATION_SECRET_ENV}}"`, + "server:", + ` port: ${String(input.port)}`, + " cors_origins:", + ' - "*"', + "database:", + ` data_dir: ${JSON.stringify(input.databasePath)}`, + "", + ].join("\n"); +} + +function endpointMessages( + client: MoltZapAgentClient, +): Effect.Effect { + return client + .subscribeScoped(messageReceivedNotificationDefinition) + .pipe( + Effect.map((received) => + received.pipe( + Stream.mapError((cause) => networkError("receive", cause)), + ), + ), + ); +} + +function openConversationWith( + client: MoltZapAgentClient, +): EndpointTransport["openConversation"] { + return (participants: ParticipantIds) => + client + .callDefinition(agentConversationCreate, { + participants, + }) + .pipe( + Effect.mapError((cause) => networkError("open-conversation", cause)), + Effect.map((result) => ({ conversationId: result.conversation.id })), + ); +} + +function sendWith(client: MoltZapAgentClient): EndpointTransport["send"] { + return (conversationId, parts) => + client.callDefinition(messagesSend, { conversationId, parts }).pipe( + Effect.map((result) => result.message), + Effect.mapError((cause) => networkError("send", cause)), + ); +} + +function endpointTransport( + address: ServerBaseUrl, + key: AgentKey, +): Effect.Effect { + return Effect.gen(function* () { + const client = new MoltZapAgentClient({ + serverUrl: httpBaseUrl(address), + agentKey: key, + }); + yield* Effect.addFinalizer(() => client.close()); + const received = yield* endpointMessages(client); + yield* client.connect(); + return { + received, + openConversation: openConversationWith(client), + send: sendWith(client), + }; + }); +} + +function awaitServerHealthy( + address: ServerBaseUrl, + startupTimeout: Duration.Duration, +): Effect.Effect { + const healthUrl = `${httpBaseUrl(address)}/health`; + const probe = HttpClient.HttpClient.pipe( + Effect.flatMap((client) => client.get(healthUrl)), + Effect.map((response) => response.status === 200), + Effect.orElseSucceed(() => false), + ); + return probe.pipe( + Effect.filterOrFail( + (healthy) => healthy, + () => undefined, + ), + Effect.retry({ + schedule: Schedule.spaced(Duration.millis(SERVER_HEALTH_POLL_MS)), + }), + Effect.timeout(startupTimeout), + Effect.asVoid, + Effect.provide(NodeHttpClient.layer), + ); +} + +function registerIdentity( + address: ServerBaseUrl, + name: AgentName, + registrationSecret: RunRegistrationSecret, +): Effect.Effect { + return registerAgent(httpBaseUrl(address), name, { + inviteCode: Redacted.value(registrationSecret), + }).pipe( + Effect.map((identity) => ({ + agentId: identity.agentId, + key: identity.apiKey, + })), + ); +} + +function startServerProcess( + input: ServerProcessStart, +): Effect.Effect { + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const baseEnvironment = yield* baseChildEnvironmentConfig; + const scope = yield* Scope.make(); + const processTreeCleanup: ProcessTreeCleanup = { + claimed: false, + launcherOwnsExitCleanup: true, + }; + const command = makeExactEnvironmentCommand({ + command: input.binary, + args: [], + cwd: input.runDirectory, + cleanupTreeOnExit: true, + env: { + ...baseEnvironment, + HOME: input.runDirectory, + NODE_ENV: "production", + MOLTZAP_CONFIG: input.configurationPath, + PORT: String(SERVER_CONTAINER_PORT), + [SERVER_REGISTRATION_SECRET_ENV]: Redacted.value( + input.registrationSecret, + ), + }, + }); + const started = yield* restore( + startSupervisedProcess( + command, + scope, + () => undefined, + processTreeCleanup, + ), + ).pipe( + Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), + ); + return { ...started, scope }; + }), + ); +} + +function stopServerProcess(process: StartedServerProcess): Effect.Effect { + return escalatingKill( + process.proc, + process.exitFiber, + { + termWaitMs: SERVER_TERM_WAIT_MS, + killWaitMs: SERVER_KILL_WAIT_MS, + }, + process.processTreeCleanup, + ).pipe(Effect.zipRight(Scope.close(process.scope, Exit.void))); +} + +function realServerProcessOperations(): ServerProcessRouterOperations { + const provideNode = Effect.provide(NodeContext.layer); + return { + cleanupTimeout: SERVER_CLEANUP_TIMEOUT, + resolveBinary: Effect.try({ + try: () => resolveInstalledPackageBin(SERVER_PACKAGE, SERVER_BIN), + catch: () => undefined, + }), + createRunDirectory: provideNode( + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => + fileSystem.makeTempDirectory({ + prefix: "moltzap-controller-router-", + }), + ), + ), + ), + writeConfiguration: (runDirectory, input) => { + const configurationPath = join(runDirectory, SERVER_CONFIG_FILE); + return provideNode( + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => + fileSystem.writeFileString( + configurationPath, + renderServerProcessConfiguration(input), + ), + ), + Effect.as(configurationPath), + ), + ); + }, + startProcess: (input) => provideNode(startServerProcess(input)), + awaitHealthy: awaitServerHealthy, + register: registerIdentity, + attachEndpoint: endpointTransport, + stopProcess: stopServerProcess, + readCommittedMessages: readCommittedRouterMessages, + removeRunDirectory: (runDirectory) => + provideNode( + FileSystem.FileSystem.pipe( + Effect.flatMap((fileSystem) => + fileSystem.remove(runDirectory, { recursive: true, force: true }), + ), + ), + ), + }; +} + +function emptyOwnedResources(): OwnedResources { + return { + runDirectory: { _tag: "absent" }, + process: { _tag: "absent" }, + }; +} + +function claimResource( + acquire: Effect.Effect, + claim: (resource: A) => void, +): Effect.Effect { + return Effect.uninterruptibleMask((restore) => + restore(acquire).pipe( + Effect.tap((resource) => + Effect.sync(() => { + claim(resource); + }), + ), + ), + ); +} + +function captureCleanup( + label: "server-process" | "server-run-directory", + effect: Effect.Effect, + timeout: Duration.Duration, + confirm: () => void, +): Effect.Effect { + return effect.pipe( + Effect.interruptible, + Effect.timeout(timeout), + Effect.tap(() => Effect.sync(confirm)), + Effect.exit, + Effect.map((result) => (Exit.isSuccess(result) ? [] : [label])), + ); +} + +function stopOwnedProcess( + operations: ServerProcessRouterOperations, + owned: OwnedResources, + permit: Effect.Semaphore, +): Effect.Effect { + return permit + .withPermits(1)( + Effect.suspend(() => { + if (owned.process._tag !== "running") { + return Effect.succeed([]); + } + return captureCleanup( + "server-process", + operations.stopProcess(owned.process.handle), + operations.cleanupTimeout, + () => { + owned.process = { _tag: "stopped" }; + }, + ); + }), + ) + .pipe(Effect.uninterruptible); +} + +function removeOwnedRunDirectory( + operations: ServerProcessRouterOperations, + owned: OwnedResources, + permit: Effect.Semaphore, +): Effect.Effect { + return permit.withPermits(1)( + Effect.suspend(() => { + if (owned.runDirectory._tag !== "owned") { + return Effect.succeed([]); + } + if (owned.process._tag === "running") { + return Effect.succeed(["server-run-directory"]); + } + return captureCleanup( + "server-run-directory", + operations.removeRunDirectory(owned.runDirectory.path), + operations.cleanupTimeout, + () => { + owned.runDirectory = { _tag: "removed" }; + }, + ); + }), + ); +} + +function cleanupAll( + operations: ServerProcessRouterOperations, + owned: OwnedResources, + permit: Effect.Semaphore, +): Effect.Effect { + return Effect.gen(function* () { + const processFailures = yield* stopOwnedProcess(operations, owned, permit); + const directoryFailures = yield* removeOwnedRunDirectory( + operations, + owned, + permit, + ); + return [...processFailures, ...directoryFailures]; + }); +} + +function acquireProcess( + startupTimeout: Duration.Duration, + operations: ServerProcessRouterOperations, + owned: OwnedResources, +): Effect.Effect { + return Effect.gen(function* () { + const binary = yield* atStage("resolve-binary", operations.resolveBinary); + const runDirectory = yield* claimResource( + atStage("create-run-directory", operations.createRunDirectory), + (path) => { + owned.runDirectory = { _tag: "owned", path }; + }, + ); + const databasePath = messageDatabasePathForVolume(runDirectory); + const configurationPath = yield* atStage( + "write-configuration", + operations.writeConfiguration(runDirectory, { + databasePath, + port: SERVER_CONTAINER_PORT, + }), + ); + const registrationSecret = yield* makeRunRegistrationSecret(); + yield* claimResource( + atStage( + "start-process", + operations.startProcess({ + binary, + configurationPath, + registrationSecret, + runDirectory, + }), + ), + (handle) => { + owned.process = { _tag: "running", handle }; + }, + ); + yield* atStage( + "wait-for-health", + operations.awaitHealthy(LOOPBACK_SERVER_URL, startupTimeout), + ); + return { databasePath, registrationSecret, startupTimeout }; + }); +} + +function boundedOperation( + timeout: Duration.Duration, + effect: Effect.Effect, +) { + return effect.pipe(Effect.interruptible, Effect.timeout(timeout)); +} + +function collectStoppedRouter( + acquired: AcquiredProcess, + operations: ServerProcessRouterOperations, + owned: OwnedResources, + permit: Effect.Semaphore, +): Effect.Effect> { + return Effect.gen(function* () { + yield* stopOwnedProcess(operations, owned, permit).pipe( + Effect.flatMap((failures) => + failures.length === 0 && owned.process._tag === "stopped" + ? Effect.void + : Effect.fail( + networkError( + "stop-router", + "the controller router process could not be terminated", + ), + ), + ), + ); + const messages = yield* operations + .readCommittedMessages(acquired.databasePath) + .pipe( + Effect.mapError(() => + networkError( + "stop-router", + "committed router messages could not be read", + ), + ), + ); + return makeRouterStopReport(messages); + }); +} + +function makeDriver( + advertisedServerUrl: ServerBaseUrl, + acquired: AcquiredProcess, + operations: ServerProcessRouterOperations, + runtime: { + readonly owned: OwnedResources; + readonly permit: Effect.Semaphore; + }, +): RouterDriver { + return { + address: advertisedServerUrl, + register: (name) => + atStage( + "register-agent", + boundedOperation( + acquired.startupTimeout, + operations.register( + LOOPBACK_SERVER_URL, + name, + acquired.registrationSecret, + ), + ), + ), + attachEndpoint: (key) => + operations.attachEndpoint(LOOPBACK_SERVER_URL, key), + stopAndCollect: collectStoppedRouter( + acquired, + operations, + runtime.owned, + runtime.permit, + ), + }; +} + +function finalRelease( + operations: ServerProcessRouterOperations, + owned: OwnedResources, + permit: Effect.Semaphore, +): Effect.Effect { + return cleanupAll(operations, owned, permit).pipe( + Effect.flatMap((failures) => + failures.length === 0 + ? Effect.void + : Effect.logError("MoltZap server process cleanup was incomplete").pipe( + Effect.annotateLogs({ resources: failures.join(",") }), + ), + ), + Effect.uninterruptible, + ); +} + +function acquireServerProcessDriver( + options: ServerProcessRouterOptions, + operations: ServerProcessRouterOperations, +): Effect.Effect { + return Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const owned = emptyOwnedResources(); + const permit = yield* Effect.makeSemaphore(1); + const attempt = yield* restore( + acquireProcess(options.startupTimeout, operations, owned), + ).pipe(Effect.exit); + if (Exit.isFailure(attempt)) { + const cleanupFailures = yield* cleanupAll(operations, owned, permit); + if (cleanupFailures.length > 0) { + if (Cause.isInterruptedOnly(attempt.cause)) { + yield* Effect.logError( + "Interrupted MoltZap server process acquisition left incomplete cleanup", + ); + return yield* Effect.failCause(attempt.cause); + } + return yield* processFailure( + "cleanup", + "server process acquisition cleanup did not complete", + ); + } + return yield* Effect.failCause(attempt.cause); + } + yield* Effect.addFinalizer(() => finalRelease(operations, owned, permit)); + return makeDriver( + options.advertisedServerUrl, + attempt.value, + operations, + { owned, permit }, + ); + }), + ).pipe(Effect.withSpan("acquireMoltZapServerProcess")); +} + +/** + * Install a controller-owned server process as the run's router driver. The + * startup deadline arrives with each acquisition, so only the advertised URL + * is fixed here. + * @param advertisedServerUrl Service URL handed to agents outside the Pod. + * @param operations Injectable lifecycle operations. + * @internal + * @returns A Layer providing the router driver acquirer. + */ +export function serverProcessRouterOperationsLayer( + advertisedServerUrl: ServerBaseUrl, + operations: ServerProcessRouterOperations, +): Layer.Layer { + return Layer.succeed(RouterOperations, (driverOptions) => + acquireServerProcessDriver( + { + advertisedServerUrl, + startupTimeout: driverOptions.startupTimeout, + }, + operations, + ), + ); +} + +/** + * Publish the package-private router service backed by a real server process. + * @param options Advertised Service URL and startup deadline. + * @internal + * @returns A Layer providing the controller router service. + */ +export function serverProcessRouterProviderLayer( + options: ServerProcessRouterOptions, +): Layer.Layer { + return routerProviderLayer({ startupTimeout: options.startupTimeout }).pipe( + Layer.provide( + serverProcessRouterOperationsLayer( + options.advertisedServerUrl, + realServerProcessOperations(), + ), + ), + ); +} diff --git a/packages/simulator/src/package-exports.test.ts b/packages/simulator/src/package-exports.test.ts index e2ec5c85b..59da96698 100644 --- a/packages/simulator/src/package-exports.test.ts +++ b/packages/simulator/src/package-exports.test.ts @@ -4,7 +4,7 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; import * as customerApi from "./index.js"; import * as ledgerApi from "./ledger.js"; -import * as runtimeApi from "./runtime.js"; +import * as runtimeApi from "./agents.js"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -21,8 +21,8 @@ function loadPackageExports(): Record { } // @agent-code-guard/regression-only: exact package surfaces are finite dependency and privilege boundaries -describe("@moltzap/simulator package exports", () => { - it("publishes exactly the customer, network, ledger, and runtime surfaces", () => { +describe("@moltzap/simulator package map", () => { + it("publishes exactly the customer, network, ledger, and agents surfaces", () => { expect(loadPackageExports()).toEqual({ ".": { types: "./dist/index.d.ts", @@ -36,14 +36,16 @@ describe("@moltzap/simulator package exports", () => { types: "./dist/ledger.d.ts", import: "./dist/ledger.js", }, - "./runtime": { - types: "./dist/runtime.d.ts", - import: "./dist/runtime.js", + "./agents": { + types: "./dist/agents.d.ts", + import: "./dist/agents.js", }, }); }); +}); - it("keeps platform-authoring values off the experiment root", () => { +describe("@moltzap/simulator root export", () => { + it("keeps cluster-authoring values off the experiment root", () => { expect(Object.keys(customerApi)).not.toEqual( expect.arrayContaining([ "AgentRoster", @@ -53,30 +55,47 @@ describe("@moltzap/simulator package exports", () => { "makeAgentHandle", "makeParticipantHandle", "makeRouterStopReport", - "networkFailure", + "networkError", "effectRuntime", "nanoclawRuntime", "openClawRuntime", ]), ); + expect( + Object.keys(customerApi).filter( + (name) => + /platform|kubernetes|k8s|kueue|temporal|sandbox|fake/iu.test(name) || + (name.endsWith("Controller") && name !== "LinkController"), + ), + ).toEqual([]); }); - it("keeps run-ledger construction and producer writers inside the kernel", () => { - expect(ledgerApi).not.toHaveProperty("makeRunLedger"); + it("exposes RunSpec as the only execution entry point", () => { + expect(customerApi).not.toHaveProperty("defineSimulator"); + expect(customerApi).not.toHaveProperty("defineRunSpec"); + expect(customerApi).not.toHaveProperty("executeRunSpec"); + expect(customerApi).not.toHaveProperty("simulator"); + expect(customerApi).not.toHaveProperty("simulatorLayer"); + expect(customerApi.RunSpec).toHaveProperty("define"); + expect(customerApi.Run).toHaveProperty("execute"); + expect(customerApi).toHaveProperty("ClusterError"); }); +}); - it("exposes one definition constructor through simulator", () => { - expect(customerApi).not.toHaveProperty("defineSimulator"); - expect(customerApi.simulator).toHaveProperty("define"); +describe("@moltzap/simulator/ledger package export", () => { + it("keeps run-ledger construction and producer writers inside the kernel", () => { + expect(ledgerApi).not.toHaveProperty("makeRunLedger"); }); }); -describe("@moltzap/simulator/runtime package export", () => { - it("publishes the shipped autonomous runtime implementations", () => { +describe("@moltzap/simulator/agents package export", () => { + it("publishes container runtime definitions and shipped implementations", () => { expect([ - typeof runtimeApi.effectRuntime, + typeof runtimeApi.defineContainerRuntime, typeof runtimeApi.nanoclawRuntime, typeof runtimeApi.openClawRuntime, ]).toEqual(["function", "function", "function"]); + expect(runtimeApi).not.toHaveProperty("defineRuntime"); + expect(runtimeApi).not.toHaveProperty("effectRuntime"); }); }); diff --git a/packages/simulator/src/run-spec.types-check.ts b/packages/simulator/src/run-spec.types-check.ts new file mode 100644 index 000000000..0a5a823ac --- /dev/null +++ b/packages/simulator/src/run-spec.types-check.ts @@ -0,0 +1,229 @@ +/** + * A RunSpec preserves exact heterogeneous gateways and contains customer + * completion inside ProgramFinished. Its cluster Layer supplies the + * kernel and cluster, removes customer-used extra outputs, and leaves only + * the Layer input plus customer-owned requirements outside. + */ + +import { + Context, + Data, + Effect, + type Exit, + Layer, + Schema, + type Stream, +} from "effect"; +import { EventCatalog } from "./events/catalog.js"; +import { coreEvents } from "./events/core.js"; +import type { LedgerFailure } from "./ledger/append.js"; +import type { LedgerRef } from "./ledger/schema.js"; +import { openLedger } from "./ledger/read.js"; +import { LedgerStorage, type LedgerStorageError } from "./ledger/storage.js"; +import { RouterProvider } from "./network/router.js"; +import { Run, RunSpec } from "./definition.js"; +import type { ProgramFinished, SimulatorRunFailure } from "./run/execute.js"; +import { type ClusterError, Cluster } from "./cluster/cluster.js"; +import { defineRuntime } from "./agents/agent.js"; + +interface AlphaGateway { + readonly runtime: "alpha"; + readonly submit: (input: string) => Effect.Effect<"alpha-accepted">; +} + +interface BetaGateway { + readonly runtime: "beta"; + readonly inspect: Effect.Effect<"beta-ready">; +} + +class ClusterInput extends Context.Tag( + "@moltzap/simulator/test/RunSpecClusterInput", +)() {} + +class ClusterExtra extends Context.Tag( + "@moltzap/simulator/test/RunSpecClusterExtra", +)() {} + +class CustomerRequirement extends Context.Tag( + "@moltzap/simulator/test/RunSpecCustomerRequirement", +)< + CustomerRequirement, + { readonly check: Effect.Effect } +>() {} + +class CustomerFailure extends Data.TaggedError("CustomerFailure")<{ + readonly detail: string; +}> {} + +class ClusterUnavailable extends Data.TaggedError("ClusterUnavailable")<{ + readonly detail: string; +}> {} + +class Observation extends Schema.TaggedClass()( + "acme.run-spec-observation/v1", + { + detail: Schema.String, + }, +) {} + +const runtimeConfiguration = Schema.Struct({}); +const configuration = { + schema: runtimeConfiguration, + value: {}, +}; + +const alphaRuntime = defineRuntime< + AlphaGateway, + never, + typeof runtimeConfiguration +>({ + name: "alpha", + configuration, +}); + +const betaRuntime = defineRuntime< + BetaGateway, + never, + typeof runtimeConfiguration +>({ + name: "beta", + configuration, +}); + +const unavailableCluster = Effect.gen(function* () { + yield* ClusterInput; + return yield* Effect.fail( + new ClusterUnavailable({ detail: "compile-time canary" }), + ); +}); + +const cluster = Layer.mergeAll( + Layer.effect(LedgerStorage, unavailableCluster), + Layer.effect(RouterProvider, unavailableCluster), + Layer.effect(Cluster, unavailableCluster), + Layer.effect(ClusterExtra, unavailableCluster), +); + +const observations = EventCatalog.make(Observation); + +/** Representative RunSpec retained for compile-time inference checks. */ +export const runSpecCanary = RunSpec.define({ + id: "acme.run-spec-canary/v1", + events: [observations], + agents: { + alice: alphaRuntime, + bob: betaRuntime, + }, + cluster, + execute: ({ agents, events }) => + Effect.gen(function* () { + const customer = yield* CustomerRequirement; + const extra = yield* ClusterExtra; + yield* customer.check; + yield* events + .emit(Observation.make({ detail: extra.marker })) + .pipe(Effect.ignore); + return [ + agents.alice.gateway.runtime, + agents.bob.gateway.runtime, + extra.marker, + ] as const; + }).pipe(Effect.withSpan("runSpecCanary")), +}); + +/** Representative root execution retained for compile-time contract checks. */ +export const runSpecCanaryExecution = Run.execute(runSpecCanary); + +type Equal = [Left, Right] extends [Right, Left] ? true : false; +type Expect = Value; +type ProgramTypes = + Outcome extends ProgramFinished + ? readonly [Success, Failure] + : never; + +type ExecuteContext = Parameters[0]; +type Agents = ExecuteContext["agents"]; +type ExecutionRequirements = Effect.Effect.Context< + typeof runSpecCanaryExecution +>; + +type AgentKeysAreExact = Expect>; +type AliceNameIsExact = Expect< + Equal +>; +type AliceGatewayIsExact = Expect< + Equal +>; +type BobGatewayIsExact = Expect>; +type CustomerExitIsRetained = Expect< + Equal< + ProgramTypes>, + readonly [readonly ["alpha", "beta", "layer-output"], CustomerFailure] + > +>; +type OuterErrorsAreClusterOnly = Expect< + Equal< + Effect.Effect.Error, + ClusterUnavailable | LedgerStorageError + > +>; +// Exhaustive: the cluster Layer's extra output, the kernel services it +// supplies, Scope, and the parent span are all absent from this exact union. +type ExternalRequirementsAreExact = Expect< + Equal +>; +type LiveRecordsRetainClusterError = Expect< + Equal, LedgerFailure> +>; + +/** + * Matching completed-ledger reader retained for stream error checks. + * @param ref Durable ledger identity used by the canary. + * @returns The matching completed-ledger reader Effect. + */ +export const completedRunSpecCanaryReader = (ref: LedgerRef) => + openLedger( + EventCatalog.merge(coreEvents, observations), + ref, + "acme.run-spec-canary/v1", + ); +type OpenedLedger = Effect.Effect.Success< + ReturnType +>; +type CompletedRecordsCannotFail = Expect< + Equal, never> +>; +type FinishedOutcome = Extract< + Effect.Effect.Success, + { readonly _tag: "ProgramFinished" } +>; +type ProgramFinishedExitIsExact = Expect< + Equal< + FinishedOutcome["exit"], + Exit.Exit + > +>; +type ClusterErrorUsesPublicShape = Expect< + Equal< + Extract< + SimulatorRunFailure, + { readonly _tag: "ClusterError" } + >, + ClusterError + > +>; + +/** Compile-time assertions for the additive RunSpec execution surface. */ +export type RunSpecCanaries = [ + AgentKeysAreExact, + AliceNameIsExact, + AliceGatewayIsExact, + BobGatewayIsExact, + CustomerExitIsRetained, + OuterErrorsAreClusterOnly, + ExternalRequirementsAreExact, + LiveRecordsRetainClusterError, + CompletedRecordsCannotFail, + ProgramFinishedExitIsExact, + ClusterErrorUsesPublicShape, +]; diff --git a/packages/simulator/src/kernel/runtimes.test.ts b/packages/simulator/src/run/acquire.test.ts similarity index 63% rename from packages/simulator/src/kernel/runtimes.test.ts rename to packages/simulator/src/run/acquire.test.ts index ef89bd044..43e1e5bd0 100644 --- a/packages/simulator/src/kernel/runtimes.test.ts +++ b/packages/simulator/src/run/acquire.test.ts @@ -2,18 +2,15 @@ import { assert, effect as test } from "@effect/vitest"; import { serverBaseUrlSchema } from "@moltzap/protocol/network"; import { agentId, redactedAgentKey } from "@moltzap/protocol/testing"; import { Effect, Schema } from "effect"; -import type { linkEvents, runtimeEvents } from "../events/core.js"; -import type { LedgerWriter } from "../ledger/live.js"; +import type { runtimeEvents } from "../events/core.js"; +import type { LedgerWriter } from "../ledger/append.js"; import { makeAgentHandle } from "../network/participant.js"; -import type { NetworkOperation, Router } from "../network/router.js"; -import { - RuntimeCompleted, - RuntimeExited, - defineRuntime, -} from "../runtime/runtime.js"; -import { makeAgentRosterBuilder } from "../runtime/roster.js"; -import { makeLinkFabric, type LinkFabric } from "./link-fabric.js"; -import { acquireRoster } from "./runtimes.js"; +import type { Router } from "../network/router.js"; +import { defineFakeRuntime, makeFakeCluster } from "../cluster/fake.js"; +import { ClusterError } from "../cluster/cluster.js"; +import { RuntimeExited } from "../agents/agent.js"; +import { makeAgentRosterBuilder } from "../agents/roster.js"; +import { acquireRoster } from "./acquire.js"; const routerUrl = Schema.decodeUnknownSync(serverBaseUrlSchema)( "http://127.0.0.1:43100", @@ -29,13 +26,12 @@ const configuration = { value: {}, }; -const DISABLE_LINK_OPERATION: NetworkOperation = "disable-link"; const alphaGateway = Object.freeze({ runtime: "alpha" }); const betaGateway = Object.freeze({ runtime: "beta" }); -const alphaTermination = Effect.succeed(RuntimeCompleted.make({})); -const betaTermination = Effect.succeed(RuntimeExited.make({ code: 0 })); +const alphaTermination = Effect.never; +const betaTermination = Effect.never; -const alphaRuntime = defineRuntime({ +const alphaRuntime = defineFakeRuntime({ name: "alpha", configuration, acquire: () => @@ -44,7 +40,7 @@ const alphaRuntime = defineRuntime({ termination: alphaTermination, }), }); -const betaRuntime = defineRuntime({ +const betaRuntime = defineFakeRuntime({ name: "beta", configuration, acquire: () => @@ -93,37 +89,17 @@ function testWriter(): LedgerWriter { }; } -function testLinkWriter(): LedgerWriter { - return { - write: ({ event }) => - Effect.succeed({ - runId: "runtime-gateway-test", - eventId: "runtime-gateway-link-event", - logicalSequence: 0, - elapsedNanos: 0n, - observedAt: 0, - producer: "kernel.link", - event, - }), - }; -} - -function acquireTestRoster(fabric: LinkFabric) { - return acquireRoster({ - router: testRouter(), - roster, - writer: testWriter(), - interceptor: fabric.interceptor, - }); -} - // @agent-code-guard/regression-only: exact gateway identity and lifecycle capabilities must survive heterogeneous roster acquisition test("installs each runtime gateway beside its router identity", () => Effect.scoped( Effect.gen(function* () { - const agents = yield* acquireTestRoster( - yield* makeLinkFabric(testLinkWriter()), - ); + const session = yield* makeFakeCluster().prepare(roster); + const agents = yield* acquireRoster({ + router: testRouter(), + roster, + session, + writer: testWriter(), + }); assert.strictEqual(agents.alice.agent.id, aliceId); assert.strictEqual(agents.alice.gateway, alphaGateway); @@ -137,16 +113,29 @@ test("installs each runtime gateway beside its router identity", () => }), )); -test("an agent whose runtime never acquires the stage is no policy target", () => +test("rejects an already-terminated runtime before the fake cohort gate", () => Effect.scoped( Effect.gen(function* () { - const fabric = yield* makeLinkFabric(testLinkWriter()); - yield* acquireTestRoster(fabric); - - const failure = yield* fabric.driver - .disable(aliceId, bobId) - .pipe(Effect.flip); + const terminated = defineFakeRuntime({ + name: "terminated-before-cohort", + configuration, + acquire: () => + Effect.succeed({ + gateway: undefined, + termination: Effect.succeed(RuntimeExited.make({ code: 0 })), + }), + }); + const terminatedRoster = makeAgentRosterBuilder( + "acme.runtime-pre-dispatch-loss/v1", + )({ alice: terminated }); + const session = yield* makeFakeCluster().prepare(terminatedRoster); + const failure = yield* acquireRoster({ + router: testRouter(), + roster: terminatedRoster, + session, + writer: testWriter(), + }).pipe(Effect.flip); - assert.strictEqual(failure.operation, DISABLE_LINK_OPERATION); + assert.instanceOf(failure, ClusterError); }), )); diff --git a/packages/simulator/src/kernel/runtimes.ts b/packages/simulator/src/run/acquire.ts similarity index 59% rename from packages/simulator/src/kernel/runtimes.ts rename to packages/simulator/src/run/acquire.ts index dadb73b71..74802bf6b 100644 --- a/packages/simulator/src/kernel/runtimes.ts +++ b/packages/simulator/src/run/acquire.ts @@ -1,33 +1,37 @@ /** @file Mixed-roster acquisition and runtime-termination observation. */ +// safer-arch-ignore no-cross-domain-sibling-import: Roster acquisition supervises agents against the cluster while writing router evidence to the ledger. import type { AgentId, AgentName } from "@moltzap/protocol/identity"; -import { Cause, Effect, Exit, type Scope } from "effect"; +import { Cause, Deferred, Effect, Exit, Ref, type Scope } from "effect"; import { AgentRuntimeReady, AgentRuntimeStartFailed, type runtimeEvents, } from "../events/core.js"; -import type { LedgerFailure, LedgerWriter } from "../ledger/live.js"; -import type { InboundLinkStage } from "../network/link.js"; -import type { AgentConnection, Router } from "../network/router.js"; +import type { LedgerFailure, LedgerWriter } from "../ledger/append.js"; +import type { Router } from "../network/router.js"; +import { type Society, ClusterError } from "../cluster/cluster.js"; import type { AgentRoster, - AgentRosterAcquisitionError, - AgentRosterRequirements, RuntimeGatewayOf, StartedAgent, StartedAgents, -} from "../runtime/roster.js"; +} from "../agents/roster.js"; import { RuntimeFailed, type AgentRuntimeLike, - type RunningAgent, -} from "../runtime/runtime.js"; -import type { InboundLinkInterceptor } from "./link-fabric.js"; + type RuntimeTermination, +} from "../agents/agent.js"; import { nonEmptyCause, runtimeEvent } from "./outcomes.js"; const MAX_PARALLEL_RUNTIME_ACQUISITIONS = 32; type RuntimeEventWriter = LedgerWriter; +type DispatchState = "pending" | "lost" | "open"; + +interface DispatchFence { + readonly state: Ref.Ref; + readonly failure: Deferred.Deferred; +} interface AcquiredAgent { readonly name: Name; @@ -45,8 +49,9 @@ interface AcquireAgentInput< readonly name: Name; readonly agentName: AgentName; readonly runtime: Definitions[Name]; + readonly session: Society; + readonly dispatch: DispatchFence; readonly writer: RuntimeEventWriter; - readonly interceptor: InboundLinkInterceptor; } interface AcquireRosterInput< @@ -55,26 +60,8 @@ interface AcquireRosterInput< > { readonly router: Router; readonly roster: AgentRoster; + readonly session: Society; readonly writer: RuntimeEventWriter; - readonly interceptor: InboundLinkInterceptor; -} - -function runtimeAcquire< - Definitions extends Readonly>, - Name extends Extract, ->( - runtime: Definitions[Name], - agentName: AgentName, - connection: AgentConnection, - interceptInbound: Effect.Effect, -): Effect.Effect< - RunningAgent>, - AgentRosterAcquisitionError, - AgentRosterRequirements | Scope.Scope -> { - // The keyed entry keeps its exact gateway while this supervisor widens its - // failure and service requirements to the complete roster unions. - return runtime.acquire({ agentName, connection, interceptInbound }); } function attemptAgent< @@ -83,7 +70,7 @@ function attemptAgent< >( input: Pick< AcquireAgentInput, - "router" | "name" | "agentName" | "runtime" | "interceptor" + "router" | "name" | "agentName" | "runtime" | "session" >, ) { return Effect.gen(function* () { @@ -91,15 +78,12 @@ function attemptAgent< input.name, input.agentName, ); - // The runtime, not this supervisor, acquires the stage: only a runtime - // that owns its agent's inbound stream can apply one, and acquisition is - // what registers the agent as a link-policy target. - const running = yield* runtimeAcquire( - input.runtime, - input.agentName, + const running = yield* input.session.acquireAgent({ + name: input.name, + runtime: input.runtime, + agentName: input.agentName, connection, - input.interceptor.attach(connection.agent.id), - ); + }); const started = Object.freeze({ agent: connection.agent, gateway: running.gateway, @@ -115,31 +99,59 @@ function attemptAgent< }); } +function claimPreDispatchLoss(dispatch: DispatchFence) { + return Ref.modify(dispatch.state, (state) => + state === "pending" ? ([true, "lost"] as const) : ([false, state] as const), + ); +} + +function recordTermination( + acquired: AcquiredAgent, + termination: RuntimeTermination, + writer: RuntimeEventWriter, + dispatch: DispatchFence, +) { + return Effect.gen(function* () { + const beforeDispatch = yield* claimPreDispatchLoss(dispatch); + const recorded = yield* Effect.exit( + writer.write({ event: runtimeEvent(acquired, termination) }), + ); + if (beforeDispatch) { + if (Exit.isFailure(recorded)) { + yield* Deferred.failCause(dispatch.failure, recorded.cause); + } else { + yield* Deferred.fail( + dispatch.failure, + new ClusterError({ + detail: `${acquired.name} terminated before cohort readiness (${termination._tag})`, + }), + ); + } + } + if (Exit.isFailure(recorded)) { + return yield* Effect.failCause(recorded.cause); + } + }); +} + function monitorRuntime( acquired: AcquiredAgent, writer: RuntimeEventWriter, + dispatch: DispatchFence, ): Effect.Effect { return acquired.started.termination.pipe( Effect.matchCauseEffect({ onFailure: (cause) => Cause.isInterruptedOnly(cause) ? Effect.void - : writer - .write({ - event: runtimeEvent( - acquired, - RuntimeFailed.make({ - detail: nonEmptyCause(cause), - }), - ), - }) - .pipe(Effect.asVoid), + : recordTermination( + acquired, + RuntimeFailed.make({ detail: nonEmptyCause(cause) }), + writer, + dispatch, + ), onSuccess: (termination) => - writer - .write({ - event: runtimeEvent(acquired, termination), - }) - .pipe(Effect.asVoid), + recordTermination(acquired, termination, writer, dispatch), }), Effect.withSpan("Simulator.runtimeTermination", { attributes: { @@ -153,10 +165,11 @@ function monitorRuntime( function startMonitor( acquired: AcquiredAgent, writer: RuntimeEventWriter, + dispatch: DispatchFence, ): Effect.Effect { // Registration follows runtime acquisition so LIFO scope closure interrupts // this observer before runtime teardown. Teardown is not terminal evidence. - return monitorRuntime(acquired, writer).pipe( + return monitorRuntime(acquired, writer, dispatch).pipe( Effect.forkScoped, Effect.asVoid, ); @@ -212,7 +225,7 @@ function acquireAgent< ); } yield* recordReady(attempted.value, input.writer); - yield* startMonitor(attempted.value, input.writer); + yield* startMonitor(attempted.value, input.writer, input.dispatch); return attempted.value; }).pipe( Effect.withSpan("Simulator.acquireAgent", { @@ -244,6 +257,18 @@ function withoutPeerCancellation( return Cause.isEmpty(primary) ? cause : primary; } +function openDispatchFence(dispatch: DispatchFence) { + return Ref.modify(dispatch.state, (state) => + state === "pending" + ? ([true, "open"] as const) + : ([state === "open", state] as const), + ).pipe( + Effect.flatMap((opened) => + opened ? Effect.void : Deferred.await(dispatch.failure), + ), + ); +} + /** * Executes the acquire roster operation. * @param input Input value to process. @@ -254,22 +279,41 @@ export function acquireRoster< Definitions extends Readonly>, >(input: AcquireRosterInput) { type Name = Extract; - return Effect.forEach( - input.roster.validatedDefinitions, - (entry) => - acquireAgent({ - router: input.router, - name: entry.name, - agentName: entry.agentName, - runtime: entry.runtime, - writer: input.writer, - interceptor: input.interceptor, - }), - { concurrency: MAX_PARALLEL_RUNTIME_ACQUISITIONS }, - ).pipe( + return Effect.gen(function* () { + const dispatch: DispatchFence = { + state: yield* Ref.make("pending"), + failure: yield* Deferred.make(), + }; + const acquired = yield* Effect.raceFirst( + Effect.forEach( + input.roster.validatedDefinitions, + (entry) => + acquireAgent({ + router: input.router, + name: entry.name, + agentName: entry.agentName, + runtime: entry.runtime, + session: input.session, + dispatch, + writer: input.writer, + }), + { concurrency: MAX_PARALLEL_RUNTIME_ACQUISITIONS }, + ), + Deferred.await(dispatch.failure), + ); + // Registered observers run once before the fence so an already-terminal + // runtime cannot be dispatched by an immediately ready platform. + yield* Effect.yieldNow(); + yield* Effect.raceFirst( + input.session.cohortReady, + Deferred.await(dispatch.failure), + ); + yield* openDispatchFence(dispatch); + return startedAgents(acquired); + }).pipe( Effect.catchAllCause((cause) => Effect.failCause(withoutPeerCancellation(cause)), ), - Effect.map(startedAgents), + Effect.withSpan("Simulator.acquireRoster"), ); } diff --git a/packages/simulator/src/kernel/endpoints.test.ts b/packages/simulator/src/run/endpoints.test.ts similarity index 98% rename from packages/simulator/src/kernel/endpoints.test.ts rename to packages/simulator/src/run/endpoints.test.ts index e6f35d12f..d590ff4a0 100644 --- a/packages/simulator/src/kernel/endpoints.test.ts +++ b/packages/simulator/src/run/endpoints.test.ts @@ -23,13 +23,13 @@ import { EndpointMessageReceived, EndpointMessageSent, } from "../events/core.js"; -import type { LedgerWriter } from "../ledger/live.js"; +import type { LedgerWriter } from "../ledger/append.js"; import { LedgerStorageError } from "../ledger/storage.js"; import { makeAgentHandle, makeParticipantHandle, makeRouterStopReport, - networkFailure, + networkError, type EndpointTransport, type ReceivedMessage, type Router, @@ -295,10 +295,7 @@ function retryingRouter( Effect.zipRight(Deferred.await(gates.releaseFirst)), Effect.zipRight( Effect.fail( - networkFailure( - "attach-endpoint", - "temporarily unavailable", - ), + networkError("attach-endpoint", "temporarily unavailable"), ), ), ) diff --git a/packages/simulator/src/kernel/endpoints.ts b/packages/simulator/src/run/endpoints.ts similarity index 92% rename from packages/simulator/src/kernel/endpoints.ts rename to packages/simulator/src/run/endpoints.ts index 1478a3dd6..374ee1e2f 100644 --- a/packages/simulator/src/kernel/endpoints.ts +++ b/packages/simulator/src/run/endpoints.ts @@ -24,42 +24,41 @@ import { EndpointMessageReceived, EndpointMessageSent, } from "../events/core.js"; -import type { LedgerWriter } from "../ledger/live.js"; +import type { LedgerWriter } from "../ledger/append.js"; import { type Endpoint, makeEndpoint, type EndpointInbox, type NetworkService, } from "../network/endpoint.js"; -import { - networkFailure, - type AttachedEndpoint, - type EndpointTransport, - type NetworkFailure, - type ParticipantIds, - type ReceivedMessage, - type Router, +import type { + AttachedEndpoint, + EndpointTransport, + ParticipantIds, + ReceivedMessage, + Router, } from "../network/router.js"; +import { networkError, type NetworkError } from "../network/failure.js"; import type { InboundLinkInterceptor } from "./link-fabric.js"; -type DeliveryMailbox = Mailbox.Mailbox; +type DeliveryMailbox = Mailbox.Mailbox; type EndpointEventWriter = LedgerWriter; -type EndpointCache = Cache.Cache>; +type EndpointCache = Cache.Cache>; interface InboxState { readonly conversations: ReadonlyMap; - readonly exit?: Exit.Exit; + readonly exit?: Exit.Exit; } interface InboxRuntime { - readonly all: PubSub.PubSub>; + readonly all: PubSub.PubSub>; readonly state: Ref.Ref; readonly transition: Effect.Semaphore; } function conversationStream( mailbox: DeliveryMailbox, -): Stream.Stream { +): Stream.Stream { return Stream.repeatEffectOption( mailbox.take.pipe( Effect.mapError((error) => @@ -72,14 +71,14 @@ function conversationStream( } function terminalStream( - exit: Exit.Exit, -): Stream.Stream { + exit: Exit.Exit, +): Stream.Stream { return Exit.isSuccess(exit) ? Stream.empty : Stream.failCause(exit.cause); } function endpointMessages( runtime: InboxRuntime, -): Stream.Stream { +): Stream.Stream { return Stream.unwrapScoped( runtime.transition.withPermits(1)( Effect.gen(function* () { @@ -117,7 +116,7 @@ function publish( const key = received.message.conversationId; let conversation = state.conversations.get(key); if (conversation === undefined) { - conversation = yield* Mailbox.make(); + conversation = yield* Mailbox.make(); const conversations = new Map(state.conversations); conversations.set(key, conversation); yield* Ref.set(runtime.state, { @@ -132,7 +131,7 @@ function publish( function finish( runtime: InboxRuntime, - exit: Exit.Exit, + exit: Exit.Exit, ): Effect.Effect { return runtime.transition.withPermits(1)( Effect.gen(function* () { @@ -164,7 +163,7 @@ function conversation(runtime: InboxRuntime): EndpointInbox["conversation"] { if (existing !== undefined) { return conversationStream(existing); } - const mailbox = yield* Mailbox.make(); + const mailbox = yield* Mailbox.make(); if (state.exit !== undefined) { yield* mailbox.done(state.exit); } else { @@ -184,7 +183,7 @@ function runIngress( attachment: AttachedEndpoint, writer: EndpointEventWriter, runtime: InboxRuntime, - received: Stream.Stream, + received: Stream.Stream, ) { return received.pipe( Stream.runForEach((received) => @@ -219,8 +218,7 @@ function makeInbox( ): Effect.Effect { return Effect.gen(function* () { const runtime: InboxRuntime = { - all: - yield* PubSub.unbounded>(), + all: yield* PubSub.unbounded>(), state: yield* Ref.make({ conversations: new Map(), exit: undefined, @@ -352,9 +350,9 @@ function shapeAttachment( function acquireEndpoint( context: EndpointAcquisitionContext, name: string, -): Effect.Effect> { +): Effect.Effect> { const acquire = Schema.decodeUnknown(agentName)(name).pipe( - Effect.mapError((cause) => networkFailure("attach-endpoint", cause)), + Effect.mapError((cause) => networkError("attach-endpoint", cause)), Effect.flatMap((agentName) => context.router.attachEndpoint(name, agentName), ), @@ -385,7 +383,7 @@ function acquireEndpoint( function cachedEndpoint( endpoints: EndpointCache, name: Name, -): Effect.Effect, NetworkFailure> { +): Effect.Effect, NetworkError> { return /* Safe because the surrounding invariant establishes this asserted shape. */ Effect.uninterruptibleMask( (restore) => restore(endpoints.get(name)).pipe( @@ -394,7 +392,7 @@ function cachedEndpoint( ), Effect.flatten, ), - ) as Effect.Effect, NetworkFailure>; + ) as Effect.Effect, NetworkError>; } /** diff --git a/packages/simulator/src/kernel/event-services.test.ts b/packages/simulator/src/run/events.test.ts similarity index 94% rename from packages/simulator/src/kernel/event-services.test.ts rename to packages/simulator/src/run/events.test.ts index 2c13e5fed..44236586f 100644 --- a/packages/simulator/src/kernel/event-services.test.ts +++ b/packages/simulator/src/run/events.test.ts @@ -6,9 +6,9 @@ import { LedgerStorageError, } from "../ledger.js"; import { EventCatalog } from "../events/catalog.js"; -import type { LedgerWriter, RunLedger } from "../ledger/live.js"; -import type { LedgerRecord } from "../ledger/model.js"; -import { makeDefinitionEventServices } from "./event-services.js"; +import type { LedgerWriter, RunLedger } from "../ledger/append.js"; +import type { LedgerRecord } from "../ledger/schema.js"; +import { makeDefinitionEventServices } from "./events.js"; class Observation extends Schema.TaggedClass()( "acme.observation/v1", diff --git a/packages/simulator/src/kernel/event-services.ts b/packages/simulator/src/run/events.ts similarity index 97% rename from packages/simulator/src/kernel/event-services.ts rename to packages/simulator/src/run/events.ts index 19adb1ba9..2197b1d20 100644 --- a/packages/simulator/src/kernel/event-services.ts +++ b/packages/simulator/src/run/events.ts @@ -5,12 +5,16 @@ import { type EventClassOf, type EventOf, } from "../events/catalog.js"; -import type { LedgerFailure, LedgerWriter, RunLedger } from "../ledger/live.js"; +import type { + LedgerFailure, + LedgerWriter, + RunLedger, +} from "../ledger/append.js"; import type { LedgerManifest, LedgerRecord, LedgerRef, -} from "../ledger/model.js"; +} from "../ledger/schema.js"; import { coreEvents } from "../events/core.js"; type CatalogSchema = Schema.Schema.All; diff --git a/packages/simulator/src/kernel/event-services.types-check.ts b/packages/simulator/src/run/events.types-check.ts similarity index 97% rename from packages/simulator/src/kernel/event-services.types-check.ts rename to packages/simulator/src/run/events.types-check.ts index dd5eebfbd..5b3e37cd0 100644 --- a/packages/simulator/src/kernel/event-services.types-check.ts +++ b/packages/simulator/src/run/events.types-check.ts @@ -7,7 +7,7 @@ import { Schema } from "effect"; import { EventCatalog } from "../events/catalog.js"; import { type ProgramSucceeded, RunStarted } from "../events/core.js"; -import { makeDefinitionEventServices } from "./event-services.js"; +import { makeDefinitionEventServices } from "./events.js"; class CustomerObservation extends Schema.TaggedClass()( "acme.customer-observation/v1", diff --git a/packages/simulator/src/kernel/run.test.ts b/packages/simulator/src/run/execute.test.ts similarity index 60% rename from packages/simulator/src/kernel/run.test.ts rename to packages/simulator/src/run/execute.test.ts index 093c758d3..2fb5bc716 100644 --- a/packages/simulator/src/kernel/run.test.ts +++ b/packages/simulator/src/run/execute.test.ts @@ -1,26 +1,15 @@ +/* eslint-disable agent-code-guard/no-example-only-tests -- Regression-only suite: each case pins one ordering, evidence, or cleanup guarantee across a full run lifecycle, so the cases are timelines rather than an input domain and each keeps its setup beside its assertions. */ + import { assert, effect as test } from "@effect/vitest"; -import type { ConversationId } from "@moltzap/protocol/conversation"; -import type { AgentId } from "@moltzap/protocol/identity"; -import { serverBaseUrlSchema } from "@moltzap/protocol/network"; -import { - conversationId, - agentId as protocolAgentId, - messageId, - redactedAgentKey, -} from "@moltzap/protocol/testing"; import { Cause, Chunk, - DateTime, Deferred, Duration, Effect, Exit, Fiber, - Mailbox, Ref, - Schema, - type Scope, Stream, TestClock, } from "effect"; @@ -43,369 +32,89 @@ import { RouterStarted, RunStarted, } from "../events/core.js"; -import { EventCatalog } from "../events/catalog.js"; -import { - LedgerCompletion, - ledgerDigest, - LedgerManifest, - ledgerRef, -} from "../ledger/model.js"; import { LedgerStorage, LedgerStorageError, - type LedgerArtifact, type LedgerStorageService, } from "../ledger/storage.js"; import { LinkController, linkPolicy, Network, - NetworkFailure, + NetworkError, RouterProvider, - type RouterStopped, - makeAgentHandle, - makeParticipantHandle, - makeRouterStopReport, - type AttachedEndpoint, - type EndpointTransport, - type MessageParts, type ReceivedMessage, type Router, - type RouterProviderService, } from "../network.js"; import { CompletedLedgerReceipt, IncompleteLedgerReceipt, ProgramFinished, - RunInfrastructureFailed, -} from "./run.js"; -import { - RuntimeCompleted, - RuntimeExited, - defineRuntime, -} from "../runtime/runtime.js"; -import { simulator } from "../definition.js"; - -class Observation extends Schema.TaggedClass()( - "acme.kernel-observation/v1", - { value: Schema.String }, -) {} - -const customerEvents = EventCatalog.make(Observation); -const society = simulator.define("acme.kernel-test/v1", customerEvents); -const DIGEST = Schema.decodeSync(ledgerDigest)("a".repeat(64)); -const REF = Schema.decodeSync(ledgerRef)("kernel-test-ledger"); -const ROUTER_URL = Schema.decodeSync(serverBaseUrlSchema)( - "http://127.0.0.1:43100", -); -const OBSERVED_EXIT_CODE = 7; -const PRIMARY_AGENT_NAME = "alice"; -const testRuntimeConfiguration = Schema.Struct({ - kind: Schema.String, -}); - -function configuration(kind: string) { - return { - schema: testRuntimeConfiguration, - value: { kind }, - }; -} - -function agentId(suffix: number) { - return protocolAgentId( - `00000000-0000-4000-8000-${String(suffix).padStart(12, "0")}`, - ); -} - -function agentKey(suffix: number) { - return redactedAgentKey( - `moltzap_agent_${String(suffix).padStart(16, "0")}_${String(suffix).padStart(48, "0")}`, - ); -} - -function completion(manifest: LedgerManifest, count: number): LedgerCompletion { - return LedgerCompletion.make({ - ledgerFormatVersion: 1, - runId: manifest.runId, - recordCount: count, - artifacts: { manifest: DIGEST, records: DIGEST }, - }); -} - -function compareText(left: string, right: string): number { - return left.localeCompare(right); -} - -function assertDefaultProvenance(manifest: LedgerManifest): void { - assert.deepStrictEqual(manifest.provenance, { - agents: [ - { - name: "alice", - runtime: "effect", - configuration: { kind: "in-process" }, - }, - { - name: "bob", - runtime: "process", - configuration: { kind: "external-process" }, - }, - ], - }); -} - -function memoryStorage(failOnEventTag?: string): LedgerStorageService { - const files = new Map(); - return { - allocate: (input) => { - const manifest = LedgerManifest.make({ - ledgerFormatVersion: 1, - definitionId: input.definitionId, - runId: "kernel-test-run", - catalogTags: [...input.catalogTags].sort(compareText), - createdAt: DateTime.unsafeMake(0), - provenance: input.provenance, - metadata: input.metadata, - }); - const records: string[] = []; - files.set( - "manifest", - JSON.stringify(Schema.encodeSync(LedgerManifest)(manifest)), - ); - files.set("records", ""); - return Effect.succeed({ - ref: REF, - runId: manifest.runId, - manifest, - append: (record: string) => - failOnEventTag !== undefined && record.includes(failOnEventTag) - ? Effect.fail( - LedgerStorageError.make({ - operation: "append", - detail: `failed ${failOnEventTag}`, - }), - ) - : Effect.sync(() => { - records.push(record); - files.set("records", `${records.join("\n")}\n`); - }), - complete: (count: number) => { - const done = completion(manifest, count); - files.set( - "completion", - JSON.stringify(Schema.encodeSync(LedgerCompletion)(done)), - ); - return Effect.succeed(done); - }, - }); - }, - read: (...[, artifact]) => Effect.succeed(files.get(artifact) ?? ""), - digest: () => Effect.succeed(DIGEST), - }; -} - -function increment(current: number): number { - return current + 1; -} - -function observeCompletions( - storage: LedgerStorageService, - completions: Ref.Ref, -): LedgerStorageService { - return { - ...storage, - allocate: (input) => - storage.allocate(input).pipe( - Effect.map((allocation) => ({ - ...allocation, - complete: (count: number) => - allocation - .complete(count) - .pipe(Effect.zipLeft(Ref.update(completions, increment))), - })), - ), - }; -} - -function hubMessage( - endpointId: AgentId, - currentConversationId: ConversationId, - parts: MessageParts, - sequence: number, -) { - return { - id: messageId( - `00000000-0000-4000-8000-${String(400 + sequence).padStart(12, "0")}`, - ), - conversationId: currentConversationId, - senderId: endpointId, - parts, - createdAt: "2026-07-28T00:00:00.000Z", - }; -} - -interface Counter { - value: number; -} - -// In-memory loopback hub: every endpoint send fans out into every other -// attachment's received stream, so kernel tests observe real deliveries. -interface FakeHubState { - readonly inboxes: Map>; - readonly endpoints: Counter; - readonly messages: Counter; - readonly committedSends?: Ref.Ref; -} - -function hubSend( - hub: FakeHubState, - endpointId: AgentId, -): EndpointTransport["send"] { - return (currentConversationId, parts) => - Effect.gen(function* () { - if (hub.committedSends !== undefined) { - yield* Ref.update(hub.committedSends, increment); - } - hub.messages.value += 1; - const message = hubMessage( - endpointId, - currentConversationId, - parts, - hub.messages.value, - ); - yield* Effect.forEach( - hub.inboxes, - ([id, inbox]) => - id === endpointId ? Effect.void : inbox.offer({ message }), - { concurrency: 1, discard: true }, - ); - return message; - }); -} - -function hubAttachment( - name: Name, - endpointId: AgentId, - mailbox: Mailbox.Mailbox, - send: EndpointTransport["send"], -): AttachedEndpoint { - return { - participant: makeParticipantHandle(name, endpointId), - transport: { - received: Mailbox.toStream(mailbox), - openConversation: () => - Effect.succeed({ - conversationId: conversationId( - "00000000-0000-4000-8000-000000000102", - ), - }), - send, - }, - }; -} + ClusterLost, +} from "./execute.js"; +import { RuntimeCompleted, RuntimeExited } from "../agents/agent.js"; +import { defineFakeRuntime } from "../cluster/fake.js"; -function hubAttach( - hub: FakeHubState, - name: Name, -): Effect.Effect, never, Scope.Scope> { - return Effect.gen(function* () { - hub.endpoints.value += 1; - const endpointId = agentId(100 + hub.endpoints.value); - const mailbox = yield* Mailbox.make(); - hub.inboxes.set(endpointId, mailbox); - yield* Effect.addFinalizer(() => - Effect.sync(() => hub.inboxes.delete(endpointId)), - ); - return hubAttachment(name, endpointId, mailbox, hubSend(hub, endpointId)); - }); -} - -function fakeRouterProvider( - committedSends?: Ref.Ref, -): RouterProviderService { - return { - acquire: Effect.gen(function* () { - const stopped = yield* Deferred.make(); - const hub: FakeHubState = { - inboxes: new Map(), - endpoints: { value: 0 }, - messages: { value: 0 }, - committedSends, - }; - let nextIdentity = 0; - const router: Router = { - address: ROUTER_URL, - stopped: Deferred.await(stopped), - attachAgent: (name) => - Effect.sync(() => { - nextIdentity += 1; - return { - agent: makeAgentHandle(name, agentId(nextIdentity)), - key: agentKey(nextIdentity), - routerUrl: ROUTER_URL, - }; +import { + OBSERVED_EXIT_CODE, + Observation, + PRIMARY_AGENT_NAME, + REF, + ROUTER_URL, + assertDefaultProvenance, + configuration, + fakeRouterProvider, + kernelHarness, + memoryStorage, + observeCompletions, + ongoingRoster, + type testRuntimeConfiguration, +} from "../test-utils/index.js"; + +// eslint-disable-next-line max-lines-per-function, sonarjs/max-lines-per-function -- One mixed-roster lifetime: splitting it would separate the ordering assertions from the timeline they pin. +test("runs mixed runtimes until customer policy completes", () => + // eslint-disable-next-line max-lines-per-function, sonarjs/max-lines-per-function -- the generator is the same ordered lifecycle regression as its enclosing test callback. + Effect.gen(function* () { + const codeTermination = yield* Deferred.make(); + const processTermination = yield* Deferred.make(); + const roster = kernelHarness.agents({ + alice: defineFakeRuntime({ + name: "effect", + configuration: configuration("in-process"), + acquire: () => + Effect.succeed({ + gateway: undefined, + termination: Deferred.await(codeTermination), }), - attachEndpoint: (name) => hubAttach(hub, name), - }; - yield* Effect.addFinalizer(() => - Deferred.succeed(stopped, makeRouterStopReport([])).pipe(Effect.asVoid), + }), + bob: defineFakeRuntime({ + name: "process", + configuration: configuration("external-process"), + acquire: () => + Effect.succeed({ + gateway: undefined, + termination: Deferred.await(processTermination), + }), + }), + }); + const program = Effect.gen(function* () { + const agents = yield* roster.startedAgents; + const ledger = yield* kernelHarness.ledger; + const events = yield* kernelHarness.events; + yield* Network; + yield* Deferred.succeed(codeTermination, RuntimeCompleted.make({})); + yield* Deferred.succeed( + processTermination, + RuntimeExited.make({ code: 0 }), ); - return router; - }), - }; -} - -const codeRuntime = defineRuntime({ - name: "effect", - configuration: configuration("in-process"), - acquire: () => - Effect.succeed({ - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }), -}); - -const processRuntime = defineRuntime({ - name: "process", - configuration: configuration("external-process"), - acquire: () => - Effect.succeed({ - gateway: undefined, - termination: Effect.succeed(RuntimeExited.make({ code: 0 })), - }), -}); - -const roster = society.agents({ - alice: codeRuntime, - bob: processRuntime, -}); - -const ongoingRuntime = defineRuntime({ - name: "ongoing", - configuration: configuration("ongoing"), - acquire: () => - Effect.succeed({ gateway: undefined, termination: Effect.never }), -}); - -const ongoingRoster = society.agents({ - alice: ongoingRuntime, -}); - -// @agent-code-guard/regression-only: controlled scopes and deferred termination expose exact lifecycle evidence and cancellation order -test("runs mixed runtimes until customer policy completes", () => { - const program = Effect.gen(function* () { - const agents = yield* roster.startedAgents; - const ledger = yield* society.ledger; - const events = yield* society.events; - yield* Network; - yield* ledger - .events(AgentRuntimeCompleted) - .pipe(Stream.take(1), Stream.runDrain); - yield* Effect.yieldNow(); - yield* events.emit(Observation.make({ value: "done" })); - return [agents.alice.agent.name, agents.bob.agent.name] as const; - }); - return Effect.gen(function* () { - const result = yield* society.run(roster, program); + yield* ledger + .events(AgentRuntimeCompleted) + .pipe(Stream.take(1), Stream.runDrain); + yield* Effect.yieldNow(); + yield* events.emit(Observation.make({ value: "done" })); + return [agents.alice.agent.name, agents.bob.agent.name] as const; + }); + const result = yield* kernelHarness.run(roster, program); assert.instanceOf(result, ProgramFinished); if (!(result instanceof ProgramFinished)) { return; @@ -416,7 +125,7 @@ test("runs mixed runtimes until customer policy completes", () => { } assert.deepStrictEqual(result.exit.value, ["alice", "bob"]); - const ledger = yield* society.openLedger(result.receipt.ledger); + const ledger = yield* kernelHarness.openLedger(result.receipt.ledger); assertDefaultProvenance(ledger.manifest); assert.lengthOf( yield* Stream.runCollect(ledger.events(AgentRuntimeReady)), @@ -430,12 +139,11 @@ test("runs mixed runtimes until customer policy completes", () => { }).pipe( Effect.provideService(LedgerStorage, memoryStorage()), Effect.provideService(RouterProvider, fakeRouterProvider()), - ); -}); + )); test("scope teardown interrupts an unfinished runtime observation", () => Effect.gen(function* () { - const result = yield* society.run( + const result = yield* kernelHarness.run( ongoingRoster, Effect.succeed("policy-complete"), ); @@ -445,7 +153,7 @@ test("scope teardown interrupts an unfinished runtime observation", () => } assert.deepStrictEqual(result.exit, Exit.succeed("policy-complete")); - const ledger = yield* society.openLedger(result.receipt.ledger); + const ledger = yield* kernelHarness.openLedger(result.receipt.ledger); assert.lengthOf( yield* Stream.runCollect(ledger.events(AgentRuntimeCompleted)), 0, @@ -467,37 +175,19 @@ test("scope teardown interrupts an unfinished runtime observation", () => Effect.provideService(RouterProvider, fakeRouterProvider()), )); -test("captures run description values before the lazy Effect executes", () => +test("records the roster as the manifest's complete run description", () => Effect.gen(function* () { - const provenance = { - suite: "captured-suite", - environment: { region: "west" }, - agents: ["caller-supplied"], - }; - const metadata = { - case: "captured-case", - labels: ["original"], - }; - const run = society.run(ongoingRoster, Effect.succeed("policy-complete"), { - provenance, - metadata, - }); - - provenance.suite = "mutated-suite"; - provenance.environment.region = "east"; - metadata.case = "mutated-case"; - metadata.labels.push("mutated"); - - const result = yield* run; + const result = yield* kernelHarness.run( + ongoingRoster, + Effect.succeed("policy-complete"), + ); assert.instanceOf(result, ProgramFinished); if (!(result instanceof ProgramFinished)) { return; } - const ledger = yield* society.openLedger(result.receipt.ledger); + const ledger = yield* kernelHarness.openLedger(result.receipt.ledger); assert.deepStrictEqual(ledger.manifest.provenance, { - suite: "captured-suite", - environment: { region: "west" }, agents: [ { name: "alice", @@ -506,10 +196,7 @@ test("captures run description values before the lazy Effect executes", () => }, ], }); - assert.deepStrictEqual(ledger.manifest.metadata, { - case: "captured-case", - labels: ["original"], - }); + assert.deepStrictEqual(ledger.manifest.metadata, {}); }).pipe( Effect.provideService(LedgerStorage, memoryStorage()), Effect.provideService(RouterProvider, fakeRouterProvider()), @@ -518,7 +205,7 @@ test("captures run description values before the lazy Effect executes", () => test("records genuine runtime termination while policy remains active", () => Effect.gen(function* () { const termination = yield* Deferred.make(); - const observedRuntime = defineRuntime({ + const observedRuntime = defineFakeRuntime({ name: "observed-process", configuration: configuration("observed-process"), acquire: () => @@ -527,11 +214,11 @@ test("records genuine runtime termination while policy remains active", () => termination: Deferred.await(termination), }), }); - const observedRoster = society.agents({ + const observedRoster = kernelHarness.agents({ alice: observedRuntime, }); const program = Effect.gen(function* () { - const ledger = yield* society.ledger; + const ledger = yield* kernelHarness.ledger; yield* Deferred.succeed( termination, RuntimeExited.make({ code: OBSERVED_EXIT_CODE }), @@ -542,13 +229,13 @@ test("records genuine runtime termination while policy remains active", () => return "observed"; }); - const result = yield* society.run(observedRoster, program); + const result = yield* kernelHarness.run(observedRoster, program); assert.instanceOf(result, ProgramFinished); if (!(result instanceof ProgramFinished)) { return; } assert.deepStrictEqual(result.exit, Exit.succeed("observed")); - const ledger = yield* society.openLedger(result.receipt.ledger); + const ledger = yield* kernelHarness.openLedger(result.receipt.ledger); const exits = yield* Stream.runCollect(ledger.events(AgentProcessExited)); assert.strictEqual(exits.length, 1); assert.strictEqual(Chunk.unsafeGet(exits, 0).code, OBSERVED_EXIT_CODE); @@ -559,33 +246,37 @@ test("records genuine runtime termination while policy remains active", () => test("records a defective termination observer as runtime failure", () => Effect.gen(function* () { - const defectiveRuntime = defineRuntime({ + const triggerDefect = yield* Deferred.make(); + const defectiveRuntime = defineFakeRuntime({ name: "defective-termination-observer", configuration: configuration("defective-observer"), acquire: () => Effect.succeed({ gateway: undefined, - termination: Effect.dieMessage("termination observer defect"), + termination: Deferred.await(triggerDefect).pipe( + Effect.zipRight(Effect.dieMessage("termination observer defect")), + ), }), }); - const defectiveRoster = society.agents({ + const defectiveRoster = kernelHarness.agents({ alice: defectiveRuntime, }); const program = Effect.gen(function* () { - const ledger = yield* society.ledger; + const ledger = yield* kernelHarness.ledger; + yield* Deferred.succeed(triggerDefect, undefined); yield* ledger .events(AgentRuntimeFailed) .pipe(Stream.take(1), Stream.runDrain); return "observed"; }); - const result = yield* society.run(defectiveRoster, program); + const result = yield* kernelHarness.run(defectiveRoster, program); assert.instanceOf(result, ProgramFinished); if (!(result instanceof ProgramFinished)) { return; } assert.deepStrictEqual(result.exit, Exit.succeed("observed")); - const ledger = yield* society.openLedger(result.receipt.ledger); + const ledger = yield* kernelHarness.openLedger(result.receipt.ledger); const failures = yield* Stream.runCollect( ledger.events(AgentRuntimeFailed), ); @@ -611,7 +302,7 @@ test("fails the run ledger without making a committed endpoint send retryable", return "sent"; }); - const outcome = yield* society + const outcome = yield* kernelHarness .run(ongoingRoster, program) .pipe( Effect.provideService( @@ -624,8 +315,8 @@ test("fails the run ledger without making a committed endpoint send retryable", ), ); - assert.instanceOf(outcome, RunInfrastructureFailed); - if (outcome instanceof RunInfrastructureFailed) { + assert.instanceOf(outcome, ClusterLost); + if (outcome instanceof ClusterLost) { assert.isTrue( Array.from(Cause.failures(outcome.cause)).some( (failure) => @@ -640,10 +331,10 @@ test("fails the run ledger without making a committed endpoint send retryable", test("returns an incomplete receipt when the first post-allocation append fails", () => Effect.gen(function* () { - const outcome = yield* society.run(ongoingRoster, Effect.void); + const outcome = yield* kernelHarness.run(ongoingRoster, Effect.void); - assert.instanceOf(outcome, RunInfrastructureFailed); - if (outcome instanceof RunInfrastructureFailed) { + assert.instanceOf(outcome, ClusterLost); + if (outcome instanceof ClusterLost) { assert.instanceOf(outcome.receipt, IncompleteLedgerReceipt); assert.strictEqual(outcome.receipt.ledger, REF); assert.isTrue( @@ -660,7 +351,7 @@ test("returns an incomplete receipt when the first post-allocation append fails" )); test("retains router stop failure when started-event storage fails", () => { - const stopFailure = NetworkFailure.make({ + const stopFailure = NetworkError.make({ operation: "stop-router", detail: "router shutdown failed", }); @@ -671,15 +362,18 @@ test("retains router stop failure when started-event storage fails", () => { attachEndpoint: () => Effect.dieMessage("unused"), }; return Effect.gen(function* () { - const outcome = yield* society.run(society.agents({}), Effect.void); + const outcome = yield* kernelHarness.run( + kernelHarness.agents({}), + Effect.void, + ); - assert.instanceOf(outcome, RunInfrastructureFailed); - if (outcome instanceof RunInfrastructureFailed) { + assert.instanceOf(outcome, ClusterLost); + if (outcome instanceof ClusterLost) { const failures = Array.from(Cause.failures(outcome.cause)); assert.isTrue( failures.some( (failure) => - failure instanceof NetworkFailure && + failure instanceof NetworkError && failure.operation === "stop-router" && failure.detail === stopFailure.detail, ), @@ -703,7 +397,7 @@ test("keeps allocation failure in the Effect error channel", () => ...memoryStorage(), allocate: () => Effect.fail(allocationFailure), }; - const failure = yield* society + const failure = yield* kernelHarness .run(ongoingRoster, Effect.void) .pipe( Effect.provideService(LedgerStorage, storage), @@ -724,7 +418,7 @@ test("completes the ledger before preserving caller interruption", () => const program = Deferred.succeed(programStarted, undefined).pipe( Effect.zipRight(Effect.never), ); - const run = yield* society + const run = yield* kernelHarness .run(ongoingRoster, program) .pipe( Effect.provideService(LedgerStorage, storage), @@ -740,7 +434,7 @@ test("completes the ledger before preserving caller interruption", () => assert.isTrue(Cause.isInterruptedOnly(exit.cause)); } assert.strictEqual(yield* Ref.get(completions), 1); - const ledger = yield* society + const ledger = yield* kernelHarness .openLedger(REF) .pipe(Effect.provideService(LedgerStorage, storage)); assert.strictEqual(ledger.ref, REF); @@ -759,7 +453,7 @@ test("preserves caller interruption composed with cleanup failure", () => ), ).pipe(Effect.zipRight(Effect.never)), ); - const run = yield* society + const run = yield* kernelHarness .run(ongoingRoster, program) .pipe( Effect.provideService(LedgerStorage, storage), @@ -776,7 +470,7 @@ test("preserves caller interruption composed with cleanup failure", () => } assert.isTrue(yield* Ref.get(cleanupRan)); assert.strictEqual(yield* Ref.get(completions), 1); - const ledger = yield* society + const ledger = yield* kernelHarness .openLedger(REF) .pipe(Effect.provideService(LedgerStorage, storage)); assert.strictEqual(ledger.ref, REF); @@ -787,7 +481,7 @@ test("preserves caller interruption during roster acquisition", () => const acquisitionStarted = yield* Deferred.make(); const completions = yield* Ref.make(0); const storage = observeCompletions(memoryStorage(), completions); - const acquiringRuntime = defineRuntime({ + const acquiringRuntime = defineFakeRuntime({ name: "acquiring", configuration: configuration("acquiring"), acquire: () => @@ -795,10 +489,10 @@ test("preserves caller interruption during roster acquisition", () => Effect.zipRight(Effect.never), ), }); - const acquiringRoster = society.agents({ + const acquiringRoster = kernelHarness.agents({ alice: acquiringRuntime, }); - const run = yield* society + const run = yield* kernelHarness .run(acquiringRoster, Effect.void) .pipe( Effect.provideService(LedgerStorage, storage), @@ -814,7 +508,7 @@ test("preserves caller interruption during roster acquisition", () => assert.isTrue(Cause.isInterruptedOnly(exit.cause)); } assert.strictEqual(yield* Ref.get(completions), 1); - const ledger = yield* society + const ledger = yield* kernelHarness .openLedger(REF) .pipe(Effect.provideService(LedgerStorage, storage)); assert.strictEqual(ledger.ref, REF); @@ -834,7 +528,7 @@ test("masks physical allocation through the kernel ownership handoff", () => Effect.tap(() => Deferred.await(releaseAllocation)), ), }; - const run = yield* society + const run = yield* kernelHarness .run(ongoingRoster, Effect.never) .pipe( Effect.provideService(LedgerStorage, storage), @@ -853,7 +547,7 @@ test("masks physical allocation through the kernel ownership handoff", () => assert.isTrue(Cause.isInterruptedOnly(exit.cause)); } assert.strictEqual(yield* Ref.get(completions), 1); - const ledger = yield* society + const ledger = yield* kernelHarness .openLedger(REF) .pipe(Effect.provideService(LedgerStorage, storage)); assert.strictEqual(ledger.ref, REF); @@ -862,10 +556,9 @@ test("masks physical allocation through the kernel ownership handoff", () => test("peer acquisition cancellation is not a startup failure", () => Effect.gen(function* () { const siblingStarted = yield* Deferred.make(); - const primary = defineRuntime< + const primary = defineFakeRuntime< never, string, - never, typeof testRuntimeConfiguration >({ name: "primary-failure", @@ -875,7 +568,7 @@ test("peer acquisition cancellation is not a startup failure", () => Effect.zipRight(Effect.fail("primary failed")), ), }); - const interruptedPeer = defineRuntime({ + const interruptedPeer = defineFakeRuntime({ name: "interrupted-peer", configuration: configuration("interrupted-peer"), acquire: () => @@ -883,19 +576,19 @@ test("peer acquisition cancellation is not a startup failure", () => Effect.zipRight(Effect.never), ), }); - const failingRoster = society.agents({ + const failingRoster = kernelHarness.agents({ [PRIMARY_AGENT_NAME]: primary, bob: interruptedPeer, }); - const result = yield* society.run(failingRoster, Effect.void); - assert.instanceOf(result, RunInfrastructureFailed); - if (result instanceof RunInfrastructureFailed) { + const result = yield* kernelHarness.run(failingRoster, Effect.void); + assert.instanceOf(result, ClusterLost); + if (result instanceof ClusterLost) { assert.instanceOf(result.receipt, CompletedLedgerReceipt); assert.isFalse(Cause.isInterrupted(result.cause)); } - const ledger = yield* society.openLedger(REF); + const ledger = yield* kernelHarness.openLedger(REF); const failures = yield* Stream.runCollect( ledger.events(AgentRuntimeStartFailed), ); @@ -914,7 +607,7 @@ test("releases an acquired peer when parallel roster acquisition fails", () => Effect.gen(function* () { const peerAcquired = yield* Deferred.make(); const peerReleased = yield* Ref.make(false); - const primary = defineRuntime({ + const primary = defineFakeRuntime({ name: "primary-failure", configuration: configuration("primary-failure"), acquire: () => @@ -922,7 +615,7 @@ test("releases an acquired peer when parallel roster acquisition fails", () => Effect.zipRight(Effect.fail("primary failed")), ), }); - const acquiredPeer = defineRuntime({ + const acquiredPeer = defineFakeRuntime({ name: "acquired-peer", configuration: configuration("acquired-peer"), acquire: () => @@ -933,17 +626,17 @@ test("releases an acquired peer when parallel roster acquisition fails", () => () => Ref.set(peerReleased, true), ), }); - const failingRoster = society.agents({ + const failingRoster = kernelHarness.agents({ [PRIMARY_AGENT_NAME]: primary, bob: acquiredPeer, }); - const result = yield* society.run(failingRoster, Effect.void); - assert.instanceOf(result, RunInfrastructureFailed); - if (result instanceof RunInfrastructureFailed) { + const result = yield* kernelHarness.run(failingRoster, Effect.void); + assert.instanceOf(result, ClusterLost); + if (result instanceof ClusterLost) { assert.instanceOf(result.receipt, CompletedLedgerReceipt); } assert.isTrue(yield* Ref.get(peerReleased)); - const ledger = yield* society.openLedger(REF); + const ledger = yield* kernelHarness.openLedger(REF); const failures = yield* Stream.runCollect( ledger.events(AgentRuntimeStartFailed), ); @@ -993,7 +686,7 @@ function openPair() { function disableRoundTripProgram() { return Effect.gen(function* () { const links = yield* LinkController; - const ledger = yield* society.ledger; + const ledger = yield* kernelHarness.ledger; const { sender, receiver, socket } = yield* openPair(); const received = yield* receiver .messages() @@ -1020,8 +713,8 @@ function disableRoundTripProgram() { test("scoped link disable drops deliveries with evidence and restores delivery", () => Effect.gen(function* () { - const result = yield* society.run( - society.agents({}), + const result = yield* kernelHarness.run( + kernelHarness.agents({}), disableRoundTripProgram(), ); assert.instanceOf(result, ProgramFinished); @@ -1030,7 +723,7 @@ test("scoped link disable drops deliveries with evidence and restores delivery", } assert.deepStrictEqual(result.exit, Exit.succeed(["baseline", "after"])); - const ledger = yield* society.openLedger(result.receipt.ledger); + const ledger = yield* kernelHarness.openLedger(result.receipt.ledger); assert.lengthOf(yield* Stream.runCollect(ledger.events(LinkDown)), 1); assert.lengthOf(yield* Stream.runCollect(ledger.events(LinkUp)), 1); const dropped = yield* Stream.runCollect(ledger.events(LinkMessageDropped)); @@ -1043,7 +736,7 @@ test("scoped link disable drops deliveries with evidence and restores delivery", function delayUnderShapeProgram() { return Effect.gen(function* () { const links = yield* LinkController; - const ledger = yield* society.ledger; + const ledger = yield* kernelHarness.ledger; const { sender, receiver, socket } = yield* openPair(); const received = yield* receiver .messages() @@ -1072,8 +765,8 @@ function delayUnderShapeProgram() { test("a shaped delay defers delivery until the ambient clock advances", () => Effect.gen(function* () { - const result = yield* society.run( - society.agents({}), + const result = yield* kernelHarness.run( + kernelHarness.agents({}), delayUnderShapeProgram(), ); assert.instanceOf(result, ProgramFinished); @@ -1082,7 +775,7 @@ test("a shaped delay defers delivery until the ambient clock advances", () => } assert.deepStrictEqual(result.exit, Exit.succeed(["delayed"])); - const ledger = yield* society.openLedger(result.receipt.ledger); + const ledger = yield* kernelHarness.openLedger(result.receipt.ledger); const set = yield* Stream.runCollect(ledger.events(LinkPolicySet)); assert.strictEqual(Chunk.unsafeGet(set, 0).policy, SHAPE_DESCRIPTION); const cleared = yield* Stream.runCollect(ledger.events(LinkPolicyCleared)); @@ -1096,3 +789,5 @@ test("a shaped delay defers delivery until the ambient clock advances", () => Effect.provideService(LedgerStorage, memoryStorage()), Effect.provideService(RouterProvider, fakeRouterProvider()), )); + +/* eslint-enable agent-code-guard/no-example-only-tests -- Restore the project default after the run-lifecycle regressions. */ diff --git a/packages/simulator/src/kernel/run.ts b/packages/simulator/src/run/execute.ts similarity index 82% rename from packages/simulator/src/kernel/run.ts rename to packages/simulator/src/run/execute.ts index 37f033519..10653c1a8 100644 --- a/packages/simulator/src/kernel/run.ts +++ b/packages/simulator/src/run/execute.ts @@ -1,4 +1,5 @@ /** @file Allocation, execution, and ordered finalization of one run. */ +// safer-arch-ignore no-cross-domain-sibling-import: The run kernel is the composition root that wires ledger, network, agents, and cluster into one customer program. import { Cause, Data, Effect, Exit, Layer, Option, Ref, Schema } from "effect"; import { @@ -15,37 +16,39 @@ import { type ActiveRunLedger, type LedgerFailure, type LedgerWriter, -} from "../ledger/live.js"; -import { - LedgerCompletion, - ledgerRef, - type JsonObject, -} from "../ledger/model.js"; +} from "../ledger/append.js"; +import { LedgerCompletion, ledgerRef } from "../ledger/schema.js"; import type { LedgerStorageError } from "../ledger/storage.js"; import { LinkController, - LinkDriver, type LinkControllerService, + LinkDriver, type LinkDriverService, } from "../network/link.js"; import { Network, type NetworkService } from "../network/endpoint.js"; -import type { NetworkFailure, Router } from "../network/router.js"; +import type { Router } from "../network/router.js"; +import type { NetworkError } from "../network/failure.js"; +import { + Cluster, + type Society, + type ClusterError, +} from "../cluster/cluster.js"; import type { AgentRoster, AgentRosterAcquisitionError, StartedAgents, -} from "../runtime/roster.js"; +} from "../agents/roster.js"; import { runtimeConfigurationProjection, type AgentRuntimeLike, -} from "../runtime/runtime.js"; +} from "../agents/agent.js"; import { programEvent } from "./outcomes.js"; import { makeNetworkService } from "./endpoints.js"; -import { makeLinkFabric, type LinkFabric } from "./link-fabric.js"; +import { makeLinkFabric } from "./link-fabric.js"; import { makeLinkController } from "./links.js"; -import { acquireRoster } from "./runtimes.js"; +import { acquireRoster } from "./acquire.js"; import { acquireRouter, recordStoppedRouter } from "./router.js"; -import type { makeDefinitionEventServices } from "./event-services.js"; +import type { makeDefinitionEventServices } from "./events.js"; type CatalogSchema = Schema.Schema.AnyNoContext; @@ -57,12 +60,6 @@ type DefinitionEventServices< typeof makeDefinitionEventServices >; -/** Optional run metadata; platform and runtime policy belong in Layers. */ -export interface SimulatorRunOptions { - readonly provenance?: JsonObject; - readonly metadata?: JsonObject; -} - /** Physical receipt for a ledger whose completion marker is durable. */ export class CompletedLedgerReceipt extends Schema.TaggedClass()( "CompletedLedgerReceipt", @@ -96,10 +93,10 @@ export class ProgramFinished extends Data.TaggedClass("ProgramFinished")<{ readonly receipt: CompletedLedgerReceipt; }> {} -/** Post-allocation infrastructure failure plus all durable evidence retained. */ -export class RunInfrastructureFailed< +/** Post-allocation cluster error plus all durable evidence retained. */ +export class ClusterLost< Definitions extends Readonly>, -> extends Data.TaggedClass("RunInfrastructureFailed")<{ +> extends Data.TaggedClass("ClusterLost")<{ readonly cause: Cause.Cause>; readonly receipt: LedgerReceipt; }> {} @@ -109,12 +106,16 @@ export type SimulatorRunOutcome< A, E, Definitions extends Readonly>, -> = ProgramFinished | RunInfrastructureFailed; +> = ProgramFinished | ClusterLost; /** Represents simulator run failure conditions. */ export type SimulatorRunFailure< Definitions extends Readonly>, -> = AgentRosterAcquisitionError | LedgerFailure | NetworkFailure; +> = + | AgentRosterAcquisitionError + | ClusterError + | LedgerFailure + | NetworkError; interface RunInput< Id extends string, @@ -133,7 +134,6 @@ interface RunInput< >; readonly roster: AgentRoster; readonly program: Effect.Effect; - readonly options: SimulatorRunOptions; } interface ProgramLayerInput< @@ -210,18 +210,26 @@ interface KernelContext< readonly router: Ref.Ref>; } -function composeProvenance< +interface SocietyExecutionInput< Id extends string, + CustomerSchema extends CatalogSchema, + CustomerClasses extends EventClass, Definitions extends Readonly>, ->(roster: AgentRoster, customerProvenance?: JsonObject) { - return { - ...customerProvenance, - agents: Object.entries(roster.definitions).map(([name, runtime]) => ({ - name, - runtime: runtime.name, - configuration: runtimeConfigurationProjection(runtime), - })), - }; + A, + E, + R, +> { + readonly context: KernelContext< + Id, + CustomerSchema, + CustomerClasses, + Definitions, + A, + E, + R + >; + readonly router: Router; + readonly session: Society; } function allocateRunLedger< @@ -235,8 +243,16 @@ function allocateRunLedger< >(input: RunInput) { return makeRunLedger(input.eventServices.catalog, { definitionId: input.definitionId, - provenance: composeProvenance(input.roster, input.options.provenance), - metadata: input.options.metadata ?? {}, + provenance: { + agents: Object.entries(input.roster.definitions).map( + ([name, runtime]) => ({ + name, + runtime: runtime.name, + configuration: runtimeConfigurationProjection(runtime), + }), + ), + }, + metadata: {}, }); } @@ -269,28 +285,7 @@ function makeContext< }); } -interface NetworkServiceWriters { - readonly endpointWriter: LedgerWriter; - readonly linkWriter: LedgerWriter; -} - -function acquireNetworkServices( - writers: NetworkServiceWriters, - router: Router, - fabric: LinkFabric, -) { - return Effect.gen(function* () { - const network = yield* makeNetworkService( - router, - writers.endpointWriter, - fabric.interceptor, - ); - const links = yield* makeLinkController(writers.linkWriter); - return { driver: fabric.driver, network, links }; - }); -} - -function executeProgram< +function executeSociety< Id extends string, CustomerSchema extends CatalogSchema, CustomerClasses extends EventClass, @@ -299,7 +294,7 @@ function executeProgram< E, R, >( - context: KernelContext< + input: SocietyExecutionInput< Id, CustomerSchema, CustomerClasses, @@ -309,26 +304,31 @@ function executeProgram< R >, ) { + const { context, router, session } = input; return Effect.gen(function* () { - yield* context.runWriter.write({ - event: RunStarted.make({ definitionId: context.input.definitionId }), - }); - const router = yield* acquireRouter(context.routerWriter, context.router); - // The fabric precedes the roster so an in-process runtime can register its - // agent as a link-policy target while it acquires its inbound stream. + // The fabric shapes what this process can observe: the customer's own + // controlled endpoints. A roster agent runs in its own container, so its + // agent-to-agent traffic never crosses this stream and the fabric does not + // register it. Link control over a containerized agent therefore fails + // rather than silently passing traffic it claims to police. const fabric = yield* makeLinkFabric(context.linkWriter); const agents = yield* acquireRoster({ router, roster: context.input.roster, + session, writer: context.runtimeWriter, - interceptor: fabric.interceptor, }); - const services = yield* acquireNetworkServices(context, router, fabric); + const network = yield* makeNetworkService( + router, + context.endpointWriter, + fabric.interceptor, + ); + const links = yield* makeLinkController(context.linkWriter); const layer = makeProgramLayer({ eventServices: context.input.eventServices, roster: context.input.roster, active: context.active, - services, + services: { driver: fabric.driver, links, network }, agents, }); const exit = yield* context.input.program.pipe( @@ -341,7 +341,7 @@ function executeProgram< }); } -function recordRouterStop< +function executeProgram< Id extends string, CustomerSchema extends CatalogSchema, CustomerClasses extends EventClass, @@ -360,11 +360,29 @@ function recordRouterStop< R >, ) { - return Ref.get(context.router).pipe( + return Effect.gen(function* () { + yield* context.runWriter.write({ + event: RunStarted.make({ definitionId: context.input.definitionId }), + }); + const router = yield* acquireRouter(context.routerWriter, context.router); + const platform = yield* Cluster; + const session = yield* platform.prepare(context.input.roster); + return yield* Effect.raceFirst( + executeSociety({ context, router, session }), + session.failure, + ); + }); +} + +function recordRouterStop( + routerRef: Ref.Ref>, + writer: LedgerWriter, +) { + return Ref.get(routerRef).pipe( Effect.flatMap( Option.match({ onNone: () => Effect.void, - onSome: (router) => recordStoppedRouter(router, context.routerWriter), + onSome: (router) => recordStoppedRouter(router, writer), }), ), ); @@ -438,7 +456,9 @@ function finalizeRun< execution: Exit.Exit, SimulatorRunFailure>, ) { return Effect.gen(function* () { - const routerStop = yield* Effect.exit(recordRouterStop(context)); + const routerStop = yield* Effect.exit( + recordRouterStop(context.router, context.routerWriter), + ); const completion = yield* Effect.exit(context.active.complete()); const receipt = Exit.isSuccess(completion) ? CompletedLedgerReceipt.make({ @@ -449,7 +469,7 @@ function finalizeRun< ledger: context.active.ledger.ref, }); if (Exit.isFailure(execution)) { - return new RunInfrastructureFailed({ + return new ClusterLost({ cause: appendFailure( appendFailure(execution.cause, routerStop), completion, @@ -458,13 +478,13 @@ function finalizeRun< }); } if (Exit.isFailure(routerStop)) { - return new RunInfrastructureFailed({ + return new ClusterLost({ cause: appendFailure(routerStop.cause, completion), receipt, }); } if (Exit.isFailure(completion)) { - return new RunInfrastructureFailed({ + return new ClusterLost({ cause: completion.cause, receipt, }); @@ -479,10 +499,6 @@ function finalizeRun< }); } -type RestoreInterruptibility = ( - effect: Effect.Effect, -) => Effect.Effect; - function runContext< Id extends string, CustomerSchema extends CatalogSchema, @@ -501,7 +517,9 @@ function runContext< E, R >, - restore: RestoreInterruptibility, + restore: ( + effect: Effect.Effect, + ) => Effect.Effect, ) { return restore( Effect.raceFirst( @@ -543,20 +561,6 @@ function executeRun< ).pipe(Effect.withSpan("Simulator.run")); } -type RunRequirements< - Id extends string, - CustomerSchema extends CatalogSchema, - CustomerClasses extends EventClass, - Definitions extends Readonly>, - A, - E, - R, -> = Effect.Effect.Context< - ReturnType< - typeof executeRun - > ->; - /** * Execute one definition against one mixed roster. Nested scopes stop * endpoints, runtimes, and the router before publishing ledger completion. @@ -576,7 +580,19 @@ export function runSociety< ): Effect.Effect< SimulatorRunOutcome, LedgerStorageError, - RunRequirements + Effect.Effect.Context< + ReturnType< + typeof executeRun< + Id, + CustomerSchema, + CustomerClasses, + Definitions, + A, + E, + R + > + > + > > { return executeRun(input); } diff --git a/packages/simulator/src/kernel/link-fabric.test.ts b/packages/simulator/src/run/link-fabric.test.ts similarity index 99% rename from packages/simulator/src/kernel/link-fabric.test.ts rename to packages/simulator/src/run/link-fabric.test.ts index 512908594..e843db79a 100644 --- a/packages/simulator/src/kernel/link-fabric.test.ts +++ b/packages/simulator/src/run/link-fabric.test.ts @@ -10,7 +10,7 @@ import { LinkMessageDropped, LinkMessageHeld, } from "../events/core.js"; -import type { LedgerWriter } from "../ledger/live.js"; +import type { LedgerWriter } from "../ledger/append.js"; import { linkPolicy, type InboundLinkStage, diff --git a/packages/simulator/src/kernel/link-fabric.ts b/packages/simulator/src/run/link-fabric.ts similarity index 98% rename from packages/simulator/src/kernel/link-fabric.ts rename to packages/simulator/src/run/link-fabric.ts index f10bc2364..5407944e6 100644 --- a/packages/simulator/src/kernel/link-fabric.ts +++ b/packages/simulator/src/run/link-fabric.ts @@ -19,7 +19,7 @@ import { LinkMessageDropped, LinkMessageHeld, } from "../events/core.js"; -import type { LedgerWriter } from "../ledger/live.js"; +import type { LedgerWriter } from "../ledger/append.js"; import { linkPolicy, linkVerdict, @@ -31,10 +31,10 @@ import { type LinkVerdict, } from "../network/link.js"; import { - networkFailure, - type NetworkFailure, + networkError, + type NetworkError, type NetworkOperation, -} from "../network/router.js"; +} from "../network/failure.js"; type LinkEventWriter = LedgerWriter; @@ -332,12 +332,12 @@ function requireReceiver( state: FabricState, operation: NetworkOperation, to: AgentId, -): Effect.Effect { +): Effect.Effect { return Ref.get(state.receivers).pipe( Effect.filterOrFail( (receivers) => receivers.has(to), () => - networkFailure( + networkError( operation, `agent ${to} is not an attached in-process receiver`, ), @@ -362,7 +362,7 @@ function disable(state: FabricState): LinkDriverService["disable"] { yield* requireReceiver(state, "disable-link", to); const key = keyOf(from, to); if ((yield* Ref.get(state.disables)).has(key)) { - return yield* networkFailure( + return yield* networkError( "disable-link", `link ${key} is already disabled`, ); @@ -387,7 +387,7 @@ function enable(state: FabricState): LinkDriverService["enable"] { const key = keyOf(from, to); const lease = (yield* Ref.get(state.disables)).get(key); if (lease === undefined) { - return yield* networkFailure( + return yield* networkError( "enable-link", `link ${key} is not disabled`, ); diff --git a/packages/simulator/src/kernel/links.test.ts b/packages/simulator/src/run/links.test.ts similarity index 97% rename from packages/simulator/src/kernel/links.test.ts rename to packages/simulator/src/run/links.test.ts index fdc4227df..56969b986 100644 --- a/packages/simulator/src/kernel/links.test.ts +++ b/packages/simulator/src/run/links.test.ts @@ -19,14 +19,14 @@ import { LinkPolicySet, LinkUp, } from "../events/core.js"; -import type { LedgerWriter } from "../ledger/live.js"; +import type { LedgerWriter } from "../ledger/append.js"; import { LedgerStorageError } from "../ledger/storage.js"; import { LinkDriver, linkPolicy, makeParticipantHandle, - NetworkFailure, - networkFailure, + NetworkError, + networkError, type LinkDriverService, type LinkPolicy, type NetworkOperation, @@ -92,7 +92,7 @@ interface PolicyApplication { function policyDriver( applications: PolicyApplication[], cleared: string[], - clearFailure?: NetworkFailure, + clearFailure?: NetworkError, ): LinkDriverService { return { disable: () => Effect.dieMessage("disable is not under test"), @@ -128,7 +128,7 @@ function unavailableRollbackDriver(): LinkDriverService { return { disable: () => Effect.void, enable: () => - Effect.fail(networkFailure(ENABLE_LINK_OPERATION, ROLLBACK_UNAVAILABLE)), + Effect.fail(networkError(ENABLE_LINK_OPERATION, ROLLBACK_UNAVAILABLE)), apply: unusedApply, }; } @@ -301,7 +301,7 @@ test("returns only the real rollback failure after ledger evidence fails", () => if (Exit.isFailure(exit)) { const failures = Array.from(Cause.failures(exit.cause)); assert.lengthOf(failures, 1); - assert.instanceOf(failures[0], NetworkFailure); + assert.instanceOf(failures[0], NetworkError); assert.strictEqual(failures[0]?.operation, ENABLE_LINK_OPERATION); assert.strictEqual(failures[0]?.detail, ROLLBACK_UNAVAILABLE); } @@ -390,7 +390,7 @@ test("surfaces a platform enable failure from scoped cleanup", () => const enableFails: LinkDriverService = { disable: () => Effect.void, enable: () => - Effect.fail(networkFailure("enable-link", "router unavailable")), + Effect.fail(networkError("enable-link", "router unavailable")), apply: unusedApply, }; @@ -406,7 +406,7 @@ test("surfaces a platform enable failure from scoped cleanup", () => if (Exit.isFailure(exit)) { const defects = Array.from(Cause.defects(exit.cause)); assert.lengthOf(defects, 1); - assert.instanceOf(defects[0], NetworkFailure); + assert.instanceOf(defects[0], NetworkError); } assert.lengthOf(events, 1); assert.instanceOf(events[0], LinkDown); @@ -435,7 +435,7 @@ test("does not publish link-down evidence when the driver rejects it", () => const unavailable: LinkDriverService = { disable: () => Effect.fail( - networkFailure(DISABLE_LINK_OPERATION, "unsupported topology"), + networkError(DISABLE_LINK_OPERATION, "unsupported topology"), ), enable: () => Effect.void, apply: unusedApply, @@ -569,7 +569,7 @@ test("returns only the real rollback failure after policy evidence fails", () => policyDriver( applications, [], - networkFailure(SHAPE_LINK_OPERATION, ROLLBACK_UNAVAILABLE), + networkError(SHAPE_LINK_OPERATION, ROLLBACK_UNAVAILABLE), ), ), Effect.exit, @@ -579,7 +579,7 @@ test("returns only the real rollback failure after policy evidence fails", () => if (Exit.isFailure(exit)) { const failures = Array.from(Cause.failures(exit.cause)); assert.lengthOf(failures, 1); - assert.instanceOf(failures[0], NetworkFailure); + assert.instanceOf(failures[0], NetworkError); assert.strictEqual(failures[0]?.operation, SHAPE_LINK_OPERATION); assert.strictEqual(failures[0]?.detail, ROLLBACK_UNAVAILABLE); } diff --git a/packages/simulator/src/kernel/links.ts b/packages/simulator/src/run/links.ts similarity index 97% rename from packages/simulator/src/kernel/links.ts rename to packages/simulator/src/run/links.ts index 5c6276c93..a448a5676 100644 --- a/packages/simulator/src/kernel/links.ts +++ b/packages/simulator/src/run/links.ts @@ -8,7 +8,7 @@ import { LinkPolicySet, LinkUp, } from "../events/core.js"; -import type { LedgerWriter } from "../ledger/live.js"; +import type { LedgerWriter } from "../ledger/append.js"; import { LinkDriver, linkPolicy, @@ -18,7 +18,7 @@ import { type LinkPolicyLease, } from "../network/link.js"; import type { ParticipantHandle } from "../network/participant.js"; -import { networkFailure, type NetworkFailure } from "../network/router.js"; +import { networkError, type NetworkError } from "../network/failure.js"; interface DirectedLink { readonly key: string; @@ -120,7 +120,7 @@ function rollbackLedgerFailure(driver: LinkDriverService, link: DirectedLink) { function releaseLease( runtime: LinkControllerRuntime, link: ActiveLink, -): Effect.Effect { +): Effect.Effect { return runtime.transition.withPermits(1)( Effect.gen(function* () { const leases = yield* Ref.get(link.leases); @@ -263,7 +263,7 @@ function releasePolicy( link: DirectedLink, lease: LinkPolicyLease, description: string, -): Effect.Effect { +): Effect.Effect { return lease.clear.pipe( Effect.zipRight( recordPolicyCleared(runtime, link, description).pipe( @@ -314,13 +314,13 @@ function shape(runtime: LinkControllerRuntime): LinkControllerService["shape"] { return (from, to, policy, description) => Effect.gen(function* () { if (from.id === to.id) { - return yield* networkFailure( + return yield* networkError( "shape-link", "a directed link requires two different participants", ); } if (description.length === 0) { - return yield* networkFailure( + return yield* networkError( "shape-link", "a link policy requires a nonempty description", ); @@ -357,7 +357,7 @@ function disable( return (from, to) => Effect.gen(function* () { if (from.id === to.id) { - return yield* networkFailure( + return yield* networkError( "disable-link", "a directed link requires two different participants", ); diff --git a/packages/simulator/src/kernel/outcomes.ts b/packages/simulator/src/run/outcomes.ts similarity index 92% rename from packages/simulator/src/kernel/outcomes.ts rename to packages/simulator/src/run/outcomes.ts index 6c7397474..732667ae8 100644 --- a/packages/simulator/src/kernel/outcomes.ts +++ b/packages/simulator/src/run/outcomes.ts @@ -1,4 +1,5 @@ /** @file Conversion of Effect/runtime outcomes into exact ledger events. */ +// safer-arch-ignore no-cross-domain-sibling-import: Converts Effect and runtime outcomes into ledger events, so it names both domains. import type { AgentId, AgentName } from "@moltzap/protocol/identity"; import { Cause, Exit } from "effect"; @@ -11,7 +12,7 @@ import { ProgramInterrupted, ProgramSucceeded, } from "../events/core.js"; -import type { RuntimeTermination } from "../runtime/runtime.js"; +import type { RuntimeTermination } from "../agents/agent.js"; /** Describes runtime evidence input. */ export interface RuntimeEvidenceInput { diff --git a/packages/simulator/src/kernel/router.test.ts b/packages/simulator/src/run/router.test.ts similarity index 94% rename from packages/simulator/src/kernel/router.test.ts rename to packages/simulator/src/run/router.test.ts index a54253aa1..094fd7dc8 100644 --- a/packages/simulator/src/kernel/router.test.ts +++ b/packages/simulator/src/run/router.test.ts @@ -4,14 +4,14 @@ import { assert, effect as test } from "@effect/vitest"; import { serverBaseUrlSchema } from "@moltzap/protocol/network"; import { Cause, Effect, Exit, Fiber, Option, Ref, Schema } from "effect"; import type { routerEvents } from "../events/core.js"; -import type { LedgerWriter } from "../ledger/live.js"; +import type { LedgerWriter } from "../ledger/append.js"; import { LedgerStorageError } from "../ledger/storage.js"; import { makeRouterStopReport, type Router, RouterProvider, - networkFailure, } from "../network/router.js"; +import { networkError } from "../network/failure.js"; import { acquireRouter } from "./router.js"; type RouterEventWriter = LedgerWriter; @@ -62,7 +62,7 @@ test("keeps router and evidence-write failures in causal order", () => Effect.scoped(acquireRouter(writer, routerRef)).pipe( Effect.provideService(RouterProvider, { acquire: Effect.fail( - networkFailure("acquire-router", "router unavailable"), + networkError("acquire-router", "router unavailable"), ), }), ), diff --git a/packages/simulator/src/kernel/router.ts b/packages/simulator/src/run/router.ts similarity index 91% rename from packages/simulator/src/kernel/router.ts rename to packages/simulator/src/run/router.ts index 4d7df82cb..a5c46726f 100644 --- a/packages/simulator/src/kernel/router.ts +++ b/packages/simulator/src/run/router.ts @@ -8,12 +8,9 @@ import { RouterStarted, RouterStopFailed, } from "../events/core.js"; -import type { LedgerFailure, LedgerWriter } from "../ledger/live.js"; -import { - RouterProvider, - type NetworkFailure, - type Router, -} from "../network/router.js"; +import type { LedgerFailure, LedgerWriter } from "../ledger/append.js"; +import { RouterProvider, type Router } from "../network/router.js"; +import type { NetworkError } from "../network/failure.js"; import { nonEmptyCause } from "./outcomes.js"; /** @@ -27,7 +24,7 @@ export function acquireRouter( routerRef: Ref.Ref>, ): Effect.Effect< Router, - NetworkFailure | LedgerFailure, + NetworkError | LedgerFailure, RouterProvider | Scope.Scope > { return Effect.uninterruptibleMask((restore) => @@ -93,7 +90,7 @@ function recordCommits( export function recordStoppedRouter( router: Router, writer: LedgerWriter, -): Effect.Effect { +): Effect.Effect { return Effect.exit(recordCommits(router, writer)).pipe( Effect.flatMap((stopped) => { if (Exit.isSuccess(stopped)) { diff --git a/packages/simulator/src/run/run-spec.test.ts b/packages/simulator/src/run/run-spec.test.ts new file mode 100644 index 000000000..c6f5b2023 --- /dev/null +++ b/packages/simulator/src/run/run-spec.test.ts @@ -0,0 +1,725 @@ +/* eslint-disable max-lines-per-function, max-statements, sonarjs/max-lines-per-function -- lifecycle regressions keep their ordered gates, invocation count, evidence, and cleanup assertions together. */ + +import { assert, effect as test } from "@effect/vitest"; +import { serverBaseUrlSchema } from "@moltzap/protocol/network"; +import { + agentId as protocolAgentId, + conversationId, + messageId, + redactedAgentKey, +} from "@moltzap/protocol/testing"; +import { + Cause, + DateTime, + Deferred, + Effect, + Exit, + Fiber, + Layer, + Ref, + Schema, + Stream, +} from "effect"; +import { Run, RunSpec } from "../definition.js"; +import { EventCatalog } from "../events/catalog.js"; +import { + AgentProcessExited, + AgentRuntimeReady, + coreEvents, + EndpointMessageSent, +} from "../events/core.js"; +import { + LedgerCompletion, + ledgerDigest, + LedgerManifest, + ledgerRef, +} from "../ledger/schema.js"; +import { openLedger } from "../ledger/read.js"; +import { + LedgerStorage, + LedgerStorageError, + type LedgerArtifact, + type LedgerStorageService, +} from "../ledger/storage.js"; +import { + makeAgentHandle, + makeParticipantHandle, + makeRouterStopReport, + RouterProvider, + type AttachedEndpoint, + type Router, + type RouterProviderService, + type RouterStopped, +} from "../network.js"; +import { + Cluster, + type ClusterService, + ClusterError, +} from "../cluster/cluster.js"; +import { defineFakeRuntime, makeFakeCluster } from "../cluster/fake.js"; +import { RuntimeExited } from "../agents/agent.js"; +import { + CompletedLedgerReceipt, + ProgramFinished, + ClusterLost, +} from "./execute.js"; + +class Observation extends Schema.TaggedClass()( + "acme.run-spec-observation/v1", + { value: Schema.String }, +) {} + +const customerEvents = EventCatalog.make(Observation); +const DIGEST = Schema.decodeSync(ledgerDigest)("a".repeat(64)); +const REF = Schema.decodeSync(ledgerRef)("run-spec-test-ledger"); +const ROUTER_URL = Schema.decodeSync(serverBaseUrlSchema)( + "http://127.0.0.1:43100", +); +const OBSERVED_EXIT_CODE = 7; +const runtimeConfiguration = Schema.Struct({ kind: Schema.String }); + +function configuration(kind: string) { + return { schema: runtimeConfiguration, value: { kind } }; +} + +function agentId(suffix: number) { + return protocolAgentId( + `00000000-0000-4000-8000-${String(suffix).padStart(12, "0")}`, + ); +} + +function agentKey(suffix: number) { + return redactedAgentKey( + `moltzap_agent_${String(suffix).padStart(16, "0")}_${String(suffix).padStart(48, "0")}`, + ); +} + +function compareText(left: string, right: string): number { + return left.localeCompare(right); +} + +function ledgerCompletion( + manifest: LedgerManifest, + count: number, +): LedgerCompletion { + return LedgerCompletion.make({ + ledgerFormatVersion: 1, + runId: manifest.runId, + recordCount: count, + artifacts: { manifest: DIGEST, records: DIGEST }, + }); +} + +function memoryStorage(failOnEventTag?: string): LedgerStorageService { + const files = new Map(); + return { + allocate: (input) => { + const manifest = LedgerManifest.make({ + ledgerFormatVersion: 1, + definitionId: input.definitionId, + runId: "run-spec-test-run", + catalogTags: [...input.catalogTags].sort(compareText), + createdAt: DateTime.unsafeMake(0), + provenance: input.provenance, + metadata: input.metadata, + }); + const records: string[] = []; + files.set( + "manifest", + JSON.stringify(Schema.encodeSync(LedgerManifest)(manifest)), + ); + files.set("records", ""); + return Effect.succeed({ + ref: REF, + runId: manifest.runId, + manifest, + append: (record: string) => + failOnEventTag !== undefined && record.includes(failOnEventTag) + ? Effect.fail( + LedgerStorageError.make({ + operation: "append", + detail: `failed ${failOnEventTag}`, + }), + ) + : Effect.sync(() => { + records.push(record); + files.set("records", `${records.join("\n")}\n`); + }), + complete: (count: number) => + Effect.sync(() => { + const completion = ledgerCompletion(manifest, count); + files.set( + "completion", + JSON.stringify(Schema.encodeSync(LedgerCompletion)(completion)), + ); + return completion; + }), + }); + }, + read: (...[, artifact]) => Effect.succeed(files.get(artifact) ?? ""), + digest: () => Effect.succeed(DIGEST), + }; +} + +function attachFakeEndpoint( + name: Name, + committedSends?: Ref.Ref, +): Effect.Effect> { + const endpointId = agentId(100); + return Effect.succeed({ + participant: makeParticipantHandle(name, endpointId), + transport: { + received: Stream.never, + openConversation: () => + Effect.succeed({ + conversationId: conversationId( + "00000000-0000-4000-8000-000000000102", + ), + }), + send: (currentConversationId, parts) => + (committedSends === undefined + ? Effect.void + : Ref.update(committedSends, (count) => count + 1) + ).pipe( + Effect.as({ + id: messageId("00000000-0000-4000-8000-000000000103"), + conversationId: currentConversationId, + senderId: endpointId, + parts, + createdAt: "2026-07-28T00:00:00.000Z", + }), + ), + }, + }); +} + +function fakeRouterProvider( + committedSends?: Ref.Ref, +): RouterProviderService { + return { + acquire: Effect.gen(function* () { + const stopped = yield* Deferred.make(); + let nextIdentity = 0; + const router: Router = { + address: ROUTER_URL, + stopped: Deferred.await(stopped), + attachAgent: (name) => + Effect.sync(() => { + nextIdentity += 1; + return { + agent: makeAgentHandle(name, agentId(nextIdentity)), + key: agentKey(nextIdentity), + routerUrl: ROUTER_URL, + }; + }), + attachEndpoint: (name) => attachFakeEndpoint(name, committedSends), + }; + yield* Effect.addFinalizer(() => + Deferred.succeed(stopped, makeRouterStopReport([])).pipe(Effect.asVoid), + ); + return router; + }), + }; +} + +function fakeCluster( + storage?: LedgerStorageService, + router?: RouterProviderService, +) { + return Layer.merge( + Layer.succeed(LedgerStorage, storage ?? memoryStorage()), + Layer.succeed(RouterProvider, router ?? fakeRouterProvider()), + ); +} + +function fakeClusterLayer( + cluster: ClusterService, + storage?: LedgerStorageService, + router?: RouterProviderService, +) { + const resolvedStorage = storage ?? memoryStorage(); + const resolvedRouter = router ?? fakeRouterProvider(); + return Layer.merge( + fakeCluster(resolvedStorage, resolvedRouter), + Layer.succeed(Cluster, cluster), + ); +} + +interface GatedRuntimeInput { + readonly name: string; + readonly waiting: Deferred.Deferred; + readonly allowed: Deferred.Deferred; + readonly releases: Ref.Ref; + readonly gateway: Gateway; +} + +function makeGatedRuntime(input: GatedRuntimeInput) { + return defineFakeRuntime({ + name: input.name, + configuration: configuration(input.name), + acquire: () => + Effect.acquireRelease( + Deferred.succeed(input.waiting, undefined).pipe( + Effect.zipRight(Deferred.await(input.allowed)), + Effect.as({ gateway: input.gateway, termination: Effect.never }), + ), + () => Ref.update(input.releases, (count) => count + 1), + ), + }); +} + +function assertCohortLedger(storage: LedgerStorageService) { + return Effect.gen(function* () { + const ledger = yield* openLedger( + EventCatalog.merge(coreEvents, customerEvents), + REF, + "acme.run-spec-cohort/v1", + ).pipe(Effect.provideService(LedgerStorage, storage)); + const records = Array.from(yield* Stream.runCollect(ledger.records)); + const tags = records.map((record) => record.event._tag); + assert.lengthOf( + records.filter((record) => record.event._tag === AgentRuntimeReady._tag), + 2, + ); + assert.isAbove( + tags.indexOf(Observation._tag), + tags.lastIndexOf(AgentRuntimeReady._tag), + ); + }); +} + +function cohortGateCase() { + return Effect.scoped( + Effect.gen(function* () { + const aliceWaiting = yield* Deferred.make(); + const bobWaiting = yield* Deferred.make(); + const cohortWaiting = yield* Deferred.make(); + const allowAlice = yield* Deferred.make(); + const allowBob = yield* Deferred.make(); + const allowCohort = yield* Deferred.make(); + const executions = yield* Ref.make(0); + const releases = yield* Ref.make(0); + const clusterReleased = yield* Ref.make(false); + const acquiredNames = yield* Ref.make([]); + const storage = memoryStorage(); + const alice = makeGatedRuntime({ + name: "run-spec-alice", + waiting: aliceWaiting, + allowed: allowAlice, + releases, + gateway: Object.freeze({ runtime: "alice" as const }), + }); + const bob = makeGatedRuntime({ + name: "run-spec-bob", + waiting: bobWaiting, + allowed: allowBob, + releases, + gateway: Object.freeze({ runtime: "bob" as const }), + }); + const cluster = makeFakeCluster({ + cohortReady: Deferred.succeed(cohortWaiting, undefined).pipe( + Effect.zipRight(Deferred.await(allowCohort)), + ), + failure: Effect.never, + onAcquire: (name) => + Ref.update(acquiredNames, (names) => [...names, name]), + onRelease: Ref.set(clusterReleased, true), + }); + const spec = RunSpec.define({ + id: "acme.run-spec-cohort/v1", + events: [customerEvents], + agents: { alice, bob }, + cluster: fakeClusterLayer(cluster, storage), + execute: ({ agents, events }) => + Ref.update(executions, (count) => count + 1).pipe( + Effect.zipRight( + events.emit(Observation.make({ value: "cohort-ready" })), + ), + Effect.as([ + agents.alice.gateway.runtime, + agents.bob.gateway.runtime, + ] as const), + ), + }); + const fiber = yield* Run.execute(spec).pipe(Effect.fork); + yield* Deferred.await(aliceWaiting); + yield* Deferred.await(bobWaiting); + yield* Deferred.succeed(allowAlice, undefined); + assert.strictEqual(yield* Ref.get(executions), 0); + yield* Deferred.succeed(allowBob, undefined); + yield* Deferred.await(cohortWaiting); + assert.deepStrictEqual( + [...(yield* Ref.get(acquiredNames))].sort(compareText), + ["alice", "bob"], + ); + assert.strictEqual(yield* Ref.get(executions), 0); + yield* Deferred.succeed(allowCohort, undefined); + const result = yield* Fiber.join(fiber); + assert.instanceOf(result, ProgramFinished); + assert.strictEqual(yield* Ref.get(executions), 1); + assert.strictEqual(yield* Ref.get(releases), 2); + assert.isTrue(yield* Ref.get(clusterReleased)); + yield* assertCohortLedger(storage); + }), + ); +} + +// @agent-code-guard/regression-only: deterministic cluster gates prove exact dispatch, failure, evidence, and cleanup ordering +test( + "Run.execute waits for the complete cluster cohort and cleans up", + cohortGateCase, +); + +test("Run.execute never dispatches an incomplete roster", () => + Effect.gen(function* () { + const peerAcquired = yield* Deferred.make(); + const peerReleased = yield* Ref.make(false); + const clusterReleased = yield* Ref.make(false); + const executions = yield* Ref.make(0); + const primary = defineFakeRuntime< + never, + string, + typeof runtimeConfiguration + >({ + name: "run-spec-primary-failure", + configuration: configuration("run-spec-primary-failure"), + acquire: () => + Deferred.await(peerAcquired).pipe( + Effect.zipRight(Effect.fail("primary failed")), + ), + }); + const peer = defineFakeRuntime({ + name: "run-spec-acquired-peer", + configuration: configuration("run-spec-acquired-peer"), + acquire: () => + Effect.acquireRelease( + Deferred.succeed(peerAcquired, undefined).pipe( + Effect.as({ gateway: undefined, termination: Effect.never }), + ), + () => Ref.set(peerReleased, true), + ), + }); + const cluster = makeFakeCluster({ + cohortReady: Effect.void, + failure: Effect.never, + onRelease: Ref.set(clusterReleased, true), + }); + const spec = RunSpec.define({ + id: "acme.run-spec-acquisition-failure/v1", + events: [], + agents: { primary, peer }, + cluster: fakeClusterLayer(cluster), + execute: () => + Ref.update(executions, (count) => count + 1).pipe( + Effect.as("dispatched"), + ), + }); + const result = yield* Run.execute(spec); + assert.instanceOf(result, ClusterLost); + assert.strictEqual(yield* Ref.get(executions), 0); + assert.isTrue(yield* Ref.get(peerReleased)); + assert.isTrue(yield* Ref.get(clusterReleased)); + })); + +test("Run.execute cancels a peer acquisition when a ready runtime terminates", () => + Effect.gen(function* () { + const observerStarted = yield* Deferred.make(); + const peerWaiting = yield* Deferred.make(); + const termination = yield* Deferred.make(); + const executions = yield* Ref.make(0); + const cohortChecks = yield* Ref.make(0); + const readyReleased = yield* Ref.make(false); + const peerReleased = yield* Ref.make(false); + const clusterReleased = yield* Ref.make(false); + const storage = memoryStorage(); + const ready = defineFakeRuntime({ + name: "run-spec-ready-before-peer", + configuration: configuration("run-spec-ready-before-peer"), + acquire: () => + Effect.acquireRelease( + Effect.succeed({ + gateway: undefined, + termination: Deferred.succeed(observerStarted, undefined).pipe( + Effect.zipRight(Deferred.await(termination)), + ), + }), + () => Ref.set(readyReleased, true), + ), + }); + const peer = defineFakeRuntime({ + name: "run-spec-blocked-peer", + configuration: configuration("run-spec-blocked-peer"), + acquire: () => + Effect.acquireRelease(Effect.void, () => + Ref.set(peerReleased, true), + ).pipe( + Effect.zipRight(Deferred.succeed(peerWaiting, undefined)), + Effect.zipRight(Effect.never), + ), + }); + const cluster = makeFakeCluster({ + cohortReady: Ref.update(cohortChecks, (count) => count + 1), + failure: Effect.never, + onRelease: Ref.set(clusterReleased, true), + }); + const spec = RunSpec.define({ + id: "acme.run-spec-loss-during-acquisition/v1", + events: [], + agents: { ready, peer }, + cluster: fakeClusterLayer(cluster, storage), + execute: () => + Ref.update(executions, (count) => count + 1).pipe( + Effect.as("dispatched"), + ), + }); + const fiber = yield* Run.execute(spec).pipe(Effect.fork); + yield* Deferred.await(observerStarted); + yield* Deferred.await(peerWaiting); + yield* Deferred.succeed( + termination, + RuntimeExited.make({ code: OBSERVED_EXIT_CODE }), + ); + const result = yield* Fiber.join(fiber); + + assert.instanceOf(result, ClusterLost); + assert.strictEqual(yield* Ref.get(executions), 0); + assert.strictEqual(yield* Ref.get(cohortChecks), 0); + assert.isTrue(yield* Ref.get(readyReleased)); + assert.isTrue(yield* Ref.get(peerReleased)); + assert.isTrue(yield* Ref.get(clusterReleased)); + + const ledger = yield* openLedger( + coreEvents, + REF, + "acme.run-spec-loss-during-acquisition/v1", + ).pipe(Effect.provideService(LedgerStorage, storage)); + const exits = Array.from( + yield* Stream.runCollect(ledger.events(AgentProcessExited)), + ); + assert.strictEqual(exits.length, 1); + assert.strictEqual(exits[0]?.code, OBSERVED_EXIT_CODE); + })); + +test("Run.execute invalidates a blocked cohort when a ready runtime terminates", () => + Effect.gen(function* () { + const gateEntered = yield* Deferred.make(); + const termination = yield* Deferred.make(); + const executions = yield* Ref.make(0); + const runtimeReleased = yield* Ref.make(false); + const clusterReleased = yield* Ref.make(false); + const runtime = defineFakeRuntime({ + name: "run-spec-pre-dispatch-loss", + configuration: configuration("run-spec-pre-dispatch-loss"), + acquire: () => + Effect.acquireRelease( + Effect.succeed({ + gateway: undefined, + termination: Deferred.await(termination), + }), + () => Ref.set(runtimeReleased, true), + ), + }); + const cluster = makeFakeCluster({ + cohortReady: Deferred.succeed(gateEntered, undefined).pipe( + Effect.zipRight(Effect.never), + ), + failure: Effect.never, + onRelease: Ref.set(clusterReleased, true), + }); + const spec = RunSpec.define({ + id: "acme.run-spec-pre-dispatch-loss/v1", + events: [], + agents: { alice: runtime }, + cluster: fakeClusterLayer(cluster), + execute: () => + Ref.update(executions, (count) => count + 1).pipe( + Effect.as("dispatched"), + ), + }); + const fiber = yield* Run.execute(spec).pipe(Effect.fork); + yield* Deferred.await(gateEntered); + yield* Deferred.succeed( + termination, + RuntimeExited.make({ code: OBSERVED_EXIT_CODE }), + ); + const result = yield* Fiber.join(fiber); + + assert.instanceOf(result, ClusterLost); + if (result instanceof ClusterLost) { + assert.isTrue( + Array.from(Cause.failures(result.cause)).some( + (cause) => cause instanceof ClusterError, + ), + ); + } + assert.strictEqual(yield* Ref.get(executions), 0); + assert.isTrue(yield* Ref.get(runtimeReleased)); + assert.isTrue(yield* Ref.get(clusterReleased)); + })); + +test("Run.execute does not retry after a post-dispatch ledger failure", () => + Effect.gen(function* () { + const executions = yield* Ref.make(0); + const released = yield* Ref.make(false); + const committedSends = yield* Ref.make(0); + const runtime = defineFakeRuntime({ + name: "run-spec-post-dispatch-failure", + configuration: configuration("run-spec-post-dispatch-failure"), + acquire: () => + Effect.acquireRelease( + Effect.succeed({ gateway: undefined, termination: Effect.never }), + () => Ref.set(released, true), + ), + }); + const cluster = makeFakeCluster({ + cohortReady: Effect.void, + failure: Effect.never, + }); + const spec = RunSpec.define({ + id: "acme.run-spec-post-dispatch-failure/v1", + events: [], + agents: { alice: runtime }, + cluster: fakeClusterLayer( + cluster, + memoryStorage(EndpointMessageSent._tag), + fakeRouterProvider(committedSends), + ), + execute: ({ agents, network }) => + Ref.update(executions, (count) => count + 1).pipe( + Effect.zipRight(network.endpoint("probe")), + Effect.flatMap((probe) => probe.open(agents.alice.agent)), + Effect.flatMap((socket) => socket.send("request")), + Effect.as("sent"), + ), + }); + const result = yield* Run.execute(spec); + assert.instanceOf(result, ClusterLost); + assert.strictEqual(yield* Ref.get(executions), 1); + assert.strictEqual(yield* Ref.get(committedSends), 1); + assert.isTrue(yield* Ref.get(released)); + })); + +test("Run.execute fails on post-dispatch cluster loss without replay", () => + Effect.gen(function* () { + const programStarted = yield* Deferred.make(); + const platformLost = yield* Deferred.make(); + const executions = yield* Ref.make(0); + const runtimeReleased = yield* Ref.make(false); + const clusterReleased = yield* Ref.make(false); + const runtime = defineFakeRuntime({ + name: "run-spec-cluster-loss", + configuration: configuration("run-spec-cluster-loss"), + acquire: () => + Effect.acquireRelease( + Effect.succeed({ gateway: undefined, termination: Effect.never }), + () => Ref.set(runtimeReleased, true), + ), + }); + const cluster = makeFakeCluster({ + cohortReady: Effect.void, + failure: Deferred.await(platformLost), + onRelease: Ref.set(clusterReleased, true), + }); + const spec = RunSpec.define({ + id: "acme.run-spec-cluster-loss/v1", + events: [], + agents: { alice: runtime }, + cluster: fakeClusterLayer(cluster), + execute: () => + Ref.update(executions, (count) => count + 1).pipe( + Effect.zipRight(Deferred.succeed(programStarted, undefined)), + Effect.zipRight(Effect.never), + ), + }); + const fiber = yield* Run.execute(spec).pipe(Effect.fork); + yield* Deferred.await(programStarted); + const failure = new ClusterError({ + detail: "controller ownership lost", + }); + yield* Deferred.fail(platformLost, failure); + const result = yield* Fiber.join(fiber); + assert.instanceOf(result, ClusterLost); + if (result instanceof ClusterLost) { + assert.instanceOf(result.receipt, CompletedLedgerReceipt); + assert.isTrue( + Array.from(Cause.failures(result.cause)).some( + (cause) => + cause instanceof ClusterError && cause.detail === failure.detail, + ), + ); + } + assert.strictEqual(yield* Ref.get(executions), 1); + assert.isTrue(yield* Ref.get(runtimeReleased)); + assert.isTrue(yield* Ref.get(clusterReleased)); + })); + +function readTerminationEvidence(storage: LedgerStorageService) { + return Effect.gen(function* () { + const ledger = yield* openLedger( + coreEvents, + REF, + "acme.run-spec-runtime-termination/v1", + ).pipe(Effect.provideService(LedgerStorage, storage)); + return Array.from( + yield* Stream.runCollect(ledger.events(AgentProcessExited)), + ); + }); +} + +test("Run.execute leaves post-dispatch runtime termination to customer policy", () => + Effect.gen(function* () { + const termination = yield* Deferred.make(); + const executions = yield* Ref.make(0); + const clusterReleased = yield* Ref.make(false); + const storage = memoryStorage(); + const runtime = defineFakeRuntime({ + name: "run-spec-runtime-termination", + configuration: configuration("run-spec-runtime-termination"), + acquire: () => + Effect.succeed({ + gateway: undefined, + termination: Deferred.await(termination), + }), + }); + const cluster = makeFakeCluster({ + cohortReady: Effect.void, + failure: Effect.never, + onRelease: Ref.set(clusterReleased, true), + }); + const spec = RunSpec.define({ + id: "acme.run-spec-runtime-termination/v1", + events: [], + agents: { alice: runtime }, + cluster: fakeClusterLayer(cluster, storage), + execute: ({ ledger }) => + Ref.update(executions, (count) => count + 1).pipe( + Effect.zipRight( + Deferred.succeed( + termination, + RuntimeExited.make({ code: OBSERVED_EXIT_CODE }), + ), + ), + Effect.zipRight( + ledger + .events(AgentProcessExited) + .pipe(Stream.take(1), Stream.runDrain), + ), + Effect.as("customer-observed-termination"), + ), + }); + const result = yield* Run.execute(spec); + assert.instanceOf(result, ProgramFinished); + if (result instanceof ProgramFinished) { + assert.deepStrictEqual( + result.exit, + Exit.succeed("customer-observed-termination"), + ); + } + assert.strictEqual(yield* Ref.get(executions), 1); + assert.isTrue(yield* Ref.get(clusterReleased)); + const exits = yield* readTerminationEvidence(storage); + assert.strictEqual(exits.length, 1); + assert.strictEqual(exits[0]?.code, OBSERVED_EXIT_CODE); + })); + +/* eslint-enable max-lines-per-function, max-statements, sonarjs/max-lines-per-function -- restore the project limits after the ordered lifecycle regressions */ diff --git a/packages/simulator/src/runtime.ts b/packages/simulator/src/runtime.ts deleted file mode 100644 index 33d673a2a..000000000 --- a/packages/simulator/src/runtime.ts +++ /dev/null @@ -1,77 +0,0 @@ -/** @file Autonomous agent runtime contracts and shipped implementations. */ - -/** Re-exports the public API from `./runtime/runtime.js`. */ -export { - AgentRuntimeDefinitionError, - RuntimeCompleted, - RuntimeExited, - RuntimeFailed, - RuntimeSignaled, - defineRuntime, - runtimeConfigurationProjection, - type AgentRuntime, - type AgentRuntimeDefinition, - type AgentRuntimeInput, - type RunningAgent, - type RuntimeTermination, -} from "./runtime/runtime.js"; - -/** Re-exports the public API from `./runtime/roster.js`. */ -export type { - AgentRoster, - AgentRosterAcquisitionError, - AgentRosterRequirements, - AgentsService, - RuntimeGatewayOf, - StartedAgent, - StartedAgents, -} from "./runtime/roster.js"; - -/** Re-exports the public API from `./runtime/effect.js`. */ -export { - EffectRuntimeStartFailed, - effectRuntime, - type EffectAgent, - type EffectRuntimeContext, - type EffectRuntimeOptions, -} from "./runtime/effect.js"; - -/** Re-exports the public API from `./runtime/openclaw/runtime.js`. */ -export { - openClawRuntime, - type OpenClawRuntimeAcquisitionError, - type OpenClawRuntimeOptions, - type OpenClawSandboxConfig, - type OpenClawToolsConfig, -} from "./runtime/openclaw/runtime.js"; - -/** Re-exports the public API from `./runtime/openclaw/gateway.js`. */ -export { - OpenClawGatewayRequest, - OpenClawGatewayRequestFailed, - OpenClawGatewayResponse, - OpenClawGatewaySucceeded, - OpenClawGatewayTimedOut, - type OpenClawGateway, -} from "./runtime/openclaw/gateway.js"; - -/** Re-exports the public API from `./runtime/nanoclaw/runtime.js`. */ -export { - nanoclawRuntime, - type NanoclawRuntimeAcquisitionError, - type NanoclawRuntimeOptions, -} from "./runtime/nanoclaw/runtime.js"; - -/** Re-exports the public API from `./runtime/nanoclaw/gateway.js`. */ -export { - NanoclawGatewayError, - NanoclawGatewayInput, - NanoclawGatewayOutput, - type NanoclawGateway, -} from "./runtime/nanoclaw/gateway.js"; - -/** Re-exports the public API from `./runtime/process.js`. */ -export { RuntimeAcquisitionFailed } from "./runtime/process.js"; - -/** Re-exports the public API from `./runtime/packages.js`. */ -export type { InstallMode } from "./runtime/packages.js"; diff --git a/packages/simulator/src/runtime/cache.test.ts b/packages/simulator/src/runtime/cache.test.ts deleted file mode 100644 index b73ef4b8b..000000000 --- a/packages/simulator/src/runtime/cache.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Deferred, Effect, Fiber } from "effect"; -import { describe, expect, it } from "vitest"; -import { makeSuccessMemo } from "./cache.js"; - -const ACQUIRED_VALUE = { state: "ready" } as const; -const REPAIRED_VALUE = { state: "repaired" } as const; -const EXPECTED_FAILURE = Symbol("expected acquisition failure"); - -describe("success memo", () => { - it("retries after a failed acquisition", retriesAfterFailure); - it("retries after an interrupted acquisition", retriesAfterInterruption); -}); - -function retriesAfterFailure() { - return Effect.runPromise( - Effect.gen(function* () { - const memo = yield* makeSuccessMemo(); - let attempts = 0; - const acquire = Effect.suspend(() => { - attempts += 1; - return attempts === 1 - ? Effect.fail(EXPECTED_FAILURE) - : Effect.succeed(ACQUIRED_VALUE); - }); - - expect( - yield* memo.getOrAcquire("runtime", acquire).pipe(Effect.flip), - ).toBe(EXPECTED_FAILURE); - expect(yield* memo.getOrAcquire("runtime", acquire)).toBe(ACQUIRED_VALUE); - expect( - yield* memo.getOrAcquire( - "runtime", - Effect.die("cached success must bypass acquisition"), - ), - ).toBe(ACQUIRED_VALUE); - expect(attempts).toBe(2); - }), - ); -} - -function retriesAfterInterruption() { - return Effect.runPromise( - Effect.gen(function* () { - const memo = yield* makeSuccessMemo(); - const started = yield* Deferred.make(); - const interrupted = yield* memo - .getOrAcquire( - "runtime", - Deferred.succeed(started, undefined).pipe( - Effect.zipRight(Effect.never), - ), - ) - .pipe(Effect.fork); - - yield* Deferred.await(started); - yield* Fiber.interrupt(interrupted); - expect( - yield* memo.getOrAcquire("runtime", Effect.succeed(REPAIRED_VALUE)), - ).toBe(REPAIRED_VALUE); - }), - ); -} diff --git a/packages/simulator/src/runtime/cache.ts b/packages/simulator/src/runtime/cache.ts deleted file mode 100644 index 0ead9fa1a..000000000 --- a/packages/simulator/src/runtime/cache.ts +++ /dev/null @@ -1,373 +0,0 @@ -/** @file Immutable runtime artifact cache. */ - -import { createHash } from "node:crypto"; -import { homedir } from "node:os"; -import { basename, join } from "node:path"; -import { FileSystem } from "@effect/platform"; -import type { PlatformError } from "@effect/platform/Error"; -import { Effect, Option, Ref } from "effect"; -import { makeCommandHelpers } from "./command.js"; - -// Install caches and runtime dirs can become Docker bind-mount sources. Keeping -// the shared root under home makes those paths visible to VM-backed engines. -/** Provides the moltzap simulator cache root runtime value. */ -export const MOLTZAP_SIMULATOR_CACHE_ROOT = join( - homedir(), - ".cache", - "moltzap-simulator", -); - -const BUILDING_CACHE_PREFIX = ".building-"; -const CACHE_GENERATION_PREFIX = "generation-"; -const READY_MARKER = ".ready"; -const STALE_BUILDING_CACHE_MAX_AGE_MS = 86_400_000; - -// Cold builds download, compile, and image-build multi-minute artifacts, so -// every cache in this process takes turns rather than multiplying that cost -// across concurrent agent spawns. -/** Provides the cache build permit runtime value. */ -export const CACHE_BUILD_PERMIT = Effect.runSync(Effect.makeSemaphore(1)); - -type ErrorFactory = (reason: string, cause?: unknown) => E; - -/** - * Coalesces concurrent acquisitions and remembers only successful values. - * Failed, defecting, and interrupted acquisitions leave the key empty, so the - * next caller performs a fresh acquisition. - * @returns The created success memo. - */ -export const makeSuccessMemo = Effect.fn("makeSuccessMemo")(function* < - Key, - Value, ->() { - const values = yield* Ref.make>(new Map()); - const permit = yield* Effect.makeSemaphore(1); - - return { - peek: (key: Key) => peekSuccessMemo(values, key), - getOrAcquire: (key: Key, acquire: Effect.Effect) => - getOrAcquireSuccess(values, permit, key, acquire), - }; -}); - -function peekSuccessMemo( - values: Ref.Ref>, - key: Key, -) { - return Ref.get(values).pipe( - Effect.map((entries) => entries.get(key) ?? null), - ); -} - -function getOrAcquireSuccess( - values: Ref.Ref>, - permit: Effect.Semaphore, - key: Key, - acquire: Effect.Effect, -) { - return Effect.gen(function* () { - const present = yield* peekSuccessMemo(values, key); - if (present !== null) { - return present; - } - return yield* permit.withPermits(1)( - acquireSuccessAfterPermit(values, key, acquire), - ); - }); -} - -function acquireSuccessAfterPermit( - values: Ref.Ref>, - key: Key, - acquire: Effect.Effect, -) { - return Effect.gen(function* () { - const concurrent = yield* peekSuccessMemo(values, key); - if (concurrent !== null) { - return concurrent; - } - const value = yield* acquire; - yield* Ref.update(values, (entries) => { - const next = new Map(entries); - next.set(key, value); - return next; - }); - return value; - }); -} - -// The cache's own filesystem-error mapper: bound once per cache so each -// operation reports failures in its owner's error channel. -type FsEffect = ( - reason: string, - effect: Effect.Effect, -) => Effect.Effect; - -/** - * Hashes one cache's field list into the key naming its generations. Callers - * own every field, including host identity, so their tests can vary it. - * @param schemaVersion Value supplied to the operation. - * @param payload Value supplied to the operation. - * @returns The cache fingerprint result. - */ -export function cacheFingerprint( - schemaVersion: number, - payload: Readonly>, -): string { - return createHash("sha256") - .update(JSON.stringify({ cacheSchema: schemaVersion, ...payload })) - .digest("hex"); -} - -/** - * Binds the filesystem lifecycle shared by immutable install caches to one - * cache root. Each owner supplies its typed error factory while cleanup and - * stale-cache sweeping remain best-effort operations. - * @param cacheRoot Value supplied to the operation. - * @param makeError Value supplied to the operation. - * @returns The created immutable cache. - */ -export function makeImmutableCache( - cacheRoot: string, - makeError: ErrorFactory, -) { - const { fsEffect } = makeCommandHelpers(makeError); - return { - createBuildingCache: () => createBuildingCache(cacheRoot, fsEffect), - findCacheGeneration: (fingerprint: string) => - // eslint-disable-next-line @typescript-eslint/no-use-before-define -- cache methods are invoked after module initialization. - findCacheGeneration(cacheRoot, fingerprint, fsEffect), - publishCacheGeneration: (buildingDir: string) => - publishCacheGeneration(cacheRoot, buildingDir, fsEffect), - removeBuildingCacheBestEffort, - sweepStaleBuildingCaches: ( - maxAgeMs: number = STALE_BUILDING_CACHE_MAX_AGE_MS, - ) => sweepStaleBuildingCaches(cacheRoot, maxAgeMs), - writeReadyMarker: (cacheDir: string, fingerprint: string) => - writeReadyMarker(cacheDir, fingerprint, fsEffect), - }; -} - -function readReadyFingerprint(readyMarker: string, fsEffect: FsEffect) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const exists = yield* fsEffect( - "check immutable cache ready marker " + readyMarker, - fileSystem.exists(readyMarker), - ); - if (!exists) { - return null; - } - return yield* fileSystem - .readFileString(readyMarker, "utf8") - .pipe( - Effect.catchAll((cause) => - Effect.logDebug( - "ignoring unreadable immutable cache ready marker", - cause, - ).pipe(Effect.as(null)), - ), - ); - }); -} - -function createBuildingCache(cacheRoot: string, fsEffect: FsEffect) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fsEffect( - "create immutable cache root " + cacheRoot, - fileSystem.makeDirectory(cacheRoot, { recursive: true }), - ); - return yield* fsEffect( - "create unique immutable building cache", - fileSystem.makeTempDirectory({ - directory: cacheRoot, - prefix: BUILDING_CACHE_PREFIX, - }), - ); - }); -} - -function removeBuildingCacheBestEffort(buildingDir: string) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.remove(buildingDir, { recursive: true, force: true }), - ), - Effect.catchAll((cause) => - Effect.logWarning( - "failed to remove immutable building cache " + buildingDir, - cause, - ), - ), - ); -} - -// A hard-killed installer cannot run its ensuring cleanup. The age gate keeps -// one process from deleting another process's in-progress build. -function sweepStaleBuildingCaches(cacheRoot: string, maxAgeMs: number) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const exists = yield* fileSystem.exists(cacheRoot); - if (!exists) { - return; - } - const entries = yield* fileSystem.readDirectory(cacheRoot); - const cutoff = Date.now() - maxAgeMs; - const buildingDirs = entries - .filter((entry) => entry.startsWith(BUILDING_CACHE_PREFIX)) - .map((entry) => join(cacheRoot, entry)); - for (const buildingDir of buildingDirs) { - const info = yield* fileSystem.stat(buildingDir); - const mtime = Option.getOrNull(info.mtime); - if (mtime !== null && mtime.getTime() <= cutoff) { - yield* fileSystem.remove(buildingDir, { - recursive: true, - force: true, - }); - } - } - }).pipe( - Effect.catchAll((cause) => - Effect.logWarning( - "failed to sweep stale immutable building caches in " + cacheRoot, - cause, - ), - ), - Effect.withSpan("sweepStaleBuildingCaches"), - ); -} - -function writeReadyMarker( - cacheDir: string, - fingerprint: string, - fsEffect: FsEffect, -) { - const readyMarker = readyMarkerPath(cacheDir); - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fsEffect( - "write immutable cache ready marker " + readyMarker, - fileSystem.writeFileString(readyMarker, fingerprint), - ), - ), - ); -} - -const findCacheGeneration = Effect.fn("findCacheGeneration")(function* ( - cacheRoot: string, - fingerprint: string, - fsEffect: FsEffect, -) { - const fileSystem = yield* FileSystem.FileSystem; - const exists = yield* fsEffect( - "check immutable cache root " + cacheRoot, - fileSystem.exists(cacheRoot), - ); - if (!exists) { - return null; - } - const entries = yield* fsEffect( - "list immutable cache generations " + cacheRoot, - fileSystem.readDirectory(cacheRoot), - ); - for (const entry of entries - .filter(isCacheGeneration) - .sort((left, right) => left.localeCompare(right))) { - const generationDir = join(cacheRoot, entry); - const readyFingerprint = yield* readReadyFingerprint( - readyMarkerPath(generationDir), - fsEffect, - ); - if (readyFingerprint === fingerprint) { - return generationDir; - } - } - return null; -}); - -function publishCacheGeneration( - cacheRoot: string, - buildingDir: string, - fsEffect: FsEffect, -) { - const generationDir = join( - cacheRoot, - CACHE_GENERATION_PREFIX + - basename(buildingDir).slice(BUILDING_CACHE_PREFIX.length), - ); - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fsEffect( - "publish immutable cache generation " + generationDir, - fileSystem.rename(buildingDir, generationDir), - ), - ), - Effect.as(generationDir), - Effect.withSpan("publishCacheGeneration"), - ); -} - -function readyMarkerPath(cacheDir: string): string { - return join(cacheDir, READY_MARKER); -} - -function isCacheGeneration(entry: string): boolean { - return entry.startsWith(CACHE_GENERATION_PREFIX); -} - -/** - * Bind decoded manifest and lockfile guards to an owner's typed error. - * Value guards throw only inside the caller's `Effect.try` decode boundary. - * @param makeError Value supplied to the operation. - * @returns The created json guards. - */ -export function makeJsonGuards(makeError: ErrorFactory) { - function requireRecord( - value: unknown, - label: string, - ): Readonly> { - if (!isRecord(value)) { - throw makeError(`Expected ${label} to be an object`); - } - return value; - } - - function requireString(value: unknown, label: string): string { - if (typeof value !== "string") { - throw makeError(`Expected ${label} to be a string`); - } - return value; - } - - function requireExactValue( - actual: unknown, - expected: string, - label: string, - ): void { - if (actual !== expected) { - throw makeError(`Expected ${label} to equal ${expected}`); - } - } - - function requireSoleEntry( - entries: readonly string[], - label: string, - ): Effect.Effect { - const [entry] = entries; - return entry === undefined || entries.length !== 1 - ? Effect.fail(makeError(`Expected one ${label}; found ${entries.length}`)) - : Effect.succeed(entry); - } - - return { - isRecord, - requireExactValue, - requireRecord, - requireSoleEntry, - requireString, - }; -} - -function isRecord(value: unknown): value is Readonly> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/simulator/src/runtime/command.ts b/packages/simulator/src/runtime/command.ts deleted file mode 100644 index f15c11fa6..000000000 --- a/packages/simulator/src/runtime/command.ts +++ /dev/null @@ -1,602 +0,0 @@ -/** @file Effect Platform process execution and supervised process lifetime. */ - -import { Buffer } from "node:buffer"; -import { homedir } from "node:os"; -import { execPath } from "node:process"; -import { Command } from "@effect/platform"; -import type { - CommandExecutor, - ExitCode, - Process, - Signal, -} from "@effect/platform/CommandExecutor"; -import type { PlatformError } from "@effect/platform/Error"; -import { - Config, - Data, - Duration, - Effect, - Fiber, - Option, - Scope, - Stream, -} from "effect"; - -/** Configures command run. */ -export interface CommandRunOptions { - readonly cwd?: string; - readonly timeout?: number; -} - -/** Describes captured command output. */ -export interface CapturedCommandOutput { - readonly stdout: string; - readonly stderr: string; -} - -/** - * The only operator variables a runtime child inherits: PATH so the runtime - * can find its tools, HOME so per-user state resolution works inside the - * exact-environment replacement. - */ -export type BaseChildEnvironment = Readonly>; - -/** Provides the base child environment config runtime value. */ -export const baseChildEnvironmentConfig: Config.Config = - Config.all({ - PATH: Config.string("PATH"), - HOME: Config.string("HOME").pipe(Config.withDefault(homedir())), - }); - -/** Configures exact environment command. */ -export interface ExactEnvironmentCommandOptions { - readonly command: string; - readonly args: readonly string[]; - readonly cwd: string; - readonly env: Readonly>; - readonly cleanupTreeOnExit?: boolean; -} - -type ErrorFactory = (reason: string, cause?: unknown) => E; -const LOG_HEAD_CAPACITY = 64 * 1024; -const LOG_TAIL_CAPACITY = 256 * 1024; -const LOG_ELISION_MARKER = "\n[... log window elided ...]\n"; - -const EXACT_ENVIRONMENT_LAUNCHER = ` -const { spawn } = require("node:child_process"); -const payload = JSON.parse( - Buffer.from(process.argv[1], "base64url").toString("utf8"), -); -process.on("SIGTERM", () => {}); -const cleanupTree = () => { - if (process.platform === "win32") { - const reaper = spawn( - "taskkill", - ["/pid", String(process.pid), "/T", "/F"], - { detached: true, stdio: "ignore", windowsHide: true }, - ); - reaper.unref(); - setInterval(() => {}, 0x7fffffff); - return; - } - process.kill(-process.pid, "SIGKILL"); -}; -const child = spawn(payload.command, payload.args, { - cwd: payload.cwd, - env: payload.env, - stdio: "inherit", - windowsHide: true, -}); -child.once("error", (error) => { - console.error(error); - process.exit(1); -}); -child.once("exit", (code) => { - if (payload.cleanupTreeOnExit === true) { - cleanupTree(); - return; - } - process.exit(code ?? 1); -}); -`; - -/** - * Builds a command whose target receives exactly `env`. Effect's Node command - * executor merges command variables over the operator environment, so a - * trusted Node launcher starts the target with an explicit replacement. The - * launcher and target share the detached group created by the executor, which - * keeps tree-directed teardown semantics on every supported Node platform. - * Long-lived runtimes opt into launcher-owned exit cleanup so the group - * leader remains present until every residual descendant receives KILL. - * @param options Options that control the operation. - * @returns The created exact environment command. - */ -export function makeExactEnvironmentCommand( - options: ExactEnvironmentCommandOptions, -): Command.Command { - const payload = Buffer.from(JSON.stringify(options)).toString("base64url"); - return Command.make(execPath, "-e", EXACT_ENVIRONMENT_LAUNCHER, payload).pipe( - Command.workingDirectory(options.cwd), - ); -} - -function makeShellCommand(commandText: string) { - return Command.make(commandText).pipe(Command.runInShell(true)); -} - -function makeShellCommandInDirectory(commandText: string, cwd: string) { - return Command.workingDirectory(makeShellCommand(commandText), cwd); -} - -// Callers parse this output, so capture is faithful rather than windowed -// like BoundedLogBuffer: eliding the middle of a JSON document turns -// "output too large" into a misleading parse error. The cap still bounds a -// runaway child, but surfaces as its own actionable failure. -const MAX_CAPTURED_OUTPUT_CHARS = 8 * 1024 * 1024; - -class CapturedOutputTooLarge extends Data.TaggedError( - "CapturedOutputTooLarge", -)<{ - readonly limit: number; -}> { - override get message(): string { - return `command produced more than ${String(this.limit)} characters of output`; - } -} - -function captureCommandStream(stream: Stream.Stream) { - const chunks: string[] = []; - let total = 0; - return stream.pipe( - Stream.decodeText(), - Stream.runForEach((chunk) => { - total += chunk.length; - if (total > MAX_CAPTURED_OUTPUT_CHARS) { - return Effect.fail( - new CapturedOutputTooLarge({ limit: MAX_CAPTURED_OUTPUT_CHARS }), - ); - } - chunks.push(chunk); - return Effect.void; - }), - Effect.map(() => chunks.join("")), - ); -} - -function captureCommandOutput(command: Command.Command) { - return Effect.scoped( - Effect.gen(function* () { - const process = yield* Command.start(command); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - captureCommandStream(process.stdout), - captureCommandStream(process.stderr), - process.exitCode, - ], - { concurrency: 3 }, - ); - return { stdout, stderr, exitCode: Number(exitCode) }; - }), - ); -} - -/** - * How much of each stream a failed command carries into its typed error. - * The bound is per stream and keeps the tail, where the first real error - * surfaces after a wall of progress output. - */ -const COMMAND_DIAGNOSTICS_TAIL_CHARS = 16 * 1024; - -// Both streams are retained because build tools split diagnostics across them: -// npm writes its lifecycle summary to stderr while the compiler it invoked -// writes the actual errors to stdout. Keeping only the non-empty one collapses -// a diagnosable failure back into a bare exit code. -function commandFailureReason( - description: string, - output: CapturedCommandOutput, - exitCode: number, -): string { - const streams: ReadonlyArray = [ - ["stderr", output.stderr.trim()], - ["stdout", output.stdout.trim()], - ]; - const diagnostics = streams - .filter(([, text]) => text.length > 0) - .map( - ([name, text]) => - `${name}:\n${text.slice(-COMMAND_DIAGNOSTICS_TAIL_CHARS)}`, - ); - return ( - `command failed with exit code ${exitCode}: ${description}` + - (diagnostics.length === 0 ? "" : `\n${diagnostics.join("\n")}`) - ); -} - -function commandOutputEffectWith( - makeError: ErrorFactory, - description: string, - command: Command.Command, - timeout: number, -): Effect.Effect { - const captured = captureCommandOutput(command).pipe( - Effect.mapError((cause) => - makeError(`command failed: ${description}`, cause), - ), - ); - return captured.pipe( - Effect.timeoutFail({ - duration: Duration.millis(timeout), - onTimeout: () => - makeError(`command timed out after ${timeout}ms: ${description}`), - }), - Effect.flatMap(({ exitCode, ...output }) => - exitCode === 0 - ? Effect.succeed(output) - : Effect.fail( - makeError(commandFailureReason(description, output, exitCode)), - ), - ), - ); -} - -function unboundedCommandOutputEffectWith( - makeError: ErrorFactory, - description: string, - command: Command.Command, -): Effect.Effect { - return captureCommandOutput(command).pipe( - Effect.mapError((cause) => - makeError(`command failed: ${description}`, cause), - ), - Effect.flatMap(({ exitCode, ...output }) => - exitCode === 0 - ? Effect.succeed(output) - : Effect.fail( - makeError(commandFailureReason(description, output, exitCode)), - ), - ), - ); -} - -// Output is captured rather than discarded even though no caller reads it on -// success: a runtime install runs `npm ci`, `npm run build`, and image builds -// whose staging directory is deleted during cleanup, so a failure that carries -// only an exit code cannot be diagnosed from the durable evaluation result. -function execEffectWith( - makeError: ErrorFactory, - commandText: string, - options: CommandRunOptions, -): Effect.Effect { - const { cwd, timeout } = options; - const command = - cwd === undefined - ? makeShellCommand(commandText) - : makeShellCommandInDirectory(commandText, cwd); - const captured = - timeout === undefined - ? unboundedCommandOutputEffectWith(makeError, commandText, command) - : commandOutputEffectWith(makeError, commandText, command, timeout); - return captured.pipe(Effect.asVoid); -} - -/** - * Shell-command and platform-error helpers shared by external runtimes. - * Each module supplies its own tagged-error factory so failures stay in - * that module's error channel. - * @param makeError Value supplied to the operation. - * @returns The created command helpers. - */ -export function makeCommandHelpers(makeError: ErrorFactory) { - return { - execEffect: (commandText: string, options?: CommandRunOptions) => - execEffectWith(makeError, commandText, options ?? {}), - commandOutputEffect: ( - description: string, - command: Command.Command, - options?: Pick, - ) => { - const timeout = options?.timeout; - return timeout === undefined - ? unboundedCommandOutputEffectWith(makeError, description, command) - : commandOutputEffectWith(makeError, description, command, timeout); - }, - fsEffect: ( - reason: string, - effect: Effect.Effect, - ): Effect.Effect => - effect.pipe(Effect.mapError((cause) => makeError(reason, cause))), - }; -} - -/** - * How much of a child's own output a launch failure carries. The bound is - * a character count rather than a line count because a runtime is free to - * emit one enormous line. - */ -const CHILD_OUTPUT_TAIL_CHARS = 2000; - -/** - * Appends the tail of a child's output to a failure detail. - * - * Redaction runs before the cut because whole-value redactors cannot match a - * credential fragment created by slicing. Cutting redacted text can only - * split the replacement marker. - * @param detail Value supplied to the operation. - * @param output Value supplied to the operation. - * @param redact Value supplied to the operation. - * @returns The attach child output result. - */ -export function attachChildOutput( - detail: string, - output: string, - redact: (text: string) => string, -): string { - const tail = redact(output) - .trimEnd() - .slice(-CHILD_OUTPUT_TAIL_CHARS) - .trimStart(); - return tail.length === 0 - ? detail - : `${detail}; last output from the agent process:\n${tail}`; -} - -/** - * Append-only process log window: the first `headCapacity` chars (startup - * diagnostics) plus a rolling tail, so a chatty long-lived agent cannot - * grow memory unbounded. Offsets are positions in the ORIGINAL stream — - * pollers keep monotonic cursors even after the middle is elided. - */ -export class BoundedLogBuffer { - private head = ""; - private tail = ""; - private total = 0; - - private readonly headCapacity: number; - private readonly tailCapacity: number; - - constructor( - headCapacity = LOG_HEAD_CAPACITY, - tailCapacity = LOG_TAIL_CAPACITY, - ) { - this.headCapacity = headCapacity; - this.tailCapacity = tailCapacity; - } - - append(chunk: string): void { - this.total += chunk.length; - let rest = chunk; - if (this.head.length < this.headCapacity) { - const take = Math.min(this.headCapacity - this.head.length, rest.length); - this.head += rest.slice(0, take); - rest = rest.slice(take); - } - if (rest.length === 0) { - return; - } - // Compact only past 2x capacity: V8 rope concatenation keeps `+=` cheap, - // so the flatten amortizes to O(1)/char at a 2x memory high-water mark. - this.tail += rest; - if (this.tail.length >= 2 * this.tailCapacity) { - this.tail = this.tail.slice(-this.tailCapacity); - } - } - - /** - * Text from `offset` (original-stream position) to the current end; - * regions no longer retained collapse into an elision marker. - * @param offset Value supplied to the operation. - * @returns The consume process stream result. - */ - read(offset: number): { readonly text: string; readonly nextOffset: number } { - const tailStart = this.total - this.tail.length; - if (offset >= tailStart) { - return { - text: this.tail.slice(offset - tailStart), - nextOffset: this.total, - }; - } - const elided = tailStart > this.head.length; - return { - text: - this.head.slice(offset) + - (elided ? LOG_ELISION_MARKER : "") + - this.tail, - nextOffset: this.total, - }; - } - - /** - * The full retained window (head + elision marker + tail). - * @returns The consume process stream result. - */ - get text(): string { - return this.read(0).text; - } -} - -/** - * Drains a child stdout/stderr stream into the caller's log accumulator. - * @param stream Value supplied to the operation. - * @param append Value supplied to the operation. - * @param processId Value supplied to the operation. - * @param streamName Value supplied to the operation. - * @returns The consume process stream result. - */ -function consumeProcessStream( - stream: Stream.Stream, - append: (chunk: string) => void, - processId: Process["pid"], - streamName: "stdout" | "stderr", -): Effect.Effect { - const decoder = new TextDecoder("utf-8"); - return Stream.runForEach(stream, (chunk) => - Effect.sync(() => { - append(decoder.decode(chunk, { stream: true })); - }), - ).pipe( - Effect.zipRight( - Effect.sync(() => { - const tail = decoder.decode(); - if (tail.length > 0) { - append(tail); - } - }), - ), - Effect.catchAll((cause) => - Effect.logWarning("child process output stream failed").pipe( - Effect.annotateLogs({ processId, streamName, cause }), - ), - ), - ); -} - -/** - * Starts a command under `scope`, preserves the platform process wait in its - * typed exit fiber, and drains stdout/stderr into `appendLog`. - * @param command Value supplied to the operation. - * @param scope Value supplied to the operation. - * @param appendLog Value supplied to the operation. - * @param processTreeCleanup Value supplied to the operation. - * @returns The start supervised process result. - */ -export const startSupervisedProcess = Effect.fn("startSupervisedProcess")( - function* ( - command: Command.Command, - scope: Scope.CloseableScope, - appendLog: (chunk: string) => void, - processTreeCleanup: ProcessTreeCleanup = { claimed: false }, - ) { - const proc = yield* Command.start(command).pipe(Scope.extend(scope)); - const exitFiber = yield* proc.exitCode.pipe(Effect.forkIn(scope)); - yield* consumeProcessStream( - proc.stdout, - appendLog, - proc.pid, - "stdout", - ).pipe(Effect.forkIn(scope)); - yield* consumeProcessStream( - proc.stderr, - appendLog, - proc.pid, - "stderr", - ).pipe(Effect.forkIn(scope)); - if (!processTreeCleanup.launcherOwnsExitCleanup) { - yield* Fiber.await(exitFiber).pipe( - Effect.zipRight(dispatchProcessTreeKill(proc, processTreeCleanup)), - Effect.forkDaemon, - ); - } - return { proc, exitFiber, processTreeCleanup }; - }, -); - -const EXIT_POLL_INTERVAL_MS = 100; - -/** Describes process tree cleanup. */ -export interface ProcessTreeCleanup { - claimed: boolean; - readonly launcherOwnsExitCleanup?: boolean; -} - -/** - * TERM→KILL escalation with bounded waits. Teardown runs in uninterruptible - * regions, so each wait polls the exit fiber instead of racing the platform - * `kill` await (which resolves only at process death and cannot be - * interrupted there); the signals themselves are fired as daemons. The Node - * executor starts a detached process group on POSIX and `Process.kill` - * signals that group before falling back to the direct pid. On Windows the - * same call uses `taskkill /T`, so both escalation stages include descendants. - * @param proc Value supplied to the operation. - * @param exitFiber Value supplied to the operation. - * @param waits Value supplied to the operation. - * @param waits.termWaitMs Value supplied to the operation. - * @param waits.killWaitMs Value supplied to the operation. - * @param processTreeCleanup Value supplied to the operation. - * @returns The escalating kill result. - */ -export const escalatingKill = Effect.fn("escalatingKill")(function* ( - proc: Process, - exitFiber: Fiber.RuntimeFiber, - waits: { readonly termWaitMs: number; readonly killWaitMs: number }, - processTreeCleanup: ProcessTreeCleanup = { claimed: false }, -) { - const initialExit = yield* Fiber.poll(exitFiber); - if (Option.isSome(initialExit)) { - yield* cleanupAfterLeaderExit(proc, processTreeCleanup); - return; - } - yield* sendSignal(proc, "SIGTERM"); - const leaderExited = yield* exitedWithin(exitFiber, waits.termWaitMs); - if (leaderExited) { - yield* cleanupAfterLeaderExit(proc, processTreeCleanup); - return; - } - yield* dispatchProcessTreeKill(proc, processTreeCleanup); - const killed = yield* exitedWithin(exitFiber, waits.killWaitMs); - if (!killed) { - yield* Effect.logWarning( - "child process remained alive after the SIGKILL wait", - ).pipe( - Effect.annotateLogs({ - processId: proc.pid, - killWaitMs: waits.killWaitMs, - }), - ); - } -}); - -function cleanupAfterLeaderExit( - proc: Process, - cleanup: ProcessTreeCleanup, -): Effect.Effect { - return cleanup.launcherOwnsExitCleanup - ? Effect.void - : dispatchProcessTreeKill(proc, cleanup); -} - -function dispatchProcessTreeKill( - proc: Process, - cleanup: ProcessTreeCleanup, -): Effect.Effect { - return Effect.suspend(() => { - if (cleanup.claimed) { - return Effect.void; - } - cleanup.claimed = true; - return sendSignal(proc, "SIGKILL"); - }); -} - -function sendSignal(proc: Process, signal: Signal): Effect.Effect { - return Effect.forkDaemon( - proc - .kill(signal) - .pipe( - Effect.catchAll((cause) => - Effect.logWarning("child process signal failed").pipe( - Effect.annotateLogs({ processId: proc.pid, signal, cause }), - ), - ), - ), - ).pipe(Effect.zipRight(Effect.yieldNow()), Effect.asVoid); -} - -function exitedWithin( - exitFiber: Fiber.RuntimeFiber, - waitMs: number, -): Effect.Effect { - return Effect.iterate( - { elapsedMs: 0, exited: false }, - { - while: (state) => !state.exited && state.elapsedMs < waitMs, - body: (state) => - Effect.sleep(Duration.millis(EXIT_POLL_INTERVAL_MS)).pipe( - Effect.zipRight(Fiber.poll(exitFiber)), - Effect.map((exit) => ({ - elapsedMs: state.elapsedMs + EXIT_POLL_INTERVAL_MS, - exited: Option.isSome(exit), - })), - ), - }, - ).pipe(Effect.map((state) => state.exited)); -} diff --git a/packages/simulator/src/runtime/effect.test.ts b/packages/simulator/src/runtime/effect.test.ts deleted file mode 100644 index 43ea497c4..000000000 --- a/packages/simulator/src/runtime/effect.test.ts +++ /dev/null @@ -1,460 +0,0 @@ -import { assert, beforeEach, expect, it } from "@effect/vitest"; -import { - messageReceivedNotificationDefinition, - messagesSend, - type MessageReceivedNotification, -} from "@moltzap/protocol/message"; -import { httpBaseUrl, serverBaseUrl } from "@moltzap/protocol/network"; -import { - agentId, - agentName, - agentKeyString, - conversationId, - messageId, - redactedAgentKey, -} from "@moltzap/protocol/testing"; -import { - Chunk, - Deferred, - Duration, - Effect, - Fiber, - Option, - Ref, - Schema, - Stream, -} from "effect"; -import { vi } from "vitest"; -import { - type AgentConnection, - type InboundLinkStage, - makeAgentHandle, -} from "../network.js"; -import { - EffectRuntimeStartFailed, - effectRuntime, - type EffectRuntimeContext, -} from "./effect.js"; -import { RuntimeCompleted, RuntimeFailed } from "./runtime.js"; - -interface FakeClientState { - received?: Stream.Stream; - readonly constructed: Array<{ - readonly serverUrl: string; - readonly agentKey: unknown; - }>; - readonly events: string[]; - readonly sent: Array<{ - readonly definition: string; - readonly payload: Readonly>; - }>; - connects: number; - closes: number; -} - -const clientState = vi.hoisted( - (): FakeClientState => ({ - received: undefined, - constructed: [], - events: [], - sent: [], - connects: 0, - closes: 0, - }), -); - -vi.mock("@moltzap/client", () => ({ - MoltZapAgentClient: class { - constructor(options: { - readonly serverUrl: string; - readonly agentKey: unknown; - }) { - clientState.constructed.push(options); - } - - connect() { - return Effect.sync(() => { - clientState.events.push("connect"); - clientState.connects += 1; - }); - } - - close() { - return Effect.sync(() => { - clientState.events.push("close"); - clientState.closes += 1; - }); - } - - subscribeScoped(definition: { readonly name: string }) { - clientState.events.push(`subscribe:${definition.name}`); - return clientState.received === undefined - ? Effect.dieMessage("test did not install a receive stream") - : Effect.succeed(clientState.received); - } - - callDefinition( - definition: { readonly name: string }, - payload: Readonly>, - ) { - return Effect.sync(() => { - clientState.sent.push({ - definition: definition.name, - payload, - }); - return {}; - }); - } - }, -})); - -const AGENT_ID = agentId("11111111-1111-4111-8111-111111111111"); -const SENDER_ID = agentId("22222222-2222-4222-8222-222222222222"); -const BLOCKED_SENDER_ID = agentId("33333333-3333-4333-8333-333333333333"); -const AGENT_KEY = redactedAgentKey(agentKeyString(80)); -const ROUTER_URL = serverBaseUrl("ws://127.0.0.1:3000"); -const STARTUP_TIMEOUT = Duration.seconds(3); -const EXPECTED_RUNTIME_NAME = "effect"; -const ROSTER_KEY = "alice"; -const AGENT_NAME = agentName(ROSTER_KEY); -const ORIGINAL_VERSION = "original"; -const REPLACEMENT_VERSION = "replacement"; -const INCOMING: MessageReceivedNotification = { - message: { - id: messageId("44444444-4444-4444-8444-444444444444"), - conversationId: conversationId("55555555-5555-4555-8555-555555555555"), - senderId: SENDER_ID, - parts: [{ type: "text", text: "ping" }], - createdAt: "2026-07-28T00:00:00.000Z", - }, -}; -const BLOCKED: MessageReceivedNotification = { - message: { - id: messageId("66666666-6666-4666-8666-666666666666"), - conversationId: INCOMING.message.conversationId, - senderId: BLOCKED_SENDER_ID, - parts: [{ type: "text", text: "partitioned" }], - createdAt: "2026-07-28T00:00:00.000Z", - }, -}; - -beforeEach(() => { - clientState.received = undefined; - clientState.constructed.length = 0; - clientState.events.length = 0; - clientState.sent.length = 0; - clientState.connects = 0; - clientState.closes = 0; -}); - -const connection: AgentConnection<"alice"> = { - agent: makeAgentHandle(ROSTER_KEY, AGENT_ID), - key: AGENT_KEY, - routerUrl: ROUTER_URL, -}; - -interface ReceivedDelivery { - readonly context: EffectRuntimeContext; - readonly notification: MessageReceivedNotification; -} - -function makeGatewayRuntime(received: Deferred.Deferred) { - return effectRuntime({ - startupTimeout: STARTUP_TIMEOUT, - build: (context) => - Effect.sync(() => { - clientState.events.push("build"); - return { - gateway: { - send: (text: string) => - context.client - .callDefinition(messagesSend, { - conversationId: INCOMING.message.conversationId, - parts: [{ type: "text", text }], - }) - .pipe(Effect.asVoid), - }, - behavior: context.messages.pipe( - Stream.runForEach((notification) => - Deferred.succeed(received, { - context, - notification, - }).pipe(Effect.asVoid), - ), - ), - }; - }), - }); -} - -type Inbound = readonly MessageReceivedNotification[]; - -const dropBlockedSender: InboundLinkStage = (inbound) => - Stream.filter(inbound, (item) => item.message.senderId !== BLOCKED_SENDER_ID); - -function makeCollectingRuntime(collected: Deferred.Deferred) { - return effectRuntime({ - startupTimeout: STARTUP_TIMEOUT, - build: (context) => - Effect.succeed({ - gateway: {}, - behavior: context.messages.pipe( - Stream.runCollect, - Effect.flatMap((received) => - Deferred.succeed(collected, Chunk.toReadonlyArray(received)), - ), - Effect.asVoid, - ), - }), - }); -} - -function assertStartupOrder(): void { - assert.strictEqual( - clientState.events[0], - `subscribe:${messageReceivedNotificationDefinition.name}`, - ); - assert.isBelow( - clientState.events.indexOf("connect"), - clientState.events.indexOf("build"), - ); -} - -it("publishes definition-time policy without exposing customer code", () => { - const runtime = effectRuntime({ - startupTimeout: STARTUP_TIMEOUT, - build: () => - Effect.succeed({ - gateway: {}, - behavior: Effect.never, - }), - }); - const encoded = Schema.encodeSync(runtime.configuration.schema)( - runtime.configuration.value, - ); - - expect(encoded).toStrictEqual({ - startupTimeout: Duration.toMillis(STARTUP_TIMEOUT), - }); -}); - -// @agent-code-guard/regression-only: controlled client lifecycles expose protocol routing, termination, and scope cleanup order directly -it.effect( - "exposes a typed gateway, identity, and eagerly registered message stream", - () => - Effect.scoped( - Effect.gen(function* () { - const delivery = yield* Deferred.make(); - const received = yield* Deferred.make(); - clientState.received = Stream.fromEffect(Deferred.await(delivery)); - const runtime = makeGatewayRuntime(received); - - const running = yield* runtime.acquire({ - agentName: AGENT_NAME, - connection, - }); - yield* running.gateway.send("outbound"); - yield* Deferred.succeed(delivery, INCOMING); - const observed = yield* Deferred.await(received); - const termination = yield* running.termination; - - assert.instanceOf(termination, RuntimeCompleted); - assert.strictEqual(observed.context.agent.id, AGENT_ID); - assert.strictEqual(observed.context.agent.name, AGENT_NAME); - assert.deepStrictEqual(observed.notification, INCOMING); - assert.strictEqual(clientState.connects, 1); - assert.strictEqual( - clientState.constructed[0]?.serverUrl, - httpBaseUrl(ROUTER_URL), - ); - assertStartupOrder(); - assert.strictEqual(clientState.sent[0]?.definition, messagesSend.name); - assert.deepEqual(clientState.sent[0]?.payload, { - conversationId: INCOMING.message.conversationId, - parts: [{ type: "text", text: "outbound" }], - }); - }), - ), -); - -it.effect( - "shapes the agent's inbound stream with the kernel's link stage", - () => - Effect.scoped( - Effect.gen(function* () { - const acquired = yield* Ref.make(false); - const collected = yield* Deferred.make(); - clientState.received = Stream.fromIterable([BLOCKED, INCOMING]); - - const running = yield* makeCollectingRuntime(collected).acquire({ - agentName: AGENT_NAME, - connection, - interceptInbound: Ref.set(acquired, true).pipe( - Effect.as(dropBlockedSender), - ), - }); - const observed = yield* Deferred.await(collected); - const termination = yield* running.termination; - - assert.instanceOf(termination, RuntimeCompleted); - assert.isTrue(yield* Ref.get(acquired)); - assert.deepStrictEqual(observed, [INCOMING]); - }), - ), -); - -it.effect("delivers every message when the run offers no link stage", () => - Effect.scoped( - Effect.gen(function* () { - const collected = yield* Deferred.make(); - clientState.received = Stream.fromIterable([BLOCKED, INCOMING]); - - const running = yield* makeCollectingRuntime(collected).acquire({ - agentName: AGENT_NAME, - connection, - }); - const observed = yield* Deferred.await(collected); - yield* running.termination; - - assert.deepStrictEqual(observed, [BLOCKED, INCOMING]); - }), - ), -); - -it.effect("turns behavior failure into a runtime observation", () => - Effect.scoped( - Effect.gen(function* () { - clientState.received = Stream.never; - const runtime = effectRuntime({ - build: () => - Effect.succeed({ - gateway: {}, - behavior: Effect.fail("behavior failed"), - }), - }); - const running = yield* runtime.acquire({ - agentName: AGENT_NAME, - connection, - }); - - const termination = yield* running.termination; - - assert.instanceOf(termination, RuntimeFailed); - assert.include(termination.detail, "behavior failed"); - assert.isAtLeast(clientState.closes, 1); - assert.lengthOf(clientState.sent, 0); - }), - ), -); - -it.effect("reports autonomous interruption as runtime failure", () => - Effect.scoped( - Effect.gen(function* () { - clientState.received = Stream.never; - const runtime = effectRuntime({ - build: () => - Effect.succeed({ - gateway: {}, - behavior: Effect.interrupt, - }), - }); - const running = yield* runtime.acquire({ - agentName: AGENT_NAME, - connection, - }); - - const termination = yield* running.termination; - - assert.instanceOf(termination, RuntimeFailed); - assert.include(termination.detail, "interrupted"); - }), - ), -); - -it.effect("maps builder failure to acquisition failure", () => - Effect.gen(function* () { - clientState.received = Stream.never; - const runtime = effectRuntime({ - build: () => Effect.fail("builder failed"), - }); - - const failure = yield* Effect.scoped( - runtime - .acquire({ - agentName: AGENT_NAME, - connection, - }) - .pipe(Effect.flip), - ); - - assert.instanceOf(failure, EffectRuntimeStartFailed); - assert.include(failure.detail, "builder failed"); - assert.strictEqual(clientState.closes, 1); - }), -); - -it.effect("scope teardown closes the client without reporting completion", () => - Effect.gen(function* () { - clientState.received = Stream.never; - - const running = yield* Effect.scoped( - effectRuntime({ - build: () => - Effect.succeed({ - gateway: {}, - behavior: Effect.never, - }), - }).acquire({ - agentName: AGENT_NAME, - connection, - }), - ); - const termination = yield* Effect.fork(running.termination); - yield* Effect.yieldNow(); - const observed = yield* Fiber.poll(termination); - yield* Fiber.interrupt(termination); - - assert.strictEqual(clientState.closes, 1); - assert.isTrue(Option.isNone(observed)); - }), -); - -it.effect("snapshots the builder at runtime construction", () => - Effect.scoped( - Effect.gen(function* () { - clientState.received = Stream.never; - const options = { - build: () => - Effect.succeed({ - gateway: { version: ORIGINAL_VERSION }, - behavior: Effect.never, - }), - }; - const runtime = effectRuntime(options); - options.build = () => - Effect.succeed({ - gateway: { version: REPLACEMENT_VERSION }, - behavior: Effect.never, - }); - - const running = yield* runtime.acquire({ - agentName: AGENT_NAME, - connection, - }); - - assert.strictEqual(running.gateway.version, ORIGINAL_VERSION); - }), - ), -); - -it("identifies the runtime implementation", () => { - expect( - effectRuntime({ - build: () => - Effect.succeed({ - gateway: {}, - behavior: Effect.never, - }), - }).name, - ).toBe(EXPECTED_RUNTIME_NAME); -}); diff --git a/packages/simulator/src/runtime/effect.ts b/packages/simulator/src/runtime/effect.ts deleted file mode 100644 index be9a75ab2..000000000 --- a/packages/simulator/src/runtime/effect.ts +++ /dev/null @@ -1,303 +0,0 @@ -/** @file In-process Effect agents connected through the production protocol. */ - -import { MoltZapAgentClient } from "@moltzap/client"; -import { - messageReceivedNotificationDefinition, - type MessageReceivedNotification, -} from "@moltzap/protocol/message"; -import { httpBaseUrl } from "@moltzap/protocol/network"; -import { - Cause, - Deferred, - Duration, - Effect, - Ref, - Schema, - type Scope, - type Stream, -} from "effect"; -import type { AgentHandle } from "../network/participant.js"; -import { - type AgentRuntime, - type AgentRuntimeInput, - RuntimeCompleted, - RuntimeFailed, - type RunningAgent, - type RuntimeTermination, - defineRuntime, -} from "./runtime.js"; - -const EFFECT_RUNTIME_NAME = "effect"; -const DEFAULT_STARTUP_TIMEOUT = Duration.seconds(10); - -/** Acquisition failed before an in-process agent became ready. */ -export class EffectRuntimeStartFailed extends Schema.TaggedError()( - "EffectRuntimeStartFailed", - { - agent: Schema.String, - detail: Schema.String, - }, -) { - override get message(): string { - return `Effect runtime for "${this.agent}" failed to start: ${this.detail}`; - } -} - -/** - * Runtime-owned capabilities available while constructing an in-process agent. - * The message stream is registered before the client connects, so delivery - * cannot race construction, and it already carries any directed-link policy - * the run installs against this agent. Social traffic still goes through - * `client`. - */ -export interface EffectRuntimeContext { - readonly agent: AgentHandle; - readonly messages: Stream.Stream; - readonly client: MoltZapAgentClient; -} - -/** Principal gateway and autonomous behavior owned by an in-process agent. */ -export interface EffectAgent { - readonly gateway: Gateway; - readonly behavior: Effect.Effect; -} - -/** Construction options owned by one in-process runtime implementation. */ -export interface EffectRuntimeOptions< - Gateway, - BuilderRequirements = never, - BehaviorRequirements = never, -> { - readonly startupTimeout?: Duration.Duration; - readonly build: ( - context: EffectRuntimeContext, - ) => Effect.Effect< - EffectAgent, - unknown, - BuilderRequirements - >; -} - -/** Sanitized definition-time configuration for an Effect runtime. */ -export class EffectRuntimeConfiguration extends Schema.Class( - "EffectRuntimeConfiguration", -)({ - startupTimeout: Schema.DurationFromMillis, -}) {} - -function startFailure( - input: AgentRuntimeInput, - cause: unknown, -): EffectRuntimeStartFailed { - return EffectRuntimeStartFailed.make({ - agent: input.connection.agent.name, - detail: String(cause), - }); -} - -function completeTermination( - termination: Deferred.Deferred, - observed: RuntimeTermination, -): Effect.Effect { - return Deferred.succeed(termination, observed).pipe(Effect.asVoid); -} - -function observeBehavior( - behavior: Effect.Effect, - client: MoltZapAgentClient, - termination: Deferred.Deferred, - scopeClosing: Ref.Ref, -): Effect.Effect { - return behavior.pipe( - Effect.matchCauseEffect({ - onFailure: (cause) => - Ref.get(scopeClosing).pipe( - Effect.flatMap((closing) => - closing && Cause.isInterruptedOnly(cause) - ? Effect.void - : completeTermination( - termination, - RuntimeFailed.make({ detail: Cause.pretty(cause) }), - ).pipe(Effect.zipRight(client.close())), - ), - ), - onSuccess: () => - completeTermination(termination, RuntimeCompleted.make({})).pipe( - Effect.zipRight(client.close()), - ), - }), - ); -} - -function awaitStartup( - input: AgentRuntimeInput, - client: MoltZapAgentClient, - startupTimeout: Duration.Duration, -): Effect.Effect { - return client.connect().pipe( - Effect.timeoutFail({ - duration: startupTimeout, - onTimeout: () => - `connect did not complete within ${Duration.format(startupTimeout)}`, - }), - Effect.mapError((cause) => startFailure(input, cause)), - ); -} - -interface ConnectedEffectClient { - readonly client: MoltZapAgentClient; - readonly messages: Stream.Stream; -} - -/** - * Acquires the kernel's inbound link stage when the run offers one, which is - * also what registers this agent as a directed-link policy target. - * @param input Router attachment issued to this runtime. - * @param subscribed Raw notification stream owned by the connected client. - * @returns The subscribed stream, shaped by the run's link policies. - */ -function shapeInbound( - input: AgentRuntimeInput, - subscribed: Stream.Stream, -): Effect.Effect< - Stream.Stream, - never, - Scope.Scope -> { - const intercept = input.interceptInbound; - return intercept === undefined - ? Effect.succeed(subscribed) - : intercept.pipe(Effect.map((stage) => stage(subscribed))); -} - -function acquireClient( - input: AgentRuntimeInput, - startupTimeout: Duration.Duration, -): Effect.Effect { - return Effect.gen(function* () { - const client = yield* Effect.try({ - try: () => - new MoltZapAgentClient({ - serverUrl: httpBaseUrl(input.connection.routerUrl), - agentKey: input.connection.key, - }), - catch: (cause) => startFailure(input, cause), - }); - const subscribed = yield* client.subscribeScoped( - messageReceivedNotificationDefinition, - ); - const messages = yield* shapeInbound(input, subscribed); - yield* Effect.addFinalizer(() => client.close()); - yield* awaitStartup(input, client, startupTimeout); - return { client, messages }; - }); -} - -function startBehavior( - built: EffectAgent, - client: MoltZapAgentClient, -): Effect.Effect, never, Scope.Scope | Requirements> { - return Effect.gen(function* () { - const termination = yield* Deferred.make(); - const scopeClosing = yield* Ref.make(false); - yield* observeBehavior( - built.behavior, - client, - termination, - scopeClosing, - ).pipe(Effect.forkScoped); - // `forkScoped` registers first. Scope finalizers run LIFO, so this marker - // distinguishes caller teardown from an agent that interrupts itself. - yield* Effect.addFinalizer(() => Ref.set(scopeClosing, true)); - return { - gateway: built.gateway, - termination: Deferred.await(termination), - }; - }); -} - -function acquireEffectRuntime< - Gateway, - BuilderRequirements, - BehaviorRequirements, - Name extends string, ->( - options: EffectRuntimeOptions< - Gateway, - BuilderRequirements, - BehaviorRequirements - >, - input: AgentRuntimeInput, -): Effect.Effect< - RunningAgent, - EffectRuntimeStartFailed, - Scope.Scope | BuilderRequirements | BehaviorRequirements -> { - return Effect.gen(function* () { - const connected = yield* acquireClient( - input, - options.startupTimeout ?? DEFAULT_STARTUP_TIMEOUT, - ); - const built = yield* options - .build( - Object.freeze({ - agent: input.connection.agent, - messages: connected.messages, - client: connected.client, - }), - ) - .pipe(Effect.mapError((cause) => startFailure(input, cause))); - return yield* startBehavior(built, connected.client); - }).pipe(Effect.withSpan("effectRuntime.acquire")); -} - -function snapshotOptions( - options: EffectRuntimeOptions< - Gateway, - BuilderRequirements, - BehaviorRequirements - >, -): EffectRuntimeOptions { - const startupTimeout = options.startupTimeout; - const build = options.build; - return Object.freeze({ - build, - ...(startupTimeout === undefined ? {} : { startupTimeout }), - }); -} - -/** - * Create a scoped in-process agent that communicates through the production - * MoltZap protocol. - * @param options Runtime-owned startup policy and customer agent builder. - * @returns An autonomous runtime with the builder's exact principal gateway. - */ -export function effectRuntime< - Gateway, - BuilderRequirements = never, - BehaviorRequirements = never, ->( - options: EffectRuntimeOptions< - Gateway, - BuilderRequirements, - BehaviorRequirements - >, -): AgentRuntime< - Gateway, - EffectRuntimeStartFailed, - BuilderRequirements | BehaviorRequirements, - typeof EffectRuntimeConfiguration -> { - const capturedOptions = snapshotOptions(options); - return defineRuntime({ - name: EFFECT_RUNTIME_NAME, - configuration: { - schema: EffectRuntimeConfiguration, - value: EffectRuntimeConfiguration.make({ - startupTimeout: - capturedOptions.startupTimeout ?? DEFAULT_STARTUP_TIMEOUT, - }), - }, - acquire: (input) => acquireEffectRuntime(capturedOptions, input), - }); -} diff --git a/packages/simulator/src/runtime/effect.types-check.ts b/packages/simulator/src/runtime/effect.types-check.ts deleted file mode 100644 index 090ae0de1..000000000 --- a/packages/simulator/src/runtime/effect.types-check.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * An Effect runtime preserves its customer gateway exactly and exposes every - * builder and behavior requirement. The keyed roster relies on both facts to - * install a precise started-agent service without hiding missing dependencies. - */ - -import { Context, Effect } from "effect"; -import { effectRuntime, type EffectRuntimeStartFailed } from "./effect.js"; -import type { AgentRuntime } from "./runtime.js"; - -interface TestGateway { - readonly submit: (value: string) => Effect.Effect; -} - -class BuilderDependency extends Context.Tag( - "@moltzap/simulator/test/EffectRuntimeBuilderDependency", -)() {} - -class BehaviorDependency extends Context.Tag( - "@moltzap/simulator/test/EffectRuntimeBehaviorDependency", -) }>() {} - -/** Representative runtime retained for compile-time inference checks. */ -export const effectRuntimeCanary = effectRuntime({ - build: (context) => - Effect.gen(function* () { - const builder = yield* BuilderDependency; - const gateway: TestGateway = { - submit: (value) => - Effect.sync( - () => `${builder.prefix}${context.agent.name}${value}`, - ).pipe(Effect.asVoid), - }; - const behavior = Effect.gen(function* () { - const dependency = yield* BehaviorDependency; - yield* dependency.observe; - return yield* Effect.never; - }); - return { gateway, behavior }; - }).pipe(Effect.withSpan("effectRuntimeCanary")), -}); - -type RuntimeTypes = - Runtime extends AgentRuntime< - infer Gateway, - infer AcquisitionError, - infer Requirements, - infer Configuration - > - ? readonly [Gateway, AcquisitionError, Requirements, Configuration] - : never; - -type Equal = [Left, Right] extends [Right, Left] ? true : false; -type Expect = Value; - -type GatewayIsExact = Expect< - Equal[0], TestGateway> ->; -type AcquisitionErrorIsBounded = Expect< - Equal[1], EffectRuntimeStartFailed> ->; -type RequirementsAreExact = Expect< - Equal< - RuntimeTypes[2], - BuilderDependency | BehaviorDependency - > ->; - -/** Compile-time assertions for the Effect runtime's inferred public contract. */ -export type EffectRuntimeCanaries = [ - GatewayIsExact, - AcquisitionErrorIsBounded, - RequirementsAreExact, -]; diff --git a/packages/simulator/src/runtime/nanoclaw/install.integration.test.ts b/packages/simulator/src/runtime/nanoclaw/install.integration.test.ts deleted file mode 100644 index f45a561ac..000000000 --- a/packages/simulator/src/runtime/nanoclaw/install.integration.test.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { basename, join } from "node:path"; -import { Command, FileSystem } from "@effect/platform"; -import { NodeContext } from "@effect/platform-node"; -import { Config, Effect } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - ensureNanoclawRuntimeInstalledEffect, - findWarmNanoclawRuntimeInstallEffect, -} from "./install.js"; - -const PUBLISHED_INSTALL_MODE = "published"; -const CACHE_GENERATION_PREFIX = "generation-"; -const READY_MARKER = ".ready"; -const DOCKER_COMMAND = "docker"; -const DOCKER_IMAGE_SUBCOMMAND = "image"; -const DOCKER_INSPECT_SUBCOMMAND = "inspect"; -const SUCCESS_EXIT_CODE = 0; - -// The no-process-env-at-runtime guard applies to test files, so the gate -// reads the flag through Config under the default env-backed provider. -const NANOCLAW_INSTALL_INTEGRATION_ENABLED = Effect.runSync( - Config.string("MOLTZAP_NANOCLAW_ITEST").pipe( - Config.withDefault("0"), - Config.map((value) => value === "1"), - ), -); - -describe.skipIf(!NANOCLAW_INSTALL_INTEGRATION_ENABLED)( - "NanoClaw real install cache", - () => { - it( - "reuses a fingerprint-matched generation with its existing image", - reusesWarmInstall, - ); - }, -); - -function reusesWarmInstall() { - return Effect.runPromise( - Effect.gen(function* () { - const warmCandidate = yield* findWarmNanoclawRuntimeInstallEffect( - PUBLISHED_INSTALL_MODE, - ); - expect(warmCandidate).not.toBeNull(); - if (warmCandidate === null) { - return; - } - - const fileSystem = yield* FileSystem.FileSystem; - const readyFingerprint = yield* fileSystem.readFileString( - join(warmCandidate.cacheDir, READY_MARKER), - "utf8", - ); - expect(readyFingerprint).toBe(warmCandidate.cacheFingerprint); - expect( - basename(warmCandidate.cacheDir).startsWith(CACHE_GENERATION_PREFIX), - ).toBeTruthy(); - - const imageExitCode = yield* Command.exitCode( - Command.make( - DOCKER_COMMAND, - DOCKER_IMAGE_SUBCOMMAND, - DOCKER_INSPECT_SUBCOMMAND, - warmCandidate.containerImage, - ), - ); - expect(Number(imageExitCode)).toBe(SUCCESS_EXIT_CODE); - - const firstInstall = yield* ensureNanoclawRuntimeInstalledEffect( - PUBLISHED_INSTALL_MODE, - ); - expect(firstInstall).toEqual(warmCandidate); - const secondInstall = yield* ensureNanoclawRuntimeInstalledEffect( - PUBLISHED_INSTALL_MODE, - ); - expect(secondInstall).toBe(firstInstall); - }).pipe(Effect.provide(NodeContext.layer)), - ); -} diff --git a/packages/simulator/src/runtime/nanoclaw/install.test.ts b/packages/simulator/src/runtime/nanoclaw/install.test.ts deleted file mode 100644 index 2ae264e77..000000000 --- a/packages/simulator/src/runtime/nanoclaw/install.test.ts +++ /dev/null @@ -1,179 +0,0 @@ -import { basename, join } from "node:path"; -import { FileSystem } from "@effect/platform"; -import { NodeContext } from "@effect/platform-node"; -import { Effect } from "effect"; -import { describe, expect, it } from "vitest"; - -import { nanoclawInstallCache } from "./install.js"; - -const CACHE_FINGERPRINT = "a".repeat(64); -const OTHER_FINGERPRINT = "b".repeat(64); -const PADDED_FINGERPRINT = CACHE_FINGERPRINT + "\n"; -const READY_MARKER = ".ready"; -const GENERATION_PREFIX = "generation-"; -const FIRST_PAYLOAD = "first"; -const SECOND_PAYLOAD = "second"; -const PAYLOAD_FILE = "payload"; -const PUBLISHED_GENERATION_COUNT = 2; - -describe("NanoClaw cache generations", () => { - it("ignores corrupt and mismatched generations", ignoresInvalidGenerations); - it("selects a generation with the exact fingerprint", selectsExactGeneration); - it( - "publishes concurrent builds to unique generations", - publishesConcurrently, - ); - it( - "sweeps stale building caches but keeps fresh ones and generations", - sweepsOnlyStaleBuildingCaches, - ); -}); - -const SWEEP_MAX_AGE_MS = 60_000; - -function sweepsOnlyStaleBuildingCaches() { - return runWithFixture((cacheRoot) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const stale = join(cacheRoot, ".building-stale"); - const fresh = join(cacheRoot, ".building-fresh"); - const generation = join(cacheRoot, GENERATION_PREFIX + "keep"); - yield* fileSystem.makeDirectory(stale, { recursive: true }); - yield* fileSystem.makeDirectory(fresh, { recursive: true }); - yield* makeGeneration(generation); - const staleDate = new Date(Date.now() - SWEEP_MAX_AGE_MS * 2); - yield* fileSystem.utimes(stale, staleDate, staleDate); - - yield* nanoclawInstallCache(cacheRoot).sweepStaleBuildingCaches( - SWEEP_MAX_AGE_MS, - ); - - expect(yield* fileSystem.exists(stale)).toBe(false); - expect(yield* fileSystem.exists(fresh)).toBe(true); - expect(yield* fileSystem.exists(generation)).toBe(true); - }), - ); -} - -function ignoresInvalidGenerations() { - return runWithFixture((cacheRoot) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const corrupt = join(cacheRoot, GENERATION_PREFIX + "corrupt"); - yield* fileSystem.makeDirectory(join(corrupt, READY_MARKER), { - recursive: true, - }); - yield* makeGeneration( - join(cacheRoot, GENERATION_PREFIX + "mismatch"), - OTHER_FINGERPRINT, - ); - yield* makeGeneration( - join(cacheRoot, GENERATION_PREFIX + "padded"), - PADDED_FINGERPRINT, - ); - yield* makeGeneration(join(cacheRoot, ".building-complete")); - - const found = - yield* nanoclawInstallCache(cacheRoot).findCacheGeneration( - CACHE_FINGERPRINT, - ); - - expect(found).toBe(null); - }), - ); -} - -function selectsExactGeneration() { - return runWithFixture((cacheRoot) => - Effect.gen(function* () { - const expected = join(cacheRoot, GENERATION_PREFIX + "valid"); - yield* makeGeneration(expected); - - const found = - yield* nanoclawInstallCache(cacheRoot).findCacheGeneration( - CACHE_FINGERPRINT, - ); - - expect(found).toBe(expected); - }), - ); -} - -function publishesConcurrently() { - return runWithFixture((cacheRoot) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const firstBuilding = join(cacheRoot, ".building-first"); - const secondBuilding = join(cacheRoot, ".building-second"); - yield* makeGeneration(firstBuilding, CACHE_FINGERPRINT, FIRST_PAYLOAD); - yield* makeGeneration(secondBuilding, CACHE_FINGERPRINT, SECOND_PAYLOAD); - - const cache = nanoclawInstallCache(cacheRoot); - const published = yield* Effect.all( - [ - cache.publishCacheGeneration(firstBuilding), - cache.publishCacheGeneration(secondBuilding), - ], - { concurrency: PUBLISHED_GENERATION_COUNT }, - ); - - expect(new Set(published).size).toBe(PUBLISHED_GENERATION_COUNT); - for (const generationDir of published) { - expect(basename(generationDir).startsWith(GENERATION_PREFIX)).toBe( - true, - ); - expect(yield* fileSystem.exists(generationDir)).toBe(true); - } - expect(yield* fileSystem.exists(firstBuilding)).toBe(false); - expect(yield* fileSystem.exists(secondBuilding)).toBe(false); - const payloads = yield* Effect.forEach( - published, - (generationDir) => - fileSystem.readFileString(join(generationDir, PAYLOAD_FILE)), - { concurrency: PUBLISHED_GENERATION_COUNT }, - ); - expect(new Set(payloads)).toEqual( - new Set([FIRST_PAYLOAD, SECOND_PAYLOAD]), - ); - expect(yield* cache.findCacheGeneration(CACHE_FINGERPRINT)).not.toBe( - null, - ); - }), - ); -} - -function makeGeneration( - directory: string, - fingerprint = CACHE_FINGERPRINT, - payload?: string, -) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(directory, { recursive: true }); - if (payload !== undefined) { - yield* fileSystem.writeFileString(join(directory, PAYLOAD_FILE), payload); - } - yield* fileSystem.writeFileString( - join(directory, READY_MARKER), - fingerprint, - ); - }); -} - -function runWithFixture( - use: (cacheRoot: string) => Effect.Effect, -) { - return Effect.runPromise( - Effect.scoped( - FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.makeTempDirectoryScoped({ - prefix: "nanoclaw-cache-generations-", - }), - ), - Effect.flatMap(use), - Effect.provide(NodeContext.layer), - ), - ), - ); -} diff --git a/packages/simulator/src/runtime/nanoclaw/install.ts b/packages/simulator/src/runtime/nanoclaw/install.ts deleted file mode 100644 index 61d0f9deb..000000000 --- a/packages/simulator/src/runtime/nanoclaw/install.ts +++ /dev/null @@ -1,1095 +0,0 @@ -/** @file Immutable NanoClaw installation acquisition. */ - -import { createHash } from "node:crypto"; -import { basename, join, posix } from "node:path"; -import { Command, FileSystem } from "@effect/platform"; -import { Data, Duration, Effect } from "effect"; -import { makeCommandHelpers } from "../command.js"; -import { - cacheFingerprint, - CACHE_BUILD_PERMIT, - makeJsonGuards, - makeImmutableCache, - makeSuccessMemo, - MOLTZAP_SIMULATOR_CACHE_ROOT, -} from "../cache.js"; -import { - findWorkspacePackagesDir, - resolveOwningPackageRoot, - type InstallMode, -} from "../packages.js"; - -/** Pinned NanoClaw source revision; the simulator's manifest cites it as the runtime version. */ -const NANOCLAW_SHA = "641963c1e4b7ba4f000a18dfc5e2fea29069feec"; -const NANOCLAW_URL = - "https://github.com/nanocoai/nanoclaw/archive/" + NANOCLAW_SHA + ".tar.gz"; -const NANOCLAW_CACHE_SCHEMA_VERSION = 5; -const NANOCLAW_IMAGE_REPOSITORY = "nanoclaw-agent"; -const NANOCLAW_IMAGE_TAG_PREFIX = "moltzap"; -const CLIENT_PACKAGE_NAME = "@moltzap/client"; -const PROTOCOL_PACKAGE_NAME = "@moltzap/protocol"; -const WORKSPACE_VENDOR_DIRECTORY = "vendor"; -const WORKSPACE_DIST_ENTRY = join("dist", "index.js"); -const WORKSPACE_PACK_TIMEOUT_MS = 120_000; -const WORKSPACE_LOCK_TIMEOUT_MS = 120_000; -const TARBALL_EXTENSION = ".tgz"; -const SHA512_INTEGRITY_PREFIX = "sha512-"; -const JSON_INDENT_SPACES = 2; -const REGISTRY_MOLTZAP_PATTERN = /registry\.npmjs\.org\/@moltzap(?:\/|%2f)/i; -const SIMULATOR_PACKAGE_NAME = "@moltzap/simulator"; -const NANOCLAW_ASSETS_DIRECTORY = join( - resolveOwningPackageRoot(SIMULATOR_PACKAGE_NAME, import.meta.url), - "dist", - "nanoclaw-assets", -); - -// A verified install is process-invariant for one mode, so later spawns reuse -// it without repeating filesystem and Docker verification. The map changes -// only after verification succeeds; failure and interruption leave no state. -const WARM_INSTALLS = Effect.runSync( - makeSuccessMemo(), -); - -/** Describes nanoclaw runtime install. */ -export interface NanoclawRuntimeInstall { - readonly cacheDir: string; - readonly cacheFingerprint: string; - readonly containerImage: string; -} - -interface BaseNanoclawCacheTarget { - readonly cacheRoot: string; - readonly cacheFingerprint: string; -} - -interface PublishedNanoclawCacheTarget extends BaseNanoclawCacheTarget { - readonly installMode: "published"; -} - -interface WorkspaceNanoclawCacheTarget extends BaseNanoclawCacheTarget { - readonly installMode: "workspace"; - readonly workspaceDependencies: NanoclawWorkspaceDependencies; -} - -type NanoclawCacheTarget = - | PublishedNanoclawCacheTarget - | WorkspaceNanoclawCacheTarget; - -interface NanoclawFingerprintInput { - readonly channelHash: string; - readonly evalProvisionHash: string; - readonly skillHash: string; - readonly packageJsonHash: string; - readonly packageLockHash: string; - readonly platform: string; - readonly architecture: string; - readonly nodeAbi: string; -} - -/** Describes nanoclaw workspace tarball. */ -export interface NanoclawWorkspaceTarball { - readonly packageName: string; - readonly version: string; - readonly tarballPath: string; - readonly tarballFileName: string; - readonly sha256: string; - readonly integrity: string; -} - -/** Describes nanoclaw workspace dependencies. */ -export interface NanoclawWorkspaceDependencies { - readonly client: NanoclawWorkspaceTarball; - readonly protocol: NanoclawWorkspaceTarball; -} - -interface WorkspacePackageManifest { - readonly name: string; - readonly version: string; - readonly dependencies: Readonly>; -} - -interface PreparedWorkspaceTarball { - readonly manifest: WorkspacePackageManifest; - readonly tarball: NanoclawWorkspaceTarball; -} - -class NanoclawInstallError extends Data.TaggedError("NanoclawInstallError")<{ - readonly reason: string; - readonly cause?: unknown; -}> { - override get message(): string { - return this.reason; - } -} - -function installError(reason: string, cause?: unknown) { - return new NanoclawInstallError({ - reason, - ...(cause === undefined ? {} : { cause }), - }); -} - -const { commandOutputEffect, execEffect, fsEffect } = - makeCommandHelpers(installError); -const { requireExactValue, requireRecord, requireSoleEntry, requireString } = - makeJsonGuards(installError); - -function sha256Hex(data: string | Uint8Array): string { - return createHash("sha256").update(data).digest("hex"); -} - -function sha512Integrity(data: Uint8Array): string { - return ( - SHA512_INTEGRITY_PREFIX + createHash("sha512").update(data).digest("base64") - ); -} - -function sha256OfFile(filePath: string) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fsEffect( - "read file for sha256 " + filePath, - fileSystem.readFile(filePath), - ), - ), - Effect.map(sha256Hex), - ); -} - -function bundledAssetPath(assetName: string): string { - return join(NANOCLAW_ASSETS_DIRECTORY, assetName); -} - -// A cold acquisition hashes the package-owned assets directly. A verified -// warm install bypasses this work entirely. -function nanoclawFingerprintInput() { - return Effect.gen(function* () { - const [ - channelHash, - evalProvisionHash, - skillHash, - packageJsonHash, - packageLockHash, - ] = yield* Effect.all( - [ - sha256OfFile(bundledAssetPath("moltzap.ts")), - sha256OfFile(bundledAssetPath("moltzap-eval-provision.ts")), - sha256OfFile(bundledAssetPath("SKILL.md")), - sha256OfFile(bundledAssetPath("package.json")), - sha256OfFile(bundledAssetPath("package-lock.json")), - ], - { concurrency: 5 }, - ); - return { - channelHash, - evalProvisionHash, - skillHash, - packageJsonHash, - packageLockHash, - platform: process.platform, - architecture: process.arch, - nodeAbi: process.versions.modules, - } satisfies NanoclawFingerprintInput; - }); -} - -/** - * Derive the immutable NanoClaw cache identity from source and workspace inputs. - * - * @param input Input value to process. - * @param workspaceHashes Value supplied to the operation. - * @param workspaceHashes.clientTarballHash Value supplied to the operation. - * @param workspaceHashes.protocolTarballHash Value supplied to the operation. - * @internal - * @returns The nanoclaw cache fingerprint result. - */ -export function nanoclawCacheFingerprint( - input: NanoclawFingerprintInput, - workspaceHashes?: { - readonly clientTarballHash: string; - readonly protocolTarballHash: string; - }, -): string { - return cacheFingerprint(NANOCLAW_CACHE_SCHEMA_VERSION, { - nanoclawSha: NANOCLAW_SHA, - channelHash: input.channelHash, - evalProvisionHash: input.evalProvisionHash, - skillHash: input.skillHash, - packageJsonHash: input.packageJsonHash, - packageLockHash: input.packageLockHash, - platform: input.platform, - architecture: input.architecture, - nodeAbi: input.nodeAbi, - ...workspaceHashes, - }); -} - -function nanoclawCacheRoot(cacheFingerprint: string): string { - return join(MOLTZAP_SIMULATOR_CACHE_ROOT, "nanoclaw", cacheFingerprint); -} - -function resolvePublishedCacheTarget() { - return nanoclawFingerprintInput().pipe( - Effect.map((input) => { - const fingerprint = nanoclawCacheFingerprint(input); - return { - installMode: "published", - cacheRoot: nanoclawCacheRoot(fingerprint), - cacheFingerprint: fingerprint, - } satisfies PublishedNanoclawCacheTarget; - }), - ); -} - -function resolveWorkspaceCacheTarget() { - return Effect.gen(function* () { - const input = yield* nanoclawFingerprintInput(); - const workspaceDependencies = yield* prepareNanoclawWorkspaceDependencies(); - const fingerprint = nanoclawCacheFingerprint(input, { - clientTarballHash: workspaceDependencies.client.sha256, - protocolTarballHash: workspaceDependencies.protocol.sha256, - }); - return { - installMode: "workspace", - cacheRoot: nanoclawCacheRoot(fingerprint), - cacheFingerprint: fingerprint, - workspaceDependencies, - } satisfies WorkspaceNanoclawCacheTarget; - }); -} - -function resolveCacheTarget(installMode: InstallMode) { - return installMode === "published" - ? resolvePublishedCacheTarget() - : resolveWorkspaceCacheTarget(); -} - -function prepareNanoclawWorkspaceDependencies() { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const packagesDir = findWorkspacePackagesDir(import.meta.url); - if (packagesDir === null) { - return yield* installError( - "Workspace install mode requires a MoltZap source checkout with packages/client and packages/protocol", - ); - } - // The target consumes both tarballs before this acquisition scope closes. - const packRoot = yield* Effect.acquireRelease( - fsEffect( - "create temporary NanoClaw workspace pack directory", - fileSystem.makeTempDirectory({ - prefix: "moltzap-nanoclaw-workspace-", - }), - ), - (directory) => - fileSystem - .remove(directory, { recursive: true, force: true }) - .pipe(Effect.catchAll(() => Effect.void)), - ); - const [client, protocol] = yield* Effect.all( - [ - packWorkspacePackage( - join(packagesDir, "client"), - CLIENT_PACKAGE_NAME, - packRoot, - ), - packWorkspacePackage( - join(packagesDir, "protocol"), - PROTOCOL_PACKAGE_NAME, - packRoot, - ), - ], - { concurrency: 2 }, - ); - yield* assertPackedWorkspaceVersions({ - clientManifest: client.manifest, - protocolManifest: protocol.manifest, - clientVersion: client.tarball.version, - protocolVersion: protocol.tarball.version, - }); - return { - client: client.tarball, - protocol: protocol.tarball, - } satisfies NanoclawWorkspaceDependencies; - }); -} - -function packWorkspacePackage( - packageDir: string, - packageName: string, - packRoot: string, -) { - return Effect.gen(function* () { - const sourceManifest = yield* readWorkspacePackageManifest( - join(packageDir, "package.json"), - packageName, - ); - yield* requireBuiltWorkspacePackage(packageDir, packageName); - const tarballPath = yield* createWorkspaceTarball( - packageDir, - packageName, - packRoot, - ); - const manifest = yield* readPackedWorkspaceManifest( - tarballPath, - packageName, - ); - if (manifest.version !== sourceManifest.version) { - return yield* installError( - `Packed ${packageName} version ${manifest.version} does not match workspace version ${sourceManifest.version}`, - ); - } - const fileSystem = yield* FileSystem.FileSystem; - const bytes = yield* fsEffect( - `read packed workspace dependency ${tarballPath}`, - fileSystem.readFile(tarballPath), - ); - return { - manifest, - tarball: { - packageName, - version: manifest.version, - tarballPath, - tarballFileName: basename(tarballPath), - sha256: sha256Hex(bytes), - integrity: sha512Integrity(bytes), - }, - } satisfies PreparedWorkspaceTarball; - }); -} - -function requireBuiltWorkspacePackage(packageDir: string, packageName: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const distEntry = join(packageDir, WORKSPACE_DIST_ENTRY); - const exists = yield* fsEffect( - `check built workspace dependency ${distEntry}`, - fileSystem.exists(distEntry), - ); - if (!exists) { - return yield* installError( - `Build ${packageName} before using NanoClaw workspace install mode; expected ${distEntry}`, - ); - } - }); -} - -function createWorkspaceTarball( - packageDir: string, - packageName: string, - packRoot: string, -) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const outputDir = join(packRoot, basename(packageDir)); - yield* fsEffect( - `create ${packageName} pack output directory`, - fileSystem.makeDirectory(outputDir, { recursive: true }), - ); - const command = Command.make( - "pnpm", - "pack", - "--pack-destination", - outputDir, - ).pipe(Command.workingDirectory(packageDir)); - yield* commandOutputEffect( - `pack workspace package ${packageName}`, - command, - { - timeout: WORKSPACE_PACK_TIMEOUT_MS, - }, - ); - const entries = (yield* fsEffect( - `list packed workspace package ${packageName}`, - fileSystem.readDirectory(outputDir), - )).filter((entry) => entry.endsWith(TARBALL_EXTENSION)); - const entry = yield* requireSoleEntry( - entries, - `packed tarball for ${packageName}`, - ); - return join(outputDir, entry); - }); -} - -function readPackedWorkspaceManifest(tarballPath: string, packageName: string) { - return commandOutputEffect( - `read packed ${packageName} manifest`, - Command.make("tar", "-xOf", tarballPath, "package/package.json"), - { timeout: WORKSPACE_PACK_TIMEOUT_MS }, - ).pipe( - Effect.flatMap((output) => - decodeWorkspacePackageManifest(output.stdout, packageName), - ), - ); -} - -function readWorkspacePackageManifest( - manifestPath: string, - packageName: string, -) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fsEffect( - `read workspace package manifest ${manifestPath}`, - fileSystem.readFileString(manifestPath, "utf8"), - ), - ), - Effect.flatMap((contents) => - decodeWorkspacePackageManifest(contents, packageName), - ), - ); -} - -function decodeWorkspacePackageManifest(contents: string, packageName: string) { - return Effect.try({ - try: () => { - const value: unknown = JSON.parse(contents); - const manifest = requireRecord(value, `${packageName} package.json`); - const name = requireString(manifest.name, `${packageName} name`); - const version = requireString(manifest.version, `${packageName} version`); - if (name !== packageName) { - throw installError( - `Expected packed workspace package ${packageName}; found ${name}`, - ); - } - return { - name, - version, - dependencies: optionalRecord( - manifest.dependencies, - `${packageName} dependencies`, - ), - } satisfies WorkspacePackageManifest; - }, - catch: (cause) => - cause instanceof NanoclawInstallError - ? cause - : installError(`Unable to decode ${packageName} package.json`, cause), - }); -} - -/** - * Verify that packed client and protocol manifests agree on one version. - * - * @param input Input value to process. - * @param input.clientManifest Value supplied to the operation. - * @param input.protocolManifest Value supplied to the operation. - * @param input.clientVersion Value supplied to the operation. - * @param input.protocolVersion Value supplied to the operation. - * @internal - * @returns The assert packed workspace versions result. - */ -export function assertPackedWorkspaceVersions(input: { - readonly clientManifest: WorkspacePackageManifest; - readonly protocolManifest: WorkspacePackageManifest; - readonly clientVersion: string; - readonly protocolVersion: string; -}) { - return Effect.try({ - try: () => { - requireExactValue( - input.clientManifest.name, - CLIENT_PACKAGE_NAME, - "packed client name", - ); - requireExactValue( - input.protocolManifest.name, - PROTOCOL_PACKAGE_NAME, - "packed protocol name", - ); - requireExactValue( - input.clientManifest.version, - input.clientVersion, - "packed client version", - ); - requireExactValue( - input.protocolManifest.version, - input.protocolVersion, - "packed protocol version", - ); - requireExactValue( - input.clientManifest.dependencies[PROTOCOL_PACKAGE_NAME], - input.protocolManifest.version, - "packed client protocol dependency", - ); - }, - catch: (cause) => - cause instanceof NanoclawInstallError - ? cause - : installError( - "Unable to validate packed NanoClaw workspace versions", - cause, - ), - }); -} - -/** - * Resolves a ready generation without building so integration probes can - * guarantee they exercise the warm install path. - * @param installMode Value supplied to the operation. - * @internal - * @returns The find warm nanoclaw runtime install effect result. - */ -export function findWarmNanoclawRuntimeInstallEffect(installMode: InstallMode) { - return Effect.scoped( - Effect.gen(function* () { - const warm = yield* warmNanoclawInstall(installMode); - if (warm !== null) { - return warm; - } - const target = yield* resolveCacheTarget(installMode); - const generationDir = yield* nanoclawInstallCache( - target.cacheRoot, - ).findCacheGeneration(target.cacheFingerprint); - return generationDir === null - ? null - : runtimeInstall(generationDir, target.cacheFingerprint); - }), - ).pipe(Effect.withSpan("findWarmNanoclawRuntimeInstallEffect")); -} - -function runtimeInstall( - cacheDir: string, - cacheFingerprint: string, -): NanoclawRuntimeInstall { - return { - cacheDir, - cacheFingerprint, - containerImage: - NANOCLAW_IMAGE_REPOSITORY + ":" + containerImageTag(cacheFingerprint), - }; -} - -function containerImageTag(cacheFingerprint: string): string { - return NANOCLAW_IMAGE_TAG_PREFIX + "-" + cacheFingerprint; -} - -/** - * Binds the immutable cache lifecycle to this installer's error channel for - * one cache root. - * @param cacheRoot Value supplied to the operation. - * @internal - * @returns The nanoclaw install cache result. - */ -export function nanoclawInstallCache(cacheRoot: string) { - return makeImmutableCache(cacheRoot, installError); -} - -function ensureBundledAssetExists(assetPath: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const exists = yield* fsEffect( - "check bundled nanoclaw asset " + assetPath, - fileSystem.exists(assetPath), - ); - if (!exists) { - return yield* installError( - "Expected bundled NanoClaw asset at " + - assetPath + - "; rebuild @moltzap/simulator", - ); - } - }); -} - -function copyBundledAsset(assetName: string, destination: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const source = bundledAssetPath(assetName); - yield* ensureBundledAssetExists(source); - yield* fsEffect( - "copy bundled NanoClaw asset " + assetName, - fileSystem.copyFile(source, destination), - ); - }); -} - -function injectBundledAssets(tmpDir: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* copyBundledAsset( - "moltzap.ts", - join(tmpDir, "src/channels/moltzap.ts"), - ); - yield* copyBundledAsset( - "moltzap-eval-provision.ts", - join(tmpDir, "src/moltzap-eval-provision.ts"), - ); - - const barrelPath = join(tmpDir, "src/channels/index.ts"); - const barrel = yield* fsEffect( - "read nanoclaw channel barrel " + barrelPath, - fileSystem.readFileString(barrelPath, "utf8"), - ); - if (!barrel.includes("import './moltzap.js';")) { - yield* fsEffect( - "write nanoclaw channel barrel " + barrelPath, - fileSystem.writeFileString( - barrelPath, - barrel.trimEnd() + "\n\nimport './moltzap.js';\n", - ), - ); - } - - const skillDir = join(tmpDir, "container/skills/moltzap"); - yield* fsEffect( - "create nanoclaw moltzap skill directory", - fileSystem.makeDirectory(skillDir, { recursive: true }), - ); - yield* copyBundledAsset("SKILL.md", join(skillDir, "SKILL.md")); - // The bundled manifest mirrors upstream's with two deliberate - // divergences: @moltzap/{client,protocol} are added for the channel, - // and better-sqlite3 rides the v12 line because upstream's exact 11.x - // pin has no prebuilds for current host Node and its source no longer - // compiles against modern V8. - yield* copyBundledAsset("package.json", join(tmpDir, "package.json")); - yield* copyBundledAsset( - "package-lock.json", - join(tmpDir, "package-lock.json"), - ); - }); -} - -function workspaceTarballSpec(tarball: NanoclawWorkspaceTarball): string { - return ( - "file:" + posix.join(WORKSPACE_VENDOR_DIRECTORY, tarball.tarballFileName) - ); -} - -function copyWorkspaceDependencyTarballs( - stagingDir: string, - dependencies: NanoclawWorkspaceDependencies, -) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const vendorDir = join(stagingDir, WORKSPACE_VENDOR_DIRECTORY); - yield* fsEffect( - "create NanoClaw workspace vendor directory", - fileSystem.makeDirectory(vendorDir, { recursive: true }), - ); - yield* Effect.forEach( - [dependencies.client, dependencies.protocol], - (tarball) => - fsEffect( - `copy ${tarball.packageName} workspace tarball`, - fileSystem.copyFile( - tarball.tarballPath, - join(vendorDir, tarball.tarballFileName), - ), - ), - { concurrency: 2, discard: true }, - ); - }); -} - -/** - * Rewrite NanoClaw's staged manifest to consume the workspace tarballs. - * - * @param stagingDir Value supplied to the operation. - * @param dependencies Value supplied to the operation. - * @internal - * @returns The rewrite nanoclaw workspace manifest result. - */ -export const rewriteNanoclawWorkspaceManifest = Effect.fn( - "rewriteNanoclawWorkspaceManifest", -)(function* (stagingDir: string, dependencies: NanoclawWorkspaceDependencies) { - const fileSystem = yield* FileSystem.FileSystem; - const manifestPath = join(stagingDir, "package.json"); - const manifestText = yield* fsEffect( - "read staged NanoClaw package.json", - fileSystem.readFileString(manifestPath, "utf8"), - ); - const rewrittenText = yield* Effect.try({ - try: () => rewriteWorkspaceManifestText(manifestText, dependencies), - catch: (cause) => - cause instanceof NanoclawInstallError - ? cause - : installError("Unable to rewrite staged NanoClaw package.json", cause), - }); - yield* fsEffect( - "write staged NanoClaw workspace package.json", - fileSystem.writeFileString(manifestPath, rewrittenText), - ); -}); - -function rewriteWorkspaceManifestText( - manifestText: string, - dependencies: NanoclawWorkspaceDependencies, -): string { - const parsed: unknown = JSON.parse(manifestText); - const manifest = requireRecord(parsed, "staged NanoClaw package.json"); - const manifestDependencies = requireRecord( - manifest.dependencies, - "staged NanoClaw dependencies", - ); - const rewritten = { - ...manifest, - dependencies: { - ...manifestDependencies, - [CLIENT_PACKAGE_NAME]: workspaceTarballSpec(dependencies.client), - [PROTOCOL_PACKAGE_NAME]: workspaceTarballSpec(dependencies.protocol), - }, - }; - return JSON.stringify(rewritten, null, JSON_INDENT_SPACES) + "\n"; -} - -/** - * Install and validate NanoClaw's workspace package dependencies. - * - * @param stagingDir Value supplied to the operation. - * @param dependencies Value supplied to the operation. - * @internal - * @returns The materialize nanoclaw workspace dependencies result. - */ -export const materializeNanoclawWorkspaceDependencies = Effect.fn( - "materializeNanoclawWorkspaceDependencies", -)(function* (stagingDir: string, dependencies: NanoclawWorkspaceDependencies) { - yield* copyWorkspaceDependencyTarballs(stagingDir, dependencies); - yield* rewriteNanoclawWorkspaceManifest(stagingDir, dependencies); - yield* execEffect( - "HUSKY=0 npm install --package-lock-only --ignore-scripts", - { - cwd: stagingDir, - timeout: WORKSPACE_LOCK_TIMEOUT_MS, - }, - ); - yield* assertNanoclawWorkspaceLock(stagingDir, dependencies); -}); - -/** - * Verify NanoClaw's lockfile contains only the expected workspace artifacts. - * - * @param stagingDir Value supplied to the operation. - * @param dependencies Value supplied to the operation. - * @internal - * @returns The assert nanoclaw workspace lock result. - */ -export function assertNanoclawWorkspaceLock( - stagingDir: string, - dependencies: NanoclawWorkspaceDependencies, -) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fsEffect( - "read staged NanoClaw workspace package-lock.json", - fileSystem.readFileString( - join(stagingDir, "package-lock.json"), - "utf8", - ), - ), - ), - Effect.flatMap((lockText) => - Effect.try({ - try: () => { - validateNanoclawWorkspaceLock(lockText, dependencies); - }, - catch: (cause) => - cause instanceof NanoclawInstallError - ? cause - : installError( - "Unable to validate staged NanoClaw workspace package-lock.json", - cause, - ), - }), - ), - Effect.withSpan("assertNanoclawWorkspaceLock"), - ); -} - -function validateNanoclawWorkspaceLock( - lockText: string, - dependencies: NanoclawWorkspaceDependencies, -): void { - if (REGISTRY_MOLTZAP_PATTERN.test(lockText)) { - throw installError( - "NanoClaw workspace lock contains a MoltZap registry artifact", - ); - } - const parsed: unknown = JSON.parse(lockText); - const lock = requireRecord(parsed, "NanoClaw workspace package lock"); - const packages = requireRecord( - lock.packages, - "NanoClaw workspace lock packages", - ); - const root = requireRecord(packages[""], "NanoClaw workspace lock root"); - const rootDependencies = requireRecord( - root.dependencies, - "NanoClaw workspace lock root dependencies", - ); - requireExactValue( - rootDependencies[CLIENT_PACKAGE_NAME], - workspaceTarballSpec(dependencies.client), - "NanoClaw lock client dependency", - ); - requireExactValue( - rootDependencies[PROTOCOL_PACKAGE_NAME], - workspaceTarballSpec(dependencies.protocol), - "NanoClaw lock protocol dependency", - ); - requireExactMoltzapPackageKeys(packages); - validateWorkspaceLockEntry(packages, dependencies.client); - validateWorkspaceLockEntry(packages, dependencies.protocol); - const clientEntry = requireRecord( - packages[`node_modules/${CLIENT_PACKAGE_NAME}`], - "NanoClaw lock client entry", - ); - const clientDependencies = requireRecord( - clientEntry.dependencies, - "NanoClaw lock client dependencies", - ); - requireExactValue( - clientDependencies[PROTOCOL_PACKAGE_NAME], - dependencies.protocol.version, - "NanoClaw lock client protocol dependency", - ); -} - -function requireExactMoltzapPackageKeys( - packages: Readonly>, -): void { - const actual = Object.keys(packages) - .filter((location) => - /(?:^|\/)node_modules\/@moltzap\/[^/]+$/u.test(location), - ) - .sort((left, right) => left.localeCompare(right)); - const expected = [ - `node_modules/${CLIENT_PACKAGE_NAME}`, - `node_modules/${PROTOCOL_PACKAGE_NAME}`, - ].sort((left, right) => left.localeCompare(right)); - if ( - actual.length !== expected.length || - actual.some((location, index) => location !== expected[index]) - ) { - throw installError( - `Expected only direct MoltZap workspace lock entries; found ${actual.join(", ") || "none"}`, - ); - } -} - -function validateWorkspaceLockEntry( - packages: Readonly>, - tarball: NanoclawWorkspaceTarball, -): void { - const location = `node_modules/${tarball.packageName}`; - const entry = requireRecord( - packages[location], - `NanoClaw lock entry ${location}`, - ); - requireExactValue(entry.version, tarball.version, `${location} version`); - requireExactValue( - entry.resolved, - workspaceTarballSpec(tarball), - `${location} resolved`, - ); - requireExactValue( - entry.integrity, - tarball.integrity, - `${location} integrity`, - ); -} - -function optionalRecord( - value: unknown, - label: string, -): Readonly> { - return value === undefined ? {} : requireRecord(value, label); -} - -function downloadPinnedSource(destDir: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fsEffect( - "create nanoclaw download directory " + destDir, - fileSystem.makeDirectory(destDir, { recursive: true }), - ); - const tarballPath = join(destDir, "nanoclaw.tar.gz"); - yield* execEffect( - 'curl -fsSL "' + NANOCLAW_URL + '" -o "' + tarballPath + '"', - { timeout: 60_000 }, - ); - yield* execEffect( - 'tar -xzf "' + - tarballPath + - '" -C "' + - destDir + - '" --strip-components=1', - { timeout: 30_000 }, - ); - yield* fsEffect( - "remove downloaded nanoclaw tarball " + tarballPath, - fileSystem.remove(tarballPath), - ); - }); -} - -function preflightDocker() { - return execEffect("docker info", { timeout: 5_000 }).pipe( - Effect.mapError((cause) => - installError( - "NanoClaw requires Docker on the host. docker info failed: " + - cause.message, - cause, - ), - ), - ); -} - -function dockerImageExists(containerImage: string) { - return Command.exitCode( - Command.make("docker", "image", "inspect", containerImage), - ).pipe( - Effect.timeoutFail({ - duration: Duration.seconds(10), - onTimeout: () => - installError( - "timed out checking NanoClaw container image " + containerImage, - ), - }), - Effect.map((code) => Number(code) === 0), - Effect.mapError((cause) => - cause instanceof NanoclawInstallError - ? cause - : installError( - "check NanoClaw container image " + containerImage, - cause, - ), - ), - ); -} - -function buildContainerImage(install: NanoclawRuntimeInstall) { - // Upstream's container/build.sh derives the image name from its own - // checkout path; the simulator owns naming (one fingerprint-tagged image - // shared by every per-agent runtime dir, selected via the CONTAINER_IMAGE - // env override), so it drives `docker build` directly. A cold build pulls - // the base image and apt/CLI layers — multi-minute single-layer steps the - // hang guard must not trip on. - return execEffect( - 'docker build -t "' + install.containerImage + '" container', - { cwd: install.cacheDir, timeout: 900_000 }, - ); -} - -function requireContainerImage(containerImage: string) { - return dockerImageExists(containerImage).pipe( - Effect.flatMap((exists) => - exists - ? Effect.void - : Effect.fail( - installError( - "NanoClaw container build did not create " + containerImage, - ), - ), - ), - ); -} - -function ensureContainerImage(install: NanoclawRuntimeInstall) { - return Effect.gen(function* () { - if (yield* dockerImageExists(install.containerImage)) { - return; - } - yield* buildContainerImage(install); - yield* requireContainerImage(install.containerImage); - }); -} - -// The npm leg (host deps, dist, upgrade marker) and the image leg share -// only the immutable container/ build context, so they run concurrently. -function buildRuntime(install: NanoclawRuntimeInstall) { - return Effect.all( - [ - execEffect("HUSKY=0 npm ci", { - cwd: install.cacheDir, - timeout: 300_000, - }).pipe( - Effect.andThen( - execEffect("npm run build", { - cwd: install.cacheDir, - timeout: 120_000, - }), - ), - Effect.andThen(stampUpgradeMarker(install.cacheDir)), - ), - buildContainerImage(install).pipe( - Effect.andThen(requireContainerImage(install.containerImage)), - ), - ], - { concurrency: 2, discard: true }, - ); -} - -// NanoClaw's startup tripwire requires data/upgrade-state.json to match the -// code version; stamping through upstream's own writer keeps the marker -// schema tracking upstream across SHA bumps. -function stampUpgradeMarker(sourceDir: string) { - return execEffect( - '"node_modules/.bin/tsx" scripts/upgrade-state.ts set "" moltzap-simulator', - { cwd: sourceDir, timeout: 60_000 }, - ); -} - -function buildAndPublish(target: NanoclawCacheTarget) { - const cache = nanoclawInstallCache(target.cacheRoot); - return Effect.gen(function* () { - const buildingDir = yield* cache.createBuildingCache(); - return yield* Effect.gen(function* () { - const buildingInstall = runtimeInstall( - buildingDir, - target.cacheFingerprint, - ); - yield* downloadPinnedSource(buildingDir); - yield* injectBundledAssets(buildingDir); - if (target.installMode === "workspace") { - yield* materializeNanoclawWorkspaceDependencies( - buildingDir, - target.workspaceDependencies, - ); - } - yield* buildRuntime(buildingInstall); - yield* cache.writeReadyMarker(buildingDir, target.cacheFingerprint); - const generationDir = yield* cache.publishCacheGeneration(buildingDir); - return runtimeInstall(generationDir, target.cacheFingerprint); - }).pipe(Effect.ensuring(cache.removeBuildingCacheBestEffort(buildingDir))); - }); -} - -/** - * Executes the ensure nanoclaw runtime installed effect operation. - * @param installMode Value supplied to the operation. - * @returns The ensure nanoclaw runtime installed effect result. - */ -export function ensureNanoclawRuntimeInstalledEffect(installMode: InstallMode) { - return Effect.scoped( - WARM_INSTALLS.getOrAcquire( - installMode, - CACHE_BUILD_PERMIT.withPermits(1)( - Effect.gen(function* () { - const target = yield* resolveCacheTarget(installMode); - return yield* verifyOrBuildInstall(target); - }), - ), - ), - ).pipe(Effect.withSpan("ensureNanoclawRuntimeInstalledEffect")); -} - -function warmNanoclawInstall(installMode: InstallMode) { - return WARM_INSTALLS.peek(installMode); -} - -function verifyOrBuildInstall(target: NanoclawCacheTarget) { - const cache = nanoclawInstallCache(target.cacheRoot); - return Effect.gen(function* () { - yield* cache.sweepStaleBuildingCaches(); - yield* preflightDocker(); - const generationDir = yield* cache.findCacheGeneration( - target.cacheFingerprint, - ); - if (generationDir === null) { - return yield* buildAndPublish(target); - } - const install = runtimeInstall(generationDir, target.cacheFingerprint); - yield* ensureContainerImage(install); - return install; - }); -} diff --git a/packages/simulator/src/runtime/nanoclaw/onecli.test.ts b/packages/simulator/src/runtime/nanoclaw/onecli.test.ts deleted file mode 100644 index 2d7db2024..000000000 --- a/packages/simulator/src/runtime/nanoclaw/onecli.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { Buffer } from "node:buffer"; -import { platform } from "node:os"; -import { execPath } from "node:process"; -import { Command, FileSystem } from "@effect/platform"; -import { NodeContext } from "@effect/platform-node"; -import { Duration, Effect, Fiber } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - buildExclusiveFileLockProcessPlan, - runCommandWithExclusiveFileLock, -} from "./onecli.js"; - -const LOCK_FILE_NAME = "onecli-start.lock"; -const LOCK_CONTENTS = "persistent-lock-inode"; -const FIRST_CONTENDER = "first"; -const SECOND_CONTENDER = "second"; -const PROTECTED_COMMAND_DURATION_MS = 200; -const FILE_POLL_INTERVAL_MS = 10; -const TEST_TIMEOUT_MS = 3_000; -const ZERO_EXIT_CODE = 0; -const EXPECTED_FAILURE_EXIT_CODE = 7; -const LOCK_EVENT_SCRIPT = ` -const fs = require("node:fs"); -const [eventPath, label, durationMs] = process.argv.slice(1); -fs.appendFileSync(eventPath, "start:" + label + "\\n"); -setTimeout(() => { - fs.appendFileSync(eventPath, "end:" + label + "\\n"); -}, Number(durationMs)); -`; -const MARK_AND_WAIT_SCRIPT = ` -require("node:fs").writeFileSync(process.argv[1], "held"); -setInterval(() => {}, 0x7fffffff); -`; -const PARENT_CRASH_SCRIPT = ` -const { spawn } = require("node:child_process"); -const fs = require("node:fs"); -const payload = JSON.parse( - Buffer.from(process.argv[1], "base64url").toString("utf8"), -); -const child = spawn(payload.command, payload.args, { - detached: true, - stdio: ["pipe", "ignore", "ignore"], -}); -child.unref(); -const waitForMarker = () => { - if (fs.existsSync(payload.markerPath)) { - process.exit(0); - } - setTimeout(waitForMarker, ${FILE_POLL_INTERVAL_MS}); -}; -waitForMarker(); -`; -const FIRST_THEN_SECOND_EVENTS = [ - `start:${FIRST_CONTENDER}`, - `end:${FIRST_CONTENDER}`, - `start:${SECOND_CONTENDER}`, - `end:${SECOND_CONTENDER}`, -]; -const SECOND_THEN_FIRST_EVENTS = [ - `start:${SECOND_CONTENDER}`, - `end:${SECOND_CONTENDER}`, - `start:${FIRST_CONTENDER}`, - `end:${FIRST_CONTENDER}`, -]; - -describe.skipIf(platform() !== "darwin" && platform() !== "linux")( - "runCommandWithExclusiveFileLock", - () => { - it("serializes protected subprocesses", serializesContenders); - it("uses an existing unlocked lock file", usesExistingUnlockedFile); - it("releases the lock after command failure", releasesAfterFailure); - it( - "releases the lock when its owning fiber is interrupted", - releasesOnExit, - ); - it("releases the lock when the parent process crashes", releasesOnCrash); - }, -); - -function serializesContenders() { - return runTest( - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped(); - const lockPath = `${directory}/${LOCK_FILE_NAME}`; - const eventPath = `${directory}/events.log`; - - yield* Effect.all( - [ - runCommandWithExclusiveFileLock( - { path: lockPath }, - lockEventCommand(eventPath, FIRST_CONTENDER), - ), - runCommandWithExclusiveFileLock( - { path: lockPath }, - lockEventCommand(eventPath, SECOND_CONTENDER), - ), - ], - { concurrency: 2 }, - ); - - const events = (yield* fileSystem.readFileString(eventPath)) - .trim() - .split("\n"); - expect( - events.join("\n") === FIRST_THEN_SECOND_EVENTS.join("\n") || - events.join("\n") === SECOND_THEN_FIRST_EVENTS.join("\n"), - ).toBe(true); - }), - ), - ); -} - -function usesExistingUnlockedFile() { - return runTest( - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped(); - const lockPath = `${directory}/${LOCK_FILE_NAME}`; - yield* fileSystem.writeFileString(lockPath, LOCK_CONTENTS); - - yield* runCommandWithExclusiveFileLock( - { path: lockPath }, - successfulCommand(), - ); - - expect(yield* fileSystem.readFileString(lockPath)).toBe(LOCK_CONTENTS); - }), - ), - ); -} - -function releasesAfterFailure() { - return runTest( - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped(); - const lockPath = `${directory}/${LOCK_FILE_NAME}`; - - const exitCode = yield* runCommandWithExclusiveFileLock( - { path: lockPath }, - { - command: execPath, - args: ["-e", `process.exit(${EXPECTED_FAILURE_EXIT_CODE})`], - }, - ); - expect(Number(exitCode)).toBe(EXPECTED_FAILURE_EXIT_CODE); - - const retryExitCode = yield* runCommandWithExclusiveFileLock( - { path: lockPath }, - successfulCommand(), - ); - expect(Number(retryExitCode)).toBe(ZERO_EXIT_CODE); - }), - ), - ); -} - -function releasesOnExit() { - return runTest( - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped(); - const lockPath = `${directory}/${LOCK_FILE_NAME}`; - const markerPath = `${directory}/interrupted-holder`; - const ownerFiber = yield* runCommandWithExclusiveFileLock( - { path: lockPath }, - markerAndWaitCommand(markerPath), - ).pipe(Effect.fork); - yield* waitForFile(fileSystem, markerPath); - - yield* Fiber.interrupt(ownerFiber); - const exitCode = yield* runCommandWithExclusiveFileLock( - { path: lockPath }, - successfulCommand(), - ).pipe(Effect.timeout(Duration.millis(TEST_TIMEOUT_MS))); - - expect(Number(exitCode)).toBe(ZERO_EXIT_CODE); - }), - ), - ); -} - -function releasesOnCrash() { - return runTest( - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const directory = yield* fileSystem.makeTempDirectoryScoped(); - const lockPath = `${directory}/${LOCK_FILE_NAME}`; - const markerPath = `${directory}/crashed-parent-holder`; - const lockPlan = yield* buildExclusiveFileLockProcessPlan( - { path: lockPath }, - markerAndWaitCommand(markerPath), - ); - const parentPayload = Buffer.from( - JSON.stringify({ ...lockPlan, markerPath }), - ).toString("base64url"); - - const parentExitCode = yield* Command.exitCode( - Command.make(execPath, "-e", PARENT_CRASH_SCRIPT, parentPayload), - ).pipe(Effect.timeout(Duration.millis(TEST_TIMEOUT_MS))); - expect(Number(parentExitCode)).toBe(ZERO_EXIT_CODE); - - const retryExitCode = yield* runCommandWithExclusiveFileLock( - { path: lockPath }, - successfulCommand(), - ).pipe(Effect.timeout(Duration.millis(TEST_TIMEOUT_MS))); - expect(Number(retryExitCode)).toBe(ZERO_EXIT_CODE); - }), - ), - ); -} - -function lockEventCommand( - eventPath: string, - label: string, -): { - readonly command: string; - readonly args: readonly string[]; -} { - return { - command: execPath, - args: [ - "-e", - LOCK_EVENT_SCRIPT, - eventPath, - label, - String(PROTECTED_COMMAND_DURATION_MS), - ], - }; -} - -function markerAndWaitCommand(markerPath: string) { - return { - command: execPath, - args: ["-e", MARK_AND_WAIT_SCRIPT, markerPath], - }; -} - -function successfulCommand() { - return { - command: execPath, - args: ["-e", ""], - }; -} - -function waitForFile( - fileSystem: FileSystem.FileSystem, - path: string, -): Effect.Effect { - return fileSystem.exists(path).pipe( - Effect.orDie, - Effect.flatMap((exists) => - exists - ? Effect.void - : Effect.sleep(Duration.millis(FILE_POLL_INTERVAL_MS)).pipe( - Effect.zipRight(waitForFile(fileSystem, path)), - ), - ), - Effect.timeout(Duration.millis(TEST_TIMEOUT_MS)), - Effect.orDie, - ); -} - -function runTest(effect: Effect.Effect) { - return Effect.runPromise( - effect.pipe(Effect.provide(NodeContext.layer), Effect.orDie), - ); -} diff --git a/packages/simulator/src/runtime/nanoclaw/onecli.ts b/packages/simulator/src/runtime/nanoclaw/onecli.ts deleted file mode 100644 index 8e56c553e..000000000 --- a/packages/simulator/src/runtime/nanoclaw/onecli.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** - * OneCLI gateway acquisition for NanoClaw runtimes. - * - * NanoClaw's container runner obtains per-container credentials from this - * host-local gateway. The in-process permit suppresses duplicate startup - * work in one simulator while the native file lock serializes independent - * simulator processes. - */ -import { Buffer } from "node:buffer"; -import { homedir, platform } from "node:os"; -import { join } from "node:path"; -import { execPath } from "node:process"; -import { - Command, - FileSystem, - HttpClient, - HttpClientRequest, -} from "@effect/platform"; -import type { CommandExecutor } from "@effect/platform/CommandExecutor"; -import { Data, Duration, Effect } from "effect"; - -/** Provides the onecli gateway url runtime value. */ -export const ONECLI_GATEWAY_URL = "http://127.0.0.1:10254"; - -const ONECLI_COMPOSE_PATH = join(homedir(), ".onecli/docker-compose.yml"); -const ONECLI_START_LOCK_PATH = join( - homedir(), - ".onecli/moltzap-simulator-start.lock", -); -const ONECLI_START_PERMIT = Effect.runSync(Effect.makeSemaphore(1)); -const ONECLI_PROBE_TIMEOUT_MS = 2_000; -const ONECLI_READY_PROBE_LIMIT = 20; -const ONECLI_READY_PROBE_INTERVAL_MS = 500; -const ONECLI_COMPOSE_TIMEOUT_MS = 120_000; -const MILLISECONDS_PER_SECOND = 1_000; -const DOCKER_COMMAND = "docker"; - -/** Configures exclusive file lock. */ -export interface ExclusiveFileLockOptions { - readonly path: string; -} - -/** Describes exclusive file lock command. */ -export interface ExclusiveFileLockCommand { - readonly command: string; - readonly args: readonly string[]; - readonly cwd?: string; -} - -/** Describes exclusive file lock process plan. */ -export interface ExclusiveFileLockProcessPlan { - readonly command: string; - readonly args: readonly string[]; -} - -/** Reports exclusive file lock failures. */ -export class ExclusiveFileLockError extends Data.TaggedError( - "ExclusiveFileLockError", -)<{ - readonly reason: string; - readonly cause?: unknown; -}> { - override get message(): string { - return this.reason; - } -} - -const LOCK_SUPERVISOR_SCRIPT = ` -const { spawn } = require("node:child_process"); -const payload = JSON.parse( - Buffer.from(process.argv[1], "base64url").toString("utf8"), -); -let child; -let stopping = false; -const stop = () => { - if (stopping) return; - stopping = true; - if (child?.pid !== undefined) { - try { - process.kill(-child.pid, "SIGKILL"); - } catch { // #ignore-sloppy-code[bare-catch]: group-kill ESRCH falls back to direct kill — the fallback is the handling - child.kill("SIGKILL"); - } - } - process.exit(1); -}; -process.stdin.resume(); -process.stdin.once("end", stop); -process.stdin.once("close", stop); -process.on("SIGTERM", stop); -child = spawn(payload.command, payload.args, { - cwd: payload.cwd, - detached: true, - stdio: ["ignore", "inherit", "inherit"], - windowsHide: true, -}); -child.once("error", (error) => { - console.error(error); - process.exit(1); -}); -child.once("exit", (code) => { - process.exit(code ?? 1); -}); -`; -const DARWIN_PLATFORM = "darwin"; -const LINUX_PLATFORM = "linux"; -const DARWIN_LOCK_COMMAND = "/usr/bin/lockf"; -const DARWIN_KEEP_LOCK_FILE_FLAG = "-k"; -const LINUX_LOCK_COMMAND = "flock"; -const LINUX_EXCLUSIVE_FLAG = "-x"; - -function toLockError(reason: string, cause?: unknown) { - return new ExclusiveFileLockError({ - reason, - ...(cause === undefined ? {} : { cause }), - }); -} - -/** - * Build the native lock-holder command for the current operating system. - * @param options Options that control the operation. - * @param protectedCommand Value supplied to the operation. - * @returns The created exclusive file lock process plan. - */ -export function buildExclusiveFileLockProcessPlan( - options: ExclusiveFileLockOptions, - protectedCommand: ExclusiveFileLockCommand, -): Effect.Effect { - const payload = Buffer.from(JSON.stringify(protectedCommand)).toString( - "base64url", - ); - const supervisorArgs = ["-e", LOCK_SUPERVISOR_SCRIPT, payload]; - const currentPlatform = platform(); - if (currentPlatform === DARWIN_PLATFORM) { - return Effect.succeed({ - command: DARWIN_LOCK_COMMAND, - args: [ - DARWIN_KEEP_LOCK_FILE_FLAG, - options.path, - execPath, - ...supervisorArgs, - ], - }); - } - if (currentPlatform === LINUX_PLATFORM) { - return Effect.succeed({ - command: LINUX_LOCK_COMMAND, - args: [LINUX_EXCLUSIVE_FLAG, options.path, execPath, ...supervisorArgs], - }); - } - return Effect.fail( - toLockError( - `cross-process file locking is unsupported on ${currentPlatform}`, - ), - ); -} - -/** - * Run a command while the operating system holds an exclusive file lock. - * @param options Options that control the operation. - * @param protectedCommand Value supplied to the operation. - * @returns The run command with exclusive file lock result. - */ -export function runCommandWithExclusiveFileLock( - options: ExclusiveFileLockOptions, - protectedCommand: ExclusiveFileLockCommand, -) { - return buildExclusiveFileLockProcessPlan(options, protectedCommand).pipe( - Effect.flatMap((plan) => - Command.make(plan.command, ...plan.args).pipe( - Command.stdout("inherit"), - Command.stderr("inherit"), - Command.exitCode, - ), - ), - Effect.mapError((cause) => - cause instanceof ExclusiveFileLockError - ? cause - : toLockError(`run command under lock ${options.path}`, cause), - ), - Effect.withSpan("runCommandWithExclusiveFileLock"), - ); -} - -/** Represents onecli gateway error factory conditions. */ -export type OnecliGatewayErrorFactory = ( - reason: string, - cause?: unknown, -) => E; - -function isOnecliReachable(): Effect.Effect< - boolean, - never, - HttpClient.HttpClient -> { - return Effect.gen(function* () { - // A failed status, including an unrelated process on the port, does not - // satisfy the gateway readiness contract. - const client = HttpClient.filterStatusOk(yield* HttpClient.HttpClient); - yield* client.execute( - HttpClientRequest.get(`${ONECLI_GATEWAY_URL}/api/container-config`), - ); - return true; - }).pipe( - Effect.timeoutFail({ - duration: Duration.millis(ONECLI_PROBE_TIMEOUT_MS), - onTimeout: () => new Error("OneCLI reachability probe timed out"), - }), - Effect.catchAll((reachabilityError) => - reachabilityError instanceof Error && - reachabilityError.message.includes("timed out") - ? Effect.succeed(false) - : Effect.logWarning( - "failed to probe OneCLI reachability", - reachabilityError, - ).pipe(Effect.as(false)), - ), - ); -} - -function runOnecliComposeUnderLock( - makeError: OnecliGatewayErrorFactory, -): Effect.Effect { - return runCommandWithExclusiveFileLock( - { path: ONECLI_START_LOCK_PATH }, - { - command: DOCKER_COMMAND, - args: [ - "compose", - "-p", - "onecli", - "-f", - ONECLI_COMPOSE_PATH, - "up", - "-d", - "--wait", - ], - }, - ).pipe( - Effect.mapError((cause) => - makeError("start OneCLI under the host lock", cause), - ), - Effect.timeoutFail({ - duration: Duration.millis(ONECLI_COMPOSE_TIMEOUT_MS), - onTimeout: () => makeError("OneCLI compose startup timed out"), - }), - Effect.flatMap((composeExitCode) => - Number(composeExitCode) === 0 - ? Effect.void - : Effect.fail( - makeError( - `OneCLI compose startup failed with exit code ${composeExitCode}`, - ), - ), - ), - ); -} - -function waitForOnecliReadiness( - makeError: OnecliGatewayErrorFactory, -): Effect.Effect { - return Effect.gen(function* () { - // `--wait` observes compose healthchecks. The bounded HTTP probe also - // waits for the gateway listener to accept real requests. - for (let probe = 0; probe < ONECLI_READY_PROBE_LIMIT; probe++) { - if (yield* isOnecliReachable()) { - return; - } - yield* Effect.sleep(Duration.millis(ONECLI_READY_PROBE_INTERVAL_MS)); - } - - const probeWindowSeconds = - (ONECLI_READY_PROBE_LIMIT * ONECLI_READY_PROBE_INTERVAL_MS) / - MILLISECONDS_PER_SECOND; - return yield* Effect.fail( - makeError( - `OneCLI gateway started but not reachable at ${ONECLI_GATEWAY_URL} ` + - `after ${probeWindowSeconds}s. ` + - `Check: docker compose -p onecli -f ${ONECLI_COMPOSE_PATH} logs`, - ), - ); - }); -} - -function startOnecliUnderLock( - makeError: OnecliGatewayErrorFactory, -): Effect.Effect { - return Effect.gen(function* () { - if (yield* isOnecliReachable()) { - return; - } - yield* runOnecliComposeUnderLock(makeError); - yield* waitForOnecliReadiness(makeError); - }); -} - -/** - * Executes the ensure onecli running operation. - * @param makeError Value supplied to the operation. - * @returns The ensure onecli running result. - */ -export function ensureOnecliRunning( - makeError: OnecliGatewayErrorFactory, -): Effect.Effect< - void, - E, - CommandExecutor | FileSystem.FileSystem | HttpClient.HttpClient -> { - return Effect.gen(function* () { - if (yield* isOnecliReachable()) { - return; - } - - const fileSystem = yield* FileSystem.FileSystem; - const composeFileExists = yield* fileSystem - .exists(ONECLI_COMPOSE_PATH) - .pipe( - Effect.mapError((cause) => - makeError(`check OneCLI compose file ${ONECLI_COMPOSE_PATH}`, cause), - ), - ); - if (!composeFileExists) { - return yield* Effect.fail( - makeError( - `OneCLI gateway not running and not installed at ${ONECLI_COMPOSE_PATH}. ` + - `Nanoclaw requires OneCLI to inject credentials into agent subcontainers. ` + - `Install once with:\n\n curl -fsSL https://onecli.sh/install | sh\n\n` + - `Then open http://127.0.0.1:10254 and add your Anthropic credentials.`, - ), - ); - } - - yield* ONECLI_START_PERMIT.withPermits(1)(startOnecliUnderLock(makeError)); - }).pipe(Effect.withSpan("ensureOnecliRunning")); -} diff --git a/packages/simulator/src/runtime/nanoclaw/process.test.ts b/packages/simulator/src/runtime/nanoclaw/process.test.ts deleted file mode 100644 index fc0fdbca3..000000000 --- a/packages/simulator/src/runtime/nanoclaw/process.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { Command, Path } from "@effect/platform"; -import { NodeContext } from "@effect/platform-node"; -import { Effect } from "effect"; -import { - agentId, - agentName, - agentKeyString, - redactedAgentKey, -} from "@moltzap/protocol/testing"; -import { serverBaseUrl } from "@moltzap/protocol/network"; -import { describe, expect, it } from "vitest"; - -import type { NanoclawRuntimeInstall } from "./install.js"; -import { - buildNanoclawContainerListCommand, - buildNanoclawContainerRemoveCommand, - buildNanoclawEvalProvisionPlan, - buildNanoclawProcessPlan, - nanoclawInstallSlug, - NANOCLAW_EVAL_AGENT_GROUP_ID, -} from "./process.js"; - -const FIRST_RUNTIME_DIR = "isolated/moltzap-nanoclaw-first"; -const SECOND_RUNTIME_DIR = "isolated/moltzap-nanoclaw-second"; -const CONFIG_DIRECTORY = ".moltzap"; -const EVAL_MODE_ENV = "MOLTZAP_EVAL_MODE"; -const LEGACY_DATA_DIR_ENV = "DATA_DIR"; -const TEST_PATH = "/test/bin:/usr/bin:/bin"; -const TEST_HOME = "/test/home"; -const TEST_BASE_CHILD_ENVIRONMENT = { - PATH: TEST_PATH, - HOME: TEST_HOME, -}; -// Every variable a NanoClaw child may see; anything beyond this set leaks -// operator state into the runtime. -const EXPECTED_CHILD_ENV_KEYS = [ - "PATH", - "HOME", - "MOLTZAP_PROFILE", - "MOLTZAP_CONFIG_HOME", - "MOLTZAP_SERVER_URL", - "MOLTZAP_EVAL_MODE", - "CONTAINER_RUNTIME", - "CONTAINER_IMAGE", - "ONECLI_URL", - "TMPDIR", - "LOG_LEVEL", -]; -const EXPECTED_FIRST_RUNTIME_SLUG = "d3574d3e"; -const FIRST_CONTAINER_ID = "0123456789ab"; -const SECOND_CONTAINER_ID = "fedcba987654"; -const DOCKER_COMMAND = "docker"; -const NODE_COMMAND = "node"; -const EVAL_PROVISION_ENTRYPOINT = "dist/moltzap-eval-provision.js"; -const TEST_AGENT_NAME = agentName("nanoclaw-agent"); -const EXPECTED_CONTAINER_LIST_ARGS = [ - "ps", - "--quiet", - "--filter", - `label=nanoclaw-install=${EXPECTED_FIRST_RUNTIME_SLUG}`, -]; -const EXPECTED_CONTAINER_REMOVE_ARGS = [ - "rm", - "--force", - FIRST_CONTAINER_ID, - SECOND_CONTAINER_ID, -]; - -describe("NanoClaw process isolation", () => { - it( - "uses an agent-local cwd with an absolute cached entrypoint", - usesAgentLocalProcessRoot, - ); - it("derives distinct process roots for distinct agents", isolatesAgents); - it( - "keeps unknown-conversation registration opt-in", - configuresAutoRegistrationExplicitly, - ); - it( - "normalizes the ws server url into the runtime env", - normalizesServerUrlForRuntime, - ); - it("uses only the explicit child environment", usesExplicitChildEnvironment); - it( - "derives the upstream install slug from the runtime directory", - derivesRuntimeInstallSlug, - ); - it( - "scopes Docker cleanup to the runtime install label", - scopesDockerContainerCleanup, - ); - it( - "builds eval provisioning from the immutable install", - buildsEvalProvisioningPlan, - ); -}); - -function usesAgentLocalProcessRoot() { - return runTest( - Path.Path.pipe( - Effect.tap((path) => { - const runtimeDir = path.resolve(FIRST_RUNTIME_DIR); - const install = stubInstall(path.resolve("cache/nanoclaw")); - const plan = buildNanoclawProcessPlan( - stubStartOptions(), - runtimeDir, - install, - TEST_BASE_CHILD_ENVIRONMENT, - ); - expect(plan.cwd).toBe(runtimeDir); - expect(path.isAbsolute(plan.args[0] ?? "")).toBe(true); - expect(plan.args[0]).toBe(path.join(install.cacheDir, "dist/index.js")); - expect(plan.env.MOLTZAP_CONFIG_HOME).toBe( - path.join(runtimeDir, CONFIG_DIRECTORY), - ); - expect(plan.env.CONTAINER_IMAGE).toBe(install.containerImage); - expect(plan.env).not.toHaveProperty(LEGACY_DATA_DIR_ENV); - }), - Effect.asVoid, - Effect.provide(NodeContext.layer), - ), - ); -} - -function isolatesAgents() { - return runTest( - Path.Path.pipe( - Effect.tap((path) => { - const first = buildNanoclawProcessPlan( - stubStartOptions(), - path.resolve(FIRST_RUNTIME_DIR), - stubInstall(path.resolve("cache/first")), - TEST_BASE_CHILD_ENVIRONMENT, - ); - const second = buildNanoclawProcessPlan( - stubStartOptions("22222222-2222-4222-8222-222222222222"), - path.resolve(SECOND_RUNTIME_DIR), - stubInstall(path.resolve("cache/second")), - TEST_BASE_CHILD_ENVIRONMENT, - ); - - expect(first.cwd).not.toBe(second.cwd); - expect(first.env.MOLTZAP_CONFIG_HOME).not.toBe( - second.env.MOLTZAP_CONFIG_HOME, - ); - }), - Effect.asVoid, - Effect.provide(NodeContext.layer), - ), - ); -} - -function configuresAutoRegistrationExplicitly() { - return runTest( - Path.Path.pipe( - Effect.tap((path) => { - const runtimeDir = path.resolve(FIRST_RUNTIME_DIR); - const install = stubInstall(path.resolve("cache/nanoclaw")); - const defaults = stubStartOptions(); - const disabled = buildNanoclawProcessPlan( - defaults, - runtimeDir, - install, - TEST_BASE_CHILD_ENVIRONMENT, - ); - const enabled = buildNanoclawProcessPlan( - { ...defaults, autoRegisterConversations: true }, - path.resolve(FIRST_RUNTIME_DIR), - install, - TEST_BASE_CHILD_ENVIRONMENT, - ); - - expect(disabled.env[EVAL_MODE_ENV]).toBe("0"); - expect(enabled.env[EVAL_MODE_ENV]).toBe("1"); - }), - Effect.asVoid, - Effect.provide(NodeContext.layer), - ), - ); -} - -const NORMALIZED_INSECURE_SERVER_URL = "http://localhost:9999"; -const SECURE_SERVER_URL = "wss://example.test:8443/ws"; -const NORMALIZED_SECURE_SERVER_URL = "https://example.test:8443"; - -function normalizesServerUrlForRuntime() { - return runTest( - Path.Path.pipe( - Effect.tap((path) => { - const install = stubInstall(path.resolve("cache/nanoclaw")); - const insecure = buildNanoclawProcessPlan( - stubStartOptions(), - path.resolve(FIRST_RUNTIME_DIR), - install, - TEST_BASE_CHILD_ENVIRONMENT, - ); - const secure = buildNanoclawProcessPlan( - { - ...stubStartOptions(), - serverUrl: serverBaseUrl(SECURE_SERVER_URL), - }, - path.resolve(SECOND_RUNTIME_DIR), - install, - TEST_BASE_CHILD_ENVIRONMENT, - ); - - expect(insecure.env.MOLTZAP_SERVER_URL).toBe( - NORMALIZED_INSECURE_SERVER_URL, - ); - expect(secure.env.MOLTZAP_SERVER_URL).toBe( - NORMALIZED_SECURE_SERVER_URL, - ); - }), - Effect.asVoid, - Effect.provide(NodeContext.layer), - ), - ); -} - -function usesExplicitChildEnvironment() { - return runTest( - Path.Path.pipe( - Effect.tap((path) => { - const runtimeDir = path.resolve(FIRST_RUNTIME_DIR); - const plan = buildNanoclawProcessPlan( - stubStartOptions(), - runtimeDir, - stubInstall(path.resolve("cache/nanoclaw")), - TEST_BASE_CHILD_ENVIRONMENT, - ); - - expect( - Object.keys(plan.env).sort((left, right) => - left.localeCompare(right), - ), - ).toEqual( - [...EXPECTED_CHILD_ENV_KEYS].sort((left, right) => - left.localeCompare(right), - ), - ); - expect(plan.env.PATH).toBe(TEST_PATH); - expect(plan.env.HOME).toBe(TEST_HOME); - expect(plan.env.TMPDIR).toBe(path.join(runtimeDir, "tmp")); - }), - Effect.asVoid, - Effect.provide(NodeContext.layer), - ), - ); -} - -function derivesRuntimeInstallSlug() { - expect(nanoclawInstallSlug(FIRST_RUNTIME_DIR)).toBe( - EXPECTED_FIRST_RUNTIME_SLUG, - ); -} - -function scopesDockerContainerCleanup() { - const [listCommand] = Command.flatten( - buildNanoclawContainerListCommand(FIRST_RUNTIME_DIR), - ); - const [removeCommand] = Command.flatten( - buildNanoclawContainerRemoveCommand([ - FIRST_CONTAINER_ID, - SECOND_CONTAINER_ID, - ]), - ); - - expect(listCommand.command).toBe(DOCKER_COMMAND); - expect(listCommand.args).toEqual(EXPECTED_CONTAINER_LIST_ARGS); - expect(removeCommand.command).toBe(DOCKER_COMMAND); - expect(removeCommand.args).toEqual(EXPECTED_CONTAINER_REMOVE_ARGS); -} - -function buildsEvalProvisioningPlan() { - return runTest( - Path.Path.pipe( - Effect.tap((path) => { - const runtimeDir = path.resolve(FIRST_RUNTIME_DIR); - const install = stubInstall(path.resolve("cache/nanoclaw")); - const plan = buildNanoclawEvalProvisionPlan( - stubStartOptions(undefined, true), - runtimeDir, - install, - TEST_BASE_CHILD_ENVIRONMENT, - ); - - expect(plan.command).toBe(NODE_COMMAND); - expect(plan.args).toEqual([ - path.join(install.cacheDir, EVAL_PROVISION_ENTRYPOINT), - NANOCLAW_EVAL_AGENT_GROUP_ID, - TEST_AGENT_NAME, - NANOCLAW_EVAL_AGENT_GROUP_ID, - ]); - expect(plan.cwd).toBe(runtimeDir); - expect( - Object.keys(plan.env).sort((left, right) => - left.localeCompare(right), - ), - ).toEqual( - [...EXPECTED_CHILD_ENV_KEYS].sort((left, right) => - left.localeCompare(right), - ), - ); - }), - Effect.asVoid, - Effect.provide(NodeContext.layer), - ), - ); -} - -function stubStartOptions( - id = "11111111-1111-4111-8111-111111111111", - autoRegisterConversations = false, -) { - return { - agentName: TEST_AGENT_NAME, - agentId: agentId(id), - apiKey: redactedAgentKey(agentKeyString(91)), - serverUrl: serverBaseUrl("ws://localhost:9999/ws"), - autoRegisterConversations, - }; -} - -function stubInstall(cacheDir: string): NanoclawRuntimeInstall { - return { - cacheDir, - cacheFingerprint: "a".repeat(64), - containerImage: "nanoclaw-agent:moltzap-" + "a".repeat(64), - }; -} - -function runTest(effect: Effect.Effect) { - return Effect.runPromise(effect); -} diff --git a/packages/simulator/src/runtime/nanoclaw/process.ts b/packages/simulator/src/runtime/nanoclaw/process.ts deleted file mode 100644 index a05fa343a..000000000 --- a/packages/simulator/src/runtime/nanoclaw/process.ts +++ /dev/null @@ -1,711 +0,0 @@ -/** @file NanoClaw runtime directories and supervised process lifetime. */ -import { createHash } from "node:crypto"; -import { join } from "node:path"; -import { execPath } from "node:process"; -import { Command, FileSystem } from "@effect/platform"; -import type { - CommandExecutor, - ExitCode, - Process, -} from "@effect/platform/CommandExecutor"; -import type { PlatformError } from "@effect/platform/Error"; -import type { AgentId, AgentKey, AgentName } from "@moltzap/protocol/identity"; -import { httpBaseUrl, type ServerBaseUrl } from "@moltzap/protocol/network"; -import { - Data, - Duration, - Effect, - Exit, - type Fiber, - Option, - Scope, - Stream, -} from "effect"; -import { - seedWorkspaceFiles, - SIMULATOR_PROFILE_NAME, - writeMoltZapProfileConfig, -} from "../workspace.js"; -import type { NanoclawRuntimeInstall } from "./install.js"; -import { MOLTZAP_SIMULATOR_CACHE_ROOT } from "../cache.js"; -import { - type BaseChildEnvironment, - baseChildEnvironmentConfig, - BoundedLogBuffer, - escalatingKill, - makeExactEnvironmentCommand, - makeCommandHelpers, - type ProcessTreeCleanup, - startSupervisedProcess, -} from "../command.js"; -import { ensureOnecliRunning, ONECLI_GATEWAY_URL } from "./onecli.js"; - -// NanoClaw waits up to ten seconds for its queue to drain before disconnecting. -// Leave margin for channel disconnect and process exit before escalating. -const NANOCLAW_TERM_WAIT_MS = 12_000; -const NANOCLAW_KILL_WAIT_MS = 5_000; -const NANOCLAW_INSTALL_SLUG_LENGTH = 8; -const NANOCLAW_INSTALL_LABEL_KEY = "nanoclaw-install"; -const DOCKER_COMMAND = "docker"; -const NANOCLAW_DOCKER_COMMAND_TIMEOUT_MS = 10_000; -const NANOCLAW_EVAL_PROVISION_TIMEOUT_MS = 30_000; -const NANOCLAW_EVAL_PROVISION_ENTRYPOINT = "dist/moltzap-eval-provision.js"; -/** Provides the nanoclaw eval agent group id runtime value. */ -export const NANOCLAW_EVAL_AGENT_GROUP_ID = "eval-agent"; - -/** Describes nanoclaw runtime handle. */ -export interface NanoclawRuntimeHandle { - proc: Process; - scope: Scope.CloseableScope; - exitFiber: Fiber.RuntimeFiber; - processTreeCleanup?: ProcessTreeCleanup; - runtimeDir: string; - logs: BoundedLogBuffer; -} - -interface StartNanoclawRuntimeOptions { - agentName: AgentName; - agentId: AgentId; - apiKey: AgentKey; - serverUrl: ServerBaseUrl; - autoRegisterConversations: boolean; - workspaceFiles?: ReadonlyArray<{ - relativePath: string; - content: string; - }>; - /** Honored through the eval agent group's container config (moltzap channel). */ - modelId?: string; - /** Stdio MCP servers mounted into the container via the container config. */ - mcpServers?: ReadonlyArray<{ - readonly name: string; - readonly command: string; - readonly args: readonly string[]; - readonly env: Readonly>; - }>; -} - -/** Describes nanoclaw process plan. */ -export interface NanoclawProcessPlan { - readonly command: string; - readonly args: readonly string[]; - readonly cwd: string; - readonly env: Readonly>; -} - -class NanoclawRuntimeProcessError extends Data.TaggedError( - "NanoclawRuntimeProcessError", -)<{ - readonly reason: string; - readonly cause?: unknown; -}> { - override get message(): string { - return this.reason; - } -} - -interface StartedNanoclawProcess { - readonly proc: Process; - readonly scope: Scope.CloseableScope; - readonly exitFiber: Fiber.RuntimeFiber; - readonly processTreeCleanup: ProcessTreeCleanup; -} - -interface CommandResult { - readonly stdout: string; - readonly stderr: string; - readonly exitCode: number; -} - -function toRuntimeError(message: string, cause?: unknown) { - return new NanoclawRuntimeProcessError({ - reason: message, - ...(cause === undefined ? {} : { cause }), - }); -} - -const { fsEffect } = makeCommandHelpers(toRuntimeError); - -// Runtime dirs are docker bind-mount sources (agent-runner src, group and -// session dirs), and macOS VM-backed engines only share paths under the -// user home by default — the system temp dir is invisible to containers — -// so per-agent dirs live under the simulator cache root instead. -const NANOCLAW_RUNTIME_DIR_ROOT = join( - MOLTZAP_SIMULATOR_CACHE_ROOT, - "nanoclaw-runtimes", -); - -// Hard-killed runs skip teardown, and outside the OS temp dir no reaper -// backstops the leak. The generous age gate exists because a live agent's -// root mtime never refreshes — only dirs no plausible run still owns are -// swept. -const STALE_RUNTIME_DIR_MAX_AGE_MS = 7 * 86_400_000; - -function sweepStaleRuntimeDirs() { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - Effect.gen(function* () { - if (!(yield* fileSystem.exists(NANOCLAW_RUNTIME_DIR_ROOT))) { - return; - } - const entries = yield* fileSystem.readDirectory( - NANOCLAW_RUNTIME_DIR_ROOT, - ); - const cutoff = Date.now() - STALE_RUNTIME_DIR_MAX_AGE_MS; - for (const entry of entries) { - const dir = join(NANOCLAW_RUNTIME_DIR_ROOT, entry); - const info = yield* fileSystem.stat(dir); - const mtime = Option.getOrNull(info.mtime); - if (mtime !== null && mtime.getTime() <= cutoff) { - yield* fileSystem.remove(dir, { recursive: true, force: true }); - } - } - }), - ), - Effect.catchAll((cause) => - Effect.logWarning("failed to sweep stale nanoclaw runtime dirs", cause), - ), - Effect.withSpan("sweepStaleRuntimeDirs"), - ); -} - -function createNanoclawRuntimeDir() { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fsEffect( - "create nanoclaw runtime directory", - fileSystem - .makeDirectory(NANOCLAW_RUNTIME_DIR_ROOT, { recursive: true }) - .pipe( - Effect.andThen( - fileSystem.makeTempDirectory({ - directory: NANOCLAW_RUNTIME_DIR_ROOT, - prefix: "moltzap-nanoclaw-runtime-", - }), - ), - ), - ), - ), - ); -} - -function writeRuntimeWorkspaceFiles( - runtimeDir: string, - workspaceFiles: StartNanoclawRuntimeOptions["workspaceFiles"], -) { - return seedWorkspaceFiles( - join(runtimeDir, "container/skills"), - workspaceFiles, - ).pipe( - Effect.mapError((cause) => - toRuntimeError("seed nanoclaw workspace files", cause), - ), - ); -} - -function seedNanoclawRuntimeDir( - runtimeDir: string, - install: NanoclawRuntimeInstall, -) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - Effect.all( - [ - // The runtime's cwd doubles as NanoClaw's PROJECT_ROOT: the - // startup tripwire reads ./package.json and the sanctioned-upgrade - // marker in data/ (stamped at install time through upstream's own - // writer), so both ride along with container/ and scripts/. - ...["container", "scripts", "data"].map((directory) => - fsEffect( - `copy nanoclaw ${directory} into isolated runtime`, - fileSystem.copy( - join(install.cacheDir, directory), - join(runtimeDir, directory), - { overwrite: true }, - ), - ), - ), - fsEffect( - "copy nanoclaw manifest into isolated runtime", - fileSystem.copyFile( - join(install.cacheDir, "package.json"), - join(runtimeDir, "package.json"), - ), - ), - fsEffect( - "create nanoclaw runtime temp directory", - fileSystem.makeDirectory(join(runtimeDir, "tmp"), { - recursive: true, - }), - ), - ], - { concurrency: 5, discard: true }, - ), - ), - ); -} - -function buildNanoclawChildEnvironment( - opts: StartNanoclawRuntimeOptions, - runtimeDir: string, - install: NanoclawRuntimeInstall, - baseEnvironment: BaseChildEnvironment, -): Readonly> { - return { - ...baseEnvironment, - MOLTZAP_PROFILE: SIMULATOR_PROFILE_NAME, - MOLTZAP_CONFIG_HOME: join(runtimeDir, ".moltzap"), - MOLTZAP_SERVER_URL: httpBaseUrl(opts.serverUrl), - MOLTZAP_EVAL_MODE: opts.autoRegisterConversations ? "1" : "0", - CONTAINER_RUNTIME: "docker", - CONTAINER_IMAGE: install.containerImage, - ONECLI_URL: ONECLI_GATEWAY_URL, - // The OneCLI SDK stages its gateway CA/credential bind-mount sources - // under os.tmpdir(); pointing TMPDIR into the runtime dir keeps them - // docker-shareable on macOS (the OS temp root is invisible to - // VM-backed engines). - TMPDIR: join(runtimeDir, "tmp"), - LOG_LEVEL: "info", - }; -} - -/** - * The simulator's per-agent model and MCP mounts, as the env pairs the eval provisioner materializes into the container config. - * @param opts Value supplied to the operation. - * @returns The created container defaults environment. - */ -function buildContainerDefaultsEnvironment( - opts: StartNanoclawRuntimeOptions, -): Readonly> { - return { - ...(opts.modelId === undefined || opts.modelId.length === 0 - ? {} - : { MOLTZAP_AGENT_MODEL: opts.modelId }), - ...(opts.mcpServers === undefined || opts.mcpServers.length === 0 - ? {} - : { - MOLTZAP_MCP_SERVERS: JSON.stringify( - Object.fromEntries( - opts.mcpServers.map((server) => [ - server.name, - { - command: server.command, - args: [...server.args], - env: { ...server.env }, - }, - ]), - ), - ), - }), - }; -} - -/** - * Creates nanoclaw process plan. - * @param opts Value supplied to the operation. - * @param runtimeDir Value supplied to the operation. - * @param install Value supplied to the operation. - * @param baseEnvironment Value supplied to the operation. - * @returns The created nanoclaw process plan. - */ -export function buildNanoclawProcessPlan( - opts: StartNanoclawRuntimeOptions, - runtimeDir: string, - install: NanoclawRuntimeInstall, - baseEnvironment: BaseChildEnvironment, -): NanoclawProcessPlan { - const entrypoint = join(install.cacheDir, "dist/index.js"); - return { - command: "node", - args: [entrypoint], - cwd: runtimeDir, - env: { - ...buildContainerDefaultsEnvironment(opts), - ...buildNanoclawChildEnvironment( - opts, - runtimeDir, - install, - baseEnvironment, - ), - }, - }; -} - -/** - * The provision plan carries the same container defaults as the runtime - * plan: the provisioner applies `MOLTZAP_AGENT_MODEL` / - * `MOLTZAP_MCP_SERVERS` to the seeded container-config row before the - * first container spawn reads it. - * @param opts Value supplied to the operation. - * @param runtimeDir Value supplied to the operation. - * @param install Value supplied to the operation. - * @param baseEnvironment Value supplied to the operation. - * @internal - * @returns The created nanoclaw eval provision plan. - */ -export function buildNanoclawEvalProvisionPlan( - opts: StartNanoclawRuntimeOptions, - runtimeDir: string, - install: NanoclawRuntimeInstall, - baseEnvironment: BaseChildEnvironment, -): NanoclawProcessPlan { - return { - command: "node", - args: [ - join(install.cacheDir, NANOCLAW_EVAL_PROVISION_ENTRYPOINT), - NANOCLAW_EVAL_AGENT_GROUP_ID, - opts.agentName, - NANOCLAW_EVAL_AGENT_GROUP_ID, - ], - cwd: runtimeDir, - env: { - ...buildContainerDefaultsEnvironment(opts), - ...buildNanoclawChildEnvironment( - opts, - runtimeDir, - install, - baseEnvironment, - ), - }, - }; -} - -function makeNanoclawCommand( - opts: StartNanoclawRuntimeOptions, - runtimeDir: string, - install: NanoclawRuntimeInstall, -) { - return baseChildEnvironmentConfig.pipe( - Effect.map((baseEnvironment) => - makeExactEnvironmentCommand({ - ...buildNanoclawProcessPlan(opts, runtimeDir, install, baseEnvironment), - cleanupTreeOnExit: true, - }), - ), - ); -} - -function provisionNanoclawEvalAgent( - opts: StartNanoclawRuntimeOptions, - runtimeDir: string, - install: NanoclawRuntimeInstall, -) { - return baseChildEnvironmentConfig.pipe( - Effect.map((baseEnvironment) => { - // One-shot provisioner: it writes sqlite and exits, so the inherited - // operator environment is harmless and the exact-environment launcher - // hop is unnecessary. - const plan = buildNanoclawEvalProvisionPlan( - opts, - runtimeDir, - install, - baseEnvironment, - ); - return Command.make(execPath, ...plan.args).pipe( - Command.env(plan.env), - Command.workingDirectory(plan.cwd), - ); - }), - Effect.flatMap((command) => - runCommand(command, { - timeoutMs: NANOCLAW_EVAL_PROVISION_TIMEOUT_MS, - timeoutMessage: "timed out provisioning NanoClaw eval agent", - }), - ), - Effect.flatMap((result) => - requireSuccessfulCommand("provision NanoClaw eval agent", result), - ), - Effect.mapError((cause) => - cause instanceof NanoclawRuntimeProcessError - ? cause - : toRuntimeError("provision NanoClaw eval agent", cause), - ), - Effect.withSpan("provisionNanoclawEvalAgent"), - ); -} - -function writeNanoclawMoltZapProfileConfig( - opts: StartNanoclawRuntimeOptions, - runtimeDir: string, -) { - const configDir = join(runtimeDir, ".moltzap"); - return writeMoltZapProfileConfig(configDir, opts).pipe( - Effect.mapError((cause) => - toRuntimeError(`write moltzap profile config ${configDir}`, cause), - ), - ); -} - -function startNanoclawProcess( - opts: StartNanoclawRuntimeOptions, - runtimeDir: string, - install: NanoclawRuntimeInstall, - logs: BoundedLogBuffer, -) { - return Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const command = yield* makeNanoclawCommand(opts, runtimeDir, install); - const scope = yield* Scope.make(); - return yield* restore( - initializeNanoclawProcess(command, scope, logs), - ).pipe( - Effect.onError((cause) => Scope.close(scope, Exit.failCause(cause))), - ); - }), - ).pipe( - Effect.mapError((cause) => toRuntimeError("spawn nanoclaw runtime", cause)), - ); -} - -function initializeNanoclawProcess( - command: Command.Command, - scope: Scope.CloseableScope, - logs: BoundedLogBuffer, -) { - return startSupervisedProcess( - command, - scope, - (chunk) => { - logs.append(chunk); - }, - { - claimed: false, - launcherOwnsExitCleanup: true, - }, - ).pipe( - Effect.map( - ({ proc, exitFiber, processTreeCleanup }) => - ({ - proc, - scope, - exitFiber, - processTreeCleanup, - }) satisfies StartedNanoclawProcess, - ), - ); -} - -// Spawn commits as soon as the process starts; readiness — server-confirmed -// authentication raced against subprocess exit, bounded by the caller's -// budget — lives entirely in the owning runtime. -function startConfiguredNanoclawRuntime( - opts: StartNanoclawRuntimeOptions, - runtimeDir: string, - install: NanoclawRuntimeInstall, -) { - return Effect.gen(function* () { - yield* seedNanoclawRuntimeDir(runtimeDir, install); - yield* writeRuntimeWorkspaceFiles(runtimeDir, opts.workspaceFiles); - yield* writeNanoclawMoltZapProfileConfig(opts, runtimeDir); - yield* provisionNanoclawEvalAgent(opts, runtimeDir, install); - - const logs = new BoundedLogBuffer(); - const started = yield* startNanoclawProcess( - opts, - runtimeDir, - install, - logs, - ); - return { ...started, runtimeDir, logs }; - }); -} - -/** - * Executes the start nanoclaw runtime effect operation. - * @param opts Value supplied to the operation. - * @param install Value supplied to the operation. - * @returns The start nanoclaw runtime effect result. - */ -export const startNanoclawRuntimeEffect = Effect.fn( - "startNanoclawRuntimeEffect", -)(function* ( - opts: StartNanoclawRuntimeOptions, - install: NanoclawRuntimeInstall, -) { - yield* ensureOnecliRunning(toRuntimeError); - yield* sweepStaleRuntimeDirs(); - const runtimeDir = yield* createNanoclawRuntimeDir(); - return yield* startConfiguredNanoclawRuntime(opts, runtimeDir, install).pipe( - Effect.onError(() => removeNanoclawRuntimeDir(runtimeDir)), - ); -}); - -/** - * Executes the stop nanoclaw runtime effect operation. - * @param handle Value supplied to the operation. - * @returns The stop nanoclaw runtime effect result. - */ -export function stopNanoclawRuntimeEffect( - handle: NanoclawRuntimeHandle, -): Effect.Effect< - void, - NanoclawRuntimeProcessError, - CommandExecutor | FileSystem.FileSystem -> { - return Effect.uninterruptible( - stopNanoclawProcess(handle).pipe( - Effect.ensuring(Scope.close(handle.scope, Exit.succeed(undefined))), - // eslint-disable-next-line @typescript-eslint/no-use-before-define -- cleanup runs after module initialization. - Effect.ensuring(sweepNanoclawContainers(handle.runtimeDir)), - Effect.ensuring(removeNanoclawRuntimeDir(handle.runtimeDir)), - ), - ).pipe(Effect.withSpan("stopNanoclawRuntimeEffect")); -} - -/** - * Derive NanoClaw's stable installation label from a runtime directory. - * - * @param runtimeDir Value supplied to the operation. - * @internal - * @returns The nanoclaw install slug result. - */ -export function nanoclawInstallSlug(runtimeDir: string): string { - // eslint-disable-next-line sonarjs/hashing -- Matches NanoClaw's non-security checkout identifier. - return createHash("sha1") - .update(runtimeDir) - .digest("hex") - .slice(0, NANOCLAW_INSTALL_SLUG_LENGTH); -} - -function nanoclawInstallLabel(runtimeDir: string): string { - return `${NANOCLAW_INSTALL_LABEL_KEY}=${nanoclawInstallSlug(runtimeDir)}`; -} - -/** - * Build the Docker command that lists containers owned by one NanoClaw runtime. - * - * @param runtimeDir Value supplied to the operation. - * @internal - * @returns The created nanoclaw container list command. - */ -export function buildNanoclawContainerListCommand( - runtimeDir: string, -): Command.Command { - return Command.make( - DOCKER_COMMAND, - "ps", - "--quiet", - "--filter", - `label=${nanoclawInstallLabel(runtimeDir)}`, - ); -} - -/** - * Build the Docker command that removes owned NanoClaw containers. - * - * @param containerIds Value supplied to the operation. - * @internal - * @returns The created nanoclaw container remove command. - */ -export function buildNanoclawContainerRemoveCommand( - containerIds: readonly string[], -): Command.Command { - return Command.make(DOCKER_COMMAND, "rm", "--force", ...containerIds); -} - -function captureCommandStream( - stream: Stream.Stream, -): Effect.Effect { - return stream.pipe( - Stream.decodeText(), - Stream.runFold("", (output, chunk) => output + chunk), - ); -} - -interface RunCommandOptions { - readonly timeoutMs: number; - readonly timeoutMessage: string; -} - -const DOCKER_RUN_COMMAND_OPTIONS: RunCommandOptions = { - timeoutMs: NANOCLAW_DOCKER_COMMAND_TIMEOUT_MS, - timeoutMessage: "timed out sweeping NanoClaw runtime containers", -}; - -function runCommand(command: Command.Command, options: RunCommandOptions) { - return Effect.scoped( - Effect.gen(function* () { - const process = yield* Command.start(command); - const [stdout, stderr, exitCode] = yield* Effect.all( - [ - captureCommandStream(process.stdout), - captureCommandStream(process.stderr), - process.exitCode, - ], - { concurrency: 3 }, - ); - return { - stdout, - stderr, - exitCode: Number(exitCode), - } satisfies CommandResult; - }), - ).pipe( - Effect.timeoutFail({ - duration: Duration.millis(options.timeoutMs), - onTimeout: () => toRuntimeError(options.timeoutMessage), - }), - Effect.interruptible, - ); -} - -function requireSuccessfulCommand( - operation: string, - result: CommandResult, -): Effect.Effect { - return result.exitCode === 0 - ? Effect.void - : Effect.fail( - toRuntimeError( - `${operation} failed with exit code ${result.exitCode}: ${result.stderr.trim()}`, - ), - ); -} - -const sweepNanoclawContainers = Effect.fn("sweepNanoclawContainers")( - function* (runtimeDir: string) { - const listResult = yield* runCommand( - buildNanoclawContainerListCommand(runtimeDir), - DOCKER_RUN_COMMAND_OPTIONS, - ); - yield* requireSuccessfulCommand("list NanoClaw containers", listResult); - const containerIds = listResult.stdout - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); - if (containerIds.length === 0) { - return; - } - - const removeResult = yield* runCommand( - buildNanoclawContainerRemoveCommand(containerIds), - DOCKER_RUN_COMMAND_OPTIONS, - ); - yield* requireSuccessfulCommand("remove NanoClaw containers", removeResult); - }, - Effect.catchAll((cause) => - Effect.logWarning("failed to sweep NanoClaw runtime containers", cause), - ), -); - -function stopNanoclawProcess(handle: NanoclawRuntimeHandle) { - return escalatingKill( - handle.proc, - handle.exitFiber, - { - termWaitMs: NANOCLAW_TERM_WAIT_MS, - killWaitMs: NANOCLAW_KILL_WAIT_MS, - }, - handle.processTreeCleanup, - ); -} - -function removeNanoclawRuntimeDir(runtimeDir: string) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.remove(runtimeDir, { recursive: true, force: true }), - ), - Effect.catchAll((cause) => - Effect.logWarning("failed to remove NanoClaw runtime directory", cause), - ), - ); -} diff --git a/packages/simulator/src/runtime/nanoclaw/runtime.test.ts b/packages/simulator/src/runtime/nanoclaw/runtime.test.ts deleted file mode 100644 index 73980f7c4..000000000 --- a/packages/simulator/src/runtime/nanoclaw/runtime.test.ts +++ /dev/null @@ -1,477 +0,0 @@ -import { assert, it as effectIt } from "@effect/vitest"; -import { - ExitCode as processExitCode, - type ExitCode, -} from "@effect/platform/CommandExecutor"; -import { type AgentConnection, makeAgentHandle } from "../../network.js"; -import { RuntimeExited, RuntimeFailed } from "../runtime.js"; -import { serverBaseUrl } from "@moltzap/protocol/network"; -import { - agentId, - agentName, - redactedAgentKey, -} from "@moltzap/protocol/testing"; -import { - Cause, - Deferred, - Duration, - Effect, - Exit, - Fiber, - Option, - Ref, - Schema, - Scope, - Stream, -} from "effect"; -import { describe } from "vitest"; -import { RuntimeAcquisitionFailed } from "../process.js"; -import { expireStartupDeadline } from "../process.test-utils.js"; -import type { InstallMode } from "../packages.js"; -import type { NanoclawGatewaySession } from "./gateway.js"; -import { - makeNanoclawRuntimeWith, - type NanoclawProcessInput, - type NanoclawRuntimeDriver, - type NanoclawRuntimeOptions, -} from "./runtime.js"; - -const test = effectIt.effect; -const ROSTER_KEY = "alice"; -const AGENT_NAME = agentName(ROSTER_KEY); -const AGENT_KEY_TEXT = - "moltzap_agent_0000000000000000_000000000000000000000000000000000000000000000000"; -const AGENT_KEY_REDACTION_MARKER = "[REDACTED:agent-key]"; -const AGENT_ID = agentId("00000000-0000-4000-8000-000000000001"); -const AGENT_KEY = redactedAgentKey(AGENT_KEY_TEXT); -const READY_OUTPUT = 'level=INFO message="MoltZap connected" channel=moltzap'; -const ROUTER_URL = serverBaseUrl("http://127.0.0.1:43123"); -const PROCESS_EXIT_CODE = 23; -// `awaitProcessReady` polls readiness on a fixed interval, and expiring this -// budget on the test clock costs one round of real timers per poll it covers. -// A small multiple of that interval still exercises repeated polling. -const STARTUP_TIMEOUT = Duration.millis(500); -const MODEL_ID = "test/model"; -const PROCESS_WAIT_FAILURE = "process wait failed"; -type ProcessWaitFailure = typeof PROCESS_WAIT_FAILURE; - -interface FakeInstall { - readonly mode: InstallMode; -} - -interface FakeHandle { - readonly exitCode: Deferred.Deferred; - readonly output: string; -} - -interface Fixture { - readonly runtime: ReturnType< - typeof makeNanoclawRuntimeWith - >; - readonly processInput: Deferred.Deferred; - readonly gatewayAvailable: Deferred.Deferred; - readonly gatewayWithin: Deferred.Deferred; - readonly handle: FakeHandle; - readonly teardownCount: Ref.Ref; -} - -interface FakeDriverInput { - readonly processInput: Deferred.Deferred; - readonly gatewayAvailable: Deferred.Deferred; - readonly gatewayWithin: Deferred.Deferred; - readonly handle: FakeHandle; - readonly teardownCount: Ref.Ref; -} - -const connection: AgentConnection<"alice"> = { - agent: makeAgentHandle(ROSTER_KEY, AGENT_ID), - key: AGENT_KEY, - routerUrl: ROUTER_URL, -}; - -function makeFakeDriver( - input: FakeDriverInput, -): NanoclawRuntimeDriver { - const gatewaySession: NanoclawGatewaySession = { - gateway: { - submit: () => Effect.void, - outputs: Stream.empty, - }, - failure: Effect.never, - }; - return { - resolveInstallMode: (requested) => Effect.succeed(requested ?? "workspace"), - install: (mode) => Effect.succeed({ mode }), - start: (process) => - Deferred.succeed(input.processInput, process).pipe( - Effect.as(input.handle), - ), - stop: (running) => - Ref.update(input.teardownCount, (count) => count + 1).pipe( - Effect.zipRight(Deferred.succeed(running.exitCode, processExitCode(0))), - Effect.asVoid, - ), - gateway: (running, within) => - Effect.succeed(running).pipe( - Effect.zipRight(Deferred.succeed(input.gatewayWithin, within)), - Effect.zipRight(Deferred.await(input.gatewayAvailable)), - Effect.as(gatewaySession), - ), - exitCode: (running) => Deferred.await(running.exitCode), - output: (running) => running.output, - readyWhen: (output) => output.includes("MoltZap connected"), - }; -} - -function makeFixture( - options: NanoclawRuntimeOptions, - output = READY_OUTPUT, - gatewayStartsReady = true, -): Effect.Effect { - return Effect.gen(function* () { - const processInput = yield* Deferred.make(); - const gatewayAvailable = yield* Deferred.make(); - const gatewayWithin = yield* Deferred.make(); - if (gatewayStartsReady) { - yield* Deferred.succeed(gatewayAvailable, undefined); - } - const handle: FakeHandle = { - exitCode: yield* Deferred.make(), - output, - }; - const teardownCount = yield* Ref.make(0); - const driver = makeFakeDriver({ - processInput, - gatewayAvailable, - gatewayWithin, - handle, - teardownCount, - }); - return { - runtime: makeNanoclawRuntimeWith(options, driver), - processInput, - gatewayAvailable, - gatewayWithin, - handle, - teardownCount, - }; - }); -} - -function fullRuntimeOptions(): NanoclawRuntimeOptions { - return { - startupTimeout: STARTUP_TIMEOUT, - installMode: "workspace", - modelId: MODEL_ID, - workspaceFiles: [{ relativePath: "IDENTITY.md", content: "Alice" }], - autoRegisterConversations: true, - mcpServers: [ - { - name: "memory", - command: "memory-server", - args: ["--stdio"], - env: { MEMORY_SCOPE: "alice" }, - }, - ], - }; -} - -function assertProcessInput(process: NanoclawProcessInput): void { - assert.strictEqual(process.agentName, AGENT_NAME); - assert.strictEqual(process.agentId, AGENT_ID); - assert.strictEqual(process.apiKey, AGENT_KEY); - assert.strictEqual(process.serverUrl, ROUTER_URL); - assert.strictEqual(process.modelId, MODEL_ID); - assert.isTrue(process.autoRegisterConversations); - assert.deepStrictEqual(process.workspaceFiles, [ - { relativePath: "IDENTITY.md", content: "Alice" }, - ]); - assert.deepStrictEqual(process.mcpServers, [ - { - name: "memory", - command: "memory-server", - args: ["--stdio"], - env: { MEMORY_SCOPE: "alice" }, - }, - ]); -} - -function returnsAfterReadinessTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture(fullRuntimeOptions()); - yield* Effect.scoped( - Effect.gen(function* () { - yield* fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }); - assertProcessInput(yield* Deferred.await(fixture.processInput)); - }), - ); - const gatewayWithin = yield* Deferred.await(fixture.gatewayWithin); - - assert.strictEqual( - Duration.toMillis(gatewayWithin), - Duration.toMillis(STARTUP_TIMEOUT), - ); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - }); -} - -function interruptedAcquisitionTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture({}, "still booting"); - const acquired = yield* Effect.scoped( - fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }), - ).pipe(Effect.fork); - yield* Deferred.await(fixture.processInput); - - const interrupted = yield* Fiber.interrupt(acquired); - assert.isTrue(Exit.isFailure(interrupted)); - if (Exit.isFailure(interrupted)) { - assert.isTrue(Cause.isInterruptedOnly(interrupted.cause)); - } - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - }); -} - -function exitsBeforeReadinessTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture( - {}, - `startup failed apiKey=${AGENT_KEY_TEXT}`, - false, - ); - const acquiring = yield* Effect.scoped( - fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }), - ).pipe(Effect.flip, Effect.fork); - yield* Deferred.await(fixture.processInput); - yield* Deferred.await(fixture.gatewayWithin); - yield* Deferred.succeed( - fixture.handle.exitCode, - processExitCode(PROCESS_EXIT_CODE), - ); - const failure = yield* Fiber.join(acquiring); - - assert.instanceOf(failure, RuntimeAcquisitionFailed); - assert.include(failure.detail, `exitCode=${String(PROCESS_EXIT_CODE)}`); - assert.include(failure.detail, AGENT_KEY_REDACTION_MARKER); - assert.notInclude(failure.detail, AGENT_KEY_TEXT); - assert.include(failure.detail, "startup failed"); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - }); -} - -function waitsForPrincipalGatewayTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture( - { startupTimeout: STARTUP_TIMEOUT }, - READY_OUTPUT, - false, - ); - const acquiring = yield* Effect.scoped( - fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }), - ).pipe(Effect.fork); - - const within = yield* Deferred.await(fixture.gatewayWithin); - assert.strictEqual( - Duration.toMillis(within), - Duration.toMillis(STARTUP_TIMEOUT), - ); - assert.isTrue(Option.isNone(yield* Fiber.poll(acquiring))); - - yield* Deferred.succeed(fixture.gatewayAvailable, undefined); - yield* Fiber.join(acquiring); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - }); -} - -function waitFailsBeforeReadinessTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture( - {}, - `startup failed apiKey=${AGENT_KEY_TEXT}`, - ); - const acquiring = yield* Effect.scoped( - fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }), - ).pipe(Effect.flip, Effect.fork); - yield* Deferred.await(fixture.processInput); - yield* Deferred.fail(fixture.handle.exitCode, PROCESS_WAIT_FAILURE); - const failure = yield* Fiber.join(acquiring); - - assert.instanceOf(failure, RuntimeAcquisitionFailed); - assert.include(failure.detail, "without an observable exit code"); - assert.include(failure.detail, AGENT_KEY_REDACTION_MARKER); - assert.notInclude(failure.detail, AGENT_KEY_TEXT); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - }); -} - -function readinessFailureTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture( - { startupTimeout: STARTUP_TIMEOUT }, - "still booting", - ); - const acquiring = yield* Effect.scoped( - fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }), - ).pipe(Effect.flip, Effect.fork); - yield* expireStartupDeadline(STARTUP_TIMEOUT); - const observed = yield* Fiber.join(acquiring); - - assert.instanceOf(observed, RuntimeAcquisitionFailed); - assert.include(observed.detail, "did not announce readiness"); - assert.include(observed.detail, "still booting"); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - }); -} - -function teardownIsNotTerminationTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture({}); - const scope = yield* Scope.make(); - const running = yield* fixture.runtime - .acquire({ - agentName: AGENT_NAME, - connection, - }) - .pipe(Scope.extend(scope)); - const observing = yield* running.termination.pipe(Effect.forkIn(scope)); - yield* Scope.close(scope, Exit.void); - - const observed = yield* Fiber.await(observing); - assert.isTrue(Exit.isFailure(observed)); - if (Exit.isFailure(observed)) { - assert.isTrue(Cause.isInterruptedOnly(observed.cause)); - } - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - }); -} - -function observeTermination(exitCode: ExitCode) { - return Effect.gen(function* () { - const fixture = yield* makeFixture({}); - const observation = yield* Effect.scoped( - Effect.gen(function* () { - const acquiring = yield* fixture.runtime - .acquire({ - agentName: AGENT_NAME, - connection, - }) - .pipe(Effect.fork); - yield* Deferred.await(fixture.processInput); - const running = yield* Fiber.join(acquiring); - yield* Deferred.succeed(fixture.handle.exitCode, exitCode); - return yield* running.termination; - }), - ); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - return observation; - }); -} - -function observeWaitFailure() { - return Effect.gen(function* () { - const fixture = yield* makeFixture({}); - const observation = yield* Effect.scoped( - Effect.gen(function* () { - const acquiring = yield* fixture.runtime - .acquire({ - agentName: AGENT_NAME, - connection, - }) - .pipe(Effect.fork); - yield* Deferred.await(fixture.processInput); - const running = yield* Fiber.join(acquiring); - yield* Deferred.fail(fixture.handle.exitCode, PROCESS_WAIT_FAILURE); - return yield* running.termination; - }), - ); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - return observation; - }); -} - -function exactTerminationTest() { - return Effect.gen(function* () { - const exited = yield* observeTermination( - processExitCode(PROCESS_EXIT_CODE), - ); - const unavailable = yield* observeWaitFailure(); - - assert.instanceOf(exited, RuntimeExited); - assert.strictEqual(exited.code, PROCESS_EXIT_CODE); - assert.instanceOf(unavailable, RuntimeFailed); - }); -} - -function sanitizedConfigurationTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture(fullRuntimeOptions()); - const encoded = yield* Schema.encode(fixture.runtime.configuration.schema)( - fixture.runtime.configuration.value, - ); - const serialized = JSON.stringify(encoded); - - assert.include(serialized, "contentDigest"); - assert.include(serialized, "definitionDigest"); - assert.include(serialized, "environmentValues"); - assert.include(serialized, '"installPolicy":"workspace"'); - assert.include(serialized, `"modelOverride":"${MODEL_ID}"`); - assert.notInclude(serialized, "Alice"); - assert.notInclude(serialized, "MEMORY_SCOPE"); - assert.notInclude(serialized, AGENT_KEY_TEXT); - }); -} - -// @agent-code-guard/regression-only: controlled handles expose process readiness, cancellation, teardown, and exact exit evidence deterministically -describe("native NanoClaw runtime", () => { - test( - "returns only after process and principal gateway readiness", - returnsAfterReadinessTest, - ); - test( - "does not treat process readiness as principal gateway readiness", - waitsForPrincipalGatewayTest, - ); - test( - "releases an interrupted process acquisition through its Scope", - interruptedAcquisitionTest, - ); - test( - "fails and releases when the process exits while its gateway is connecting", - exitsBeforeReadinessTest, - ); - test( - "reports an unavailable exit code when the process wait fails before readiness", - waitFailsBeforeReadinessTest, - ); - test( - "fails when no readiness line arrives within the startup timeout", - readinessFailureTest, - ); - test( - "does not report scoped teardown as autonomous termination", - teardownIsNotTerminationTest, - ); - test("reports the exact observed process exit status", exactTerminationTest); - test( - "publishes definition-time policy with digested workspace and MCP configuration", - sanitizedConfigurationTest, - ); -}); diff --git a/packages/simulator/src/runtime/nanoclaw/runtime.ts b/packages/simulator/src/runtime/nanoclaw/runtime.ts deleted file mode 100644 index d14f504a8..000000000 --- a/packages/simulator/src/runtime/nanoclaw/runtime.ts +++ /dev/null @@ -1,548 +0,0 @@ -/** @file Scoped NanoClaw runtime. */ - -import { Path, type FileSystem, type HttpClient } from "@effect/platform"; -import { createHash } from "node:crypto"; -import type { - CommandExecutor, - ExitCode, -} from "@effect/platform/CommandExecutor"; -import type { PlatformError } from "@effect/platform/Error"; -import type { AgentId, AgentKey, AgentName } from "@moltzap/protocol/identity"; -import type { ServerBaseUrl } from "@moltzap/protocol/network"; -import { - defineRuntime, - type AgentRuntime, - type AgentRuntimeInput, - type RunningAgent, - RuntimeFailed, - type RuntimeTermination, -} from "../runtime.js"; -import { Duration, Effect, Fiber, Schema, type Scope } from "effect"; -import { resolveInstallMode, type InstallMode } from "../packages.js"; -import { - ensureNanoclawRuntimeInstalledEffect, - type NanoclawRuntimeInstall, -} from "./install.js"; -import { - type NanoclawRuntimeHandle, - startNanoclawRuntimeEffect, - stopNanoclawRuntimeEffect, -} from "./process.js"; -import { - awaitProcessReady, - processTermination, - type ProcessObservation, - RuntimeAcquisitionFailed, -} from "../process.js"; -import { - acquireNanoclawGateway, - type NanoclawGateway, - type NanoclawGatewaySession, -} from "./gateway.js"; - -const NANOCLAW_RUNTIME_NAME = "nanoclaw"; -// The injected channel emits this after its server session is live. -const NANOCLAW_READY_MARKER = "MoltZap connected"; -const DEFAULT_NANOCLAW_STARTUP_TIMEOUT = Duration.minutes(2); - -interface NanoclawWorkspaceFile { - readonly relativePath: string; - readonly content: string; -} - -interface NanoclawMcpServer { - readonly name: string; - readonly command: string; - readonly args: readonly string[]; - readonly env: Readonly>; -} - -const configurationDigest = Schema.String.pipe( - Schema.pattern(/^[\da-f]{64}$/u), - Schema.brand("NanoclawConfigurationDigest"), -); - -class NanoclawWorkspaceFileConfiguration extends Schema.Class( - "NanoclawWorkspaceFileConfiguration", -)({ - relativePath: Schema.String, - contentDigest: configurationDigest, - redacted: Schema.Tuple(Schema.Literal("content")), -}) {} - -class NanoclawMcpServerConfiguration extends Schema.Class( - "NanoclawMcpServerConfiguration", -)({ - name: Schema.String, - definitionDigest: configurationDigest, - redacted: Schema.Tuple( - Schema.Literal("command"), - Schema.Literal("args"), - Schema.Literal("environmentValues"), - ), -}) {} - -/** - * Sanitized definition-time policy and overrides for a NanoClaw runtime. - * Acquisition may resolve different host facts from automatic policy. - */ -export class NanoclawRuntimeConfiguration extends Schema.Class( - "NanoclawRuntimeConfiguration", -)({ - startupTimeout: Schema.DurationFromMillis, - workspaceFiles: Schema.Array(NanoclawWorkspaceFileConfiguration), - modelOverride: Schema.optional(Schema.String), - installPolicy: Schema.Literal("automatic", "published", "workspace"), - autoRegisterConversations: Schema.Boolean, - mcpServers: Schema.Array(NanoclawMcpServerConfiguration), -}) {} - -/** Configuration captured by one reusable NanoClaw runtime value. */ -export interface NanoclawRuntimeOptions { - readonly startupTimeout?: Duration.Duration; - readonly workspaceFiles?: readonly NanoclawWorkspaceFile[]; - readonly modelId?: string; - readonly installMode?: InstallMode; - - /** - * Register conversations on first delivery in disposable evaluations. - * Ordinary societies leave registration to their endpoint code. - */ - readonly autoRegisterConversations?: boolean; - - /** Stdio MCP servers mounted into the NanoClaw container workspace. */ - readonly mcpServers?: readonly NanoclawMcpServer[]; -} - -interface NanoclawRuntimeSettings { - readonly startupTimeout: Duration.Duration; - readonly workspaceFiles: readonly NanoclawWorkspaceFile[]; - readonly modelId?: string; - readonly installMode?: InstallMode; - readonly autoRegisterConversations: boolean; - readonly mcpServers?: readonly NanoclawMcpServer[]; -} - -/** - * Exact low-level process input derived from one router attachment. - * @internal - */ -export interface NanoclawProcessInput { - readonly agentName: AgentName; - readonly agentId: AgentId; - readonly apiKey: AgentKey; - readonly serverUrl: ServerBaseUrl; - readonly autoRegisterConversations: boolean; - readonly workspaceFiles: readonly NanoclawWorkspaceFile[]; - readonly modelId?: string; - readonly mcpServers?: readonly NanoclawMcpServer[]; -} - -type NanoclawGatewayAcquirer = ( - handle: Handle, - within: Duration.Duration, -) => Effect.Effect; - -/** - * NanoClaw-specific process seam. Production binds this to the immutable - * install and supervised-process primitives; lifecycle tests bind controlled - * handles without starting Docker. - * @internal - */ -export interface NanoclawRuntimeDriver< - Install, - Handle, - WaitFailure = unknown, - Requirements = never, -> { - readonly resolveInstallMode: ( - requested?: InstallMode, - ) => Effect.Effect; - readonly install: ( - mode: InstallMode, - ) => Effect.Effect; - readonly start: ( - input: NanoclawProcessInput, - install: Install, - ) => Effect.Effect; - readonly stop: (handle: Handle) => Effect.Effect; - readonly gateway: NanoclawGatewayAcquirer; - readonly exitCode: (handle: Handle) => Effect.Effect; - readonly output: (handle: Handle) => string; - readonly readyWhen: (output: string) => boolean; -} - -/** Failure returned when NanoClaw cannot become router-visible. */ -export type NanoclawRuntimeAcquisitionError = RuntimeAcquisitionFailed; - -type NanoclawHostServices = - | CommandExecutor - | FileSystem.FileSystem - | HttpClient.HttpClient - | Path.Path; - -const nativeNanoclawDriver: NanoclawRuntimeDriver< - NanoclawRuntimeInstall, - NanoclawRuntimeHandle, - PlatformError, - NanoclawHostServices -> = { - resolveInstallMode, - install: ensureNanoclawRuntimeInstalledEffect, - start: startNanoclawRuntimeEffect, - stop: (handle) => - stopNanoclawRuntimeEffect(handle).pipe( - Effect.catchAll((cause) => - Effect.logWarning("failed to tear down NanoClaw runtime", cause), - ), - ), - gateway: (handle, within) => - Path.Path.pipe( - Effect.flatMap((path) => - acquireNanoclawGateway( - path.join(handle.runtimeDir, "data", "cli.sock"), - within, - ), - ), - ), - exitCode: (handle) => Fiber.join(handle.exitFiber), - output: (handle) => handle.logs.text, - readyWhen: (output) => output.includes(NANOCLAW_READY_MARKER), -}; - -function snapshotWorkspaceFiles( - files?: readonly NanoclawWorkspaceFile[], -): readonly NanoclawWorkspaceFile[] { - return Object.freeze((files ?? []).map((file) => Object.freeze({ ...file }))); -} - -function snapshotMcpServers( - servers?: readonly NanoclawMcpServer[], -): readonly NanoclawMcpServer[] | undefined { - return servers === undefined - ? undefined - : Object.freeze( - servers.map((server) => - Object.freeze({ - name: server.name, - command: server.command, - args: Object.freeze([...server.args]), - env: Object.freeze({ ...server.env }), - }), - ), - ); -} - -function snapshotOptions( - options: NanoclawRuntimeOptions, -): NanoclawRuntimeSettings { - const modelId = options.modelId; - const installMode = options.installMode; - const mcpServers = snapshotMcpServers(options.mcpServers); - return Object.freeze({ - startupTimeout: options.startupTimeout ?? DEFAULT_NANOCLAW_STARTUP_TIMEOUT, - workspaceFiles: snapshotWorkspaceFiles(options.workspaceFiles), - autoRegisterConversations: options.autoRegisterConversations ?? false, - ...(modelId === undefined ? {} : { modelId }), - ...(installMode === undefined ? {} : { installMode }), - ...(mcpServers === undefined ? {} : { mcpServers }), - }); -} - -function digestText(value: string): typeof configurationDigest.Type { - return Schema.decodeUnknownSync(configurationDigest)( - createHash("sha256").update(value, "utf8").digest("hex"), - ); -} - -function workspaceConfiguration( - files: readonly NanoclawWorkspaceFile[], -): readonly NanoclawWorkspaceFileConfiguration[] { - return files.map((file) => - NanoclawWorkspaceFileConfiguration.make({ - relativePath: file.relativePath, - contentDigest: digestText(file.content), - redacted: ["content"], - }), - ); -} - -function mcpServerDefinition(server: NanoclawMcpServer): string { - return JSON.stringify({ - name: server.name, - command: server.command, - args: server.args, - environmentKeys: Object.keys(server.env).sort((left, right) => - left.localeCompare(right), - ), - }); -} - -function mcpConfiguration( - servers?: readonly NanoclawMcpServer[], -): readonly NanoclawMcpServerConfiguration[] { - return (servers ?? []).map((server) => - NanoclawMcpServerConfiguration.make({ - name: server.name, - definitionDigest: digestText(mcpServerDefinition(server)), - redacted: ["command", "args", "environmentValues"], - }), - ); -} - -function runtimeConfiguration( - settings: NanoclawRuntimeSettings, -): NanoclawRuntimeConfiguration { - return NanoclawRuntimeConfiguration.make({ - startupTimeout: settings.startupTimeout, - workspaceFiles: workspaceConfiguration(settings.workspaceFiles), - installPolicy: settings.installMode ?? "automatic", - autoRegisterConversations: settings.autoRegisterConversations, - mcpServers: mcpConfiguration(settings.mcpServers), - ...(settings.modelId === undefined - ? {} - : { modelOverride: settings.modelId }), - }); -} - -function processInput( - input: AgentRuntimeInput, - settings: NanoclawRuntimeSettings, -): NanoclawProcessInput { - return { - agentName: input.agentName, - agentId: input.connection.agent.id, - apiKey: input.connection.key, - serverUrl: input.connection.routerUrl, - autoRegisterConversations: settings.autoRegisterConversations, - workspaceFiles: settings.workspaceFiles, - ...(settings.modelId === undefined ? {} : { modelId: settings.modelId }), - ...(settings.mcpServers === undefined - ? {} - : { mcpServers: settings.mcpServers }), - }; -} - -function acquisitionFailure( - agentName: string, - operation: string, - cause: unknown, -): RuntimeAcquisitionFailed { - return RuntimeAcquisitionFailed.make({ - runtime: NANOCLAW_RUNTIME_NAME, - agent: agentName, - detail: `${operation}: ${String(cause)}`, - }); -} - -function startProcessScoped( - process: NanoclawProcessInput, - install: Install, - driver: NanoclawRuntimeDriver, -): Effect.Effect { - const start = driver - .start(process, install) - .pipe( - Effect.mapError((cause) => - acquisitionFailure(process.agentName, "start process", cause), - ), - ); - return Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const handle = yield* restore(start); - yield* Effect.addFinalizer(() => driver.stop(handle)); - return handle; - }), - ); -} - -interface AcquiredNanoclawProcess { - readonly handle: Handle; - readonly input: NanoclawProcessInput; - readonly observation: ProcessObservation; -} - -function acquireNanoclawProcess< - Name extends string, - Install, - Handle, - WaitFailure, - Requirements, ->( - settings: NanoclawRuntimeSettings, - driver: NanoclawRuntimeDriver, - input: AgentRuntimeInput, -): Effect.Effect< - AcquiredNanoclawProcess, - NanoclawRuntimeAcquisitionError, - Scope.Scope | Requirements -> { - return Effect.gen(function* () { - const process = processInput(input, settings); - const installMode = yield* driver - .resolveInstallMode(settings.installMode) - .pipe( - Effect.mapError((cause) => - acquisitionFailure(process.agentName, "select packages", cause), - ), - ); - const install = yield* driver - .install(installMode) - .pipe( - Effect.mapError((cause) => - acquisitionFailure(process.agentName, "install runtime", cause), - ), - ); - const handle = yield* startProcessScoped(process, install, driver); - const observation: ProcessObservation = { - exitCode: driver.exitCode(handle), - output: () => driver.output(handle), - }; - return { handle, input: process, observation }; - }); -} - -function awaitNanoclawGateway( - process: AcquiredNanoclawProcess, - within: Duration.Duration, - acquireGateway: NanoclawGatewayAcquirer, - readyWhen: (output: string) => boolean, -): Effect.Effect< - NanoclawGatewaySession, - NanoclawRuntimeAcquisitionError, - Scope.Scope | Requirements -> { - const gateway = acquireGateway(process.handle, within).pipe( - Effect.mapError((cause) => - acquisitionFailure( - process.input.agentName, - "connect principal gateway", - cause, - ), - ), - ); - const ready = awaitProcessReady({ - within, - agentName: process.input.agentName, - agentKey: process.input.apiKey, - runtimeName: NANOCLAW_RUNTIME_NAME, - observation: process.observation, - readyWhen, - }); - return Effect.all([gateway, ready] as const, { concurrency: 2 }).pipe( - Effect.map(([session]) => session), - ); -} - -function nanoclawTermination( - process: AcquiredNanoclawProcess, - gateway: NanoclawGatewaySession, -): Effect.Effect { - const gatewayTermination = gateway.failure.pipe( - Effect.catchAll((cause) => - Effect.succeed( - RuntimeFailed.make({ - detail: `NanoClaw principal gateway for agent "${process.input.agentName}" disconnected: ${String(cause)}`, - }), - ), - ), - ); - return Effect.raceFirst( - processTermination( - { - agentName: process.input.agentName, - runtimeName: NANOCLAW_RUNTIME_NAME, - }, - process.observation, - ), - gatewayTermination, - ); -} - -function acquireNanoclawRuntime< - Name extends string, - Install, - Handle, - WaitFailure, - Requirements, ->( - settings: NanoclawRuntimeSettings, - driver: NanoclawRuntimeDriver, - input: AgentRuntimeInput, -): Effect.Effect< - RunningAgent, - NanoclawRuntimeAcquisitionError, - Scope.Scope | Requirements -> { - return Effect.gen(function* () { - const process = yield* acquireNanoclawProcess(settings, driver, input); - const gateway = yield* awaitNanoclawGateway( - process, - settings.startupTimeout, - driver.gateway, - driver.readyWhen, - ); - return { - gateway: gateway.gateway, - termination: nanoclawTermination(process, gateway), - }; - }).pipe( - Effect.withSpan("nanoclawRuntime.acquire", { - attributes: { - "agent.name": input.connection.agent.name, - "runtime.name": NANOCLAW_RUNTIME_NAME, - }, - }), - ); -} - -/** - * Build NanoClaw's process-backed runtime against an explicit low-level driver. - * Production uses {@link nanoclawRuntime}; this seam keeps lifecycle tests - * free of Docker and immutable-install work. - * @param options Options that control the operation. - * @param driver Value supplied to the operation. - * @internal - * @returns The created nanoclaw runtime with. - */ -export function makeNanoclawRuntimeWith< - Install, - Handle, - WaitFailure = unknown, - Requirements = never, ->( - options: NanoclawRuntimeOptions, - driver: NanoclawRuntimeDriver, -): AgentRuntime< - NanoclawGateway, - NanoclawRuntimeAcquisitionError, - Requirements, - typeof NanoclawRuntimeConfiguration -> { - const settings = snapshotOptions(options); - return defineRuntime({ - name: NANOCLAW_RUNTIME_NAME, - configuration: { - schema: NanoclawRuntimeConfiguration, - value: runtimeConfiguration(settings), - }, - acquire: (input) => acquireNanoclawRuntime(settings, driver, input), - }); -} - -/** - * Construct a NanoClaw runtime that binds each roster identity to one - * scoped container-backed process and waits for router-visible readiness. - * @param options Options that control the operation. - * @returns The nanoclaw runtime result. - */ -export function nanoclawRuntime( - options: NanoclawRuntimeOptions = {}, -): AgentRuntime< - NanoclawGateway, - NanoclawRuntimeAcquisitionError, - NanoclawHostServices, - typeof NanoclawRuntimeConfiguration -> { - return makeNanoclawRuntimeWith(options, nativeNanoclawDriver); -} diff --git a/packages/simulator/src/runtime/nanoclaw/workspace.integration.test.ts b/packages/simulator/src/runtime/nanoclaw/workspace.integration.test.ts deleted file mode 100644 index 35ab93081..000000000 --- a/packages/simulator/src/runtime/nanoclaw/workspace.integration.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { join } from "node:path"; -import { FileSystem } from "@effect/platform"; -import { NodeContext } from "@effect/platform-node"; -import { Config, Effect } from "effect"; -import { describe, expect, it } from "vitest"; - -import { ensureNanoclawRuntimeInstalledEffect } from "./install.js"; - -const CLIENT_PACKAGE_NAME = "@moltzap/client"; -const PROTOCOL_PACKAGE_NAME = "@moltzap/protocol"; -const FILE_VENDOR_PREFIX = "file:vendor/"; -const WORKSPACE_INSTALL_TEST_TIMEOUT_MS = 1_500_000; -const REGISTRY_MOLTZAP_PATTERN = /registry\.npmjs\.org\/@moltzap(?:\/|%2f)/i; -const EXPECTED_MOLTZAP_LOCK_KEYS = [ - `node_modules/${CLIENT_PACKAGE_NAME}`, - `node_modules/${PROTOCOL_PACKAGE_NAME}`, -].sort((left, right) => left.localeCompare(right)); - -const NANOCLAW_INSTALL_INTEGRATION_ENABLED = Effect.runSync( - Config.string("MOLTZAP_NANOCLAW_ITEST").pipe( - Config.withDefault("0"), - Config.map((value) => value === "1"), - ), -); - -describe.skipIf(!NANOCLAW_INSTALL_INTEGRATION_ENABLED)( - "NanoClaw real workspace install", - () => { - it( - "uses only the two workspace MoltZap tarballs", - verifiesWorkspaceInstallLock, - WORKSPACE_INSTALL_TEST_TIMEOUT_MS, - ); - }, -); - -function verifiesWorkspaceInstallLock() { - return Effect.runPromise( - Effect.gen(function* () { - const install = yield* ensureNanoclawRuntimeInstalledEffect("workspace"); - const fileSystem = yield* FileSystem.FileSystem; - const lockText = yield* fileSystem.readFileString( - join(install.cacheDir, "package-lock.json"), - "utf8", - ); - expect(lockText).not.toMatch(REGISTRY_MOLTZAP_PATTERN); - - const parsed: unknown = JSON.parse(lockText); - const lock = requireRecord(parsed); - const packages = requireRecord(lock.packages); - const root = requireRecord(packages[""]); - const rootDependencies = requireRecord(root.dependencies); - expect(rootDependencies[CLIENT_PACKAGE_NAME]).toMatch(FILE_VENDOR_PREFIX); - expect(rootDependencies[PROTOCOL_PACKAGE_NAME]).toMatch( - FILE_VENDOR_PREFIX, - ); - const moltzapKeys = Object.keys(packages) - .filter((location) => location.includes("node_modules/@moltzap/")) - .sort((left, right) => left.localeCompare(right)); - expect(moltzapKeys).toEqual(EXPECTED_MOLTZAP_LOCK_KEYS); - for (const location of moltzapKeys) { - const entry = requireRecord(packages[location]); - expect(entry.resolved).toMatch(FILE_VENDOR_PREFIX); - } - }).pipe(Effect.provide(NodeContext.layer)), - ); -} - -function requireRecord(value: unknown): Readonly> { - if (!isRecord(value)) { - throw new Error("Expected NanoClaw package lock object"); - } - return value; -} - -function isRecord(value: unknown): value is Readonly> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/packages/simulator/src/runtime/nanoclaw/workspace.test.ts b/packages/simulator/src/runtime/nanoclaw/workspace.test.ts deleted file mode 100644 index 5c97cdf19..000000000 --- a/packages/simulator/src/runtime/nanoclaw/workspace.test.ts +++ /dev/null @@ -1,579 +0,0 @@ -import { createHash } from "node:crypto"; -import { join } from "node:path"; -import { Command, FileSystem } from "@effect/platform"; -import type { CommandExecutor } from "@effect/platform/CommandExecutor"; -import { NodeContext } from "@effect/platform-node"; -import { Effect } from "effect"; -import { describe, expect, it } from "vitest"; - -import { makeCommandHelpers } from "../command.js"; -import { - assertNanoclawWorkspaceLock, - assertPackedWorkspaceVersions, - materializeNanoclawWorkspaceDependencies, - nanoclawCacheFingerprint, - rewriteNanoclawWorkspaceManifest, - type NanoclawWorkspaceDependencies, - type NanoclawWorkspaceTarball, -} from "./install.js"; - -const NANOCLAW_SHA = "641963c1e4b7ba4f000a18dfc5e2fea29069feec"; -const NANOCLAW_CACHE_SCHEMA_VERSION = 5; -const CLIENT_PACKAGE_NAME = "@moltzap/client"; -const PROTOCOL_PACKAGE_NAME = "@moltzap/protocol"; -const PACKAGE_VERSION = "2026.724.2"; -const STALE_PACKAGE_VERSION = "2026.724.1"; -const CLIENT_TARBALL_FILE = "moltzap-client-2026.724.2.tgz"; -const PROTOCOL_TARBALL_FILE = "moltzap-protocol-2026.724.2.tgz"; -const CLIENT_TARBALL_SPEC = `file:vendor/${CLIENT_TARBALL_FILE}`; -const PROTOCOL_TARBALL_SPEC = `file:vendor/${PROTOCOL_TARBALL_FILE}`; -const CLIENT_INTEGRITY = "sha512-client-integrity"; -const PROTOCOL_INTEGRITY = "sha512-protocol-integrity"; -const STALE_INTEGRITY = "sha512-stale-integrity"; -const OTHER_DEPENDENCY_NAME = "effect"; -const OTHER_DEPENDENCY_VERSION = "3.22.0"; -const BUILD_SCRIPT = "tsc"; -const PROTOCOL_MISMATCH_REASON = "packed client protocol dependency"; -const TRANSITIVE_FIXTURE_PACKAGE_NAME = "@fixture/transitive"; -const HASH_HEX_LENGTH = 64; -const CLIENT_HASH = "a".repeat(HASH_HEX_LENGTH); -const OTHER_CLIENT_HASH = "b".repeat(HASH_HEX_LENGTH); -const PROTOCOL_HASH = "c".repeat(HASH_HEX_LENGTH); -const OTHER_PROTOCOL_HASH = "d".repeat(HASH_HEX_LENGTH); -const REGISTRY_LEAK = "https://registry.npmjs.org/@moltzap/client/-/client.tgz"; -const FIXTURE_PACKAGE_NAME = "nanoclaw-workspace-staging-fixture"; -const FIXTURE_PACKAGE_VERSION = "1.0.0"; -const FIXTURE_COMMAND_TIMEOUT_MS = 30_000; -const FIXTURE_TEST_TIMEOUT_MS = 150_000; -const FIXTURE_NPM_CONFIG = [ - "offline=true", - "audit=false", - "fund=false", - "update-notifier=false", -].join("\n"); - -const { commandOutputEffect } = makeCommandHelpers( - (reason, cause) => - new Error(reason, cause === undefined ? undefined : { cause }), -); - -const WORKSPACE_DEPENDENCIES = { - client: { - packageName: CLIENT_PACKAGE_NAME, - version: PACKAGE_VERSION, - tarballPath: `/fixtures/${CLIENT_TARBALL_FILE}`, - tarballFileName: CLIENT_TARBALL_FILE, - sha256: CLIENT_HASH, - integrity: CLIENT_INTEGRITY, - }, - protocol: { - packageName: PROTOCOL_PACKAGE_NAME, - version: PACKAGE_VERSION, - tarballPath: `/fixtures/${PROTOCOL_TARBALL_FILE}`, - tarballFileName: PROTOCOL_TARBALL_FILE, - sha256: PROTOCOL_HASH, - integrity: PROTOCOL_INTEGRITY, - }, -} as const satisfies NanoclawWorkspaceDependencies; - -const FINGERPRINT_INPUT = { - channelHash: "channel-hash", - evalProvisionHash: "eval-provision-hash", - skillHash: "skill-hash", - packageJsonHash: "package-json-hash", - packageLockHash: "package-lock-hash", - platform: "test-platform", - architecture: "test-architecture", - nodeAbi: "test-node-abi", -} as const; - -// @agent-code-guard/regression-only: these cases pin cache compatibility and workspace rebuild invalidation -describe("NanoClaw workspace cache fingerprint", () => { - it("preserves the published fingerprint payload", preservesPublishedHash); - it( - "keys both workspace tarballs and remains stable", - includesWorkspaceHashes, - ); -}); - -// @agent-code-guard/regression-only: fixture manifests and locks pin every local-artifact provenance check -describe("NanoClaw workspace dependency staging", () => { - it( - "copies tarballs and refreshes an offline npm lock", - materializesWorkspaceDependencies, - FIXTURE_TEST_TIMEOUT_MS, - ); - it( - "rewrites both direct dependencies and preserves other fields", - rewritesManifest, - ); - it("accepts an exact two-package file lock", acceptsWorkspaceLock); - it( - "accepts ordinary dependencies nested beneath MoltZap packages", - acceptsNestedNonMoltzapDependencies, - ); - it( - "rejects a packed client built against another protocol", - rejectsMismatchedBuilds, - ); - it("rejects MoltZap registry leakage", rejectsRegistryLeakage); - it("rejects a nested protocol copy", rejectsNestedProtocol); - it("rejects stale tarball integrity", rejectsStaleIntegrity); -}); - -function preservesPublishedHash() { - const expected = createHash("sha256") - .update( - JSON.stringify({ - cacheSchema: NANOCLAW_CACHE_SCHEMA_VERSION, - nanoclawSha: NANOCLAW_SHA, - ...FINGERPRINT_INPUT, - }), - ) - .digest("hex"); - - expect(nanoclawCacheFingerprint(FINGERPRINT_INPUT)).toBe(expected); -} - -function includesWorkspaceHashes() { - const baseline = nanoclawCacheFingerprint(FINGERPRINT_INPUT, { - clientTarballHash: CLIENT_HASH, - protocolTarballHash: PROTOCOL_HASH, - }); - const clientRebuilt = nanoclawCacheFingerprint(FINGERPRINT_INPUT, { - clientTarballHash: OTHER_CLIENT_HASH, - protocolTarballHash: PROTOCOL_HASH, - }); - const protocolRebuilt = nanoclawCacheFingerprint(FINGERPRINT_INPUT, { - clientTarballHash: CLIENT_HASH, - protocolTarballHash: OTHER_PROTOCOL_HASH, - }); - const repeated = nanoclawCacheFingerprint(FINGERPRINT_INPUT, { - clientTarballHash: CLIENT_HASH, - protocolTarballHash: PROTOCOL_HASH, - }); - - expect(clientRebuilt).not.toBe(baseline); - expect(protocolRebuilt).not.toBe(baseline); - expect(repeated).toBe(baseline); -} - -function materializesWorkspaceDependencies() { - return runWithFixture((root) => - Effect.gen(function* () { - const prepared = yield* prepareWorkspaceStagingFixture(root); - yield* materializeNanoclawWorkspaceDependencies( - prepared.stagingDir, - prepared.dependencies, - ); - yield* assertMaterializedWorkspaceFixture(prepared); - }), - ); -} - -function prepareWorkspaceStagingFixture(root: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const packDir = join(root, "packs"); - const stagingDir = join(root, "staging"); - yield* fileSystem.makeDirectory(packDir, { recursive: true }); - const protocol = yield* packFixturePackage({ - root, - packDir, - directoryName: "protocol", - packageName: PROTOCOL_PACKAGE_NAME, - tarballFileName: PROTOCOL_TARBALL_FILE, - dependencies: {}, - }); - const client = yield* packFixturePackage({ - root, - packDir, - directoryName: "client", - packageName: CLIENT_PACKAGE_NAME, - tarballFileName: CLIENT_TARBALL_FILE, - dependencies: { [PROTOCOL_PACKAGE_NAME]: PACKAGE_VERSION }, - }); - yield* seedWorkspaceStagingDir(root, stagingDir); - return { - stagingDir, - dependencies: { client, protocol }, - } satisfies MaterializedWorkspaceFixture; - }); -} - -interface FixturePackageInput { - readonly root: string; - readonly packDir: string; - readonly directoryName: string; - readonly packageName: string; - readonly tarballFileName: string; - readonly dependencies: Readonly>; -} - -function packFixturePackage(input: FixturePackageInput) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const packageDir = join(input.root, input.directoryName); - yield* fileSystem.makeDirectory(packageDir, { recursive: true }); - yield* fileSystem.writeFileString( - join(packageDir, "package.json"), - JSON.stringify({ - name: input.packageName, - version: PACKAGE_VERSION, - dependencies: input.dependencies, - }), - ); - const command = Command.make( - "npm", - "pack", - "--pack-destination", - input.packDir, - "--cache", - join(input.root, "npm-cache"), - "--offline", - ).pipe(Command.workingDirectory(packageDir)); - yield* commandOutputEffect(`pack fixture ${input.packageName}`, command, { - timeout: FIXTURE_COMMAND_TIMEOUT_MS, - }); - return yield* describeFixtureTarball( - join(input.packDir, input.tarballFileName), - input.packageName, - input.tarballFileName, - ); - }); -} - -function describeFixtureTarball( - tarballPath: string, - packageName: string, - tarballFileName: string, -) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => fileSystem.readFile(tarballPath)), - Effect.map( - (bytes) => - ({ - packageName, - version: PACKAGE_VERSION, - tarballPath, - tarballFileName, - sha256: createHash("sha256").update(bytes).digest("hex"), - integrity: - "sha512-" + createHash("sha512").update(bytes).digest("base64"), - }) satisfies NanoclawWorkspaceTarball, - ), - ); -} - -function seedWorkspaceStagingDir(root: string, stagingDir: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const dependencies = { - [CLIENT_PACKAGE_NAME]: STALE_PACKAGE_VERSION, - [PROTOCOL_PACKAGE_NAME]: STALE_PACKAGE_VERSION, - }; - yield* fileSystem.makeDirectory(stagingDir, { recursive: true }); - yield* fileSystem.writeFileString( - join(stagingDir, "package.json"), - JSON.stringify({ - name: FIXTURE_PACKAGE_NAME, - version: FIXTURE_PACKAGE_VERSION, - private: true, - scripts: { build: BUILD_SCRIPT }, - dependencies, - }), - ); - yield* fileSystem.writeFileString( - join(stagingDir, "package-lock.json"), - JSON.stringify({ - name: FIXTURE_PACKAGE_NAME, - version: FIXTURE_PACKAGE_VERSION, - lockfileVersion: 3, - requires: true, - packages: { - "": { - name: FIXTURE_PACKAGE_NAME, - version: FIXTURE_PACKAGE_VERSION, - dependencies, - }, - }, - }), - ); - yield* fileSystem.writeFileString( - join(stagingDir, ".npmrc"), - `${FIXTURE_NPM_CONFIG}\ncache=${join(root, "npm-cache")}\n`, - ); - }); -} - -interface MaterializedWorkspaceFixture { - readonly stagingDir: string; - readonly dependencies: NanoclawWorkspaceDependencies; -} - -function assertMaterializedWorkspaceFixture( - fixture: MaterializedWorkspaceFixture, -) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - for (const tarball of [ - fixture.dependencies.client, - fixture.dependencies.protocol, - ]) { - const [source, vendored] = yield* Effect.all([ - fileSystem.readFile(tarball.tarballPath), - fileSystem.readFile( - join(fixture.stagingDir, "vendor", tarball.tarballFileName), - ), - ]); - expect(vendored).toEqual(source); - } - const manifest = readTestRecord( - JSON.parse( - yield* fileSystem.readFileString( - join(fixture.stagingDir, "package.json"), - "utf8", - ), - ), - ); - const dependencies = readTestRecord(manifest.dependencies); - expect(dependencies[CLIENT_PACKAGE_NAME]).toBe(CLIENT_TARBALL_SPEC); - expect(dependencies[PROTOCOL_PACKAGE_NAME]).toBe(PROTOCOL_TARBALL_SPEC); - expect(manifest.scripts).toEqual({ build: BUILD_SCRIPT }); - const lockText = yield* fileSystem.readFileString( - join(fixture.stagingDir, "package-lock.json"), - "utf8", - ); - expect(lockText).not.toMatch(REGISTRY_LEAK); - const lock = readTestRecord(JSON.parse(lockText)); - const packages = readTestRecord(lock.packages); - expect( - Object.keys(packages) - .filter((key) => key.includes("node_modules/@moltzap/")) - .sort((left, right) => left.localeCompare(right)), - ).toEqual([ - `node_modules/${CLIENT_PACKAGE_NAME}`, - `node_modules/${PROTOCOL_PACKAGE_NAME}`, - ]); - }); -} - -function readTestRecord(value: unknown): Readonly> { - if (!isTestRecord(value)) { - throw new Error("Expected fixture JSON object"); - } - return value; -} - -function isTestRecord( - value: unknown, -): value is Readonly> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function rewritesManifest() { - return runWithFixture((root) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const manifestPath = join(root, "package.json"); - yield* fileSystem.writeFileString( - manifestPath, - JSON.stringify({ - name: "nanoclaw", - scripts: { build: BUILD_SCRIPT }, - dependencies: { - [CLIENT_PACKAGE_NAME]: PACKAGE_VERSION, - [PROTOCOL_PACKAGE_NAME]: PACKAGE_VERSION, - [OTHER_DEPENDENCY_NAME]: OTHER_DEPENDENCY_VERSION, - }, - }), - ); - - yield* rewriteNanoclawWorkspaceManifest(root, WORKSPACE_DEPENDENCIES); - - const rewritten: unknown = JSON.parse( - yield* fileSystem.readFileString(manifestPath, "utf8"), - ); - expect(rewritten).toMatchObject({ - scripts: { build: BUILD_SCRIPT }, - dependencies: { - [CLIENT_PACKAGE_NAME]: CLIENT_TARBALL_SPEC, - [PROTOCOL_PACKAGE_NAME]: PROTOCOL_TARBALL_SPEC, - [OTHER_DEPENDENCY_NAME]: OTHER_DEPENDENCY_VERSION, - }, - }); - }), - ); -} - -function acceptsWorkspaceLock() { - return runWithLock(makeWorkspaceLock(), (root) => - assertNanoclawWorkspaceLock(root, WORKSPACE_DEPENDENCIES).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result).toBeUndefined(); - }), - ), - ), - ); -} - -function acceptsNestedNonMoltzapDependencies() { - const lock = makeWorkspaceLock(); - return runWithLock( - { - ...lock, - packages: { - ...lock.packages, - [`node_modules/${CLIENT_PACKAGE_NAME}/node_modules/${TRANSITIVE_FIXTURE_PACKAGE_NAME}`]: - {}, - }, - }, - (root) => assertNanoclawWorkspaceLock(root, WORKSPACE_DEPENDENCIES), - ); -} - -function rejectsMismatchedBuilds() { - return Effect.runPromise( - Effect.gen(function* () { - const error = yield* assertPackedWorkspaceVersions({ - clientManifest: { - name: CLIENT_PACKAGE_NAME, - version: PACKAGE_VERSION, - dependencies: { - [PROTOCOL_PACKAGE_NAME]: STALE_PACKAGE_VERSION, - }, - }, - protocolManifest: { - name: PROTOCOL_PACKAGE_NAME, - version: PACKAGE_VERSION, - dependencies: {}, - }, - clientVersion: PACKAGE_VERSION, - protocolVersion: PACKAGE_VERSION, - }).pipe(Effect.flip); - - expect(error.reason).toContain(PROTOCOL_MISMATCH_REASON); - }), - ); -} - -function rejectsRegistryLeakage() { - return expectInvalidLock( - { ...makeWorkspaceLock(), registryLeak: REGISTRY_LEAK }, - "registry artifact", - ); -} - -function rejectsNestedProtocol() { - const lock = makeWorkspaceLock(); - const packages = lock.packages; - return expectInvalidLock( - { - ...lock, - packages: { - ...packages, - [`node_modules/${CLIENT_PACKAGE_NAME}/node_modules/${PROTOCOL_PACKAGE_NAME}`]: - packages[`node_modules/${PROTOCOL_PACKAGE_NAME}`], - }, - }, - "only direct", - ); -} - -function rejectsStaleIntegrity() { - const lock = makeWorkspaceLock(); - return expectInvalidLock( - { - ...lock, - packages: { - ...lock.packages, - [`node_modules/${CLIENT_PACKAGE_NAME}`]: { - ...lock.packages[`node_modules/${CLIENT_PACKAGE_NAME}`], - integrity: STALE_INTEGRITY, - }, - }, - }, - CLIENT_INTEGRITY, - ); -} - -function expectInvalidLock( - lock: Readonly>, - reasonFragment: string, -) { - return runWithLock(lock, (root) => - Effect.gen(function* () { - const error = yield* assertNanoclawWorkspaceLock( - root, - WORKSPACE_DEPENDENCIES, - ).pipe(Effect.flip); - - expect(error.reason).toContain(reasonFragment); - }), - ); -} - -function makeWorkspaceLock() { - return { - lockfileVersion: 3, - packages: { - "": { - dependencies: { - [CLIENT_PACKAGE_NAME]: CLIENT_TARBALL_SPEC, - [PROTOCOL_PACKAGE_NAME]: PROTOCOL_TARBALL_SPEC, - }, - }, - [`node_modules/${CLIENT_PACKAGE_NAME}`]: { - version: PACKAGE_VERSION, - resolved: CLIENT_TARBALL_SPEC, - integrity: CLIENT_INTEGRITY, - dependencies: { - [PROTOCOL_PACKAGE_NAME]: PACKAGE_VERSION, - }, - }, - [`node_modules/${PROTOCOL_PACKAGE_NAME}`]: { - version: PACKAGE_VERSION, - resolved: PROTOCOL_TARBALL_SPEC, - integrity: PROTOCOL_INTEGRITY, - }, - }, - }; -} - -function runWithLock( - lock: Readonly>, - use: (root: string) => Effect.Effect, -) { - return runWithFixture((root) => - FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.writeFileString( - join(root, "package-lock.json"), - JSON.stringify(lock), - ), - ), - Effect.zipRight(use(root)), - ), - ); -} - -function runWithFixture( - use: ( - root: string, - ) => Effect.Effect, -) { - return Effect.runPromise( - Effect.scoped( - FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.makeTempDirectoryScoped({ - prefix: "nanoclaw-workspace-install-test-", - }), - ), - Effect.flatMap(use), - Effect.provide(NodeContext.layer), - ), - ), - ); -} diff --git a/packages/simulator/src/runtime/openclaw/cache.integration.test.ts b/packages/simulator/src/runtime/openclaw/cache.integration.test.ts deleted file mode 100644 index 019aece76..000000000 --- a/packages/simulator/src/runtime/openclaw/cache.integration.test.ts +++ /dev/null @@ -1,187 +0,0 @@ -import { join } from "node:path"; -import { FileSystem } from "@effect/platform"; -import { NodeContext } from "@effect/platform-node"; -import { Config, Data, Effect, Schema } from "effect"; -import { describe, expect, it } from "vitest"; - -import { makeCommandHelpers } from "../command.js"; -import { - makeOpenClawCommand, - materializePublishedOpenClawPlugin, -} from "./cache.js"; -import { - resolveInstalledPackageBin, - resolveInstalledPackageDependency, -} from "../packages.js"; - -const OPENCLAW_PLUGIN_ID = "openclaw-channel"; -const CHANNEL_PACKAGE_NAME = "@moltzap/openclaw-channel"; -const SIMULATOR_PACKAGE_NAME = "@moltzap/simulator"; -const OPENCLAW_COMMAND_TIMEOUT_MS = 30_000; -// The cold path runs a real npm install (measured ~60s) plus a full -// per-agent materialization before its assertions. -const OPENCLAW_INSTALL_TEST_TIMEOUT_MS = 600_000; -const JSON_INDENT_SPACES = 2; -const LOADED_PLUGIN_STATUS = "loaded"; -const NPM_INSTALL_SOURCE = "npm"; -const PROVENANCE_DIAGNOSTIC_PATTERN = /provenance|untracked/i; - -const openClawPluginInfoOutput = Schema.Struct({ - plugin: Schema.Struct({ - id: Schema.String, - enabled: Schema.Boolean, - status: Schema.String, - }), - install: Schema.Struct({ - source: Schema.String, - spec: Schema.String, - }), - diagnostics: Schema.Array( - Schema.Struct({ - level: Schema.Literal("warn", "error"), - message: Schema.String, - }), - ), -}); - -class OpenClawIntegrationCommandError extends Data.TaggedError( - "OpenClawIntegrationCommandError", -)<{ - readonly reason: string; - readonly cause?: unknown; -}> {} - -function commandError(reason: string, cause?: unknown) { - return new OpenClawIntegrationCommandError({ - reason, - ...(cause === undefined ? {} : { cause }), - }); -} - -const { commandOutputEffect } = makeCommandHelpers(commandError); - -interface PublishedPluginFixture { - readonly home: string; - readonly environment: Readonly>; - readonly expectedChannelSpec: string; - readonly openclawBin: string; -} - -// Integration gates use Config so test modules follow the same environment -// boundary as runtime code. -const OPENCLAW_INSTALL_INTEGRATION_ENABLED = Effect.runSync( - Config.string("MOLTZAP_OPENCLAW_ITEST").pipe( - Config.withDefault("0"), - Config.map((value) => value === "1"), - ), -); - -describe.skipIf(!OPENCLAW_INSTALL_INTEGRATION_ENABLED)( - "OpenClaw real published plugin cache", - () => { - it( - "retains npm provenance after per-agent materialization", - verifiesPublishedPluginProvenance, - OPENCLAW_INSTALL_TEST_TIMEOUT_MS, - ); - }, -); - -function verifiesPublishedPluginProvenance() { - return Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "openclaw-plugin-cache-integration-", - }); - const fixture = yield* preparePublishedPluginFixture(root); - yield* assertPublishedPluginInfo(fixture); - }).pipe(Effect.provide(NodeContext.layer)), - ), - ); -} - -function preparePublishedPluginFixture(root: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const stateDir = join(root, "agent-state"); - const configPath = join(stateDir, "openclaw.json"); - const openclawBin = resolveInstalledPackageBin("openclaw", "openclaw"); - const channel = yield* Effect.try({ - try: () => - resolveInstalledPackageDependency( - SIMULATOR_PACKAGE_NAME, - CHANNEL_PACKAGE_NAME, - import.meta.url, - ), - catch: (cause) => - commandError("Unable to resolve the expected channel version", cause), - }); - yield* materializePublishedOpenClawPlugin({ - stateDir, - openclawBin, - cacheBaseDir: join(root, "cache"), - }); - yield* fileSystem.writeFileString( - configPath, - JSON.stringify({}, null, JSON_INDENT_SPACES), - ); - return { - home: root, - environment: { - OPENCLAW_HOME: root, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: configPath, - }, - expectedChannelSpec: `${CHANNEL_PACKAGE_NAME}@${channel.version}`, - openclawBin, - } satisfies PublishedPluginFixture; - }); -} - -function assertPublishedPluginInfo(fixture: PublishedPluginFixture) { - return Effect.gen(function* () { - const infoCommand = yield* makeOpenClawCommand( - fixture.openclawBin, - ["plugins", "info", OPENCLAW_PLUGIN_ID, "--runtime", "--json"], - fixture.environment, - fixture.home, - ); - const infoOutput = yield* commandOutputEffect( - "inspect materialized OpenClaw plugin", - infoCommand, - { timeout: OPENCLAW_COMMAND_TIMEOUT_MS }, - ); - const info = yield* decodePluginInfo(infoOutput.stdout); - expect(info.plugin).toMatchObject({ - id: OPENCLAW_PLUGIN_ID, - enabled: true, - status: LOADED_PLUGIN_STATUS, - }); - expect(info.install).toEqual({ - source: NPM_INSTALL_SOURCE, - spec: fixture.expectedChannelSpec, - }); - expect( - info.diagnostics.some((diagnostic) => - PROVENANCE_DIAGNOSTIC_PATTERN.test(diagnostic.message), - ), - ).toBe(false); - expect(infoOutput.stderr).not.toMatch(PROVENANCE_DIAGNOSTIC_PATTERN); - }); -} - -function decodePluginInfo(output: string) { - return Effect.try({ - try: (): unknown => JSON.parse(output), - catch: (cause) => commandError("OpenClaw returned invalid JSON", cause), - }).pipe( - Effect.flatMap(Schema.decodeUnknown(openClawPluginInfoOutput)), - Effect.mapError((cause) => - cause instanceof OpenClawIntegrationCommandError - ? cause - : commandError("Unable to decode OpenClaw plugin info", cause), - ), - ); -} diff --git a/packages/simulator/src/runtime/openclaw/cache.test.ts b/packages/simulator/src/runtime/openclaw/cache.test.ts deleted file mode 100644 index 242aaa4fa..000000000 --- a/packages/simulator/src/runtime/openclaw/cache.test.ts +++ /dev/null @@ -1,255 +0,0 @@ -import { join } from "node:path"; -import { FileSystem } from "@effect/platform"; -import { NodeContext } from "@effect/platform-node"; -import { Effect } from "effect"; -import { describe, expect, it } from "vitest"; - -import { - materializeOpenClawPluginCacheGeneration, - openClawPluginCacheFingerprint, - validateOpenClawPluginProject, -} from "./cache.js"; - -const CHANNEL_PACKAGE_NAME = "@moltzap/openclaw-channel"; -const CHANNEL_VERSION = "1.2.3"; -const OPENCLAW_VERSION = "2026.6.33"; -const TEST_PLATFORM = "test-platform"; -const TEST_ARCHITECTURE = "test-architecture"; -const OTHER_CHANNEL_VERSION = "1.2.4"; -const OTHER_OPENCLAW_VERSION = "2026.6.34"; -const OTHER_PLATFORM = "other-platform"; -const OTHER_ARCHITECTURE = "other-architecture"; -const PROJECT_SLUG = "moltzap-openclaw-channel-test"; -const CHANNEL_PAYLOAD = "registry plugin payload"; -const HASH_HEX_LENGTH = 64; -const REGISTRY_CHANNEL_TARBALL = - "https://registry.npmjs.org/@moltzap/openclaw-channel/-/openclaw-channel-1.2.3.tgz"; -const LOCAL_CHANNEL_TARBALL = "file:../openclaw-channel.tgz"; -const TEST_INTEGRITY = "sha512-test-integrity"; -const REGISTRY_BACKED_REASON = "registry-backed"; - -const BASE_FINGERPRINT_INPUT = { - channelVersion: CHANNEL_VERSION, - openclawVersion: OPENCLAW_VERSION, - platform: TEST_PLATFORM, - architecture: TEST_ARCHITECTURE, -} as const; - -const FINGERPRINT_VARIANTS = [ - { - label: "channel version", - input: { - ...BASE_FINGERPRINT_INPUT, - channelVersion: OTHER_CHANNEL_VERSION, - }, - }, - { - label: "OpenClaw version", - input: { - ...BASE_FINGERPRINT_INPUT, - openclawVersion: OTHER_OPENCLAW_VERSION, - }, - }, - { - label: "platform", - input: { ...BASE_FINGERPRINT_INPUT, platform: OTHER_PLATFORM }, - }, - { - label: "architecture", - input: { - ...BASE_FINGERPRINT_INPUT, - architecture: OTHER_ARCHITECTURE, - }, - }, -] as const; - -describe("OpenClaw published plugin cache fingerprint", () => { - const baseline = openClawPluginCacheFingerprint(BASE_FINGERPRINT_INPUT); - - it("is a sha256 digest", () => { - expect(baseline).toHaveLength(HASH_HEX_LENGTH); - }); - - it.each(FINGERPRINT_VARIANTS)("includes $label", ({ input }) => { - expect(openClawPluginCacheFingerprint(input)).not.toBe(baseline); - }); -}); - -describe("OpenClaw npm project provenance", () => { - it( - "accepts exact registry-backed MoltZap artifacts", - acceptsRegistryArtifacts, - ); - it("rejects a local MoltZap artifact", rejectsLocalArtifacts); -}); - -describe("OpenClaw plugin cache materialization", () => { - it( - "copies one project and rebuilds its OpenClaw peer link", - rebuildsOpenClawPeerLink, - ); - it( - "fails clearly when the canonical OpenClaw package is absent", - rejectsMissingOpenClawPackage, - ); -}); - -function acceptsRegistryArtifacts() { - return runWithFixture((root) => - Effect.gen(function* () { - const projectDir = join(root, "valid-project"); - yield* seedProject(projectDir, REGISTRY_CHANNEL_TARBALL); - - const result = yield* validateOpenClawPluginProject( - projectDir, - CHANNEL_VERSION, - ); - - expect(result).toBeUndefined(); - }), - ); -} - -function rejectsLocalArtifacts() { - return runWithFixture((root) => - Effect.gen(function* () { - const projectDir = join(root, "local-project"); - yield* seedProject(projectDir, LOCAL_CHANNEL_TARBALL); - - const error = yield* validateOpenClawPluginProject( - projectDir, - CHANNEL_VERSION, - ).pipe(Effect.flip); - - expect(error.reason).toContain(REGISTRY_BACKED_REASON); - }), - ); -} - -function rebuildsOpenClawPeerLink() { - return runWithFixture((root) => - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const fixture = yield* seedCachedProject(root); - - const projectDir = yield* materializeOpenClawPluginCacheGeneration({ - generationDir: fixture.generationDir, - stateDir: fixture.stateDir, - openclawPackageRoot: fixture.openclawPackageRoot, - }); - - expect( - yield* fileSystem.readFileString( - join(projectDir, "payload.txt"), - "utf8", - ), - ).toBe(CHANNEL_PAYLOAD); - const [linkTarget, canonicalRoot] = yield* Effect.all([ - fileSystem.readLink(openclawPeerLinkPath(projectDir)), - fileSystem.realPath(fixture.openclawPackageRoot), - ]); - expect(linkTarget).toBe(canonicalRoot); - }), - ); -} - -function rejectsMissingOpenClawPackage() { - return runWithFixture((root) => - Effect.gen(function* () { - const fixture = yield* seedCachedProject(root); - const missingPackageRoot = join(root, "missing-openclaw"); - - const error = yield* materializeOpenClawPluginCacheGeneration({ - generationDir: fixture.generationDir, - stateDir: fixture.stateDir, - openclawPackageRoot: missingPackageRoot, - }).pipe(Effect.flip); - - expect(error.reason).toContain(missingPackageRoot); - }), - ); -} - -function seedProject(projectDir: string, resolved: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - yield* fileSystem.makeDirectory(projectDir, { recursive: true }); - yield* fileSystem.writeFileString( - join(projectDir, "package.json"), - JSON.stringify({ - dependencies: { [CHANNEL_PACKAGE_NAME]: CHANNEL_VERSION }, - }), - ); - yield* fileSystem.writeFileString( - join(projectDir, "package-lock.json"), - JSON.stringify({ - packages: { - "": { - dependencies: { [CHANNEL_PACKAGE_NAME]: CHANNEL_VERSION }, - }, - [`node_modules/${CHANNEL_PACKAGE_NAME}`]: { - version: CHANNEL_VERSION, - resolved, - integrity: TEST_INTEGRITY, - }, - }, - }), - ); - }); -} - -function seedCachedProject(root: string) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const generationDir = join(root, "generation"); - const projectDir = join(generationDir, "npm", "projects", PROJECT_SLUG); - const stateDir = join(root, "state"); - const openclawPackageRoot = join(root, "canonical-openclaw"); - const staleOpenclawRoot = join(root, "stale-openclaw"); - const peerLink = openclawPeerLinkPath(projectDir); - yield* Effect.all( - [projectDir, stateDir, openclawPackageRoot, staleOpenclawRoot].map( - (directory) => fileSystem.makeDirectory(directory, { recursive: true }), - ), - { concurrency: 4, discard: true }, - ); - yield* fileSystem.writeFileString( - join(projectDir, "payload.txt"), - CHANNEL_PAYLOAD, - ); - yield* fileSystem.makeDirectory(join(peerLink, ".."), { - recursive: true, - }); - yield* fileSystem.symlink(staleOpenclawRoot, peerLink); - return { generationDir, openclawPackageRoot, stateDir }; - }); -} - -function openclawPeerLinkPath(projectDir: string): string { - return join( - projectDir, - "node_modules", - "@moltzap", - "openclaw-channel", - "node_modules", - "openclaw", - ); -} - -function runWithFixture( - use: (root: string) => Effect.Effect, -) { - return Effect.runPromise( - Effect.scoped( - FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.makeTempDirectoryScoped({ - prefix: "openclaw-plugin-cache-test-", - }), - ), - Effect.flatMap(use), - Effect.provide(NodeContext.layer), - ), - ), - ); -} diff --git a/packages/simulator/src/runtime/openclaw/cache.ts b/packages/simulator/src/runtime/openclaw/cache.ts deleted file mode 100644 index 66b5422e5..000000000 --- a/packages/simulator/src/runtime/openclaw/cache.ts +++ /dev/null @@ -1,633 +0,0 @@ -/** @file Immutable OpenClaw channel-plugin materialization. */ - -import { basename, dirname, join } from "node:path"; -import { execPath } from "node:process"; -import { FileSystem } from "@effect/platform"; -import { Data, Effect, Ref, Schema } from "effect"; -import { - baseChildEnvironmentConfig, - makeCommandHelpers, - makeExactEnvironmentCommand, - type CapturedCommandOutput, -} from "../command.js"; -import { - cacheFingerprint, - CACHE_BUILD_PERMIT, - makeJsonGuards, - makeImmutableCache, - MOLTZAP_SIMULATOR_CACHE_ROOT, -} from "../cache.js"; -import { resolveInstalledPackageDependency } from "../packages.js"; - -const CHANNEL_PACKAGE_NAME = "@moltzap/openclaw-channel"; -const OPENCLAW_PACKAGE_NAME = "openclaw"; -const OPENCLAW_PLUGIN_ID = "openclaw-channel"; -const OPENCLAW_CACHE_SCHEMA_VERSION = 1; -const OPENCLAW_INSTALL_TIMEOUT_MS = 120_000; -const OPENCLAW_LIST_TIMEOUT_MS = 30_000; -const NPM_REGISTRY_PREFIX = "https://registry.npmjs.org/"; -const NPM_INTEGRITY_PREFIX = "sha512-"; - -const openClawPluginListOutput = Schema.Struct({ - plugins: Schema.Array( - Schema.Struct({ - id: Schema.String, - enabled: Schema.Boolean, - status: Schema.String, - }), - ), -}); - -// Everything a cache generation is keyed by and built from. The pinned -// dependency resolution is process-constant; only `cacheRoot` varies, because -// tests redirect the cache away from the shared root. -interface OpenClawPluginCacheTargetInput { - readonly cacheFingerprint: string; - readonly channelSpec: string; - readonly channelVersion: string; - readonly openclawPackageRoot: string; -} - -interface OpenClawPluginCacheTarget extends OpenClawPluginCacheTargetInput { - readonly cacheRoot: string; -} - -interface WarmCacheGeneration { - readonly cacheFingerprint: string; - readonly cacheRoot: string; - readonly generationDir: string; -} - -/** Configures materialize published open claw plugin. */ -export interface MaterializePublishedOpenClawPluginOptions { - readonly stateDir: string; - readonly openclawBin: string; - readonly cacheBaseDir?: string; -} - -// A published generation is immutable, so the first spawn's resolution answers -// for every later spawn instead of re-sweeping and re-scanning the cache. -const WARM_CACHE_GENERATION = Effect.runSync( - Ref.make(null), -); - -class OpenClawPluginCacheError extends Data.TaggedError( - "OpenClawPluginCacheError", -)<{ - readonly reason: string; - readonly cause?: unknown; -}> { - override get message(): string { - return this.reason; - } -} - -function cacheError(reason: string, cause?: unknown) { - return new OpenClawPluginCacheError({ - reason, - ...(cause === undefined ? {} : { cause }), - }); -} - -const { commandOutputEffect, fsEffect } = makeCommandHelpers(cacheError); -const { - isRecord, - requireExactValue, - requireRecord, - requireSoleEntry, - requireString, -} = makeJsonGuards(cacheError); - -/** - * Installs or reuses the pinned npm project and copies it into one agent's - * state directory with a peer link to the simulator's OpenClaw package. - * @param options Options that control the operation. - * @returns The materialize published open claw plugin result. - */ -export const materializePublishedOpenClawPlugin = Effect.fn( - "materializePublishedOpenClawPlugin", -)(function* (options: MaterializePublishedOpenClawPluginOptions) { - const target = yield* resolveCacheTarget(options.cacheBaseDir); - const generationDir = yield* resolveCacheGeneration( - target, - options.openclawBin, - ); - // eslint-disable-next-line @typescript-eslint/no-use-before-define -- cache helper is initialized before this effect executes. - return yield* materializeOpenClawPluginCacheGeneration({ - generationDir, - stateDir: options.stateDir, - openclawPackageRoot: target.openclawPackageRoot, - }); -}); - -// The installed dependency versions and this host's identity cannot change -// while the process runs, so the two directory-walking package resolutions and -// the digest run once for every agent it spawns. -const cachedCacheTargetInput = Effect.runSync( - Effect.cached( - Effect.try({ - try: (): OpenClawPluginCacheTargetInput => { - const channel = resolveInstalledPackageDependency( - "@moltzap/simulator", - CHANNEL_PACKAGE_NAME, - import.meta.url, - ); - const openclaw = resolveInstalledPackageDependency( - "@moltzap/simulator", - OPENCLAW_PACKAGE_NAME, - import.meta.url, - ); - return { - cacheFingerprint: openClawPluginCacheFingerprint({ - channelVersion: channel.version, - openclawVersion: openclaw.version, - platform: process.platform, - architecture: process.arch, - }), - channelSpec: `${CHANNEL_PACKAGE_NAME}@${channel.version}`, - channelVersion: channel.version, - openclawPackageRoot: openclaw.packageRoot, - }; - }, - catch: (cause) => - cacheError( - "Unable to resolve exact simulator dependencies for the published OpenClaw plugin cache", - cause, - ), - }), - ), -); - -function resolveCacheTarget(cacheBaseDir?: string) { - return cachedCacheTargetInput.pipe( - Effect.map( - (input) => - ({ - ...input, - cacheRoot: join( - cacheBaseDir ?? - join(MOLTZAP_SIMULATOR_CACHE_ROOT, "openclaw-plugin"), - input.cacheFingerprint, - ), - }) satisfies OpenClawPluginCacheTarget, - ), - ); -} - -/** - * Derive the immutable OpenClaw plugin cache identity. - * - * @param input Input value to process. - * @param input.channelVersion Value supplied to the operation. - * @param input.openclawVersion Value supplied to the operation. - * @param input.platform Value supplied to the operation. - * @param input.architecture Value supplied to the operation. - * @internal - * @returns The open claw plugin cache fingerprint result. - */ -export function openClawPluginCacheFingerprint(input: { - readonly channelVersion: string; - readonly openclawVersion: string; - readonly platform: string; - readonly architecture: string; -}): string { - return cacheFingerprint(OPENCLAW_CACHE_SCHEMA_VERSION, { - channelVersion: input.channelVersion, - openclawVersion: input.openclawVersion, - platform: input.platform, - architecture: input.architecture, - }); -} - -function resolveCacheGeneration( - target: OpenClawPluginCacheTarget, - openclawBin: string, -) { - return Effect.gen(function* () { - const warm = yield* Ref.get(WARM_CACHE_GENERATION); - if ( - warm !== null && - warm.cacheFingerprint === target.cacheFingerprint && - warm.cacheRoot === target.cacheRoot - ) { - return warm.generationDir; - } - const generationDir = yield* CACHE_BUILD_PERMIT.withPermits(1)( - ensureCacheGeneration(target, openclawBin), - ); - yield* Ref.set(WARM_CACHE_GENERATION, { - cacheFingerprint: target.cacheFingerprint, - cacheRoot: target.cacheRoot, - generationDir, - }); - return generationDir; - }); -} - -function ensureCacheGeneration( - target: OpenClawPluginCacheTarget, - openclawBin: string, -) { - const cache = makeImmutableCache(target.cacheRoot, cacheError); - return Effect.gen(function* () { - yield* cache.sweepStaleBuildingCaches(); - const ready = yield* cache.findCacheGeneration(target.cacheFingerprint); - if (ready !== null) { - return ready; - } - return yield* buildAndPublishCacheGeneration(target, openclawBin); - }); -} - -function buildAndPublishCacheGeneration( - target: OpenClawPluginCacheTarget, - openclawBin: string, -) { - const cache = makeImmutableCache(target.cacheRoot, cacheError); - return Effect.gen(function* () { - const buildingDir = yield* cache.createBuildingCache(); - return yield* Effect.scoped( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const stagingHome = yield* fsEffect( - "create isolated OpenClaw plugin staging home", - fileSystem.makeTempDirectoryScoped({ - prefix: "moltzap-openclaw-plugin-", - }), - ); - yield* coldInstallPlugin(openclawBin, stagingHome, target); - const sourceProjectDir = yield* findInstalledChannelProject( - join(stagingHome, ".openclaw", "npm", "projects"), - target.channelVersion, - ); - // eslint-disable-next-line @typescript-eslint/no-use-before-define -- validation helper runs after module initialization. - yield* validateOpenClawPluginProject( - sourceProjectDir, - target.channelVersion, - ); - yield* copyProjectIntoBuildingCache(sourceProjectDir, buildingDir); - yield* cache.writeReadyMarker(buildingDir, target.cacheFingerprint); - return yield* cache.publishCacheGeneration(buildingDir); - }), - ).pipe(Effect.ensuring(cache.removeBuildingCacheBestEffort(buildingDir))); - }); -} - -function coldInstallPlugin( - openclawBin: string, - stagingHome: string, - target: OpenClawPluginCacheTarget, -) { - const stateDir = join(stagingHome, ".openclaw"); - const environment = { - OPENCLAW_HOME: stagingHome, - OPENCLAW_STATE_DIR: stateDir, - OPENCLAW_CONFIG_PATH: join(stateDir, "openclaw.json"), - }; - return Effect.gen(function* () { - const installCommand = yield* makeOpenClawCommand( - openclawBin, - ["plugins", "install", target.channelSpec, "--pin"], - environment, - stagingHome, - ); - const listCommand = yield* makeOpenClawCommand( - openclawBin, - ["plugins", "list", "--enabled", "--json"], - environment, - stagingHome, - ); - yield* commandOutputEffect( - `install ${target.channelSpec} with OpenClaw`, - installCommand, - { timeout: OPENCLAW_INSTALL_TIMEOUT_MS }, - ); - const listed = yield* commandOutputEffect( - "list enabled OpenClaw plugins", - listCommand, - { timeout: OPENCLAW_LIST_TIMEOUT_MS }, - ); - yield* verifyEnabledPlugin(listed); - }); -} - -/** - * Builds one OpenClaw CLI invocation under an exact environment rather than - * the operator's: ambient variables change the CLI's behavior (a test-runner - * marker silences its JSON output entirely), which would make cache builds - * depend on who launched them. - * @param openclawBin Value supplied to the operation. - * @param args Value supplied to the operation. - * @param environment Value supplied to the operation. - * @param cwd Value supplied to the operation. - * @internal - * @returns The created open claw command. - */ -export function makeOpenClawCommand( - openclawBin: string, - args: readonly string[], - environment: Readonly>, - cwd: string, -) { - const isNodeScript = openclawBin.endsWith(".mjs"); - return Effect.map(baseChildEnvironmentConfig, (base) => - makeExactEnvironmentCommand({ - command: isNodeScript ? execPath : openclawBin, - args: isNodeScript ? [openclawBin, ...args] : [...args], - cwd, - env: { ...base, ...environment }, - }), - ); -} - -function verifyEnabledPlugin(output: CapturedCommandOutput) { - return Effect.try({ - try: (): unknown => JSON.parse(output.stdout), - catch: (cause) => - cacheError("OpenClaw plugins list returned invalid JSON", cause), - }).pipe( - Effect.flatMap(Schema.decodeUnknown(openClawPluginListOutput)), - Effect.mapError((cause) => - cause instanceof OpenClawPluginCacheError - ? cause - : cacheError("Unable to decode OpenClaw plugins list", cause), - ), - Effect.flatMap((decoded) => { - const plugin = decoded.plugins.find( - (candidate) => candidate.id === OPENCLAW_PLUGIN_ID, - ); - return plugin?.enabled === true && plugin.status === "loaded" - ? Effect.void - : Effect.fail( - cacheError( - `OpenClaw did not report ${OPENCLAW_PLUGIN_ID} enabled and loaded after install`, - ), - ); - }), - ); -} - -function findInstalledChannelProject( - projectsDir: string, - channelVersion: string, -) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const entries = yield* fsEffect( - "list installed OpenClaw npm projects " + projectsDir, - fileSystem.readDirectory(projectsDir), - ); - const matches = yield* Effect.filter(entries, (entry) => - projectDeclaresChannel( - join(projectsDir, entry, "package.json"), - channelVersion, - ), - ); - const match = yield* requireSoleEntry( - matches, - `OpenClaw npm project for ${CHANNEL_PACKAGE_NAME}@${channelVersion}`, - ); - return join(projectsDir, match); - }); -} - -function projectDeclaresChannel( - packageJsonPath: string, - channelVersion: string, -) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.readFileString(packageJsonPath, "utf8"), - ), - Effect.flatMap((contents) => - Effect.try({ - try: () => { - const parsed: unknown = JSON.parse(contents); - return ( - isRecord(parsed) && - isRecord(parsed.dependencies) && - parsed.dependencies[CHANNEL_PACKAGE_NAME] === channelVersion - ); - }, - catch: () => false, - }).pipe(Effect.merge), - ), - Effect.orElseSucceed(() => false), - ); -} - -/** - * Validate the materialized OpenClaw plugin project and its channel dependency. - * - * @param projectDir Value supplied to the operation. - * @param channelVersion Value supplied to the operation. - * @internal - * @returns The validate open claw plugin project result. - */ -export const validateOpenClawPluginProject = Effect.fn( - "validateOpenClawPluginProject", -)(function* (projectDir: string, channelVersion: string) { - const fileSystem = yield* FileSystem.FileSystem; - const [manifestText, lockText] = yield* Effect.all([ - fsEffect( - "read OpenClaw npm project manifest", - fileSystem.readFileString(join(projectDir, "package.json"), "utf8"), - ), - fsEffect( - "read OpenClaw npm project lock", - fileSystem.readFileString(join(projectDir, "package-lock.json"), "utf8"), - ), - ]); - yield* Effect.try({ - try: () => { - validateProjectProvenance( - JSON.parse(manifestText), - JSON.parse(lockText), - channelVersion, - ); - }, - catch: (cause) => - cause instanceof OpenClawPluginCacheError - ? cause - : cacheError("Unable to validate OpenClaw npm provenance", cause), - }); -}); - -function validateProjectProvenance( - manifest: unknown, - lock: unknown, - channelVersion: string, -): void { - const manifestRecord = requireRecord(manifest, "npm project package.json"); - const manifestDependencies = requireRecord( - manifestRecord.dependencies, - "npm project dependencies", - ); - requireExactValue( - manifestDependencies[CHANNEL_PACKAGE_NAME], - channelVersion, - "npm project channel dependency", - ); - const lockRecord = requireRecord(lock, "npm project package-lock.json"); - const lockPackages = requireRecord( - lockRecord.packages, - "npm project lock packages", - ); - const rootLock = requireRecord(lockPackages[""], "npm project lock root"); - const rootDependencies = requireRecord( - rootLock.dependencies, - "npm project lock root dependencies", - ); - requireExactValue( - rootDependencies[CHANNEL_PACKAGE_NAME], - channelVersion, - "npm lock channel dependency", - ); - validateMoltzapLockEntries(lockPackages, channelVersion); -} - -function validateMoltzapLockEntries( - lockPackages: Readonly>, - channelVersion: string, -): void { - let channelFound = false; - for (const [location, value] of Object.entries(lockPackages)) { - if (!location.includes("node_modules/@moltzap/")) { - continue; - } - const entry = requireRecord(value, `npm lock entry ${location}`); - const resolved = requireString(entry.resolved, `${location} resolved`); - const integrity = requireString(entry.integrity, `${location} integrity`); - if ( - entry.link === true || - !resolved.startsWith(NPM_REGISTRY_PREFIX) || - !integrity.startsWith(NPM_INTEGRITY_PREFIX) - ) { - throw cacheError( - `Published OpenClaw plugin dependency ${location} is not registry-backed with sha512 integrity`, - ); - } - if (location.endsWith(`node_modules/${CHANNEL_PACKAGE_NAME}`)) { - requireExactValue( - entry.version, - channelVersion, - "installed channel version", - ); - channelFound = true; - } - } - if (!channelFound) { - throw cacheError( - `OpenClaw npm lock does not contain ${CHANNEL_PACKAGE_NAME}`, - ); - } -} - -function copyProjectIntoBuildingCache( - sourceProjectDir: string, - buildingDir: string, -) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const destination = join( - buildingDir, - "npm", - "projects", - basename(sourceProjectDir), - ); - yield* fsEffect( - "copy OpenClaw npm project into immutable cache", - fileSystem.copy(sourceProjectDir, destination), - ); - yield* fsEffect( - "remove cached OpenClaw peer link", - fileSystem.remove(openclawPeerLinkPath(destination), { - recursive: true, - force: true, - }), - ); - }); -} - -/** - * Copies one cached npm project and rebuilds the only machine-specific link. - * @param options Options that control the operation. - * @param options.generationDir Value supplied to the operation. - * @param options.stateDir Value supplied to the operation. - * @param options.openclawPackageRoot Value supplied to the operation. - * @internal - * @returns The materialize open claw plugin cache generation result. - */ -export const materializeOpenClawPluginCacheGeneration = Effect.fn( - "materializeOpenClawPluginCacheGeneration", -)(function* (options: { - readonly generationDir: string; - readonly stateDir: string; - readonly openclawPackageRoot: string; -}) { - const fileSystem = yield* FileSystem.FileSystem; - const cachedProjectsDir = join(options.generationDir, "npm", "projects"); - const entries = yield* fsEffect( - "list cached OpenClaw npm projects", - fileSystem.readDirectory(cachedProjectsDir), - ); - const entry = yield* requireSoleEntry(entries, "cached OpenClaw npm project"); - const projectDir = join(options.stateDir, "npm", "projects", entry); - yield* fsEffect( - "materialize cached OpenClaw npm project", - fileSystem.copy(join(cachedProjectsDir, entry), projectDir), - ); - yield* recreateOpenClawPeerLink(projectDir, options.openclawPackageRoot); - return projectDir; -}); - -function recreateOpenClawPeerLink( - projectDir: string, - openclawPackageRoot: string, -) { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const info = yield* fsEffect( - "inspect simulator-resolved OpenClaw package root", - fileSystem.stat(openclawPackageRoot), - ); - if (info.type !== "Directory") { - return yield* cacheError( - `Unable to resolve OpenClaw peer link target at ${openclawPackageRoot}`, - ); - } - const peerLink = openclawPeerLinkPath(projectDir); - const canonicalRoot = yield* fsEffect( - "canonicalize simulator-resolved OpenClaw package root", - fileSystem.realPath(openclawPackageRoot), - ); - yield* fsEffect( - "remove stale OpenClaw peer link", - fileSystem.remove(peerLink, { recursive: true, force: true }), - ); - yield* fsEffect( - "create OpenClaw peer link", - fileSystem - .makeDirectory(dirname(peerLink), { recursive: true }) - .pipe(Effect.zipRight(fileSystem.symlink(canonicalRoot, peerLink))), - ); - }).pipe( - Effect.mapError((cause) => - cacheError( - `Unable to resolve OpenClaw peer link target at ${openclawPackageRoot}`, - cause, - ), - ), - ); -} - -function openclawPeerLinkPath(projectDir: string): string { - return join( - projectDir, - "node_modules", - "@moltzap", - "openclaw-channel", - "node_modules", - "openclaw", - ); -} diff --git a/packages/simulator/src/runtime/openclaw/process.test.ts b/packages/simulator/src/runtime/openclaw/process.test.ts deleted file mode 100644 index fdf35bab3..000000000 --- a/packages/simulator/src/runtime/openclaw/process.test.ts +++ /dev/null @@ -1,357 +0,0 @@ -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { CommandExecutor } from "@effect/platform"; -import { NodeContext, NodeSocketServer } from "@effect/platform-node"; -import { - Cause, - Deferred, - Duration, - Effect, - Either, - Exit, - Fiber, - Redacted, -} from "effect"; -import { serverBaseUrl } from "@moltzap/protocol/network"; -import { - agentId, - agentName, - agentKeyString, - redactedAgentKey, -} from "@moltzap/protocol/testing"; -import { OpenClawSchema } from "openclaw/plugin-sdk/config-schema"; -import { isToolAllowed } from "openclaw/plugin-sdk/sandbox"; -import { describe, expect, it } from "vitest"; -import { - acquireOpenClawProcess, - buildOpenClawConfig, - buildOpenClawProcessPlan, - leaseOpenClawPort, - type OpenClawProcessInput, - type OpenClawSandboxConfig, - type OpenClawToolsConfig, -} from "./process.js"; - -const EPHEMERAL_PORT = 0; -const PORT_LEASE_CONCURRENCY = 64; -const ACQUISITION_INTERRUPT_TIMEOUT_MS = 1_000; -const PROCESS_PORT = 44_321; -const STATE_DIR = "/run/moltzap/openclaw/alice"; -const OPERATOR_HOME = "/home/operator"; -const CHANNEL_DIST_DIR = resolve( - dirname(fileURLToPath(import.meta.url)), - "../../../../openclaw-channel/dist", -); -const PROCESS_INPUT: OpenClawProcessInput = { - agentName: agentName("alice"), - agentId: agentId("00000000-0000-4000-8000-000000000001"), - apiKey: redactedAgentKey(agentKeyString(97)), - serverUrl: serverBaseUrl("http://127.0.0.1:43123"), -}; -const FAIL_CLOSED_TOOLS = { - deny: ["*"], - elevated: { enabled: false }, - exec: { mode: "deny" }, -} satisfies OpenClawToolsConfig; -const MESSAGE_ONLY_TOOLS = { - allow: ["message"], - sandbox: { - tools: { - allow: ["message"], - }, - }, - elevated: { enabled: false }, - exec: { mode: "deny" }, -} satisfies OpenClawToolsConfig; -const FAIL_CLOSED_SANDBOX = { - mode: "all", - backend: "docker", - scope: "session", - workspaceAccess: "none", - docker: { network: "none" }, -} satisfies OpenClawSandboxConfig; - -function rendersFailClosedPolicy(): void { - const config = buildOpenClawConfig( - { - agentName: PROCESS_INPUT.agentName, - installMode: "workspace", - tools: FAIL_CLOSED_TOOLS, - sandbox: FAIL_CLOSED_SANDBOX, - gatewayToken: Redacted.make("test-gateway-token"), - }, - join(STATE_DIR, "workspace"), - ); - - expect(OpenClawSchema.safeParse(config).success).toBe(true); - expect(config.tools).toEqual(FAIL_CLOSED_TOOLS); - expect(config.agents?.defaults?.sandbox).toEqual(FAIL_CLOSED_SANDBOX); - for (const tool of [ - "exec", - "read", - "web_fetch", - "session_status", - "moltzap_custom", - ]) { - expect(isToolAllowed(config.tools ?? {}, tool)).toBe(false); - } -} - -function preservesOmittedPolicy(): void { - const config = buildOpenClawConfig( - { - agentName: PROCESS_INPUT.agentName, - installMode: "workspace", - gatewayToken: Redacted.make("test-gateway-token"), - }, - join(STATE_DIR, "workspace"), - ); - - expect(config).not.toHaveProperty("tools"); - expect(config.agents?.defaults).not.toHaveProperty("sandbox"); - expect(config.agents?.list).toEqual([ - { id: PROCESS_INPUT.agentName, default: true }, - ]); -} - -function allowsOnlyNativeMessageTool(): void { - const config = buildOpenClawConfig( - { - agentName: PROCESS_INPUT.agentName, - installMode: "workspace", - tools: MESSAGE_ONLY_TOOLS, - gatewayToken: Redacted.make("test-gateway-token"), - }, - join(STATE_DIR, "workspace"), - ); - - expect(isToolAllowed(config.tools ?? {}, "message")).toBe(true); - expect(isToolAllowed(config.tools?.sandbox?.tools ?? {}, "message")).toBe( - true, - ); - for (const tool of ["exec", "read", "web_fetch", "moltzap_custom"]) { - expect(isToolAllowed(config.tools ?? {}, tool)).toBe(false); - } -} - -function usesIsolatedStateDirectory(): void { - const plan = buildOpenClawProcessPlan({ - openclawBin: "openclaw", - port: PROCESS_PORT, - stateDir: STATE_DIR, - input: PROCESS_INPUT, - baseEnvironment: { - PATH: "/usr/bin", - HOME: OPERATOR_HOME, - }, - }); - - expect(plan.cwd).toBe(STATE_DIR); - expect(plan.env.HOME).toBe(STATE_DIR); - expect(plan.env.HOME).not.toBe(OPERATOR_HOME); - expect(plan.env.OPENCLAW_CONFIG_PATH).toBe(join(STATE_DIR, "openclaw.json")); -} - -describe("OpenClaw generated policy", () => { - it( - "renders a native fail-closed tool and sandbox policy", - rendersFailClosedPolicy, - ); - it("allows only the native social message tool", allowsOnlyNativeMessageTool); - it("preserves omitted customer policy", preservesOmittedPolicy); - it( - "uses the isolated state directory as the child HOME", - usesIsolatedStateDirectory, - ); -}); - -describe("OpenClaw port claims", () => { - it( - "leases unique logical ports after every probe has closed", - openClawPortLeasesRemainUniqueAfterProbeClose, - ); - - it( - "does not reissue a transferred logical claim", - openClawPortClaimSurvivesProbeClose, - ); - - it( - "releases an untransferred claim with its startup scope", - openClawStartupScopeReleasesPortClaim, - ); - - it( - "finishes probe teardown when concurrent startup is interrupted", - interruptedOpenClawPortProbesFinish, - ); - - it( - "interrupts process acquisition while startup is pending", - interruptedOpenClawProcessAcquisitionFinishes, - ); -}); - -function openClawPortLeasesRemainUniqueAfterProbeClose() { - return Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const claims = yield* Effect.all( - Array.from({ length: PORT_LEASE_CONCURRENCY }, () => - leaseOpenClawPort(), - ), - { concurrency: PORT_LEASE_CONCURRENCY }, - ); - const ports = claims.map((claim) => claim.port); - expect(new Set(ports).size).toBe(PORT_LEASE_CONCURRENCY); - - const competingBinds = yield* Effect.all( - ports.map((port) => - Effect.scoped( - NodeSocketServer.make({ host: "127.0.0.1", port }), - ).pipe(Effect.either), - ), - { concurrency: PORT_LEASE_CONCURRENCY }, - ); - expect(competingBinds.every(Either.isRight)).toBe(true); - }), - ).pipe(Effect.provide(NodeContext.layer), Effect.orDie), - ); -} - -function openClawPortClaimSurvivesProbeClose() { - return Effect.runPromise( - Effect.gen(function* () { - const claim = yield* leaseTransferredClaim(); - yield* expectOpenClawPortClaimed(claim.port).pipe( - Effect.ensuring(claim.release()), - ); - }).pipe(Effect.orDie), - ); -} - -function openClawStartupScopeReleasesPortClaim() { - return Effect.runPromise( - Effect.gen(function* () { - let port = EPHEMERAL_PORT; - yield* Effect.scoped( - leaseOpenClawPort().pipe( - Effect.tap((claim) => - Effect.sync(() => { - port = claim.port; - }), - ), - ), - ); - yield* expectOpenClawPortReleased(port); - }).pipe(Effect.orDie), - ); -} - -function interruptedOpenClawPortProbesFinish() { - return Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const acquisitions = yield* Effect.all( - Array.from({ length: PORT_LEASE_CONCURRENCY }, () => - leaseOpenClawPort().pipe(Effect.fork), - ), - { concurrency: PORT_LEASE_CONCURRENCY }, - ); - yield* Effect.yieldNow(); - yield* Effect.forEach(acquisitions, Fiber.interrupt, { - concurrency: PORT_LEASE_CONCURRENCY, - discard: true, - }); - }), - ).pipe(Effect.provide(NodeContext.layer), Effect.orDie), - ); -} - -function interruptedOpenClawProcessAcquisitionFinishes() { - return Effect.runPromise( - Effect.scoped( - Effect.gen(function* () { - const commandStarted = yield* Deferred.make(); - const stalledCommandExecutor = CommandExecutor.makeExecutor(() => - Deferred.succeed(commandStarted, undefined).pipe( - Effect.zipRight(Effect.never), - ), - ); - const acquisition = yield* acquireOpenClawProcess( - { - openclawBin: "unused", - channelDistDir: CHANNEL_DIST_DIR, - installMode: "workspace", - }, - PROCESS_INPUT, - ).pipe( - Effect.provideService( - CommandExecutor.CommandExecutor, - stalledCommandExecutor, - ), - Effect.forkDaemon, - ); - - yield* Deferred.await(commandStarted); - yield* Fiber.interruptFork(acquisition); - const exit = yield* Fiber.await(acquisition).pipe( - Effect.timeout(Duration.millis(ACQUISITION_INTERRUPT_TIMEOUT_MS)), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(Cause.isInterruptedOnly(exit.cause)).toBe(true); - } - }), - ).pipe(Effect.provide(NodeContext.layer), Effect.orDie), - ); -} - -function expectOpenClawPortClaimed(port: number) { - return observeOpenClawPortCandidates([port, EPHEMERAL_PORT]).pipe( - Effect.tap(({ allocatedPort, requested }) => - Effect.sync(() => { - expect(requested.slice(0, 2)).toEqual([port, EPHEMERAL_PORT]); - expect(allocatedPort).not.toBe(port); - }), - ), - Effect.asVoid, - ); -} - -function expectOpenClawPortReleased(port: number) { - return observeOpenClawPortCandidates([port, EPHEMERAL_PORT]).pipe( - Effect.tap(({ allocatedPort, requested }) => - Effect.sync(() => { - expect(requested).toEqual([port]); - expect(allocatedPort).toBe(port); - }), - ), - Effect.asVoid, - ); -} - -function observeOpenClawPortCandidates(candidatePorts: readonly number[]) { - const candidates = [...candidatePorts]; - const requested: number[] = []; - return Effect.scoped( - leaseOpenClawPort({ - candidatePort: () => { - const candidate = candidates.shift() ?? EPHEMERAL_PORT; - requested.push(candidate); - return candidate; - }, - }).pipe( - Effect.map((claim) => ({ - allocatedPort: claim.port, - requested, - })), - ), - ); -} - -function leaseTransferredClaim() { - return Effect.scoped( - leaseOpenClawPort().pipe(Effect.tap((claim) => claim.transfer())), - ); -} diff --git a/packages/simulator/src/runtime/openclaw/process.ts b/packages/simulator/src/runtime/openclaw/process.ts deleted file mode 100644 index 74467b3c3..000000000 --- a/packages/simulator/src/runtime/openclaw/process.ts +++ /dev/null @@ -1,1109 +0,0 @@ -/* eslint-disable jsdoc/text-escaping -- Mermaid blocks need literal `
` (HTML5) for renderer compatibility. */ -/** @file OpenClaw process configuration, resource acquisition, and supervision. */ -import { createHash, randomBytes } from "node:crypto"; -import { homedir } from "node:os"; -import { join, resolve, sep } from "node:path"; -import { - type Command, - type CommandExecutor, - type Error as PlatformError, - FileSystem, - Path, - type SocketServer, -} from "@effect/platform"; -import { - Cause, - Config, - Data, - Effect, - Exit, - Fiber, - Inspectable, - Redacted, - Scope, -} from "effect"; -import * as NodeSocketServer from "@effect/platform-node/NodeSocketServer"; -import type { MoltzapChannelPlugin } from "@moltzap/openclaw-channel"; -import type { AgentId, AgentKey, AgentName } from "@moltzap/protocol/identity"; -import { httpBaseUrl, type ServerBaseUrl } from "@moltzap/protocol/network"; -import type { OpenClawConfig } from "openclaw/plugin-sdk"; -import type { - AgentDefaultsConfig, - ToolsConfig, -} from "openclaw/plugin-sdk/config-types"; - -import { - type BaseChildEnvironment, - baseChildEnvironmentConfig, - BoundedLogBuffer, - escalatingKill, - makeExactEnvironmentCommand, - type ProcessTreeCleanup, - startSupervisedProcess, -} from "../command.js"; -import { - installChannelPlugin, - seedWorkspaceFiles, - SIMULATOR_PROFILE_NAME, - writeMoltZapProfileConfig, -} from "../workspace.js"; -import { - type InstallMode, - resolveInstalledPackageBin, - resolveInstalledPackageRoot, -} from "../packages.js"; -import { materializePublishedOpenClawPlugin } from "./cache.js"; - -const OPENCLAW_TERM_WAIT_MS = 10_000; -const OPENCLAW_KILL_WAIT_MS = 5_000; -const DEFAULT_OPENCLAW_MODEL_ID = "openai/gpt-5.5"; -const OPENCLAW_CHANNEL_ID = "moltzap" satisfies MoltzapChannelPlugin["id"]; -const OPENCLAW_EXTENSION_NAME = "openclaw-channel"; -const OPENCLAW_GATEWAY_TOKEN_BYTES = 32; -const OPENCLAW_GATEWAY_TOKEN_REDACTION_MARKER = - "[REDACTED:openclaw-gateway-token]"; -const JSON_INDENT_SPACES = 2; -const OPENCLAW_WORKSPACE_DIRNAME = "workspace"; -const OPENCLAW_ATTESTATION_DIRNAME = "workspace-attestations"; -const OPENCLAW_ATTESTATION_SUFFIX = ".attested"; -const EPHEMERAL_PORT = 0; - -/** Ports assigned to OpenClaw children that still own their process scope. */ -const CLAIMED_OPENCLAW_PORTS = new Set(); - -class PortAllocationFailed extends Data.TaggedError("PortAllocationFailed")<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -class OpenClawInstallModeError extends Data.TaggedError( - "OpenClawInstallModeError", -)<{ - readonly message: string; - readonly channelDistDir: string; - readonly resolvedChannelDistDir: string; -}> {} - -function stopSpawnedOpenClawProcess(proc: SpawnedProcess): Effect.Effect { - return Effect.uninterruptible( - Effect.gen(function* () { - yield* escalatingKill( - proc.proc, - proc.exitFiber, - { - termWaitMs: OPENCLAW_TERM_WAIT_MS, - killWaitMs: OPENCLAW_KILL_WAIT_MS, - }, - proc.processTreeCleanup, - ); - yield* Scope.close(proc.scope, Exit.succeed(undefined)); - }), - ); -} - -function initializeOpenClawProcess( - command: Command.Command, - logBuffer: BoundedLogBuffer, - scope: Scope.CloseableScope, -) { - return startSupervisedProcess( - command, - scope, - (chunk) => { - logBuffer.append(chunk); - }, - { - claimed: false, - launcherOwnsExitCleanup: true, - }, - ).pipe( - Effect.map( - ({ proc, exitFiber, processTreeCleanup }) => - ({ - proc, - exitFiber, - processTreeCleanup, - scope, - }) satisfies SpawnedProcess, - ), - ); -} - -function closeScopeOnFailedProcessStart( - scope: Scope.CloseableScope, - exit: Exit.Exit, -): Effect.Effect { - return Exit.isSuccess(exit) ? Effect.void : Scope.close(scope, exit); -} - -function captureSpawnedOpenClawProcess( - lease: OpenClawSpawnLease, - process: SpawnedProcess, -): Effect.Effect { - return Effect.sync(() => { - lease.process = process; - }); -} - -function releaseOpenClawSpawnLease( - lease: OpenClawSpawnLease, -): Effect.Effect { - return lease.committed || lease.process === null - ? Effect.void - : stopSpawnedOpenClawProcess(lease.process); -} - -function releasePortClaimWhenProcessEnds( - process: SpawnedProcess, - portClaim: OpenClawPortClaim, -): Effect.Effect { - return Fiber.join(process.exitFiber).pipe( - Effect.asVoid, - Effect.ensuring(portClaim.release()), - Effect.forkIn(process.scope), - Effect.asVoid, - ); -} - -function spawnOpenClawProcess(opts: { - readonly command: string; - readonly args: readonly string[]; - readonly cwd: string; - readonly env: Readonly>; - readonly logBuffer: BoundedLogBuffer; - readonly onStarted: (process: SpawnedProcess) => Effect.Effect; -}): Effect.Effect { - const command = makeExactEnvironmentCommand({ - ...opts, - cleanupTreeOnExit: true, - }); - - return Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const scope = yield* Scope.make(); - return yield* Effect.gen(function* () { - const started = yield* restore( - initializeOpenClawProcess(command, opts.logBuffer, scope), - ); - yield* opts.onStarted(started); - return started; - }).pipe( - Effect.onExit((exit) => closeScopeOnFailedProcessStart(scope, exit)), - ); - }), - ).pipe( - Effect.mapError((cause) => - cause instanceof Error ? cause : new Cause.UnknownException(cause), - ), - ); -} - -/** One stdio MCP server wired into an OpenClaw process at spawn time. */ -interface McpServerMount { - readonly name: string; - readonly command: string; - readonly args: readonly string[]; - readonly env: Readonly>; -} - -/** Native OpenClaw tool exposure and execution configuration. */ -export type OpenClawToolsConfig = ToolsConfig; - -/** Native OpenClaw sandbox configuration for the runtime's default agent. */ -export type OpenClawSandboxConfig = NonNullable; - -/** - * Immutable host configuration for one OpenClaw process. - * @internal - */ -export interface OpenClawProcessOptions { - readonly openclawBin: string; - readonly channelDistDir: string; - readonly installMode: InstallMode; - readonly mcpServers?: readonly McpServerMount[]; -} - -/** - * Optional package locations accepted before host configuration is resolved. - * @internal - */ -export interface OpenClawProcessOptionOverrides { - readonly openclawBin?: string; - readonly channelDistDir?: string; - readonly installMode: InstallMode; - readonly mcpServers?: readonly McpServerMount[]; -} - -/** - * Router attachment material consumed by the OpenClaw child process. - * @internal - */ -export interface OpenClawProcessInput { - readonly agentName: AgentName; - readonly apiKey: AgentKey; - readonly agentId: AgentId; - readonly serverUrl: ServerBaseUrl; - readonly workspaceFiles?: ReadonlyArray<{ - readonly relativePath: string; - readonly content: string; - }>; - readonly modelId?: string; - readonly tools?: OpenClawToolsConfig; - readonly sandbox?: OpenClawSandboxConfig; -} - -/** - * Scope-owned observations for one OpenClaw process. - * @internal - */ -export interface OpenClawProcessSession { - readonly exitCode: Effect.Effect< - CommandExecutor.ExitCode, - PlatformError.PlatformError - >; - readonly output: () => string; - readonly gatewayUrl: `ws://127.0.0.1:${number}`; - readonly gatewayToken: Redacted.Redacted; - readonly agentName: AgentName; -} - -interface SpawnedProcess { - readonly proc: CommandExecutor.Process; - readonly exitFiber: Fiber.RuntimeFiber< - CommandExecutor.ExitCode, - PlatformError.PlatformError - >; - readonly processTreeCleanup?: ProcessTreeCleanup; - readonly scope: Scope.CloseableScope; -} - -interface OpenClawSpawnLease { - process: SpawnedProcess | null; - committed: boolean; -} - -interface BoundOpenClawPort { - readonly port: number; -} - -interface OpenClawPortClaim { - readonly port: number; - transfer(): Effect.Effect; - release(): Effect.Effect; -} - -/** - * Explicitly owned resources for one running OpenClaw gateway. - * @internal - */ -interface OpenClawRuntimeHandle { - readonly process: SpawnedProcess; - readonly stateDir: string; - readonly logBuffer: BoundedLogBuffer; - readonly portClaim: OpenClawPortClaim; - readonly gatewayToken: Redacted.Redacted; - readonly agentName: AgentName; -} - -type LeasedOpenClawPortClaim = OpenClawPortClaim & { - closeStartupLease(): Effect.Effect; -}; - -interface OpenClawPortClaimState { - transferred: boolean; - released: boolean; -} - -interface OpenClawPortLeaseOptions { - /** - * Candidate request used by deterministic allocation tests. Production - * requests port zero so the kernel chooses each candidate. - */ - readonly candidatePort?: () => number; -} - -interface OpenClawProcessPlan { - readonly command: string; - readonly args: readonly string[]; - readonly cwd: string; - readonly env: Readonly>; -} - -/** - * Build the exact OpenClaw child-process command and environment. - * - * @param opts Value supplied to the operation. - * @param opts.openclawBin Value supplied to the operation. - * @param opts.port Value supplied to the operation. - * @param opts.stateDir Value supplied to the operation. - * @param opts.input Value supplied to the operation. - * @param opts.baseEnvironment Value supplied to the operation. - * @internal - * @returns The created open claw process plan. - */ -export function buildOpenClawProcessPlan(opts: { - readonly openclawBin: string; - readonly port: number; - readonly stateDir: string; - readonly input: OpenClawProcessInput; - readonly baseEnvironment: BaseChildEnvironment; -}): OpenClawProcessPlan { - const openclawArgs = [ - "gateway", - "run", - "--allow-unconfigured", - "--port", - String(opts.port), - ]; - const entrypoint = opts.openclawBin.endsWith(".mjs") - ? { command: "node", args: [opts.openclawBin, ...openclawArgs] } - : { command: opts.openclawBin, args: openclawArgs }; - return { - ...entrypoint, - cwd: opts.stateDir, - env: { - ...opts.baseEnvironment, - HOME: opts.stateDir, - OPENCLAW_STATE_DIR: opts.stateDir, - OPENCLAW_CONFIG_PATH: join(opts.stateDir, "openclaw.json"), - MOLTZAP_CONFIG_HOME: join(opts.stateDir, ".moltzap"), - MOLTZAP_SERVER_URL: httpBaseUrl(opts.input.serverUrl), - }, - }; -} - -function allocateOpenClawStateDir( - input: OpenClawProcessInput, -): Effect.Effect { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.makeTempDirectory({ - prefix: `openclaw-${input.agentName}-`, - }), - ), - ); -} - -// Model-provider auth lives in the per-state-dir agent store, and login is -// an interactive flow — spawned agents get fresh temp state dirs, so the -// operator logs in once against the default ~/.openclaw state and every -// agent seeds its store from there. The sqlite WAL companions are copied -// with the store so a not-yet-checkpointed login survives the copy. -const OPERATOR_AUTH_STORE_FILES = [ - "auth-profiles.json", - "openclaw-agent.sqlite", - "openclaw-agent.sqlite-shm", - "openclaw-agent.sqlite-wal", -]; - -// "main" is openclaw's default agent id; per-agent auth resolution beyond -// the OPENCLAW_HOME override stays with the granularity follow-up. -const OPERATOR_AGENT_REL_DIR = join("agents", "main", "agent"); - -const operatorOpenClawHome = Config.string("OPENCLAW_HOME").pipe( - Config.withDefault(""), - Config.map((value) => value.trim() || join(homedir(), ".openclaw")), -); - -function seedModelAuthProfile( - stateDir: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const operatorHome = yield* operatorOpenClawHome; - const operatorAgentDir = join(operatorHome, OPERATOR_AGENT_REL_DIR); - const present = yield* Effect.all( - OPERATOR_AUTH_STORE_FILES.map((fileName) => - fileSystem - .exists(join(operatorAgentDir, fileName)) - .pipe(Effect.map((exists) => (exists ? fileName : null))), - ), - { concurrency: OPERATOR_AUTH_STORE_FILES.length }, - ); - const fileNames = present.filter( - (fileName): fileName is string => fileName !== null, - ); - if (fileNames.length === 0) { - return; - } - const destinationDir = join(stateDir, OPERATOR_AGENT_REL_DIR); - yield* fileSystem.makeDirectory(destinationDir, { recursive: true }); - yield* Effect.all( - fileNames.map((fileName) => - fileSystem.copyFile( - join(operatorAgentDir, fileName), - join(destinationDir, fileName), - ), - ), - { concurrency: fileNames.length, discard: true }, - ); - }).pipe( - Effect.catchAll((cause) => - Effect.logWarning("failed to seed openclaw model auth store", cause), - ), - ); -} - -function openClawWorkspaceDir(stateDir: string): string { - return join(stateDir, OPENCLAW_WORKSPACE_DIRNAME); -} - -/** - * Occupies the attestation paths OpenClaw derives for this run's workspace - * with directories. `lstat` succeeds and `isFile()` is false, so OpenClaw - * reads the workspace as never attested and writes no marker of its own. - * - * OpenClaw's guard refuses to reseed a workspace that was attested recently - * and is now empty, which protects a durable operator workspace from silent - * reseeding. A simulated agent's workspace is per-run, empty unless the - * runtime policy declares files, and the agent may delete anything in it: one - * create-then-delete otherwise leaves the guard throwing for the rest of the - * run, uncaught, and the ledger records that as agent silence. - * - * OpenClaw consults a third candidate under its legacy home state dir. That - * path is the operator's rather than the run's, so it is left alone. Of the - * two occupied here only the first is one OpenClaw ever writes; the sibling - * marker it reads but never writes is held defensively. - * - * The derivation is OpenClaw's own, recomputed because no public entry - * exports it, and it fails unsafely: a sentinel at the wrong path leaves - * OpenClaw free to write a real attestation at the right one. Only directories - * work. Blocking a path instead, by permissions or by an `ENOTDIR` parent, - * makes OpenClaw trust what it cannot read as attested and arms the guard on - * the first turn. - * @param stateDir Value supplied to the operation. - * @returns The disarm open claw attestation guard result. - */ -function disarmOpenClawAttestationGuard( - stateDir: string, -): Effect.Effect { - const resolvedWorkspaceDir = resolve(openClawWorkspaceDir(stateDir)); - const key = createHash("sha256").update(resolvedWorkspaceDir).digest("hex"); - const sentinels = [ - join( - stateDir, - OPENCLAW_ATTESTATION_DIRNAME, - `${key}${OPENCLAW_ATTESTATION_SUFFIX}`, - ), - `${resolvedWorkspaceDir}${OPENCLAW_ATTESTATION_SUFFIX}`, - ]; - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - Effect.all( - sentinels.map((sentinel) => - fileSystem.makeDirectory(sentinel, { recursive: true }), - ), - { concurrency: sentinels.length, discard: true }, - ), - ), - ); -} - -/** - * Materialize the OpenClaw state directory and its simulator-owned config. - * - * @param deps Value supplied to the operation. - * @param input Input value to process. - * @param stateDir Value supplied to the operation. - * @param gatewayToken Private token shared with the scoped gateway client. - * @internal - * @returns The configure open claw state dir result. - */ -function configureOpenClawStateDir( - deps: OpenClawProcessOptions, - input: OpenClawProcessInput, - stateDir: string, - gatewayToken: Redacted.Redacted, -): Effect.Effect< - void, - unknown, - CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path -> { - return Effect.all( - [ - writeOpenClawConfig({ - stateDir, - agentName: input.agentName, - agentId: input.agentId, - apiKey: input.apiKey, - modelId: input.modelId, - installMode: deps.installMode, - mcpServers: deps.mcpServers, - tools: input.tools, - sandbox: input.sandbox, - gatewayToken, - }), - seedWorkspaceFiles(openClawWorkspaceDir(stateDir), input.workspaceFiles), - seedModelAuthProfile(stateDir), - disarmOpenClawAttestationGuard(stateDir), - ], - { concurrency: 4, discard: true }, - ).pipe(Effect.zipRight(installConfiguredChannel(deps, stateDir))); -} - -function installConfiguredChannel( - deps: OpenClawProcessOptions, - stateDir: string, -): Effect.Effect< - void, - unknown, - CommandExecutor.CommandExecutor | FileSystem.FileSystem | Path.Path -> { - if (deps.installMode === "published") { - return materializePublishedOpenClawPlugin({ - stateDir, - openclawBin: deps.openclawBin, - }).pipe(Effect.asVoid); - } - return assertWorkspaceChannelDist(deps.channelDistDir).pipe( - Effect.zipRight( - installChannelPlugin({ - stateDir, - channelDistDir: deps.channelDistDir, - extName: OPENCLAW_EXTENSION_NAME, - // OpenClaw discovers channel plugins through this package-root manifest. - extraPackageFiles: ["openclaw.plugin.json"], - }), - ), - Effect.asVoid, - ); -} - -/** - * Workspace mode accepts local build output, including a node_modules symlink - * whose real target is local, but never an installed package-store copy. - * @param channelDistDir Value supplied to the operation. - * @internal - * @returns The assert workspace channel dist result. - */ -function assertWorkspaceChannelDist(channelDistDir: string) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => fileSystem.realPath(channelDistDir)), - Effect.flatMap((resolvedChannelDistDir) => - resolvedChannelDistDir.split(sep).includes("node_modules") - ? Effect.fail( - new OpenClawInstallModeError({ - message: - "OpenClaw workspace install mode requires local channel build output", - channelDistDir, - resolvedChannelDistDir, - }), - ) - : Effect.void, - ), - Effect.withSpan("assertWorkspaceChannelDist"), - ); -} - -function removeOpenClawStateDir( - stateDir: string, -): Effect.Effect { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.remove(stateDir, { recursive: true, force: true }), - ), - Effect.catchAll((cause) => - Effect.logWarning("failed to remove OpenClaw state directory", cause), - ), - ); -} - -function spawnConfiguredOpenClaw(options: { - readonly deps: OpenClawProcessOptions; - readonly stateDir: string; - readonly input: OpenClawProcessInput; - readonly port: number; - readonly logBuffer: BoundedLogBuffer; - readonly onStarted: (process: SpawnedProcess) => Effect.Effect; -}): Effect.Effect { - return Effect.gen(function* () { - const baseEnvironment = yield* baseChildEnvironmentConfig; - return yield* spawnOpenClawProcess({ - ...buildOpenClawProcessPlan({ - openclawBin: options.deps.openclawBin, - port: options.port, - stateDir: options.stateDir, - input: options.input, - baseEnvironment, - }), - logBuffer: options.logBuffer, - onStarted: options.onStarted, - }); - }).pipe( - Effect.mapError((cause) => - cause instanceof Error - ? cause - : new Cause.UnknownException(cause, Inspectable.format(cause)), - ), - ); -} - -/** - * Starts one configured OpenClaw gateway and hands its process, state - * directory, log buffer, and logical port claim to the caller. - * - * ```mermaid - * flowchart TD - * START["startOpenClawRuntimeEffect"] - * PORT["lease loopback port
close probe, retain logical claim"] - * STATE["create + configure isolated state dir"] - * MODE{"install mode"} - * WORKSPACE["workspace
validate + copy channel"] - * PUBLISHED["published
materialize pinned plugin"] - * PROCESS["start supervised process
exact environment + bounded logs"] - * HANDOFF["transfer resources to runtime handle"] - * RELEASE["failure or interruption
stop process + remove state + release claim"] - * START --> PORT --> STATE --> MODE - * MODE -->|workspace| WORKSPACE --> PROCESS - * MODE -->|published| PUBLISHED --> PROCESS - * PROCESS --> HANDOFF - * PORT -.-> RELEASE - * STATE -.-> RELEASE - * WORKSPACE -.-> RELEASE - * PUBLISHED -.-> RELEASE - * PROCESS -.-> RELEASE - * ``` - * - * Router-visible readiness remains the owning runtime's concern. - * @internal - */ -const startOpenClawRuntimeEffect = Effect.fn("OpenClawProcess.start")( - function* (deps: OpenClawProcessOptions, input: OpenClawProcessInput) { - return yield* acquireOpenClawRuntimeHandle(deps, input).pipe( - Effect.withSpan("startOpenClawRuntimeEffect"), - ); - }, -); - -function acquireOpenClawRuntimeHandle( - deps: OpenClawProcessOptions, - input: OpenClawProcessInput, -) { - return Effect.uninterruptibleMask((restore) => - Effect.scoped( - Effect.gen(function* () { - const portClaim = yield* restore(leaseOpenClawPort()); - const lease: OpenClawSpawnLease = { - process: null, - committed: false, - }; - const gatewayToken = yield* Effect.sync(makeOpenClawGatewayToken); - const stateDir = yield* restore(allocateOpenClawStateDir(input)); - yield* Effect.addFinalizer(() => - lease.committed ? Effect.void : removeOpenClawStateDir(stateDir), - ); - yield* restore( - configureOpenClawStateDir(deps, input, stateDir, gatewayToken), - ); - - const logBuffer = new BoundedLogBuffer(); - const process = yield* restore( - Effect.acquireReleaseInterruptible( - spawnConfiguredOpenClaw({ - deps, - stateDir, - input, - port: portClaim.port, - logBuffer, - onStarted: (started) => - captureSpawnedOpenClawProcess(lease, started), - }), - () => releaseOpenClawSpawnLease(lease), - ), - ); - return yield* commitOpenClawRuntimeHandle(lease, { - process, - stateDir, - logBuffer, - portClaim, - gatewayToken, - agentName: input.agentName, - }); - }), - ), - ); -} - -function makeOpenClawGatewayToken(): Redacted.Redacted { - return Redacted.make( - randomBytes(OPENCLAW_GATEWAY_TOKEN_BYTES).toString("base64url"), - ); -} - -function commitOpenClawRuntimeHandle( - lease: OpenClawSpawnLease, - handle: OpenClawRuntimeHandle, -): Effect.Effect { - return releasePortClaimWhenProcessEnds(handle.process, handle.portClaim).pipe( - Effect.zipRight(handle.portClaim.transfer()), - Effect.zipRight( - Effect.sync(() => { - lease.committed = true; - }), - ), - Effect.as(handle), - ); -} - -/** - * Stops a running gateway and releases every resource in its handle. - * @param handle Value supplied to the operation. - * @returns The stop open claw runtime effect result. - */ -function stopOpenClawRuntimeEffect( - handle: OpenClawRuntimeHandle, -): Effect.Effect { - return Effect.uninterruptible( - stopSpawnedOpenClawProcess(handle.process).pipe( - Effect.ensuring(handle.portClaim.release()), - Effect.ensuring(removeOpenClawStateDir(handle.stateDir)), - Effect.ensuring( - Effect.sync(() => { - Redacted.unsafeWipe(handle.gatewayToken); - }), - ), - ), - ).pipe(Effect.withSpan("stopOpenClawRuntimeEffect")); -} - -function acquireScopedOpenClawRuntimeHandle( - deps: OpenClawProcessOptions, - input: OpenClawProcessInput, -) { - return Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const handle = yield* restore(startOpenClawRuntimeEffect(deps, input)); - yield* Effect.addFinalizer(openClawRuntimeFinalizer(handle)); - return handle; - }), - ); -} - -function openClawRuntimeFinalizer(handle: OpenClawRuntimeHandle) { - return () => stopOpenClawRuntimeEffect(handle); -} - -function openClawProcessSession( - handle: OpenClawRuntimeHandle, -): OpenClawProcessSession { - return { - exitCode: Fiber.join(handle.process.exitFiber), - output: () => - handle.logBuffer.text - .split(Redacted.value(handle.gatewayToken)) - .join(OPENCLAW_GATEWAY_TOKEN_REDACTION_MARKER), - gatewayUrl: `ws://127.0.0.1:${handle.portClaim.port}`, - gatewayToken: handle.gatewayToken, - agentName: handle.agentName, - }; -} - -/** - * Acquires one gateway in the caller's Scope and exposes only process - * observations needed by process-backed runtimes. - * @internal - */ -export const acquireOpenClawProcess = Effect.fn("OpenClawProcess.acquire")( - (deps: OpenClawProcessOptions, input: OpenClawProcessInput) => - acquireScopedOpenClawRuntimeHandle(deps, input).pipe( - Effect.map(openClawProcessSession), - ), -); - -/** - * Resolves omitted package locations into exact process host configuration. - * @param input Input value to process. - * @internal - * @returns The resolve open claw process options result. - */ -export function resolveOpenClawProcessOptions( - input: OpenClawProcessOptionOverrides, -): OpenClawProcessOptions { - return { - openclawBin: - input.openclawBin ?? resolveInstalledPackageBin("openclaw", "openclaw"), - channelDistDir: input.channelDistDir ?? resolveOpenClawChannelDistDir(), - installMode: input.installMode, - ...(input.mcpServers === undefined ? {} : { mcpServers: input.mcpServers }), - }; -} - -function resolveOpenClawChannelDistDir(): string { - return join( - resolveInstalledPackageRoot("@moltzap/openclaw-channel", import.meta.url), - "dist", - ); -} - -/** - * Selects an available loopback port and retains a process-local logical - * claim. The probe listener closes before this acquisition returns so the - * OpenClaw child never races a listener owned by its parent. - * @param options Options that control the operation. - * @internal - * @returns The lease open claw port result. - */ -export function leaseOpenClawPort( - options: OpenClawPortLeaseOptions = {}, -): Effect.Effect< - OpenClawPortClaim, - PortAllocationFailed | SocketServer.SocketServerError, - Scope.Scope -> { - const candidatePort = options.candidatePort ?? (() => EPHEMERAL_PORT); - return Effect.uninterruptibleMask((restore) => - Effect.gen(function* () { - const claim = yield* restore(claimOpenClawPort(candidatePort)); - yield* Effect.addFinalizer(() => claim.closeStartupLease()); - return claim; - }), - ).pipe(Effect.withSpan("leaseOpenClawPort")); -} - -function claimOpenClawPort( - candidatePort: () => number, -): Effect.Effect< - LeasedOpenClawPortClaim, - PortAllocationFailed | SocketServer.SocketServerError -> { - return Effect.suspend(() => { - const requestedPort = candidatePort(); - return requestedPort !== EPHEMERAL_PORT && - CLAIMED_OPENCLAW_PORTS.has(requestedPort) - ? claimOpenClawPort(candidatePort) - : bindOpenClawPortCandidate(requestedPort); - }).pipe( - Effect.flatMap((candidate) => - Effect.sync(() => { - if (CLAIMED_OPENCLAW_PORTS.has(candidate.port)) { - return false; - } - CLAIMED_OPENCLAW_PORTS.add(candidate.port); - return true; - }).pipe( - Effect.flatMap((claimed) => - claimed - ? Effect.succeed(makeOpenClawPortClaim(candidate)) - : Effect.suspend(() => claimOpenClawPort(candidatePort)), - ), - ), - ), - ); -} - -function bindOpenClawPortCandidate( - requestedPort: number, -): Effect.Effect< - BoundOpenClawPort, - PortAllocationFailed | SocketServer.SocketServerError -> { - return Effect.scoped( - Effect.gen(function* () { - const server = yield* acquireOpenClawPortProbe(requestedPort); - if (server.address._tag !== "TcpAddress") { - return yield* new PortAllocationFailed({ - message: "TCP port allocation returned a non-TCP address", - cause: server.address, - }); - } - return { port: server.address.port }; - }), - ); -} - -function acquireOpenClawPortProbe(requestedPort: number) { - // The socket constructor races listener startup against error observation. - // Its interruptible child can cancel that internal race, while the joined - // parent keeps external cancellation from splitting listen from teardown. - return Effect.uninterruptible( - NodeSocketServer.make({ - host: "127.0.0.1", - port: requestedPort, - }).pipe(Effect.interruptible, Effect.fork, Effect.flatMap(Fiber.join)), - ); -} - -function makeOpenClawPortClaim( - candidate: BoundOpenClawPort, -): LeasedOpenClawPortClaim { - const state: OpenClawPortClaimState = { - transferred: false, - released: false, - }; - const releaseLogicalClaim = Effect.sync(() => { - if (state.released) { - return; - } - state.released = true; - CLAIMED_OPENCLAW_PORTS.delete(candidate.port); - }); - return { - port: candidate.port, - transfer: () => - Effect.sync(() => { - state.transferred = true; - }), - release: () => releaseLogicalClaim, - closeStartupLease: () => - Effect.suspend(() => - state.transferred ? Effect.void : releaseLogicalClaim, - ), - }; -} - -// --- Config and plugin install (module-private) --- - -function writeOpenClawConfig(opts: { - stateDir: string; - agentName: AgentName; - agentId: OpenClawProcessInput["agentId"]; - apiKey: OpenClawProcessInput["apiKey"]; - modelId?: string; - installMode: InstallMode; - mcpServers?: readonly McpServerMount[]; - tools?: OpenClawToolsConfig; - sandbox?: OpenClawSandboxConfig; - gatewayToken: Redacted.Redacted; -}): Effect.Effect { - return Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const workspaceDir = openClawWorkspaceDir(opts.stateDir); - const config = buildOpenClawConfig(opts, workspaceDir); - - yield* Effect.all([ - fileSystem.makeDirectory(workspaceDir, { - recursive: true, - }), - fileSystem.makeDirectory(path.join(opts.stateDir, "logs"), { - recursive: true, - }), - fileSystem.writeFileString( - path.join(opts.stateDir, "openclaw.json"), - JSON.stringify(config, null, JSON_INDENT_SPACES), - ), - writeMoltZapProfileConfig(path.join(opts.stateDir, ".moltzap"), opts), - ]); - }); -} - -/** - * Render the optional MCP server mounts into OpenClaw configuration. - * - * @param mcpServers Value supplied to the operation. - * @internal - * @returns The mcp config section result. - */ -function mcpConfigSection( - mcpServers?: readonly McpServerMount[], -): Pick { - if (mcpServers === undefined || mcpServers.length === 0) { - return {}; - } - return { - mcp: { - servers: Object.fromEntries( - mcpServers.map((server) => [ - server.name, - { - transport: "stdio" as const, - command: server.command, - args: [...server.args], - env: { ...server.env }, - }, - ]), - ), - }, - }; -} - -/** - * Builds the simulator-owned native OpenClaw configuration. - * @param opts Runtime and channel configuration. - * @param opts.agentName Stable roster identity presented to OpenClaw. - * @param opts.modelId Optional native model override. - * @param opts.installMode Package source selected for the channel plugin. - * @param opts.mcpServers Optional native MCP server definitions. - * @param opts.tools Optional native tool policy. - * @param opts.sandbox Optional native sandbox policy. - * @param opts.gatewayToken Secret used by the owner-local gateway. - * @param workspaceDir Isolated workspace for the OpenClaw agent. - * @internal - * @returns The complete native OpenClaw configuration. - */ -export function buildOpenClawConfig( - opts: { - readonly agentName: AgentName; - readonly modelId?: string; - readonly installMode: InstallMode; - readonly mcpServers?: readonly McpServerMount[]; - readonly tools?: OpenClawToolsConfig; - readonly sandbox?: OpenClawSandboxConfig; - readonly gatewayToken: Redacted.Redacted; - }, - workspaceDir: string, -): OpenClawConfig { - const pluginTrust = - opts.installMode === "workspace" - ? { - // Workspace copies have no npm install provenance, so their - // extension trust is pinned explicitly. - plugins: { allow: [OPENCLAW_EXTENSION_NAME] }, - } - : {}; - return { - ...mcpConfigSection(opts.mcpServers), - agents: { - defaults: { - model: { primary: opts.modelId ?? DEFAULT_OPENCLAW_MODEL_ID }, - workspace: workspaceDir, - compaction: { mode: "safeguard" }, - ...(opts.sandbox === undefined ? {} : { sandbox: opts.sandbox }), - // Left unset, openclaw seeds BOOTSTRAP.md into the empty per-agent - // workspace and runs its first-run onboarding ritual, whose scripted - // opening line the agent sends in place of answering the step. - skipBootstrap: true, - }, - list: [{ id: opts.agentName, default: true }], - }, - ...(opts.tools === undefined ? {} : { tools: opts.tools }), - commands: { native: "auto", nativeSkills: "auto", restart: true }, - ...pluginTrust, - messages: { - // openclaw's own default and the closest heir to the removed passive - // "queue" mode: mid-turn messages steer the active turn instead of - // buffering (matching the nanoclaw runtime's push behavior). - queue: { mode: "steer", debounceMs: 0, cap: 100, drop: "new" }, - }, - // Fleet agents use direct MoltZap channel addressing, so LAN discovery - // only creates contention between colocated gateways. - discovery: { mdns: { mode: "off" } }, - channels: { - [OPENCLAW_CHANNEL_ID]: { - accounts: [ - { - id: SIMULATOR_PROFILE_NAME, - agentName: opts.agentName, - }, - ], - }, - }, - ...openClawGatewayConfig(opts.gatewayToken), - }; -} - -function openClawGatewayConfig( - gatewayToken: Redacted.Redacted, -): Pick { - return { - gateway: { - mode: "local", - auth: { - mode: "token", - token: Redacted.value(gatewayToken), - }, - }, - }; -} - -/* eslint-enable jsdoc/text-escaping -- Restore strict defaults after the scoped file-level exception. */ diff --git a/packages/simulator/src/runtime/openclaw/runtime.test.ts b/packages/simulator/src/runtime/openclaw/runtime.test.ts deleted file mode 100644 index eef01e95c..000000000 --- a/packages/simulator/src/runtime/openclaw/runtime.test.ts +++ /dev/null @@ -1,582 +0,0 @@ -import { assert, it as effectIt } from "@effect/vitest"; -import { - ExitCode as processExitCode, - type ExitCode, -} from "@effect/platform/CommandExecutor"; -import { type AgentConnection, makeAgentHandle } from "../../network.js"; -import { RuntimeExited, RuntimeFailed } from "../runtime.js"; -import { serverBaseUrl } from "@moltzap/protocol/network"; -import { - agentId, - agentName, - redactedAgentKey, -} from "@moltzap/protocol/testing"; -import { - Cause, - Deferred, - Duration, - Effect, - Exit, - Fiber, - Ref, - Schema, - Scope, -} from "effect"; -import { describe } from "vitest"; -import { RuntimeAcquisitionFailed } from "../process.js"; -import { expireStartupDeadline } from "../process.test-utils.js"; -import type { - OpenClawProcessInput, - OpenClawProcessOptions, -} from "./process.js"; -import { OpenClawGatewaySucceeded, type OpenClawGateway } from "./gateway.js"; -import { - makeOpenClawRuntimeWith, - type OpenClawRuntimeDriver, - type OpenClawRuntimeOptions, - type OpenClawSandboxConfig, - type OpenClawToolsConfig, -} from "./runtime.js"; - -const test = effectIt.effect; -const ROSTER_KEY = "alice"; -const AGENT_NAME = agentName(ROSTER_KEY); -const AGENT_KEY_TEXT = - "moltzap_agent_0000000000000000_000000000000000000000000000000000000000000000000"; -const AGENT_KEY_REDACTION_MARKER = "[REDACTED:agent-key]"; -const AGENT_ID = agentId("00000000-0000-4000-8000-000000000001"); -const AGENT_KEY = redactedAgentKey(AGENT_KEY_TEXT); -const READY_OUTPUT = "MoltZap: connected as alice (agent-1)"; -const ROUTER_URL = serverBaseUrl("http://127.0.0.1:43123"); -const PROCESS_EXIT_CODE = 23; -// `awaitProcessReady` polls readiness on a fixed interval, and expiring this -// budget on the test clock costs one round of real timers per poll it covers. -// A small multiple of that interval still exercises repeated polling. -const STARTUP_TIMEOUT = Duration.millis(500); -const MODEL_ID = "test/model"; -const OPENCLAW_BIN = "/opt/openclaw/bin/openclaw"; -const CHANNEL_DIST_DIR = "/opt/moltzap/openclaw-channel/dist"; -const PROCESS_WAIT_FAILURE = "process wait failed"; -type ProcessWaitFailure = typeof PROCESS_WAIT_FAILURE; -const RUNTIME_TOOLS = { - deny: ["*"], - elevated: { enabled: false }, - exec: { mode: "deny" }, -} satisfies OpenClawToolsConfig; -const RUNTIME_SANDBOX = { - mode: "all", - backend: "docker", - scope: "session", - workspaceAccess: "none", - docker: { network: "none" }, -} satisfies OpenClawSandboxConfig; - -interface FakeSession { - readonly exitCode: Deferred.Deferred; - readonly output: string; -} - -interface AcquiredOpenClaw { - readonly input: OpenClawProcessInput; - readonly options: OpenClawProcessOptions; -} - -interface Fixture { - readonly runtime: ReturnType< - typeof makeOpenClawRuntimeWith - >; - readonly acquired: Deferred.Deferred; - readonly session: FakeSession; - readonly teardownCount: Ref.Ref; - readonly gatewayEntered: Deferred.Deferred; - readonly gatewayTeardownCount: Ref.Ref; -} - -interface FakeDriverState { - readonly acquired: Deferred.Deferred; - readonly session: FakeSession; - readonly teardownCount: Ref.Ref; - readonly gatewayEntered: Deferred.Deferred; - readonly gatewayTeardownCount: Ref.Ref; -} - -const PRINCIPAL_GATEWAY: OpenClawGateway = Object.freeze({ - agent: () => - Effect.succeed( - OpenClawGatewaySucceeded.make({ - runId: "unused", - status: "ok", - summary: "completed", - result: {}, - }), - ), -}); - -const connection: AgentConnection<"alice"> = { - agent: makeAgentHandle(ROSTER_KEY, AGENT_ID), - key: AGENT_KEY, - routerUrl: ROUTER_URL, -}; - -function fakeProcessOptions( - input: Parameters< - OpenClawRuntimeDriver["resolveProcessOptions"] - >[0], -): OpenClawProcessOptions { - return { - openclawBin: input.openclawBin ?? OPENCLAW_BIN, - channelDistDir: input.channelDistDir ?? CHANNEL_DIST_DIR, - installMode: input.installMode, - ...(input.mcpServers === undefined ? {} : { mcpServers: input.mcpServers }), - }; -} - -function fakeDriver( - state: FakeDriverState, -): OpenClawRuntimeDriver { - return { - resolveInstallMode: (requested) => Effect.succeed(requested ?? "workspace"), - resolveProcessOptions: (input) => Effect.succeed(fakeProcessOptions(input)), - acquire: (processOptions, processInput) => - Effect.acquireRelease( - Deferred.succeed(state.acquired, { - input: processInput, - options: processOptions, - }).pipe(Effect.as(state.session)), - (running) => - Ref.update(state.teardownCount, (count) => count + 1).pipe( - Effect.zipRight( - Deferred.succeed(running.exitCode, processExitCode(0)), - ), - Effect.asVoid, - ), - ), - acquireGateway: () => - Effect.acquireRelease( - Deferred.succeed(state.gatewayEntered, undefined).pipe( - Effect.as(PRINCIPAL_GATEWAY), - ), - () => Ref.update(state.gatewayTeardownCount, (count) => count + 1), - ), - exitCode: (running) => Deferred.await(running.exitCode), - output: (running) => running.output, - readyWhen: (output) => output.includes("connected as"), - }; -} - -function makeFixture( - options: OpenClawRuntimeOptions, - output = READY_OUTPUT, -): Effect.Effect { - return Effect.gen(function* () { - const acquired = yield* Deferred.make(); - const session: FakeSession = { - exitCode: yield* Deferred.make(), - output, - }; - const teardownCount = yield* Ref.make(0); - const gatewayEntered = yield* Deferred.make(); - const gatewayTeardownCount = yield* Ref.make(0); - const driver = fakeDriver({ - acquired, - session, - teardownCount, - gatewayEntered, - gatewayTeardownCount, - }); - return { - runtime: makeOpenClawRuntimeWith(options, driver), - acquired, - session, - teardownCount, - gatewayEntered, - gatewayTeardownCount, - }; - }); -} - -function fullRuntimeOptions(): OpenClawRuntimeOptions { - return { - startupTimeout: STARTUP_TIMEOUT, - installMode: "workspace", - openclawBin: OPENCLAW_BIN, - channelDistDir: CHANNEL_DIST_DIR, - modelId: MODEL_ID, - workspaceFiles: [{ relativePath: "IDENTITY.md", content: "Alice" }], - mcpServers: [ - { - name: "memory", - command: "memory-server", - args: ["--stdio"], - env: { MEMORY_SCOPE: "alice" }, - }, - ], - tools: RUNTIME_TOOLS, - sandbox: RUNTIME_SANDBOX, - }; -} - -function assertProcessAcquisition(acquired: AcquiredOpenClaw): void { - assert.strictEqual(acquired.input.agentName, AGENT_NAME); - assert.strictEqual(acquired.input.agentId, AGENT_ID); - assert.strictEqual(acquired.input.apiKey, AGENT_KEY); - assert.strictEqual(acquired.input.serverUrl, ROUTER_URL); - assert.strictEqual(acquired.input.modelId, MODEL_ID); - assert.deepStrictEqual(acquired.input.workspaceFiles, [ - { relativePath: "IDENTITY.md", content: "Alice" }, - ]); - assert.deepStrictEqual(acquired.input.tools, RUNTIME_TOOLS); - assert.deepStrictEqual(acquired.input.sandbox, RUNTIME_SANDBOX); - assert.deepStrictEqual( - Object.keys(acquired.input).sort((left, right) => - left.localeCompare(right), - ), - [ - "agentId", - "agentName", - "apiKey", - "modelId", - "sandbox", - "serverUrl", - "tools", - "workspaceFiles", - ], - ); - assert.strictEqual(acquired.options.openclawBin, OPENCLAW_BIN); - assert.strictEqual(acquired.options.channelDistDir, CHANNEL_DIST_DIR); - assert.deepStrictEqual(acquired.options.mcpServers, [ - { - name: "memory", - command: "memory-server", - args: ["--stdio"], - env: { MEMORY_SCOPE: "alice" }, - }, - ]); -} - -function returnsAfterReadinessTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture(fullRuntimeOptions()); - yield* Effect.scoped( - Effect.gen(function* () { - const running = yield* fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }); - assertProcessAcquisition(yield* Deferred.await(fixture.acquired)); - assert.strictEqual(running.gateway, PRINCIPAL_GATEWAY); - }), - ); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - assert.strictEqual(yield* Ref.get(fixture.gatewayTeardownCount), 1); - }); -} - -function interruptedAcquisitionTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture({}, "still booting"); - const acquired = yield* Effect.scoped( - fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }), - ).pipe(Effect.fork); - yield* Deferred.await(fixture.acquired); - yield* Deferred.await(fixture.gatewayEntered); - - const interrupted = yield* Fiber.interrupt(acquired); - assert.isTrue(Exit.isFailure(interrupted)); - if (Exit.isFailure(interrupted)) { - assert.isTrue(Cause.isInterruptedOnly(interrupted.cause)); - } - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - assert.strictEqual(yield* Ref.get(fixture.gatewayTeardownCount), 1); - }); -} - -function exitsBeforeReadinessTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture( - {}, - `startup failed apiKey=${AGENT_KEY_TEXT}`, - ); - const acquiring = yield* Effect.scoped( - fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }), - ).pipe(Effect.flip, Effect.fork); - yield* Deferred.await(fixture.acquired); - yield* Deferred.succeed( - fixture.session.exitCode, - processExitCode(PROCESS_EXIT_CODE), - ); - const failure = yield* Fiber.join(acquiring); - - assert.instanceOf(failure, RuntimeAcquisitionFailed); - assert.include(failure.detail, `exitCode=${String(PROCESS_EXIT_CODE)}`); - assert.include(failure.detail, AGENT_KEY_REDACTION_MARKER); - assert.notInclude(failure.detail, AGENT_KEY_TEXT); - assert.include(failure.detail, "startup failed"); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - assert.strictEqual(yield* Ref.get(fixture.gatewayTeardownCount), 1); - }); -} - -function waitFailsBeforeReadinessTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture( - {}, - `startup failed apiKey=${AGENT_KEY_TEXT}`, - ); - const acquiring = yield* Effect.scoped( - fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }), - ).pipe(Effect.flip, Effect.fork); - yield* Deferred.await(fixture.acquired); - yield* Deferred.fail(fixture.session.exitCode, PROCESS_WAIT_FAILURE); - const failure = yield* Fiber.join(acquiring); - - assert.instanceOf(failure, RuntimeAcquisitionFailed); - assert.include(failure.detail, "without an observable exit code"); - assert.include(failure.detail, AGENT_KEY_REDACTION_MARKER); - assert.notInclude(failure.detail, AGENT_KEY_TEXT); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - assert.strictEqual(yield* Ref.get(fixture.gatewayTeardownCount), 1); - }); -} - -function readinessFailureTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture(fullRuntimeOptions(), "still booting"); - const acquiring = yield* Effect.scoped( - fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }), - ).pipe(Effect.flip, Effect.fork); - yield* expireStartupDeadline(STARTUP_TIMEOUT); - const observed = yield* Fiber.join(acquiring); - - assert.instanceOf(observed, RuntimeAcquisitionFailed); - assert.include(observed.detail, "did not announce readiness"); - assert.include(observed.detail, "still booting"); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - assert.strictEqual(yield* Ref.get(fixture.gatewayTeardownCount), 1); - }); -} - -function teardownIsNotTerminationTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture({}); - const scope = yield* Scope.make(); - const running = yield* fixture.runtime - .acquire({ - agentName: AGENT_NAME, - connection, - }) - .pipe(Scope.extend(scope)); - const observing = yield* running.termination.pipe(Effect.forkIn(scope)); - yield* Scope.close(scope, Exit.void); - - const observed = yield* Fiber.await(observing); - assert.isTrue(Exit.isFailure(observed)); - if (Exit.isFailure(observed)) { - assert.isTrue(Cause.isInterruptedOnly(observed.cause)); - } - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - assert.strictEqual(yield* Ref.get(fixture.gatewayTeardownCount), 1); - }); -} - -function observeTermination(exitCode: ExitCode) { - return Effect.gen(function* () { - const fixture = yield* makeFixture({}); - const observation = yield* Effect.scoped( - Effect.gen(function* () { - const acquiring = yield* fixture.runtime - .acquire({ - agentName: AGENT_NAME, - connection, - }) - .pipe(Effect.fork); - yield* Deferred.await(fixture.acquired); - const running = yield* Fiber.join(acquiring); - yield* Deferred.succeed(fixture.session.exitCode, exitCode); - return yield* running.termination; - }), - ); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - assert.strictEqual(yield* Ref.get(fixture.gatewayTeardownCount), 1); - return observation; - }); -} - -function observeWaitFailure() { - return Effect.gen(function* () { - const fixture = yield* makeFixture({}); - const observation = yield* Effect.scoped( - Effect.gen(function* () { - const acquiring = yield* fixture.runtime - .acquire({ - agentName: AGENT_NAME, - connection, - }) - .pipe(Effect.fork); - yield* Deferred.await(fixture.acquired); - const running = yield* Fiber.join(acquiring); - yield* Deferred.fail(fixture.session.exitCode, PROCESS_WAIT_FAILURE); - return yield* running.termination; - }), - ); - assert.strictEqual(yield* Ref.get(fixture.teardownCount), 1); - assert.strictEqual(yield* Ref.get(fixture.gatewayTeardownCount), 1); - return observation; - }); -} - -function exactTerminationTest() { - return Effect.gen(function* () { - const exited = yield* observeTermination( - processExitCode(PROCESS_EXIT_CODE), - ); - const unavailable = yield* observeWaitFailure(); - - assert.instanceOf(exited, RuntimeExited); - assert.strictEqual(exited.code, PROCESS_EXIT_CODE); - assert.instanceOf(unavailable, RuntimeFailed); - }); -} - -function sanitizedConfigurationTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture(fullRuntimeOptions()); - const encoded = yield* Schema.encode(fixture.runtime.configuration.schema)( - fixture.runtime.configuration.value, - ); - const serialized = JSON.stringify(encoded); - - assert.include(serialized, "contentDigest"); - assert.include(serialized, "definitionDigest"); - assert.include(serialized, "environmentValues"); - assert.include(serialized, '"installPolicy":"workspace"'); - assert.include(serialized, `"modelOverride":"${MODEL_ID}"`); - assert.include(serialized, "openclawBinOverride"); - assert.include(serialized, "channelDistDirOverride"); - assert.include(serialized, '"tools":{"definitionDigest"'); - assert.include(serialized, '"sandbox":{"definitionDigest"'); - assert.include(serialized, '"redacted":["configuration"]'); - assert.notInclude(serialized, "Alice"); - assert.notInclude(serialized, "MEMORY_SCOPE"); - assert.notInclude(serialized, OPENCLAW_BIN); - assert.notInclude(serialized, CHANNEL_DIST_DIR); - assert.notInclude(serialized, AGENT_KEY_TEXT); - assert.notInclude(serialized, '"deny"'); - assert.notInclude(serialized, '"network"'); - }); -} - -function omittedPolicyConfigurationTest() { - return Effect.gen(function* () { - const fixture = yield* makeFixture({}); - const encoded = yield* Schema.encode(fixture.runtime.configuration.schema)( - fixture.runtime.configuration.value, - ); - - assert.notProperty(encoded, "tools"); - assert.notProperty(encoded, "sandbox"); - }); -} - -function snapshotsNativePolicyTest() { - return Effect.gen(function* () { - const tools: OpenClawToolsConfig = { - deny: ["read"], - elevated: { enabled: false }, - exec: { mode: "deny" }, - }; - const sandbox: OpenClawSandboxConfig = { - mode: "all", - backend: "docker", - scope: "session", - workspaceAccess: "none", - docker: { network: "none" }, - }; - const fixture = yield* makeFixture({ tools, sandbox }); - - tools.deny?.push("exec"); - sandbox.mode = "off"; - if (sandbox.docker !== undefined) { - sandbox.docker.network = "host"; - } - - const acquiring = yield* Effect.scoped( - fixture.runtime.acquire({ - agentName: AGENT_NAME, - connection, - }), - ).pipe(Effect.fork); - const acquired = yield* Deferred.await(fixture.acquired); - - assert.deepStrictEqual(acquired.input.tools, { - deny: ["read"], - elevated: { enabled: false }, - exec: { mode: "deny" }, - }); - assert.deepStrictEqual(acquired.input.sandbox, { - mode: "all", - backend: "docker", - scope: "session", - workspaceAccess: "none", - docker: { network: "none" }, - }); - assert.isTrue(Object.isFrozen(acquired.input.tools)); - assert.isTrue(Object.isFrozen(acquired.input.tools?.deny)); - assert.isTrue(Object.isFrozen(acquired.input.sandbox)); - assert.isTrue(Object.isFrozen(acquired.input.sandbox?.docker)); - yield* Fiber.interrupt(acquiring); - }); -} - -// @agent-code-guard/regression-only: controlled sessions pin readiness, cancellation, scoped teardown, private host configuration, and exact process evidence -describe("native OpenClaw runtime", () => { - test( - "requires process readiness and exposes the scoped principal gateway", - returnsAfterReadinessTest, - ); - test( - "releases an interrupted process acquisition through its Scope", - interruptedAcquisitionTest, - ); - test( - "fails and releases when the process exits before readiness", - exitsBeforeReadinessTest, - ); - test( - "reports an unavailable exit code when the process wait fails before readiness", - waitFailsBeforeReadinessTest, - ); - test( - "fails when no readiness line arrives within the startup timeout", - readinessFailureTest, - ); - test( - "does not report scoped teardown as autonomous termination", - teardownIsNotTerminationTest, - ); - test("reports the exact observed process exit status", exactTerminationTest); - test( - "publishes definition-time policy with digested workspace, MCP, and host paths", - sanitizedConfigurationTest, - ); - test( - "preserves omitted native policy for customer-owned runtimes", - omittedPolicyConfigurationTest, - ); - test( - "snapshots native policy before caller mutation", - snapshotsNativePolicyTest, - ); -}); diff --git a/packages/simulator/src/runtime/openclaw/runtime.ts b/packages/simulator/src/runtime/openclaw/runtime.ts deleted file mode 100644 index 93e91d8c3..000000000 --- a/packages/simulator/src/runtime/openclaw/runtime.ts +++ /dev/null @@ -1,560 +0,0 @@ -/** @file Scoped OpenClaw runtime. */ - -import type { FileSystem, Path } from "@effect/platform"; -import { createHash } from "node:crypto"; -import type { - CommandExecutor, - ExitCode, -} from "@effect/platform/CommandExecutor"; -import type { PlatformError } from "@effect/platform/Error"; -import { - defineRuntime, - type AgentRuntime, - type AgentRuntimeInput, - type RunningAgent, -} from "../runtime.js"; -import { - Cause, - Duration, - Effect, - Inspectable, - Schema, - type Scope, -} from "effect"; -import { resolveInstallMode, type InstallMode } from "../packages.js"; -import { - acquireOpenClawProcess, - resolveOpenClawProcessOptions, - type OpenClawProcessInput, - type OpenClawProcessOptionOverrides, - type OpenClawProcessOptions, - type OpenClawProcessSession, - type OpenClawSandboxConfig, - type OpenClawToolsConfig, -} from "./process.js"; -import { acquireOpenClawGateway, type OpenClawGateway } from "./gateway.js"; -import { - awaitProcessReady, - processTermination, - type ProcessObservation, - RuntimeAcquisitionFailed, -} from "../process.js"; - -/** Native OpenClaw policy types accepted by the shipped runtime. */ -export type { OpenClawSandboxConfig, OpenClawToolsConfig } from "./process.js"; - -const OPENCLAW_RUNTIME_NAME = "openclaw"; -// The MoltZap channel emits this after its server session is live. -const OPENCLAW_READY_MARKER = "connected as"; -const DEFAULT_OPENCLAW_STARTUP_TIMEOUT = Duration.minutes(2); - -interface OpenClawWorkspaceFile { - readonly relativePath: string; - readonly content: string; -} - -interface OpenClawMcpServer { - readonly name: string; - readonly command: string; - readonly args: readonly string[]; - readonly env: Readonly>; -} - -const configurationDigest = Schema.String.pipe( - Schema.pattern(/^[\da-f]{64}$/u), - Schema.brand("OpenClawConfigurationDigest"), -); - -class OpenClawWorkspaceFileConfiguration extends Schema.Class( - "OpenClawWorkspaceFileConfiguration", -)({ - relativePath: Schema.String, - contentDigest: configurationDigest, - redacted: Schema.Tuple(Schema.Literal("content")), -}) {} - -class OpenClawMcpServerConfiguration extends Schema.Class( - "OpenClawMcpServerConfiguration", -)({ - name: Schema.String, - definitionDigest: configurationDigest, - redacted: Schema.Tuple( - Schema.Literal("command"), - Schema.Literal("args"), - Schema.Literal("environmentValues"), - ), -}) {} - -class OpenClawHostPathConfiguration extends Schema.Class( - "OpenClawHostPathConfiguration", -)({ - digest: configurationDigest, - redacted: Schema.Tuple(Schema.Literal("path")), -}) {} - -class OpenClawNativePolicyConfiguration extends Schema.Class( - "OpenClawNativePolicyConfiguration", -)({ - definitionDigest: configurationDigest, - redacted: Schema.Tuple(Schema.Literal("configuration")), -}) {} - -/** - * Sanitized definition-time policy and overrides for an OpenClaw runtime. - * Acquisition may resolve different host facts from automatic policy. - */ -export class OpenClawRuntimeConfiguration extends Schema.Class( - "OpenClawRuntimeConfiguration", -)({ - startupTimeout: Schema.DurationFromMillis, - workspaceFiles: Schema.Array(OpenClawWorkspaceFileConfiguration), - modelOverride: Schema.optional(Schema.String), - installPolicy: Schema.Literal("automatic", "published", "workspace"), - openclawBinOverride: Schema.optional(OpenClawHostPathConfiguration), - channelDistDirOverride: Schema.optional(OpenClawHostPathConfiguration), - mcpServers: Schema.Array(OpenClawMcpServerConfiguration), - tools: Schema.optional(OpenClawNativePolicyConfiguration), - sandbox: Schema.optional(OpenClawNativePolicyConfiguration), -}) {} - -/** Configuration captured by one reusable OpenClaw runtime value. */ -export interface OpenClawRuntimeOptions { - readonly startupTimeout?: Duration.Duration; - readonly workspaceFiles?: readonly OpenClawWorkspaceFile[]; - readonly modelId?: string; - readonly installMode?: InstallMode; - readonly openclawBin?: string; - readonly channelDistDir?: string; - readonly mcpServers?: readonly OpenClawMcpServer[]; - readonly tools?: OpenClawToolsConfig; - readonly sandbox?: OpenClawSandboxConfig; -} - -interface OpenClawRuntimeSettings { - readonly startupTimeout: Duration.Duration; - readonly workspaceFiles: readonly OpenClawWorkspaceFile[]; - readonly modelId?: string; - readonly installMode?: InstallMode; - readonly openclawBin?: string; - readonly channelDistDir?: string; - readonly mcpServers?: readonly OpenClawMcpServer[]; - readonly tools?: OpenClawToolsConfig; - readonly sandbox?: OpenClawSandboxConfig; -} - -/** - * OpenClaw-specific host seam. Production binds it to the scoped process - * primitive; lifecycle tests bind controlled sessions without launching a - * gateway. - * @internal - */ -export interface OpenClawRuntimeDriver< - Session, - WaitFailure = unknown, - Requirements = never, -> { - readonly resolveInstallMode: ( - requested?: InstallMode, - ) => Effect.Effect; - readonly resolveProcessOptions: ( - input: OpenClawProcessOptionOverrides, - ) => Effect.Effect; - readonly acquire: ( - options: OpenClawProcessOptions, - input: OpenClawProcessInput, - ) => Effect.Effect; - readonly acquireGateway: ( - session: Session, - within: Duration.Duration, - ) => Effect.Effect; - readonly exitCode: (session: Session) => Effect.Effect; - readonly output: (session: Session) => string; - readonly readyWhen: (output: string) => boolean; -} - -/** Failure returned when OpenClaw cannot become router-visible. */ -export type OpenClawRuntimeAcquisitionError = RuntimeAcquisitionFailed; - -type OpenClawHostServices = CommandExecutor | FileSystem.FileSystem | Path.Path; - -const nativeOpenClawDriver: OpenClawRuntimeDriver< - OpenClawProcessSession, - PlatformError, - OpenClawHostServices -> = { - resolveInstallMode, - resolveProcessOptions: (input) => - Effect.try({ - try: () => resolveOpenClawProcessOptions(input), - catch: (cause) => new Cause.UnknownException(cause), - }), - acquire: acquireOpenClawProcess, - acquireGateway: acquireOpenClawGateway, - exitCode: (session) => session.exitCode, - output: (session) => session.output(), - readyWhen: (output) => output.includes(OPENCLAW_READY_MARKER), -}; - -function snapshotWorkspaceFiles( - files?: readonly OpenClawWorkspaceFile[], -): readonly OpenClawWorkspaceFile[] { - return Object.freeze((files ?? []).map((file) => Object.freeze({ ...file }))); -} - -function snapshotMcpServers( - servers?: readonly OpenClawMcpServer[], -): readonly OpenClawMcpServer[] | undefined { - return servers === undefined - ? undefined - : Object.freeze( - servers.map((server) => - Object.freeze({ - name: server.name, - command: server.command, - args: Object.freeze([...server.args]), - env: Object.freeze({ ...server.env }), - }), - ), - ); -} - -function freezeNativeConfiguration(value: unknown): void { - if (typeof value !== "object" || value === null || Object.isFrozen(value)) { - return; - } - for (const nested of Object.values(value)) { - freezeNativeConfiguration(nested); - } - Object.freeze(value); -} - -function snapshotNativeConfiguration( - value?: Value, -): Value | undefined { - if (value === undefined) { - return undefined; - } - const snapshot = structuredClone(value); - freezeNativeConfiguration(snapshot); - return snapshot; -} - -function snapshotOptions( - options: OpenClawRuntimeOptions, -): OpenClawRuntimeSettings { - return Object.freeze({ - startupTimeout: options.startupTimeout ?? DEFAULT_OPENCLAW_STARTUP_TIMEOUT, - workspaceFiles: snapshotWorkspaceFiles(options.workspaceFiles), - modelId: options.modelId, - installMode: options.installMode, - openclawBin: options.openclawBin, - channelDistDir: options.channelDistDir, - mcpServers: snapshotMcpServers(options.mcpServers), - tools: snapshotNativeConfiguration(options.tools), - sandbox: snapshotNativeConfiguration(options.sandbox), - }); -} - -function digestText(value: string): typeof configurationDigest.Type { - return Schema.decodeUnknownSync(configurationDigest)( - createHash("sha256").update(value, "utf8").digest("hex"), - ); -} - -function workspaceConfiguration( - files: readonly OpenClawWorkspaceFile[], -): readonly OpenClawWorkspaceFileConfiguration[] { - return files.map((file) => - OpenClawWorkspaceFileConfiguration.make({ - relativePath: file.relativePath, - contentDigest: digestText(file.content), - redacted: ["content"], - }), - ); -} - -function mcpServerDefinition(server: OpenClawMcpServer): string { - return JSON.stringify({ - name: server.name, - command: server.command, - args: server.args, - environmentKeys: Object.keys(server.env).sort((left, right) => - left.localeCompare(right), - ), - }); -} - -function mcpConfiguration( - servers?: readonly OpenClawMcpServer[], -): readonly OpenClawMcpServerConfiguration[] { - return (servers ?? []).map((server) => - OpenClawMcpServerConfiguration.make({ - name: server.name, - definitionDigest: digestText(mcpServerDefinition(server)), - redacted: ["command", "args", "environmentValues"], - }), - ); -} - -function nativePolicyConfiguration( - policy?: object, -): OpenClawNativePolicyConfiguration | undefined { - if (policy === undefined) { - return undefined; - } - return OpenClawNativePolicyConfiguration.make({ - definitionDigest: digestText(Inspectable.stringifyCircular(policy)), - redacted: ["configuration"], - }); -} - -function runtimeConfiguration( - settings: OpenClawRuntimeSettings, -): OpenClawRuntimeConfiguration { - const tools = nativePolicyConfiguration(settings.tools); - const sandbox = nativePolicyConfiguration(settings.sandbox); - return OpenClawRuntimeConfiguration.make({ - startupTimeout: settings.startupTimeout, - workspaceFiles: workspaceConfiguration(settings.workspaceFiles), - installPolicy: settings.installMode ?? "automatic", - mcpServers: mcpConfiguration(settings.mcpServers), - ...(tools === undefined ? {} : { tools }), - ...(sandbox === undefined ? {} : { sandbox }), - ...(settings.modelId === undefined - ? {} - : { modelOverride: settings.modelId }), - ...(settings.openclawBin === undefined - ? {} - : { - openclawBinOverride: OpenClawHostPathConfiguration.make({ - digest: digestText(settings.openclawBin), - redacted: ["path"], - }), - }), - ...(settings.channelDistDir === undefined - ? {} - : { - channelDistDirOverride: OpenClawHostPathConfiguration.make({ - digest: digestText(settings.channelDistDir), - redacted: ["path"], - }), - }), - }); -} - -function processOptions( - settings: OpenClawRuntimeSettings, - installMode: InstallMode, -): OpenClawProcessOptionOverrides { - return { - installMode, - ...(settings.openclawBin === undefined - ? {} - : { openclawBin: settings.openclawBin }), - ...(settings.channelDistDir === undefined - ? {} - : { channelDistDir: settings.channelDistDir }), - ...(settings.mcpServers === undefined - ? {} - : { mcpServers: settings.mcpServers }), - }; -} - -function processInput( - input: AgentRuntimeInput, - settings: OpenClawRuntimeSettings, -): OpenClawProcessInput { - return { - agentName: input.agentName, - agentId: input.connection.agent.id, - apiKey: input.connection.key, - serverUrl: input.connection.routerUrl, - workspaceFiles: settings.workspaceFiles, - ...(settings.modelId === undefined ? {} : { modelId: settings.modelId }), - ...(settings.tools === undefined ? {} : { tools: settings.tools }), - ...(settings.sandbox === undefined ? {} : { sandbox: settings.sandbox }), - }; -} - -function acquisitionFailure( - agentName: string, - operation: string, - cause: unknown, -): RuntimeAcquisitionFailed { - return RuntimeAcquisitionFailed.make({ - runtime: OPENCLAW_RUNTIME_NAME, - agent: agentName, - detail: `${operation}: ${String(cause)}`, - }); -} - -interface AcquiredOpenClawProcess { - readonly input: OpenClawProcessInput; - readonly observation: ProcessObservation; - readonly session: Session; -} - -function acquireOpenClawSession< - Name extends string, - Session, - WaitFailure, - Requirements, ->( - settings: OpenClawRuntimeSettings, - driver: OpenClawRuntimeDriver, - input: AgentRuntimeInput, -): Effect.Effect< - AcquiredOpenClawProcess, - OpenClawRuntimeAcquisitionError, - Scope.Scope | Requirements -> { - return Effect.gen(function* () { - const process = processInput(input, settings); - const installMode = yield* driver - .resolveInstallMode(settings.installMode) - .pipe( - Effect.mapError((cause) => - acquisitionFailure(process.agentName, "select packages", cause), - ), - ); - const host = yield* driver - .resolveProcessOptions(processOptions(settings, installMode)) - .pipe( - Effect.mapError((cause) => - acquisitionFailure(process.agentName, "resolve process", cause), - ), - ); - const session = yield* driver - .acquire(host, process) - .pipe( - Effect.mapError((cause) => - acquisitionFailure(process.agentName, "acquire process", cause), - ), - ); - const observation: ProcessObservation = { - exitCode: driver.exitCode(session), - output: () => driver.output(session), - }; - return { input: process, observation, session }; - }); -} - -function acquireOpenClawRuntime< - Name extends string, - Session, - WaitFailure, - Requirements, ->( - settings: OpenClawRuntimeSettings, - driver: OpenClawRuntimeDriver, - input: AgentRuntimeInput, -): Effect.Effect< - RunningAgent, - OpenClawRuntimeAcquisitionError, - Scope.Scope | Requirements -> { - return Effect.gen(function* () { - const process = yield* acquireOpenClawSession(settings, driver, input); - const gateway = yield* awaitOpenClawRuntimeReady(settings, driver, process); - return { - gateway, - termination: processTermination( - { - agentName: process.input.agentName, - runtimeName: OPENCLAW_RUNTIME_NAME, - }, - process.observation, - ), - }; - }).pipe( - Effect.withSpan("openClawRuntime.acquire", { - attributes: { - "agent.name": input.connection.agent.name, - "runtime.name": OPENCLAW_RUNTIME_NAME, - }, - }), - ); -} - -function awaitOpenClawRuntimeReady( - settings: OpenClawRuntimeSettings, - driver: OpenClawRuntimeDriver, - process: AcquiredOpenClawProcess, -): Effect.Effect< - OpenClawGateway, - OpenClawRuntimeAcquisitionError, - Scope.Scope | Requirements -> { - const gateway = driver - .acquireGateway(process.session, settings.startupTimeout) - .pipe( - Effect.mapError((cause) => - acquisitionFailure( - process.input.agentName, - "connect principal gateway", - cause, - ), - ), - ); - const ready = awaitProcessReady({ - within: settings.startupTimeout, - agentName: process.input.agentName, - agentKey: process.input.apiKey, - runtimeName: OPENCLAW_RUNTIME_NAME, - observation: process.observation, - readyWhen: driver.readyWhen, - }); - return Effect.all([gateway, ready] as const, { - concurrency: 2, - }).pipe(Effect.map(([principalGateway]) => principalGateway)); -} - -/** - * Build OpenClaw's process-backed runtime against an explicit low-level driver. - * Production uses {@link openClawRuntime}; this seam keeps lifecycle tests - * free of gateway processes. - * @param options Options that control the operation. - * @param driver Value supplied to the operation. - * @internal - * @returns The created open claw runtime with. - */ -export function makeOpenClawRuntimeWith< - Session, - WaitFailure = unknown, - Requirements = never, ->( - options: OpenClawRuntimeOptions, - driver: OpenClawRuntimeDriver, -): AgentRuntime< - OpenClawGateway, - OpenClawRuntimeAcquisitionError, - Requirements, - typeof OpenClawRuntimeConfiguration -> { - const settings = snapshotOptions(options); - return defineRuntime({ - name: OPENCLAW_RUNTIME_NAME, - configuration: { - schema: OpenClawRuntimeConfiguration, - value: runtimeConfiguration(settings), - }, - acquire: (input) => acquireOpenClawRuntime(settings, driver, input), - }); -} - -/** - * Construct an OpenClaw runtime that binds each roster identity to one - * scoped gateway process and waits for router-visible readiness. - * @param options Options that control the operation. - * @returns The open claw runtime result. - */ -export function openClawRuntime( - options: OpenClawRuntimeOptions = {}, -): AgentRuntime< - OpenClawGateway, - OpenClawRuntimeAcquisitionError, - OpenClawHostServices, - typeof OpenClawRuntimeConfiguration -> { - return makeOpenClawRuntimeWith(options, nativeOpenClawDriver); -} diff --git a/packages/simulator/src/runtime/packages.test.ts b/packages/simulator/src/runtime/packages.test.ts deleted file mode 100644 index 1806ae7fb..000000000 --- a/packages/simulator/src/runtime/packages.test.ts +++ /dev/null @@ -1,445 +0,0 @@ -/* eslint-disable agent-code-guard/prefer-effect-platform -- Synchronous package fixtures exercise Node's synchronous createRequire resolution boundary. */ - -import { - mkdirSync, - mkdtempSync, - realpathSync, - rmSync, - writeFileSync, -} from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; -import { Cause, Effect, Exit, Option } from "effect"; -import { afterAll, describe, expect, it, vi } from "vitest"; -import { - makeInstallModeResolver, - RuntimePackageError, - resolveInstalledPackageDependency, - resolveInstalledPackageBin, - resolveInstalledPackageRoot, - resolveOwningPackageRoot, - resolvePackageRoot, - type InstallMode, -} from "./packages.js"; - -const SCOPED_PACKAGE_NAME = "@moltzap-test/resolved"; -const OWNER_PACKAGE_NAME = "@moltzap-test/owner"; -const DECOY_MANIFEST_NAME = "some-other-package"; -const MISSING_PACKAGE_NAME = "@moltzap-test/definitely-missing"; -const REAL_PACKAGE_NAME = "effect"; -const MISSING_BIN_NAME = "no-such-bin"; -const DECLARED_DEPENDENCY_SPEC = "^1.2.0"; -const INSTALLED_PACKAGE_VERSION = "1.2.3"; -const NON_EXACT_PACKAGE_VERSION = "^1.2.3"; -const NESTED_PACKAGE_VERSION = "9.9.9"; - -const fixtureRoot = mkdtempSync(join(tmpdir(), "package-resolution-test-")); - -afterAll(() => { - rmSync(fixtureRoot, { recursive: true, force: true }); -}); - -function seedConsumer( - fixtureName: string, - manifest: Record, -): { readonly anchor: string; readonly packageRoot: string } { - const consumerRoot = join(fixtureRoot, fixtureName); - const anchor = join(consumerRoot, "package.json"); - const packageRoot = join(consumerRoot, "node_modules", SCOPED_PACKAGE_NAME); - mkdirSync(packageRoot, { recursive: true }); - writeFileSync( - anchor, - JSON.stringify({ name: `package-resolution-${fixtureName}` }), - ); - writeFileSync(join(packageRoot, "package.json"), JSON.stringify(manifest)); - return { anchor, packageRoot }; -} - -function seedLayeredConsumer( - fixtureName: string, - nearestManifest: string, -): { readonly anchor: string; readonly packageRoot: string } { - const fixtureDir = join(fixtureRoot, fixtureName); - const consumerRoot = join(fixtureDir, "consumer"); - const anchor = join(consumerRoot, "package.json"); - const nearestPackageRoot = join( - consumerRoot, - "node_modules", - SCOPED_PACKAGE_NAME, - ); - const packageRoot = join(fixtureDir, "node_modules", SCOPED_PACKAGE_NAME); - mkdirSync(nearestPackageRoot, { recursive: true }); - mkdirSync(packageRoot, { recursive: true }); - writeFileSync( - anchor, - JSON.stringify({ name: `package-resolution-${fixtureName}` }), - ); - writeFileSync(join(nearestPackageRoot, "package.json"), nearestManifest); - writeFileSync( - join(packageRoot, "package.json"), - JSON.stringify({ name: SCOPED_PACKAGE_NAME }), - ); - return { anchor, packageRoot }; -} - -function seedOwnedDependency( - fixtureName: string, - ownerManifest: Record, - installedManifest: Record, -): { - readonly anchor: string; - readonly ownerPackageRoot: string; - readonly packageRoot: string; -} { - const ownerPackageRoot = join(fixtureRoot, fixtureName); - const anchor = join(ownerPackageRoot, "src", "nested", "anchor.js"); - const packageRoot = join( - ownerPackageRoot, - "node_modules", - SCOPED_PACKAGE_NAME, - ); - mkdirSync(join(ownerPackageRoot, "src", "nested"), { recursive: true }); - mkdirSync(packageRoot, { recursive: true }); - writeFileSync(anchor, "export {};"); - writeFileSync( - join(ownerPackageRoot, "package.json"), - JSON.stringify(ownerManifest), - ); - writeFileSync( - join(packageRoot, "package.json"), - JSON.stringify(installedManifest), - ); - return { anchor, ownerPackageRoot, packageRoot }; -} - -// @agent-code-guard/regression-only: seeded module layouts exercise Node resolution branches whose inputs are filesystem topology rather than generated values -describe("resolvePackageRoot", () => { - it("resolves from the supplied consumer anchor", () => { - const fixture = seedConsumer("anchored", { - name: SCOPED_PACKAGE_NAME, - }); - - const root = resolvePackageRoot(fixture.anchor, SCOPED_PACKAGE_NAME); - - expect(root === null ? null : realpathSync(root)).toBe( - realpathSync(fixture.packageRoot), - ); - }); - - it("resolves package.json when an exports map hides the subpath", () => { - const fixture = seedConsumer("export-restricted", { - name: SCOPED_PACKAGE_NAME, - exports: { ".": "./dist/index.js" }, - }); - const root = resolvePackageRoot(fixture.anchor, SCOPED_PACKAGE_NAME); - - expect(root === null ? null : realpathSync(root)).toBe( - realpathSync(fixture.packageRoot), - ); - }); - - it("skips a nearer package whose manifest name differs", () => { - const fixture = seedLayeredConsumer( - "decoy", - JSON.stringify({ name: DECOY_MANIFEST_NAME }), - ); - - const root = resolvePackageRoot(fixture.anchor, SCOPED_PACKAGE_NAME); - - expect(root === null ? null : realpathSync(root)).toBe( - realpathSync(fixture.packageRoot), - ); - }); - - it("skips a nearer package whose manifest is unparsable", () => { - const fixture = seedLayeredConsumer("broken", "{not json"); - - const root = resolvePackageRoot(fixture.anchor, SCOPED_PACKAGE_NAME); - - expect(root === null ? null : realpathSync(root)).toBe( - realpathSync(fixture.packageRoot), - ); - }); -}); - -describe("resolvePackageRoot public-entry fallback", () => { - it("does not recover a rejected package through its public entry", () => { - const fixture = seedConsumer("only-decoy", { - name: DECOY_MANIFEST_NAME, - main: "index.js", - }); - writeFileSync(join(fixture.packageRoot, "index.js"), "export {};"); - - expect(resolvePackageRoot(fixture.anchor, SCOPED_PACKAGE_NAME)).toBeNull(); - }); - - it("recovers a scoped root from a public entry without a manifest", () => { - const consumerRoot = join(fixtureRoot, "public-entry"); - const anchor = join(consumerRoot, "package.json"); - const packageRoot = join(consumerRoot, "node_modules", SCOPED_PACKAGE_NAME); - mkdirSync(packageRoot, { recursive: true }); - writeFileSync(anchor, JSON.stringify({ name: "public-entry-consumer" })); - writeFileSync(join(packageRoot, "index.js"), "export {};"); - - const root = resolvePackageRoot(anchor, SCOPED_PACKAGE_NAME); - - expect(root === null ? null : realpathSync(root)).toBe( - realpathSync(packageRoot), - ); - }); - - it("returns null when the package resolves nowhere", () => { - const fixture = seedConsumer("missing", { - name: SCOPED_PACKAGE_NAME, - }); - - expect(resolvePackageRoot(fixture.anchor, MISSING_PACKAGE_NAME)).toBeNull(); - }); -}); - -describe("resolveInstalledPackageRoot", () => { - it("throws when the package resolves nowhere", () => { - const fixture = seedConsumer("throwing", { - name: SCOPED_PACKAGE_NAME, - }); - - expect(() => - resolveInstalledPackageRoot(MISSING_PACKAGE_NAME, fixture.anchor), - ).toThrow(); - }); - - it("resolves an installed package from the default anchor", () => { - expect(resolveInstalledPackageRoot(REAL_PACKAGE_NAME)).toContain( - REAL_PACKAGE_NAME, - ); - }); -}); - -describe("resolveOwningPackageRoot", () => { - it("finds the named owner independently of module depth", () => { - const fixture = seedOwnedDependency( - "owning-package", - { - name: OWNER_PACKAGE_NAME, - dependencies: { - [SCOPED_PACKAGE_NAME]: DECLARED_DEPENDENCY_SPEC, - }, - }, - { - name: SCOPED_PACKAGE_NAME, - version: INSTALLED_PACKAGE_VERSION, - }, - ); - - expect(resolveOwningPackageRoot(OWNER_PACKAGE_NAME, fixture.anchor)).toBe( - fixture.ownerPackageRoot, - ); - }); -}); - -describe("resolveInstalledPackageDependency metadata", () => { - it("returns the owner's declared spec and installed exact version", () => { - const fixture = seedOwnedDependency( - "installed-dependency", - { - name: OWNER_PACKAGE_NAME, - dependencies: { - [SCOPED_PACKAGE_NAME]: DECLARED_DEPENDENCY_SPEC, - }, - }, - { - name: SCOPED_PACKAGE_NAME, - version: INSTALLED_PACKAGE_VERSION, - }, - ); - - expect( - resolveInstalledPackageDependency( - OWNER_PACKAGE_NAME, - SCOPED_PACKAGE_NAME, - fixture.anchor, - ), - ).toEqual({ - ownerPackageRoot: fixture.ownerPackageRoot, - declaredSpec: DECLARED_DEPENDENCY_SPEC, - packageRoot: realpathSync(fixture.packageRoot), - version: INSTALLED_PACKAGE_VERSION, - }); - }); -}); - -describe("resolveInstalledPackageDependency anchoring", () => { - it("resolves from the owner anchor instead of a nested dependency", () => { - const fixture = seedOwnedDependency( - "owner-anchored-dependency", - { - name: OWNER_PACKAGE_NAME, - dependencies: { - [SCOPED_PACKAGE_NAME]: DECLARED_DEPENDENCY_SPEC, - }, - }, - { - name: SCOPED_PACKAGE_NAME, - version: INSTALLED_PACKAGE_VERSION, - }, - ); - const nestedPackageRoot = join( - dirname(fixture.anchor), - "node_modules", - SCOPED_PACKAGE_NAME, - ); - mkdirSync(nestedPackageRoot, { recursive: true }); - writeFileSync( - join(nestedPackageRoot, "package.json"), - JSON.stringify({ - name: SCOPED_PACKAGE_NAME, - version: NESTED_PACKAGE_VERSION, - }), - ); - - const resolved = resolveInstalledPackageDependency( - OWNER_PACKAGE_NAME, - SCOPED_PACKAGE_NAME, - fixture.anchor, - ); - - expect(resolved.packageRoot).toBe(realpathSync(fixture.packageRoot)); - expect(resolved.version).toBe(INSTALLED_PACKAGE_VERSION); - }); -}); - -describe("resolveInstalledPackageDependency declaration validation", () => { - it("requires the dependency in the owner's own dependencies", () => { - const fixture = seedOwnedDependency( - "dev-only-dependency", - { - name: OWNER_PACKAGE_NAME, - devDependencies: { - [SCOPED_PACKAGE_NAME]: DECLARED_DEPENDENCY_SPEC, - }, - }, - { - name: SCOPED_PACKAGE_NAME, - version: INSTALLED_PACKAGE_VERSION, - }, - ); - - expect(() => - resolveInstalledPackageDependency( - OWNER_PACKAGE_NAME, - SCOPED_PACKAGE_NAME, - fixture.anchor, - ), - ).toThrow(`must declare ${SCOPED_PACKAGE_NAME} in its own dependencies`); - }); -}); - -describe("resolveInstalledPackageDependency version validation", () => { - it("rejects an installed manifest without an exact version", () => { - const fixture = seedOwnedDependency( - "ranged-installed-version", - { - name: OWNER_PACKAGE_NAME, - dependencies: { - [SCOPED_PACKAGE_NAME]: DECLARED_DEPENDENCY_SPEC, - }, - }, - { - name: SCOPED_PACKAGE_NAME, - version: NON_EXACT_PACKAGE_VERSION, - }, - ); - - expect(() => - resolveInstalledPackageDependency( - OWNER_PACKAGE_NAME, - SCOPED_PACKAGE_NAME, - fixture.anchor, - ), - ).toThrow(`does not declare an exact version`); - }); -}); - -describe("resolveInstalledPackageBin", () => { - it("fails with PackageResolutionFailed when the bin is not exposed", () => { - expect(() => - resolveInstalledPackageBin(REAL_PACKAGE_NAME, MISSING_BIN_NAME), - ).toThrow(`does not expose bin ${MISSING_BIN_NAME}`); - }); -}); - -const WORKSPACE_PACKAGES_DIR = join("/workspace", "packages"); -const WORKSPACE_CHANNEL_ROOT = join(WORKSPACE_PACKAGES_DIR, "openclaw-channel"); -const INSTALLED_CHANNEL_ROOT = join( - "/consumer", - "node_modules", - "@moltzap", - "openclaw-channel", -); - -interface DecisionCase { - readonly expected: InstallMode; - readonly explicit?: InstallMode; - readonly packageRoot: string; - readonly title: string; -} - -const DECISION_CASES: readonly DecisionCase[] = [ - { - title: "infers workspace from a workspace package root", - packageRoot: WORKSPACE_CHANNEL_ROOT, - expected: "workspace", - }, - { - title: "infers published from an installed node_modules root", - packageRoot: INSTALLED_CHANNEL_ROOT, - expected: "published", - }, - { - title: "lets a published override beat workspace inference", - explicit: "published", - packageRoot: WORKSPACE_CHANNEL_ROOT, - expected: "published", - }, - { - title: "lets a workspace override beat published inference", - explicit: "workspace", - packageRoot: INSTALLED_CHANNEL_ROOT, - expected: "workspace", - }, -]; - -describe("resolveInstallMode", () => { - it.each(DECISION_CASES)("$title", ({ expected, explicit, packageRoot }) => { - const resolveChannelPackageRoot = vi.fn(() => packageRoot); - const resolve = makeInstallModeResolver({ - resolveChannelPackageRoot, - workspacePackagesDir: WORKSPACE_PACKAGES_DIR, - }); - - const mode = Effect.runSync(resolve(explicit)); - - expect(mode).toBe(expected); - expect(resolveChannelPackageRoot).toHaveBeenCalledTimes( - explicit === undefined ? 1 : 0, - ); - }); - - it("surfaces package-resolution failures in the typed error channel", () => { - const resolve = makeInstallModeResolver({ - resolveChannelPackageRoot: () => { - throw new Error("package root unavailable"); - }, - workspacePackagesDir: WORKSPACE_PACKAGES_DIR, - }); - const exit = Effect.runSync(Effect.exit(resolve())); - - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const failure = Option.getOrThrow(Cause.failureOption(exit.cause)); - expect(failure).toBeInstanceOf(RuntimePackageError); - } - }); -}); - -/* eslint-enable agent-code-guard/prefer-effect-platform -- Restore strict defaults after the scoped file-level exception. */ diff --git a/packages/simulator/src/runtime/packages.ts b/packages/simulator/src/runtime/packages.ts deleted file mode 100644 index 9ea635951..000000000 --- a/packages/simulator/src/runtime/packages.ts +++ /dev/null @@ -1,660 +0,0 @@ -/** @file Installed-package resolution and runtime artifact selection. */ - -// eslint-disable-next-line agent-code-guard/prefer-effect-platform -- Node package ownership follows createRequire synchronously; this is the module-resolution boundary, not filesystem application logic. -import { existsSync } from "node:fs"; -import { createRequire } from "node:module"; -import { - basename, - dirname, - isAbsolute, - join, - parse, - relative, - resolve, - sep, -} from "node:path"; -import { fileURLToPath } from "node:url"; -import { Data, Effect, Schema } from "effect"; - -const requireFromHere = createRequire(import.meta.url); -const PACKAGE_RESOLUTION_ANCHOR = import.meta.url; -const VERSION_NUMBER_PATTERN = /^(?:0|[1-9]\d*)$/; -const VERSION_IDENTIFIER_PATTERN = /^[0-9A-Za-z-]+$/; - -class PackageResolutionFailed extends Data.TaggedError( - "PackageResolutionFailed", -)<{ - readonly message: string; - readonly packageName: string; - readonly cause?: unknown; -}> {} - -interface PackageJson { - readonly name?: unknown; - readonly bin?: unknown; - readonly dependencies?: unknown; - readonly version?: unknown; -} - -interface PackageJsonResolution { - readonly rejectedRoots: ReadonlySet; - readonly root: string | null; - readonly unexpectedCause: unknown; -} - -interface PackageJsonCandidateResolution { - readonly rejectedRoot: string | null; - readonly root: string | null; - readonly unexpectedCause: unknown; -} - -interface OwningPackage { - readonly manifest: PackageJson; - readonly root: string; -} - -/** Describes installed package dependency. */ -export interface InstalledPackageDependency { - readonly ownerPackageRoot: string; - readonly declaredSpec: string; - readonly packageRoot: string; - readonly version: string; -} - -function isPackageJson(value: unknown): value is PackageJson { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function isPropertyRecord( - value: unknown, -): value is Readonly> { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -function splitAtFirst( - value: string, - separator: string, -): readonly [string, string | null] { - const separatorIndex = value.indexOf(separator); - if (separatorIndex < 0) { - return [value, null]; - } - return [ - value.slice(0, separatorIndex), - value.slice(separatorIndex + separator.length), - ]; -} - -function isValidPrerelease(value: string): boolean { - if (value.length === 0) { - return false; - } - return value.split(".").every((identifier) => { - if (!VERSION_IDENTIFIER_PATTERN.test(identifier)) { - return false; - } - return /^\d+$/.test(identifier) - ? VERSION_NUMBER_PATTERN.test(identifier) - : true; - }); -} - -function isValidBuild(value: string): boolean { - return ( - value.length > 0 && - value - .split(".") - .every((identifier) => VERSION_IDENTIFIER_PATTERN.test(identifier)) - ); -} - -function isExactPackageVersion(version: string): boolean { - const [withoutBuild, build] = splitAtFirst(version, "+"); - if (build !== null && !isValidBuild(build)) { - return false; - } - const [core, prerelease] = splitAtFirst(withoutBuild, "-"); - if (prerelease !== null && !isValidPrerelease(prerelease)) { - return false; - } - const coreIdentifiers = core.split("."); - return ( - coreIdentifiers.length === 3 && - coreIdentifiers.every((identifier) => - VERSION_NUMBER_PATTERN.test(identifier), - ) - ); -} - -function parsePackageJson( - requireFromAnchor: NodeJS.Require, - packageRoot: string, - packageName: string, -): PackageJson { - const packageJsonPath = join(packageRoot, "package.json"); - let manifest: unknown; - try { - manifest = requireFromAnchor(packageJsonPath); - } catch (cause) { - throw new PackageResolutionFailed({ - packageName, - cause, - message: `Unable to read package.json for ${packageName} at ${packageJsonPath}`, - }); - } - if (!isPackageJson(manifest)) { - throw new PackageResolutionFailed({ - packageName, - message: `Invalid package.json for ${packageName} at ${packageJsonPath}: expected an object`, - }); - } - return manifest; -} - -function packageRootFromResolvedFile( - packageName: string, - resolvedFile: string, -): string { - const packageSegments = packageName.split("/"); - const separator = sep; - const resolvedSegments = resolvedFile.split(separator); - for ( - let index = resolvedSegments.length - packageSegments.length; - index >= 0; - index-- - ) { - if ( - packageSegments.every( - (segment, offset) => resolvedSegments[index + offset] === segment, - ) - ) { - return resolvedSegments - .slice(0, index + packageSegments.length) - .join(separator); - } - } - const packageBaseName = packageSegments.at(-1); - if (packageBaseName !== undefined) { - const packageIndex = resolvedSegments.lastIndexOf(packageBaseName); - if (packageIndex >= 0) { - return resolvedSegments.slice(0, packageIndex + 1).join(separator); - } - } - throw new PackageResolutionFailed({ - packageName, - message: `Unable to find package root for ${resolvedFile}`, - }); -} - -function isExpectedResolutionFailure(cause: unknown): boolean { - const code = - cause instanceof Error && "code" in cause ? cause.code : undefined; - return ( - code === "MODULE_NOT_FOUND" || code === "ERR_PACKAGE_PATH_NOT_EXPORTED" - ); -} - -function resolvePackageJsonCandidate( - requireFromAnchor: NodeJS.Require, - packageName: string, - candidate: string, -): PackageJsonCandidateResolution { - let packageJsonPath: string; - try { - packageJsonPath = requireFromAnchor.resolve(candidate); - } catch (cause) { - return { - rejectedRoot: null, - root: null, - unexpectedCause: isExpectedResolutionFailure(cause) ? null : cause, - }; - } - const packageRoot = dirname(packageJsonPath); - try { - const manifest = parsePackageJson( - requireFromAnchor, - packageRoot, - packageName, - ); - return manifest.name === packageName - ? { rejectedRoot: null, root: packageRoot, unexpectedCause: null } - : { rejectedRoot: packageRoot, root: null, unexpectedCause: null }; - } catch (cause) { - return { - rejectedRoot: packageRoot, - root: null, - unexpectedCause: cause, - }; - } -} - -function resolvePackageJson( - requireFromAnchor: NodeJS.Require, - packageName: string, -): PackageJsonResolution { - const packageJsonCandidates = [ - `${packageName}/package.json`, - ...(requireFromAnchor.resolve.paths(packageName) ?? []).map((lookupPath) => - join(lookupPath, packageName, "package.json"), - ), - ]; - const rejectedRoots = new Set(); - let unexpectedCause: unknown = null; - for (const candidate of packageJsonCandidates) { - const resolution = resolvePackageJsonCandidate( - requireFromAnchor, - packageName, - candidate, - ); - if (resolution.root !== null) { - return { rejectedRoots, root: resolution.root, unexpectedCause: null }; - } - if (resolution.rejectedRoot !== null) { - rejectedRoots.add(resolution.rejectedRoot); - } - unexpectedCause ??= resolution.unexpectedCause; - } - return { rejectedRoots, root: null, unexpectedCause }; -} - -/** - * Resolves a package root from the same module-resolution context as `anchor`. - * - * A package may hide `package.json` behind its exports map, so lookup-path - * candidates precede recovery from the package's public entry point. - * @param anchor Value supplied to the operation. - * @param packageName Value supplied to the operation. - * @returns The resolve package root result. - */ -export function resolvePackageRoot( - anchor: string | URL, - packageName: string, -): string | null { - const requireFromAnchor = createRequire(anchor); - const packageJsonResolution = resolvePackageJson( - requireFromAnchor, - packageName, - ); - if (packageJsonResolution.root !== null) { - return packageJsonResolution.root; - } - try { - const publicEntryRoot = packageRootFromResolvedFile( - packageName, - requireFromAnchor.resolve(packageName), - ); - return packageJsonResolution.rejectedRoots.has(publicEntryRoot) - ? null - : publicEntryRoot; - } catch (cause) { - if (!isExpectedResolutionFailure(cause)) { - throw cause; - } - if (packageJsonResolution.unexpectedCause !== null) { - throw new PackageResolutionFailed({ - packageName, - cause: packageJsonResolution.unexpectedCause, - message: `Unable to resolve package metadata for ${packageName}`, - }); - } - return null; - } -} - -function packageBinTarget( - packageRoot: string, - packageName: string, - binName: string, -): string { - const packageJson = parsePackageJson( - requireFromHere, - packageRoot, - packageName, - ); - const { bin } = packageJson; - if (typeof bin === "string") { - return join(packageRoot, bin); - } - if (isPropertyRecord(bin)) { - const target = bin[binName]; - if (typeof target === "string") { - return join(packageRoot, target); - } - } - throw new PackageResolutionFailed({ - packageName, - message: `Package ${packageName} does not expose bin ${binName}`, - }); -} - -/** - * Resolves installed package root. - * @param packageName Value supplied to the operation. - * @param anchor Value supplied to the operation. - * @returns The resolve installed package root result. - */ -export function resolveInstalledPackageRoot( - packageName: string, - anchor: string | URL = PACKAGE_RESOLUTION_ANCHOR, -): string { - try { - const packageRoot = resolvePackageRoot(anchor, packageName); - if (packageRoot !== null) { - return packageRoot; - } - } catch (cause) { - if (cause instanceof PackageResolutionFailed) { - throw cause; - } - throw new PackageResolutionFailed({ - packageName, - cause, - message: `Unable to resolve installed package ${packageName}`, - }); - } - throw new PackageResolutionFailed({ - packageName, - message: `Unable to resolve installed package ${packageName}`, - }); -} - -function anchorFilePath(anchor: string | URL, packageName: string): string { - try { - const path = - typeof anchor === "string" && !anchor.startsWith("file:") - ? anchor - : fileURLToPath(anchor); - return resolve(path); - } catch (cause) { - throw new PackageResolutionFailed({ - packageName, - cause, - message: `Unable to interpret package-resolution anchor ${String(anchor)}`, - }); - } -} - -function findOwningPackage( - ownerPackageName: string, - anchor: string | URL, -): OwningPackage { - const anchorPath = anchorFilePath(anchor, ownerPackageName); - let candidateRoot = dirname(anchorPath); - while (true) { - const manifestPath = join(candidateRoot, "package.json"); - if (existsSync(manifestPath)) { - const manifest = parsePackageJson( - createRequire(manifestPath), - candidateRoot, - ownerPackageName, - ); - if (manifest.name !== ownerPackageName) { - throw new PackageResolutionFailed({ - packageName: ownerPackageName, - message: `Package-resolution anchor ${anchorPath} belongs to ${String(manifest.name)}, not ${ownerPackageName}`, - }); - } - return { manifest, root: candidateRoot }; - } - const parent = dirname(candidateRoot); - if (parent === candidateRoot) { - throw new PackageResolutionFailed({ - packageName: ownerPackageName, - message: `Unable to find owning package ${ownerPackageName} from ${anchorPath}`, - }); - } - candidateRoot = parent; - } -} - -/** - * Locate the package that owns a module anchor. - * - * Runtime assets use package ownership rather than source-file depth, so - * moving compiled modules cannot change which package artifact they read. - * @param ownerPackageName Value supplied to the operation. - * @param anchor Value supplied to the operation. - * @internal - * @returns The resolve owning package root result. - */ -export function resolveOwningPackageRoot( - ownerPackageName: string, - anchor: string | URL, -): string { - return findOwningPackage(ownerPackageName, anchor).root; -} - -function ownDependencySpec( - ownerPackageName: string, - ownerPackageRoot: string, - manifest: PackageJson, - dependencyName: string, -): string { - const dependencies = manifest.dependencies; - if ( - !Object.hasOwn(manifest, "dependencies") || - !isPropertyRecord(dependencies) - ) { - throw new PackageResolutionFailed({ - packageName: dependencyName, - message: `Package ${ownerPackageName} at ${ownerPackageRoot} must declare ${dependencyName} in its own dependencies`, - }); - } - if (!Object.hasOwn(dependencies, dependencyName)) { - throw new PackageResolutionFailed({ - packageName: dependencyName, - message: `Package ${ownerPackageName} at ${ownerPackageRoot} must declare ${dependencyName} in its own dependencies`, - }); - } - const declaredSpec = dependencies[dependencyName]; - if (typeof declaredSpec !== "string" || declaredSpec.length === 0) { - throw new PackageResolutionFailed({ - packageName: dependencyName, - message: `Package ${ownerPackageName} at ${ownerPackageRoot} has an invalid dependencies declaration for ${dependencyName}`, - }); - } - return declaredSpec; -} - -/** - * Resolves one of an owning package's runtime dependencies and reports both - * the declared install contract and the exact installed artifact. - * - * Reading from the owner's manifest anchor prevents a nested caller path from - * changing which installed dependency Node selects. - * @param ownerPackageName Value supplied to the operation. - * @param dependencyName Value supplied to the operation. - * @param anchor Value supplied to the operation. - * @returns The resolve installed package dependency result. - */ -export function resolveInstalledPackageDependency( - ownerPackageName: string, - dependencyName: string, - anchor: string | URL = PACKAGE_RESOLUTION_ANCHOR, -): InstalledPackageDependency { - const owner = findOwningPackage(ownerPackageName, anchor); - const declaredSpec = ownDependencySpec( - ownerPackageName, - owner.root, - owner.manifest, - dependencyName, - ); - const ownerManifestPath = join(owner.root, "package.json"); - const packageRoot = resolveInstalledPackageRoot( - dependencyName, - ownerManifestPath, - ); - const installedManifest = parsePackageJson( - createRequire(ownerManifestPath), - packageRoot, - dependencyName, - ); - if (installedManifest.name !== dependencyName) { - throw new PackageResolutionFailed({ - packageName: dependencyName, - message: `Installed package at ${packageRoot} is named ${String(installedManifest.name)}, not ${dependencyName}`, - }); - } - if ( - typeof installedManifest.version !== "string" || - !isExactPackageVersion(installedManifest.version) - ) { - throw new PackageResolutionFailed({ - packageName: dependencyName, - message: `Installed package ${dependencyName} at ${packageRoot} does not declare an exact version`, - }); - } - return { - ownerPackageRoot: owner.root, - declaredSpec, - packageRoot, - version: installedManifest.version, - }; -} - -/** - * Resolves installed package bin. - * @param packageName Value supplied to the operation. - * @param binName Value supplied to the operation. - * @returns The resolve installed package bin result. - */ -export function resolveInstalledPackageBin( - packageName: string, - binName: string, -): string { - return packageBinTarget( - resolveInstalledPackageRoot(packageName), - packageName, - binName, - ); -} - -/** Represents install mode values. */ -export type InstallMode = "published" | "workspace"; - -const CHANNEL_PACKAGE_NAME = "@moltzap/openclaw-channel"; - -/** Runtime package placement could not be determined. */ -export class RuntimePackageError extends Schema.TaggedError()( - "RuntimePackageError", - { - detail: Schema.String, - }, -) {} - -interface InstallModeResolverDeps { - readonly resolveChannelPackageRoot: () => string; - readonly workspacePackagesDir: string | null; -} - -interface InstallModeDecision { - readonly determinedBy: "explicit override" | "package resolution"; - readonly mode: InstallMode; - readonly packageRoot: string | null; -} - -const defaultResolverDeps: InstallModeResolverDeps = { - resolveChannelPackageRoot: () => - resolveInstalledPackageRoot(CHANNEL_PACKAGE_NAME, import.meta.url), - workspacePackagesDir: findWorkspacePackagesDir(import.meta.url), -}; - -/** - * Build an install-mode resolver around explicit package-location seams. - * @param deps Value supplied to the operation. - * @returns The created install mode resolver. - */ -export function makeInstallModeResolver(deps: InstallModeResolverDeps) { - return (installMode?: InstallMode) => - Effect.try({ - try: () => decideInstallMode(deps, installMode), - catch: (cause) => - RuntimePackageError.make({ - detail: `Could not resolve the OpenClaw channel package location: ${String(cause)}`, - }), - }).pipe( - Effect.tap(logInstallModeDecision), - Effect.map((decision) => decision.mode), - ); -} - -/** - * Select workspace sources or exact installed packages for one runtime. - * @param installMode Value supplied to the operation. - * @returns The resolve install mode result. - */ -export function resolveInstallMode(installMode?: InstallMode) { - return makeInstallModeResolver(defaultResolverDeps)(installMode); -} - -function decideInstallMode( - deps: InstallModeResolverDeps, - installMode?: InstallMode, -): InstallModeDecision { - if (installMode !== undefined) { - return { - determinedBy: "explicit override", - mode: installMode, - packageRoot: null, - }; - } - const packageRoot = deps.resolveChannelPackageRoot(); - return { - determinedBy: "package resolution", - mode: isWorkspacePackageRoot(packageRoot, deps.workspacePackagesDir) - ? "workspace" - : "published", - packageRoot, - }; -} - -function logInstallModeDecision(decision: InstallModeDecision) { - return Effect.logInfo("resolved runtime install mode").pipe( - Effect.annotateLogs({ - installMode: decision.mode, - determinedBy: decision.determinedBy, - ...(decision.packageRoot === null - ? {} - : { packageRoot: decision.packageRoot }), - }), - ); -} - -function isWorkspacePackageRoot( - packageRoot: string, - workspacePackagesDir: string | null, -): boolean { - if (workspacePackagesDir === null) { - return false; - } - const relativeRoot = relative(workspacePackagesDir, packageRoot); - if ( - relativeRoot === "" || - relativeRoot === ".." || - relativeRoot.startsWith(".." + sep) || - isAbsolute(relativeRoot) - ) { - return false; - } - return !relativeRoot.split(sep).includes("node_modules"); -} - -/** - * Find the workspace package directory containing the simulator package. - * @param moduleUrl Value supplied to the operation. - * @returns The find workspace packages dir result. - */ -export function findWorkspacePackagesDir( - moduleUrl: string | URL, -): string | null { - let current = dirname(fileURLToPath(moduleUrl)); - const root = parse(current).root; - while (current !== root) { - const parent = dirname(current); - if (basename(current) === "simulator" && basename(parent) === "packages") { - return parent; - } - current = parent; - } - return null; -} diff --git a/packages/simulator/src/runtime/process.test-utils.ts b/packages/simulator/src/runtime/process.test-utils.ts deleted file mode 100644 index db440de7f..000000000 --- a/packages/simulator/src/runtime/process.test-utils.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** @file Test-clock control over the startup deadline runtimes arm. */ - -import { Chunk, Duration, Effect, TestClock } from "effect"; - -/** - * Scheduler rounds a forked acquisition may take to arm its startup deadline. - * Registration needs a handful; the bound exists so a runtime that arms no - * deadline fails the calling test instead of parking on the test clock. - */ -const DEADLINE_ARMING_ROUNDS = 100; - -/** - * Yield until the test clock holds a wake-up at `deadline`. - * @param deadline Clock instant the runtime under test is expected to wake on. - * @returns Whether that wake-up is registered within the round bound. - */ -function awaitArmedDeadline(deadline: number): Effect.Effect { - return TestClock.sleeps().pipe( - Effect.map((scheduled) => - Chunk.some(scheduled, (instant) => instant === deadline), - ), - Effect.zipLeft(Effect.yieldNow()), - Effect.repeat({ - until: (armed: boolean) => armed, - times: DEADLINE_ARMING_ROUNDS, - }), - ); -} - -/** - * Expire the startup budget of a runtime acquisition running on another fiber. - * - * A runtime arms its startup deadline several fiber hops after its driver hands - * back a session, so a fixture that has observed acquisition has not yet - * observed the deadline. `TestClock.adjust` wakes only the sleepers already - * registered when it runs and anchors a later registration to the clock it has - * already advanced, which leaves the acquisition waiting on an instant that - * never arrives. Waiting for the deadline itself to appear among the scheduled - * wake-ups keeps fiber registration order out of the outcome. - * @param within Startup budget the runtime under test was configured with. - * @returns An effect that advances the test clock onto the armed deadline. - */ -export function expireStartupDeadline( - within: Duration.Duration, -): Effect.Effect { - return TestClock.currentTimeMillis.pipe( - Effect.flatMap((now) => - awaitArmedDeadline(now + Duration.toMillis(within)), - ), - Effect.flatMap((armed) => - armed - ? TestClock.adjust(within) - : Effect.die( - new Error( - `runtime under test armed no startup deadline at ${Duration.format(within)}`, - ), - ), - ), - ); -} diff --git a/packages/simulator/src/runtime/process.ts b/packages/simulator/src/runtime/process.ts deleted file mode 100644 index 6347f31ba..000000000 --- a/packages/simulator/src/runtime/process.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** @file Shared observation of already-acquired autonomous processes. */ - -import type { ExitCode } from "@effect/platform/CommandExecutor"; -import type { AgentKey, AgentName } from "@moltzap/protocol/identity"; -import { - RuntimeExited, - RuntimeFailed, - type RuntimeTermination, -} from "./runtime.js"; -import { Duration, Effect, Redacted, Schedule, Schema } from "effect"; -import { attachChildOutput } from "./command.js"; - -const AGENT_KEY_REDACTION_MARKER = "[REDACTED:agent-key]"; -const READY_POLL_INTERVAL = Duration.millis(100); - -/** Runtime-specific observations exposed by one acquired process resource. */ -export interface ProcessObservation { - readonly exitCode: Effect.Effect; - readonly output: () => string; -} - -interface ProcessIdentity { - readonly agentName: AgentName; - readonly agentKey: AgentKey; - readonly runtimeName: string; -} - -interface ProcessReadiness extends ProcessIdentity { - readonly within: Duration.Duration; - readonly observation: ProcessObservation; - - /** - * Recognizes the agent's readiness line in the child's accumulated output. - * Each runtime owns its own line, so this module never names one. - */ - readonly readyWhen: (output: string) => boolean; -} - -/** An external runtime did not become a ready participant. */ -export class RuntimeAcquisitionFailed extends Schema.TaggedError()( - "RuntimeAcquisitionFailed", - { - runtime: Schema.NonEmptyString, - agent: Schema.NonEmptyString, - detail: Schema.String, - }, -) { - override get message(): string { - return `${this.runtime} runtime for "${this.agent}" failed to start: ${this.detail}`; - } -} - -function redactAgentKey(key: AgentKey, text: string): string { - return text.split(Redacted.value(key)).join(AGENT_KEY_REDACTION_MARKER); -} - -function acquisitionFailed( - identity: ProcessIdentity, - observation: ProcessObservation, - diagnostic: { - readonly detail: string; - }, -): RuntimeAcquisitionFailed { - const output = observation.output(); - return RuntimeAcquisitionFailed.make({ - runtime: identity.runtimeName, - agent: identity.agentName, - detail: attachChildOutput(diagnostic.detail, output, (text) => - redactAgentKey(identity.agentKey, text), - ), - }); -} - -/** - * Wait for the child to announce readiness on its own output, racing that - * announcement against actual process exit so an agent that dies during - * startup fails immediately instead of burning the whole budget. The - * runtime-specific owner supplies process observations and its readiness - * predicate, not lifecycle configuration or teardown. - * @param input Input value to process. - * @returns The await process ready result. - */ -export function awaitProcessReady( - input: ProcessReadiness, -): Effect.Effect { - const exited = input.observation.exitCode.pipe( - Effect.matchEffect({ - onFailure: () => - Effect.fail( - acquisitionFailed(input, input.observation, { - detail: `Agent "${input.agentName}" stopped before announcing readiness without an observable exit code`, - }), - ), - onSuccess: (code) => - Effect.fail( - acquisitionFailed(input, input.observation, { - detail: `Agent "${input.agentName}" exited before announcing readiness (exitCode=${String(code)})`, - }), - ), - }), - ); - // The accumulated window is matched whole: a readiness line can arrive split - // across stream chunks, and the buffer retains the startup head verbatim. - const ready = Effect.sync(() => - input.readyWhen(input.observation.output()), - ).pipe( - Effect.repeat({ - schedule: Schedule.spaced(READY_POLL_INTERVAL), - until: (announced) => announced, - }), - Effect.asVoid, - ); - return Effect.raceFirst(ready, exited).pipe( - Effect.timeoutFail({ - duration: input.within, - onTimeout: () => - acquisitionFailed(input, input.observation, { - detail: `Agent "${input.agentName}" did not announce readiness within ${Duration.format(input.within)}`, - }), - }), - ); -} - -/** - * Convert one process exit observation into runtime evidence. - * @param identity Value supplied to the operation. - * @param observation Value supplied to the operation. - * @returns The process termination result. - */ -export function processTermination( - identity: Pick, - observation: ProcessObservation, -): Effect.Effect { - return observation.exitCode.pipe( - Effect.match({ - onFailure: () => - RuntimeFailed.make({ - detail: `${identity.runtimeName} process for agent "${identity.agentName}" completed without an observable exit code`, - }), - onSuccess: (code) => RuntimeExited.make({ code: Number(code) }), - }), - ); -} diff --git a/packages/simulator/src/runtime/runtime.test.ts b/packages/simulator/src/runtime/runtime.test.ts deleted file mode 100644 index 75bc94944..000000000 --- a/packages/simulator/src/runtime/runtime.test.ts +++ /dev/null @@ -1,247 +0,0 @@ -import { assert, it } from "@effect/vitest"; -import { Effect, Ref, Schema } from "effect"; -import { serverBaseUrlSchema } from "@moltzap/protocol/network"; -import { - agentId, - agentName, - redactedAgentKey, -} from "@moltzap/protocol/testing"; -import { makeAgentHandle } from "../network/participant.js"; -import type { AgentConnection } from "../network/router.js"; -import { - AgentRuntimeDefinitionError, - RuntimeCompleted, - defineRuntime, - runtimeConfigurationProjection, -} from "./runtime.js"; -import { makeAgentRosterBuilder } from "./roster.js"; - -const ALICE_ID = agentId("00000000-0000-4000-8000-000000000001"); -const ALICE_NAME = agentName("alice"); -const key = redactedAgentKey( - "moltzap_agent_0000000000000000_000000000000000000000000000000000000000000000000", -); -const routerUrl = Schema.decodeUnknownSync(serverBaseUrlSchema)( - "http://127.0.0.1:3000", -); -const testRuntimeConfiguration = Schema.Struct({ - label: Schema.String, -}); -const configuration = { - schema: testRuntimeConfiguration, - value: { label: "test" }, -}; - -function isDeeplyFrozen(value: unknown): boolean { - if (typeof value !== "object" || value === null) { - return true; - } - return ( - Object.isFrozen(value) && - Object.values(value).every((member) => isDeeplyFrozen(member)) - ); -} - -const connection: AgentConnection<"alice"> = { - agent: makeAgentHandle("alice", ALICE_ID), - key, - routerUrl, -}; - -// @agent-code-guard/regression-only: exact scoped acquisition and invalid declaration cases pin runtime construction invariants -it.effect("releases an acquired runtime with its caller scope", () => - Effect.gen(function* () { - const released = yield* Ref.make(false); - const runtime = defineRuntime< - undefined, - never, - never, - typeof testRuntimeConfiguration - >({ - name: "scoped", - configuration, - acquire: () => - Effect.acquireRelease( - Effect.succeed({ - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }), - () => Ref.set(released, true), - ), - }); - - yield* Effect.scoped( - Effect.gen(function* () { - const running = yield* runtime.acquire({ - agentName: ALICE_NAME, - connection, - }); - const termination = yield* running.termination; - - assert.instanceOf(termination, RuntimeCompleted); - assert.isFalse(yield* Ref.get(released)); - }), - ); - - assert.isTrue(yield* Ref.get(released)); - }), -); - -it("validates roster keys when the definition constructs its roster", () => { - const runtime = defineRuntime< - undefined, - never, - never, - typeof testRuntimeConfiguration - >({ - name: "test", - configuration, - acquire: () => - Effect.succeed({ - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }), - }); - const makeRoster = makeAgentRosterBuilder("acme.society/v1"); - - assert.throws(() => - makeRoster({ - "Not Wire Safe": runtime, - }), - ); -}); - -it("rejects empty runtime names before a run starts", () => { - assert.throws( - () => - defineRuntime({ - name: "", - configuration, - acquire: () => - Effect.succeed({ - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }), - }), - AgentRuntimeDefinitionError, - ); -}); - -it.effect("captures runtime behavior when the definition is constructed", () => - Effect.gen(function* () { - const calls: string[] = []; - const source = { - name: "captured", - configuration, - acquire: () => - Effect.sync(() => { - calls.push("original"); - return { - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }; - }), - }; - const runtime = defineRuntime(source); - source.acquire = () => - Effect.sync(() => { - calls.push("mutated"); - return { - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }; - }); - - yield* Effect.scoped( - runtime.acquire({ agentName: ALICE_NAME, connection }), - ); - - assert.deepStrictEqual(calls, ["original"]); - }), -); - -it("copies and freezes roster declarations without mutating caller input", () => { - const runtime = defineRuntime({ - name: "immutable", - configuration, - acquire: () => - Effect.succeed({ - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }), - }); - const definitions = { alice: runtime }; - const roster = makeAgentRosterBuilder("acme.society/v1")(definitions); - - assert.isFalse(Object.isFrozen(definitions)); - assert.notStrictEqual(roster.definitions, definitions); - assert.strictEqual(roster.definitions.alice, runtime); - assert.isTrue(Object.isFrozen(roster.definitions)); -}); - -it("rejects runtime configurations that do not encode to JSON", () => { - assert.throws( - () => - defineRuntime({ - name: "invalid-configuration", - configuration: { - schema: Schema.Undefined, - value: undefined, - }, - acquire: () => - Effect.succeed({ - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }), - }), - AgentRuntimeDefinitionError, - ); -}); - -it("isolates the canonical projection and every native configuration view", () => { - const mutableConfiguration = Schema.Struct({ - nested: Schema.Struct({ - labels: Schema.Array(Schema.String), - }), - at: Schema.Date, - }); - const source = { - nested: { - labels: ["original"], - }, - at: new Date("2026-01-01T00:00:00.000Z"), - }; - const runtime = defineRuntime({ - name: "snapshotted-configuration", - configuration: { - schema: mutableConfiguration, - value: source, - }, - acquire: () => - Effect.succeed({ - gateway: undefined, - termination: Effect.succeed(RuntimeCompleted.make({})), - }), - }); - - source.nested.labels.push("source-mutation"); - source.at.setUTCFullYear(2027); - const first = runtime.configuration.value; - const projection = runtimeConfigurationProjection(runtime); - - assert.isTrue(isDeeplyFrozen(projection)); - assert.isTrue(Reflect.set(first.nested.labels, "0", "native-mutation")); - first.at.setUTCFullYear(2030); - if (typeof projection === "object" && projection !== null) { - assert.isFalse(Reflect.set(projection, "nested", null)); - } - assert.deepStrictEqual(runtime.configuration.value, { - nested: { labels: ["original"] }, - at: new Date("2026-01-01T00:00:00.000Z"), - }); - assert.deepStrictEqual(runtimeConfigurationProjection(runtime), { - nested: { labels: ["original"] }, - at: "2026-01-01T00:00:00.000Z", - }); - assert.notStrictEqual(runtime.configuration.value, first); -}); diff --git a/packages/simulator/src/runtime/workspace.test.ts b/packages/simulator/src/runtime/workspace.test.ts deleted file mode 100644 index 8f913bb0f..000000000 --- a/packages/simulator/src/runtime/workspace.test.ts +++ /dev/null @@ -1,763 +0,0 @@ -/** - * Unit tests for the channel-plugin install helpers. - * - * `resolveChannelDependency` should walk Node's standard module resolution - * starting from the channel package's `package.json`, so it finds the dep - * whether it is package-local, hoisted, or hidden behind an export map. - * Installed plugins link every declared runtime dependency from that - * resolution chain. - */ -import { pathToFileURL } from "node:url"; -import { FileSystem, Path } from "@effect/platform"; -import { NodeContext } from "@effect/platform-node"; -import { Effect, Option } from "effect"; -import { - agentId, - agentName, - agentKeyString, - redactedAgentKey, -} from "@moltzap/protocol/testing"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; - -import { - installChannelPlugin, - resolveChannelDependency, - seedWorkspaceFiles, - serializeMoltZapProfileConfig, - SIMULATOR_PROFILE_NAME, - writeMoltZapProfileConfig, -} from "./workspace.js"; - -const OPENCLAW_CHANNEL_PACKAGE = "@moltzap/openclaw-channel"; -const CLIENT_PACKAGE = "@moltzap/client"; -const PROTOCOL_PACKAGE = "@moltzap/protocol"; -const EFFECT_PACKAGE = "effect"; -const EFFECT_PLATFORM_PACKAGE = "@effect/platform"; -const EFFECT_PLATFORM_NODE_PACKAGE = "@effect/platform-node"; -const FANCY_DEP_PACKAGE = "fancy-dep"; -const LEGACY_DIST_NODE_MODULES = "dist/node_modules"; -const NONEXISTENT_DEP_PACKAGE = "@moltzap/__nonexistent-dep-285__"; -const CHANNEL_PACKAGE_DIR = "openclaw-channel"; -const CHANNEL_EXTENSION_NAME = "openclaw-channel"; -const CHANNEL_ENTRY_FILE = "openclaw-entry.js"; -const PROFILE_CONFIG_FILE_NAME = "config.json"; -const PROFILE_FILE_PERMISSION_MASK = 0o777; -const PROFILE_FILE_MODE = 0o600; -// Staging a real npm consumer layout costs seconds of filesystem work, and the -// budget is sized for a machine already running the rest of the suite rather -// than for an idle one. -const NPM_FIXTURE_TIMEOUT_MS = 60_000; -const TEST_AGENT_NAME = agentName("network-agent"); -const WORKSPACE_FILE_CONTENT = "review"; -const WORKSPACE_FILE_PATH = "skills/reviewer.md"; -const TEST_AGENT_ID = agentId("11111111-1111-4111-8111-111111111111"); -const TEST_AGENT_KEY_TEXT = agentKeyString(29); -const TEST_AGENT_KEY = redactedAgentKey(TEST_AGENT_KEY_TEXT); -const CHANNEL_DEPENDENCIES = [ - EFFECT_PLATFORM_PACKAGE, - EFFECT_PLATFORM_NODE_PACKAGE, - CLIENT_PACKAGE, - PROTOCOL_PACKAGE, - EFFECT_PACKAGE, -] as const; - -let workDir = ""; - -beforeEach(() => - runWithNodeFileSystem( - FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.makeTempDirectory({ - prefix: "channel-plugin-install-", - }), - ), - Effect.tap((directory) => - Effect.sync(() => { - workDir = directory; - }), - ), - ), - ), -); - -afterEach(() => - runWithNodeFileSystem( - FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.remove(workDir, { recursive: true, force: true }), - ), - ), - ), -); - -describe("resolveChannelDependency", () => { - it( - "resolves a dep installed at the channel package's own node_modules", - resolvesOwnNodeModules, - ); - it( - "resolves a dep hoisted to a parent node_modules", - resolvesHoistedDependency, - ); - it( - "returns null when the channel package has no package.json", - missingPackageJsonReturnsNull, - ); - it("returns null when the dep cannot be found", missingDependencyReturnsNull); - it( - "returns the package root for packages whose main lives under dist", - resolvesPackageRoot, - ); - it( - "resolves a scoped dependency whose export map hides package.json", - resolvesExportRestrictedScopedPackage, - ); - it( - "resolves dependencies beside a pnpm virtual-store package", - resolvesPnpmVirtualStoreDependency, - ); - it( - "property: resolved dependency roots never point into legacy dist node_modules", - resolvedRootsAvoidLegacyDistNodeModules, - ); -}); - -describe("installChannelPlugin", () => { - it( - "symlinks a declared dependency from the channel node_modules", - symlinksWorkspaceDependency, - ); - it( - "loads every declared dependency from an npm consumer layout", - symlinksNpmDependencies, - NPM_FIXTURE_TIMEOUT_MS, - ); - it( - "fails instead of creating a dangling link for a missing declared dependency", - missingDeclaredDependencyFails, - ); - it( - "fails cleanly when the channel manifest is malformed", - malformedChannelManifestFails, - ); -}); - -function malformedChannelManifestFails() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const path = yield* Path.Path; - const channelPkg = path.join(workDir, "malformed", CHANNEL_PACKAGE_DIR); - const channelDist = path.join(channelPkg, "dist"); - const stateDir = path.join(workDir, ".malformed-state"); - yield* seedPackage(channelPkg, { - name: OPENCLAW_CHANNEL_PACKAGE, - type: "module", - dependencies: "not-a-record", - }); - yield* seedChannelEntry(channelDist, []); - yield* makeDirectory(stateDir); - - const error = yield* installChannelPlugin({ - stateDir, - channelDistDir: channelDist, - extName: CHANNEL_EXTENSION_NAME, - }).pipe(Effect.flip); - - expect(error).toMatchObject({ _tag: "ChannelPluginInstallError" }); - }), - ); -} - -describe("simulator profile config", () => { - it( - "serializes the fixed selector with the network agent name", - serializesSimulatorProfile, - ); - it("writes credentials with owner-only permissions", writesSecureProfile); -}); - -describe("workspace files", () => { - it( - "writes nested files below the agent workspace", - writesNestedWorkspaceFile, - ); - it( - "rejects paths that escape the agent workspace", - rejectsEscapingWorkspaceFiles, - ); -}); - -function serializesSimulatorProfile() { - expect(serializeMoltZapProfileConfig(testProfile())).toBe( - JSON.stringify( - { - profiles: { - [SIMULATOR_PROFILE_NAME]: { - agentId: TEST_AGENT_ID, - apiKey: TEST_AGENT_KEY_TEXT, - agentName: TEST_AGENT_NAME, - }, - }, - }, - null, - 2, - ), - ); -} - -function writesSecureProfile() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const configHome = path.join(workDir, ".moltzap"); - const configPath = path.join(configHome, PROFILE_CONFIG_FILE_NAME); - - yield* writeMoltZapProfileConfig(configHome, testProfile()); - - const [contents, info] = yield* Effect.all([ - fileSystem.readFileString(configPath), - fileSystem.stat(configPath), - ]); - expect(contents).toBe(serializeMoltZapProfileConfig(testProfile())); - expect(info.mode & PROFILE_FILE_PERMISSION_MASK).toBe(PROFILE_FILE_MODE); - }), - ); -} - -function testProfile() { - return { - agentName: TEST_AGENT_NAME, - agentId: TEST_AGENT_ID, - apiKey: TEST_AGENT_KEY, - }; -} - -function writesNestedWorkspaceFile() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* seedWorkspaceFiles(path.join(workDir, "workspace"), [ - { - relativePath: WORKSPACE_FILE_PATH, - content: WORKSPACE_FILE_CONTENT, - }, - ]); - const written = yield* fileSystem.readFileString( - path.join(workDir, "workspace", WORKSPACE_FILE_PATH), - ); - expect(written).toBe(WORKSPACE_FILE_CONTENT); - }), - ); -} - -function rejectsEscapingWorkspaceFiles() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const escapedPath = path.join(workDir, "escaped.md"); - for (const relativePath of ["../escaped.md", escapedPath]) { - yield* seedWorkspaceFiles(path.join(workDir, "workspace"), [ - { relativePath, content: "escape" }, - ]).pipe(Effect.flip); - } - expect(yield* fileSystem.exists(escapedPath)).toBe(false); - }), - ); -} - -function resolvesOwnNodeModules() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const path = yield* Path.Path; - const channelPkg = path.join(workDir, CHANNEL_PACKAGE_DIR); - const depPkg = path.join(channelPkg, "node_modules", EFFECT_PACKAGE); - yield* seedPackage(channelPkg, { name: OPENCLAW_CHANNEL_PACKAGE }); - yield* seedPackage(depPkg, { name: EFFECT_PACKAGE, version: "3.21.0" }); - - const resolved = yield* resolveChannelDependency( - channelPkg, - EFFECT_PACKAGE, - ); - - yield* expectSamePath(resolved, depPkg); - expect(resolved).not.toContain(LEGACY_DIST_NODE_MODULES); - }), - ); -} - -function resolvesHoistedDependency() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const path = yield* Path.Path; - const channelPkg = path.join(workDir, "packages", CHANNEL_PACKAGE_DIR); - const hoistedDep = path.join(workDir, "node_modules", EFFECT_PACKAGE); - yield* seedPackage(channelPkg, { name: OPENCLAW_CHANNEL_PACKAGE }); - yield* seedPackage(hoistedDep, { - name: EFFECT_PACKAGE, - version: "3.21.0", - }); - - const resolved = yield* resolveChannelDependency( - channelPkg, - EFFECT_PACKAGE, - ); - - yield* expectSamePath(resolved, hoistedDep); - }), - ); -} - -function missingPackageJsonReturnsNull() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const channelPkg = path.join(workDir, CHANNEL_PACKAGE_DIR); - yield* fileSystem.makeDirectory(channelPkg, { recursive: true }); - - const resolved = yield* resolveChannelDependency( - channelPkg, - EFFECT_PACKAGE, - ); - - expect(resolved).toBeNull(); - }), - ); -} - -function missingDependencyReturnsNull() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const path = yield* Path.Path; - const channelPkg = path.join(workDir, CHANNEL_PACKAGE_DIR); - yield* seedPackage(channelPkg, { name: OPENCLAW_CHANNEL_PACKAGE }); - - const resolved = yield* resolveChannelDependency( - channelPkg, - NONEXISTENT_DEP_PACKAGE, - ); - - expect(resolved).toBeNull(); - }), - ); -} - -function resolvesPackageRoot() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const path = yield* Path.Path; - const channelPkg = path.join(workDir, CHANNEL_PACKAGE_DIR); - const depPkg = path.join(channelPkg, "node_modules", FANCY_DEP_PACKAGE); - yield* seedPackage(channelPkg, { name: OPENCLAW_CHANNEL_PACKAGE }); - yield* seedPackage(depPkg, { - name: FANCY_DEP_PACKAGE, - version: "1.0.0", - main: "dist/index.js", - }); - - const resolved = yield* resolveChannelDependency( - channelPkg, - FANCY_DEP_PACKAGE, - ); - - yield* expectSamePath(resolved, depPkg); - }), - ); -} - -function resolvesExportRestrictedScopedPackage() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const path = yield* Path.Path; - const channelPkg = path.join( - workDir, - "node_modules", - OPENCLAW_CHANNEL_PACKAGE, - ); - const clientPkg = path.join(workDir, "node_modules", CLIENT_PACKAGE); - yield* seedPackage(channelPkg, { name: OPENCLAW_CHANNEL_PACKAGE }); - yield* seedExportRestrictedPackage(clientPkg, CLIENT_PACKAGE); - - const resolved = yield* resolveChannelDependency( - channelPkg, - CLIENT_PACKAGE, - ); - - yield* expectSamePath(resolved, clientPkg); - }), - ); -} - -function resolvedRootsAvoidLegacyDistNodeModules() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const roots = yield* Effect.all([ - resolveDependencyInOwnNodeModules(), - resolveDependencyInHoistedNodeModules(), - ]); - - for (const root of roots) { - expect(root).not.toBeNull(); - expect(root).not.toContain(LEGACY_DIST_NODE_MODULES); - } - }), - ); -} - -function resolveDependencyInOwnNodeModules() { - return Effect.gen(function* () { - const path = yield* Path.Path; - const channelPkg = path.join(workDir, "own", CHANNEL_PACKAGE_DIR); - const depPkg = path.join(channelPkg, "node_modules", EFFECT_PACKAGE); - yield* seedPackage(channelPkg, { name: OPENCLAW_CHANNEL_PACKAGE }); - yield* seedPackage(depPkg, { name: EFFECT_PACKAGE, version: "3.21.0" }); - return yield* resolveChannelDependency(channelPkg, EFFECT_PACKAGE); - }); -} - -function resolveDependencyInHoistedNodeModules() { - return Effect.gen(function* () { - const path = yield* Path.Path; - const root = path.join(workDir, "hoisted"); - const channelPkg = path.join(root, "packages", CHANNEL_PACKAGE_DIR); - const depPkg = path.join(root, "node_modules", EFFECT_PACKAGE); - yield* seedPackage(channelPkg, { name: OPENCLAW_CHANNEL_PACKAGE }); - yield* seedPackage(depPkg, { name: EFFECT_PACKAGE, version: "3.21.0" }); - return yield* resolveChannelDependency(channelPkg, EFFECT_PACKAGE); - }); -} - -function resolvesPnpmVirtualStoreDependency() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const consumerNodeModules = path.join(workDir, "pnpm", "node_modules"); - const virtualNodeModules = path.join( - consumerNodeModules, - ".pnpm", - "@moltzap+openclaw-channel@1.0.0", - "node_modules", - ); - const realChannelPackage = path.join( - virtualNodeModules, - OPENCLAW_CHANNEL_PACKAGE, - ); - const linkedChannelPackage = path.join( - consumerNodeModules, - OPENCLAW_CHANNEL_PACKAGE, - ); - const dependencyPackage = path.join( - virtualNodeModules, - FANCY_DEP_PACKAGE, - ); - - yield* seedPackage(realChannelPackage, { - name: OPENCLAW_CHANNEL_PACKAGE, - }); - yield* seedPackage(dependencyPackage, { name: FANCY_DEP_PACKAGE }); - yield* makeDirectory(path.dirname(linkedChannelPackage)); - yield* fileSystem.symlink(realChannelPackage, linkedChannelPackage); - - const resolved = yield* resolveChannelDependency( - linkedChannelPackage, - FANCY_DEP_PACKAGE, - ); - - yield* expectSamePath(resolved, dependencyPackage); - }), - ); -} - -function symlinksWorkspaceDependency() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const fixture = yield* createWorkspaceDependencyFixture(workDir); - - const effectResolved = yield* resolveChannelDependency( - fixture.channelPkg, - EFFECT_PACKAGE, - ); - yield* expectSamePath(effectResolved, fixture.channelDepDir); - - const extDir = yield* installPlugin(fixture); - yield* assertEffectSymlinkTarget(extDir, fixture.channelDepDir); - }), - ); -} - -function symlinksNpmDependencies() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const fixture = yield* createNpmDependencyFixture(workDir); - - const extDir = yield* installPlugin(fixture); - - yield* Effect.forEach( - fixture.dependencies, - (dependency) => - assertPackageSymlinkTarget( - extDir, - dependency.packageName, - dependency.packageDir, - ), - { concurrency: 1, discard: true }, - ); - yield* loadCopiedChannelEntry(extDir); - }), - ); -} - -function missingDeclaredDependencyFails() { - return runWithNodeFileSystem( - Effect.gen(function* () { - const path = yield* Path.Path; - const fileSystem = yield* FileSystem.FileSystem; - const missingPackage = "missing-dependency"; - const channelPkg = path.join(workDir, "missing", CHANNEL_PACKAGE_DIR); - const channelDist = path.join(channelPkg, "dist"); - const stateDir = path.join(workDir, ".missing-state"); - yield* seedChannelPackage(channelPkg, [missingPackage]); - yield* seedChannelEntry(channelDist, []); - yield* makeDirectory(stateDir); - - const error = yield* installChannelPlugin({ - stateDir, - channelDistDir: channelDist, - extName: CHANNEL_EXTENSION_NAME, - }).pipe(Effect.flip); - - expect(error.message).toContain(missingPackage); - const missingLink = path.join( - stateDir, - "extensions", - CHANNEL_EXTENSION_NAME, - "node_modules", - missingPackage, - ); - const linkTarget = yield* fileSystem - .readLink(missingLink) - .pipe(Effect.option); - expect(Option.isNone(linkTarget)).toBe(true); - }), - ); -} - -function createWorkspaceDependencyFixture(root: string) { - return Effect.gen(function* () { - const path = yield* Path.Path; - const channelPkg = path.join(root, "packages", CHANNEL_PACKAGE_DIR); - const channelDist = path.join(channelPkg, "dist"); - const channelDepDir = path.join(channelPkg, "node_modules", EFFECT_PACKAGE); - const stateDir = path.join(root, ".state"); - - yield* seedChannelPackage(channelPkg, [EFFECT_PACKAGE]); - yield* seedChannelEntry(channelDist, []); - yield* seedLoadableExportRestrictedPackage(channelDepDir, EFFECT_PACKAGE); - yield* makeDirectory(stateDir); - - return { channelPkg, channelDist, channelDepDir, stateDir }; - }); -} - -function createNpmDependencyFixture(root: string) { - return Effect.gen(function* () { - const path = yield* Path.Path; - const consumerRoot = path.join(root, "consumer"); - const channelPkg = path.join( - consumerRoot, - "node_modules", - OPENCLAW_CHANNEL_PACKAGE, - ); - const channelDist = path.join(channelPkg, "dist"); - const stateDir = path.join(root, ".npm-state"); - const dependencies = CHANNEL_DEPENDENCIES.map((packageName) => ({ - packageName, - packageDir: path.join(consumerRoot, "node_modules", packageName), - })); - - yield* seedChannelPackage(channelPkg, CHANNEL_DEPENDENCIES); - yield* seedChannelEntry(channelDist, CHANNEL_DEPENDENCIES); - yield* Effect.forEach( - dependencies, - (dependency) => - seedLoadableExportRestrictedPackage( - dependency.packageDir, - dependency.packageName, - ), - { concurrency: 1, discard: true }, - ); - yield* makeDirectory(stateDir); - - return { - channelDist, - dependencies, - stateDir, - }; - }); -} - -function installPlugin(fixture: { - readonly stateDir: string; - readonly channelDist: string; -}) { - return installChannelPlugin({ - stateDir: fixture.stateDir, - channelDistDir: fixture.channelDist, - extName: CHANNEL_EXTENSION_NAME, - }); -} - -function assertEffectSymlinkTarget(extDir: string, expectedTarget: string) { - return assertPackageSymlinkTarget(extDir, EFFECT_PACKAGE, expectedTarget); -} - -function assertPackageSymlinkTarget( - extDir: string, - packageName: string, - expectedTarget: string, -) { - return Effect.gen(function* () { - const path = yield* Path.Path; - const symlinkPath = path.join(extDir, "node_modules", packageName); - const linkTarget = yield* readLink(symlinkPath); - yield* expectSamePath(linkTarget, expectedTarget); - }); -} - -function expectSamePath(actual: string | null, expected: string) { - return Effect.gen(function* () { - expect(actual).not.toBeNull(); - const [actualReal, expectedReal] = yield* Effect.all([ - realPath(actual ?? expected), - realPath(expected), - ]); - expect(actualReal).toBe(expectedReal); - }); -} - -function loadCopiedChannelEntry(extDir: string) { - return Effect.gen(function* () { - const path = yield* Path.Path; - const entryUrl = pathToFileURL( - path.join(extDir, "dist", CHANNEL_ENTRY_FILE), - ).href; - yield* Effect.tryPromise({ - try: () => import(entryUrl), - catch: (cause) => - cause instanceof Error ? cause : new Error(String(cause)), - }).pipe(Effect.asVoid); - }); -} - -function seedChannelPackage( - channelPkg: string, - dependencies: readonly string[], -) { - return seedPackage(channelPkg, { - name: OPENCLAW_CHANNEL_PACKAGE, - type: "module", - dependencies: Object.fromEntries( - dependencies.map((packageName) => [packageName, "1.0.0"]), - ), - }); -} - -function seedChannelEntry( - channelDist: string, - dependencies: readonly string[], -) { - const source = [ - ...dependencies.map( - (packageName) => `import ${JSON.stringify(packageName)};`, - ), - "export const loaded = true;", - "", - ].join("\n"); - return Effect.gen(function* () { - const path = yield* Path.Path; - yield* makeDirectory(channelDist); - yield* writeTextFile(path.join(channelDist, CHANNEL_ENTRY_FILE), source); - }); -} - -function seedPackage( - pkgDir: string, - pkgJson: Readonly>, -) { - return Effect.gen(function* () { - const path = yield* Path.Path; - yield* makeDirectory(pkgDir); - yield* writeTextFile( - path.join(pkgDir, "package.json"), - JSON.stringify(pkgJson, null, 2), - ); - }); -} - -function seedExportRestrictedPackage(pkgDir: string, packageName: string) { - return seedPackage(pkgDir, { - name: packageName, - exports: { - ".": { - types: "./dist/index.d.ts", - import: "./dist/index.js", - }, - }, - }); -} - -function seedLoadableExportRestrictedPackage( - pkgDir: string, - packageName: string, -) { - return Effect.gen(function* () { - const path = yield* Path.Path; - yield* seedPackage(pkgDir, { - name: packageName, - type: "module", - exports: { ".": "./index.js" }, - }); - yield* writeTextFile( - path.join(pkgDir, "index.js"), - "export const loaded = true;\n", - ); - }); -} - -function makeDirectory(directory: string) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.makeDirectory(directory, { recursive: true }), - ), - ); -} - -function writeTextFile(filePath: string, content: string) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => - fileSystem.writeFileString(filePath, content), - ), - ); -} - -function readLink(filePath: string) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => fileSystem.readLink(filePath)), - ); -} - -function realPath(filePath: string) { - return FileSystem.FileSystem.pipe( - Effect.flatMap((fileSystem) => fileSystem.realPath(filePath)), - ); -} - -function runWithNodeFileSystem( - effect: Effect.Effect, -) { - return Effect.runPromise(effect.pipe(Effect.provide(NodeContext.layer))); -} diff --git a/packages/simulator/src/runtime/workspace.ts b/packages/simulator/src/runtime/workspace.ts deleted file mode 100644 index de92c7f07..000000000 --- a/packages/simulator/src/runtime/workspace.ts +++ /dev/null @@ -1,436 +0,0 @@ -/** @file Channel installation, credentials, and agent workspace material. */ - -import { FileSystem, Path } from "@effect/platform"; -import type { PlatformError } from "@effect/platform/Error"; -import { Cause, Data, Effect, Redacted, Schema } from "effect"; -import type { AgentId, AgentKey, AgentName } from "@moltzap/protocol/identity"; -import { resolvePackageRoot } from "./packages.js"; - -const PROFILE_CONFIG_INDENT_SPACES = 2; -const PROFILE_CONFIG_FILE_MODE = 0o600; -const PROFILE_CONFIG_FILE_NAME = "config.json"; - -/** Profile selector shared by isolated runtime state directories. */ -export const SIMULATOR_PROFILE_NAME = "simulator-agent"; - -const channelPackageManifest = Schema.parseJson( - Schema.Struct({ - dependencies: Schema.optionalWith( - Schema.Record({ key: Schema.String, value: Schema.String }), - { default: () => ({}) }, - ), - }), -); - -/** - * Serializes the per-agent MoltZap profile selected by external runtimes. - * @param profile Value supplied to the operation. - * @param profile.agentName Value supplied to the operation. - * @param profile.agentId Value supplied to the operation. - * @param profile.apiKey Value supplied to the operation. - * @returns The serialize molt zap profile config result. - */ -export function serializeMoltZapProfileConfig(profile: { - readonly agentName: AgentName; - readonly agentId: AgentId; - readonly apiKey: AgentKey; -}): string { - return JSON.stringify( - { - profiles: { - [SIMULATOR_PROFILE_NAME]: { - agentId: profile.agentId, - apiKey: Redacted.value(profile.apiKey), - agentName: profile.agentName, - }, - }, - }, - null, - PROFILE_CONFIG_INDENT_SPACES, - ); -} - -/** - * Writes the credentials used by a runtime's isolated channel process. - * @param configHome Value supplied to the operation. - * @param profile Value supplied to the operation. - * @param profile.agentName Value supplied to the operation. - * @param profile.agentId Value supplied to the operation. - * @param profile.apiKey Value supplied to the operation. - * @returns The write molt zap profile config result. - */ -export function writeMoltZapProfileConfig( - configHome: string, - profile: { - readonly agentName: AgentName; - readonly agentId: AgentId; - readonly apiKey: AgentKey; - }, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const configPath = path.join(configHome, PROFILE_CONFIG_FILE_NAME); - - yield* fileSystem.makeDirectory(configHome, { recursive: true }); - yield* fileSystem.writeFileString( - configPath, - serializeMoltZapProfileConfig(profile), - { mode: PROFILE_CONFIG_FILE_MODE }, - ); - yield* fileSystem.chmod(configPath, PROFILE_CONFIG_FILE_MODE); - }).pipe(Effect.withSpan("writeMoltZapProfileConfig")); -} - -class ChannelPluginInstallError extends Data.TaggedError( - "ChannelPluginInstallError", -)<{ - readonly message: string; - readonly cause?: unknown; -}> {} - -/** Describes install channel plugin opts. */ -export interface InstallChannelPluginOpts { - readonly stateDir: string; - readonly channelDistDir: string; - /** Subdirectory under `<stateDir>/extensions/`. */ - readonly extName: string; - - /** - * Extra files copied verbatim from the channel package root into the - * installed extension dir. Each entry is a basename (e.g. - * `openclaw.plugin.json`); silently skipped if not present. - */ - readonly extraPackageFiles?: readonly string[]; -} - -interface CopyDirectoryContext { - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly root: string; -} - -interface LinkChannelDependenciesContext { - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly channelPackageDir: string; - readonly pluginNodeModules: string; -} - -/** - * Install a moltzap channel package into a per-agent state dir. - * - * Standard layout produced: - * <stateDir>/extensions/<extName>/dist/... ← copied from channelDistDir - * <stateDir>/extensions/<extName>/package.json ← copied from channel pkg root - * <stateDir>/extensions/<extName>/node_modules/... → each declared channel dependency - * <stateDir>/extensions/<extName>/<extraPackageFiles[i]> (when present). - * - * Returns the absolute path to the installed extension dir. - * @param opts Value supplied to the operation. - * @returns The install channel plugin result. - */ -export function installChannelPlugin( - opts: InstallChannelPluginOpts, -): Effect.Effect< - string, - ChannelPluginInstallError | PlatformError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const extDir = path.join(opts.stateDir, "extensions", opts.extName); - const channelPackageDir = path.dirname(opts.channelDistDir); - - yield* fileSystem.makeDirectory(extDir, { recursive: true }); - yield* copyDistDirectory( - fileSystem, - path, - opts.channelDistDir, - path.join(extDir, "dist"), - ); - yield* copyPackageFiles({ - fileSystem, - path, - channelPackageDir, - extDir, - extraPackageFiles: opts.extraPackageFiles ?? [], - }); - const pluginNm = path.join(extDir, "node_modules"); - yield* linkChannelDependencies({ - fileSystem, - path, - channelPackageDir, - pluginNodeModules: pluginNm, - }); - - return extDir; - }).pipe(Effect.withSpan("installChannelPlugin")); -} - -function copyPackageFiles(input: { - readonly fileSystem: FileSystem.FileSystem; - readonly path: Path.Path; - readonly channelPackageDir: string; - readonly extDir: string; - readonly extraPackageFiles: readonly string[]; -}): Effect.Effect { - return Effect.gen(function* () { - const packageJsonPath = input.path.join( - input.channelPackageDir, - "package.json", - ); - yield* copyFileIfExists( - input.fileSystem, - packageJsonPath, - input.path.join(input.extDir, "package.json"), - ); - for (const extra of input.extraPackageFiles) { - const src = input.path.join(input.channelPackageDir, extra); - yield* copyFileIfExists( - input.fileSystem, - src, - input.path.join(input.extDir, extra), - ); - } - }); -} - -function linkChannelDependencies( - context: LinkChannelDependenciesContext, -): Effect.Effect< - void, - ChannelPluginInstallError | PlatformError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const dependencyNames = yield* readChannelDependencyNames(context); - for (const packageName of dependencyNames) { - yield* linkChannelDependency(context, packageName); - } - }); -} - -function readChannelDependencyNames( - context: LinkChannelDependenciesContext, -): Effect.Effect { - return Effect.gen(function* () { - const manifestPath = context.path.join( - context.channelPackageDir, - "package.json", - ); - const source = yield* context.fileSystem.readFileString(manifestPath); - const manifest = yield* Schema.decodeUnknown(channelPackageManifest)( - source, - ).pipe( - Effect.catchTag("ParseError", (cause) => - Effect.fail( - new ChannelPluginInstallError({ - cause, - message: `channel-plugin-install: invalid package manifest at ${manifestPath}`, - }), - ), - ), - ); - return Object.keys(manifest.dependencies); - }); -} - -function linkChannelDependency( - context: LinkChannelDependenciesContext, - packageName: string, -): Effect.Effect< - void, - ChannelPluginInstallError | PlatformError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - const resolved = yield* resolveChannelDependency( - context.channelPackageDir, - packageName, - ); - if (resolved === null) { - return yield* new ChannelPluginInstallError({ - message: `channel-plugin-install: cannot resolve declared dependency ${packageName} from ${context.channelPackageDir}`, - }); - } - const linkTarget = context.path.join( - context.pluginNodeModules, - packageName, - ); - yield* context.fileSystem.makeDirectory(context.path.dirname(linkTarget), { - recursive: true, - }); - yield* context.fileSystem.symlink(resolved, linkTarget); - }); -} - -/** Describes workspace file. */ -export interface WorkspaceFile { - readonly relativePath: string; - readonly content: string; -} - -/** - * Write caller-supplied files below an isolated agent workspace root. - * @param workspaceDir Value supplied to the operation. - * @param workspaceFiles Value supplied to the operation. - * @returns The seed workspace files result. - */ -export function seedWorkspaceFiles( - workspaceDir: string, - workspaceFiles?: readonly WorkspaceFile[], -): Effect.Effect< - void, - PlatformError | ChannelPluginInstallError, - FileSystem.FileSystem | Path.Path -> { - return Effect.gen(function* () { - if (workspaceFiles === undefined) { - return; - } - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - yield* fileSystem.makeDirectory(workspaceDir, { recursive: true }); - for (const file of workspaceFiles) { - const destination = resolveWorkspaceFileDestination( - path, - workspaceDir, - file.relativePath, - ); - if (destination === null) { - return yield* new ChannelPluginInstallError({ - message: `workspace path must stay below its agent root: ${file.relativePath}`, - }); - } - yield* fileSystem.makeDirectory(path.dirname(destination), { - recursive: true, - }); - yield* fileSystem.writeFileString(destination, file.content); - } - }).pipe(Effect.withSpan("seedWorkspaceFiles")); -} - -function resolveWorkspaceFileDestination( - path: Path.Path, - workspaceRoot: string, - relativePath: string, -): string | null { - if (relativePath.length === 0 || path.isAbsolute(relativePath)) { - return null; - } - const root = path.resolve(workspaceRoot); - const destination = path.resolve(root, relativePath); - const relativeDestination = path.relative(root, destination); - if ( - relativeDestination.length === 0 || - relativeDestination === ".." || - relativeDestination.startsWith(`..${path.sep}`) || - path.isAbsolute(relativeDestination) - ) { - return null; - } - return destination; -} - -/** - * Resolves a runtime dependency imported by the channel package. - * @param channelPackageDir Value supplied to the operation. - * @param packageName Value supplied to the operation. - * @returns The resolve channel dependency result. - */ -export function resolveChannelDependency( - channelPackageDir: string, - packageName: string, -): Effect.Effect { - return Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const anchor = path.join(channelPackageDir, "package.json"); - const anchorExists = yield* fileSystem - .exists(anchor) - .pipe(Effect.orElseSucceed(() => false)); - if (!anchorExists) { - return null; - } - - const resolutionAnchor = yield* fileSystem - .realPath(anchor) - .pipe( - Effect.catchAll((cause) => - Effect.logWarning( - "failed to resolve real channel package path; using linked path", - cause, - ).pipe(Effect.as(anchor)), - ), - ); - return yield* Effect.try({ - try: () => resolvePackageRoot(resolutionAnchor, packageName), - catch: (cause) => new Cause.UnknownException(cause), - }).pipe( - Effect.catchAll((cause) => - Effect.logWarning("failed to resolve channel dependency", cause).pipe( - Effect.as(null), - ), - ), - ); - }).pipe(Effect.withSpan("resolveChannelDependency")); -} - -function copyFileIfExists( - fileSystem: FileSystem.FileSystem, - src: string, - dest: string, -): Effect.Effect { - return Effect.gen(function* () { - const exists = yield* fileSystem.exists(src); - if (!exists) { - return; - } - yield* fileSystem.copyFile(src, dest); - }); -} - -function copyDistDirectory( - fileSystem: FileSystem.FileSystem, - path: Path.Path, - src: string, - dest: string, -): Effect.Effect { - return copyFilteredDirectory({ fileSystem, path, root: src }, src, dest); -} - -function copyFilteredDirectory( - context: CopyDirectoryContext, - src: string, - dest: string, -): Effect.Effect { - return Effect.gen(function* () { - const rel = context.path.relative(context.root, src); - if (rel.startsWith("node_modules") || rel.startsWith("src")) { - return; - } - - const info = yield* context.fileSystem.stat(src); - if (info.type === "Directory") { - yield* context.fileSystem.makeDirectory(dest, { recursive: true }); - const entries = yield* context.fileSystem.readDirectory(src); - for (const entry of entries) { - yield* copyFilteredDirectory( - context, - context.path.join(src, entry), - context.path.join(dest, entry), - ); - } - return; - } - - if (info.type === "File") { - yield* context.fileSystem.makeDirectory(context.path.dirname(dest), { - recursive: true, - }); - yield* context.fileSystem.copyFile(src, dest); - } - }); -} diff --git a/packages/simulator/src/test-utils/index.ts b/packages/simulator/src/test-utils/index.ts new file mode 100644 index 000000000..81bc54568 --- /dev/null +++ b/packages/simulator/src/test-utils/index.ts @@ -0,0 +1,20 @@ +/** @file Shared simulator test utility exports. */ + +// safer-arch-ignore no-public-test-helper-leak: ./test-utils is the package's explicitly allowed test-only subpath, and this index is its curated facade. + +/** Re-exports the public API from `./kernel-harness.js`. */ +export { + OBSERVED_EXIT_CODE, + Observation, + PRIMARY_AGENT_NAME, + REF, + ROUTER_URL, + assertDefaultProvenance, + configuration, + fakeRouterProvider, + kernelHarness, + memoryStorage, + observeCompletions, + ongoingRoster, + testRuntimeConfiguration, +} from "./kernel-harness.js"; diff --git a/packages/simulator/src/test-utils/kernel-harness.ts b/packages/simulator/src/test-utils/kernel-harness.ts new file mode 100644 index 000000000..fac38cca3 --- /dev/null +++ b/packages/simulator/src/test-utils/kernel-harness.ts @@ -0,0 +1,363 @@ +/* eslint-disable jsdoc/require-jsdoc, agent-code-guard/no-exported-brand-constructor, agent-code-guard/require-span-on-exported-effect -- Test fixtures exported only to the package's own regressions; each is named for the exact value it builds and has no consumer outside this package's own test files. */ + +import { assert } from "@effect/vitest"; +import type { ConversationId } from "@moltzap/protocol/conversation"; +import type { AgentId } from "@moltzap/protocol/identity"; +import { serverBaseUrlSchema } from "@moltzap/protocol/network"; +import { + conversationId, + agentId as protocolAgentId, + messageId, + redactedAgentKey, +} from "@moltzap/protocol/testing"; +import { + DateTime, + Deferred, + Effect, + Mailbox, + Ref, + Schema, + type Scope, +} from "effect"; +import { EventCatalog } from "../events/catalog.js"; +import { makeDefinitionEventServices } from "../run/events.js"; +import { + LedgerCompletion, + ledgerDigest, + LedgerManifest, + ledgerRef, +} from "../ledger/schema.js"; +import { openLedger } from "../ledger/read.js"; +import { + LedgerStorageError, + type LedgerArtifact, + type LedgerStorageService, +} from "../ledger/storage.js"; +import { + type RouterStopped, + makeAgentHandle, + makeParticipantHandle, + makeRouterStopReport, + type AttachedEndpoint, + type EndpointTransport, + type MessageParts, + type ReceivedMessage, + type Router, + type RouterProviderService, +} from "../network.js"; +import { runSociety } from "../run/execute.js"; +import type { AgentRuntimeLike } from "../agents/agent.js"; +import { defineFakeRuntime, makeFakeCluster } from "../cluster/fake.js"; +import { Cluster } from "../cluster/cluster.js"; +import { makeAgentRosterBinding, type AgentRoster } from "../agents/roster.js"; + +export class Observation extends Schema.TaggedClass()( + "acme.kernel-observation/v1", + { value: Schema.String }, +) {} + +const customerEvents = EventCatalog.make(Observation); +const DEFINITION_ID = "acme.kernel-test/v1"; +const eventServices = makeDefinitionEventServices( + DEFINITION_ID, + customerEvents, +); +const rosterBinding = makeAgentRosterBinding(DEFINITION_ID); +const runKernel = < + const Definitions extends Readonly>, + A, + E, + R, +>( + roster: AgentRoster, + program: Effect.Effect, +) => + runSociety({ + definitionId: DEFINITION_ID, + eventServices, + roster, + program, + }).pipe(Effect.provideService(Cluster, makeFakeCluster())); +export const kernelHarness = Object.freeze({ + agents: rosterBinding.agents, + ledger: eventServices.ledger, + events: eventServices.events, + run: runKernel, + openLedger: (ref: typeof ledgerRef.Type) => + openLedger(eventServices.catalog, ref, DEFINITION_ID), +}); +const DIGEST = Schema.decodeSync(ledgerDigest)("a".repeat(64)); +export const REF = Schema.decodeSync(ledgerRef)("kernel-test-ledger"); +export const ROUTER_URL = Schema.decodeSync(serverBaseUrlSchema)( + "http://127.0.0.1:43100", +); +export const OBSERVED_EXIT_CODE = 7; +export const PRIMARY_AGENT_NAME = "alice"; +export const testRuntimeConfiguration = Schema.Struct({ + kind: Schema.String, +}); + +export function configuration(kind: string) { + return { + schema: testRuntimeConfiguration, + value: { kind }, + }; +} + +function agentId(suffix: number) { + return protocolAgentId( + `00000000-0000-4000-8000-${String(suffix).padStart(12, "0")}`, + ); +} + +function agentKey(suffix: number) { + return redactedAgentKey( + `moltzap_agent_${String(suffix).padStart(16, "0")}_${String(suffix).padStart(48, "0")}`, + ); +} + +function completion(manifest: LedgerManifest, count: number): LedgerCompletion { + return LedgerCompletion.make({ + ledgerFormatVersion: 1, + runId: manifest.runId, + recordCount: count, + artifacts: { manifest: DIGEST, records: DIGEST }, + }); +} + +function compareText(left: string, right: string): number { + return left.localeCompare(right); +} + +export function assertDefaultProvenance(manifest: LedgerManifest): void { + assert.deepStrictEqual(manifest.provenance, { + agents: [ + { + name: "alice", + runtime: "effect", + configuration: { kind: "in-process" }, + }, + { + name: "bob", + runtime: "process", + configuration: { kind: "external-process" }, + }, + ], + }); +} + +export function memoryStorage(failOnEventTag?: string): LedgerStorageService { + const files = new Map(); + return { + allocate: (input) => { + const manifest = LedgerManifest.make({ + ledgerFormatVersion: 1, + definitionId: input.definitionId, + runId: "kernel-test-run", + catalogTags: [...input.catalogTags].sort(compareText), + createdAt: DateTime.unsafeMake(0), + provenance: input.provenance, + metadata: input.metadata, + }); + const records: string[] = []; + files.set( + "manifest", + JSON.stringify(Schema.encodeSync(LedgerManifest)(manifest)), + ); + files.set("records", ""); + return Effect.succeed({ + ref: REF, + runId: manifest.runId, + manifest, + append: (record: string) => + failOnEventTag !== undefined && record.includes(failOnEventTag) + ? Effect.fail( + LedgerStorageError.make({ + operation: "append", + detail: `failed ${failOnEventTag}`, + }), + ) + : Effect.sync(() => { + records.push(record); + files.set("records", `${records.join("\n")}\n`); + }), + complete: (count: number) => { + const done = completion(manifest, count); + files.set( + "completion", + JSON.stringify(Schema.encodeSync(LedgerCompletion)(done)), + ); + return Effect.succeed(done); + }, + }); + }, + read: (...[, artifact]) => Effect.succeed(files.get(artifact) ?? ""), + digest: () => Effect.succeed(DIGEST), + }; +} + +function increment(current: number): number { + return current + 1; +} + +export function observeCompletions( + storage: LedgerStorageService, + completions: Ref.Ref, +): LedgerStorageService { + return { + ...storage, + allocate: (input) => + storage.allocate(input).pipe( + Effect.map((allocation) => ({ + ...allocation, + complete: (count: number) => + allocation + .complete(count) + .pipe(Effect.zipLeft(Ref.update(completions, increment))), + })), + ), + }; +} + +function hubMessage( + endpointId: AgentId, + currentConversationId: ConversationId, + parts: MessageParts, + sequence: number, +) { + return { + id: messageId( + `00000000-0000-4000-8000-${String(400 + sequence).padStart(12, "0")}`, + ), + conversationId: currentConversationId, + senderId: endpointId, + parts, + createdAt: "2026-07-28T00:00:00.000Z", + }; +} + +interface Counter { + value: number; +} + +// In-memory loopback hub: every endpoint send fans out into every other +// attachment's received stream, so kernel tests observe real deliveries. +interface FakeHubState { + readonly inboxes: Map>; + readonly endpoints: Counter; + readonly messages: Counter; + readonly committedSends?: Ref.Ref; +} + +function hubSend( + hub: FakeHubState, + endpointId: AgentId, +): EndpointTransport["send"] { + return (currentConversationId, parts) => + Effect.gen(function* () { + if (hub.committedSends !== undefined) { + yield* Ref.update(hub.committedSends, increment); + } + hub.messages.value += 1; + const message = hubMessage( + endpointId, + currentConversationId, + parts, + hub.messages.value, + ); + yield* Effect.forEach( + hub.inboxes, + ([id, inbox]) => + id === endpointId ? Effect.void : inbox.offer({ message }), + { concurrency: 1, discard: true }, + ); + return message; + }); +} + +function hubAttachment( + name: Name, + endpointId: AgentId, + mailbox: Mailbox.Mailbox, + send: EndpointTransport["send"], +): AttachedEndpoint { + return { + participant: makeParticipantHandle(name, endpointId), + transport: { + received: Mailbox.toStream(mailbox), + openConversation: () => + Effect.succeed({ + conversationId: conversationId( + "00000000-0000-4000-8000-000000000102", + ), + }), + send, + }, + }; +} + +function hubAttach( + hub: FakeHubState, + name: Name, +): Effect.Effect, never, Scope.Scope> { + return Effect.gen(function* () { + hub.endpoints.value += 1; + const endpointId = agentId(100 + hub.endpoints.value); + const mailbox = yield* Mailbox.make(); + hub.inboxes.set(endpointId, mailbox); + yield* Effect.addFinalizer(() => + Effect.sync(() => hub.inboxes.delete(endpointId)), + ); + return hubAttachment(name, endpointId, mailbox, hubSend(hub, endpointId)); + }); +} + +export function fakeRouterProvider( + committedSends?: Ref.Ref, +): RouterProviderService { + return { + acquire: Effect.gen(function* () { + const stopped = yield* Deferred.make(); + const hub: FakeHubState = { + inboxes: new Map(), + endpoints: { value: 0 }, + messages: { value: 0 }, + committedSends, + }; + let nextIdentity = 0; + const router: Router = { + address: ROUTER_URL, + stopped: Deferred.await(stopped), + attachAgent: (name) => + Effect.sync(() => { + nextIdentity += 1; + return { + agent: makeAgentHandle(name, agentId(nextIdentity)), + key: agentKey(nextIdentity), + routerUrl: ROUTER_URL, + }; + }), + attachEndpoint: (name) => hubAttach(hub, name), + }; + yield* Effect.addFinalizer(() => + Deferred.succeed(stopped, makeRouterStopReport([])).pipe(Effect.asVoid), + ); + return router; + }), + }; +} + +const ongoingRuntime = defineFakeRuntime({ + name: "ongoing", + configuration: configuration("ongoing"), + acquire: () => + Effect.succeed({ gateway: undefined, termination: Effect.never }), +}); + +export const ongoingRoster = kernelHarness.agents({ + alice: ongoingRuntime, +}); + +// @agent-code-guard/regression-only: controlled scopes and deferred termination expose exact lifecycle evidence and cancellation order + +/* eslint-enable jsdoc/require-jsdoc, agent-code-guard/no-exported-brand-constructor, agent-code-guard/require-span-on-exported-effect -- Restore the project default after the shared fixtures. */ diff --git a/packages/simulator/vitest.cluster.config.mjs b/packages/simulator/vitest.cluster.config.mjs new file mode 100644 index 000000000..1fa08d215 --- /dev/null +++ b/packages/simulator/vitest.cluster.config.mjs @@ -0,0 +1,21 @@ +import { defineConfig } from "vitest/config"; +import { workspaceSourceAliases } from "../../vitest.workspace-aliases.js"; + +// Opt-in suites that assert against a live local cluster. They are not part of +// the default test target: each one creates real Kubernetes objects, kills real +// processes, and takes minutes rather than milliseconds. +export default defineConfig({ + resolve: { + alias: workspaceSourceAliases, + }, + test: { + include: ["src/**/*.cluster.test.ts"], + // One run's controller Job, its reclamation, and the Kubernetes deletion + // that follows are all measured in minutes. + testTimeout: 900_000, + hookTimeout: 900_000, + // One cluster, one Temporal task queue: concurrent suites would observe + // each other's namespaces. + fileParallelism: false, + }, +}); diff --git a/packages/simulator/vitest.config.mjs b/packages/simulator/vitest.config.mjs index fd9daef14..df3fa3774 100644 --- a/packages/simulator/vitest.config.mjs +++ b/packages/simulator/vitest.config.mjs @@ -7,6 +7,21 @@ export default defineConfig({ }, test: { include: ["src/**/*.test.ts"], - exclude: ["src/**/*.integration.test.ts"], + // Cluster suites need a live cluster; `vitest.cluster.config.mjs` runs them. + exclude: ["src/**/*.integration.test.ts", "src/**/*.cluster.test.ts"], + coverage: { + provider: "v8", + // text-summary for a human reading the run; json-summary so a refactor can + // be compared against a recorded baseline instead of a remembered one. + reporter: ["text-summary", "json-summary"], + // Covered by the root .gitignore `coverage` rule. + reportsDirectory: "coverage", + // The denominator is every source file, not only the ones a test happens to + // import: a module that loses its last branch must not look the same as a + // module that never had one. Workspace aliases resolve sibling packages' + // sources, so the glob is anchored to this package. + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/*.types-check.ts"], + }, }, }); diff --git a/packages/simulator/vitest.integration.config.mjs b/packages/simulator/vitest.integration.config.mjs deleted file mode 100644 index 7a13015e8..000000000 --- a/packages/simulator/vitest.integration.config.mjs +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "vitest/config"; -import { workspaceSourceAliases } from "../../vitest.workspace-aliases.js"; - -const INSTALL_TEST_TIMEOUT_MS = 600_000; - -export default defineConfig({ - resolve: { - alias: workspaceSourceAliases, - }, - test: { - include: ["src/**/*.integration.test.ts"], - fileParallelism: false, - testTimeout: INSTALL_TEST_TIMEOUT_MS, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9236c03e5..2435fddec 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 + '@vitest/coverage-v8': + specifier: ^3.2.4 + version: 3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) eslint: specifier: ^9 version: 9.39.4(jiti@1.21.7) @@ -46,7 +49,7 @@ importers: version: 4.2.749(@radix-ui/react-popover@1.1.15(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.3))(react@19.2.3))(@types/node@25.5.0)(@types/react@19.2.14)(@typescript/typescript6@6.0.2)(react-dom@19.2.6(react@19.2.3)) nx: specifier: ^22.7.5 - version: 22.7.5 + version: 22.7.5(@swc/core@1.15.47(@swc/helpers@0.5.21)) oxfmt: specifier: ^0.7.0 version: 0.7.0 @@ -107,7 +110,7 @@ importers: devDependencies: '@effect/vitest': specifier: ^0.30.0 - version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) '@moltzap/server-core': specifier: workspace:* version: link:../server @@ -128,13 +131,13 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^3.2.0 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) packages/evals: dependencies: '@arizeai/phoenix-client': specifier: ^7.1.1 - version: 7.1.1(@ai-sdk/otel@1.0.46(zod@4.4.3))(@opentelemetry/semantic-conventions@1.41.1)(ai@7.0.46(zod@4.4.3))(openai@6.39.1(ws@8.21.0)(zod@4.4.3))(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 7.1.1(@ai-sdk/otel@1.0.46(zod@4.4.3))(@opentelemetry/semantic-conventions@1.41.1)(ai@7.0.46(zod@4.4.3))(openai@6.39.1(ws@8.21.0)(zod@4.4.3))(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) '@effect/ai': specifier: ^0.37.0 version: 0.37.0(@effect/experimental@0.61.0(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.97.0(effect@3.22.0))(@effect/rpc@0.76.0(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(effect@3.22.0) @@ -168,7 +171,7 @@ importers: devDependencies: '@effect/vitest': specifier: ^0.30.0 - version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 @@ -180,7 +183,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^3.2.0 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) packages/nanoclaw-channel: dependencies: @@ -202,7 +205,7 @@ importers: version: 0.108.0(@effect/cluster@0.60.0(@effect/platform@0.97.0(effect@3.22.0))(@effect/rpc@0.76.0(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.52.0(@effect/experimental@0.61.0(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(@effect/workflow@0.19.0(@effect/experimental@0.61.0(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.97.0(effect@3.22.0))(@effect/rpc@0.76.0(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.97.0(effect@3.22.0))(@effect/rpc@0.76.0(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(@effect/sql@0.52.0(@effect/experimental@0.61.0(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(effect@3.22.0) '@effect/vitest': specifier: ^0.30.0 - version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 @@ -214,7 +217,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^3.2.0 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) packages/openclaw-channel: dependencies: @@ -239,7 +242,7 @@ importers: devDependencies: '@effect/vitest': specifier: ^0.30.0 - version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) '@testcontainers/postgresql': specifier: ^10.18.0 version: 10.28.0 @@ -266,7 +269,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^3.2.0 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) packages/protocol: dependencies: @@ -306,7 +309,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^3.0.0 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) packages/server: dependencies: @@ -370,7 +373,7 @@ importers: devDependencies: '@effect/vitest': specifier: ^0.30.0 - version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) + version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3)) '@types/pg': specifier: ^8.11.0 version: 8.20.0 @@ -397,7 +400,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^3.2.0 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3) packages/simulator: dependencies: @@ -413,6 +416,9 @@ importers: '@electric-sql/pglite': specifier: 0.4.4 version: 0.4.4 + '@kubernetes/client-node': + specifier: 1.4.0 + version: 1.4.0 '@moltzap/client': specifier: workspace:^ version: link:../client @@ -425,6 +431,18 @@ importers: '@moltzap/server-core': specifier: workspace:* version: link:../server + '@temporalio/activity': + specifier: 1.21.1 + version: 1.21.1 + '@temporalio/client': + specifier: 1.21.1 + version: 1.21.1 + '@temporalio/worker': + specifier: 1.21.1 + version: 1.21.1(@swc/helpers@0.5.21)(postcss@8.5.22) + '@temporalio/workflow': + specifier: 1.21.1 + version: 1.21.1 effect: specifier: ^3.22.0 version: 3.22.0 @@ -434,7 +452,7 @@ importers: devDependencies: '@effect/vitest': specifier: ^0.30.0 - version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) '@types/node': specifier: ^25.5.0 version: 25.5.0 @@ -449,7 +467,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: ^3.2.0 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) v2/endpoint: dependencies: @@ -511,7 +529,7 @@ importers: devDependencies: '@effect/vitest': specifier: ^0.30.0 - version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) '@electric-sql/pglite': specifier: 0.4.4 version: 0.4.4 @@ -535,7 +553,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: 3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) v2/router: dependencies: @@ -563,7 +581,7 @@ importers: devDependencies: '@effect/vitest': specifier: ^0.30.0 - version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + version: 0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) '@typescript/native': specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 @@ -578,7 +596,7 @@ importers: version: '@typescript/typescript6@6.0.2' vitest: specifier: 3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + version: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) v2/simulator: dependencies: @@ -686,6 +704,10 @@ packages: resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==} engines: {node: '>=10'} + '@ampproject/remapping@2.3.0': + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} @@ -918,17 +940,34 @@ packages: resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.28.5': - resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} engines: {node: '>=6.9.0'} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/runtime@7.29.2': resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} engines: {node: '>=6.9.0'} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + '@balena/dockerignore@1.0.2': resolution: {integrity: sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q==} + '@bcoe/v8-coverage@1.0.2': + resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} + engines: {node: '>=18'} + '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} @@ -1932,6 +1971,10 @@ packages: resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} engines: {node: '>=18.0.0'} + '@istanbuljs/schema@0.1.6': + resolution: {integrity: sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==} + engines: {node: '>=8'} + '@jest/diff-sequences@30.0.1': resolution: {integrity: sha512-n5H8QLDJ47QqbCNn5SuFjCRDrOLEZ0h8vAHCK5RL9Ls7Xa8AQLa/YxAc9UjFqoEDM48muwtBGjtMY5cr0PLDCw==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -1943,6 +1986,9 @@ packages: resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} engines: {node: '>=6.0.0'} + '@jridgewell/source-map@0.3.11': + resolution: {integrity: sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -1970,6 +2016,129 @@ packages: peerDependencies: jsep: ^0.4.0||^1.0.0 + '@jsonjoy.com/base64@1.1.2': + resolution: {integrity: sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/base64@17.67.0': + resolution: {integrity: sha512-5SEsJGsm15aP8TQGkDfJvz9axgPwAEm98S5DxOuYe8e1EbfajcDmgeXXzccEjh+mLnjqEKrkBdjHWS5vFNwDdw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@1.2.1': + resolution: {integrity: sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/buffers@17.67.0': + resolution: {integrity: sha512-tfExRpYxBvi32vPs9ZHaTjSP4fHAfzSmcahOfNxtvGHcyJel+aibkPlGeBB+7AoC6hL7lXIE++8okecBxx7lcw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@1.0.0': + resolution: {integrity: sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/codegen@17.67.0': + resolution: {integrity: sha512-idnkUplROpdBOV0HMcwhsCUS5TRUi9poagdGs70A6S4ux9+/aPuKbh8+UYRTLYQHtXvAdNfQWXDqZEx5k4Dj2Q==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-core@4.66.1': + resolution: {integrity: sha512-8nvZo0NSi4LArgvN0M+xJbIP+2p8Gl615y0tXYCsCCbYcRDKnAvxylc1zIEZoOs1oT/npotPVHIAKIx+savFlA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-fsa@4.66.1': + resolution: {integrity: sha512-7Ow+igS/bPSzlVWFiB/cjNzobhwBZn4pu7FHiy0/KSjUwJ//RaLG2UHHPnr+FmwdQykvVux9VQarZxozsSh1dA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-builtins@4.66.1': + resolution: {integrity: sha512-KWsERloam7LL2TMNnRQoosjTKmUchBUbkX3iSgEfxJh+CFQcjD1fUoDLrW4IkmdpDpCKvKtQtWU316ziCbadFA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-to-fsa@4.66.1': + resolution: {integrity: sha512-ZrLba5Li6EIBWIEf4d6Ynd0VvHZawmgMoU1KZb1+L0CGYrIME/sSlikqYXW6Rz8p9Qlh2lgLjTooDHUXEApHPA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node-utils@4.66.1': + resolution: {integrity: sha512-uT47QQHagIwHXJLufJ0St5anvn5+XPft5coItIwXpu+BSIkchole1VcTWyrsrqNlkMkhoiPTCEsNROlDIc4u9A==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-node@4.66.1': + resolution: {integrity: sha512-lFlBqITscYHoBq4xqZP090pXCeSYfv//yipuliA26D2l+50P/7L9IaQnSZE3QH6Ai1DLuZq72X0MeAj30QbzNw==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-print@4.66.1': + resolution: {integrity: sha512-Hzor0pXAXkVIsmLvEcKTd8GG8I2DztOZEK1kxkf6eolZJfKQyKo2BLHVCwm99iiwfJZpfb7zbtGnENUGo+r8eg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/fs-snapshot@4.66.1': + resolution: {integrity: sha512-dDH60OcZ3Q9aOhg1CoKzoqGGsu6MPkepmRQqr1SGpYNrv3TgO7VuFAew7bmDbOpilBtq1MLr1ZzS5vmbz2XYgA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@1.21.0': + resolution: {integrity: sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pack@17.67.0': + resolution: {integrity: sha512-t0ejURcGaZsn1ClbJ/3kFqSOjlryd92eQY465IYrezsXmPcfHPE/av4twRSxf6WE+TkZgLY+71vCZbiIiFKA/w==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@1.0.2': + resolution: {integrity: sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/json-pointer@17.67.0': + resolution: {integrity: sha512-+iqOFInH+QZGmSuaybBUNdh7yvNrXvqR+h3wjXm0N/3JK1EyyFAeGJvqnmQL61d1ARLlk/wJdFKSL+LHJ1eaUA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@1.9.0': + resolution: {integrity: sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@jsonjoy.com/util@17.67.0': + resolution: {integrity: sha512-6+8xBaz1rLSohlGh68D1pdw3AwDi9xydm8QNlAFkvnavCJYSze+pxoW2VKP8p308jtlMRLs5NTHfPlZLd4w7ew==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + + '@kubernetes/client-node@1.4.0': + resolution: {integrity: sha512-Zge3YvF7DJi264dU1b3wb/GmzR99JhUpqTvp+VGHfwZT+g7EOOYNScDJNZwXy9cszyIGPIs0VHr+kk8e95qqrA==} + '@leichtgewicht/ip-codec@2.0.5': resolution: {integrity: sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==} @@ -3049,12 +3218,21 @@ packages: '@protobufjs/codegen@2.0.4': resolution: {integrity: sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==} + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + '@protobufjs/eventemitter@1.1.0': resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==} + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + '@protobufjs/fetch@1.1.0': resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==} + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + '@protobufjs/float@1.0.2': resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} @@ -3070,6 +3248,9 @@ packages: '@protobufjs/utf8@1.1.0': resolution: {integrity: sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==} + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@puppeteer/browsers@2.13.2': resolution: {integrity: sha512-5EUZSUIc37H6aIXyWO0Z4y8NlF8NnjgmqeQgOGiswAU7pY0HOo16ho4+alIWmSfdZnjqBRawMsP3I5YqLSn6kw==} engines: {node: '>=18'} @@ -3768,9 +3949,96 @@ packages: resolution: {integrity: sha512-JZlVFE6/dYpP9tQmV0/ADfn32L9uFarHWxfcRhReKUnljz1ZiUM5zpX+PH8h5CJs6lao3TuFqnPm9IJJCEkE2w==} engines: {node: '>=10.8'} + '@swc/core-darwin-arm64@1.15.47': + resolution: {integrity: sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + + '@swc/core-darwin-x64@1.15.47': + resolution: {integrity: sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + + '@swc/core-linux-arm-gnueabihf@1.15.47': + resolution: {integrity: sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + + '@swc/core-linux-arm64-gnu@1.15.47': + resolution: {integrity: sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-arm64-musl@1.15.47': + resolution: {integrity: sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + + '@swc/core-linux-ppc64-gnu@1.15.47': + resolution: {integrity: sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==} + engines: {node: '>=10'} + cpu: [ppc64] + os: [linux] + + '@swc/core-linux-s390x-gnu@1.15.47': + resolution: {integrity: sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==} + engines: {node: '>=10'} + cpu: [s390x] + os: [linux] + + '@swc/core-linux-x64-gnu@1.15.47': + resolution: {integrity: sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-linux-x64-musl@1.15.47': + resolution: {integrity: sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + + '@swc/core-win32-arm64-msvc@1.15.47': + resolution: {integrity: sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + + '@swc/core-win32-ia32-msvc@1.15.47': + resolution: {integrity: sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + + '@swc/core-win32-x64-msvc@1.15.47': + resolution: {integrity: sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@swc/core@1.15.47': + resolution: {integrity: sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==} + engines: {node: '>=10'} + peerDependencies: + '@swc/helpers': '>=0.5.17' + peerDependenciesMeta: + '@swc/helpers': + optional: true + + '@swc/counter@0.1.3': + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@swc/types@0.1.28': + resolution: {integrity: sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==} + '@szmarczak/http-timer@5.0.1': resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} engines: {node: '>=14.16'} @@ -3787,6 +4055,38 @@ packages: '@telegraf/types@7.1.0': resolution: {integrity: sha512-kGevOIbpMcIlCDeorKGpwZmdH7kHbqlk/Yj6dEpJMKEQw5lk0KVQY0OLXaCswy8GqlIVLd5625OB+rAntP9xVw==} + '@temporalio/activity@1.21.1': + resolution: {integrity: sha512-UjA1d4ugL3pRXElwnggtVAMe3lppUPzYpBPmpDLTXfOtxEXPTK1x+8OC2jrZY7qmvpHkOdoo86S+UYFzJkMk/A==} + engines: {node: '>= 20.3.0'} + + '@temporalio/client@1.21.1': + resolution: {integrity: sha512-rdZAh20wzI5i/SyS46Nv9mE6t2KkS9DTrB57HEMLsq6eqm58Nc6la0WQKJGsUNkmN98KKirByidZ5D/ik3kiQw==} + engines: {node: '>= 20.3.0'} + + '@temporalio/common@1.21.1': + resolution: {integrity: sha512-8Pis59xYLrGu6GfkkWvrYWkSPEvo8lBBILsR6gBHGbymNlEc0ynylRXqPmRjwuLfYS7jHzxqjjO7cvXJQ/z2Fg==} + engines: {node: '>= 20.3.0'} + + '@temporalio/core-bridge@1.21.1': + resolution: {integrity: sha512-gCy/6TFhcFAjFPRN1DeHSwAnXU380Jn/y6yG2dkSV+rYK0JRl66SE2NvdcAjzhoPO/twHEaHklbppojH0cUpUw==} + engines: {node: '>= 20.3.0'} + + '@temporalio/nexus@1.21.1': + resolution: {integrity: sha512-CIAoTt/WpSE0bn1mE9q5O6hU76q97W349e0FSrUuwQAYDXBy7jzFM6ZzYmm7S5Uk2tC1xrRCAjUGg1lMnHQppw==} + engines: {node: '>= 20.3.0'} + + '@temporalio/proto@1.21.1': + resolution: {integrity: sha512-eSHGrZ6CxbtjrAzxiMgKrWeDiBlWk6/JkIqsB1hrkPB6TQXC67Az8v0BL0Fj3ur8ktQZbaacON/xCDjTsvJyGw==} + engines: {node: '>= 20.3.0'} + + '@temporalio/worker@1.21.1': + resolution: {integrity: sha512-ccXus6+w317tL+NsJXEYWpHFxcy0VDPfstmBzej0yZrkzWNvAkaWVGQbguKoLToDM8+DMaqklx1dX4gJnknR1g==} + engines: {node: '>= 20.3.0'} + + '@temporalio/workflow@1.21.1': + resolution: {integrity: sha512-Tsoe9RnB0mL75DGVo3wJJrgTl+QnHYUysPjqRkzQdzgeKravN8RxE0HCfS3cYRZej3eEVwoDftgbo7/KR0NXWQ==} + engines: {node: '>= 20.3.0'} + '@testcontainers/postgresql@10.28.0': resolution: {integrity: sha512-NN25rruG5D4Q7pCNIJuHwB+G85OSeJ3xHZ2fWx0O6sPoPEfCYwvpj8mq99cyn68nxFkFYZeyrZJtSFO+FnydiA==} @@ -3944,6 +4244,9 @@ packages: '@types/http-cache-semantics@4.2.0': resolution: {integrity: sha512-L3LgimLHXtGkWikKnsPg0/VFx9OGZaC+eN1u4r+OB1XRqH3meBIAVC2zr1WdMH+RHmnRkqliQAOHNJ/E0j/e0Q==} + '@types/js-yaml@4.0.9': + resolution: {integrity: sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -3965,6 +4268,9 @@ packages: '@types/nlcst@2.0.3': resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node-fetch@2.6.13': + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==} + '@types/node@18.19.130': resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==} @@ -3992,6 +4298,9 @@ packages: '@types/ssh2@1.15.5': resolution: {integrity: sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ==} + '@types/stream-buffers@3.0.8': + resolution: {integrity: sha512-J+7VaHKNvlNPJPEJXX/fKa9DZtR/xPMwuIbe+yNOwp1YB+ApUOBv2aUpEoBJEi8nJgbgs1x8e73ttg0r1rSUdw==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -4319,6 +4628,15 @@ packages: resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} engines: {node: '>= 20'} + '@vitest/coverage-v8@3.2.4': + resolution: {integrity: sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==} + peerDependencies: + '@vitest/browser': 3.2.4 + vitest: 3.2.4 + peerDependenciesMeta: + '@vitest/browser': + optional: true + '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} @@ -4348,9 +4666,60 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@webassemblyjs/ast@1.14.1': + resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==} + + '@webassemblyjs/floating-point-hex-parser@1.13.2': + resolution: {integrity: sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==} + + '@webassemblyjs/helper-api-error@1.13.2': + resolution: {integrity: sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==} + + '@webassemblyjs/helper-buffer@1.14.1': + resolution: {integrity: sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==} + + '@webassemblyjs/helper-numbers@1.13.2': + resolution: {integrity: sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==} + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': + resolution: {integrity: sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==} + + '@webassemblyjs/helper-wasm-section@1.14.1': + resolution: {integrity: sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==} + + '@webassemblyjs/ieee754@1.13.2': + resolution: {integrity: sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==} + + '@webassemblyjs/leb128@1.13.2': + resolution: {integrity: sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==} + + '@webassemblyjs/utf8@1.13.2': + resolution: {integrity: sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==} + + '@webassemblyjs/wasm-edit@1.14.1': + resolution: {integrity: sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==} + + '@webassemblyjs/wasm-gen@1.14.1': + resolution: {integrity: sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==} + + '@webassemblyjs/wasm-opt@1.14.1': + resolution: {integrity: sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==} + + '@webassemblyjs/wasm-parser@1.14.1': + resolution: {integrity: sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==} + + '@webassemblyjs/wast-printer@1.14.1': + resolution: {integrity: sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==} + '@workflow/serde@4.1.0': resolution: {integrity: sha512-pav4F2BoirECWR7Nf1TKt+2eETcBj7jj4cBefQ8VXQCA6NPkaKeLfj/zMgi+3zYV5ZIBT4GuUiphsj0/b9hPQQ==} + '@xtuc/ieee754@1.2.0': + resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} + + '@xtuc/long@4.2.2': + resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@yarnpkg/lockfile@1.1.0': resolution: {integrity: sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ==} @@ -4450,6 +4819,11 @@ packages: ajv: optional: true + ajv-keywords@5.1.0: + resolution: {integrity: sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==} + peerDependencies: + ajv: ^8.8.2 + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -4572,6 +4946,9 @@ packages: resolution: {integrity: sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w==} engines: {node: '>=4'} + ast-v8-to-istanbul@0.3.12: + resolution: {integrity: sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==} + astring@1.9.0: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true @@ -4687,6 +5064,11 @@ packages: resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==} engines: {node: ^4.5.0 || >= 5.9} + baseline-browser-mapping@2.11.12: + resolution: {integrity: sha512-r7WnVImvVCeFpf2DOXfy41aPWzeNg3H/A2X4dKmy1QL0MSyyk/e7z8ihJ3N6Nn2PsdhkVlqnEfnUE4a05P2aTA==} + engines: {node: '>=6.0.0'} + hasBin: true + basic-ftp@5.2.0: resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==} engines: {node: '>=10.0.0'} @@ -4750,6 +5132,11 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} + browserslist@4.28.7: + resolution: {integrity: sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + bs58@6.0.0: resolution: {integrity: sha512-PD0wEnEYg6ijszw/u8s+iI3H17cTymlrwkKhDhPZq+Sokl3AU4htyBFTjAeNAlCCmg0f53g6ih3jATyCKftTfw==} @@ -4833,6 +5220,9 @@ packages: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} + caniuse-lite@1.0.30001806: + resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==} + canonicalize@3.0.0: resolution: {integrity: sha512-yYLfHyDMIXRyRqsKBRLX023riFLpXY2YOfdtqKXZRZy9qsfOJ9U+4F9YZL7MEzL5+ziN2x2nlBvY/Voi3EBljA==} engines: {node: '>=18'} @@ -4903,6 +5293,10 @@ packages: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} + chrome-trace-event@1.0.4: + resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} + engines: {node: '>=6.0'} + chromium-bidi@14.0.0: resolution: {integrity: sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw==} peerDependencies: @@ -5570,6 +5964,9 @@ packages: engines: {node: '>=0.12.18'} hasBin: true + electron-to-chromium@1.5.400: + resolution: {integrity: sha512-96EWDNjM59SYflgeV5Ylsf4EMiq1a25YjCnJH7cxn/AF2H3pILRweaUnoLax0yKHWdpOzY6JKEu45e8irqZIHA==} + elkjs@0.9.3: resolution: {integrity: sha512-f/ZeWvW/BCXbhGEf1Ujp29EASo/lk1FDnETgNKwJrsVvGZhUWCZyg3xLJjAsxfOmt8KjswHmI5EwCQcPMpOYhQ==} @@ -5597,6 +5994,10 @@ packages: resolution: {integrity: sha512-clKkw4C7nJ22mGgoVcCg6V/W/TxdNyIOTr89k2ONZu81qqkddPFDF0LXcbAwhzPD8DjkiRCjzuiO6Y+fkpD4vg==} engines: {node: '>=10.2.0'} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} + engines: {node: '>=10.13.0'} + enquirer@2.3.6: resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} engines: {node: '>=8.6'} @@ -5753,6 +6154,10 @@ packages: peerDependencies: eslint: ^8.0.0 || ^9.0.0 || ^10.0.0 + eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + eslint-scope@8.4.0: resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -5800,6 +6205,10 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} + estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} @@ -6120,6 +6529,9 @@ packages: resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} engines: {node: '>=14.14'} + fs-monkey@1.1.0: + resolution: {integrity: sha512-QMUezzXWII9EV5aTFXW1UBVUO77wYPpjqIF8/AviUCThNeSYZykpoTixUeaNNBwmCev0AMDWMAni+f8Hxb1IFw==} + fs.realpath@1.0.0: resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} @@ -6230,6 +6642,12 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob-to-regex.js@1.2.0: + resolution: {integrity: sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me @@ -6407,6 +6825,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + heap-js@2.7.1: + resolution: {integrity: sha512-EQfezRg0NCZGNlhlDR3Evrw1FVL2G3LhU7EgPoxufQKruNBSYA8MiRPHeWbU+36o+Fhel0wMwM+sLEiBAlNLJA==} + engines: {node: '>=10.0.0'} + highlight.js@10.7.3: resolution: {integrity: sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==} @@ -6426,9 +6848,16 @@ packages: resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} engines: {node: ^20.17.0 || >=22.9.0} + hpagent@1.2.0: + resolution: {integrity: sha512-A91dYTeIB6NoXG+PxTQpCCDDnfHsW9kc06Lvpu1TEe9gnd6ZFeiBoRO9JvzEv6xK7EX97/dUE8g/vBMTqTS3CA==} + engines: {node: '>=14'} + html-entities@2.6.0: resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-escaper@3.0.3: resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} @@ -6476,6 +6905,10 @@ packages: engines: {node: '>=18'} hasBin: true + hyperdyperid@1.2.0: + resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} + engines: {node: '>=10.18'} + ico-endec@0.1.6: resolution: {integrity: sha512-ZdLU38ZoED3g1j3iEyzcQj+wAkY2xfWNkymszfJPoxucIUhK7NayQ+/C4Kv0nDFMIsbtbEHldv3V8PU494/ueQ==} @@ -6799,6 +7232,27 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + isomorphic-ws@5.0.0: + resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==} + peerDependencies: + ws: '*' + + istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + + istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + + istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + + istanbul-reports@3.2.0: + resolution: {integrity: sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==} + engines: {node: '>=8'} + jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} @@ -6807,6 +7261,10 @@ packages: engines: {node: '>=10'} hasBin: true + jest-worker@27.5.1: + resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} + engines: {node: '>= 10.13.0'} + jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -6844,6 +7302,9 @@ packages: react: optional: true + js-tokens@10.0.0: + resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -7157,6 +7618,13 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + + make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + markdown-extensions@2.0.0: resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} engines: {node: '>=16'} @@ -7271,6 +7739,9 @@ packages: resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} engines: {node: '>= 0.8'} + memfs@4.66.1: + resolution: {integrity: sha512-kHQesIzNf/h57sTonjIFNF5oCTHkeDYV6QXtDdapn6TCKLJHCfKMM93Jq6BO0ubtzlgN+Yd53kKWl7TvU79X5Q==} + merge-descriptors@1.0.3: resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} @@ -7278,6 +7749,9 @@ packages: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} + merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + merge2@1.4.1: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} @@ -7466,6 +7940,49 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minimizer-webpack-plugin@5.6.1: + resolution: {integrity: sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==} + engines: {node: '>= 10.13.0'} + peerDependencies: + '@minify-html/node': '*' + '@swc/core': '*' + '@swc/css': '*' + '@swc/html': '*' + clean-css: '*' + cssnano: '*' + csso: '*' + esbuild: '*' + html-minifier-terser: '*' + lightningcss: '*' + postcss: '*' + uglify-js: '*' + webpack: ^5.1.0 + peerDependenciesMeta: + '@minify-html/node': + optional: true + '@swc/core': + optional: true + '@swc/css': + optional: true + '@swc/html': + optional: true + clean-css: + optional: true + cssnano: + optional: true + csso: + optional: true + esbuild: + optional: true + html-minifier-terser: + optional: true + lightningcss: + optional: true + postcss: + optional: true + uglify-js: + optional: true + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -7503,6 +8020,10 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + ms@3.0.0-canary.1: + resolution: {integrity: sha512-kh8ARjh8rMN7Du2igDRO9QJnqCb2xYTJxyQYK7vJJS4TvLLmsbyhiKpSW+t+y26gyOyMd0riphX0GeWKU3ky5g==} + engines: {node: '>=12.13'} + msgpackr-extract@3.0.3: resolution: {integrity: sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==} hasBin: true @@ -7556,6 +8077,9 @@ packages: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} + neo-async@2.6.2: + resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + neotraverse@0.6.18: resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} engines: {node: '>= 10'} @@ -7571,6 +8095,10 @@ packages: react: '>= 18.3.0 < 19.0.0' react-dom: '>= 18.3.0 < 19.0.0' + nexus-rpc@0.0.2: + resolution: {integrity: sha512-IWjIExdVYlmwXuzHdY/Q3lXCv1gbqoAXPazQhy2w4Xgtgha3H0OOujEESVPQcFUFMWm+pAk2gKnb57g8S41JZg==} + engines: {node: '>= 20.0.0'} + nimma@0.2.3: resolution: {integrity: sha512-1ZOI8J+1PKKGceo/5CT5GfQOG6H8I2BencSK06YarZ2wXwH37BSSUWldqJmMJYA5JfqDqffxDXynt6f11AyKcA==} engines: {node: ^12.20 || >=14.13} @@ -7639,6 +8167,10 @@ packages: node-readable-to-web-readable-stream@0.4.2: resolution: {integrity: sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==} + node-releases@2.0.51: + resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} + engines: {node: '>=18'} + non-error@0.1.0: resolution: {integrity: sha512-TMB1uHiGsHRGv1uYclfhivcnf0/PdFp2pNqRxXjncaAsjYMoisaQJI+SSZCqRq+VliwRTC8tsMQfmrWjDMhkPQ==} engines: {node: '>=20'} @@ -8263,10 +8795,18 @@ packages: prosemirror-model@1.25.11: resolution: {integrity: sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==} + proto3-json-serializer@2.0.2: + resolution: {integrity: sha512-SAzp/O4Yh02jGdRc+uIrGoe87dkN/XtwxfZ4ZyafJHymd79ozp5VG5nyZ7ygqPM5+cpLDjjGnYFUkngonyDPOQ==} + engines: {node: '>=14.0.0'} + protobufjs@7.5.4: resolution: {integrity: sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg==} engines: {node: '>=12.0.0'} + protobufjs@7.6.5: + resolution: {integrity: sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -8643,6 +9183,9 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rfc4648@1.5.4: + resolution: {integrity: sha512-rRg/6Lb+IGfJqO05HZkN50UtY7K/JhxJag1kP23+zyMfrvoB0B7RWv06MbOzoc79RgCdNTiUaNsTT1AJZ7Z+cg==} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -8716,6 +9259,10 @@ packages: scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + schema-utils@4.3.3: + resolution: {integrity: sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==} + engines: {node: '>= 10.13.0'} + scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} engines: {node: ^14.0.0 || >=16.0.0} @@ -8906,6 +9453,12 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + source-map-loader@5.0.0: + resolution: {integrity: sha512-k2Dur7CbSLcAH73sBcIkV5xjPV4SzqO1NJ7+XaQl8if3VODDUj3FNchNGpqgJSKbvUfJuhVdv8K2Eu8/TNl2eA==} + engines: {node: '>= 18.12.0'} + peerDependencies: + webpack: ^5.72.1 + source-map-support@0.5.21: resolution: {integrity: sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==} @@ -8999,6 +9552,10 @@ packages: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} + stream-buffers@3.0.3: + resolution: {integrity: sha512-pqMqwQCso0PBJt2PQmDO0cFj0lyqmiwOMiMSkVtRokl7e+ZTRYgDHKnuZNbqjiJXgsg4nuqtD/zxuo9KqTp0Yw==} + engines: {node: '>= 0.10.0'} + streamx@2.25.0: resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} @@ -9111,6 +9668,12 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + swc-loader@0.2.7: + resolution: {integrity: sha512-nwYWw3Fh9ame3Rtm7StS9SBLpHRRnYcK7bnpF3UKZmesAK0gw2/ADvlURFAINmPvKtDLzp+GBiP9yLoEjg6S9w==} + peerDependencies: + '@swc/core': ^1.2.147 + webpack: '>=2' + tabbable@6.4.0: resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} @@ -9131,6 +9694,10 @@ packages: engines: {node: '>=14.0.0'} hasBin: true + tapable@2.3.3: + resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} + engines: {node: '>=6'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -9164,6 +9731,15 @@ packages: engines: {node: ^12.20.0 || >=14.13.1} hasBin: true + terser@5.49.0: + resolution: {integrity: sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==} + engines: {node: '>=10'} + hasBin: true + + test-exclude@7.0.2: + resolution: {integrity: sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==} + engines: {node: '>=18'} + testcontainers@10.28.0: resolution: {integrity: sha512-1fKrRRCsgAQNkarjHCMKzBKXSJFmzNTiTbhb5E/j5hflRXChEtHvkefjaHlgkNUjfw92/Dq8LTgwQn6RDBFbMg==} @@ -9177,6 +9753,12 @@ packages: thenify@3.3.1: resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + thingies@2.6.1: + resolution: {integrity: sha512-cV/CMGTK3M4MlnJ/0At6ismOw/A0EEniDNScajjz/Br3c1sqE72YD01rGpPTKwd27wAxI5Pr+6+0w8yofzFRYw==} + engines: {node: '>=10.18'} + peerDependencies: + tslib: ^2 + thread-stream@2.7.0: resolution: {integrity: sha512-qQiRWsU/wvNolI6tbbCKd9iKaTnCXsTwVxhhKM6nctPdujTyztjlbUkUTUymidWcMnZ5pWR0ej4a0tjsW021vw==} @@ -9246,6 +9828,12 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tree-dump@1.1.0: + resolution: {integrity: sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA==} + engines: {node: '>=10.0'} + peerDependencies: + tslib: '2' + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -9453,6 +10041,9 @@ packages: unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} + unionfs@4.6.0: + resolution: {integrity: sha512-fJAy3gTHjFi5S3TP5EGdjs/OUMFFvI/ady3T8qVuZfkv8Qi8prV/Q8BuFEgODJslhZTT2z2qdD2lGdee9qjEnA==} + unist-builder@4.0.0: resolution: {integrity: sha512-wmRFnH+BLpZnTKpc5L7O67Kac89s9HMrtELpnNaE6TAobq5DTZZs5YaTQfAZBA9bFPECx2uVAPO31c+GVug8mg==} @@ -9515,6 +10106,12 @@ packages: unrs-resolver@1.12.2: resolution: {integrity: sha512-dmlRxBJJayXjqTwC+JtF1HhJmgf3ftQ3YejFcZrf4+KKtJv0qDsK1pjqaaVjG7wJ5NJ6UVP1OqRMQ71Z4C3rxQ==} + update-browserslist-db@1.2.3: + resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -9682,6 +10279,10 @@ packages: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} + watchpack@2.5.2: + resolution: {integrity: sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==} + engines: {node: '>=10.13.0'} + wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} @@ -9706,6 +10307,20 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webpack-sources@3.5.1: + resolution: {integrity: sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==} + engines: {node: '>=10.13.0'} + + webpack@5.109.2: + resolution: {integrity: sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==} + engines: {node: '>=10.13.0'} + hasBin: true + peerDependencies: + webpack-cli: '*' + peerDependenciesMeta: + webpack-cli: + optional: true + whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} @@ -9986,6 +10601,11 @@ snapshots: '@alloc/quick-lru@5.2.0': {} + '@ampproject/remapping@2.3.0': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@antfu/install-pkg@1.1.0': dependencies: package-manager-detector: 1.6.0 @@ -10044,7 +10664,7 @@ snapshots: '@opentelemetry/semantic-conventions': 1.41.1 ai: 7.0.46(zod@4.4.3) - '@arizeai/phoenix-client@7.1.1(@ai-sdk/otel@1.0.46(zod@4.4.3))(@opentelemetry/semantic-conventions@1.41.1)(ai@7.0.46(zod@4.4.3))(openai@6.39.1(ws@8.21.0)(zod@4.4.3))(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': + '@arizeai/phoenix-client@7.1.1(@ai-sdk/otel@1.0.46(zod@4.4.3))(@opentelemetry/semantic-conventions@1.41.1)(ai@7.0.46(zod@4.4.3))(openai@6.39.1(ws@8.21.0)(zod@4.4.3))(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@arizeai/openinference-semantic-conventions': 2.5.0 '@arizeai/phoenix-config': 0.4.0 @@ -10056,7 +10676,7 @@ snapshots: optionalDependencies: ai: 7.0.46(zod@4.4.3) openai: 6.39.1(ws@8.21.0)(zod@4.4.3) - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - '@ai-sdk/otel' - '@opentelemetry/semantic-conventions' @@ -10496,16 +11116,29 @@ snapshots: '@babel/code-frame@7.29.0': dependencies: - '@babel/helper-validator-identifier': 7.28.5 + '@babel/helper-validator-identifier': 7.29.7 js-tokens: 4.0.0 picocolors: 1.1.1 - '@babel/helper-validator-identifier@7.28.5': {} + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 '@babel/runtime@7.29.2': {} + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + '@balena/dockerignore@1.0.2': {} + '@bcoe/v8-coverage@1.0.2': {} + '@borewit/text-codec@0.2.2': {} '@braintree/sanitize-url@7.1.2': {} @@ -10732,15 +11365,15 @@ snapshots: dependencies: effect: 3.22.0 - '@effect/vitest@0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))': + '@effect/vitest@0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: effect: 3.22.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3) - '@effect/vitest@0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': + '@effect/vitest@0.30.0(effect@3.22.0)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: effect: 3.22.0 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) '@effect/workflow@0.19.0(@effect/experimental@0.61.0(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(@effect/platform@0.97.0(effect@3.22.0))(@effect/rpc@0.76.0(@effect/platform@0.97.0(effect@3.22.0))(effect@3.22.0))(effect@3.22.0)': dependencies: @@ -11415,6 +12048,8 @@ snapshots: dependencies: minipass: 7.1.3 + '@istanbuljs/schema@0.1.6': {} + '@jest/diff-sequences@30.0.1': {} '@jridgewell/gen-mapping@0.3.13': @@ -11424,6 +12059,11 @@ snapshots: '@jridgewell/resolve-uri@3.1.2': {} + '@jridgewell/source-map@0.3.11': + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 + '@jridgewell/sourcemap-codec@1.5.5': {} '@jridgewell/trace-mapping@0.3.31': @@ -11445,6 +12085,161 @@ snapshots: dependencies: jsep: 1.4.0 + '@jsonjoy.com/base64@1.1.2(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/base64@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@1.2.1(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/buffers@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@1.0.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/codegen@17.67.0(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-core@4.66.1(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.66.1(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-fsa@4.66.1(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.66.1(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-builtins@4.66.1(tslib@2.8.1)': + dependencies: + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-to-fsa@4.66.1(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-fsa': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.66.1(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node-utils@4.66.1(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-builtins': 4.66.1(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-node@4.66.1(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-core': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.66.1(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-print@4.66.1(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/fs-node-utils': 4.66.1(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/fs-snapshot@4.66.1(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/json-pack': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@1.21.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 1.1.2(tslib@2.8.1) + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 1.0.2(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.6.1(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pack@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/base64': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/json-pointer': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + hyperdyperid: 1.2.0 + thingies: 2.6.1(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@1.0.2(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/json-pointer@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/util': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@1.9.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 1.2.1(tslib@2.8.1) + '@jsonjoy.com/codegen': 1.0.0(tslib@2.8.1) + tslib: 2.8.1 + + '@jsonjoy.com/util@17.67.0(tslib@2.8.1)': + dependencies: + '@jsonjoy.com/buffers': 17.67.0(tslib@2.8.1) + '@jsonjoy.com/codegen': 17.67.0(tslib@2.8.1) + tslib: 2.8.1 + + '@kubernetes/client-node@1.4.0': + dependencies: + '@types/js-yaml': 4.0.9 + '@types/node': 24.12.0 + '@types/node-fetch': 2.6.13 + '@types/stream-buffers': 3.0.8 + form-data: 4.0.5 + hpagent: 1.2.0 + isomorphic-ws: 5.0.0(ws@8.21.0) + js-yaml: 4.1.1 + jsonpath-plus: 10.4.0 + node-fetch: 2.7.0 + openid-client: 6.8.2 + rfc4648: 1.5.4 + socks-proxy-agent: 8.0.5 + stream-buffers: 3.0.3 + tar-fs: 3.1.2 + ws: 8.21.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - bufferutil + - encoding + - react-native-b4a + - supports-color + - utf-8-validate + '@leichtgewicht/ip-codec@2.0.5': {} '@line/bot-sdk@10.6.0': @@ -12755,13 +13550,21 @@ snapshots: '@protobufjs/codegen@2.0.4': {} + '@protobufjs/codegen@2.0.5': {} + '@protobufjs/eventemitter@1.1.0': {} + '@protobufjs/eventemitter@1.1.1': {} + '@protobufjs/fetch@1.1.0': dependencies: '@protobufjs/aspromise': 1.1.2 '@protobufjs/inquire': 1.1.0 + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/float@1.0.2': {} '@protobufjs/inquire@1.1.0': {} @@ -12772,6 +13575,8 @@ snapshots: '@protobufjs/utf8@1.1.0': {} + '@protobufjs/utf8@1.1.2': {} + '@puppeteer/browsers@2.13.2': dependencies: debug: 4.4.3(supports-color@8.1.1) @@ -13593,10 +14398,71 @@ snapshots: '@stoplight/yaml-ast-parser': 0.0.50 tslib: 2.8.1 + '@swc/core-darwin-arm64@1.15.47': + optional: true + + '@swc/core-darwin-x64@1.15.47': + optional: true + + '@swc/core-linux-arm-gnueabihf@1.15.47': + optional: true + + '@swc/core-linux-arm64-gnu@1.15.47': + optional: true + + '@swc/core-linux-arm64-musl@1.15.47': + optional: true + + '@swc/core-linux-ppc64-gnu@1.15.47': + optional: true + + '@swc/core-linux-s390x-gnu@1.15.47': + optional: true + + '@swc/core-linux-x64-gnu@1.15.47': + optional: true + + '@swc/core-linux-x64-musl@1.15.47': + optional: true + + '@swc/core-win32-arm64-msvc@1.15.47': + optional: true + + '@swc/core-win32-ia32-msvc@1.15.47': + optional: true + + '@swc/core-win32-x64-msvc@1.15.47': + optional: true + + '@swc/core@1.15.47(@swc/helpers@0.5.21)': + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.28 + optionalDependencies: + '@swc/core-darwin-arm64': 1.15.47 + '@swc/core-darwin-x64': 1.15.47 + '@swc/core-linux-arm-gnueabihf': 1.15.47 + '@swc/core-linux-arm64-gnu': 1.15.47 + '@swc/core-linux-arm64-musl': 1.15.47 + '@swc/core-linux-ppc64-gnu': 1.15.47 + '@swc/core-linux-s390x-gnu': 1.15.47 + '@swc/core-linux-x64-gnu': 1.15.47 + '@swc/core-linux-x64-musl': 1.15.47 + '@swc/core-win32-arm64-msvc': 1.15.47 + '@swc/core-win32-ia32-msvc': 1.15.47 + '@swc/core-win32-x64-msvc': 1.15.47 + '@swc/helpers': 0.5.21 + + '@swc/counter@0.1.3': {} + '@swc/helpers@0.5.21': dependencies: tslib: 2.8.1 + '@swc/types@0.1.28': + dependencies: + '@swc/counter': 0.1.3 + '@szmarczak/http-timer@5.0.1': dependencies: defer-to-connect: 2.0.1 @@ -13612,6 +14478,90 @@ snapshots: '@telegraf/types@7.1.0': optional: true + '@temporalio/activity@1.21.1': + dependencies: + '@temporalio/client': 1.21.1 + '@temporalio/common': 1.21.1 + + '@temporalio/client@1.21.1': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@temporalio/common': 1.21.1 + '@temporalio/proto': 1.21.1 + abort-controller: 3.0.0 + long: 5.3.2 + nexus-rpc: 0.0.2 + uuid: 11.1.1 + + '@temporalio/common@1.21.1': + dependencies: + '@temporalio/proto': 1.21.1 + long: 5.3.2 + ms: 3.0.0-canary.1 + nexus-rpc: 0.0.2 + proto3-json-serializer: 2.0.2 + + '@temporalio/core-bridge@1.21.1': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@temporalio/common': 1.21.1 + + '@temporalio/nexus@1.21.1': + dependencies: + '@temporalio/client': 1.21.1 + '@temporalio/common': 1.21.1 + '@temporalio/proto': 1.21.1 + long: 5.3.2 + nexus-rpc: 0.0.2 + + '@temporalio/proto@1.21.1': + dependencies: + long: 5.3.2 + protobufjs: 7.6.5 + + '@temporalio/worker@1.21.1(@swc/helpers@0.5.21)(postcss@8.5.22)': + dependencies: + '@grpc/grpc-js': 1.14.3 + '@swc/core': 1.15.47(@swc/helpers@0.5.21) + '@temporalio/activity': 1.21.1 + '@temporalio/client': 1.21.1 + '@temporalio/common': 1.21.1 + '@temporalio/core-bridge': 1.21.1 + '@temporalio/nexus': 1.21.1 + '@temporalio/proto': 1.21.1 + '@temporalio/workflow': 1.21.1 + heap-js: 2.7.1 + memfs: 4.66.1 + nexus-rpc: 0.0.2 + protobufjs: 7.6.5 + rxjs: 7.8.2 + source-map: 0.7.6 + source-map-loader: 5.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22)) + supports-color: 8.1.1 + swc-loader: 0.2.7(@swc/core@1.15.47(@swc/helpers@0.5.21))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22)) + unionfs: 4.6.0 + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22) + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/css' + - '@swc/helpers' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + - webpack-cli + + '@temporalio/workflow@1.21.1': + dependencies: + '@temporalio/common': 1.21.1 + '@temporalio/proto': 1.21.1 + nexus-rpc: 0.0.2 + '@testcontainers/postgresql@10.28.0': dependencies: testcontainers: 10.28.0 @@ -13810,6 +14760,8 @@ snapshots: '@types/http-cache-semantics@4.2.0': {} + '@types/js-yaml@4.0.9': {} + '@types/json-schema@7.0.15': {} '@types/katex@0.16.8': {} @@ -13828,6 +14780,11 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/node-fetch@2.6.13': + dependencies: + '@types/node': 25.5.0 + form-data: 4.0.5 + '@types/node@18.19.130': dependencies: undici-types: 5.26.5 @@ -13865,6 +14822,10 @@ snapshots: dependencies: '@types/node': 18.19.130 + '@types/stream-buffers@3.0.8': + dependencies: + '@types/node': 25.5.0 + '@types/trusted-types@2.0.7': optional: true @@ -14124,6 +15085,25 @@ snapshots: '@vercel/oidc@3.2.0': {} + '@vitest/coverage-v8@3.2.4(vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0))': + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 1.0.2 + ast-v8-to-istanbul: 0.3.12 + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.2.0 + magic-string: 0.30.21 + magicast: 0.3.5 + std-env: 3.10.0 + test-exclude: 7.0.2 + tinyrainbow: 2.0.0 + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) + transitivePeerDependencies: + - supports-color + '@vitest/expect@3.2.4': dependencies: '@types/chai': 5.2.3 @@ -14132,21 +15112,29 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) - '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0))': + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3) + + '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.4 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) '@vitest/pretty-format@3.2.4': dependencies: @@ -14174,8 +15162,88 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@webassemblyjs/ast@1.14.1': + dependencies: + '@webassemblyjs/helper-numbers': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + + '@webassemblyjs/floating-point-hex-parser@1.13.2': {} + + '@webassemblyjs/helper-api-error@1.13.2': {} + + '@webassemblyjs/helper-buffer@1.14.1': {} + + '@webassemblyjs/helper-numbers@1.13.2': + dependencies: + '@webassemblyjs/floating-point-hex-parser': 1.13.2 + '@webassemblyjs/helper-api-error': 1.13.2 + '@xtuc/long': 4.2.2 + + '@webassemblyjs/helper-wasm-bytecode@1.13.2': {} + + '@webassemblyjs/helper-wasm-section@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/wasm-gen': 1.14.1 + + '@webassemblyjs/ieee754@1.13.2': + dependencies: + '@xtuc/ieee754': 1.2.0 + + '@webassemblyjs/leb128@1.13.2': + dependencies: + '@xtuc/long': 4.2.2 + + '@webassemblyjs/utf8@1.13.2': {} + + '@webassemblyjs/wasm-edit@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/helper-wasm-section': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-opt': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + '@webassemblyjs/wast-printer': 1.14.1 + + '@webassemblyjs/wasm-gen@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wasm-opt@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-buffer': 1.14.1 + '@webassemblyjs/wasm-gen': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + + '@webassemblyjs/wasm-parser@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/helper-api-error': 1.13.2 + '@webassemblyjs/helper-wasm-bytecode': 1.13.2 + '@webassemblyjs/ieee754': 1.13.2 + '@webassemblyjs/leb128': 1.13.2 + '@webassemblyjs/utf8': 1.13.2 + + '@webassemblyjs/wast-printer@1.14.1': + dependencies: + '@webassemblyjs/ast': 1.14.1 + '@xtuc/long': 4.2.2 + '@workflow/serde@4.1.0': {} + '@xtuc/ieee754@1.2.0': {} + + '@xtuc/long@4.2.2': {} + '@yarnpkg/lockfile@1.1.0': {} '@zenuml/core@3.49.0(@types/react@19.2.14)(playwright-core@1.60.0)(tsx@4.21.0)(yaml@2.9.0)': @@ -14280,6 +15348,11 @@ snapshots: optionalDependencies: ajv: 8.18.0 + ajv-keywords@5.1.0(ajv@8.18.0): + dependencies: + ajv: 8.18.0 + fast-deep-equal: 3.1.3 + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -14419,6 +15492,12 @@ snapshots: dependencies: tslib: 2.8.1 + ast-v8-to-istanbul@0.3.12: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + estree-walker: 3.0.3 + js-tokens: 10.0.0 + astring@1.9.0: {} async-function@1.0.0: {} @@ -14514,6 +15593,8 @@ snapshots: base64id@2.0.0: {} + baseline-browser-mapping@2.11.12: {} + basic-ftp@5.2.0: {} bcrypt-pbkdf@1.0.2: @@ -14599,6 +15680,14 @@ snapshots: dependencies: fill-range: 7.1.1 + browserslist@4.28.7: + dependencies: + baseline-browser-mapping: 2.11.12 + caniuse-lite: 1.0.30001806 + electron-to-chromium: 1.5.400 + node-releases: 2.0.51 + update-browserslist-db: 1.2.3(browserslist@4.28.7) + bs58@6.0.0: dependencies: base-x: 5.0.1 @@ -14679,6 +15768,8 @@ snapshots: camelcase@5.3.1: {} + caniuse-lite@1.0.30001806: {} + canonicalize@3.0.0: {} ccount@2.0.1: {} @@ -14752,6 +15843,8 @@ snapshots: chownr@3.0.0: {} + chrome-trace-event@1.0.4: {} + chromium-bidi@14.0.0(devtools-protocol@0.0.1608973): dependencies: devtools-protocol: 0.0.1608973 @@ -15399,6 +16492,8 @@ snapshots: ejs@5.0.1: {} + electron-to-chromium@1.5.400: {} + elkjs@0.9.3: {} emoji-regex@10.6.0: {} @@ -15432,6 +16527,11 @@ snapshots: - supports-color - utf-8-validate + enhanced-resolve@5.24.5: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.3.3 + enquirer@2.3.6: dependencies: ansi-colors: 4.1.3 @@ -15702,6 +16802,11 @@ snapshots: ts-api-utils: 2.5.0(typescript@5.9.3) typescript: 5.9.3 + eslint-scope@5.1.1: + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + eslint-scope@8.4.0: dependencies: esrecurse: 4.3.0 @@ -15817,6 +16922,8 @@ snapshots: dependencies: estraverse: 5.3.0 + estraverse@4.3.0: {} + estraverse@5.3.0: {} estree-util-attach-comments@3.0.0: @@ -15850,7 +16957,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} @@ -16220,6 +17327,8 @@ snapshots: jsonfile: 6.2.0 universalify: 2.0.1 + fs-monkey@1.1.0: {} + fs.realpath@1.0.0: {} fsevents@2.3.3: @@ -16352,6 +17461,10 @@ snapshots: dependencies: is-glob: 4.0.3 + glob-to-regex.js@1.2.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + glob@10.5.0: dependencies: foreground-child: 3.3.1 @@ -16698,6 +17811,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + heap-js@2.7.1: {} + highlight.js@10.7.3: {} highlight.js@11.11.1: {} @@ -16712,8 +17827,12 @@ snapshots: dependencies: lru-cache: 11.2.7 + hpagent@1.2.0: {} + html-entities@2.6.0: {} + html-escaper@2.0.2: {} + html-escaper@3.0.3: {} html-to-image@1.11.13: {} @@ -16771,6 +17890,8 @@ snapshots: husky@9.1.7: {} + hyperdyperid@1.2.0: {} + ico-endec@0.1.6: {} iconv-lite@0.4.24: @@ -17074,6 +18195,31 @@ snapshots: isexe@2.0.0: {} + isomorphic-ws@5.0.0(ws@8.21.0): + dependencies: + ws: 8.21.0 + + istanbul-lib-coverage@3.2.2: {} + + istanbul-lib-report@3.0.1: + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + + istanbul-lib-source-maps@5.0.6: + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + debug: 4.4.3(supports-color@8.1.1) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + + istanbul-reports@3.2.0: + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + jackspeak@3.4.3: dependencies: '@isaacs/cliui': 8.0.2 @@ -17086,6 +18232,12 @@ snapshots: filelist: 1.0.6 picocolors: 1.1.1 + jest-worker@27.5.1: + dependencies: + '@types/node': 25.5.0 + merge-stream: 2.0.0 + supports-color: 8.1.1 + jiti@1.21.7: {} jiti@2.0.0-beta.3: {} @@ -17101,6 +18253,8 @@ snapshots: '@types/react': 19.2.14 react: 19.2.3 + js-tokens@10.0.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -17375,6 +18529,16 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 + magicast@0.3.5: + dependencies: + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 + source-map-js: 1.2.1 + + make-dir@4.0.0: + dependencies: + semver: 7.8.0 + markdown-extensions@2.0.0: {} markdown-it@14.1.1: @@ -17658,10 +18822,29 @@ snapshots: media-typer@1.1.0: {} + memfs@4.66.1: + dependencies: + '@jsonjoy.com/fs-core': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-fsa': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node-builtins': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node-to-fsa': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-node-utils': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-print': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/fs-snapshot': 4.66.1(tslib@2.8.1) + '@jsonjoy.com/json-pack': 1.21.0(tslib@2.8.1) + '@jsonjoy.com/util': 1.9.0(tslib@2.8.1) + glob-to-regex.js: 1.2.0(tslib@2.8.1) + thingies: 2.6.1(tslib@2.8.1) + tree-dump: 1.1.0(tslib@2.8.1) + tslib: 2.8.1 + merge-descriptors@1.0.3: {} merge-descriptors@2.0.0: {} + merge-stream@2.0.0: {} + merge2@1.4.1: {} mermaid@11.15.0: @@ -18023,6 +19206,17 @@ snapshots: minimist@1.2.8: {} + minimizer-webpack-plugin@5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22)): + dependencies: + '@jridgewell/trace-mapping': 0.3.31 + jest-worker: 27.5.1 + schema-utils: 4.3.3 + terser: 5.49.0 + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22) + optionalDependencies: + '@swc/core': 1.15.47(@swc/helpers@0.5.21) + postcss: 8.5.22 + minipass@7.1.3: {} minizlib@3.1.0: @@ -18064,6 +19258,8 @@ snapshots: ms@2.1.3: {} + ms@3.0.0-canary.1: {} + msgpackr-extract@3.0.3: dependencies: node-gyp-build-optional-packages: 5.2.2 @@ -18109,6 +19305,8 @@ snapshots: negotiator@1.0.0: {} + neo-async@2.6.2: {} + neotraverse@0.6.18: {} netmask@2.0.2: {} @@ -18129,6 +19327,8 @@ snapshots: - supports-color - unified + nexus-rpc@0.0.2: {} + nimma@0.2.3: dependencies: '@jsep-plugin/regex': 1.0.4(jsep@1.4.0) @@ -18193,6 +19393,8 @@ snapshots: node-readable-to-web-readable-stream@0.4.2: optional: true + node-releases@2.0.51: {} + non-error@0.1.0: {} normalize-path@3.0.0: {} @@ -18207,7 +19409,7 @@ snapshots: dependencies: boolbase: 1.0.0 - nx@22.7.5: + nx@22.7.5(@swc/core@1.15.47(@swc/helpers@0.5.21)): dependencies: '@emnapi/core': 1.4.5 '@emnapi/runtime': 1.4.5 @@ -18330,6 +19532,7 @@ snapshots: '@nx/nx-linux-x64-musl': 22.7.5 '@nx/nx-win32-arm64-msvc': 22.7.5 '@nx/nx-win32-x64-msvc': 22.7.5 + '@swc/core': 1.15.47(@swc/helpers@0.5.21) transitivePeerDependencies: - debug @@ -19077,6 +20280,10 @@ snapshots: dependencies: orderedmap: 2.1.1 + proto3-json-serializer@2.0.2: + dependencies: + protobufjs: 7.5.4 + protobufjs@7.5.4: dependencies: '@protobufjs/aspromise': 1.1.2 @@ -19092,6 +20299,20 @@ snapshots: '@types/node': 25.5.0 long: 5.3.2 + protobufjs@7.6.5: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 25.5.0 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -19659,6 +20880,8 @@ snapshots: reusify@1.1.0: {} + rfc4648@1.5.4: {} + robust-predicates@3.0.3: {} rollup@4.60.1: @@ -19764,6 +20987,13 @@ snapshots: scheduler@0.27.0: {} + schema-utils@4.3.3: + dependencies: + '@types/json-schema': 7.0.15 + ajv: 8.18.0 + ajv-formats: 2.1.1(ajv@8.18.0) + ajv-keywords: 5.1.0(ajv@8.18.0) + scslre@0.3.0: dependencies: '@eslint-community/regexpp': 4.12.2 @@ -20081,6 +21311,12 @@ snapshots: source-map-js@1.2.1: {} + source-map-loader@5.0.0(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22)): + dependencies: + iconv-lite: 0.6.3 + source-map-js: 1.2.1 + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22) + source-map-support@0.5.21: dependencies: buffer-from: 1.1.2 @@ -20165,6 +21401,8 @@ snapshots: es-errors: 1.3.0 internal-slot: 1.1.0 + stream-buffers@3.0.3: {} + streamx@2.25.0: dependencies: events-universal: 1.0.1 @@ -20304,6 +21542,12 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + swc-loader@0.2.7(@swc/core@1.15.47(@swc/helpers@0.5.21))(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22)): + dependencies: + '@swc/core': 1.15.47(@swc/helpers@0.5.21) + '@swc/counter': 0.1.3 + webpack: 5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22) + tabbable@6.4.0: {} tagged-tag@1.0.0: {} @@ -20365,6 +21609,8 @@ snapshots: - tsx - yaml + tapable@2.3.3: {} + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -20449,6 +21695,19 @@ snapshots: - supports-color optional: true + terser@5.49.0: + dependencies: + '@jridgewell/source-map': 0.3.11 + acorn: 8.16.0 + commander: 2.20.3 + source-map-support: 0.5.21 + + test-exclude@7.0.2: + dependencies: + '@istanbuljs/schema': 0.1.6 + glob: 10.5.0 + minimatch: 10.2.5 + testcontainers@10.28.0: dependencies: '@balena/dockerignore': 1.0.2 @@ -20486,6 +21745,10 @@ snapshots: dependencies: any-promise: 1.3.0 + thingies@2.6.1(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + thread-stream@2.7.0: dependencies: real-require: 0.2.0 @@ -20541,6 +21804,10 @@ snapshots: tr46@0.0.3: {} + tree-dump@1.1.0(tslib@2.8.1): + dependencies: + tslib: 2.8.1 + tree-kill@1.2.2: {} tree-sitter-bash@0.25.1: @@ -20767,6 +22034,10 @@ snapshots: trough: 2.2.0 vfile: 6.0.3 + unionfs@4.6.0: + dependencies: + fs-monkey: 1.1.0 + unist-builder@4.0.0: dependencies: '@types/unist': 3.0.3 @@ -20878,6 +22149,12 @@ snapshots: '@unrs/resolver-binding-win32-ia32-msvc': 1.12.2 '@unrs/resolver-binding-win32-x64-msvc': 1.12.2 + update-browserslist-db@1.2.3(browserslist@4.28.7): + dependencies: + browserslist: 4.28.7 + escalade: 3.2.0 + picocolors: 1.1.1 + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -20939,13 +22216,13 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite-node@3.2.4(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3): + vite-node@3.2.4(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) transitivePeerDependencies: - '@types/node' - jiti @@ -20960,13 +22237,13 @@ snapshots: - tsx - yaml - vite-node@3.2.4(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): + vite-node@3.2.4(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: cac: 6.7.14 debug: 4.4.3(supports-color@8.1.1) es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3) transitivePeerDependencies: - '@types/node' - jiti @@ -20981,7 +22258,44 @@ snapshots: - tsx - yaml - vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3): + vite-node@3.2.4(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3(supports-color@8.1.1) + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0): + dependencies: + esbuild: 0.27.4 + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + postcss: 8.5.8 + rollup: 4.60.1 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 25.5.0 + fsevents: 2.3.3 + jiti: 1.21.7 + terser: 5.49.0 + tsx: 4.21.0 + yaml: 2.9.0 + + vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -20993,10 +22307,11 @@ snapshots: '@types/node': 25.5.0 fsevents: 2.3.3 jiti: 2.7.0 + terser: 5.49.0 tsx: 4.21.0 yaml: 2.8.3 - vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): + vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: esbuild: 0.27.4 fdir: 6.5.0(picomatch@4.0.4) @@ -21008,14 +22323,57 @@ snapshots: '@types/node': 25.5.0 fsevents: 2.3.3 jiti: 2.7.0 + terser: 5.49.0 tsx: 4.21.0 yaml: 2.9.0 - vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3(supports-color@8.1.1) + expect-type: 1.3.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.1(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@25.5.0)(jiti@1.21.7)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/debug': 4.1.13 + '@types/node': 25.5.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -21033,8 +22391,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) - vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.8.3) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3) + vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -21053,11 +22411,11 @@ snapshots: - tsx - yaml - vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0)) + '@vitest/mocker': 3.2.4(vite@7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -21075,8 +22433,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) - vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.7.0)(tsx@4.21.0)(yaml@2.9.0) + vite: 7.3.1(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@25.5.0)(jiti@2.7.0)(terser@5.49.0)(tsx@4.21.0)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -21110,6 +22468,10 @@ snapshots: walk-up-path@4.0.0: {} + watchpack@2.5.2: + dependencies: + graceful-fs: 4.2.11 + wcwidth@1.0.1: dependencies: defaults: 1.0.4 @@ -21134,6 +22496,44 @@ snapshots: webidl-conversions@3.0.1: {} + webpack-sources@3.5.1: {} + + webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22): + dependencies: + '@types/estree': 1.0.9 + '@types/json-schema': 7.0.15 + '@webassemblyjs/ast': 1.14.1 + '@webassemblyjs/wasm-edit': 1.14.1 + '@webassemblyjs/wasm-parser': 1.14.1 + acorn: 8.16.0 + browserslist: 4.28.7 + chrome-trace-event: 1.0.4 + enhanced-resolve: 5.24.5 + es-module-lexer: 2.3.1 + eslint-scope: 5.1.1 + events: 3.3.0 + graceful-fs: 4.2.11 + mime-db: 1.54.0 + minimizer-webpack-plugin: 5.6.1(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22)(webpack@5.109.2(@swc/core@1.15.47(@swc/helpers@0.5.21))(postcss@8.5.22)) + neo-async: 2.6.2 + schema-utils: 4.3.3 + tapable: 2.3.3 + watchpack: 2.5.2 + webpack-sources: 3.5.1 + transitivePeerDependencies: + - '@minify-html/node' + - '@swc/core' + - '@swc/css' + - '@swc/html' + - clean-css + - cssnano + - csso + - esbuild + - html-minifier-terser + - lightningcss + - postcss + - uglify-js + whatwg-url@5.0.0: dependencies: tr46: 0.0.3 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8ae83ad74..4df469038 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,5 @@ packages: - "packages/*" - - "examples/*" - "v2/*" overrides: diff --git a/scripts/architecture/gen-configs.mjs b/scripts/architecture/gen-configs.mjs index fd0cd9655..6ba7e8721 100644 --- a/scripts/architecture/gen-configs.mjs +++ b/scripts/architecture/gen-configs.mjs @@ -336,6 +336,14 @@ const packageDefinitions = { maxPublicReexports: 15, minPublicFacadeModules: 16, minFolderReadmeChildren: 100, + folderChildCountOverrides: [ + { + folder: "cluster", + maxChildren: 16, + reason: + "Cluster is one subsystem whose children each name a step of a run's life: scaffold, cohort, reclaim, watch, install, bootstrap, and submit, plus its two vendor adapters and the controller that runs in-cluster", + }, + ], facadeFiles: [ { file: "network.ts", @@ -348,7 +356,7 @@ const packageDefinitions = { "Published ledger contract for records, storage, live runs, and offline inspection", }, { - file: "runtime.ts", + file: "agents.ts", reason: "Published runtime contract for autonomous agents, keyed rosters, and shipped runtime implementations", }, @@ -363,12 +371,12 @@ const packageDefinitions = { "Closed kernel event catalog and producer-bound event writer contracts", }, { - file: "kernel/event-services.ts", + file: "run/events.ts", reason: "Definition-bound Effect services for readable ledgers and customer-owned event emission", }, { - file: "ledger/model.ts", + file: "ledger/schema.ts", reason: "Durable record, manifest, completion, digest, and ledger-reference model", }, @@ -378,34 +386,69 @@ const packageDefinitions = { "Storage port that keeps allocation, append, completion, and reading independent of the filesystem implementation", }, { - file: "ledger/live.ts", + file: "ledger/append.ts", reason: "Live-ledger boundary for ordered append, failure latching, completion, and typed event streams", }, { - file: "ledger/open.ts", + file: "ledger/read.ts", reason: "Completed-ledger validation and offline opening boundary", }, { - file: "kernel/link-fabric.ts", + file: "run/link-fabric.ts", reason: "Link-fabric boundary coupling the platform link driver, receiver registration, and the policy interpreter", }, { - file: "kernel/outcomes.ts", + file: "run/outcomes.ts", reason: "Causal outcome conversion shared by runtime, router, and program lifecycle modules", }, { - file: "kernel/router.ts", + file: "run/router.ts", reason: "Router lifecycle boundary coupling scoped acquisition and shutdown with durable causal outcomes", }, { - file: "kernel/run.ts", + file: "run/execute.ts", reason: "Run boundary composing definitions, scoped resources, lifecycle outcomes, and the customer Effect", }, + { + file: "cluster/cluster.ts", + reason: + "Cluster seam the run kernel acquires: the platform port plus the society and slot shapes every implementation satisfies", + }, + { + file: "cluster/submit.ts", + reason: + "Submission boundary shared by the local and GKE profiles, owning run identity and the sanitized failure they both report", + }, + { + file: "cluster/temporal.ts", + reason: + "The package's only Temporal adapter: worker, client, activity, and workflow bindings behind one Promise boundary", + }, + { + file: "cluster/kubernetes/calls.ts", + reason: + "The package's only Kubernetes API surface, wrapping a Promise-native client as typed Effects", + }, + { + file: "cluster/kubernetes/objects.ts", + reason: + "Every Kubernetes object the cluster creates, kept beside the calls that submit them", + }, + { + file: "cluster/controller/configuration.ts", + reason: + "Closed environment contract decoded once at the in-cluster controller boundary", + }, + { + file: "definition.ts", + reason: + "Public authoring surface composing catalogs, roster, cluster layer, and the customer Effect into one runnable spec", + }, { file: "network/endpoint.ts", reason: @@ -431,53 +474,52 @@ const packageDefinitions = { "Router port, framed message model, connection contract, and typed network failures", }, { - file: "network/server.ts", + file: "network/server/process.ts", reason: "Scoped MoltZap server ownership for image, storage, process, observation, and identity resources", }, { - file: "runtime/runtime.ts", + file: "agents/agent.ts", reason: "Autonomous participant lifecycle contract implemented by every runtime family", }, { - file: "runtime/roster.ts", + file: "agents/roster.ts", reason: "Keyed mixed-runtime roster preserving each agent's acquisition errors and Effect requirements", }, { - file: "runtime/process.ts", - reason: - "Scoped process bridge shared by the external runtime implementations", - }, - { - file: "runtime/packages.ts", + file: "network/server/packages.ts", reason: "Runtime package discovery and install-policy boundary shared by shipped runtime families", }, + ], + layers: [ { - file: "runtime/nanoclaw/install.ts", - reason: - "NanoClaw installation boundary composing source acquisition, package assets, and dependency materialization", - }, - { - file: "runtime/openclaw/process.ts", + name: "controller", + folders: ["cluster/controller"], reason: - "OpenClaw process boundary composing workspace setup, channel materialization, gateway configuration, port ownership, and supervised lifetime", + "The in-cluster executable that loads one spec and invokes the run kernel, so it depends on the kernel while nothing depends on it", }, - ], - layers: [ { - name: "kernel", - folders: ["kernel"], + name: "run", + folders: ["run"], reason: "The run kernel orchestrates capability contracts without becoming a dependency of them", }, { name: "capabilities", - folders: ["events", "ledger", "network", "runtime"], - reason: - "Peer event, ledger, network, and runtime capabilities compose through typed ports and do not form a truthful linear stack", + folders: [ + "events", + "ledger", + "network", + "agents", + "cluster", + "cluster/kubernetes", + "cluster/profiles", + ], + reason: + "Peer event, ledger, network, agent, and cluster capabilities compose through typed ports and do not form a truthful linear stack; each exposes a port the run kernel requires and hides its adapters behind it", }, ], }, diff --git a/scripts/test/simulator-packages.mjs b/scripts/test/simulator-packages.mjs index 914b42bfa..a590374bf 100644 --- a/scripts/test/simulator-packages.mjs +++ b/scripts/test/simulator-packages.mjs @@ -1,5 +1,5 @@ import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; import { mkdir, mkdtemp, @@ -17,6 +17,38 @@ const exec = promisify(execFile); const workspaceRoot = dirname(dirname(dirname(fileURLToPath(import.meta.url)))); const packageRoot = join(workspaceRoot, "packages", "simulator"); const temporaryRoot = await mkdtemp(join(tmpdir(), "moltzap-simulator-pack-")); +const forbiddenSimulatorPaths = [ + "scripts/build-server-image.mjs", + "server-image/Dockerfile", + "server-image/moltzap.yaml", + "src/layer.ts", + "src/network/server.ts", + "src/network/server-image.ts", + "src/agents/cache.ts", + "src/agents/effect.ts", + "src/agents/nanoclaw/install.ts", + "src/agents/nanoclaw/onecli.ts", + "src/agents/nanoclaw/process.ts", + "src/agents/openclaw/cache.ts", + "src/agents/openclaw/process.ts", +]; +const forbiddenStandaloneWorkspacePaths = [ + "examples/simulator/README.md", + "examples/simulator/hello.ts", + "examples/simulator/openclaw-container.mjs", + "examples/simulator/openclaw-container.test.mjs", + "examples/simulator/openclaw-image.json", + "examples/simulator/package.json", + "examples/simulator/tsconfig.json", +]; +const standaloneWorkspaceControlFiles = [ + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", + "knip.json", + "tools/workspace/project.json", + ".github/workflows/ci.yml", +]; function requireCondition(condition, detail) { if (!condition) { @@ -24,6 +56,58 @@ function requireCondition(condition, detail) { } } +function isMissing(cause) { + return ( + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "ENOENT" + ); +} + +async function requirePathMissing(root, relativePath, detail) { + try { + await access(join(root, relativePath)); + } catch (cause) { + if (isMissing(cause)) { + return; + } + throw cause; + } + throw new Error(detail); +} + +async function verifyRepositoryCutover() { + await Promise.all( + forbiddenStandaloneWorkspacePaths.map((relativePath) => + requirePathMissing( + workspaceRoot, + relativePath, + `standalone simulator workspace path remains: ${relativePath}`, + ), + ), + ); + await Promise.all( + forbiddenSimulatorPaths.map((relativePath) => + requirePathMissing( + packageRoot, + relativePath, + `obsolete simulator path remains in the repository: ${relativePath}`, + ), + ), + ); + await Promise.all( + standaloneWorkspaceControlFiles.map(async (relativePath) => { + const source = await readFile(join(workspaceRoot, relativePath), "utf8"); + requireCondition( + !source.includes("examples/simulator") && + !source.includes("simulator-example"), + `standalone simulator workspace remains configured in ${relativePath}`, + ); + }), + ); +} + async function packedTarball() { const { stdout } = await exec( "pnpm", @@ -48,13 +132,10 @@ async function verifyPackedFiles(extractedPackage) { "dist/network.d.ts", "dist/ledger.js", "dist/ledger.d.ts", - "dist/runtime.js", - "dist/runtime.d.ts", + "dist/agents.js", + "dist/agents.d.ts", "dist/nanoclaw-assets/SKILL.md", "dist/nanoclaw-assets/moltzap.ts", - "scripts/build-server-image.mjs", - "server-image/Dockerfile", - "server-image/moltzap.yaml", ]; await Promise.all( required.map(async (relativePath) => { @@ -66,14 +147,23 @@ async function verifyPackedFiles(extractedPackage) { }); }), ); + await Promise.all( + forbiddenSimulatorPaths.map((relativePath) => + requirePathMissing( + extractedPackage, + relativePath, + `packed simulator contains obsolete path ${relativePath}`, + ), + ), + ); const manifest = JSON.parse( await readFile(join(extractedPackage, "package.json"), "utf8"), ); requireCondition( JSON.stringify(Object.keys(manifest.exports)) === - JSON.stringify([".", "./network", "./ledger", "./runtime"]), - "packed simulator exports must be root, network, ledger, and runtime", + JSON.stringify([".", "./network", "./ledger", "./agents"]), + "packed simulator exports must be root, network, ledger, and agents", ); } @@ -94,12 +184,18 @@ async function verifyConsumerImports(extractedPackage) { 'import * as simulator from "@moltzap/simulator";', 'import * as network from "@moltzap/simulator/network";', 'import * as ledger from "@moltzap/simulator/ledger";', - 'import * as runtime from "@moltzap/simulator/runtime";', - 'for (const name of ["simulator", "simulatorLayer"]) {', + 'import * as agents from "@moltzap/simulator/agents";', + 'for (const name of ["Run", "RunSpec"]) {', " if (!(name in simulator)) throw new Error(`missing root export ${name}`);", "}", - 'for (const name of ["defineRuntime", "effectRuntime", "openClawRuntime", "nanoclawRuntime"]) {', - " if (!(name in runtime)) throw new Error(`missing runtime export ${name}`);", + 'for (const name of ["defineContainerRuntime", "openClawRuntime", "nanoclawRuntime"]) {', + " if (!(name in agents)) throw new Error(`missing agents export ${name}`);", + "}", + 'for (const name of ["simulator", "simulatorLayer"]) {', + " if (name in simulator) throw new Error(`obsolete root export ${name}`);", + "}", + 'for (const name of ["defineRuntime", "effectRuntime"]) {', + " if (name in agents) throw new Error(`obsolete agents export ${name}`);", "}", 'if (!("RouterProvider" in network)) throw new Error("missing network RouterProvider");', 'if (!("LedgerStorage" in ledger)) throw new Error("missing ledger LedgerStorage");', @@ -110,6 +206,7 @@ async function verifyConsumerImports(extractedPackage) { } try { + await verifyRepositoryCutover(); const tarball = await packedTarball(); const extractedRoot = join(temporaryRoot, "extracted"); await mkdir(extractedRoot); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json index ca834e27b..d7a7a9202 100644 --- a/tsconfig.eslint.json +++ b/tsconfig.eslint.json @@ -7,6 +7,7 @@ "noEmit": true }, "include": [ - "*.ts" + "*.ts", + "examples/**/*.ts" ] }