@quolu/lattice 0.50.1 → 0.52.0

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.
Files changed (42) hide show
  1. package/bin/lattice-work-order-adapter.mjs +20 -0
  2. package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -0
  3. package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -0
  4. package/package.json +5 -2
  5. package/src/boundary-observation-compiler-v2.mjs +1 -1
  6. package/src/cli-help.mjs +33 -2
  7. package/src/rc3-actual-dogfood.mjs +6 -2
  8. package/src/rc3-scripted-campaign.mjs +37 -10
  9. package/src/rc4-stage1-dogfood.mjs +6 -2
  10. package/src/runtime-adapter-registry.mjs +21 -7
  11. package/src/runtime-cli.mjs +476 -34
  12. package/src/runtime-contracts.mjs +59 -13
  13. package/src/runtime-controller-protocol.mjs +48 -3
  14. package/src/runtime-decision-verifier.mjs +70 -0
  15. package/src/runtime-diff-observer.mjs +66 -4
  16. package/src/runtime-direct-os-observer.mjs +25 -8
  17. package/src/runtime-driver-state.mjs +162 -0
  18. package/src/runtime-engine.mjs +37 -6
  19. package/src/runtime-front-end.mjs +39 -1
  20. package/src/runtime-managed-supervisor.mjs +80 -14
  21. package/src/runtime-multi-epoch-store.mjs +87 -14
  22. package/src/runtime-pull-intake.mjs +1188 -0
  23. package/src/runtime-work-order-contracts.mjs +91 -0
  24. package/src/runtime-work-order-controller.mjs +1167 -0
  25. package/src/seam-proposal-queries.mjs +1 -1
  26. package/src/todo-cli.mjs +273 -7
  27. package/src/todo-contracts.mjs +19 -2
  28. package/src/todo-gantt-html-independence.mjs +3 -2
  29. package/src/todo-gantt-html-shared.mjs +1 -2
  30. package/src/todo-gantt-html-style.mjs +13 -0
  31. package/src/todo-gantt-html.mjs +15 -2
  32. package/src/todo-gantt-layout.mjs +75 -1
  33. package/src/todo-gantt-nested.mjs +263 -0
  34. package/src/todo-gantt-svg.mjs +80 -5
  35. package/src/todo-independence-contracts.mjs +73 -7
  36. package/src/todo-independence-guidance.mjs +30 -1
  37. package/src/todo-independence.mjs +89 -7
  38. package/src/todo-revision.mjs +1 -1
  39. package/src/todo-split.mjs +472 -0
  40. package/src/todo-status.mjs +10 -1
  41. package/src/todo-store-git-transaction.mjs +418 -0
  42. package/src/todo-store.mjs +144 -4
