@quolu/lattice 0.52.0 → 0.52.2

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 (182) hide show
  1. package/LICENSE +147 -147
  2. package/README.ja.md +355 -355
  3. package/README.md +258 -258
  4. package/bin/lattice-mcp.mjs +0 -0
  5. package/bin/lattice-scripted-adapter.mjs +0 -0
  6. package/bin/lattice-scripted-worker.mjs +0 -0
  7. package/bin/lattice-work-order-adapter.mjs +0 -0
  8. package/bin/lattice.mjs +0 -0
  9. package/docs/bridge-setup.md +132 -132
  10. package/docs/schemas/lattice.executor_packet.v1.schema.json +57 -57
  11. package/docs/schemas/lattice.executor_receipt.v1.schema.json +66 -66
  12. package/docs/schemas/lattice.phase_todo_revision.v3.schema.json +360 -360
  13. package/docs/schemas/lattice.plan_create_input.v1.schema.json +56 -56
  14. package/docs/schemas/lattice.plan_create_input.v2.schema.json +72 -72
  15. package/docs/schemas/lattice.plan_create_input.v3.schema.json +81 -81
  16. package/docs/schemas/lattice.plan_create_input.v4.schema.json +85 -85
  17. package/docs/schemas/lattice.run_request.v1.schema.json +238 -238
  18. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -55
  19. package/docs/schemas/lattice.runtime_adapter_registration_input.v1.schema.json +78 -78
  20. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -86
  21. package/docs/schemas/lattice.todo_extraction.v2.schema.json +298 -298
  22. package/docs/schemas/lattice.todo_extraction.v3.schema.json +146 -146
  23. package/docs/schemas/lattice.todo_revision.v2.schema.json +260 -260
  24. package/docs/schemas/lattice.todo_revision_set.v3.schema.json +363 -363
  25. package/package.json +103 -103
  26. package/sensor/LICENSE +21 -21
  27. package/sensor/NOTICE +19 -19
  28. package/sensor/dist/bin/lattice-sensor.js +9 -9
  29. package/sensor/dist/db/index.js +24 -24
  30. package/sensor/dist/db/migrations.js +41 -41
  31. package/sensor/dist/db/queries.js +164 -164
  32. package/sensor/dist/db/schema.sql +205 -205
  33. package/sensor/dist/directory.js +5 -5
  34. package/sensor/dist/extraction/wasm/tree-sitter-c_sharp.wasm +0 -0
  35. package/sensor/dist/extraction/wasm/tree-sitter-cfml.wasm +0 -0
  36. package/sensor/dist/extraction/wasm/tree-sitter-cfquery.wasm +0 -0
  37. package/sensor/dist/extraction/wasm/tree-sitter-cfscript.wasm +0 -0
  38. package/sensor/dist/extraction/wasm/tree-sitter-cobol.wasm +0 -0
  39. package/sensor/dist/extraction/wasm/tree-sitter-erlang.wasm +0 -0
  40. package/sensor/dist/extraction/wasm/tree-sitter-go.wasm +0 -0
  41. package/sensor/dist/extraction/wasm/tree-sitter-java.wasm +0 -0
  42. package/sensor/dist/extraction/wasm/tree-sitter-javascript.wasm +0 -0
  43. package/sensor/dist/extraction/wasm/tree-sitter-nix.wasm +0 -0
  44. package/sensor/dist/extraction/wasm/tree-sitter-pascal.wasm +0 -0
  45. package/sensor/dist/extraction/wasm/tree-sitter-python.wasm +0 -0
  46. package/sensor/dist/extraction/wasm/tree-sitter-tsx.wasm +0 -0
  47. package/sensor/dist/extraction/wasm/tree-sitter-typescript.wasm +0 -0
  48. package/sensor/dist/extraction/wasm/tree-sitter-vbnet.wasm +0 -0
  49. package/sensor/dist/mcp/liveness-watchdog.js +53 -53
  50. package/sensor/dist/mcp/server-instructions.js +95 -95
  51. package/sensor/package.json +56 -56
  52. package/src/artifact-contracts-v2.mjs +325 -325
  53. package/src/artifact-contracts.mjs +895 -895
  54. package/src/boundary-compiler.mjs +712 -712
  55. package/src/boundary-observation-compiler-v2.mjs +344 -344
  56. package/src/bounded-seam.mjs +230 -230
  57. package/src/bridge-address.mjs +107 -107
  58. package/src/bridge-cli.mjs +297 -297
  59. package/src/bridge-config.mjs +346 -346
  60. package/src/bridge-daemon.mjs +378 -378
  61. package/src/bridge-launch-agent.mjs +323 -323
  62. package/src/bridge-registrar.mjs +102 -102
  63. package/src/bridge-server.mjs +376 -376
  64. package/src/cli-help.mjs +308 -308
  65. package/src/cli-stdio.mjs +40 -40
  66. package/src/control-compiler.mjs +532 -532
  67. package/src/dag-chain.mjs +261 -261
  68. package/src/factory-diagnostics.mjs +188 -188
  69. package/src/hash-chain.mjs +76 -76
  70. package/src/hooks-cli.mjs +1057 -1053
  71. package/src/isolation-runner.mjs +409 -409
  72. package/src/node-version-guard.mjs +44 -44
  73. package/src/project-cli.mjs +697 -697
  74. package/src/project-identity.mjs +130 -130
  75. package/src/rc1-black-box-oracle.mjs +906 -906
  76. package/src/rc1-comparison.mjs +154 -154
  77. package/src/rc1-evidence-bundle.mjs +456 -456
  78. package/src/rc1-v4-campaign.mjs +708 -708
  79. package/src/rc1-v4-transform.mjs +467 -467
  80. package/src/rc1-v5-artifact-set.mjs +855 -855
  81. package/src/rc1-v5-behavior-evidence.mjs +594 -594
  82. package/src/rc1-v5-campaign.mjs +797 -797
  83. package/src/rc1-v5-transform.mjs +421 -421
  84. package/src/rc1-v6-artifact-set.mjs +807 -807
  85. package/src/rc1-v6-behavior-evidence.mjs +273 -273
  86. package/src/rc1-v6-campaign.mjs +623 -623
  87. package/src/rc1-v6-causal-binding.mjs +473 -473
  88. package/src/rc1-v6-measurement.mjs +313 -313
  89. package/src/rc2-artifact-set.mjs +1584 -1584
  90. package/src/rc2-campaign.mjs +1506 -1502
  91. package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
  92. package/src/rc2-delivery-policy-oracle.mjs +134 -134
  93. package/src/rc2-delivery-policy-transform.mjs +1127 -1127
  94. package/src/rc2-rc1-transfer-front-end.mjs +511 -511
  95. package/src/rc3-actual-dogfood.mjs +652 -652
  96. package/src/rc3-dogfood-scaffold.mjs +315 -315
  97. package/src/rc3-scripted-campaign.mjs +1383 -1383
  98. package/src/rc4-stage1-dogfood.mjs +673 -673
  99. package/src/runtime-adapter-registry.mjs +524 -520
  100. package/src/runtime-cli.mjs +4588 -4584
  101. package/src/runtime-contracts.mjs +824 -824
  102. package/src/runtime-control-store.mjs +604 -600
  103. package/src/runtime-controller-protocol.mjs +587 -587
  104. package/src/runtime-decision-verifier.mjs +701 -701
  105. package/src/runtime-diff-observer.mjs +361 -361
  106. package/src/runtime-direct-os-observer.mjs +301 -301
  107. package/src/runtime-driver-state.mjs +166 -162
  108. package/src/runtime-engine.mjs +779 -779
  109. package/src/runtime-errors.mjs +356 -356
  110. package/src/runtime-event-store.mjs +189 -189
  111. package/src/runtime-front-end.mjs +925 -925
  112. package/src/runtime-gate-store.mjs +481 -477
  113. package/src/runtime-hold-recompile.mjs +916 -916
  114. package/src/runtime-io-sentinel.mjs +391 -391
  115. package/src/runtime-lifecycle-lock.mjs +294 -290
  116. package/src/runtime-managed-supervisor.mjs +1490 -1490
  117. package/src/runtime-multi-epoch-store.mjs +838 -834
  118. package/src/runtime-projection.mjs +269 -269
  119. package/src/runtime-pull-intake.mjs +1192 -1188
  120. package/src/runtime-scripted-adapter-controller.mjs +1160 -1156
  121. package/src/runtime-scripted-executor.mjs +163 -163
  122. package/src/runtime-scripted-worktree.mjs +104 -104
  123. package/src/runtime-seam-resolve.mjs +428 -428
  124. package/src/runtime-seam-treatment.mjs +173 -173
  125. package/src/runtime-socket-owner.mjs +125 -125
  126. package/src/runtime-work-order-contracts.mjs +91 -91
  127. package/src/runtime-work-order-controller.mjs +1171 -1167
  128. package/src/runtime-worktree-executor.mjs +199 -199
  129. package/src/schedulability-compiler-v2.mjs +303 -303
  130. package/src/schedulability-verifier-v2.mjs +317 -317
  131. package/src/seam-apply.mjs +549 -549
  132. package/src/seam-commit-shared.mjs +22 -22
  133. package/src/seam-commit-transform.mjs +81 -81
  134. package/src/seam-commit.mjs +18 -18
  135. package/src/seam-cost.mjs +322 -322
  136. package/src/seam-derivation.mjs +188 -188
  137. package/src/seam-gate.mjs +146 -146
  138. package/src/seam-proposal-contracts.mjs +446 -446
  139. package/src/seam-proposal-queries.mjs +521 -521
  140. package/src/seam-proposal.mjs +2011 -2011
  141. package/src/seam-ref.mjs +33 -33
  142. package/src/seam-rewrite.mjs +286 -286
  143. package/src/seam-transform.mjs +554 -554
  144. package/src/seam-verification.mjs +260 -260
  145. package/src/sensor-adapter.mjs +432 -432
  146. package/src/sensor-cli.mjs +139 -139
  147. package/src/sensor-diff.mjs +661 -661
  148. package/src/sensor-node-runtime.mjs +53 -53
  149. package/src/sensor-runtime.mjs +52 -52
  150. package/src/timestamp-contract.mjs +8 -8
  151. package/src/todo-audit-pending.mjs +91 -91
  152. package/src/todo-chain.mjs +178 -178
  153. package/src/todo-cli.mjs +3141 -3126
  154. package/src/todo-contracts.mjs +728 -728
  155. package/src/todo-dashboard-registry.mjs +573 -573
  156. package/src/todo-dispatch-shape.mjs +190 -190
  157. package/src/todo-gantt-html-independence.mjs +239 -239
  158. package/src/todo-gantt-html-shared.mjs +226 -226
  159. package/src/todo-gantt-html-style.mjs +131 -131
  160. package/src/todo-gantt-html.mjs +248 -248
  161. package/src/todo-gantt-layout.mjs +974 -974
  162. package/src/todo-gantt-live.mjs +361 -361
  163. package/src/todo-gantt-nested.mjs +263 -263
  164. package/src/todo-gantt-presentation.mjs +217 -217
  165. package/src/todo-gantt-scope.mjs +123 -123
  166. package/src/todo-gantt-svg.mjs +353 -353
  167. package/src/todo-independence-contracts.mjs +595 -595
  168. package/src/todo-independence-guidance.mjs +322 -322
  169. package/src/todo-independence.mjs +640 -640
  170. package/src/todo-markdown-renderer.mjs +260 -260
  171. package/src/todo-migration.mjs +448 -448
  172. package/src/todo-narrative-anchor.mjs +130 -130
  173. package/src/todo-note-store.mjs +629 -625
  174. package/src/todo-parallel-candidates.mjs +114 -114
  175. package/src/todo-revision.mjs +995 -995
  176. package/src/todo-split.mjs +472 -472
  177. package/src/todo-status.mjs +690 -690
  178. package/src/todo-store-git-transaction.mjs +418 -418
  179. package/src/todo-store.mjs +4322 -4314
  180. package/src/treatment-compiler.mjs +728 -728
  181. package/src/treatment-runner.mjs +656 -656
  182. 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
+ }