@quolu/lattice 0.54.0 → 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/docs/bridge-setup.md +80 -0
- package/package.json +1 -1
- package/src/bridge-cli.mjs +116 -16
- package/src/bridge-daemon.mjs +27 -0
- package/src/bridge-executable.mjs +100 -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/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-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
|
@@ -306,6 +306,33 @@ async function attest(descriptor) {
|
|
|
306
306
|
} catch { return null; }
|
|
307
307
|
}
|
|
308
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
|
+
|
|
309
336
|
async function healthy(descriptor, config) {
|
|
310
337
|
if (descriptor === null || descriptor.address !== config.listen.address
|
|
311
338
|
|| descriptor.port !== config.listen.port) return false;
|
|
@@ -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
|
+
}
|
|
@@ -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
|
}
|