@zhin.js/gateway 0.0.1-bootstrap.0 → 1.1.2

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 (181) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +61 -0
  3. package/lib/config/ai-config-loader.d.ts +22 -0
  4. package/lib/config/ai-config-loader.js +96 -0
  5. package/lib/config/configuration-chat-policy.d.ts +57 -0
  6. package/lib/config/configuration-chat-policy.js +423 -0
  7. package/lib/config/configuration-patch.d.ts +16 -0
  8. package/lib/config/configuration-patch.js +105 -0
  9. package/lib/config/configuration-path.d.ts +6 -0
  10. package/lib/config/configuration-path.js +40 -0
  11. package/lib/config/configuration-projection.d.ts +6 -0
  12. package/lib/config/configuration-projection.js +28 -0
  13. package/lib/config/configuration-redaction.d.ts +18 -0
  14. package/lib/config/configuration-redaction.js +75 -0
  15. package/lib/config/configuration.d.ts +81 -0
  16. package/lib/config/configuration.js +686 -0
  17. package/lib/config/console-schema.d.ts +11 -0
  18. package/lib/config/console-schema.js +223 -0
  19. package/lib/config/environment-files.d.ts +3 -0
  20. package/lib/config/environment-files.js +14 -0
  21. package/lib/config/literal-secrets.d.ts +34 -0
  22. package/lib/config/literal-secrets.js +136 -0
  23. package/lib/config/plugin-package-map.d.ts +2 -0
  24. package/lib/config/plugin-package-map.js +14 -0
  25. package/lib/config/plugin-schema-catalog.d.ts +22 -0
  26. package/lib/config/plugin-schema-catalog.js +262 -0
  27. package/lib/config.d.ts +13 -0
  28. package/lib/config.js +13 -0
  29. package/lib/database/config.d.ts +22 -0
  30. package/lib/database/config.js +159 -0
  31. package/lib/database/index.d.ts +2 -0
  32. package/lib/database/index.js +2 -0
  33. package/lib/database/project-database.d.ts +31 -0
  34. package/lib/database/project-database.js +130 -0
  35. package/lib/database.d.ts +2 -0
  36. package/lib/database.js +2 -0
  37. package/lib/http/console-rpc.d.ts +27 -0
  38. package/lib/http/console-rpc.js +51 -0
  39. package/lib/http/gateway-console.d.ts +63 -0
  40. package/lib/http/gateway-console.js +309 -0
  41. package/lib/http/gateway-events.d.ts +40 -0
  42. package/lib/http/gateway-events.js +185 -0
  43. package/lib/http/gateway-http-server.d.ts +95 -0
  44. package/lib/http/gateway-http-server.js +522 -0
  45. package/lib/http/local-principal.d.ts +2 -0
  46. package/lib/http/local-principal.js +16 -0
  47. package/lib/http/plugin-switch-rpc.d.ts +19 -0
  48. package/lib/http/plugin-switch-rpc.js +214 -0
  49. package/lib/http/run-gateway.d.ts +43 -0
  50. package/lib/http/run-gateway.js +268 -0
  51. package/lib/http/start-gateway.d.ts +114 -0
  52. package/lib/http/start-gateway.js +226 -0
  53. package/lib/http-response.d.ts +6 -0
  54. package/lib/http-response.js +25 -0
  55. package/lib/http.d.ts +6 -0
  56. package/lib/http.js +6 -0
  57. package/lib/iam/crypto.d.ts +12 -0
  58. package/lib/iam/crypto.js +58 -0
  59. package/lib/iam/index.d.ts +8 -0
  60. package/lib/iam/index.js +8 -0
  61. package/lib/iam/install.d.ts +36 -0
  62. package/lib/iam/install.js +74 -0
  63. package/lib/iam/local-admin.d.ts +27 -0
  64. package/lib/iam/local-admin.js +102 -0
  65. package/lib/iam/rate-limit.d.ts +15 -0
  66. package/lib/iam/rate-limit.js +52 -0
  67. package/lib/iam/routes.d.ts +23 -0
  68. package/lib/iam/routes.js +157 -0
  69. package/lib/iam/rpc.d.ts +14 -0
  70. package/lib/iam/rpc.js +100 -0
  71. package/lib/iam/service.d.ts +203 -0
  72. package/lib/iam/service.js +784 -0
  73. package/lib/iam/setup-service.d.ts +83 -0
  74. package/lib/iam/setup-service.js +206 -0
  75. package/lib/iam/tables.d.ts +20 -0
  76. package/lib/iam/tables.js +82 -0
  77. package/lib/iam.d.ts +2 -0
  78. package/lib/iam.js +2 -0
  79. package/lib/index.d.ts +22 -0
  80. package/lib/index.js +22 -0
  81. package/lib/local/client.d.ts +71 -0
  82. package/lib/local/client.js +296 -0
  83. package/lib/local/handler.d.ts +30 -0
  84. package/lib/local/handler.js +86 -0
  85. package/lib/local/local-socket.d.ts +55 -0
  86. package/lib/local/local-socket.js +232 -0
  87. package/lib/local/paths.d.ts +34 -0
  88. package/lib/local/paths.js +58 -0
  89. package/lib/local/protocol.d.ts +140 -0
  90. package/lib/local/protocol.js +90 -0
  91. package/lib/local/state-file.d.ts +8 -0
  92. package/lib/local/state-file.js +45 -0
  93. package/lib/local.d.ts +10 -0
  94. package/lib/local.js +10 -0
  95. package/lib/onboarding/allowlist.d.ts +54 -0
  96. package/lib/onboarding/allowlist.js +106 -0
  97. package/lib/onboarding/errors.d.ts +7 -0
  98. package/lib/onboarding/errors.js +17 -0
  99. package/lib/onboarding/index.d.ts +6 -0
  100. package/lib/onboarding/index.js +6 -0
  101. package/lib/onboarding/install.d.ts +99 -0
  102. package/lib/onboarding/install.js +132 -0
  103. package/lib/onboarding/package-manager.d.ts +47 -0
  104. package/lib/onboarding/package-manager.js +179 -0
  105. package/lib/onboarding/service.d.ts +53 -0
  106. package/lib/onboarding/service.js +144 -0
  107. package/lib/onboarding/state-store.d.ts +26 -0
  108. package/lib/onboarding/state-store.js +97 -0
  109. package/lib/onboarding.d.ts +2 -0
  110. package/lib/onboarding.js +2 -0
  111. package/lib/packages/authorization.d.ts +24 -0
  112. package/lib/packages/authorization.js +128 -0
  113. package/lib/packages/errors.d.ts +11 -0
  114. package/lib/packages/errors.js +26 -0
  115. package/lib/packages/index.d.ts +5 -0
  116. package/lib/packages/index.js +5 -0
  117. package/lib/packages/rpc.d.ts +30 -0
  118. package/lib/packages/rpc.js +160 -0
  119. package/lib/packages/service.d.ts +139 -0
  120. package/lib/packages/service.js +504 -0
  121. package/lib/packages/snapshot.d.ts +14 -0
  122. package/lib/packages/snapshot.js +73 -0
  123. package/lib/packages.d.ts +2 -0
  124. package/lib/packages.js +2 -0
  125. package/lib/plugin-management/activity.d.ts +6 -0
  126. package/lib/plugin-management/activity.js +14 -0
  127. package/lib/plugin-management/config-explanation.d.ts +6 -0
  128. package/lib/plugin-management/config-explanation.js +113 -0
  129. package/lib/plugin-management/use-cases.d.ts +15 -0
  130. package/lib/plugin-management/use-cases.js +120 -0
  131. package/lib/plugin-management/workflow.d.ts +136 -0
  132. package/lib/plugin-management/workflow.js +878 -0
  133. package/lib/plugin-management.d.ts +5 -0
  134. package/lib/plugin-management.js +5 -0
  135. package/lib/project/config-file.d.ts +6 -0
  136. package/lib/project/config-file.js +58 -0
  137. package/lib/project/environment.d.ts +13 -0
  138. package/lib/project/environment.js +52 -0
  139. package/lib/project/instance-declarations.d.ts +55 -0
  140. package/lib/project/instance-declarations.js +542 -0
  141. package/lib/project/legacy-config.d.ts +25 -0
  142. package/lib/project/legacy-config.js +70 -0
  143. package/lib/project/node-requirements.d.ts +7 -0
  144. package/lib/project/node-requirements.js +20 -0
  145. package/lib/project/tsx-module-loader.d.ts +7 -0
  146. package/lib/project/tsx-module-loader.js +29 -0
  147. package/lib/project.d.ts +7 -0
  148. package/lib/project.js +7 -0
  149. package/lib/server.d.ts +11 -0
  150. package/lib/server.js +11 -0
  151. package/lib/supervisor/instance-logs.d.ts +39 -0
  152. package/lib/supervisor/instance-logs.js +152 -0
  153. package/lib/supervisor/instance-supervisor.d.ts +140 -0
  154. package/lib/supervisor/instance-supervisor.js +408 -0
  155. package/lib/supervisor/run-instance.d.ts +35 -0
  156. package/lib/supervisor/run-instance.js +161 -0
  157. package/lib/supervisor/terminal-state.d.ts +30 -0
  158. package/lib/supervisor/terminal-state.js +57 -0
  159. package/lib/supervisor.d.ts +4 -0
  160. package/lib/supervisor.js +4 -0
  161. package/lib/terminal/backends.d.ts +38 -0
  162. package/lib/terminal/backends.js +193 -0
  163. package/lib/terminal/config.d.ts +63 -0
  164. package/lib/terminal/config.js +237 -0
  165. package/lib/terminal/host.d.ts +26 -0
  166. package/lib/terminal/host.js +76 -0
  167. package/lib/terminal/index.d.ts +60 -0
  168. package/lib/terminal/index.js +135 -0
  169. package/lib/terminal/modules.d.ts +87 -0
  170. package/lib/terminal/modules.js +44 -0
  171. package/lib/terminal/service.d.ts +101 -0
  172. package/lib/terminal/service.js +455 -0
  173. package/lib/terminal/tickets.d.ts +46 -0
  174. package/lib/terminal/tickets.js +76 -0
  175. package/lib/terminal.d.ts +3 -0
  176. package/lib/terminal.js +3 -0
  177. package/lib/usage/aggregate.d.ts +87 -0
  178. package/lib/usage/aggregate.js +212 -0
  179. package/lib/usage.d.ts +2 -0
  180. package/lib/usage.js +2 -0
  181. package/package.json +117 -4
