@quolu/lattice 0.52.3 → 0.53.0

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