@nylorun/runtime 0.9.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 (129) hide show
  1. package/CHANGELOG.md +35 -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 -1447
  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 +3 -1
  20. package/dist/core/store.js +14 -73
  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 +3 -1
  75. package/dist/mcp/pool.js +2 -1
  76. package/dist/mcp/stdio.d.ts +2 -0
  77. package/dist/mcp/stdio.js +3 -1
  78. package/dist/model/auth-store.d.ts +1 -1
  79. package/dist/model/auth-store.js +2 -2
  80. package/dist/model/models.d.ts +4 -0
  81. package/dist/model/models.js +22 -26
  82. package/dist/model/pi-model.js +1 -1
  83. package/dist/model/settings.d.ts +5 -1
  84. package/dist/model/settings.js +6 -2
  85. package/dist/sandbox/select.d.ts +4 -3
  86. package/dist/sandbox/select.js +15 -9
  87. package/dist/sandbox/share.d.ts +23 -0
  88. package/dist/sandbox/share.js +58 -0
  89. package/dist/tenant/envelope.d.ts +10 -0
  90. package/dist/tenant/envelope.js +54 -0
  91. package/dist/tenant/ephemeral.d.ts +31 -0
  92. package/dist/tenant/ephemeral.js +156 -0
  93. package/dist/tenant/logger.d.ts +9 -0
  94. package/dist/tenant/logger.js +25 -0
  95. package/dist/tenant/migration.d.ts +16 -0
  96. package/dist/tenant/migration.js +116 -0
  97. package/dist/tenant/module.d.ts +17 -0
  98. package/dist/tenant/module.js +315 -0
  99. package/dist/tenant/paths.d.ts +24 -0
  100. package/dist/tenant/paths.js +65 -0
  101. package/dist/tenant/pool.d.ts +10 -0
  102. package/dist/tenant/pool.js +54 -0
  103. package/dist/tenant/principals.d.ts +17 -0
  104. package/dist/tenant/principals.js +44 -0
  105. package/dist/tenant/quarantine-error.d.ts +6 -0
  106. package/dist/tenant/quarantine-error.js +15 -0
  107. package/dist/tenant/quarantine.d.ts +35 -0
  108. package/dist/tenant/quarantine.js +94 -0
  109. package/dist/tenant/reset.d.ts +21 -0
  110. package/dist/tenant/reset.js +125 -0
  111. package/dist/tenant/runtime.d.ts +80 -0
  112. package/dist/tenant/runtime.js +2256 -0
  113. package/dist/tenant/schema.d.ts +23 -0
  114. package/dist/tenant/schema.js +151 -0
  115. package/dist/tenant/status.d.ts +28 -0
  116. package/dist/tenant/status.js +95 -0
  117. package/dist/tenant/store-fs.d.ts +16 -0
  118. package/dist/tenant/store-fs.js +205 -0
  119. package/dist/tenant/store-memory.d.ts +19 -0
  120. package/dist/tenant/store-memory.js +100 -0
  121. package/dist/tenant/types.d.ts +125 -0
  122. package/dist/tenant/types.js +1 -0
  123. package/dist/vault/kek.d.ts +5 -2
  124. package/dist/vault/kek.js +7 -10
  125. package/dist/version.d.ts +1 -1
  126. package/dist/version.js +1 -1
  127. package/package.json +9 -6
  128. package/dist/core/main.js +0 -35
  129. /package/dist/{core/main.d.ts → host/config.js} +0 -0
