@quolu/lattice 0.79.3 → 0.79.5

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 (48) hide show
  1. package/README.md +8 -2
  2. package/bin/lattice-bridge.mjs +31 -24
  3. package/bin/lattice-dashboard-supervisor.mjs +18 -0
  4. package/bin/lattice-dashboard.mjs +37 -14
  5. package/bin/lattice-mcp.mjs +2 -1
  6. package/docs/bridge-setup.md +37 -0
  7. package/package.json +2 -2
  8. package/sensor/dist/bin/lattice-sensor.js +6 -26
  9. package/sensor/dist/bin/node-version-check.d.ts +10 -1
  10. package/sensor/dist/bin/node-version-check.d.ts.map +1 -1
  11. package/sensor/dist/bin/node-version-check.js +43 -1
  12. package/sensor/dist/bin/node-version-check.js.map +1 -1
  13. package/sensor/dist/mcp/daemon-paths.d.ts +4 -0
  14. package/sensor/dist/mcp/daemon-paths.d.ts.map +1 -1
  15. package/sensor/dist/mcp/daemon-paths.js +12 -0
  16. package/sensor/dist/mcp/daemon-paths.js.map +1 -1
  17. package/sensor/dist/mcp/index.d.ts +3 -1
  18. package/sensor/dist/mcp/index.d.ts.map +1 -1
  19. package/sensor/dist/mcp/index.js +59 -30
  20. package/sensor/dist/mcp/index.js.map +1 -1
  21. package/sensor/dist/mcp/proxy.d.ts +2 -0
  22. package/sensor/dist/mcp/proxy.d.ts.map +1 -1
  23. package/sensor/dist/mcp/proxy.js +9 -3
  24. package/sensor/dist/mcp/proxy.js.map +1 -1
  25. package/sensor/dist/mcp/session.d.ts +7 -0
  26. package/sensor/dist/mcp/session.d.ts.map +1 -1
  27. package/sensor/dist/mcp/session.js +4 -1
  28. package/sensor/dist/mcp/session.js.map +1 -1
  29. package/sensor/dist/mcp/transport.d.ts +5 -5
  30. package/sensor/dist/mcp/transport.d.ts.map +1 -1
  31. package/sensor/dist/mcp/transport.js +14 -6
  32. package/sensor/dist/mcp/transport.js.map +1 -1
  33. package/sensor/package.json +1 -1
  34. package/src/bridge-cli.mjs +6 -2
  35. package/src/bridge-config.mjs +8 -0
  36. package/src/bridge-daemon.mjs +30 -2
  37. package/src/dashboard-delivery.mjs +14 -2
  38. package/src/dashboard-lifecycle-ipc.mjs +5 -0
  39. package/src/dashboard-lifecycle-journal.mjs +237 -0
  40. package/src/dashboard-process-control.mjs +74 -0
  41. package/src/dashboard-supervisor.mjs +180 -0
  42. package/src/dashboard-windows-process-control.ps1 +40 -0
  43. package/src/factory-diagnostics.mjs +22 -9
  44. package/src/mcp-startup-receipt.mjs +99 -0
  45. package/src/node-version-guard.mjs +3 -42
  46. package/src/setup-config.mjs +7 -3
  47. package/src/todo-dashboard-registry.mjs +24 -3
  48. package/src/todo-store-cache.mjs +50 -6
