@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 readTodoStoreStable({ repoRoot: entry.repo_root })).active_set.length > 0;
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 readTodoStoreStable({ repoRoot: entry.repo_root })).manifest.manifest_digest,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.12.5",
3
+ "version": "0.12.7",
4
4
  "description": "Lattice — phase-aware TODO graph compiler and conflict-aware orchestration runtime",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -67,7 +67,9 @@ async function readDashboardDescriptor(ref) {
67
67
  } finally { await handle?.close(); }
68
68
  }
69
69
 
70
- export async function resolveBridgeUpstream(upstream, { env = process.env } = {}) {
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(500),
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
- || !sortedUnique(entry.successor_task_ids, (id) => IDENTIFIER.test(id))
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 = stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot });
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(500),
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)
@@ -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
- || entry.successor_task_ids.some((id, targetIndex) => targetIndex > 0
239
- && compareText(entry.successor_task_ids[targetIndex - 1], id) >= 0)
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)