@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 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.6",
3
+ "version": "0.12.8",
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
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 === taskId);
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 targetReady = projection.next_ready.some((task) => (
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 = stable ? await readTodoStoreStable({ repoRoot }) : await readTodoStore({ repoRoot });
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(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)
@@ -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
- if (!continued && !desiredTombstoneKeys.has(`${entry.source_ref}\0${entry.source_digest}`)) {
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
  }