@nylorun/runtime 0.9.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.
Files changed (192) hide show
  1. package/CHANGELOG.md +81 -0
  2. package/README.md +110 -37
  3. package/dist/adapters/execution/restate.d.ts +130 -0
  4. package/dist/adapters/execution/restate.js +442 -0
  5. package/dist/adapters/media.d.ts +2 -2
  6. package/dist/adapters/media.js +2 -2
  7. package/dist/adapters/observe.d.ts +2 -1
  8. package/dist/adapters/observe.js +2 -3
  9. package/dist/adapters/sandbox/virtual.js +1 -1
  10. package/dist/adapters/streams/s2.d.ts +18 -0
  11. package/dist/adapters/streams/s2.js +361 -0
  12. package/dist/assets.d.ts +2 -2
  13. package/dist/assets.js +5 -3
  14. package/dist/core/executors.d.ts +15 -3
  15. package/dist/core/executors.js +40 -5
  16. package/dist/core/flow-host.d.ts +225 -0
  17. package/dist/core/flow-host.js +518 -0
  18. package/dist/core/limits.d.ts +40 -0
  19. package/dist/core/limits.js +76 -0
  20. package/dist/core/runtime.d.ts +5 -90
  21. package/dist/core/runtime.js +3 -1447
  22. package/dist/core/sandbox-routes.d.ts +40 -0
  23. package/dist/core/sandbox-routes.js +106 -0
  24. package/dist/core/turn-manifest.d.ts +50 -0
  25. package/dist/core/turn-manifest.js +66 -0
  26. package/dist/execution/memory.d.ts +54 -0
  27. package/dist/execution/memory.js +217 -0
  28. package/dist/execution/types.d.ts +125 -0
  29. package/dist/execution/types.js +52 -0
  30. package/dist/host/config-for.d.ts +17 -0
  31. package/dist/host/config-for.js +27 -0
  32. package/dist/host/config.d.ts +16 -0
  33. package/dist/host/create-host.d.ts +56 -0
  34. package/dist/host/create-host.js +420 -0
  35. package/dist/host/environment.d.ts +13 -0
  36. package/dist/host/environment.js +53 -0
  37. package/dist/host/execution.d.ts +61 -0
  38. package/dist/host/execution.js +69 -0
  39. package/dist/host/http.d.ts +39 -0
  40. package/dist/host/http.js +142 -0
  41. package/dist/host/index.d.ts +8 -0
  42. package/dist/host/index.js +7 -0
  43. package/dist/host/logger.d.ts +3 -0
  44. package/dist/host/logger.js +18 -0
  45. package/dist/host/main.d.ts +3 -0
  46. package/dist/host/main.js +201 -0
  47. package/dist/host/stack-config.d.ts +72 -0
  48. package/dist/host/stack-config.js +193 -0
  49. package/dist/index.d.ts +7 -2
  50. package/dist/index.js +4 -2
  51. package/dist/infra/database.d.ts +16 -0
  52. package/dist/infra/database.js +31 -0
  53. package/dist/infra/execution.d.ts +39 -0
  54. package/dist/infra/execution.js +68 -0
  55. package/dist/infra/index.d.ts +42 -0
  56. package/dist/infra/index.js +56 -0
  57. package/dist/infra/readiness.d.ts +33 -0
  58. package/dist/infra/readiness.js +53 -0
  59. package/dist/infra/streams.d.ts +17 -0
  60. package/dist/infra/streams.js +33 -0
  61. package/dist/infra/workers.d.ts +39 -0
  62. package/dist/infra/workers.js +31 -0
  63. package/dist/mcp/connect.d.ts +1 -0
  64. package/dist/mcp/connect.js +2 -0
  65. package/dist/mcp/pool.d.ts +3 -1
  66. package/dist/mcp/pool.js +2 -1
  67. package/dist/mcp/stdio.d.ts +2 -0
  68. package/dist/mcp/stdio.js +3 -1
  69. package/dist/model/auth-store.d.ts +1 -1
  70. package/dist/model/auth-store.js +2 -2
  71. package/dist/model/models.d.ts +4 -0
  72. package/dist/model/models.js +22 -26
  73. package/dist/model/pi-model.d.ts +2 -2
  74. package/dist/model/pi-model.js +6 -6
  75. package/dist/model/settings.d.ts +5 -1
  76. package/dist/model/settings.js +6 -2
  77. package/dist/sandbox/manager.d.ts +26 -8
  78. package/dist/sandbox/manager.js +95 -47
  79. package/dist/sandbox/select.d.ts +5 -4
  80. package/dist/sandbox/select.js +18 -13
  81. package/dist/sandbox/share.d.ts +23 -0
  82. package/dist/sandbox/share.js +58 -0
  83. package/dist/sandbox/types.d.ts +2 -2
  84. package/dist/store/canonical.d.ts +5 -0
  85. package/dist/store/canonical.js +17 -0
  86. package/dist/store/cursor.d.ts +9 -0
  87. package/dist/store/cursor.js +18 -0
  88. package/dist/store/memory.d.ts +57 -0
  89. package/dist/store/memory.js +643 -0
  90. package/dist/store/ownership.d.ts +19 -0
  91. package/dist/store/ownership.js +33 -0
  92. package/dist/store/postgres/connect.d.ts +25 -0
  93. package/dist/store/postgres/connect.js +25 -0
  94. package/dist/store/postgres/locking.d.ts +16 -0
  95. package/dist/store/postgres/locking.js +22 -0
  96. package/dist/store/postgres/migrations/001_initial.d.ts +27 -0
  97. package/dist/store/postgres/migrations/001_initial.js +196 -0
  98. package/dist/store/postgres/migrations/index.d.ts +49 -0
  99. package/dist/store/postgres/migrations/index.js +75 -0
  100. package/dist/store/postgres/names.d.ts +11 -0
  101. package/dist/store/postgres/names.js +42 -0
  102. package/dist/store/postgres/store.d.ts +11 -0
  103. package/dist/store/postgres/store.js +790 -0
  104. package/dist/store/postgres/tenants.d.ts +90 -0
  105. package/dist/store/postgres/tenants.js +207 -0
  106. package/dist/store/types.d.ts +440 -0
  107. package/dist/store/types.js +10 -0
  108. package/dist/streams/basin.d.ts +23 -0
  109. package/dist/streams/basin.js +51 -0
  110. package/dist/streams/memory.d.ts +25 -0
  111. package/dist/streams/memory.js +124 -0
  112. package/dist/streams/relay.d.ts +37 -0
  113. package/dist/streams/relay.js +254 -0
  114. package/dist/streams/types.d.ts +115 -0
  115. package/dist/streams/types.js +86 -0
  116. package/dist/tenant/actions.d.ts +153 -0
  117. package/dist/tenant/actions.js +191 -0
  118. package/dist/tenant/advance.d.ts +15 -0
  119. package/dist/tenant/advance.js +439 -0
  120. package/dist/tenant/auth.d.ts +11 -0
  121. package/dist/tenant/auth.js +34 -0
  122. package/dist/tenant/commands.d.ts +3 -0
  123. package/dist/tenant/commands.js +384 -0
  124. package/dist/tenant/context.d.ts +140 -0
  125. package/dist/tenant/context.js +40 -0
  126. package/dist/tenant/effects.d.ts +20 -0
  127. package/dist/tenant/effects.js +539 -0
  128. package/dist/tenant/envelope.d.ts +7 -0
  129. package/dist/tenant/envelope.js +22 -0
  130. package/dist/tenant/ephemeral.d.ts +42 -0
  131. package/dist/tenant/ephemeral.js +224 -0
  132. package/dist/tenant/http.d.ts +32 -0
  133. package/dist/tenant/http.js +51 -0
  134. package/dist/tenant/live.d.ts +92 -0
  135. package/dist/tenant/live.js +290 -0
  136. package/dist/tenant/logger.d.ts +9 -0
  137. package/dist/tenant/logger.js +25 -0
  138. package/dist/tenant/model-setting.d.ts +16 -0
  139. package/dist/tenant/model-setting.js +12 -0
  140. package/dist/tenant/module.d.ts +38 -0
  141. package/dist/tenant/module.js +319 -0
  142. package/dist/tenant/paths.d.ts +28 -0
  143. package/dist/tenant/paths.js +91 -0
  144. package/dist/tenant/pool.d.ts +10 -0
  145. package/dist/tenant/pool.js +54 -0
  146. package/dist/tenant/principals.d.ts +13 -0
  147. package/dist/tenant/principals.js +37 -0
  148. package/dist/tenant/quarantine-error.d.ts +6 -0
  149. package/dist/tenant/quarantine-error.js +9 -0
  150. package/dist/tenant/quarantine.d.ts +27 -0
  151. package/dist/tenant/quarantine.js +72 -0
  152. package/dist/tenant/reset.d.ts +22 -0
  153. package/dist/tenant/reset.js +81 -0
  154. package/dist/tenant/routes-tenant.d.ts +12 -0
  155. package/dist/tenant/routes-tenant.js +126 -0
  156. package/dist/tenant/routes.d.ts +9 -0
  157. package/dist/tenant/routes.js +142 -0
  158. package/dist/tenant/runtime.d.ts +81 -0
  159. package/dist/tenant/runtime.js +298 -0
  160. package/dist/tenant/scheduler.d.ts +25 -0
  161. package/dist/tenant/scheduler.js +84 -0
  162. package/dist/tenant/session.d.ts +60 -0
  163. package/dist/tenant/session.js +94 -0
  164. package/dist/tenant/sessions.d.ts +34 -0
  165. package/dist/tenant/sessions.js +149 -0
  166. package/dist/tenant/status.d.ts +37 -0
  167. package/dist/tenant/status.js +130 -0
  168. package/dist/tenant/store-memory.d.ts +16 -0
  169. package/dist/tenant/store-memory.js +111 -0
  170. package/dist/tenant/store-pg.d.ts +15 -0
  171. package/dist/tenant/store-pg.js +140 -0
  172. package/dist/tenant/streams.d.ts +126 -0
  173. package/dist/tenant/streams.js +419 -0
  174. package/dist/tenant/sweep.d.ts +15 -0
  175. package/dist/tenant/sweep.js +122 -0
  176. package/dist/tenant/types.d.ts +205 -0
  177. package/dist/tenant/types.js +23 -0
  178. package/dist/tenant/worker.d.ts +84 -0
  179. package/dist/tenant/worker.js +189 -0
  180. package/dist/vault/kek.d.ts +5 -2
  181. package/dist/vault/kek.js +7 -10
  182. package/dist/vault/service.d.ts +37 -31
  183. package/dist/vault/service.js +439 -359
  184. package/dist/version.d.ts +1 -1
  185. package/dist/version.js +1 -1
  186. package/package.json +14 -11
  187. package/dist/adapters/sandbox/microsandbox.d.ts +0 -2
  188. package/dist/adapters/sandbox/microsandbox.js +0 -238
  189. package/dist/core/main.js +0 -35
  190. package/dist/core/store.d.ts +0 -30
  191. package/dist/core/store.js +0 -202
  192. /package/dist/{core/main.d.ts → host/config.js} +0 -0
