@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.
- package/README.md +649 -162
- package/dist/index.cjs +809 -144
- package/dist/index.d.cts +315 -19
- package/dist/index.d.ts +315 -19
- package/dist/index.js +808 -144
- package/docs/architecture.md +75 -12
- package/docs/http-api.md +81 -8
- package/docs/migrating-to-0.116.md +21 -6
- package/docs/testing.md +27 -2
- package/openapi.json +1797 -1
- package/package.json +9 -8
package/README.md
CHANGED
|
@@ -9,139 +9,380 @@
|
|
|
9
9
|
|
|
10
10
|
<h1 align="center">@supernovae-st/nika</h1>
|
|
11
11
|
|
|
12
|
-
<p align="center"
|
|
12
|
+
<p align="center">
|
|
13
|
+
<strong>Run AI workflows from TypeScript.</strong><br>
|
|
14
|
+
Audit a <code>.nika</code> file, run it, read the result, verify the receipt — locally
|
|
15
|
+
or against authenticated <code>nika serve</code>.
|
|
16
|
+
</p>
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
<p align="center">
|
|
19
|
+
<a href="https://www.npmjs.com/package/@supernovae-st/nika"><img src="https://img.shields.io/npm/v/@supernovae-st/nika?label=npm" alt="npm version"></a>
|
|
20
|
+
<a href="https://github.com/supernovae-st/nika-client/actions/workflows/ci.yml"><img src="https://github.com/supernovae-st/nika-client/actions/workflows/ci.yml/badge.svg?branch=main" alt="CI status"></a>
|
|
21
|
+
<a href="https://github.com/supernovae-st/nika/releases/latest"><img src="https://img.shields.io/github/v/release/supernovae-st/nika?label=engine" alt="Engine release"></a>
|
|
22
|
+
<a href="https://docs.nika.sh"><img src="https://img.shields.io/badge/docs-docs.nika.sh-8b8cf8.svg" alt="Documentation"></a>
|
|
23
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-Apache--2.0-blue.svg" alt="Apache-2.0"></a>
|
|
24
|
+
</p>
|
|
20
25
|
|
|
21
|
-
|
|
26
|
+
<p align="center">
|
|
27
|
+
<a href="https://scorecard.dev/viewer/?uri=github.com/supernovae-st/nika-client"><img src="https://api.scorecard.dev/projects/github.com/supernovae-st/nika-client/badge" alt="OpenSSF Scorecard"></a>
|
|
28
|
+
<a href="https://www.npmjs.com/package/@supernovae-st/nika"><img src="https://img.shields.io/badge/npm-provenance-2ea44f.svg" alt="Published with provenance through GitHub Actions trusted publishing"></a>
|
|
29
|
+
<a href="https://www.npmjs.com/package/@supernovae-st/nika"><img src="https://img.shields.io/npm/dm/@supernovae-st/nika?label=downloads" alt="npm downloads"></a>
|
|
30
|
+
<a href="https://archive.softwareheritage.org/browse/origin/?origin_url=https://github.com/supernovae-st/nika-client"><img src="https://archive.softwareheritage.org/badge/origin/https://github.com/supernovae-st/nika-client/" alt="Archived by Software Heritage"></a>
|
|
31
|
+
</p>
|
|
22
32
|
|
|
23
|
-
|
|
24
|
-
refused, and `npm install` does not warn about it)
|
|
25
|
-
- for native execution or local snapshot capture, a compatible `nika` engine, resolved from `config.bin`, then `NIKA_BIN`
|
|
26
|
-
(absolute paths only), then the exact optional platform package; a bare
|
|
27
|
-
name or a relative path is refused because the operating system would
|
|
28
|
-
resolve it through `PATH` or the working directory, and a `nika` found on
|
|
29
|
-
`PATH` is deliberately never used
|
|
30
|
-
- a `.nika.yaml` workflow
|
|
33
|
+
## Run a workflow from your app
|
|
31
34
|
|
|
32
|
-
|
|
35
|
+
Put repeatable AI work in a `.nika` file. From Node, run it and read the
|
|
36
|
+
result. No server and no API key for the first run: `mock/echo` is a
|
|
37
|
+
**local simulation** (output is prefixed `mock(echo) ·`, not a model answer).
|
|
33
38
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
idempotency, and schedule CAS
|
|
38
|
-
- [Testing and release evidence](docs/testing.md) — layered gauntlets and the
|
|
39
|
-
Socratic risk matrix
|
|
40
|
-
- [Migrating to 0.116](docs/migrating-to-0.116.md) — intentional breaking
|
|
41
|
-
migration to the smaller durable client surface
|
|
39
|
+
```sh
|
|
40
|
+
npm install @supernovae-st/nika
|
|
41
|
+
```
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
Save as `hello.nika`:
|
|
44
44
|
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
|
|
45
|
+
```yaml
|
|
46
|
+
nika: hello
|
|
47
|
+
model: mock/echo
|
|
48
|
+
permits: {}
|
|
49
|
+
tasks:
|
|
50
|
+
greeting:
|
|
51
|
+
infer:
|
|
52
|
+
prompt: "Say hello from the Nika SDK."
|
|
53
|
+
max_tokens: 32
|
|
54
|
+
outputs:
|
|
55
|
+
greeting: ${{ tasks.greeting.output }}
|
|
48
56
|
```
|
|
49
57
|
|
|
50
|
-
|
|
51
|
-
complete. Earlier packages expose the retired `LocalNika`/HTTP split and do
|
|
52
|
-
not implement the root facade documented below. The publication is complete
|
|
53
|
-
only when the four matching native payload packages and this root client are
|
|
54
|
-
all visible on npm.
|
|
58
|
+
Save as `demo.mjs`:
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
for the versions it already holds, and receives no further releases. The
|
|
59
|
-
native payloads were already `@supernovae-st/nika-<os>-<arch>`, and the
|
|
60
|
-
repository keeps its name (`supernovae-st/nika-client`).
|
|
60
|
+
```js
|
|
61
|
+
import { Nika, isNikaRunSucceeded } from '@supernovae-st/nika';
|
|
61
62
|
|
|
62
|
-
|
|
63
|
+
const run = await new Nika({ cwd: process.cwd() }).run('hello.nika', { maxCostUsd: 0 });
|
|
64
|
+
const result = await run.result();
|
|
65
|
+
if (!isNikaRunSucceeded(result)) {
|
|
66
|
+
console.error(result.status, result.error?.code, result.error?.message);
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
} else {
|
|
69
|
+
console.log(result.outputs);
|
|
70
|
+
// { greeting: "mock(echo) · Say hello from the Nika SDK." }
|
|
71
|
+
}
|
|
72
|
+
```
|
|
63
73
|
|
|
64
74
|
```sh
|
|
65
|
-
node
|
|
75
|
+
node demo.mjs
|
|
66
76
|
```
|
|
67
77
|
|
|
68
|
-
|
|
69
|
-
|
|
78
|
+
`run()` already admits: a red file throws `NikaOperationError` and never
|
|
79
|
+
returns a handle. The `.nika` file is the contract; the SDK does not parse
|
|
80
|
+
YAML. Pin the version you tested — see [Install](#install): the SDK and the
|
|
81
|
+
standalone engine CLI release on independent clocks.
|
|
70
82
|
|
|
71
|
-
|
|
83
|
+
`check()`, `run.events()`, and `traceVerify()` are the next steps. They stay
|
|
84
|
+
taught and tested; they are not required to see the first result.
|
|
72
85
|
|
|
73
|
-
|
|
86
|
+
### Next: audit without running (`check`)
|
|
74
87
|
|
|
75
88
|
```sh
|
|
76
|
-
./node_modules/.bin/nika
|
|
77
|
-
./node_modules/.bin/nika new 01-hello hello.nika.yaml
|
|
89
|
+
./node_modules/.bin/nika check hello.nika
|
|
78
90
|
```
|
|
79
91
|
|
|
80
|
-
`nika.yaml` is the project control plane. `hello.nika.yaml` is executable
|
|
81
|
-
workflow intent and is the file passed to `check()` and `run()`. The scaffold
|
|
82
|
-
writes the engine's own annotated `01-hello` example (its task is named
|
|
83
|
-
`greet` and its prompt asks for French); the contract this README relies on is
|
|
84
|
-
the `outputs.greeting` key and the `mock/echo` model, and the same file can be
|
|
85
|
-
written by hand with this public envelope:
|
|
86
|
-
|
|
87
|
-
```yaml
|
|
88
|
-
nika: sdk-hello
|
|
89
|
-
model: mock/echo
|
|
90
|
-
permits: {}
|
|
91
|
-
tasks:
|
|
92
|
-
greeting:
|
|
93
|
-
infer:
|
|
94
|
-
prompt: "Say hello from the Nika SDK."
|
|
95
|
-
max_tokens: 32
|
|
96
|
-
outputs:
|
|
97
|
-
greeting: ${{ tasks.greeting.output }}
|
|
98
92
|
```
|
|
93
|
+
✔ ORDER no exec: sits downstream of a net-effecting task · unauthored content never reaches a shell
|
|
94
|
+
✔ PERMITS literal + const: args fit the boundary · computed paths + symlinks are the RUN's verdict
|
|
95
|
+
✔ TRIFECTA no lethal trifecta over the declared permits: without a human gate
|
|
96
|
+
✔ JOURNEY internal · 0 sources · 0 destinations · 1 model endpoint · no secret reaches an external destination
|
|
97
|
+
✔ audited · 1 task · 1 wave · permits {} · est out ≤$0.0000 · 0 hints · risk low
|
|
98
|
+
layers · valid ✔ · access ready ✔ · capacity fit ✔ · run ready ✔
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
The same report is `await nika.check('hello.nika', { nativeStrict: true })`.
|
|
102
|
+
A red check never becomes a run.
|
|
99
103
|
|
|
100
|
-
|
|
104
|
+
### Next: watch the run (`run.events()`)
|
|
101
105
|
|
|
102
106
|
```ts
|
|
103
|
-
|
|
107
|
+
for await (const event of run.events()) {
|
|
108
|
+
console.log([event.kind, event.task, event.status].filter(Boolean).join(' '));
|
|
109
|
+
}
|
|
110
|
+
```
|
|
104
111
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
112
|
+
Expected native events for this one-task file: `run.started`,
|
|
113
|
+
`task.scheduled greeting`, `task.started greeting`,
|
|
114
|
+
`task.completed greeting`, `engine.event` (`workflow_completed` on
|
|
115
|
+
`event.raw.kind`), then `run.settled succeeded`. Six `run.events()` frames,
|
|
116
|
+
sealed or not (probed on 0.120.0). A session retains the most recent 4096
|
|
117
|
+
frames by default; see
|
|
118
|
+
[Observing a run after the fact](#observing-a-run-after-the-fact).
|
|
109
119
|
|
|
110
|
-
|
|
111
|
-
nativeStrict: true,
|
|
112
|
-
});
|
|
113
|
-
if (!report.clean) throw new Error('workflow did not pass nika check');
|
|
120
|
+
### Next: verify a seal (`traceVerify`)
|
|
114
121
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
// Native progress frames carry no status; only the terminal frame does.
|
|
119
|
-
console.log(event.kind, event.status ?? '');
|
|
120
|
-
}
|
|
121
|
-
})();
|
|
122
|
+
`traceVerify` **verifies** an existing seal. It does not create one. A keyless
|
|
123
|
+
machine still **succeeds**; the receipt is unsealed — see
|
|
124
|
+
[Admission, execution, seal](#admission-execution-seal).
|
|
122
125
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
```
|
|
126
|
+
<p align="center">
|
|
127
|
+
<img src="https://raw.githubusercontent.com/supernovae-st/nika-client/main/media/local-driver.gif" alt="The typed driver over the released binary: check the workflow, gate on the report, run it to the end under a cost ceiling, count the events" width="960">
|
|
128
|
+
</p>
|
|
127
129
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
130
|
+
*Recorded by `scripts/media/render.sh` against this package and the released
|
|
131
|
+
engine; every line on screen is the SDK's own output. The recorded driver
|
|
132
|
+
predates the Run-owned lifecycle: it still calls `nika.events(run)`, a
|
|
133
|
+
deprecated wrapper that keeps working through the
|
|
134
|
+
[compatibility window](#migrating-to-the-run-owned-lifecycle), and the
|
|
135
|
+
`run.done` alias, which is not deprecated.*
|
|
131
136
|
|
|
132
137
|
Native checks and explicit local snapshot checks preserve the engine's
|
|
133
138
|
`findings[]` and `exitCode`. A check by served name returns the resident's
|
|
134
139
|
compact acknowledgement with `clean: true`, or its typed workflow refusal
|
|
135
140
|
with `clean: false`; it does not invent local findings or an exit code.
|
|
136
141
|
|
|
137
|
-
`run()` returns after stable admission
|
|
142
|
+
`run()` returns after stable admission, and the `NikaRun` it returns owns its
|
|
143
|
+
lifecycle: `run.events()`, `run.result()`, `run.status()` and `run.cancel()`.
|
|
144
|
+
`run.result()` is the sole terminal result (`run.done` is its compatibility
|
|
145
|
+
alias, the same promise).
|
|
146
|
+
A workflow the engine refuses before admission never yields a run: `run()`
|
|
147
|
+
itself rejects with a `NikaOperationError` that names the engine's code and,
|
|
148
|
+
for a red check, carries its `findings[]` (see [Errors](#errors)). The engine
|
|
149
|
+
checks on every run, so no `check()` is needed first to be protected or taught.
|
|
150
|
+
|
|
151
|
+
### Workflow inputs
|
|
152
|
+
|
|
153
|
+
`inputs` binds the workflow's declared `inputs:` by name, and means the same
|
|
154
|
+
thing on both transports:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
const run = await nika.run('support-triage.nika', {
|
|
158
|
+
inputs: { ticketId: '42' },
|
|
159
|
+
// idempotencyKey: `triage-${ticket.id}`, // HTTP only
|
|
160
|
+
});
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Values are strict JSON and stay literal. `'42'` stays a string and `42` a
|
|
164
|
+
number; `'@env:HOME'` and `'${{ tasks.x.output }}'` are text, never read from
|
|
165
|
+
the environment or evaluated; nothing is coerced to the declared type. The
|
|
166
|
+
engine owns the verdict and refuses before any run exists: an undeclared key
|
|
167
|
+
(`unknown_input`), a value that does not fit its declared type
|
|
168
|
+
(`input_type_mismatch`), a required input left out (`NIKA-1708`). Each rejects
|
|
169
|
+
`run()` as a `NikaOperationError` with that code, native (`status: 3`) or HTTP
|
|
170
|
+
(`status: 422`). Supplied values are recorded with `api-caller` provenance;
|
|
171
|
+
declared defaults keep `file`.
|
|
172
|
+
|
|
173
|
+
The SDK refuses, before it spawns or sends anything, a value JSON would
|
|
174
|
+
silently lose: `undefined`, a function, a symbol, a bigint, `NaN` or
|
|
175
|
+
`Infinity`, a cycle, a class instance (a `Date`, a `Map`), an object whose
|
|
176
|
+
prototype only claims to be plain, an array hole, an accessor. That is a
|
|
177
|
+
`NikaConfigurationError` naming the path (`inputs.ticket.tags[1] is
|
|
178
|
+
undefined`), never the value. No caller code runs while the map is judged: a
|
|
179
|
+
getter is never invoked, and a `Proxy` is refused before it is read, so none
|
|
180
|
+
of its traps run. The serialized map is
|
|
181
|
+
bounded at 1 MiB on both transports. Do not put a secret in `inputs`.
|
|
182
|
+
|
|
183
|
+
The engine must advertise the channel, and the SDK checks before admission:
|
|
184
|
+
|
|
185
|
+
- Native: `inputsLiteral` in `nika --sdk-identity`. The map rides the engine's
|
|
186
|
+
stdin (`nika run --inputs-json -`), so a value never appears in a process
|
|
187
|
+
listing.
|
|
188
|
+
- HTTP: `jobInputs` in `GET /health`, for a workflow run by its served name.
|
|
189
|
+
An execution snapshot froze its inputs and takes no overlay, so an HTTP run
|
|
190
|
+
of a local path (`./flow.nika`) refuses `inputs`, an empty map included.
|
|
191
|
+
|
|
192
|
+
An engine without the capability rejects with `NikaCompatibilityError`
|
|
193
|
+
(`capability: 'inputsLiteral'` or `'jobInputs'`) and nothing runs. The SDK
|
|
194
|
+
never falls back to `--var`, and a resident that merely answers 202 has
|
|
195
|
+
negotiated nothing: one from before the envelope accepts the field and ignores
|
|
196
|
+
its values.
|
|
197
|
+
|
|
198
|
+
The **published 0.120.0** payload advertises `inputsLiteral` (`nika --sdk-identity`).
|
|
199
|
+
Native `run({ inputs })` works on it. It does **not** advertise `jobInputs`;
|
|
200
|
+
HTTP literal inputs stay a compatibility refusal until a resident that does.
|
|
201
|
+
|
|
202
|
+
`vars` is deprecated. It remains the native `--var KEY=VALUE` operator channel,
|
|
203
|
+
unchanged: the engine reads `@env:NAME` from its environment and coerces text
|
|
204
|
+
to the declared type, so it cannot carry literal values and has no HTTP form.
|
|
205
|
+
`inputs` and `vars` together reject `run()`; they are never merged.
|
|
138
206
|
An admitted workflow failure is result data with `status: "failed"` and, when
|
|
139
207
|
the engine named the failing task, `error: { code, message, task }`; transport,
|
|
140
208
|
protocol, configuration, and compatibility failures throw typed SDK errors.
|
|
141
|
-
A `try { await run.
|
|
209
|
+
A `try { await run.result() } catch {}` alone therefore never catches a failed
|
|
142
210
|
workflow: a CI job or an application must read `result.status` and treat
|
|
143
211
|
anything but `succeeded` as its own failure, or a red run passes silently.
|
|
144
212
|
|
|
213
|
+
`isNikaRunSucceeded` is exported by published **0.120.0**. Use it (or
|
|
214
|
+
`result.status === 'succeeded'`) before treating a result as success.
|
|
215
|
+
Paused, failed, cancelled, and interrupted all return false:
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
import { Nika, isNikaRunSucceeded } from '@supernovae-st/nika';
|
|
219
|
+
|
|
220
|
+
const run = await new Nika().run<{ answer: number }>('flow.nika');
|
|
221
|
+
const result = await run.result();
|
|
222
|
+
if (isNikaRunSucceeded(result)) {
|
|
223
|
+
console.log(result.outputs?.answer); // outputs stay typed and optional
|
|
224
|
+
} else {
|
|
225
|
+
console.error(result.status, result.error?.code, result.error?.message);
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
}
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
A paused result is a human gate, not a successful completion and not a
|
|
231
|
+
failure. It arrives as a `run.waiting` event, never as `run.settled`, and
|
|
232
|
+
`result.status` keeps the engine's word, `paused`. Applications can render
|
|
233
|
+
that state separately; a CI job awaiting completion must not pass it as
|
|
234
|
+
success. The guard reads the engine's status and never turns absent outputs
|
|
235
|
+
into a fabricated output map.
|
|
236
|
+
|
|
237
|
+
## Why this door
|
|
238
|
+
|
|
239
|
+
- **Audited before it runs.** `check()` returns the engine's verdict on the
|
|
240
|
+
order of effects, the permits, the lethal trifecta, the journey of every
|
|
241
|
+
secret and the cost floor. A red check never becomes a run.
|
|
242
|
+
- **Sovereign by default.** The same file runs on local models (Ollama,
|
|
243
|
+
llama.cpp, vLLM), on Mistral, Hugging Face, OpenAI, xAI, Anthropic and the
|
|
244
|
+
rest of the engine's catalog; `mock/echo` is a local simulation with no key
|
|
245
|
+
and no network.
|
|
246
|
+
- **Traced after.** A native run writes a hash-chained journal and a receipt.
|
|
247
|
+
`traceVerify()` asks the engine to verify that evidence. A succeeded run can
|
|
248
|
+
still be unsealed. The SDK never re-implements the proof.
|
|
249
|
+
- **One vocabulary, two transports.** `check`, `run`, `events`, `cancel`,
|
|
250
|
+
`traceVerify` and `schedule` read the same against a local process and an
|
|
251
|
+
authenticated `nika serve`; only the constructor changes. Run events carry
|
|
252
|
+
the same lifecycle words on both (`run.started`, `run.waiting`,
|
|
253
|
+
`run.settled`), with the engine's own frame kept on `event.raw`.
|
|
254
|
+
|
|
255
|
+
## Admission, execution, seal
|
|
256
|
+
|
|
257
|
+
Three different facts. Do not collapse them.
|
|
258
|
+
|
|
259
|
+
| Fact | How you see it | What it is not |
|
|
260
|
+
|---|---|---|
|
|
261
|
+
| **Admission** | `check()` returns `clean: false` and findings. `run()` **throws** `NikaOperationError` (`NIKA-PARSE-005`, `NIKA-1708`, …) and yields no handle. | Not a successful execution. |
|
|
262
|
+
| **Execution** | `run.result()` / `isNikaRunSucceeded(result)`. Admitted failure is **data** (`status: "failed"`). A human gate is `paused` (`run.waiting`), not success. | Not proof the journal is sealed. |
|
|
263
|
+
| **Seal** | `result.receipt.sealed` and `traceVerify(receipt)`. Tamper-evident, not tamper-proof; not replayed unless you pass `--replay`; not anchored without a sidecar. | Not “the workflow was correct” and not “a human read the output”. |
|
|
264
|
+
|
|
265
|
+
Probed on published 0.120.0 (2026-09-19): `run.events()` still yields the
|
|
266
|
+
same six lifecycle frames with or without a signing key. With
|
|
267
|
+
`~/.nika/keys/run-signing.*`, the hello fixture sealed and `traceVerify`
|
|
268
|
+
returned `{ verified: true, verdict: "verified" }`. With an isolated `HOME`
|
|
269
|
+
and no key files (keychain skipped: stderr not a TTY), the same file
|
|
270
|
+
**succeeded**, `sealed: false`, and `traceVerify` returned
|
|
271
|
+
`{ verified: false, verdict: "invalid", reason: "receipt_mismatch" }` while
|
|
272
|
+
the engine still said the journal chain was OK and **UNSEALED**. In this
|
|
273
|
+
keyless case that reason means no signed binding, not a failed workflow.
|
|
274
|
+
`receipt_mismatch` is also the engine's word for a tampered or
|
|
275
|
+
field-mismatched receipt — do not treat every occurrence as merely unsigned.
|
|
276
|
+
The CLI verify line that counts journal events is not `run.events()`.
|
|
277
|
+
|
|
278
|
+
## One vocabulary
|
|
279
|
+
|
|
280
|
+
`Nika` exposes `check`, `run`, `attachRun`, `traceVerify`, `listWorkflows`,
|
|
281
|
+
`workflow`, `schedule`, and `scheduleStatus`. The `NikaRun` it returns owns the
|
|
282
|
+
run's lifecycle:
|
|
283
|
+
|
|
284
|
+
```
|
|
285
|
+
NikaRun
|
|
286
|
+
├── id
|
|
287
|
+
├── events() one lifecycle vocabulary · event.raw keeps the protocol frame
|
|
288
|
+
├── result() the one settlement · admitted failure is data, never a throw
|
|
289
|
+
├── status() durable over HTTP · a typed refusal on a native process
|
|
290
|
+
└── cancel() idempotent
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
`nika.events(run)`, `nika.cancel(run)` and `nika.status(run)` remain as
|
|
294
|
+
deprecated wrappers for one release train, counted from the first published
|
|
295
|
+
train that carries this API; see
|
|
296
|
+
[Migrating to the Run-owned lifecycle](#migrating-to-the-run-owned-lifecycle).
|
|
297
|
+
The handle owns observation, settlement, `status` and cancellation and nothing
|
|
298
|
+
else: checking, proof, catalogs and authoring stay on `Nika`.
|
|
299
|
+
|
|
300
|
+
The engine remains authoritative for parsing, admission, execution, receipts,
|
|
301
|
+
traces, permits, scheduling, and cost. The SDK transports those facts; it does
|
|
302
|
+
not parse YAML or reconstruct proof in TypeScript.
|
|
303
|
+
|
|
304
|
+
## Requirements
|
|
305
|
+
|
|
306
|
+
- Node.js 22 or newer (the tested floor; an older major is unsupported, not
|
|
307
|
+
refused, and `npm install` does not warn about it)
|
|
308
|
+
- for native execution or local snapshot capture, a compatible `nika` engine, resolved from `config.bin`, then `NIKA_BIN`
|
|
309
|
+
(absolute paths only), then the exact optional platform package; a bare
|
|
310
|
+
name or a relative path is refused because the operating system would
|
|
311
|
+
resolve it through `PATH` or the working directory, and a `nika` found on
|
|
312
|
+
`PATH` is deliberately never used
|
|
313
|
+
- a `.nika` workflow
|
|
314
|
+
|
|
315
|
+
## Documentation
|
|
316
|
+
|
|
317
|
+
- [Architecture](docs/architecture.md) · Modules, Interface, Seam, Adapters,
|
|
318
|
+
lifecycle, and authority boundaries
|
|
319
|
+
- [HTTP contract](docs/http-api.md) · every live route, recovery, security,
|
|
320
|
+
idempotency, and schedule CAS
|
|
321
|
+
- [Testing and release evidence](docs/testing.md) · layered gauntlets and the
|
|
322
|
+
Socratic risk matrix
|
|
323
|
+
- [Migrating to 0.116](docs/migrating-to-0.116.md) · the intentional breaking
|
|
324
|
+
migration to the smaller durable client surface
|
|
325
|
+
- [docs.nika.sh](https://docs.nika.sh) · language and engine
|
|
326
|
+
- [SDK quickstart](https://docs.nika.sh/sdk/start/quickstart) · app walkthrough
|
|
327
|
+
(install **npm `@supernovae-st/nika`**, matching this README)
|
|
328
|
+
|
|
329
|
+
## Install
|
|
330
|
+
|
|
331
|
+
```sh
|
|
332
|
+
npm install @supernovae-st/nika
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Every SDK package bundles its own matching engine: the `nika` binary under
|
|
336
|
+
`node_modules/.bin` is the exact engine that package was qualified against.
|
|
337
|
+
The standalone engine CLI (GitHub releases, brew, install script) releases on
|
|
338
|
+
an independent clock, so its newest tag can be ahead of or behind the engine
|
|
339
|
+
bundled in the latest npm package. The
|
|
340
|
+
[npm registry](https://www.npmjs.com/package/@supernovae-st/nika?activeTab=versions)
|
|
341
|
+
lists current published versions. Pin the version you tested, then verify the
|
|
342
|
+
package the project actually resolved:
|
|
343
|
+
|
|
344
|
+
```sh
|
|
345
|
+
node -p "require('@supernovae-st/nika/package.json').version"
|
|
346
|
+
./node_modules/.bin/nika --version
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
This repository's source train is **0.120.2** (`package.json`), lockstep with
|
|
350
|
+
public engine tag `v0.120.2` (`289a9adea`). A source train is not a published
|
|
351
|
+
npm version until the release workflow publishes it.
|
|
352
|
+
|
|
353
|
+
This package metadata subpath is exported for CommonJS, ESM build tools and CI
|
|
354
|
+
pin checks. It reports the installed dependency, not a moving registry tag.
|
|
355
|
+
The native payloads `@supernovae-st/nika-<os>-<arch>` are optional
|
|
356
|
+
dependencies; npm installs the one that matches your platform.
|
|
357
|
+
|
|
358
|
+
Earlier packages expose the retired `LocalNika`/HTTP split and do not
|
|
359
|
+
implement the root facade documented here. This package carries the product's
|
|
360
|
+
name: up to 0.115.0 it was published as `@supernovae-st/nika-client`. That
|
|
361
|
+
name receives no further releases from this repository and stays installable
|
|
362
|
+
for the versions it already holds. It is **not** marked deprecated on the npm
|
|
363
|
+
registry: its published versions carry no `deprecated` field, so `npm install
|
|
364
|
+
@supernovae-st/nika-client` still succeeds without a warning and installs the
|
|
365
|
+
retired 0.115.0 API. The move to the new name says nothing about the
|
|
366
|
+
registry; check it yourself with `npm view @supernovae-st/nika-client
|
|
367
|
+
deprecated`. The repository keeps its name (`supernovae-st/nika-client`).
|
|
368
|
+
|
|
369
|
+
## Scaffold with the engine
|
|
370
|
+
|
|
371
|
+
On the published 0.120.0 binary, `nika new` is **retired** (`unrecognized
|
|
372
|
+
subcommand`). The creation door is `compile`. Exact skeleton name or `hello`;
|
|
373
|
+
free-text intent stays incomplete and writes nothing:
|
|
374
|
+
|
|
375
|
+
```sh
|
|
376
|
+
./node_modules/.bin/nika init --project-file
|
|
377
|
+
./node_modules/.bin/nika compile hello hello.nika
|
|
378
|
+
```
|
|
379
|
+
|
|
380
|
+
No destination means preview only. `nika.yaml` is the project control plane
|
|
381
|
+
(needed for `nika serve`). `hello.nika` is the executable contract passed to
|
|
382
|
+
`check()` and `run()`. The engine's `hello` skeleton names its task `greet`
|
|
383
|
+
and asks for French; the hand-written file above is enough if it keeps
|
|
384
|
+
`outputs.greeting` and `model: mock/echo`.
|
|
385
|
+
|
|
145
386
|
## Verify a local trace
|
|
146
387
|
|
|
147
388
|
Local terminal results carry an engine-issued receipt when tracing is enabled.
|
|
@@ -150,7 +391,12 @@ Pass that receipt back unchanged:
|
|
|
150
391
|
```ts
|
|
151
392
|
if (!result.receipt) throw new Error('run did not issue a receipt');
|
|
152
393
|
const proof = await nika.traceVerify(result.receipt);
|
|
153
|
-
|
|
394
|
+
// proof.verified is the seal/binding, not the workflow outcome.
|
|
395
|
+
if (isNikaRunSucceeded(result) && proof.verified) {
|
|
396
|
+
// run succeeded and the journal is sealed
|
|
397
|
+
} else if (isNikaRunSucceeded(result) && !proof.verified) {
|
|
398
|
+
// succeeded, unsigned or otherwise unverified — read proof.reason
|
|
399
|
+
}
|
|
154
400
|
```
|
|
155
401
|
|
|
156
402
|
The SDK does not implement cryptography or inspect the trace itself. It asks the
|
|
@@ -181,33 +427,35 @@ into SDK configuration, source control, workflow inputs, or an HTTP request.
|
|
|
181
427
|
## Cancel a run
|
|
182
428
|
|
|
183
429
|
```ts
|
|
184
|
-
const run = await nika.run('slow.nika
|
|
185
|
-
const cancellation = await
|
|
186
|
-
const result = await run.
|
|
430
|
+
const run = await nika.run('slow.nika');
|
|
431
|
+
const cancellation = await run.cancel();
|
|
432
|
+
const result = await run.result();
|
|
187
433
|
|
|
188
434
|
console.log(cancellation.accepted, result.status);
|
|
189
435
|
```
|
|
190
436
|
|
|
191
|
-
Cancellation is idempotent per `NikaRun
|
|
192
|
-
|
|
193
|
-
in for `cancel(
|
|
437
|
+
Cancellation is idempotent per `NikaRun`: every `run.cancel()` returns the one
|
|
438
|
+
request. An `AbortSignal` passed to `check`, `events`, or `traceVerify` only
|
|
439
|
+
stops that request or observer; it never stands in for `run.cancel()`.
|
|
194
440
|
|
|
195
441
|
Over HTTP a running job answers the request with 202: `cancellation` reads
|
|
196
|
-
`{ accepted: true, status: 'cancellation_requested' }` and `run.
|
|
197
|
-
on the terminal the resident records, `cancelled`, `succeeded`,
|
|
198
|
-
`interrupted` once its grace expired. A job that already ended
|
|
199
|
-
result with `accepted: false` and `status: 'already_settled'`. The
|
|
200
|
-
transport signals its process the same way
|
|
442
|
+
`{ accepted: true, status: 'cancellation_requested' }` and `run.result()`
|
|
443
|
+
settles on the terminal the resident records, `cancelled`, `succeeded`,
|
|
444
|
+
`failed`, or `interrupted` once its grace expired. A job that already ended
|
|
445
|
+
replays its result with `accepted: false` and `status: 'already_settled'`. The
|
|
446
|
+
native transport signals its process the same way; the result is whatever the
|
|
447
|
+
engine then wrote, `cancelled` when it settled the request, or `interrupted`
|
|
448
|
+
when the process ended with no settlement frame.
|
|
201
449
|
|
|
202
450
|
## Connect to `nika serve`
|
|
203
451
|
|
|
204
|
-
A contained workflow name such as `hello.nika
|
|
205
|
-
`daily/report.nika
|
|
452
|
+
A contained workflow name such as `hello.nika` or
|
|
453
|
+
`daily/report.nika` is resolved by the resident registry. `check()` and
|
|
206
454
|
`run()` send that name without a local engine or a local workflow file.
|
|
207
455
|
Use `listWorkflows()` to discover the served names.
|
|
208
456
|
|
|
209
457
|
To capture your local file instead, pass an explicit path such as
|
|
210
|
-
`./hello.nika
|
|
458
|
+
`./hello.nika`. The compatible local engine captures an immutable
|
|
211
459
|
snapshot, and the SDK sends its exact bytes and verifies the acknowledgement.
|
|
212
460
|
Only this path needs `bin`, `NIKA_BIN`, or the exact optional native package.
|
|
213
461
|
Observation and scheduling also use the server identity alone.
|
|
@@ -250,16 +498,27 @@ const nika = new Nika({
|
|
|
250
498
|
// bin: '/absolute/path/to/nika',
|
|
251
499
|
});
|
|
252
500
|
|
|
253
|
-
const report = await nika.check('hello.nika
|
|
254
|
-
const run = await nika.run('hello.nika
|
|
255
|
-
idempotencyKey: 'hello-2026-08-30',
|
|
501
|
+
const report = await nika.check('hello.nika');
|
|
502
|
+
const run = await nika.run('hello.nika', {
|
|
503
|
+
idempotencyKey: 'hello-2026-08-30', // persist before admission; reuse on retry
|
|
256
504
|
});
|
|
257
|
-
for await (const event of
|
|
505
|
+
for await (const event of run.events()) {
|
|
258
506
|
console.log(event.sequence, event.kind, event.status);
|
|
259
507
|
}
|
|
260
|
-
console.log(await run.
|
|
508
|
+
console.log(await run.result());
|
|
261
509
|
```
|
|
262
510
|
|
|
511
|
+
The application code after `new Nika(...)` is the same as the local one: the
|
|
512
|
+
events read `run.started` then `run.settled`, in the same words. The resident
|
|
513
|
+
streams no per-task frame today, so an HTTP run yields no `task.*` event; the
|
|
514
|
+
SDK never invents one. `event.sequence` is the resident's replay cursor and
|
|
515
|
+
exists only over HTTP.
|
|
516
|
+
|
|
517
|
+
HTTP `run()` requires a caller-owned `idempotencyKey` before it sends a request.
|
|
518
|
+
Persist a unique key for each business operation. If the response is lost or times
|
|
519
|
+
out, retry the same request with that key; a new key can admit a second job.
|
|
520
|
+
Direct native runs omit the key and reject one if supplied.
|
|
521
|
+
|
|
263
522
|
If the Node process restarts after admission, recover the durable job without
|
|
264
523
|
submitting the workflow again:
|
|
265
524
|
|
|
@@ -267,22 +526,30 @@ submitting the workflow again:
|
|
|
267
526
|
const recovered = await nika.attachRun(saved.jobId, {
|
|
268
527
|
lastEventId: saved.lastEventSequence,
|
|
269
528
|
});
|
|
270
|
-
for await (const event of
|
|
529
|
+
for await (const event of recovered.events()) {
|
|
271
530
|
await saveApplicationCheckpoint(recovered.id, event.sequence);
|
|
272
531
|
}
|
|
273
|
-
console.log(await recovered.
|
|
532
|
+
console.log(await recovered.result());
|
|
274
533
|
```
|
|
275
534
|
|
|
276
|
-
|
|
535
|
+
`attachRun` is the one recovery door, and it returns a full `NikaRun`. A run
|
|
536
|
+
handle is process-bound and is not serialized: persist the **job id** and the
|
|
537
|
+
last committed `event.sequence` in application state. That id is durable only
|
|
538
|
+
over HTTP, where `run.id` is the resident's job id. A native `run.id` is an
|
|
539
|
+
ephemeral correlation id of the SDK process: it appears in no journal, cannot
|
|
540
|
+
be recovered after the process ends, and `attachRun` refuses it. The
|
|
277
541
|
idempotency namespace spans the server's entire `state-root` and currently has
|
|
278
542
|
no TTL; use globally unique business keys and do not recycle them between
|
|
279
543
|
workflows.
|
|
280
544
|
|
|
281
545
|
When observation loses connectivity past its retry budget, the SDK performs
|
|
282
|
-
one final durable read before giving up: a terminal record settles
|
|
283
|
-
from the workflow's truth, and a still-running record rejects
|
|
284
|
-
`NikaObservationInterrupted`, whose `lastSequence` feeds
|
|
285
|
-
`attachRun(id, { lastEventId })` to resume.
|
|
546
|
+
one final durable read before giving up: a terminal record settles
|
|
547
|
+
`run.result()` from the workflow's truth, and a still-running record rejects
|
|
548
|
+
with `NikaObservationInterrupted`, whose `lastSequence` feeds
|
|
549
|
+
`attachRun(id, { lastEventId })` to resume. That error is about this client's
|
|
550
|
+
view, not about the run, which may still be running. It is not the engine's
|
|
551
|
+
own `interrupted` state: a resident that lost an execution says so with a
|
|
552
|
+
`run.interrupted` event and `result.status === 'interrupted'`, as data.
|
|
286
553
|
|
|
287
554
|
Plain HTTP is accepted only for a loopback host (`localhost`, `127.0.0.0/8`,
|
|
288
555
|
`[::1]`), and only when `allowInsecureHttp: true` is explicit. Every other host
|
|
@@ -291,8 +558,10 @@ bearer token never leaves the machine in plaintext. A URL may not contain
|
|
|
291
558
|
credentials, a query, or a fragment, and a 32–512 byte visible-ASCII token is
|
|
292
559
|
mandatory.
|
|
293
560
|
|
|
294
|
-
|
|
295
|
-
|
|
561
|
+
A remote run by served name carries per-call `inputs` once the resident
|
|
562
|
+
advertises `jobInputs` (see [Workflow inputs](#workflow-inputs)); a snapshot
|
|
563
|
+
takes none. There is no request envelope for `model`, and none for the
|
|
564
|
+
deprecated `vars`: declare a model in the workflow. There is no per-run spend
|
|
296
565
|
bound over HTTP at all today: `maxCostUsd` is refused, the workflow language
|
|
297
566
|
has no budget field, and the resident applies its own server-wide default
|
|
298
567
|
ceiling. Bound a remote run by its model and `max_tokens` until the request
|
|
@@ -307,7 +576,7 @@ client refuses `schedule` and `scheduleStatus` because a short-lived process
|
|
|
307
576
|
cannot honestly own durable schedule state.
|
|
308
577
|
|
|
309
578
|
```ts
|
|
310
|
-
const applied = await nika.schedule('hello.nika
|
|
579
|
+
const applied = await nika.schedule('hello.nika', {
|
|
311
580
|
id: 'weekday-hello',
|
|
312
581
|
when: { kind: 'cadence', expression: 'TZ=Europe/Paris 0 9 * * 1-5' },
|
|
313
582
|
maxCostUsd: 0.01,
|
|
@@ -319,7 +588,7 @@ const applied = await nika.schedule('hello.nika.yaml', {
|
|
|
319
588
|
const status = await nika.scheduleStatus('weekday-hello');
|
|
320
589
|
console.log(applied.changed, status.next, status.lastDecision);
|
|
321
590
|
|
|
322
|
-
await nika.schedule('hello.nika
|
|
591
|
+
await nika.schedule('hello.nika', {
|
|
323
592
|
id: 'weekday-hello',
|
|
324
593
|
when: { kind: 'cadence', expression: 'TZ=Europe/Paris 0 9 * * 1-5' },
|
|
325
594
|
maxCostUsd: 0.01,
|
|
@@ -354,18 +623,23 @@ it; a scheduled budget is always a real number.
|
|
|
354
623
|
| Operation | Native process | HTTP |
|
|
355
624
|
|---|---|---|
|
|
356
625
|
| `check` | yes; `model` and `nativeStrict` allowed | yes; those two overrides refused |
|
|
357
|
-
| `run` | yes; `
|
|
358
|
-
| `
|
|
359
|
-
| `
|
|
360
|
-
| `
|
|
361
|
-
| `
|
|
626
|
+
| `run` | yes; `model`, `maxCostUsd` allowed | yes; `idempotencyKey` required; `model`, `maxCostUsd` refused |
|
|
627
|
+
| `run` `inputs` | literal JSON over stdin; engine must advertise `inputsLiteral` | literal JSON by served name; resident must advertise `jobInputs`; a snapshot refuses them |
|
|
628
|
+
| `run` `vars` (deprecated) | the `--var` operator channel, unchanged | typed refusal |
|
|
629
|
+
| `attachRun` | typed refusal: a native run is process-bound | reattach to a durable job with an optional SSE cursor |
|
|
630
|
+
| `run.status()` | typed refusal; await `run.result()` | durable status projection |
|
|
631
|
+
| `run.events()` | lifecycle words over the engine's task and run frames | the same lifecycle words over sequenced SSE frames with bounded replay; no per-task frame |
|
|
632
|
+
| `run.cancel()` | signal-backed, idempotent | 200 settles the job; 202 accepts the request and `run.result()` settles on the resident's terminal |
|
|
633
|
+
| `run.id` | ephemeral correlation id; never durable | the resident's durable job id; the one to persist |
|
|
362
634
|
| `traceVerify` | engine verification + signed receipt binding | typed verdict: `unavailable` until remote journal authority exists, then the CLI's tiers |
|
|
363
635
|
| `schedule` / `scheduleStatus` | typed refusal | resident schedule authority |
|
|
364
636
|
| `listWorkflows` / `workflow` | typed refusal | contained path-free workflow catalog |
|
|
365
637
|
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
638
|
+
The lifecycle vocabulary is one; the cardinality is not. Native execution
|
|
639
|
+
exposes detailed task frames, HTTP exposes durable sequenced execution frames,
|
|
640
|
+
and the SDK names only the facts a transport actually emitted. Consumers must
|
|
641
|
+
not assume identical cardinality across transports. The protocol vocabulary
|
|
642
|
+
under `event.raw` stays deliberately open.
|
|
369
643
|
|
|
370
644
|
## API
|
|
371
645
|
|
|
@@ -375,7 +649,9 @@ Shared options:
|
|
|
375
649
|
|
|
376
650
|
- `cwd`: engine working directory and snapshot root
|
|
377
651
|
- `bin`: explicit engine path
|
|
378
|
-
- `eventBufferSize`:
|
|
652
|
+
- `eventBufferSize`: how many of a run's most recent frames a session retains
|
|
653
|
+
for a view opened after the fact, and the largest `bufferSize` a view may
|
|
654
|
+
ask for; default 4096. See [Observing a run after the fact](#observing-a-run-after-the-fact)
|
|
379
655
|
- `machineBufferBytes`: machine frame/diagnostic ceiling, default 64 KiB
|
|
380
656
|
|
|
381
657
|
Remote-only options:
|
|
@@ -390,21 +666,198 @@ Remote-only options:
|
|
|
390
666
|
| Method | Result |
|
|
391
667
|
|---|---|
|
|
392
668
|
| `check(workflow, options?)` | `clean` plus the native check report or resident acknowledgement/refusal |
|
|
393
|
-
| `run(workflow, options?)` | admitted `NikaRun
|
|
394
|
-
| `attachRun(id, options?)` | reattached durable HTTP `NikaRun
|
|
395
|
-
| `status(run)` | current durable HTTP status |
|
|
396
|
-
| `events(run, options?)` | bounded `AsyncIterable<NikaEvent>` |
|
|
397
|
-
| `cancel(run)` | `NikaCancelResult` |
|
|
669
|
+
| `run(workflow, options?)` | admitted `NikaRun`; rejects without one when the engine refuses |
|
|
670
|
+
| `attachRun(id, options?)` | reattached durable HTTP `NikaRun`: the one recovery door |
|
|
398
671
|
| `traceVerify(receipt, options?)` | `NikaTraceVerifyResult` |
|
|
399
672
|
| `schedule(workflow, options)` | durable apply acknowledgement |
|
|
400
673
|
| `scheduleStatus(id)` | fresh engine schedule projection |
|
|
401
674
|
| `listWorkflows()` | contained resident workflow names |
|
|
402
675
|
| `workflow(name)` | path-free resident workflow metadata |
|
|
403
676
|
|
|
404
|
-
###
|
|
677
|
+
### `NikaRun`
|
|
678
|
+
|
|
679
|
+
| Member | Result |
|
|
680
|
+
|---|---|
|
|
681
|
+
| `run.id` | `NikaRunId`: the durable job id over HTTP, an ephemeral correlation id natively |
|
|
682
|
+
| `run.events(options?)` | bounded `AsyncIterable<NikaRunEvent>` in the lifecycle vocabulary |
|
|
683
|
+
| `run.result()` | `Promise<NikaRunResult>`, settled once; an admitted failure resolves |
|
|
684
|
+
| `run.status()` | current durable HTTP status; a typed refusal natively |
|
|
685
|
+
| `run.cancel()` | `NikaCancelResult`; idempotent |
|
|
686
|
+
| `run.done` | compatibility alias of `run.result()`: the same promise |
|
|
687
|
+
|
|
688
|
+
Every member is bound to its run, so it can be extracted:
|
|
689
|
+
`const { events, result } = run`. The handle is process-bound; it carries no
|
|
690
|
+
`list`, `search`, proof, catalog or authoring door.
|
|
691
|
+
|
|
692
|
+
### Observing a run after the fact
|
|
693
|
+
|
|
694
|
+
```ts
|
|
695
|
+
const run = await nika.run('wide.nika');
|
|
696
|
+
const result = await run.result(); // first the result,
|
|
697
|
+
for await (const event of run.events()) {} // then every frame the session saw
|
|
698
|
+
```
|
|
699
|
+
|
|
700
|
+
A view opened late is seeded with every frame the session observed, or it is
|
|
701
|
+
refused. It is never handed a shortened replay. How many frames a session
|
|
702
|
+
retains is `eventBufferSize`, **4096 by default**.
|
|
703
|
+
|
|
704
|
+
**The measured frame count: `3N + 3`, for one sealed shape.** On the released
|
|
705
|
+
0.118.7 engine (and still the native 0.120.0 hello fixture when sealed), a
|
|
706
|
+
clean run of N independent `mock/echo` `infer` tasks wrote
|
|
707
|
+
`workflow_started`, then `task_scheduled`, `task_started` and `task_completed`
|
|
708
|
+
per task, then `workflow_completed` and `run_settled`. Two points were
|
|
709
|
+
measured: 1 task is 6 frames, 90 tasks are 273. That is this fixture, not a
|
|
710
|
+
law of N-task workflows. Other shapes write more: a `nika:wait` task and a
|
|
711
|
+
`nika:assert` task each showed one extra `permit_checked` frame, a failed task
|
|
712
|
+
writes `task_failed`, and retries, agents and `for_each` were not measured.
|
|
713
|
+
Count your own run rather than deriving it: `error.observed` below is the
|
|
714
|
+
number. A `nika serve` job was measured at two frames, so the bound matters on
|
|
715
|
+
a native process.
|
|
716
|
+
|
|
717
|
+
| `eventBufferSize` | frames retained | in the measured shape only |
|
|
718
|
+
|---|---|---|
|
|
719
|
+
| 256, the default up to 0.118.7 | 256 | below the 273 frames of the 90-task run |
|
|
720
|
+
| 4096, the default | 4096 | 15 times those 273 frames |
|
|
721
|
+
|
|
722
|
+
**What the bound costs.** It is finite on purpose and is never `Infinity`.
|
|
723
|
+
Every frame is bounded by `machineBufferBytes` (64 KiB), so the retained
|
|
724
|
+
**history** holds at most `eventBufferSize × machineBufferBytes` of frame text:
|
|
725
|
+
4096 × 64 KiB = 256 MiB per run at both defaults, where 256 frames gave
|
|
726
|
+
16 MiB. That is arithmetic, not a measurement: the 273 measured frames total
|
|
727
|
+
0.15 MiB (mean 571 bytes, largest 1501). It bounds the history only, not the
|
|
728
|
+
session or the process: a frame already handed to your code lives as long as
|
|
729
|
+
you keep it, and every open view and every concurrent run adds its own. Nothing
|
|
730
|
+
here is a claim about heap or process memory. If that ceiling matters to you,
|
|
731
|
+
set `eventBufferSize` yourself: an explicit value is kept exactly as given, so
|
|
732
|
+
`eventBufferSize: 256` behaves as it always did.
|
|
733
|
+
|
|
734
|
+
**Past the bound.** A run that writes more frames than the bound still runs and
|
|
735
|
+
still succeeds: `run.result()` resolves as usual and is never affected. Only a
|
|
736
|
+
view is refused, with `NikaEventBufferOverflowError`, and its `reason` says
|
|
737
|
+
which of two different bounds was exceeded:
|
|
738
|
+
|
|
739
|
+
| `error.reason` | What happened | What to do |
|
|
740
|
+
|---|---|---|
|
|
741
|
+
| `replay_truncated` | the view was opened after the run had produced more frames (`error.observed`) than it can be given (`error.limit`); `error.retained` is how many the session still holds | when `retained === observed` nothing is lost: open the view with `bufferSize >= observed`. Otherwise the earlier frames are gone from this process: set `eventBufferSize >= observed` for the next run, or observe it live |
|
|
742
|
+
| `live_backpressure` | a view that was observing live fell more than `error.limit` frames behind the stream | read faster, or raise that view's `bufferSize`. Other views and the result are unaffected |
|
|
743
|
+
|
|
744
|
+
```ts
|
|
745
|
+
try {
|
|
746
|
+
for await (const event of run.events()) render(event);
|
|
747
|
+
} catch (error) {
|
|
748
|
+
if (error instanceof NikaEventBufferOverflowError && error.reason === 'replay_truncated') {
|
|
749
|
+
// The run is fine. Its history is longer than this view can replay.
|
|
750
|
+
console.warn(`${error.observed} frames, ${error.retained} retained; run ${result.status}`);
|
|
751
|
+
} else throw error;
|
|
752
|
+
}
|
|
753
|
+
```
|
|
754
|
+
|
|
755
|
+
Neither refusal skips a frame, and neither is about the run. `error.observed`
|
|
756
|
+
is a count taken when the view was opened. The engine's journal stays the
|
|
757
|
+
source of truth for a native trace (`receipt.trace_path`, `traceVerify`): a
|
|
758
|
+
late `run.events()` is a convenience over what this process already saw, and
|
|
759
|
+
the SDK reads no journal to extend it. Over HTTP the resident holds the job,
|
|
760
|
+
so recover there with `attachRun(id, { lastEventId })`.
|
|
761
|
+
|
|
762
|
+
### The lifecycle vocabulary
|
|
763
|
+
|
|
764
|
+
`run.events()` yields `NikaRunEvent`: a lifecycle `kind` that is the same on
|
|
765
|
+
both transports, plus the exact protocol frame on `raw`.
|
|
405
766
|
|
|
406
|
-
`
|
|
407
|
-
|
|
767
|
+
| `event.kind` | Native frame (`event.raw.kind`) | HTTP frame (`event.raw.kind`) |
|
|
768
|
+
|---|---|---|
|
|
769
|
+
| `run.started` | `workflow_started` | `execution.started` |
|
|
770
|
+
| `task.scheduled` · `task.started` · `task.completed` · `task.failed` | `task_scheduled` · `task_started` · `task_completed` · `task_failed` | none: the resident streams no per-task frame |
|
|
771
|
+
| `run.waiting` | `run_settled` carrying `paused` | `execution.settled` carrying `paused` |
|
|
772
|
+
| `run.settled` | `run_settled` carrying `succeeded` · `failed` · `cancelled` | `execution.settled` carrying `succeeded` · `failed` · `cancelled`; `execution.cancelled` carrying `cancelled` only; `execution.refused` carrying `failed` only |
|
|
773
|
+
| `run.interrupted` | `workflow_interrupted` carrying `interrupted` | `execution.interrupted` · `interrupted` carrying `interrupted` |
|
|
774
|
+
| `run.sealed` | `run_sealed` | none |
|
|
775
|
+
| `engine.event` | every other frame (`workflow_completed`, `workflow_paused`, `permit_checked`, …) | every other frame, a `null` or future kind included |
|
|
776
|
+
|
|
777
|
+
The SDK names a fact only when the engine wrote it. A frame that speaks of the
|
|
778
|
+
run's state earns its name only for a (kind, status) pair a producer defines;
|
|
779
|
+
the pairs are the ones listed above and nothing is computed from them. The
|
|
780
|
+
engine's state word decides and is never defaulted, so an absent, null, future
|
|
781
|
+
or still-running status stays an `engine.event`, and so does a terminal word
|
|
782
|
+
on the wrong dedicated kind: `execution.refused` carrying `succeeded` or
|
|
783
|
+
`cancelled`, or `execution.cancelled` carrying `succeeded` or `failed`,
|
|
784
|
+
contradicts itself and is never called settled. `event.raw` still holds that
|
|
785
|
+
frame, and `run.result()` still reads the state word the engine wrote. The
|
|
786
|
+
projection keeps no state between frames, deduplicates nothing, and never
|
|
787
|
+
synthesizes an event, so transports differ in cardinality but never in names.
|
|
788
|
+
|
|
789
|
+
```ts
|
|
790
|
+
for await (const event of run.events()) {
|
|
791
|
+
switch (event.kind) {
|
|
792
|
+
case 'run.started': break;
|
|
793
|
+
case 'task.completed': console.log('done:', event.task); break;
|
|
794
|
+
case 'task.failed': console.error(event.task, event.error?.code); break;
|
|
795
|
+
case 'run.waiting': console.log('a human gate holds the run'); break;
|
|
796
|
+
case 'run.settled': console.log('ended:', event.status); break;
|
|
797
|
+
case 'run.interrupted': console.warn('the engine lost this execution'); break;
|
|
798
|
+
default: break; // additive vocabulary: event.raw.kind names the frame
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
```
|
|
802
|
+
|
|
803
|
+
`event.status` is always the engine's own word (a waiting run reads `paused`);
|
|
804
|
+
`event.task` is the task a `task.*` frame named; `event.error` is the failure
|
|
805
|
+
a `task.failed` frame or a `failed` settlement named, and no other state
|
|
806
|
+
carries one; `event.sequence` is the resident's replay cursor and exists only
|
|
807
|
+
over HTTP.
|
|
808
|
+
An `engine.event` is given no lifecycle meaning: it carries its cursor and
|
|
809
|
+
`raw`, never a `status`.
|
|
810
|
+
|
|
811
|
+
`run.waiting` is not `run.settled`: a human gate holds a resumable run, which
|
|
812
|
+
has neither failed nor completed. `run.interrupted` is the engine's report
|
|
813
|
+
that it lost an execution, whose settlement is unknown; it is unrelated to
|
|
814
|
+
the thrown `NikaObservationInterrupted`, which means this client lost its view
|
|
815
|
+
of a run that may still be running.
|
|
816
|
+
|
|
817
|
+
### Migrating to the Run-owned lifecycle
|
|
818
|
+
|
|
819
|
+
The client-level lifecycle methods are deprecated and stay for one release
|
|
820
|
+
train. They keep the ownership check: a run this client did not create still
|
|
821
|
+
throws `NikaRunOwnershipError`, because there is no global run registry.
|
|
822
|
+
|
|
823
|
+
**The compatibility window.** A release train is one published
|
|
824
|
+
SDK-and-engine version, the meaning this repository already uses (see
|
|
825
|
+
[Keeping it fresh](#keeping-it-fresh)). The window is counted from
|
|
826
|
+
publication, never from a merge:
|
|
827
|
+
|
|
828
|
+
1. It opens with the first train **published to npm** whose package carries
|
|
829
|
+
the Run-owned lifecycle. That train ships the wrappers, unchanged, next to
|
|
830
|
+
the new API.
|
|
831
|
+
2. The earliest train that may remove them is the one **after** it. Removal
|
|
832
|
+
is not automatic: it is decided by the One SDK baseline owner
|
|
833
|
+
([#114](https://github.com/supernovae-st/nika-client/issues/114)) and is
|
|
834
|
+
announced in the release notes of the train that performs it.
|
|
835
|
+
3. No version and no date are fixed here. Until a train carrying this API is
|
|
836
|
+
published, nothing has started counting and the wrappers stay.
|
|
837
|
+
|
|
838
|
+
| Deprecated | Use | What changes |
|
|
839
|
+
|---|---|---|
|
|
840
|
+
| `nika.events(run, options?)` | `run.events(options?)` | lifecycle `kind`; the protocol frame the wrapper yields is `event.raw` |
|
|
841
|
+
| `nika.cancel(run)` | `run.cancel()` | nothing: the same memoized request |
|
|
842
|
+
| `nika.status(run)` | `run.status()` | nothing |
|
|
843
|
+
| `await run.done` | `await run.result()` | nothing: `done` stays as an alias of the same promise |
|
|
844
|
+
|
|
845
|
+
`nika.events(run)` still yields the protocol vocabulary exactly as before, so
|
|
846
|
+
existing consumers keep working unchanged while they migrate:
|
|
847
|
+
|
|
848
|
+
```ts
|
|
849
|
+
// before // after
|
|
850
|
+
for await (const e of nika.events(run)) { for await (const e of run.events()) {
|
|
851
|
+
if (e.kind === 'workflow_started' || if (e.kind === 'run.started') start();
|
|
852
|
+
e.kind === 'execution.started') start();
|
|
853
|
+
} }
|
|
854
|
+
```
|
|
855
|
+
|
|
856
|
+
### Typed protocol events, outputs, and identities
|
|
857
|
+
|
|
858
|
+
`NikaEvent` is the protocol frame under `event.raw` (and what the deprecated
|
|
859
|
+
`nika.events(run)` yields): a discriminated union over the known protocol
|
|
860
|
+
kinds of both transports. A native engine process emits `workflow_started`,
|
|
408
861
|
`task_scheduled`, `task_started`, `task_completed`, `workflow_completed`,
|
|
409
862
|
`workflow_failed`, `workflow_interrupted`, `run_settled`, and `run_sealed`. A
|
|
410
863
|
`nika serve` job streams `execution.started`, `execution.settled`,
|
|
@@ -414,36 +867,36 @@ this SDK version does not know yet stay representable through the
|
|
|
414
867
|
`NikaUnknownEvent` fallback, so the union is intentionally non-exhaustive and
|
|
415
868
|
every variant keeps its future fields open.
|
|
416
869
|
|
|
417
|
-
`run
|
|
418
|
-
|
|
419
|
-
`execution.settled` / `workflow_completed` frames — without
|
|
420
|
-
validation, and defaults to `Record<string, unknown>` so untyped
|
|
421
|
-
no change:
|
|
870
|
+
`run` and `attachRun` accept one `Outputs` type argument. It types the
|
|
871
|
+
terminal settlement — `run.result()` and, on the protocol frame, the
|
|
872
|
+
`run_settled` / `execution.settled` / `workflow_completed` frames — without
|
|
873
|
+
any runtime validation, and defaults to `Record<string, unknown>` so untyped
|
|
874
|
+
callers see no change:
|
|
422
875
|
|
|
423
876
|
```ts
|
|
424
|
-
const run = await nika.run<{ answer: number }>('flow.nika
|
|
425
|
-
const result = await run.
|
|
877
|
+
const run = await nika.run<{ answer: number }>('flow.nika');
|
|
878
|
+
const result = await run.result(); // result.outputs?: { answer: number }
|
|
426
879
|
|
|
427
|
-
for await (const event of
|
|
428
|
-
if (isNikaRunSettledEvent(event)) {
|
|
880
|
+
for await (const event of run.events()) {
|
|
881
|
+
if (isNikaRunSettledEvent(event.raw)) {
|
|
429
882
|
// The settlement frame of either transport (`run_settled` natively,
|
|
430
883
|
// `execution.settled` over HTTP): status, outputs, and receipt typed
|
|
431
884
|
// together on the one frame that carries all three.
|
|
432
|
-
console.log(event.status, event.outputs?.answer, event.receipt);
|
|
885
|
+
console.log(event.raw.status, event.raw.outputs?.answer, event.raw.receipt);
|
|
433
886
|
}
|
|
434
887
|
}
|
|
435
888
|
```
|
|
436
889
|
|
|
437
|
-
A run can also end without settling
|
|
438
|
-
interrupted. `isNikaTerminalEvent(event)`
|
|
439
|
-
engine-reported `status` (`succeeded`,
|
|
440
|
-
rather than the kind, so it holds on
|
|
441
|
-
version does not know yet:
|
|
890
|
+
The protocol guards read `event.raw`. A run can also end without settling
|
|
891
|
+
outputs — cancelled, refused, or interrupted. `isNikaTerminalEvent(event.raw)`
|
|
892
|
+
narrows those too: it reads the engine-reported `status` (`succeeded`,
|
|
893
|
+
`failed`, `interrupted`, `cancelled`) rather than the kind, so it holds on
|
|
894
|
+
either transport and on kinds this SDK version does not know yet:
|
|
442
895
|
|
|
443
896
|
```ts
|
|
444
|
-
for await (const event of
|
|
445
|
-
if (isNikaTerminalEvent(event)) {
|
|
446
|
-
console.log('no further frames for this run:', event.status);
|
|
897
|
+
for await (const event of run.events()) {
|
|
898
|
+
if (isNikaTerminalEvent(event.raw)) {
|
|
899
|
+
console.log('no further frames for this run:', event.raw.status);
|
|
447
900
|
}
|
|
448
901
|
}
|
|
449
902
|
```
|
|
@@ -479,6 +932,12 @@ NikaError
|
|
|
479
932
|
└── NikaRunOwnershipError
|
|
480
933
|
```
|
|
481
934
|
|
|
935
|
+
`NikaEventBufferOverflowError` is never about the run: `run.result()` is
|
|
936
|
+
unaffected by it. Its `reason` tells a live view that fell behind
|
|
937
|
+
(`live_backpressure`) from a view opened after more frames than it can replay
|
|
938
|
+
(`replay_truncated`, with `observed` and `retained`); see
|
|
939
|
+
[Observing a run after the fact](#observing-a-run-after-the-fact).
|
|
940
|
+
|
|
482
941
|
Native engine event vocabulary stays open. HTTP events instead enforce the
|
|
483
942
|
closed, redacted `JobEvent` projection advertised by the pinned OpenAPI contract;
|
|
484
943
|
unknown HTTP fields are rejected at the trust boundary. The SDK never turns an
|
|
@@ -492,9 +951,37 @@ Server messages are engine-owned and path-free; a reflected bearer token is
|
|
|
492
951
|
redacted before it reaches an error message. A non-2xx answer without that
|
|
493
952
|
typed body stays a `NikaTransportError` whose body is redacted entirely.
|
|
494
953
|
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
`
|
|
954
|
+
A native engine refuses a workflow before admitting it: a red check, a cost
|
|
955
|
+
floor above `maxCostUsd`, a required input left unset, a file it cannot read.
|
|
956
|
+
`run()` then rejects, before any `NikaRun` exists, with a `NikaOperationError`
|
|
957
|
+
carrying `operation: 'run'` and the engine's exit status in `status`:
|
|
958
|
+
|
|
959
|
+
```ts
|
|
960
|
+
try {
|
|
961
|
+
const run = await nika.run('./workflow.nika');
|
|
962
|
+
const result = await run.result(); // admitted: a failure here is result data
|
|
963
|
+
} catch (error) {
|
|
964
|
+
if (error instanceof NikaOperationError && error.operation === 'run') {
|
|
965
|
+
console.error(error.code); // 'NIKA-SEC-004'
|
|
966
|
+
for (const finding of error.findings ?? []) console.error(finding.message);
|
|
967
|
+
} else throw error;
|
|
968
|
+
}
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
- `code` is the engine's own code: the first check finding that names one
|
|
972
|
+
(`NIKA-SEC-004`, `NIKA-PARSE-005`, `NIKA-AUTH-006`, …) or the refusal's code
|
|
973
|
+
(`NIKA-1709`, `NIKA-1708`). `machineCode` repeats it. When the engine named
|
|
974
|
+
none, as for an unreadable file, `code` is the SDK's `run_refused` and
|
|
975
|
+
`machineCode` is absent; the SDK never supplies an engine code.
|
|
976
|
+
- `findings` holds the engine's check findings untouched, as
|
|
977
|
+
`NikaCheckFinding` (`code?`, `message`, `severity`, `gate`, `kind`, `task`,
|
|
978
|
+
`docs_url`). A budget or launch refusal has no findings.
|
|
979
|
+
- Nothing was admitted, so nothing else exists: no run id, no events, no
|
|
980
|
+
trace, no receipt.
|
|
981
|
+
|
|
982
|
+
Output that proves neither an admission nor a refusal (a line that is not
|
|
983
|
+
machine output, a truncated or oversized report, an engine that exits without
|
|
984
|
+
a frame) rejects `run()` with `NikaProtocolError` instead.
|
|
498
985
|
|
|
499
986
|
## Security boundaries
|
|
500
987
|
|
|
@@ -526,7 +1013,7 @@ The repository also carries 100 distinct use-case workflows and provider proof
|
|
|
526
1013
|
under `gauntlet/`.
|
|
527
1014
|
|
|
528
1015
|
<!-- engine hero pinned to the release tag it demonstrates · re-pin on lockstep bumps -->
|
|
529
|
-

|
|
530
1017
|
|
|
531
1018
|
## Keeping it fresh
|
|
532
1019
|
|
|
@@ -549,7 +1036,7 @@ npm update @supernovae-st/nika
|
|
|
549
1036
|
⚙️ nika ───────── engine, admission, execution, receipts and schedules
|
|
550
1037
|
│
|
|
551
1038
|
▼
|
|
552
|
-
🔌 nika-client ── this
|
|
1039
|
+
🔌 nika-client ── this door, published as @supernovae-st/nika: native process or authenticated HTTP
|
|
553
1040
|
│
|
|
554
1041
|
▼
|
|
555
1042
|
🧩 Node.js applications
|