diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 40222f04c..b2ee20ce7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ jobs: run: | pnpm nx run @moltzap/simulator:local-profile-check pnpm nx run @moltzap/simulator:gke-profile-check + pnpm nx run @moltzap/simulator:nanoclaw-image-check pnpm nx run @moltzap/evals:phoenix-terraform-check - run: pnpm typecheck - run: pnpm lint diff --git a/docs/modules/simulator/src.mdx b/docs/modules/simulator/src.mdx index 25c03e176..663a0c760 100644 --- a/docs/modules/simulator/src.mdx +++ b/docs/modules/simulator/src.mdx @@ -738,6 +738,29 @@ export class IncompleteLedgerReceipt extends Schema.TaggedClass + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const producer = `packages/simulator/scripts/${script}`; + + for (const detail of [missingImageDetail(key), invalidImageDetail(key)]) { + assert.include(detail, key); + assert.include(detail, producer); + assert.include(detail, "pinnedImage"); + } + + // A named script that does not exist is worse than no remedy at all. + assert.isTrue( + yield* fileSystem.exists( + fileURLToPath(new URL(`../../../${producer}`, import.meta.url)), + ), + `${producer} does not exist`, + ); + }).pipe(Effect.provide(NodeContext.layer)), +); + +// Both infrastructure attempts already carry the operator-facing account of a +// failure, and phoenix-run publishes exactly that field as the run's error, so +// the controller's own account belongs in it rather than beside it. +effect.each([undefined, DIAGNOSTIC])( + "gives a failed run the controller account %s", + (diagnostic?: string) => + Effect.gen(function* () { + const attempt = yield* runInfrastructureFailed( + attemptContext(), + RECEIPT, + diagnostic, + ); + + assert.strictEqual(attempt.detail, diagnostic ?? attempt.detail); + assert.isNotEmpty(attempt.detail); + if (diagnostic === undefined) { + assert.include(attempt.detail, "infrastructure failure"); + } + }), +); + +effect.each([undefined, DIAGNOSTIC])( + "gives a failed allocation the controller account %s", + (diagnostic?: string) => + Effect.gen(function* () { + const attempt = yield* ledgerAllocationFailed( + attemptContext(), + diagnostic, + ); + + assert.strictEqual( + attempt.failure.detail, + diagnostic ?? attempt.failure.detail, + ); + assert.isNotEmpty(attempt.failure.detail); + if (diagnostic === undefined) { + assert.include(attempt.failure.detail, "durable ledger"); + } + }), +); + +// Without a controller account the two attempts still have to be told apart: +// a run that never got a ledger and a run that lost its cluster are different +// operator problems, and the fallback text is all that says which happened. +it("distinguishes the two infrastructure failures when neither left an account", () => + Effect.gen(function* () { + const context = attemptContext(); + const failedRun = yield* runInfrastructureFailed(context, RECEIPT); + const failedAllocation = yield* ledgerAllocationFailed(context); + + assert.notStrictEqual(failedRun.detail, failedAllocation.failure.detail); + })); diff --git a/packages/evals/src/cli.ts b/packages/evals/src/cli.ts index 5361d1626..239d1c39f 100644 --- a/packages/evals/src/cli.ts +++ b/packages/evals/src/cli.ts @@ -4,7 +4,11 @@ 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 { + isEntryModule, + type CompletedLedgerReceipt, + type LedgerReceipt, +} from "@moltzap/simulator"; import { LedgerStorageError, type CompletedLedgerArtifacts, @@ -83,6 +87,7 @@ import { type EvaluationSweepCell, } from "./sweep.js"; import { + submissionDiagnostic, submitEvaluationCell, type EvaluationSubmissionResult, type SimulatorProfile, @@ -162,7 +167,8 @@ interface EvaluationExecutionImages { readonly nanoclawApplicationImage: Image; } -interface AttemptContext { +/** One cell's identity while its attempt is being produced. */ +export interface AttemptContext { readonly cell: EvaluationSweepCell; readonly definition: BundledEvaluationCase; readonly startedAt: DateTime.Utc; @@ -483,33 +489,56 @@ function completeExecution( }); } -function ledgerAllocationFailed(context: AttemptContext) { +// Both infrastructure attempts already own a `detail`, and Phoenix already +// publishes it as a run's error. The controller's own account of the failure +// belongs there rather than beside it: a canned sentence is what an operator +// reads today, and it says only that something went wrong. +const ALLOCATION_FAILED_DETAIL = + "the simulator controller could not allocate its durable ledger"; +const RUN_FAILED_DETAIL = + "the simulator controller reported an infrastructure failure"; + +/** + * Record that ledger allocation failed, with whatever account the run left. + * @param context Cell identity and the instant execution began. + * @param diagnostic The controller's own account, when it produced one. + * @returns The terminal attempt this cell commits. + */ +export function ledgerAllocationFailed( + context: AttemptContext, + diagnostic?: string, +) { 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", + detail: diagnostic ?? ALLOCATION_FAILED_DETAIL, }), }), ), ); } -function runInfrastructureFailed( +/** + * Record an infrastructure failure, with whatever account the run left. + * @param context Cell identity and the instant execution began. + * @param receipt Durable evidence the controller retained. + * @param diagnostic The controller's own account, when it produced one. + * @returns The terminal attempt this cell commits. + */ +export function runInfrastructureFailed( context: AttemptContext, - receipt: EvaluationSubmissionResult["result"]["summary"] & { - readonly _tag: "ClusterLost"; - }, + receipt: LedgerReceipt, + diagnostic?: string, ) { return DateTime.now.pipe( Effect.map((completedAt) => RunFailedAttempt.make({ ...terminalFields(context, completedAt), - receipt: receipt.receipt, - detail: "the simulator controller reported an infrastructure failure", + receipt, + detail: diagnostic ?? RUN_FAILED_DETAIL, }), ), ); @@ -572,11 +601,12 @@ function completeSubmission( submission: EvaluationSubmissionResult, ) { const summary = submission.result.summary; + const diagnostic = submissionDiagnostic(submission); if (summary._tag === "LedgerAllocationFailed") { - return ledgerAllocationFailed(context); + return ledgerAllocationFailed(context, diagnostic); } if (summary._tag === "ClusterLost") { - return runInfrastructureFailed(context, summary); + return runInfrastructureFailed(context, summary.receipt, diagnostic); } return readCompletedArtifacts( environment, @@ -712,41 +742,66 @@ function requiredEnvironment(key: string) { ); } +/** Environment key naming one digest-pinned image an evaluation run needs. */ +export type EvaluationImageKey = + | "MOLTZAP_CONTROLLER_IMAGE" + | "MOLTZAP_SUPPORT_IMAGE" + | "MOLTZAP_NANOCLAW_IMAGE"; + +// Nothing else in the repository produces these references, so a missing one is +// an operator who has not run the producer yet rather than one who forgot to +// export a value they already had. Naming the producer is the whole remedy. +const imageProducer: Readonly> = { + MOLTZAP_CONTROLLER_IMAGE: + "packages/simulator/scripts/build-controller-image.mjs", + MOLTZAP_SUPPORT_IMAGE: + "packages/simulator/scripts/build-controller-image.mjs", + MOLTZAP_NANOCLAW_IMAGE: "packages/simulator/scripts/build-nanoclaw-image.mjs", +}; + +/** + * Say which producer builds an evaluation image the environment omitted. + * @param key Environment key the run could not read. + * @returns The operator-facing requirement, naming the producing script. + */ +export function missingImageDetail(key: EvaluationImageKey): string { + return `${key} is required for evaluation execution; build it with ${imageProducer[key]} and pass the printed pinnedImage`; +} + +/** + * Say which producer prints the pinned form of a rejected evaluation image. + * @param key Environment key whose value was not digest-pinned. + * @returns The operator-facing requirement, naming the producing script. + */ +export function invalidImageDetail(key: EvaluationImageKey): string { + return `${key} must be a lowercase SHA-256 digest-pinned image; ${imageProducer[key]} prints one as pinnedImage`; +} + function distributedApplicationImage( - key: - | "MOLTZAP_CONTROLLER_IMAGE" - | "MOLTZAP_SUPPORT_IMAGE" - | "MOLTZAP_NANOCLAW_IMAGE", + key: EvaluationImageKey, 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`, - }), + EvaluationSourceStateError.make({ detail: invalidImageDetail(key) }), ), ); } +function requiredImage(key: EvaluationImageKey) { + return Config.string(key).pipe( + Effect.mapError(() => + EvaluationSourceStateError.make({ detail: missingImageDetail(key) }), + ), + Effect.flatMap((value: string) => distributedApplicationImage(key, value)), + ); +} + 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), - ), - ), + controllerImage: requiredImage("MOLTZAP_CONTROLLER_IMAGE"), + peerApplicationImage: requiredImage("MOLTZAP_SUPPORT_IMAGE"), + nanoclawApplicationImage: requiredImage("MOLTZAP_NANOCLAW_IMAGE"), }); } @@ -957,5 +1012,14 @@ const cli = CliCommand.run(evaluationCommand, { version: CLI_VERSION, }); -// eslint-disable-next-line agent-code-guard/prefer-effect-platform -- @effect/cli owns argv decoding at the process boundary. -cli(process.argv).pipe(Effect.provide(NodeContext.layer), NodeRuntime.runMain); +// Guarded so this module can be imported: without it, reading any value here +// runs the CLI against the importer's argv, which is why the operator-facing +// vocabulary above had to live in a module that does not read it. +// 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 -- @effect/cli owns argv decoding at the process boundary. + cli(process.argv).pipe( + Effect.provide(NodeContext.layer), + NodeRuntime.runMain, + ); +} diff --git a/packages/evals/src/submission.test.ts b/packages/evals/src/submission.test.ts index 2f2f7c289..56b74b8bd 100644 --- a/packages/evals/src/submission.test.ts +++ b/packages/evals/src/submission.test.ts @@ -12,8 +12,10 @@ import { type EvaluationConditionName, } from "./model.js"; import { + decodeSubmissionOutput, evaluationControllerModule, simulatorProfileEntrypoint, + submissionDiagnostic, type SimulatorProfile, type SubmitEvaluationCellInput, } from "./submission.js"; @@ -101,3 +103,53 @@ effect.each(["local", "gke"] as const)( assert.include(scripts, simulatorProfileEntrypoint(profile).join("/")); }).pipe(Effect.provide(NodeContext.layer)), ); + +// Exactly what the simulator's submitter prints for a cluster-lost cell, so a +// consumer that stops accepting the real line fails here first. +function submitterLine(diagnostic?: string): string { + return JSON.stringify({ + runId: "mz-0123456789abcdef0123456789abcdef", + namespace: "mz-0123456789abcdef0123456789abcdef", + result: { + exitCode: 1, + summary: { + _tag: "ClusterLost", + receipt: { + _tag: "IncompleteLedgerReceipt", + ledger: "eval-006-nanoclaw-1", + }, + }, + ...(diagnostic === undefined ? {} : { diagnostic }), + }, + }); +} + +const CARRIED_DIAGNOSTIC = "controller Job failed\nreason"; +const OVERSIZED_DIAGNOSTIC_LENGTH = 32_768; + +effect.each([undefined, CARRIED_DIAGNOSTIC])( + "decodes a submitter result whose diagnostic is %s", + (diagnostic?: string) => + Effect.gen(function* () { + const decoded = yield* decodeSubmissionOutput(submitterLine(diagnostic)); + + assert.strictEqual(submissionDiagnostic(decoded), diagnostic); + }), +); + +// The rest of that line is the run's only receipt. Refusing an over-long +// diagnostic would discard it, turning a cell that failed with evidence into a +// cell with no attempt at all. +it("keeps the receipt when a submitter diagnostic exceeds the bound", () => + Effect.gen(function* () { + const decoded = yield* decodeSubmissionOutput( + submitterLine("x".repeat(OVERSIZED_DIAGNOSTIC_LENGTH)), + ); + + // The receipt survives; only the decoration is trimmed. + assert.isTrue("receipt" in decoded.result.summary); + assert.isBelow( + (submissionDiagnostic(decoded) ?? "").length, + OVERSIZED_DIAGNOSTIC_LENGTH, + ); + })); diff --git a/packages/evals/src/submission.ts b/packages/evals/src/submission.ts index 4599901b9..2f126a1f3 100644 --- a/packages/evals/src/submission.ts +++ b/packages/evals/src/submission.ts @@ -44,6 +44,11 @@ const runInfrastructureFailedSummary = Schema.Struct({ const ledgerAllocationFailedSummary = Schema.Struct({ _tag: Schema.Literal("LedgerAllocationFailed"), }); +// Bounded here rather than in the schema, because the diagnostic is decoration +// on a line whose other fields are the run's only receipt: a producer that +// overshoots must not be able to take the receipt down with it. The submitter +// bounds what it publishes; this bounds what a report will hold. +const DIAGNOSTIC_MAX_LENGTH = 8_192; const evaluationSubmissionResult = Schema.Struct({ runId: Schema.NonEmptyString, namespace: Schema.NonEmptyString, @@ -58,12 +63,31 @@ const evaluationSubmissionResult = Schema.Struct({ runInfrastructureFailedSummary, ledgerAllocationFailedSummary, ), + // Optional because the submitter carries one only when the controller + // Job's own output was still readable, and this decode rejects excess + // properties: a submitter that never learned the reason still decodes. + diagnostic: Schema.optional(Schema.String), }), ), }); /** Decoded result printed by the simulator's local or GKE submitter. */ export type EvaluationSubmissionResult = typeof evaluationSubmissionResult.Type; +/** + * The controller's own account of why one submission failed. + * @param submission Decoded submitter result for one cell. + * @returns Bounded diagnostic text, or undefined when the cell carried none. + */ +export function submissionDiagnostic( + submission: EvaluationSubmissionResult, +): string | undefined { + const diagnostic = + submission.result.exitCode === 1 ? submission.result.diagnostic : undefined; + return diagnostic === undefined || diagnostic.length === 0 + ? undefined + : diagnostic.slice(-DIAGNOSTIC_MAX_LENGTH); +} + /** A repository-local cell could not be submitted or decoded. */ export class EvaluationSubmissionFailed extends Schema.TaggedError()( "EvaluationSubmissionFailed", @@ -152,7 +176,17 @@ function commandFailure(cause: unknown): EvaluationSubmissionFailed { }); } -function decodeSubmissionOutput( +/** + * Decode the final result line the simulator's submitter printed. + * + * Exported so the stdout contract can be pinned directly: the submitter is a + * spawned process, so nothing else in this package would notice the two sides + * disagreeing until a live sweep produced an undecodable line. + * + * @param output Complete captured stdout of one submitter process. + * @returns The decoded result, from the last line that is one. + */ +export function decodeSubmissionOutput( output: string, ): Effect.Effect { const lines = output.split(/\r?\n/u); diff --git a/packages/simulator/gke/README.md b/packages/simulator/gke/README.md index 637cf462c..5cee414f8 100644 --- a/packages/simulator/gke/README.md +++ b/packages/simulator/gke/README.md @@ -19,6 +19,9 @@ costs, because creating the cluster is slow and keeping nodes is expensive: | --- | --- | --- | | `./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 run SPEC.mjs` | submit one RunSpec | run-sized | +| `./cluster.sh evals ARG...` | run an evaluation sweep against this cluster | sweep-sized | +| `./cluster.sh publish-image` | publish the controller image and print its digest | ~3 min | | `./cluster.sh down` | park the controller | ~1 min | | `./cluster.sh delete` | destroy the substrate | ~8 min | @@ -89,9 +92,32 @@ 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. +## Temporal + +`setup` applies the same experiment-grade Temporal deployment the local profile +uses, into `moltzap-system`. Production hosting and high availability remain +deliberately unselected; this is a single Deployment sized for experiments. + +Nothing publishes it. `run` and `evals` open a supervised port-forward and set +`MOLTZAP_TEMPORAL_ADDRESS` to it themselves, replacing a dropped forward for as +long as the run lasts. An operator driving `dist/cluster/profiles/gke.js` +directly supplies that address instead. + +The in-cluster run worker reaches Temporal by a different route than the +operator does — a `localhost` port-forward means nothing inside a Pod — so the +worker's endpoint is configured separately: + +| variable | read by | selects | +| --- | --- | --- | +| `MOLTZAP_TEMPORAL_ADDRESS` | the submitting process | how *this host* reaches Temporal | +| `MOLTZAP_TEMPORAL_CLUSTER_ADDRESS` | the worker Deployment the submission installs | how the *cluster* reaches Temporal | + +`MOLTZAP_TEMPORAL_CLUSTER_ADDRESS` is optional and defaults to the in-cluster +service the local profile installs, which is the one `setup` applies here too. +Set it only when this cluster's Temporal is a different deployment; pointing it +at an address the worker Pod cannot resolve leaves submissions pending with no +error, because a worker that never connects is indistinguishable from a queue +with nothing on it. ## Immutable simulator image @@ -117,6 +143,36 @@ 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. +`./cluster.sh publish-image` performs the publish and prints only the digest +reference, so it can be assigned directly: + +```bash +MOLTZAP_CONTROLLER_IMAGE="$(packages/simulator/gke/cluster.sh publish-image)" +``` + +## Evaluation sweeps + +`./cluster.sh evals` publishes the controller image, holds the Temporal +forward, exports every identity above, and hands the rest of its arguments to +[`@moltzap/evals`](../../evals/README.md) with `--profile gke`: + +```bash +OPENAI_API_KEY=... \ +ANTHROPIC_API_KEY=... \ +MOLTZAP_NANOCLAW_IMAGE="$(node packages/simulator/scripts/build-nanoclaw-image.mjs \ + | node -e 'process.stdin.on("data",(d)=>process.stdout.write(JSON.parse(d).pinnedImage))')" \ +packages/simulator/gke/cluster.sh evals \ + --report-id baseline-2026-08-06 \ + --openclaw-model "$OPENCLAW_MODEL" \ + --nanoclaw-model "$NANOCLAW_MODEL" +``` + +The NanoClaw image is passed through rather than built by the verb: it is the +agent runtime under evaluation, not this cluster's infrastructure, and pushing +it to this registry is the caller's choice. Every other image and endpoint the +sweep needs is derived from the cluster the verb just attached to, so the two +cannot disagree about which cluster is being measured. + ## Private platform contract `profile.json` is the private handoff consumed by the GKE infrastructure @@ -149,12 +205,18 @@ 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. +A hundred-agent society run has completed on this profile through +`Run.execute`. That is the decision log's claim, not one a reader can check +from a checkout: the run's exported ledger is retained nowhere in the +repository, as +[the execution trajectory](../../../docs/decision-evidence/20260801-main-kubernetes-society-execution-trajectory.md) +records. + +The ADR's GKE gate is therefore still open on its evaluation half. Do not claim +it until one OpenClaw and one NanoClaw evaluation complete through +`./cluster.sh evals`, their ledgers are readable in the artifact bucket, +run-owned Kubernetes residue is zero, and that evidence is retained where a +reader can find it. Static validation does not contact Google Cloud or a Kubernetes cluster: diff --git a/packages/simulator/gke/cluster.sh b/packages/simulator/gke/cluster.sh index dcc4ff2eb..91462d45b 100755 --- a/packages/simulator/gke/cluster.sh +++ b/packages/simulator/gke/cluster.sh @@ -6,11 +6,13 @@ set -euo pipefail readonly profile_root="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" readonly simulator_root="$(cd "$profile_root/.." && pwd)" +readonly workspace_root="$(cd "$simulator_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 + echo "usage: $0 (setup|up|run SPEC|evals [ARG...]|publish-image|down|delete)" >&2 + echo " [--delete-artifacts]" >&2 exit 64 } @@ -20,6 +22,14 @@ shift delete_artifacts=false run_spec="" +evals_args=() +# Everything after `evals` belongs to the evaluation CLI, which owns its own +# option vocabulary. Parsing it here would fork that vocabulary, and the first +# option this script did not know about would be rejected by the wrong program. +if [[ "$command" == "evals" ]]; then + evals_args=("$@") + set -- +fi while [[ $# -gt 0 ]]; do case "$1" in --delete-artifacts) delete_artifacts=true ;; @@ -31,7 +41,7 @@ while [[ $# -gt 0 ]]; do shift done -for executable in terraform gcloud kubectl helm docker node nc; do +for executable in terraform gcloud kubectl helm docker node nc corepack; do if ! command -v "$executable" >/dev/null 2>&1; then echo "required executable is unavailable: $executable" >&2 exit 69 @@ -127,6 +137,24 @@ open_temporal_forward() { done } +# Everything `run` and `evals` both need before they can reach the cluster: +# credentials, an immutable controller image, and a Temporal endpoint that +# survives a dropped forward. Sharing it is what keeps the trap in one place — +# a leaked port-forward still accepts connections while proxying to a pod that +# no longer exists, so a fix applied to one verb has to apply to both. +begin_cluster_session() { + 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" +} + discard_artifacts() { local bucket="$1" # The bucket refuses to be destroyed while it holds objects, so discarding @@ -162,6 +190,39 @@ case "$command" in gcloud auth configure-docker "$(registry_host)" --quiet echo echo "setup complete; submit a run with '$0 run SPEC.mjs'" + echo "or an evaluation sweep with '$0 evals --report-id REPORT_ID ...'" + ;; + + evals) + begin_cluster_session + + # Every identity the sweep cannot derive for itself, each one a property of + # the cluster just attached to, so the sweep and the operator cannot + # disagree about which cluster is being measured. MOLTZAP_NANOCLAW_IMAGE is + # deliberately absent: it is the runtime under evaluation rather than this + # cluster's infrastructure, so the caller's own value passes straight + # through. `scripts/build-nanoclaw-image.mjs` prints one. + export MOLTZAP_KUBE_CONTEXT="$(kubectl config current-context)" + export MOLTZAP_GKE_ARTIFACT_BUCKET="$(terraform_output artifact_bucket_name)" + export MOLTZAP_TEMPORAL_ADDRESS="localhost:${forward_port}" + export MOLTZAP_CONTROLLER_IMAGE="$controller_image" + export MOLTZAP_SUPPORT_IMAGE="$controller_image" + + # Not exec: the forward supervisor is this shell's background job, and + # replacing the shell would strand it with no trap left to reap it. Running + # in the foreground under errexit propagates the sweep's exit status after + # cleanup, which is what a caller reads anyway. + cd "$workspace_root" + # `${a[@]+"${a[@]}"}` rather than `"${a[@]}"`: under errexit an empty array + # is an unbound expansion on the bash macOS still ships. + corepack pnpm nx run @moltzap/evals:eval -- \ + --profile gke ${evals_args[@]+"${evals_args[@]}"} + ;; + + publish-image) + # The digest the profile's images must be pinned to, on stdout alone, so a + # caller can assign it: MOLTZAP_CONTROLLER_IMAGE="$(... publish-image)". + publish_controller_image ;; run) @@ -169,16 +230,7 @@ case "$command" in [[ -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" + begin_cluster_session cd "$simulator_root" MOLTZAP_KUBE_CONTEXT="$(kubectl config current-context)" \ diff --git a/packages/simulator/gke/profile.test.mjs b/packages/simulator/gke/profile.test.mjs index 14ef724bd..de8071aac 100644 --- a/packages/simulator/gke/profile.test.mjs +++ b/packages/simulator/gke/profile.test.mjs @@ -226,3 +226,46 @@ test("the GKE target enters the core Temporal path with explicit identities", as assert.match(entrypoint, /MOLTZAP_KUBE_CONTEXT/); assert.match(entrypoint, /MOLTZAP_TEMPORAL_ADDRESS/); }); + +test("the evals verb hands the sweep every identity it cannot derive", async () => { + const script = await read("cluster.sh"); + + assert.match(script, /^ {2}evals\)$/m); + assert.match(script, /^ {2}publish-image\)$/m); + // Derived from the cluster the verb attached to, so the sweep and the + // operator cannot disagree about which cluster is being measured. + for (const key of [ + "MOLTZAP_KUBE_CONTEXT", + "MOLTZAP_GKE_ARTIFACT_BUCKET", + "MOLTZAP_TEMPORAL_ADDRESS", + "MOLTZAP_CONTROLLER_IMAGE", + "MOLTZAP_SUPPORT_IMAGE", + ]) { + assert.match(script, new RegExp(`export ${key}`)); + } + // The runtime under evaluation is the caller's to choose, so the verb must + // not derive or re-export it. + assert.doesNotMatch(script, /export MOLTZAP_NANOCLAW_IMAGE/); + assert.match(script, /@moltzap\/evals:eval/); + assert.match(script, /--profile gke/); + // The forward supervisor is a background job of this shell; exec would strand + // it with no trap left to reap it. + assert.doesNotMatch(script, /exec corepack/); + // One prelude, so a trap fix cannot reach `run` and miss `evals`. + assert.equal(script.match(/begin_cluster_session/g)?.length, 3); + assert.equal( + script.match(/open_temporal_forward "\$forward_port"/g)?.length, + 1, + ); +}); + +test("the README describes the Temporal this profile actually installs", async () => { + const readme = await read("README.md"); + + assert.doesNotMatch(readme, /no Temporal deployment/); + assert.doesNotMatch(readme, /cannot prove live GKE qualification/); + // The one endpoint a reader cannot discover from any other file. + assert.match(readme, /MOLTZAP_TEMPORAL_CLUSTER_ADDRESS/); + assert.match(readme, /cluster\.sh evals/); + assert.match(readme, /cluster\.sh publish-image/); +}); diff --git a/packages/simulator/nanoclaw-image/Dockerfile b/packages/simulator/nanoclaw-image/Dockerfile new file mode 100644 index 000000000..9f0db88c0 --- /dev/null +++ b/packages/simulator/nanoclaw-image/Dockerfile @@ -0,0 +1,59 @@ +# The NanoClaw half of one evaluation cell: the pinned upstream checkout, the +# MoltZap channel that carries its social traffic, and the bootstrap entrypoint +# the simulator's container runtime executes. Every input is staged by +# scripts/build-nanoclaw-image.mjs, so the build context is content-addressed +# and this file never reaches the network for source. + +FROM node:22.22.0-bookworm-slim@sha256:dd9d21971ec4395903fa6143c2b9267d048ae01ca6d3ea96f16cb30df6187d94 AS nanoclaw + +# better-sqlite3 falls back to compiling from source when no prebuild matches +# the image's Node ABI, and a NanoClaw that cannot open its database is a run +# that fails after the cohort is already admitted. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates g++ make python3 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /build +COPY nanoclaw-source.tar.gz prepare.mjs ./ +COPY assets ./assets +COPY tarballs ./tarballs +RUN mkdir -p /build/app \ + && tar -xzf nanoclaw-source.tar.gz -C /build/app --strip-components=1 \ + && rm nanoclaw-source.tar.gz \ + && node prepare.mjs /build/app /build/assets /build/tarballs + +WORKDIR /build/app +# HUSKY=0 because upstream's `prepare` script installs git hooks, and the +# extracted tarball is not a repository. +ENV HUSKY=0 +# The final `rm -rf` drops what no agent turn reads and what is never small: +# upstream's marketing imagery is ~8.5MB, its translated docs another ~0.6MB, +# and better-sqlite3 keeps the whole SQLite C amalgamation beside the prebuild +# it actually loads. The agent pool autoscales from zero, so every retained byte +# is pulled onto a fresh node for every run. +RUN npm install --no-audit --no-fund \ + && npm run build \ + && test -f dist/index.js \ + && test -f dist/moltzap-eval-provision.js \ + && node_modules/.bin/tsx scripts/upgrade-state.ts set "" moltzap-simulator \ + && npm prune --omit=dev \ + && rm -rf vendor /root/.npm ./assets ./docs ./repo-tokens \ + ./README_*.md ./CHANGELOG.md node_modules/better-sqlite3/deps + +FROM node:22.22.0-bookworm-slim@sha256:dd9d21971ec4395903fa6143c2b9267d048ae01ca6d3ea96f16cb30df6187d94 + +# NanoClaw executes every agent turn in a container it spawns itself and refuses +# to start when no runtime answers, so the client ships here and the platform +# supplies the endpoint through DOCKER_HOST. +COPY --from=docker.io/library/docker:cli@sha256:27a51d5ab1cd38d9eeaba7b415b8c07bc10c31e1cf1ec8d78f6413fcfab3f44f \ + /usr/local/bin/docker /usr/local/bin/docker + +ENV NODE_ENV=production +COPY --from=nanoclaw --chown=node:node /build/app /opt/moltzap/nanoclaw/app +COPY --chown=node:node entrypoint.mjs /opt/moltzap/nanoclaw/entrypoint.mjs +RUN mkdir -p /var/lib/moltzap/nanoclaw \ + && chown -R node:node /var/lib/moltzap/nanoclaw + +USER node +WORKDIR /var/lib/moltzap/nanoclaw +ENTRYPOINT ["node", "/opt/moltzap/nanoclaw/entrypoint.mjs"] diff --git a/packages/simulator/nanoclaw-image/entrypoint.mjs b/packages/simulator/nanoclaw-image/entrypoint.mjs new file mode 100644 index 000000000..aa64f061d --- /dev/null +++ b/packages/simulator/nanoclaw-image/entrypoint.mjs @@ -0,0 +1,263 @@ +// The MoltZap application entrypoint of the NanoClaw image: the process the +// simulator's container runtime starts, and the only thing in the image that +// knows the bootstrap contract in `src/agents/nanoclaw/runtime.ts`. +// +// It reads the `moltzap.nanoclaw-application/v1` bootstrap config, materializes +// a writable NanoClaw project root, seeds the eval agent group, starts NanoClaw, +// and republishes NanoClaw's owner-local CLI socket on the fixed bridge port. +// +// The project root has to be writable and has to be NanoClaw's cwd: NanoClaw +// resolves `data/`, `groups/`, `store/`, and its own `package.json` from +// `process.cwd()`. The installed tree under /opt is immutable and shared, so +// this materializes a per-run root that links the immutable halves and owns the +// mutable ones. +// +// The bridge is a byte relay, not a protocol: NanoClaw's CLI channel already +// speaks the NDJSON `{ "text": ... }` frames the controller's gateway decodes, +// so the TCP realization differs from the Unix-socket one only in transport. +// It starts listening only once the socket exists, because an open port is what +// the controller reads as readiness and a port opened early would silently +// discard the run's first instruction. + +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { connect, createServer } from "node:net"; +import { cp, mkdir, readFile, stat, symlink } from "node:fs/promises"; +import { join } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const APPLICATION_ROOT = "/opt/moltzap/nanoclaw/app"; +const BOOTSTRAP_API_VERSION = "moltzap.nanoclaw-application/v1"; +const EVAL_AGENT_GROUP_ID = "eval-agent"; +const CLI_SOCKET_POLL_MS = 100; +const CLI_SOCKET_TIMEOUT_MS = 120_000; +const PROVISION_TIMEOUT_MS = 120_000; +const SHUTDOWN_GRACE_MS = 15_000; +// NanoClaw resolves these from its cwd and writes into all of them. `data` +// is absent because the copied install already carries it, stamped marker +// included. +const MUTABLE_DIRECTORIES = ["groups", "store", "tmp"]; +// Read-only halves of the install. `container` is copied rather than linked +// because the run's own workspace files are seeded into `container/skills`. +const LINKED_ENTRIES = ["dist", "node_modules", "src", "scripts", "templates"]; +const COPIED_ENTRIES = ["container", "package.json", "data"]; + +function fail(detail) { + throw new Error(`NanoClaw application bootstrap failed: ${detail}`); +} + +function requiredEnvironment(key) { + const value = process.env[key]; + if (value === undefined || value.length === 0) { + fail(`${key} is required`); + } + return value; +} + +async function readBootstrapConfig() { + const path = requiredEnvironment("MOLTZAP_NANOCLAW_CONFIG"); + const config = JSON.parse(await readFile(path, "utf8")); + if (config.apiVersion !== BOOTSTRAP_API_VERSION) { + fail(`${path} is not ${BOOTSTRAP_API_VERSION}`); + } + if (typeof config.agentName !== "string" || config.agentName.length === 0) { + fail(`${path} carries no agent name`); + } + return config; +} + +// Concurrently, because every entry targets a distinct path and this runs +// before the bridge port opens — which is what the controller reads as +// readiness, so anything serialized here is startup latency for the whole run. +async function materializeProjectRoot(config) { + const projectRoot = + config.stateDirectory ?? requiredEnvironment("MOLTZAP_NANOCLAW_STATE"); + await mkdir(projectRoot, { recursive: true }); + await Promise.all([ + ...LINKED_ENTRIES.map((entry) => + symlink(join(APPLICATION_ROOT, entry), join(projectRoot, entry)), + ), + ...COPIED_ENTRIES.map((entry) => + cp(join(APPLICATION_ROOT, entry), join(projectRoot, entry), { + recursive: true, + }), + ), + ...MUTABLE_DIRECTORIES.map((directory) => + mkdir(join(projectRoot, directory), { recursive: true }), + ), + ]); + return projectRoot; +} + +// The run's workspace files are NanoClaw skills, and NanoClaw mounts +// `container/skills` into every agent turn. +async function seedWorkspace(config, projectRoot) { + const source = config.workspaceDirectory; + if (typeof source !== "string" || source.length === 0) { + return; + } + const exists = await stat(source).then( + () => true, + () => false, + ); + if (exists) { + await cp(source, join(projectRoot, "container", "skills"), { + recursive: true, + }); + } +} + +// Rekeyed by name and otherwise forwarded verbatim, because the definition is +// the runtime's, not this file's: a server may be spawned over stdio or reached +// over streamable HTTP, and rebuilding either shape here would silently drop +// whichever field this file was not written for. The pinned NanoClaw revision +// honours the stdio shape only; a URL server rides through untouched so a +// revision bump starts honouring it without another change here. +function mcpServerConfiguration(servers) { + return Object.fromEntries( + servers.map(({ name, ...definition }) => [name, definition]), + ); +} + +function childEnvironment(config, projectRoot) { + const mcpServers = Array.isArray(config.mcpServers) ? config.mcpServers : []; + return { + ...process.env, + MOLTZAP_EVAL_MODE: config.autoRegisterConversations === true ? "1" : "0", + TMPDIR: join(projectRoot, "tmp"), + ...(typeof config.modelId === "string" && config.modelId.length > 0 + ? { MOLTZAP_AGENT_MODEL: config.modelId } + : {}), + ...(mcpServers.length === 0 + ? {} + : { + MOLTZAP_MCP_SERVERS: JSON.stringify( + mcpServerConfiguration(mcpServers), + ), + }), + }; +} + +// The runtime database starts empty and NanoClaw's router drops an unwired +// conversation, so the eval agent group and its CLI wiring exist before the +// first inbound delivery rather than after it. +async function provisionEvalAgent(config, projectRoot, environment) { + const child = spawn( + "node", + [ + join(APPLICATION_ROOT, "dist", "moltzap-eval-provision.js"), + EVAL_AGENT_GROUP_ID, + config.agentName, + EVAL_AGENT_GROUP_ID, + ], + { + cwd: projectRoot, + env: environment, + stdio: "inherit", + timeout: PROVISION_TIMEOUT_MS, + killSignal: "SIGKILL", + }, + ); + const [code, signal] = await once(child, "exit"); + if (code !== 0) { + fail( + `eval provisioning exited with code=${String(code)} signal=${String(signal)}`, + ); + } +} + +async function waitForCliSocket(socketPath, stopped) { + const deadline = Date.now() + CLI_SOCKET_TIMEOUT_MS; + while (Date.now() < deadline) { + if (stopped.exited) { + fail(`NanoClaw exited before its CLI socket appeared: ${stopped.detail}`); + } + const ready = await stat(socketPath).then( + () => true, + () => false, + ); + if (ready) { + return; + } + await delay(CLI_SOCKET_POLL_MS); + } + fail( + `NanoClaw did not open ${socketPath} within ${String(CLI_SOCKET_TIMEOUT_MS)}ms`, + ); +} + +function relay(socketPath, inbound) { + const upstream = connect(socketPath); + const close = () => { + inbound.destroy(); + upstream.destroy(); + }; + inbound.on("error", close); + upstream.on("error", close); + inbound.pipe(upstream); + upstream.pipe(inbound); +} + +function listenBridge(config, socketPath) { + const gateway = config.gateway ?? {}; + const port = gateway.port; + if (typeof port !== "number") { + fail("the bootstrap config carries no gateway port"); + } + return new Promise((resolve, reject) => { + const server = createServer((inbound) => { + relay(socketPath, inbound); + }); + server.once("error", reject); + server.listen(port, gateway.host ?? "0.0.0.0", () => { + resolve(server); + }); + }); +} + +function forwardShutdown(child) { + for (const signal of ["SIGTERM", "SIGINT"]) { + process.on(signal, () => { + child.kill(signal); + setTimeout(() => { + child.kill("SIGKILL"); + }, SHUTDOWN_GRACE_MS).unref(); + }); + } +} + +function startNanoClaw(projectRoot, environment) { + const child = spawn("node", [join(APPLICATION_ROOT, "dist", "index.js")], { + cwd: projectRoot, + env: environment, + stdio: "inherit", + }); + const stopped = { exited: false, detail: "" }; + const exit = new Promise((resolve) => { + child.once("exit", (code, signal) => { + stopped.exited = true; + stopped.detail = `code=${String(code)} signal=${String(signal)}`; + resolve(code ?? 1); + }); + child.once("error", (cause) => { + stopped.exited = true; + stopped.detail = String(cause); + resolve(1); + }); + }); + forwardShutdown(child); + return { exit, stopped }; +} + +const config = await readBootstrapConfig(); +const projectRoot = await materializeProjectRoot(config); +await seedWorkspace(config, projectRoot); +const environment = childEnvironment(config, projectRoot); +await provisionEvalAgent(config, projectRoot, environment); +const nanoclaw = startNanoClaw(projectRoot, environment); +const socketPath = join(projectRoot, "data", "cli.sock"); +await waitForCliSocket(socketPath, nanoclaw.stopped); +const bridge = await listenBridge(config, socketPath); +const exitCode = await nanoclaw.exit; +bridge.close(); +process.exit(exitCode); diff --git a/packages/simulator/nanoclaw-image/image.test.mjs b/packages/simulator/nanoclaw-image/image.test.mjs new file mode 100644 index 000000000..5a0c9c2ef --- /dev/null +++ b/packages/simulator/nanoclaw-image/image.test.mjs @@ -0,0 +1,93 @@ +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 { Either, Schema } from "effect"; +import { image } from "../dist/agents.js"; +import { + assertRepository, + NANOCLAW_SOURCE_REVISION, + pinnedImageReference, +} from "../scripts/build-nanoclaw-image.mjs"; + +const imageRoot = dirname(fileURLToPath(import.meta.url)); +const read = (path) => readFile(join(imageRoot, path), "utf8"); +const decodeImage = Schema.decodeUnknownEither(image); +const DIGEST = `sha256:${"a".repeat(64)}`; + +// The evaluation CLI rejects anything this schema rejects, and it is the same +// schema value both sides read, so a producer that satisfies it here cannot +// print a reference the sweep then refuses. +test("the produced reference is what the evaluation image schema accepts", () => { + for (const repository of [ + "moltzap-simulator-nanoclaw", + "us-central1-docker.pkg.dev/project/repository/nanoclaw", + ]) { + const reference = pinnedImageReference(repository, DIGEST); + assert.equal(reference, `${repository}@${DIGEST}`); + assert.ok(Either.isRight(decodeImage(reference))); + } + + assert.ok(Either.isLeft(decodeImage("moltzap-simulator-nanoclaw:latest"))); + assert.throws(() => + pinnedImageReference("moltzap-simulator-nanoclaw", "sha256:NOTADIGEST"), + ); +}); + +// One rule, applied when the argument arrives as well as when the reference is +// built, so a repository that could never name an immutable image is refused +// before the build rather than at the last statement after it. +test("a repository that cannot name one immutable image is refused", () => { + for (const repository of ["", `nanoclaw@${DIGEST}`, "nano claw"]) { + assert.throws(() => assertRepository(repository)); + assert.throws(() => pinnedImageReference(repository, DIGEST)); + } + assert.doesNotThrow(() => assertRepository("registry.example/nanoclaw")); +}); + +test("the build pins its NanoClaw source and prints a digest identity", async () => { + const script = await read("../scripts/build-nanoclaw-image.mjs"); + + assert.match(NANOCLAW_SOURCE_REVISION, /^[0-9a-f]{40}$/); + assert.match(script, /github\.com\/nanocoai\/nanoclaw\/archive\//); + assert.match(script, /--metadata-file/); + assert.match(script, /containerimage\.digest/); + assert.match(script, /pinnedImage: pinnedImageReference\(/); +}); + +test("the image satisfies the NanoClaw container runtime contract", async () => { + const [dockerfile, entrypoint] = await Promise.all([ + read("Dockerfile"), + read("entrypoint.mjs"), + ]); + + // Exactly the contract src/agents/nanoclaw/runtime.ts renders: the process + // it starts, the config it mounts, the directory it hands over, and the port + // the controller's bridge dials. + assert.match( + dockerfile, + /ENTRYPOINT \["node", "\/opt\/moltzap\/nanoclaw\/entrypoint\.mjs"\]/, + ); + assert.match(dockerfile, /\/var\/lib\/moltzap\/nanoclaw/); + assert.match(entrypoint, /"moltzap\.nanoclaw-application\/v1"/); + assert.match(entrypoint, /MOLTZAP_NANOCLAW_CONFIG/); + assert.match(entrypoint, /MOLTZAP_NANOCLAW_STATE/); + assert.match(entrypoint, /config\.gateway/); + assert.match( + entrypoint, + /dist\/moltzap-eval-provision\.js|"moltzap-eval-provision\.js"/, + ); + assert.match(entrypoint, /cli\.sock/); + // An MCP server may be stdio or streamable HTTP, and the entrypoint owns + // neither shape: rebuilding one here would drop the other's only field. + assert.match(entrypoint, /\{ name, \.\.\.definition \}/); + + // Every layer this image is assembled from is immutable. + const bases = dockerfile.match(/^(?:FROM|COPY --from=)\S*[^\n]*$/gmu) ?? []; + const remote = bases.filter((line) => !/--from=nanoclaw\b/u.test(line)); + assert.ok(remote.length >= 3); + for (const line of remote) { + assert.match(line, /@sha256:[0-9a-f]{64}/); + } +}); diff --git a/packages/simulator/nanoclaw-image/prepare.mjs b/packages/simulator/nanoclaw-image/prepare.mjs new file mode 100644 index 000000000..c3136f109 --- /dev/null +++ b/packages/simulator/nanoclaw-image/prepare.mjs @@ -0,0 +1,123 @@ +// Overlays the MoltZap channel, eval provisioner, skill, and manifest onto an +// extracted NanoClaw checkout, then points the two MoltZap dependencies at the +// packed workspace tarballs. Runs inside the image build, where the checkout is +// already writable; the caller stages every input it reads. +// +// The manifest overlay is upstream's with two deliberate divergences carried by +// `nanoclaw-assets/package.json`: `@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 Node and its source no longer compiles +// against modern V8. + +import { + copyFile, + mkdir, + readFile, + readdir, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; + +const MOLTZAP_PACKAGES = ["@moltzap/client", "@moltzap/protocol"]; +const CHANNEL_REGISTRATION = "import './moltzap.js';"; +const JSON_INDENT_SPACES = 2; +const TARBALL_EXTENSION = ".tgz"; + +function usage() { + throw new TypeError("usage: prepare.mjs APP_ROOT ASSETS_ROOT TARBALLS_ROOT"); +} + +async function overlayAssets(appRoot, assetsRoot) { + await mkdir(join(appRoot, "container", "skills", "moltzap"), { + recursive: true, + }); + await Promise.all([ + copyFile( + join(assetsRoot, "moltzap.ts"), + join(appRoot, "src", "channels", "moltzap.ts"), + ), + copyFile( + join(assetsRoot, "moltzap-eval-provision.ts"), + join(appRoot, "src", "moltzap-eval-provision.ts"), + ), + copyFile( + join(assetsRoot, "SKILL.md"), + join(appRoot, "container", "skills", "moltzap", "SKILL.md"), + ), + copyFile(join(assetsRoot, "package.json"), join(appRoot, "package.json")), + copyFile( + join(assetsRoot, "package-lock.json"), + join(appRoot, "package-lock.json"), + ), + ]); +} + +// NanoClaw discovers a channel by importing it for its self-registration side +// effect, so the barrel is the one file that decides whether the MoltZap +// channel exists at all. +async function registerChannel(appRoot) { + const barrelPath = join(appRoot, "src", "channels", "index.ts"); + const barrel = await readFile(barrelPath, "utf8"); + if (barrel.includes(CHANNEL_REGISTRATION)) { + return; + } + await writeFile( + barrelPath, + `${barrel.trimEnd()}\n\n${CHANNEL_REGISTRATION}\n`, + ); +} + +async function vendorTarballs(appRoot, tarballsRoot) { + const vendor = join(appRoot, "vendor"); + await mkdir(vendor, { recursive: true }); + const archives = (await readdir(tarballsRoot)).filter((entry) => + entry.endsWith(TARBALL_EXTENSION), + ); + await Promise.all( + archives.map((entry) => + copyFile(join(tarballsRoot, entry), join(vendor, entry)), + ), + ); + return Object.fromEntries( + MOLTZAP_PACKAGES.map((name) => { + const prefix = `${name.replace("@", "").replace("/", "-")}-`; + const archive = archives.find((entry) => entry.startsWith(prefix)); + if (archive === undefined) { + throw new Error(`no packed workspace tarball for ${name}`); + } + return [name, `file:vendor/${archive}`]; + }), + ); +} + +// An override as well as a dependency: the channel's own transitive resolution +// of @moltzap/protocol would otherwise come from the registry, and a published +// copy beside the packed one is exactly the drift the workspace build avoids. +async function bindWorkspaceDependencies(appRoot, specifiers) { + const manifestPath = join(appRoot, "package.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + const rewritten = { + ...manifest, + dependencies: { ...manifest.dependencies, ...specifiers }, + overrides: { ...manifest.overrides, ...specifiers }, + }; + await writeFile( + manifestPath, + `${JSON.stringify(rewritten, null, JSON_INDENT_SPACES)}\n`, + ); +} + +const [appRoot, assetsRoot, tarballsRoot] = process.argv.slice(2); +if ( + appRoot === undefined || + assetsRoot === undefined || + tarballsRoot === undefined +) { + usage(); +} +await overlayAssets(appRoot, assetsRoot); +await registerChannel(appRoot); +await bindWorkspaceDependencies( + appRoot, + await vendorTarballs(appRoot, tarballsRoot), +); diff --git a/packages/simulator/package.json b/packages/simulator/package.json index 35cccb7a0..daf4b1ccd 100644 --- a/packages/simulator/package.json +++ b/packages/simulator/package.json @@ -46,6 +46,8 @@ "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", + "nanoclaw:image": "nx run @moltzap/simulator:nanoclaw-image", + "nanoclaw:image:check": "nx run @moltzap/simulator:nanoclaw-image-check", "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", @@ -94,6 +96,29 @@ "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" } }, + "nanoclaw-image-check": { + "dependsOn": [ + "build" + ], + "executor": "nx:run-commands", + "inputs": [ + "default", + "{projectRoot}/nanoclaw-image/**/*", + "{projectRoot}/scripts/build-nanoclaw-image.mjs" + ], + "options": { + "cwd": "packages/simulator", + "command": "node --test nanoclaw-image/image.test.mjs && node --check scripts/build-nanoclaw-image.mjs && node --check nanoclaw-image/prepare.mjs && node --check nanoclaw-image/entrypoint.mjs" + } + }, + "nanoclaw-image": { + "cache": false, + "executor": "nx:run-commands", + "options": { + "cwd": "packages/simulator", + "command": "node scripts/build-nanoclaw-image.mjs" + } + }, "local-cluster-create": { "cache": false, "executor": "nx:run-commands", diff --git a/packages/simulator/scripts/build-nanoclaw-image.mjs b/packages/simulator/scripts/build-nanoclaw-image.mjs new file mode 100644 index 000000000..f695ccebd --- /dev/null +++ b/packages/simulator/scripts/build-nanoclaw-image.mjs @@ -0,0 +1,259 @@ +// Builds the NanoClaw application image and prints both its local tag and +// manifest-digest identity. The caller decides whether to load or push it. +// +// `@moltzap/evals` requires MOLTZAP_NANOCLAW_IMAGE as a digest-pinned +// reference, and the digest a registry assigns is only knowable after a push, +// so this prints `pinnedImage` for the local repository and leaves publication +// to the caller — the same split the controller image uses. +// +// NanoClaw runs every agent turn in a container it spawns itself, so a cell +// running this image also needs a reachable container runtime (DOCKER_HOST). +// The image carries the client; it cannot carry the daemon. +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 imageRoot = join(packageRoot, "nanoclaw-image"); +const assetsRoot = join(packageRoot, "nanoclaw-assets"); +const channelSource = join( + workspaceRoot, + "packages", + "nanoclaw-channel", + "src", + "channels", + "moltzap.ts", +); + +/** + * Pinned NanoClaw source revision. The image's runtime version is this commit, + * not the version string in the overlaid manifest. + */ +export const NANOCLAW_SOURCE_REVISION = + "641963c1e4b7ba4f000a18dfc5e2fea29069feec"; +const NANOCLAW_SOURCE_URL = `https://github.com/nanocoai/nanoclaw/archive/${NANOCLAW_SOURCE_REVISION}.tar.gz`; +const DEFAULT_REPOSITORY = "moltzap-simulator-nanoclaw"; +const BUILD_TIMEOUT_MS = 45 * 60 * 1_000; +const PACK_TIMEOUT_MS = 5 * 60 * 1_000; +const DOWNLOAD_TIMEOUT_MS = 5 * 60 * 1_000; +const SHA256_DIGEST = /^sha256:[0-9a-f]{64}$/; +const BUNDLED_ASSETS = [ + "SKILL.md", + "moltzap-eval-provision.ts", + "package.json", + "package-lock.json", +]; +// The channel the image builds is the workspace one, so its two MoltZap +// dependencies are the workspace ones too; a published copy beside them is the +// drift the packed tarballs exist to prevent. +const workspacePackages = { + "@moltzap/client": join(workspaceRoot, "packages", "client"), + "@moltzap/protocol": join(workspaceRoot, "packages", "protocol"), +}; + +/** + * Refuse a repository that could not name one immutable image. + * @param repository Repository the image will be tagged into. + */ +export function assertRepository(repository) { + // The repository half excludes `@` so a trailing digest cannot be smuggled in + // behind an earlier one — the same reason the image schema excludes it. One + // rule, checked when the argument arrives rather than only after the build. + if (repository.length === 0 || /[@\s]/.test(repository)) { + throw new TypeError( + "a nanoclaw image repository must be nonempty and carry no digest", + ); + } +} + +/** + * Digest-pinned reference accepted by the evaluation image schema. + * @param repository Local or remote repository the image was tagged into. + * @param digest Manifest digest reported by the build. + * @returns The immutable `repository@sha256:<64 hex>` reference. + */ +export function pinnedImageReference(repository, digest) { + assertRepository(repository); + if (!SHA256_DIGEST.test(digest)) { + throw new TypeError("a pinned image needs a lowercase SHA-256 digest"); + } + return `${repository}@${digest}`; +} + +function report(message) { + process.stderr.write(`[moltzap nanoclaw 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-nanoclaw-image.mjs [--repository NAME]"); + } + const repository = args[1]; + assertRepository(repository); + 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); +} + +async function downloadSource() { + const response = await fetch(NANOCLAW_SOURCE_URL, { + signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS), + }); + if (!response.ok) { + throw new Error( + `NanoClaw source ${NANOCLAW_SOURCE_REVISION} returned HTTP ${String(response.status)}`, + ); + } + return new Uint8Array(await response.arrayBuffer()); +} + +async function stage(source) { + const root = await mkdtemp(join(tmpdir(), "moltzap-nanoclaw-image-")); + const tarballs = join(root, "tarballs"); + const assets = join(root, "assets"); + await Promise.all([mkdir(tarballs), mkdir(assets)]); + await Promise.all([ + writeFile(join(root, "nanoclaw-source.tar.gz"), await source), + copyFile(join(imageRoot, "Dockerfile"), join(root, "Dockerfile")), + copyFile(join(imageRoot, "prepare.mjs"), join(root, "prepare.mjs")), + copyFile(join(imageRoot, "entrypoint.mjs"), join(root, "entrypoint.mjs")), + copyFile(channelSource, join(assets, "moltzap.ts")), + ...BUNDLED_ASSETS.map((name) => + copyFile(join(assetsRoot, name), join(assets, name)), + ), + ...Object.values(workspacePackages).map((directory) => + pack(directory, tarballs), + ), + ]); + return root; +} + +async function stagedFiles(root) { + const entries = await readdir(root, { recursive: true, withFileTypes: true }); + return entries + .filter((entry) => entry.isFile()) + .map((entry) => join(entry.parentPath.slice(root.length + 1), entry.name)); +} + +async function fingerprint(root) { + const hash = createHash("sha256"); + hash.update(NANOCLAW_SOURCE_REVISION); + for (const path of (await stagedFiles(root)).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 buildImage(staging, image) { + 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 { 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 nanoclaw image id"); + } + return { imageDigest: buildDigest(metadata), imageId }; +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + // Started first and awaited in `stage`: the pinned source depends on nothing + // the workspace build produces, so its transfer hides behind that build. + const source = downloadSource(); + report("building the workspace dependencies the MoltZap channel consumes"); + await exec( + "pnpm", + [ + "nx", + "run-many", + "--target=build", + "--projects=@moltzap/client,@moltzap/protocol", + ], + { cwd: workspaceRoot, timeout: BUILD_TIMEOUT_MS }, + ); + report(`staging NanoClaw ${NANOCLAW_SOURCE_REVISION} and its overlay`); + const staging = await stage(source); + try { + const image = `${options.repository}:${await fingerprint(staging)}`; + const { imageDigest, imageId } = await buildImage(staging, image); + process.stdout.write( + `${JSON.stringify({ + image, + pinnedImage: pinnedImageReference(options.repository, imageDigest), + imageDigest, + imageId, + sourceRevision: NANOCLAW_SOURCE_REVISION, + applicationEntrypoint: "/opt/moltzap/nanoclaw/entrypoint.mjs", + bootstrapConfig: "/var/run/moltzap/bootstrap/nanoclaw/runtime.json", + stateDirectory: "/var/lib/moltzap/nanoclaw", + gatewayPort: 18790, + })}\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/src/MODULE.md b/packages/simulator/src/MODULE.md index 4a51770bf..6a8d792c1 100644 --- a/packages/simulator/src/MODULE.md +++ b/packages/simulator/src/MODULE.md @@ -733,6 +733,29 @@ export class IncompleteLedgerReceipt extends Schema.TaggedClass >; -/** Closed controller process result retained by the coarse workflow. */ +/** + * Closed controller process result retained by the coarse workflow. + * + * The failed branch carries the sanitized controller output the host activity + * already collected. A failure summary names what ended the run and nothing + * about why, so without this the operator's only copy of the reason is a Pod + * log in a namespace the workflow deletes on its way out. + */ export type RunControllerResult = | { readonly exitCode: 0; @@ -39,6 +46,7 @@ export type RunControllerResult = | { readonly exitCode: 1; readonly summary: ControllerFailedRunSummary; + readonly diagnostic?: string; }; /* eslint-disable agent-code-guard/promise-type, @typescript-eslint/no-invalid-void-type -- Temporal activity implementations are Promise-native functions consumed directly by proxyActivities. */ diff --git a/packages/simulator/src/cluster/reclaim.types-check.ts b/packages/simulator/src/cluster/reclaim.types-check.ts index 3be2cc5b8..37b572941 100644 --- a/packages/simulator/src/cluster/reclaim.types-check.ts +++ b/packages/simulator/src/cluster/reclaim.types-check.ts @@ -3,6 +3,7 @@ * data, and the coarse workflow preserves the controller's operational result. */ +import type { ControllerFailedRunSummary } from "./controller/summary.js"; import type { CleanupRunInput, RunControllerResult, @@ -46,6 +47,21 @@ type WorkflowResultIsOperational = Expect< Equal>, RunControllerResult> >; +// A whole-shape equality rather than a key list: with exactOptionalPropertyTypes +// off, `keyof` and an indexed access both read the same for `diagnostic?: string` +// and `diagnostic: string | undefined`, so only this form pins the optionality +// that keeps every existing producer of the failed branch compiling. +type FailedResultIsClosed = Expect< + Equal< + Extract, + { + readonly exitCode: 1; + readonly summary: ControllerFailedRunSummary; + readonly diagnostic?: string; + } + > +>; + /** Compile-time assertions for the private coarse-workflow boundary. */ export type TemporalWorkflowCanaries = [ WorkflowInputKeysAreClosed, @@ -53,4 +69,5 @@ export type TemporalWorkflowCanaries = [ ControllerActivityInputIsExact, CleanupActivityInputIsExact, WorkflowResultIsOperational, + FailedResultIsClosed, ]; diff --git a/packages/simulator/src/cluster/submit.test.ts b/packages/simulator/src/cluster/submit.test.ts index 3415a8b9b..7dbb19cee 100644 --- a/packages/simulator/src/cluster/submit.test.ts +++ b/packages/simulator/src/cluster/submit.test.ts @@ -5,8 +5,10 @@ import { Cause, Data, Effect, Layer, Logger } from "effect"; import type { RunControllerResult } from "./reclaim.js"; import { LOCAL_KUBERNETES_EXECUTION_PROFILE } from "./profile.js"; import { + boundedDiagnostic, runKubernetesSociety, SUBMIT_STAGE, + SUBMITTED_DIAGNOSTIC_MAX_BYTES, SubmitOperations, type RunEnvironment, type RunSubmission, @@ -179,4 +181,38 @@ describe("the cohort's startup budget", () => { }); }); +describe("the published controller diagnostic", () => { + const byteLength = (value: string) => + new TextEncoder().encode(value).byteLength; + + it("keeps text already inside the bound exactly as it was", () => { + for (const value of ["", "controller Job failed", "é".repeat(64)]) { + expect(boundedDiagnostic(value)).toBe(value); + } + }); + + // A multi-byte log is what makes a character bound and a byte bound differ, + // and the trim has to land on a code point rather than inside one. + it("holds every encoding to the byte bound without splitting a code point", () => { + for (const unit of ["x", "é", "漢", "🙂"]) { + const bounded = boundedDiagnostic( + unit.repeat(SUBMITTED_DIAGNOSTIC_MAX_BYTES), + ); + + expect(byteLength(bounded)).toBeLessThanOrEqual( + SUBMITTED_DIAGNOSTIC_MAX_BYTES, + ); + expect(bounded).not.toContain("\uFFFD"); + expect(bounded.endsWith(unit)).toBe(true); + } + }); + + // The reason a controller stopped is the last thing it writes. + it("keeps the tail rather than the head", () => { + const value = `${"x".repeat(SUBMITTED_DIAGNOSTIC_MAX_BYTES)}TAIL`; + + expect(boundedDiagnostic(value).endsWith("TAIL")).toBe(true); + }); +}); + /* 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 index a1a0c88aa..cc018c59a 100644 --- a/packages/simulator/src/cluster/submit.ts +++ b/packages/simulator/src/cluster/submit.ts @@ -46,6 +46,14 @@ export class SubmitOperations extends Context.Tag( "@moltzap/simulator/SubmitOperations", )() {} +/** + * Upper bound on the diagnostic one submission publishes to its caller. + * + * The stdout line is a contract, and an unbounded field makes the whole line + * long rather than the one field long. + */ +export const SUBMITTED_DIAGNOSTIC_MAX_BYTES = 8 * 1_024; + /** Successful submission reported to the operator. */ export interface RunSubmission { readonly runId: string; @@ -320,6 +328,38 @@ function prepareRun( }; } +/** + * Hold a diagnostic to the published byte bound, keeping its tail. + * + * The tail because a controller writes the reason it stopped last. On the byte + * array because a UTF-16 slice cannot express a byte count, and the + * continuation-byte skip puts the cut on a code-point boundary rather than + * leaving a replacement character at the front. + * + * @param value Sanitized controller output collected by the host activity. + * @returns The same text, or its last whole code points within the bound. + */ +export function boundedDiagnostic(value: string): string { + const bytes = new TextEncoder().encode(value); + if (bytes.byteLength <= SUBMITTED_DIAGNOSTIC_MAX_BYTES) { + return value; + } + let start = bytes.byteLength - SUBMITTED_DIAGNOSTIC_MAX_BYTES; + while (start < bytes.byteLength && ((bytes[start] ?? 0) & 0xc0) === 0x80) { + start += 1; + } + return new TextDecoder().decode(bytes.subarray(start)); +} + +// The value crossed Temporal and a worker this process does not own, so the +// bound is enforced here rather than trusted. +function boundedResult(result: RunControllerResult): RunControllerResult { + if (result.exitCode === 0 || result.diagnostic === undefined) { + return result; + } + return { ...result, diagnostic: boundedDiagnostic(result.diagnostic) }; +} + function executePreparedRun( prepared: PreparedRun, operations: SubmitOperationsService, @@ -365,7 +405,7 @@ function executePreparedRun( }, operations, ); - return Object.freeze({ ...identity, result }); + return Object.freeze({ ...identity, result: boundedResult(result) }); }); } diff --git a/packages/simulator/src/cluster/temporal.test.ts b/packages/simulator/src/cluster/temporal.test.ts index a3dc2ba97..d727d2709 100644 --- a/packages/simulator/src/cluster/temporal.test.ts +++ b/packages/simulator/src/cluster/temporal.test.ts @@ -123,7 +123,7 @@ describe("run lifecycle activities", () => { it("creates one controller attempt and waits for its successful Job", async () => { const current = state([ { _tag: "running" }, - { _tag: "succeeded", result: PROGRAM_RESULT }, + { _tag: "completed", result: PROGRAM_RESULT }, ]); const activities = fakeActivities(current); @@ -147,13 +147,9 @@ describe("run lifecycle activities", () => { }); it("returns a closed failed result from a nonzero controller Job", async () => { - const current = state([ - { - _tag: "failed", - detail: "controller Job failed", - result: FAILED_RESULT, - }, - ]); + // A controller that produced a decodable failure summary is completed, not + // failed: the activity returns its result rather than failing the attempt. + const current = state([{ _tag: "completed", result: FAILED_RESULT }]); const activities = fakeActivities(current); await expect(activities.runControllerOnce(INPUT)).resolves.toEqual( diff --git a/packages/simulator/src/cluster/temporal.ts b/packages/simulator/src/cluster/temporal.ts index ec9265b98..9d498e62d 100644 --- a/packages/simulator/src/cluster/temporal.ts +++ b/packages/simulator/src/cluster/temporal.ts @@ -42,17 +42,23 @@ import { makeKubernetesRunLifecycleOperations } from "./watch.js"; const WORKFLOW_TYPE = "runSocietyWorkflow"; const DEFAULT_TEMPORAL_NAMESPACE = "default"; -/** Coarse controller state observed by the host-side activity. */ +/** + * Coarse controller state observed by the host-side activity. + * + * `completed` is every controller that produced a decodable result, whether or + * not the run inside it succeeded — the activity returns both the same way. The + * two were one state with an optional result and a detail that was dead + * whenever the result was present, which invited them to disagree. + */ export type ControllerObservation = | { readonly _tag: "running" } | { - readonly _tag: "succeeded"; + readonly _tag: "completed"; readonly result: RunControllerResult; } | { readonly _tag: "failed"; readonly detail: string; - readonly result?: RunControllerResult; }; /** Process environment read by the in-cluster worker Deployment. */ @@ -166,12 +172,9 @@ function runControllerOnce( for (;;) { const observation = yield* operations.observeController(input); switch (observation._tag) { - case "succeeded": + case "completed": return observation.result; case "failed": - if (observation.result !== undefined) { - return observation.result; - } return yield* Effect.fail( new ControllerAttemptFailed(observation.detail), ); diff --git a/packages/simulator/src/cluster/watch.test.ts b/packages/simulator/src/cluster/watch.test.ts index 9bb13c665..af0b23d12 100644 --- a/packages/simulator/src/cluster/watch.test.ts +++ b/packages/simulator/src/cluster/watch.test.ts @@ -107,7 +107,7 @@ describe("controller Job diagnostics", () => { encodedSummary(PROGRAM_SUMMARY), ), ).toEqual({ - _tag: "succeeded", + _tag: "completed", result: { exitCode: 0, summary: PROGRAM_SUMMARY }, }); }); @@ -128,10 +128,17 @@ describe("controller Job diagnostics", () => { job({ failed: 1 }), `${encodedSummary(summary)}\nSimulator controller execution failed`, ), + // A decodable failure summary is a completed observation: the activity + // returns it rather than failing, so the reason the Job gave rides the + // result or disappears with the namespace. ).toEqual({ - _tag: "failed", - detail: "controller Job failed\nSimulator controller execution failed", - result: { exitCode: 1, summary }, + _tag: "completed", + result: { + exitCode: 1, + summary, + diagnostic: + "controller Job failed\nSimulator controller execution failed", + }, }); }); @@ -198,7 +205,7 @@ it("reads a bounded log tail once the Job is terminal", async () => { await expect( Effect.runPromise(observeController(api, INPUT)), ).resolves.toEqual({ - _tag: "succeeded", + _tag: "completed", result: { exitCode: 0, summary: PROGRAM_SUMMARY }, }); diff --git a/packages/simulator/src/cluster/watch.ts b/packages/simulator/src/cluster/watch.ts index 7cd8a2751..6de0efb86 100644 --- a/packages/simulator/src/cluster/watch.ts +++ b/packages/simulator/src/cluster/watch.ts @@ -28,6 +28,7 @@ import { import { prepareRun } from "./scaffold.js"; const OBSERVATION_INTERVAL_MS = 1_000; +const FAILED_JOB_DETAIL = "controller Job failed"; const DIAGNOSTIC_LIMIT = 4_096; const CONTROLLER_LOG_TAIL_LINES = 200; const SENSITIVE_LOG_LINE = @@ -81,7 +82,7 @@ function conditionDetail(job: JobObservation): string | undefined { return undefined; } const detail = [failed.reason, failed.message].filter(Boolean).join(": "); - return detail.length === 0 ? undefined : sanitizeControllerDiagnostic(detail); + return detail.length === 0 ? undefined : detail; } function jobConditionIsTrue(job: JobObservation, type: string): boolean { @@ -104,7 +105,7 @@ function controllerSummary(logs: string) { return decodeControllerRunSummary(logs); } -function succeededControllerObservation(logs: string): ControllerObservation { +function completedControllerObservation(logs: string): ControllerObservation { const summary = controllerSummary(logs); if (summary === undefined || summary._tag !== "ProgramFinished") { return { @@ -113,12 +114,19 @@ function succeededControllerObservation(logs: string): ControllerObservation { }; } return { - _tag: "succeeded", + _tag: "completed", result: { exitCode: 0, summary }, }; } -function failedControllerResult(logs: string): RunControllerResult | undefined { +type FailedControllerResult = Extract< + RunControllerResult, + { readonly exitCode: 1 } +>; + +function failedControllerResult( + logs: string, +): FailedControllerResult | undefined { const summary = controllerSummary(logs); if (summary === undefined || summary._tag === "ProgramFinished") { return undefined; @@ -126,30 +134,36 @@ function failedControllerResult(logs: string): RunControllerResult | undefined { return { exitCode: 1, summary }; } -function sanitizedControllerLogs(logs: string): string { +function controllerLogs(logs: string): string { + return logs + .split("\n") + .filter((line) => !line.startsWith(CONTROLLER_SUMMARY_PREFIX)) + .join("\n"); +} + +// Sanitized once, over the whole composition rather than each part: bounding +// the pieces separately lets their concatenation exceed the bound, and the +// caller then trims the front — which is where the Job condition's reason is. +function failureDetail(job: JobObservation, logs: string): string { return sanitizeControllerDiagnostic( - logs - .split("\n") - .filter((line) => !line.startsWith(CONTROLLER_SUMMARY_PREFIX)) + [FAILED_JOB_DETAIL, conditionDetail(job), controllerLogs(logs)] + .filter((part): part is string => part !== undefined && part.length > 0) .join("\n"), ); } +// A run that failed with a decodable summary is a completed observation, not a +// failed one: the activity returns it rather than failing, so the reason the +// Job gave has to ride the result or it is gone with the namespace. 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"); + const detail = failureDetail(job, logs); return result === undefined ? { _tag: "failed", detail } - : { _tag: "failed", detail, result }; + : { _tag: "completed", result: { ...result, diagnostic: detail } }; } /** @@ -164,7 +178,7 @@ export function controllerObservation( ): ControllerObservation { const resolvedLogs = logs ?? ""; if (jobSucceeded(job)) { - return succeededControllerObservation(resolvedLogs); + return completedControllerObservation(resolvedLogs); } if (!jobFailed(job)) { return { _tag: "running" }; diff --git a/packages/simulator/src/index.ts b/packages/simulator/src/index.ts index 988cbefd0..9895a2dbe 100644 --- a/packages/simulator/src/index.ts +++ b/packages/simulator/src/index.ts @@ -96,3 +96,6 @@ export { /** Re-exports the mechanism-neutral cluster error. */ export { ClusterError } from "./cluster/cluster.js"; + +/** Re-exports the direct-invocation check every shipped entrypoint needs. */ +export { isEntryModule } from "./cluster/entry.js";