@quolu/lattice 0.52.2 → 0.52.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/package.json +1 -1
  2. package/src/artifact-contracts-v2.mjs +325 -325
  3. package/src/artifact-contracts.mjs +895 -895
  4. package/src/boundary-compiler.mjs +712 -712
  5. package/src/boundary-observation-compiler-v2.mjs +344 -344
  6. package/src/bounded-seam.mjs +230 -230
  7. package/src/bridge-address.mjs +107 -107
  8. package/src/bridge-cli.mjs +297 -297
  9. package/src/bridge-config.mjs +346 -346
  10. package/src/bridge-daemon.mjs +378 -378
  11. package/src/bridge-hub-protocol.mjs +198 -0
  12. package/src/bridge-launch-agent.mjs +323 -323
  13. package/src/bridge-registrar.mjs +102 -102
  14. package/src/bridge-server.mjs +376 -376
  15. package/src/cli-help.mjs +308 -308
  16. package/src/cli-stdio.mjs +40 -40
  17. package/src/control-compiler.mjs +532 -532
  18. package/src/dag-chain.mjs +261 -261
  19. package/src/factory-diagnostics.mjs +188 -188
  20. package/src/git-process.mjs +72 -0
  21. package/src/hash-chain.mjs +76 -76
  22. package/src/hooks-cli.mjs +1057 -1057
  23. package/src/isolation-runner.mjs +409 -409
  24. package/src/node-version-guard.mjs +44 -44
  25. package/src/project-cli.mjs +699 -697
  26. package/src/project-identity.mjs +130 -130
  27. package/src/rc1-black-box-oracle.mjs +906 -906
  28. package/src/rc1-comparison.mjs +154 -154
  29. package/src/rc1-evidence-bundle.mjs +456 -456
  30. package/src/rc1-v4-campaign.mjs +708 -708
  31. package/src/rc1-v4-transform.mjs +467 -467
  32. package/src/rc1-v5-artifact-set.mjs +855 -855
  33. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  34. package/src/rc1-v5-campaign.mjs +797 -797
  35. package/src/rc1-v5-transform.mjs +421 -421
  36. package/src/rc1-v6-artifact-set.mjs +807 -807
  37. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  38. package/src/rc1-v6-campaign.mjs +623 -623
  39. package/src/rc1-v6-causal-binding.mjs +473 -473
  40. package/src/rc1-v6-measurement.mjs +313 -313
  41. package/src/rc2-artifact-set.mjs +1584 -1584
  42. package/src/rc2-campaign.mjs +1506 -1506
  43. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  44. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  45. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  46. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  47. package/src/rc3-actual-dogfood.mjs +652 -652
  48. package/src/rc3-dogfood-scaffold.mjs +315 -315
  49. package/src/rc3-scripted-campaign.mjs +1383 -1383
  50. package/src/rc4-stage1-dogfood.mjs +673 -673
  51. package/src/runtime-adapter-registry.mjs +524 -524
  52. package/src/runtime-cli.mjs +4588 -4588
  53. package/src/runtime-contracts.mjs +824 -824
  54. package/src/runtime-control-store.mjs +604 -604
  55. package/src/runtime-controller-protocol.mjs +587 -587
  56. package/src/runtime-decision-verifier.mjs +701 -701
  57. package/src/runtime-diff-observer.mjs +361 -361
  58. package/src/runtime-direct-os-observer.mjs +301 -301
  59. package/src/runtime-driver-state.mjs +166 -166
  60. package/src/runtime-engine.mjs +779 -779
  61. package/src/runtime-errors.mjs +356 -356
  62. package/src/runtime-event-store.mjs +189 -189
  63. package/src/runtime-front-end.mjs +925 -925
  64. package/src/runtime-gate-store.mjs +481 -481
  65. package/src/runtime-hold-recompile.mjs +916 -916
  66. package/src/runtime-io-sentinel.mjs +391 -391
  67. package/src/runtime-lifecycle-lock.mjs +294 -294
  68. package/src/runtime-managed-supervisor.mjs +1490 -1490
  69. package/src/runtime-multi-epoch-store.mjs +838 -838
  70. package/src/runtime-projection.mjs +269 -269
  71. package/src/runtime-pull-intake.mjs +1192 -1192
  72. package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
  73. package/src/runtime-scripted-executor.mjs +163 -163
  74. package/src/runtime-scripted-worktree.mjs +104 -104
  75. package/src/runtime-seam-resolve.mjs +428 -428
  76. package/src/runtime-seam-treatment.mjs +173 -173
  77. package/src/runtime-socket-owner.mjs +125 -125
  78. package/src/runtime-work-order-contracts.mjs +91 -91
  79. package/src/runtime-work-order-controller.mjs +1171 -1171
  80. package/src/runtime-worktree-executor.mjs +199 -199
  81. package/src/schedulability-compiler-v2.mjs +303 -303
  82. package/src/schedulability-verifier-v2.mjs +317 -317
  83. package/src/seam-apply.mjs +549 -549
  84. package/src/seam-commit-shared.mjs +22 -22
  85. package/src/seam-commit-transform.mjs +81 -81
  86. package/src/seam-commit.mjs +18 -18
  87. package/src/seam-cost.mjs +322 -322
  88. package/src/seam-derivation.mjs +188 -188
  89. package/src/seam-gate.mjs +146 -146
  90. package/src/seam-proposal-contracts.mjs +446 -446
  91. package/src/seam-proposal-queries.mjs +521 -521
  92. package/src/seam-proposal.mjs +2011 -2011
  93. package/src/seam-ref.mjs +33 -33
  94. package/src/seam-rewrite.mjs +286 -286
  95. package/src/seam-transform.mjs +554 -554
  96. package/src/seam-verification.mjs +260 -260
  97. package/src/sensor-adapter.mjs +432 -432
  98. package/src/sensor-cli.mjs +139 -139
  99. package/src/sensor-diff.mjs +661 -661
  100. package/src/sensor-node-runtime.mjs +53 -53
  101. package/src/sensor-runtime.mjs +52 -52
  102. package/src/timestamp-contract.mjs +8 -8
  103. package/src/todo-audit-pending.mjs +91 -91
  104. package/src/todo-chain.mjs +178 -178
  105. package/src/todo-cli.mjs +3143 -3141
  106. package/src/todo-contracts.mjs +728 -728
  107. package/src/todo-dashboard-registry.mjs +573 -573
  108. package/src/todo-dispatch-shape.mjs +190 -190
  109. package/src/todo-gantt-html-independence.mjs +239 -239
  110. package/src/todo-gantt-html-shared.mjs +226 -226
  111. package/src/todo-gantt-html-style.mjs +131 -131
  112. package/src/todo-gantt-html.mjs +248 -248
  113. package/src/todo-gantt-layout.mjs +974 -974
  114. package/src/todo-gantt-live.mjs +361 -361
  115. package/src/todo-gantt-nested.mjs +263 -263
  116. package/src/todo-gantt-presentation.mjs +217 -217
  117. package/src/todo-gantt-scope.mjs +123 -123
  118. package/src/todo-gantt-svg.mjs +353 -353
  119. package/src/todo-independence-contracts.mjs +595 -595
  120. package/src/todo-independence-guidance.mjs +322 -322
  121. package/src/todo-independence.mjs +640 -640
  122. package/src/todo-markdown-renderer.mjs +260 -260
  123. package/src/todo-migration.mjs +448 -448
  124. package/src/todo-narrative-anchor.mjs +130 -130
  125. package/src/todo-note-store.mjs +629 -629
  126. package/src/todo-parallel-candidates.mjs +114 -114
  127. package/src/todo-revision.mjs +995 -995
  128. package/src/todo-split.mjs +472 -472
  129. package/src/todo-status.mjs +690 -690
  130. package/src/todo-store-git-transaction.mjs +418 -418
  131. package/src/todo-store.mjs +4360 -4322
  132. package/src/treatment-compiler.mjs +728 -728
  133. package/src/treatment-runner.mjs +656 -656
  134. package/src/witness-scaffold.mjs +180 -180
