@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.
- package/bin/lattice-work-order-adapter.mjs +20 -0
- package/docs/schemas/lattice.runtime_adapter_capabilities.v2.schema.json +55 -0
- package/docs/schemas/lattice.runtime_adapter_registration_input.v2.schema.json +86 -0
- package/package.json +5 -2
- package/src/boundary-observation-compiler-v2.mjs +1 -1
- package/src/cli-help.mjs +33 -2
- package/src/rc3-actual-dogfood.mjs +6 -2
- package/src/rc3-scripted-campaign.mjs +37 -10
- package/src/rc4-stage1-dogfood.mjs +6 -2
- package/src/runtime-adapter-registry.mjs +21 -7
- package/src/runtime-cli.mjs +476 -34
- package/src/runtime-contracts.mjs +59 -13
- package/src/runtime-controller-protocol.mjs +48 -3
- package/src/runtime-decision-verifier.mjs +70 -0
- package/src/runtime-diff-observer.mjs +66 -4
- package/src/runtime-direct-os-observer.mjs +25 -8
- package/src/runtime-driver-state.mjs +162 -0
- package/src/runtime-engine.mjs +37 -6
- package/src/runtime-front-end.mjs +39 -1
- package/src/runtime-managed-supervisor.mjs +80 -14
- package/src/runtime-multi-epoch-store.mjs +87 -14
- package/src/runtime-pull-intake.mjs +1188 -0
- package/src/runtime-work-order-contracts.mjs +91 -0
- package/src/runtime-work-order-controller.mjs +1167 -0
- package/src/seam-proposal-queries.mjs +1 -1
- package/src/todo-cli.mjs +273 -7
- package/src/todo-contracts.mjs +19 -2
- package/src/todo-gantt-html-independence.mjs +3 -2
- package/src/todo-gantt-html-shared.mjs +1 -2
- package/src/todo-gantt-html-style.mjs +13 -0
- package/src/todo-gantt-html.mjs +15 -2
- package/src/todo-gantt-layout.mjs +75 -1
- package/src/todo-gantt-nested.mjs +263 -0
- package/src/todo-gantt-svg.mjs +80 -5
- package/src/todo-independence-contracts.mjs +73 -7
- package/src/todo-independence-guidance.mjs +30 -1
- package/src/todo-independence.mjs +89 -7
- package/src/todo-revision.mjs +1 -1
- package/src/todo-split.mjs +472 -0
- package/src/todo-status.mjs +10 -1
- package/src/todo-store-git-transaction.mjs +418 -0
- 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
|
|
239
|
-
*
|
|
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.
|
|
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 = [
|
|
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
|
-
|
|
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.
|
|
449
|
+
* `lattice.boundary_manifest.v4`。v4はpathを跨ぐ意味的な線を任意記録できる。
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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, ...
|
|
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 =
|
|
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
|
+
}
|