@@ -0,0 +1,408 @@
1
+ /**
2
+ * Instance supervisor (ADR 0054 §3 / §5): spawns the Zhin instance with an IPC control channel
3
+ * (`serialization: 'advanced'`), waits for its `ready` handshake, stops it with a graceful
4
+ * `shutdown` message before escalating to SIGTERM / SIGKILL, restarts it on `restart-request`
5
+ * and on crashes with backoff and storm protection, and captures its stdout / stderr.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import { EventEmitter } from 'node:events';
9
+ import { INSTANCE_BRIDGE_PROTOCOL_VERSION, INSTANCE_CHILD_ENV, IpcInstanceTransport, parseInstanceMessage, } from '@zhin.js/host-http/bridge';
10
+ import { DEFAULT_SHUTDOWN_BUDGET_MS } from '@zhin.js/runtime';
11
+ import { InstanceLogCollector } from './instance-logs.js';
12
+ export const DEFAULT_INSTANCE_READY_TIMEOUT_MS = 60_000;
13
+ export const DEFAULT_INSTANCE_KILL_GRACE_MS = 2_000;
14
+ /** Storm guard: 10 automatic restarts per minute, 3 s apart (ADR 0054 §5). */
15
+ export const MAX_RESTARTS_PER_WINDOW = 10;
16
+ export const RESTART_WINDOW_MS = 60_000;
17
+ export const RESTART_DELAY_MS = 3_000;
18
+ const FAILURE_LOG_TAIL_LINES = 50;
19
+ const STDIO_DRAIN_MS = 250;
20
+ export class InstanceProtocolMismatchError extends Error {
21
+ instanceVersion;
22
+ accepted;
23
+ code = 'ZHIN_INSTANCE_PROTOCOL_MISMATCH';
24
+ constructor(instanceVersion, accepted) {
25
+ super(`Instance protocol version ${instanceVersion} is not supported by this gateway (accepts ${accepted.join(', ')}); upgrade zhin.js and @zhin.js/gateway together`);
26
+ this.instanceVersion = instanceVersion;
27
+ this.accepted = accepted;
28
+ this.name = 'InstanceProtocolMismatchError';
29
+ }
30
+ }
31
+ export class InstanceStartError extends Error {
32
+ failure;
33
+ code = 'ZHIN_INSTANCE_START_FAILED';
34
+ constructor(failure) {
35
+ super(failure.message);
36
+ this.failure = failure;
37
+ this.name = 'InstanceStartError';
38
+ }
39
+ }
40
+ export class InstanceSupervisor extends EventEmitter {
41
+ logs;
42
+ /**
43
+ * HTTP / WebSocket bridge to the current instance child (ADR 0054 §2). Stable across
44
+ * restarts: the gateway's HTTP server forwards through it; it is `ready` while a child that
45
+ * announced its route table runs.
46
+ */
47
+ transport;
48
+ #options;
49
+ #readyTimeoutMs;
50
+ #accepted;
51
+ #state = 'stopped';
52
+ #child;
53
+ #starting;
54
+ #stopping;
55
+ #backoffTimer;
56
+ #restartTimes = [];
57
+ #ready;
58
+ #startedAt;
59
+ #lastExit;
60
+ #lastFailure;
61
+ /** Bumped by every stop(); a pending automatic restart only proceeds when it is unchanged. */
62
+ #stopIntent = 0;
63
+ constructor(options) {
64
+ super();
65
+ this.#options = options;
66
+ this.#readyTimeoutMs = options.readyTimeoutMs;
67
+ this.#accepted = Object.freeze([...(options.acceptedProtocolVersions ?? [INSTANCE_BRIDGE_PROTOCOL_VERSION])]);
68
+ this.logs = new InstanceLogCollector(options.logs);
69
+ this.transport = new IpcInstanceTransport(options.transport);
70
+ this.logs.subscribe((line) => { this.emit('log', line); });
71
+ }
72
+ get state() { return this.#state; }
73
+ status() {
74
+ const child = this.#child;
75
+ return Object.freeze({
76
+ state: this.#state,
77
+ ...(child?.process.pid !== undefined ? { pid: child.process.pid } : {}),
78
+ ...(this.#startedAt !== undefined && this.#state === 'running' ? { startedAt: this.#startedAt } : {}),
79
+ ...(this.#ready?.generation !== undefined ? { generation: this.#ready.generation } : {}),
80
+ ...(this.#ready ? { protocolVersion: this.#ready.version } : {}),
81
+ lastExitCode: this.#lastExit?.code ?? null,
82
+ lastExitSignal: this.#lastExit?.signal ?? null,
83
+ ...(this.#lastFailure ? { lastFailure: this.#lastFailure } : {}),
84
+ recentRestarts: this.#recentRestarts(Date.now()).length,
85
+ });
86
+ }
87
+ /**
88
+ * Starts the instance and resolves on its `ready`. A manual start resets the storm window.
89
+ * Rejects with {@link InstanceStartError} / {@link InstanceProtocolMismatchError} when this
90
+ * attempt fails; automatic restarts after a crash still follow the restart policy.
91
+ */
92
+ async start() {
93
+ if (this.#stopping)
94
+ await this.#stopping.catch(() => undefined);
95
+ // A child that is being replaced (requested restart, ready failure) must be gone first.
96
+ if (this.#child?.terminating)
97
+ await this.#child.exited;
98
+ if (this.#state === 'running')
99
+ return;
100
+ if (this.#starting)
101
+ return this.#starting;
102
+ this.#clearBackoff();
103
+ this.#restartTimes = [];
104
+ return this.#spawn();
105
+ }
106
+ /** Graceful `shutdown`, then SIGTERM after `timeoutMs`, then SIGKILL after the kill grace. */
107
+ stop(options = {}) {
108
+ this.#stopIntent += 1;
109
+ this.#clearBackoff();
110
+ const child = this.#child;
111
+ if (!child) {
112
+ if (this.#state === 'backoff' || this.#state === 'crashed')
113
+ this.#setState('stopped');
114
+ return this.#stopping ?? Promise.resolve();
115
+ }
116
+ if (options.force)
117
+ child.process.kill('SIGKILL');
118
+ if (this.#stopping)
119
+ return this.#stopping;
120
+ this.#setState('stopping');
121
+ const stopping = this.#terminate(child, options.reason ?? 'stop', options.timeoutMs).then(() => undefined);
122
+ this.#stopping = stopping.finally(() => { this.#stopping = undefined; });
123
+ return this.#stopping;
124
+ }
125
+ /**
126
+ * Ready timeout for the next spawns (`gateway.readyTimeout`, re-read by the gateway before each
127
+ * start, restart and transaction); `undefined` goes back to the constructor option.
128
+ */
129
+ setReadyTimeout(ms) {
130
+ this.#readyTimeoutMs = ms ?? this.#options.readyTimeoutMs;
131
+ }
132
+ /** Ready timeout the next spawn uses. */
133
+ get readyTimeoutMs() {
134
+ return this.#readyTimeoutMs ?? DEFAULT_INSTANCE_READY_TIMEOUT_MS;
135
+ }
136
+ /** Manual restart: stop gracefully, then start (not counted by the storm guard). */
137
+ async restart(options = {}) {
138
+ await this.stop({ ...options, reason: options.reason ?? 'restart' });
139
+ await this.start();
140
+ }
141
+ /** Releases timers and the log file; kills a child that is still running. */
142
+ dispose() {
143
+ this.#clearBackoff();
144
+ try {
145
+ this.#child?.process.kill('SIGKILL');
146
+ }
147
+ catch { /* already gone */ }
148
+ this.transport.detach('Supervisor disposed');
149
+ this.logs.close();
150
+ this.removeAllListeners();
151
+ }
152
+ #spawn() {
153
+ const { spawn: spec } = this.#options;
154
+ const output = this.#options.output ?? 'pipe';
155
+ this.#ready = undefined;
156
+ this.#startedAt = undefined;
157
+ this.#setState('starting');
158
+ let processHandle;
159
+ let resolveExited;
160
+ const exited = new Promise((resolve) => { resolveExited = resolve; });
161
+ try {
162
+ processHandle = spawn(spec.command, [...spec.args], {
163
+ cwd: spec.cwd,
164
+ // The marker carries the protocol version: the instance refuses an incompatible gateway.
165
+ env: { ...(spec.env ?? process.env), [INSTANCE_CHILD_ENV]: String(INSTANCE_BRIDGE_PROTOCOL_VERSION) },
166
+ stdio: [this.#options.input ?? 'ignore', output, output, 'ipc'],
167
+ serialization: 'advanced',
168
+ windowsHide: true,
169
+ });
170
+ }
171
+ catch (error) {
172
+ const failure = this.#failure('spawn-error', `Failed to spawn the instance: ${errorMessage(error)}`, null, null);
173
+ this.#afterFailedChild(failure, false);
174
+ return Promise.reject(new InstanceStartError(failure));
175
+ }
176
+ const child = { process: processHandle, exited, resolveExited, ready: false, terminating: false };
177
+ this.#child = child;
178
+ if (output === 'pipe') {
179
+ if (processHandle.stdout)
180
+ this.logs.attach(processHandle.stdout, 'stdout');
181
+ if (processHandle.stderr)
182
+ this.logs.attach(processHandle.stderr, 'stderr');
183
+ }
184
+ const starting = new Promise((resolve, reject) => {
185
+ child.settleStart = (error) => {
186
+ child.settleStart = undefined;
187
+ if (error)
188
+ reject(error);
189
+ else
190
+ resolve();
191
+ };
192
+ });
193
+ const guarded = starting.finally(() => {
194
+ if (this.#starting === guarded)
195
+ this.#starting = undefined;
196
+ });
197
+ this.#starting = guarded;
198
+ // Callers may ignore automatic-restart attempts; never leave them unhandled.
199
+ guarded.catch(() => undefined);
200
+ const readyTimeoutMs = this.readyTimeoutMs;
201
+ child.readyTimer = setTimeout(() => {
202
+ if (child.ready || this.#child !== child)
203
+ return;
204
+ const failure = this.#failure('ready-timeout', `Instance did not report ready within ${readyTimeoutMs} ms`, null, null);
205
+ child.failure = failure;
206
+ child.settleStart?.(new InstanceStartError(failure));
207
+ void this.#terminate(child, 'ready-timeout');
208
+ }, readyTimeoutMs);
209
+ processHandle.on('message', (raw) => { this.#onMessage(child, raw); });
210
+ // Bridge streams (routes, HTTP, WebSocket) of this child go to the transport.
211
+ this.transport.attachChildProcess(processHandle);
212
+ processHandle.once('error', (error) => {
213
+ // Spawn failures (ENOENT, EACCES) emit `error` and no `exit`.
214
+ if (processHandle.pid !== undefined && processHandle.exitCode === null && processHandle.signalCode === null)
215
+ return;
216
+ child.failure ??= this.#failure('spawn-error', `Failed to spawn the instance: ${error.message}`, null, null);
217
+ this.#onExit(child, null, null);
218
+ });
219
+ // Let stdout / stderr drain so the log tail is complete, but do not wait on `close`: a
220
+ // grandchild that inherited the pipes could hold them open long after the instance died.
221
+ const drained = Promise.all([processHandle.stdout, processHandle.stderr].map((stream) => (stream && !stream.readableEnded
222
+ ? new Promise((resolve) => { stream.once('end', resolve); stream.once('close', resolve); })
223
+ : undefined)));
224
+ processHandle.once('exit', (code, signal) => {
225
+ const settle = () => {
226
+ clearTimeout(timer);
227
+ this.#onExit(child, code, signal);
228
+ };
229
+ const timer = setTimeout(settle, STDIO_DRAIN_MS);
230
+ void drained.then(settle);
231
+ });
232
+ return guarded;
233
+ }
234
+ #onMessage(child, raw) {
235
+ if (this.#child !== child)
236
+ return;
237
+ const message = parseInstanceMessage(raw);
238
+ if (!message)
239
+ return;
240
+ if (message.type === 'ready') {
241
+ if (child.ready || child.terminating)
242
+ return;
243
+ clearTimeout(child.readyTimer);
244
+ if (!this.#accepted.includes(message.payload.version)) {
245
+ const error = new InstanceProtocolMismatchError(message.payload.version, this.#accepted);
246
+ child.failure = this.#failure('protocol-mismatch', error.message, null, null);
247
+ child.settleStart?.(error);
248
+ void this.#terminate(child, 'protocol-mismatch');
249
+ return;
250
+ }
251
+ child.ready = true;
252
+ this.#ready = message.payload;
253
+ this.#startedAt = Date.now();
254
+ this.#setState('running');
255
+ this.emit('ready', message.payload);
256
+ child.settleStart?.();
257
+ return;
258
+ }
259
+ if (message.type !== 'restart-request')
260
+ return;
261
+ this.emit('restart-request', message.payload);
262
+ if (this.#options.restart?.onRequest === false)
263
+ return;
264
+ if (!child.ready || child.terminating || this.#state !== 'running')
265
+ return;
266
+ if (!this.#admitRestart()) {
267
+ child.failure = this.#failure('restart-storm', `Too many restarts within ${this.#windowMs()} ms; the instance stays stopped`, null, null);
268
+ void this.#terminate(child, 'restart-storm');
269
+ return;
270
+ }
271
+ // Respawned straight from the exit handler, so observers never see a transient `stopped`.
272
+ child.restartIntent = this.#stopIntent;
273
+ this.#setState('stopping');
274
+ void this.#terminate(child, `restart:${message.payload.reason}`);
275
+ }
276
+ #onExit(child, code, signal) {
277
+ if (this.#child !== child)
278
+ return;
279
+ clearTimeout(child.readyTimer);
280
+ clearTimeout(child.termTimer);
281
+ clearTimeout(child.killTimer);
282
+ this.logs.flush();
283
+ this.#child = undefined;
284
+ const expected = child.terminating;
285
+ const exit = Object.freeze({ code, signal, expected });
286
+ this.#lastExit = exit;
287
+ this.emit('exit', exit);
288
+ if (child.failure) {
289
+ const failure = { ...child.failure, exitCode: code, signal };
290
+ this.#afterFailedChild(Object.freeze(failure), child.failure.reason === 'ready-timeout');
291
+ child.settleStart?.(new InstanceStartError(failure));
292
+ }
293
+ else if (child.restartIntent !== undefined && child.restartIntent === this.#stopIntent) {
294
+ child.resolveExited(exit);
295
+ void this.#spawn().catch(() => undefined);
296
+ return;
297
+ }
298
+ else if (expected || (code === 0 && signal === null)) {
299
+ // Requested stop, or the instance ended on its own cleanly (e.g. `--once`).
300
+ this.#setState('stopped');
301
+ child.settleStart?.(new InstanceStartError(this.#failure('exit', 'Instance exited before it was ready', code, signal)));
302
+ }
303
+ else {
304
+ const failure = this.#failure('exit', `Instance exited unexpectedly (code ${code ?? 'none'}${signal ? `, signal ${signal}` : ''})`, code, signal);
305
+ this.#afterFailedChild(failure, true);
306
+ child.settleStart?.(new InstanceStartError(failure));
307
+ }
308
+ child.resolveExited(exit);
309
+ }
310
+ /** Records a failure and either schedules a restart (`backoff`) or stays `crashed`. */
311
+ #afterFailedChild(failure, restartable) {
312
+ this.#lastFailure = failure;
313
+ this.emit('failure', failure);
314
+ if (restartable && this.#options.restart?.onCrash !== false && this.#admitRestart()) {
315
+ this.#setState('backoff');
316
+ this.#backoffTimer = setTimeout(() => {
317
+ this.#backoffTimer = undefined;
318
+ if (this.#state === 'backoff')
319
+ void this.#spawn().catch(() => undefined);
320
+ }, this.#options.restart?.delayMs ?? RESTART_DELAY_MS);
321
+ return;
322
+ }
323
+ if (restartable && this.#options.restart?.onCrash !== false) {
324
+ this.#lastFailure = Object.freeze({
325
+ ...failure,
326
+ reason: 'restart-storm',
327
+ message: `${failure.message}; too many restarts within ${this.#windowMs()} ms, the instance stays crashed`,
328
+ });
329
+ this.emit('failure', this.#lastFailure);
330
+ }
331
+ this.#setState('crashed');
332
+ }
333
+ #terminate(child, reason, timeoutMs) {
334
+ if (child.terminating)
335
+ return child.exited;
336
+ child.terminating = true;
337
+ clearTimeout(child.readyTimer);
338
+ const budgetMs = this.#options.shutdownBudgetMs ?? DEFAULT_SHUTDOWN_BUDGET_MS;
339
+ const waitMs = timeoutMs ?? budgetMs + 1_000;
340
+ const killGraceMs = this.#options.killGraceMs ?? DEFAULT_INSTANCE_KILL_GRACE_MS;
341
+ const escalate = () => {
342
+ if (this.#child !== child)
343
+ return;
344
+ try {
345
+ child.process.kill('SIGTERM');
346
+ }
347
+ catch { /* already gone */ }
348
+ child.killTimer = setTimeout(() => {
349
+ if (this.#child !== child)
350
+ return;
351
+ try {
352
+ child.process.kill('SIGKILL');
353
+ }
354
+ catch { /* already gone */ }
355
+ }, killGraceMs);
356
+ };
357
+ if (child.process.connected) {
358
+ const message = { type: 'shutdown', payload: { reason, budgetMs } };
359
+ try {
360
+ child.process.send(message, () => undefined);
361
+ }
362
+ catch { /* channel closed */ }
363
+ child.termTimer = setTimeout(escalate, waitMs);
364
+ }
365
+ else {
366
+ escalate();
367
+ }
368
+ return child.exited;
369
+ }
370
+ #admitRestart(now = Date.now()) {
371
+ const recent = this.#recentRestarts(now);
372
+ if (recent.length >= (this.#options.restart?.maxRestarts ?? MAX_RESTARTS_PER_WINDOW)) {
373
+ this.#restartTimes = recent;
374
+ return false;
375
+ }
376
+ this.#restartTimes = [...recent, now];
377
+ return true;
378
+ }
379
+ #recentRestarts(now) {
380
+ const windowMs = this.#windowMs();
381
+ return this.#restartTimes.filter((time) => now - time < windowMs);
382
+ }
383
+ #windowMs() { return this.#options.restart?.windowMs ?? RESTART_WINDOW_MS; }
384
+ #failure(reason, message, exitCode, signal) {
385
+ return Object.freeze({
386
+ reason,
387
+ message,
388
+ at: Date.now(),
389
+ exitCode,
390
+ signal,
391
+ logTail: Object.freeze(this.logs.tail(FAILURE_LOG_TAIL_LINES).map((line) => line.line)),
392
+ });
393
+ }
394
+ #clearBackoff() {
395
+ if (this.#backoffTimer)
396
+ clearTimeout(this.#backoffTimer);
397
+ this.#backoffTimer = undefined;
398
+ }
399
+ #setState(state) {
400
+ if (this.#state === state)
401
+ return;
402
+ this.#state = state;
403
+ this.emit('state', this.status());
404
+ }
405
+ }
406
+ function errorMessage(error) {
407
+ return error instanceof Error ? error.message : String(error);
408
+ }
@@ -0,0 +1,35 @@
1
+ import type { StartOptions } from '@zhin.js/runtime';
2
+ import { InstanceSupervisor } from './instance-supervisor.js';
3
+ import type { InstanceLogOptions } from './instance-logs.js';
4
+ /** Default daemon log file (ADR 0054 §3). */
5
+ export declare function defaultInstanceLogFile(root: string): string;
6
+ /** Whether this process is the supervised instance child rather than the supervisor. */
7
+ export declare function isSupervisedInstanceProcess(environment?: Readonly<Record<string, string | undefined>>): boolean;
8
+ /** Preserve process loader configuration when the supervisor creates a child. */
9
+ export declare function supervisedNodeArguments(execArguments?: readonly string[]): readonly string[];
10
+ export interface RunSupervisedInstanceOptions {
11
+ readonly root: string;
12
+ readonly options: StartOptions;
13
+ /** Executable script the child runs; defaults to `process.argv[1]` (the `zhin` CLI). */
14
+ readonly entry?: string;
15
+ /** Arguments after the entry; defaults to `process.argv.slice(2)`. */
16
+ readonly args?: readonly string[];
17
+ readonly logs?: Omit<InstanceLogOptions, 'file'>;
18
+ /**
19
+ * Attaches a component (the gateway HTTP server) to the supervisor before the instance starts;
20
+ * closed when the run ends. Its `keepAlive()` keeps the run going while it owns the instance's
21
+ * lifecycle (a Console stop, a dependency transaction).
22
+ */
23
+ readonly attach?: (supervisor: InstanceSupervisor) => Promise<SupervisedAttachment>;
24
+ }
25
+ export interface SupervisedAttachment {
26
+ /** True while a stopped / crashed instance must not end the run. */
27
+ keepAlive?(): boolean;
28
+ close(): Promise<void>;
29
+ }
30
+ /**
31
+ * Runs the instance under supervision and resolves with the exit code this process should use.
32
+ * Foreground runs share the terminal (`inherit`) and do not restart crashes; `--daemon` captures
33
+ * output into a rotated log file and restarts crashes with storm protection; `--once` never restarts.
34
+ */
35
+ export declare function runSupervisedInstance(input: RunSupervisedInstanceOptions): Promise<number>;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Parent side of `zhin runtime start`: runs the instance under an {@link InstanceSupervisor} until it
3
+ * stops for good (signal, clean exit, or crash without restart).
4
+ */
5
+ import { readFileSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { mkdir } from 'node:fs/promises';
7
+ import { dirname, join } from 'node:path';
8
+ import { INSTANCE_CHILD_ENV } from '@zhin.js/host-http/bridge';
9
+ import { formatCompact, getLogger } from '@zhin.js/logger';
10
+ import { isNodeVersionSupported, NODE_ENGINES_HINT } from '../project/node-requirements.js';
11
+ import { InstanceSupervisor } from './instance-supervisor.js';
12
+ const DISABLE_EXPERIMENTAL_WARNING_FLAG = '--disable-warning=ExperimentalWarning';
13
+ const NODE_LOADER_OPTIONS = new Set([
14
+ '--conditions',
15
+ '--experimental-loader',
16
+ '--import',
17
+ '--loader',
18
+ '--require',
19
+ '-C',
20
+ '-r',
21
+ ]);
22
+ /** Default daemon log file (ADR 0054 §3). */
23
+ export function defaultInstanceLogFile(root) {
24
+ return join(root, '.zhin', 'logs', 'instance.log');
25
+ }
26
+ /** Whether this process is the supervised instance child rather than the supervisor. */
27
+ export function isSupervisedInstanceProcess(environment = process.env) {
28
+ return Boolean(environment[INSTANCE_CHILD_ENV]);
29
+ }
30
+ /** Preserve process loader configuration when the supervisor creates a child. */
31
+ export function supervisedNodeArguments(execArguments = process.execArgv) {
32
+ const inherited = [];
33
+ for (let index = 0; index < execArguments.length; index += 1) {
34
+ const argument = execArguments[index];
35
+ const option = argument.split('=', 1)[0];
36
+ if (!NODE_LOADER_OPTIONS.has(option))
37
+ continue;
38
+ inherited.push(argument);
39
+ if (argument === option) {
40
+ const value = execArguments[index + 1];
41
+ if (value !== undefined) {
42
+ inherited.push(value);
43
+ index += 1;
44
+ }
45
+ }
46
+ }
47
+ return Object.freeze([
48
+ ...inherited,
49
+ '--experimental-strip-types',
50
+ DISABLE_EXPERIMENTAL_WARNING_FLAG,
51
+ ]);
52
+ }
53
+ const KEEP_ALIVE_POLL_MS = 500;
54
+ /**
55
+ * Runs the instance under supervision and resolves with the exit code this process should use.
56
+ * Foreground runs share the terminal (`inherit`) and do not restart crashes; `--daemon` captures
57
+ * output into a rotated log file and restarts crashes with storm protection; `--once` never restarts.
58
+ */
59
+ export async function runSupervisedInstance(input) {
60
+ if (!isNodeVersionSupported()) {
61
+ throw new Error(`zhin runtime start requires Node ${NODE_ENGINES_HINT}; found ${process.versions.node}`);
62
+ }
63
+ const entry = input.entry ?? process.argv[1];
64
+ if (!entry)
65
+ throw new Error('Cannot determine the zhin runtime executable path');
66
+ const { root, options } = input;
67
+ const logger = getLogger('runtime');
68
+ const pidFile = join(root, '.zhin.pid');
69
+ const logFile = options.daemon ? (options.logFile ?? defaultInstanceLogFile(root)) : undefined;
70
+ if (logFile)
71
+ await mkdir(dirname(logFile), { recursive: true });
72
+ const supervisor = new InstanceSupervisor({
73
+ spawn: {
74
+ command: process.execPath,
75
+ args: [...supervisedNodeArguments(), entry, ...(input.args ?? process.argv.slice(2))],
76
+ cwd: root,
77
+ },
78
+ output: options.daemon ? 'pipe' : 'inherit',
79
+ logs: { ...input.logs, file: logFile ?? false },
80
+ restart: {
81
+ onCrash: options.daemon && !options.once,
82
+ onRequest: !options.once,
83
+ },
84
+ });
85
+ if (options.daemon) {
86
+ writeFileSync(pidFile, String(process.pid));
87
+ logger.info(formatCompact({
88
+ op: 'daemon_start', pid: process.pid, log: logFile,
89
+ hint: `stop: zhin stop 或 kill -TERM ${process.pid}`,
90
+ }));
91
+ }
92
+ supervisor.on('failure', (failure) => {
93
+ logger.warn(formatCompact({ op: 'instance_failure', reason: failure.reason, message: failure.message }));
94
+ });
95
+ supervisor.on('restart-request', (request) => {
96
+ logger.info(formatCompact({ op: 'instance_restart_request', reason: request.reason }));
97
+ });
98
+ let interrupted = false;
99
+ const onSignal = () => {
100
+ // A second signal kills the instance without waiting for its graceful stop.
101
+ const force = interrupted;
102
+ interrupted = true;
103
+ void supervisor.stop({ reason: 'signal', force });
104
+ };
105
+ const onSighup = () => { if (!options.daemon)
106
+ onSignal(); };
107
+ const onExit = () => {
108
+ supervisor.dispose();
109
+ removePidFile();
110
+ };
111
+ const removePidFile = () => {
112
+ if (!options.daemon)
113
+ return;
114
+ try {
115
+ if (readFileSync(pidFile, 'utf8').trim() === String(process.pid))
116
+ rmSync(pidFile, { force: true });
117
+ }
118
+ catch { /* already gone */ }
119
+ };
120
+ process.on('SIGINT', onSignal);
121
+ process.on('SIGTERM', onSignal);
122
+ process.on('SIGHUP', onSighup);
123
+ process.on('exit', onExit);
124
+ let attachment;
125
+ let keepAliveTimer;
126
+ try {
127
+ attachment = await input.attach?.(supervisor);
128
+ const ended = () => (supervisor.state === 'stopped' || supervisor.state === 'crashed')
129
+ && (interrupted || attachment?.keepAlive?.() !== true);
130
+ const settled = new Promise((resolve) => {
131
+ const check = () => {
132
+ if (ended())
133
+ resolve(supervisor.status());
134
+ };
135
+ supervisor.on('state', check);
136
+ if (attachment?.keepAlive) {
137
+ keepAliveTimer = setInterval(check, KEEP_ALIVE_POLL_MS);
138
+ keepAliveTimer.unref?.();
139
+ }
140
+ });
141
+ await supervisor.start().catch(() => undefined);
142
+ const status = ended() ? supervisor.status() : await settled;
143
+ if (interrupted)
144
+ return status.lastExitCode ?? 130;
145
+ if (status.state === 'crashed')
146
+ return status.lastExitCode || 1;
147
+ return status.lastExitCode ?? 0;
148
+ }
149
+ finally {
150
+ clearInterval(keepAliveTimer);
151
+ await attachment?.close().catch((error) => {
152
+ logger.warn(formatCompact({ op: 'gateway_close_failed', error: error instanceof Error ? error.message : String(error) }));
153
+ });
154
+ process.off('SIGINT', onSignal);
155
+ process.off('SIGTERM', onSignal);
156
+ process.off('SIGHUP', onSighup);
157
+ process.off('exit', onExit);
158
+ supervisor.dispose();
159
+ removePidFile();
160
+ }
161
+ }
@@ -0,0 +1,30 @@
1
+ export interface TerminalProbe {
2
+ readonly platform?: NodeJS.Platform;
3
+ readonly pid?: number;
4
+ /** `/proc/<pid>/stat` text (Linux). */
5
+ readonly readStat?: (pid: number) => string;
6
+ /** `ps -o pgid=,tpgid= -p <pid>` output (other Unix). */
7
+ readonly ps?: (pid: number) => string;
8
+ }
9
+ /**
10
+ * Whether this process is in the terminal's foreground process group. A background job
11
+ * (`zhin start --foreground &`) must not hand its TTY stdin to the instance: reading it would
12
+ * stop the instance with SIGTTIN. Unknown (no controlling terminal, probe failed) counts as
13
+ * foreground, the previous behaviour.
14
+ */
15
+ export declare function isForegroundProcessGroup(probe?: TerminalProbe): boolean;
16
+ export interface TerminalState {
17
+ /** Puts the terminal back as it was when captured (e.g. after an instance left it in raw mode). */
18
+ restore(): void;
19
+ }
20
+ /** Runs `stty` on the inherited terminal; undefined when it fails. */
21
+ export type SttyRunner = (args: readonly string[]) => string | undefined;
22
+ /**
23
+ * Saves the terminal settings (`stty -g`) so they can be restored once the instance that shares
24
+ * the TTY exits — a crashed terminal adapter can leave it in raw / no-echo mode. Undefined on
25
+ * Windows or without a usable `stty`.
26
+ */
27
+ export declare function captureTerminalState(options?: {
28
+ readonly platform?: NodeJS.Platform;
29
+ readonly stty?: SttyRunner;
30
+ }): TerminalState | undefined;