@nylorun/runtime 0.8.0-beta → 0.10.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 (130) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +96 -25
  3. package/dist/adapters/media.d.ts +2 -2
  4. package/dist/adapters/media.js +2 -2
  5. package/dist/adapters/observe.d.ts +2 -1
  6. package/dist/adapters/observe.js +2 -3
  7. package/dist/assets.d.ts +2 -2
  8. package/dist/assets.js +5 -3
  9. package/dist/core/executors.d.ts +11 -2
  10. package/dist/core/executors.js +20 -4
  11. package/dist/core/flow-host.d.ts +264 -0
  12. package/dist/core/flow-host.js +783 -0
  13. package/dist/core/limits.d.ts +40 -0
  14. package/dist/core/limits.js +76 -0
  15. package/dist/core/runtime.d.ts +5 -90
  16. package/dist/core/runtime.js +3 -1418
  17. package/dist/core/sandbox-routes.d.ts +35 -0
  18. package/dist/core/sandbox-routes.js +106 -0
  19. package/dist/core/store.d.ts +5 -2
  20. package/dist/core/store.js +20 -72
  21. package/dist/core/turn-manifest.d.ts +50 -0
  22. package/dist/core/turn-manifest.js +66 -0
  23. package/dist/host/config-for.d.ts +16 -0
  24. package/dist/host/config-for.js +45 -0
  25. package/dist/host/config.d.ts +24 -0
  26. package/dist/host/create-host.d.ts +21 -0
  27. package/dist/host/create-host.js +396 -0
  28. package/dist/host/environment.d.ts +13 -0
  29. package/dist/host/environment.js +53 -0
  30. package/dist/host/http.d.ts +35 -0
  31. package/dist/host/http.js +136 -0
  32. package/dist/host/index.d.ts +6 -0
  33. package/dist/host/index.js +5 -0
  34. package/dist/host/logger.d.ts +3 -0
  35. package/dist/host/logger.js +18 -0
  36. package/dist/host/main.d.ts +3 -0
  37. package/dist/host/main.js +142 -0
  38. package/dist/index.d.ts +7 -2
  39. package/dist/index.js +4 -2
  40. package/dist/launcher/commands.d.ts +24 -0
  41. package/dist/launcher/commands.js +260 -0
  42. package/dist/launcher/ensure-config.d.ts +16 -0
  43. package/dist/launcher/ensure-config.js +79 -0
  44. package/dist/launcher/errors.d.ts +12 -0
  45. package/dist/launcher/errors.js +22 -0
  46. package/dist/launcher/host-config.d.ts +17 -0
  47. package/dist/launcher/host-config.js +64 -0
  48. package/dist/launcher/host-state.d.ts +17 -0
  49. package/dist/launcher/host-state.js +61 -0
  50. package/dist/launcher/lifecycle.d.ts +66 -0
  51. package/dist/launcher/lifecycle.js +407 -0
  52. package/dist/launcher/locks.d.ts +26 -0
  53. package/dist/launcher/locks.js +117 -0
  54. package/dist/launcher/logs.d.ts +18 -0
  55. package/dist/launcher/logs.js +117 -0
  56. package/dist/launcher/main.d.ts +2 -0
  57. package/dist/launcher/main.js +66 -0
  58. package/dist/launcher/output.d.ts +11 -0
  59. package/dist/launcher/output.js +68 -0
  60. package/dist/launcher/paths.d.ts +20 -0
  61. package/dist/launcher/paths.js +36 -0
  62. package/dist/launcher/probe.d.ts +23 -0
  63. package/dist/launcher/probe.js +72 -0
  64. package/dist/launcher/protocol.d.ts +62 -0
  65. package/dist/launcher/protocol.js +1 -0
  66. package/dist/launcher/schema-guard.d.ts +6 -0
  67. package/dist/launcher/schema-guard.js +36 -0
  68. package/dist/launcher/spawn.d.ts +22 -0
  69. package/dist/launcher/spawn.js +85 -0
  70. package/dist/launcher/status.d.ts +6 -0
  71. package/dist/launcher/status.js +65 -0
  72. package/dist/mcp/connect.d.ts +1 -0
  73. package/dist/mcp/connect.js +2 -0
  74. package/dist/mcp/pool.d.ts +16 -2
  75. package/dist/mcp/pool.js +54 -19
  76. package/dist/mcp/snapshot.d.ts +4 -0
  77. package/dist/mcp/stdio.d.ts +2 -0
  78. package/dist/mcp/stdio.js +3 -1
  79. package/dist/model/auth-store.d.ts +1 -1
  80. package/dist/model/auth-store.js +2 -2
  81. package/dist/model/models.d.ts +4 -0
  82. package/dist/model/models.js +22 -26
  83. package/dist/model/pi-model.js +1 -1
  84. package/dist/model/settings.d.ts +5 -1
  85. package/dist/model/settings.js +6 -2
  86. package/dist/sandbox/select.d.ts +4 -3
  87. package/dist/sandbox/select.js +15 -9
  88. package/dist/sandbox/share.d.ts +23 -0
  89. package/dist/sandbox/share.js +58 -0
  90. package/dist/tenant/envelope.d.ts +10 -0
  91. package/dist/tenant/envelope.js +54 -0
  92. package/dist/tenant/ephemeral.d.ts +31 -0
  93. package/dist/tenant/ephemeral.js +156 -0
  94. package/dist/tenant/logger.d.ts +9 -0
  95. package/dist/tenant/logger.js +25 -0
  96. package/dist/tenant/migration.d.ts +16 -0
  97. package/dist/tenant/migration.js +116 -0
  98. package/dist/tenant/module.d.ts +17 -0
  99. package/dist/tenant/module.js +315 -0
  100. package/dist/tenant/paths.d.ts +24 -0
  101. package/dist/tenant/paths.js +65 -0
  102. package/dist/tenant/pool.d.ts +10 -0
  103. package/dist/tenant/pool.js +54 -0
  104. package/dist/tenant/principals.d.ts +17 -0
  105. package/dist/tenant/principals.js +44 -0
  106. package/dist/tenant/quarantine-error.d.ts +6 -0
  107. package/dist/tenant/quarantine-error.js +15 -0
  108. package/dist/tenant/quarantine.d.ts +35 -0
  109. package/dist/tenant/quarantine.js +94 -0
  110. package/dist/tenant/reset.d.ts +21 -0
  111. package/dist/tenant/reset.js +125 -0
  112. package/dist/tenant/runtime.d.ts +80 -0
  113. package/dist/tenant/runtime.js +2256 -0
  114. package/dist/tenant/schema.d.ts +23 -0
  115. package/dist/tenant/schema.js +151 -0
  116. package/dist/tenant/status.d.ts +28 -0
  117. package/dist/tenant/status.js +95 -0
  118. package/dist/tenant/store-fs.d.ts +16 -0
  119. package/dist/tenant/store-fs.js +205 -0
  120. package/dist/tenant/store-memory.d.ts +19 -0
  121. package/dist/tenant/store-memory.js +100 -0
  122. package/dist/tenant/types.d.ts +125 -0
  123. package/dist/tenant/types.js +1 -0
  124. package/dist/vault/kek.d.ts +5 -2
  125. package/dist/vault/kek.js +7 -10
  126. package/dist/version.d.ts +1 -1
  127. package/dist/version.js +1 -1
  128. package/package.json +9 -6
  129. package/dist/core/main.js +0 -35
  130. /package/dist/{core/main.d.ts → host/config.js} +0 -0
