@supernovae-st/nika 0.118.7 → 0.120.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -26,14 +26,28 @@ local nika process authenticated nika serve
26
26
  Adapters must implement and makes unsupported authority explicit.
27
27
  - `src/lib/native-process-transport.ts` is the local Adapter. It spawns the
28
28
  selected engine without a shell and consumes newline-delimited machine
29
- events.
29
+ events. It reads the first machine frame before it returns a run, so
30
+ `run()` resolves on the engine's admission and rejects on its refusal.
31
+ - `src/lib/run-refusal.ts` reads the engine's one pre-run refusal object (its
32
+ check report carrying `clean: false`, or its `{ error }` envelope) and turns
33
+ it into the typed `NikaOperationError`. It judges nothing: a shape the
34
+ engine was not measured to write is a protocol fault, never a refusal.
35
+ - `src/lib/legacy-run-refusals.ts` is temporary. It recovers the three refusal
36
+ dialects of engines up to 0.119.0, which predate the one `run --json`
37
+ grammar, and is deleted with its call sites once no supported engine writes
38
+ them.
30
39
  - `src/lib/http-transport.ts` is the remote Adapter. It verifies the remote
31
40
  server identity once per client, resolves and verifies a local engine only
32
41
  for caller-owned snapshot capture, then uses the authenticated HTTP
33
42
  contract.
34
43
  - `src/lib/run-session.ts` is the lifecycle Seam. It owns the eager event
35
44
  pump, bounded independent observers, cancellation memoization, and the sole
36
- terminal `run.done` settlement.
45
+ terminal settlement. It builds the `NikaRun` handle as closures over itself,
46
+ so the Run owns `events()`, `result()`, `status()` and `cancel()`.
47
+ - `src/lib/run-events.ts` is the semantic Adapter: a pure projection of one
48
+ protocol frame onto the SDK's lifecycle vocabulary, with the frame kept by
49
+ identity on `raw`. It is applied at the edge of an observer view. There is
50
+ one history and one bounded queue per observer, and no second event bus.
37
51
  - `openapi.json` and `src/generated/openapi.d.ts` pin the HTTP contract judged
38
52
  by CI. `scripts/check-sdk-coverage.js` fails if a live runtime path is
39
53
  missing or if the SDK names a path outside that contract.
@@ -62,19 +76,68 @@ Some operations deliberately have one authority:
62
76
  ## Lifecycle invariants
63
77
 
64
78
  1. `run()` resolves only after stable admission and returns an immutable
65
- `{ id, done }` handle.
66
- 2. `run.done` is the only terminal promise. Workflow failure is result data;
67
- configuration, transport, protocol, and compatibility failures throw.
68
- 3. `events(run)` creates an independent bounded observer. Aborting an observer
69
- never cancels the run.
70
- 4. `cancel(run)` is idempotent per owned run handle.
71
- 5. `status(run)` reads the durable HTTP projection and refuses a native-only
79
+ `NikaRun` handle: `id`, `events()`, `result()`, `status()`, `cancel()`, and
80
+ `done`, the compatibility alias of `result()`. Admission is not execution;
81
+ a succeeded `result()` is not a sealed receipt (`traceVerify` can return
82
+ `receipt_mismatch` on an UNSEALED journal). Its members are closures
83
+ over the run's one session, so an extracted method still works. The handle
84
+ owns the lifecycle and nothing else: checking, proof, catalogs and
85
+ authoring stay on the facade. A run handle never means "maybe a run": a refusal
86
+ before admission rejects `run()` with `NikaOperationError` and no handle
87
+ exists. Over HTTP a red local snapshot refuses before any request is sent;
88
+ on the native transport the first machine frame decides. A run event is the
89
+ engine's admission evidence and is replayed as the first event; a refusal
90
+ object is the whole stream. The one spawn that executes the workflow is the
91
+ one that judges it: the SDK adds no preflight check and never spawns twice.
92
+ 2. `run.result()` is the only terminal promise (`run.done` is the same one).
93
+ Workflow failure is result data; configuration, transport, protocol, and
94
+ compatibility failures throw. A `paused` result is a human gate: neither a
95
+ failure nor a completed execution.
96
+ 3. A session's history retains at most `eventBufferSize` frames (default 4096,
97
+ never unbounded; each frame is bounded by `machineBufferBytes`). That
98
+ product bounds the retained history only, not the session or the process:
99
+ frames already handed to a consumer, other views and other runs are not
100
+ counted in it. Two different
101
+ bounds can be exceeded and they are never confused: a live view that falls
102
+ behind fails with `reason: 'live_backpressure'`, and a view opened after
103
+ more frames than it can be given is refused with
104
+ `reason: 'replay_truncated'` plus what was `observed` and `retained`.
105
+ Neither skips a frame, neither touches another view, and neither touches
106
+ the result: a run longer than the bound still settles normally. The
107
+ session is memory over what this process saw. It is not a control plane,
108
+ keeps nothing on disk, and reads no engine journal to extend a replay.
109
+ `run.events()` creates an independent bounded observer in the lifecycle
110
+ vocabulary. Aborting an observer never cancels the run. The vocabulary is a
111
+ stateless per-frame projection: it names a fact only for a (kind, status)
112
+ pair a producer defines, never defaults a state word, never deduplicates,
113
+ and never synthesizes a frame a transport did not emit. Every other frame
114
+ keeps flowing as `engine.event`, so transports differ in cardinality but
115
+ never in names, and `event.raw` is always the untouched protocol frame.
116
+ 4. `run.cancel()` is idempotent per run: every call returns the one request.
117
+ 5. `run.status()` reads the durable HTTP projection and refuses a native-only
72
118
  run instead of guessing from local process state.
