@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,17 @@
1
+ import type { HostPaths } from "./paths.js";
2
+ export interface HostStateFile {
3
+ pid: number;
4
+ startedAt: string;
5
+ version: string;
6
+ entry: string;
7
+ url: string;
8
+ }
9
+ export interface HostCredentialsFile {
10
+ adminKey: string;
11
+ }
12
+ export declare function readHostState(paths: HostPaths): Promise<HostStateFile | undefined>;
13
+ export declare function writeHostState(paths: HostPaths, state: HostStateFile): Promise<void>;
14
+ export declare function clearHostState(paths: HostPaths): Promise<void>;
15
+ export declare function readHostCredentials(paths: HostPaths): Promise<HostCredentialsFile | undefined>;
16
+ /** Create host-credentials.json mode 0600 if missing (Tenants D15). */
17
+ export declare function ensureHostCredentials(paths: HostPaths): Promise<HostCredentialsFile>;
@@ -0,0 +1,61 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { unlink } from "node:fs/promises";
3
+ import { readFile, rename, writeFile, chmod } from "node:fs/promises";
4
+ export async function readHostState(paths) {
5
+ try {
6
+ const value = JSON.parse(await readFile(paths.state, "utf8"));
7
+ return typeof value?.pid === "number" ? value : undefined;
8
+ }
9
+ catch {
10
+ return undefined;
11
+ }
12
+ }
13
+ export async function writeHostState(paths, state) {
14
+ const temporary = `${paths.state}.${randomBytes(8).toString("hex")}.tmp`;
15
+ await writeFile(temporary, `${JSON.stringify(state, null, 2)}\n`, {
16
+ mode: 0o600,
17
+ });
18
+ await rename(temporary, paths.state);
19
+ }
20
+ export async function clearHostState(paths) {
21
+ try {
22
+ await unlink(paths.state);
23
+ }
24
+ catch {
25
+ /* absent */
26
+ }
27
+ }
28
+ export async function readHostCredentials(paths) {
29
+ try {
30
+ const value = JSON.parse(await readFile(paths.credentials, "utf8"));
31
+ if (typeof value?.adminKey === "string" &&
32
+ /^[0-9a-f]{64}$/.test(value.adminKey)) {
33
+ return value;
34
+ }
35
+ return undefined;
36
+ }
37
+ catch {
38
+ return undefined;
39
+ }
40
+ }
41
+ /** Create host-credentials.json mode 0600 if missing (Tenants D15). */
42
+ export async function ensureHostCredentials(paths) {
43
+ const existing = await readHostCredentials(paths);
44
+ if (existing)
45
+ return existing;
46
+ const credentials = {
47
+ adminKey: randomBytes(32).toString("hex"),
48
+ };
49
+ const temporary = `${paths.credentials}.${randomBytes(8).toString("hex")}.tmp`;
50
+ await writeFile(temporary, `${JSON.stringify(credentials)}\n`, {
51
+ mode: 0o600,
52
+ });
53
+ await rename(temporary, paths.credentials);
54
+ try {
55
+ await chmod(paths.credentials, 0o600);
56
+ }
57
+ catch {
58
+ /* best-effort */
59
+ }
60
+ return credentials;
61
+ }
@@ -0,0 +1,66 @@
1
+ import { type LockOptions } from "./locks.js";
2
+ import { type HostPaths } from "./paths.js";
3
+ import type { DownResult, LauncherEvent, UpResult } from "./protocol.js";
4
+ import { spawnHostProcess } from "./spawn.js";
5
+ export type LifecycleEmit = (event: LauncherEvent) => void;
6
+ export interface LifecycleContext {
7
+ paths: HostPaths;
8
+ platform: NodeJS.Platform;
9
+ /** Node that runs the Host: the launcher's own `process.execPath`. */
10
+ nodeBinary: string;
11
+ /** Host entry of this package (`dist/host/main.js`). */
12
+ hostEntry: string;
13
+ /** Version of this `@nylorun/runtime` package. */
14
+ runtimeVersion: string;
15
+ /** Highest Tenant schema this package can open. */
16
+ tenantSchemaMax: number;
17
+ /** Allowlisted ambient baseline (from main.ts). */
18
+ baselineEnv: Readonly<Record<string, string | undefined>>;
19
+ emit?: LifecycleEmit;
20
+ fetchImpl?: typeof fetch;
21
+ lock?: LockOptions;
22
+ /** Injectable spawn for tests. */
23
+ spawnHost?: typeof spawnHostProcess;
24
+ /** Injectable signals for `run` tests. */
25
+ onSignal?: (handler: (signal: NodeJS.Signals) => void) => () => void;
26
+ }
27
+ export interface UpOptions {
28
+ port?: number;
29
+ /** Foreground mode used by `run`. */
30
+ foreground?: boolean;
31
+ /** Start even when this Runtime is older than the one that last ran. */
32
+ allowDowngrade?: boolean;
33
+ }
34
+ /**
35
+ * Start the local Host (D§9.6). Never restarts a running Host.
36
+ */
37
+ export declare function up(ctx: LifecycleContext, options?: UpOptions): Promise<UpResult>;
38
+ export interface DownOptions {
39
+ wait?: boolean;
40
+ force?: boolean;
41
+ timeoutMs?: number;
42
+ }
43
+ /**
44
+ * Stop the local Host (D§9.7).
45
+ */
46
+ export declare function down(ctx: LifecycleContext, options?: DownOptions): Promise<DownResult>;
47
+ export interface RestartOptions {
48
+ allowDowngrade?: boolean;
49
+ wait?: boolean;
50
+ force?: boolean;
51
+ timeoutMs?: number;
52
+ port?: number;
53
+ }
54
+ /**
55
+ * Check the installed Runtime can take over, stop, start (D§9.8).
56
+ * runtimeVersion updates only after ready.
57
+ */
58
+ export declare function restart(ctx: LifecycleContext, options?: RestartOptions): Promise<UpResult>;
59
+ /**
60
+ * Foreground Host: print up result once ready; stop on SIGINT/SIGTERM (D§9.3).
61
+ */
62
+ export declare function run(ctx: LifecycleContext, options?: {
63
+ port?: number;
64
+ allowDowngrade?: boolean;
65
+ onReady?: (result: UpResult) => void;
66
+ }): Promise<UpResult>;
@@ -0,0 +1,407 @@
1
+ import { existsSync, renameSync, statSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { compareVersions, PROTOCOL_HEADER, PROTOCOL_VERSION, } from "@nylorun/core/compatibility";
5
+ import { baselineEnvironment, hostProcessEnvironment, } from "../host/environment.js";
6
+ import { LauncherError } from "./errors.js";
7
+ import { ensureHostConfig } from "./ensure-config.js";
8
+ import { writeHostConfig } from "./host-config.js";
9
+ import { clearHostState, ensureHostCredentials, readHostCredentials, readHostState, writeHostState, } from "./host-state.js";
10
+ import { LIFECYCLE_LOCK_WAIT_MS, processAlive, withProcessLock, } from "./locks.js";
11
+ import { hostUrl } from "./paths.js";
12
+ import { probeHostHealth, probeReady } from "./probe.js";
13
+ import { assertSchemaGuard } from "./schema-guard.js";
14
+ import { forceKillHost, spawnHostProcess } from "./spawn.js";
15
+ import { status } from "./status.js";
16
+ const READY_TIMEOUT_MS = 30_000;
17
+ const LOG_LIMIT_BYTES = 10 * 1024 * 1024;
18
+ const DOWN_KILL_WAIT_MS = 30_000;
19
+ const WAIT_IDLE_DEFAULT_MS = 300_000;
20
+ function emitProgress(ctx, phase, message) {
21
+ ctx.emit?.({
22
+ type: "progress",
23
+ phase,
24
+ ...(message ? { message } : {}),
25
+ });
26
+ }
27
+ function rotateLog(path) {
28
+ try {
29
+ if (statSync(path).size > LOG_LIMIT_BYTES) {
30
+ renameSync(path, `${path}.1`);
31
+ }
32
+ }
33
+ catch {
34
+ /* no log yet */
35
+ }
36
+ }
37
+ async function logTail(path, lines = 40) {
38
+ try {
39
+ const text = await readFile(path, "utf8");
40
+ return text.trimEnd().split("\n").slice(-lines).join("\n");
41
+ }
42
+ catch {
43
+ return "";
44
+ }
45
+ }
46
+ /**
47
+ * Refuse to start an older Runtime than the one that last ran this Host root,
48
+ * unless the caller confirmed the downgrade.
49
+ */
50
+ function assertNotDowngrade(recorded, target, allowDowngrade) {
51
+ if (!recorded || allowDowngrade)
52
+ return;
53
+ if (compareVersions(target, recorded) >= 0)
54
+ return;
55
+ throw new LauncherError("downgrade_refused", `This Host root last ran Runtime ${recorded}; the installed Runtime is ${target}.`, `Install ${recorded} or newer (npm install --global @nylorun/runtime@${recorded}), or pass --allow-downgrade after confirming Tenants can open on the older schema.`);
56
+ }
57
+ async function awaitHostReady(url, logPath, childPid) {
58
+ const deadline = Date.now() + READY_TIMEOUT_MS;
59
+ while (Date.now() < deadline) {
60
+ if (childPid !== undefined && !processAlive(childPid)) {
61
+ const tail = await logTail(logPath);
62
+ throw new LauncherError("host_start_failed", `The Runtime Host exited before becoming ready.${tail ? `\n${tail}` : ""}`, 'Inspect "nylorun-runtime logs" and retry up.', { logExcerpt: tail });
63
+ }
64
+ if (await probeReady(url, 500))
65
+ return;
66
+ const probe = await probeHostHealth(url, 500);
67
+ if (probe.health)
68
+ return;
69
+ await new Promise((r) => setTimeout(r, 200));
70
+ }
71
+ const tail = await logTail(logPath);
72
+ throw new LauncherError("host_start_failed", `The Runtime Host did not become ready within 30 seconds.${tail ? `\n${tail}` : ""}`, 'Inspect "nylorun-runtime logs" and retry up.', { logExcerpt: tail });
73
+ }
74
+ function adminHeaders(adminKey) {
75
+ return {
76
+ Authorization: `Bearer ${adminKey}`,
77
+ [PROTOCOL_HEADER]: String(PROTOCOL_VERSION),
78
+ Accept: "application/json",
79
+ };
80
+ }
81
+ async function fetchAdminAggregate(url, adminKey, fetchImpl) {
82
+ try {
83
+ const response = await fetchImpl(`${url}/v1/admin/status`, {
84
+ headers: adminHeaders(adminKey),
85
+ signal: AbortSignal.timeout(5_000),
86
+ });
87
+ if (!response.ok)
88
+ return undefined;
89
+ const body = (await response.json());
90
+ return body.aggregate;
91
+ }
92
+ catch {
93
+ return undefined;
94
+ }
95
+ }
96
+ async function requestShutdown(url, adminKey, fetchImpl) {
97
+ try {
98
+ await fetchImpl(`${url}/v1/admin/host/shutdown`, {
99
+ method: "POST",
100
+ headers: adminHeaders(adminKey),
101
+ signal: AbortSignal.timeout(5_000),
102
+ });
103
+ }
104
+ catch {
105
+ /* fall through to signal/kill */
106
+ }
107
+ }
108
+ /**
109
+ * Start the local Host (D§9.6). Never restarts a running Host.
110
+ */
111
+ export async function up(ctx, options = {}) {
112
+ return withProcessLock(ctx.paths.lifecycleLock, ctx.lock?.waitMs ?? LIFECYCLE_LOCK_WAIT_MS, async () => upLocked(ctx, options), ctx.lock);
113
+ }
114
+ async function upLocked(ctx, options) {
115
+ const { config } = await ensureHostConfig(ctx.paths, {
116
+ ...(options.port !== undefined ? { port: options.port } : {}),
117
+ });
118
+ await ensureHostCredentials(ctx.paths);
119
+ const before = await status(ctx.paths, ctx.runtimeVersion);
120
+ if (before.state === "running" && !options.foreground) {
121
+ return {
122
+ url: before.url,
123
+ hostId: before.hostId ?? config.hostId,
124
+ pid: before.pid,
125
+ version: before.version ?? before.runtimeVersion ?? "unknown",
126
+ started: false,
127
+ };
128
+ }
129
+ if (before.state === "foreign-port") {
130
+ throw new LauncherError("foreign_port", `Port ${config.port} on ${config.host} is in use by another process.`, 'Choose a free port with "nylorun-runtime up --port <n>", or stop the other process.');
131
+ }
132
+ if (before.state === "unresponsive") {
133
+ throw new LauncherError("host_unresponsive", `A Host process is running (pid ${before.pid}) but is not answering ${before.url}.`, 'Inspect it with "nylorun-runtime logs", then stop it with "nylorun-runtime down".', { pid: before.pid });
134
+ }
135
+ // Clear stale state before start.
136
+ const stateFile = await readHostState(ctx.paths);
137
+ if (stateFile && !processAlive(stateFile.pid)) {
138
+ await clearHostState(ctx.paths);
139
+ }
140
+ const target = ctx.runtimeVersion;
141
+ assertNotDowngrade(typeof config.runtimeVersion === "string" ? config.runtimeVersion : undefined, target, options.allowDowngrade);
142
+ assertSchemaGuard(ctx.paths, ctx.tenantSchemaMax);
143
+ const entry = ctx.hostEntry;
144
+ const nodeBinary = ctx.nodeBinary;
145
+ if (!existsSync(entry)) {
146
+ throw new LauncherError("host_start_failed", `The installed Runtime has no Host entry at ${entry}.`, `Reinstall it: npm install --global @nylorun/runtime@${target}`);
147
+ }
148
+ const baseline = baselineEnvironment(ctx.baselineEnv);
149
+ const environment = hostProcessEnvironment(baseline, config, {
150
+ root: ctx.paths.root,
151
+ home: ctx.paths.home,
152
+ tmp: ctx.paths.tmp,
153
+ });
154
+ const devModel = ctx.baselineEnv.NYLORUN_DEV_MODEL?.trim();
155
+ if (devModel)
156
+ environment.NYLORUN_DEV_MODEL = devModel;
157
+ const spawnFn = ctx.spawnHost ?? spawnHostProcess;
158
+ const url = hostUrl(config);
159
+ if (options.foreground) {
160
+ emitProgress(ctx, "start");
161
+ const child = spawnFn({
162
+ nodeBinary,
163
+ entry,
164
+ environment,
165
+ cwd: ctx.paths.home,
166
+ detached: false,
167
+ platform: ctx.platform,
168
+ });
169
+ await awaitHostReady(url, ctx.paths.log, child.pid);
170
+ await recordReady(ctx.paths, config, child.pid, target, entry);
171
+ return {
172
+ url,
173
+ hostId: config.hostId,
174
+ pid: child.pid,
175
+ version: target,
176
+ started: true,
177
+ };
178
+ }
179
+ rotateLog(ctx.paths.log);
180
+ emitProgress(ctx, "start");
181
+ const child = spawnFn({
182
+ nodeBinary,
183
+ entry,
184
+ environment,
185
+ cwd: ctx.paths.home,
186
+ detached: true,
187
+ logPath: ctx.paths.log,
188
+ platform: ctx.platform,
189
+ });
190
+ try {
191
+ await awaitHostReady(url, ctx.paths.log, child.pid);
192
+ }
193
+ catch (error) {
194
+ if (child.pid)
195
+ forceKillHost(child.pid, ctx.platform);
196
+ throw error;
197
+ }
198
+ child.unref();
199
+ await recordReady(ctx.paths, config, child.pid, target, entry);
200
+ return {
201
+ url,
202
+ hostId: config.hostId,
203
+ pid: child.pid,
204
+ version: target,
205
+ started: true,
206
+ };
207
+ }
208
+ async function recordReady(paths, config, pid, version, entry) {
209
+ const url = hostUrl(config);
210
+ await writeHostState(paths, {
211
+ pid,
212
+ startedAt: new Date().toISOString(),
213
+ version,
214
+ entry,
215
+ url,
216
+ });
217
+ // runtimeVersion changes only after ready (D§9.6 / D§9.8).
218
+ await writeHostConfig(paths, {
219
+ ...config,
220
+ format: 1,
221
+ runtimeVersion: version,
222
+ });
223
+ }
224
+ /**
225
+ * Stop the local Host (D§9.7).
226
+ */
227
+ export async function down(ctx, options = {}) {
228
+ return withProcessLock(ctx.paths.lifecycleLock, ctx.lock?.waitMs ?? LIFECYCLE_LOCK_WAIT_MS, async () => downLocked(ctx, options), ctx.lock);
229
+ }
230
+ async function downLocked(ctx, options) {
231
+ const current = await status(ctx.paths, ctx.runtimeVersion);
232
+ const stateFile = await readHostState(ctx.paths);
233
+ const pid = current.pid ?? stateFile?.pid;
234
+ if (current.state === "absent" ||
235
+ current.state === "stopped" ||
236
+ (!pid && current.state !== "running" && current.state !== "unresponsive")) {
237
+ await clearHostState(ctx.paths);
238
+ return { stopped: false };
239
+ }
240
+ if (current.state === "foreign-port") {
241
+ throw new LauncherError("foreign_port", `Port is serving a foreign process; refusing to stop it.`, "Stop the other process manually, or use a different --port.");
242
+ }
243
+ const fetchImpl = ctx.fetchImpl ?? fetch;
244
+ const credentials = await readHostCredentials(ctx.paths);
245
+ const url = current.url ?? stateFile?.url;
246
+ if (!options.force && credentials && url && current.state === "running") {
247
+ emitProgress(ctx, "wait");
248
+ let aggregate = await fetchAdminAggregate(url, credentials.adminKey, fetchImpl);
249
+ if (aggregate &&
250
+ (aggregate.runningSessions > 0 || aggregate.connectedExecutors > 0)) {
251
+ if (!options.wait) {
252
+ throw new LauncherError("active_work", `Host has ${aggregate.runningSessions} running session(s) and ${aggregate.connectedExecutors} connected executor(s).`, 'Pass --wait to drain, or --force to stop immediately.', { aggregate });
253
+ }
254
+ const timeoutMs = options.timeoutMs ?? WAIT_IDLE_DEFAULT_MS;
255
+ const deadline = Date.now() + timeoutMs;
256
+ while (Date.now() < deadline) {
257
+ aggregate = await fetchAdminAggregate(url, credentials.adminKey, fetchImpl);
258
+ if (!aggregate ||
259
+ (aggregate.runningSessions === 0 &&
260
+ aggregate.connectedExecutors === 0)) {
261
+ break;
262
+ }
263
+ await new Promise((r) => setTimeout(r, 1000));
264
+ }
265
+ if (aggregate &&
266
+ (aggregate.runningSessions > 0 || aggregate.connectedExecutors > 0)) {
267
+ throw new LauncherError("active_work", `Timed out waiting for the Host to become idle.`, "Retry with a longer --timeout, or pass --force.", { aggregate });
268
+ }
269
+ }
270
+ }
271
+ emitProgress(ctx, "stop");
272
+ if (credentials && url) {
273
+ await requestShutdown(url, credentials.adminKey, fetchImpl);
274
+ }
275
+ if (pid) {
276
+ try {
277
+ process.kill(pid, "SIGTERM");
278
+ }
279
+ catch (error) {
280
+ if (error.code !== "ESRCH")
281
+ throw error;
282
+ }
283
+ const deadline = Date.now() + DOWN_KILL_WAIT_MS;
284
+ while (Date.now() < deadline) {
285
+ if (!processAlive(pid)) {
286
+ await clearHostState(ctx.paths);
287
+ return { stopped: true };
288
+ }
289
+ await new Promise((r) => setTimeout(r, 100));
290
+ }
291
+ forceKillHost(pid, ctx.platform);
292
+ }
293
+ await clearHostState(ctx.paths);
294
+ return { stopped: true };
295
+ }
296
+ /**
297
+ * Check the installed Runtime can take over, stop, start (D§9.8).
298
+ * runtimeVersion updates only after ready.
299
+ */
300
+ export async function restart(ctx, options = {}) {
301
+ return withProcessLock(ctx.paths.lifecycleLock, ctx.lock?.waitMs ?? LIFECYCLE_LOCK_WAIT_MS, async () => {
302
+ const config = (await ensureHostConfig(ctx.paths, {
303
+ ...(options.port !== undefined ? { port: options.port } : {}),
304
+ })).config;
305
+ const target = ctx.runtimeVersion;
306
+ // Check before touching the running Host.
307
+ assertNotDowngrade(typeof config.runtimeVersion === "string"
308
+ ? config.runtimeVersion
309
+ : undefined, target, options.allowDowngrade);
310
+ assertSchemaGuard(ctx.paths, ctx.tenantSchemaMax);
311
+ await downLocked(ctx, {
312
+ wait: options.wait,
313
+ force: options.force ?? true,
314
+ ...(options.timeoutMs !== undefined
315
+ ? { timeoutMs: options.timeoutMs }
316
+ : {}),
317
+ });
318
+ try {
319
+ return await upLocked(ctx, {
320
+ allowDowngrade: true,
321
+ ...(options.port !== undefined ? { port: options.port } : {}),
322
+ });
323
+ }
324
+ catch (error) {
325
+ if (error instanceof LauncherError && error.code === "host_start_failed") {
326
+ const tail = typeof error.details === "object" &&
327
+ error.details &&
328
+ "logExcerpt" in error.details
329
+ ? String(error.details.logExcerpt ?? "")
330
+ : await logTail(ctx.paths.log);
331
+ throw new LauncherError("upgrade_failed", `Failed to start Runtime ${target} after stop.${tail ? `\n${tail}` : ""}`, "The previous Host is stopped. Fix the issue, or reinstall the last good version (npm install --global @nylorun/runtime@<version>) and run up.", { version: target, logExcerpt: tail });
332
+ }
333
+ throw error;
334
+ }
335
+ }, ctx.lock);
336
+ }
337
+ /**
338
+ * Foreground Host: print up result once ready; stop on SIGINT/SIGTERM (D§9.3).
339
+ */
340
+ export async function run(ctx, options = {}) {
341
+ return withProcessLock(ctx.paths.lifecycleLock, ctx.lock?.waitMs ?? LIFECYCLE_LOCK_WAIT_MS, async () => {
342
+ const result = await upLocked(ctx, {
343
+ port: options.port,
344
+ allowDowngrade: options.allowDowngrade,
345
+ foreground: true,
346
+ });
347
+ options.onReady?.(result);
348
+ const state = await readHostState(ctx.paths);
349
+ const pid = state?.pid ?? result.pid;
350
+ await new Promise((resolvePromise) => {
351
+ let settled = false;
352
+ const finish = async () => {
353
+ if (settled)
354
+ return;
355
+ settled = true;
356
+ cleanup();
357
+ clearInterval(poll);
358
+ try {
359
+ if (pid)
360
+ process.kill(pid, "SIGTERM");
361
+ }
362
+ catch {
363
+ /* gone */
364
+ }
365
+ const deadline = Date.now() + DOWN_KILL_WAIT_MS;
366
+ while (Date.now() < deadline && pid && processAlive(pid)) {
367
+ await new Promise((r) => setTimeout(r, 100));
368
+ }
369
+ if (pid && processAlive(pid))
370
+ forceKillHost(pid, ctx.platform);
371
+ await clearHostState(ctx.paths);
372
+ resolvePromise();
373
+ };
374
+ const handler = () => {
375
+ void finish();
376
+ };
377
+ let remove;
378
+ if (ctx.onSignal) {
379
+ remove = ctx.onSignal(handler);
380
+ }
381
+ else {
382
+ process.on("SIGINT", handler);
383
+ process.on("SIGTERM", handler);
384
+ remove = () => {
385
+ process.off("SIGINT", handler);
386
+ process.off("SIGTERM", handler);
387
+ };
388
+ }
389
+ const cleanup = () => remove();
390
+ const poll = setInterval(() => {
391
+ if (pid && !processAlive(pid)) {
392
+ void (async () => {
393
+ if (settled)
394
+ return;
395
+ settled = true;
396
+ cleanup();
397
+ clearInterval(poll);
398
+ await clearHostState(ctx.paths);
399
+ resolvePromise();
400
+ })();
401
+ }
402
+ }, 250);
403
+ poll.unref();
404
+ });
405
+ return result;
406
+ }, ctx.lock);
407
+ }
@@ -0,0 +1,26 @@
1
+ export interface ProcessLock {
2
+ pid: number;
3
+ startTime: string;
4
+ }
5
+ export interface LockOptions {
6
+ /** Poll interval while waiting (ms). */
7
+ pollMs?: number;
8
+ /** Max wait for another holder (ms). */
9
+ waitMs?: number;
10
+ }
11
+ declare function processStartTime(pid: number): string | undefined;
12
+ export declare function processAlive(pid: number): boolean;
13
+ declare function tryAcquireLock(path: string): boolean;
14
+ declare function releaseLock(path: string): void;
15
+ declare function recoverStaleLock(path: string): void;
16
+ /**
17
+ * Hold a PID+start-identity lock. Stale when the holder process is gone.
18
+ * Times out with `lock_timeout`.
19
+ */
20
+ export declare function withProcessLock<T>(path: string, waitMs: number, body: () => Promise<T>, options?: LockOptions): Promise<T>;
21
+ export declare const LIFECYCLE_LOCK_WAIT_MS = 60000;
22
+ export declare function readProcessLock(path: string): ProcessLock | undefined;
23
+ export declare function lockFileExists(path: string): boolean;
24
+ /** Test helper: force-write a lock as if held by `pid` with optional startTime. */
25
+ export declare function writeProcessLockForTest(path: string, lock: ProcessLock): void;
26
+ export { processStartTime, recoverStaleLock, releaseLock, tryAcquireLock, };
@@ -0,0 +1,117 @@
1
+ import { closeSync, existsSync, openSync, readFileSync, unlinkSync, writeFileSync, } from "node:fs";
2
+ import { LauncherError } from "./errors.js";
3
+ function processStartTime(pid) {
4
+ try {
5
+ const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
6
+ const closeParen = stat.lastIndexOf(")");
7
+ if (closeParen < 0)
8
+ return undefined;
9
+ const fields = stat.slice(closeParen + 2).split(" ");
10
+ return fields[19];
11
+ }
12
+ catch {
13
+ return undefined;
14
+ }
15
+ }
16
+ export function processAlive(pid) {
17
+ try {
18
+ process.kill(pid, 0);
19
+ return true;
20
+ }
21
+ catch (error) {
22
+ return error.code !== "ESRCH";
23
+ }
24
+ }
25
+ function readLock(path) {
26
+ try {
27
+ const value = JSON.parse(readFileSync(path, "utf8"));
28
+ if (typeof value?.pid === "number" && typeof value.startTime === "string") {
29
+ return value;
30
+ }
31
+ return undefined;
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ function lockIsLive(lock) {
38
+ if (!processAlive(lock.pid))
39
+ return false;
40
+ const start = processStartTime(lock.pid);
41
+ // When /proc is unavailable, treat a live PID as holding the lock.
42
+ if (start === undefined)
43
+ return true;
44
+ return start === lock.startTime;
45
+ }
46
+ function tryAcquireLock(path) {
47
+ try {
48
+ const fd = openSync(path, "wx", 0o600);
49
+ try {
50
+ const startTime = processStartTime(process.pid) ?? String(Date.now());
51
+ writeFileSync(fd, JSON.stringify({ pid: process.pid, startTime }));
52
+ }
53
+ finally {
54
+ closeSync(fd);
55
+ }
56
+ return true;
57
+ }
58
+ catch (error) {
59
+ if (error.code === "EEXIST")
60
+ return false;
61
+ throw error;
62
+ }
63
+ }
64
+ function releaseLock(path) {
65
+ try {
66
+ unlinkSync(path);
67
+ }
68
+ catch {
69
+ /* gone */
70
+ }
71
+ }
72
+ function recoverStaleLock(path) {
73
+ const lock = readLock(path);
74
+ if (!lock || lockIsLive(lock))
75
+ return;
76
+ releaseLock(path);
77
+ }
78
+ /**
79
+ * Hold a PID+start-identity lock. Stale when the holder process is gone.
80
+ * Times out with `lock_timeout`.
81
+ */
82
+ export async function withProcessLock(path, waitMs, body, options = {}) {
83
+ const pollMs = options.pollMs ?? 100;
84
+ const deadline = Date.now() + waitMs;
85
+ for (;;) {
86
+ recoverStaleLock(path);
87
+ if (tryAcquireLock(path)) {
88
+ try {
89
+ return await body();
90
+ }
91
+ finally {
92
+ releaseLock(path);
93
+ }
94
+ }
95
+ while (Date.now() < deadline) {
96
+ const held = readLock(path);
97
+ if (!held || !lockIsLive(held))
98
+ break;
99
+ await new Promise((r) => setTimeout(r, pollMs));
100
+ }
101
+ if (Date.now() >= deadline) {
102
+ throw new LauncherError("lock_timeout", `Timed out waiting for lock ${path}.`, "Retry after the other launcher process finishes, or remove a stale lock if no launcher is running.", { path, waitMs });
103
+ }
104
+ }
105
+ }
106
+ export const LIFECYCLE_LOCK_WAIT_MS = 60_000;
107
+ export function readProcessLock(path) {
108
+ return readLock(path);
109
+ }
110
+ export function lockFileExists(path) {
111
+ return existsSync(path);
112
+ }
113
+ /** Test helper: force-write a lock as if held by `pid` with optional startTime. */
114
+ export function writeProcessLockForTest(path, lock) {
115
+ writeFileSync(path, JSON.stringify(lock), { mode: 0o600 });
116
+ }
117
+ export { processStartTime, recoverStaleLock, releaseLock, tryAcquireLock, };