@quolu/lattice 0.53.1 → 0.55.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.mjs +59 -2
- package/bin/lattice-dashboard.mjs +7 -25
- package/docs/bridge-setup.md +80 -0
- package/package.json +1 -1
- package/src/bridge-address.mjs +19 -0
- package/src/bridge-cli.mjs +116 -16
- package/src/bridge-daemon.mjs +51 -1
- package/src/bridge-executable.mjs +100 -0
- package/src/bridge-hub-migration.mjs +118 -0
- package/src/bridge-hub-server.mjs +9 -0
- package/src/bridge-launch-agent.mjs +44 -4
- package/src/bridge-server.mjs +10 -1
- package/src/bridge-startup-folder.mjs +39 -5
- package/src/runtime-work-order-controller.mjs +9 -2
- package/src/todo-cli.mjs +38 -11
- package/src/todo-store-cache.mjs +45 -0
- package/src/todo-store.mjs +22 -1
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/docs/bridge-setup.md
CHANGED
|
@@ -33,6 +33,86 @@ fileを除去し、JSON結果の`recovery`へ処置を明示する。その後
|
|
|
33
33
|
自動化・隔離testではabsoluteな`LATTICE_CONFIG_DIR`で設定rootを変更できる。無効な設定、低いport、
|
|
34
34
|
使用中の明示port、危険なrequest target、到達不能upstreamはsilent fallbackせずtyped errorを返す。
|
|
35
35
|
|
|
36
|
+
## 常駐が黙って死んでいないか確かめる
|
|
37
|
+
|
|
38
|
+
`reachable`は「設定したaddressで誰かが応答しているか」しか答えない。常駐設定(macOSのLaunchAgent、
|
|
39
|
+
WindowsのStartup launcher)が消えたbinaryを指していると、supervisorは起動できないprocessを回し続け、
|
|
40
|
+
どこにもエラーが出ないまま公開面から端末だけが消える。`status`はそれを1回で名指しする。
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
lattice bridge status --json
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
bridgeが無効な間は以下すべてnullで、bridgeを有効にしている時だけ観測する。
|
|
47
|
+
|
|
48
|
+
**`persistence`** — 常駐設定が実際に起動する対象。
|
|
49
|
+
|
|
50
|
+
| field | 意味 |
|
|
51
|
+
| --- | --- |
|
|
52
|
+
| `state` | `installed`/`not_installed`/`unreadable` |
|
|
53
|
+
| `loaded` | launchdへ読み込み済みか。Windowsには対応概念が無いのでnull |
|
|
54
|
+
| `node_path`・`node_exists` | 起動するNode実行体と、それが今も存在するか |
|
|
55
|
+
| `bridge_path`・`bridge_exists` | 起動するbridge scriptと、それが今も存在するか |
|
|
56
|
+
| `error` | `unreadable`のときだけtyped code(例`BRIDGE_LAUNCH_AGENT_PLIST_UNSAFE`) |
|
|
57
|
+
|
|
58
|
+
**`runtime`** — いま応答しているprocess自身の申告。`state`は`running`/`not_running`/`unattested`/
|
|
59
|
+
`descriptor_invalid`で、`running`以外では各値がnullになる。`running`でも、identityを返さない
|
|
60
|
+
0.55.0より前のdaemonが走っている間は`version`以下がnullになる(この場合`runtime_drift`は空になり、
|
|
61
|
+
乖離の有無は判定できていない——「乖離なし」ではない)。
|
|
62
|
+
|
|
63
|
+
| field | 意味 |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `pid` | 応答しているprocessのpid |
|
|
66
|
+
| `version` | そのprocessが読み込んでいるLatticeの版 |
|
|
67
|
+
| `node_path`・`node_version` | そのprocessを実行しているNodeの実体pathと版 |
|
|
68
|
+
| `bridge_path` | そのprocessが実行しているbridge script |
|
|
69
|
+
|
|
70
|
+
**`runtime_drift`** — 両者の食い違い。空配列は「差が無い」または「`runtime`が名乗っていないので
|
|
71
|
+
判定できない」のどちらかである。
|
|
72
|
+
|
|
73
|
+
| 値 | 意味 |
|
|
74
|
+
| --- | --- |
|
|
75
|
+
| `bridge_path` | 常駐設定と違うtreeのcodeが走っている(開発treeの残骸など) |
|
|
76
|
+
| `node_path` | 常駐設定が指すnodeと実走nodeが別実体。焼くのは意図的にaliasなので、比較はrealpathで行う |
|
|
77
|
+
| `version` | npm更新後まだ旧moduleを保持している |
|
|
78
|
+
|
|
79
|
+
**`remedy`** — 自己解消しない状態にだけ、打つべきコマンドが入る。出るのは次の4つ。
|
|
80
|
+
|
|
81
|
+
- `persistence.node_exists`または`bridge_exists`がfalse(起動対象が消えた)
|
|
82
|
+
- `persistence.state`が`not_installed`(bridgeは有効なのに常駐設定が無い。いま走っているdaemonが
|
|
83
|
+
最後の1つで、再起動しても戻らない)
|
|
84
|
+
- `persistence.state`が`unreadable`(常駐設定を読めない)
|
|
85
|
+
- `runtime_drift`に`node_path`または`bridge_path`がある
|
|
86
|
+
|
|
87
|
+
`version`だけの差には`remedy`を出さない。daemonは60秒ごとにon-diskのpackage.jsonと自分の版を
|
|
88
|
+
突き合わせ、差があれば自ら終了してsupervisorに新codeで起動し直させる。放っておいて最大1分ほどで
|
|
89
|
+
解消するので、コマンドを出す状態ではない。自己解消する差にコマンドを出すと、本物の障害が埋もれる。
|
|
90
|
+
|
|
91
|
+
`remedy`が出たら`reconfigure`で作り直す。plistやlauncherを手で書き換えない。
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
lattice bridge reconfigure --json
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 焼き込むnode pathの選び方(ADR 0163)
|
|
98
|
+
|
|
99
|
+
常駐設定へ焼くnodeのpathは、版付きの実体(homebrewの`Cellar/node/<version>/bin/node`、nvm-windowsの
|
|
100
|
+
版ディレクトリ)ではなく、**同じbinaryを指すとrealpathで検証できた安定alias**(`/opt/homebrew/bin/node`、
|
|
101
|
+
`C:\Program Files\nodejs\node.exe`)を選ぶ。`brew upgrade node`が旧versionのディレクトリごと消しても
|
|
102
|
+
起動対象が残るようにするためである。
|
|
103
|
+
|
|
104
|
+
安定aliasを検証できない環境(shim方式のasdf/volta等。shimは自身のlauncherへ解決されるので実体と
|
|
105
|
+
一致しない)では、版付きpathのまま焼く。検証していないpathを推測で焼けば別のnodeでdaemonが起動して
|
|
106
|
+
しまうためで、そこでの防御は起動継続ではなく`node_exists`による消滅の可視化である。
|
|
107
|
+
|
|
108
|
+
> **0.55.0より前に設定した常駐は自動では移行しない。** 焼き直しは`reconfigure`を実行した時にだけ
|
|
109
|
+
> 起きるので、Latticeを更新しただけの端末は版付きpathを抱えたままになる。更新後に各端末で
|
|
110
|
+
> `lattice bridge reconfigure --json`を1回打つ。現在どちらを焼いているかは`persistence.node_path`で読める。
|
|
111
|
+
|
|
112
|
+
なお`setup`/`reconfigure`をnode_modules配下でない実体(開発tree)から実行すると、結果の`warnings`へ
|
|
113
|
+
`BRIDGE_PERSISTED_FROM_DEVELOPMENT_TREE`が入る(該当しなければ空配列)。そのtreeを動かすと常駐が
|
|
114
|
+
止まり、`npm`更新も反映されない。開発treeから常駐させること自体は正当な操作なので拒否はしない。
|
|
115
|
+
|
|
36
116
|
## listen IPがDHCPで動く場合
|
|
37
117
|
|
|
38
118
|
設定したlisten IPがホストから消えると、古いsocketは死んだアドレスへ取り残され、LANから到達できなくなる。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@quolu/lattice",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.55.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
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises';
|
|
1
2
|
import { createConnection, isIP } from 'node:net';
|
|
2
3
|
import { networkInterfaces } from 'node:os';
|
|
3
4
|
import * as clack from '@clack/prompts';
|
|
4
5
|
|
|
6
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
5
7
|
import { resolveBridgeListenAddress } from './bridge-address.mjs';
|
|
6
8
|
import { registerBridgeUpstream } from './bridge-registrar.mjs';
|
|
7
9
|
import {
|
|
@@ -10,24 +12,30 @@ import {
|
|
|
10
12
|
} from './bridge-config.mjs';
|
|
11
13
|
import {
|
|
12
14
|
clearBridgeStopControl, ensureBridgeDaemon, readBridgeDaemonDescriptor,
|
|
13
|
-
removeBridgeDaemonActiveMarker, removeBridgeDaemonDescriptor,
|
|
14
|
-
stopBridgeDaemon,
|
|
15
|
+
readBridgeRuntimeIdentity, removeBridgeDaemonActiveMarker, removeBridgeDaemonDescriptor,
|
|
16
|
+
requestBridgeDaemonStop, stopBridgeDaemon,
|
|
15
17
|
} from './bridge-daemon.mjs';
|
|
18
|
+
import { bridgeDevelopmentTreeWarning, DEFAULT_BRIDGE_PATH } from './bridge-executable.mjs';
|
|
16
19
|
import {
|
|
17
|
-
disableBridgeLaunchAgent, installBridgeLaunchAgent,
|
|
18
|
-
snapshotBridgeLaunchAgent,
|
|
20
|
+
describeBridgeLaunchAgent, disableBridgeLaunchAgent, installBridgeLaunchAgent,
|
|
21
|
+
restoreBridgeLaunchAgent, snapshotBridgeLaunchAgent,
|
|
19
22
|
} from './bridge-launch-agent.mjs';
|
|
20
23
|
import {
|
|
21
|
-
disableBridgeStartupFolder, installBridgeStartupFolder,
|
|
22
|
-
snapshotBridgeStartupFolder,
|
|
24
|
+
describeBridgeStartupFolder, disableBridgeStartupFolder, installBridgeStartupFolder,
|
|
25
|
+
restoreBridgeStartupFolder, snapshotBridgeStartupFolder,
|
|
23
26
|
} from './bridge-startup-folder.mjs';
|
|
24
27
|
|
|
25
28
|
// v2 adds the liveness fields. `enabled` only says the configuration is on;
|
|
26
29
|
// it never said the bridge could actually be reached, which let a DHCP lease
|
|
27
30
|
// change take the published surface down while status kept reporting health.
|
|
28
31
|
// v3 adds `hub` (bh3): the terminal's registered bridge-hub, if any.
|
|
29
|
-
|
|
32
|
+
// v4 adds `persistence`/`runtime`/`runtime_drift`/`remedy`/`warnings`: the
|
|
33
|
+
// configuration being reachable still said nothing about whether the OS
|
|
34
|
+
// persistence entry points at binaries that exist, or whether the process
|
|
35
|
+
// actually serving is the code and node a restart would bring back.
|
|
36
|
+
const RESULT_SCHEMA = 'lattice.bridge_cli_result.v4';
|
|
30
37
|
const REACHABILITY_PROBE_TIMEOUT_MS = 750;
|
|
38
|
+
const RECONFIGURE_COMMAND = 'lattice bridge reconfigure --json';
|
|
31
39
|
|
|
32
40
|
/** TCP connect probe. Answers "is anything accepting there right now". */
|
|
33
41
|
export function probeBridgeListener({ address, port, timeoutMs = REACHABILITY_PROBE_TIMEOUT_MS }) {
|
|
@@ -80,10 +88,86 @@ async function bridgeLiveness(config, { interfaces = networkInterfaces(), probe
|
|
|
80
88
|
function platformLaunchAgent() {
|
|
81
89
|
if (process.platform === 'win32') {
|
|
82
90
|
return { snapshot: snapshotBridgeStartupFolder, install: installBridgeStartupFolder,
|
|
83
|
-
disable: disableBridgeStartupFolder, restore: restoreBridgeStartupFolder
|
|
91
|
+
disable: disableBridgeStartupFolder, restore: restoreBridgeStartupFolder,
|
|
92
|
+
describe: describeBridgeStartupFolder };
|
|
84
93
|
}
|
|
85
94
|
return { snapshot: snapshotBridgeLaunchAgent, install: installBridgeLaunchAgent,
|
|
86
|
-
disable: disableBridgeLaunchAgent, restore: restoreBridgeLaunchAgent
|
|
95
|
+
disable: disableBridgeLaunchAgent, restore: restoreBridgeLaunchAgent,
|
|
96
|
+
describe: describeBridgeLaunchAgent };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const UNREADABLE_PERSISTENCE = Object.freeze({ loaded: null, node_path: null, node_exists: false,
|
|
100
|
+
bridge_path: null, bridge_exists: false });
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* What the OS persistence entry (LaunchAgent plist, Windows Startup launcher)
|
|
104
|
+
* actually points at, and whether those paths still exist. A read failure is
|
|
105
|
+
* reported as `unreadable` rather than thrown: this is a diagnostic, and it
|
|
106
|
+
* is worth least on exactly the broken hosts where it would otherwise abort.
|
|
107
|
+
*/
|
|
108
|
+
async function bridgePersistence({ launchAgent, env }) {
|
|
109
|
+
try {
|
|
110
|
+
const snapshot = await launchAgent.snapshot({ env });
|
|
111
|
+
const described = await launchAgent.describe({ snapshot, env });
|
|
112
|
+
// `loaded` is launchd-specific; the Windows Startup folder has no such
|
|
113
|
+
// concept and reports null rather than pretending to know.
|
|
114
|
+
const loaded = typeof snapshot?.loaded === 'boolean' ? snapshot.loaded : null;
|
|
115
|
+
if (described === null) return { state: 'not_installed', ...UNREADABLE_PERSISTENCE, loaded, error: null };
|
|
116
|
+
return { state: 'installed', loaded, ...described, error: null };
|
|
117
|
+
} catch (error) {
|
|
118
|
+
// Only environment failures degrade into a report: typed BridgeConfigErrors
|
|
119
|
+
// and raw fs errnos both carry a string `code`. Anything else is a defect in
|
|
120
|
+
// this codebase (a missing describe implementation, a bad argument) and must
|
|
121
|
+
// surface as itself rather than be laundered into "unreadable".
|
|
122
|
+
if (typeof error?.code !== 'string') throw error;
|
|
123
|
+
return { state: 'unreadable', ...UNREADABLE_PERSISTENCE, error: error.code };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Where the running process disagrees with what a restart would produce.
|
|
129
|
+
* `node_path` is compared through realpath because the persisted path is
|
|
130
|
+
* deliberately a stable alias (see bridge-executable.mjs) — the strings are
|
|
131
|
+
* expected to differ; the binaries behind them are not.
|
|
132
|
+
*/
|
|
133
|
+
async function bridgeRuntimeDrift(persistence, runtime) {
|
|
134
|
+
if (persistence?.state !== 'installed' || runtime?.state !== 'running') return [];
|
|
135
|
+
const drift = [];
|
|
136
|
+
if (runtime.bridge_path !== null && persistence.bridge_path !== null
|
|
137
|
+
&& runtime.bridge_path !== persistence.bridge_path) drift.push('bridge_path');
|
|
138
|
+
if (runtime.node_path !== null && persistence.node_path !== null) {
|
|
139
|
+
const target = await realpath(persistence.node_path).catch(() => null);
|
|
140
|
+
if (target !== null && target !== runtime.node_path) drift.push('node_path');
|
|
141
|
+
}
|
|
142
|
+
if (runtime.version !== null && runtime.version !== packageJson.version) drift.push('version');
|
|
143
|
+
return drift;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* `version` drift alone carries no remedy on purpose: the daemon polls its own
|
|
148
|
+
* on-disk package version and exits for the supervisor to relaunch on the new
|
|
149
|
+
* code (see bridgeDaemonVersionDrifted), so it resolves itself within a minute.
|
|
150
|
+
* Everything else here outlives the current process: a missing binary, a
|
|
151
|
+
* mismatched path, or an enabled bridge with no persistence entry at all all
|
|
152
|
+
* survive until someone reinstalls the entry.
|
|
153
|
+
*/
|
|
154
|
+
function bridgeRemedy(persistence, drift) {
|
|
155
|
+
if (persistence === null) return null;
|
|
156
|
+
if (persistence.state === 'unreadable') return RECONFIGURE_COMMAND;
|
|
157
|
+
// Only reached with the bridge enabled, so "nothing is installed" means the
|
|
158
|
+
// currently-serving daemon is the last one: nothing brings it back at reboot.
|
|
159
|
+
if (persistence.state === 'not_installed') return RECONFIGURE_COMMAND;
|
|
160
|
+
if (persistence.state === 'installed'
|
|
161
|
+
&& (!persistence.node_exists || !persistence.bridge_exists)) return RECONFIGURE_COMMAND;
|
|
162
|
+
return drift.includes('node_path') || drift.includes('bridge_path') ? RECONFIGURE_COMMAND : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
async function bridgeDiagnostics({ config, launchAgent, env, runtimeIdentity }) {
|
|
166
|
+
if (config?.enabled !== true) return null;
|
|
167
|
+
const persistence = await bridgePersistence({ launchAgent, env });
|
|
168
|
+
const runtime = await runtimeIdentity({ env });
|
|
169
|
+
const drift = await bridgeRuntimeDrift(persistence, runtime);
|
|
170
|
+
return { persistence, runtime, drift, remedy: bridgeRemedy(persistence, drift) };
|
|
87
171
|
}
|
|
88
172
|
|
|
89
173
|
function fail(stderr, code, message) {
|
|
@@ -121,7 +205,7 @@ function parseOptions(words) {
|
|
|
121
205
|
return options;
|
|
122
206
|
}
|
|
123
207
|
|
|
124
|
-
function result(action, config, recovery = null, liveness = null) {
|
|
208
|
+
function result(action, config, recovery = null, liveness = null, diagnostics = null, warnings = null) {
|
|
125
209
|
return { schema: RESULT_SCHEMA, action, configured: config !== null, enabled: config?.enabled ?? false,
|
|
126
210
|
listen: config?.listen ?? null, allowed_hosts: config?.allowed_hosts ?? null,
|
|
127
211
|
upstream: config?.upstream ?? null, hub: config?.hub ?? null, updated_at: config?.updated_at ?? null, recovery,
|
|
@@ -129,7 +213,12 @@ function result(action, config, recovery = null, liveness = null) {
|
|
|
129
213
|
effective_listen: liveness?.effective_listen ?? null,
|
|
130
214
|
listen_candidates: liveness?.listen_candidates ?? null,
|
|
131
215
|
reachable: liveness?.reachable ?? null,
|
|
132
|
-
liveness_reason: liveness?.liveness_reason ?? null
|
|
216
|
+
liveness_reason: liveness?.liveness_reason ?? null,
|
|
217
|
+
persistence: diagnostics?.persistence ?? null,
|
|
218
|
+
runtime: diagnostics?.runtime ?? null,
|
|
219
|
+
runtime_drift: diagnostics?.drift ?? null,
|
|
220
|
+
remedy: diagnostics?.remedy ?? null,
|
|
221
|
+
warnings };
|
|
133
222
|
}
|
|
134
223
|
|
|
135
224
|
export async function collectBridgeSetupWizard({ input, output, prompts = clack } = {}) {
|
|
@@ -180,7 +269,8 @@ export async function collectBridgeSetupWizard({ input, output, prompts = clack
|
|
|
180
269
|
export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
|
|
181
270
|
stdin = process.stdin, daemon = { ensure: ensureBridgeDaemon, requestStop: requestBridgeDaemonStop,
|
|
182
271
|
stop: stopBridgeDaemon, clearStop: clearBridgeStopControl },
|
|
183
|
-
launchAgent = platformLaunchAgent(),
|
|
272
|
+
launchAgent = platformLaunchAgent(), runtimeIdentity = readBridgeRuntimeIdentity,
|
|
273
|
+
bridgePath = DEFAULT_BRIDGE_PATH,
|
|
184
274
|
prompts = clack, probe = probeBridgeListener, interfaces = networkInterfaces() } = {}) {
|
|
185
275
|
if (!Array.isArray(argv)) {
|
|
186
276
|
return fail(stderr, 'USAGE', 'usage: lattice bridge <setup|reconfigure|status|disable|register> [options] --json');
|
|
@@ -308,11 +398,21 @@ export async function runBridgeCli({ argv, stdout, stderr, env = process.env,
|
|
|
308
398
|
return configured;
|
|
309
399
|
});
|
|
310
400
|
} else return fail(stderr, 'USAGE', 'unknown bridge command or options');
|
|
311
|
-
// Liveness
|
|
312
|
-
//
|
|
401
|
+
// Liveness and the persistence/runtime diagnostics are only meaningful for
|
|
402
|
+
// a read: the mutating commands have just reconfigured the daemon and the
|
|
403
|
+
// socket may not have settled yet.
|
|
313
404
|
const liveness = command === 'status' ? await bridgeLiveness(config, { probe, interfaces }) : null;
|
|
314
|
-
|
|
315
|
-
|
|
405
|
+
const diagnostics = command === 'status'
|
|
406
|
+
? await bridgeDiagnostics({ config, launchAgent, env, runtimeIdentity }) : null;
|
|
407
|
+
// An install persisted straight out of a checkout is legitimate but must
|
|
408
|
+
// never be silent — it is half of what made the 2026-08-10 outage take
|
|
409
|
+
// manual launchctl archaeology to explain.
|
|
410
|
+
const warnings = command === 'setup' || command === 'reconfigure'
|
|
411
|
+
? [bridgeDevelopmentTreeWarning(bridgePath)].filter((warning) => warning !== null) : null;
|
|
412
|
+
if (wizard) {
|
|
413
|
+
stdout.write(`Lattice bridgeを${config.listen.address}:${config.listen.port}で有効にしました。\n`);
|
|
414
|
+
for (const warning of warnings ?? []) stdout.write(`警告: ${warning.message}\n`);
|
|
415
|
+
} else stdout.write(`${JSON.stringify(result(command, config, recovery, liveness, diagnostics, warnings))}\n`);
|
|
316
416
|
return 0;
|
|
317
417
|
} catch (error) {
|
|
318
418
|
stderr.write(`${JSON.stringify({ schema: 'lattice.cli_error.v2',
|
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;
|
|
@@ -305,6 +306,33 @@ async function attest(descriptor) {
|
|
|
305
306
|
} catch { return null; }
|
|
306
307
|
}
|
|
307
308
|
|
|
309
|
+
const UNIDENTIFIED_RUNTIME = Object.freeze({ pid: null, version: null, node_path: null,
|
|
310
|
+
node_version: null, bridge_path: null });
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Who is actually serving right now — pid, product version, node binary — as
|
|
314
|
+
* reported by the running process itself over its attested health endpoint,
|
|
315
|
+
* not as recorded by any file. This is the only way to see that a daemon is
|
|
316
|
+
* executing code or a node binary that no longer matches what a restart would
|
|
317
|
+
* pick up. A malformed descriptor is reported as its own state rather than
|
|
318
|
+
* thrown: this feeds `lattice bridge status`, the command an operator reaches
|
|
319
|
+
* for precisely when something is already broken.
|
|
320
|
+
*/
|
|
321
|
+
export async function readBridgeRuntimeIdentity({ env = process.env } = {}) {
|
|
322
|
+
let descriptor;
|
|
323
|
+
try { descriptor = await readBridgeDaemonDescriptor({ env }); } catch (error) {
|
|
324
|
+
if (error?.code !== 'BRIDGE_DAEMON_DESCRIPTOR_INVALID') throw error;
|
|
325
|
+
return { ...UNIDENTIFIED_RUNTIME, state: 'descriptor_invalid' };
|
|
326
|
+
}
|
|
327
|
+
if (descriptor === null) return { ...UNIDENTIFIED_RUNTIME, state: 'not_running' };
|
|
328
|
+
const body = await attest(descriptor);
|
|
329
|
+
if (body === null) return { ...UNIDENTIFIED_RUNTIME, state: 'unattested', pid: descriptor.pid };
|
|
330
|
+
const text = (value) => (typeof value === 'string' ? value : null);
|
|
331
|
+
return { state: 'running', pid: descriptor.pid, version: text(body.version),
|
|
332
|
+
node_path: text(body.node_path), node_version: text(body.node_version),
|
|
333
|
+
bridge_path: text(body.bridge_path) };
|
|
334
|
+
}
|
|
335
|
+
|
|
308
336
|
async function healthy(descriptor, config) {
|
|
309
337
|
if (descriptor === null || descriptor.address !== config.listen.address
|
|
310
338
|
|| descriptor.port !== config.listen.port) return false;
|
|
@@ -379,3 +407,25 @@ export async function stopBridgeDaemon({ env = process.env } = {}) {
|
|
|
379
407
|
}
|
|
380
408
|
throw new BridgeConfigError('BRIDGE_DAEMON_STOP_FAILED', 'bridge daemon did not stop');
|
|
381
409
|
}
|
|
410
|
+
|
|
411
|
+
const PACKAGE_JSON_PATH = path.resolve(import.meta.dirname, '../package.json');
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Whether the on-disk package.json now reports a different version than the
|
|
415
|
+
* one this running process loaded at start — i.e. `npm install`/`update`
|
|
416
|
+
* replaced the files under a still-running daemon (the "daemon の版持ち"
|
|
417
|
+
* trap, AGENTS.md: a daemon keeps serving whatever module it imported at
|
|
418
|
+
* startup no matter what gets installed afterward). A long-running process
|
|
419
|
+
* cannot hot-swap its own already-imported modules; the only fix is to exit
|
|
420
|
+
* and let whatever supervises it (launchd's KeepAlive, the Windows
|
|
421
|
+
* supervisor's restart loop) relaunch a fresh process that imports the new
|
|
422
|
+
* code. Read failures return `false` — an unrelated fs hiccup must not force
|
|
423
|
+
* a restart loop.
|
|
424
|
+
*/
|
|
425
|
+
export async function bridgeDaemonVersionDrifted({ packageJsonPath = PACKAGE_JSON_PATH } = {}) {
|
|
426
|
+
let onDisk;
|
|
427
|
+
try {
|
|
428
|
+
onDisk = JSON.parse(await readFile(packageJsonPath, 'utf8'));
|
|
429
|
+
} catch { return false; }
|
|
430
|
+
return typeof onDisk?.version === 'string' && onDisk.version !== packageJson.version;
|
|
431
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What gets baked into the OS persistence surfaces (the macOS LaunchAgent
|
|
3
|
+
* plist, the Windows Startup launcher) as the Node executable — and how a
|
|
4
|
+
* bridge installed out of a development tree is called out at setup time.
|
|
5
|
+
*
|
|
6
|
+
* `process.execPath` is already realpath-resolved by libuv, so under Homebrew
|
|
7
|
+
* it reads `/opt/homebrew/Cellar/node/<version>/bin/node` even when node was
|
|
8
|
+
* invoked through `/opt/homebrew/bin/node`. Baking that version-pinned path
|
|
9
|
+
* into a LaunchAgent makes `brew upgrade node` delete the very binary launchd
|
|
10
|
+
* is told to exec: KeepAlive then respins a process that can never start, and
|
|
11
|
+
* nothing anywhere reports it — the terminal just disappears from the
|
|
12
|
+
* published view (hit on this Mac 2026-08-08 and again 2026-08-10).
|
|
13
|
+
*
|
|
14
|
+
* So we bake a stable alias whenever one demonstrably resolves to the same
|
|
15
|
+
* binary. The test is deliberately narrow — `realpath(candidate)` equals the
|
|
16
|
+
* already-validated `resolved` path, and nothing else. In particular we do
|
|
17
|
+
* NOT additionally require the candidate's parent directories to be free of
|
|
18
|
+
* group write: the standard Homebrew prefix (`/opt/homebrew/bin`, drwxrwxr-x)
|
|
19
|
+
* fails that check, so the rule would refuse to fire in exactly the
|
|
20
|
+
* environment it exists for. Nor would it buy anything — the Cellar path we
|
|
21
|
+
* bake today sits under an equally group-writable `/opt/homebrew/Cellar`, so
|
|
22
|
+
* the set of principals who can swap the binary is identical either way. The
|
|
23
|
+
* binary itself is still checked (owner, mode, exec bit) by the caller before
|
|
24
|
+
* any of this runs.
|
|
25
|
+
*
|
|
26
|
+
* Shim-based version managers (asdf, volta) resolve to their own launcher
|
|
27
|
+
* rather than to the node binary, so they never match and we keep the
|
|
28
|
+
* resolved path. That is the honest outcome: we never bake a path we could
|
|
29
|
+
* not verify, and `lattice bridge status` reports what the persistence
|
|
30
|
+
* surface actually points at, so a version-pinned path is visible before it
|
|
31
|
+
* dies rather than silent after.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { realpath } from 'node:fs/promises';
|
|
35
|
+
import path from 'node:path';
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_BRIDGE_PATH = path.resolve(import.meta.dirname, '../bin/lattice-bridge.mjs');
|
|
38
|
+
export const DEFAULT_SUPERVISOR_PATH =
|
|
39
|
+
path.resolve(import.meta.dirname, '../bin/lattice-bridge-supervisor.mjs');
|
|
40
|
+
|
|
41
|
+
// Directories that hold a stable `node` on a default install of the platform's
|
|
42
|
+
// usual package manager. They are appended to (not substituted for) whatever
|
|
43
|
+
// PATH the installing shell had, because launchd and the Windows Startup
|
|
44
|
+
// folder inherit no PATH at all — the candidate has to come from here.
|
|
45
|
+
const WELL_KNOWN_DIRECTORIES = {
|
|
46
|
+
darwin: ['/opt/homebrew/bin', '/usr/local/bin', '/usr/bin'],
|
|
47
|
+
win32: ['C:\\Program Files\\nodejs'],
|
|
48
|
+
other: ['/usr/local/bin', '/usr/bin'],
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
function candidateDirectories(env, platform) {
|
|
52
|
+
const raw = typeof env.PATH === 'string' ? env.PATH
|
|
53
|
+
: typeof env.Path === 'string' ? env.Path : '';
|
|
54
|
+
const fromPath = raw.split(platform === 'win32' ? ';' : ':')
|
|
55
|
+
.filter((entry) => entry !== '' && path.isAbsolute(entry));
|
|
56
|
+
const wellKnown = WELL_KNOWN_DIRECTORIES[platform] ?? WELL_KNOWN_DIRECTORIES.other;
|
|
57
|
+
return [...new Set([...fromPath, ...wellKnown])];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function samePath(left, right, platform) {
|
|
61
|
+
return platform === 'win32' ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The path to bake for a node executable whose real location is `resolved`:
|
|
66
|
+
* the first candidate alias that resolves to exactly that binary, or
|
|
67
|
+
* `resolved` itself when no alias can be verified.
|
|
68
|
+
*/
|
|
69
|
+
export async function stableNodePath({ resolved, env = process.env,
|
|
70
|
+
platform = process.platform } = {}) {
|
|
71
|
+
if (typeof resolved !== 'string' || !path.isAbsolute(resolved)) {
|
|
72
|
+
throw new TypeError('resolved node executable path required');
|
|
73
|
+
}
|
|
74
|
+
const name = platform === 'win32' ? 'node.exe' : 'node';
|
|
75
|
+
for (const directory of candidateDirectories(env, platform)) {
|
|
76
|
+
const candidate = path.join(directory, name);
|
|
77
|
+
if (samePath(candidate, resolved, platform)) continue;
|
|
78
|
+
let target;
|
|
79
|
+
try { target = await realpath(candidate); } catch { continue; }
|
|
80
|
+
if (samePath(target, resolved, platform)) return candidate;
|
|
81
|
+
}
|
|
82
|
+
return resolved;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A bridge script outside any `node_modules` is being persisted straight out
|
|
87
|
+
* of a checkout: the daemon then survives every `npm update` unchanged, and
|
|
88
|
+
* dies for good if the tree is moved or deleted. Not an error — installing
|
|
89
|
+
* from a development tree is a legitimate thing to do deliberately — but it
|
|
90
|
+
* must not happen without the operator being told.
|
|
91
|
+
*/
|
|
92
|
+
export function bridgeDevelopmentTreeWarning(bridgePath) {
|
|
93
|
+
if (typeof bridgePath !== 'string') return null;
|
|
94
|
+
if (bridgePath.split(/[\\/]/u).includes('node_modules')) return null;
|
|
95
|
+
return {
|
|
96
|
+
code: 'BRIDGE_PERSISTED_FROM_DEVELOPMENT_TREE',
|
|
97
|
+
message: `the bridge is persisted from a development tree (${bridgePath}); `
|
|
98
|
+
+ 'it will not follow npm updates and stops for good if that tree moves',
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -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
|
+
|
|
@@ -523,6 +523,15 @@ export async function startBridgeHubServer({
|
|
|
523
523
|
return;
|
|
524
524
|
}
|
|
525
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
|
+
}
|
|
526
535
|
if (rawPath === '/__lattice/hub/register') { await handleRegister(incoming, response); return; }
|
|
527
536
|
if (rawPath === '/projects/') { await handleProjectsIndex(incoming, response); return; }
|
|
528
537
|
const match = PROJECT_ROUTE.exec(rawPath);
|
|
@@ -2,13 +2,14 @@ import { execFile } from 'node:child_process';
|
|
|
2
2
|
import { randomBytes } from 'node:crypto';
|
|
3
3
|
import { constants as fsConstants } from 'node:fs';
|
|
4
4
|
import {
|
|
5
|
-
chmod, lstat, mkdir, open,
|
|
5
|
+
chmod, lstat, mkdir, open, realpath, rename, rm, stat, writeFile,
|
|
6
6
|
} from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { promisify } from 'node:util';
|
|
9
9
|
|
|
10
10
|
import { BridgeConfigError, readBridgeConfig } from './bridge-config.mjs';
|
|
11
11
|
import { readBridgeDaemonDescriptor } from './bridge-daemon.mjs';
|
|
12
|
+
import { DEFAULT_BRIDGE_PATH, stableNodePath } from './bridge-executable.mjs';
|
|
12
13
|
import { bridgeRegistrarSettings } from './bridge-registrar.mjs';
|
|
13
14
|
|
|
14
15
|
export const BRIDGE_LAUNCH_AGENT_LABEL = 'dev.kitepon.lattice.bridge';
|
|
@@ -252,6 +253,40 @@ export async function snapshotBridgeLaunchAgent({ env = process.env,
|
|
|
252
253
|
return Object.freeze({ installed: content !== null, loaded, content });
|
|
253
254
|
}
|
|
254
255
|
|
|
256
|
+
const PROGRAM_ARGUMENTS_PATTERN =
|
|
257
|
+
/<key>ProgramArguments<\/key>\s*<array>\s*<string>([^<]*)<\/string>\s*<string>([^<]*)<\/string>\s*<\/array>/u;
|
|
258
|
+
|
|
259
|
+
/** Inverse of `xml`, innermost-last so an escaped `&lt;` survives intact. */
|
|
260
|
+
function unxml(value) {
|
|
261
|
+
return value.replaceAll(''', "'").replaceAll('"', '"')
|
|
262
|
+
.replaceAll('>', '>').replaceAll('<', '<').replaceAll('&', '&');
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function pathPresent(ref) {
|
|
266
|
+
if (typeof ref !== 'string') return false;
|
|
267
|
+
try { await stat(ref); return true; } catch { return false; }
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* What the installed plist actually tells launchd to run, and whether those
|
|
272
|
+
* paths still exist. A LaunchAgent whose ProgramArguments point at a deleted
|
|
273
|
+
* binary is the product's worst failure mode: KeepAlive keeps respinning it,
|
|
274
|
+
* nothing logs, and the only visible symptom is a terminal missing from the
|
|
275
|
+
* published view. Reporting it here is what turns that into an answer
|
|
276
|
+
* `lattice bridge status` can give in one call.
|
|
277
|
+
*/
|
|
278
|
+
export async function describeBridgeLaunchAgent({ snapshot } = {}) {
|
|
279
|
+
if (snapshot?.installed !== true) return null;
|
|
280
|
+
const match = typeof snapshot.content === 'string'
|
|
281
|
+
? snapshot.content.match(PROGRAM_ARGUMENTS_PATTERN) : null;
|
|
282
|
+
const nodePath = match === null ? null : unxml(match[1]);
|
|
283
|
+
const bridgePath = match === null ? null : unxml(match[2]);
|
|
284
|
+
return {
|
|
285
|
+
node_path: nodePath, node_exists: await pathPresent(nodePath),
|
|
286
|
+
bridge_path: bridgePath, bridge_exists: await pathPresent(bridgePath),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
255
290
|
async function bootoutIfLoaded({ runner, uid }) {
|
|
256
291
|
if (!await loadedState(runner, uid)) return false;
|
|
257
292
|
await launchctl(runner, ['bootout', service(uid)], 'BRIDGE_LAUNCHCTL_BOOTOUT_FAILED',
|
|
@@ -261,17 +296,22 @@ async function bootoutIfLoaded({ runner, uid }) {
|
|
|
261
296
|
|
|
262
297
|
export async function installBridgeLaunchAgent({ config, env = process.env,
|
|
263
298
|
runner = defaultLaunchctlRunner, uid = userId(), nodePath = process.execPath,
|
|
264
|
-
bridgePath =
|
|
299
|
+
bridgePath = DEFAULT_BRIDGE_PATH,
|
|
265
300
|
waitReady = defaultWaitReady, waitStopped = defaultWaitStopped,
|
|
266
|
-
previousListen = null } = {}) {
|
|
301
|
+
stableNode = stableNodePath, previousListen = null } = {}) {
|
|
267
302
|
if (config?.enabled !== true) throw fail('BRIDGE_DISABLED', 'bridge is disabled');
|
|
268
303
|
const refs = bridgeLaunchAgentPaths(env);
|
|
269
304
|
await prepareDirectory(refs.directory, uid);
|
|
270
305
|
await strictPlist(refs.plist, uid);
|
|
306
|
+
// The safety checks run against the real binary; what goes into the plist is
|
|
307
|
+
// a stable alias for it when one can be verified, so a version-manager
|
|
308
|
+
// upgrade cannot delete the path launchd was told to exec. See
|
|
309
|
+
// bridge-executable.mjs for why the alias is not additionally permission-checked.
|
|
271
310
|
const resolvedNode = await executablePath(nodePath, 'node executable', { uid });
|
|
311
|
+
const bakedNode = await stableNode({ resolved: resolvedNode, env });
|
|
272
312
|
const resolvedBridge = await executablePath(bridgePath, 'bridge executable', { executable: false, uid });
|
|
273
313
|
const instanceToken = randomBytes(32).toString('hex');
|
|
274
|
-
const content = plistDocument({ nodePath:
|
|
314
|
+
const content = plistDocument({ nodePath: bakedNode, bridgePath: resolvedBridge, instanceToken, env });
|
|
275
315
|
const stopped = await bootoutIfLoaded({ runner, uid });
|
|
276
316
|
if (stopped) await waitStopped({ listen: previousListen, env });
|
|
277
317
|
await atomicPlist(refs.plist, content);
|
package/src/bridge-server.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { parseTree } from 'jsonc-parser';
|
|
|
7
7
|
|
|
8
8
|
import { networkInterfaces } from 'node:os';
|
|
9
9
|
|
|
10
|
+
import packageJson from '../package.json' with { type: 'json' };
|
|
10
11
|
import { resolveBridgeListenAddress } from './bridge-address.mjs';
|
|
11
12
|
import { registerBridgeUpstream } from './bridge-registrar.mjs';
|
|
12
13
|
import {
|
|
@@ -228,10 +229,18 @@ export async function startBridgeServer({
|
|
|
228
229
|
response.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
229
230
|
const attested = typeof instanceToken === 'string'
|
|
230
231
|
&& incoming.headers['x-lattice-bridge-instance-token'] === instanceToken;
|
|
232
|
+
// The identity fields answer "what is actually running right now", which
|
|
233
|
+
// no descriptor can: the descriptor records what was configured, while a
|
|
234
|
+
// daemon can outlive an npm update or keep executing a node binary its
|
|
235
|
+
// own persistence entry no longer points at. Attested callers only —
|
|
236
|
+
// these are local filesystem paths and must not leak to the public
|
|
237
|
+
// availability probe.
|
|
231
238
|
response.end(`${JSON.stringify(attested
|
|
232
239
|
? { schema: 'lattice.bridge_health.v1', pid: process.pid,
|
|
233
240
|
address: currentConfig.listen.address, port: currentConfig.listen.port,
|
|
234
|
-
updated_at: currentConfig.updated_at ?? null
|
|
241
|
+
updated_at: currentConfig.updated_at ?? null,
|
|
242
|
+
version: packageJson.version, node_path: process.execPath,
|
|
243
|
+
node_version: process.version, bridge_path: process.argv[1] ?? null }
|
|
235
244
|
: { schema: 'lattice.bridge_health.v1', status: 'available' })}\n`);
|
|
236
245
|
return;
|
|
237
246
|
}
|
|
@@ -32,13 +32,16 @@ import { execFile } from 'node:child_process';
|
|
|
32
32
|
import { randomBytes } from 'node:crypto';
|
|
33
33
|
import { constants as fsConstants } from 'node:fs';
|
|
34
34
|
import {
|
|
35
|
-
lstat, mkdir, open, readFile, realpath, rename, rm, writeFile,
|
|
35
|
+
lstat, mkdir, open, readFile, realpath, rename, rm, stat, writeFile,
|
|
36
36
|
} from 'node:fs/promises';
|
|
37
37
|
import path from 'node:path';
|
|
38
38
|
import { promisify } from 'node:util';
|
|
39
39
|
|
|
40
40
|
import { BridgeConfigError, readBridgeConfig } from './bridge-config.mjs';
|
|
41
41
|
import { readBridgeDaemonDescriptor } from './bridge-daemon.mjs';
|
|
42
|
+
import {
|
|
43
|
+
DEFAULT_BRIDGE_PATH, DEFAULT_SUPERVISOR_PATH, stableNodePath,
|
|
44
|
+
} from './bridge-executable.mjs';
|
|
42
45
|
import { bridgeRegistrarSettings } from './bridge-registrar.mjs';
|
|
43
46
|
|
|
44
47
|
export const BRIDGE_STARTUP_LABEL = 'LatticeBridge';
|
|
@@ -242,6 +245,33 @@ export async function snapshotBridgeStartupFolder({ env = process.env } = {}) {
|
|
|
242
245
|
return Object.freeze({ installed: launcherContent !== null, launcherContent, descriptorContent });
|
|
243
246
|
}
|
|
244
247
|
|
|
248
|
+
async function pathPresent(ref) {
|
|
249
|
+
if (typeof ref !== 'string') return false;
|
|
250
|
+
try { await stat(ref); return true; } catch { return false; }
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The Windows counterpart of `describeBridgeLaunchAgent`, reporting the same
|
|
255
|
+
* shape: what the persisted launcher runs and whether it is still there. The
|
|
256
|
+
* node path comes from the `.vbs` (first triple-quoted argument), the bridge
|
|
257
|
+
* script from the supervisor descriptor that owns it.
|
|
258
|
+
*/
|
|
259
|
+
export async function describeBridgeStartupFolder({ snapshot } = {}) {
|
|
260
|
+
if (snapshot?.installed !== true) return null;
|
|
261
|
+
const quoted = typeof snapshot.launcherContent === 'string'
|
|
262
|
+
? [...snapshot.launcherContent.matchAll(/"""([^"]*)"""/gu)].map((match) => match[1]) : [];
|
|
263
|
+
const nodePath = quoted.length > 0 ? quoted[0] : null;
|
|
264
|
+
let bridgePath = null;
|
|
265
|
+
try {
|
|
266
|
+
const parsed = JSON.parse(snapshot.descriptorContent);
|
|
267
|
+
if (typeof parsed?.bridgePath === 'string') bridgePath = parsed.bridgePath;
|
|
268
|
+
} catch {}
|
|
269
|
+
return {
|
|
270
|
+
node_path: nodePath, node_exists: await pathPresent(nodePath),
|
|
271
|
+
bridge_path: bridgePath, bridge_exists: await pathPresent(bridgePath),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
245
275
|
/** Read the supervisor's own recorded pid and kill its whole process tree
|
|
246
276
|
* (`taskkill /T /F`) — a forcibly-terminated supervisor cannot clean up its
|
|
247
277
|
* child itself, so the tree kill is what actually stops the bridge, not the
|
|
@@ -266,17 +296,21 @@ async function stopRunning({ env, listen, runner, waitStopped }) {
|
|
|
266
296
|
|
|
267
297
|
export async function installBridgeStartupFolder({ config, env = process.env,
|
|
268
298
|
runner = defaultStartupRunner, nodePath = process.execPath,
|
|
269
|
-
bridgePath =
|
|
270
|
-
supervisorPath = path.resolve(import.meta.dirname, '../bin/lattice-bridge-supervisor.mjs'),
|
|
299
|
+
bridgePath = DEFAULT_BRIDGE_PATH, supervisorPath = DEFAULT_SUPERVISOR_PATH,
|
|
271
300
|
waitReady = defaultWaitReady, waitStopped = defaultWaitStopped,
|
|
272
|
-
previousListen = null } = {}) {
|
|
301
|
+
stableNode = stableNodePath, previousListen = null } = {}) {
|
|
273
302
|
if (config?.enabled !== true) throw fail('BRIDGE_DISABLED', 'bridge is disabled');
|
|
274
303
|
const refs = bridgeStartupFolderPaths(env);
|
|
275
304
|
await prepareDirectory(refs.startupDirectory);
|
|
276
305
|
await prepareDirectory(refs.runtimeDirectory);
|
|
277
306
|
await strictFile(refs.launcher);
|
|
278
307
|
await strictFile(refs.descriptor);
|
|
308
|
+
// Same reasoning as the LaunchAgent: validate the real binary, bake a
|
|
309
|
+
// verified stable alias for it. nvm-windows swaps the version behind the
|
|
310
|
+
// `C:\Program Files\nodejs` junction exactly the way Homebrew swaps the
|
|
311
|
+
// Cellar directory behind `/opt/homebrew/bin/node`.
|
|
279
312
|
const resolvedNode = await executablePath(nodePath, 'node executable');
|
|
313
|
+
const bakedNode = await stableNode({ resolved: resolvedNode, env });
|
|
280
314
|
const resolvedBridge = await executablePath(bridgePath, 'bridge executable');
|
|
281
315
|
const resolvedSupervisor = await executablePath(supervisorPath, 'supervisor executable');
|
|
282
316
|
const instanceToken = randomBytes(32).toString('hex');
|
|
@@ -286,7 +320,7 @@ export async function installBridgeStartupFolder({ config, env = process.env,
|
|
|
286
320
|
await stopRunning({ env, listen: previousListen, runner, waitStopped });
|
|
287
321
|
await atomicFile(refs.descriptor, descriptorContent);
|
|
288
322
|
await atomicFile(refs.launcher,
|
|
289
|
-
launcherScript({ nodePath:
|
|
323
|
+
launcherScript({ nodePath: bakedNode, supervisorPath: resolvedSupervisor, descriptorPath: refs.descriptor }));
|
|
290
324
|
await launch(runner, ['wscript.exe', refs.launcher], 'BRIDGE_STARTUP_LAUNCHER_FAILED',
|
|
291
325
|
'could not start the bridge startup process');
|
|
292
326
|
return waitReady({ config, instanceToken, env });
|
|
@@ -348,8 +348,15 @@ async function observeWorkerProcessTree(pid, { requireDescendantsInRootGroup = t
|
|
|
348
348
|
rawState: match[4],
|
|
349
349
|
startedIdentity: match[5].trim(),
|
|
350
350
|
};
|
|
351
|
-
|
|
352
|
-
|
|
351
|
+
// processGroupIdは0を許す: Linuxのkernel thread(pid 2 kthreaddとその子)は
|
|
352
|
+
// 実在するがユーザー空間のprocess groupに属さないためpgid=0を持つ——実機の`ps -axo`は
|
|
353
|
+
// これを常に含むので、pid>0の一律要求ではこの関数が動くたびに毎回fail closedしていた
|
|
354
|
+
// (2026-08-10、CIのubuntu-latestで再現・確認)。0は安全側にも効く: 実workerのroot
|
|
355
|
+
// process groupは常にpid>0のleaderが持つ実PIDなので、pgid=0のrecordは下の
|
|
356
|
+
// 「同じprocess groupを共有する無関係processが無いか」の照合に決して誤って一致しない。
|
|
357
|
+
if (!Number.isSafeInteger(record.pid) || record.pid <= 0
|
|
358
|
+
|| !Number.isSafeInteger(record.processGroupId) || record.processGroupId < 0
|
|
359
|
+
|| !Number.isSafeInteger(record.parentPid) || record.parentPid < 0
|
|
353
360
|
|| record.startedIdentity.length === 0 || records.has(record.pid)) {
|
|
354
361
|
fail('WORK_ORDER_REPORT_INVALID', 'ps process recordが不正');
|
|
355
362
|
}
|
package/src/todo-cli.mjs
CHANGED
|
@@ -183,6 +183,29 @@ function typedFailure(stderr, error) {
|
|
|
183
183
|
return 1;
|
|
184
184
|
}
|
|
185
185
|
|
|
186
|
+
/**
|
|
187
|
+
* An error that reached here is, by definition, one none of the typed
|
|
188
|
+
* TodoStoreError/TypeError branches recognized — the one case where the
|
|
189
|
+
* caller most needs to know *what actually happened*. Collapsing it to just
|
|
190
|
+
* the constructor name (`{code:'INTERNAL_FAILURE',message:'Error'}`) was
|
|
191
|
+
* itself a diagnosability defect: a crashed `todo start` produced exactly
|
|
192
|
+
* that content-free payload, and the only way to find the real cause
|
|
193
|
+
* (`manifest_journal_head_mismatch`) was importing todo-store.mjs directly
|
|
194
|
+
* and calling readTodoStore() by hand (2026-08-10 P0). The actual message
|
|
195
|
+
* and a short stack excerpt cost nothing to include — this is a local CLI's
|
|
196
|
+
* own stderr, not a response surface with an untrusted audience.
|
|
197
|
+
*/
|
|
198
|
+
function internalFailure(stderr, error) {
|
|
199
|
+
const constructorName = error?.constructor?.name ?? 'Error';
|
|
200
|
+
const message = typeof error?.message === 'string' && error.message.length > 0
|
|
201
|
+
? error.message : constructorName;
|
|
202
|
+
const detail = { error_name: constructorName };
|
|
203
|
+
if (typeof error?.stack === 'string' && error.stack.length > 0) {
|
|
204
|
+
detail.stack_excerpt = error.stack.split('\n').slice(0, 6).join('\n');
|
|
205
|
+
}
|
|
206
|
+
return typedFailure(stderr, { code: 'INTERNAL_FAILURE', message, detail });
|
|
207
|
+
}
|
|
208
|
+
|
|
186
209
|
const TODO_COMMAND_NAMES = Object.freeze([
|
|
187
210
|
'status', 'show', 'note', 'bindings', 'independence', 'seam-profile', 'seam-proposal',
|
|
188
211
|
'verify', 'snapshot', 'gantt', 'dashboard', 'phase', 'migrate', 'start', 'block',
|
|
@@ -2811,9 +2834,7 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2811
2834
|
await runTodoSchemaCommand(argv[0], stdout);
|
|
2812
2835
|
return 0;
|
|
2813
2836
|
} catch (error) {
|
|
2814
|
-
return
|
|
2815
|
-
code: 'INTERNAL_FAILURE', message: error?.constructor?.name ?? 'Error',
|
|
2816
|
-
});
|
|
2837
|
+
return internalFailure(stderr, error);
|
|
2817
2838
|
}
|
|
2818
2839
|
}
|
|
2819
2840
|
|
|
@@ -2835,9 +2856,7 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
2835
2856
|
} catch (error) {
|
|
2836
2857
|
if (typeof error?.code === 'string' && error.detail !== null
|
|
2837
2858
|
&& typeof error.detail === 'object') return typedFailure(stderr, error);
|
|
2838
|
-
return
|
|
2839
|
-
code: 'INTERNAL_FAILURE', message: error?.constructor?.name ?? 'Error',
|
|
2840
|
-
});
|
|
2859
|
+
return internalFailure(stderr, error);
|
|
2841
2860
|
}
|
|
2842
2861
|
}
|
|
2843
2862
|
|
|
@@ -3118,7 +3137,18 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
3118
3137
|
const ganttCommand = argv[0] === 'gantt';
|
|
3119
3138
|
const dashboardAdopt = argv[0] === 'dashboard' && argv[1] === 'adopt';
|
|
3120
3139
|
const migrationDryRun = argv[0] === 'migrate' && argv.includes('--dry-run');
|
|
3121
|
-
|
|
3140
|
+
// `verify` is the store's own read-only recovery diagnostic — the command
|
|
3141
|
+
// an operator reaches for precisely when the store might be inconsistent.
|
|
3142
|
+
// ensureActiveProjectDashboard calls readTodoStoreStable for an unrelated
|
|
3143
|
+
// side effect (registering this session as an active dashboard project),
|
|
3144
|
+
// and readTodoStoreStable retries a persistent STORE_INCONSISTENT as if it
|
|
3145
|
+
// were a transient in-flight write before giving up and reporting a
|
|
3146
|
+
// content-free STORE_BUSY. Running that pre-hook ahead of `verify` meant
|
|
3147
|
+
// the one command meant to surface the real inconsistency never got to —
|
|
3148
|
+
// it died in the same generic way every other command did (2026-08-10 P0:
|
|
3149
|
+
// `todo verify` was unreachable for the exact case it exists to diagnose).
|
|
3150
|
+
const verifyCommand = argv[0] === 'verify';
|
|
3151
|
+
if (!ganttCommand && !dashboardAdopt && !migrationDryRun && !verifyCommand) {
|
|
3122
3152
|
await ensureActiveProjectDashboard({ repoRoot, env });
|
|
3123
3153
|
}
|
|
3124
3154
|
const result = atomicCommit
|
|
@@ -3135,9 +3165,6 @@ export async function runTodoCli({ argv, cwd, stdout, stderr, env = process.env
|
|
|
3135
3165
|
message: error.message,
|
|
3136
3166
|
});
|
|
3137
3167
|
}
|
|
3138
|
-
return
|
|
3139
|
-
code: 'INTERNAL_FAILURE',
|
|
3140
|
-
message: error?.constructor?.name ?? 'Error',
|
|
3141
|
-
});
|
|
3168
|
+
return internalFailure(stderr, error);
|
|
3142
3169
|
}
|
|
3143
3170
|
}
|
|
@@ -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
|
|