@@ -1,573 +1,573 @@
1
- import { spawn } from 'node:child_process';
2
- import { randomBytes } from 'node:crypto';
3
- import { homedir } from 'node:os';
4
- import {
5
- mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile,
6
- } from 'node:fs/promises';
7
- import path from 'node:path';
8
-
9
- import packageJson from '../package.json' with { type: 'json' };
10
- import { isAuditPendingPhaseStatus } from './todo-audit-pending.mjs';
11
-
12
- /**
13
- * The version of the code in THIS process. A daemon loads its modules once at
14
- * startup and keeps serving them, so installing a new package does not change
15
- * what the running daemon serves. The daemon reports the version it started
16
- * with; anything else is code that has already been replaced on disk.
17
- */
18
- export const TODO_DASHBOARD_CODE_VERSION = packageJson.version;
19
-
20
- const REGISTRY_SCHEMA = 'lattice.todo_dashboard_registry.v1';
21
- const DAEMON_SCHEMA = 'lattice.todo_dashboard_daemon.v1';
22
- const DEFAULT_PORT = 0;
23
- export const TODO_DASHBOARD_STALE_MS = 2 * 60 * 60 * 1_000;
24
- const LOCK_ATTEMPTS = 240;
25
- const LOCK_WAIT_MS = 25;
26
- const LOCK_STALE_MS = 30_000;
27
-
28
- function identifier(value) {
29
- return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);
30
- }
31
-
32
- function runtimeDir(env) {
33
- const configured = env.LATTICE_DASHBOARD_RUNTIME_DIR;
34
- return typeof configured === 'string' && path.isAbsolute(configured)
35
- ? configured : path.join(homedir(), '.lattice', 'dashboard');
36
- }
37
-
38
- function paths(env) {
39
- const root = runtimeDir(env);
40
- return {
41
- root,
42
- registry: path.join(root, 'projects.json'),
43
- descriptor: path.join(root, 'daemon.json'),
44
- daemons: path.join(root, 'daemons'),
45
- lock: path.join(root, 'registry.lock'),
46
- startupLock: path.join(root, 'daemon-start.lock'),
47
- };
48
- }
49
-
50
- function validEntry(entry) {
51
- return entry !== null && typeof entry === 'object' && !Array.isArray(entry)
52
- && Object.keys(entry).sort().join(',') === 'display_name,last_seen_at,project_id,repo_root,session_id'
53
- && identifier(entry.project_id) && identifier(entry.session_id)
54
- && typeof entry.display_name === 'string' && entry.display_name.length > 0
55
- && typeof entry.repo_root === 'string' && path.isAbsolute(entry.repo_root)
56
- && Number.isFinite(Date.parse(entry.last_seen_at));
57
- }
58
-
59
- /** active taskが無くても、監査の判断待ち・棄却後なら公開工程から消してはいけない。 */
60
- export function todoDashboardMemberNeedsVisibility(member) {
61
- return Array.isArray(member?.phases)
62
- && member.phases.some(({ status }) => isAuditPendingPhaseStatus(status));
63
- }
64
-
65
- function validateRegistry(value) {
66
- if (value === null || typeof value !== 'object' || Array.isArray(value)
67
- || value.schema !== REGISTRY_SCHEMA || !Array.isArray(value.projects)
68
- || !value.projects.every(validEntry)) {
69
- const error = new Error('dashboard registry schema invalid');
70
- error.code = 'DASHBOARD_REGISTRY_INVALID';
71
- throw error;
72
- }
73
- const ids = new Set();
74
- for (const entry of value.projects) {
75
- if (ids.has(entry.project_id)) {
76
- const error = new Error(`dashboard project duplicate: ${entry.project_id}`);
77
- error.code = 'DASHBOARD_REGISTRY_INVALID';
78
- throw error;
79
- }
80
- ids.add(entry.project_id);
81
- }
82
- return value;
83
- }
84
-
85
- async function readJson(ref, missing) {
86
- let bytes;
87
- try { bytes = await readFile(ref, 'utf8'); } catch (error) {
88
- if (error?.code === 'ENOENT') return missing;
89
- throw error;
90
- }
91
- try { return JSON.parse(bytes); } catch {
92
- const error = new Error(`invalid JSON: ${ref}`);
93
- error.code = 'DASHBOARD_REGISTRY_INVALID';
94
- throw error;
95
- }
96
- }
97
-
98
- async function atomicJson(ref, value) {
99
- const temporary = `${ref}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
100
- await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
101
- await rename(temporary, ref);
102
- }
103
-
104
- async function withLock(lockRef, action) {
105
- for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
106
- let handle;
107
- try {
108
- handle = await open(lockRef, 'wx', 0o600);
109
- await handle.writeFile(`${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`);
110
- try { return await action(); } finally {
111
- await handle.close();
112
- await rm(lockRef, { force: true });
113
- }
114
- } catch (error) {
115
- if (error?.code !== 'EEXIST') throw error;
116
- try {
117
- const lock = JSON.parse(await readFile(lockRef, 'utf8'));
118
- let alive = Number.isSafeInteger(lock.pid) && lock.pid > 0;
119
- if (alive) {
120
- try { process.kill(lock.pid, 0); } catch { alive = false; }
121
- }
122
- if (!alive || Date.now() - Date.parse(lock.created_at) > LOCK_STALE_MS) {
123
- await rm(lockRef, { force: true });
124
- continue;
125
- }
126
- } catch (lockError) {
127
- if (lockError?.code === 'ENOENT') continue;
128
- }
129
- await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_MS));
130
- }
131
- }
132
- const error = new Error('dashboard registry lock timeout');
133
- error.code = 'DASHBOARD_REGISTRY_BUSY';
134
- throw error;
135
- }
136
-
137
- export async function readActiveTodoDashboardProjects({ env = process.env, now = Date.now() } = {}) {
138
- const ref = paths(env).registry;
139
- const document = validateRegistry(await readJson(ref, { schema: REGISTRY_SCHEMA, projects: [] }));
140
- return document.projects.filter((entry) => now - Date.parse(entry.last_seen_at) <= TODO_DASHBOARD_STALE_MS)
141
- .sort((left, right) => left.display_name.localeCompare(right.display_name, 'ja')
142
- || left.project_id.localeCompare(right.project_id, 'en'));
143
- }
144
-
145
- export async function readVisibleTodoDashboardProjects({
146
- env = process.env, now = Date.now(), projectHasActiveRun,
147
- } = {}) {
148
- if (typeof projectHasActiveRun !== 'function') throw new TypeError('projectHasActiveRun required');
149
- const ref = paths(env).registry;
150
- const document = validateRegistry(await readJson(ref, { schema: REGISTRY_SCHEMA, projects: [] }));
151
- const visible = [];
152
- for (const entry of document.projects) {
153
- if (now - Date.parse(entry.last_seen_at) <= TODO_DASHBOARD_STALE_MS
154
- || await projectHasActiveRun(entry)) visible.push(entry);
155
- }
156
- return visible.sort((left, right) => left.display_name.localeCompare(right.display_name, 'ja')
157
- || left.project_id.localeCompare(right.project_id, 'en'));
158
- }
159
-
160
- /**
161
- * 見るのはrepo_root directoryの存在だけ。`.lattice`の有無で判定すると、storeを持たない
162
- * 生きたrepoまで登録簿から消える。ENOENT/ENOTDIR以外のerrorは「消えた証拠」ではないので
163
- * 生存扱いのまま残す——判定できない相手を消す方が損害が大きい。
164
- */
165
- async function repoRootPresent(ref) {
166
- try { return (await stat(ref)).isDirectory(); } catch (error) {
167
- return !['ENOENT', 'ENOTDIR'].includes(error?.code);
168
- }
169
- }
170
-
171
- export async function registerTodoDashboardActivity({
172
- repoRoot, projectId, displayName = projectId, sessionId, env = process.env,
173
- now = new Date(), adopt = false,
174
- }) {
175
- if (!path.isAbsolute(repoRoot) || !identifier(projectId) || !identifier(sessionId)
176
- || typeof displayName !== 'string' || displayName.length === 0 || displayName !== displayName.trim()
177
- || !(now instanceof Date) || !Number.isFinite(now.getTime()) || typeof adopt !== 'boolean') {
178
- throw new TypeError('dashboard activity is invalid');
179
- }
180
- const canonicalRoot = await realpath(repoRoot);
181
- const refs = paths(env);
182
- await mkdir(refs.root, { recursive: true, mode: 0o700 });
183
- const pruned = [];
184
- await withLock(refs.lock, async () => {
185
- const current = validateRegistry(await readJson(refs.registry, { schema: REGISTRY_SCHEMA, projects: [] }));
186
- const existing = current.projects.find((entry) => entry.project_id === projectId);
187
- if (existing !== undefined && existing.repo_root !== canonicalRoot && !adopt) {
188
- const error = new Error('project id is already owned by another canonical root');
189
- error.code = 'PROJECT_ROOT_CONFLICT';
190
- // 公開・CLI errorへlocal absolute pathを運ばない。project_idだけで人と機械が
191
- // 衝突対象を特定でき、registry bytesはこの分岐より後で一切変更しない。
192
- error.detail = {
193
- project_id: projectId,
194
- next_action: 'lattice todo dashboard adopt --json',
195
- };
196
- throw error;
197
- }
198
- // 死んだ登録を落とす機会はここしかない。登録は全sessionが必ず通る一点であり、
199
- // 掃除を「人がコマンドを叩いた時」に置くと、誰も叩かないので永久に積み上がる。
200
- const projects = [];
201
- for (const entry of current.projects) {
202
- if (entry.project_id === projectId) continue;
203
- if (await repoRootPresent(entry.repo_root)) projects.push(entry);
204
- else pruned.push(entry.project_id);
205
- }
206
- projects.push({ project_id: projectId, display_name: displayName, repo_root: canonicalRoot,
207
- session_id: sessionId, last_seen_at: now.toISOString() });
208
- projects.sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
209
- pruned.sort((left, right) => left.localeCompare(right, 'en'));
210
- await atomicJson(refs.registry, { schema: REGISTRY_SCHEMA, projects });
211
- });
212
- return { projectId, displayName, repoRoot: canonicalRoot, sessionId, adopted: adopt, pruned };
213
- }
214
-
215
- /**
216
- * 登録簿から1件を明示的に外す唯一の入口。対象repoが既に消えていても叩けるよう、
217
- * storeもdaemonも経由しない。該当が無いのは暗黙の成功にしない——「消したつもり」で
218
- * 別のproject_idが残り続ける状態を、成功の応答で隠すことになる。
219
- */
220
- export async function removeTodoDashboardProject({ projectId, env = process.env }) {
221
- if (!identifier(projectId)) throw new TypeError('dashboard project id is invalid');
222
- const refs = paths(env);
223
- await mkdir(refs.root, { recursive: true, mode: 0o700 });
224
- await withLock(refs.lock, async () => {
225
- const current = validateRegistry(await readJson(refs.registry, { schema: REGISTRY_SCHEMA, projects: [] }));
226
- const projects = current.projects.filter((entry) => entry.project_id !== projectId);
227
- if (projects.length === current.projects.length) {
228
- const error = new Error('dashboard project is not registered');
229
- error.code = 'PROJECT_NOT_REGISTERED';
230
- // 公開・CLI errorへlocal absolute pathを運ばない。project_idだけで対象を特定できる。
231
- error.detail = { project_id: projectId };
232
- throw error;
233
- }
234
- await atomicJson(refs.registry, { schema: REGISTRY_SCHEMA, projects });
235
- });
236
- return { projectId, removed: true };
237
- }
238
-
239
- /** 配信元rootを移す唯一の明示入口。通常activity登録はこのflagを立てない。 */
240
- export async function adoptTodoDashboardActivity(options) {
241
- return registerTodoDashboardActivity({ ...options, adopt: true });
242
- }
243
-
244
- function validDaemonDescriptor(descriptor) {
245
- return descriptor !== null && typeof descriptor === 'object' && !Array.isArray(descriptor)
246
- && Object.keys(descriptor).sort().join(',') === 'pid,port,schema,started_at'
247
- && descriptor.schema === DAEMON_SCHEMA
248
- && Number.isSafeInteger(descriptor.pid) && descriptor.pid > 0
249
- && Number.isSafeInteger(descriptor.port) && descriptor.port > 0 && descriptor.port <= 65_535
250
- && typeof descriptor.started_at === 'string' && Number.isFinite(Date.parse(descriptor.started_at));
251
- }
252
-
253
- async function daemonAttestation(descriptor, { timeoutMs = 2_000 } = {}) {
254
- if (!validDaemonDescriptor(descriptor)) return null;
255
- try {
256
- const response = await fetch(`http://127.0.0.1:${descriptor.port}/__lattice/health`, {
257
- signal: AbortSignal.timeout(timeoutMs),
258
- });
259
- if (response.status !== 200) return null;
260
- const body = await response.json();
261
- if (body?.schema !== 'lattice.todo_dashboard_health.v1' || body.pid !== descriptor.pid
262
- || !Array.isArray(body.project_ids)) return null;
263
- const keys = Object.keys(body).sort().join(',');
264
- // 'legacy' means "alive, but serving code we have already replaced" — the
265
- // caller starts a replacement and stops it. A daemon that predates the
266
- // version field, or one still running an older package, is exactly that.
267
- if (keys === 'pid,port,project_ids,schema,version' && body.port === descriptor.port) {
268
- return body.version === TODO_DASHBOARD_CODE_VERSION ? 'current' : 'legacy';
269
- }
270
- if (keys === 'pid,port,project_ids,schema' && body.port === descriptor.port) return 'legacy';
271
- if (keys === 'pid,project_ids,schema') return 'legacy';
272
- return null;
273
- } catch { return null; }
274
- }
275
-
276
- async function daemonHealthy(descriptor) {
277
- return await daemonAttestation(descriptor) === 'current';
278
- }
279
-
280
- function processIsAlive(pid) {
281
- try { process.kill(pid, 0); return true; } catch (error) {
282
- if (error?.code === 'ESRCH') return false;
283
- throw error;
284
- }
285
- }
286
-
287
- function daemonRecordRef(refs, pid) {
288
- return path.join(refs.daemons, `${pid}.json`);
289
- }
290
-
291
- /**
292
- * daemonごとの記録。descriptorは1枚しか無いので、そこから落ちたdaemonは二度と誰にも
293
- * 観測されず、生きたまま不死になる(2026-08-03に2重起動を実測)。descriptorの所有者は
294
- * 起動側のままにして、pidごとの記録だけをdaemon自身に書かせる——記録があれば、
295
- * descriptorを失っても後続の起動が見つけられる。
296
- */
297
- async function readDaemonRecords(refs) {
298
- let names;
299
- try { names = await readdir(refs.daemons); } catch (error) {
300
- if (error?.code === 'ENOENT') return [];
301
- throw error;
302
- }
303
- const records = [];
304
- for (const name of names) {
305
- if (!/^[1-9][0-9]{0,9}\.json$/u.test(name)) continue;
306
- const ref = path.join(refs.daemons, name);
307
- let record = null;
308
- try { record = JSON.parse(await readFile(ref, 'utf8')); } catch { record = null; }
309
- // 壊れた記録、file名のpidと中身が食い違う記録は、誰の生死も語れないので捨てる。
310
- // 記録はatomicJsonで置かれるので、読み手が書きかけを見ることはない。
311
- if (!validDaemonDescriptor(record) || `${record.pid}.json` !== name) {
312
- await rm(ref, { force: true });
313
- continue;
314
- }
315
- records.push(record);
316
- }
317
- return records;
318
- }
319
-
320
- /**
321
- * 死んだpidの記録を落とす。全daemonが必ず通る起動時に呼ぶので、登録簿は
322
- * 「生きているdaemon+前回の起動以降に死んだ分」までしか育たない。
323
- * keepはhealth応答で生存を確認済みのdaemon——process.kill(0)より強い証拠を
324
- * 既に持っているので、その記録は生死判定に掛けない。
325
- */
326
- async function sweepDaemonRecords(refs, { isProcessAlive, keep = null }) {
327
- const live = [];
328
- for (const record of await readDaemonRecords(refs)) {
329
- if (record.pid === keep || await isProcessAlive(record.pid)) live.push(record);
330
- else await rm(daemonRecordRef(refs, record.pid), { force: true });
331
- }
332
- return live;
333
- }
334
-
335
- async function awaitDaemonStopped(descriptor, { isProcessAlive, deadline }) {
336
- for (;;) {
337
- const alive = await isProcessAlive(descriptor.pid);
338
- const stillServing = await daemonAttestation(descriptor) !== null;
339
- if (!alive && !stillServing) return true;
340
- if (Date.now() >= deadline) return false;
341
- await new Promise((resolve) => setTimeout(resolve, 50));
342
- }
343
- }
344
-
345
- /** 相手が既に死んでいるのは停止の成功であって失敗ではない。 */
346
- function signalIfPresent(signalProcess, pid, signal) {
347
- try { signalProcess(pid, signal); } catch (error) {
348
- if (error?.code !== 'ESRCH') throw error;
349
- }
350
- }
351
-
352
- async function stopAttestedLegacyDaemon(descriptor, {
353
- signalProcess = process.kill, isProcessAlive = processIsAlive, timeoutMs = 3_000,
354
- } = {}) {
355
- const attestation = await daemonAttestation(descriptor);
356
- if (attestation !== 'legacy') {
357
- if (!await isProcessAlive(descriptor.pid) && attestation === null) return;
358
- const error = new Error('legacy dashboard daemon attestation was lost before signal');
359
- error.code = 'DASHBOARD_LEGACY_ATTESTATION_LOST';
360
- throw error;
361
- }
362
- signalProcess(descriptor.pid, 'SIGTERM');
363
- if (await awaitDaemonStopped(descriptor, { isProcessAlive, deadline: Date.now() + timeoutMs })) return;
364
- const error = new Error('legacy dashboard daemon did not stop');
365
- error.code = 'DASHBOARD_LEGACY_STOP_FAILED';
366
- throw error;
367
- }
368
-
369
- /**
370
- * descriptorの外に居るdaemonを止める。signalを送るのは、いま再認証を通った相手だけ——
371
- * 応答しないpidへ送れば、pid再利用で無関係のprocessを殺しうる。認証できない生存pidは
372
- * 記録を残したまま見送り、次の起動が判定し直す。
373
- * 認証を通った相手はLattice自身のdaemonなので、SIGTERMで死ななければSIGKILLへ上げる。
374
- * 置換前のlegacy停止と1つの関数へ畳まない——あちらは認証を失ったこと自体がerror
375
- * (版を入れ替える確証が要る)で、こちらは見送ってよい。契約が違う。
376
- */
377
- async function stopStrayDaemon(descriptor, {
378
- signalProcess = process.kill, isProcessAlive = processIsAlive, timeoutMs = 3_000,
379
- } = {}) {
380
- if (await daemonAttestation(descriptor) === null) return false;
381
- signalIfPresent(signalProcess, descriptor.pid, 'SIGTERM');
382
- const half = Math.ceil(timeoutMs / 2);
383
- if (await awaitDaemonStopped(descriptor, { isProcessAlive, deadline: Date.now() + half })) return true;
384
- signalIfPresent(signalProcess, descriptor.pid, 'SIGKILL');
385
- if (await awaitDaemonStopped(descriptor, {
386
- isProcessAlive, deadline: Date.now() + (timeoutMs - half),
387
- })) return true;
388
- const error = new Error('stray dashboard daemon did not stop');
389
- error.code = 'DASHBOARD_ORPHAN_STOP_FAILED';
390
- throw error;
391
- }
392
-
393
- /**
394
- * descriptorが指すdaemon以外を掃除する。呼ぶのはdescriptorの整合が確立した後だけ——
395
- * 失敗経路で呼べば、可用性を守るrollback契約より先に生きたdaemonを消してしまう。
396
- */
397
- async function reapStrayDaemons(refs, keepPid, { signalProcess, isProcessAlive, timeoutMs }) {
398
- for (const record of await sweepDaemonRecords(refs, { isProcessAlive, keep: keepPid })) {
399
- if (record.pid === keepPid) continue;
400
- if (await stopStrayDaemon(record, { signalProcess, isProcessAlive, timeoutMs })) {
401
- await rm(daemonRecordRef(refs, record.pid), { force: true });
402
- }
403
- }
404
- }
405
-
406
- /** descriptorを失った時、登録簿の生存daemonから配信を続けている1つを選ぶ。 */
407
- async function adoptableDaemon(records, { attestationTimeoutMs }) {
408
- let legacy = null;
409
- for (const record of records) {
410
- const attestation = await daemonAttestation(record, { timeoutMs: attestationTimeoutMs });
411
- if (attestation === 'current') return { record, attestation };
412
- if (attestation === 'legacy' && legacy === null) legacy = { record, attestation };
413
- }
414
- return legacy;
415
- }
416
-
417
- async function stopSpawnedReplacement(child, descriptor, {
418
- isProcessAlive = processIsAlive, timeoutMs = 3_000,
419
- } = {}) {
420
- const stopped = async () => {
421
- const exited = child.exitCode !== null || child.signalCode !== null
422
- || !await isProcessAlive(child.pid);
423
- return exited && await daemonAttestation(descriptor) === null;
424
- };
425
- child.kill('SIGTERM');
426
- let deadline = Date.now() + Math.ceil(timeoutMs / 2);
427
- while (Date.now() < deadline) {
428
- if (await stopped()) return;
429
- await new Promise((resolve) => setTimeout(resolve, 50));
430
- }
431
- child.kill('SIGKILL');
432
- deadline = Date.now() + Math.floor(timeoutMs / 2);
433
- while (Date.now() < deadline) {
434
- if (await stopped()) return;
435
- await new Promise((resolve) => setTimeout(resolve, 50));
436
- }
437
- const error = new Error('replacement dashboard daemon rollback did not stop');
438
- error.code = 'DASHBOARD_REPLACEMENT_ROLLBACK_FAILED';
439
- throw error;
440
- }
441
-
442
- export async function writeTodoDashboardDaemonDescriptor({ port, env = process.env }) {
443
- if (!Number.isSafeInteger(port) || port <= 0 || port > 65_535) throw new TypeError('daemon port invalid');
444
- const refs = paths(env);
445
- await mkdir(refs.daemons, { recursive: true, mode: 0o700 });
446
- const record = { schema: DAEMON_SCHEMA, pid: process.pid, port,
447
- started_at: new Date().toISOString() };
448
- // 記録を先に置く。descriptorだけが在って記録が無い瞬間を作ると、その隙に起動側が
449
- // 死んだ時に、まさに直そうとしている「誰からも観測されないdaemon」が生まれる。
450
- await atomicJson(daemonRecordRef(refs, process.pid), record);
451
- await atomicJson(refs.descriptor, record);
452
- }
453
-
454
- /** 自分の記録だけを消す。descriptorは起動側が所有するので、死ぬ側からは触らない。 */
455
- export async function forgetTodoDashboardDaemonRecord({ env = process.env } = {}) {
456
- await rm(daemonRecordRef(paths(env), process.pid), { force: true });
457
- }
458
-
459
- export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon = spawn,
460
- signalProcess = process.kill, isProcessAlive = processIsAlive, startupTimeoutMs = 120_000,
461
- legacyStopTimeoutMs = 3_000, replacementIsProcessAlive = processIsAlive,
462
- attestationTimeoutMs = 2_000, strayStopTimeoutMs = 3_000 } = {}) {
463
- const refs = paths(env);
464
- await mkdir(refs.root, { recursive: true, mode: 0o700 });
465
- // 掃除と孤児の始末の機会はここしかない。daemonの起動は全daemonが必ず通る一点であり、
466
- // 誰かがコマンドを叩くのを待つ形にすると、記録は誰にも掃除されず積み上がる。
467
- const reap = (keepPid) => reapStrayDaemons(refs, keepPid,
468
- { signalProcess, isProcessAlive, timeoutMs: strayStopTimeoutMs });
469
- return withLock(refs.startupLock, async () => {
470
- const existing = await readJson(refs.descriptor, null);
471
- const existingAttestation = await daemonAttestation(existing, { timeoutMs: attestationTimeoutMs });
472
- if (existingAttestation === 'current') {
473
- await reap(existing.pid);
474
- return existing;
475
- }
476
- if (validDaemonDescriptor(existing) && existingAttestation === null
477
- && await isProcessAlive(existing.pid)) {
478
- const error = new Error('dashboard daemon is alive but temporarily unresponsive');
479
- error.code = 'DASHBOARD_DAEMON_UNRESPONSIVE';
480
- throw error;
481
- }
482
- let legacy = existingAttestation === 'legacy' ? existing : null;
483
- if (legacy === null) {
484
- // descriptorの指す先が居ない。登録簿に配信中のdaemonが残っているなら、2本目を
485
- // 建てずに引き取る——descriptorだけが失われた場合の直接の修理である。
486
- const adopted = await adoptableDaemon(
487
- (await sweepDaemonRecords(refs, { isProcessAlive })).filter(({ pid }) => pid !== existing?.pid),
488
- { attestationTimeoutMs },
489
- );
490
- if (adopted !== null) {
491
- // 引き取った相手は生きて応答しているので、descriptorの不変条件を満たす。
492
- await atomicJson(refs.descriptor, adopted.record);
493
- if (adopted.attestation === 'current') {
494
- await reap(adopted.record.pid);
495
- return adopted.record;
496
- }
497
- legacy = adopted.record;
498
- }
499
- }
500
- const portText = env.LATTICE_DASHBOARD_PORT;
501
- const configuredPort = typeof portText === 'string' && /^(?:0|[1-9][0-9]{0,4})$/u.test(portText)
502
- && Number(portText) <= 65_535 ? Number(portText) : DEFAULT_PORT;
503
- const port = legacy === null ? configuredPort : 0;
504
- if (legacy === null) await rm(refs.descriptor, { force: true });
505
- const child = spawnDaemon(process.execPath, [path.resolve(import.meta.dirname, '../bin/lattice-dashboard.mjs')], {
506
- detached: true,
507
- stdio: 'ignore',
508
- env: { ...env, LATTICE_DASHBOARD_RUNTIME_DIR: refs.root, LATTICE_DASHBOARD_PORT: String(port) },
509
- });
510
- child.unref();
511
- // 起動待ちの終わりを固定秒数で決めない。それは機械の速さの見積りになり、遅い側
512
- // へ外すとpublish済みのcodeを載せたdaemonへ入れ替われず、公開面が古いまま取り
513
- // 残される。daemonはdescriptorを書く前に登録済み全projectのstoreを読むので、
514
- // 起動時間はproject数とstore規模に比例する(実測: 8 projectで約51秒)。
515
- // 待つのはspawnした子が生きている間だけとし、死んだら即座に諦める。
516
- // startupTimeoutMsは無反応な子に対するbackstopであって起動時間の上限ではない。
517
- const deadline = Date.now() + startupTimeoutMs;
518
- while (Date.now() < deadline) {
519
- await new Promise((resolve) => setTimeout(resolve, 50));
520
- const descriptor = await readJson(refs.descriptor, null);
521
- const healthy = await daemonHealthy(descriptor);
522
- if (!healthy && typeof child.pid === 'number'
523
- && !await replacementIsProcessAlive(child.pid)) break;
524
- if (healthy && descriptor.pid !== legacy?.pid) {
525
- if (legacy !== null) {
526
- try {
527
- await stopAttestedLegacyDaemon(legacy, {
528
- signalProcess, isProcessAlive, timeoutMs: legacyStopTimeoutMs,
529
- });
530
- } catch (error) {
531
- await stopSpawnedReplacement(child, descriptor, { isProcessAlive: replacementIsProcessAlive });
532
- await atomicJson(refs.descriptor, legacy);
533
- throw error;
534
- }
535
- }
536
- await reap(descriptor.pid);
537
- return descriptor;
538
- }
539
- }
540
- child.kill?.('SIGTERM');
541
- if (legacy !== null) await atomicJson(refs.descriptor, legacy);
542
- else await rm(refs.descriptor, { force: true });
543
- const error = new Error('dashboard daemon did not become ready');
544
- error.code = 'DASHBOARD_DAEMON_UNAVAILABLE';
545
- throw error;
546
- });
547
- }
548
-
549
- export async function ensureTodoDashboardActivity(options) {
550
- const registered = await registerTodoDashboardActivity(options);
551
- const daemon = await ensureTodoDashboardDaemon({ env: options.env });
552
- const deadline = Date.now() + 2_500;
553
- let visible = false;
554
- while (!visible && Date.now() < deadline) {
555
- try {
556
- const response = await fetch(`http://127.0.0.1:${daemon.port}/__lattice/health`, {
557
- signal: AbortSignal.timeout(500),
558
- });
559
- const health = response.status === 200 ? await response.json() : null;
560
- visible = health?.schema === 'lattice.todo_dashboard_health.v1'
561
- && health.port === daemon.port
562
- && health.project_ids?.includes(registered.projectId);
563
- } catch { visible = false; }
564
- if (!visible) await new Promise((resolve) => setTimeout(resolve, 50));
565
- }
566
- if (!visible) {
567
- const error = new Error('registered project did not become visible');
568
- error.code = 'DASHBOARD_PROJECT_UNAVAILABLE';
569
- throw error;
570
- }
571
- return { ...registered, host: '127.0.0.1', port: daemon.port,
572
- url: `http://127.0.0.1:${daemon.port}/projects/${encodeURIComponent(registered.projectId)}/` };
573
- }
1
+ import { spawn } from 'node:child_process';
2
+ import { randomBytes } from 'node:crypto';
3
+ import { homedir } from 'node:os';
4
+ import {
5
+ mkdir, open, readdir, readFile, realpath, rename, rm, stat, writeFile,
6
+ } from 'node:fs/promises';
7
+ import path from 'node:path';
8
+
9
+ import packageJson from '../package.json' with { type: 'json' };
10
+ import { isAuditPendingPhaseStatus } from './todo-audit-pending.mjs';
11
+
12
+ /**
13
+ * The version of the code in THIS process. A daemon loads its modules once at
14
+ * startup and keeps serving them, so installing a new package does not change
15
+ * what the running daemon serves. The daemon reports the version it started
16
+ * with; anything else is code that has already been replaced on disk.
17
+ */
18
+ export const TODO_DASHBOARD_CODE_VERSION = packageJson.version;
19
+
20
+ const REGISTRY_SCHEMA = 'lattice.todo_dashboard_registry.v1';
21
+ const DAEMON_SCHEMA = 'lattice.todo_dashboard_daemon.v1';
22
+ const DEFAULT_PORT = 0;
23
+ export const TODO_DASHBOARD_STALE_MS = 2 * 60 * 60 * 1_000;
24
+ const LOCK_ATTEMPTS = 240;
25
+ const LOCK_WAIT_MS = 25;
26
+ const LOCK_STALE_MS = 30_000;
27
+
28
+ function identifier(value) {
29
+ return typeof value === 'string' && /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u.test(value);
30
+ }
31
+
32
+ function runtimeDir(env) {
33
+ const configured = env.LATTICE_DASHBOARD_RUNTIME_DIR;
34
+ return typeof configured === 'string' && path.isAbsolute(configured)
35
+ ? configured : path.join(homedir(), '.lattice', 'dashboard');
36
+ }
37
+
38
+ function paths(env) {
39
+ const root = runtimeDir(env);
40
+ return {
41
+ root,
42
+ registry: path.join(root, 'projects.json'),
43
+ descriptor: path.join(root, 'daemon.json'),
44
+ daemons: path.join(root, 'daemons'),
45
+ lock: path.join(root, 'registry.lock'),
46
+ startupLock: path.join(root, 'daemon-start.lock'),
47
+ };
48
+ }
49
+
50
+ function validEntry(entry) {
51
+ return entry !== null && typeof entry === 'object' && !Array.isArray(entry)
52
+ && Object.keys(entry).sort().join(',') === 'display_name,last_seen_at,project_id,repo_root,session_id'
53
+ && identifier(entry.project_id) && identifier(entry.session_id)
54
+ && typeof entry.display_name === 'string' && entry.display_name.length > 0
55
+ && typeof entry.repo_root === 'string' && path.isAbsolute(entry.repo_root)
56
+ && Number.isFinite(Date.parse(entry.last_seen_at));
57
+ }
58
+
59
+ /** active taskが無くても、監査の判断待ち・棄却後なら公開工程から消してはいけない。 */
60
+ export function todoDashboardMemberNeedsVisibility(member) {
61
+ return Array.isArray(member?.phases)
62
+ && member.phases.some(({ status }) => isAuditPendingPhaseStatus(status));
63
+ }
64
+
65
+ function validateRegistry(value) {
66
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
67
+ || value.schema !== REGISTRY_SCHEMA || !Array.isArray(value.projects)
68
+ || !value.projects.every(validEntry)) {
69
+ const error = new Error('dashboard registry schema invalid');
70
+ error.code = 'DASHBOARD_REGISTRY_INVALID';
71
+ throw error;
72
+ }
73
+ const ids = new Set();
74
+ for (const entry of value.projects) {
75
+ if (ids.has(entry.project_id)) {
76
+ const error = new Error(`dashboard project duplicate: ${entry.project_id}`);
77
+ error.code = 'DASHBOARD_REGISTRY_INVALID';
78
+ throw error;
79
+ }
80
+ ids.add(entry.project_id);
81
+ }
82
+ return value;
83
+ }
84
+
85
+ async function readJson(ref, missing) {
86
+ let bytes;
87
+ try { bytes = await readFile(ref, 'utf8'); } catch (error) {
88
+ if (error?.code === 'ENOENT') return missing;
89
+ throw error;
90
+ }
91
+ try { return JSON.parse(bytes); } catch {
92
+ const error = new Error(`invalid JSON: ${ref}`);
93
+ error.code = 'DASHBOARD_REGISTRY_INVALID';
94
+ throw error;
95
+ }
96
+ }
97
+
98
+ async function atomicJson(ref, value) {
99
+ const temporary = `${ref}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`;
100
+ await writeFile(temporary, `${JSON.stringify(value)}\n`, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
101
+ await rename(temporary, ref);
102
+ }
103
+
104
+ async function withLock(lockRef, action) {
105
+ for (let attempt = 0; attempt < LOCK_ATTEMPTS; attempt += 1) {
106
+ let handle;
107
+ try {
108
+ handle = await open(lockRef, 'wx', 0o600);
109
+ await handle.writeFile(`${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`);
110
+ try { return await action(); } finally {
111
+ await handle.close();
112
+ await rm(lockRef, { force: true });
113
+ }
114
+ } catch (error) {
115
+ if (error?.code !== 'EEXIST') throw error;
116
+ try {
117
+ const lock = JSON.parse(await readFile(lockRef, 'utf8'));
118
+ let alive = Number.isSafeInteger(lock.pid) && lock.pid > 0;
119
+ if (alive) {
120
+ try { process.kill(lock.pid, 0); } catch { alive = false; }
121
+ }
122
+ if (!alive || Date.now() - Date.parse(lock.created_at) > LOCK_STALE_MS) {
123
+ await rm(lockRef, { force: true });
124
+ continue;
125
+ }
126
+ } catch (lockError) {
127
+ if (lockError?.code === 'ENOENT') continue;
128
+ }
129
+ await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_MS));
130
+ }
131
+ }
132
+ const error = new Error('dashboard registry lock timeout');
133
+ error.code = 'DASHBOARD_REGISTRY_BUSY';
134
+ throw error;
135
+ }
136
+
137
+ export async function readActiveTodoDashboardProjects({ env = process.env, now = Date.now() } = {}) {
138
+ const ref = paths(env).registry;
139
+ const document = validateRegistry(await readJson(ref, { schema: REGISTRY_SCHEMA, projects: [] }));
140
+ return document.projects.filter((entry) => now - Date.parse(entry.last_seen_at) <= TODO_DASHBOARD_STALE_MS)
141
+ .sort((left, right) => left.display_name.localeCompare(right.display_name, 'ja')
142
+ || left.project_id.localeCompare(right.project_id, 'en'));
143
+ }
144
+
145
+ export async function readVisibleTodoDashboardProjects({
146
+ env = process.env, now = Date.now(), projectHasActiveRun,
147
+ } = {}) {
148
+ if (typeof projectHasActiveRun !== 'function') throw new TypeError('projectHasActiveRun required');
149
+ const ref = paths(env).registry;
150
+ const document = validateRegistry(await readJson(ref, { schema: REGISTRY_SCHEMA, projects: [] }));
151
+ const visible = [];
152
+ for (const entry of document.projects) {
153
+ if (now - Date.parse(entry.last_seen_at) <= TODO_DASHBOARD_STALE_MS
154
+ || await projectHasActiveRun(entry)) visible.push(entry);
155
+ }
156
+ return visible.sort((left, right) => left.display_name.localeCompare(right.display_name, 'ja')
157
+ || left.project_id.localeCompare(right.project_id, 'en'));
158
+ }
159
+
160
+ /**
161
+ * 見るのはrepo_root directoryの存在だけ。`.lattice`の有無で判定すると、storeを持たない
162
+ * 生きたrepoまで登録簿から消える。ENOENT/ENOTDIR以外のerrorは「消えた証拠」ではないので
163
+ * 生存扱いのまま残す——判定できない相手を消す方が損害が大きい。
164
+ */
165
+ async function repoRootPresent(ref) {
166
+ try { return (await stat(ref)).isDirectory(); } catch (error) {
167
+ return !['ENOENT', 'ENOTDIR'].includes(error?.code);
168
+ }
169
+ }
170
+
171
+ export async function registerTodoDashboardActivity({
172
+ repoRoot, projectId, displayName = projectId, sessionId, env = process.env,
173
+ now = new Date(), adopt = false,
174
+ }) {
175
+ if (!path.isAbsolute(repoRoot) || !identifier(projectId) || !identifier(sessionId)
176
+ || typeof displayName !== 'string' || displayName.length === 0 || displayName !== displayName.trim()
177
+ || !(now instanceof Date) || !Number.isFinite(now.getTime()) || typeof adopt !== 'boolean') {
178
+ throw new TypeError('dashboard activity is invalid');
179
+ }
180
+ const canonicalRoot = await realpath(repoRoot);
181
+ const refs = paths(env);
182
+ await mkdir(refs.root, { recursive: true, mode: 0o700 });
183
+ const pruned = [];
184
+ await withLock(refs.lock, async () => {
185
+ const current = validateRegistry(await readJson(refs.registry, { schema: REGISTRY_SCHEMA, projects: [] }));
186
+ const existing = current.projects.find((entry) => entry.project_id === projectId);
187
+ if (existing !== undefined && existing.repo_root !== canonicalRoot && !adopt) {
188
+ const error = new Error('project id is already owned by another canonical root');
189
+ error.code = 'PROJECT_ROOT_CONFLICT';
190
+ // 公開・CLI errorへlocal absolute pathを運ばない。project_idだけで人と機械が
191
+ // 衝突対象を特定でき、registry bytesはこの分岐より後で一切変更しない。
192
+ error.detail = {
193
+ project_id: projectId,
194
+ next_action: 'lattice todo dashboard adopt --json',
195
+ };
196
+ throw error;
197
+ }
198
+ // 死んだ登録を落とす機会はここしかない。登録は全sessionが必ず通る一点であり、
199
+ // 掃除を「人がコマンドを叩いた時」に置くと、誰も叩かないので永久に積み上がる。
200
+ const projects = [];
201
+ for (const entry of current.projects) {
202
+ if (entry.project_id === projectId) continue;
203
+ if (await repoRootPresent(entry.repo_root)) projects.push(entry);
204
+ else pruned.push(entry.project_id);
205
+ }
206
+ projects.push({ project_id: projectId, display_name: displayName, repo_root: canonicalRoot,
207
+ session_id: sessionId, last_seen_at: now.toISOString() });
208
+ projects.sort((left, right) => left.project_id.localeCompare(right.project_id, 'en'));
209
+ pruned.sort((left, right) => left.localeCompare(right, 'en'));
210
+ await atomicJson(refs.registry, { schema: REGISTRY_SCHEMA, projects });
211
+ });
212
+ return { projectId, displayName, repoRoot: canonicalRoot, sessionId, adopted: adopt, pruned };
213
+ }
214
+
215
+ /**
216
+ * 登録簿から1件を明示的に外す唯一の入口。対象repoが既に消えていても叩けるよう、
217
+ * storeもdaemonも経由しない。該当が無いのは暗黙の成功にしない——「消したつもり」で
218
+ * 別のproject_idが残り続ける状態を、成功の応答で隠すことになる。
219
+ */
220
+ export async function removeTodoDashboardProject({ projectId, env = process.env }) {
221
+ if (!identifier(projectId)) throw new TypeError('dashboard project id is invalid');
222
+ const refs = paths(env);
223
+ await mkdir(refs.root, { recursive: true, mode: 0o700 });
224
+ await withLock(refs.lock, async () => {
225
+ const current = validateRegistry(await readJson(refs.registry, { schema: REGISTRY_SCHEMA, projects: [] }));
226
+ const projects = current.projects.filter((entry) => entry.project_id !== projectId);
227
+ if (projects.length === current.projects.length) {
228
+ const error = new Error('dashboard project is not registered');
229
+ error.code = 'PROJECT_NOT_REGISTERED';
230
+ // 公開・CLI errorへlocal absolute pathを運ばない。project_idだけで対象を特定できる。
231
+ error.detail = { project_id: projectId };
232
+ throw error;
233
+ }
234
+ await atomicJson(refs.registry, { schema: REGISTRY_SCHEMA, projects });
235
+ });
236
+ return { projectId, removed: true };
237
+ }
238
+
239
+ /** 配信元rootを移す唯一の明示入口。通常activity登録はこのflagを立てない。 */
240
+ export async function adoptTodoDashboardActivity(options) {
241
+ return registerTodoDashboardActivity({ ...options, adopt: true });
242
+ }
243
+
244
+ function validDaemonDescriptor(descriptor) {
245
+ return descriptor !== null && typeof descriptor === 'object' && !Array.isArray(descriptor)
246
+ && Object.keys(descriptor).sort().join(',') === 'pid,port,schema,started_at'
247
+ && descriptor.schema === DAEMON_SCHEMA
248
+ && Number.isSafeInteger(descriptor.pid) && descriptor.pid > 0
249
+ && Number.isSafeInteger(descriptor.port) && descriptor.port > 0 && descriptor.port <= 65_535
250
+ && typeof descriptor.started_at === 'string' && Number.isFinite(Date.parse(descriptor.started_at));
251
+ }
252
+
253
+ async function daemonAttestation(descriptor, { timeoutMs = 2_000 } = {}) {
254
+ if (!validDaemonDescriptor(descriptor)) return null;
255
+ try {
256
+ const response = await fetch(`http://127.0.0.1:${descriptor.port}/__lattice/health`, {
257
+ signal: AbortSignal.timeout(timeoutMs),
258
+ });
259
+ if (response.status !== 200) return null;
260
+ const body = await response.json();
261
+ if (body?.schema !== 'lattice.todo_dashboard_health.v1' || body.pid !== descriptor.pid
262
+ || !Array.isArray(body.project_ids)) return null;
263
+ const keys = Object.keys(body).sort().join(',');
264
+ // 'legacy' means "alive, but serving code we have already replaced" — the
265
+ // caller starts a replacement and stops it. A daemon that predates the
266
+ // version field, or one still running an older package, is exactly that.
267
+ if (keys === 'pid,port,project_ids,schema,version' && body.port === descriptor.port) {
268
+ return body.version === TODO_DASHBOARD_CODE_VERSION ? 'current' : 'legacy';
269
+ }
270
+ if (keys === 'pid,port,project_ids,schema' && body.port === descriptor.port) return 'legacy';
271
+ if (keys === 'pid,project_ids,schema') return 'legacy';
272
+ return null;
273
+ } catch { return null; }
274
+ }
275
+
276
+ async function daemonHealthy(descriptor) {
277
+ return await daemonAttestation(descriptor) === 'current';
278
+ }
279
+
280
+ function processIsAlive(pid) {
281
+ try { process.kill(pid, 0); return true; } catch (error) {
282
+ if (error?.code === 'ESRCH') return false;
283
+ throw error;
284
+ }
285
+ }
286
+
287
+ function daemonRecordRef(refs, pid) {
288
+ return path.join(refs.daemons, `${pid}.json`);
289
+ }
290
+
291
+ /**
292
+ * daemonごとの記録。descriptorは1枚しか無いので、そこから落ちたdaemonは二度と誰にも
293
+ * 観測されず、生きたまま不死になる(2026-08-03に2重起動を実測)。descriptorの所有者は
294
+ * 起動側のままにして、pidごとの記録だけをdaemon自身に書かせる——記録があれば、
295
+ * descriptorを失っても後続の起動が見つけられる。
296
+ */
297
+ async function readDaemonRecords(refs) {
298
+ let names;
299
+ try { names = await readdir(refs.daemons); } catch (error) {
300
+ if (error?.code === 'ENOENT') return [];
301
+ throw error;
302
+ }
303
+ const records = [];
304
+ for (const name of names) {
305
+ if (!/^[1-9][0-9]{0,9}\.json$/u.test(name)) continue;
306
+ const ref = path.join(refs.daemons, name);
307
+ let record = null;
308
+ try { record = JSON.parse(await readFile(ref, 'utf8')); } catch { record = null; }
309
+ // 壊れた記録、file名のpidと中身が食い違う記録は、誰の生死も語れないので捨てる。
310
+ // 記録はatomicJsonで置かれるので、読み手が書きかけを見ることはない。
311
+ if (!validDaemonDescriptor(record) || `${record.pid}.json` !== name) {
312
+ await rm(ref, { force: true });
313
+ continue;
314
+ }
315
+ records.push(record);
316
+ }
317
+ return records;
318
+ }
319
+
320
+ /**
321
+ * 死んだpidの記録を落とす。全daemonが必ず通る起動時に呼ぶので、登録簿は
322
+ * 「生きているdaemon+前回の起動以降に死んだ分」までしか育たない。
323
+ * keepはhealth応答で生存を確認済みのdaemon——process.kill(0)より強い証拠を
324
+ * 既に持っているので、その記録は生死判定に掛けない。
325
+ */
326
+ async function sweepDaemonRecords(refs, { isProcessAlive, keep = null }) {
327
+ const live = [];
328
+ for (const record of await readDaemonRecords(refs)) {
329
+ if (record.pid === keep || await isProcessAlive(record.pid)) live.push(record);
330
+ else await rm(daemonRecordRef(refs, record.pid), { force: true });
331
+ }
332
+ return live;
333
+ }
334
+
335
+ async function awaitDaemonStopped(descriptor, { isProcessAlive, deadline }) {
336
+ for (;;) {
337
+ const alive = await isProcessAlive(descriptor.pid);
338
+ const stillServing = await daemonAttestation(descriptor) !== null;
339
+ if (!alive && !stillServing) return true;
340
+ if (Date.now() >= deadline) return false;
341
+ await new Promise((resolve) => setTimeout(resolve, 50));
342
+ }
343
+ }
344
+
345
+ /** 相手が既に死んでいるのは停止の成功であって失敗ではない。 */
346
+ function signalIfPresent(signalProcess, pid, signal) {
347
+ try { signalProcess(pid, signal); } catch (error) {
348
+ if (error?.code !== 'ESRCH') throw error;
349
+ }
350
+ }
351
+
352
+ async function stopAttestedLegacyDaemon(descriptor, {
353
+ signalProcess = process.kill, isProcessAlive = processIsAlive, timeoutMs = 3_000,
354
+ } = {}) {
355
+ const attestation = await daemonAttestation(descriptor);
356
+ if (attestation !== 'legacy') {
357
+ if (!await isProcessAlive(descriptor.pid) && attestation === null) return;
358
+ const error = new Error('legacy dashboard daemon attestation was lost before signal');
359
+ error.code = 'DASHBOARD_LEGACY_ATTESTATION_LOST';
360
+ throw error;
361
+ }
362
+ signalProcess(descriptor.pid, 'SIGTERM');
363
+ if (await awaitDaemonStopped(descriptor, { isProcessAlive, deadline: Date.now() + timeoutMs })) return;
364
+ const error = new Error('legacy dashboard daemon did not stop');
365
+ error.code = 'DASHBOARD_LEGACY_STOP_FAILED';
366
+ throw error;
367
+ }
368
+
369
+ /**
370
+ * descriptorの外に居るdaemonを止める。signalを送るのは、いま再認証を通った相手だけ——
371
+ * 応答しないpidへ送れば、pid再利用で無関係のprocessを殺しうる。認証できない生存pidは
372
+ * 記録を残したまま見送り、次の起動が判定し直す。
373
+ * 認証を通った相手はLattice自身のdaemonなので、SIGTERMで死ななければSIGKILLへ上げる。
374
+ * 置換前のlegacy停止と1つの関数へ畳まない——あちらは認証を失ったこと自体がerror
375
+ * (版を入れ替える確証が要る)で、こちらは見送ってよい。契約が違う。
376
+ */
377
+ async function stopStrayDaemon(descriptor, {
378
+ signalProcess = process.kill, isProcessAlive = processIsAlive, timeoutMs = 3_000,
379
+ } = {}) {
380
+ if (await daemonAttestation(descriptor) === null) return false;
381
+ signalIfPresent(signalProcess, descriptor.pid, 'SIGTERM');
382
+ const half = Math.ceil(timeoutMs / 2);
383
+ if (await awaitDaemonStopped(descriptor, { isProcessAlive, deadline: Date.now() + half })) return true;
384
+ signalIfPresent(signalProcess, descriptor.pid, 'SIGKILL');
385
+ if (await awaitDaemonStopped(descriptor, {
386
+ isProcessAlive, deadline: Date.now() + (timeoutMs - half),
387
+ })) return true;
388
+ const error = new Error('stray dashboard daemon did not stop');
389
+ error.code = 'DASHBOARD_ORPHAN_STOP_FAILED';
390
+ throw error;
391
+ }
392
+
393
+ /**
394
+ * descriptorが指すdaemon以外を掃除する。呼ぶのはdescriptorの整合が確立した後だけ——
395
+ * 失敗経路で呼べば、可用性を守るrollback契約より先に生きたdaemonを消してしまう。
396
+ */
397
+ async function reapStrayDaemons(refs, keepPid, { signalProcess, isProcessAlive, timeoutMs }) {
398
+ for (const record of await sweepDaemonRecords(refs, { isProcessAlive, keep: keepPid })) {
399
+ if (record.pid === keepPid) continue;
400
+ if (await stopStrayDaemon(record, { signalProcess, isProcessAlive, timeoutMs })) {
401
+ await rm(daemonRecordRef(refs, record.pid), { force: true });
402
+ }
403
+ }
404
+ }
405
+
406
+ /** descriptorを失った時、登録簿の生存daemonから配信を続けている1つを選ぶ。 */
407
+ async function adoptableDaemon(records, { attestationTimeoutMs }) {
408
+ let legacy = null;
409
+ for (const record of records) {
410
+ const attestation = await daemonAttestation(record, { timeoutMs: attestationTimeoutMs });
411
+ if (attestation === 'current') return { record, attestation };
412
+ if (attestation === 'legacy' && legacy === null) legacy = { record, attestation };
413
+ }
414
+ return legacy;
415
+ }
416
+
417
+ async function stopSpawnedReplacement(child, descriptor, {
418
+ isProcessAlive = processIsAlive, timeoutMs = 3_000,
419
+ } = {}) {
420
+ const stopped = async () => {
421
+ const exited = child.exitCode !== null || child.signalCode !== null
422
+ || !await isProcessAlive(child.pid);
423
+ return exited && await daemonAttestation(descriptor) === null;
424
+ };
425
+ child.kill('SIGTERM');
426
+ let deadline = Date.now() + Math.ceil(timeoutMs / 2);
427
+ while (Date.now() < deadline) {
428
+ if (await stopped()) return;
429
+ await new Promise((resolve) => setTimeout(resolve, 50));
430
+ }
431
+ child.kill('SIGKILL');
432
+ deadline = Date.now() + Math.floor(timeoutMs / 2);
433
+ while (Date.now() < deadline) {
434
+ if (await stopped()) return;
435
+ await new Promise((resolve) => setTimeout(resolve, 50));
436
+ }
437
+ const error = new Error('replacement dashboard daemon rollback did not stop');
438
+ error.code = 'DASHBOARD_REPLACEMENT_ROLLBACK_FAILED';
439
+ throw error;
440
+ }
441
+
442
+ export async function writeTodoDashboardDaemonDescriptor({ port, env = process.env }) {
443
+ if (!Number.isSafeInteger(port) || port <= 0 || port > 65_535) throw new TypeError('daemon port invalid');
444
+ const refs = paths(env);
445
+ await mkdir(refs.daemons, { recursive: true, mode: 0o700 });
446
+ const record = { schema: DAEMON_SCHEMA, pid: process.pid, port,
447
+ started_at: new Date().toISOString() };
448
+ // 記録を先に置く。descriptorだけが在って記録が無い瞬間を作ると、その隙に起動側が
449
+ // 死んだ時に、まさに直そうとしている「誰からも観測されないdaemon」が生まれる。
450
+ await atomicJson(daemonRecordRef(refs, process.pid), record);
451
+ await atomicJson(refs.descriptor, record);
452
+ }
453
+
454
+ /** 自分の記録だけを消す。descriptorは起動側が所有するので、死ぬ側からは触らない。 */
455
+ export async function forgetTodoDashboardDaemonRecord({ env = process.env } = {}) {
456
+ await rm(daemonRecordRef(paths(env), process.pid), { force: true });
457
+ }
458
+
459
+ export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon = spawn,
460
+ signalProcess = process.kill, isProcessAlive = processIsAlive, startupTimeoutMs = 120_000,
461
+ legacyStopTimeoutMs = 3_000, replacementIsProcessAlive = processIsAlive,
462
+ attestationTimeoutMs = 2_000, strayStopTimeoutMs = 3_000 } = {}) {
463
+ const refs = paths(env);
464
+ await mkdir(refs.root, { recursive: true, mode: 0o700 });
465
+ // 掃除と孤児の始末の機会はここしかない。daemonの起動は全daemonが必ず通る一点であり、
466
+ // 誰かがコマンドを叩くのを待つ形にすると、記録は誰にも掃除されず積み上がる。
467
+ const reap = (keepPid) => reapStrayDaemons(refs, keepPid,
468
+ { signalProcess, isProcessAlive, timeoutMs: strayStopTimeoutMs });
469
+ return withLock(refs.startupLock, async () => {
470
+ const existing = await readJson(refs.descriptor, null);
471
+ const existingAttestation = await daemonAttestation(existing, { timeoutMs: attestationTimeoutMs });
472
+ if (existingAttestation === 'current') {
473
+ await reap(existing.pid);
474
+ return existing;
475
+ }
476
+ if (validDaemonDescriptor(existing) && existingAttestation === null
477
+ && await isProcessAlive(existing.pid)) {
478
+ const error = new Error('dashboard daemon is alive but temporarily unresponsive');
479
+ error.code = 'DASHBOARD_DAEMON_UNRESPONSIVE';
480
+ throw error;
481
+ }
482
+ let legacy = existingAttestation === 'legacy' ? existing : null;
483
+ if (legacy === null) {
484
+ // descriptorの指す先が居ない。登録簿に配信中のdaemonが残っているなら、2本目を
485
+ // 建てずに引き取る——descriptorだけが失われた場合の直接の修理である。
486
+ const adopted = await adoptableDaemon(
487
+ (await sweepDaemonRecords(refs, { isProcessAlive })).filter(({ pid }) => pid !== existing?.pid),
488
+ { attestationTimeoutMs },
489
+ );
490
+ if (adopted !== null) {
491
+ // 引き取った相手は生きて応答しているので、descriptorの不変条件を満たす。
492
+ await atomicJson(refs.descriptor, adopted.record);
493
+ if (adopted.attestation === 'current') {
494
+ await reap(adopted.record.pid);
495
+ return adopted.record;
496
+ }
497
+ legacy = adopted.record;
498
+ }
499
+ }
500
+ const portText = env.LATTICE_DASHBOARD_PORT;
501
+ const configuredPort = typeof portText === 'string' && /^(?:0|[1-9][0-9]{0,4})$/u.test(portText)
502
+ && Number(portText) <= 65_535 ? Number(portText) : DEFAULT_PORT;
503
+ const port = legacy === null ? configuredPort : 0;
504
+ if (legacy === null) await rm(refs.descriptor, { force: true });
505
+ const child = spawnDaemon(process.execPath, [path.resolve(import.meta.dirname, '../bin/lattice-dashboard.mjs')], {
506
+ detached: true,
507
+ stdio: 'ignore',
508
+ env: { ...env, LATTICE_DASHBOARD_RUNTIME_DIR: refs.root, LATTICE_DASHBOARD_PORT: String(port) },
509
+ });
510
+ child.unref();
511
+ // 起動待ちの終わりを固定秒数で決めない。それは機械の速さの見積りになり、遅い側
512
+ // へ外すとpublish済みのcodeを載せたdaemonへ入れ替われず、公開面が古いまま取り
513
+ // 残される。daemonはdescriptorを書く前に登録済み全projectのstoreを読むので、
514
+ // 起動時間はproject数とstore規模に比例する(実測: 8 projectで約51秒)。
515
+ // 待つのはspawnした子が生きている間だけとし、死んだら即座に諦める。
516
+ // startupTimeoutMsは無反応な子に対するbackstopであって起動時間の上限ではない。
517
+ const deadline = Date.now() + startupTimeoutMs;
518
+ while (Date.now() < deadline) {
519
+ await new Promise((resolve) => setTimeout(resolve, 50));
520
+ const descriptor = await readJson(refs.descriptor, null);
521
+ const healthy = await daemonHealthy(descriptor);
522
+ if (!healthy && typeof child.pid === 'number'
523
+ && !await replacementIsProcessAlive(child.pid)) break;
524
+ if (healthy && descriptor.pid !== legacy?.pid) {
525
+ if (legacy !== null) {
526
+ try {
527
+ await stopAttestedLegacyDaemon(legacy, {
528
+ signalProcess, isProcessAlive, timeoutMs: legacyStopTimeoutMs,
529
+ });
530
+ } catch (error) {
531
+ await stopSpawnedReplacement(child, descriptor, { isProcessAlive: replacementIsProcessAlive });
532
+ await atomicJson(refs.descriptor, legacy);
533
+ throw error;
534
+ }
535
+ }
536
+ await reap(descriptor.pid);
537
+ return descriptor;
538
+ }
539
+ }
540
+ child.kill?.('SIGTERM');
541
+ if (legacy !== null) await atomicJson(refs.descriptor, legacy);
542
+ else await rm(refs.descriptor, { force: true });
543
+ const error = new Error('dashboard daemon did not become ready');
544
+ error.code = 'DASHBOARD_DAEMON_UNAVAILABLE';
545
+ throw error;
546
+ });
547
+ }
548
+
549
+ export async function ensureTodoDashboardActivity(options) {
550
+ const registered = await registerTodoDashboardActivity(options);
551
+ const daemon = await ensureTodoDashboardDaemon({ env: options.env });
552
+ const deadline = Date.now() + 2_500;
553
+ let visible = false;
554
+ while (!visible && Date.now() < deadline) {
555
+ try {
556
+ const response = await fetch(`http://127.0.0.1:${daemon.port}/__lattice/health`, {
557
+ signal: AbortSignal.timeout(500),
558
+ });
559
+ const health = response.status === 200 ? await response.json() : null;
560
+ visible = health?.schema === 'lattice.todo_dashboard_health.v1'
561
+ && health.port === daemon.port
562
+ && health.project_ids?.includes(registered.projectId);
563
+ } catch { visible = false; }
564
+ if (!visible) await new Promise((resolve) => setTimeout(resolve, 50));
565
+ }
566
+ if (!visible) {
567
+ const error = new Error('registered project did not become visible');
568
+ error.code = 'DASHBOARD_PROJECT_UNAVAILABLE';
569
+ throw error;
570
+ }
571
+ return { ...registered, host: '127.0.0.1', port: daemon.port,
572
+ url: `http://127.0.0.1:${daemon.port}/projects/${encodeURIComponent(registered.projectId)}/` };
573
+ }