diff --git a/CHANGELOG.md b/CHANGELOG.md index f52b1ec..1417dfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/). `Subject:search(query)` find literal final rendered text in the active mount and return zero-based inclusive screen-cell bounds. Subject and rectangle areas are spatial filters, not render-ownership claims. +- Structured `dwarfspec.error.v1` rejection codes now cover package admission, + scheduler conflicts, run mutation and recovery, polling, and event cursors + while preserving their existing runner classifications and exit codes. + +### Changed + +- Package-version mismatch diagnostics label the process-wide + `running_version` and current command's `requested_version` explicitly and + require a complete Dwarf Fortress/DFHack restart, removing the ambiguous + `expected` and `found` wording. + +## [0.2.2] - 2026-08-03 + +### Changed + +- Report connection preflight failures as distinct invocation, subprocess, + missing-response, malformed-response, protocol, core-context, and timeout + diagnostics with bounded captured output. +- Ship the updated controller and DFHack probe together while retaining probe + protocol version 2. + +### Fixed + +- Avoid attributing a failed DFHack connection to a selected specification + when test selection succeeded before the connection preflight. ## [0.2.1] - 2026-07-31 diff --git a/docs/command-line.md b/docs/command-line.md index e5b3bdb..190a4ad 100644 --- a/docs/command-line.md +++ b/docs/command-line.md @@ -160,6 +160,35 @@ complete terminal result without writing a file. A terminal service generation is acknowledged only after its file replacement succeeds, or after successful no-results validation. +### Connection preflight diagnostics + +`dwarfspec run` completes discovery and selection before it starts the DFHack +connection preflight. A connection failure therefore does not implicate the +selected specification path. The path is included in a diagnostic only when +DFHack's subprocess output itself includes it. + +Every connection preflight failure exits with code 4, but its message identifies +the failed boundary and the action needed to investigate it: + +- an invocation failure reports the resolved runner path and the original + process-launch error; +- a nonzero probe exit reports the numeric exit code and bounded probe output; +- a missing response marker reports that no probe response was found and + includes bounded probe output; +- multiple response markers report the observed count and bounded probe output; +- a malformed response reports the first grammar error and the bounded + offending response line; +- a protocol mismatch reports the expected and observed protocol versions; +- a core-context failure reports that DFHack did not provide a healthy core Lua + context; and +- a timeout-capability failure reports that the required timeout support is + unavailable. + +Captured subprocess output is length-bounded and sanitized before display. For +a protocol mismatch, reinstall or upgrade DwarfSpec from one package artifact +so that the external controller and bundled DFHack probe come from the same +DwarfSpec release. The probe protocol remains version 2 for this release. + Focus diagnostics are nonfatal. They do not change test counts, terminal state, cleanup confirmation, or the process result. A run whose tests pass and whose cleanup is confirmed therefore exits with code 0 even when it retains focus @@ -178,6 +207,47 @@ Exit codes are stable: | 7 | Execution timeout or the distinct queue-timeout classification | | 8 | Active abort or the distinct pre-activation cancellation classification | +When bootstrap rejects a command because the running DFHack process retained a +different DwarfSpec package version, the diagnostic identifies the running +service version and the current command version separately. Save and fully +exit Dwarf Fortress/DFHack, relaunch it, and retry the command. Returning to +the title screen or unloading the world does not unload the process-wide +DwarfSpec service. This remains a registration rejection with +`registration_error` result state and exit code 5. + +### Structured host rejections + +Expected service decisions use a validated `dwarfspec.error.v1` response. The +command selects guidance from its stable code and safe context fields; it does +not parse the human-readable message. These decisions preserve the existing +runner classification, persisted result state, and exit code 5. + +| Code | When it is reported | Operator action | +|---|---|---| +| `package_version_mismatch` | DFHack retained a different process-wide DwarfSpec package version | Fully exit and relaunch Dwarf Fortress/DFHack, then retry with the current command package | +| `project_busy` | Another retained run owns the project | Wait for and consume the blocking run's result | +| `request_key_conflict` | A request identity was reused for different work | Retry the identical request or choose a new run identity | +| `result_path_busy` | Another retained run owns the result destination | Wait for result consumption or choose another result destination | +| `service_not_loaded` | The requested mutating, recovery, polling, or event operation has no loaded service | Start a run to load the service; for an in-flight command, treat the service as unavailable and retry only after checking its result | +| `run_not_found` | The named retained run does not exist | Verify the run identity; do not fabricate local run state | +| `generation_mismatch` | The run exists at a newer generation | Refresh authoritative run state and retry with its current generation | +| `invalid_run_state` | The operation is not legal in the run's current state | Inspect the reported state and choose the matching status, recovery, acknowledgement, or discard workflow | +| `owner_capability_rejected` | The caller no longer owns the requested run operation | Stop retrying with the rejected ownership context and recover or inspect through the command that created the run | +| `quarantine_mismatch` | Executor recovery targeted a different quarantined run generation | Refresh scheduler state and recover the reported blocking run | +| `clean_state_unverified` | Executor recovery could not prove native cleanup | Keep the executor quarantined and resolve the reported cleanup condition before retrying recovery | +| `event_cursor_ahead` | Polling requested events beyond the retained journal | Restart observation from the reported retained cursor without advancing the local cursor | + +Normal read-only absence is not a rejection. Service-wide status reports +`service_loaded`, and history, inspection, and log queries report `found` when +the service or run is unavailable. A structured domain rejection means the +host understood a valid operation and declined it without performing the +forbidden state change. A bridge failure means the external `dfhack-run` +process failed, timed out, or returned no valid response; its diagnostic may +include only bounded, sanitized subprocess output. Malformed responses, +corrupt service state, impossible invariants, and unexpected host exceptions +remain internal faults instead of being mislabeled as operator-correctable +rejection codes. + On timeout, interruption, or malformed transport after bootstrap, the command asks the service to recover from authoritative current state. A queued run is cancelled without native cleanup; an active run is aborted with cleanup. If @@ -185,8 +255,9 @@ recovery also fails, the original runner failure remains primary. ## Protocol compatibility -All bundled commands and adapters use `dwarfspec.transport.v2`, structured -`dwarfspec.event.v1` events, `dwarfspec.run.v2` snapshots, and -`dwarfspec.result.v2` results. Legacy `dwarfspec.run.v1` reports and formatted -progress lines are not accepted. Readers reject unknown schemas and protocols -instead of guessing. +All bundled commands and adapters use `dwarfspec.transport.v2`; expected +adapter rejections use `dwarfspec.error.v1`; structured events use +`dwarfspec.event.v1`; snapshots use `dwarfspec.run.v2`; and results use +`dwarfspec.result.v2`. Legacy `dwarfspec.run.v1` reports and formatted progress +lines are not accepted. Readers reject unknown schemas and protocols instead +of guessing. diff --git a/docs/connection-probe-contract.md b/docs/connection-probe-contract.md new file mode 100644 index 0000000..1684b37 --- /dev/null +++ b/docs/connection-probe-contract.md @@ -0,0 +1,180 @@ +# DwarfSpec connection probe contract + +## Purpose + +The connection probe determines whether the resolved `dfhack-run` process can +execute the minimum DFHack core Lua API required by DwarfSpec. It runs after +project discovery and test selection but before bootstrap, admission, or test +execution. + +The probe reports observed facts. The controller owns parsing, compatibility +decisions, failure classification, and user-facing diagnostics. + +## Compatibility invariants + +- The controller and probe protocol remains version 2. +- Every unsuccessful probe is a `RunnerFailureKind.CONNECTION` failure, maps to + the `connection_error` invocation result state, and exits with code 4. +- A diagnostic improvement must not change project-root resolution, test + discovery, selector glob semantics, runner lookup, registration, bootstrap, + admission, scheduler behavior, or cleanup behavior. +- A selected spec identity is not part of a connection diagnostic unless the + invoked subprocess independently emitted it. +- Protocol mismatches, non-core contexts, and missing required capabilities + remain fatal. More precise reporting must not make health validation more + permissive. +- The probe has no DwarfSpec module, project module, JSON, configuration, host + service, or third-party dependency. +- The distributed controller and probe must come from the same DwarfSpec + package. Shipping this contract requires a patch-version package release so + the two artifacts are updated together. + +## Probe response grammar + +The probe emits exactly one response line. Unrelated output from DFHack may +appear before or after it. + +```text +DWARFSPEC_PROBE protocol=2 core=true timeout=function [dfhack=] +``` + +A candidate response line begins with the exact ASCII marker +`DWARFSPEC_PROBE`, followed by the end of the line or one ASCII space. A bare +marker is therefore a malformed candidate with missing required fields. A +marker embedded later in a line is ordinary subprocess output. + +After the marker, the response consists of one or more fields separated by one +or more ASCII spaces. Each field has the form `name=value`: + +- `name` matches `[a-z][a-z0-9_]*`; +- `value` matches `[A-Za-z0-9._+-]+`; +- field names are unique; +- `protocol`, `core`, and `timeout` occur exactly once; +- `protocol` is a positive base-10 integer without a sign; +- `core` is `true`, `false`, or `unavailable`; +- `timeout` is one of the Lua `type()` names `nil`, `boolean`, `number`, + `string`, `function`, `userdata`, `thread`, or `table`, or the value + `unavailable`. + +The optional `dfhack` field contains `dfhack.VERSION` only when its string form +matches the value grammar. The probe omits it otherwise. Its presence and value +are diagnostic only and never affect health. + +Unknown well-formed fields are permitted and ignored by controllers that do not +recognize them. This permits additive diagnostics without weakening required +field validation. A duplicate field, including an unknown field, is malformed +because its meaning would be ambiguous. + +The probe observes incomplete contexts without indexing an unavailable value: + +- when the global `dfhack` value is not a table, it reports + `core=unavailable timeout=unavailable`; +- when `dfhack.is_core_context` is absent, `core=unavailable`; +- otherwise `core` is the string form of the boolean value, with any value + other than `true` or `false` normalized to `unavailable`; +- when `dfhack.timeout` is absent because no DFHack table is available, + `timeout=unavailable`; +- otherwise `timeout` is the result of Lua `type(dfhack.timeout)`, including + `nil` when the field is absent from an available table. + +A response is healthy only when it contains `protocol=2`, `core=true`, and +`timeout=function`. + +## Controller classification order + +The controller classifies a probe result in this order so one subprocess result +has one deterministic primary cause: + +1. Invocation exception: invoking the resolved runner did not return a result. +2. Nonzero exit: the subprocess returned an exit code other than zero. Marker + parsing is not attempted because process failure is primary. +3. Missing marker: a zero-exit result contains no candidate probe response. +4. Multiple markers: a zero-exit result contains more than one candidate. +5. Malformed response: the sole candidate violates the response grammar. +6. Protocol mismatch: the parsed protocol differs from 2. +7. Core-context failure: `core` is not `true`. +8. Timeout-capability failure: `timeout` is not `function`. +9. Healthy response: every required field has its accepted value. + +The controller searches the complete output instead of requiring the probe to +be the final line. Unrelated output does not invalidate one otherwise healthy +response. + +## Diagnostic catalog + +Messages use these stable forms. Angle-bracketed terms are substituted with +observed, cleaned, and bounded values. + +| Condition | Diagnostic | +| --- | --- | +| Invocation exception | `Could not invoke DFHack runner "": ` | +| Nonzero exit | `DFHack connection probe through "" exited with code . Output: ` | +| Missing marker | `DFHack responded through "", but emitted no DwarfSpec probe report. Output: ` | +| Multiple markers | `DFHack emitted DwarfSpec probe reports; expected exactly one. Output: ` | +| Malformed response | `DFHack emitted a malformed DwarfSpec probe report: . Probe: ` | +| Protocol mismatch | `DwarfSpec protocol mismatch: controller expects 2, probe reported . Check for mixed installed DwarfSpec package versions.` | +| Core-context failure | `DFHack probe did not run in a healthy core Lua context: expected core=true, reported core=.` | +| Timeout-capability failure | `DFHack core Lua context is missing the required dfhack.timeout function: reported timeout=.` | + +Invocation exceptions include the resolved runner path and the cleaned exception +text. They do not claim that DFHack accepted a connection. + +A malformed-response reason identifies the first grammar violation in parsing +order: invalid token, invalid field name, empty value, duplicate field, missing +required field, invalid protocol, invalid core value, or invalid timeout value. + +## Bounded output excerpts + +Diagnostics may include subprocess output for nonzero exits, missing markers, +multiple markers, and malformed responses. Formatting is deterministic: + +1. Convert each supplied line to a safe string under a protected call so a + failing `tostring` metamethod cannot raise a secondary formatting error. Use + `` when conversion fails. +2. Replace tab characters with one ASCII space. Replace ASCII control bytes + `0x00` through `0x1f` and `0x7f` with `?`, then trim surrounding ASCII + whitespace. +3. Discard empty normalized lines. +4. Limit each line to 512 bytes, including the suffix + `...` when truncation occurs. +5. Retain the final eight non-empty lines in their original order. When earlier + lines were omitted, prepend ``. +6. Join rendered entries with ` | `. +7. Limit the complete excerpt to 2,048 bytes. If necessary, preserve the most + recent output and prefix it with ` ` within that limit. +8. Render `` when no non-empty content remains. + +The limits are byte limits because Lua strings and the current subprocess +surface are byte-oriented. An implementation must not split a valid UTF-8 code +point when it truncates otherwise valid UTF-8 output. + +The formatter may reproduce paths or other text already emitted by the +subprocess. It must not add command arguments, environment variables, or secret +values from controller state. Existing result persistence rules determine +whether the resulting connection failure message is written to a result file. + +## Ownership boundaries + +- `src/dwarfspec/host/entrypoints/probe.lua` owns safe observation and one-line + response emission. +- `src/dwarfspec/controller/execution/transport_client.lua`, or a focused + controller helper extracted from it, owns response parsing, output bounding, + health checks, and connection failure construction. +- `src/dwarfspec/controller/execution/runner.lua` continues to orchestrate + preflight before bootstrap and does not interpret probe fields. +- Test discovery continues to own canonical identity selection before runner + orchestration. It does not diagnose DFHack connectivity. + +## Verification obligations + +Later implementation work must independently prove: + +- probe behavior for healthy, absent, and incomplete DFHack globals; +- parser behavior for noise, missing and multiple markers, malformed fields, + protocol mismatch, unhealthy capabilities, and unknown fields; +- exact bounded-output behavior at line-count, per-line, total-byte, control + character, empty-output, and UTF-8 boundaries; +- preservation of connection failure kind, result state, and exit code; +- absence of bootstrap after any failed probe; +- package co-location of the controller and probe; and +- installed live success plus terminal cleanup evidence. diff --git a/docs/installation.md b/docs/installation.md index 5f6a9fc..273c809 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -10,7 +10,7 @@ For a local release candidate, build and install the generated rock instead of loading files from a sibling checkout: ```powershell -luarocks install .\dist\dwarfspec-0.2.1-1.all.rock +luarocks install .\dist\dwarfspec-0.2.2-1.all.rock ``` The VS Code `Publish` task, or `tools/Publish.ps1`, produces that portable diff --git a/docs/test-runner-service-design.md b/docs/test-runner-service-design.md index 7856eeb..c080422 100644 --- a/docs/test-runner-service-design.md +++ b/docs/test-runner-service-design.md @@ -673,6 +673,64 @@ The JSON payload uses `dwarfspec.transport.v2` and contains: } ``` +### Adapter rejection envelope + +An expected adapter rejection uses the existing `dwarfspec.error.v1` +envelope. `kind` remains the broad runner classification, `message` remains a +required non-empty diagnostic that is meaningful without subtype handling, +and the optional `code` identifies a machine-readable subtype. Adding a code +does not change the runner failure kind, persisted result state, or process +exit code associated with `kind`. + +A package-version mismatch is a registration rejection with this contract: + +```json +{ + "schema": "dwarfspec.error.v1", + "protocol": 2, + "kind": "registration", + "code": "package_version_mismatch", + "message": "DFHack already has a different DwarfSpec version loaded", + "running_version": "0.2.1", + "requested_version": "0.2.2" +} +``` + +For `code="package_version_mismatch"`, `running_version` and +`requested_version` are required non-empty strings. `running_version` is the +DwarfSpec package version retained by the process-wide DFHack service +registry. `requested_version` is the version supplied by the host loaded from +the current DwarfSpec command's package. The response does not include project +paths, selected specifications, installation-tree paths, package roots, or +assumptions about how DFHack was launched. + +The controller renders a valid package-version mismatch with this canonical +diagnostic, substituting the two structured values: + +```text +DwarfSpec could not start because DFHack already has a different DwarfSpec version loaded. + + Running DFHack service: 0.2.1 + Current DwarfSpec command: 0.2.2 + +To use 0.2.2, save and fully exit Dwarf Fortress/DFHack, relaunch it, +and retry this command. Returning to the title screen or unloading the +world will not unload the process-wide DwarfSpec service. +``` + +Generic registration envelopes without `code` remain valid. An unknown future +registration code is displayed using its supplied `message` and must not be +treated as a package-version mismatch. The existing +`kind="executor_quarantined"` envelope and its required +`blocking_run_id`, `blocking_generation`, and `reason` fields are unchanged. +Generic and package-version registration rejections retain the `registration` +runner failure kind, `registration_error` persisted result state, and exit +code 5. The executor-quarantine envelope retains the `executor_quarantined` +runner failure kind and persisted result state, exit code 5, and its existing +recovery behavior. A package-version mismatch does not initiate recovery and +does not modify the service registry, projects, queue, scheduler, ownership, +timestamps, or retained runs. + `OUTPUT`, `DETAIL`, `HOST_ERROR`, and similar formatted protocol lines are not part of the service transport and are neither emitted nor parsed. The canonical JSON line contains all command feedback. @@ -897,6 +955,136 @@ It must also solve authentication, encryption, project synchronization, package deployment, and remote path identity. Enabling DFHack's unrestricted remote command listener is not considered a DwarfSpec remote execution design. +## Adapter error boundary + +`dwarfspec.error.v1` is the sole adapter-error envelope. Its `kind` is the +existing broad runner classification chosen at the adapter/controller boundary; +its optional `code` is a stable domain subtype chosen by the service or +scheduler layer that owns the rejection decision. Adding a code or safe field +is additive and does not change result state, exit code, retry, recovery, or +primary-versus-secondary error precedence. + +The shared contract accepts `registration`, `executor_quarantined`, and `host` +as envelope kinds. A domain rejection has a non-empty `code` and `message`. +Generic compatibility errors omit `code`; unknown future codes retain their +message and safe common fields but receive no code-specific guidance. Known +codes must contain exactly their required subtype fields. A malformed known +payload is an invalid host response, while an unexpected exception becomes a +bounded uncoded `host` error. These cases remain observably distinct. +Known subtype policy is centralized in +`dwarfspec.protocol.adapter_errors`. Package mismatch remains a private +protocol code; admission conflicts reuse the public immutable +`SchedulerFailureKind` values `project_busy`, `request_key_conflict`, and +`result_path_busy` so the scheduler classification is preserved verbatim. + +The common optional fields are `operation`, `run_id`, `generation`, +`current_generation`, `state`, `blocking_run_id`, `blocking_generation`, and +`reason`. Identifiers, states, and reasons are +non-empty strings; generations are positive integers. Subtype contracts add +only fields needed for remediation. Owner capabilities, authorization proofs, +package or project roots, result paths, and unrelated machine paths are +forbidden. The package mismatch code requires `running_version` and +`requested_version`. The existing uncoded executor-quarantine compatibility +shape requires `blocking_run_id`, `blocking_generation`, and `reason`. + +Each admission-conflict code has broad kind `registration` and requires +`blocking_run_id`, `blocking_generation`, `state`, and the scheduler's safe +`reason`. The blocking fields identify +the exact retained run that owns the conflict and are sufficient for +remediation, so the envelope deliberately omits project identity, normalized +result-path identity, and every raw path. `project_busy` tells the caller to +wait for and consume the outstanding result; `request_key_conflict` tells the +caller to retry the identical request or choose a new run identity; and +`result_path_busy` tells the caller to wait for result consumption or select a +different result destination. Controller guidance is selected by `code` and +formatted from the blocking fields, never by parsing `message` or `reason`. + +Admission rejection does not mutate the registry, outstanding ownership, +queue order, generation, leases, request-key bindings, or result-path +reservations. An identical request-key retry remains an accepted idempotent +transport response. Invalid scheduler invariants and identifier or capability +generator failures remain internal host faults. All three expected conflicts +retain the registration failure kind, `registration_error` persisted state, +exit code 5, a single bootstrap attempt, and no recovery attempt. + +Mutation adapters use the same envelope for expected orchestration rejections. +`abort`, `cancel`, `recover`, `acknowledge`, `discard`, and executor recovery +may report `service_not_loaded`, `run_not_found`, `generation_mismatch`, +`invalid_run_state`, `owner_capability_rejected`, `quarantine_mismatch`, or +`clean_state_unverified`. These codes retain broad kind `host` and direct +command exit code 5. Their required fields contain only the operation and the +minimum applicable run identifier, requested or current generation, state, +blocking quarantine identity, or clean-state reason. + +The controller selects remediation by `code` and renders identifiers, +generations, and states only from validated subtype fields. A recovery or +acknowledgement rejection is appended as secondary detail when an earlier run +failure exists; it never replaces the original timeout, interruption, host, or +test failure. Rejected service decisions complete before lease, journal, +ownership, quarantine, result-retention, or native-cleanup mutation. Owner +capabilities and authorization proofs never cross the error boundary. + +Polling and event adapters also use the shared envelope without changing the +healthy transport or read-only query schemas. `status`, `event_read`, and the +run-specific scheduler-status branch report `service_not_loaded`, +`run_not_found`, `generation_mismatch`, and +`owner_capability_rejected` where applicable. A cursor beyond the retained +journal reports `event_cursor_ahead` with the run identifier, generation, +state, requested `after_sequence`, and retained `last_sequence`. + +Expected generation and cursor validation happens before status polling renews +the owner lease. Therefore a rejected poll cannot advance a lease, event +cursor, journal, observation, or persisted report. Malformed cursors, corrupt +event journals, impossible run states, and unexpected host exceptions remain +uncoded internal failures. Terminal observation remains successful, including +its existing no-renewal behavior. The service-wide `dwarfspec.status.v1` +response continues to expose `service_loaded`; history, inspection, and logs +continue to represent normal absence with `service_loaded` and `found` rather +than adapter errors. + +The controller validates polling envelopes before generic subprocess handling +and selects remediation from subtype fields. A primary polling rejection still +uses the existing host-failure and state-aware recovery path. Any structured +recovery rejection is appended without replacing that primary failure. +Transports are consumed only after validation, preserving retry, timeout, +cursor advancement, lease renewal, cleanup confirmation, and acknowledgement +semantics. + +Adapters may emit a valid error envelope with either a zero or nonzero process +exit during migration. The controller inspects and validates the envelope +before interpreting the process exit. A valid structured rejection is retained; +a nonzero result without one remains a bridge or host failure and includes only +bounded, sanitized captured output. Healthy transports, read-only response +schemas, and the `DWARFSPEC_PROBE` connection grammar do not use this envelope. + +The canonical package-mismatch diagnostic remains the persisted result error +and CLI text. This additive envelope extraction does not require a package +version bump: it preserves schema and protocol versions, existing generic and +quarantine shapes, runner classifications, result states, and exit meanings. + +### Entrypoint failure inventory + +| Entrypoint | Expected domain rejection | Other structured state | Boundary or internal failures | +|---|---|---|---| +| `bootstrap` | package mismatch, scheduler admission, executor quarantine | successful `dwarfspec.transport.v2` | option, module-load, and unexpected host faults | +| `abort` | run identity, ownership, and state rejection | successful transport | argument, load, and unexpected host faults | +| `acknowledge` | generation, ownership, cursor, and state rejection | successful transport | argument, load, and unexpected host faults | +| `cancel` | run identity, ownership, cursor, and state rejection | successful transport | argument, load, and unexpected host faults | +| `discard` | run identity, generation, cursor, and state rejection | successful transport | argument, load, and unexpected host faults | +| `recover` | run identity, ownership, cursor, cleanup, and state rejection | successful transport | argument, load, and unexpected host faults | +| `recover_executor` | quarantine identity, generation, cursor, and clean-state rejection | successful transport | argument, load, and unexpected host faults | +| `status` | run identity, ownership, and cursor rejection | successful transport | subprocess/bridge, argument, load, and unexpected host faults | +| `event_read` | run identity and cursor rejection | successful transport | argument, load, and unexpected host faults | +| `scheduler_status` | none | `dwarfspec.status.v1` or scheduler/transport response | argument, load, and unexpected host faults | +| `run_query` | invalid query arguments | history, inspection, or log response schemas, including `found=false` | argument, load, and unexpected host faults | +| `probe` | none | `DWARFSPEC_PROBE` connection state | unavailable or malformed DFHack context | + +The domain families listed above are migrated separately. Query `found=false`, +an unloaded status response, healthy scheduler state, and probe state are data, +not rejections. Process invocation failures belong to the controller's +connection or host classification. Assertions for malformed internal requests, +impossible invariants, module loading, and serialization remain uncoded faults. + ## Compatibility The existing CLI command names and exit-code meanings remain stable. Terminal @@ -906,9 +1094,10 @@ queue timeout receive distinct classifications without changing existing code meanings. Native run snapshots use `dwarfspec.run.v2`; adapters use -`dwarfspec.transport.v2`; event envelopes use `dwarfspec.event.v1`; and -persisted results use `dwarfspec.result.v2`. Legacy `dwarfspec.run.v1` reports -and formatted progress lines are unsupported. Schema identifiers version each +`dwarfspec.transport.v2`; expected adapter rejections use +`dwarfspec.error.v1`; event envelopes use `dwarfspec.event.v1`; and persisted +results use `dwarfspec.result.v2`. Legacy `dwarfspec.run.v1` reports and +formatted progress lines are unsupported. Schema identifiers version each document type independently; readers reject unknown versions instead of guessing. @@ -937,6 +1126,7 @@ The implementation uses these module boundaries: | `dwarfspec.protocol.enums.test_statuses` | Immutable Busted result-status identifiers. | | `dwarfspec.protocol.enums.result_policies` | Immutable result-persistence policies. | | `dwarfspec.protocol.schemas` | Versioned service, scheduler, run, transport, event, and result validation. | +| `dwarfspec.protocol.adapter_errors` | Canonical adapter-error construction, field policy, validation, and safe serialization. | | `dwarfspec.host.service.snapshots` | Immutable run, history, and scheduler snapshot construction. | | `dwarfspec.host.execution.host` | Busted execution, native state transitions, and cleanup. | | `dwarfspec.host.execution.output_handler` | Translation from Busted callbacks into service events. | diff --git a/dwarfspec-0.2.1-1.rockspec b/dwarfspec-0.2.2-1.rockspec similarity index 95% rename from dwarfspec-0.2.1-1.rockspec rename to dwarfspec-0.2.2-1.rockspec index 59b3c5c..39c7951 100644 --- a/dwarfspec-0.2.1-1.rockspec +++ b/dwarfspec-0.2.2-1.rockspec @@ -1,11 +1,11 @@ rockspec_format = "3.0" package = "dwarfspec" -version = "0.2.1-1" +version = "0.2.2-1" source = { url = "git+https://github.com/dsisco11/DwarfSpec.git", - tag = "v0.2.1", + tag = "v0.2.2", } description = { diff --git a/src/dwarfspec/controller/command_line.lua b/src/dwarfspec/controller/command_line.lua index a4d2dec..0374fe1 100644 --- a/src/dwarfspec/controller/command_line.lua +++ b/src/dwarfspec/controller/command_line.lua @@ -5,11 +5,11 @@ local glob = require('dwarfspec.support.glob') local result_store = require('dwarfspec.controller.result_store') local command_line = { - version='0.2.1', + version='0.2.2', } local HELP = [[ -DwarfSpec 0.2.1 - live DFHack automation with in-process Busted +DwarfSpec 0.2.2 - live DFHack automation with in-process Busted Usage: dwarfspec diff --git a/src/dwarfspec/controller/execution/command_builder.lua b/src/dwarfspec/controller/execution/command_builder.lua index a79d1f6..65c996b 100644 --- a/src/dwarfspec/controller/execution/command_builder.lua +++ b/src/dwarfspec/controller/execution/command_builder.lua @@ -123,10 +123,16 @@ function M.new(dependencies) ---@param run_id string ---@param owner_capability string ---@param after_sequence integer + ---@param generation integer|nil ---@return string[] - function builder.poll(options, run_id, owner_capability, after_sequence) - return {'lua', '-f', builder.host_script(options, 'status'), run_id, - owner_capability, tostring(after_sequence)} + function builder.poll(options, run_id, owner_capability, after_sequence, + generation) + local arguments = {'lua', '-f', builder.host_script(options, 'status'), + run_id, owner_capability, tostring(after_sequence)} + if generation ~= nil then + table.insert(arguments, tostring(generation)) + end + return arguments end ---Builds a scheduler-status command vector. diff --git a/src/dwarfspec/controller/execution/run_poller.lua b/src/dwarfspec/controller/execution/run_poller.lua index 9faddfe..9bd6e82 100644 --- a/src/dwarfspec/controller/execution/run_poller.lua +++ b/src/dwarfspec/controller/execution/run_poller.lua @@ -83,7 +83,8 @@ function M.new(dependencies) local expected = scope.expectation(cursor) local transport = client.transport(scope.options, scope.runner, builder.poll(scope.options, scope.run_id, - scope.owner_capability, cursor), expected, 'status') + scope.owner_capability, cursor, expected.generation), + expected, 'status') scope.execution_started_at = execution_started_at local consumed = poller.consume(scope, transport, true) report = consumed.report diff --git a/src/dwarfspec/controller/execution/run_recovery.lua b/src/dwarfspec/controller/execution/run_recovery.lua index 65df4d3..59197a4 100644 --- a/src/dwarfspec/controller/execution/run_recovery.lua +++ b/src/dwarfspec/controller/execution/run_recovery.lua @@ -45,6 +45,12 @@ function M.new(dependencies) return nil, 'recovery bridge failed: ' .. clean_message(result) end if result.exit_code ~= 0 then + if client.parse_transport_response then + local parsed, transport, rejection = pcall( + client.parse_transport_response, result.lines, + expected or {run_id=run_id}, options.decode_json) + if parsed and rejection then return nil, rejection.message end + end return nil, 'recovery exited with ' .. result.exit_code end local parse_expected = {} @@ -52,9 +58,16 @@ function M.new(dependencies) parse_expected[name] = value end parse_expected.after_sequence = after_sequence - local ok, transport = pcall(client.parse_transport, result.lines, - parse_expected, options.decode_json) + local ok, transport, rejection + if client.parse_transport_response then + ok, transport, rejection = pcall(client.parse_transport_response, + result.lines, parse_expected, options.decode_json) + else + ok, transport = pcall(client.parse_transport, result.lines, + parse_expected, options.decode_json) + end if not ok then return nil, tostring(transport) end + if rejection then return nil, rejection.message end local report = transport.snapshot if not report.terminal then return transport, 'recovery left the run nonterminal' end if report.state == RunState.ABORTED and not report.cleanup_confirmed then diff --git a/src/dwarfspec/controller/execution/runner.lua b/src/dwarfspec/controller/execution/runner.lua index 5d697ea..9be4497 100644 --- a/src/dwarfspec/controller/execution/runner.lua +++ b/src/dwarfspec/controller/execution/runner.lua @@ -8,6 +8,8 @@ local result_interpreter_module = require('dwarfspec.controller.execution.result local ErrorFormat = require('dwarfspec.protocol.configuration.error_formats') local ResultPolicy = require('dwarfspec.protocol.enums.result_policies') local RunnerFailureKind = require('dwarfspec.protocol.enums.runner_failure_kinds') +local SchedulerFailureKind = + require('dwarfspec.protocol.enums.scheduler_failure_kinds') local M = {} @@ -80,6 +82,9 @@ end ---@param value any ---@return string local function clean_message(value) + if type(value) == 'table' and type(value.message) == 'string' then + return value.message + end return tostring(value):gsub('^.-:%d+: ', '') end @@ -130,16 +135,45 @@ local recovery = run_recovery_module.new({ clean_message=clean_message, }) ----Adds actionable guidance to one host registration rejection. ----@param message string +---Formats one validated host registration rejection. +---@param rejection table ---@return string -local function registration_message(message) - local result = 'DwarfSpec bootstrap rejected: ' .. message - if message:match('incompatible automation package version') then - result = result .. '. Restart DFHack to unload the running ' .. - 'DwarfSpec service before using a different package version' +local function registration_message(rejection) + if rejection.code == 'package_version_mismatch' then + return ('DwarfSpec could not start because DFHack already has a ' .. + 'different DwarfSpec version loaded.\n\n' .. + ' Running DFHack service: %s\n' .. + ' Current DwarfSpec command: %s\n\n' .. + 'To use %s, save and fully exit Dwarf Fortress/DFHack, ' .. + 'relaunch it,\nand retry this command. Returning to the title ' .. + 'screen or unloading the\nworld will not unload the process-wide ' .. + 'DwarfSpec service.'):format(rejection.running_version, + rejection.requested_version, rejection.requested_version) + end + if rejection.code == SchedulerFailureKind.PROJECT_BUSY then + return ('DwarfSpec could not start because this project already has ' .. + 'an outstanding run.\n\n Blocking run: %s\n Generation: %d\n' .. + ' State: %s\n\nWait for that run to finish and consume its result, ' .. + 'then retry this command.'):format(rejection.blocking_run_id, + rejection.blocking_generation, rejection.state) + end + if rejection.code == SchedulerFailureKind.REQUEST_KEY_CONFLICT then + return ('DwarfSpec could not start because this request identity is ' .. + 'already bound to a different run.\n\n Blocking run: %s\n' .. + ' Generation: %d\n State: %s\n\nRetry the identical request, ' .. + 'or submit this work with a new run identity.'):format( + rejection.blocking_run_id, rejection.blocking_generation, + rejection.state) end - return result + if rejection.code == SchedulerFailureKind.RESULT_PATH_BUSY then + return ('DwarfSpec could not start because the configured result ' .. + 'destination is reserved by another run.\n\n Blocking run: %s\n' .. + ' Generation: %d\n State: %s\n\nWait until that result is ' .. + 'consumed, or choose a different result destination.'):format( + rejection.blocking_run_id, rejection.blocking_generation, + rejection.state) + end + return 'DwarfSpec bootstrap rejected: ' .. rejection.message end @@ -253,13 +287,20 @@ function M.run(options) bootstrap_rejected = true local message = response_error.kind == RunnerFailureKind.REGISTRATION and - registration_message(response_error.message) or + registration_message(response_error) or response_error.message fail(response_error.kind, message) end owner_capability = capability return transport else + if type(transport) == 'table' and + transport.invalid_adapter_error then + bootstrap_rejected = true + fail(RunnerFailureKind.REGISTRATION, + 'DwarfSpec bootstrap response was invalid: ' .. + clean_message(transport.message)) + end if type(transport) == 'table' and transport.exit_code and not transport.retryable then error(transport, 0) @@ -385,11 +426,11 @@ function M.run(options) event_cursor) if not acknowledge_ok and not runner_error then runner_error = failure(RunnerFailureKind.HOST, - tostring(acknowledge_error)) + clean_message(acknowledge_error)) elseif not acknowledge_ok then runner_error.message = runner_error.message .. '; could not acknowledge terminal result: ' .. - tostring(acknowledge_error) + clean_message(acknowledge_error) end end diff --git a/src/dwarfspec/controller/execution/transport_client.lua b/src/dwarfspec/controller/execution/transport_client.lua index 7ead570..baf4913 100644 --- a/src/dwarfspec/controller/execution/transport_client.lua +++ b/src/dwarfspec/controller/execution/transport_client.lua @@ -4,6 +4,245 @@ local process = require('dwarfspec.controller.execution.process') local reports = require('dwarfspec.controller.reporting.report') local M = {} +local PROBE_MARKER = 'DWARFSPEC_PROBE' +local EXPECTED_PROTOCOL = 2 +local MAX_OUTPUT_LINES = 8 +local MAX_LINE_BYTES = 512 +local MAX_OUTPUT_BYTES = 2048 +local LINE_TRUNCATED = '...' +local OUTPUT_TRUNCATED = ' ' + +---Renders remediation using only validated structured rejection fields. +---@param value table +---@return string +local function operation_rejection_message(value) + local messages = { + service_not_loaded=function() + return ('%s Bootstrap DwarfSpec with a run before retrying %s.') + :format(value.message, value.operation) + end, + run_not_found=function() + return ('%s Run %s is no longer retained; refresh status before retrying %s.') + :format(value.message, value.run_id, value.operation) + end, + generation_mismatch=function() + return ('%s Run %s requested generation %d, current generation %d; refresh status and retry.') + :format(value.message, value.run_id, value.generation, + value.current_generation) + end, + invalid_run_state=function() + return ('%s Run %s generation %d is %s; refresh status and choose an operation valid for that state.') + :format(value.message, value.run_id, value.generation, + value.state) + end, + owner_capability_rejected=function() + return ('%s Run %s generation %d is %s; retry from its owning DwarfSpec process or use an authorized operator command.') + :format(value.message, value.run_id, value.generation, + value.state) + end, + quarantine_mismatch=function() + return ('%s Requested run %s generation %d, but executor quarantine belongs to run %s generation %d; refresh status and recover that exact generation.') + :format(value.message, value.run_id, value.generation, + value.blocking_run_id, value.blocking_generation) + end, + clean_state_unverified=function() + return ('%s Run %s generation %d: %s Resolve remaining live resources, then retry executor recovery.') + :format(value.message, value.run_id, value.generation, + value.reason) + end, + event_cursor_ahead=function() + return ('%s Run %s generation %d is %s; requested cursor %d, retained cursor %d. Restart observation from the retained cursor without advancing local state.') + :format(value.message, value.run_id, value.generation, + value.state, value.after_sequence, value.last_sequence) + end, + } + return messages[value.code] and messages[value.code]() or value.message +end + +---Converts an arbitrary captured value without allowing tostring errors to escape. +---@param value any +---@return string +local function safe_tostring(value) + local ok, rendered = pcall(tostring, value) + if not ok or type(rendered) ~= 'string' then return '' end + return rendered +end + +---Returns the longest prefix within the byte limit without splitting valid UTF-8. +---@param value string +---@param limit integer +---@return string +local function utf8_prefix(value, limit) + if #value <= limit then return value end + local finish = limit + while finish > 0 do + local next_byte = value:byte(finish + 1) + if not next_byte or next_byte < 0x80 or next_byte > 0xbf then break end + finish = finish - 1 + end + return value:sub(1, finish) +end + +---Returns the longest suffix within the byte limit without splitting valid UTF-8. +---@param value string +---@param limit integer +---@return string +local function utf8_suffix(value, limit) + if #value <= limit then return value end + local first = #value - limit + 1 + while first <= #value do + local byte = value:byte(first) + if not byte or byte < 0x80 or byte > 0xbf then break end + first = first + 1 + end + return value:sub(first) +end + +---Collects captured output in deterministic numeric-index order. +---@param lines any +---@return any[] +local function ordered_output(lines) + if lines == nil then return {} end + if type(lines) ~= 'table' then return {lines} end + local indexes = {} + local index = next(lines) + while index ~= nil do + if type(index) == 'number' and index >= 1 and index % 1 == 0 then + indexes[#indexes + 1] = index + end + index = next(lines, index) + end + table.sort(indexes) + local ordered = {} + for _, numeric_index in ipairs(indexes) do + ordered[#ordered + 1] = lines[numeric_index] + end + return ordered +end + +---Normalizes and bounds one captured output line. +---@param value any +---@return string|nil +local function format_output_line(value) + local rendered = safe_tostring(value):gsub('\t', ' '):gsub('[%z\1-\31\127]', '?') + rendered = rendered:gsub('^ +', ''):gsub(' +$', '') + if rendered == '' then return nil end + if #rendered > MAX_LINE_BYTES then + rendered = utf8_prefix(rendered, MAX_LINE_BYTES - #LINE_TRUNCATED) .. + LINE_TRUNCATED + end + return rendered +end + +---Formats recent merged subprocess output within deterministic byte and line limits. +---@param lines any +---@return string +local function format_output(lines) + local formatted = {} + for _, value in ipairs(ordered_output(lines)) do + local line = format_output_line(value) + if line then formatted[#formatted + 1] = line end + end + if #formatted == 0 then return '' end + + local retained = {} + local first = math.max(1, #formatted - MAX_OUTPUT_LINES + 1) + if first > 1 then + retained[#retained + 1] = + ('<%d earlier lines omitted>'):format(first - 1) + end + for index = first, #formatted do + retained[#retained + 1] = formatted[index] + end + + local output = table.concat(retained, ' | ') + if #output > MAX_OUTPUT_BYTES then + output = OUTPUT_TRUNCATED .. utf8_suffix(output, + MAX_OUTPUT_BYTES - #OUTPUT_TRUNCATED) + end + return output +end + +---Returns a validated canonical adapter error from subprocess output, if any. +---@param result table +---@param expected table|nil +---@param decoder function|nil +---@return table|nil +local function adapter_error_from_result(result, expected, decoder) + local parsed, _, _, adapter_error = pcall( + reports.parse_transport_response, result.lines, expected or {}, decoder) + if parsed then return adapter_error end + return nil +end + +---Builds one bounded fallback for a nonzero subprocess result. +---@param operation string +---@param result table +---@return string +local function nonzero_message(operation, result) + return ('DwarfSpec %s exited with %s. Output: %s'):format( + operation, safe_tostring(result.exit_code), format_output(result.lines)) +end + +---Finds exact probe marker lines without treating embedded marker text as a report. +---@param lines any +---@return string[] +local function probe_candidates(lines) + local candidates = {} + for _, value in ipairs(ordered_output(lines)) do + local line = safe_tostring(value) + if line == PROBE_MARKER or + line:sub(1, #PROBE_MARKER + 1) == PROBE_MARKER .. ' ' then + candidates[#candidates + 1] = line + end + end + return candidates +end + +---Parses one exact probe report according to the controller probe grammar. +---@param line string +---@return table|nil, string|nil +local function parse_probe(line) + local fields = {} + local remainder = line:sub(#PROBE_MARKER + 2) + for token in remainder:gmatch('[^ ]+') do + local name, value = token:match('^([^=]+)=([^=]*)$') + if not name then + return nil, 'invalid token: ' .. safe_tostring(token) + end + if not name:match('^[a-z][a-z0-9_]*$') then + return nil, 'invalid field name: ' .. safe_tostring(name) + end + if value == '' then return nil, 'empty value for field ' .. name end + if not value:match('^[A-Za-z0-9._+%-]+$') then + return nil, ('invalid value for field %s: %s'):format( + name, safe_tostring(value)) + end + if fields[name] ~= nil then return nil, 'duplicate field: ' .. name end + fields[name] = value + end + + for _, name in ipairs({'protocol', 'core', 'timeout'}) do + if fields[name] == nil then + return nil, 'missing required field: ' .. name + end + end + if not fields.protocol:match('^[1-9][0-9]*$') then + return nil, 'invalid protocol value: ' .. fields.protocol + end + if fields.core ~= 'true' and fields.core ~= 'false' and + fields.core ~= 'unavailable' then + return nil, 'invalid core value: ' .. fields.core + end + local timeout_values = { + ['nil']=true, boolean=true, number=true, string=true, ['function']=true, + userdata=true, thread=true, table=true, unavailable=true, + } + if not timeout_values[fields.timeout] then + return nil, 'invalid timeout value: ' .. fields.timeout + end + return fields, nil +end ---Creates a transport client over the subprocess and report authorities. ---@param dependencies table @@ -16,6 +255,19 @@ function M.new(dependencies) local clean_message = assert(dependencies.clean_message, 'transport error cleaner is required') local client = {} + ---Converts a validated wire rejection into a classified controller error. + ---@param value table + ---@return table + local function controller_rejection(value) + local detail = failure(value.kind, operation_rejection_message(value)) + for name, field_value in pairs(value) do + if name ~= 'message' and name ~= 'kind' then + detail[name] = field_value + end + end + return detail + end + ---Resolves the configured dfhack-run executable. ---@param options table ---@return string|nil, table|nil @@ -33,14 +285,54 @@ function M.new(dependencies) local ok, result = pcall(invoke, runner, builder.probe(options)) if not ok then error(failure(kinds.CONNECTION, - 'could not contact DFHack through ' .. runner .. ': ' .. - clean_message(result)), 0) + ('Could not invoke DFHack runner "%s": %s'):format( + runner, clean_message(result))), 0) + end + if result.exit_code ~= 0 then + error(failure(kinds.CONNECTION, + ('DFHack connection probe through "%s" exited with code %s. ' .. + 'Output: %s'):format(runner, safe_tostring(result.exit_code), + format_output(result.lines))), 0) + end + + local candidates = probe_candidates(result.lines) + if #candidates == 0 then + error(failure(kinds.CONNECTION, + ('DFHack responded through "%s", but emitted no DwarfSpec ' .. + 'probe report. Output: %s'):format( + runner, format_output(result.lines))), 0) + end + if #candidates > 1 then + error(failure(kinds.CONNECTION, + ('DFHack emitted %d DwarfSpec probe reports; expected exactly ' .. + 'one. Output: %s'):format( + #candidates, format_output(result.lines))), 0) + end + + local probe, reason = parse_probe(candidates[1]) + if not probe then + error(failure(kinds.CONNECTION, + ('DFHack emitted a malformed DwarfSpec probe report: %s. ' .. + 'Probe: %s'):format(format_output({reason}), + format_output({candidates[1]}))), 0) + end + if probe.protocol ~= tostring(EXPECTED_PROTOCOL) then + error(failure(kinds.CONNECTION, + ('DwarfSpec protocol mismatch: controller expects %d, probe ' .. + 'reported %s. Check for mixed installed DwarfSpec package ' .. + 'versions.'):format(EXPECTED_PROTOCOL, probe.protocol)), 0) + end + if probe.core ~= 'true' then + error(failure(kinds.CONNECTION, + ('DFHack probe did not run in a healthy core Lua context: ' .. + 'expected core=true, reported core=%s.'):format( + probe.core)), 0) end - if result.exit_code ~= 0 or - result.lines[#result.lines] ~= - 'DWARFSPEC_PROBE protocol=2 core=true timeout=function' then + if probe.timeout ~= 'function' then error(failure(kinds.CONNECTION, - 'DFHack is not running or did not provide a healthy core Lua context'), 0) + ('DFHack core Lua context is missing the required ' .. + 'dfhack.timeout function: reported timeout=%s.'):format( + probe.timeout)), 0) end end @@ -68,10 +360,17 @@ function M.new(dependencies) operation, clean_message(result))), 0) end if result.exit_code ~= 0 then - error(failure(kinds.HOST, - ('DwarfSpec %s exited with %d'):format(operation, result.exit_code)), 0) + local adapter_error = adapter_error_from_result( + result, expected, options.decode_json) + if adapter_error then + error(controller_rejection(adapter_error), 0) + end + error(failure(kinds.HOST, nonzero_message(operation, result)), 0) end - return reports.parse_transport(result.lines, expected, options.decode_json) + local transport, _, adapter_error = reports.parse_transport_response( + result.lines, expected, options.decode_json) + if adapter_error then error(controller_rejection(adapter_error), 0) end + return transport end ---Invokes and parses a bootstrap response that may contain a rejection. @@ -89,8 +388,11 @@ function M.new(dependencies) error(detail, 0) end if result.exit_code ~= 0 then + local adapter_error = adapter_error_from_result( + result, expected, options.decode_json) + if adapter_error then return nil, nil, adapter_error end error(failure(kinds.REGISTRATION, - 'DwarfSpec bootstrap exited with ' .. result.exit_code), 0) + nonzero_message('bootstrap', result)), 0) end local transport, _, adapter_error = reports.parse_transport_response( result.lines, expected, options.decode_json) @@ -110,8 +412,10 @@ function M.new(dependencies) 'DwarfSpec status bridge failed: ' .. clean_message(result)), 0) end if result.exit_code ~= 0 then - error(failure(kinds.HOST, - 'DwarfSpec status exited with ' .. result.exit_code), 0) + local adapter_error = adapter_error_from_result( + result, nil, options.decode_json) + if adapter_error then error(adapter_error, 0) end + error(failure(kinds.HOST, nonzero_message('status', result)), 0) end return reports.parse_status(result.lines, options.decode_json) end @@ -131,9 +435,11 @@ function M.new(dependencies) operation, clean_message(result))), 0) end if result.exit_code ~= 0 then + local adapter_error = adapter_error_from_result( + result, nil, options.decode_json) + if adapter_error then error(adapter_error, 0) end error(failure(kinds.HOST, - ('DwarfSpec %s query exited with %d'):format( - operation, result.exit_code)), 0) + nonzero_message(operation .. ' query', result)), 0) end local parsers = { history=reports.parse_run_history, @@ -154,6 +460,20 @@ function M.new(dependencies) return reports.parse_transport(lines, expected, decoder) end + ---Parses a raw transport-or-rejection response for recovery workflows. + ---@param lines string[] + ---@param expected table + ---@param decoder function|nil + ---@return table|nil, table|nil + function client.parse_transport_response(lines, expected, decoder) + local transport, _, adapter_error = reports.parse_transport_response( + lines, expected, decoder) + if adapter_error then + return nil, controller_rejection(adapter_error) + end + return transport, nil + end + ---Returns the report authority's event formatter for polling composition. ---@return function function client.event_formatter() diff --git a/src/dwarfspec/controller/reporting/report.lua b/src/dwarfspec/controller/reporting/report.lua index 44330c2..4e76b80 100644 --- a/src/dwarfspec/controller/reporting/report.lua +++ b/src/dwarfspec/controller/reporting/report.lua @@ -2,6 +2,7 @@ local M = {} local events = require('dwarfspec.protocol.events') +local adapter_errors = require('dwarfspec.protocol.adapter_errors') local EventType = require('dwarfspec.protocol.enums.event_types') local diagnostic_formatter = require('dwarfspec.controller.reporting.diagnostic_formatter') local focus = @@ -11,7 +12,6 @@ local focus_warning = local schemas = require('dwarfspec.protocol.schemas') local SchedulerFailureKind = require('dwarfspec.protocol.enums.scheduler_failure_kinds') -local RunnerFailureKind = require('dwarfspec.protocol.enums.runner_failure_kinds') local PREFIX = 'DWARFSPEC_JSON ' local OWNER_PREFIX = 'DWARFSPEC_OWNER ' @@ -20,26 +20,17 @@ local OWNER_PREFIX = 'DWARFSPEC_OWNER ' ---@param report table ---@return table local function validate_error(report) - events.copy_json(report, 'adapter error response') - assert(report.protocol == 2, - 'unsupported DwarfSpec protocol: ' .. tostring(report.protocol)) - assert(report.kind == RunnerFailureKind.REGISTRATION or - report.kind == RunnerFailureKind.EXECUTOR_QUARANTINED, - 'unsupported DwarfSpec adapter error kind: ' .. tostring(report.kind)) - assert(type(report.message) == 'string' and report.message ~= '', - 'DwarfSpec adapter error message must be a non-empty string') - if report.kind == RunnerFailureKind.EXECUTOR_QUARANTINED then - assert(type(report.blocking_run_id) == 'string' and - report.blocking_run_id ~= '', - 'DwarfSpec quarantine error requires blocking run id') - assert(type(report.blocking_generation) == 'number' and - report.blocking_generation > 0 and - report.blocking_generation % 1 == 0, - 'DwarfSpec quarantine error requires blocking generation') - assert(type(report.reason) == 'string' and report.reason ~= '', - 'DwarfSpec quarantine error requires a reason') - end - return report + return adapter_errors.validate(report) +end + +---Wraps one malformed adapter-error diagnostic for bootstrap orchestration. +---@param value any +---@return table +local function invalid_adapter_error(value) + return { + invalid_adapter_error=true, + message=tostring(value), + } end ---Returns every machine-readable report payload in output order. @@ -109,7 +100,9 @@ end function M.parse_transport_response(lines, expected, decoder) local report, payload = decode_report(lines, decoder) if report.schema == 'dwarfspec.error.v1' then - return nil, payload, validate_error(report) + local valid, response_error = pcall(validate_error, report) + if not valid then error(invalid_adapter_error(response_error), 0) end + return nil, payload, response_error end if report.schema ~= 'dwarfspec.transport.v2' then error('unsupported DwarfSpec report schema: ' .. diff --git a/src/dwarfspec/host/entrypoints/abort.lua b/src/dwarfspec/host/entrypoints/abort.lua index ce5d80d..48364c0 100644 --- a/src/dwarfspec/host/entrypoints/abort.lua +++ b/src/dwarfspec/host/entrypoints/abort.lua @@ -47,9 +47,13 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local run = host.abort(run_id, owner_capability) -local transport = host.transport(run.run_id, after_sequence) -print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d') - :format(transport.protocol, transport.run_id, - transport.snapshot.state, transport.generation)) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + local run = host.abort(run_id, owner_capability) + return host.transport(run.run_id, after_sequence) +end, function(transport) + print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d') + :format(transport.protocol, transport.run_id, + transport.snapshot.state, transport.generation)) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/acknowledge.lua b/src/dwarfspec/host/entrypoints/acknowledge.lua index 1a41b81..40fafab 100644 --- a/src/dwarfspec/host/entrypoints/acknowledge.lua +++ b/src/dwarfspec/host/entrypoints/acknowledge.lua @@ -51,10 +51,14 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local run = host.acknowledge(run_id, generation, owner_capability) -local transport = host.transport(run.run_id, after_sequence) -print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d ' .. - 'acknowledged=true') - :format(transport.protocol, transport.run_id, - transport.snapshot.state, transport.generation)) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + local run = host.acknowledge(run_id, generation, owner_capability) + return host.transport(run.run_id, after_sequence) +end, function(transport) + print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d ' .. + 'acknowledged=true') + :format(transport.protocol, transport.run_id, + transport.snapshot.state, transport.generation)) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/bootstrap.lua b/src/dwarfspec/host/entrypoints/bootstrap.lua index 80490b1..63eb35c 100644 --- a/src/dwarfspec/host/entrypoints/bootstrap.lua +++ b/src/dwarfspec/host/entrypoints/bootstrap.lua @@ -138,40 +138,15 @@ end local root, lua_root = package_root() local json = require('json') +local adapter_errors = require('dwarfspec.protocol.adapter_errors') local RunnerFailureKind = require('dwarfspec.protocol.enums.runner_failure_kinds') -local SchedulerFailureKind = - require('dwarfspec.protocol.enums.scheduler_failure_kinds') - ----Removes an incidental Lua source location from an adapter error. ----@param value any ----@return string -local function clean_message(value) - return tostring(value):gsub('^.-:%d+: ', '') -end ---Emits one canonical bootstrap rejection. ---@param value any local function emit_error(value) - local response = { - schema='dwarfspec.error.v1', - protocol=2, - kind=RunnerFailureKind.REGISTRATION, - message=clean_message(value), - } - if type(value) == 'table' and - value.kind == SchedulerFailureKind.EXECUTOR_QUARANTINED then - response.kind = RunnerFailureKind.EXECUTOR_QUARANTINED - response.blocking_run_id = value.blocking_run_id - response.blocking_generation = value.blocking_generation - response.reason = value.reason - response.message = ('DwarfSpec executor is quarantined by run %s ' .. - 'generation %d: %s. Recover it with: dwarfspec ' .. - 'recover-executor %s --generation %d'):format( - value.blocking_run_id, value.blocking_generation, - value.reason, value.blocking_run_id, - value.blocking_generation) - end - print('DWARFSPEC_JSON ' .. json.encode(response, {pretty=false})) + local encoded = adapter_errors.serialize( + value, RunnerFailureKind.REGISTRATION, json.encode) + print('DWARFSPEC_JSON ' .. encoded) end ---Registers one run without emitting a partial success response. diff --git a/src/dwarfspec/host/entrypoints/cancel.lua b/src/dwarfspec/host/entrypoints/cancel.lua index a8da329..dda93a4 100644 --- a/src/dwarfspec/host/entrypoints/cancel.lua +++ b/src/dwarfspec/host/entrypoints/cancel.lua @@ -48,7 +48,11 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local run = host.cancel(run_id, owner_capability, - reason or 'external runner cancellation') -local transport = host.transport(run.run_id, after_sequence) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + local run = host.cancel(run_id, owner_capability, + reason or 'external runner cancellation') + return host.transport(run.run_id, after_sequence) +end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/discard.lua b/src/dwarfspec/host/entrypoints/discard.lua index e77e3e7..12b2fab 100644 --- a/src/dwarfspec/host/entrypoints/discard.lua +++ b/src/dwarfspec/host/entrypoints/discard.lua @@ -43,7 +43,11 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local run = host.discard(run_id, generation, - reason or 'local operator discarded retained result') -local transport = host.transport(run.run_id, after_sequence) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + local run = host.discard(run_id, generation, + reason or 'local operator discarded retained result') + return host.transport(run.run_id, after_sequence) +end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/event_read.lua b/src/dwarfspec/host/entrypoints/event_read.lua index 4738a7b..ad669e1 100644 --- a/src/dwarfspec/host/entrypoints/event_read.lua +++ b/src/dwarfspec/host/entrypoints/event_read.lua @@ -1,9 +1,11 @@ -- Production adapter that reads events without renewing a run lease. -local run_id, after_sequence_text = ... +local run_id, after_sequence_text, generation_text = ... assert(run_id, 'run id argument is required') local after_sequence = assert(tonumber(after_sequence_text), 'event cursor argument must be numeric') +local generation = generation_text and assert(tonumber(generation_text), + 'generation argument must be numeric') or nil ---Configures pure-Lua lookup and derives the DwarfSpec runtime root. ---@return string, string|nil @@ -41,5 +43,9 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local transport = host.transport(run_id, after_sequence) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + return host.transport(run_id, after_sequence, 'event read', generation) +end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/operation_response.lua b/src/dwarfspec/host/entrypoints/operation_response.lua new file mode 100644 index 0000000..c1d839d --- /dev/null +++ b/src/dwarfspec/host/entrypoints/operation_response.lua @@ -0,0 +1,30 @@ +-- Canonical success-or-rejection emission for mutation adapters. + +local adapter_errors = require('dwarfspec.protocol.adapter_errors') +local RunnerFailureKind = + require('dwarfspec.protocol.enums.runner_failure_kinds') + +local M = {} + +---Runs one adapter operation and emits exactly one canonical JSON response. +---@param operation function +---@param emit_success function +---@param encoder function +---@return boolean, any +function M.execute(operation, emit_success, encoder) + local succeeded, value = pcall(operation) + if not succeeded then + if type(value) ~= 'table' or type(value.code) ~= 'string' or + value.code == '' then + error(value, 0) + end + local encoded = adapter_errors.serialize( + value, RunnerFailureKind.HOST, encoder) + print('DWARFSPEC_JSON ' .. encoded) + return false, value + end + emit_success(value) + return true, value +end + +return M diff --git a/src/dwarfspec/host/entrypoints/probe.lua b/src/dwarfspec/host/entrypoints/probe.lua index b55f75d..6bcfe88 100644 --- a/src/dwarfspec/host/entrypoints/probe.lua +++ b/src/dwarfspec/host/entrypoints/probe.lua @@ -1,4 +1,41 @@ -- Production adapter that verifies access to DFHack's core Lua context. -print(('DWARFSPEC_PROBE protocol=2 core=%s timeout=%s') - :format(tostring(dfhack.is_core_context), type(dfhack.timeout))) +---Returns the available DFHack table through the active script environment. +---@return table|nil +local function dfhack_context() + local ok, context = pcall(function() return dfhack end) + return ok and type(context) == 'table' and context or nil +end + +---Returns the normalized core-context capability value. +---@param context table|nil +---@return string +local function core_capability(context) + if context and type(context.is_core_context) == 'boolean' then + return tostring(context.is_core_context) + end + return 'unavailable' +end + +---Returns the normalized timeout capability type. +---@param context table|nil +---@return string +local function timeout_capability(context) + if not context then return 'unavailable' end + return type(context.timeout) +end + +---Returns an optional safe DFHack version field. +---@param context table|nil +---@return string +local function version_field(context) + if not context or context.VERSION == nil then return '' end + local ok, version = pcall(tostring, context.VERSION) + if not ok or not version:match('^[A-Za-z0-9._+-]+$') then return '' end + return ' dfhack=' .. version +end + +local context = dfhack_context() +print(('DWARFSPEC_PROBE protocol=2 core=%s timeout=%s%s') + :format(core_capability(context), timeout_capability(context), + version_field(context))) diff --git a/src/dwarfspec/host/entrypoints/recover.lua b/src/dwarfspec/host/entrypoints/recover.lua index 593bdc7..b00b75a 100644 --- a/src/dwarfspec/host/entrypoints/recover.lua +++ b/src/dwarfspec/host/entrypoints/recover.lua @@ -48,7 +48,11 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local run = host.recover(run_id, owner_capability, - reason or 'external runner recovery') -local transport = host.transport(run.run_id, after_sequence) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + local run = host.recover(run_id, owner_capability, + reason or 'external runner recovery') + return host.transport(run.run_id, after_sequence) +end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/recover_executor.lua b/src/dwarfspec/host/entrypoints/recover_executor.lua index 89c2a3b..5b41891 100644 --- a/src/dwarfspec/host/entrypoints/recover_executor.lua +++ b/src/dwarfspec/host/entrypoints/recover_executor.lua @@ -50,8 +50,13 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -host.recover_executor(run_id, generation, - reason or 'local operator verified executor clean state') -local transport = host.transport(run_id, after_sequence) -transport.scheduler = host.scheduler_snapshot() -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + host.recover_executor(run_id, generation, + reason or 'local operator verified executor clean state') + local transport = host.transport(run_id, after_sequence) + transport.scheduler = host.scheduler_snapshot() + return transport +end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/entrypoints/scheduler_status.lua b/src/dwarfspec/host/entrypoints/scheduler_status.lua index fbad396..440335d 100644 --- a/src/dwarfspec/host/entrypoints/scheduler_status.lua +++ b/src/dwarfspec/host/entrypoints/scheduler_status.lua @@ -1,11 +1,13 @@ -- Production adapter for scheduler state and retained-run transport. -local run_id, after_sequence_text = ... +local run_id, after_sequence_text, generation_text = ... local after_sequence if run_id ~= nil then after_sequence = assert(tonumber(after_sequence_text), 'event cursor argument must be numeric') end +local generation = generation_text and assert(tonumber(generation_text), + 'generation argument must be numeric') or nil ---Configures pure-Lua lookup and derives the DwarfSpec runtime root. ---@return string, string|nil @@ -53,7 +55,13 @@ if run_id == nil then scheduler=loaded and host.scheduler_snapshot() or nil, })) else - local transport = host.transport(run_id, after_sequence) - transport.scheduler = host.scheduler_snapshot() - print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) + local response = require('dwarfspec.host.entrypoints.operation_response') + response.execute(function() + local transport = host.transport(run_id, after_sequence, + 'run scheduler status', generation) + transport.scheduler = host.scheduler_snapshot() + return transport + end, function(transport) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) + end, require('json').encode) end diff --git a/src/dwarfspec/host/entrypoints/status.lua b/src/dwarfspec/host/entrypoints/status.lua index e4ef968..1d92026 100644 --- a/src/dwarfspec/host/entrypoints/status.lua +++ b/src/dwarfspec/host/entrypoints/status.lua @@ -1,10 +1,12 @@ -- Production adapter that polls a run through cursor-based transport. -local run_id, owner_capability, after_sequence_text = ... +local run_id, owner_capability, after_sequence_text, generation_text = ... assert(run_id, 'run id argument is required') assert(owner_capability, 'owner capability argument is required') local after_sequence = assert(tonumber(after_sequence_text), 'event cursor argument must be numeric') +local generation = generation_text and assert(tonumber(generation_text), + 'generation argument must be numeric') or nil ---Configures pure-Lua module lookup and derives the DwarfSpec runtime root. ---@return string, string|nil @@ -48,10 +50,13 @@ end local root, lua_root = package_root() local host = load_host(root, lua_root) -local poll_ok, transport = pcall(host.poll_transport, run_id, - owner_capability, after_sequence) -if not poll_ok then qerror(transport) end -print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d') - :format(transport.protocol, transport.run_id, - transport.snapshot.state, transport.generation)) -print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +local response = require('dwarfspec.host.entrypoints.operation_response') +response.execute(function() + return host.poll_transport(run_id, owner_capability, after_sequence, + generation) +end, function(transport) + print(('DWARFSPEC protocol=%d run_id=%s state=%s generation=%d') + :format(transport.protocol, transport.run_id, + transport.snapshot.state, transport.generation)) + print('DWARFSPEC_JSON ' .. host.encode_transport(transport)) +end, require('json').encode) diff --git a/src/dwarfspec/host/execution/host.lua b/src/dwarfspec/host/execution/host.lua index fc9428a..fbc942a 100644 --- a/src/dwarfspec/host/execution/host.lua +++ b/src/dwarfspec/host/execution/host.lua @@ -11,6 +11,7 @@ local OwnerKind = require('dwarfspec.protocol.enums.owner_kinds') local ResultPolicy = require('dwarfspec.protocol.enums.result_policies') local SchedulerFailureKind = require('dwarfspec.protocol.enums.scheduler_failure_kinds') +local adapter_errors = require('dwarfspec.protocol.adapter_errors') local service = require('dwarfspec.host.service.service') local module_environment_module = require('dwarfspec.host.environment.module_environment') @@ -27,7 +28,7 @@ local run_lifecycle_module = require('dwarfspec.host.execution.run_lifecycle') local M = { protocol_version=2, - package_version='0.2.1', + package_version='0.2.2', } local RUN_STATE_TERMINAL = { @@ -146,9 +147,17 @@ local function validate_run_id(run_id) end ---Returns the compatible process-wide service registry. +---@param operation string|nil ---@return table -local function get_registry() +local function get_registry(operation) local registry = dfhack.dwarfspec + if operation and (type(registry) ~= 'table' or + registry.protocol_version ~= M.protocol_version or + registry.schema ~= service.schema) then + error(adapter_errors.domain('service_not_loaded', + 'The compatible DwarfSpec service is not loaded.', + {operation=operation}), 0) + end assert(type(registry) == 'table' and registry.protocol_version == M.protocol_version and registry.schema == service.schema, @@ -569,6 +578,29 @@ local function service_selection(specs) return identities end +local ADMISSION_MESSAGES = { + [SchedulerFailureKind.PROJECT_BUSY]= + 'This project already has an outstanding DwarfSpec run.', + [SchedulerFailureKind.REQUEST_KEY_CONFLICT]= + 'This request identity is already bound to a different DwarfSpec run.', + [SchedulerFailureKind.RESULT_PATH_BUSY]= + 'The configured result destination is reserved by another DwarfSpec run.', +} + +---Constructs a public structured rejection for one expected admission conflict. +---@param outcome table +---@return table +local function admission_rejection(outcome) + local message = ADMISSION_MESSAGES[outcome.kind] + if not message then return nil end + return adapter_errors.domain(outcome.kind, message, { + blocking_run_id=outcome.identity.run_id, + blocking_generation=outcome.identity.generation, + state=outcome.snapshot.state, + reason=outcome.reason, + }) +end + ---Starts one service-owned nonblocking automation run. ---@param package_root string ---@param project_root string @@ -619,12 +651,9 @@ function M.start(package_root, project_root, options) selection={identities=service_selection(options.specs)}, }, dependencies) if not outcome.accepted then - if outcome.snapshot.terminal then - error(('automation run %s has an unobserved %s result') - :format(outcome.identity.run_id, outcome.snapshot.state)) - end - error(('automation run %s is already %s') - :format(outcome.identity.run_id, outcome.snapshot.state)) + local rejection = admission_rejection(outcome) + if rejection then error(rejection, 0) end + error(outcome.reason or 'DwarfSpec scheduler rejected the run', 0) end local registry = get_registry() local run = assert(registry.runs[outcome.identity.run_id], @@ -639,9 +668,10 @@ end ---Returns any retained service run by exact identifier. ---@param run_id string +---@param operation string|nil ---@return table|nil -function M.find(run_id) - local registry = get_registry() +function M.find(run_id, operation) + local registry = get_registry(operation) return registry.runs[run_id] end @@ -661,19 +691,60 @@ end ---Observes one retained run without renewing or transferring ownership. ---@param run_id string +---@param operation string|nil ---@return table -function M.observe(run_id) - local run = M.find(run_id) - if not run then error('automation run not found: ' .. run_id) end +function M.observe(run_id, operation) + operation = operation or 'observe' + local run = M.find(run_id, operation) + if not run then + error(adapter_errors.domain('run_not_found', + 'DwarfSpec run was not found.', + {operation=operation, run_id=run_id}), 0) + end return run end +---Validates expected polling identity and cursor before any lease mutation. +---@param run table +---@param operation string +---@param after_sequence integer|nil +---@param expected_generation integer|nil +local function validate_transport_request(run, operation, after_sequence, + expected_generation) + if expected_generation ~= nil and + expected_generation ~= run.generation then + error(adapter_errors.domain('generation_mismatch', + 'The requested run generation is stale.', { + operation=operation, run_id=run.run_id, + generation=expected_generation, + current_generation=run.generation, + }), 0) + end + if after_sequence == nil then return end + assert(type(after_sequence) == 'number' and after_sequence >= 0 and + after_sequence % 1 == 0, + 'event cursor must be a nonnegative integer') + events.validate_journal(run.event_journal) + local last_sequence = #run.event_journal.events + if after_sequence > last_sequence then + error(adapter_errors.domain('event_cursor_ahead', + 'The requested event cursor is ahead of the retained journal.', { + operation=operation, run_id=run.run_id, + generation=run.generation, state=run.state, + after_sequence=after_sequence, + last_sequence=last_sequence, + }), 0) + end +end + ---Renews an owned nonterminal run and returns its current state. ---@param run_id string ---@param owner_capability string +---@param expected_generation integer|nil ---@return table -function M.poll(run_id, owner_capability) - local run = M.observe(run_id) +function M.poll(run_id, owner_capability, expected_generation) + local run = M.observe(run_id, 'status poll') + validate_transport_request(run, 'status poll', nil, expected_generation) assert(type(owner_capability) == 'string' and owner_capability ~= '', 'status poll requires the owner capability') if not M.is_terminal(run) then @@ -690,8 +761,13 @@ end ---Returns canonical transport data after one event sequence cursor. ---@param run_id string ---@param after_sequence integer +---@param operation string|nil +---@param expected_generation integer|nil ---@return table -function M.transport(run_id, after_sequence) +function M.transport(run_id, after_sequence, operation, expected_generation) + local label = operation or 'event read' + local run = M.observe(run_id, label) + validate_transport_request(run, label, after_sequence, expected_generation) return service.transport(run_id, after_sequence, service_dependencies()) end @@ -699,10 +775,15 @@ end ---@param run_id string ---@param owner_capability string ---@param after_sequence integer +---@param expected_generation integer|nil ---@return table -function M.poll_transport(run_id, owner_capability, after_sequence) - M.poll(run_id, owner_capability) - return M.transport(run_id, after_sequence) +function M.poll_transport(run_id, owner_capability, after_sequence, + expected_generation) + local run = M.observe(run_id, 'status poll') + validate_transport_request(run, 'status poll', after_sequence, + expected_generation) + M.poll(run_id, owner_capability, expected_generation) + return service.transport(run_id, after_sequence, service_dependencies()) end ---Acknowledges successful persistence for one exact terminal owner. @@ -711,7 +792,7 @@ end ---@param owner_capability string ---@return table function M.acknowledge(run_id, generation, owner_capability) - local run = M.observe(run_id) + local run = M.observe(run_id, 'acknowledgement') local request = owner_request(run, owner_capability) request.generation = generation request.persistence = { @@ -729,9 +810,7 @@ end ---@param reason string|nil ---@return table function M.cancel(run_id, owner_capability, reason) - local run = M.observe(run_id) - assert(run.state == RunState.QUEUED, - 'only a queued automation run can be cancelled') + local run = M.observe(run_id, 'cancel') local request = owner_request(run, owner_capability) request.reason = reason or 'by request' service.cancel(request, service_dependencies()) @@ -746,7 +825,7 @@ end ---@param reason string|nil ---@return table function M.recover(run_id, owner_capability, reason) - local run = M.observe(run_id) + local run = M.observe(run_id, 'recover') if M.is_terminal(run) then return run end if run.state == RunState.QUEUED then return M.cancel(run_id, owner_capability, @@ -764,7 +843,7 @@ end ---@param reason string ---@return table function M.discard(run_id, generation, reason) - local run = M.observe(run_id) + local run = M.observe(run_id, 'discard') service.discard({ service_instance_id=run.service_instance_id, project_id=run.project_id, @@ -817,7 +896,7 @@ end ---@param reason string ---@return table function M.recover_executor(run_id, generation, reason) - local registry = get_registry() + local registry = get_registry('recover executor') local outcome = service.recover_executor({ service_instance_id=registry.service_instance_id, run_id=run_id, @@ -834,9 +913,13 @@ end ---@param owner_capability string|nil ---@return table function M.abort(run_id, owner_capability) - local registry = get_registry() + local registry = get_registry('abort') local run = registry.runs[run_id] - if not run then error('automation run not found: ' .. run_id) end + if not run then + error(adapter_errors.domain('run_not_found', + 'DwarfSpec run was not found.', + {operation='abort', run_id=run_id}), 0) + end if M.is_terminal(run) then return run end local reason = 'by request' if run.state == RunState.QUEUED then diff --git a/src/dwarfspec/host/service/scheduler/queue.lua b/src/dwarfspec/host/service/scheduler/queue.lua index a11991f..83be04b 100644 --- a/src/dwarfspec/host/service/scheduler/queue.lua +++ b/src/dwarfspec/host/service/scheduler/queue.lua @@ -77,8 +77,13 @@ function M.cancel(registry, request, context) assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'cancel reason must be a nonempty bounded string') - assert(run.state == RunState.QUEUED and not run.terminal, - 'only a queued run can be cancelled') + if run.state ~= RunState.QUEUED or run.terminal then + validation.reject('invalid_run_state', + 'Only a queued run can be cancelled.', { + operation='cancel', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end return transitions.cancel_queued(registry, run, request.reason, run.owner_kind, validation.current_time(context)) end @@ -86,8 +91,13 @@ end ---Cancels one queued run through operator authority. function M.operator_cancel(registry, request, context) local run = validation.exact_run(registry, request, 'operator cancel') - assert(run.state == RunState.QUEUED and not run.terminal, - 'only a queued run can be force-cancelled') + if run.state ~= RunState.QUEUED or run.terminal then + validation.reject('invalid_run_state', + 'Only a queued run can be force-cancelled.', { + operation='operator cancel', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'operator cancel reason must be a nonempty bounded string') diff --git a/src/dwarfspec/host/service/scheduler/recovery.lua b/src/dwarfspec/host/service/scheduler/recovery.lua index 0139a49..2d41061 100644 --- a/src/dwarfspec/host/service/scheduler/recovery.lua +++ b/src/dwarfspec/host/service/scheduler/recovery.lua @@ -18,23 +18,40 @@ function M.authorize_abort(registry, request) assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'abort reason must be a nonempty bounded string') - assert(registry.active_run_id == run.run_id and ACTIVE[run.state] and - not run.terminal, 'only the active run can be aborted') + if registry.active_run_id ~= run.run_id or not ACTIVE[run.state] or + run.terminal then + validation.reject('invalid_run_state', + 'Only the active run can be aborted.', { + operation='abort', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end return run end ---Clears executor quarantine after authoritative clean-state verification. function M.recover_executor(registry, request, context) - assert(registry.quarantine.active, - 'automation executor is not quarantined') assert(type(request) == 'table', 'executor recovery request must be a table') assert(request.service_instance_id == registry.service_instance_id, 'executor recovery service identity does not match') - assert(request.run_id == registry.quarantine.run_id, - 'executor recovery run identity does not match quarantine') - assert(request.generation == registry.quarantine.generation, - 'executor recovery generation does not match quarantine') + if not registry.quarantine.active then + validation.reject('invalid_run_state', + 'The DwarfSpec executor is not quarantined.', { + operation='recover executor', run_id=request.run_id, + generation=request.generation, state='not_quarantined', + }) + end + if request.run_id ~= registry.quarantine.run_id or + request.generation ~= registry.quarantine.generation then + validation.reject('quarantine_mismatch', + 'The requested generation does not own executor quarantine.', { + operation='recover executor', run_id=request.run_id, + generation=request.generation, + blocking_run_id=registry.quarantine.run_id, + blocking_generation=registry.quarantine.generation, + }) + end assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'executor recovery reason must be a nonempty bounded string') @@ -44,8 +61,15 @@ function M.recover_executor(registry, request, context) assert(type(context.verify_clean_state) == 'function', 'executor recovery requires an authoritative verifier') local verified, detail = context.verify_clean_state(request.proof) - assert(verified == true, detail or - 'executor clean-state proof was rejected') + if verified ~= true then + validation.reject('clean_state_unverified', + 'Executor clean state could not be verified.', { + operation='recover executor', run_id=request.run_id, + generation=request.generation, + reason=validation.safe_reason(detail, + 'clean-state proof was rejected'), + }) + end transitions.clear_quarantine(registry) return {recovered=true} end @@ -53,10 +77,20 @@ end ---Acknowledges one exact owner-retained terminal result after persistence. function M.acknowledge(registry, request, context) local run = validation.authorize_owner(registry, request, 'acknowledgement') - assert(run.terminal and TERMINAL[run.state], - 'only a terminal run can be acknowledged') - assert(run.acknowledged ~= true and run.discarded ~= true, - 'terminal run has already been released') + if not run.terminal or not TERMINAL[run.state] then + validation.reject('invalid_run_state', + 'Only a terminal run can be acknowledged.', { + operation='acknowledgement', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end + if run.acknowledged == true or run.discarded == true then + validation.reject('invalid_run_state', + 'The terminal run has already been released.', { + operation='acknowledgement', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end local persistence = request.persistence assert(type(persistence) == 'table' and persistence.succeeded == true, 'acknowledgement requires successful persistence') @@ -73,10 +107,20 @@ end ---Releases one exact retained terminal result through operator authority. function M.discard(registry, request, context) local run = validation.exact_run(registry, request, 'discard') - assert(run.terminal and TERMINAL[run.state], - 'only a terminal run can be discarded') - assert(run.acknowledged ~= true and run.discarded ~= true, - 'terminal run has already been released') + if not run.terminal or not TERMINAL[run.state] then + validation.reject('invalid_run_state', + 'Only a terminal run can be discarded.', { + operation='discard', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end + if run.acknowledged == true or run.discarded == true then + validation.reject('invalid_run_state', + 'The terminal run has already been released.', { + operation='discard', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'discard reason must be a nonempty bounded string') @@ -93,8 +137,14 @@ end ---Authorizes an operator recovery abort without owner impersonation. function M.authorize_operator_abort(registry, request, context) local run = validation.exact_run(registry, request, 'operator abort') - assert(registry.active_run_id == run.run_id and ACTIVE[run.state] and - not run.terminal, 'only the active run can be force-aborted') + if registry.active_run_id ~= run.run_id or not ACTIVE[run.state] or + run.terminal then + validation.reject('invalid_run_state', + 'Only the active run can be force-aborted.', { + operation='operator abort', run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end assert(type(request.reason) == 'string' and request.reason ~= '' and #request.reason <= 1024, 'operator abort reason must be a nonempty bounded string') diff --git a/src/dwarfspec/host/service/scheduler/request_validation.lua b/src/dwarfspec/host/service/scheduler/request_validation.lua index 04d4287..d155f88 100644 --- a/src/dwarfspec/host/service/scheduler/request_validation.lua +++ b/src/dwarfspec/host/service/scheduler/request_validation.lua @@ -4,11 +4,29 @@ local events = require('dwarfspec.protocol.events') local OwnerKind = require('dwarfspec.protocol.enums.owner_kinds') local projects = require('dwarfspec.host.service.projects') local ResultPolicy = require('dwarfspec.protocol.enums.result_policies') +local adapter_errors = require('dwarfspec.protocol.adapter_errors') local M = {} local SUBMISSION_FIELDS = {selection=true, request_key=true, owner_kind=true, queue_lease_ms=true, execution_lease_ms=true, lease_check_frames=true} +---Raises one expected scheduler mutation rejection as a structured value. +---@param code string +---@param message string +---@param fields table +function M.reject(code, message, fields) + error(adapter_errors.domain(code, message, fields), 0) +end + +---Returns one non-empty bounded reason safe for an adapter rejection. +---@param value any +---@param fallback string +---@return string +function M.safe_reason(value, fallback) + if type(value) ~= 'string' or value == '' then value = fallback end + return adapter_errors.safe_message(value) +end + ---Returns one validated monotonic timestamp. function M.current_time(context) local value = context.now_ms() @@ -88,17 +106,30 @@ function M.authorize_owner(registry, request, operation) operation .. ' project id must be a nonempty string') assert(type(request.run_id) == 'string' and request.run_id ~= '', operation .. ' run id must be a nonempty string') - local run = assert(registry.runs[request.run_id], - 'automation run was not found: ' .. tostring(request.run_id)) + local run = registry.runs[request.run_id] + if not run then + M.reject('run_not_found', 'DwarfSpec run was not found.', + {operation=operation, run_id=request.run_id}) + end assert(run.project_id == request.project_id, operation .. ' project identity does not match run') - assert(run.generation == request.generation, - operation .. ' generation does not match run') - assert(type(request.owner_capability) == 'string' and - request.owner_capability ~= '', - operation .. ' owner capability must be a nonempty string') - assert(run.owner_capability == request.owner_capability, - operation .. ' owner capability does not match run') + if run.generation ~= request.generation then + M.reject('generation_mismatch', + 'The requested run generation is stale.', { + operation=operation, run_id=run.run_id, + generation=request.generation, + current_generation=run.generation, + }) + end + if type(request.owner_capability) ~= 'string' or + request.owner_capability == '' or + run.owner_capability ~= request.owner_capability then + M.reject('owner_capability_rejected', + 'The run owner capability was rejected.', { + operation=operation, run_id=run.run_id, + generation=run.generation, state=run.state, + }) + end M.run_identity(registry, run) return run end @@ -108,12 +139,21 @@ function M.exact_run(registry, request, operation) assert(type(request) == 'table', operation .. ' request must be a table') assert(request.service_instance_id == registry.service_instance_id, operation .. ' service identity does not match') - local run = assert(registry.runs[request.run_id], - 'automation run was not found: ' .. tostring(request.run_id)) + local run = registry.runs[request.run_id] + if not run then + M.reject('run_not_found', 'DwarfSpec run was not found.', + {operation=operation, run_id=request.run_id}) + end assert(run.project_id == request.project_id, operation .. ' project identity does not match run') - assert(run.generation == request.generation, - operation .. ' generation does not match run') + if run.generation ~= request.generation then + M.reject('generation_mismatch', + 'The requested run generation is stale.', { + operation=operation, run_id=run.run_id, + generation=request.generation, + current_generation=run.generation, + }) + end M.run_identity(registry, run) return run end diff --git a/src/dwarfspec/host/service/service.lua b/src/dwarfspec/host/service/service.lua index 3b9e320..ce76cbf 100644 --- a/src/dwarfspec/host/service/service.lua +++ b/src/dwarfspec/host/service/service.lua @@ -1,6 +1,7 @@ -- Process-wide multi-project automation service runtime and public boundary. local projects = require('dwarfspec.host.service.projects') +local adapter_errors = require('dwarfspec.protocol.adapter_errors') local events = require('dwarfspec.protocol.events') local OwnerKind = require('dwarfspec.protocol.enums.owner_kinds') local RunState = require('dwarfspec.protocol.enums.run_states') @@ -194,6 +195,18 @@ local function service_summary(registry) return summary end +---Returns a structured rejection for an incompatible bootstrap package. +---@param running_version string +---@param requested_version string +---@return table +local function package_version_mismatch(running_version, requested_version) + return adapter_errors.domain('package_version_mismatch', + 'DFHack already has a different DwarfSpec version loaded', { + running_version=running_version, + requested_version=requested_version, + }) +end + ---Validates one project client's compatibility with the running service. ---@param registry table ---@param request table @@ -223,10 +236,10 @@ function M.bootstrap(request, dependencies) local registry = namespace.dwarfspec if registry ~= nil then validate_registry(registry) - assert(request.package_version == registry.package_version, - ('incompatible automation package version: expected %s, found %s') - :format(registry.package_version, - tostring(request.package_version))) + if request.package_version ~= registry.package_version then + error(package_version_mismatch( + registry.package_version, request.package_version), 0) + end return service_summary(registry) end diff --git a/src/dwarfspec/protocol/adapter_errors.lua b/src/dwarfspec/protocol/adapter_errors.lua new file mode 100644 index 0000000..d15eb85 --- /dev/null +++ b/src/dwarfspec/protocol/adapter_errors.lua @@ -0,0 +1,325 @@ +-- Canonical construction, validation, and serialization for adapter errors. + +local events = require('dwarfspec.protocol.events') +local RunnerFailureKind = + require('dwarfspec.protocol.enums.runner_failure_kinds') +local SchedulerFailureKind = + require('dwarfspec.protocol.enums.scheduler_failure_kinds') + +local M = { + protocol=2, + schema='dwarfspec.error.v1', +} + +local COMMON_FIELDS = { + operation='string', + run_id='string', + generation='positive_integer', + state='string', + blocking_run_id='string', + blocking_generation='positive_integer', + current_generation='positive_integer', + reason='string', + after_sequence='nonnegative_integer', + last_sequence='nonnegative_integer', +} + +local FORBIDDEN_FIELDS = { + authorization_proof=true, + owner_capability=true, + package_root=true, + project_root=true, + result_path=true, +} + +local KNOWN_CODES = { + package_version_mismatch={ + kind=RunnerFailureKind.REGISTRATION, + required={ + running_version='string', + requested_version='string', + }, + }, + [SchedulerFailureKind.PROJECT_BUSY]={ + kind=RunnerFailureKind.REGISTRATION, + required={ + blocking_run_id='string', + blocking_generation='positive_integer', + state='string', + reason='string', + }, + }, + [SchedulerFailureKind.REQUEST_KEY_CONFLICT]={ + kind=RunnerFailureKind.REGISTRATION, + required={ + blocking_run_id='string', + blocking_generation='positive_integer', + state='string', + reason='string', + }, + }, + [SchedulerFailureKind.RESULT_PATH_BUSY]={ + kind=RunnerFailureKind.REGISTRATION, + required={ + blocking_run_id='string', + blocking_generation='positive_integer', + state='string', + reason='string', + }, + }, + service_not_loaded={kind=RunnerFailureKind.HOST, + required={operation='string'}}, + run_not_found={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string'}}, + generation_mismatch={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', current_generation='positive_integer'}}, + invalid_run_state={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', state='string'}}, + owner_capability_rejected={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', state='string'}}, + quarantine_mismatch={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', blocking_run_id='string', + blocking_generation='positive_integer'}}, + clean_state_unverified={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', reason='string'}}, + event_cursor_ahead={kind=RunnerFailureKind.HOST, + required={operation='string', run_id='string', + generation='positive_integer', state='string', + after_sequence='nonnegative_integer', + last_sequence='nonnegative_integer'}}, +} + +local APPROVED_KINDS = { + [RunnerFailureKind.REGISTRATION]=true, + [RunnerFailureKind.EXECUTOR_QUARANTINED]=true, + [RunnerFailureKind.HOST]=true, +} + +---Returns whether a value is a positive integer. +---@param value any +---@return boolean +local function is_positive_integer(value) + return type(value) == 'number' and value > 0 and value % 1 == 0 +end + +---Returns whether a value is a nonnegative integer. +---@param value any +---@return boolean +local function is_nonnegative_integer(value) + return type(value) == 'number' and value >= 0 and value % 1 == 0 +end + +---Validates one field against its public adapter-error type. +---@param value any +---@param field_type string +---@param field_name string +local function validate_field(value, field_type, field_name) + if field_type == 'positive_integer' then + assert(is_positive_integer(value), + 'DwarfSpec adapter error field ' .. field_name .. + ' must be a positive integer') + elseif field_type == 'nonnegative_integer' then + assert(is_nonnegative_integer(value), + 'DwarfSpec adapter error field ' .. field_name .. + ' must be a nonnegative integer') + else + assert(type(value) == field_type and value ~= '', + 'DwarfSpec adapter error field ' .. field_name .. + ' must be a non-empty ' .. field_type) + end +end + +---Returns a safe bounded rendering of an arbitrary internal exception. +---@param value any +---@return string +function M.safe_message(value) + local ok, rendered = pcall(tostring, value) + if not ok or type(rendered) ~= 'string' then + return 'DwarfSpec host reported an unprintable internal error' + end + rendered = rendered:gsub('^.-:%d+: ', '') + :gsub('[%z\1-\31\127]', '?') + if rendered == '' then rendered = 'DwarfSpec host reported an internal error' end + if #rendered > 512 then rendered = rendered:sub(1, 509) .. '...' end + return rendered +end + +---Constructs one detached JSON-safe domain rejection value. +---@param code string +---@param message string +---@param fields table|nil +---@return table +function M.domain(code, message, fields) + assert(type(code) == 'string' and code ~= '', + 'DwarfSpec domain rejection code must be a non-empty string') + assert(type(message) == 'string' and message ~= '', + 'DwarfSpec domain rejection message must be a non-empty string') + local rejection = {code=code, message=message} + for name, value in pairs(fields or {}) do rejection[name] = value end + events.copy_json(rejection, 'DwarfSpec domain rejection') + local candidate = { + schema=M.schema, + protocol=M.protocol, + kind=KNOWN_CODES[code] and KNOWN_CODES[code].kind or + RunnerFailureKind.HOST, + } + for name, value in pairs(rejection) do candidate[name] = value end + M.validate(candidate) + return rejection +end + +---Constructs the compatibility executor-quarantine adapter error. +---@param value table +---@return table +function M.executor_quarantine(value) + local response = { + schema=M.schema, + protocol=M.protocol, + kind=RunnerFailureKind.EXECUTOR_QUARANTINED, + blocking_run_id=value.blocking_run_id, + blocking_generation=value.blocking_generation, + reason=value.reason, + message=('DwarfSpec executor is quarantined by run %s generation %s: ' .. + '%s. Recover it with: dwarfspec recover-executor %s --generation %s') + :format(M.safe_message(value.blocking_run_id), + M.safe_message(value.blocking_generation), + M.safe_message(value.reason), M.safe_message(value.blocking_run_id), + M.safe_message(value.blocking_generation)), + } + return M.validate(response) +end + +---Constructs a canonical envelope from a domain rejection or internal fault. +---@param value any +---@param default_kind string +---@return table +function M.envelope(value, default_kind) + if type(value) == 'table' and + value.kind == 'executor_quarantined' then + return M.executor_quarantine(value) + end + local response = { + schema=M.schema, + protocol=M.protocol, + kind=default_kind, + message=M.safe_message(value), + } + if type(value) == 'table' and type(value.code) == 'string' and + value.code ~= '' then + for name, field_value in pairs(value) do + if name ~= 'kind' then response[name] = field_value end + end + end + return M.validate(response) +end + +---Validates and returns one canonical adapter-error envelope. +---@param response table +---@return table +function M.validate(response) + events.copy_json(response, 'adapter error response') + assert(response.schema == M.schema, + 'unsupported DwarfSpec adapter error schema: ' .. tostring(response.schema)) + assert(response.protocol == M.protocol, + 'unsupported DwarfSpec protocol: ' .. tostring(response.protocol)) + assert(APPROVED_KINDS[response.kind], + 'unsupported DwarfSpec adapter error kind: ' .. tostring(response.kind)) + validate_field(response.message, 'string', 'message') + if response.code ~= nil then + local label = response.kind == RunnerFailureKind.REGISTRATION and + 'registration error code' or 'adapter error code' + assert(type(response.code) == 'string' and response.code ~= '', + 'DwarfSpec ' .. label .. ' must be a non-empty string') + end + + for name in pairs(FORBIDDEN_FIELDS) do + assert(response[name] == nil, + 'DwarfSpec adapter error forbids field ' .. name) + end + for name, field_type in pairs(COMMON_FIELDS) do + local quarantine_field = + response.kind == RunnerFailureKind.EXECUTOR_QUARANTINED and + response.code == nil and + (name == 'blocking_run_id' or name == 'blocking_generation') + if response[name] ~= nil and not quarantine_field then + validate_field(response[name], field_type, name) + end + end + + local allowed = {schema=true, protocol=true, kind=true, code=true, message=true} + for name in pairs(COMMON_FIELDS) do allowed[name] = true end + local contract = response.code and KNOWN_CODES[response.code] + if contract then + assert(response.kind == contract.kind, + 'DwarfSpec adapter error code has incompatible kind') + for name, field_type in pairs(contract.required) do + if response.code == 'package_version_mismatch' and + name == 'running_version' then + assert(type(response[name]) == 'string' and response[name] ~= '', + 'DwarfSpec package version mismatch requires running version') + elseif response.code == 'package_version_mismatch' and + name == 'requested_version' then + assert(type(response[name]) == 'string' and response[name] ~= '', + 'DwarfSpec package version mismatch requires requested version') + else + validate_field(response[name], field_type, name) + end + allowed[name] = true + end + if response.code == 'event_cursor_ahead' then + assert(response.after_sequence > response.last_sequence, + 'DwarfSpec event cursor rejection requires requested cursor ' .. + 'to be ahead of retained cursor') + end + elseif response.kind == RunnerFailureKind.EXECUTOR_QUARANTINED and + response.code == nil then + assert(type(response.blocking_run_id) == 'string' and + response.blocking_run_id ~= '', + 'DwarfSpec quarantine error requires blocking run id') + assert(is_positive_integer(response.blocking_generation), + 'DwarfSpec quarantine error requires blocking generation') + assert(type(response.reason) == 'string' and response.reason ~= '', + 'DwarfSpec quarantine error requires a reason') + allowed.reason = true + end + for name in pairs(response) do + assert(allowed[name], 'DwarfSpec adapter error has unsupported field ' .. name) + end + return response +end + +---Serializes one canonical adapter error with a non-throwing host-fault fallback. +---@param value any +---@param default_kind string +---@param encoder function +---@return string, table +function M.serialize(value, default_kind, encoder) + local built, response = pcall(M.envelope, value, default_kind) + if not built then + response = { + schema=M.schema, + protocol=M.protocol, + kind=RunnerFailureKind.HOST, + message='DwarfSpec host could not serialize an adapter error', + } + end + local encoded, json = pcall(encoder, response, {pretty=false}) + if encoded and type(json) == 'string' then return json, response end + response = { + schema=M.schema, + protocol=M.protocol, + kind=RunnerFailureKind.HOST, + message='DwarfSpec host could not serialize an adapter error', + } + local fallback = '{"schema":"dwarfspec.error.v1","protocol":2,' .. + '"kind":"host","message":' .. + '"DwarfSpec host could not serialize an adapter error"}' + return fallback, response +end + +return M diff --git a/tests/unit/cli_selection_spec.lua b/tests/unit/cli_selection_spec.lua index a476cb2..f92ad80 100644 --- a/tests/unit/cli_selection_spec.lua +++ b/tests/unit/cli_selection_spec.lua @@ -288,7 +288,7 @@ describe('DwarfSpec CLI selection', function() assert.matches('Usage: dwarfspec run', output.text, 1, true) output.text = '' assert.equals(0, cli.main({'version'}, context)) - assert.equals('DwarfSpec 0.2.1\n', output.text) + assert.equals('DwarfSpec 0.2.2\n', output.text) assert.is_nil(invoked) end) diff --git a/tests/unit/controller/application_spec.lua b/tests/unit/controller/application_spec.lua index 8ce4c56..8b5c509 100644 --- a/tests/unit/controller/application_spec.lua +++ b/tests/unit/controller/application_spec.lua @@ -70,7 +70,7 @@ describe('DwarfSpec application', function() output.text = '' assert.equals(0, application.main({'version'}, context)) - assert.equals('DwarfSpec 0.2.1\n', output.text) + assert.equals('DwarfSpec 0.2.2\n', output.text) output.text = '' assert.equals(0, application.main({'list'}, context)) diff --git a/tests/unit/controller/command_line_spec.lua b/tests/unit/controller/command_line_spec.lua index d05550a..189cccf 100644 --- a/tests/unit/controller/command_line_spec.lua +++ b/tests/unit/controller/command_line_spec.lua @@ -4,7 +4,7 @@ local command_line = require('dwarfspec.controller.command_line') describe('DwarfSpec command line', function() it('constructs general and command-specific help documents', function() - assert.matches('DwarfSpec 0%.2%.1', command_line.help()) + assert.matches('DwarfSpec 0%.2%.2', command_line.help()) for _, topic in ipairs({ 'list', 'run', 'status', 'history', 'show', 'logs', 'abort', 'recover-executor'}) do diff --git a/tests/unit/controller/execution/command_builder_spec.lua b/tests/unit/controller/execution/command_builder_spec.lua index 8b49b18..2deea65 100644 --- a/tests/unit/controller/execution/command_builder_spec.lua +++ b/tests/unit/controller/execution/command_builder_spec.lua @@ -36,6 +36,8 @@ describe('controller command builder', function() }, builder.bootstrap(value, 'run')) assert.same({'lua', '-f', value.host_scripts.status, 'run', 'owner', '7'}, builder.poll(value, 'run', 'owner', 7)) + assert.same({'lua', '-f', value.host_scripts.status, 'run', 'owner', + '7', '3'}, builder.poll(value, 'run', 'owner', 7, 3)) assert.same({'lua', '-f', value.host_scripts.run_query, 'show', 'run'}, builder.query(value, 'show', 'run')) assert.same({'lua', '-f', value.host_scripts.scheduler_status}, diff --git a/tests/unit/controller/execution/run_poller_spec.lua b/tests/unit/controller/execution/run_poller_spec.lua index 4095bdb..ff58c70 100644 --- a/tests/unit/controller/execution/run_poller_spec.lua +++ b/tests/unit/controller/execution/run_poller_spec.lua @@ -8,8 +8,10 @@ local RunState = require('dwarfspec.protocol.enums.run_states') ---@return table, table local function fixture(transports) local record = {polls={}, invocations={}, formatted={}} - local builder = {poll=function(_, run_id, owner_capability, cursor) - local arguments = {'poll', run_id, owner_capability, tostring(cursor)} + local builder = {poll=function(_, run_id, owner_capability, cursor, + generation) + local arguments = {'poll', run_id, owner_capability, tostring(cursor), + tostring(generation)} table.insert(record.polls, arguments) return arguments end} @@ -43,7 +45,9 @@ local function scope(overrides) report={state=RunState.QUEUED, terminal=false}, cursor=4, queue_started_at=0, now=function() return 1 end, sleep=function() end, - expectation=function(cursor) return {after_sequence=cursor} end, + expectation=function(cursor) + return {after_sequence=cursor, generation=3} + end, journal={}, activated_at=function() return activated_at end, entered_executor=function(report) return report.activated_at_ms ~= nil end, activate=function() @@ -77,11 +81,13 @@ describe('controller run poller', function() assert.same(RunState.PASSED, outcome.report.state) assert.same(6, outcome.cursor) assert.same({ - {'poll', 'run', 'owner', '4'}, - {'poll', 'run', 'owner', '5'}, + {'poll', 'run', 'owner', '4', '3'}, + {'poll', 'run', 'owner', '5', '3'}, }, calls.polls) - assert.same({after_sequence=4}, calls.invocations[1].expected) - assert.same({after_sequence=5}, calls.invocations[2].expected) + assert.same({after_sequence=4, generation=3}, + calls.invocations[1].expected) + assert.same({after_sequence=5, generation=3}, + calls.invocations[2].expected) assert.same(1, observed.activated) assert.same({'first', 'second'}, observed.emitted) assert.same({RunState.RUNNING, RunState.PASSED}, observed.persisted) @@ -121,6 +127,31 @@ describe('controller run poller', function() assert.same({}, calls.invocations) end) + it('does not advance cursor or observations after a rejected poll', function() + local detail = {kind='host', code='event_cursor_ahead', + message='requested cursor 8, retained cursor 7'} + local poller = module.new({ + builder={poll=function() return {'poll'} end}, + client={transport=function() error(detail, 0) end}, + format_events=function() return {} end, + fail=function(kind, message) + error({kind=kind, message=message}, 0) + end, + failure_kinds={HOST='host'}, clean_message=tostring, + }) + local value, observed = scope() + local original_report = value.report + local ok, rejection = pcall(poller.until_terminal, value) + assert.is_false(ok) + assert.equals('event_cursor_ahead', rejection.code) + assert.equals(4, value.cursor) + assert.equals(original_report, value.report) + assert.same({}, value.journal) + assert.same({}, observed.persisted) + assert.same({}, observed.observed) + assert.same({}, observed.emitted) + end) + it('classifies formatting failures before persistence', function() local poller = module.new({builder={}, client={}, format_events=function() error('formatter failed') end, diff --git a/tests/unit/controller/execution/run_recovery_spec.lua b/tests/unit/controller/execution/run_recovery_spec.lua index 2b409d1..6d17ea5 100644 --- a/tests/unit/controller/execution/run_recovery_spec.lua +++ b/tests/unit/controller/execution/run_recovery_spec.lua @@ -39,6 +39,12 @@ local function fixture(behavior) if behavior.parse_error then error(behavior.parse_error) end return behavior.recovery_transport end, + parse_transport_response=function(_, expected) + record.parse_expected=expected + if behavior.parse_error then error(behavior.parse_error) end + if behavior.rejection then return nil, behavior.rejection end + return behavior.recovery_transport, nil + end, transport=function(_, _, arguments, expected, operation) record.transport={arguments=arguments, expected=expected, operation=operation} @@ -109,6 +115,29 @@ describe('controller run recovery', function() original.message) end) + it('appends a structured recovery rejection without replacing the primary', + function() + for _, code in ipairs({ + 'service_not_loaded', 'run_not_found', 'generation_mismatch', + 'invalid_run_state', 'owner_capability_rejected', + 'quarantine_mismatch', 'clean_state_unverified', + 'event_cursor_ahead', + }) do + local rejection_message = 'structured ' .. code .. ' guidance' + local service = fixture({rejection={kind='host', code=code, + message=rejection_message}}) + local _, detail = service.after_failure({}, 'runner', 'run-1', + 'secret-owner', {run_id='run-1', generation=2}, 4) + local original = {kind='timeout', exit_code=7, + message='original timeout'} + service.preserve_error(original, detail) + assert.equals('timeout', original.kind) + assert.equals(7, original.exit_code) + assert.equals('original timeout; recovery failed: ' .. + rejection_message, original.message) + end + end) + it('classifies queued and active explicit abort cleanup outcomes', function() local service = fixture({transport={snapshot={state=RunState.CANCELLED}, events={'cancelled'}}}) diff --git a/tests/unit/controller/execution/runner_spec.lua b/tests/unit/controller/execution/runner_spec.lua index 35179b4..600215f 100644 --- a/tests/unit/controller/execution/runner_spec.lua +++ b/tests/unit/controller/execution/runner_spec.lua @@ -9,6 +9,8 @@ local EventType = require('dwarfspec.protocol.enums.event_types') local ErrorFormat = require('dwarfspec.protocol.configuration.error_formats') local ResultState = require('dwarfspec.protocol.enums.result_states') local RunState = require('dwarfspec.protocol.enums.run_states') +local SchedulerFailureKind = + require('dwarfspec.protocol.enums.scheduler_failure_kinds') local RUN_STATE_TERMINAL = { [RunState.QUEUED]=false, @@ -271,6 +273,48 @@ local function options(run_id) } end +---Runs one representative failed probe through the complete run boundary. +---@param case table +---@return table, table +local function run_probe_failure(case) + local run_options = options('connection-' .. case.name) + run_options.identities = {'tests/private-selected-' .. case.name .. '.ds.lua'} + run_options.test_glob = 'tests/private-selection-' .. case.name .. '/*.lua' + run_options.result_path = 'D:/results/connection-' .. case.name .. '.json' + local persisted + run_options.result_store = { + write=function(_, result) persisted = result end, + } + local calls = 0 + local bootstrap_attempted = false + run_options.invoke = function(_, arguments) + calls = calls + 1 + if not arguments[3]:match('probe%.lua$') then + bootstrap_attempted = true + end + if case.exception then error(case.exception) end + return case.result + end + + local outcome = runner.run(run_options) + + assert.equals(4, outcome.exit_code, case.name) + assert.same(runner.failure_kinds.CONNECTION, outcome.error.kind, case.name) + assert.equals(ResultState.CONNECTION_ERROR, outcome.result.state, case.name) + assert.equals(ResultState.CONNECTION_ERROR, persisted.state, case.name) + assert.is_false(bootstrap_attempted, case.name) + assert.equals(1, calls, case.name) + assert.is_truthy(outcome.error.message:find(case.message, 1, true), case.name) + for _, selected_path in ipairs({ + run_options.project_root, run_options.test_glob, + run_options.identities[1], + }) do + assert.is_falsy(outcome.error.message:find(selected_path, 1, true), + case.name .. ': ' .. selected_path) + end + return outcome, persisted +end + describe('DwarfSpec external runner', function() it('streams progress and returns zero only after passing cleanup', function() local calls = 0 @@ -848,18 +892,35 @@ describe('DwarfSpec external runner', function() outcome.error.message, 1, true) end) - it('returns a connection failure before bootstrap', function() - local run_options = options('connection-run') - run_options.invoke = function() - return {exit_code=1, lines={'not running'}} + it('preserves orchestration outcomes for every probe failure category', function() + local cases = { + {name='invocation', exception='process launch failed', + message='Could not invoke DFHack runner "bin/dwarfspec":'}, + {name='nonzero', result={exit_code=1, lines={'not running'}}, + message='exited with code 1. Output: not running'}, + {name='missing', result={exit_code=0, lines={'ordinary output'}}, + message='emitted no DwarfSpec probe report'}, + {name='multiple', result={exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', + }}, message='emitted 2 DwarfSpec probe reports'}, + {name='malformed', result={exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true', + }}, message='malformed DwarfSpec probe report'}, + {name='protocol', result={exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=3 core=true timeout=function', + }}, message='controller expects 2, probe reported 3'}, + {name='core', result={exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=false timeout=function', + }}, message='reported core=false'}, + {name='timeout', result={exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=nil', + }}, message='reported timeout=nil'}, + } + for _, case in ipairs(cases) do + local outcome = run_probe_failure(case) + assert.is_nil(outcome.report, case.name) end - local outcome = runner.run(run_options) - assert.equals(runner.exit_codes[runner.failure_kinds.CONNECTION], - outcome.exit_code) - assert.equals(ResultState.CONNECTION_ERROR, outcome.result.state) - assert.matches('DFHack is not running', outcome.error.message, - 1, true) - assert.is_nil(outcome.report) end) it('classifies a missing configured runner as a dependency failure', @@ -883,20 +944,6 @@ describe('DwarfSpec external runner', function() outcome.error.message, 1, true) end) - it('classifies a probe launch exception as an actionable connection error', - function() - local run_options = options('probe-launch') - run_options.invoke = function() - error('process launch failed') - end - local outcome = runner.run(run_options) - assert.equals(runner.exit_codes[runner.failure_kinds.CONNECTION], - outcome.exit_code) - assert.equals(ResultState.CONNECTION_ERROR, outcome.result.state) - assert.matches('could not contact DFHack through', - outcome.error.message, 1, true) - end) - it('treats interruption as abort and confirms native cleanup', function() local run_options = options('interrupted-run') run_options.sleep = function() error('interrupted by user') end @@ -1000,8 +1047,182 @@ describe('DwarfSpec external runner', function() schema='dwarfspec.error.v1', protocol=2, kind=runner.failure_kinds.REGISTRATION, - message='incompatible automation package version: ' .. - 'expected 0.1.3, found 0.2.1', + code='package_version_mismatch', + message='different version loaded', + running_version='0.1.3', + requested_version='0.2.1', + })}} + end + recovery_calls = recovery_calls + 1 + return {exit_code=0, lines={}} + end + + local outcome = runner.run(run_options) + + assert.equals(1, bootstrap_calls) + assert.equals(0, recovery_calls) + assert.equals(runner.exit_codes[ + runner.failure_kinds.REGISTRATION], outcome.exit_code) + assert.equals(runner.failure_kinds.REGISTRATION, outcome.error.kind) + assert.equals(ResultState.REGISTRATION_ERROR, outcome.result.state) + assert.equals( + 'DwarfSpec could not start because DFHack already has a ' .. + 'different DwarfSpec version loaded.\n\n' .. + ' Running DFHack service: 0.1.3\n' .. + ' Current DwarfSpec command: 0.2.1\n\n' .. + 'To use 0.2.1, save and fully exit Dwarf Fortress/DFHack, ' .. + 'relaunch it,\nand retry this command. Returning to the ' .. + 'title screen or unloading the\nworld will not unload the ' .. + 'process-wide DwarfSpec service.', outcome.error.message) + assert.equals(outcome.error.message, outcome.result.error.message) + assert.is_nil(outcome.error.message:find('expected', 1, true)) + assert.is_nil(outcome.error.message:find('found', 1, true)) + assert.is_nil(outcome.report) + end) + + it('renders every admission conflict from its structured subtype without recovery', + function() + local cases = { + { + code=SchedulerFailureKind.PROJECT_BUSY, + phrase='this project already has an outstanding run', + action='Wait for that run to finish and consume its result', + }, + { + code=SchedulerFailureKind.REQUEST_KEY_CONFLICT, + phrase='this request identity is already bound to a different run', + action='submit this work with a new run identity', + }, + { + code=SchedulerFailureKind.RESULT_PATH_BUSY, + phrase='configured result destination is reserved by another run', + action='choose a different result destination', + }, + } + for _, case in ipairs(cases) do + local bootstrap_calls = 0 + local recovery_calls = 0 + local run_options = options('admission-' .. case.code) + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} + elseif arguments[3]:match('bootstrap%.lua$') then + bootstrap_calls = bootstrap_calls + 1 + return {exit_code=0, lines={'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', + protocol=2, + kind=runner.failure_kinds.REGISTRATION, + code=case.code, + message='opaque host wording that must not be parsed', + blocking_run_id='blocking-run', + blocking_generation=9, + state='queued', + reason='scheduler classification detail', + })}} + end + recovery_calls = recovery_calls + 1 + return {exit_code=0, lines={}} + end + + local outcome = runner.run(run_options) + + assert.equals(1, bootstrap_calls) + assert.equals(0, recovery_calls) + assert.equals(5, outcome.exit_code) + assert.equals(runner.failure_kinds.REGISTRATION, + outcome.error.kind) + assert.equals(ResultState.REGISTRATION_ERROR, + outcome.result.state) + assert.equals(outcome.error.message, + outcome.result.error.message) + assert.matches(case.phrase, outcome.error.message, 1, true) + assert.matches(case.action, outcome.error.message, 1, true) + assert.matches('Blocking run: blocking-run', + outcome.error.message, 1, true) + assert.matches('Generation: 9', outcome.error.message, 1, true) + assert.matches('State: queued', outcome.error.message, 1, true) + assert.is_nil(outcome.error.message:find( + 'opaque host wording', 1, true)) + assert.is_nil(outcome.error.message:find( + 'selected specification', 1, true)) + assert.is_nil(outcome.report) + end + end) + + it('does not infer version guidance from generic registration text or code', + function() + local cases = { + { + name='generic-old-phrase', + response={ + schema='dwarfspec.error.v1', + protocol=2, + kind=runner.failure_kinds.REGISTRATION, + message='incompatible automation package version in ' .. + 'unrelated registration detail', + }, + }, + { + name='unknown-code', + response={ + schema='dwarfspec.error.v1', + protocol=2, + kind=runner.failure_kinds.REGISTRATION, + code='future_registration_code', + message='future registration rejection', + }, + }, + } + for _, case in ipairs(cases) do + local recovery_calls = 0 + local run_options = options(case.name) + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true ' .. + 'timeout=function'}} + elseif arguments[3]:match('bootstrap%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_JSON ' .. json.encode(case.response)}} + end + recovery_calls = recovery_calls + 1 + return {exit_code=0, lines={}} + end + + local outcome = runner.run(run_options) + + assert.equals(runner.exit_codes[ + runner.failure_kinds.REGISTRATION], outcome.exit_code) + assert.equals(ResultState.REGISTRATION_ERROR, + outcome.result.state) + assert.matches(case.response.message, outcome.error.message, + 1, true) + assert.is_nil(outcome.error.message:find( + 'Running DFHack service:', 1, true)) + assert.is_nil(outcome.error.message:find( + 'fully exit Dwarf Fortress/DFHack', 1, true)) + assert.equals(0, recovery_calls) + end + end) + + it('rejects a malformed mismatch response without recovery', function() + local bootstrap_calls = 0 + local recovery_calls = 0 + local run_options = options('malformed-version-rejection') + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} + elseif arguments[3]:match('bootstrap%.lua$') then + bootstrap_calls = bootstrap_calls + 1 + return {exit_code=0, lines={'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', + protocol=2, + kind=runner.failure_kinds.REGISTRATION, + code='package_version_mismatch', + message='different version loaded', + running_version='0.1.3', })}} end recovery_calls = recovery_calls + 1 @@ -1016,9 +1237,10 @@ describe('DwarfSpec external runner', function() runner.failure_kinds.REGISTRATION], outcome.exit_code) assert.equals(runner.failure_kinds.REGISTRATION, outcome.error.kind) assert.equals(ResultState.REGISTRATION_ERROR, outcome.result.state) - assert.matches('expected 0.1.3, found 0.2.1', + assert.matches('DwarfSpec bootstrap response was invalid', + outcome.error.message, 1, true) + assert.matches('requires requested version', outcome.error.message, 1, true) - assert.matches('Restart DFHack', outcome.error.message, 1, true) assert.is_nil(outcome.report) end) @@ -1298,6 +1520,107 @@ describe('DwarfSpec external runner', function() outcome.error.message) end) + it('preserves structured poll context when recovery is also rejected', + function() + local run_options = options('structured-poll-failure') + local status_calls, recovery_calls = 0, 0 + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} + elseif arguments[3]:match('bootstrap%.lua$') then + return {exit_code=0, lines=transport_lines(arguments, + run_options.run_id, RunState.STARTING, false)} + elseif arguments[3]:match('status%.lua$') then + status_calls = status_calls + 1 + return {exit_code=0, lines={'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', protocol=2, + kind=runner.failure_kinds.HOST, + code='event_cursor_ahead', message='cursor rejected', + operation='status poll', run_id=run_options.run_id, + generation=1, state='starting', after_sequence=4, + last_sequence=3, + })}} + end + assert.matches('recover%.lua$', arguments[3]) + recovery_calls = recovery_calls + 1 + return {exit_code=0, lines={'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', protocol=2, + kind=runner.failure_kinds.HOST, + code='run_not_found', message='run disappeared', + operation='recover', run_id=run_options.run_id, + })}} + end + local outcome = runner.run(run_options) + assert.equals(5, outcome.exit_code) + assert.equals(runner.failure_kinds.HOST, outcome.error.kind) + assert.matches('requested cursor 4, retained cursor 3', + outcome.error.message, 1, true) + assert.matches('recovery failed:', outcome.error.message, 1, true) + assert.matches('no longer retained', outcome.error.message, 1, true) + assert.equals(1, status_calls) + assert.equals(1, recovery_calls) + assert.equals(0, outcome.report.last_sequence) + end) + + it('attributes a selected path only when subprocess output emitted it', function() + local run_options = options('emitted-selection') + local identity = 'tests/private-emitted-selection.ds.lua' + run_options.identities = {identity} + run_options.invoke = function() + return {exit_code=1, lines={'runner echoed ' .. identity}} + end + + local outcome = runner.run(run_options) + + assert.equals(4, outcome.exit_code) + assert.same(runner.failure_kinds.CONNECTION, outcome.error.kind) + assert.is_truthy(outcome.error.message:find(identity, 1, true)) + end) + + it('preserves connection preflight for every auxiliary command', function() + local cases = { + {name='abort', invoke=function(run_options) + return runner.abort(run_options, 'retained-run') + end}, + {name='status', invoke=function(run_options) + return runner.status(run_options) + end}, + {name='history', invoke=function(run_options) + return runner.history(run_options) + end}, + {name='show', invoke=function(run_options) + return runner.inspect(run_options, 'retained-run') + end}, + {name='logs', invoke=function(run_options) + return runner.logs(run_options, 'retained-run') + end}, + {name='executor-recovery', invoke=function(run_options) + return runner.recover_executor(run_options, 'retained-run', 3, + 'operator verified clean state') + end}, + } + for _, case in ipairs(cases) do + local run_options = options('command-' .. case.name) + local calls = 0 + run_options.invoke = function() + calls = calls + 1 + return {exit_code=7, lines={case.name .. ' probe unavailable'}} + end + + local outcome = case.invoke(run_options) + + assert.equals(4, outcome.exit_code, case.name) + assert.same(runner.failure_kinds.CONNECTION, outcome.error.kind, + case.name) + assert.is_truthy(outcome.error.message:find( + 'DFHack connection probe through "bin/dwarfspec" exited with ' .. + 'code 7. Output: ' .. case.name .. ' probe unavailable', + 1, true), case.name) + assert.equals(1, calls, case.name) + end + end) + it('recovers one exact quarantined generation through host verification', function() local run_options = options('unused-recovery-id') @@ -1330,4 +1653,85 @@ describe('DwarfSpec external runner', function() }) assert.is_false(outcome.scheduler.quarantine.active) end) + + it('returns exit 5 for structured direct mutation rejections', function() + local cases = { + { + name='abort', + invoke=function(run_options) + return runner.abort(run_options, 'direct-run') + end, + response={code='invalid_run_state', operation='abort', + run_id='direct-run', generation=2, state='passed'}, + expected='is passed', + }, + { + name='recover-executor', + invoke=function(run_options) + return runner.recover_executor(run_options, + 'direct-run', 2, 'verified clean') + end, + response={code='clean_state_unverified', + operation='recover executor', run_id='direct-run', + generation=2, reason='owned screen remains active'}, + expected='Resolve remaining live resources', + }, + } + for _, case in ipairs(cases) do + local run_options = options('direct-' .. case.name) + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} + end + local response = {schema='dwarfspec.error.v1', protocol=2, + kind=runner.failure_kinds.HOST, + message='structured direct rejection'} + for name, value in pairs(case.response) do + response[name] = value + end + return {exit_code=0, lines={ + 'DWARFSPEC_JSON ' .. json.encode(response), + }} + end + local outcome = case.invoke(run_options) + assert.equals(5, outcome.exit_code, case.name) + assert.equals(runner.failure_kinds.HOST, outcome.error.kind) + assert.equals(case.response.code, outcome.error.code) + assert.matches(case.expected, outcome.error.message, 1, true) + end + end) + + it('appends structured acknowledgement detail to the original failure', + function() + local run_options = options('acknowledgement-secondary') + run_options.invoke = function(_, arguments) + if arguments[3]:match('probe%.lua$') then + return {exit_code=0, lines={ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} + elseif arguments[3]:match('bootstrap%.lua$') then + return {exit_code=0, lines=transport_lines(arguments, + run_options.run_id, RunState.STARTING, false)} + elseif arguments[3]:match('status%.lua$') then + return {exit_code=0, lines=transport_lines(arguments, + run_options.run_id, RunState.FAILED, true)} + end + assert.matches('acknowledge%.lua$', arguments[3]) + return {exit_code=0, lines={'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', protocol=2, + kind=runner.failure_kinds.HOST, + code='owner_capability_rejected', + message='owner rejected', operation='acknowledgement', + run_id=run_options.run_id, generation=1, state='failed', + })}} + end + local outcome = runner.run(run_options) + assert.equals(runner.failure_kinds.TEST, outcome.error.kind) + assert.equals(6, outcome.exit_code) + assert.matches('could not acknowledge terminal result:', + outcome.error.message, 1, true) + assert.matches('owning DwarfSpec process', outcome.error.message, + 1, true) + assert.is_falsy(outcome.error.message:find('table:', 1, true)) + end) end) diff --git a/tests/unit/controller/execution/transport_client_spec.lua b/tests/unit/controller/execution/transport_client_spec.lua index 12a203c..de9faf9 100644 --- a/tests/unit/controller/execution/transport_client_spec.lua +++ b/tests/unit/controller/execution/transport_client_spec.lua @@ -3,6 +3,8 @@ local module = require('dwarfspec.controller.execution.transport_client') local json = require('dkjson') local RunState = require('dwarfspec.protocol.enums.run_states') +local HEALTHY_PROBE = + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function' ---Creates a transport client with a minimal command builder. ---@return table @@ -20,6 +22,41 @@ local function client() }) end +---Captures one connection failure for a simulated subprocess result. +---@param lines any +---@param exit_code any|nil +---@return table +local function connection_failure(lines, exit_code) + local transport = client() + local ok, detail = pcall(transport.verify_connection, { + invoke=function() + return {exit_code=exit_code == nil and 0 or exit_code, lines=lines} + end, + }, 'runner') + assert.is_false(ok) + assert.same('connection', detail.kind) + return detail +end + +---Verifies that one simulated subprocess result passes connection preflight. +---@param lines any +local function assert_connection_success(lines) + local transport = client() + local options = {invoke=function() + return {exit_code=0, lines=lines} + end} + assert.has_no.errors(function() + transport.verify_connection(options, 'runner') + end) +end + +---Returns the bounded output excerpt from one missing-marker diagnostic. +---@param lines any +---@return string +local function output_excerpt(lines) + return assert(connection_failure(lines).message:match('Output: (.*)$')) +end + ---Builds one valid terminal transport at the requested cursor. ---@param after_sequence integer ---@return string[] @@ -44,32 +81,301 @@ local function transport_lines(after_sequence) end describe('controller transport client', function() - it('classifies process exceptions and unhealthy probes as connection failures', function() + it('classifies probe invocation exceptions separately', function() local transport = client() local ok, detail = pcall(transport.verify_connection, { invoke=function() error('bridge unavailable') end}, 'runner') assert.is_false(ok) assert.same('connection', detail.kind) - ok, detail = pcall(transport.verify_connection, { - invoke=function() return {exit_code=0, lines={'wrong'}} end}, 'runner') - assert.is_false(ok) - assert.same('connection', detail.kind) + assert.is_truthy(detail.message:find( + 'Could not invoke DFHack runner "runner":', 1, true)) + assert.is_truthy(detail.message:find('bridge unavailable', 1, true)) + end) + + it('accepts a healthy probe as the only output line', function() + assert_connection_success({HEALTHY_PROBE}) + end) + + it('accepts unrelated output before a healthy probe', function() + assert_connection_success({'before', HEALTHY_PROBE}) + end) + + it('accepts unrelated output after a healthy probe', function() + assert_connection_success({HEALTHY_PROBE, 'after'}) + end) + + it('ignores embedded markers and well-formed unknown fields', function() + assert_connection_success({ + 'prefix DWARFSPEC_PROBE protocol=999 core=false timeout=nil', + 'DWARFSPEC_PROBE timeout=function future=value protocol=2 core=true', + }) + end) + + it('reports nonzero probe exits with empty and non-empty output', function() + local detail = connection_failure({}, 17) + assert.same('DFHack connection probe through "runner" exited with code 17. ' .. + 'Output: ', detail.message) + + detail = connection_failure({ + HEALTHY_PROBE, + 'subprocess failed', + }, 17) + assert.same('DFHack connection probe through "runner" exited with code 17. ' .. + 'Output: ' .. HEALTHY_PROBE .. ' | subprocess failed', detail.message) + end) + + it('distinguishes missing and multiple probe reports', function() + local no_output_message = 'DFHack responded through "runner", but emitted ' .. + 'no DwarfSpec probe report. Output: ' + assert.same(no_output_message, connection_failure(nil).message) + assert.same(no_output_message, connection_failure({}).message) + + local detail = connection_failure({'ordinary DFHack output'}) + assert.same('DFHack responded through "runner", but emitted no DwarfSpec ' .. + 'probe report. Output: ordinary DFHack output', detail.message) + + detail = connection_failure({ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', + }) + assert.same('DFHack emitted 2 DwarfSpec probe reports; expected exactly ' .. + 'one. Output: DWARFSPEC_PROBE protocol=2 core=true timeout=function | ' .. + 'DWARFSPEC_PROBE protocol=2 core=true timeout=function', detail.message) + end) + + it('reports malformed probe fields with specific context', function() + local cases = { + {'DWARFSPEC_PROBE protocol', 'invalid token: protocol'}, + {'DWARFSPEC_PROBE Protocol=2 core=true timeout=function', + 'invalid field name: Protocol'}, + {'DWARFSPEC_PROBE protocol= core=true timeout=function', + 'empty value for field protocol'}, + {'DWARFSPEC_PROBE protocol=2 protocol=3 core=true timeout=function', + 'duplicate field: protocol'}, + {'DWARFSPEC_PROBE core=true timeout=function', + 'missing required field: protocol'}, + {'DWARFSPEC_PROBE protocol=02 core=true timeout=function', + 'invalid protocol value: 02'}, + {'DWARFSPEC_PROBE protocol=2 core=yes timeout=function', + 'invalid core value: yes'}, + {'DWARFSPEC_PROBE protocol=2 core=true timeout=callable', + 'invalid timeout value: callable'}, + {'DWARFSPEC_PROBE protocol=2 core=true timeout=function future=bad/value', + 'invalid value for field future: bad/value'}, + } + for _, case in ipairs(cases) do + local detail = connection_failure({case[1]}) + assert.is_truthy(detail.message:find( + 'DFHack emitted a malformed DwarfSpec probe report: ' .. case[2], + 1, true), case[1]) + assert.is_truthy(detail.message:find('Probe: ' .. case[1], 1, true), + case[1]) + end end) - it('accepts the exact healthy probe', function() + it('reports expected and observed protocol values before health failures', function() + local detail = connection_failure({ + 'DWARFSPEC_PROBE protocol=3 core=false timeout=nil', + }) + assert.same('DwarfSpec protocol mismatch: controller expects 2, probe ' .. + 'reported 3. Check for mixed installed DwarfSpec package versions.', + detail.message) + end) + + it('reports core=false independently from timeout health', function() + local detail = connection_failure({ + 'DWARFSPEC_PROBE protocol=2 core=false timeout=function', + }) + assert.same('DFHack probe did not run in a healthy core Lua context: ' .. + 'expected core=true, reported core=false.', detail.message) + end) + + it('reports every non-function timeout type independently', function() + for _, timeout_type in ipairs({ + 'nil', 'boolean', 'number', 'string', 'userdata', 'thread', + 'table', 'unavailable', + }) do + local detail = connection_failure({ + 'DWARFSPEC_PROBE protocol=2 core=true timeout=' .. timeout_type, + }) + assert.same('DFHack core Lua context is missing the required ' .. + 'dfhack.timeout function: reported timeout=' .. timeout_type .. '.', + detail.message) + end + end) + + it('bounds and sanitizes sparse non-string probe output', function() + local unprintable = setmetatable({}, { + __tostring=function() error('cannot render') end, + }) + local lines = { + [1]=' first\tline\1 ', + [3]=unprintable, + [5]=string.rep('x', 600), + } + local detail = connection_failure(lines) + assert.is_truthy(detail.message:find('first line?', 1, true)) + assert.is_truthy(detail.message:find('', 1, true)) + assert.is_truthy(detail.message:find('...', 1, true)) + + detail = connection_failure({string.rep('\195\169', 300)}) + local output = assert(detail.message:match('Output: (.*)$')) + assert.is_not_nil(utf8.len(output)) + assert.is_true(#output <= 512) + end) + + it('retains recent probe output within line and total byte limits', function() + local lines = {} + for index = 1, 10 do + lines[index] = ('line-%02d-%s'):format(index, string.rep('x', 500)) + end + local detail = connection_failure(lines) + local output = assert(detail.message:match('Output: (.*)$')) + assert.is_true(#output <= 2048) + assert.is_truthy(output:find(' ', 1, true)) + assert.is_falsy(output:find('line-01-', 1, true)) + assert.is_truthy(output:find('line-10-', 1, true)) + end) + + it('enforces exact line-count and byte truncation boundaries', function() + local lines = {} + for index = 1, 8 do lines[index] = 'line-' .. index end + assert.same(table.concat(lines, ' | '), output_excerpt(lines)) + + table.insert(lines, 'line-9') + assert.same('<1 earlier lines omitted> | ' .. + table.concat({table.unpack(lines, 2, 9)}, ' | '), + output_excerpt(lines)) + + local exact_line = string.rep('x', 512) + assert.same(exact_line, output_excerpt({exact_line})) + local truncated_line = output_excerpt({string.rep('x', 513)}) + assert.same(512, #truncated_line) + assert.is_truthy(truncated_line:find('...', 1, true)) + + lines = {} + for index = 1, 7 do lines[index] = string.rep('x', 253) end + lines[8] = string.rep('x', 256) + local exact_output = output_excerpt(lines) + assert.same(2048, #exact_output) + assert.is_falsy(exact_output:find(' ', 1, true)) + + lines[8] = string.rep('x', 257) + local truncated_output = output_excerpt(lines) + assert.same(2048, #truncated_output) + assert.same(' ', truncated_output:sub(1, 19)) + assert.same(lines[8], truncated_output:sub(-#lines[8])) + end) + + it('preserves structured errors from nonzero subprocess results', function() + local lines = {'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', protocol=2, + kind='registration', code='package_version_mismatch', + message='different version loaded', + running_version='0.2.1', requested_version='0.2.2', + })} local transport = client() - local options = {invoke=function() return {exit_code=0, lines={ - 'DWARFSPEC_PROBE protocol=2 core=true timeout=function'}} end} - assert.has_no.errors(function() transport.verify_connection(options, 'runner') end) + local ok, detail = pcall(transport.transport, { + invoke=function() return {exit_code=4, lines=lines} end, + }, 'runner', {}, {}, 'poll') + assert.is_false(ok) + assert.equals('package_version_mismatch', detail.code) + assert.equals('0.2.1', detail.running_version) + + local response, owner, bootstrap_error = transport.bootstrap_response({ + invoke=function() return {exit_code=4, lines=lines} end, + }, 'runner', {}, {}) + assert.is_nil(response) + assert.is_nil(owner) + assert.equals('package_version_mismatch', bootstrap_error.code) end) - it('classifies nonzero exits before canonical parsing', function() + it('classifies zero-exit mutation rejections with actionable safe context', + function() + local cases = { + {code='service_not_loaded', operation='abort', + guidance='Bootstrap DwarfSpec'}, + {code='run_not_found', operation='cancel', run_id='run-1', + guidance='refresh status before retrying cancel'}, + {code='generation_mismatch', operation='acknowledgement', + run_id='run-1', generation=2, current_generation=3, + guidance='requested generation 2, current generation 3'}, + {code='invalid_run_state', operation='discard', run_id='run-1', + generation=3, state='running', guidance='is running'}, + {code='owner_capability_rejected', operation='recover', + run_id='run-1', generation=3, state='running', + guidance='owning DwarfSpec process'}, + {code='quarantine_mismatch', operation='recover executor', + run_id='run-1', generation=3, blocking_run_id='run-2', + blocking_generation=4, + guidance='belongs to run run-2 generation 4'}, + {code='clean_state_unverified', operation='recover executor', + run_id='run-1', generation=3, reason='cleanup remains active', + guidance='Resolve remaining live resources'}, + {code='event_cursor_ahead', operation='status poll', + run_id='run-1', generation=3, state='running', + after_sequence=8, last_sequence=7, + guidance='requested cursor 8, retained cursor 7'}, + } + for _, case in ipairs(cases) do + local response = {schema='dwarfspec.error.v1', protocol=2, + kind='host', message='opaque host prose'} + for name, value in pairs(case) do + if name ~= 'guidance' then response[name] = value end + end + local transport = client() + local ok, detail = pcall(transport.transport, { + invoke=function() + return {exit_code=0, lines={ + 'DWARFSPEC_JSON ' .. json.encode(response), + }} + end, + }, 'runner', {}, {}, case.operation) + assert.is_false(ok, case.code) + assert.equals(case.code, detail.code) + assert.equals('host', detail.kind) + assert.equals(9, detail.exit_code) + assert.is_truthy(detail.message:find('opaque host prose', 1, true)) + assert.is_nil(detail.owner_capability) + assert.is_nil(detail.authorization_proof) + assert.is_truthy(detail.message:find(case.guidance, 1, true), + case.code) + for name, value in pairs(case) do + if name ~= 'guidance' then + assert.equals(value, detail[name], case.code .. '.' .. name) + end + end + if case.run_id then + assert.is_truthy(detail.message:find(case.run_id, 1, true), + case.code) + end + end + end) + + it('classifies nonzero exits without valid JSON using bounded output', function() local transport = client() local ok, detail = pcall(transport.transport, { - invoke=function() return {exit_code=4, lines={}} end}, 'runner', {}, {}, 'poll') + invoke=function() + return {exit_code=4, lines={string.rep('x', 3000)}} + end}, 'runner', {}, {}, 'poll') assert.is_false(ok) assert.same('host', detail.kind) assert.matches('poll exited with 4', detail.message) + assert.matches('Output:', detail.message, 1, true) + assert.is_true(#detail.message < 2200) + + local malformed = {'DWARFSPEC_JSON ' .. json.encode({ + schema='dwarfspec.error.v1', protocol=2, + kind='registration', code='package_version_mismatch', + message='different version loaded', running_version='0.2.1', + })} + ok, detail = pcall(transport.transport, { + invoke=function() return {exit_code=4, lines=malformed} end, + }, 'runner', {}, {}, 'poll') + assert.is_false(ok) + assert.equals('host', detail.kind) + assert.is_nil(detail.code) + assert.matches('DWARFSPEC_JSON', detail.message, 1, true) end) it('rejects missing and malformed canonical envelopes', function() diff --git a/tests/unit/controller/process_report_spec.lua b/tests/unit/controller/process_report_spec.lua index bcec1f0..e8adccb 100644 --- a/tests/unit/controller/process_report_spec.lua +++ b/tests/unit/controller/process_report_spec.lua @@ -250,7 +250,7 @@ describe('DwarfSpec native reports', function() })) end) - it('returns a canonical adapter rejection separately from transport', + it('returns a generic registration rejection separately from transport', function() local transport, _, response_error = report.parse_transport_response({'DWARFSPEC_JSON ignored'}, { @@ -270,6 +270,120 @@ describe('DwarfSpec native reports', function() assert.equals('registration', response_error.kind) assert.equals('incompatible automation package version: ' .. 'expected 0.1.3, found 0.2.1', response_error.message) + assert.is_nil(response_error.code) + end) + + it('validates package mismatch and quarantine adapter errors', function() + local _, _, mismatch = report.parse_transport_response( + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code='package_version_mismatch', + message='different version loaded', + running_version='0.2.1', + requested_version='0.2.2', + } + end) + assert.equals('package_version_mismatch', mismatch.code) + assert.equals('0.2.1', mismatch.running_version) + assert.equals('0.2.2', mismatch.requested_version) + + local _, _, quarantine = report.parse_transport_response( + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='executor_quarantined', + message='cleanup unconfirmed', + blocking_run_id='run-blocking', + blocking_generation=7, + reason='cleanup unconfirmed', + } + end) + assert.equals('run-blocking', quarantine.blocking_run_id) + assert.equals(7, quarantine.blocking_generation) + assert.equals('cleanup unconfirmed', quarantine.reason) + end) + + it('accepts unknown registration codes without mismatch fields', function() + local _, _, response_error = report.parse_transport_response( + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code='future_registration_code', + message='future registration rejection', + } + end) + + assert.equals('future_registration_code', response_error.code) + assert.equals('future registration rejection', response_error.message) + assert.is_nil(response_error.running_version) + assert.is_nil(response_error.requested_version) + end) + + it('rejects malformed package mismatch fields', function() + local cases = { + {'running version missing', nil, '0.2.2', 'running version'}, + {'running version empty', '', '0.2.2', 'running version'}, + {'running version typed', 201, '0.2.2', 'running version'}, + {'requested version missing', '0.2.1', nil, 'requested version'}, + {'requested version empty', '0.2.1', '', 'requested version'}, + {'requested version typed', '0.2.1', 202, 'requested version'}, + } + for _, case in ipairs(cases) do + local accepted, rejection = pcall( + report.parse_transport_response, + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code='package_version_mismatch', + message='different version loaded', + running_version=case[2], + requested_version=case[3], + } + end) + assert.is_false(accepted, case[1]) + assert.is_table(rejection, case[1]) + assert.is_true(rejection.invalid_adapter_error, case[1]) + assert.matches(case[4], rejection.message, 1, true) + end + + local accepted, rejection = pcall( + report.parse_transport_response, + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code=42, + message='typed code', + } + end) + assert.is_false(accepted) + assert.is_true(rejection.invalid_adapter_error) + assert.matches('code must be a non-empty string', + rejection.message, 1, true) + + accepted, rejection = pcall( + report.parse_transport_response, + {'DWARFSPEC_JSON ignored'}, {}, function() + return { + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + message='unsafe rejection', + unsafe=function() end, + } + end) + assert.is_false(accepted) + assert.is_true(rejection.invalid_adapter_error) + assert.matches('JSON-safe', rejection.message, 1, true) end) it('accepts one exact version 2 transport identity and cursor', function() diff --git a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua index 0923e08..ee73b60 100644 --- a/tests/unit/host/entrypoints/entrypoint_contract_spec.lua +++ b/tests/unit/host/entrypoints/entrypoint_contract_spec.lua @@ -110,6 +110,182 @@ describe('version 2 automation entrypoint contract', function() assert.is_nil(dfhack.dwarfspec) end) + it('emits one structured rejection from every unloaded mutation adapter', + function() + local cases = { + {name='abort', arguments={'missing-run', ''}}, + {name='cancel', arguments={'missing-run', 'owner-secret', '0'}}, + {name='recover', arguments={'missing-run', 'owner-secret', '0'}}, + {name='acknowledge', arguments={ + 'missing-run', '1', 'owner-secret', '0'}}, + {name='discard', arguments={'missing-run', '1', '0'}}, + {name='recover_executor', arguments={'missing-run', '1', '0'}}, + } + for _, case in ipairs(cases) do + lines = {} + load_host_script(case.name)(table.unpack(case.arguments)) + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines, case.name) + local rejection = encoded[#encoded] + assert.equals('dwarfspec.error.v1', rejection.schema) + assert.equals('host', rejection.kind) + assert.equals('service_not_loaded', rejection.code) + assert.is_nil(rejection.owner_capability) + assert.is_nil(rejection.authorization_proof) + assert.is_falsy(rejection.message:find('owner-secret', 1, true)) + end + assert.is_nil(dfhack.dwarfspec) + end) + + it('serializes polling and event rejections with one safe response', + function() + for _, case in ipairs({ + {name='status', arguments={'missing-run', 'owner-secret', '0', '1'}}, + {name='event_read', arguments={'missing-run', '0', '1'}}, + {name='scheduler_status', arguments={'missing-run', '0', '1'}}, + }) do + lines = {} + load_host_script(case.name)(table.unpack(case.arguments)) + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.equals('service_not_loaded', encoded[#encoded].code) + assert.is_falsy(encoded[#encoded].message:find( + 'owner-secret', 1, true)) + end + + local root = require('lfs').currentdir():gsub('\\', '/') + lines = {} + load_host_script('bootstrap')('poll-entrypoint', + '--project-root=' .. root, '--defer-frames=1') + local run = assert(dfhack.dwarfspec.runs['poll-entrypoint']) + local last_sequence = #run.event_journal.events + local cases = { + {name='status', arguments={run.run_id, run.owner_capability, + '0', tostring(run.generation + 1)}, + code='generation_mismatch'}, + {name='status', arguments={run.run_id, 'owner-secret', '0', + tostring(run.generation)}, code='owner_capability_rejected'}, + {name='event_read', arguments={run.run_id, + tostring(last_sequence + 1), tostring(run.generation)}, + code='event_cursor_ahead'}, + {name='event_read', arguments={'missing-run', '0', '1'}, + code='run_not_found'}, + {name='scheduler_status', arguments={run.run_id, '0', + tostring(run.generation + 1)}, code='generation_mismatch'}, + } + for _, case in ipairs(cases) do + lines = {} + load_host_script(case.name)(table.unpack(case.arguments)) + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines, case.name) + local rejection = encoded[#encoded] + assert.equals('dwarfspec.error.v1', rejection.schema) + assert.equals(case.code, rejection.code) + assert.is_nil(rejection.owner_capability) + assert.is_nil(rejection.authorization_proof) + assert.is_falsy(rejection.message:find('owner-secret', 1, true)) + end + end) + + it('keeps unexpected adapter faults on the subprocess failure path', + function() + local response = + require('dwarfspec.host.entrypoints.operation_response') + local ok, detail = pcall(response.execute, + function() error('unexpected invariant failure', 0) end, + function() error('success must not be emitted') end, + require('json').encode) + assert.is_false(ok) + assert.equals('unexpected invariant failure', detail) + assert.same({}, lines) + assert.same({}, encoded) + end) + + it('preserves generic string bootstrap rejections', function() + load_host_script('bootstrap')( + 'entrypoint-generic-rejection', '--unknown=value') + + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.equals('dwarfspec.error.v1', encoded[1].schema) + assert.equals(2, encoded[1].protocol) + assert.equals('registration', encoded[1].kind) + assert.matches('unknown automation option: --unknown', + encoded[1].message, 1, true) + assert.is_nil(encoded[1].code) + assert.is_nil(encoded[1].running_version) + assert.is_nil(encoded[1].requested_version) + assert.is_false(encode_options[1].pretty) + assert.is_nil(dfhack.dwarfspec) + end) + + it('serializes admission conflicts with exact safe blocking fields', + function() + local root = require('lfs').currentdir():gsub('\\', '/') + local result_path = root .. '/shared-result.json' + load_host_script('bootstrap')( + 'admission-owner', '--project-root=.', '--result-policy=file', + '--result-path=' .. result_path) + local registry = dfhack.dwarfspec + local owner = registry.runs['admission-owner'] + + lines = {} + load_host_script('bootstrap')( + 'admission-owner', '--project-root=.', '--result-policy=file', + '--result-path=' .. result_path) + assert.equals('dwarfspec.transport.v2', encoded[2].schema) + assert.matches('DWARFSPEC_OWNER ', lines[2], 1, true) + assert.equals(1, registry.generation) + + lines = {} + load_host_script('bootstrap')( + 'admission-owner', '--project-root=.', '--result-policy=file', + '--result-path=' .. result_path, '--spec=different.ds.lua') + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.same({ + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code='request_key_conflict', + message='This request identity is already bound to a different ' .. + 'DwarfSpec run.', + blocking_run_id=owner.run_id, + blocking_generation=owner.generation, + state=owner.state, + reason='request key is already bound to a different request', + }, encoded[3]) + + lines = {} + load_host_script('bootstrap')( + 'admission-project-busy', '--project-root=.', + '--result-policy=file', '--result-path=' .. result_path) + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.equals('project_busy', encoded[4].code) + assert.equals('registration', encoded[4].kind) + assert.equals(owner.run_id, encoded[4].blocking_run_id) + assert.equals(owner.generation, encoded[4].blocking_generation) + assert.equals(owner.state, encoded[4].state) + assert.equals('project already owns an outstanding run', + encoded[4].reason) + assert.is_string(encoded[4].message) + assert.is_true(encoded[4].message ~= '') + + lines = {} + load_host_script('bootstrap')( + 'admission-result-busy', '--project-root=tests', + '--result-policy=file', '--result-path=' .. result_path) + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.equals('result_path_busy', encoded[5].code) + assert.equals('registration', encoded[5].kind) + assert.equals(owner.run_id, encoded[5].blocking_run_id) + assert.equals(owner.generation, encoded[5].blocking_generation) + assert.equals(owner.state, encoded[5].state) + assert.equals('result path is owned by another outstanding run', + encoded[5].reason) + assert.is_nil(encoded[5].project_root) + assert.is_nil(encoded[5].result_path) + assert.is_nil(encoded[5].owner_capability) + assert.equals(1, registry.generation) + assert.is_nil(registry.runs['admission-project-busy']) + assert.is_nil(registry.runs['admission-result-busy']) + end) + it('starts and aborts through version 2 transport entrypoints', function() local root = require('lfs').currentdir() @@ -180,12 +356,18 @@ describe('version 2 automation entrypoint contract', function() assert.equals('dwarfspec.error.v1', encoded[4].schema) assert.equals(2, encoded[4].protocol) assert.equals('registration', encoded[4].kind) + assert.equals('package_version_mismatch', encoded[4].code) + assert.equals('0.1.3', encoded[4].running_version) + assert.equals('0.2.2', encoded[4].requested_version) + assert.is_string(encoded[4].message) + assert.is_true(encoded[4].message ~= '') assert.is_false(encode_options[4].pretty) - assert.matches('incompatible automation package version: ' .. - 'expected 0.1.3, found 0.2.1', encoded[4].message, 1, true) + assert.matches('DFHack already has a different DwarfSpec version ' .. + 'loaded', encoded[4].message, 1, true) + assert.is_nil(encoded[4].package_root) assert.is_nil(registry.runs['entrypoint-version-rejection']) - registry.package_version = '0.2.1' + registry.package_version = '0.2.2' registry.quarantine = { active=true, run_id=run.run_id, @@ -297,7 +479,19 @@ describe('version 2 automation entrypoint contract', function() assert.equals('dwarfspec.transport.v2', encoded[#encoded].schema) - dfhack.dwarfspec.quarantine = {active=false} + dfhack.dwarfspec.quarantine = { + active=true, run_id=active.run_id, + generation=active.generation, reason='fixture quarantine', + } + lines = {} + load_host_script('recover_executor')( + active.run_id, tostring(active.generation), + tostring(#active.event_journal.events), 'fixture verified clean') + assert.same({'DWARFSPEC_JSON {"encoded":true}'}, lines) + assert.equals('dwarfspec.transport.v2', encoded[#encoded].schema) + assert.is_false(encoded[#encoded].scheduler.quarantine.active) + assert.is_false(dfhack.dwarfspec.quarantine.active) + lines = {} load_host_script('scheduler_status')() assert.equals('DWARFSPEC_JSON {"encoded":true}', lines[1]) diff --git a/tests/unit/host/entrypoints/probe_spec.lua b/tests/unit/host/entrypoints/probe_spec.lua new file mode 100644 index 0000000..8029de5 --- /dev/null +++ b/tests/unit/host/entrypoints/probe_spec.lua @@ -0,0 +1,149 @@ +-- Unit contracts for the dependency-free DFHack connection probe. + +local layout = require('dwarfspec.layout') + +---Loads the direct probe entrypoint through the package layout authority. +---@param environment table|nil +---@return function +local function load_probe(environment) + local path = layout.current().host_scripts.probe + if environment == nil then return assert(loadfile(path)) end + return assert(loadfile(path, 't', environment)) +end + +---Returns the number of currently loaded Lua modules. +---@return integer +local function loaded_module_count() + local count = 0 + for _ in pairs(package.loaded) do count = count + 1 end + return count +end + +describe('DFHack connection probe entrypoint', function() + local original_dfhack + local original_print + local original_require + local original_reqscript + local lines + + before_each(function() + original_dfhack = rawget(_G, 'dfhack') + original_print = rawget(_G, 'print') + original_require = rawget(_G, 'require') + original_reqscript = rawget(_G, 'reqscript') + lines = {} + rawset(_G, 'print', function(line) + table.insert(lines, line) + end) + end) + + after_each(function() + rawset(_G, 'dfhack', original_dfhack) + rawset(_G, 'print', original_print) + rawset(_G, 'require', original_require) + rawset(_G, 'reqscript', original_reqscript) + end) + + ---Executes the probe with one modeled DFHack global. + ---@param context any + ---@return string + local function probe(context) + rawset(_G, 'dfhack', context) + assert.has_no.errors(load_probe()) + assert.equals(1, #lines) + assert.matches('^DWARFSPEC_PROBE ', lines[1]) + return lines[1] + end + + it('reports the exact healthy protocol 2 response', function() + local line = probe({ + VERSION='53.15-r1', + is_core_context=true, + timeout=function() end, + }) + + assert.equals('DWARFSPEC_PROBE protocol=2 core=true ' .. + 'timeout=function dfhack=53.15-r1', line) + end) + + it('resolves DFHack through the script environment lookup chain', function() + local context = { + VERSION='53.15-r2', + is_core_context=true, + timeout=function() end, + } + local base_environment = setmetatable({ + dfhack=context, + print=function(line) table.insert(lines, line) end, + }, {__index=_G}) + local environment = setmetatable({}, {__index=base_environment}) + environment._G = environment + + assert.has_no.errors(load_probe(environment)) + + assert.same({'DWARFSPEC_PROBE protocol=2 core=true ' .. + 'timeout=function dfhack=53.15-r2'}, lines) + end) + + it('reports an absent DFHack global without throwing', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=unavailable ' .. + 'timeout=unavailable', probe(nil)) + end) + + it('reports a missing core-context capability independently', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=unavailable ' .. + 'timeout=function', probe({timeout=function() end})) + end) + + it('reports a missing timeout capability independently', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=true timeout=nil', + probe({is_core_context=true})) + end) + + it('normalizes an incorrectly typed core-context capability', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=unavailable ' .. + 'timeout=function', probe({ + is_core_context='true', + timeout=function() end, + })) + end) + + it('reports an incorrectly typed timeout capability', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=true timeout=table', + probe({is_core_context=true, timeout={}})) + end) + + it('omits an unsafe optional DFHack version', function() + assert.equals('DWARFSPEC_PROBE protocol=2 core=true ' .. + 'timeout=function', probe({ + VERSION='53.15 release candidate', + is_core_context=true, + timeout=function() end, + })) + end) + + it('does not load project or third-party modules', function() + rawset(_G, 'dfhack', { + is_core_context=true, + timeout=function() end, + }) + local chunk = load_probe() + rawset(_G, 'require', function() + error('probe must not call require') + end) + rawset(_G, 'reqscript', function() + error('probe must not call reqscript') + end) + local loaded_before = loaded_module_count() + + local ok, probe_error = pcall(chunk) + local loaded_after = loaded_module_count() + rawset(_G, 'require', original_require) + rawset(_G, 'reqscript', original_reqscript) + + assert.is_true(ok, probe_error) + assert.equals(loaded_before, loaded_after) + assert.same({'DWARFSPEC_PROBE protocol=2 core=true ' .. + 'timeout=function'}, lines) + end) +end) diff --git a/tests/unit/host/execution/host_spec.lua b/tests/unit/host/execution/host_spec.lua index 1bb9482..4389715 100644 --- a/tests/unit/host/execution/host_spec.lua +++ b/tests/unit/host/execution/host_spec.lua @@ -243,12 +243,88 @@ describe('automation host ownership', function() outstanding_run_id) end) + it('rejects stale polling context before lease or cursor mutation', + function() + local service = require('dwarfspec.host.service.service') + local run = host.start('.', '.', options('poll-rejection')) + + ---Captures the complete detached service state. + ---@return table + local function summary() + return service.summary({namespace=dfhack}) + end + + ---Asserts one polling rejection leaves all service state unchanged. + ---@param expected_code string + ---@param operation function + ---@return table + local function rejected(expected_code, operation) + local before = summary() + local ok, detail = pcall(operation) + assert.is_false(ok) + assert.equals(expected_code, detail.code) + assert.same(before, summary()) + return detail + end + + local stale = rejected('generation_mismatch', function() + host.poll_transport(run.run_id, run.owner_capability, 0, + run.generation + 1) + end) + assert.equals(run.generation + 1, stale.generation) + assert.equals(run.generation, stale.current_generation) + + local cursor = rejected('event_cursor_ahead', function() + host.poll_transport(run.run_id, run.owner_capability, + #run.event_journal.events + 1, run.generation) + end) + assert.equals(#run.event_journal.events + 1, cursor.after_sequence) + assert.equals(#run.event_journal.events, cursor.last_sequence) + + local unauthorized = rejected('owner_capability_rejected', function() + host.poll_transport(run.run_id, 'wrong-owner-capability', 0, + run.generation) + end) + assert.equals(run.state, unauthorized.state) + + local missing = rejected('run_not_found', function() + host.transport('missing-poll-run', 0, 'event read', 1) + end) + assert.equals('missing-poll-run', missing.run_id) + end) + it('rejects overlap and ignores a callback after abort', function() local run = host.start('.', '.', options('owner')) assert.equals('starting', run.state) - assert.has_error(function() + local reused = host.start('.', '.', options('owner')) + assert.equals(run, reused) + assert.equals(1, dfhack.dwarfspec.generation) + + local changed = options('owner') + changed.specs = {'different.ds.lua'} + local conflict_accepted, conflict = pcall(function() + host.start('.', '.', changed) + end) + assert.is_false(conflict_accepted) + assert.equals(SchedulerFailureKind.REQUEST_KEY_CONFLICT, conflict.code) + assert.equals('owner', conflict.blocking_run_id) + assert.equals(1, conflict.blocking_generation) + assert.equals('starting', conflict.state) + assert.equals('request key is already bound to a different request', + conflict.reason) + + local accepted, rejection = pcall(function() host.start('.', '.', options('overlap')) - end, 'automation run owner is already starting') + end) + assert.is_false(accepted) + assert.same({ + code=SchedulerFailureKind.PROJECT_BUSY, + message='This project already has an outstanding DwarfSpec run.', + blocking_run_id='owner', + blocking_generation=1, + state='starting', + reason='project already owns an outstanding run', + }, rejection) local cleaned = false run.cleanup_module.push(run.cleanup_registry, 'abort proof', function() @@ -302,9 +378,16 @@ describe('automation host ownership', function() local retained = host.start('.', '.', options('retained')) local aborted = host.abort(retained.run_id, retained.owner_capability) - assert.has_error(function() + local accepted, rejection = pcall(function() host.start('.', '.', options('replacement')) - end, 'automation run retained has an unobserved aborted result') + end) + assert.is_false(accepted) + assert.equals(SchedulerFailureKind.PROJECT_BUSY, rejection.code) + assert.equals('retained', rejection.blocking_run_id) + assert.equals(retained.generation, rejection.blocking_generation) + assert.equals('aborted', rejection.state) + assert.equals('project already owns an outstanding run', + rejection.reason) host.acknowledge(aborted.run_id, aborted.generation, aborted.owner_capability) @@ -374,10 +457,13 @@ describe('automation host ownership', function() assert.equals(1, aborted.mount_cleanup_state.active_screen_count) assert.matches('mount lifecycle verification failed', aborted.failure_details[1].message, 1, true) - assert.has_error(function() + local recovered, rejection = pcall(function() host.recover_executor(aborted.run_id, aborted.generation, 'unsafe fixture recovery') - end, 'quarantined mount state is not clean') + end) + assert.is_false(recovered) + assert.equals('clean_state_unverified', rejection.code) + assert.equals('quarantined mount state is not clean', rejection.reason) end) it('refuses cleanup confirmation for retained ownership evidence', @@ -401,10 +487,13 @@ describe('automation host ownership', function() assert.is_false(aborted.cleanup_confirmed) assert.is_false(aborted.mount_cleanup_state.verified) - assert.has_error(function() + local recovered, rejection = pcall(function() host.recover_executor(aborted.run_id, aborted.generation, 'unsafe retained ownership recovery') - end, 'quarantined mount state is not clean') + end) + assert.is_false(recovered) + assert.equals('clean_state_unverified', rejection.code) + assert.equals('quarantined mount state is not clean', rejection.reason) end) it('never confirms cleanup after an earlier cleanup action failed', diff --git a/tests/unit/host/service/scheduler/recovery_spec.lua b/tests/unit/host/service/scheduler/recovery_spec.lua index 1f097b2..a9ee632 100644 --- a/tests/unit/host/service/scheduler/recovery_spec.lua +++ b/tests/unit/host/service/scheduler/recovery_spec.lua @@ -30,11 +30,34 @@ describe('scheduler recovery policy', function() local dependencies, controls = support.environment() controls.registry.quarantine = {active=true, run_id='failed-proof', generation=3, reason='unclean'} - assert.has_error(function() recovery.recover_executor(controls.registry, + local ok, rejection = pcall(function() + recovery.recover_executor(controls.registry, {service_instance_id=controls.registry.service_instance_id, run_id='failed-proof', generation=3, - reason='not clean', proof={clean=false}}, dependencies) end, - 'clean-state proof rejected') + reason='not clean', proof={clean=false}}, dependencies) + end) + assert.is_false(ok) + assert.equals('clean_state_unverified', rejection.code) + assert.equals('clean-state proof rejected', rejection.reason) + assert.is_true(controls.registry.quarantine.active) + end) + + it('normalizes empty verifier detail without clearing quarantine', function() + local dependencies, controls = support.environment() + controls.registry.quarantine = {active=true, + run_id='empty-detail', generation=4, reason='unclean'} + dependencies.verify_clean_state = function() + return false, '' + end + local ok, rejection = pcall(recovery.recover_executor, + controls.registry, { + service_instance_id=controls.registry.service_instance_id, + run_id='empty-detail', generation=4, + reason='not clean', proof={clean=false}, + }, dependencies) + assert.is_false(ok) + assert.equals('clean_state_unverified', rejection.code) + assert.equals('clean-state proof was rejected', rejection.reason) assert.is_true(controls.registry.quarantine.active) end) diff --git a/tests/unit/host/service/service_scheduler_spec.lua b/tests/unit/host/service/service_scheduler_spec.lua index 32aae2d..0bd02da 100644 --- a/tests/unit/host/service/service_scheduler_spec.lua +++ b/tests/unit/host/service/service_scheduler_spec.lua @@ -255,6 +255,73 @@ describe('multi-project automation service scheduler', function() renewed.execution_lease.expires_at_ms) end) + it('returns structured mutation rejections without changing service state', + function() + local dependencies = environment() + local project = register_project(dependencies, 1) + local admitted = service.submit(project.project_id, + submission('structured-mutation'), dependencies) + + ---Captures and verifies one rejected operation atomically. + ---@param expected_code string + ---@param operation function + ---@return table + local function rejected(expected_code, operation) + local scheduler_before = service.scheduler_snapshot(dependencies) + local run_before = service.snapshot(admitted.identity.run_id, + dependencies) + local ok, detail = pcall(operation) + assert.is_false(ok) + assert.equals(expected_code, detail.code) + assert.same(scheduler_before, + service.scheduler_snapshot(dependencies)) + assert.same(run_before, service.snapshot( + admitted.identity.run_id, dependencies)) + assert.is_nil(detail.owner_capability) + assert.is_nil(detail.authorization_proof) + return detail + end + + local missing = owner_request(admitted, {run_id='missing-run'}) + missing.reason = 'cancel' + rejected('run_not_found', function() + service.cancel(missing, dependencies) + end) + local stale = owner_request(admitted, { + generation=admitted.identity.generation + 1, reason='cancel', + }) + rejected('generation_mismatch', function() + service.cancel(stale, dependencies) + end) + local unauthorized = owner_request(admitted, { + owner_capability='wrong-owner-capability-00000000001', + reason='cancel', + }) + rejected('owner_capability_rejected', function() + service.cancel(unauthorized, dependencies) + end) + + service.activate_next(dependencies) + local active_request = owner_request(admitted, {reason='cancel'}) + local invalid = rejected('invalid_run_state', function() + service.cancel(active_request, dependencies) + end) + assert.equals(RunState.STARTING, invalid.state) + + local cleanup_called = false + dependencies.abort_active = function() + cleanup_called = true + end + local rejected_abort = owner_request(admitted, { + owner_capability='wrong-owner-capability-00000000001', + reason='abort', + }) + rejected('owner_capability_rejected', function() + service.abort(rejected_abort, dependencies) + end) + assert.is_false(cleanup_called) + end) + it('expires queued owners without cleanup and blocks only their project', function() local dependencies, clock = environment() @@ -488,6 +555,7 @@ describe('multi-project automation service scheduler', function() assert.is_nil(first.snapshot.owner_capability) assert.is_nil(service.events(first.identity.run_id, 0, dependencies).events[1].owner_capability) + local after_reuse = service.summary(dependencies) local mismatched_retry = submission('alpha') mismatched_retry.selection.identities = {'tests/live/other.ds.lua'} @@ -497,12 +565,15 @@ describe('multi-project automation service scheduler', function() assert.equals(SchedulerFailureKind.REQUEST_KEY_CONFLICT, conflict.kind) assert.equals(first.identity.run_id, conflict.identity.run_id) + assert.same(after_reuse, service.summary(dependencies)) + local before_busy = service.summary(dependencies) local busy = service.submit(projects[1].project_id, submission('alpha-other'), dependencies) assert.is_false(busy.accepted) assert.equals(SchedulerFailureKind.PROJECT_BUSY, busy.kind) assert.equals(first.identity.run_id, busy.identity.run_id) + assert.same(before_busy, service.summary(dependencies)) local second = service.submit(projects[2].project_id, submission('alpha'), dependencies) @@ -702,7 +773,8 @@ describe('multi-project automation service scheduler', function() assert.equals(1, service.snapshot(third.identity.run_id, dependencies).queue_position) - assert.has_error(function() + local before_recovery = service.scheduler_snapshot(dependencies) + local recovered, rejection = pcall(function() service.recover_executor({ service_instance_id=scheduler.service_instance_id, run_id=scheduler.quarantine.run_id, @@ -710,8 +782,11 @@ describe('multi-project automation service scheduler', function() reason='stale recovery', proof={clean=true}, }, dependencies) - end, 'executor recovery generation does not match quarantine') - assert.has_error(function() + end) + assert.is_false(recovered) + assert.equals('quarantine_mismatch', rejection.code) + assert.same(before_recovery, service.scheduler_snapshot(dependencies)) + recovered, rejection = pcall(function() service.recover_executor({ service_instance_id=scheduler.service_instance_id, run_id=scheduler.quarantine.run_id, @@ -719,7 +794,11 @@ describe('multi-project automation service scheduler', function() reason='unverified recovery', proof={clean=true}, }, dependencies) - end, 'fixture clean-state proof was rejected') + end) + assert.is_false(recovered) + assert.equals('clean_state_unverified', rejection.code) + assert.equals('fixture clean-state proof was rejected', rejection.reason) + assert.same(before_recovery, service.scheduler_snapshot(dependencies)) assert.is_true(service.scheduler_snapshot( dependencies).quarantine.active) diff --git a/tests/unit/host/service/service_spec.lua b/tests/unit/host/service/service_spec.lua index 511f2e5..fe7e63f 100644 --- a/tests/unit/host/service/service_spec.lua +++ b/tests/unit/host/service/service_spec.lua @@ -289,20 +289,30 @@ describe('multi-project automation service', function() local queue_before = registry.queue local quarantine_before = registry.quarantine local terminals_before = registry.latest_terminal_results + local registry_before = events.copy_json( + registry, 'registry before incompatible bootstrap') - assert.has_error(function() + local compatible, rejection = pcall(function() service.bootstrap(bootstrap_request(nil, '9.9.9'), dependencies) - end, 'incompatible automation package version: expected 0.2.1, ' .. - 'found 9.9.9') + end) + assert.is_false(compatible) + assert.same({ + code='package_version_mismatch', + message='DFHack already has a different DwarfSpec version loaded', + running_version='0.2.1', + requested_version='9.9.9', + }, rejection) assert.has_error(function() service.bootstrap({ protocol_version=1, package_root='D:/Packages/DwarfSpec', - package_version='0.2.1', + package_version='9.9.9', }, dependencies) end, 'incompatible automation service protocol: expected 2, found 1') assert.equals(registry, namespace.dwarfspec) + assert.same(registry_before, events.copy_json( + registry, 'registry after incompatible bootstrap')) assert.equals(projects_before, registry.projects) assert.equals(runs_before, registry.runs) assert.equals(queue_before, registry.queue) diff --git a/tests/unit/protocol/adapter_errors_spec.lua b/tests/unit/protocol/adapter_errors_spec.lua new file mode 100644 index 0000000..8c4c2f3 --- /dev/null +++ b/tests/unit/protocol/adapter_errors_spec.lua @@ -0,0 +1,209 @@ +-- Unit contract for canonical adapter-error construction and serialization. + +local adapter_errors = require('dwarfspec.protocol.adapter_errors') +local reports = require('dwarfspec.controller.reporting.report') +local RunnerFailureKind = + require('dwarfspec.protocol.enums.runner_failure_kinds') +local SchedulerFailureKind = + require('dwarfspec.protocol.enums.scheduler_failure_kinds') + +---Encodes one value through the controller test JSON implementation. +---@param value table +---@return string +local function encode(value) + return require('dkjson').encode(value) +end + +---Returns one valid package-version domain rejection. +---@return table +local function mismatch() + return adapter_errors.domain('package_version_mismatch', + 'different version loaded', { + running_version='0.2.1', + requested_version='0.2.2', + }) +end + +describe('adapter error protocol', function() + it('round trips construction, serialization, and controller validation', + function() + local json, emitted = adapter_errors.serialize(mismatch(), + RunnerFailureKind.REGISTRATION, encode) + local transport, payload, rejection = reports.parse_transport_response({ + 'DWARFSPEC_JSON ' .. json, + }, {}) + assert.is_nil(transport) + assert.equals(json, payload) + assert.same(emitted, rejection) + assert.same({ + schema='dwarfspec.error.v1', + protocol=2, + kind='registration', + code='package_version_mismatch', + message='different version loaded', + running_version='0.2.1', + requested_version='0.2.2', + }, rejection) + end) + + it('requires every exact known-code field', function() + for _, fields in ipairs({ + {requested_version='0.2.2'}, + {running_version='0.2.1'}, + {running_version='', requested_version='0.2.2'}, + {running_version='0.2.1', requested_version=2}, + {running_version='0.2.1', requested_version='0.2.2', extra=true}, + }) do + assert.has_error(function() + adapter_errors.domain('package_version_mismatch', 'message', fields) + end) + end + end) + + it('validates every admission conflict with only safe blocking context', + function() + for _, code in ipairs({ + SchedulerFailureKind.PROJECT_BUSY, + SchedulerFailureKind.REQUEST_KEY_CONFLICT, + SchedulerFailureKind.RESULT_PATH_BUSY, + }) do + local rejection = adapter_errors.domain(code, 'admission conflict', { + blocking_run_id='blocking-run', + blocking_generation=7, + state='queued', + reason='scheduler classification detail', + }) + local envelope = adapter_errors.envelope(rejection, + RunnerFailureKind.REGISTRATION) + assert.equals(RunnerFailureKind.REGISTRATION, envelope.kind) + assert.equals(code, envelope.code) + assert.equals('blocking-run', envelope.blocking_run_id) + assert.equals(7, envelope.blocking_generation) + assert.equals('queued', envelope.state) + assert.is_nil(envelope.project_root) + assert.is_nil(envelope.result_path) + + for _, missing in ipairs({ + 'blocking_run_id', 'blocking_generation', 'state', 'reason', + }) do + local fields = { + blocking_run_id='blocking-run', + blocking_generation=7, + state='queued', + reason='scheduler classification detail', + } + fields[missing] = nil + assert.has_error(function() + adapter_errors.domain(code, 'admission conflict', fields) + end) + end + end + end) + + it('validates every mutation rejection subtype and safe required fields', + function() + local cases = { + service_not_loaded={operation='abort'}, + run_not_found={operation='cancel', run_id='run-1'}, + generation_mismatch={operation='acknowledgement', run_id='run-1', + generation=2, current_generation=3}, + invalid_run_state={operation='discard', run_id='run-1', + generation=3, state='running'}, + owner_capability_rejected={operation='recover', run_id='run-1', + generation=3, state='running'}, + quarantine_mismatch={operation='recover executor', run_id='run-1', + generation=3, blocking_run_id='run-2', blocking_generation=4}, + clean_state_unverified={operation='recover executor', run_id='run-1', + generation=3, reason='cleanup remains active'}, + event_cursor_ahead={operation='status poll', run_id='run-1', + generation=3, state='running', after_sequence=8, + last_sequence=7}, + } + for code, fields in pairs(cases) do + local rejection = adapter_errors.domain(code, 'rejected', fields) + local envelope = adapter_errors.envelope(rejection, + RunnerFailureKind.HOST) + assert.equals(RunnerFailureKind.HOST, envelope.kind) + assert.equals(code, envelope.code) + assert.is_nil(envelope.owner_capability) + assert.is_nil(envelope.authorization_proof) + for name in pairs(fields) do + local incomplete = {} + for field, value in pairs(fields) do incomplete[field] = value end + incomplete[name] = nil + assert.has_error(function() + adapter_errors.domain(code, 'rejected', incomplete) + end) + end + end + end) + + it('rejects non-JSON-safe and forbidden domain fields', function() + for _, fields in ipairs({ + {operation=function() end}, + {owner_capability='secret'}, + {authorization_proof='secret'}, + {package_root='C:/private'}, + {project_root='C:/private'}, + {result_path='C:/private/result.json'}, + }) do + assert.has_error(function() + adapter_errors.domain('future_code', 'message', fields) + end) + end + end) + + it('requires an event cursor rejection to describe an ahead cursor', + function() + for _, after_sequence in ipairs({6, 7}) do + assert.has_error(function() + adapter_errors.domain('event_cursor_ahead', 'stale cursor', { + operation='status poll', run_id='run-1', generation=3, + state='running', after_sequence=after_sequence, + last_sequence=7, + }) + end) + end + end) + + it('retains generic and unknown compatibility without known fields', function() + local generic = adapter_errors.envelope('generic rejection', + RunnerFailureKind.REGISTRATION) + assert.is_nil(generic.code) + assert.equals('generic rejection', generic.message) + + local unknown = adapter_errors.envelope(adapter_errors.domain( + 'future_code', 'future rejection', { + operation='cancel', run_id='run-1', generation=2, + state='queued', blocking_run_id='run-0', + blocking_generation=1, + }), RunnerFailureKind.HOST) + assert.equals('future_code', unknown.code) + assert.equals('host', unknown.kind) + assert.equals('cancel', unknown.operation) + end) + + it('preserves the executor quarantine compatibility fixture', function() + local response = adapter_errors.executor_quarantine({ + blocking_run_id='run-1', + blocking_generation=3, + reason='cleanup was not confirmed', + }) + assert.equals('executor_quarantined', response.kind) + assert.is_nil(response.code) + assert.matches('recover-executor run-1 --generation 3', response.message, + 1, true) + end) + + it('uses an uncoded bounded host fallback when serialization fails', + function() + local unprintable = setmetatable({}, { + __tostring=function() error('cannot render') end, + }) + local json, response = adapter_errors.serialize(unprintable, + RunnerFailureKind.REGISTRATION, function() error('encode failure') end) + assert.matches('"kind":"host"', json, 1, true) + assert.is_nil(response.code) + assert.is_true(#response.message <= 512) + end) +end)