@@ -0,0 +1,315 @@
1
+ import { existsSync } from "node:fs";
2
+ import { isTenantId } from "@nylorun/core/compatibility";
3
+ import { TENANT_SCHEMA_VERSION } from "./schema.js";
4
+ import { envelopeNow } from "./envelope.js";
5
+ import { tenantPaths } from "./paths.js";
6
+ import { mapPool, TimeoutError, withTimeout } from "./pool.js";
7
+ import { asQuarantine, isQuarantineError, quarantine, TenantBusyError, TenantConflictError, } from "./quarantine.js";
8
+ const OPEN_CONCURRENCY = 4;
9
+ const OPEN_TIMEOUT_MS = 30_000;
10
+ function hasLiveWork(summary) {
11
+ return (summary.runningSessions > 0 ||
12
+ summary.connectedExecutors > 0 ||
13
+ summary.pendingActions > 0);
14
+ }
15
+ function toAdmin(id, entry, envelope) {
16
+ if (!entry) {
17
+ return {
18
+ id,
19
+ name: envelope?.name ?? null,
20
+ state: "quarantined",
21
+ envelope,
22
+ };
23
+ }
24
+ if (entry.kind === "open") {
25
+ return {
26
+ id,
27
+ name: entry.handle.envelope.name,
28
+ state: "open",
29
+ envelope: entry.handle.envelope,
30
+ };
31
+ }
32
+ return {
33
+ id,
34
+ name: envelope?.name ?? null,
35
+ state: "quarantined",
36
+ envelope,
37
+ };
38
+ }
39
+ /**
40
+ * Deep Tenant module (§8). Discovery, create, resolve, list, delete and
41
+ * summarize. Storage and Tenant Runtime are injected so WS-B tests without WS-A.
42
+ */
43
+ export function createTenantModule(options) {
44
+ const { hostRoot, store, logger, openConcurrency = OPEN_CONCURRENCY, openTimeoutMs = OPEN_TIMEOUT_MS, } = options;
45
+ // openRuntime / configFor are consumed by the store; kept on options for B1.
46
+ void options.openRuntime;
47
+ void options.configFor;
48
+ const entries = new Map();
49
+ let started = false;
50
+ let closed = false;
51
+ function rememberOpen(handle) {
52
+ entries.set(handle.envelope.id, { kind: "open", handle });
53
+ }
54
+ function rememberQuarantine(id, q) {
55
+ entries.set(id, { kind: "quarantined", quarantine: q });
56
+ logger.warn("tenant quarantined", {
57
+ tenantId: id,
58
+ code: q.code,
59
+ repair: q.repair,
60
+ });
61
+ }
62
+ async function openOne(id) {
63
+ const existing = entries.get(id);
64
+ if (existing?.kind === "open")
65
+ return;
66
+ try {
67
+ const handle = await withTimeout(store.open(id), openTimeoutMs, (late) => {
68
+ void late
69
+ .then((h) => h.close())
70
+ .catch(() => undefined);
71
+ });
72
+ rememberOpen(handle);
73
+ }
74
+ catch (error) {
75
+ if (error instanceof TimeoutError) {
76
+ rememberQuarantine(id, quarantine("open-timeout", `open timed out after ${openTimeoutMs}ms`, {
77
+ tenantId: id,
78
+ }).toQuarantine());
79
+ return;
80
+ }
81
+ const q = asQuarantine(error);
82
+ if (q) {
83
+ rememberQuarantine(id, q);
84
+ return;
85
+ }
86
+ rememberQuarantine(id, quarantine("open-failed", error instanceof Error ? error.message : "open failed", { tenantId: id }).toQuarantine());
87
+ }
88
+ }
89
+ async function discoverId(id) {
90
+ if (entries.has(id))
91
+ return entries.get(id);
92
+ const paths = tenantPaths(hostRoot, id);
93
+ if (!existsSync(paths.root))
94
+ return undefined;
95
+ await openOne(id);
96
+ return entries.get(id);
97
+ }
98
+ const module = {
99
+ get started() {
100
+ return started;
101
+ },
102
+ async start() {
103
+ if (closed)
104
+ throw new Error("Tenant module is closed");
105
+ const ids = await store.enumerate();
106
+ await mapPool(ids, openConcurrency, async (id) => {
107
+ await openOne(id);
108
+ });
109
+ started = true;
110
+ logger.info("tenant module started", {
111
+ tenants: ids.length,
112
+ open: [...entries.values()].filter((e) => e.kind === "open").length,
113
+ quarantined: [...entries.values()].filter((e) => e.kind === "quarantined").length,
114
+ });
115
+ },
116
+ resolve(id) {
117
+ if (!isTenantId(id))
118
+ return { kind: "not-found" };
119
+ const entry = entries.get(id);
120
+ if (!entry) {
121
+ // Sync miss: existence check only (disk I/O allowed on miss). Full open
122
+ // happens on list()/status()/create(); Host sees opaque not-found until then.
123
+ try {
124
+ const paths = tenantPaths(hostRoot, id);
125
+ if (!existsSync(paths.root))
126
+ return { kind: "not-found" };
127
+ }
128
+ catch {
129
+ return { kind: "not-found" };
130
+ }
131
+ return { kind: "not-found" };
132
+ }
133
+ if (entry.kind === "open") {
134
+ return { kind: "open", handle: entry.handle };
135
+ }
136
+ return { kind: "quarantined", quarantine: entry.quarantine };
137
+ },
138
+ async create(input) {
139
+ if (!isTenantId(input.tenantId)) {
140
+ throw new Error(`Invalid tenant id: ${input.tenantId}`);
141
+ }
142
+ const bootstrap = {
143
+ principalId: input.principalId,
144
+ credentialHash: input.credentialHash,
145
+ idempotencyKey: input.idempotencyKey,
146
+ };
147
+ const envelope = envelopeNow({
148
+ id: input.tenantId,
149
+ name: input.name,
150
+ schemaVersion: TENANT_SCHEMA_VERSION,
151
+ });
152
+ let outcome;
153
+ try {
154
+ outcome = await store.create(envelope, bootstrap);
155
+ }
156
+ catch (error) {
157
+ await store.removePartial(input.tenantId).catch(() => undefined);
158
+ throw error;
159
+ }
160
+ if (outcome === "exists") {
161
+ const matches = await store.bootstrapMatches(input.tenantId, bootstrap);
162
+ if (!matches)
163
+ throw new TenantConflictError();
164
+ const existing = await store.readEnvelope(input.tenantId);
165
+ const entry = entries.get(input.tenantId);
166
+ if (!entry || entry.kind !== "open") {
167
+ await openOne(input.tenantId);
168
+ }
169
+ return { envelope: existing, created: false };
170
+ }
171
+ await openOne(input.tenantId);
172
+ const opened = entries.get(input.tenantId);
173
+ if (opened?.kind === "open") {
174
+ return { envelope: opened.handle.envelope, created: true };
175
+ }
176
+ // Created on disk but failed to open — still report the written envelope.
177
+ return { envelope, created: true };
178
+ },
179
+ async list() {
180
+ const ids = new Set(await store.enumerate());
181
+ for (const id of entries.keys())
182
+ ids.add(id);
183
+ const result = [];
184
+ for (const id of ids) {
185
+ if (!isTenantId(id))
186
+ continue;
187
+ let entry = entries.get(id);
188
+ if (!entry) {
189
+ await openOne(id);
190
+ entry = entries.get(id);
191
+ }
192
+ let envelope = null;
193
+ try {
194
+ envelope = await store.readEnvelope(id);
195
+ if (envelope.id !== id) {
196
+ rememberQuarantine(id, quarantine("envelope-invalid", `tenant.json id ${envelope.id} does not match directory ${id}`, { tenantId: id }).toQuarantine());
197
+ entry = entries.get(id);
198
+ envelope = null;
199
+ }
200
+ }
201
+ catch (error) {
202
+ const q = asQuarantine(error);
203
+ if (q) {
204
+ rememberQuarantine(id, q);
205
+ entry = entries.get(id);
206
+ }
207
+ else {
208
+ rememberQuarantine(id, quarantine("envelope-invalid", "tenant.json is unreadable", { tenantId: id }).toQuarantine());
209
+ entry = entries.get(id);
210
+ }
211
+ envelope = null;
212
+ }
213
+ result.push(toAdmin(id, entry, envelope));
214
+ }
215
+ return result;
216
+ },
217
+ async status(id) {
218
+ if (!isTenantId(id))
219
+ return undefined;
220
+ let entry = entries.get(id);
221
+ if (!entry) {
222
+ entry = await discoverId(id);
223
+ if (!entry) {
224
+ // Not in memory and not on disk.
225
+ const known = (await store.enumerate()).includes(id);
226
+ if (!known)
227
+ return undefined;
228
+ await openOne(id);
229
+ entry = entries.get(id);
230
+ }
231
+ }
232
+ if (!entry)
233
+ return undefined;
234
+ let envelope = null;
235
+ try {
236
+ envelope =
237
+ entry.kind === "open"
238
+ ? entry.handle.envelope
239
+ : await store.readEnvelope(id);
240
+ }
241
+ catch {
242
+ envelope = null;
243
+ }
244
+ const base = toAdmin(id, entry, envelope);
245
+ if (entry.kind === "quarantined") {
246
+ return { ...base, quarantine: entry.quarantine };
247
+ }
248
+ return base;
249
+ },
250
+ async delete(id, activeWork) {
251
+ if (!isTenantId(id))
252
+ throw new Error(`Invalid tenant id: ${id}`);
253
+ let entry = entries.get(id);
254
+ if (!entry) {
255
+ await discoverId(id);
256
+ entry = entries.get(id);
257
+ }
258
+ if (!entry) {
259
+ // Still on disk without an entry?
260
+ const ids = await store.enumerate();
261
+ if (!ids.includes(id)) {
262
+ throw quarantine("open-failed", `Tenant ${id} not found`, {
263
+ tenantId: id,
264
+ });
265
+ }
266
+ await openOne(id);
267
+ entry = entries.get(id);
268
+ }
269
+ if (entry?.kind === "open") {
270
+ const summary = entry.handle.summary();
271
+ if (activeWork === "refuse" && hasLiveWork(summary)) {
272
+ throw new TenantBusyError();
273
+ }
274
+ if (activeWork === "drain" || activeWork === "cancel") {
275
+ await entry.handle.drain(activeWork);
276
+ }
277
+ // Sandbox prefix cleanup is owned by TenantHandle.close() (WS-A).
278
+ await entry.handle.close();
279
+ }
280
+ await store.trash(id, new Date());
281
+ entries.delete(id);
282
+ logger.info("tenant deleted", { tenantId: id, activeWork });
283
+ },
284
+ summarize() {
285
+ let runningSessions = 0;
286
+ let connectedExecutors = 0;
287
+ let pendingActions = 0;
288
+ let uncertainEffects = 0;
289
+ for (const entry of entries.values()) {
290
+ if (entry.kind !== "open")
291
+ continue;
292
+ const s = entry.handle.summary();
293
+ runningSessions += s.runningSessions;
294
+ connectedExecutors += s.connectedExecutors;
295
+ pendingActions += s.pendingActions;
296
+ uncertainEffects += s.uncertainEffects;
297
+ }
298
+ return {
299
+ runningSessions,
300
+ connectedExecutors,
301
+ pendingActions,
302
+ uncertainEffects,
303
+ };
304
+ },
305
+ async close() {
306
+ closed = true;
307
+ started = false;
308
+ const open = [...entries.values()].filter((e) => e.kind === "open");
309
+ await Promise.all(open.map((e) => e.handle.close().catch(() => undefined)));
310
+ entries.clear();
311
+ },
312
+ };
313
+ return module;
314
+ }
315
+ export { TenantBusyError, TenantConflictError, isQuarantineError, quarantine, };
@@ -0,0 +1,24 @@
1
+ import type { TenantPaths } from "./types.js";
2
+ export interface HostPaths {
3
+ root: string;
4
+ config: string;
5
+ state: string;
6
+ credentials: string;
7
+ log: string;
8
+ home: string;
9
+ tmp: string;
10
+ runtime: string;
11
+ tenants: string;
12
+ trash: string;
13
+ }
14
+ /** Host layout under NYLORUN_HOME / ~/.nylorun. */
15
+ export declare function hostPaths(hostRoot: string): HostPaths;
16
+ /**
17
+ * Absolute Tenant paths under `<hostRoot>/tenants/<tenantId>/`.
18
+ * Validates the id and asserts containment after realpath.
19
+ *
20
+ * Basenames frozen here for Wave 0 (design Rev 3 absent):
21
+ * `tenant.json`, `tenant.sqlite`, `.runtime-lock`, `vault-kek`,
22
+ * `home/`, `tmp/`, `.migration/`, `sandboxes/`, `plugin-data/`, `logs/tenant.log`.
23
+ */
24
+ export declare function tenantPaths(hostRoot: string, tenantId: string): TenantPaths;
@@ -0,0 +1,65 @@
1
+ import { existsSync, mkdirSync, realpathSync } from "node:fs";
2
+ import { join, resolve, sep } from "node:path";
3
+ import { isTenantId } from "@nylorun/core/compatibility";
4
+ function ensureDir(path) {
5
+ mkdirSync(path, { recursive: true });
6
+ return realpathSync(path);
7
+ }
8
+ function assertContained(parent, child) {
9
+ const root = parent.endsWith(sep) ? parent : parent + sep;
10
+ if (child !== parent && !child.startsWith(root)) {
11
+ throw new Error(`Path ${child} escapes host tenants root ${parent}`);
12
+ }
13
+ }
14
+ /** Host layout under NYLORUN_HOME / ~/.nylorun. */
15
+ export function hostPaths(hostRoot) {
16
+ const root = resolve(hostRoot);
17
+ return {
18
+ root,
19
+ config: join(root, "host.json"),
20
+ state: join(root, "host-state.json"),
21
+ credentials: join(root, "host-credentials.json"),
22
+ log: join(root, "runtime.log"),
23
+ home: join(root, "home"),
24
+ tmp: join(root, "tmp"),
25
+ runtime: join(root, "runtime"),
26
+ tenants: join(root, "tenants"),
27
+ trash: join(root, "trash"),
28
+ };
29
+ }
30
+ /**
31
+ * Absolute Tenant paths under `<hostRoot>/tenants/<tenantId>/`.
32
+ * Validates the id and asserts containment after realpath.
33
+ *
34
+ * Basenames frozen here for Wave 0 (design Rev 3 absent):
35
+ * `tenant.json`, `tenant.sqlite`, `.runtime-lock`, `vault-kek`,
36
+ * `home/`, `tmp/`, `.migration/`, `sandboxes/`, `plugin-data/`, `logs/tenant.log`.
37
+ */
38
+ export function tenantPaths(hostRoot, tenantId) {
39
+ if (!isTenantId(tenantId)) {
40
+ throw new Error(`Invalid tenant id: ${String(tenantId)}`);
41
+ }
42
+ const host = hostPaths(hostRoot);
43
+ const tenantsRoot = ensureDir(host.tenants);
44
+ const root = join(tenantsRoot, tenantId);
45
+ // Containment before the Tenant directory exists: resolve under real tenants root.
46
+ const resolvedRoot = resolve(tenantsRoot, tenantId);
47
+ assertContained(tenantsRoot, resolvedRoot);
48
+ if (existsSync(root)) {
49
+ assertContained(tenantsRoot, realpathSync(root));
50
+ }
51
+ return {
52
+ root: resolvedRoot,
53
+ envelope: join(resolvedRoot, "tenant.json"),
54
+ database: join(resolvedRoot, "tenant.sqlite"),
55
+ lock: join(resolvedRoot, ".runtime-lock"),
56
+ kek: join(resolvedRoot, "vault-kek"),
57
+ home: join(resolvedRoot, "home"),
58
+ tmp: join(resolvedRoot, "tmp"),
59
+ migration: join(resolvedRoot, ".migration"),
60
+ sandboxes: join(resolvedRoot, "sandboxes"),
61
+ pluginData: join(resolvedRoot, "plugin-data"),
62
+ logs: join(resolvedRoot, "logs"),
63
+ log: join(resolvedRoot, "logs", "tenant.log"),
64
+ };
65
+ }
@@ -0,0 +1,10 @@
1
+ /** Run async work over items with a fixed concurrency limit. */
2
+ export declare function mapPool<T, R>(items: readonly T[], concurrency: number, worker: (item: T, index: number) => Promise<R>): Promise<R[]>;
3
+ export declare class TimeoutError extends Error {
4
+ constructor(message?: string);
5
+ }
6
+ /**
7
+ * Race a promise against a timeout. On timeout the late result is handed to
8
+ * `onLate` (Risk R6: mark quarantined and close a late handle).
9
+ */
10
+ export declare function withTimeout<T>(promise: Promise<T>, timeoutMs: number, onLate?: (late: Promise<T>) => void): Promise<T>;
@@ -0,0 +1,54 @@
1
+ /** Run async work over items with a fixed concurrency limit. */
2
+ export async function mapPool(items, concurrency, worker) {
3
+ if (items.length === 0)
4
+ return [];
5
+ const limit = Math.max(1, Math.min(concurrency, items.length));
6
+ const results = new Array(items.length);
7
+ let next = 0;
8
+ async function run() {
9
+ for (;;) {
10
+ const index = next++;
11
+ if (index >= items.length)
12
+ return;
13
+ results[index] = await worker(items[index], index);
14
+ }
15
+ }
16
+ await Promise.all(Array.from({ length: limit }, () => run()));
17
+ return results;
18
+ }
19
+ export class TimeoutError extends Error {
20
+ constructor(message = "operation timed out") {
21
+ super(message);
22
+ this.name = "TimeoutError";
23
+ }
24
+ }
25
+ /**
26
+ * Race a promise against a timeout. On timeout the late result is handed to
27
+ * `onLate` (Risk R6: mark quarantined and close a late handle).
28
+ */
29
+ export async function withTimeout(promise, timeoutMs, onLate) {
30
+ let timer;
31
+ let timedOut = false;
32
+ const timeout = new Promise((_, reject) => {
33
+ timer = setTimeout(() => {
34
+ timedOut = true;
35
+ reject(new TimeoutError(`timed out after ${timeoutMs}ms`));
36
+ }, timeoutMs);
37
+ });
38
+ try {
39
+ const result = await Promise.race([promise, timeout]);
40
+ return result;
41
+ }
42
+ catch (error) {
43
+ if (timedOut && onLate) {
44
+ onLate(promise.then((value) => value, (lateError) => {
45
+ throw lateError;
46
+ }));
47
+ }
48
+ throw error;
49
+ }
50
+ finally {
51
+ if (timer !== undefined)
52
+ clearTimeout(timer);
53
+ }
54
+ }
@@ -0,0 +1,17 @@
1
+ import type { DatabaseSync } from "node:sqlite";
2
+ import type { BootstrapPrincipal } from "./types.js";
3
+ export interface PrincipalRow {
4
+ id: string;
5
+ role: "application";
6
+ tokenHash: string;
7
+ idempotencyKey: string | null;
8
+ createdAt: string;
9
+ }
10
+ /**
11
+ * Writes the application principal and idempotency key in one transaction.
12
+ * Exported for WS-B's `TenantStore.create` (A3).
13
+ */
14
+ export declare function bootstrapPrincipal(db: DatabaseSync, bootstrap: BootstrapPrincipal): void;
15
+ export declare function findPrincipalByTokenHash(db: DatabaseSync, tokenHash: string): PrincipalRow | undefined;
16
+ export declare function applicationTokenHashes(db: DatabaseSync): readonly string[];
17
+ export declare function isApplicationTokenHash(db: DatabaseSync, tokenHash: string): boolean;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Writes the application principal and idempotency key in one transaction.
3
+ * Exported for WS-B's `TenantStore.create` (A3).
4
+ */
5
+ export function bootstrapPrincipal(db, bootstrap) {
6
+ const now = new Date().toISOString();
7
+ db.exec("BEGIN IMMEDIATE");
8
+ try {
9
+ db.prepare(`INSERT INTO principals(id, role, token_hash, idempotency_key, created_at)
10
+ VALUES(?, 'application', ?, ?, ?)`).run(bootstrap.principalId, bootstrap.credentialHash, bootstrap.idempotencyKey, now);
11
+ db.exec("COMMIT");
12
+ }
13
+ catch (error) {
14
+ db.exec("ROLLBACK");
15
+ throw error;
16
+ }
17
+ }
18
+ export function findPrincipalByTokenHash(db, tokenHash) {
19
+ const row = db
20
+ .prepare(`SELECT id, role, token_hash, idempotency_key, created_at
21
+ FROM principals WHERE token_hash = ?`)
22
+ .get(tokenHash);
23
+ if (!row)
24
+ return undefined;
25
+ return {
26
+ id: String(row.id),
27
+ role: "application",
28
+ tokenHash: String(row.token_hash),
29
+ idempotencyKey: row.idempotency_key == null ? null : String(row.idempotency_key),
30
+ createdAt: String(row.created_at),
31
+ };
32
+ }
33
+ export function applicationTokenHashes(db) {
34
+ return db
35
+ .prepare(`SELECT token_hash FROM principals WHERE role = 'application'`)
36
+ .all()
37
+ .map((row) => String(row.token_hash));
38
+ }
39
+ export function isApplicationTokenHash(db, tokenHash) {
40
+ const row = db
41
+ .prepare(`SELECT 1 AS ok FROM principals WHERE role = 'application' AND token_hash = ?`)
42
+ .get(tokenHash);
43
+ return !!row;
44
+ }
@@ -0,0 +1,6 @@
1
+ import type { Quarantine } from "./types.js";
2
+ /** Typed quarantine raised while opening a Tenant Runtime (A7). */
3
+ export declare class QuarantineError extends Error {
4
+ readonly quarantine: Quarantine;
5
+ constructor(code: Quarantine["code"], message: string, repair?: string, extra?: Pick<Quarantine, "lockPath" | "lockPid">);
6
+ }
@@ -0,0 +1,15 @@
1
+ /** Typed quarantine raised while opening a Tenant Runtime (A7). */
2
+ export class QuarantineError extends Error {
3
+ quarantine;
4
+ constructor(code, message, repair = "nylorun tenant status", extra) {
5
+ super(message);
6
+ this.name = "QuarantineError";
7
+ this.quarantine = {
8
+ code,
9
+ message,
10
+ repair,
11
+ ...(extra?.lockPath === undefined ? {} : { lockPath: extra.lockPath }),
12
+ ...(extra?.lockPid === undefined ? {} : { lockPid: extra.lockPid }),
13
+ };
14
+ }
15
+ }
@@ -0,0 +1,35 @@
1
+ import type { Quarantine } from "./types.js";
2
+ /** Typed quarantine failure thrown by stores and migration. */
3
+ export declare class QuarantineError extends Error implements Quarantine {
4
+ readonly code: Quarantine["code"];
5
+ readonly repair: string;
6
+ readonly lockPath?: string;
7
+ readonly lockPid?: number;
8
+ constructor(fields: Quarantine);
9
+ toQuarantine(): Quarantine;
10
+ }
11
+ export declare function isQuarantineError(error: unknown): error is QuarantineError;
12
+ export declare function asQuarantine(error: unknown): Quarantine | undefined;
13
+ /** CLI-facing repair strings for every quarantine code (B9). */
14
+ export declare function repairFor(code: Quarantine["code"], detail?: {
15
+ tenantId?: string;
16
+ lockPath?: string;
17
+ lockPid?: number;
18
+ migrationPath?: string;
19
+ }): string;
20
+ export declare function quarantine(code: Quarantine["code"], message: string, detail?: {
21
+ tenantId?: string;
22
+ lockPath?: string;
23
+ lockPid?: number;
24
+ migrationPath?: string;
25
+ }): QuarantineError;
26
+ /** Conflict when create bootstrap material does not match an existing Tenant (HTTP 409). */
27
+ export declare class TenantConflictError extends Error {
28
+ readonly statusCode: 409;
29
+ constructor(message?: string);
30
+ }
31
+ /** Delete refused because the Tenant still has live work (HTTP 409). */
32
+ export declare class TenantBusyError extends Error {
33
+ readonly statusCode: 409;
34
+ constructor(message?: string);
35
+ }
@@ -0,0 +1,94 @@
1
+ /** Typed quarantine failure thrown by stores and migration. */
2
+ export class QuarantineError extends Error {
3
+ code;
4
+ repair;
5
+ lockPath;
6
+ lockPid;
7
+ constructor(fields) {
8
+ super(fields.message);
9
+ this.name = "QuarantineError";
10
+ this.code = fields.code;
11
+ this.repair = fields.repair;
12
+ if (fields.lockPath !== undefined)
13
+ this.lockPath = fields.lockPath;
14
+ if (fields.lockPid !== undefined)
15
+ this.lockPid = fields.lockPid;
16
+ }
17
+ toQuarantine() {
18
+ return {
19
+ code: this.code,
20
+ message: this.message,
21
+ repair: this.repair,
22
+ ...(this.lockPath !== undefined ? { lockPath: this.lockPath } : {}),
23
+ ...(this.lockPid !== undefined ? { lockPid: this.lockPid } : {}),
24
+ };
25
+ }
26
+ }
27
+ export function isQuarantineError(error) {
28
+ return error instanceof QuarantineError;
29
+ }
30
+ export function asQuarantine(error) {
31
+ if (isQuarantineError(error))
32
+ return error.toQuarantine();
33
+ if (error &&
34
+ typeof error === "object" &&
35
+ "code" in error &&
36
+ "message" in error &&
37
+ "repair" in error) {
38
+ return error;
39
+ }
40
+ return undefined;
41
+ }
42
+ /** CLI-facing repair strings for every quarantine code (B9). */
43
+ export function repairFor(code, detail = {}) {
44
+ const id = detail.tenantId ? ` ${detail.tenantId}` : "";
45
+ switch (code) {
46
+ case "locked":
47
+ return (`nylorun tenant status${id}` +
48
+ (detail.lockPath !== undefined && detail.lockPid !== undefined
49
+ ? ` — stop pid ${detail.lockPid} holding ${detail.lockPath}, or remove a stale lock`
50
+ : " — resolve the .runtime-lock conflict"));
51
+ case "kek-missing":
52
+ return `nylorun tenant status${id} — restore vault-kek for this Tenant (ciphertext cannot be opened without it)`;
53
+ case "corrupt":
54
+ return `nylorun tenant status${id} — repair or restore the Tenant directory from backup`;
55
+ case "schema-too-new":
56
+ return `nylorun runtime restart` +
57
+ `${id ? ` — Tenant${id} needs a newer Host` : " — Host schema is older than this Tenant"}`;
58
+ case "migration-failed":
59
+ return detail.migrationPath
60
+ ? `restore ${detail.migrationPath} over the Tenant files, then nylorun tenant status${id}`
61
+ : `nylorun tenant status${id} — restore from .migration/ snapshot, then retry`;
62
+ case "envelope-invalid":
63
+ return `nylorun tenant status${id} — fix or replace tenant.json so its id matches the directory name`;
64
+ case "open-timeout":
65
+ return `nylorun tenant status${id} — open timed out; inspect locks and SQLite, then retry`;
66
+ case "open-failed":
67
+ return `nylorun tenant status${id} — inspect Tenant logs and repair before the Host retries open`;
68
+ }
69
+ }
70
+ export function quarantine(code, message, detail = {}) {
71
+ return new QuarantineError({
72
+ code,
73
+ message,
74
+ repair: repairFor(code, detail),
75
+ ...(detail.lockPath !== undefined ? { lockPath: detail.lockPath } : {}),
76
+ ...(detail.lockPid !== undefined ? { lockPid: detail.lockPid } : {}),
77
+ });
78
+ }
79
+ /** Conflict when create bootstrap material does not match an existing Tenant (HTTP 409). */
80
+ export class TenantConflictError extends Error {
81
+ statusCode = 409;
82
+ constructor(message = "Tenant already exists with different bootstrap material") {
83
+ super(message);
84
+ this.name = "TenantConflictError";
85
+ }
86
+ }
87
+ /** Delete refused because the Tenant still has live work (HTTP 409). */
88
+ export class TenantBusyError extends Error {
89
+ statusCode = 409;
90
+ constructor(message = "Tenant has live work; pass activeWork=drain|cancel or retry later") {
91
+ super(message);
92
+ this.name = "TenantBusyError";
93
+ }
94
+ }