@nylorun/runtime 0.10.0-beta → 0.11.0-beta
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/CHANGELOG.md +46 -0
- package/README.md +53 -51
- package/dist/adapters/execution/restate.d.ts +130 -0
- package/dist/adapters/execution/restate.js +442 -0
- package/dist/adapters/sandbox/virtual.js +1 -1
- package/dist/adapters/streams/s2.d.ts +18 -0
- package/dist/adapters/streams/s2.js +361 -0
- package/dist/core/executors.d.ts +4 -1
- package/dist/core/executors.js +20 -1
- package/dist/core/flow-host.d.ts +93 -132
- package/dist/core/flow-host.js +282 -547
- package/dist/core/sandbox-routes.d.ts +8 -3
- package/dist/core/sandbox-routes.js +10 -10
- package/dist/execution/memory.d.ts +54 -0
- package/dist/execution/memory.js +217 -0
- package/dist/execution/types.d.ts +125 -0
- package/dist/execution/types.js +52 -0
- package/dist/host/config-for.d.ts +2 -1
- package/dist/host/config-for.js +3 -21
- package/dist/host/config.d.ts +0 -8
- package/dist/host/create-host.d.ts +36 -1
- package/dist/host/create-host.js +48 -24
- package/dist/host/execution.d.ts +61 -0
- package/dist/host/execution.js +69 -0
- package/dist/host/http.d.ts +4 -0
- package/dist/host/http.js +6 -0
- package/dist/host/index.d.ts +3 -1
- package/dist/host/index.js +2 -0
- package/dist/host/main.js +106 -47
- package/dist/host/stack-config.d.ts +72 -0
- package/dist/host/stack-config.js +193 -0
- package/dist/index.d.ts +1 -1
- package/dist/infra/database.d.ts +16 -0
- package/dist/infra/database.js +31 -0
- package/dist/infra/execution.d.ts +39 -0
- package/dist/infra/execution.js +68 -0
- package/dist/infra/index.d.ts +42 -0
- package/dist/infra/index.js +56 -0
- package/dist/infra/readiness.d.ts +33 -0
- package/dist/infra/readiness.js +53 -0
- package/dist/infra/streams.d.ts +17 -0
- package/dist/infra/streams.js +33 -0
- package/dist/infra/workers.d.ts +39 -0
- package/dist/infra/workers.js +31 -0
- package/dist/model/pi-model.d.ts +2 -2
- package/dist/model/pi-model.js +5 -5
- package/dist/sandbox/manager.d.ts +26 -8
- package/dist/sandbox/manager.js +95 -47
- package/dist/sandbox/select.d.ts +1 -1
- package/dist/sandbox/select.js +4 -5
- package/dist/sandbox/types.d.ts +2 -2
- package/dist/store/canonical.d.ts +5 -0
- package/dist/store/canonical.js +17 -0
- package/dist/store/cursor.d.ts +9 -0
- package/dist/store/cursor.js +18 -0
- package/dist/store/memory.d.ts +57 -0
- package/dist/store/memory.js +643 -0
- package/dist/store/ownership.d.ts +19 -0
- package/dist/store/ownership.js +33 -0
- package/dist/store/postgres/connect.d.ts +25 -0
- package/dist/store/postgres/connect.js +25 -0
- package/dist/store/postgres/locking.d.ts +16 -0
- package/dist/store/postgres/locking.js +22 -0
- package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
- package/dist/store/postgres/migrations/001_initial.js +196 -0
- package/dist/store/postgres/migrations/index.d.ts +49 -0
- package/dist/store/postgres/migrations/index.js +75 -0
- package/dist/store/postgres/names.d.ts +11 -0
- package/dist/store/postgres/names.js +42 -0
- package/dist/store/postgres/store.d.ts +11 -0
- package/dist/store/postgres/store.js +790 -0
- package/dist/store/postgres/tenants.d.ts +90 -0
- package/dist/store/postgres/tenants.js +207 -0
- package/dist/store/types.d.ts +440 -0
- package/dist/store/types.js +10 -0
- package/dist/streams/basin.d.ts +23 -0
- package/dist/streams/basin.js +51 -0
- package/dist/streams/memory.d.ts +25 -0
- package/dist/streams/memory.js +124 -0
- package/dist/streams/relay.d.ts +37 -0
- package/dist/streams/relay.js +254 -0
- package/dist/streams/types.d.ts +115 -0
- package/dist/streams/types.js +86 -0
- package/dist/tenant/actions.d.ts +153 -0
- package/dist/tenant/actions.js +191 -0
- package/dist/tenant/advance.d.ts +15 -0
- package/dist/tenant/advance.js +439 -0
- package/dist/tenant/auth.d.ts +11 -0
- package/dist/tenant/auth.js +34 -0
- package/dist/tenant/commands.d.ts +3 -0
- package/dist/tenant/commands.js +384 -0
- package/dist/tenant/context.d.ts +140 -0
- package/dist/tenant/context.js +40 -0
- package/dist/tenant/effects.d.ts +20 -0
- package/dist/tenant/effects.js +539 -0
- package/dist/tenant/envelope.d.ts +0 -3
- package/dist/tenant/envelope.js +2 -34
- package/dist/tenant/ephemeral.d.ts +15 -4
- package/dist/tenant/ephemeral.js +106 -38
- package/dist/tenant/http.d.ts +32 -0
- package/dist/tenant/http.js +51 -0
- package/dist/tenant/live.d.ts +92 -0
- package/dist/tenant/live.js +290 -0
- package/dist/tenant/model-setting.d.ts +16 -0
- package/dist/tenant/model-setting.js +12 -0
- package/dist/tenant/module.d.ts +29 -8
- package/dist/tenant/module.js +162 -158
- package/dist/tenant/paths.d.ts +12 -8
- package/dist/tenant/paths.js +38 -12
- package/dist/tenant/principals.d.ts +9 -13
- package/dist/tenant/principals.js +31 -38
- package/dist/tenant/quarantine-error.d.ts +1 -1
- package/dist/tenant/quarantine-error.js +2 -8
- package/dist/tenant/quarantine.d.ts +0 -8
- package/dist/tenant/quarantine.js +8 -30
- package/dist/tenant/reset.d.ts +7 -6
- package/dist/tenant/reset.js +7 -51
- package/dist/tenant/routes-tenant.d.ts +12 -0
- package/dist/tenant/routes-tenant.js +126 -0
- package/dist/tenant/routes.d.ts +9 -0
- package/dist/tenant/routes.js +142 -0
- package/dist/tenant/runtime.d.ts +59 -58
- package/dist/tenant/runtime.js +243 -2201
- package/dist/tenant/scheduler.d.ts +25 -0
- package/dist/tenant/scheduler.js +84 -0
- package/dist/tenant/session.d.ts +60 -0
- package/dist/tenant/session.js +94 -0
- package/dist/tenant/sessions.d.ts +34 -0
- package/dist/tenant/sessions.js +149 -0
- package/dist/tenant/status.d.ts +13 -4
- package/dist/tenant/status.js +76 -41
- package/dist/tenant/store-memory.d.ts +9 -12
- package/dist/tenant/store-memory.js +79 -68
- package/dist/tenant/store-pg.d.ts +15 -0
- package/dist/tenant/store-pg.js +140 -0
- package/dist/tenant/streams.d.ts +126 -0
- package/dist/tenant/streams.js +419 -0
- package/dist/tenant/sweep.d.ts +15 -0
- package/dist/tenant/sweep.js +122 -0
- package/dist/tenant/types.d.ts +95 -15
- package/dist/tenant/types.js +23 -1
- package/dist/tenant/worker.d.ts +84 -0
- package/dist/tenant/worker.js +189 -0
- package/dist/vault/service.d.ts +37 -31
- package/dist/vault/service.js +439 -359
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +13 -13
- package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
- package/dist/adapters/sandbox/microsandbox.js +0 -238
- package/dist/core/store.d.ts +0 -32
- package/dist/core/store.js +0 -143
- package/dist/launcher/commands.d.ts +0 -24
- package/dist/launcher/commands.js +0 -260
- package/dist/launcher/ensure-config.d.ts +0 -16
- package/dist/launcher/ensure-config.js +0 -79
- package/dist/launcher/errors.d.ts +0 -12
- package/dist/launcher/errors.js +0 -22
- package/dist/launcher/host-config.d.ts +0 -17
- package/dist/launcher/host-config.js +0 -64
- package/dist/launcher/host-state.d.ts +0 -17
- package/dist/launcher/host-state.js +0 -61
- package/dist/launcher/lifecycle.d.ts +0 -66
- package/dist/launcher/lifecycle.js +0 -407
- package/dist/launcher/locks.d.ts +0 -26
- package/dist/launcher/locks.js +0 -117
- package/dist/launcher/logs.d.ts +0 -18
- package/dist/launcher/logs.js +0 -117
- package/dist/launcher/main.d.ts +0 -2
- package/dist/launcher/main.js +0 -66
- package/dist/launcher/output.d.ts +0 -11
- package/dist/launcher/output.js +0 -68
- package/dist/launcher/paths.d.ts +0 -20
- package/dist/launcher/paths.js +0 -36
- package/dist/launcher/probe.d.ts +0 -23
- package/dist/launcher/probe.js +0 -72
- package/dist/launcher/protocol.d.ts +0 -62
- package/dist/launcher/protocol.js +0 -1
- package/dist/launcher/schema-guard.d.ts +0 -6
- package/dist/launcher/schema-guard.js +0 -36
- package/dist/launcher/spawn.d.ts +0 -22
- package/dist/launcher/spawn.js +0 -85
- package/dist/launcher/status.d.ts +0 -6
- package/dist/launcher/status.js +0 -65
- package/dist/tenant/migration.d.ts +0 -16
- package/dist/tenant/migration.js +0 -116
- package/dist/tenant/schema.d.ts +0 -23
- package/dist/tenant/schema.js +0 -151
- package/dist/tenant/store-fs.d.ts +0 -16
- package/dist/tenant/store-fs.js +0 -205
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Durable Streams on S2 (architecture §12.4). The only file that imports the
|
|
3
|
+
* S2 SDK (`@s2-dev/streamstore`). Works against the S2 service and s2-lite,
|
|
4
|
+
* which serve the same REST API.
|
|
5
|
+
*
|
|
6
|
+
* ## Layout
|
|
7
|
+
*
|
|
8
|
+
* - One basin per Tenant, named by `tenantBasinName` (`streams/basin.ts`):
|
|
9
|
+
* `<basinPrefix>tn-<ulid>` for a Tenant id `tn_<ulid>`.
|
|
10
|
+
* - The basin is created with `createStreamOnAppend`, so `sessions/<id>/<incarnation>`,
|
|
11
|
+
* `tenant/work` and `tenant/control` come into existence on their first
|
|
12
|
+
* append. Its default stream config sets infinite retention, which session
|
|
13
|
+
* streams keep; the work and control streams are created with a one-day
|
|
14
|
+
* age-based retention instead (signals are latency hints, not history).
|
|
15
|
+
* - Bodies are JSON text in string records. Record timestamps are S2 arrival
|
|
16
|
+
* times.
|
|
17
|
+
* - Appends use `appendRetryPolicy: "noSideEffects"`: the SDK retries only
|
|
18
|
+
* failures that guarantee nothing was written, so an append is never
|
|
19
|
+
* duplicated here. A caller retrying an unacknowledged append passes
|
|
20
|
+
* `matchSeq` (as the relay does) and gets `seq_mismatch` if it had landed.
|
|
21
|
+
*
|
|
22
|
+
* ## s2-lite behaviour (architecture §19 question 3), verified on
|
|
23
|
+
* `ghcr.io/s2-streamstore/s2:0.43.0` (`s2 lite`, in-memory) with SDK 0.27.0
|
|
24
|
+
*
|
|
25
|
+
* - **Retention.** The default stream config is `retentionPolicy: { ageSecs:
|
|
26
|
+
* 604800 }` (7 days) and `storageClass: express`. Streams therefore need an
|
|
27
|
+
* explicit `{ infinite: {} }` retention to keep session history; this adapter
|
|
28
|
+
* sets it as the basin default, and s2-lite honours it (a new stream reports
|
|
29
|
+
* `infinite`). A `streamConfig` sent with an append that creates the stream
|
|
30
|
+
* is honoured too (used for the work and control streams). `deleteOnEmpty`
|
|
31
|
+
* defaults to disabled (`minAgeSecs: 0`).
|
|
32
|
+
* - **Stream deletion** is immediate for our purposes: right after `DELETE`,
|
|
33
|
+
* `checkTail` and reads return 404 `stream_not_found` (new read sessions
|
|
34
|
+
* can briefly see 409 `stream_deletion_pending`), and an append re-creates
|
|
35
|
+
* the stream from sequence 0. Deleting a missing stream succeeds. Open read
|
|
36
|
+
* sessions see a command record, then fail with `stream_not_found`.
|
|
37
|
+
* - **Basin deletion** is asynchronous: `DELETE` returns at once (also for a
|
|
38
|
+
* basin already being deleted), after which appends and `ensure` fail with
|
|
39
|
+
* 409 `basin_deletion_pending` for about a minute (55 s measured) until the
|
|
40
|
+
* name is free again. Meanwhile the basin is listed with `deletedAt`, and
|
|
41
|
+
* its streams answer `stream_not_found`, so live reads check the basin when
|
|
42
|
+
* their stream is missing. `ensureTenant` waits the deletion out (up to
|
|
43
|
+
* `basinDeletionWaitMs`), so a Tenant reset that recreates the basin stalls
|
|
44
|
+
* for up to a minute on s2-lite.
|
|
45
|
+
* - **Conditional appends.** `match_seq_num` equal to the tail appends;
|
|
46
|
+
* anything else fails with 412 (`SeqNumMismatchError`) carrying the tail as
|
|
47
|
+
* `expectedSeqNum`, and writes nothing. A retry of an acknowledged append
|
|
48
|
+
* with the same `match_seq_num` is rejected this way, never duplicated. On a
|
|
49
|
+
* missing stream a failed conditional append still creates the (empty)
|
|
50
|
+
* stream when `createStreamOnAppend` is on. Concurrent appends to one stream
|
|
51
|
+
* can fail with 409 `transaction_conflict` (nothing written); `append`
|
|
52
|
+
* retries those, and the conditional retry then resolves to ok or
|
|
53
|
+
* `seq_mismatch`.
|
|
54
|
+
* - **Reads.** A read starting at or past the tail fails with 416
|
|
55
|
+
* (`RangeNotSatisfiableError`); with `clamp` a read session starts at the
|
|
56
|
+
* tail instead and follows it. Reading a missing stream is 404
|
|
57
|
+
* `stream_not_found`, a missing basin 404 `basin_not_found`. Read sessions
|
|
58
|
+
* use the SDK's HTTP/2 (s2s) transport, which s2-lite serves over cleartext.
|
|
59
|
+
*/
|
|
60
|
+
import { AppendInput, AppendRecord, RangeNotSatisfiableError, S2, S2Error, SeqNumMismatchError, } from "@s2-dev/streamstore";
|
|
61
|
+
import { tenantBasinName, validateBasinPrefix } from "../../streams/basin.js";
|
|
62
|
+
import { CONTROL_STREAM, WORK_STREAM, } from "../../streams/types.js";
|
|
63
|
+
/** Retention of the work and control signal streams. */
|
|
64
|
+
const SIGNAL_RETENTION_SECS = 24 * 60 * 60;
|
|
65
|
+
/** Records per unary read while reading history. */
|
|
66
|
+
const HISTORY_PAGE = 1000;
|
|
67
|
+
const RETRY_MIN_MS = 100;
|
|
68
|
+
const RETRY_MAX_MS = 2000;
|
|
69
|
+
/** Attempts for an append that loses a `transaction_conflict` race. */
|
|
70
|
+
const CONFLICT_ATTEMPTS = 6;
|
|
71
|
+
export function createS2Streams(options = {}) {
|
|
72
|
+
return new S2Streams(options);
|
|
73
|
+
}
|
|
74
|
+
class S2Streams {
|
|
75
|
+
s2;
|
|
76
|
+
prefix;
|
|
77
|
+
deletionWaitMs;
|
|
78
|
+
/** Aborted by `close()`; ends every read and pending wait. */
|
|
79
|
+
closing = new AbortController();
|
|
80
|
+
constructor(options) {
|
|
81
|
+
this.prefix = validateBasinPrefix(options.basinPrefix ?? "");
|
|
82
|
+
this.deletionWaitMs = options.basinDeletionWaitMs ?? 120_000;
|
|
83
|
+
this.s2 = new S2({
|
|
84
|
+
accessToken: options.token ?? "s2-lite",
|
|
85
|
+
...(options.endpoint
|
|
86
|
+
? { endpoints: { account: options.endpoint, basin: options.endpoint } }
|
|
87
|
+
: {}),
|
|
88
|
+
retry: { appendRetryPolicy: "noSideEffects" },
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
async append(tenantId, stream, records, options = {}) {
|
|
92
|
+
this.checkOpen();
|
|
93
|
+
if (records.length === 0)
|
|
94
|
+
throw new Error("append needs at least one record");
|
|
95
|
+
const input = AppendInput.create(records.map((record) => {
|
|
96
|
+
const body = JSON.stringify(record);
|
|
97
|
+
if (body === undefined)
|
|
98
|
+
throw new Error("Stream records must be JSON");
|
|
99
|
+
return AppendRecord.string({ body });
|
|
100
|
+
}), {
|
|
101
|
+
...(options.matchSeq !== undefined ? { matchSeqNum: options.matchSeq } : {}),
|
|
102
|
+
...(stream === WORK_STREAM || stream === CONTROL_STREAM
|
|
103
|
+
? { streamConfig: { retentionPolicy: { ageSecs: SIGNAL_RETENTION_SECS } } }
|
|
104
|
+
: {}),
|
|
105
|
+
});
|
|
106
|
+
const handle = this.handle(tenantId, stream);
|
|
107
|
+
let delay = RETRY_MIN_MS / 4;
|
|
108
|
+
for (let attempt = 1;; attempt += 1) {
|
|
109
|
+
try {
|
|
110
|
+
const ack = await handle.append(input, { signal: this.closing.signal });
|
|
111
|
+
return { status: "ok", start: ack.start.seqNum, end: ack.end.seqNum };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error instanceof SeqNumMismatchError)
|
|
115
|
+
return { status: "seq_mismatch", tail: error.expectedSeqNum };
|
|
116
|
+
// Concurrent appends to one stream can abort each other; the aborted
|
|
117
|
+
// one wrote nothing, so it is retried.
|
|
118
|
+
if (!isTransactionConflict(error) || attempt >= CONFLICT_ATTEMPTS)
|
|
119
|
+
throw error;
|
|
120
|
+
await sleep(delay * (1 + Math.random()), this.closing.signal);
|
|
121
|
+
this.checkOpen();
|
|
122
|
+
delay *= 2;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
read(tenantId, stream, fromSeq, options = {}) {
|
|
127
|
+
if (!Number.isInteger(fromSeq) || fromSeq < 0)
|
|
128
|
+
throw new Error("fromSeq must be a non-negative integer");
|
|
129
|
+
return options.follow === false
|
|
130
|
+
? this.readHistory(tenantId, stream, fromSeq, options.signal)
|
|
131
|
+
: this.readLive(tenantId, stream, fromSeq, options.signal);
|
|
132
|
+
}
|
|
133
|
+
async tail(tenantId, stream) {
|
|
134
|
+
this.checkOpen();
|
|
135
|
+
try {
|
|
136
|
+
const { tail } = await this.handle(tenantId, stream).checkTail({
|
|
137
|
+
signal: this.closing.signal,
|
|
138
|
+
});
|
|
139
|
+
return tail.seqNum;
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
if (isMissing(error))
|
|
143
|
+
return 0;
|
|
144
|
+
throw error;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
async ensureTenant(tenantId) {
|
|
148
|
+
this.checkOpen();
|
|
149
|
+
const basin = this.basinName(tenantId);
|
|
150
|
+
const deadline = Date.now() + this.deletionWaitMs;
|
|
151
|
+
let delay = RETRY_MIN_MS * 5;
|
|
152
|
+
for (;;) {
|
|
153
|
+
try {
|
|
154
|
+
await this.s2.basins.ensure({
|
|
155
|
+
basin,
|
|
156
|
+
config: {
|
|
157
|
+
createStreamOnAppend: true,
|
|
158
|
+
defaultStreamConfig: { retentionPolicy: { infinite: {} } },
|
|
159
|
+
},
|
|
160
|
+
}, { signal: this.closing.signal });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (!(error instanceof S2Error) || error.code !== "basin_deletion_pending")
|
|
165
|
+
throw error;
|
|
166
|
+
if (Date.now() + delay > deadline)
|
|
167
|
+
throw error;
|
|
168
|
+
await sleep(delay, this.closing.signal);
|
|
169
|
+
this.checkOpen();
|
|
170
|
+
delay = Math.min(delay * 2, RETRY_MAX_MS);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
async deleteTenant(tenantId) {
|
|
175
|
+
this.checkOpen();
|
|
176
|
+
try {
|
|
177
|
+
await this.s2.basins.delete({ basin: this.basinName(tenantId) }, { signal: this.closing.signal });
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
if (!isMissing(error))
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async deleteStream(tenantId, stream) {
|
|
185
|
+
this.checkOpen();
|
|
186
|
+
try {
|
|
187
|
+
await this.s2
|
|
188
|
+
.basin(this.basinName(tenantId))
|
|
189
|
+
.streams.delete({ stream }, { signal: this.closing.signal });
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (!isMissing(error))
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async listStreams(tenantId, prefix) {
|
|
197
|
+
this.checkOpen();
|
|
198
|
+
const names = [];
|
|
199
|
+
try {
|
|
200
|
+
for await (const info of this.s2
|
|
201
|
+
.basin(this.basinName(tenantId))
|
|
202
|
+
.streams.listAll({ prefix }, { signal: this.closing.signal }))
|
|
203
|
+
if (info.deletedAt == null)
|
|
204
|
+
names.push(info.name);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
if (isMissing(error) || isTenantGone(error))
|
|
208
|
+
return [];
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
return names.sort();
|
|
212
|
+
}
|
|
213
|
+
async close() {
|
|
214
|
+
this.closing.abort();
|
|
215
|
+
}
|
|
216
|
+
/** Lists at most one basin: reachable, and the token (if any) is accepted. */
|
|
217
|
+
async probe(signal) {
|
|
218
|
+
this.checkOpen();
|
|
219
|
+
await this.s2.basins.list({ limit: 1 }, { signal: anySignal(signal, this.closing.signal) });
|
|
220
|
+
}
|
|
221
|
+
// -------------------------------------------------------------------------
|
|
222
|
+
async *readHistory(tenantId, stream, fromSeq, signal) {
|
|
223
|
+
const aborted = anySignal(signal, this.closing.signal);
|
|
224
|
+
const end = await this.tail(tenantId, stream);
|
|
225
|
+
const handle = this.handle(tenantId, stream);
|
|
226
|
+
let next = fromSeq;
|
|
227
|
+
while (next < end && !aborted.aborted) {
|
|
228
|
+
let records;
|
|
229
|
+
try {
|
|
230
|
+
({ records } = await handle.read({
|
|
231
|
+
start: { from: { seqNum: next } },
|
|
232
|
+
stop: { limits: { count: Math.min(HISTORY_PAGE, end - next) } },
|
|
233
|
+
ignoreCommandRecords: true,
|
|
234
|
+
}, { signal: aborted }));
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
// Trimmed, deleted or aborted under us: the history ends here.
|
|
238
|
+
if (aborted.aborted || error instanceof RangeNotSatisfiableError || isMissing(error))
|
|
239
|
+
return;
|
|
240
|
+
throw error;
|
|
241
|
+
}
|
|
242
|
+
if (records.length === 0)
|
|
243
|
+
return;
|
|
244
|
+
for (const record of records) {
|
|
245
|
+
if (record.seqNum >= end)
|
|
246
|
+
return;
|
|
247
|
+
if (record.seqNum < next)
|
|
248
|
+
continue;
|
|
249
|
+
next = record.seqNum + 1;
|
|
250
|
+
yield toRecord(record);
|
|
251
|
+
if (aborted.aborted)
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Follows `stream` from `fromSeq` with S2 read sessions, reopening a session
|
|
258
|
+
* from the next sequence whenever one ends or fails, until aborted, closed,
|
|
259
|
+
* or the Tenant's basin is gone. A missing stream is polled until it exists.
|
|
260
|
+
*/
|
|
261
|
+
async *readLive(tenantId, stream, fromSeq, signal) {
|
|
262
|
+
const aborted = anySignal(signal, this.closing.signal);
|
|
263
|
+
const handle = this.handle(tenantId, stream);
|
|
264
|
+
let next = fromSeq;
|
|
265
|
+
let delay = RETRY_MIN_MS;
|
|
266
|
+
try {
|
|
267
|
+
while (!aborted.aborted) {
|
|
268
|
+
try {
|
|
269
|
+
const session = await handle.readSession(
|
|
270
|
+
// `clamp` starts at the tail when `next` is past it; records before
|
|
271
|
+
// `next` are then skipped below.
|
|
272
|
+
{ start: { from: { seqNum: next }, clamp: true }, ignoreCommandRecords: true }, { signal: aborted });
|
|
273
|
+
for await (const record of session) {
|
|
274
|
+
if (record.seqNum < next)
|
|
275
|
+
continue;
|
|
276
|
+
next = record.seqNum + 1;
|
|
277
|
+
delay = RETRY_MIN_MS;
|
|
278
|
+
yield toRecord(record);
|
|
279
|
+
if (aborted.aborted)
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
if (aborted.aborted || isTenantGone(error))
|
|
285
|
+
return;
|
|
286
|
+
// While a basin is being deleted its streams report
|
|
287
|
+
// `stream_not_found`, so a missing stream also checks the basin.
|
|
288
|
+
if ((isMissing(error) || isCode(error, "stream_deletion_pending")) &&
|
|
289
|
+
(await this.basinGone(tenantId, aborted)))
|
|
290
|
+
return;
|
|
291
|
+
// Stream not created yet, or S2 unreachable: retry below.
|
|
292
|
+
}
|
|
293
|
+
await sleep(delay, aborted);
|
|
294
|
+
delay = Math.min(delay * 2, RETRY_MAX_MS);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
finally {
|
|
298
|
+
await handle.close().catch(() => { });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
/** True when the Tenant's basin does not exist or is being deleted. */
|
|
302
|
+
async basinGone(tenantId, signal) {
|
|
303
|
+
const basin = this.basinName(tenantId);
|
|
304
|
+
try {
|
|
305
|
+
const { basins } = await this.s2.basins.list({ prefix: basin, limit: 1 }, { signal });
|
|
306
|
+
const info = basins.find((b) => b.name === basin);
|
|
307
|
+
return !info || info.deletedAt != null;
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
handle(tenantId, stream) {
|
|
314
|
+
return this.s2.basin(this.basinName(tenantId)).stream(stream);
|
|
315
|
+
}
|
|
316
|
+
basinName(tenantId) {
|
|
317
|
+
return tenantBasinName(tenantId, this.prefix);
|
|
318
|
+
}
|
|
319
|
+
checkOpen() {
|
|
320
|
+
if (this.closing.signal.aborted)
|
|
321
|
+
throw new Error("DurableStreams is closed");
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
function toRecord(record) {
|
|
325
|
+
return {
|
|
326
|
+
seq: record.seqNum,
|
|
327
|
+
timestamp: record.timestamp.getTime(),
|
|
328
|
+
body: JSON.parse(record.body),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** A 404: the basin or the stream does not exist. */
|
|
332
|
+
function isMissing(error) {
|
|
333
|
+
return error instanceof S2Error && error.status === 404;
|
|
334
|
+
}
|
|
335
|
+
function isCode(error, code) {
|
|
336
|
+
return error instanceof S2Error && error.code === code;
|
|
337
|
+
}
|
|
338
|
+
/** 409 `transaction_conflict`: a concurrent append to the stream won; nothing was written. */
|
|
339
|
+
function isTransactionConflict(error) {
|
|
340
|
+
return isCode(error, "transaction_conflict");
|
|
341
|
+
}
|
|
342
|
+
/** The Tenant's basin is missing or being deleted: live reads end. */
|
|
343
|
+
function isTenantGone(error) {
|
|
344
|
+
return isCode(error, "basin_not_found") || isCode(error, "basin_deletion_pending");
|
|
345
|
+
}
|
|
346
|
+
function anySignal(...signals) {
|
|
347
|
+
return AbortSignal.any(signals.filter((s) => s !== undefined));
|
|
348
|
+
}
|
|
349
|
+
function sleep(ms, signal) {
|
|
350
|
+
return new Promise((resolve) => {
|
|
351
|
+
if (signal.aborted)
|
|
352
|
+
return resolve();
|
|
353
|
+
const done = () => {
|
|
354
|
+
clearTimeout(timer);
|
|
355
|
+
signal.removeEventListener("abort", done);
|
|
356
|
+
resolve();
|
|
357
|
+
};
|
|
358
|
+
const timer = setTimeout(done, ms);
|
|
359
|
+
signal.addEventListener("abort", done, { once: true });
|
|
360
|
+
});
|
|
361
|
+
}
|
package/dist/core/executors.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { ExecutorScope } from "@nylorun/core/contracts";
|
|
2
|
+
import type { SessionStore } from "../store/types.js";
|
|
2
3
|
/**
|
|
3
4
|
* Executor bearer tokens are 256-bit CSPRNG values minted by the CLI and the wire schema
|
|
4
5
|
* requires at least 16 characters, so an unsalted digest is adequate to keep them out of
|
|
5
|
-
*
|
|
6
|
+
* the Session Store at rest. This is not a password KDF and must not be used for one.
|
|
6
7
|
*/
|
|
7
8
|
export declare const hashToken: (token: string) => string;
|
|
8
9
|
export interface ExecutorRecord extends ExecutorScope {
|
|
@@ -39,3 +40,5 @@ export declare class ExecutorRegistry {
|
|
|
39
40
|
remove(agentId: string): ExecutorRecord | undefined;
|
|
40
41
|
private write;
|
|
41
42
|
}
|
|
43
|
+
/** A registry seeded from the executors the store persisted (at Tenant open). */
|
|
44
|
+
export declare function loadExecutorRegistry(store: SessionStore): Promise<ExecutorRegistry>;
|
package/dist/core/executors.js
CHANGED
|
@@ -6,7 +6,7 @@ const equals = (a, b) => {
|
|
|
6
6
|
/**
|
|
7
7
|
* Executor bearer tokens are 256-bit CSPRNG values minted by the CLI and the wire schema
|
|
8
8
|
* requires at least 16 characters, so an unsalted digest is adequate to keep them out of
|
|
9
|
-
*
|
|
9
|
+
* the Session Store at rest. This is not a password KDF and must not be used for one.
|
|
10
10
|
*/
|
|
11
11
|
export const hashToken = (token) => createHash("sha256").update(token, "utf8").digest("hex");
|
|
12
12
|
/**
|
|
@@ -74,3 +74,22 @@ export class ExecutorRegistry {
|
|
|
74
74
|
this.byHash.set(record.tokenHash, record);
|
|
75
75
|
}
|
|
76
76
|
}
|
|
77
|
+
/** A registry seeded from the executors the store persisted (at Tenant open). */
|
|
78
|
+
export async function loadExecutorRegistry(store) {
|
|
79
|
+
const registry = new ExecutorRegistry();
|
|
80
|
+
const rows = await store.tx((t) => t.listExecutors());
|
|
81
|
+
registry.seed(rows.map((row) => ({
|
|
82
|
+
agentId: row.agentId,
|
|
83
|
+
implementationVersion: row.implementationVersion,
|
|
84
|
+
...(row.manifestHash === undefined
|
|
85
|
+
? {}
|
|
86
|
+
: { manifestHash: row.manifestHash }),
|
|
87
|
+
tokenHash: row.tokenHash,
|
|
88
|
+
persisted: true,
|
|
89
|
+
updatedAt: row.updatedAt,
|
|
90
|
+
...(row.principalId === undefined
|
|
91
|
+
? {}
|
|
92
|
+
: { principalId: row.principalId }),
|
|
93
|
+
})));
|
|
94
|
+
return registry;
|
|
95
|
+
}
|