@p10i/create-nestbox 0.5.0 → 0.6.0

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.
@@ -1,205 +0,0 @@
1
- #!/usr/bin/env python3
2
- import http.client
3
- import json
4
- import signal
5
- import sys
6
- import threading
7
- import time
8
- from urllib.parse import quote
9
-
10
- PROTOCOL_VERSION = 1
11
- MAX_LINE_BYTES = 64 * 1024 * 1024
12
- HEARTBEAT_SECONDS = 4
13
- stopping = threading.Event()
14
- input_condition = threading.Condition()
15
- input_messages = []
16
- input_error = None
17
- input_eof = False
18
- connections = set()
19
- connections_lock = threading.Lock()
20
-
21
-
22
- def diagnostic(message):
23
- sys.stderr.write(f"nestbox host bridge: {message}\n")
24
- sys.stderr.flush()
25
-
26
-
27
- def stop_requests():
28
- stopping.set()
29
- with connections_lock:
30
- active = list(connections)
31
- for connection in active:
32
- connection.close()
33
- with input_condition:
34
- input_condition.notify_all()
35
-
36
-
37
- def send(message):
38
- data = json.dumps({"protocolVersion": PROTOCOL_VERSION, **message}, separators=(",", ":"))
39
- sys.stdout.write(data + "\n")
40
- sys.stdout.flush()
41
-
42
-
43
- def read_input():
44
- global input_error, input_eof
45
- try:
46
- while True:
47
- line = sys.stdin.buffer.readline(MAX_LINE_BYTES + 1)
48
- if not line:
49
- with input_condition:
50
- input_eof = True
51
- input_condition.notify_all()
52
- stop_requests()
53
- return
54
- if len(line) > MAX_LINE_BYTES or not line.endswith(b"\n"):
55
- raise ValueError("protocol line exceeds the limit")
56
- value = json.loads(line)
57
- if not isinstance(value, dict) or value.get("protocolVersion") != PROTOCOL_VERSION:
58
- raise ValueError("unsupported protocol message")
59
- with input_condition:
60
- if len(input_messages) >= 2:
61
- raise ValueError("too many queued protocol messages")
62
- input_messages.append(value)
63
- input_condition.notify_all()
64
- except Exception as error:
65
- with input_condition:
66
- input_error = error
67
- input_condition.notify_all()
68
- stop_requests()
69
-
70
-
71
- def receive():
72
- with input_condition:
73
- while not input_messages and not input_eof and input_error is None and not stopping.is_set():
74
- input_condition.wait()
75
- if input_error is not None:
76
- raise input_error
77
- if not input_messages:
78
- raise EOFError
79
- return input_messages.pop(0)
80
-
81
-
82
- def request(method, path, runner_id, session_id, body=None, timeout=35):
83
- payload = json.dumps(body, separators=(",", ":")).encode() if body is not None else None
84
- headers = {
85
- "X-Nestbox-Runner-Id": runner_id,
86
- "X-Nestbox-Session-Id": session_id,
87
- "Content-Type": "application/json",
88
- }
89
- connection = http.client.HTTPConnection("127.0.0.1", 4089, timeout=timeout)
90
- with connections_lock:
91
- connections.add(connection)
92
- try:
93
- connection.request(method, path, body=payload, headers=headers)
94
- response = connection.getresponse()
95
- data = response.read(MAX_LINE_BYTES + 1)
96
- finally:
97
- connection.close()
98
- with connections_lock:
99
- connections.discard(connection)
100
- if len(data) > MAX_LINE_BYTES:
101
- raise RuntimeError("control response exceeds the limit")
102
- value = json.loads(data) if data else None
103
- return response.status, value
104
-
105
-
106
- def heartbeat(runner_id, session_id, metadata):
107
- while not stopping.wait(HEARTBEAT_SECONDS):
108
- try:
109
- status, value = request("POST", "/host/heartbeat", runner_id, session_id, metadata, timeout=5)
110
- if status == 409:
111
- diagnostic((value or {}).get("error", "session was superseded"))
112
- stop_requests()
113
- return
114
- if status >= 400:
115
- diagnostic(f"heartbeat returned HTTP {status}")
116
- except Exception as error:
117
- if not stopping.is_set():
118
- diagnostic(f"heartbeat failed: {error}")
119
-
120
-
121
- def deliver_result(runner_id, session_id, result):
122
- job_id = result.get("jobId")
123
- delay = 0.5
124
- while not stopping.is_set():
125
- try:
126
- status, response = request("POST", f"/host/jobs/{quote(str(job_id), safe='')}/result", runner_id, session_id, result)
127
- if status in (200, 202):
128
- send({"type": "result.ack", "jobId": job_id, "duplicate": bool((response or {}).get("duplicate"))})
129
- return
130
- error = (response or {}).get("error", f"result returned HTTP {status}")
131
- if status == 410:
132
- send({"type": "gone", "jobId": job_id, "error": error})
133
- return
134
- if status == 409 and "session was superseded" in error.lower():
135
- raise RuntimeError(error)
136
- if status < 500:
137
- send({"type": "result.rejected", "jobId": job_id, "error": error})
138
- return
139
- except (OSError, http.client.HTTPException) as error:
140
- if stopping.is_set():
141
- raise EOFError from error
142
- diagnostic(f"result delivery failed: {error}; retrying")
143
- stopping.wait(delay)
144
- delay = min(10, delay * 2)
145
-
146
-
147
- def main():
148
- threading.Thread(target=read_input, daemon=True).start()
149
- hello = receive()
150
- if hello.get("type") != "hello":
151
- raise ValueError("first protocol message must be hello")
152
- runner_id, session_id = hello.get("runnerId"), hello.get("sessionId")
153
- metadata = hello.get("metadata") or {}
154
- if not isinstance(runner_id, str) or not isinstance(session_id, str) or not isinstance(metadata, dict):
155
- raise ValueError("invalid hello message")
156
- status, value = request("POST", "/host/connect", runner_id, session_id, metadata)
157
- if status >= 400:
158
- raise RuntimeError(f"connect returned HTTP {status}: {(value or {}).get('error', value)}")
159
- thread = threading.Thread(target=heartbeat, args=(runner_id, session_id, metadata), daemon=True)
160
- thread.start()
161
- retained_result = hello.get("retainedResult")
162
- if retained_result is not None:
163
- if not isinstance(retained_result, dict):
164
- raise ValueError("invalid retained result")
165
- deliver_result(runner_id, session_id, retained_result)
166
- if stopping.is_set():
167
- return
168
- send({"type": "ready"})
169
- while not stopping.is_set():
170
- status, value = request("GET", "/host/jobs/next?timeout=25", runner_id, session_id, timeout=30)
171
- if status == 204:
172
- send({"type": "idle"})
173
- continue
174
- if status == 409:
175
- raise RuntimeError((value or {}).get("error", "session was superseded"))
176
- if status == 410:
177
- send({"type": "gone", "jobId": (value or {}).get("jobId"), "error": (value or {}).get("error", "job is gone")})
178
- continue
179
- if status >= 400 or not isinstance(value, dict):
180
- raise RuntimeError(f"next job returned HTTP {status}: {value}")
181
- send({"type": "job", "job": value})
182
- result = receive()
183
- if result.get("type") != "result" or not isinstance(result.get("result"), dict):
184
- raise ValueError("expected a result message")
185
- job_id = result["result"].get("jobId")
186
- if job_id != value.get("jobId"):
187
- raise ValueError("result does not match the assigned job")
188
- deliver_result(runner_id, session_id, result["result"])
189
-
190
-
191
- def stop(_signum, _frame):
192
- stop_requests()
193
-
194
-
195
- if __name__ == "__main__":
196
- signal.signal(signal.SIGTERM, stop)
197
- signal.signal(signal.SIGINT, stop)
198
- try:
199
- main()
200
- except EOFError:
201
- pass
202
- except Exception as error:
203
- if not stopping.is_set():
204
- diagnostic(str(error))
205
- sys.exit(1)
package/host-runner.mjs DELETED
@@ -1,314 +0,0 @@
1
- import { spawn } from 'node:child_process';
2
- import { createHash, randomBytes, randomUUID } from 'node:crypto';
3
- import { hostname, platform } from 'node:os';
4
- import { basename, dirname, join, resolve } from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
- import { readFile, rename, unlink, writeFile } from 'node:fs/promises';
7
-
8
- const installDirectory = dirname(fileURLToPath(import.meta.url));
9
- const defaultWorkspace = basename(installDirectory) === '.nestbox' ? dirname(installDirectory) : installDirectory;
10
- const workspace = resolve(process.env.NESTBOX_HOST_WORKSPACE || defaultWorkspace);
11
- const hostPlatform = platform();
12
- const hostName = hostname();
13
- // Stable for this runner process; each Docker Compose exec gets a new session ID.
14
- const runnerId = `runner-${randomUUID()}`;
15
- const once = process.argv.slice(2).includes('--once');
16
- const jsonOutput = process.argv.slice(2).includes('--json');
17
- const dockerExecutable = process.env.NESTBOX_HOST_DOCKER || 'docker';
18
- const dockerPrefixArgs = process.env.NESTBOX_HOST_DOCKER_ARGS ? JSON.parse(process.env.NESTBOX_HOST_DOCKER_ARGS) : [];
19
- if (!Array.isArray(dockerPrefixArgs) || !dockerPrefixArgs.every(value => typeof value === 'string')) throw new Error('NESTBOX_HOST_DOCKER_ARGS must be a JSON string array.');
20
- const npmExecutable = process.env.NESTBOX_HOST_NPM || 'npm';
21
- const npmCli = process.env.npm_execpath || (!process.env.NESTBOX_HOST_NPM && process.platform === 'win32' ? join(dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js') : '');
22
- const maximumOutputBytes = Number(process.env.NESTBOX_HOST_MAX_OUTPUT_BYTES || 4 * 1024 * 1024);
23
- const approvalTtlMs = Number(process.env.NESTBOX_HOST_APPROVAL_TTL_MS || 5 * 60 * 1000);
24
- const approvalMaximumAttempts = Number(process.env.NESTBOX_HOST_APPROVAL_ATTEMPTS || 3);
25
- const maximumPendingApprovals = Number(process.env.NESTBOX_HOST_APPROVAL_MAX_PENDING || 20);
26
- const approvals = new Map();
27
- const activeChildren = new Set();
28
- let activeBridge;
29
- let stopping = false;
30
- let announced = false;
31
- let activeJobId;
32
- let activeJobPromise;
33
- let retainedResult;
34
-
35
- function emit(type, fields = {}) {
36
- const event = { ...fields, type, timestamp: new Date().toISOString() };
37
- if (jsonOutput) return process.stdout.write(`${JSON.stringify(event)}\n`);
38
- if (type === 'runner.ready') console.log(`Nestbox host runner connected through Docker Compose for ${workspace}`);
39
- else if (type === 'npm.scripts.confirmation_required') {
40
- console.log(`\nApproval ${event.requestId}: ${event.operation} npm script "${event.name}"`);
41
- if (event.command !== undefined) console.log(`Command: ${event.command}`);
42
- console.log(`Confirmation code: ${event.code} (expires ${event.expiresAt})\n`);
43
- } else if (type === 'npm.scripts.changed') console.log(`Approved ${event.operation} for npm script "${event.name}".`);
44
- else if (type === 'runner.error') console.error(event.error);
45
- }
46
-
47
- async function packageText() { return readFile(join(workspace, 'package.json'), 'utf8'); }
48
- function parsePackageDocument(text) {
49
- const document = JSON.parse(text);
50
- if (!document || typeof document !== 'object' || Array.isArray(document)) throw new Error('package.json must contain a JSON object.');
51
- if (document.scripts !== undefined && (!document.scripts || typeof document.scripts !== 'object' || Array.isArray(document.scripts))) throw new Error('package.json scripts must be an object.');
52
- return document;
53
- }
54
- async function packageDocument() { return parsePackageDocument(await packageText()); }
55
- function hash(contents) { return createHash('sha256').update(contents).digest('hex'); }
56
- async function packageScripts() {
57
- const document = await packageDocument();
58
- if (document.scripts === undefined) return {};
59
- if (!document.scripts || typeof document.scripts !== 'object' || Array.isArray(document.scripts)) throw new Error('package.json scripts must be an object.');
60
- return document.scripts;
61
- }
62
-
63
- async function writePackageJson(document, expectedHash) {
64
- const path = join(workspace, 'package.json');
65
- const temporary = `${path}.${runnerId}.tmp`;
66
- await writeFile(temporary, `${JSON.stringify(document, null, 2)}\n`, 'utf8');
67
- if (hash(await packageText()) !== expectedHash) {
68
- await unlink(temporary).catch(() => {});
69
- throw new Error('package.json changed while the approved update was being prepared.');
70
- }
71
- try { await rename(temporary, path); }
72
- catch (error) {
73
- if (process.platform !== 'win32' || !['EEXIST', 'EPERM'].includes(error?.code)) throw error;
74
- const backup = `${path}.${runnerId}.backup`;
75
- await rename(path, backup);
76
- try { await rename(temporary, path); await unlink(backup); }
77
- catch (replaceError) { await rename(backup, path).catch(() => {}); throw replaceError; }
78
- }
79
- }
80
-
81
- function validateScriptChange(job, scripts) {
82
- if (!['add', 'edit', 'delete'].includes(job.operation)) throw new Error('Unsupported npm script operation.');
83
- if (typeof job.name !== 'string' || !/^[A-Za-z0-9:_@./ -]{1,128}$/.test(job.name)) throw new Error('Invalid npm script name.');
84
- if (job.name === 'host') throw new Error('The core host script cannot be changed.');
85
- const exists = Object.hasOwn(scripts, job.name);
86
- if (job.operation === 'add' && exists) throw new Error(`npm script already exists: ${job.name}`);
87
- if (job.operation !== 'add' && !exists) throw new Error(`npm script is not defined: ${job.name}`);
88
- if (job.operation !== 'delete' && (typeof job.command !== 'string' || !job.command.trim() || job.command.length > 8192)) throw new Error('A non-empty command of at most 8192 characters is required.');
89
- if (job.operation === 'delete' && job.command !== undefined) throw new Error('Delete does not accept a command.');
90
- }
91
-
92
- function pruneApprovals() {
93
- const now = Date.now();
94
- for (const [requestId, approval] of approvals) if (approval.expiresAtMs <= now) approvals.delete(requestId);
95
- }
96
-
97
- async function requestScriptChange(job) {
98
- if (typeof job.requestId !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$/.test(job.requestId)) throw new Error('A valid request ID is required.');
99
- pruneApprovals();
100
- if (approvals.has(job.requestId)) throw new Error('The request ID is already pending.');
101
- if (approvals.size >= maximumPendingApprovals) throw new Error('Too many npm script approvals are pending.');
102
- const text = await packageText();
103
- const scripts = parsePackageDocument(text).scripts || {};
104
- validateScriptChange(job, scripts);
105
- const code = randomBytes(6).toString('base64url').slice(0, 8).toUpperCase();
106
- const expiresAtMs = Date.now() + approvalTtlMs;
107
- approvals.set(job.requestId, { ...job, expectedCommand: Object.hasOwn(scripts, job.name) ? String(scripts[job.name]) : null, expectedPackageHash: hash(text), code, attempts: 0, expiresAtMs });
108
- const expiresAt = new Date(expiresAtMs).toISOString();
109
- emit('npm.scripts.confirmation_required', { ...job, code, expiresAt });
110
- return { jobId: job.jobId, status: 'confirmation_required', requestId: job.requestId, operation: job.operation, name: job.name, expiresAt };
111
- }
112
-
113
- async function confirmScriptChange(job) {
114
- pruneApprovals();
115
- const approval = approvals.get(job.requestId);
116
- if (!approval) throw new Error('The npm script approval was not found or has expired.');
117
- approval.attempts += 1;
118
- if (String(job.code).trim().toUpperCase() !== approval.code) {
119
- if (approval.attempts >= approvalMaximumAttempts) approvals.delete(job.requestId);
120
- throw new Error(approval.attempts >= approvalMaximumAttempts ? 'Invalid confirmation code; the approval has been cancelled.' : 'Invalid confirmation code.');
121
- }
122
- const text = await packageText();
123
- if (hash(text) !== approval.expectedPackageHash) { approvals.delete(job.requestId); throw new Error('package.json changed after approval was requested; request a new approval.'); }
124
- const document = parsePackageDocument(text), scripts = document.scripts || {};
125
- validateScriptChange(approval, scripts);
126
- if (approval.operation === 'delete') delete scripts[approval.name]; else scripts[approval.name] = approval.command;
127
- document.scripts = scripts;
128
- await writePackageJson(document, approval.expectedPackageHash);
129
- approvals.delete(job.requestId);
130
- emit('npm.scripts.changed', approval);
131
- return { jobId: job.jobId, status: 'success', requestId: approval.requestId, operation: approval.operation, name: approval.name };
132
- }
133
-
134
- function appendLimited(chunks, chunk, state) {
135
- const buffer = Buffer.from(chunk); state.totalBytes += buffer.length;
136
- const available = Math.max(0, maximumOutputBytes - state.bytes);
137
- if (available) chunks.push(buffer.subarray(0, available));
138
- state.bytes += Math.min(buffer.length, available); if (buffer.length > available) state.truncated = true;
139
- }
140
-
141
- async function runNpm(job) {
142
- const scripts = await packageScripts();
143
- if (typeof job.script !== 'string' || !Array.isArray(job.args) || !job.args.every(value => typeof value === 'string')) throw new Error('Invalid host npm job.');
144
- if (job.script === 'host') throw new Error('The host bridge cannot invoke itself.');
145
- if (!Object.hasOwn(scripts, job.script)) throw new Error(`npm script is not defined: ${job.script}`);
146
- const args = ['run', job.script, ...(job.args.length ? ['--', ...job.args] : [])];
147
- const executable = npmCli ? process.execPath : npmExecutable;
148
- const executableArgs = npmCli ? [npmCli, ...args] : args;
149
- const startedAt = new Date().toISOString();
150
- return new Promise(resolveJob => {
151
- const child = spawn(executable, executableArgs, { cwd: workspace, env: process.env, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
152
- activeChildren.add(child);
153
- const stdout = [], stderr = [], out = { bytes: 0, totalBytes: 0, truncated: false }, err = { bytes: 0, totalBytes: 0, truncated: false };
154
- child.stdout.on('data', chunk => appendLimited(stdout, chunk, out)); child.stderr.on('data', chunk => appendLimited(stderr, chunk, err));
155
- const finish = (exitCode, message = '') => resolveJob({ jobId: job.jobId, exitCode, stdout: Buffer.concat(stdout).toString('utf8'), stderr: message || Buffer.concat(stderr).toString('utf8'), pid: child.pid ?? null, hostPlatform, hostName, stdoutTruncated: out.truncated, stderrTruncated: err.truncated, stdoutTotalBytes: out.totalBytes, stderrTotalBytes: message ? Buffer.byteLength(message) : err.totalBytes, startedAt, completedAt: new Date().toISOString() });
156
- child.on('error', error => { activeChildren.delete(child); finish(1, error.message); });
157
- child.on('close', code => { activeChildren.delete(child); finish(Number.isInteger(code) ? code : 1); });
158
- });
159
- }
160
-
161
- async function execute(job) {
162
- if (job?.schemaVersion !== 1 || typeof job?.jobId !== 'string') throw new Error('Unsupported host job schema.');
163
- if (job.type === 'npm.scripts.request') return requestScriptChange(job);
164
- if (job.type === 'npm.scripts.confirm') return confirmScriptChange(job);
165
- if (job.type === 'npm.run') return runNpm(job);
166
- throw new Error('Unsupported host job type.');
167
- }
168
-
169
- async function stopChildren() {
170
- await Promise.all([...activeChildren].map(child => new Promise(resolveStop => {
171
- if (!child.pid) return resolveStop();
172
- const done = () => resolveStop(); child.once('close', done);
173
- if (process.platform === 'win32') { const killer = spawn('taskkill', ['/pid', String(child.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' }); killer.once('close', done); killer.once('error', done); }
174
- else { try { process.kill(-child.pid, 'SIGTERM'); } catch { return done(); } setTimeout(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} }, 2000).unref(); }
175
- })));
176
- }
177
-
178
- function sendBridge(child, message) {
179
- if (!child.stdin.writable) throw new Error('The Docker Compose bridge input closed unexpectedly.');
180
- child.stdin.write(`${JSON.stringify({ protocolVersion: 1, ...message })}\n`);
181
- }
182
-
183
- function bridgeSession() {
184
- const sessionId = randomUUID();
185
- const args = [...dockerPrefixArgs, 'compose', 'exec', '-T', 'control', '/usr/local/bin/nestbox-host-bridge'];
186
- const child = spawn(dockerExecutable, args, { cwd: installDirectory, env: process.env, shell: false, windowsHide: true, detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'] });
187
- activeBridge = child;
188
- let diagnostics = '', buffer = '', settled = false;
189
- const finish = (resolveSession, rejectSession, error, value) => {
190
- if (settled) return;
191
- settled = true;
192
- if (activeBridge === child) activeBridge = undefined;
193
- error ? rejectSession(error) : resolveSession(value);
194
- };
195
- return new Promise((resolveSession, rejectSession) => {
196
- child.stdin.on('error', () => {});
197
- child.stderr.on('data', chunk => {
198
- diagnostics = (diagnostics + chunk.toString('utf8')).slice(-65536);
199
- if (!jsonOutput) process.stderr.write(chunk);
200
- });
201
- child.stdout.on('data', chunk => {
202
- buffer += chunk.toString('utf8');
203
- if (Buffer.byteLength(buffer) > 64 * 1024 * 1024) {
204
- child.stdin.end();
205
- finish(resolveSession, rejectSession, new Error('The host bridge protocol line exceeded 64 MiB.'));
206
- return;
207
- }
208
- let newline;
209
- while ((newline = buffer.indexOf('\n')) >= 0) {
210
- const line = buffer.slice(0, newline); buffer = buffer.slice(newline + 1);
211
- if (!line) continue;
212
- let message;
213
- try { message = JSON.parse(line); }
214
- catch { finish(resolveSession, rejectSession, new Error('The host bridge returned invalid JSON.')); child.stdin.end(); return; }
215
- if (message?.protocolVersion !== 1 || typeof message.type !== 'string') {
216
- finish(resolveSession, rejectSession, new Error('The host bridge returned an unsupported protocol message.')); child.stdin.end(); return;
217
- }
218
- if (message.type === 'ready') {
219
- if (!announced) {
220
- announced = true;
221
- emit('runner.ready', { workspace, pid: process.pid, platform: hostPlatform, hostname: hostName });
222
- }
223
- } else if (message.type === 'idle' && once && !retainedResult && !activeJobPromise) {
224
- child.stdin.end();
225
- finish(resolveSession, rejectSession, null, { done: true });
226
- } else if (message.type === 'job') {
227
- const job = message.job;
228
- if (retainedResult) {
229
- if (job?.jobId !== retainedResult.jobId) { finish(resolveSession, rejectSession, new Error('The bridge assigned new work before acknowledging the previous result.')); child.stdin.end(); }
230
- else sendBridge(child, { type: 'result', result: retainedResult });
231
- continue;
232
- }
233
- if (activeJobPromise && activeJobId !== job?.jobId) {
234
- finish(resolveSession, rejectSession, new Error('The bridge assigned new work while another host job was still running.')); child.stdin.end(); continue;
235
- }
236
- if (!activeJobPromise) {
237
- activeJobId = job?.jobId;
238
- activeJobPromise = Promise.resolve().then(async () => {
239
- if (stopping) {
240
- const timestamp = new Date().toISOString();
241
- return { jobId: job?.jobId, exitCode: 1, stdout: '', stderr: 'The host runner stopped before executing the assigned job.', pid: null, hostPlatform, hostName, interrupted: true, startedAt: timestamp, completedAt: timestamp };
242
- }
243
- try { return await execute(job); }
244
- catch (error) { return { jobId: job?.jobId, exitCode: 1, stdout: '', stderr: error instanceof Error ? error.message : String(error), pid: null, hostPlatform, hostName, startedAt: new Date().toISOString(), completedAt: new Date().toISOString() }; }
245
- }).then(result => {
246
- retainedResult = result;
247
- activeJobId = undefined;
248
- activeJobPromise = undefined;
249
- return result;
250
- });
251
- }
252
- activeJobPromise.then(result => { if (!settled) sendBridge(child, { type: 'result', result }); }).catch(error => {
253
- child.stdin.end();
254
- finish(resolveSession, rejectSession, error);
255
- });
256
- } else if (message.type === 'result.ack') {
257
- if (!retainedResult || message.jobId !== retainedResult.jobId) { finish(resolveSession, rejectSession, new Error('The bridge acknowledged an unknown result.')); child.stdin.end(); return; }
258
- retainedResult = undefined;
259
- if (once) { child.stdin.end(); finish(resolveSession, rejectSession, null, { done: true }); }
260
- } else if (message.type === 'gone') {
261
- if (retainedResult?.jobId === message.jobId) retainedResult = undefined;
262
- emit('runner.error', { jobId: message.jobId, error: message.error || 'The assigned host job is no longer available.' });
263
- if (once) { child.stdin.end(); finish(resolveSession, rejectSession, null, { done: true }); }
264
- } else if (message.type === 'result.rejected') {
265
- if (retainedResult?.jobId === message.jobId) retainedResult = undefined;
266
- emit('runner.error', { jobId: message.jobId, error: message.error || 'Control rejected the retained host result.' });
267
- if (once) { child.stdin.end(); finish(resolveSession, rejectSession, null, { done: true }); }
268
- }
269
- }
270
- });
271
- child.once('error', error => finish(resolveSession, rejectSession, new Error(`Unable to start Docker Compose: ${error.message}`)));
272
- child.once('close', code => {
273
- if (settled) return;
274
- const stale = /nestbox-host-bridge|not found|no such file/i.test(diagnostics) || code === 126 || code === 127;
275
- const message = stale
276
- ? 'The control image is stale or missing the Nestbox host bridge. Run `docker compose build control` and `docker compose up -d control`, then retry.'
277
- : `Docker Compose host bridge exited with code ${code}${diagnostics.trim() ? `: ${diagnostics.trim()}` : ''}`;
278
- const error = new Error(message);
279
- if (stale) error.code = 'STALE_CONTROL_IMAGE';
280
- finish(resolveSession, rejectSession, stopping ? null : error, { done: stopping });
281
- });
282
- sendBridge(child, { type: 'hello', runnerId, sessionId, metadata: { pid: process.pid, platform: hostPlatform, hostname: hostName }, ...(retainedResult ? { retainedResult } : {}) });
283
- });
284
- }
285
-
286
- async function main() {
287
- let backoffMs = 500;
288
- while (!stopping) {
289
- try {
290
- const session = bridgeSession();
291
- const outcome = await session;
292
- backoffMs = 500;
293
- if (outcome?.done) break;
294
- } catch (error) {
295
- if (error.code === 'STALE_CONTROL_IMAGE') throw error;
296
- emit('runner.error', { error: `${error.message}; reconnecting` });
297
- await new Promise(resolveWait => setTimeout(resolveWait, backoffMs));
298
- backoffMs = Math.min(10000, backoffMs * 2);
299
- }
300
- }
301
- }
302
-
303
- for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, () => {
304
- if (stopping) return;
305
- stopping = true;
306
- activeBridge?.stdin.end();
307
- const bridge = activeBridge;
308
- if (bridge?.pid) setTimeout(() => {
309
- if (process.platform === 'win32') spawn('taskkill', ['/pid', String(bridge.pid), '/t', '/f'], { windowsHide: true, stdio: 'ignore' });
310
- else try { process.kill(-bridge.pid, 'SIGTERM'); } catch {}
311
- }, 2000).unref();
312
- stopChildren().catch(error => emit('runner.error', { error: error.message || String(error) }));
313
- });
314
- main().catch(error => { emit('runner.error', { error: error.message || String(error) }); stopChildren().finally(() => process.exit(1)); });
package/package.host.json DELETED
@@ -1,8 +0,0 @@
1
- {
2
- "name": "nestbox-workspace",
3
- "private": true,
4
- "scripts": {
5
- "host": "node .nestbox/host-runner.mjs",
6
- "test:host": "node .nestbox/tests/host-runner.mjs"
7
- }
8
- }