@@ -0,0 +1,156 @@
1
+ import { existsSync, mkdirSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { createRequire } from "node:module";
4
+ import { DatabaseSync } from "node:sqlite";
5
+ import { newTenantId } from "@nylorun/core/compatibility";
6
+ import { hashToken, mintBearerToken } from "../core/executors.js";
7
+ import { createHost } from "../host/create-host.js";
8
+ import { createKekFile } from "../vault/kek.js";
9
+ import { createTenantModule } from "./module.js";
10
+ import { createFsTenantStore } from "./store-fs.js";
11
+ import { bootstrapPrincipal } from "./principals.js";
12
+ import { createTenantLogger } from "./logger.js";
13
+ import { hostPaths, tenantPaths } from "./paths.js";
14
+ import { openTenantRuntime } from "./runtime.js";
15
+ import { migrateTenantDatabase, readTenantConfig } from "./schema.js";
16
+ const nodeRequire = createRequire(import.meta.url);
17
+ function coreVersion() {
18
+ try {
19
+ return nodeRequire("@nylorun/core/package.json").version;
20
+ }
21
+ catch {
22
+ return "unknown";
23
+ }
24
+ }
25
+ function newHostId() {
26
+ return `host_${newTenantId().slice(3)}`;
27
+ }
28
+ /**
29
+ * Private Host on port 0 with one Tenant (D9 / A19).
30
+ * Exported from `@nylorun/runtime/core`.
31
+ */
32
+ export async function startEphemeralRuntime(options) {
33
+ const hostRoot = options.hostRoot;
34
+ const paths = hostPaths(hostRoot);
35
+ mkdirSync(paths.home, { recursive: true });
36
+ mkdirSync(paths.tmp, { recursive: true });
37
+ mkdirSync(paths.tenants, { recursive: true });
38
+ mkdirSync(paths.trash, { recursive: true });
39
+ const hostId = newHostId();
40
+ const adminKey = options.adminKey ?? randomBytes(32).toString("hex");
41
+ const hostConfig = {
42
+ hostId,
43
+ host: "127.0.0.1",
44
+ port: 0,
45
+ };
46
+ const credentials = { adminKey };
47
+ writeFileSync(paths.config, `${JSON.stringify(hostConfig, null, 2)}\n`, {
48
+ mode: 0o600,
49
+ });
50
+ writeFileSync(paths.credentials, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
51
+ const logger = options.logger ??
52
+ {
53
+ info() { },
54
+ warn() { },
55
+ error() { },
56
+ };
57
+ const baseline = options.baseline ?? {};
58
+ const model = options.model ?? { kind: "scripted", output: "ok" };
59
+ const configFor = (tenantId) => {
60
+ const tenant = tenantPaths(hostRoot, tenantId);
61
+ let sandboxBackend = options.sandboxBackend ?? "virtual";
62
+ if (existsSync(tenant.database)) {
63
+ try {
64
+ const db = new DatabaseSync(tenant.database, { readOnly: true });
65
+ try {
66
+ const seeded = readTenantConfig(db).sandboxBackend;
67
+ if (seeded)
68
+ sandboxBackend = seeded;
69
+ }
70
+ finally {
71
+ db.close();
72
+ }
73
+ }
74
+ catch {
75
+ /* unreadable — keep default */
76
+ }
77
+ }
78
+ return {
79
+ tenantId,
80
+ mode: "ephemeral",
81
+ paths: tenant,
82
+ sandbox: { backend: sandboxBackend },
83
+ model,
84
+ childEnv: Object.freeze({
85
+ ...baseline,
86
+ HOME: tenant.home,
87
+ TMPDIR: tenant.tmp,
88
+ }),
89
+ logger: createTenantLogger({
90
+ tenantId,
91
+ logPath: tenant.log,
92
+ }),
93
+ };
94
+ };
95
+ const openRuntime = (config) => openTenantRuntime(config, { createKekIfMissing: true });
96
+ const store = createFsTenantStore({
97
+ hostRoot,
98
+ openRuntime,
99
+ configFor,
100
+ logger,
101
+ writeBootstrap: (db, bootstrap) => {
102
+ migrateTenantDatabase(db);
103
+ bootstrapPrincipal(db, bootstrap);
104
+ },
105
+ });
106
+ const module = createTenantModule({
107
+ hostRoot,
108
+ store,
109
+ openRuntime,
110
+ configFor,
111
+ logger,
112
+ });
113
+ await module.start();
114
+ const tenantId = options.tenantId ?? newTenantId();
115
+ const applicationKey = options.applicationKey ?? mintBearerToken();
116
+ const principalId = options.principalId ?? `principal_${randomBytes(8).toString("hex")}`;
117
+ const credentialHash = hashToken(applicationKey);
118
+ await module.create({
119
+ tenantId,
120
+ name: options.name ?? "ephemeral",
121
+ principalId,
122
+ credentialHash,
123
+ idempotencyKey: `ephemeral-${tenantId}`,
124
+ });
125
+ // KEK for first vault write; openTenantRuntime also creates when hooks allow.
126
+ const kekPath = tenantPaths(hostRoot, tenantId).kek;
127
+ if (!existsSync(kekPath))
128
+ createKekFile(kekPath);
129
+ const host = createHost({
130
+ hostRoot,
131
+ module,
132
+ config: hostConfig,
133
+ credentials,
134
+ logger,
135
+ coreVersion: coreVersion(),
136
+ });
137
+ await host.listen();
138
+ let closed = false;
139
+ return {
140
+ url: host.url,
141
+ tenantId,
142
+ applicationKey,
143
+ adminKey,
144
+ principalId,
145
+ hostRoot,
146
+ async close() {
147
+ if (closed)
148
+ return;
149
+ closed = true;
150
+ await host.close();
151
+ await module.close();
152
+ if (!options.retainRoot)
153
+ rmSync(hostRoot, { recursive: true, force: true });
154
+ },
155
+ };
156
+ }
@@ -0,0 +1,9 @@
1
+ import type { Logger } from "./types.js";
2
+ /**
3
+ * Tenant logger (D12): JSON lines to `logs/tenant.log` with `tenantId` on every record.
4
+ * Never writes to the process stdout/stderr APIs.
5
+ */
6
+ export declare function createTenantLogger(options: {
7
+ tenantId: string;
8
+ logPath: string;
9
+ }): Logger;
@@ -0,0 +1,25 @@
1
+ import { appendFileSync, mkdirSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ /**
4
+ * Tenant logger (D12): JSON lines to `logs/tenant.log` with `tenantId` on every record.
5
+ * Never writes to the process stdout/stderr APIs.
6
+ */
7
+ export function createTenantLogger(options) {
8
+ const { tenantId, logPath } = options;
9
+ mkdirSync(dirname(logPath), { recursive: true });
10
+ const write = (level, message, fields) => {
11
+ const record = {
12
+ ts: new Date().toISOString(),
13
+ level,
14
+ tenantId,
15
+ message,
16
+ ...(fields ?? {}),
17
+ };
18
+ appendFileSync(logPath, `${JSON.stringify(record)}\n`);
19
+ };
20
+ return {
21
+ info: (message, fields) => write("info", message, fields),
22
+ warn: (message, fields) => write("warn", message, fields),
23
+ error: (message, fields) => write("error", message, fields),
24
+ };
25
+ }
@@ -0,0 +1,16 @@
1
+ import { DatabaseSync } from "node:sqlite";
2
+ import type { TenantPaths } from "./types.js";
3
+ import type { TenantEnvelope } from "@nylorun/core/contracts";
4
+ export interface MigrationHooks {
5
+ schemaVersionOf?: (db: DatabaseSync) => number;
6
+ migrateTenantDatabase?: (db: DatabaseSync) => {
7
+ from: number;
8
+ to: number;
9
+ };
10
+ targetVersion?: number;
11
+ }
12
+ /**
13
+ * Checkpoint WAL, snapshot database + envelope + KEK, migrate, update envelope.
14
+ * On failure restore the snapshot, keep diagnostics, and throw migration-failed.
15
+ */
16
+ export declare function migrateTenantWithSnapshot(paths: TenantPaths, envelope: TenantEnvelope, hooks?: MigrationHooks, now?: Date): TenantEnvelope;
@@ -0,0 +1,116 @@
1
+ import { copyFileSync, existsSync, mkdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { DatabaseSync } from "node:sqlite";
4
+ import { TENANT_SCHEMA_VERSION, migrateTenantDatabase as defaultMigrate, schemaVersionOf as defaultSchemaVersionOf, } from "./schema.js";
5
+ import { writeEnvelopeFile } from "./envelope.js";
6
+ import { quarantine } from "./quarantine.js";
7
+ function stamp(now) {
8
+ return now.toISOString().replaceAll(":", "-");
9
+ }
10
+ function copyIfPresent(from, to) {
11
+ if (existsSync(from))
12
+ copyFileSync(from, to);
13
+ }
14
+ /**
15
+ * Checkpoint WAL, snapshot database + envelope + KEK, migrate, update envelope.
16
+ * On failure restore the snapshot, keep diagnostics, and throw migration-failed.
17
+ */
18
+ export function migrateTenantWithSnapshot(paths, envelope, hooks = {}, now = new Date()) {
19
+ const schemaVersionOf = hooks.schemaVersionOf ?? defaultSchemaVersionOf;
20
+ const migrateTenantDatabase = hooks.migrateTenantDatabase ?? defaultMigrate;
21
+ const target = hooks.targetVersion ?? TENANT_SCHEMA_VERSION;
22
+ const db = new DatabaseSync(paths.database);
23
+ let from;
24
+ try {
25
+ from = schemaVersionOf(db);
26
+ }
27
+ catch (error) {
28
+ db.close();
29
+ throw error;
30
+ }
31
+ if (from > target) {
32
+ db.close();
33
+ throw quarantine("schema-too-new", `Tenant schema version ${from} is newer than Host ${target}`, { tenantId: envelope.id });
34
+ }
35
+ if (from === target) {
36
+ db.close();
37
+ return envelope;
38
+ }
39
+ const dirName = `${from}-${target}-${stamp(now)}`;
40
+ mkdirSync(paths.migration, { recursive: true });
41
+ const snapshotDir = join(paths.migration, dirName);
42
+ mkdirSync(snapshotDir, { recursive: true });
43
+ try {
44
+ // Checkpoint WAL so the database file is self-contained (invariant 7).
45
+ db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
46
+ db.close();
47
+ copyIfPresent(paths.database, join(snapshotDir, "tenant.sqlite"));
48
+ copyIfPresent(paths.envelope, join(snapshotDir, "tenant.json"));
49
+ copyIfPresent(paths.kek, join(snapshotDir, "vault-kek"));
50
+ // Side-car WAL/SHM if present.
51
+ copyIfPresent(`${paths.database}-wal`, join(snapshotDir, "tenant.sqlite-wal"));
52
+ copyIfPresent(`${paths.database}-shm`, join(snapshotDir, "tenant.sqlite-shm"));
53
+ const migrating = new DatabaseSync(paths.database);
54
+ let result;
55
+ try {
56
+ migrating.exec("BEGIN IMMEDIATE");
57
+ result = migrateTenantDatabase(migrating);
58
+ migrating.exec("COMMIT");
59
+ }
60
+ catch (error) {
61
+ try {
62
+ migrating.exec("ROLLBACK");
63
+ }
64
+ catch {
65
+ /* ignore */
66
+ }
67
+ migrating.close();
68
+ throw error;
69
+ }
70
+ migrating.close();
71
+ const updated = {
72
+ ...envelope,
73
+ schemaVersion: result.to,
74
+ updatedAt: now.toISOString(),
75
+ };
76
+ writeEnvelopeFile(paths.envelope, updated);
77
+ return updated;
78
+ }
79
+ catch (error) {
80
+ restoreSnapshot(paths, snapshotDir);
81
+ writeFileSync(join(snapshotDir, "failure.json"), `${JSON.stringify({
82
+ at: now.toISOString(),
83
+ message: error instanceof Error ? error.message : String(error),
84
+ }, null, 2)}\n`);
85
+ throw quarantine("migration-failed", error instanceof Error ? error.message : "migration failed", { tenantId: envelope.id, migrationPath: snapshotDir });
86
+ }
87
+ }
88
+ function restoreSnapshot(paths, snapshotDir) {
89
+ const dbSnap = join(snapshotDir, "tenant.sqlite");
90
+ const envSnap = join(snapshotDir, "tenant.json");
91
+ const kekSnap = join(snapshotDir, "vault-kek");
92
+ if (existsSync(dbSnap)) {
93
+ // Replace via temp rename so a partial copy cannot leave a half file.
94
+ const tmp = `${paths.database}.restore-tmp`;
95
+ copyFileSync(dbSnap, tmp);
96
+ renameSync(tmp, paths.database);
97
+ }
98
+ if (existsSync(envSnap)) {
99
+ const tmp = `${paths.envelope}.restore-tmp`;
100
+ copyFileSync(envSnap, tmp);
101
+ renameSync(tmp, paths.envelope);
102
+ }
103
+ if (existsSync(kekSnap)) {
104
+ const tmp = `${paths.kek}.restore-tmp`;
105
+ copyFileSync(kekSnap, tmp);
106
+ renameSync(tmp, paths.kek);
107
+ }
108
+ for (const side of ["-wal", "-shm"]) {
109
+ const snap = join(snapshotDir, `tenant.sqlite${side}`);
110
+ const dest = `${paths.database}${side}`;
111
+ if (existsSync(snap))
112
+ copyFileSync(snap, dest);
113
+ else if (existsSync(dest))
114
+ rmSync(dest, { force: true });
115
+ }
116
+ }
@@ -0,0 +1,17 @@
1
+ import { isQuarantineError, quarantine, TenantBusyError, TenantConflictError } from "./quarantine.js";
2
+ import type { Logger, OpenTenantRuntime, TenantConfig, TenantModule, TenantStore } from "./types.js";
3
+ export interface CreateTenantModuleOptions {
4
+ hostRoot: string;
5
+ store: TenantStore;
6
+ openRuntime: OpenTenantRuntime;
7
+ configFor: (tenantId: string) => TenantConfig;
8
+ logger: Logger;
9
+ openConcurrency?: number;
10
+ openTimeoutMs?: number;
11
+ }
12
+ /**
13
+ * Deep Tenant module (§8). Discovery, create, resolve, list, delete and
14
+ * summarize. Storage and Tenant Runtime are injected so WS-B tests without WS-A.
15
+ */
16
+ export declare function createTenantModule(options: CreateTenantModuleOptions): TenantModule;
17
+ export { TenantBusyError, TenantConflictError, isQuarantineError, quarantine, };
@@ -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;