@quolu/lattice 0.52.2 → 0.52.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/artifact-contracts-v2.mjs +325 -325
- package/src/artifact-contracts.mjs +895 -895
- package/src/boundary-compiler.mjs +712 -712
- package/src/boundary-observation-compiler-v2.mjs +344 -344
- package/src/bounded-seam.mjs +230 -230
- package/src/bridge-address.mjs +107 -107
- package/src/bridge-cli.mjs +297 -297
- package/src/bridge-config.mjs +346 -346
- package/src/bridge-daemon.mjs +378 -378
- package/src/bridge-hub-protocol.mjs +198 -0
- package/src/bridge-launch-agent.mjs +323 -323
- package/src/bridge-registrar.mjs +102 -102
- package/src/bridge-server.mjs +376 -376
- package/src/cli-help.mjs +308 -308
- package/src/cli-stdio.mjs +40 -40
- package/src/control-compiler.mjs +532 -532
- package/src/dag-chain.mjs +261 -261
- package/src/factory-diagnostics.mjs +188 -188
- package/src/git-process.mjs +72 -0
- package/src/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1057
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +699 -697
- package/src/project-identity.mjs +130 -130
- package/src/rc1-black-box-oracle.mjs +906 -906
- package/src/rc1-comparison.mjs +154 -154
- package/src/rc1-evidence-bundle.mjs +456 -456
- package/src/rc1-v4-campaign.mjs +708 -708
- package/src/rc1-v4-transform.mjs +467 -467
- package/src/rc1-v5-artifact-set.mjs +855 -855
- package/src/rc1-v5-behavior-evidence.mjs +594 -594
- package/src/rc1-v5-campaign.mjs +797 -797
- package/src/rc1-v5-transform.mjs +421 -421
- package/src/rc1-v6-artifact-set.mjs +807 -807
- package/src/rc1-v6-behavior-evidence.mjs +273 -273
- package/src/rc1-v6-campaign.mjs +623 -623
- package/src/rc1-v6-causal-binding.mjs +473 -473
- package/src/rc1-v6-measurement.mjs +313 -313
- package/src/rc2-artifact-set.mjs +1584 -1584
- package/src/rc2-campaign.mjs +1506 -1506
- package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
- package/src/rc2-delivery-policy-oracle.mjs +134 -134
- package/src/rc2-delivery-policy-transform.mjs +1127 -1127
- package/src/rc2-rc1-transfer-front-end.mjs +511 -511
- package/src/rc3-actual-dogfood.mjs +652 -652
- package/src/rc3-dogfood-scaffold.mjs +315 -315
- package/src/rc3-scripted-campaign.mjs +1383 -1383
- package/src/rc4-stage1-dogfood.mjs +673 -673
- package/src/runtime-adapter-registry.mjs +524 -524
- package/src/runtime-cli.mjs +4588 -4588
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -604
- package/src/runtime-controller-protocol.mjs +587 -587
- package/src/runtime-decision-verifier.mjs +701 -701
- package/src/runtime-diff-observer.mjs +361 -361
- package/src/runtime-direct-os-observer.mjs +301 -301
- package/src/runtime-driver-state.mjs +166 -166
- package/src/runtime-engine.mjs +779 -779
- package/src/runtime-errors.mjs +356 -356
- package/src/runtime-event-store.mjs +189 -189
- package/src/runtime-front-end.mjs +925 -925
- package/src/runtime-gate-store.mjs +481 -481
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -294
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -838
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1192
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
- package/src/runtime-scripted-executor.mjs +163 -163
- package/src/runtime-scripted-worktree.mjs +104 -104
- package/src/runtime-seam-resolve.mjs +428 -428
- package/src/runtime-seam-treatment.mjs +173 -173
- package/src/runtime-socket-owner.mjs +125 -125
- package/src/runtime-work-order-contracts.mjs +91 -91
- package/src/runtime-work-order-controller.mjs +1171 -1171
- package/src/runtime-worktree-executor.mjs +199 -199
- package/src/schedulability-compiler-v2.mjs +303 -303
- package/src/schedulability-verifier-v2.mjs +317 -317
- package/src/seam-apply.mjs +549 -549
- package/src/seam-commit-shared.mjs +22 -22
- package/src/seam-commit-transform.mjs +81 -81
- package/src/seam-commit.mjs +18 -18
- package/src/seam-cost.mjs +322 -322
- package/src/seam-derivation.mjs +188 -188
- package/src/seam-gate.mjs +146 -146
- package/src/seam-proposal-contracts.mjs +446 -446
- package/src/seam-proposal-queries.mjs +521 -521
- package/src/seam-proposal.mjs +2011 -2011
- package/src/seam-ref.mjs +33 -33
- package/src/seam-rewrite.mjs +286 -286
- package/src/seam-transform.mjs +554 -554
- package/src/seam-verification.mjs +260 -260
- package/src/sensor-adapter.mjs +432 -432
- package/src/sensor-cli.mjs +139 -139
- package/src/sensor-diff.mjs +661 -661
- package/src/sensor-node-runtime.mjs +53 -53
- package/src/sensor-runtime.mjs +52 -52
- package/src/timestamp-contract.mjs +8 -8
- package/src/todo-audit-pending.mjs +91 -91
- package/src/todo-chain.mjs +178 -178
- package/src/todo-cli.mjs +3143 -3141
- package/src/todo-contracts.mjs +728 -728
- package/src/todo-dashboard-registry.mjs +573 -573
- package/src/todo-dispatch-shape.mjs +190 -190
- package/src/todo-gantt-html-independence.mjs +239 -239
- package/src/todo-gantt-html-shared.mjs +226 -226
- package/src/todo-gantt-html-style.mjs +131 -131
- package/src/todo-gantt-html.mjs +248 -248
- package/src/todo-gantt-layout.mjs +974 -974
- package/src/todo-gantt-live.mjs +361 -361
- package/src/todo-gantt-nested.mjs +263 -263
- package/src/todo-gantt-presentation.mjs +217 -217
- package/src/todo-gantt-scope.mjs +123 -123
- package/src/todo-gantt-svg.mjs +353 -353
- package/src/todo-independence-contracts.mjs +595 -595
- package/src/todo-independence-guidance.mjs +322 -322
- package/src/todo-independence.mjs +640 -640
- package/src/todo-markdown-renderer.mjs +260 -260
- package/src/todo-migration.mjs +448 -448
- package/src/todo-narrative-anchor.mjs +130 -130
- package/src/todo-note-store.mjs +629 -629
- package/src/todo-parallel-candidates.mjs +114 -114
- package/src/todo-revision.mjs +995 -995
- package/src/todo-split.mjs +472 -472
- package/src/todo-status.mjs +690 -690
- package/src/todo-store-git-transaction.mjs +418 -418
- package/src/todo-store.mjs +4360 -4322
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- 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
|
+
}
|