@@ -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 ENGINES_FLOOR_RANGE_PATTERN = /^(>=(\d+)(?:\.(\d+))?(?:\.\d+)?)(?: +<25(?:\.0\.0)? +\|\| +>=26(?:\.0\.0)?)$/;
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
- const floor = ENGINES_FLOOR_RANGE_PATTERN.exec(enginesNode ?? '');
74
- if (!floor) {
75
- return check('node_runtime', false, 'package.json engines.node floor is unreadable');
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 [major, minor] = [Number(runtime[1]), Number(runtime[2])];
78
- const [floorMajor, floorMinor] = [Number(floor[2]), Number(floor[3] ?? '0')];
79
- const satisfied = major > floorMajor || (major === floorMajor && minor >= floorMinor);
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
- satisfied ? `${nodeVersion} satisfies engines.node floor ${floor[1]}`
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
 
@@ -0,0 +1,99 @@
1
+ import { Worker, isMainThread, parentPort, workerData } from 'node:worker_threads';
2
+ import { randomUUID } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { lstatSync, mkdirSync, openSync, writeFileSync, closeSync, renameSync, readdirSync } from 'node:fs';
6
+ import { ensureSafeDir, ensureSafeFile } from './runtime-errors.mjs';
7
+ import { observeDashboardProcess } from './dashboard-process-control.mjs';
8
+ import { isAclScratchName } from './windows-owner-only.mjs';
9
+
10
+ const VERSION = /^[a-zA-Z0-9.+-]{1,128}$/u;
11
+ const PHASES = new Set(['initialize_received', 'initialize_response_written']);
12
+ export const MCP_RECEIPT_SLOTS = 64;
13
+ export const MCP_RECEIPT_BYTES = 4096;
14
+
15
+ /** No filesystem or process-identity work on the MCP response event loop. */
16
+ export function createMcpStartupReceipt(wrapperVersion) {
17
+ let worker;
18
+ const posted = new Set();
19
+ const post = message => { try { worker?.postMessage(message); } catch { /* observation only */ } };
20
+ const phase = name => {
21
+ if (!worker || posted.has(name)) return;
22
+ posted.add(name); post({ phase: name, at: new Date().toISOString() });
23
+ };
24
+ return {
25
+ setMode(mode, sensorVersion) {
26
+ if (!['direct', 'proxy'].includes(mode) || !VERSION.test(wrapperVersion) || !VERSION.test(sensorVersion)) return;
27
+ if (!worker) {
28
+ try {
29
+ worker = new Worker(new URL(import.meta.url), { workerData: {
30
+ receipt: true, home: homedir(), wrapperVersion, pid: process.pid,
31
+ runId: randomUUID(), startedAt: new Date().toISOString(),
32
+ }, stdout: true, stderr: true });
33
+ worker.stdout.resume(); worker.stderr.resume();
34
+ worker.on('error', () => {});
35
+ worker.unref();
36
+ } catch { return; }
37
+ }
38
+ if (!posted.has(mode)) { posted.add(mode); post({ mode, sensorVersion }); }
39
+ },
40
+ initializeReceived() { phase('initialize_received'); },
41
+ initializeWritten() { phase('initialize_response_written'); },
42
+ };
43
+ }
44
+
45
+ if (!isMainThread && workerData?.receipt === true) {
46
+ // Parent messages carry fixed observations only, never JSON-RPC data.
47
+ let queue = Promise.resolve();
48
+ let slot = null;
49
+ let disabled = false;
50
+ let record = null;
51
+ const root = path.join(workerData.home, '.lattice', 'mcp-startup');
52
+ async function prepare() {
53
+ const parent = path.dirname(root);
54
+ try { mkdirSync(parent, { mode: 0o700 }); } catch (error) { if (error.code !== 'EEXIST') throw error; }
55
+ const info = lstatSync(parent);
56
+ if (!info.isDirectory() || info.isSymbolicLink()
57
+ || process.platform !== 'win32' && (info.uid !== process.getuid() || (info.mode & 0o022) !== 0)) throw Error('unsafe');
58
+ ensureSafeDir(root);
59
+ if (readdirSync(root).some(name => !/^slot-(?:[0-9]|[1-5][0-9]|6[0-3])$/u.test(name)
60
+ && !(process.platform === 'win32' && isAclScratchName(name)))) throw Error('unexpected entry');
61
+ for (let index = 0; index < MCP_RECEIPT_SLOTS; index++) {
62
+ const candidate = path.join(root, `slot-${index}`);
63
+ try { mkdirSync(candidate, { mode: 0o700 }); slot = candidate; break; }
64
+ catch (error) { if (error.code !== 'EEXIST') throw error; }
65
+ }
66
+ if (!slot) throw Error('capacity');
67
+ ensureSafeDir(slot);
68
+ const identity = await observeDashboardProcess(workerData.pid);
69
+ record = { schema: 'lattice.mcp_startup.v1', run_id: workerData.runId,
70
+ pid: workerData.pid, birth: identity?.birth ?? null,
71
+ wrapper_version: workerData.wrapperVersion, sensor_version: null, mode: null,
72
+ events: [{ phase: 'started', at: workerData.startedAt }] };
73
+ }
74
+ async function receive(message) {
75
+ if (disabled) return;
76
+ if (!record) await prepare();
77
+ if (['direct', 'proxy'].includes(message.mode) && VERSION.test(message.sensorVersion)) {
78
+ record.mode = message.mode; record.sensor_version = message.sensorVersion;
79
+ } else if (PHASES.has(message.phase) && typeof message.at === 'string'
80
+ && Number.isFinite(Date.parse(message.at)) && !record.events.some(e => e.phase === message.phase)) {
81
+ if (message.phase === 'initialize_response_written'
82
+ && !record.events.some(e => e.phase === 'initialize_received')) return;
83
+ record.events.push({ phase: message.phase, at: message.at });
84
+ } else return;
85
+ if (!record.mode) return;
86
+ const bytes = JSON.stringify(record) + '\n';
87
+ if (Buffer.byteLength(bytes) > MCP_RECEIPT_BYTES) throw Error('capacity');
88
+ ensureSafeDir(root); ensureSafeDir(slot);
89
+ const temporary = path.join(slot, 'pending.json'), target = path.join(slot, 'receipt.json');
90
+ try { ensureSafeFile(target, slot); } catch (error) { if (error.code !== 'ENOENT') throw error; }
91
+ const fd = openSync(temporary, 'wx', 0o600);
92
+ try { writeFileSync(fd, bytes); } finally { closeSync(fd); }
93
+ ensureSafeFile(temporary, slot);
94
+ renameSync(temporary, target);
95
+ }
96
+ parentPort.on('message', message => {
97
+ queue = queue.then(() => receive(message)).catch(() => { disabled = true; parentPort.close(); });
98
+ });
99
+ }
@@ -1,44 +1,5 @@
1
- /**
2
- * lattice-mcp bin向けNode versionガード(ADR 0049 wave2レビューでのスコープ外
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
- buildNode25BlockBanner,
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
- }
@@ -107,13 +107,17 @@ export async function writeSetupFile(target, before, text) {
107
107
  if (text === before.text) return 'unchanged';
108
108
  await mkdir(path.dirname(target), { recursive: true });
109
109
  const current = await readSetupFile(target);
110
- if (current.text !== before.text || current.exists !== before.exists) throw setupError('CONFIG_CHANGED', '設定が同時に変更されました');
110
+ if (current.text !== before.text || current.exists !== before.exists || current.mode !== before.mode) throw setupError('CONFIG_CHANGED', '設定が同時に変更されました');
111
111
  const temporary = `${target}.lattice-${randomUUID()}`;
112
112
  try {
113
113
  const handle = await open(temporary, 'wx', before.mode);
114
- try { await handle.writeFile(text); await handle.sync(); } finally { await handle.close(); }
114
+ try {
115
+ await handle.writeFile(text);
116
+ await handle.chmod(before.mode);
117
+ await handle.sync();
118
+ } finally { await handle.close(); }
115
119
  const latest = await readSetupFile(target);
116
- if (latest.text !== before.text || latest.exists !== before.exists) throw setupError('CONFIG_CHANGED', '設定が同時に変更されました');
120
+ if (latest.text !== before.text || latest.exists !== before.exists || latest.mode !== before.mode) throw setupError('CONFIG_CHANGED', '設定が同時に変更されました');
117
121
  if (before.exists) await copyFile(target, `${temporary}.backup`);
118
122
  await rename(temporary, target);
119
123
  if ((await readSetupFile(target)).text !== text) throw setupError('CONFIG_READBACK_FAILED', '設定の読戻しが一致しません');
@@ -1,4 +1,3 @@
1
- import { spawn } from 'node:child_process';
2
1
  import { randomBytes } from 'node:crypto';
3
2
  import { homedir } from 'node:os';
4
3
  import {
@@ -9,6 +8,10 @@ import path from 'node:path';
9
8
  import packageJson from '../package.json' with { type: 'json' };
10
9
  import { isAuditPendingPhaseStatus } from './todo-audit-pending.mjs';
11
10
  import { observeStartIdentityEpochMs } from './runtime-os-observation.mjs';
11
+ import { spawnSupervisedDashboard } from './dashboard-supervisor.mjs';
12
+ import { reconcileLifecycleRuns, readDashboardLifecycleStatus,
13
+ recordDashboardEnsureDecision } from './dashboard-lifecycle-journal.mjs';
14
+ import { observeDashboardProcess } from './dashboard-process-control.mjs';
12
15
 
13
16
  /**
14
17
  * The version of the code in THIS process. A daemon loads its modules once at
@@ -49,6 +52,10 @@ function paths(env) {
49
52
  };
50
53
  }
51
54
 
55
+ export async function readTodoDashboardLifecycleStatus({ env = process.env } = {}) {
56
+ return readDashboardLifecycleStatus(runtimeDir(env));
57
+ }
58
+
52
59
  function validEntry(entry) {
53
60
  return entry !== null && typeof entry === 'object' && !Array.isArray(entry)
54
61
  && Object.keys(entry).sort().join(',') === 'display_name,last_seen_at,project_id,repo_root,session_id'
@@ -523,7 +530,7 @@ export async function forgetTodoDashboardDaemonRecord({ env = process.env } = {}
523
530
  await rm(daemonRecordRef(paths(env), process.pid), { force: true });
524
531
  }
525
532
 
526
- export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon = spawn,
533
+ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon = spawnSupervisedDashboard,
527
534
  signalProcess = process.kill, isProcessAlive = processIsAlive, startupTimeoutMs = 120_000,
528
535
  legacyStopTimeoutMs = 3_000, replacementIsProcessAlive = processIsAlive,
529
536
  attestationTimeoutMs = 2_000, strayStopTimeoutMs = 3_000,
@@ -535,15 +542,18 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
535
542
  const reap = (keepPid) => reapStrayDaemons(refs, keepPid,
536
543
  { signalProcess, isProcessAlive, timeoutMs: strayStopTimeoutMs });
537
544
  return withLock(refs.startupLock, async () => {
545
+ await reconcileLifecycleRuns(refs.root, observeDashboardProcess);
538
546
  const existing = await readJson(refs.descriptor, null);
539
547
  const existingAttestation = await daemonAttestation(existing, { timeoutMs: attestationTimeoutMs });
540
548
  if (existingAttestation === 'current') {
549
+ await recordDashboardEnsureDecision(refs.root, 'reuse_current', existing.pid);
541
550
  await reap(existing.pid);
542
551
  return existing;
543
552
  }
544
553
  if (validDaemonDescriptor(existing) && existingAttestation === null
545
554
  && await isProcessAlive(existing.pid)
546
555
  && !await descriptorPidWasReused(existing, { observeProcessStartEpochMs })) {
556
+ await recordDashboardEnsureDecision(refs.root, 'alive_unresponsive', existing.pid);
547
557
  throw dashboardError('DASHBOARD_DAEMON_UNRESPONSIVE', 'dashboard daemon is alive but temporarily unresponsive');
548
558
  }
549
559
  let legacy = existingAttestation === 'legacy' ? existing : null;
@@ -558,6 +568,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
558
568
  // 引き取った相手は生きて応答しているので、descriptorの不変条件を満たす。
559
569
  await atomicJson(refs.descriptor, adopted.record);
560
570
  if (adopted.attestation === 'current') {
571
+ await recordDashboardEnsureDecision(refs.root, 'adopt_current', adopted.record.pid);
561
572
  await reap(adopted.record.pid);
562
573
  return adopted.record;
563
574
  }
@@ -569,9 +580,14 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
569
580
  && Number(portText) <= 65_535 ? Number(portText) : DEFAULT_PORT;
570
581
  const port = legacy === null ? configuredPort : 0;
571
582
  if (legacy === null) await rm(refs.descriptor, { force: true });
572
- const child = spawnDaemon(process.execPath, [path.resolve(import.meta.dirname, '../bin/lattice-dashboard.mjs')], {
583
+ const startupDecision = legacy !== null ? 'replace_legacy'
584
+ : validDaemonDescriptor(existing) && await isProcessAlive(existing.pid) ? 'pid_reused'
585
+ : 'start_missing';
586
+ await recordDashboardEnsureDecision(refs.root, startupDecision, legacy?.pid ?? null);
587
+ const child = await spawnDaemon(process.execPath, [path.resolve(import.meta.dirname, '../bin/lattice-dashboard.mjs')], {
573
588
  detached: true,
574
589
  stdio: 'ignore',
590
+ version: TODO_DASHBOARD_CODE_VERSION,
575
591
  env: { ...env, LATTICE_DASHBOARD_RUNTIME_DIR: refs.root, LATTICE_DASHBOARD_PORT: String(port) },
576
592
  });
577
593
  child.unref();
@@ -584,6 +600,10 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
584
600
  const deadline = Date.now() + startupTimeoutMs;
585
601
  while (Date.now() < deadline) {
586
602
  await new Promise((resolve) => setTimeout(resolve, 50));
603
+ if (child.observationError) {
604
+ child.kill?.('SIGTERM');
605
+ throw child.observationError;
606
+ }
587
607
  const descriptor = typeof child.pid === 'number'
588
608
  ? await readJson(daemonRecordRef(refs, child.pid), null) : null;
589
609
  const healthy = await daemonHealthy(descriptor);
@@ -601,6 +621,7 @@ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon
601
621
  }
602
622
  }
603
623
  await atomicJson(refs.descriptor, descriptor);
624
+ await recordDashboardEnsureDecision(refs.root, startupDecision, descriptor.pid);
604
625
  await reap(descriptor.pid);
605
626
  return descriptor;
606
627
  }
@@ -13,13 +13,40 @@
13
13
  */
14
14
 
15
15
  import { createHash } from 'node:crypto';
16
- import { readFile } from 'node:fs/promises';
16
+ import { readFile, stat } from 'node:fs/promises';
17
17
  import path from 'node:path';
18
18
 
19
19
  import { readTodoStoreStable } from './todo-store.mjs';
20
20
 
21
- async function manifestContentDigest(manifestRef) {
22
- return createHash('sha256').update(await readFile(manifestRef)).digest('hex');
21
+ const MANIFEST_REF = '.lattice/todo/manifest.json';
22
+ const READ_FAILURE = Symbol('store read failure');
23
+
24
+ /** Finite diagnostics only; keep the original exception and its errno intact. */
25
+ export function todoStoreReadFailurePhase(error) {
26
+ if (error?.[READ_FAILURE]) return error[READ_FAILURE];
27
+ if (['EACCES', 'EPERM'].includes(error?.code)) return 'store_access_denied';
28
+ if (error?.code === 'EIO') return 'store_io_failed';
29
+ if (error?.code === 'STORE_INCONSISTENT' && error.detail?.ref === MANIFEST_REF
30
+ && ['schema_invalid', 'artifact_truncated_or_trailing_bytes', 'non_canonical_or_duplicate_key',
31
+ 'size_limit_exceeded', 'artifact_eol_converted'].includes(error.detail.reason)) {
32
+ return 'store_manifest_invalid';
33
+ }
34
+ return 'store_read_failed';
35
+ }
36
+
37
+ async function manifestContentDigest(repoRoot) {
38
+ try {
39
+ return createHash('sha256').update(await readFile(path.join(repoRoot, MANIFEST_REF))).digest('hex');
40
+ } catch (error) {
41
+ if (error.code === 'ENOENT') {
42
+ try {
43
+ if ((await stat(repoRoot)).isDirectory()) error[READ_FAILURE] = 'store_manifest_missing';
44
+ } catch (rootError) {
45
+ if (rootError.code === 'ENOENT') error[READ_FAILURE] = 'store_root_missing';
46
+ }
47
+ }
48
+ throw error;
49
+ }
23
50
  }
24
51
 
25
52
  /**
@@ -30,14 +57,31 @@ export function createTodoStoreCache({ readStable = readTodoStoreStable } = {})
30
57
  const cache = new Map();
31
58
  return {
32
59
  async read(repoRoot) {
33
- const manifestRef = path.join(repoRoot, '.lattice', 'todo', 'manifest.json');
34
- const digest = await manifestContentDigest(manifestRef);
60
+ const digest = await manifestContentDigest(repoRoot);
35
61
  const cached = cache.get(repoRoot);
36
62
  if (cached?.digest === digest) return cached.store;
37
63
  // Read before caching: a failure here (including the store's own inconsistency
38
64
  // detection) must not populate the cache, so the very next call re-reads instead
39
65
  // of serving a poisoned entry.
40
- const store = await readStable({ repoRoot });
66
+ let store;
67
+ try { store = await readStable({ repoRoot }); }
68
+ catch (error) {
69
+ const ref = error?.detail?.ref;
70
+ const absolute = typeof ref === 'string' ? path.resolve(repoRoot, ref) : null;
71
+ if (error?.code === 'STORE_INCONSISTENT' && error.detail?.reason === 'artifact_missing'
72
+ && ref !== MANIFEST_REF && absolute?.startsWith(`${path.resolve(repoRoot, '.lattice/todo')}${path.sep}`)) {
73
+ // The store's legacy artifact_missing also covers lstat I/O errors.
74
+ try { await stat(absolute); }
75
+ catch (artifactError) {
76
+ if (artifactError.code === 'ENOENT') {
77
+ let currentDigest;
78
+ try { currentDigest = await manifestContentDigest(repoRoot); } catch { /* concurrent removal stays unknown */ }
79
+ if (currentDigest === digest) error[READ_FAILURE] = 'store_artifact_missing';
80
+ } else error[READ_FAILURE] = todoStoreReadFailurePhase(artifactError);
81
+ }
82
+ }
83
+ throw error;
84
+ }
41
85
  cache.set(repoRoot, { digest, store });
42
86
  return store;
43
87
  },