73
119
  6. Schedule creation uses `If-None-Match: *`; updates require the exact opaque
74
120
  revision in `If-Match`.
75
- 7. `attachRun()` creates a fresh owned session for an existing HTTP job; its
76
- initial sequence is caller-owned durable checkpoint state, never inferred
77
- from in-memory SDK history.
121
+ 7. `attachRun()` is the one recovery door. It creates a fresh owned session
122
+ for an existing HTTP job; its initial sequence is caller-owned durable
123
+ checkpoint state, never inferred from in-memory SDK history. A native run
124
+ is process-bound: its `run.id` is an ephemeral correlation id, and the SDK
125
+ keeps no local job store, starts no hidden `nika serve`, and reads no
126
+ journal to fake durability.
127
+ 8. Ownership has no registry. The facade maps each handle it created to its
128
+ session in a `WeakMap`. The deprecated `nika.events(run)`,
129
+ `nika.cancel(run)` and `nika.status(run)` wrappers resolve through it, so a
130
+ foreign, reconstructed, serialized, or other-client handle still throws
131
+ `NikaRunOwnershipError`. `nika.events(run)` keeps yielding the protocol
132
+ vocabulary unchanged. The wrappers stay for one release train counted from
133
+ publication: they ship unchanged in the first published train that carries
134
+ the Run-owned lifecycle, the earliest train that may remove them is the one
135
+ after it, and the removal is the One SDK baseline owner's decision (#114),
136
+ announced in that train's release notes. No version or date is fixed.
137
+ 9. An interrupted observation is a typed transport error carrying its cursor
138
+ (`NikaObservationInterrupted`), never an engine failure: the run may still
139
+ be running. The engine's own `interrupted` evidence state is data instead:
140
+ a `run.interrupted` event and `result.status === 'interrupted'`.
78
141
 
79
142
  ## Deletion test
80
143
 
package/docs/http-api.md CHANGED
@@ -18,9 +18,9 @@ Any other non-2xx body is discarded and reported as a redacted
18
18
  | `POST /v1/check` | `check()` | validates a served name or immutable snapshot bytes without a job |
19
19
  | `POST /v1/jobs` | `run()` | admits a served name or exact snapshot bytes with an idempotency key |
20
20
  | `GET /v1/jobs/{id}` | internal settlement | durable job identity, outputs, receipt, settlement, or redacted error |
21
- | `GET /v1/jobs/{id}/status` | `status(run)` | current status only |
22
- | `GET /v1/jobs/{id}/events` | `events(run)` / `attachRun()` | bounded, sequenced SSE with replay |
23
- | `POST /v1/jobs/{id}/cancel` | `cancel(run)` | 200 a settled job or its terminal replay; 202 the request accepted on a running job, settled later by observation |
21
+ | `GET /v1/jobs/{id}/status` | `run.status()` | current status only |
22
+ | `GET /v1/jobs/{id}/events` | `run.events()` / `attachRun()` | bounded, sequenced SSE with replay |
23
+ | `POST /v1/jobs/{id}/cancel` | `run.cancel()` | 200 a settled job or its terminal replay; 202 the request accepted on a running job, settled later by observation |
24
24
  | `GET /v1/jobs/{id}/trace/verify` | `traceVerify(receipt)` | engine-owned typed trace verdict; `reason` only on a verdict that does not hold |