@@ -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/assets.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- /** Resolve authored catalogs and subprocess assets in source or a built application. */
2
- export declare function projectAsset(path: string): string;
1
+ /** Resolve authored catalogs and subprocess assets under an explicit project root. */
2
+ export declare function projectAsset(path: string, root?: string): string;
package/dist/assets.js CHANGED
@@ -1,8 +1,10 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { resolve, relative } from "node:path";
3
- /** Resolve authored catalogs and subprocess assets in source or a built application. */
4
- export function projectAsset(path) {
5
- const root = process.cwd();
3
+ /** Resolve authored catalogs and subprocess assets under an explicit project root. */
4
+ export function projectAsset(path, root) {
5
+ if (!root) {
6
+ throw new Error("projectAsset requires an explicit project root");
7
+ }
6
8
  const source = resolve(root, path);
7
9
  if (relative(root, source).startsWith(".."))
8
10
  throw new Error("Asset must be inside the project.");
@@ -1,21 +1,30 @@
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
- * SQLite at rest. This is not a password KDF and must not be used for one.
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 {
9
10
  readonly tokenHash: string;
10
- /** False for scopes supplied through NYLORUN_EXECUTORS_JSON, which are never written to SQLite. */
11
+ /** Always true under Runtime Tenants; startup env executors are removed (D8). */
11
12
  readonly persisted: boolean;
12
13
  readonly updatedAt: string;
14
+ /** Application principal that last registered this executor, when known. */
15
+ readonly principalId?: string;
13
16
  }
17
+ /**
18
+ * Rejects tokens that are weak, empty of agent identity, or whose hash equals any
19
+ * application principal hash (A5). Replaces the old `serverToken` equality check.
20
+ */
14
21
  export declare function assertExecutorCredential(executor: {
15
22
  agentId: string;
16
23
  implementationVersion: string;
17
24
  token: string;
18
- }, serverToken: string): void;
25
+ }, applicationHashes: readonly string[]): void;
26
+ /** Mint a 32-byte hex application/executor key for tests and CLI bootstrap. */
27
+ export declare function mintBearerToken(): string;
19
28
  export declare class ExecutorRegistry {
20
29
  private readonly byHash;
21
30
  private readonly byAgent;
@@ -26,7 +35,10 @@ export declare class ExecutorRegistry {
26
35
  upsert(record: ExecutorRecord): {
27
36
  rotated: boolean;
28
37
  previousHash?: string;
38
+ previousPrincipalId?: string;
29
39
  };
30
40
  remove(agentId: string): ExecutorRecord | undefined;
31
41
  private write;
32
42
  }
43
+ /** A registry seeded from the executors the store persisted (at Tenant open). */
44
+ export declare function loadExecutorRegistry(store: SessionStore): Promise<ExecutorRegistry>;
@@ -1,4 +1,4 @@
1
- import { createHash, timingSafeEqual } from "node:crypto";
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
2
  const equals = (a, b) => {
3
3
  const aa = Buffer.from(a), bb = Buffer.from(b);
4
4
  return aa.length === bb.length && timingSafeEqual(aa, bb);
@@ -6,16 +6,26 @@ 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
- * SQLite at rest. This is not a password KDF and must not be used for one.
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
- export function assertExecutorCredential(executor, serverToken) {
12
+ /**
13
+ * Rejects tokens that are weak, empty of agent identity, or whose hash equals any
14
+ * application principal hash (A5). Replaces the old `serverToken` equality check.
15
+ */
16
+ export function assertExecutorCredential(executor, applicationHashes) {
17
+ const tokenHash = hashToken(executor.token);
18
+ const matchesApplication = applicationHashes.some((hash) => equals(hash, tokenHash));
13
19
  if (executor.token.length < 16 ||
14
- equals(executor.token, serverToken) ||
20
+ matchesApplication ||
15
21
  !executor.agentId ||
16
22
  !executor.implementationVersion)
17
23
  throw new Error("Executor tokens require independent credentials and an agent id");
18
24
  }
25
+ /** Mint a 32-byte hex application/executor key for tests and CLI bootstrap. */
26
+ export function mintBearerToken() {
27
+ return randomBytes(32).toString("hex");
28
+ }
19
29
  export class ExecutorRegistry {
20
30
  byHash = new Map();
21
31
  byAgent = new Map();
@@ -40,7 +50,13 @@ export class ExecutorRegistry {
40
50
  this.write(record);
41
51
  if (!previous || previous.tokenHash === record.tokenHash)
42
52
  return { rotated: false };
43
- return { rotated: true, previousHash: previous.tokenHash };
53
+ return {
54
+ rotated: true,
55
+ previousHash: previous.tokenHash,
56
+ ...(previous.principalId === undefined
57
+ ? {}
58
+ : { previousPrincipalId: previous.principalId }),
59
+ };
44
60
  }
45
61
  remove(agentId) {
46
62
  const record = this.byAgent.get(agentId);
@@ -58,3 +74,22 @@ export class ExecutorRegistry {
58
74
  this.byHash.set(record.tokenHash, record);
59
75
  }
60
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
+ }