@@ -51,6 +51,7 @@ export const RUN_EVENT_KINDS = Object.freeze([
51
51
  // ADR 0044 Decision 5のclosed conflict分類と、Decision 7.5のhold理由kind。
52
52
  export const RUNTIME_CONFLICT_KINDS = Object.freeze([
53
53
  'observed_write_conflict',
54
+ 'observed_line_change',
54
55
  'semantic_conflict_unknown',
55
56
  'effect_conflict_unknown',
56
57
  'undeclared_write',
@@ -187,6 +188,33 @@ export const MANUAL_WITNESS_FIELDS = Object.freeze([
187
188
  'affected_tests',
188
189
  'unknowns',
189
190
  ]);
191
+ export const MANUAL_WITNESS_OPTIONAL_FIELDS = Object.freeze(['lines']);
192
+ export const LINE_ROLES = Object.freeze(['reads', 'writes']);
193
+
194
+ function lineAnchor(value) {
195
+ if (!plainObject(value)) return false;
196
+ if (value.kind === 'path') {
197
+ return exactRecord(value, ['kind', 'path']) && repoRelativePath(value.path);
198
+ }
199
+ return value.kind === 'symbol'
200
+ && exactRecord(value, ['kind', 'name', 'path'])
201
+ && typeof value.name === 'string' && value.name.length > 0
202
+ && Buffer.byteLength(value.name, 'utf8') <= MAX_PATH_BYTES
203
+ && repoRelativePath(value.path);
204
+ }
205
+
206
+ function lineEntry(value) {
207
+ return plainObject(value)
208
+ && exactRecord(value, ['line_id', 'role', 'anchors'])
209
+ && identifier(value.line_id)
210
+ && LINE_ROLES.includes(value.role)
211
+ && boundedArray(value.anchors, lineAnchor, { min: 1 });
212
+ }
213
+
214
+ function lineEntries(value) {
215
+ return boundedArray(value, lineEntry)
216
+ && new Set(value.map(({ line_id: lineId }) => lineId)).size === value.length;
217
+ }
190
218
 
191
219
  // manual witness entryの判定は`explainRunRequest`だけが所有する。
192
220
  // 同じ規則をbooleanと診断の二箇所へ持たないための単一正本化(ADR 0123)。
@@ -235,15 +263,17 @@ const WITNESS_PROVENANCE = Object.freeze([
235
263
  ]);
236
264
 
237
265
  /**
238
- * 現行のrun request契約。v4は計画境界を予測として扱う。v3以前の厳密なcompile契約は、
239
- * 既存requestの意味を変えないため読み口として残す。
266
+ * 現行のrun request契約。v5はpathを跨ぐ意味的な線を任意宣言できる。v4は同じ予測契約の
267
+ * lines無し版として残し、v3以前の厳密なcompile契約も既存requestの意味を変えずに読む。
240
268
  *
241
269
  * v2はこの系列ではない。ADR 0064のepoch後継request(`predecessor_request_digest`と
242
270
  * `task_migration_digest`を持つ別shape)が既に使っている番号なので、飛ばして採番する。
243
271
  */
244
- export const RUN_REQUEST_SCHEMA = 'lattice.run_request.v4';
272
+ export const RUN_REQUEST_SCHEMA = 'lattice.run_request.v5';
273
+ export const RUN_REQUEST_PREDICTION_SCHEMA = 'lattice.run_request.v4';
245
274
  export const RUN_REQUEST_DECLARATIVE_SCHEMA = 'lattice.run_request.v3';
246
275
  export const RUN_REQUEST_LEGACY_SCHEMAS = Object.freeze([
276
+ RUN_REQUEST_PREDICTION_SCHEMA,
247
277
  RUN_REQUEST_DECLARATIVE_SCHEMA,
248
278
  'lattice.run_request.v1',
249
279
  ]);
@@ -309,7 +339,10 @@ export function explainRunRequest(value) {
309
339
  if (!exactRecord(value, RUN_REQUEST_FIELDS)) return reject('unexpected_or_missing_top_level_keys', '');
310
340
  if (!RUN_REQUEST_SCHEMAS.includes(value.schema)) return reject('schema_mismatch', '/schema');
311
341
  // 創作宣言はv2から。v1のclosed shapeは余分fieldを拒否するので加算互換が成立しない。
312
- const allowCreates = [RUN_REQUEST_SCHEMA, RUN_REQUEST_DECLARATIVE_SCHEMA].includes(value.schema);
342
+ const allowCreates = [
343
+ RUN_REQUEST_SCHEMA, RUN_REQUEST_PREDICTION_SCHEMA, RUN_REQUEST_DECLARATIVE_SCHEMA,
344
+ ].includes(value.schema);
345
+ const allowLines = value.schema === RUN_REQUEST_SCHEMA;
313
346
  if (!identifier(value.request_id)) return reject('invalid_identifier', '/request_id');
314
347
  if (!exactRecord(value.repo, ['base_sha', 'root_kind'])) return reject('unexpected_or_missing_keys', '/repo');
315
348
  if (!gitSha(value.repo.base_sha)) return reject('invalid_git_sha', '/repo/base_sha');
@@ -333,7 +366,13 @@ export function explainRunRequest(value) {
333
366
  const witness = value.manual_witness[todoId];
334
367
  const at = `/manual_witness/${todoId}`;
335
368
  if (!plainObject(witness)) return reject('not_an_object', at);
336
- if (!exactRecord(witness, MANUAL_WITNESS_FIELDS)) return reject('unexpected_or_missing_keys', at);
369
+ const hasLines = Object.hasOwn(witness, 'lines');
370
+ const witnessFields = hasLines
371
+ ? [...MANUAL_WITNESS_FIELDS, ...MANUAL_WITNESS_OPTIONAL_FIELDS]
372
+ : MANUAL_WITNESS_FIELDS;
373
+ if (!exactRecord(witness, witnessFields)) return reject('unexpected_or_missing_keys', at);
374
+ if (hasLines && !allowLines) return reject('lines_require_run_request_v5', `${at}/lines`);
375
+ if (hasLines && !lineEntries(witness.lines)) return reject('invalid_line_entries', `${at}/lines`);
337
376
  if (!boundedArray(witness.owns, (own) => ownEntry(own, { allowCreates }))) {
338
377
  return reject('invalid_own_entries', `${at}/owns`);
339
378
  }
@@ -407,20 +446,23 @@ export function verifyRuntimePlanBinding(options = {}) {
407
446
  }
408
447
 
409
448
  /**
410
- * `lattice.boundary_manifest.v3`。witness_provenanceresourceごとに区別する。
449
+ * `lattice.boundary_manifest.v4`。v4pathを跨ぐ意味的な線を任意記録できる。
411
450
  *
412
451
  * v3は`owns[].creates`だけがv2との差である。宣言が持っていた「このpathはまだ無い」を
413
452
  * 記録側でも保つ——落とすと、manifestだけを読む消費者が既存fileと同じ扱いをする。
414
- * 旧v2 manifestはrun storeに残るので読み口として受理する。
453
+ * 旧v2/v3 manifestはrun storeに残るので読み口として受理し、linesを要求しない。
415
454
  */
416
- export const BOUNDARY_MANIFEST_SCHEMA = 'lattice.boundary_manifest.v3';
455
+ export const BOUNDARY_MANIFEST_SCHEMA = 'lattice.boundary_manifest.v4';
417
456
  export const BOUNDARY_MANIFEST_SCHEMAS = Object.freeze([
418
457
  BOUNDARY_MANIFEST_SCHEMA,
458
+ 'lattice.boundary_manifest.v3',
419
459
  'lattice.boundary_manifest.v2',
420
460
  ]);
421
461
  export function validateRuntimeBoundaryManifest(value) {
422
- return validateSafely(value, (manifest) => (
423
- exactRecord(manifest, [
462
+ return validateSafely(value, (manifest) => {
463
+ const hasLines = Object.hasOwn(manifest, 'lines');
464
+ return (
465
+ exactRecord(manifest, [
424
466
  'schema',
425
467
  'todo_id',
426
468
  'owns',
@@ -432,12 +474,14 @@ export function validateRuntimeBoundaryManifest(value) {
432
474
  'affected_tests',
433
475
  'graph_evidence',
434
476
  'witness_provenance',
477
+ ...(hasLines ? ['lines'] : []),
435
478
  'manifest_digest',
436
- ])
479
+ ])
437
480
  && BOUNDARY_MANIFEST_SCHEMAS.includes(manifest.schema)
481
+ && (!hasLines || manifest.schema === BOUNDARY_MANIFEST_SCHEMA)
438
482
  && identifier(manifest.todo_id)
439
483
  && boundedArray(manifest.owns, (own) => ownEntry(own, {
440
- allowCreates: manifest.schema === BOUNDARY_MANIFEST_SCHEMA,
484
+ allowCreates: [BOUNDARY_MANIFEST_SCHEMA, 'lattice.boundary_manifest.v3'].includes(manifest.schema),
441
485
  }))
442
486
  && repoPathArray(manifest.reads)
443
487
  && repoPathArray(manifest.writes, { allowPrefix: true })
@@ -450,8 +494,10 @@ export function validateRuntimeBoundaryManifest(value) {
450
494
  && Object.values(manifest.witness_provenance).every((entry) => (
451
495
  WITNESS_PROVENANCE.includes(entry)
452
496
  ))
497
+ && (!hasLines || lineEntries(manifest.lines))
453
498
  && selfDigestValid(manifest, 'manifest_digest')
454
- ));
499
+ );
500
+ });
455
501
  }
456
502
 
457
503
  /** `lattice.runtime_plan.v1`。exact_minimum claimは1〜8 nodeだけを受理する(Decision 1)。 */
@@ -8,6 +8,10 @@ import {
8
8
 
9
9
  const SHA256 = /^[0-9a-f]{64}$/;
10
10
  const ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
11
+ const CONTROLLER_ERROR_SCHEMAS = new Set([
12
+ 'lattice.scripted_adapter_error.v1',
13
+ 'lattice.work_order_adapter_error.v1',
14
+ ]);
11
15
 
12
16
  export const CONTROLLER_OPERATIONS = Object.freeze([
13
17
  'dispatch', 'observe', 'inventory', 'barrier', 'rebind', 'prepare', 'activate', 'release', 'revoke',
@@ -106,6 +110,18 @@ function selfValid(value, field) {
106
110
  try { return digest(value[field]) && selfDigest(value, field) === value[field]; } catch { return false; }
107
111
  }
108
112
 
113
+ /** controller実装が返す既知errorを、request bindingを含めて検証する。 */
114
+ export function validateControllerError(value, expectedRequestId = null) {
115
+ return exact(value, ['schema', 'code', 'message', 'request_id', 'detail', 'error_digest'])
116
+ && CONTROLLER_ERROR_SCHEMAS.has(value.schema)
117
+ && identifier(value.code)
118
+ && typeof value.message === 'string' && value.message.length > 0
119
+ && identifier(value.request_id)
120
+ && (expectedRequestId === null || value.request_id === expectedRequestId)
121
+ && plain(value.detail)
122
+ && selfValid(value, 'error_digest');
123
+ }
124
+
109
125
  export function validateRuntimeHeartbeatPolicy(value) {
110
126
  return exact(value, ['schema', 'interval_ms', 'ttl_ms', 'disconnect_revokes_immediately', 'policy_digest'])
111
127
  && value.schema === 'lattice.runtime_heartbeat_policy.v1'
@@ -125,15 +141,30 @@ export function validateControllerHeartbeat(value) {
125
141
  }
126
142
 
127
143
  export function validateRuntimeAdapterCapabilities(value) {
128
- return exact(value, ['schema', 'operations', 'process_observation', 'worktree_fingerprint', 'staged_write_lease', 'durable_dispatch', 'capabilities_digest'])
129
- && value.schema === 'lattice.runtime_adapter_capabilities.v1'
144
+ const fields = ['schema', 'operations', 'process_observation', 'worktree_fingerprint',
145
+ 'staged_write_lease', 'durable_dispatch', 'capabilities_digest'];
146
+ if (value?.schema === 'lattice.runtime_adapter_capabilities.v2') {
147
+ fields.push('host_driven_epoch');
148
+ }
149
+ return exact(value, fields)
150
+ && ['lattice.runtime_adapter_capabilities.v1',
151
+ 'lattice.runtime_adapter_capabilities.v2'].includes(value.schema)
130
152
  && Array.isArray(value.operations) && value.operations.length === CONTROLLER_OPERATIONS.length
131
153
  && value.operations.every((op, i) => op === CONTROLLER_OPERATIONS[i])
132
154
  && value.process_observation === true && value.worktree_fingerprint === true
133
155
  && value.staged_write_lease === true && value.durable_dispatch === true
156
+ && (value.schema !== 'lattice.runtime_adapter_capabilities.v2'
157
+ || typeof value.host_driven_epoch === 'boolean')
134
158
  && selfValid(value, 'capabilities_digest');
135
159
  }
136
160
 
161
+ /** hostがmanaged epochを駆動してよいとcontroller自身が宣言した能力だけを採る。 */
162
+ export function acceptsHostDrivenEpoch(value) {
163
+ return validateRuntimeAdapterCapabilities(value)
164
+ && value.schema === 'lattice.runtime_adapter_capabilities.v2'
165
+ && value.host_driven_epoch === true;
166
+ }
167
+
137
168
  export function validateProcessStartIdentity(value) {
138
169
  return exact(value, ['schema', 'platform', 'pid', 'started_identity', 'identity_digest'])
139
170
  && value.schema === 'lattice.process_start_identity.v1'
@@ -405,7 +436,7 @@ function validateProtocolRunningBinding(value) {
405
436
  * dispatchが名指しするworker process。直接OS観測が期待するchild processと同じ形にする
406
437
  * ——照合先の形が分かれると、supervisorとcontrollerが別のものを見ていても気づけない。
407
438
  */
408
- export function validateExpectedWorkerProcess(value) {
439
+ function validateExpectedWorkerProcessLeaf(value) {
409
440
  return value !== null && typeof value === 'object' && !Array.isArray(value)
410
441
  && Object.keys(value).sort().join('\0')
411
442
  === ['pid', 'process_group_id', 'process_start_identity'].sort().join('\0')
@@ -415,6 +446,20 @@ export function validateExpectedWorkerProcess(value) {
415
446
  && value.process_start_identity.pid === value.pid;
416
447
  }
417
448
 
449
+ export function validateExpectedWorkerProcess(value) {
450
+ if (validateExpectedWorkerProcessLeaf(value)) return true;
451
+ if (value === null || typeof value !== 'object' || Array.isArray(value)
452
+ || Object.keys(value).sort().join('\0')
453
+ !== ['pid', 'process_group_id', 'process_start_identity', 'process_membership'].sort().join('\0')
454
+ || !validateExpectedWorkerProcessLeaf({
455
+ pid: value.pid,
456
+ process_group_id: value.process_group_id,
457
+ process_start_identity: value.process_start_identity,
458
+ })
459
+ || value.process_membership !== 'dynamic_group') return false;
460
+ return true;
461
+ }
462
+
418
463
  export function validateQuiescenceAck(value) {
419
464
  return exact(value, ['schema', 'ack_id', 'run_id', 'todo_id', 'executor_handle', 'worktree_id', 'plan_epoch', 'packet_digest', 'write_lease_id', 'barrier_control_digest', 'final_checkpoint_digest', 'process_observation_digest', 'worktree_fingerprint_digest', 'supervisor_session_nonce_digest', 'ack_digest'])
420
465
  && value.schema === 'lattice.executor_quiescence_ack.v1'
@@ -1,3 +1,5 @@
1
+ import path from 'node:path';
2
+
1
3
  import { digestArtifact } from './artifact-contracts.mjs';
2
4
  import { validateCarryOverWitness } from './runtime-contracts.mjs';
3
5
  import { projectRuntimeState } from './runtime-projection.mjs';
@@ -117,6 +119,57 @@ function witnessSet(manifest, kinds) {
117
119
  return resources;
118
120
  }
119
121
 
122
+ const LINE_ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
123
+ const LINE_PATH_CONTROL = /[\u0000-\u001f\u007f]/;
124
+
125
+ function runtimeLinePath(value) {
126
+ return typeof value === 'string' && value.length > 0
127
+ && Buffer.byteLength(value, 'utf8') <= 1_024
128
+ && !LINE_PATH_CONTROL.test(value) && !value.includes('\\')
129
+ && !path.posix.isAbsolute(value) && value === path.posix.normalize(value)
130
+ && !value.split('/').includes('..');
131
+ }
132
+
133
+ function runtimeLineGroups(manifests, todoIds) {
134
+ const groups = new Map();
135
+ for (const todoId of sorted(new Set(todoIds))) {
136
+ const manifest = manifests[todoId];
137
+ if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)
138
+ || !Array.isArray(manifest.lines ?? [])) {
139
+ invalidVerification(`boundary manifestのlinesが不正: ${todoId}`);
140
+ }
141
+ const seen = new Set();
142
+ for (const line of manifest.lines ?? []) {
143
+ if (line === null || typeof line !== 'object' || Array.isArray(line)
144
+ || Object.keys(line).sort().join(',') !== 'anchors,line_id,role'
145
+ || !LINE_ID.test(line.line_id ?? '') || !['reads', 'writes'].includes(line.role)
146
+ || !Array.isArray(line.anchors) || line.anchors.length === 0
147
+ || seen.has(line.line_id)) {
148
+ invalidVerification(`line宣言が不正: ${todoId}`);
149
+ }
150
+ seen.add(line.line_id);
151
+ const group = groups.get(line.line_id) ?? {
152
+ readers: new Set(), writers: new Set(), anchorPaths: new Set(),
153
+ };
154
+ group[line.role === 'reads' ? 'readers' : 'writers'].add(todoId);
155
+ for (const anchor of line.anchors) {
156
+ const keys = anchor !== null && typeof anchor === 'object' && !Array.isArray(anchor)
157
+ ? Object.keys(anchor).sort().join(',') : '';
158
+ const valid = anchor?.kind === 'path'
159
+ ? keys === 'kind,path' && runtimeLinePath(anchor.path)
160
+ : anchor?.kind === 'symbol' && keys === 'kind,name,path'
161
+ && typeof anchor.name === 'string' && anchor.name.length > 0
162
+ && runtimeLinePath(anchor.path);
163
+ if (!valid) invalidVerification(`line anchorが不正: ${todoId}/${line.line_id}`);
164
+ // producerと共有せず、観測pathだけから独立に近似する。
165
+ group.anchorPaths.add(anchor.path);
166
+ }
167
+ groups.set(line.line_id, group);
168
+ }
169
+ }
170
+ return groups;
171
+ }
172
+
120
173
  /**
121
174
  * 観測diffをdeclared path/resource witnessへcross-bindし、closed conflict分類の
122
175
  * findingを返す(ADR 0044 Decision 5)。宣言外writeと運転中overlapは別findingで
@@ -157,6 +210,23 @@ export function classifyObservedDiff(options = {}) {
157
210
  }
158
211
  }
159
212
 
213
+ const lineScope = new Set([...relevant, ...observedByTodo.keys()]);
214
+ const lineGroups = runtimeLineGroups(manifests, lineScope);
215
+ for (const [todoId, paths] of observedByTodo) {
216
+ const observedPaths = new Set(paths);
217
+ for (const [lineId, group] of lineGroups) {
218
+ if (group.writers.has(todoId)) continue;
219
+ const readers = [...group.readers].filter((readerId) => readerId !== todoId).sort();
220
+ if (readers.length === 0
221
+ || ![...group.anchorPaths].some((anchorPath) => observedPaths.has(anchorPath))) continue;
222
+ findings.push({
223
+ kind: 'observed_line_change',
224
+ todo_ids: [todoId, ...readers].sort(),
225
+ resource_id: lineId,
226
+ });
227
+ }
228
+ }
229
+
160
230
  const writersByPath = new Map();
161
231
  for (const [todoId, paths] of observedByTodo) {
162
232
  for (const path of paths) {
@@ -21,6 +21,7 @@ import { digestArtifact } from './artifact-contracts.mjs';
21
21
  const MAX_DIFF_ENTRIES = 256;
22
22
  const MAX_TRACKED_FILE_BYTES = 4_194_304;
23
23
  const GIT_SHA1 = /^[0-9a-f]{40}$/;
24
+ const LINE_ID = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/;
24
25
 
25
26
  function fail(reason) {
26
27
  throw new TypeError(`diff observer契約違反: ${reason}`);
@@ -252,6 +253,45 @@ export function coveredBy(declaredWrites, observedPath) {
252
253
  ));
253
254
  }
254
255
 
256
+ function lineGroupsFor(manifests, todoIds) {
257
+ if (!plainRecord(manifests)) fail('boundary manifestsが不正');
258
+ const groups = new Map();
259
+ for (const todoId of [...new Set(todoIds)].sort()) {
260
+ const manifest = manifests[todoId];
261
+ if (!plainRecord(manifest) || !Array.isArray(manifest.lines ?? [])) {
262
+ fail(`boundary manifestのlinesが不正: ${todoId}`);
263
+ }
264
+ const seen = new Set();
265
+ for (const line of manifest.lines ?? []) {
266
+ if (!exactRecord(line, ['line_id', 'role', 'anchors'])
267
+ || !LINE_ID.test(line.line_id ?? '')
268
+ || !['reads', 'writes'].includes(line.role)
269
+ || !Array.isArray(line.anchors) || line.anchors.length === 0
270
+ || seen.has(line.line_id)) {
271
+ fail(`line宣言が不正: ${todoId}`);
272
+ }
273
+ seen.add(line.line_id);
274
+ const group = groups.get(line.line_id) ?? {
275
+ readers: new Set(), writers: new Set(), anchorPaths: new Set(),
276
+ };
277
+ group[line.role === 'reads' ? 'readers' : 'writers'].add(todoId);
278
+ for (const anchor of line.anchors) {
279
+ const valid = anchor?.kind === 'path'
280
+ ? exactRecord(anchor, ['kind', 'path']) && safeRelativePath(anchor.path)
281
+ : anchor?.kind === 'symbol'
282
+ && exactRecord(anchor, ['kind', 'name', 'path'])
283
+ && typeof anchor.name === 'string' && anchor.name.length > 0
284
+ && safeRelativePath(anchor.path);
285
+ if (!valid) fail(`line anchorが不正: ${todoId}/${line.line_id}`);
286
+ // symbol解決は実行時diffに存在しないので、初期実装では宣言済みpathへ近似する。
287
+ group.anchorPaths.add(anchor.path);
288
+ }
289
+ groups.set(line.line_id, group);
290
+ }
291
+ }
292
+ return groups;
293
+ }
294
+
255
295
  /**
256
296
  * checkpoint diffのobserved pathをdeclared scope/他running TODOのdeclared writeへ
257
297
  * cross-bindし、closed conflict分類のfindingを返す(producer側の検出。独立再計算は
@@ -261,10 +301,10 @@ export function coveredBy(declaredWrites, observedPath) {
261
301
  * - observed_write_conflict: 他のrunning TODOのdeclared writeとのpath overlap。
262
302
  */
263
303
  export function detectCheckpointFindings(options = {}) {
264
- if (!exactRecord(options, ['todoId', 'checkpoint', 'packets', 'runningTodoIds'])) {
304
+ if (!exactRecord(options, ['todoId', 'checkpoint', 'packets', 'manifests', 'runningTodoIds'])) {
265
305
  fail('detectCheckpointFindings optionsがexact shapeでない');
266
306
  }
267
- const { todoId, checkpoint, packets, runningTodoIds } = options;
307
+ const { todoId, checkpoint, packets, manifests, runningTodoIds } = options;
268
308
  if (!plainRecord(checkpoint) || !plainRecord(checkpoint.diff) || !Array.isArray(checkpoint.diff.entries)) {
269
309
  fail('checkpoint diff recordが不正');
270
310
  }
@@ -272,10 +312,14 @@ export function detectCheckpointFindings(options = {}) {
272
312
  if (!plainRecord(packet) || !plainRecord(packet.scope) || !Array.isArray(packet.scope.writes)) {
273
313
  fail(`packetが不正: ${todoId}`);
274
314
  }
315
+ const manifest = manifests[todoId];
316
+ if (!plainRecord(manifest) || !Array.isArray(manifest.writes)) {
317
+ fail(`boundary manifestが不正: ${todoId}`);
318
+ }
275
319
  // findingはverifier(classifyObservedDiff)と同じper-path shapeで返す。
276
320
  const findings = [];
277
321
  for (const entry of [...checkpoint.diff.entries].sort((l, r) => (l.path < r.path ? -1 : 1))) {
278
- if (!coveredBy(packet.scope.writes, entry.path)) {
322
+ if (!coveredBy(manifest.writes, entry.path)) {
279
323
  findings.push({ kind: 'undeclared_write', todo_ids: [todoId], path: entry.path });
280
324
  }
281
325
  }
@@ -285,8 +329,12 @@ export function detectCheckpointFindings(options = {}) {
285
329
  if (!plainRecord(other) || !plainRecord(other.scope) || !Array.isArray(other.scope.writes)) {
286
330
  fail(`packetが不正: ${otherId}`);
287
331
  }
332
+ const otherManifest = manifests[otherId];
333
+ if (!plainRecord(otherManifest) || !Array.isArray(otherManifest.writes)) {
334
+ fail(`boundary manifestが不正: ${otherId}`);
335
+ }
288
336
  for (const entry of [...checkpoint.diff.entries].sort((l, r) => (l.path < r.path ? -1 : 1))) {
289
- if (coveredBy(other.scope.writes, entry.path)) {
337
+ if (coveredBy(otherManifest.writes, entry.path)) {
290
338
  findings.push({
291
339
  kind: 'observed_write_conflict',
292
340
  todo_ids: [todoId, otherId].sort(),
@@ -295,5 +343,19 @@ export function detectCheckpointFindings(options = {}) {
295
343
  }
296
344
  }
297
345
  }
346
+ const observedPaths = new Set(checkpoint.diff.entries.map((entry) => entry.path));
347
+ for (const [lineId, group] of lineGroupsFor(
348
+ manifests, [todoId, ...runningTodoIds],
349
+ )) {
350
+ if (group.writers.has(todoId)) continue;
351
+ const readers = [...group.readers].filter((readerId) => readerId !== todoId).sort();
352
+ if (readers.length === 0
353
+ || ![...group.anchorPaths].some((anchorPath) => observedPaths.has(anchorPath))) continue;
354
+ findings.push({
355
+ kind: 'observed_line_change',
356
+ todo_ids: [todoId, ...readers].sort(),
357
+ resource_id: lineId,
358
+ });
359
+ }
298
360
  return { findings };
299
361
  }
@@ -56,6 +56,7 @@ function validateResolvedBinding(value) {
56
56
  || value.process_start_identity.pid !== value.process_pid
57
57
  || !Array.isArray(value.process_children)
58
58
  || !value.process_children.every(validExpectedProcess)
59
+ || !['static', 'dynamic_group'].includes(value.process_membership_policy ?? 'static')
59
60
  || typeof value.worktree_path !== 'string' || !path.isAbsolute(value.worktree_path)
60
61
  || typeof value.base_sha !== 'string' || !GIT_SHA1.test(value.base_sha)) {
61
62
  fail('observation binding不正');
@@ -208,20 +209,36 @@ export function createDirectOsProcessObserver({
208
209
  };
209
210
  const root = verifyProcess(expectedRoot, records.get(resolved.process_pid), 'root');
210
211
  const actualDescendants = descendantPids(records, resolved.process_pid);
211
- const expectedDescendants = new Set(resolved.process_children.map((child) => child.pid));
212
- for (const pid of expectedDescendants) {
213
- if (!actualDescendants.has(pid)) fail(`保存済みchildが見つからない: ${pid}`);
214
- }
215
- for (const pid of actualDescendants) {
216
- if (!expectedDescendants.has(pid)) fail(`未記録childを検出: ${pid}`);
212
+ const dynamicGroup = resolved.process_membership_policy === 'dynamic_group';
213
+ const expectedChildren = dynamicGroup
214
+ ? [...actualDescendants].map((pid) => {
215
+ const record = records.get(pid);
216
+ if (record.process_group_id !== resolved.process_group_id) {
217
+ fail(`dynamic childがrootと別process groupに居る: ${pid}`);
218
+ }
219
+ return {
220
+ pid,
221
+ process_group_id: record.process_group_id,
222
+ process_start_identity: identityFor(pid, record.started_identity),
223
+ };
224
+ })
225
+ : resolved.process_children;
226
+ const expectedDescendants = new Set(expectedChildren.map((child) => child.pid));
227
+ if (!dynamicGroup) {
228
+ for (const pid of expectedDescendants) {
229
+ if (!actualDescendants.has(pid)) fail(`保存済みchildが見つからない: ${pid}`);
230
+ }
231
+ for (const pid of actualDescendants) {
232
+ if (!expectedDescendants.has(pid)) fail(`未記録childを検出: ${pid}`);
233
+ }
217
234
  }
218
- const expectedGroup = new Set([resolved.process_pid, ...expectedDescendants]);
235
+ const expectedGroup = new Set([resolved.process_pid, ...actualDescendants]);
219
236
  for (const record of records.values()) {
220
237
  if (record.process_group_id === resolved.process_group_id && !expectedGroup.has(record.pid)) {
221
238
  fail(`未記録process group memberを検出: ${record.pid}`);
222
239
  }
223
240
  }
224
- const children = resolved.process_children
241
+ const children = expectedChildren
225
242
  .map((expected) => verifyProcess(expected, records.get(expected.pid), 'child'))
226
243
  .sort((left, right) => left.pid - right.pid);
227
244
 
@@ -0,0 +1,162 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, open, readFile, rename, rm } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ import { canonicalizeArtifact } from './artifact-contracts.mjs';
6
+ import { selfDigest } from './runtime-contracts.mjs';
7
+
8
+ const DRIVER_STATE_SCHEMA = 'lattice.runtime_driver_state.v1';
9
+ const DIGEST = /^[0-9a-f]{64}$/u;
10
+ const IDENTIFIER = /^[0-9A-Za-z](?:[0-9A-Za-z._-]{0,127})$/u;
11
+ const DESCRIPTOR_REF = /^supervisor\/(?:descriptor\.json|restart-candidates\/[0-9a-f]{64}\/descriptor\.json)$/u;
12
+ const WAIT_KINDS = new Set(['frontier_dispatch', 'executor_completion']);
13
+ const MAX_STATE_BYTES = 65_536;
14
+
15
+ export class RuntimeDriverStateError extends Error {
16
+ constructor(code, message) {
17
+ super(message);
18
+ this.name = 'RuntimeDriverStateError';
19
+ this.code = code;
20
+ }
21
+ }
22
+
23
+ function fail(code, message) {
24
+ throw new RuntimeDriverStateError(code, message);
25
+ }
26
+
27
+ function plain(value) {
28
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
29
+ && Object.getPrototypeOf(value) === Object.prototype;
30
+ }
31
+
32
+ function exact(value, keys) {
33
+ return plain(value)
34
+ && Object.keys(value).sort().join('\0') === [...keys].sort().join('\0');
35
+ }
36
+
37
+ function validWaitingOn(value) {
38
+ return exact(value, ['kind', 'todo_ids'])
39
+ && WAIT_KINDS.has(value.kind)
40
+ && Array.isArray(value.todo_ids)
41
+ && value.todo_ids.length > 0
42
+ && value.todo_ids.length <= 256
43
+ && value.todo_ids.every((todoId) => IDENTIFIER.test(todoId))
44
+ && value.todo_ids.every((todoId, index) => index === 0 || value.todo_ids[index - 1] < todoId);
45
+ }
46
+
47
+ export function validateRuntimeDriverState(value) {
48
+ if (!exact(value, [
49
+ 'schema',
50
+ 'run_id',
51
+ 'supervisor_descriptor_ref',
52
+ 'supervisor_descriptor_digest',
53
+ 'supervisor_process_start_identity_digest',
54
+ 'driver_state',
55
+ 'waiting_on',
56
+ 'updated_at',
57
+ 'state_digest',
58
+ ])
59
+ || value.schema !== DRIVER_STATE_SCHEMA
60
+ || !IDENTIFIER.test(value.run_id ?? '')
61
+ || !DESCRIPTOR_REF.test(value.supervisor_descriptor_ref ?? '')
62
+ || !DIGEST.test(value.supervisor_descriptor_digest ?? '')
63
+ || !DIGEST.test(value.supervisor_process_start_identity_digest ?? '')
64
+ || !['driving', 'stopped'].includes(value.driver_state)
65
+ || typeof value.updated_at !== 'string'
66
+ || Number.isNaN(Date.parse(value.updated_at))
67
+ || !DIGEST.test(value.state_digest ?? '')
68
+ || value.state_digest !== selfDigest(value, 'state_digest')) return false;
69
+ return value.driver_state === 'stopped'
70
+ ? value.waiting_on === null
71
+ : validWaitingOn(value.waiting_on);
72
+ }
73
+
74
+ function statePath(runDir) {
75
+ return path.join(runDir, 'supervisor', 'driver-state.json');
76
+ }
77
+
78
+ async function fsyncDirectory(directory) {
79
+ const handle = await open(directory, 'r');
80
+ try {
81
+ await handle.sync();
82
+ } finally {
83
+ await handle.close();
84
+ }
85
+ }
86
+
87
+ export async function replaceRuntimeDriverState({
88
+ runDir,
89
+ runId,
90
+ supervisorDescriptorRef,
91
+ supervisorDescriptorDigest,
92
+ supervisorProcessStartIdentityDigest,
93
+ driverState,
94
+ waitingOn,
95
+ updatedAt,
96
+ } = {}) {
97
+ if (typeof runDir !== 'string' || runDir.length === 0) {
98
+ throw new TypeError('runDirが不正');
99
+ }
100
+ const state = {
101
+ schema: DRIVER_STATE_SCHEMA,
102
+ run_id: runId,
103
+ supervisor_descriptor_ref: supervisorDescriptorRef,
104
+ supervisor_descriptor_digest: supervisorDescriptorDigest,
105
+ supervisor_process_start_identity_digest: supervisorProcessStartIdentityDigest,
106
+ driver_state: driverState,
107
+ waiting_on: waitingOn === null ? null : structuredClone(waitingOn),
108
+ updated_at: updatedAt,
109
+ state_digest: '',
110
+ };
111
+ state.state_digest = selfDigest(state, 'state_digest');
112
+ if (!validateRuntimeDriverState(state)) throw new TypeError('runtime driver state入力が不正');
113
+
114
+ const supervisorDir = path.join(runDir, 'supervisor');
115
+ await mkdir(supervisorDir, { recursive: true, mode: 0o700 });
116
+ const target = statePath(runDir);
117
+ const temporary = path.join(supervisorDir, `.driver-state-${process.pid}-${randomUUID()}.tmp`);
118
+ try {
119
+ const handle = await open(temporary, 'wx', 0o600);
120
+ try {
121
+ await handle.writeFile(`${canonicalizeArtifact(state)}\n`);
122
+ await handle.sync();
123
+ } finally {
124
+ await handle.close();
125
+ }
126
+ await rename(temporary, target);
127
+ await fsyncDirectory(supervisorDir);
128
+ } finally {
129
+ await rm(temporary, { force: true }).catch(() => {});
130
+ }
131
+ return state;
132
+ }
133
+
134
+ export async function readRuntimeDriverState({ runDir } = {}) {
135
+ if (typeof runDir !== 'string' || runDir.length === 0) {
136
+ throw new TypeError('runDirが不正');
137
+ }
138
+ const target = statePath(runDir);
139
+ let info;
140
+ try {
141
+ info = await lstat(target);
142
+ } catch (error) {
143
+ if (error?.code === 'ENOENT') return null;
144
+ fail('INVALID_RUN_STORE', 'driver stateを読めない');
145
+ }
146
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_STATE_BYTES) {
147
+ fail('INVALID_RUN_STORE', 'driver stateが安全なbounded regular fileでない');
148
+ }
149
+ let bytes;
150
+ let value;
151
+ try {
152
+ bytes = await readFile(target);
153
+ value = JSON.parse(bytes);
154
+ } catch {
155
+ fail('INVALID_RUN_STORE', 'driver stateがJSONとして不正');
156
+ }
157
+ if (bytes.toString('utf8') !== `${canonicalizeArtifact(value)}\n`
158
+ || !validateRuntimeDriverState(value)) {
159
+ fail('INVALID_RUN_STORE', 'driver stateのschemaまたはcanonical bytesが不正');
160
+ }
161
+ return value;
162
+ }