25
25
  | `GET/PUT /v1/schedules/{id}` | `scheduleStatus()` / `schedule()` | resident schedule projection and CAS mutation |
26
26
 
@@ -35,12 +35,25 @@ Any other non-2xx body is discarded and reported as a redacted
35
35
  - Each request has a bounded timeout and each JSON/SSE machine frame has a
36
36
  byte ceiling.
37
37
  - Remote `check()` refuses `model` and `nativeStrict`; remote `run()` refuses
38
- `vars`, `model`, and `maxCostUsd` until the request envelope owns them.
38
+ `model`, `maxCostUsd` and the deprecated `vars` until a request envelope
39
+ owns them.
40
+ - Remote `run()` sends `inputs` as `JobByName.inputs` only for a served name
41
+ and only when `GET /health` advertises `jobInputs`. That capability, not a
42
+ 202, is the negotiation: a resident from before the envelope accepts the
43
+ extra field and ignores its values, so the SDK refuses it after `/health`
44
+ alone with `NikaCompatibilityError` (`capability: 'jobInputs'`). The
45
+ `inputs` envelope is engine-owned (nika#1642) and lands in the pinned
46
+ `openapi.json` when the engine pin reaches a release that serves it.
47
+ - A snapshot body takes no `inputs` overlay, an empty map included: `run()` of
48
+ a local path with `inputs` is refused before any capture or request.
49
+ - The serialized `inputs` map is bounded at 1 MiB by the SDK, the bound the
50
+ native channel reads. The resident's whole-request ceiling is its own and may
51
+ be lower.
39
52
  - Caller-provided workflow catalog names must be contained slash-separated
40
53
  paths. Absolute paths, backslashes, empty segments, `.` and `..` are
41
54
  rejected before network I/O.
42
55
 
43
- A contained `.nika.yaml` name uses the resident registry without a local
56
+ A contained `.nika` name uses the resident registry without a local
44
57
  engine. Prefix a local file with `./` to capture and submit its snapshot.
45
58
  A successful by-name check returns `clean: true` and the compact resident
46
59
  acknowledgement; no local check report or exit code is fabricated.
@@ -55,6 +68,62 @@ whose `status` contradicts the record carrying it, keeps fields it does not
55
68
  know, and never derives a settlement from an exit code; a job the resident
56
69
  lost (`interrupted`) carries none.
57
70
 
71
+ ## Frame time and journal evidence (engine main)
72
+
73
+ Both resident projections are closed, and the SDK refuses any field it does
74
+ not know. Engine main adds two optional fields that are
75
+ ahead of the pinned `openapi.json`: no released engine writes them yet, and a
76
+ resident that predates them never sends them, so nothing changes against a
77
+ released resident. They are read so that a resident built from engine main can
78
+ be observed at all; the pin itself moves only with a release.
79
+
80
+ - `JobEvent.at` is when the resident admitted the event: an RFC 3339 timestamp
81
+ in UTC, outside the event's hash chain. It rides `event.raw.at` untouched.
82
+ Anything that is not such a timestamp is a `NikaProtocolError`. The durable
83
+ `Job` declares no `at`, so one there is still an unknown field.
84
+ - `evidence`, on the terminal frame and on the durable `Job`, reports that the
85
+ run's journal mirror stopped recording. It is exactly a `status` and a
86
+ `reason`. The one status is `mirror_lost`. The reason is `write_failed`
87
+ (opening, writing or syncing the journal failed) or `record_refused` (a
88
+ record could not be admitted within the writer's bounds): a coarse class,
89
+ never OS text and never a path. Any other shape or word is a
90
+ `NikaProtocolError`, as the engine itself refuses one, and its value is
91
+ never quoted in the error.
92
+
93
+ `evidence` is independent of the execution and is never a verdict: a run can
94
+ settle `succeeded` while its mirror is lost. It never changes `result.status`,
95
+ the settlement, or the receipt's identity checks. `run.result()` copies it to
96
+ `result.evidence` from the frame or record that settled the run, so a caller
97
+ who never iterates events still learns the trace may be incomplete before
98
+ trusting `traceVerify`. Its absence claims nothing: not that a journal exists,
99
+ only that no loss was reported. A native run never carries it.
100
+
101
+ ## Lifecycle vocabulary over the resident's frames
102
+
103
+ `run.events()` names the resident's closed `JobEvent` frames in the SDK's
104
+ lifecycle vocabulary and keeps each frame on `event.raw`:
105
+
106
+ | `JobEvent.kind` | `JobEvent.status` | `event.kind` |
107
+ |---|---|---|
108
+ | `execution.started` | any | `run.started` |
109
+ | `execution.settled` | `paused` | `run.waiting` |
110
+ | `execution.settled` | `succeeded` · `failed` · `cancelled` | `run.settled` |
111
+ | `execution.cancelled` | `cancelled` only | `run.settled` |
112
+ | `execution.refused` | `failed` only | `run.settled` |
113
+ | `execution.interrupted` · `interrupted` | `interrupted` | `run.interrupted` |
114
+ | anything else: a `null` or future kind; an end kind whose status is absent, `null`, future, `queued` or `running`; or a pair that contradicts itself (`execution.refused` carrying `succeeded` or `cancelled`, `execution.cancelled` carrying `succeeded` or `failed`, an end kind carrying `interrupted`) | | `engine.event` |
115
+
116
+ The pairs above are exhaustive and listed, never computed as a product of
117
+ kinds and words. An unnamed frame is still delivered with `event.raw` intact,
118
+ and `run.result()` still reads the state word the engine wrote on it.
119
+
120
+ The resident streams no per-task frame, so an HTTP run yields no `task.*`
121
+ event: the SDK never synthesizes one. `event.sequence` is the validated SSE
122
+ id, the cursor to persist. The engine's `interrupted` (execution ownership
123
+ was lost, settlement unknown) is data: a `run.interrupted` event and
124
+ `result.status`. It is unrelated to `NikaObservationInterrupted` below, which
125
+ is this client losing its view of a run that may still be running.
126
+
58
127
  ## SSE recovery
59
128
 
60
129
  The client checks that SSE ids are canonical positive integers and equal
@@ -71,9 +140,13 @@ A cursor means “fully processed”, not merely “received”.
71
140
 
72
141
  ## Idempotency and schedules
73
142
 
74
- An omitted run idempotency key is generated once per admission. A caller key
75
- must be 1–255 bytes. Reusing a key with different snapshot bytes is an engine
76
- conflict, not a retry success.
143
+ HTTP `run()` requires a caller-owned `idempotencyKey` of 1–255 bytes. Omitting
144
+ it throws `NikaConfigurationError` before network I/O or local snapshot capture.
145
+ Persist the key before admission and retry the exact request with the same key
146
+ if the response is lost or times out. The SDK never generates a hidden key or
147
+ retries admission automatically. Reusing a key with different request bytes
148
+ is an engine conflict, not a retry success. Direct native runs still omit the
149
+ key and reject it if supplied.
77
150
 
78
151
  The namespace is the whole durable job store under the server's configured
79
152
  `state-root`, across workflows, clients, schedules, and server restarts. The
@@ -64,9 +64,20 @@ invariants; observe or cancel a returned run through its owned lifecycle.
64
64
  | `nika.workflows.list()` | `nika.listWorkflows()` |
65
65
  | `nika.workflows.metadata(name)` | `nika.workflow(name)` |
66
66
 
67
- The new run handle is intentionally only `{ id, done }`. Methods reject a
68
- look-alike object from another client, so persist the job id and reattach after
69
- a process restart instead of rebuilding a handle by hand.
67
+ In 0.116 the run handle was only `{ id, done }`. The Run now owns its
68
+ lifecycle: `run.events()`, `run.result()`, `run.status()` and `run.cancel()`,
69
+ with `run.done` kept as the alias of `run.result()`. The `nika.events(run)`,
70
+ `nika.cancel(run)` and `nika.status(run)` forms this guide shows still work
71
+ unchanged as deprecated wrappers, and `nika.events(run)` still yields the
72
+ protocol vocabulary; `run.events()` yields one lifecycle vocabulary on both
73
+ transports with that frame on `event.raw`. The wrappers stay for one release
74
+ train counted from the first published train that carries the new API, with
75
+ no version or date fixed; "Migrating to the Run-owned lifecycle" in the
76
+ README defines that window.
77
+
78
+ The client-level methods reject a look-alike object and a run from another
79
+ client, so persist the job id and reattach after a process restart instead of
80
+ rebuilding a handle by hand.
70
81
 
71
82
  `LocalNika.version()`, `dryRunPlan()`, and `test()` have no One SDK method in
72
83
  0.116. Keep those CLI-facing probes in deployment/CI (`nika --version`,
@@ -92,8 +103,12 @@ both versions.
92
103
 
93
104
  `runToEnd()` returned `{ ok, exitCode, events[] }` with every event buffered.
94
105
  `run.done` returns the terminal result only; the events ride `events(run)`
95
- as a bounded live iterator (default 256), so observe it concurrently or the
96
- prefix is gone. The removed methods (`version()`, `dryRunPlan()`, path-based
106
+ as a bounded iterator. In 0.116 a session retained 256 frames, below the 273 a
107
+ measured clean native run of 90 `mock/echo` tasks writes, so such a run had to
108
+ be observed concurrently or its prefix was gone. The default is now 4096 and a view opened
109
+ after the result replays every retained frame; past the bound it is refused
110
+ with `reason: 'replay_truncated'`, never shortened, and the result is
111
+ unaffected. An explicit `eventBufferSize` keeps its cap. The removed methods (`version()`, `dryRunPlan()`, path-based
97
112
  `traceVerify()`) are absent, not stubbed: calling them throws a plain
98
113
  `TypeError: … is not a function`, and a trace that only exists as a file
99
114
  path in a later process has no SDK verification door in 0.116.
@@ -111,7 +126,7 @@ These methods require an HTTP client. A native-process client returns a typed
111
126
  ## Durable status
112
127
 
113
128
  ```ts
114
- const run = await nika.run('flow.nika.yaml');
129
+ const run = await nika.run('flow.nika');
115
130
  console.log(await nika.status(run));
116
131
  console.log(await run.done);
117
132
  ```
package/docs/testing.md CHANGED
@@ -52,7 +52,15 @@ starts is a 200 `cancelled` whose terminal is one of those two writer kinds,
52
52
  only with `status=cancelled`. The verifiers bind each cancel reply to the
53
53
  terminals it may lead to, demand the run status of that terminal, and refuse any
54
54
  other pairing. The parsed deterministic and packed-project results must match
55
- exactly except for the recovery job UUID. The hostile comparison excludes
55
+ exactly except for the recovery job UUID and the depth `package_sha256`.
56
+ That digest is provenance of the tarball this replay packed (README lives
57
+ inside the npm pack): the verifier requires `depth-package.json` beside the
58
+ ledger, hashes the artifact, checks filename/size/sha512 integrity, refuses
59
+ a missing or substituted pack, then compares behavior without requiring the
60
+ committed baseline digest (that digest is a labelled historical ledger
61
+ identity, not a re-hash of this run). A documentation-only
62
+ README change retargets the digest and must still reproduce every behavioral
63
+ verdict. The hostile comparison excludes
56
64
  `generated_at` and per-scenario duration and canonicalizes only the two ratified
57
65
  writer kinds of a cancelled terminal after checking the exact pairing. This proves that the
58
66
  attested public release currently reproduces the committed behavioral claims.
@@ -86,12 +94,29 @@ Every release wave must ask and demonstrate an answer to these questions:
86
94
  knowledge?
87
95
  - Do ESM and CommonJS load from the packed tarball on every supported Node
88
96
  major?
97
+ - Does a workflow the engine refuses reject native `run()` with the engine's
98
+ code and findings, on every refusal dialect a supported engine writes, with
99
+ no run handle, no second spawn and no preflight check? Does output that
100
+ proves neither admission nor refusal stay a protocol fault? The answer is
101
+ `test/native-run-admission.test.ts`. Its `wire-*` cases replay stdout and
102
+ stderr captured byte-for-byte from real engines
103
+ (`test/fixtures/run-wire/README.md` records which, and how); its
104
+ `SYNTHETIC` cases are invented hostile shapes. A replay proves the SDK
105
+ decodes those bytes, never that an engine still writes them: a new engine
106
+ release needs a new capture.
89
107
  - What happens if the server dies after admission but before the first SSE
90
108
  frame?
91
109
  - What happens if SSE reconnects after a duplicate, gap, conflicting replay,
92
110
  or terminal race?
93
111
  - Can one slow observer overflow without damaging another observer or
94
- `run.done`?
112
+ `run.result()`?
113
+ - Does one application read the same lifecycle words over the native process
114
+ and over HTTP, with `event.raw` still the protocol frame, and without a
115
+ per-task event the resident never streamed?
116
+ - Does a frame whose state word is absent, null, future, or still running
117
+ stay an `engine.event` instead of being named settled?
118
+ - Is a human gate (`paused`) kept apart from both failure and completion, and
119
+ the engine's `interrupted` evidence apart from a broken observation?
95
120
  - Can two clients race the same idempotency key with equal and unequal
96
121
  snapshots?
97
122
  - Does cancellation win or replay honestly when settlement races it?