@quolu/lattice 0.53.0 → 0.54.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.
- package/bin/lattice-bridge-supervisor.mjs +58 -0
- package/bin/lattice-bridge.mjs +59 -2
- package/bin/lattice-dashboard.mjs +7 -25
- package/package.json +1 -1
- package/src/bridge-address.mjs +19 -0
- package/src/bridge-cli.mjs +21 -2
- package/src/bridge-config.mjs +8 -1
- package/src/bridge-daemon.mjs +28 -2
- package/src/bridge-hub-migration.mjs +118 -0
- package/src/bridge-hub-server.mjs +27 -7
- package/src/bridge-registrar.mjs +18 -0
- package/src/bridge-server.mjs +4 -1
- package/src/bridge-startup-folder.mjs +348 -0
- package/src/todo-store-cache.mjs +45 -0
- package/src/todo-store.mjs +22 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Windows bridge supervisor (bh6 prep). Startup-folder items only run once at
|
|
4
|
+
* logon — nothing like launchd's KeepAlive exists to restart a crashed
|
|
5
|
+
* process. This script is that supervision, written in JS instead of a batch
|
|
6
|
+
* GOTO loop because a loop's own process (and therefore its killability) is
|
|
7
|
+
* awkward to track reliably on Windows; a Node process's pid is not.
|
|
8
|
+
*
|
|
9
|
+
* Usage: node lattice-bridge-supervisor.mjs <descriptor.json>
|
|
10
|
+
* The descriptor supplies the environment `lattice-bridge.mjs` needs (it is
|
|
11
|
+
* never inherited from the Startup-folder launch context) and the path to
|
|
12
|
+
* write this supervisor's own pid to, so `bridge-startup-folder.mjs` can find
|
|
13
|
+
* and stop the whole tree later via `taskkill /T /F`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
import { writeFile } from 'node:fs/promises';
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
const RESTART_DELAY_MS = 3_000;
|
|
22
|
+
|
|
23
|
+
const descriptorPath = process.argv[2];
|
|
24
|
+
if (typeof descriptorPath !== 'string' || descriptorPath.length === 0) {
|
|
25
|
+
process.stderr.write('usage: lattice-bridge-supervisor.mjs <descriptor.json>\n');
|
|
26
|
+
process.exit(2);
|
|
27
|
+
}
|
|
28
|
+
const descriptor = JSON.parse(readFileSync(descriptorPath, 'utf8'));
|
|
29
|
+
if (descriptor?.schema !== 'lattice.bridge_supervisor_descriptor.v1'
|
|
30
|
+
|| typeof descriptor.bridgePath !== 'string' || typeof descriptor.pidPath !== 'string'
|
|
31
|
+
|| typeof descriptor.env !== 'object' || descriptor.env === null) {
|
|
32
|
+
process.stderr.write('bridge supervisor descriptor is invalid\n');
|
|
33
|
+
process.exit(2);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
await writeFile(descriptor.pidPath, String(process.pid), { encoding: 'utf8', flag: 'w' });
|
|
37
|
+
|
|
38
|
+
let stopping = false;
|
|
39
|
+
let child = null;
|
|
40
|
+
const stop = () => {
|
|
41
|
+
stopping = true;
|
|
42
|
+
child?.kill();
|
|
43
|
+
};
|
|
44
|
+
process.once('SIGINT', stop);
|
|
45
|
+
process.once('SIGTERM', stop);
|
|
46
|
+
|
|
47
|
+
const bridgePath = path.resolve(descriptor.bridgePath);
|
|
48
|
+
while (!stopping) {
|
|
49
|
+
child = spawn(process.execPath, [bridgePath], {
|
|
50
|
+
env: { ...process.env, ...descriptor.env },
|
|
51
|
+
stdio: 'ignore',
|
|
52
|
+
windowsHide: true,
|
|
53
|
+
});
|
|
54
|
+
await new Promise((resolve) => child.once('exit', resolve));
|
|
55
|
+
child = null;
|
|
56
|
+
if (stopping) break;
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, RESTART_DELAY_MS));
|
|
58
|
+
}
|
package/bin/lattice-bridge.mjs
CHANGED
|
@@ -2,14 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
import { readBridgeConfig } from '../src/bridge-config.mjs';
|
|
4
4
|
import {
|
|
5
|
-
readBridgeStopRequest, removeBridgeDaemonActiveMarker,
|
|
6
|
-
writeBridgeDaemonDescriptor, writeBridgeStopReceipt,
|
|
5
|
+
bridgeDaemonVersionDrifted, readBridgeStopRequest, removeBridgeDaemonActiveMarker,
|
|
6
|
+
removeBridgeDaemonDescriptor, writeBridgeDaemonDescriptor, writeBridgeStopReceipt,
|
|
7
7
|
} from '../src/bridge-daemon.mjs';
|
|
8
8
|
import { createBridgeHubHeartbeatController } from '../src/bridge-hub-heartbeat.mjs';
|
|
9
|
+
import { migrateBridgeToHub, retireBridgeTunnelLaunchAgent } from '../src/bridge-hub-migration.mjs';
|
|
10
|
+
import { bridgeRegistrarSettings } from '../src/bridge-registrar.mjs';
|
|
9
11
|
import { bridgeRuntimeController } from '../src/bridge-server.mjs';
|
|
10
12
|
|
|
13
|
+
// Throttles bridgeDaemonVersionDrifted's disk read and the migration/tunnel-
|
|
14
|
+
// retirement checks' subprocess calls (ssh, launchctl) — the 250ms reconcile
|
|
15
|
+
// tick exists for local responsiveness, not for polling external processes
|
|
16
|
+
// 4x/sec. Migration and tunnel-retirement share this interval: once migrated,
|
|
17
|
+
// the migration check itself becomes a single cheap config-field read
|
|
18
|
+
// (`current.hub !== null`), so there is no cost to leaving both armed forever.
|
|
19
|
+
const BACKGROUND_CHECK_INTERVAL_MS = 60_000;
|
|
20
|
+
|
|
11
21
|
const env = process.env;
|
|
12
22
|
const hubHeartbeat = createBridgeHubHeartbeatController({ env });
|
|
23
|
+
let lastVersionCheckAt = 0;
|
|
24
|
+
let lastMigrationCheckAt = 0;
|
|
13
25
|
const instanceToken = env.LATTICE_BRIDGE_INSTANCE_TOKEN;
|
|
14
26
|
if (typeof instanceToken !== 'string' || !/^[0-9a-f]{64}$/u.test(instanceToken)) {
|
|
15
27
|
process.stderr.write(`${JSON.stringify({ schema: 'lattice.bridge_daemon_error.v1',
|
|
@@ -61,6 +73,51 @@ timer = setInterval(async () => {
|
|
|
61
73
|
await removeBridgeDaemonActiveMarker({ env });
|
|
62
74
|
process.exit(0);
|
|
63
75
|
}
|
|
76
|
+
// A stale-version exit is a clean stop, not a failure: whatever supervises
|
|
77
|
+
// this process (launchd KeepAlive, the Windows supervisor loop) relaunches
|
|
78
|
+
// it immediately, and the fresh process imports whatever is on disk now —
|
|
79
|
+
// this is the mechanism that makes "npm update, done" actually true rather
|
|
80
|
+
// than leaving an already-running daemon serving replaced code forever.
|
|
81
|
+
if (Date.now() - lastVersionCheckAt >= BACKGROUND_CHECK_INTERVAL_MS) {
|
|
82
|
+
lastVersionCheckAt = Date.now();
|
|
83
|
+
if (await bridgeDaemonVersionDrifted({})) {
|
|
84
|
+
await close();
|
|
85
|
+
await removeBridgeDaemonDescriptor({ env });
|
|
86
|
+
await removeBridgeDaemonActiveMarker({ env });
|
|
87
|
+
process.exit(0);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
// bh5 auto-migration: a terminal still carrying the pre-hub registrar env
|
|
91
|
+
// (LaunchAgent-baked, so it outlives any single process) upgrades itself
|
|
92
|
+
// to hub registration with no operator action — see bridge-hub-migration.mjs's
|
|
93
|
+
// module doc for why this is the whole point of the owner's "update it,
|
|
94
|
+
// done" acceptance test. Runs on the same throttle as the version check;
|
|
95
|
+
// once migrated it is a single cheap config-field read, so leaving it
|
|
96
|
+
// armed forever costs nothing. Tunnel retirement is attempted alongside
|
|
97
|
+
// it (not gated to the migration transition alone) so a retirement that
|
|
98
|
+
// failed once keeps getting retried rather than being a one-shot.
|
|
99
|
+
if (Date.now() - lastMigrationCheckAt >= BACKGROUND_CHECK_INTERVAL_MS) {
|
|
100
|
+
lastMigrationCheckAt = Date.now();
|
|
101
|
+
if (bridgeRegistrarSettings(env) !== null) {
|
|
102
|
+
await migrateBridgeToHub({ env }).catch((error) => {
|
|
103
|
+
process.stderr.write(`${JSON.stringify({ schema: 'lattice.bridge_daemon_error.v1',
|
|
104
|
+
code: error?.code ?? 'BRIDGE_HUB_MIGRATION_FAILED',
|
|
105
|
+
message: error?.message ?? 'bridge hub migration failed' })}\n`);
|
|
106
|
+
});
|
|
107
|
+
const migratedConfig = await readBridgeConfig({ env });
|
|
108
|
+
if (migratedConfig?.hub !== null && migratedConfig?.hub !== undefined) {
|
|
109
|
+
// retireBridgeTunnelLaunchAgent's own contract never throws for any
|
|
110
|
+
// expected outcome (not loaded, bootout failure, launchctl absent —
|
|
111
|
+
// all typed returns); this catch is only for a genuinely unexpected
|
|
112
|
+
// bug in that function, and it is still logged, not swallowed.
|
|
113
|
+
await retireBridgeTunnelLaunchAgent({ env }).catch((error) => {
|
|
114
|
+
process.stderr.write(`${JSON.stringify({ schema: 'lattice.bridge_daemon_error.v1',
|
|
115
|
+
code: error?.code ?? 'BRIDGE_TUNNEL_RETIREMENT_FAILED',
|
|
116
|
+
message: error?.message ?? 'bridge tunnel retirement failed' })}\n`);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
64
121
|
const config = await readBridgeConfig({ env });
|
|
65
122
|
if (config === null || !config.enabled) {
|
|
66
123
|
await close();
|
|
@@ -1,10 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
import { stat } from 'node:fs/promises';
|
|
4
|
-
import path from 'node:path';
|
|
5
|
-
|
|
6
|
-
import { readTodoStoreStable } from '../src/todo-store.mjs';
|
|
7
3
|
import { TODO_STATUS_DISPATCH_ONLY, projectTodoStatus } from '../src/todo-status.mjs';
|
|
4
|
+
import { createTodoStoreCache } from '../src/todo-store-cache.mjs';
|
|
8
5
|
import { ganttLiveHeadDigest, renderTodoGanttForProject } from '../src/todo-cli.mjs';
|
|
9
6
|
import { readProjectExternalPane } from '../src/project-identity.mjs';
|
|
10
7
|
import {
|
|
@@ -35,27 +32,12 @@ const port = typeof configured === 'string' && /^(?:0|[1-9][0-9]{0,4})$/u.test(c
|
|
|
35
32
|
const registry = createTodoGanttProjectRegistry();
|
|
36
33
|
const roots = new Map();
|
|
37
34
|
const reportedStoreReadFailures = new Set();
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
async function readCachedStore(repoRoot) {
|
|
45
|
-
const manifestRef = path.join(repoRoot, '.lattice', 'todo', 'manifest.json');
|
|
46
|
-
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
47
|
-
const beforeFingerprint = manifestFingerprint(await stat(manifestRef));
|
|
48
|
-
const cached = storeCache.get(repoRoot);
|
|
49
|
-
if (cached?.fingerprint === beforeFingerprint) return cached.store;
|
|
50
|
-
const store = await readTodoStoreStable({ repoRoot });
|
|
51
|
-
const afterFingerprint = manifestFingerprint(await stat(manifestRef));
|
|
52
|
-
if (beforeFingerprint === afterFingerprint) {
|
|
53
|
-
storeCache.set(repoRoot, { fingerprint: afterFingerprint, store });
|
|
54
|
-
return store;
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
return readTodoStoreStable({ repoRoot });
|
|
58
|
-
}
|
|
35
|
+
// room 2488's "gantt serve固着" symptom (a dashboard stuck on stale/broken state that
|
|
36
|
+
// only a process restart cleared, with the store and git both already fixed) traced to
|
|
37
|
+
// this cache — see src/todo-store-cache.mjs for why it is content-digest keyed rather
|
|
38
|
+
// than stat()-fingerprint keyed.
|
|
39
|
+
const storeCache = createTodoStoreCache();
|
|
40
|
+
const readCachedStore = (repoRoot) => storeCache.read(repoRoot);
|
|
59
41
|
|
|
60
42
|
async function synchronize() {
|
|
61
43
|
const active = await readVisibleTodoDashboardProjects({ env,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Quo / クオ at kitepon.dev",
|
package/src/bridge-address.mjs
CHANGED
|
@@ -105,3 +105,22 @@ export function resolveBridgeListenAddress({ configured, interfaces = {} } = {})
|
|
|
105
105
|
return { state: 'rebindable', effective: candidates[0], configured: wanted, candidates,
|
|
106
106
|
reason: 'configured_address_absent_rebound_within_subnet' };
|
|
107
107
|
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Pick a LAN-facing address for a terminal that has none yet — bh5's Mac
|
|
111
|
+
* auto-migration (loopback + ssh tunnel → direct hub registration) and any
|
|
112
|
+
* first-time setup with no address preference. Deliberately simpler than
|
|
113
|
+
* `resolveBridgeListenAddress`: that function preserves "same intent" across
|
|
114
|
+
* a DHCP move by requiring a same-subnet match against an already-configured
|
|
115
|
+
* address, but there is no prior intent to preserve when the terminal had no
|
|
116
|
+
* LAN presence before. The first non-internal address, sorted for
|
|
117
|
+
* determinism, is a reasonable default; ambiguous hosts (more than one
|
|
118
|
+
* candidate) are still reported so a caller can choose to surface that rather
|
|
119
|
+
* than silently pick.
|
|
120
|
+
*/
|
|
121
|
+
export function pickBridgeLanAddress({ interfaces = {}, family = null } = {}) {
|
|
122
|
+
const candidates = bridgeHostAddresses(interfaces)
|
|
123
|
+
.filter((entry) => !entry.internal && (family === null || entry.family === family))
|
|
124
|
+
.map((entry) => entry.address);
|
|
125
|
+
return { address: candidates[0] ?? null, candidates };
|
|
126
|
+
}
|
package/src/bridge-cli.mjs
CHANGED
|
@@ -17,6 +17,10 @@ import {
|
|
|
17
17
|
disableBridgeLaunchAgent, installBridgeLaunchAgent, restoreBridgeLaunchAgent,
|
|
18
18
|
snapshotBridgeLaunchAgent,
|
|
19
19
|
} from './bridge-launch-agent.mjs';
|
|
20
|
+
import {
|
|
21
|
+
disableBridgeStartupFolder, installBridgeStartupFolder, restoreBridgeStartupFolder,
|
|
22
|
+
snapshotBridgeStartupFolder,
|
|
23
|
+
} from './bridge-startup-folder.mjs';
|
|
20
24
|
|
|
21
25
|
// v2 adds the liveness fields. `enabled` only says the configuration is on;
|
|
22
26
|
// it never said the bridge could actually be reached, which let a DHCP lease
|
|
@@ -66,6 +70,22 @@ async function bridgeLiveness(config, { interfaces = networkInterfaces(), probe
|
|
|
66
70
|
};
|
|
67
71
|
}
|
|
68
72
|
|
|
73
|
+
// The bridge's own persistence mechanism is OS-specific; everything above
|
|
74
|
+
// this line (config, daemon lifecycle, registrar) is not. Selecting by
|
|
75
|
+
// `process.platform` here — rather than requiring every caller to pick — is
|
|
76
|
+
// what lets `lattice bridge setup` on Windows persist via the Startup folder
|
|
77
|
+
// exactly the way it persists via a LaunchAgent on macOS, with no separate
|
|
78
|
+
// command or manual step (see bridge-startup-folder.mjs's module doc for why
|
|
79
|
+
// Task Scheduler's ONLOGON trigger could not be used instead).
|
|
80
|
+
function platformLaunchAgent() {
|
|
81
|
+
if (process.platform === 'win32') {
|
|
82
|
+
return { snapshot: snapshotBridgeStartupFolder, install: installBridgeStartupFolder,
|
|
83
|
+
disable: disableBridgeStartupFolder, restore: restoreBridgeStartupFolder };
|
|
84
|
+
}
|
|
85
|
+
return { snapshot: snapshotBridgeLaunchAgent, install: installBridgeLaunchAgent,
|
|
86
|
+
disable: disableBridgeLaunchAgent, restore: restoreBridgeLaunchAgent };
|
|
87
|
+
}
|
|
88
|
+
|
|
69
89
|
function fail(stderr, code, message) {
|
|
70
90
|
stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2', code, message })}\n`);
|
|
71
91
|
return 2;
|
|
@@ -160,8 +180,7 @@ export async function collectBridgeSetupWizard({ input, output, prompts = clack
|
|
|
160
180
|
export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
|
|
161
181
|
stdin = process.stdin, daemon = { ensure: ensureBridgeDaemon, requestStop: requestBridgeDaemonStop,
|
|
162
182
|
stop: stopBridgeDaemon, clearStop: clearBridgeStopControl },
|
|
163
|
-
launchAgent =
|
|
164
|
-
disable: disableBridgeLaunchAgent, restore: restoreBridgeLaunchAgent },
|
|
183
|
+
launchAgent = platformLaunchAgent(),
|
|
165
184
|
prompts = clack, probe = probeBridgeListener, interfaces = networkInterfaces() } = {}) {
|
|
166
185
|
if (!Array.isArray(argv)) {
|
|
167
186
|
return fail(stderr, 'USAGE', 'usage: lattice bridge <setup|reconfigure|status|disable|register> [options] --json');
|
package/src/bridge-config.mjs
CHANGED
|
@@ -175,7 +175,14 @@ async function readDocument(ref) {
|
|
|
175
175
|
if (error?.code === 'ENOENT') return null;
|
|
176
176
|
throw new BridgeConfigError('BRIDGE_CONFIG_UNREADABLE', 'bridge config cannot be read', undefined, error);
|
|
177
177
|
}
|
|
178
|
-
|
|
178
|
+
// Windows has no POSIX permission-bit model: `fs.stat().mode` never reports
|
|
179
|
+
// 0600 there regardless of what `mode`/`chmod` requested at write time, so
|
|
180
|
+
// this check is a hard, unconditional block on every platform but darwin/
|
|
181
|
+
// linux — verified against a real Windows host (`BRIDGE_CONFIG_MODE_INVALID`
|
|
182
|
+
// on a config `configureBridge` itself had just written moments earlier).
|
|
183
|
+
// The other checks (regular file, not a symlink) still apply everywhere.
|
|
184
|
+
if (!stats.isFile() || stats.isSymbolicLink()
|
|
185
|
+
|| (process.platform !== 'win32' && (stats.mode & 0o777) !== 0o600)) {
|
|
179
186
|
throw new BridgeConfigError('BRIDGE_CONFIG_MODE_INVALID', 'bridge config must be a regular 0600 file');
|
|
180
187
|
}
|
|
181
188
|
let value;
|
package/src/bridge-daemon.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { constants as fsConstants } from 'node:fs';
|
|
4
|
-
import { chmod, lstat, open, rename, rm, writeFile } from 'node:fs/promises';
|
|
4
|
+
import { chmod, lstat, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
|
|
5
5
|
import { isIP } from 'node:net';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { parseTree } from 'jsonc-parser';
|
|
@@ -9,6 +9,7 @@ import { parseTree } from 'jsonc-parser';
|
|
|
9
9
|
import {
|
|
10
10
|
BRIDGE_PORT_MAX, BRIDGE_PORT_MIN, BridgeConfigError, bridgeConfigPaths, readBridgeConfig,
|
|
11
11
|
} from './bridge-config.mjs';
|
|
12
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
12
13
|
|
|
13
14
|
const DESCRIPTOR_SCHEMA = 'lattice.bridge_daemon.v1';
|
|
14
15
|
const START_TIMEOUT_MS = 5_000;
|
|
@@ -83,7 +84,10 @@ async function readStrictJsonOnce(ref, label) {
|
|
|
83
84
|
let handle;
|
|
84
85
|
try {
|
|
85
86
|
before = await lstat(ref);
|
|
86
|
-
|
|
87
|
+
// Windows has no POSIX permission-bit model — see bridge-config.mjs's
|
|
88
|
+
// readDocument for the same guard and the real-host verification.
|
|
89
|
+
if (!before.isFile() || before.isSymbolicLink()
|
|
90
|
+
|| (process.platform !== 'win32' && (before.mode & 0o777) !== 0o600)
|
|
87
91
|
|| before.size > CONTROL_MAX_BYTES) throw new Error(`${label} unsafe`);
|
|
88
92
|
handle = await open(ref, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
89
93
|
const opened = await handle.stat();
|
|
@@ -376,3 +380,25 @@ export async function stopBridgeDaemon({ env = process.env } = {}) {
|
|
|
376
380
|
}
|
|
377
381
|
throw new BridgeConfigError('BRIDGE_DAEMON_STOP_FAILED', 'bridge daemon did not stop');
|
|
378
382
|
}
|
|
383
|
+
|
|
384
|
+
const PACKAGE_JSON_PATH = path.resolve(import.meta.dirname, '../package.json');
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Whether the on-disk package.json now reports a different version than the
|
|
388
|
+
* one this running process loaded at start — i.e. `npm install`/`update`
|
|
389
|
+
* replaced the files under a still-running daemon (the "daemon の版持ち"
|
|
390
|
+
* trap, AGENTS.md: a daemon keeps serving whatever module it imported at
|
|
391
|
+
* startup no matter what gets installed afterward). A long-running process
|
|
392
|
+
* cannot hot-swap its own already-imported modules; the only fix is to exit
|
|
393
|
+
* and let whatever supervises it (launchd's KeepAlive, the Windows
|
|
394
|
+
* supervisor's restart loop) relaunch a fresh process that imports the new
|
|
395
|
+
* code. Read failures return `false` — an unrelated fs hiccup must not force
|
|
396
|
+
* a restart loop.
|
|
397
|
+
*/
|
|
398
|
+
export async function bridgeDaemonVersionDrifted({ packageJsonPath = PACKAGE_JSON_PATH } = {}) {
|
|
399
|
+
let onDisk;
|
|
400
|
+
try {
|
|
401
|
+
onDisk = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
|
402
|
+
} catch { return false; }
|
|
403
|
+
return typeof onDisk?.version === 'string' && onDisk.version !== packageJson.version;
|
|
404
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mac auto-migration (bh5): a terminal running the old single-slot topology
|
|
3
|
+
* (loopback bridge + `LATTICE_BRIDGE_REGISTRAR_*` ssh registrar) upgrades to
|
|
4
|
+
* hub registration with zero manual commands. The owner's stated acceptance
|
|
5
|
+
* test is literal: an agent who knows nothing about hub/port/flags/migration
|
|
6
|
+
* runs a normal package update, and the bridge finds its own way onto the
|
|
7
|
+
* public page (room 2446, 2461) — no `--hub` flag, no LaunchAgent surgery.
|
|
8
|
+
*
|
|
9
|
+
* The trigger is the registrar call the daemon already makes on every new
|
|
10
|
+
* binding (`bridge-registrar.mjs`'s `registerBridgeUpstream`, used by
|
|
11
|
+
* `bridge-launch-agent.mjs`'s plist today only to keep the reverse-proxy
|
|
12
|
+
* literal current). The v2 registrar script (room 2452) additionally returns
|
|
13
|
+
* `hub_url` in that same response — this module is what turns "a hub_url
|
|
14
|
+
* showed up in a registration reply" into "reconfigure this bridge to use
|
|
15
|
+
* it and retire the ssh tunnel", entirely from information the terminal
|
|
16
|
+
* already had a reason to ask for.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { execFile } from 'node:child_process';
|
|
20
|
+
import { rm } from 'node:fs/promises';
|
|
21
|
+
import { networkInterfaces } from 'node:os';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { promisify } from 'node:util';
|
|
24
|
+
|
|
25
|
+
import { pickBridgeLanAddress } from './bridge-address.mjs';
|
|
26
|
+
import { configureBridge, readBridgeConfig } from './bridge-config.mjs';
|
|
27
|
+
import {
|
|
28
|
+
bridgeRegistrarSettings, deriveBridgeHubUrlFromRegistration, registerBridgeUpstream,
|
|
29
|
+
} from './bridge-registrar.mjs';
|
|
30
|
+
|
|
31
|
+
const execFileAsync = promisify(execFile);
|
|
32
|
+
|
|
33
|
+
/** The ssh reverse-tunnel LaunchAgent from the pre-hub topology
|
|
34
|
+
* (docs/operations/lattice-kitepon-deployment.md) — distinct from
|
|
35
|
+
* `dev.kitepon.lattice.bridge`, which `bridge-launch-agent.mjs` owns and
|
|
36
|
+
* this migration never touches. */
|
|
37
|
+
export const BRIDGE_TUNNEL_LAUNCH_AGENT_LABEL = 'dev.kitepon.lattice.bridge-tunnel';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Attempt one migration step. Called from the daemon's reconcile loop, so it
|
|
41
|
+
* must be cheap to call when there is nothing to do and must never throw for
|
|
42
|
+
* a condition the caller should just keep running through (no registrar
|
|
43
|
+
* configured, already migrated, hub unreachable this cycle) — only a
|
|
44
|
+
* genuinely invalid registrar env (`bridgeRegistrarSettings`'s own
|
|
45
|
+
* half-configured-pair failure) propagates, matching every other registrar
|
|
46
|
+
* caller's behavior.
|
|
47
|
+
*/
|
|
48
|
+
export async function migrateBridgeToHub({
|
|
49
|
+
env = process.env, interfaces = networkInterfaces(), readConfig = readBridgeConfig,
|
|
50
|
+
configure = configureBridge, register = registerBridgeUpstream,
|
|
51
|
+
} = {}) {
|
|
52
|
+
const registrar = bridgeRegistrarSettings(env);
|
|
53
|
+
if (registrar === null) return { migrated: false, reason: 'registrar_not_configured' };
|
|
54
|
+
const current = await readConfig({ env });
|
|
55
|
+
if (current === null || !current.enabled) return { migrated: false, reason: 'bridge_not_enabled' };
|
|
56
|
+
if (current.hub !== null) return { migrated: false, reason: 'already_migrated' };
|
|
57
|
+
|
|
58
|
+
const registration = await register({ port: current.listen.port, env });
|
|
59
|
+
const hubUrl = deriveBridgeHubUrlFromRegistration(registration);
|
|
60
|
+
if (hubUrl === null) return { migrated: false, reason: 'no_hub_url_available', registration };
|
|
61
|
+
|
|
62
|
+
const picked = pickBridgeLanAddress({ interfaces });
|
|
63
|
+
if (picked.address === null) return { migrated: false, reason: 'no_lan_address_available' };
|
|
64
|
+
|
|
65
|
+
const updated = await configure({
|
|
66
|
+
address: picked.address, port: null, reuseCurrentPort: false,
|
|
67
|
+
upstream: current.upstream, hub: { url: hubUrl },
|
|
68
|
+
allowedHosts: current.allowed_hosts.filter((host) => host !== current.listen.address),
|
|
69
|
+
env,
|
|
70
|
+
});
|
|
71
|
+
return { migrated: true, config: updated, hubUrl };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function launchAgentPlistPath(label, env) {
|
|
75
|
+
const home = env.HOME;
|
|
76
|
+
if (typeof home !== 'string' || !path.isAbsolute(home)) return null;
|
|
77
|
+
return path.join(home, 'Library', 'LaunchAgents', `${label}.plist`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Retire the pre-hub ssh reverse-tunnel LaunchAgent, once migration has
|
|
82
|
+
* actually landed a hub URL — never speculatively, so a bridge that never
|
|
83
|
+
* reaches `migrateBridgeToHub`'s success path never touches this agent.
|
|
84
|
+
* Idempotent and non-fatal: a tunnel that is not loaded (already retired, or
|
|
85
|
+
* this deployment never had one) is success, not an error, and any
|
|
86
|
+
* `launchctl` failure here must not crash a daemon whose primary job — hub
|
|
87
|
+
* registration — has already succeeded by the time this runs.
|
|
88
|
+
*/
|
|
89
|
+
export async function retireBridgeTunnelLaunchAgent({
|
|
90
|
+
env = process.env, uid = process.getuid?.(), runner = defaultTunnelLaunchctlRunner,
|
|
91
|
+
label = BRIDGE_TUNNEL_LAUNCH_AGENT_LABEL,
|
|
92
|
+
} = {}) {
|
|
93
|
+
if (!Number.isSafeInteger(uid) || uid < 0) return { retired: false, reason: 'uid_unavailable' };
|
|
94
|
+
const service = `gui/${uid}/${label}`;
|
|
95
|
+
let probe;
|
|
96
|
+
try { probe = await runner(['print', service]); } catch { return { retired: false, reason: 'launchctl_unavailable' }; }
|
|
97
|
+
if (probe.code !== 0) return { retired: false, reason: 'not_loaded' };
|
|
98
|
+
try {
|
|
99
|
+
const bootout = await runner(['bootout', service]);
|
|
100
|
+
if (bootout.code !== 0) return { retired: false, reason: 'bootout_failed' };
|
|
101
|
+
} catch { return { retired: false, reason: 'bootout_failed' }; }
|
|
102
|
+
const plistPath = launchAgentPlistPath(label, env);
|
|
103
|
+
if (plistPath !== null) await rm(plistPath, { force: true }).catch(() => {});
|
|
104
|
+
return { retired: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function defaultTunnelLaunchctlRunner(args) {
|
|
108
|
+
try {
|
|
109
|
+
const result = await execFileAsync('/bin/launchctl', args, { encoding: 'utf8' });
|
|
110
|
+
return { code: 0, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (Number.isInteger(error?.code)) {
|
|
113
|
+
return { code: error.code, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
|
|
114
|
+
}
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
@@ -176,19 +176,30 @@ function respondError(response, status, code, detail = null) {
|
|
|
176
176
|
response.end(`${JSON.stringify(body)}\n`);
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// This must stay visually identical to todo-gantt-live.mjs's dashboardHtml
|
|
180
|
+
// (same shell/brand/card markup, same design tokens) — the public entrance
|
|
181
|
+
// changing its own look when the routing behind it changed from single- to
|
|
182
|
+
// multi-terminal is exactly the regression the owner flagged (room 2474;
|
|
183
|
+
// plan_bridge-hub.md's non-goal "dashboard renderer/gantt UIの変更はしない"
|
|
184
|
+
// covers keeping this landing's appearance, not just the diagrams behind it).
|
|
185
|
+
// Reimplemented locally rather than imported: this codebase's bridge modules
|
|
186
|
+
// each keep their own copy of such patterns rather than cross-importing
|
|
187
|
+
// (see bh2-hub-server.md's rationale for validatedHubHost et al.), and the
|
|
188
|
+
// only genuinely new thing here — an online/offline badge per project — has
|
|
189
|
+
// no home in the single-terminal original to import from anyway.
|
|
179
190
|
function hubIndexHtml(view) {
|
|
180
191
|
const rows = view.map((project) => {
|
|
181
192
|
const href = `/projects/${encodeURIComponent(project.project_id)}/`;
|
|
182
|
-
const
|
|
193
|
+
const online = project.status === 'online';
|
|
194
|
+
const statusLabel = online ? 'オンライン' : 'オフライン';
|
|
195
|
+
const statusClass = online ? 'status-online' : 'status-offline';
|
|
183
196
|
const identity = project.display_name === project.project_id ? '' : `<code>${escapeHtml(project.project_id)}</code>`;
|
|
184
197
|
return `<li><a href="${escapeHtml(href)}"><strong>${escapeHtml(project.display_name)}</strong>`
|
|
185
|
-
+ `${identity}<span>${escapeHtml(statusLabel)}</span
|
|
198
|
+
+ `${identity}<span class="${statusClass}">${escapeHtml(statusLabel)}</span>`
|
|
199
|
+
+ `<span aria-hidden="true">→</span></a></li>`;
|
|
186
200
|
}).join('');
|
|
187
|
-
const content = rows.length === 0 ? '<p
|
|
188
|
-
return `<!doctype html><html lang="ja"><head><meta charset="utf-8"
|
|
189
|
-
+ `<meta name="viewport" content="width=device-width,initial-scale=1">`
|
|
190
|
-
+ `<title>登録済みプロジェクト — Lattice hub</title></head>`
|
|
191
|
-
+ `<body><h1>登録済みプロジェクト</h1>${content}</body></html>`;
|
|
201
|
+
const content = rows.length === 0 ? '<p>登録されている端末はありません。</p>' : `<ul>${rows}</ul>`;
|
|
202
|
+
return `<!doctype html><html lang="ja"><head><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="robots" content="noindex, nofollow"><meta property="og:title" content="公開中の工程表 — Lattice"><meta property="og:description" content="Latticeが管理している公開中の工程と現在地を確認できます。"><meta name="theme-color" content="#f7f3ea"><title>公開中の工程表 — Lattice</title><style>:root{color-scheme:light;--paper:#f7f3ea;--panel:#fffdf8;--ink:#201d19;--soft:#6c655d;--line:#d8d0c5;--cobalt:#315cbe;--orange:#e85f2a;--good:#0ca30c;--critical:#d03b3b}*{box-sizing:border-box}body{min-height:100vh;margin:0;color:var(--ink);background:var(--paper);font:16px/1.7 system-ui,-apple-system,sans-serif}.shell{max-width:880px;margin:0 auto;padding:28px 22px 40px}.brand{display:flex;align-items:center;gap:9px;padding-bottom:24px;border-bottom:1px solid var(--line);font-size:.88rem}.brand a,.footer a{color:var(--ink);font-weight:800;text-decoration:none}.brand a:hover,.footer a:hover{color:var(--cobalt)}.brand span{color:var(--soft)}main{padding:64px 0 72px}.eyebrow{margin:0 0 8px;color:var(--orange);font-size:.76rem;font-weight:800;letter-spacing:.14em}.lead{max-width:620px;margin:0 0 34px;color:var(--soft)}h1{margin:0 0 14px;font-size:clamp(2rem,6vw,3.4rem);line-height:1.12;letter-spacing:-.04em}ul{display:grid;gap:12px;margin:0;padding:0;list-style:none}li a{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:20px;padding:18px 20px;border:1px solid var(--line);border-radius:12px;color:inherit;background:var(--panel);text-decoration:none;box-shadow:0 8px 28px rgba(48,39,27,.04)}li a:hover{border-color:var(--cobalt);transform:translateY(-1px)}li strong{font-size:1.04rem}li code{color:var(--soft);font-size:.78rem}li .status-online{color:var(--good);font-weight:800}li .status-offline{color:var(--critical);font-weight:800}li>a>span[aria-hidden]{color:var(--cobalt);font-weight:800}.note{margin:28px 0 0;padding:16px 18px;border-left:3px solid var(--orange);color:var(--soft);background:rgba(255,253,248,.72);font-size:.88rem}.footer{display:flex;flex-wrap:wrap;justify-content:space-between;gap:16px;padding-top:20px;border-top:1px solid var(--line);color:var(--soft);font-size:.82rem}.footer nav{display:flex;gap:18px}@media(max-width:560px){.shell{padding:20px 16px 32px}main{padding:44px 0 56px}li a{grid-template-columns:minmax(0,1fr) auto;padding:16px}li code{grid-column:1/-1;grid-row:2}.footer{display:block}.footer nav{margin-top:10px}}</style></head><body><div class="shell"><header class="brand"><a href="https://kitepon.dev/">kitepon.dev</a><span aria-hidden="true">/</span><strong>Lattice</strong></header><main><p class="eyebrow">LIVE DEVELOPMENT</p><h1>公開中の工程表</h1><p class="lead">Latticeが管理しているプロジェクトの工程と、いまどこまで進んでいるかを公開データから確認できます。</p>${content}<p class="note">表示内容はLatticeの記録から自動生成されます。製品の紹介や使い方はGitHubをご覧ください。</p></main><footer class="footer"><span>kitepon.dev の開発工程を、Latticeで可視化しています。</span><nav aria-label="関連リンク"><a href="https://kitepon.dev/">kitepon.dev</a><a href="https://github.com/kitepon-rgb/Lattice">GitHub</a></nav></footer></div></body></html>`;
|
|
192
203
|
}
|
|
193
204
|
|
|
194
205
|
function hubProjectStatusHtml(code, projectId, requestPath, message) {
|
|
@@ -512,6 +523,15 @@ export async function startBridgeHubServer({
|
|
|
512
523
|
return;
|
|
513
524
|
}
|
|
514
525
|
const rawPath = requestUrl.split('?', 1)[0];
|
|
526
|
+
if (rawPath === '/') {
|
|
527
|
+
// The old single-terminal bridge served the project index at root; hub only ever
|
|
528
|
+
// routed `/projects/*`, so the public entrance 404'd (room 2488 — a functional
|
|
529
|
+
// regression, not a design one: the front door itself was gone, independent of how
|
|
530
|
+
// it looks). Redirect rather than duplicate handleProjectsIndex's logic at a second path.
|
|
531
|
+
response.writeHead(301, { location: '/projects/', 'cache-control': 'no-store' });
|
|
532
|
+
response.end();
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
515
535
|
if (rawPath === '/__lattice/hub/register') { await handleRegister(incoming, response); return; }
|
|
516
536
|
if (rawPath === '/projects/') { await handleProjectsIndex(incoming, response); return; }
|
|
517
537
|
const match = PROJECT_ROUTE.exec(rawPath);
|
package/src/bridge-registrar.mjs
CHANGED
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
import { execFile } from 'node:child_process';
|
|
23
23
|
|
|
24
|
+
import { normalizeBridgeHubUrl } from './bridge-config.mjs';
|
|
25
|
+
|
|
24
26
|
export const REGISTRAR_RESULT_SCHEMA = 'lattice.bridge_registrar_result.v1';
|
|
25
27
|
|
|
26
28
|
const SSH_HOST = /^[A-Za-z0-9][A-Za-z0-9._-]{0,253}$/u;
|
|
@@ -100,3 +102,19 @@ export async function registerBridgeUpstream({
|
|
|
100
102
|
state: remote.changed === true ? 'updated' : 'unchanged',
|
|
101
103
|
port, host: settings.host, remote, detail: null };
|
|
102
104
|
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Extract a validated hub URL from a `registerBridgeUpstream` result, or
|
|
108
|
+
* `null` if this response carries none (old `lattice.bridge_registration.v1`
|
|
109
|
+
* script, a failed/not_configured registration, or a malformed value).
|
|
110
|
+
*
|
|
111
|
+
* Never throws: the caller is bh5's auto-migration path, which must fail
|
|
112
|
+
* safe into the legacy (no-hub) configuration rather than crash the daemon
|
|
113
|
+
* over a malformed hint from a remote script it does not control the
|
|
114
|
+
* deployment of.
|
|
115
|
+
*/
|
|
116
|
+
export function deriveBridgeHubUrlFromRegistration(result) {
|
|
117
|
+
const hubUrl = result?.remote?.hub_url;
|
|
118
|
+
if (typeof hubUrl !== 'string' || hubUrl.length === 0) return null;
|
|
119
|
+
try { return normalizeBridgeHubUrl({ url: hubUrl }).url; } catch { return null; }
|
|
120
|
+
}
|
package/src/bridge-server.mjs
CHANGED
|
@@ -45,7 +45,10 @@ async function readDashboardDescriptor(ref) {
|
|
|
45
45
|
let handle;
|
|
46
46
|
try {
|
|
47
47
|
before = await lstat(ref);
|
|
48
|
-
|
|
48
|
+
// Windows has no POSIX permission-bit model — see bridge-config.mjs's
|
|
49
|
+
// readDocument for the same guard and the real-host verification.
|
|
50
|
+
if (!before.isFile() || before.isSymbolicLink()
|
|
51
|
+
|| (process.platform !== 'win32' && (before.mode & 0o777) !== 0o600) || before.size > 65_536) {
|
|
49
52
|
throw new BridgeConfigError('BRIDGE_UPSTREAM_INVALID', 'dashboard descriptor is unsafe');
|
|
50
53
|
}
|
|
51
54
|
handle = await open(ref, fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0));
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Windows bridge persistence via the per-user Startup folder — the Windows
|
|
3
|
+
* counterpart to `bridge-launch-agent.mjs`'s macOS LaunchAgent, with the same
|
|
4
|
+
* public contract (`snapshot`/`install`/`disable`/`restore`) so `bridge-cli.mjs`
|
|
5
|
+
* can select between them by `process.platform` without changing its own flow.
|
|
6
|
+
*
|
|
7
|
+
* Windows has no per-user analogue of launchd's KeepAlive supervision, and
|
|
8
|
+
* Task Scheduler's `ONLOGON` trigger requires elevation this product's "no
|
|
9
|
+
* ritual beyond one-time setup" bar cannot spend (verified empirically: both
|
|
10
|
+
* `schtasks /Create /SC ONLOGON` and `Register-ScheduledTask -Trigger
|
|
11
|
+
* (New-ScheduledTaskTrigger -AtLogOn)` return access-denied under a normal,
|
|
12
|
+
* non-elevated user token). The Startup folder needs no elevation — writing
|
|
13
|
+
* into `%APPDATA%\...\Startup` is an ordinary per-user file operation — but it
|
|
14
|
+
* only *starts* something at logon; nothing supervises it afterward.
|
|
15
|
+
*
|
|
16
|
+
* `lattice-bridge-supervisor.mjs` supplies that supervision (spawn, wait for
|
|
17
|
+
* exit, restart) in plain JS rather than a batch GOTO loop: a loop's own
|
|
18
|
+
* process is awkward to track and kill reliably on Windows, while a Node
|
|
19
|
+
* process's pid is not. The Startup-folder `.vbs` launcher runs the
|
|
20
|
+
* supervisor hidden (`WindowStyle 0`) so no console window appears at logon
|
|
21
|
+
* or at any crash-restart — the same class of bug the Windows-console-
|
|
22
|
+
* avalanche P0 hotfix (`windowsHide`, 0.52.4) exists to avoid, here via a
|
|
23
|
+
* different mechanism since this process tree is spawned by Windows logon
|
|
24
|
+
* rather than by this codebase's own `child_process.spawn`. Stopping the
|
|
25
|
+
* whole tree (supervisor + whatever bridge child it currently owns) uses
|
|
26
|
+
* `taskkill /T /F /PID <supervisor pid>` — Windows's own recursive-kill,
|
|
27
|
+
* since a forcibly-terminated supervisor gets no chance to clean up its own
|
|
28
|
+
* child.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { execFile } from 'node:child_process';
|
|
32
|
+
import { randomBytes } from 'node:crypto';
|
|
33
|
+
import { constants as fsConstants } from 'node:fs';
|
|
34
|
+
import {
|
|
35
|
+
lstat, mkdir, open, readFile, realpath, rename, rm, writeFile,
|
|
36
|
+
} from 'node:fs/promises';
|
|
37
|
+
import path from 'node:path';
|
|
38
|
+
import { promisify } from 'node:util';
|
|
39
|
+
|
|
40
|
+
import { BridgeConfigError, readBridgeConfig } from './bridge-config.mjs';
|
|
41
|
+
import { readBridgeDaemonDescriptor } from './bridge-daemon.mjs';
|
|
42
|
+
import { bridgeRegistrarSettings } from './bridge-registrar.mjs';
|
|
43
|
+
|
|
44
|
+
export const BRIDGE_STARTUP_LABEL = 'LatticeBridge';
|
|
45
|
+
const DESCRIPTOR_SCHEMA = 'lattice.bridge_supervisor_descriptor.v1';
|
|
46
|
+
const START_TIMEOUT_MS = 5_000;
|
|
47
|
+
const STOP_TIMEOUT_MS = 3_000;
|
|
48
|
+
const execFileAsync = promisify(execFile);
|
|
49
|
+
|
|
50
|
+
function fail(code, message, cause = undefined) {
|
|
51
|
+
return new BridgeConfigError(code, message, undefined, cause);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function bridgeStartupFolderPaths(env = process.env) {
|
|
55
|
+
const appData = env.APPDATA;
|
|
56
|
+
const localAppData = env.LOCALAPPDATA;
|
|
57
|
+
if (typeof appData !== 'string' || !path.isAbsolute(appData)) {
|
|
58
|
+
throw fail('BRIDGE_STARTUP_FOLDER_APPDATA_INVALID', 'APPDATA must be an absolute path');
|
|
59
|
+
}
|
|
60
|
+
if (typeof localAppData !== 'string' || !path.isAbsolute(localAppData)) {
|
|
61
|
+
throw fail('BRIDGE_STARTUP_FOLDER_APPDATA_INVALID', 'LOCALAPPDATA must be an absolute path');
|
|
62
|
+
}
|
|
63
|
+
// The launcher must live in the Startup folder — Windows only runs what it
|
|
64
|
+
// finds there. Everything it launches lives in our own runtime directory
|
|
65
|
+
// instead: Startup-folder contents are conventionally opaque shortcuts, and
|
|
66
|
+
// keeping the real state (descriptor, pidfile) in a folder this module
|
|
67
|
+
// fully owns keeps the safety checks below meaningful.
|
|
68
|
+
const startupDirectory = path.join(appData, 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
|
|
69
|
+
const runtimeDirectory = path.join(localAppData, 'Lattice', 'bridge-startup');
|
|
70
|
+
return Object.freeze({
|
|
71
|
+
startupDirectory, runtimeDirectory,
|
|
72
|
+
launcher: path.join(startupDirectory, `${BRIDGE_STARTUP_LABEL}.vbs`),
|
|
73
|
+
descriptor: path.join(runtimeDirectory, 'descriptor.json'),
|
|
74
|
+
pidfile: path.join(runtimeDirectory, 'supervisor.pid'),
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function prepareDirectory(directory) {
|
|
79
|
+
await mkdir(directory, { recursive: true });
|
|
80
|
+
const stats = await lstat(directory);
|
|
81
|
+
if (!stats.isDirectory() || stats.isSymbolicLink()) {
|
|
82
|
+
throw fail('BRIDGE_STARTUP_FOLDER_DIR_UNSAFE', 'startup folder path is unsafe');
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function strictFile(ref, maxBytes = 65_536) {
|
|
87
|
+
let before;
|
|
88
|
+
let handle;
|
|
89
|
+
try {
|
|
90
|
+
before = await lstat(ref);
|
|
91
|
+
if (!before.isFile() || before.isSymbolicLink() || before.size > maxBytes) {
|
|
92
|
+
throw new Error('unsafe startup file');
|
|
93
|
+
}
|
|
94
|
+
handle = await open(ref, fsConstants.O_RDONLY);
|
|
95
|
+
const opened = await handle.stat();
|
|
96
|
+
if (!opened.isFile() || opened.dev !== before.dev || opened.ino !== before.ino
|
|
97
|
+
|| opened.size !== before.size) {
|
|
98
|
+
throw new Error('startup file changed during validation');
|
|
99
|
+
}
|
|
100
|
+
const content = await handle.readFile('utf8');
|
|
101
|
+
const after = await lstat(ref);
|
|
102
|
+
if (after.dev !== opened.dev || after.ino !== opened.ino || after.size !== opened.size) {
|
|
103
|
+
throw new Error('startup file changed during read');
|
|
104
|
+
}
|
|
105
|
+
return content;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (error?.code === 'ENOENT' && before === undefined) return null;
|
|
108
|
+
throw fail('BRIDGE_STARTUP_FOLDER_FILE_UNSAFE', 'bridge startup file is unsafe', error);
|
|
109
|
+
} finally {
|
|
110
|
+
await handle?.close();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function atomicFile(ref, content) {
|
|
115
|
+
const temporary = `${ref}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
|
|
116
|
+
try {
|
|
117
|
+
await writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' });
|
|
118
|
+
await rename(temporary, ref);
|
|
119
|
+
} finally {
|
|
120
|
+
await rm(temporary, { force: true });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function executablePath(ref, label) {
|
|
125
|
+
if (typeof ref !== 'string' || !path.isAbsolute(ref)) {
|
|
126
|
+
throw fail('BRIDGE_STARTUP_FOLDER_EXECUTABLE_INVALID', `${label} path must be absolute`);
|
|
127
|
+
}
|
|
128
|
+
let resolved;
|
|
129
|
+
let stats;
|
|
130
|
+
try {
|
|
131
|
+
resolved = await realpath(ref);
|
|
132
|
+
stats = await lstat(resolved);
|
|
133
|
+
} catch (error) {
|
|
134
|
+
throw fail('BRIDGE_STARTUP_FOLDER_EXECUTABLE_INVALID', `${label} is unavailable`, error);
|
|
135
|
+
}
|
|
136
|
+
if (!stats.isFile() || stats.isSymbolicLink()) {
|
|
137
|
+
throw fail('BRIDGE_STARTUP_FOLDER_EXECUTABLE_INVALID', `${label} is unsafe`);
|
|
138
|
+
}
|
|
139
|
+
return resolved;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** VBS escapes an embedded `"` by doubling it. Paths we generate never
|
|
143
|
+
* contain one (`executablePath`/our own runtime dir), so this only guards
|
|
144
|
+
* against that invariant silently breaking rather than mis-escaping. */
|
|
145
|
+
function vbsQuoted(label, value) {
|
|
146
|
+
if (typeof value !== 'string' || value.length === 0 || value.includes('"')) {
|
|
147
|
+
throw fail('BRIDGE_STARTUP_FOLDER_VALUE_UNSAFE', `${label} is unsafe to embed in the startup launcher`);
|
|
148
|
+
}
|
|
149
|
+
return `"""${value}"""`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function launcherScript({ nodePath, supervisorPath, descriptorPath }) {
|
|
153
|
+
// WScript.Shell.Run(command, windowStyle, waitOnReturn). windowStyle 0 =
|
|
154
|
+
// hidden, waitOnReturn False = fire-and-forget (the supervisor outlives
|
|
155
|
+
// wscript.exe, which exits right after this call).
|
|
156
|
+
const command = [vbsQuoted('node executable', nodePath), vbsQuoted('supervisor script', supervisorPath),
|
|
157
|
+
vbsQuoted('descriptor path', descriptorPath)].join(' & " " & ');
|
|
158
|
+
return `Set shell = CreateObject("WScript.Shell")\r\nshell.Run ${command}, 0, False\r\n`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function supervisorDescriptor({ bridgePath, pidfile, instanceToken, env }) {
|
|
162
|
+
const forwarded = { LATTICE_BRIDGE_INSTANCE_TOKEN: instanceToken };
|
|
163
|
+
if (env.LATTICE_CONFIG_DIR !== undefined) {
|
|
164
|
+
if (typeof env.LATTICE_CONFIG_DIR !== 'string' || !path.isAbsolute(env.LATTICE_CONFIG_DIR)) {
|
|
165
|
+
throw fail('BRIDGE_CONFIG_DIR_INVALID', 'LATTICE_CONFIG_DIR must be absolute');
|
|
166
|
+
}
|
|
167
|
+
forwarded.LATTICE_CONFIG_DIR = env.LATTICE_CONFIG_DIR;
|
|
168
|
+
}
|
|
169
|
+
// Same rationale as the LaunchAgent plist: nothing here inherits the
|
|
170
|
+
// installer's shell environment at restart time, so registrar settings must
|
|
171
|
+
// be baked in or self-registration silently never fires after a crash restart.
|
|
172
|
+
const registrar = bridgeRegistrarSettings(env);
|
|
173
|
+
if (registrar !== null) {
|
|
174
|
+
forwarded.LATTICE_BRIDGE_REGISTRAR_SSH_HOST = registrar.host;
|
|
175
|
+
forwarded.LATTICE_BRIDGE_REGISTRAR_SCRIPT = registrar.script;
|
|
176
|
+
}
|
|
177
|
+
return JSON.stringify({ schema: DESCRIPTOR_SCHEMA, bridgePath, pidPath: pidfile, env: forwarded });
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export async function defaultStartupRunner(args) {
|
|
181
|
+
try {
|
|
182
|
+
const result = await execFileAsync(args[0], args.slice(1), { encoding: 'utf8', windowsHide: true });
|
|
183
|
+
return { code: 0, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (Number.isInteger(error?.code)) {
|
|
186
|
+
return { code: error.code, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
|
|
187
|
+
}
|
|
188
|
+
throw fail('BRIDGE_STARTUP_LAUNCHER_UNAVAILABLE', 'the startup launcher could not be executed', error);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function healthHost(address) {
|
|
193
|
+
if (address === '0.0.0.0') return '127.0.0.1';
|
|
194
|
+
if (address === '::') return '[::1]';
|
|
195
|
+
return address.includes(':') ? `[${address}]` : address;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function defaultWaitReady({ config, instanceToken, env, timeoutMs = START_TIMEOUT_MS }) {
|
|
199
|
+
const deadline = Date.now() + timeoutMs;
|
|
200
|
+
while (Date.now() < deadline) {
|
|
201
|
+
const descriptor = await readBridgeDaemonDescriptor({ env });
|
|
202
|
+
if (descriptor?.address === config.listen.address && descriptor?.port === config.listen.port
|
|
203
|
+
&& descriptor?.config_updated_at === config.updated_at) {
|
|
204
|
+
try {
|
|
205
|
+
const response = await fetch(
|
|
206
|
+
`http://${healthHost(descriptor.address)}:${descriptor.port}/__lattice/bridge-health`, {
|
|
207
|
+
headers: { 'x-lattice-bridge-instance-token': instanceToken },
|
|
208
|
+
signal: AbortSignal.timeout(400),
|
|
209
|
+
});
|
|
210
|
+
const body = response.status === 200 ? await response.json() : null;
|
|
211
|
+
if (body?.schema === 'lattice.bridge_health.v1' && body.pid === descriptor.pid
|
|
212
|
+
&& body.updated_at === config.updated_at) return descriptor;
|
|
213
|
+
} catch {}
|
|
214
|
+
}
|
|
215
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
216
|
+
}
|
|
217
|
+
throw fail('BRIDGE_STARTUP_FOLDER_START_FAILED', 'bridge startup process did not become healthy');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function defaultWaitStopped({ listen, timeoutMs = STOP_TIMEOUT_MS }) {
|
|
221
|
+
if (listen === null) return;
|
|
222
|
+
const deadline = Date.now() + timeoutMs;
|
|
223
|
+
while (Date.now() < deadline) {
|
|
224
|
+
try {
|
|
225
|
+
await fetch(`http://${healthHost(listen.address)}:${listen.port}/__lattice/bridge-health`,
|
|
226
|
+
{ signal: AbortSignal.timeout(300) });
|
|
227
|
+
} catch { return; }
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
229
|
+
}
|
|
230
|
+
throw fail('BRIDGE_STARTUP_FOLDER_STOP_FAILED', 'bridge startup process socket did not stop');
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
export async function snapshotBridgeStartupFolder({ env = process.env } = {}) {
|
|
234
|
+
const refs = bridgeStartupFolderPaths(env);
|
|
235
|
+
await prepareDirectory(refs.startupDirectory);
|
|
236
|
+
await prepareDirectory(refs.runtimeDirectory);
|
|
237
|
+
const launcherContent = await strictFile(refs.launcher);
|
|
238
|
+
const descriptorContent = await strictFile(refs.descriptor);
|
|
239
|
+
if ((launcherContent === null) !== (descriptorContent === null)) {
|
|
240
|
+
throw fail('BRIDGE_STARTUP_FOLDER_STATE_INVALID', 'startup launcher and descriptor disagree on installed state');
|
|
241
|
+
}
|
|
242
|
+
return Object.freeze({ installed: launcherContent !== null, launcherContent, descriptorContent });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** Read the supervisor's own recorded pid and kill its whole process tree
|
|
246
|
+
* (`taskkill /T /F`) — a forcibly-terminated supervisor cannot clean up its
|
|
247
|
+
* child itself, so the tree kill is what actually stops the bridge, not the
|
|
248
|
+
* SIGTERM handler `lattice-bridge.mjs` relies on when Node manages it directly. */
|
|
249
|
+
async function stopRunning({ env, listen, runner, waitStopped }) {
|
|
250
|
+
const refs = bridgeStartupFolderPaths(env);
|
|
251
|
+
let pidText;
|
|
252
|
+
try { pidText = await readFile(refs.pidfile, 'utf8'); } catch (error) {
|
|
253
|
+
if (error?.code === 'ENOENT') return;
|
|
254
|
+
throw fail('BRIDGE_STARTUP_FOLDER_STOP_FAILED', 'could not read supervisor pidfile', error);
|
|
255
|
+
}
|
|
256
|
+
const pid = Number(pidText.trim());
|
|
257
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return;
|
|
258
|
+
const result = await runner(['taskkill.exe', '/T', '/F', '/PID', String(pid)]);
|
|
259
|
+
// taskkill exits non-zero (128) when the target is already gone — not a failure to report.
|
|
260
|
+
if (result.code !== 0 && !/not found|not running/iu.test(result.stderr ?? '')) {
|
|
261
|
+
throw fail('BRIDGE_STARTUP_FOLDER_STOP_FAILED', 'could not stop bridge supervisor process tree');
|
|
262
|
+
}
|
|
263
|
+
await waitStopped({ listen, env });
|
|
264
|
+
await rm(refs.pidfile, { force: true });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function installBridgeStartupFolder({ config, env = process.env,
|
|
268
|
+
runner = defaultStartupRunner, nodePath = process.execPath,
|
|
269
|
+
bridgePath = path.resolve(import.meta.dirname, '../bin/lattice-bridge.mjs'),
|
|
270
|
+
supervisorPath = path.resolve(import.meta.dirname, '../bin/lattice-bridge-supervisor.mjs'),
|
|
271
|
+
waitReady = defaultWaitReady, waitStopped = defaultWaitStopped,
|
|
272
|
+
previousListen = null } = {}) {
|
|
273
|
+
if (config?.enabled !== true) throw fail('BRIDGE_DISABLED', 'bridge is disabled');
|
|
274
|
+
const refs = bridgeStartupFolderPaths(env);
|
|
275
|
+
await prepareDirectory(refs.startupDirectory);
|
|
276
|
+
await prepareDirectory(refs.runtimeDirectory);
|
|
277
|
+
await strictFile(refs.launcher);
|
|
278
|
+
await strictFile(refs.descriptor);
|
|
279
|
+
const resolvedNode = await executablePath(nodePath, 'node executable');
|
|
280
|
+
const resolvedBridge = await executablePath(bridgePath, 'bridge executable');
|
|
281
|
+
const resolvedSupervisor = await executablePath(supervisorPath, 'supervisor executable');
|
|
282
|
+
const instanceToken = randomBytes(32).toString('hex');
|
|
283
|
+
const descriptorContent = supervisorDescriptor({
|
|
284
|
+
bridgePath: resolvedBridge, pidfile: refs.pidfile, instanceToken, env,
|
|
285
|
+
});
|
|
286
|
+
await stopRunning({ env, listen: previousListen, runner, waitStopped });
|
|
287
|
+
await atomicFile(refs.descriptor, descriptorContent);
|
|
288
|
+
await atomicFile(refs.launcher,
|
|
289
|
+
launcherScript({ nodePath: resolvedNode, supervisorPath: resolvedSupervisor, descriptorPath: refs.descriptor }));
|
|
290
|
+
await launch(runner, ['wscript.exe', refs.launcher], 'BRIDGE_STARTUP_LAUNCHER_FAILED',
|
|
291
|
+
'could not start the bridge startup process');
|
|
292
|
+
return waitReady({ config, instanceToken, env });
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function launch(runner, args, code, message) {
|
|
296
|
+
let result;
|
|
297
|
+
try { result = await runner(args); } catch (error) {
|
|
298
|
+
if (error instanceof BridgeConfigError) throw error;
|
|
299
|
+
throw fail(code, message, error);
|
|
300
|
+
}
|
|
301
|
+
if (!result || result.code !== 0) throw fail(code, message);
|
|
302
|
+
return result;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
export async function disableBridgeStartupFolder({ snapshot, listen, env = process.env,
|
|
306
|
+
runner = defaultStartupRunner, waitStopped = defaultWaitStopped } = {}) {
|
|
307
|
+
if (!snapshot || typeof snapshot.installed !== 'boolean') {
|
|
308
|
+
throw new TypeError('bridge startup folder snapshot required');
|
|
309
|
+
}
|
|
310
|
+
const refs = bridgeStartupFolderPaths(env);
|
|
311
|
+
const stopped = snapshot.installed;
|
|
312
|
+
if (stopped) await stopRunning({ env, listen, runner, waitStopped });
|
|
313
|
+
await rm(refs.launcher, { force: true });
|
|
314
|
+
await rm(refs.descriptor, { force: true });
|
|
315
|
+
return { removed: snapshot.installed, stopped };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export async function restoreBridgeStartupFolder({ snapshot, listen = null, env = process.env,
|
|
319
|
+
runner = defaultStartupRunner, waitStopped = defaultWaitStopped,
|
|
320
|
+
config = undefined, waitReady = defaultWaitReady } = {}) {
|
|
321
|
+
if (!snapshot || typeof snapshot.installed !== 'boolean'
|
|
322
|
+
|| (snapshot.installed
|
|
323
|
+
&& (typeof snapshot.launcherContent !== 'string' || typeof snapshot.descriptorContent !== 'string'))) {
|
|
324
|
+
throw new TypeError('bridge startup folder snapshot required');
|
|
325
|
+
}
|
|
326
|
+
const refs = bridgeStartupFolderPaths(env);
|
|
327
|
+
await prepareDirectory(refs.startupDirectory);
|
|
328
|
+
await prepareDirectory(refs.runtimeDirectory);
|
|
329
|
+
await stopRunning({ env, listen, runner, waitStopped });
|
|
330
|
+
if (snapshot.installed) {
|
|
331
|
+
await atomicFile(refs.descriptor, snapshot.descriptorContent);
|
|
332
|
+
await atomicFile(refs.launcher, snapshot.launcherContent);
|
|
333
|
+
await launch(runner, ['wscript.exe', refs.launcher], 'BRIDGE_STARTUP_ROLLBACK_FAILED',
|
|
334
|
+
'could not restore the bridge startup process');
|
|
335
|
+
const restoredConfig = config ?? await readBridgeConfig({ env });
|
|
336
|
+
let tokenMatch = null;
|
|
337
|
+
try { tokenMatch = JSON.parse(snapshot.descriptorContent).env?.LATTICE_BRIDGE_INSTANCE_TOKEN ?? null; }
|
|
338
|
+
catch { tokenMatch = null; }
|
|
339
|
+
if (restoredConfig?.enabled !== true || typeof tokenMatch !== 'string' || !/^[0-9a-f]{64}$/u.test(tokenMatch)) {
|
|
340
|
+
throw fail('BRIDGE_STARTUP_ROLLBACK_FAILED', 'restored bridge startup process is not attestable');
|
|
341
|
+
}
|
|
342
|
+
await waitReady({ config: restoredConfig, instanceToken: tokenMatch, env });
|
|
343
|
+
} else {
|
|
344
|
+
await rm(refs.launcher, { force: true });
|
|
345
|
+
await rm(refs.descriptor, { force: true });
|
|
346
|
+
}
|
|
347
|
+
return snapshot;
|
|
348
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A content-addressed cache in front of `readTodoStoreStable`, for long-running
|
|
3
|
+
* processes (the dashboard daemon) that re-render on every request/poll and would
|
|
4
|
+
* otherwise re-validate the whole merged store every time.
|
|
5
|
+
*
|
|
6
|
+
* An earlier version keyed the cache on a stat()-derived fingerprint (dev/ino/size/
|
|
7
|
+
* mtimeMs/ctimeMs). On filesystems with coarse mtime granularity (observed on
|
|
8
|
+
* WSL/DrvFs), two different manifest contents written close together can land on the
|
|
9
|
+
* same fingerprint, so the cache would serve a stale store as current — and since the
|
|
10
|
+
* mismatch never surfaces as an error, nothing invalidates it until the process
|
|
11
|
+
* restarts. Hashing the manifest's actual bytes costs one small file read and removes
|
|
12
|
+
* that failure mode entirely.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { readFile } from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
import { readTodoStoreStable } from './todo-store.mjs';
|
|
20
|
+
|
|
21
|
+
async function manifestContentDigest(manifestRef) {
|
|
22
|
+
return createHash('sha256').update(await readFile(manifestRef)).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {object} [options]
|
|
27
|
+
* @param {(options: object) => Promise<object>} [options.readStable] injection point for tests
|
|
28
|
+
*/
|
|
29
|
+
export function createTodoStoreCache({ readStable = readTodoStoreStable } = {}) {
|
|
30
|
+
const cache = new Map();
|
|
31
|
+
return {
|
|
32
|
+
async read(repoRoot) {
|
|
33
|
+
const manifestRef = path.join(repoRoot, '.lattice', 'todo', 'manifest.json');
|
|
34
|
+
const digest = await manifestContentDigest(manifestRef);
|
|
35
|
+
const cached = cache.get(repoRoot);
|
|
36
|
+
if (cached?.digest === digest) return cached.store;
|
|
37
|
+
// Read before caching: a failure here (including the store's own inconsistency
|
|
38
|
+
// detection) must not populate the cache, so the very next call re-reads instead
|
|
39
|
+
// of serving a poisoned entry.
|
|
40
|
+
const store = await readStable({ repoRoot });
|
|
41
|
+
cache.set(repoRoot, { digest, store });
|
|
42
|
+
return store;
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
package/src/todo-store.mjs
CHANGED
|
@@ -1392,6 +1392,18 @@ export async function readTodoStoreStable(options = {}) {
|
|
|
1392
1392
|
if (!Number.isSafeInteger(maximumAttempts) || maximumAttempts < 1 || maximumAttempts > 16) {
|
|
1393
1393
|
throw new TypeError('maximumAttempts must be 1..16');
|
|
1394
1394
|
}
|
|
1395
|
+
// `manifest_journal_head_mismatch`/`manifest_plan_binding_mismatch` are treated as a
|
|
1396
|
+
// transient in-flight write and retried. That is correct while a concurrent writer is
|
|
1397
|
+
// mid-commit, but a crashed writer can leave the SAME mismatch permanently — retrying
|
|
1398
|
+
// forever against a manifest that never changes just burns attempts and then reports
|
|
1399
|
+
// a content-free STORE_BUSY, hiding the real STORE_INCONSISTENT reason the caller needs
|
|
1400
|
+
// to actually recover (2026-08-10 P0: a crashed `todo start` left exactly this behind).
|
|
1401
|
+
// Track the manifest digest seen at the START of the previous attempt: if it is
|
|
1402
|
+
// unchanged going into this attempt too, no writer completed anything in between, so
|
|
1403
|
+
// the "transient" classification no longer has evidence behind it — surface the real
|
|
1404
|
+
// error instead of exhausting the budget on a window that was never closing.
|
|
1405
|
+
let previousAttemptManifestDigest = null;
|
|
1406
|
+
let lastError = null;
|
|
1395
1407
|
for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
|
|
1396
1408
|
const before = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
1397
1409
|
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
@@ -1411,12 +1423,21 @@ export async function readTodoStoreStable(options = {}) {
|
|
|
1411
1423
|
const transientWriteWindow = error.code === 'STORE_INCONSISTENT'
|
|
1412
1424
|
&& ['manifest_journal_head_mismatch', 'manifest_plan_binding_mismatch']
|
|
1413
1425
|
.includes(error.detail.reason);
|
|
1414
|
-
|
|
1426
|
+
const stableAcrossAttempts = previousAttemptManifestDigest === before.manifest_digest;
|
|
1427
|
+
if (before.manifest_digest === after.manifest_digest
|
|
1428
|
+
&& (!transientWriteWindow || stableAcrossAttempts)) throw error;
|
|
1429
|
+
lastError = error;
|
|
1415
1430
|
}
|
|
1431
|
+
previousAttemptManifestDigest = before.manifest_digest;
|
|
1416
1432
|
if (attempt < maximumAttempts) {
|
|
1417
1433
|
await new Promise((resolve) => setTimeout(resolve, Math.min(16, 2 ** attempt)));
|
|
1418
1434
|
}
|
|
1419
1435
|
}
|
|
1436
|
+
// Exhausted without ever observing a genuinely closing write. Surface the last typed
|
|
1437
|
+
// STORE_INCONSISTENT reason rather than a bare STORE_BUSY — the caller (and a human
|
|
1438
|
+
// reading the error) needs to know which store artifact actually disagrees, not just
|
|
1439
|
+
// that reads kept failing.
|
|
1440
|
+
if (lastError !== null) throw lastError;
|
|
1420
1441
|
fail('STORE_BUSY', 'stable_read_exhausted', { attempts: maximumAttempts });
|
|
1421
1442
|
}
|
|
1422
1443
|
|