@quolu/lattice 0.12.6 → 0.12.8
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/README.md +3 -0
- package/bin/lattice-dashboard.mjs +28 -3
- package/package.json +1 -1
- package/src/bridge-server.mjs +4 -2
- package/src/todo-cli.mjs +8 -6
- package/src/todo-dashboard-registry.mjs +11 -4
- package/src/todo-store.mjs +30 -1
package/README.md
CHANGED
|
@@ -82,6 +82,9 @@ required evidenceを束縛した`todo phase accept`で重監査の判断を記
|
|
|
82
82
|
session開始時のtyped discoveryで使う`lattice status --json`と、actor環境変数を持つ通常のTODO操作は
|
|
83
83
|
active projectを自動登録し、一つのloopback dashboard daemonを再利用します。
|
|
84
84
|
`/projects/`の一覧からproject固有の工程図を開け、各projectのSSE更新は互いに分離されます。
|
|
85
|
+
dashboardはmanifestのfile identityが変わらない間のstable store readを再利用します。
|
|
86
|
+
巨大工程図のrender中にhealth応答が遅れても、生存中dashboardを新daemonで置き換えず
|
|
87
|
+
`DASHBOARD_DAEMON_UNRESPONSIVE`としてtyped拒否します。
|
|
85
88
|
最近のsession activityが期限切れでも、Lattice storeの`active_set`が非空なprojectは一覧へ残ります。
|
|
86
89
|
長時間の外部処理中にCLI呼出しが途切れても進行中projectを休眠扱いしません。
|
|
87
90
|
LANや外部reverse proxyから閲覧するoptional bridgeは既定で無効です。明示したIPにだけbindする初回設定、
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
+
import { stat } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
3
6
|
import { readTodoStoreStable } from '../src/todo-store.mjs';
|
|
4
7
|
import { projectTodoStatus } from '../src/todo-status.mjs';
|
|
5
8
|
import { renderTodoGanttForProject } from '../src/todo-cli.mjs';
|
|
@@ -29,12 +32,33 @@ const port = typeof configured === 'string' && /^(?:0|[1-9][0-9]{0,4})$/u.test(c
|
|
|
29
32
|
const registry = createTodoGanttProjectRegistry();
|
|
30
33
|
const roots = new Map();
|
|
31
34
|
const reportedStoreReadFailures = new Set();
|
|
35
|
+
const storeCache = new Map();
|
|
36
|
+
|
|
37
|
+
function manifestFingerprint(value) {
|
|
38
|
+
return `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}:${value.ctimeMs}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function readCachedStore(repoRoot) {
|
|
42
|
+
const manifestRef = path.join(repoRoot, '.lattice', 'todo', 'manifest.json');
|
|
43
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
44
|
+
const beforeFingerprint = manifestFingerprint(await stat(manifestRef));
|
|
45
|
+
const cached = storeCache.get(repoRoot);
|
|
46
|
+
if (cached?.fingerprint === beforeFingerprint) return cached.store;
|
|
47
|
+
const store = await readTodoStoreStable({ repoRoot });
|
|
48
|
+
const afterFingerprint = manifestFingerprint(await stat(manifestRef));
|
|
49
|
+
if (beforeFingerprint === afterFingerprint) {
|
|
50
|
+
storeCache.set(repoRoot, { fingerprint: afterFingerprint, store });
|
|
51
|
+
return store;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return readTodoStoreStable({ repoRoot });
|
|
55
|
+
}
|
|
32
56
|
|
|
33
57
|
async function synchronize() {
|
|
34
58
|
const active = await readVisibleTodoDashboardProjects({ env,
|
|
35
59
|
projectHasActiveRun: async (entry) => {
|
|
36
60
|
try {
|
|
37
|
-
const active = projectTodoStatus(await
|
|
61
|
+
const active = projectTodoStatus(await readCachedStore(entry.repo_root)).active_set.length > 0;
|
|
38
62
|
reportedStoreReadFailures.delete(entry.project_id);
|
|
39
63
|
return active;
|
|
40
64
|
} catch (error) {
|
|
@@ -62,11 +86,12 @@ async function synchronize() {
|
|
|
62
86
|
projectId: entry.project_id,
|
|
63
87
|
displayName: entry.display_name,
|
|
64
88
|
render: async ({ displayName }) => {
|
|
89
|
+
const store = await readCachedStore(entry.repo_root);
|
|
65
90
|
const result = await renderTodoGanttForProject({ repoRoot: entry.repo_root,
|
|
66
|
-
stable: true, displayName });
|
|
91
|
+
stable: true, displayName, readModel: store });
|
|
67
92
|
return { html: result.rendered.html, head_digest: result.metadata.manifest_digest };
|
|
68
93
|
},
|
|
69
|
-
readHead: async () => (await
|
|
94
|
+
readHead: async () => (await readCachedStore(entry.repo_root)).manifest.manifest_digest,
|
|
70
95
|
});
|
|
71
96
|
roots.set(entry.project_id, binding);
|
|
72
97
|
}
|
package/package.json
CHANGED
package/src/bridge-server.mjs
CHANGED
|
@@ -67,7 +67,9 @@ async function readDashboardDescriptor(ref) {
|
|
|
67
67
|
} finally { await handle?.close(); }
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
export async function resolveBridgeUpstream(upstream, {
|
|
70
|
+
export async function resolveBridgeUpstream(upstream, {
|
|
71
|
+
env = process.env, healthTimeoutMs = 2_000,
|
|
72
|
+
} = {}) {
|
|
71
73
|
if (upstream.mode === 'url') return new URL(upstream.url);
|
|
72
74
|
let descriptor;
|
|
73
75
|
try {
|
|
@@ -84,7 +86,7 @@ export async function resolveBridgeUpstream(upstream, { env = process.env } = {}
|
|
|
84
86
|
}
|
|
85
87
|
try {
|
|
86
88
|
const response = await fetch(`http://127.0.0.1:${descriptor.port}/__lattice/health`, {
|
|
87
|
-
signal: AbortSignal.timeout(
|
|
89
|
+
signal: AbortSignal.timeout(healthTimeoutMs),
|
|
88
90
|
});
|
|
89
91
|
const health = response.status === 200 ? await response.json() : null;
|
|
90
92
|
if (health?.schema !== 'lattice.todo_dashboard_health.v1' || health.pid !== descriptor.pid
|
package/src/todo-cli.mjs
CHANGED
|
@@ -307,7 +307,7 @@ async function mutate({ repoRoot, env, planKey, taskId, kind, payload, evidenceR
|
|
|
307
307
|
planKey,
|
|
308
308
|
event: { kind, task_id: taskId, actor, payload: eventPayload },
|
|
309
309
|
});
|
|
310
|
-
const task = snapshot.tasks.find(({ task_id: current }) => current ===
|
|
310
|
+
const task = snapshot.tasks.find(({ task_id: current }) => current === event.task_id);
|
|
311
311
|
const result = {
|
|
312
312
|
schema: 'lattice.todo_mutation_result.v1',
|
|
313
313
|
project_id: event.project_id,
|
|
@@ -328,9 +328,10 @@ async function mutate({ repoRoot, env, planKey, taskId, kind, payload, evidenceR
|
|
|
328
328
|
|
|
329
329
|
async function startTask({ repoRoot, env, planKey, taskId, overrideReason, parallelFrontier }) {
|
|
330
330
|
const projection = projectTodoStatus(await readTodoStore({ repoRoot }));
|
|
331
|
-
const
|
|
332
|
-
task.plan_key === planKey && task.task_id === taskId
|
|
331
|
+
const readyTask = projection.next_ready.find((task) => (
|
|
332
|
+
task.plan_key === planKey && task.task_id.toLowerCase() === taskId.toLowerCase()
|
|
333
333
|
));
|
|
334
|
+
const targetReady = readyTask !== undefined;
|
|
334
335
|
if (parallelFrontier && !targetReady) {
|
|
335
336
|
throw new TodoStoreError('PARALLEL_DISPATCH_INVALID', 'parallel_frontier_not_applicable');
|
|
336
337
|
}
|
|
@@ -344,7 +345,7 @@ async function startTask({ repoRoot, env, planKey, taskId, overrideReason, paral
|
|
|
344
345
|
serial_reason_flag: '--override-reason',
|
|
345
346
|
});
|
|
346
347
|
}
|
|
347
|
-
return mutate({ repoRoot, env, planKey, taskId, kind: 'start',
|
|
348
|
+
return mutate({ repoRoot, env, planKey, taskId: readyTask?.task_id ?? taskId, kind: 'start',
|
|
348
349
|
payload: { override_reason: overrideReason }, evidenceRef: null });
|
|
349
350
|
}
|
|
350
351
|
|
|
@@ -634,9 +635,10 @@ function parseGanttDescriptor(bytes, descriptorRef) {
|
|
|
634
635
|
}
|
|
635
636
|
|
|
636
637
|
export async function renderTodoGanttForProject({
|
|
637
|
-
repoRoot, stable = false, displayName = null, env = process.env,
|
|
638
|
+
repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
|
|
638
639
|
}) {
|
|
639
|
-
const store =
|
|
640
|
+
const store = readModel
|
|
641
|
+
?? (stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot }));
|
|
640
642
|
const identity = displayName === null
|
|
641
643
|
? await resolveProjectIdentity({ repoRoot, projectId: store.project_id, env })
|
|
642
644
|
: { displayName };
|
|
@@ -171,11 +171,11 @@ function validDaemonDescriptor(descriptor) {
|
|
|
171
171
|
&& typeof descriptor.started_at === 'string' && Number.isFinite(Date.parse(descriptor.started_at));
|
|
172
172
|
}
|
|
173
173
|
|
|
174
|
-
async function daemonAttestation(descriptor) {
|
|
174
|
+
async function daemonAttestation(descriptor, { timeoutMs = 2_000 } = {}) {
|
|
175
175
|
if (!validDaemonDescriptor(descriptor)) return null;
|
|
176
176
|
try {
|
|
177
177
|
const response = await fetch(`http://127.0.0.1:${descriptor.port}/__lattice/health`, {
|
|
178
|
-
signal: AbortSignal.timeout(
|
|
178
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
179
179
|
});
|
|
180
180
|
if (response.status !== 200) return null;
|
|
181
181
|
const body = await response.json();
|
|
@@ -257,13 +257,20 @@ export async function writeTodoDashboardDaemonDescriptor({ port, env = process.e
|
|
|
257
257
|
|
|
258
258
|
export async function ensureTodoDashboardDaemon({ env = process.env, spawnDaemon = spawn,
|
|
259
259
|
signalProcess = process.kill, isProcessAlive = processIsAlive, startupTimeoutMs = 4_000,
|
|
260
|
-
legacyStopTimeoutMs = 3_000, replacementIsProcessAlive = processIsAlive
|
|
260
|
+
legacyStopTimeoutMs = 3_000, replacementIsProcessAlive = processIsAlive,
|
|
261
|
+
attestationTimeoutMs = 2_000 } = {}) {
|
|
261
262
|
const refs = paths(env);
|
|
262
263
|
await mkdir(refs.root, { recursive: true, mode: 0o700 });
|
|
263
264
|
return withLock(refs.startupLock, async () => {
|
|
264
265
|
const existing = await readJson(refs.descriptor, null);
|
|
265
|
-
const existingAttestation = await daemonAttestation(existing);
|
|
266
|
+
const existingAttestation = await daemonAttestation(existing, { timeoutMs: attestationTimeoutMs });
|
|
266
267
|
if (existingAttestation === 'current') return existing;
|
|
268
|
+
if (validDaemonDescriptor(existing) && existingAttestation === null
|
|
269
|
+
&& await isProcessAlive(existing.pid)) {
|
|
270
|
+
const error = new Error('dashboard daemon is alive but temporarily unresponsive');
|
|
271
|
+
error.code = 'DASHBOARD_DAEMON_UNRESPONSIVE';
|
|
272
|
+
throw error;
|
|
273
|
+
}
|
|
267
274
|
const legacy = existingAttestation === 'legacy' ? existing : null;
|
|
268
275
|
const portText = env.LATTICE_DASHBOARD_PORT;
|
|
269
276
|
const configuredPort = typeof portText === 'string' && /^(?:0|[1-9][0-9]{0,4})$/u.test(portText)
|
package/src/todo-store.mjs
CHANGED
|
@@ -1117,6 +1117,22 @@ function resolveTargetedEvent(input, storeMember) {
|
|
|
1117
1117
|
return input;
|
|
1118
1118
|
}
|
|
1119
1119
|
|
|
1120
|
+
function resolveCanonicalTaskId(plan, requestedTaskId) {
|
|
1121
|
+
if (requestedTaskId === null || requestedTaskId === undefined) return requestedTaskId;
|
|
1122
|
+
const exact = plan.tasks.find(({ task_id: taskId }) => taskId === requestedTaskId);
|
|
1123
|
+
if (exact) return exact.task_id;
|
|
1124
|
+
const folded = requestedTaskId.toLowerCase();
|
|
1125
|
+
const matches = plan.tasks.filter(({ task_id: taskId }) => taskId.toLowerCase() === folded);
|
|
1126
|
+
if (matches.length === 0) fail('TASK_NOT_FOUND', 'task_not_found', {
|
|
1127
|
+
requested_task_id: requestedTaskId,
|
|
1128
|
+
});
|
|
1129
|
+
if (matches.length > 1) fail('TASK_ID_AMBIGUOUS', 'task_id_case_ambiguous', {
|
|
1130
|
+
requested_task_id: requestedTaskId,
|
|
1131
|
+
matching_task_ids: matches.map(({ task_id: taskId }) => taskId).sort(),
|
|
1132
|
+
});
|
|
1133
|
+
return matches[0].task_id;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1120
1136
|
export async function appendTodoEvent(options = {}) {
|
|
1121
1137
|
requireWriter(options.writer, 'g5-authoring');
|
|
1122
1138
|
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
@@ -1126,6 +1142,7 @@ export async function appendTodoEvent(options = {}) {
|
|
|
1126
1142
|
if (!member) fail('STORE_INCONSISTENT', 'plan_not_active');
|
|
1127
1143
|
const input = resolveTargetedEvent({
|
|
1128
1144
|
...options.event,
|
|
1145
|
+
task_id: resolveCanonicalTaskId(member.plan, options.event.task_id),
|
|
1129
1146
|
recorded_at: options.event.recorded_at ?? new Date().toISOString(),
|
|
1130
1147
|
}, member);
|
|
1131
1148
|
const event = nextEvent(input, member);
|
|
@@ -2575,7 +2592,19 @@ function validatePhaseV3SourceInventoryDiff(previousInventory, revision) {
|
|
|
2575
2592
|
for (const entry of previousInventory.active) {
|
|
2576
2593
|
const active = desired.active.find(({ task_id }) => task_id === entry.task_id);
|
|
2577
2594
|
const continued = active?.source_ref === entry.source_ref && active.source_digest === entry.source_digest;
|
|
2578
|
-
|
|
2595
|
+
const relocated = revision.source_cutover_batch.operations.some((operation, index) => {
|
|
2596
|
+
if (operation.source_ref !== entry.source_ref || operation.source_digest !== entry.source_digest) {
|
|
2597
|
+
return false;
|
|
2598
|
+
}
|
|
2599
|
+
const archiveRef = todoCutoverArchiveSourceRef(revision.source_cutover_batch, index);
|
|
2600
|
+
return operation.disposition === 'active'
|
|
2601
|
+
? desired.active.some((candidate) => candidate.task_id === operation.task_id
|
|
2602
|
+
&& candidate.source_ref === archiveRef && candidate.source_digest === operation.source_digest)
|
|
2603
|
+
: desired.excluded_tombstones.some((candidate) => candidate.source_ref === archiveRef
|
|
2604
|
+
&& candidate.source_digest === operation.source_digest);
|
|
2605
|
+
});
|
|
2606
|
+
if (!continued && !relocated
|
|
2607
|
+
&& !desiredTombstoneKeys.has(`${entry.source_ref}\0${entry.source_digest}`)) {
|
|
2579
2608
|
fail('REVISION_INVALID', 'predecessor_source_silently_dropped', { task_id: entry.task_id });
|
|
2580
2609
|
}
|
|
2581
2610
|
}
|