@quolu/lattice 0.12.5 → 0.12.7
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
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
|
|
@@ -77,6 +77,14 @@ function sortedUnique(values, predicate) {
|
|
|
77
77
|
|| canonicalizeArtifact(values[index - 1]) < canonicalizeArtifact(value));
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
function primaryFirstUnique(values, predicate) {
|
|
81
|
+
if (!Array.isArray(values) || !values.every(predicate)
|
|
82
|
+
|| new Set(values).size !== values.length) return false;
|
|
83
|
+
const tail = values.slice(1);
|
|
84
|
+
return tail.every((value, index) => index === 0
|
|
85
|
+
|| canonicalizeArtifact(tail[index - 1]) < canonicalizeArtifact(value));
|
|
86
|
+
}
|
|
87
|
+
|
|
80
88
|
/** ADR 0064 Decision 5のfull predecessor→successor mapping。 */
|
|
81
89
|
export function validateRuntimeTaskMigration(value, { predecessorTaskIds = null,
|
|
82
90
|
successorTaskIds = null } = {}) {
|
|
@@ -92,7 +100,7 @@ export function validateRuntimeTaskMigration(value, { predecessorTaskIds = null,
|
|
|
92
100
|
|| !IDENTIFIER.test(entry.predecessor_task_id ?? '')
|
|
93
101
|
|| !['carry', 'replace', 'split', 'retire', 'stay'].includes(entry.disposition)
|
|
94
102
|
|| typeof entry.reason !== 'string' || entry.reason.length === 0
|
|
95
|
-
|| !
|
|
103
|
+
|| !primaryFirstUnique(entry.successor_task_ids, (id) => IDENTIFIER.test(id))
|
|
96
104
|
|| !sortedUnique(entry.evidence_digests, (digest) => HEX_DIGEST.test(digest))) return false;
|
|
97
105
|
if (['carry', 'stay'].includes(entry.disposition)
|
|
98
106
|
&& canonicalizeArtifact(entry.successor_task_ids) !== canonicalizeArtifact([entry.predecessor_task_id])) return false;
|
package/src/todo-cli.mjs
CHANGED
|
@@ -634,9 +634,10 @@ function parseGanttDescriptor(bytes, descriptorRef) {
|
|
|
634
634
|
}
|
|
635
635
|
|
|
636
636
|
export async function renderTodoGanttForProject({
|
|
637
|
-
repoRoot, stable = false, displayName = null, env = process.env,
|
|
637
|
+
repoRoot, stable = false, displayName = null, env = process.env, readModel = null,
|
|
638
638
|
}) {
|
|
639
|
-
const store =
|
|
639
|
+
const store = readModel
|
|
640
|
+
?? (stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot }));
|
|
640
641
|
const identity = displayName === null
|
|
641
642
|
? await resolveProjectIdentity({ repoRoot, projectId: store.project_id, env })
|
|
642
643
|
: { 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-revision.mjs
CHANGED
|
@@ -228,6 +228,8 @@ function validRuntimeTaskMigration(value) {
|
|
|
228
228
|
|| value.migration_digest !== todoSelfDigest(value, 'migration_digest')) return false;
|
|
229
229
|
const targets = [];
|
|
230
230
|
for (const [index, entry] of value.entries.entries()) {
|
|
231
|
+
const successorTail = Array.isArray(entry.successor_task_ids)
|
|
232
|
+
? entry.successor_task_ids.slice(1) : [];
|
|
231
233
|
if (!exactRecord(entry, [
|
|
232
234
|
'predecessor_task_id', 'disposition', 'successor_task_ids', 'reason', 'evidence_digests',
|
|
233
235
|
]) || !isTodoIdentifier(entry.predecessor_task_id)
|
|
@@ -235,8 +237,8 @@ function validRuntimeTaskMigration(value) {
|
|
|
235
237
|
|| !Array.isArray(entry.successor_task_ids) || entry.successor_task_ids.length > 512
|
|
236
238
|
|| !entry.successor_task_ids.every(isTodoIdentifier)
|
|
237
239
|
|| new Set(entry.successor_task_ids).size !== entry.successor_task_ids.length
|
|
238
|
-
||
|
|
239
|
-
&& compareText(
|
|
240
|
+
|| successorTail.some((id, targetIndex) => targetIndex > 0
|
|
241
|
+
&& compareText(successorTail[targetIndex - 1], id) >= 0)
|
|
240
242
|
|| !boundedText(entry.reason) || !Array.isArray(entry.evidence_digests)
|
|
241
243
|
|| entry.evidence_digests.length < 1 || entry.evidence_digests.length > 512
|
|
242
244
|
|| !entry.evidence_digests.every(isTodoDigest)
|