@quolu/lattice 0.79.2 → 0.79.4
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.
- package/LICENSE +1 -1
- package/README.ja.md +1 -1
- package/README.md +9 -3
- package/bin/lattice-bridge.mjs +31 -24
- package/bin/lattice-dashboard-supervisor.mjs +18 -0
- package/bin/lattice-dashboard.mjs +32 -10
- package/docs/bridge-setup.md +29 -0
- package/package.json +2 -2
- package/sensor/dist/bin/lattice-sensor.js +6 -26
- package/sensor/dist/bin/node-version-check.d.ts +10 -1
- package/sensor/dist/bin/node-version-check.d.ts.map +1 -1
- package/sensor/dist/bin/node-version-check.js +43 -1
- package/sensor/dist/bin/node-version-check.js.map +1 -1
- package/sensor/dist/extraction/parse-pool.d.ts +8 -1
- package/sensor/dist/extraction/parse-pool.d.ts.map +1 -1
- package/sensor/dist/extraction/parse-pool.js +83 -12
- package/sensor/dist/extraction/parse-pool.js.map +1 -1
- package/sensor/dist/extraction/parse-worker.js +2 -0
- package/sensor/dist/extraction/parse-worker.js.map +1 -1
- package/sensor/dist/mcp/daemon-paths.d.ts +4 -0
- package/sensor/dist/mcp/daemon-paths.d.ts.map +1 -1
- package/sensor/dist/mcp/daemon-paths.js +12 -0
- package/sensor/dist/mcp/daemon-paths.js.map +1 -1
- package/sensor/dist/mcp/index.d.ts.map +1 -1
- package/sensor/dist/mcp/index.js +52 -28
- package/sensor/dist/mcp/index.js.map +1 -1
- package/sensor/package.json +1 -1
- package/src/bridge-cli.mjs +6 -2
- package/src/bridge-config.mjs +8 -0
- package/src/bridge-daemon.mjs +30 -2
- package/src/dashboard-delivery.mjs +14 -2
- package/src/dashboard-lifecycle-ipc.mjs +5 -0
- package/src/dashboard-lifecycle-journal.mjs +235 -0
- package/src/dashboard-process-control.mjs +74 -0
- package/src/dashboard-supervisor.mjs +180 -0
- package/src/dashboard-windows-process-control.ps1 +40 -0
- package/src/factory-diagnostics.mjs +22 -9
- package/src/node-version-guard.mjs +3 -42
- package/src/todo-dashboard-registry.mjs +24 -3
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, readdir, readFile, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const SCHEMA = 'lattice.dashboard_lifecycle.v1';
|
|
7
|
+
export const LIFECYCLE_MAX_RUNS = 64;
|
|
8
|
+
export const LIFECYCLE_MAX_BYTES = 16 * 1024;
|
|
9
|
+
const PHASES = new Set(['spawn', 'import', 'registry', 'store', 'listen', 'ready',
|
|
10
|
+
'periodic_sync', 'empty_shutdown', 'shutdown_request', 'sync_failed', 'store_read_failed',
|
|
11
|
+
'cleanup_failed', 'close_failed', 'close', 'closed']);
|
|
12
|
+
const RUN_ID = /^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/u;
|
|
13
|
+
const WINDOWS_REPLACE_WAIT_MS = 500;
|
|
14
|
+
const REPLACE_MAX_ATTEMPTS = 32;
|
|
15
|
+
|
|
16
|
+
export function lifecycleError(code) {
|
|
17
|
+
return Object.assign(new Error(code), { code,
|
|
18
|
+
detail: { reason: code.toLowerCase(), next_action: 'lattice todo dashboard ensure --json' } });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function directory(root) { return path.join(root, 'lifecycle'); }
|
|
22
|
+
function terminationTime(run) {
|
|
23
|
+
return run.exit_observed_at ?? run.observer_loss_observed_at ?? run.started_at;
|
|
24
|
+
}
|
|
25
|
+
function reference(root, id) {
|
|
26
|
+
if (!RUN_ID.test(id)) throw lifecycleError('DASHBOARD_LIFECYCLE_INVALID');
|
|
27
|
+
return path.join(directory(root), `${id}.json`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function checkOwned(ref, type) {
|
|
31
|
+
const info = await lstat(ref);
|
|
32
|
+
if (info.isSymbolicLink() || (type === 'directory' ? !info.isDirectory() : !info.isFile())
|
|
33
|
+
|| (typeof process.getuid === 'function' && info.uid !== process.getuid())
|
|
34
|
+
|| (process.platform !== 'win32' && (info.mode & 0o077) !== 0)) {
|
|
35
|
+
throw lifecycleError('DASHBOARD_LIFECYCLE_UNSAFE_PATH');
|
|
36
|
+
}
|
|
37
|
+
return info;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function prepareLifecycleDirectory(root) {
|
|
41
|
+
// The runtime root is already private and owned by the dashboard registry.
|
|
42
|
+
await checkOwned(root, 'directory');
|
|
43
|
+
await mkdir(directory(root), { mode: 0o700 }).catch((error) => {
|
|
44
|
+
if (error.code !== 'EEXIST') throw error;
|
|
45
|
+
});
|
|
46
|
+
await checkOwned(directory(root), 'directory');
|
|
47
|
+
// Interrupted atomic writes contain no body data either, but must not grow forever.
|
|
48
|
+
const pending = /^(?:[a-f0-9-]{36}\.json|ensure\.json)\.([1-9][0-9]*)\.[a-f0-9-]{36}\.tmp$/u;
|
|
49
|
+
for (const name of await readdir(directory(root))) {
|
|
50
|
+
const match = pending.exec(name);
|
|
51
|
+
if (!match) continue;
|
|
52
|
+
let alive = true;
|
|
53
|
+
try { process.kill(Number(match[1]), 0); } catch (error) {
|
|
54
|
+
if (error.code !== 'ESRCH') throw error;
|
|
55
|
+
alive = false;
|
|
56
|
+
}
|
|
57
|
+
if (!alive) {
|
|
58
|
+
const ref = path.join(directory(root), name);
|
|
59
|
+
await checkOwned(ref, 'file');
|
|
60
|
+
await rm(ref);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function readLifecycleRuns(root) {
|
|
66
|
+
try { await checkOwned(directory(root), 'directory'); } catch (error) {
|
|
67
|
+
if (error.code === 'ENOENT') return [];
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
const runs = [];
|
|
71
|
+
for (const name of (await readdir(directory(root))).sort()) {
|
|
72
|
+
if (!name.endsWith('.json') || !RUN_ID.test(name.slice(0, -5))) continue;
|
|
73
|
+
const ref = path.join(directory(root), name);
|
|
74
|
+
const info = await checkOwned(ref, 'file');
|
|
75
|
+
if (info.size > LIFECYCLE_MAX_BYTES) throw lifecycleError('DASHBOARD_LIFECYCLE_INVALID');
|
|
76
|
+
const handle = await open(ref, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
77
|
+
let value;
|
|
78
|
+
try { value = JSON.parse(await handle.readFile('utf8')); }
|
|
79
|
+
catch { throw lifecycleError('DASHBOARD_LIFECYCLE_INVALID'); }
|
|
80
|
+
finally { await handle.close(); }
|
|
81
|
+
if (value?.schema !== SCHEMA || value.run_id !== name.slice(0, -5)
|
|
82
|
+
|| !['active', 'closed', 'supervisor_lost'].includes(value.state)) {
|
|
83
|
+
throw lifecycleError('DASHBOARD_LIFECYCLE_INVALID');
|
|
84
|
+
}
|
|
85
|
+
runs.push(value);
|
|
86
|
+
}
|
|
87
|
+
return runs.sort((a, b) => a.started_at.localeCompare(b.started_at));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Publish only after a real atomic replacement; Windows delete-sharing can be transient. */
|
|
91
|
+
async function publishLifecycleJson(ref, bytes) {
|
|
92
|
+
const parent = path.dirname(ref);
|
|
93
|
+
await checkOwned(parent, 'directory');
|
|
94
|
+
// At most 64 live writers can race this check, so even orphaned temporary files
|
|
95
|
+
// have a finite bound. Refuse another write rather than discard an active run.
|
|
96
|
+
if ((await readdir(parent)).length >= 256) throw lifecycleError('DASHBOARD_LIFECYCLE_CAPACITY');
|
|
97
|
+
if (Buffer.byteLength(bytes) > LIFECYCLE_MAX_BYTES) throw lifecycleError('DASHBOARD_LIFECYCLE_CAPACITY');
|
|
98
|
+
try { await checkOwned(ref, 'file'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
99
|
+
const temporary = `${ref}.${process.pid}.${randomUUID()}.tmp`;
|
|
100
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
101
|
+
try {
|
|
102
|
+
await handle.writeFile(bytes, 'utf8');
|
|
103
|
+
await handle.sync();
|
|
104
|
+
} finally { await handle.close(); }
|
|
105
|
+
// Failed private temporaries stay bounded for dead-writer cleanup. Never delete
|
|
106
|
+
// the old receipt to make replacement succeed, or let cleanup mask its error.
|
|
107
|
+
const deadline = performance.now() + WINDOWS_REPLACE_WAIT_MS;
|
|
108
|
+
let attempts = 0;
|
|
109
|
+
for (;;) {
|
|
110
|
+
await checkOwned(parent, 'directory');
|
|
111
|
+
await checkOwned(temporary, 'file');
|
|
112
|
+
try { await checkOwned(ref, 'file'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
113
|
+
attempts++;
|
|
114
|
+
try {
|
|
115
|
+
await rename(temporary, ref);
|
|
116
|
+
return;
|
|
117
|
+
} catch (error) {
|
|
118
|
+
const busy = process.platform === 'win32' && ['EPERM', 'EACCES', 'EBUSY'].includes(error.code);
|
|
119
|
+
if (!busy) throw error;
|
|
120
|
+
if (performance.now() >= deadline || attempts >= REPLACE_MAX_ATTEMPTS) {
|
|
121
|
+
const failure = lifecycleError('DASHBOARD_LIFECYCLE_PUBLISH_FAILED');
|
|
122
|
+
failure.cause = error;
|
|
123
|
+
failure.detail = { ...failure.detail, operation: 'atomic_replace',
|
|
124
|
+
fs_code: error.code, attempts, temporary_retained: true };
|
|
125
|
+
throw failure;
|
|
126
|
+
}
|
|
127
|
+
await new Promise((resolve) => setTimeout(resolve,
|
|
128
|
+
Math.min(10 * 2 ** Math.min(attempts - 1, 3), 50, Math.max(1, deadline - performance.now()))));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function writeLifecycleRun(root, run) {
|
|
134
|
+
await publishLifecycleJson(reference(root, run.run_id), `${JSON.stringify(run)}\n`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Call under the existing dashboard startup lock, before spawning anything. */
|
|
138
|
+
export async function createLifecycleRun(root, version) {
|
|
139
|
+
await prepareLifecycleDirectory(root);
|
|
140
|
+
const runs = await readLifecycleRuns(root);
|
|
141
|
+
while (runs.length >= LIFECYCLE_MAX_RUNS) {
|
|
142
|
+
const oldest = runs.filter((run) => run.state !== 'active'
|
|
143
|
+
&& !run.child_at_reconciliation?.startsWith('present'))
|
|
144
|
+
.sort((a, b) => terminationTime(a).localeCompare(terminationTime(b)))[0];
|
|
145
|
+
if (!oldest) throw lifecycleError('DASHBOARD_LIFECYCLE_CAPACITY');
|
|
146
|
+
const index = runs.findIndex((run) => run.run_id === oldest.run_id);
|
|
147
|
+
const [old] = runs.splice(index, 1);
|
|
148
|
+
await rm(reference(root, old.run_id));
|
|
149
|
+
}
|
|
150
|
+
const run = { schema: SCHEMA, run_id: randomUUID(), version, platform: process.platform,
|
|
151
|
+
state: 'active', started_at: new Date().toISOString(), supervisor: null, child: null,
|
|
152
|
+
phase: 'spawn', ready_at: null, exit_observed_at: null,
|
|
153
|
+
observed_exit: { status: 'unknown' }, termination_request: null,
|
|
154
|
+
stderr: { bytes: 0, hashed_bytes: 0, sha256: null, truncated: false }, events: [] };
|
|
155
|
+
await writeLifecycleRun(root, run);
|
|
156
|
+
return run;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function recordLifecyclePhase(run, phase) {
|
|
160
|
+
if (!PHASES.has(phase) || phase === run.phase) return false;
|
|
161
|
+
run.phase = phase;
|
|
162
|
+
if (['empty_shutdown', 'shutdown_request', 'sync_failed', 'cleanup_failed', 'close_failed'].includes(phase)) {
|
|
163
|
+
run.exit_stage = phase;
|
|
164
|
+
}
|
|
165
|
+
const at = new Date().toISOString();
|
|
166
|
+
if (phase === 'ready' && run.ready_at === null) run.ready_at = at;
|
|
167
|
+
if (['store_read_failed', 'sync_failed', 'cleanup_failed', 'close_failed'].includes(phase)) {
|
|
168
|
+
run.last_failure_observed = { at, phase };
|
|
169
|
+
}
|
|
170
|
+
run.events.push({ at, phase });
|
|
171
|
+
if (run.events.length > 64) run.events.shift();
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** No fabricated exit time/value when the observer itself disappeared. */
|
|
176
|
+
export async function reconcileLifecycleRuns(root, observeIdentity) {
|
|
177
|
+
const runs = await readLifecycleRuns(root);
|
|
178
|
+
for (const run of runs) {
|
|
179
|
+
if (run.state === 'closed') continue;
|
|
180
|
+
const supervisor = run.supervisor === null ? null : await observeIdentity(run.supervisor.pid);
|
|
181
|
+
if (run.state === 'active' && supervisor !== null && supervisor.birth !== null
|
|
182
|
+
&& supervisor.birth === run.supervisor.birth) continue;
|
|
183
|
+
// Unknown birth cannot establish observer loss. Do not overwrite its receipt.
|
|
184
|
+
if (run.state === 'active' && supervisor !== null
|
|
185
|
+
&& (supervisor.birth === null || run.supervisor.birth === null)) continue;
|
|
186
|
+
const previousState = run.state;
|
|
187
|
+
const previousPresence = run.child_at_reconciliation;
|
|
188
|
+
run.state = 'supervisor_lost';
|
|
189
|
+
run.observer_loss_observed_at ??= new Date().toISOString();
|
|
190
|
+
const child = run.child === null ? null : await observeIdentity(run.child.pid);
|
|
191
|
+
run.child_at_reconciliation = run.child === null ? 'not_observed' : child === null ? 'absent'
|
|
192
|
+
: child.birth !== null && child.birth === run.child.birth ? 'present_identity_matched'
|
|
193
|
+
: child.birth !== null && run.child.birth !== null ? 'absent_identity_reused'
|
|
194
|
+
: 'present_identity_unconfirmed';
|
|
195
|
+
run.observed_exit = { status: 'unknown', reason: 'supervisor_lost' };
|
|
196
|
+
if (previousState !== run.state || previousPresence !== run.child_at_reconciliation) {
|
|
197
|
+
run.child_presence_observed_at = new Date().toISOString();
|
|
198
|
+
await writeLifecycleRun(root, run);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return runs;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** One bounded decision receipt, serialized by the registry's existing startup lock. */
|
|
205
|
+
export async function recordDashboardEnsureDecision(root, decision, childPid) {
|
|
206
|
+
if (!['reuse_current', 'adopt_current', 'replace_legacy', 'start_missing',
|
|
207
|
+
'pid_reused', 'alive_unresponsive'].includes(decision)) throw new TypeError('invalid dashboard decision');
|
|
208
|
+
await prepareLifecycleDirectory(root);
|
|
209
|
+
const matching = (await readLifecycleRuns(root)).filter((run) => run.child?.pid === childPid
|
|
210
|
+
&& (run.state === 'active' || run.child_at_reconciliation?.startsWith('present'))).at(-1);
|
|
211
|
+
const supervision = childPid === null ? 'not_started' : matching?.state === 'active' ? 'supervised'
|
|
212
|
+
: matching?.state === 'supervisor_lost' ? 'supervisor_lost' : 'legacy_unsupervised';
|
|
213
|
+
const ref = path.join(directory(root), 'ensure.json');
|
|
214
|
+
await publishLifecycleJson(ref, `${JSON.stringify({ schema: 'lattice.dashboard_ensure.v1',
|
|
215
|
+
observed_at: new Date().toISOString(), decision, child_pid: childPid,
|
|
216
|
+
supervision, run_id: matching?.run_id ?? null })}\n`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function readDashboardLifecycleStatus(root) {
|
|
220
|
+
const runs = await readLifecycleRuns(root);
|
|
221
|
+
const terminal = runs.filter((run) => run.state !== 'active').sort((a, b) =>
|
|
222
|
+
terminationTime(a).localeCompare(terminationTime(b)));
|
|
223
|
+
let ensure = null;
|
|
224
|
+
try {
|
|
225
|
+
const ref = path.join(directory(root), 'ensure.json');
|
|
226
|
+
const info = await checkOwned(ref, 'file');
|
|
227
|
+
if (info.size > LIFECYCLE_MAX_BYTES) throw lifecycleError('DASHBOARD_LIFECYCLE_INVALID');
|
|
228
|
+
try { ensure = JSON.parse(await readFile(ref, 'utf8')); }
|
|
229
|
+
catch { throw lifecycleError('DASHBOARD_LIFECYCLE_INVALID'); }
|
|
230
|
+
if (ensure.schema !== 'lattice.dashboard_ensure.v1') throw lifecycleError('DASHBOARD_LIFECYCLE_INVALID');
|
|
231
|
+
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
232
|
+
return { schema: 'lattice.dashboard_lifecycle_status.v1',
|
|
233
|
+
last_ensure: ensure, active: runs.filter((run) => run.state === 'active'),
|
|
234
|
+
last_termination: terminal.at(-1) ?? null };
|
|
235
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const execute = promisify(execFile);
|
|
7
|
+
|
|
8
|
+
/** A missing process and an unavailable birth observation are different. */
|
|
9
|
+
export async function observeDashboardProcess(pid) {
|
|
10
|
+
if (!Number.isSafeInteger(pid) || pid < 1) throw new TypeError('invalid dashboard pid');
|
|
11
|
+
try { process.kill(pid, 0); } catch (error) {
|
|
12
|
+
if (error.code === 'ESRCH') return null;
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
if (process.platform === 'linux') {
|
|
17
|
+
const [stat, boot] = await Promise.all([readFile(`/proc/${pid}/stat`, 'utf8'),
|
|
18
|
+
readFile('/proc/sys/kernel/random/boot_id', 'utf8')]);
|
|
19
|
+
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
|
20
|
+
if (fields[0] === 'Z') return null;
|
|
21
|
+
return { pid, birth: `linux:${boot.trim()}:${fields[19]}` };
|
|
22
|
+
}
|
|
23
|
+
const { stdout } = process.platform === 'win32'
|
|
24
|
+
? await execute('pwsh.exe', ['-NoProfile', '-NonInteractive', '-Command',
|
|
25
|
+
`[System.Diagnostics.Process]::GetProcessById(${pid}).StartTime.ToUniversalTime().ToFileTimeUtc()`],
|
|
26
|
+
{ encoding: 'utf8', timeout: 5_000, windowsHide: true })
|
|
27
|
+
: await execute('/bin/ps', ['-o', 'lstart=', '-p', String(pid)],
|
|
28
|
+
{ encoding: 'utf8', timeout: 5_000, env: { ...process.env, LC_ALL: 'C' } });
|
|
29
|
+
return { pid, birth: stdout.trim() || null };
|
|
30
|
+
} catch (error) {
|
|
31
|
+
// Recheck death after a racing exit. An observation failure never licenses a kill.
|
|
32
|
+
try { process.kill(pid, 0); } catch (probeError) {
|
|
33
|
+
if (probeError.code === 'ESRCH') return null;
|
|
34
|
+
throw probeError;
|
|
35
|
+
}
|
|
36
|
+
return { pid, birth: null, observation: 'unavailable' };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function observedDashboardExit(code, signal, platform = process.platform) {
|
|
41
|
+
if (platform === 'win32') {
|
|
42
|
+
if (!Number.isInteger(code)) return { status: 'unknown', signal: 'not_applicable' };
|
|
43
|
+
const uint32 = code >>> 0;
|
|
44
|
+
return { status: 'observed', uint32, hex: `0x${uint32.toString(16).padStart(8, '0').toUpperCase()}`,
|
|
45
|
+
signal: 'not_applicable', source: 'node_child_wait', origin: 'unknown' };
|
|
46
|
+
}
|
|
47
|
+
if (Number.isInteger(code)) return { status: 'observed', code, signal: null, source: 'node_child_wait' };
|
|
48
|
+
if (typeof signal === 'string') return { status: 'observed', code: null, signal, source: 'node_child_wait' };
|
|
49
|
+
return { status: 'unknown' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Use the same authenticated handle for birth check, termination and exit read. */
|
|
53
|
+
export async function terminateOwnedWindowsDashboard(identity, exitCode = 1) {
|
|
54
|
+
if (!identity || !Number.isSafeInteger(identity.pid) || !/^[0-9]+$/u.test(identity.birth ?? '')) {
|
|
55
|
+
throw Object.assign(new Error('dashboard process birth is unavailable'),
|
|
56
|
+
{ code: 'DASHBOARD_CHILD_IDENTITY_UNAVAILABLE' });
|
|
57
|
+
}
|
|
58
|
+
const script = fileURLToPath(new URL('./dashboard-windows-process-control.ps1', import.meta.url));
|
|
59
|
+
const { stdout } = await execute('pwsh.exe', ['-NoProfile', '-NonInteractive', '-File', script,
|
|
60
|
+
'-ChildPid', String(identity.pid), '-BirthFileTime', identity.birth, '-RequestedExitCode', String(exitCode)],
|
|
61
|
+
{ encoding: 'utf8', timeout: 12_000, windowsHide: true, maxBuffer: 16_384 });
|
|
62
|
+
const result = JSON.parse(stdout.trim());
|
|
63
|
+
if (result.schema !== 'lattice.dashboard_process_control.v1'
|
|
64
|
+
|| result.pid !== identity.pid || result.birth_filetime !== identity.birth
|
|
65
|
+
|| typeof result.termination_requested !== 'boolean' || result.requested_exit_code !== exitCode
|
|
66
|
+
|| !Number.isInteger(result.actual_exit_code) || result.actual_exit_code < 0
|
|
67
|
+
|| result.actual_exit_code > 0xFFFFFFFF
|
|
68
|
+
|| result.actual_exit_hex !== `0x${result.actual_exit_code.toString(16).padStart(8, '0').toUpperCase()}`
|
|
69
|
+
|| result.source !== 'GetExitCodeProcess') {
|
|
70
|
+
throw Object.assign(new Error('dashboard process control result invalid'),
|
|
71
|
+
{ code: 'DASHBOARD_CHILD_CONTROL_INVALID' });
|
|
72
|
+
}
|
|
73
|
+
return result;
|
|
74
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// This module deliberately has no dashboard, bridge or sensor dependency.
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { createLifecycleRun, lifecycleError, readLifecycleRuns, recordLifecyclePhase,
|
|
6
|
+
writeLifecycleRun } from './dashboard-lifecycle-journal.mjs';
|
|
7
|
+
import { observeDashboardProcess, observedDashboardExit,
|
|
8
|
+
terminateOwnedWindowsDashboard } from './dashboard-process-control.mjs';
|
|
9
|
+
|
|
10
|
+
function notify(message) {
|
|
11
|
+
if (process.connected) process.send(message, () => {});
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The sole owner of one child: spawn, wait, forward stop, persist receipts. */
|
|
15
|
+
export async function superviseDashboard({ executable = process.execPath, args, env,
|
|
16
|
+
root, runId, graceMs = 3_000 }) {
|
|
17
|
+
const run = (await readLifecycleRuns(root)).find((value) => value.run_id === runId);
|
|
18
|
+
if (!run || run.state !== 'active') throw lifecycleError('DASHBOARD_LIFECYCLE_INVALID');
|
|
19
|
+
run.supervisor = await observeDashboardProcess(process.pid);
|
|
20
|
+
await writeLifecycleRun(root, run);
|
|
21
|
+
// libuv assigns non-detached Windows children to its parent's kill-on-close Job.
|
|
22
|
+
// The observer-loss contract keeps this child serving after supervisor death.
|
|
23
|
+
const child = spawn(executable, args, { env, windowsHide: true, detached: process.platform === 'win32',
|
|
24
|
+
stdio: ['ignore', 'ignore', 'pipe', 'ipc'] });
|
|
25
|
+
child.once('spawn', () => { run.child_spawn_observed_at = new Date().toISOString(); });
|
|
26
|
+
recordLifecyclePhase(run, 'import');
|
|
27
|
+
const digest = createHash('sha256');
|
|
28
|
+
let writeQueue = Promise.resolve();
|
|
29
|
+
let persistenceFailure = null;
|
|
30
|
+
let stopTimer = null;
|
|
31
|
+
let controlPending = Promise.resolve();
|
|
32
|
+
let finished = false;
|
|
33
|
+
const persist = () => {
|
|
34
|
+
writeQueue = writeQueue.then(() => writeLifecycleRun(root, run));
|
|
35
|
+
writeQueue.catch((error) => {
|
|
36
|
+
if (persistenceFailure) return;
|
|
37
|
+
persistenceFailure = error;
|
|
38
|
+
notify({ type: 'supervisor_error', code: 'DASHBOARD_LIFECYCLE_WRITE_FAILED' });
|
|
39
|
+
requestStop('receipt_failure');
|
|
40
|
+
});
|
|
41
|
+
return writeQueue;
|
|
42
|
+
};
|
|
43
|
+
const requestStop = (reason, force = false) => {
|
|
44
|
+
if (finished || (run.termination_request !== null && !force)) return;
|
|
45
|
+
if (run.termination_request === null) {
|
|
46
|
+
run.termination_request = { at: new Date().toISOString(), reason, mechanism: 'ipc_shutdown',
|
|
47
|
+
child: run.child, os_request: null };
|
|
48
|
+
recordLifecyclePhase(run, 'shutdown_request');
|
|
49
|
+
if (!persistenceFailure) persist();
|
|
50
|
+
if (child.connected) child.send({ type: 'dashboard_shutdown' }, () => {});
|
|
51
|
+
}
|
|
52
|
+
clearTimeout(stopTimer);
|
|
53
|
+
stopTimer = setTimeout(() => {
|
|
54
|
+
if (finished) return;
|
|
55
|
+
controlPending = (async () => {
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
const result = await terminateOwnedWindowsDashboard(run.child);
|
|
58
|
+
run.termination_request.os_request = { mechanism: 'TerminateProcess',
|
|
59
|
+
requested_exit_code: result.requested_exit_code,
|
|
60
|
+
api_succeeded: result.termination_requested,
|
|
61
|
+
actual_exit_code: result.actual_exit_code, source: result.source };
|
|
62
|
+
} else {
|
|
63
|
+
run.termination_request.os_request = { mechanism: 'child_signal', signal: 'SIGKILL',
|
|
64
|
+
api_succeeded: child.kill('SIGKILL') };
|
|
65
|
+
}
|
|
66
|
+
if (!persistenceFailure) await persist();
|
|
67
|
+
})();
|
|
68
|
+
controlPending.catch((error) => {
|
|
69
|
+
notify({ type: 'supervisor_error', code: error.code ?? 'DASHBOARD_CHILD_CONTROL_FAILED' });
|
|
70
|
+
// Cannot authenticate/control the child: leave it available, with unknown exit.
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
});
|
|
73
|
+
}, force ? 0 : graceMs);
|
|
74
|
+
};
|
|
75
|
+
const term = () => requestStop('supervisor_SIGTERM');
|
|
76
|
+
const interrupt = () => requestStop('supervisor_SIGINT');
|
|
77
|
+
const parentMessage = (message) => {
|
|
78
|
+
if (message?.type === 'dashboard_supervisor_stop') requestStop('owner_stop', message.force === true);
|
|
79
|
+
};
|
|
80
|
+
process.on('SIGTERM', term);
|
|
81
|
+
process.on('SIGINT', interrupt);
|
|
82
|
+
process.on('message', parentMessage);
|
|
83
|
+
// A parent IPC disconnect is expected when ensure exits. The child remains serving.
|
|
84
|
+
process.channel?.unref();
|
|
85
|
+
child.stderr.on('data', (bytes) => {
|
|
86
|
+
run.stderr.bytes = Math.min(Number.MAX_SAFE_INTEGER, run.stderr.bytes + bytes.length);
|
|
87
|
+
const remaining = 65_536 - run.stderr.hashed_bytes;
|
|
88
|
+
if (remaining > 0) {
|
|
89
|
+
const part = bytes.subarray(0, remaining);
|
|
90
|
+
digest.update(part); run.stderr.hashed_bytes += part.length;
|
|
91
|
+
}
|
|
92
|
+
run.stderr.truncated = run.stderr.bytes > run.stderr.hashed_bytes;
|
|
93
|
+
});
|
|
94
|
+
child.on('message', (message) => {
|
|
95
|
+
if (message?.type === 'dashboard_phase' && recordLifecyclePhase(run, message.phase)) persist();
|
|
96
|
+
});
|
|
97
|
+
let spawnFailure = false;
|
|
98
|
+
child.once('error', () => { spawnFailure = true; });
|
|
99
|
+
const ended = new Promise((resolve) => child.once('close', (code, signal) =>
|
|
100
|
+
resolve({ code, signal, observedAt: new Date().toISOString() })));
|
|
101
|
+
if (Number.isSafeInteger(child.pid)) {
|
|
102
|
+
run.child = await observeDashboardProcess(child.pid) ?? { pid: child.pid, birth: null };
|
|
103
|
+
await persist();
|
|
104
|
+
notify({ type: 'dashboard_child_started', pid: child.pid, run_id: runId });
|
|
105
|
+
}
|
|
106
|
+
const exit = await ended;
|
|
107
|
+
finished = true;
|
|
108
|
+
clearTimeout(stopTimer);
|
|
109
|
+
try { await controlPending; } catch { /* Typed control failure was delivered above; exit is independently observed. */ }
|
|
110
|
+
run.stderr.sha256 = digest.digest('hex');
|
|
111
|
+
run.exit_observed_at = exit.observedAt;
|
|
112
|
+
run.state = 'closed';
|
|
113
|
+
run.observed_exit = spawnFailure ? { status: 'unknown', reason: 'spawn_failed' }
|
|
114
|
+
: observedDashboardExit(exit.code, exit.signal);
|
|
115
|
+
if (process.platform === 'win32' && run.termination_request?.os_request?.api_succeeded) {
|
|
116
|
+
run.observed_exit.origin = 'owned_terminate_request';
|
|
117
|
+
}
|
|
118
|
+
// phase is the last child/control stage, not an invented successful close.
|
|
119
|
+
try {
|
|
120
|
+
if (persistenceFailure) throw persistenceFailure;
|
|
121
|
+
await persist();
|
|
122
|
+
notify({ type: 'dashboard_child_exited', pid: child.pid, code: exit.code, signal: exit.signal });
|
|
123
|
+
} finally {
|
|
124
|
+
process.off('SIGTERM', term); process.off('SIGINT', interrupt);
|
|
125
|
+
process.off('message', parentMessage);
|
|
126
|
+
if (process.connected) process.disconnect();
|
|
127
|
+
}
|
|
128
|
+
process.exitCode = run.observed_exit.status !== 'observed' ? 1
|
|
129
|
+
: process.platform === 'win32' ? run.observed_exit.uint32
|
|
130
|
+
: run.observed_exit.code ?? 1;
|
|
131
|
+
return run;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** ChildProcess-compatible seam used by the existing registry readiness/rollback. */
|
|
135
|
+
export async function spawnSupervisedDashboard(executable, args, options) {
|
|
136
|
+
const root = options.env.LATTICE_DASHBOARD_RUNTIME_DIR;
|
|
137
|
+
const run = await createLifecycleRun(root, options.version);
|
|
138
|
+
const supervisor = spawn(executable,
|
|
139
|
+
[path.resolve(import.meta.dirname, '../bin/lattice-dashboard-supervisor.mjs'), root], {
|
|
140
|
+
detached: true, windowsHide: true, stdio: ['ignore', 'ignore', 'ignore', 'ipc'],
|
|
141
|
+
env: { ...options.env, LATTICE_DASHBOARD_LIFECYCLE_RUN: run.run_id },
|
|
142
|
+
});
|
|
143
|
+
// The daemon command is fixed by the product entry, never accepted from env/IPC.
|
|
144
|
+
const facade = { pid: null, exitCode: null, signalCode: null, lifecycleRunId: run.run_id,
|
|
145
|
+
unref() { supervisor.unref(); supervisor.channel?.unref(); },
|
|
146
|
+
kill(signal = 'SIGTERM') {
|
|
147
|
+
if (supervisor.connected) supervisor.send({ type: 'dashboard_supervisor_stop',
|
|
148
|
+
force: signal === 'SIGKILL' }, () => {});
|
|
149
|
+
return supervisor.connected;
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
supervisor.once('exit', (code, signal) => {
|
|
153
|
+
facade.supervisorExit = { code, signal };
|
|
154
|
+
if (facade.exitCode === null && facade.signalCode === null) {
|
|
155
|
+
facade.observationError = lifecycleError('DASHBOARD_SUPERVISOR_LOST');
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
await new Promise((resolve, reject) => {
|
|
159
|
+
const timer = setTimeout(() => {
|
|
160
|
+
facade.kill(); reject(lifecycleError('DASHBOARD_SUPERVISOR_START_TIMEOUT'));
|
|
161
|
+
}, 15_000);
|
|
162
|
+
const fail = (error) => { clearTimeout(timer); reject(error); };
|
|
163
|
+
supervisor.once('error', fail);
|
|
164
|
+
supervisor.once('exit', () => fail(lifecycleError('DASHBOARD_SUPERVISOR_UNAVAILABLE')));
|
|
165
|
+
supervisor.on('message', (message) => {
|
|
166
|
+
if (message?.type === 'dashboard_child_exited' && message.pid === facade.pid) {
|
|
167
|
+
facade.exitCode = message.code; facade.signalCode = message.signal;
|
|
168
|
+
}
|
|
169
|
+
if (message?.type === 'supervisor_error') {
|
|
170
|
+
facade.observationError = lifecycleError(message.code);
|
|
171
|
+
fail(facade.observationError);
|
|
172
|
+
}
|
|
173
|
+
if (message?.type === 'dashboard_child_started' && message.run_id === run.run_id
|
|
174
|
+
&& Number.isSafeInteger(message.pid) && message.pid > 0) {
|
|
175
|
+
clearTimeout(timer); facade.pid = message.pid; resolve();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
return facade;
|
|
180
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
param(
|
|
2
|
+
[Parameter(Mandatory=$true)][int]$ChildPid,
|
|
3
|
+
[Parameter(Mandatory=$true)][string]$BirthFileTime,
|
|
4
|
+
[uint32]$RequestedExitCode = 1
|
|
5
|
+
)
|
|
6
|
+
$ErrorActionPreference = 'Stop'
|
|
7
|
+
Add-Type -TypeDefinition @'
|
|
8
|
+
using System;
|
|
9
|
+
using System.Runtime.InteropServices;
|
|
10
|
+
public static class DashboardOwnedProcess {
|
|
11
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern IntPtr OpenProcess(uint access, bool inherit, int pid);
|
|
12
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetProcessTimes(IntPtr h, out long birth, out long exit, out long kernel, out long user);
|
|
13
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool TerminateProcess(IntPtr h, uint exit);
|
|
14
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern uint WaitForSingleObject(IntPtr h, uint ms);
|
|
15
|
+
[DllImport("kernel32.dll", SetLastError=true)] public static extern bool GetExitCodeProcess(IntPtr h, out uint exit);
|
|
16
|
+
[DllImport("kernel32.dll")] public static extern bool CloseHandle(IntPtr h);
|
|
17
|
+
}
|
|
18
|
+
'@
|
|
19
|
+
# PROCESS_QUERY_LIMITED_INFORMATION | SYNCHRONIZE | PROCESS_TERMINATE. No debug privilege.
|
|
20
|
+
$handle = [DashboardOwnedProcess]::OpenProcess(0x101001, $false, $ChildPid)
|
|
21
|
+
if ($handle -eq [IntPtr]::Zero) { throw 'DASHBOARD_CHILD_OPEN_FAILED' }
|
|
22
|
+
try {
|
|
23
|
+
[long]$birth=0; [long]$ended=0; [long]$kernel=0; [long]$user=0
|
|
24
|
+
if (-not [DashboardOwnedProcess]::GetProcessTimes($handle,[ref]$birth,[ref]$ended,[ref]$kernel,[ref]$user)) { throw 'DASHBOARD_CHILD_BIRTH_FAILED' }
|
|
25
|
+
if ($birth.ToString() -cne $BirthFileTime) { throw 'DASHBOARD_CHILD_IDENTITY_MISMATCH' }
|
|
26
|
+
$alreadyExited = [DashboardOwnedProcess]::WaitForSingleObject($handle,0) -eq 0
|
|
27
|
+
$requested = $false
|
|
28
|
+
if (-not $alreadyExited) {
|
|
29
|
+
if (-not [DashboardOwnedProcess]::TerminateProcess($handle,$RequestedExitCode)) {
|
|
30
|
+
# A racing natural exit may win. Verify it with this handle, never invent the requested value.
|
|
31
|
+
if ([DashboardOwnedProcess]::WaitForSingleObject($handle,0) -ne 0) { throw 'DASHBOARD_CHILD_TERMINATE_FAILED' }
|
|
32
|
+
} else { $requested = $true }
|
|
33
|
+
}
|
|
34
|
+
if ([DashboardOwnedProcess]::WaitForSingleObject($handle,5000) -ne 0) { throw 'DASHBOARD_CHILD_WAIT_FAILED' }
|
|
35
|
+
[uint32]$actual=0
|
|
36
|
+
if (-not [DashboardOwnedProcess]::GetExitCodeProcess($handle,[ref]$actual)) { throw 'DASHBOARD_CHILD_EXIT_READ_FAILED' }
|
|
37
|
+
[ordered]@{schema='lattice.dashboard_process_control.v1';pid=$ChildPid;birth_filetime=$BirthFileTime;
|
|
38
|
+
termination_requested=$requested;requested_exit_code=$RequestedExitCode;actual_exit_code=$actual;
|
|
39
|
+
actual_exit_hex=('0x{0:X8}' -f $actual);source='GetExitCodeProcess'} | ConvertTo-Json -Compress
|
|
40
|
+
} finally { [void][DashboardOwnedProcess]::CloseHandle($handle) }
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readFile, lstat } from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
4
|
+
import { evaluateNodeVersionGuard } from './node-version-guard.mjs';
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* dotagents工場の native factory diagnostics 面(親plan L6要件)。
|
|
@@ -17,7 +18,7 @@ const MAX_DETAIL_LENGTH = 256;
|
|
|
17
18
|
const SEMVER_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
18
19
|
const UNKNOWN_VERSION_SENTINEL = '0.0.0-unknown';
|
|
19
20
|
const NODE_VERSION_PATTERN = /^v(\d+)\.(\d+)\.\d+(?:[-+][0-9A-Za-z.-]+)?$/;
|
|
20
|
-
const
|
|
21
|
+
const ENGINE_COMPARATOR = /^(>=|<)(\d+)(?:\.(\d+))?(?:\.(\d+))?$/u;
|
|
21
22
|
const CHECK_IDS = Object.freeze([
|
|
22
23
|
'package_version',
|
|
23
24
|
'node_runtime',
|
|
@@ -70,18 +71,30 @@ function checkNodeRuntime(nodeVersion, enginesNode) {
|
|
|
70
71
|
if (!runtime) {
|
|
71
72
|
return check('node_runtime', false, 'node version is unreadable');
|
|
72
73
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
74
|
+
// Lattice declares disjunctions of numeric >= floors and optional < ceilings.
|
|
75
|
+
// Parse the entire declared contract; a floor-only check silently accepts holes.
|
|
76
|
+
const alternatives = typeof enginesNode === 'string' ? enginesNode.split(' || ') : [];
|
|
77
|
+
const ranges = alternatives.map((range) => range.split(' ').map((part) => ENGINE_COMPARATOR.exec(part)));
|
|
78
|
+
if (ranges.length === 0 || ranges.some((range) => range.length < 1 || range.length > 2
|
|
79
|
+
|| range.some((part) => part === null) || range[0][1] !== '>='
|
|
80
|
+
|| (range.length === 2 && range[1][1] !== '<'))) {
|
|
81
|
+
return check('node_runtime', false, 'package.json engines.node range is unreadable');
|
|
76
82
|
}
|
|
77
|
-
const
|
|
78
|
-
const
|
|
79
|
-
|
|
83
|
+
const actual = nodeVersion.slice(1).split(/[.+-]/u).slice(0, 3).map(Number);
|
|
84
|
+
const compare = (bound) => {
|
|
85
|
+
const version = bound.slice(2, 5).map((part) => Number(part ?? 0));
|
|
86
|
+
for (let index = 0; index < 3; index += 1) {
|
|
87
|
+
if (actual[index] !== version[index]) return actual[index] < version[index] ? -1 : 1;
|
|
88
|
+
}
|
|
89
|
+
return 0;
|
|
90
|
+
};
|
|
91
|
+
const satisfied = ranges.some((range) => range.every((bound) =>
|
|
92
|
+
bound[1] === '>=' ? compare(bound) >= 0 : compare(bound) < 0))
|
|
93
|
+
&& !evaluateNodeVersionGuard(nodeVersion.slice(1), false).blocked;
|
|
80
94
|
return check(
|
|
81
95
|
'node_runtime',
|
|
82
96
|
satisfied,
|
|
83
|
-
|
|
84
|
-
: `${nodeVersion} is below engines.node floor ${floor[1]}`,
|
|
97
|
+
`${nodeVersion} ${satisfied ? 'satisfies' : 'does not satisfy'} engines.node ${enginesNode}`,
|
|
85
98
|
);
|
|
86
99
|
}
|
|
87
100
|
|
|
@@ -1,44 +1,5 @@
|
|
|
1
|
-
/**
|
|
2
|
-
|
|
3
|
-
* 発見の修理 — bin/lattice-mcp.mjs が sensor/dist/index.js を直import して
|
|
4
|
-
* MCPServer を起動する経路には、sensor CLI (sensor/src/bin/sensor.ts) が
|
|
5
|
-
* 持つNode versionガードが一切通っていなかった)。
|
|
6
|
-
*
|
|
7
|
-
* 閾値・banner文言は複製せず、sensor CLI と同じ
|
|
8
|
-
* `sensor/dist/bin/node-version-check.js`(Node 25.x のV8 turboshaft WASM JIT
|
|
9
|
-
* Zone allocatorバグと、MIN_NODE_MAJOR未満のfloorを扱う)から再利用する。
|
|
10
|
-
*
|
|
11
|
-
* 判定ロジックだけをここに切り出し、process.versions.node / env を直接
|
|
12
|
-
* 読まない純関数にすることで、境界値(MIN_NODE_MAJOR-1 / MIN_NODE_MAJOR /
|
|
13
|
-
* 24 / 25 / 26、override有無)をunit testで固定できるようにする。
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import {
|
|
1
|
+
/** Shared sensor/MCP runtime compatibility policy; no bootstrap side effects. */
|
|
2
|
+
export {
|
|
17
3
|
MIN_NODE_MAJOR,
|
|
18
|
-
|
|
19
|
-
buildNodeTooOldBanner,
|
|
20
|
-
isNode25Affected,
|
|
4
|
+
evaluateNodeVersionGuard,
|
|
21
5
|
} from '../sensor/dist/bin/node-version-check.js';
|
|
22
|
-
|
|
23
|
-
export { MIN_NODE_MAJOR };
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* @param {string} nodeVersion - process.versions.node相当の文字列 (例: "26.5.0")
|
|
27
|
-
* @param {boolean} overrideActive - LATTICE_SENSOR_ALLOW_UNSAFE_NODE が設定されているか
|
|
28
|
-
* @returns {{ blocked: boolean, banner: string | null }}
|
|
29
|
-
* banner: 表示すべきbanner文言(sensor CLIと同一文言)。null ならサポート
|
|
30
|
-
* 対象内で表示不要。
|
|
31
|
-
* blocked: true なら呼び出し側は起動を止める(exit 1 相当)。
|
|
32
|
-
* override有効時は nodeMajor が範囲外でも blocked は false になるが、
|
|
33
|
-
* banner は「表示のみ」で non-null のまま返る(sensor CLIと同一意味論)。
|
|
34
|
-
*/
|
|
35
|
-
export function evaluateNodeVersionGuard(nodeVersion, overrideActive) {
|
|
36
|
-
const nodeMajor = parseInt(nodeVersion.split('.')[0] ?? '0', 10);
|
|
37
|
-
if (isNode25Affected(nodeMajor)) {
|
|
38
|
-
return { blocked: !overrideActive, banner: buildNode25BlockBanner(nodeVersion) };
|
|
39
|
-
}
|
|
40
|
-
if (nodeMajor < MIN_NODE_MAJOR) {
|
|
41
|
-
return { blocked: !overrideActive, banner: buildNodeTooOldBanner(nodeVersion) };
|
|
42
|
-
}
|
|
43
|
-
return { blocked: false, banner: null };
|
|
44
|
-
}
|