@quolu/lattice 0.14.0 → 0.16.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/package.json +2 -2
- package/src/cli-help.mjs +3 -0
- package/src/project-cli.mjs +2 -0
- package/src/seam-proposal-contracts.mjs +427 -0
- package/src/seam-proposal-queries.mjs +508 -0
- package/src/seam-proposal.mjs +1982 -0
- package/src/todo-cli.mjs +286 -10
- package/src/todo-gantt-html.mjs +60 -1
- package/src/todo-gantt-layout.mjs +76 -0
- package/src/todo-independence-contracts.mjs +141 -12
- package/src/todo-independence-guidance.mjs +41 -1
- package/src/todo-independence.mjs +71 -10
- package/src/todo-store.mjs +103 -2
|
@@ -13,9 +13,26 @@ import {
|
|
|
13
13
|
} from './runtime-contracts.mjs';
|
|
14
14
|
import { TODO_INDEPENDENCE_GUIDANCE_CODES } from './todo-independence-guidance.mjs';
|
|
15
15
|
|
|
16
|
-
export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.
|
|
17
|
-
|
|
16
|
+
export const TODO_WITNESS_SET_SCHEMA = 'lattice.todo_witness_set.v2';
|
|
17
|
+
/**
|
|
18
|
+
* まだ受理する旧witness set契約。v1はconcern anchorを持てないだけで、境界宣言としては
|
|
19
|
+
* v2と同値である。既存宣言を書き換えさせないために読み口を残す。
|
|
20
|
+
*/
|
|
21
|
+
export const TODO_WITNESS_SET_LEGACY_SCHEMAS = Object.freeze(['lattice.todo_witness_set.v1']);
|
|
22
|
+
export const TODO_WITNESS_SET_SCHEMAS = Object.freeze([
|
|
23
|
+
TODO_WITNESS_SET_SCHEMA,
|
|
24
|
+
...TODO_WITNESS_SET_LEGACY_SCHEMAS,
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
/** 1 taskが宣言できるconcern anchorの資源数と、資源あたりのsymbol数の上限。 */
|
|
28
|
+
export const TODO_CONCERN_ANCHOR_LIMIT = 256;
|
|
29
|
+
export const TODO_INDEPENDENCE_SCHEMA = 'lattice.todo_independence.v3';
|
|
18
30
|
export const TODO_INDEPENDENCE_PROJECTION_SCHEMA = 'lattice.todo_independence_projection.v2';
|
|
31
|
+
export const TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA = 'lattice.todo_independence_legacy_marker.v1';
|
|
32
|
+
export const TODO_INDEPENDENCE_LEGACY_SCHEMAS = Object.freeze([
|
|
33
|
+
'lattice.todo_independence.v1',
|
|
34
|
+
'lattice.todo_independence.v2',
|
|
35
|
+
]);
|
|
19
36
|
|
|
20
37
|
/** boundary compileが一度に扱えるToDo数(runtime front-endのMAX_COLLECTIONと同じ閉じ方)。 */
|
|
21
38
|
export const TODO_INDEPENDENCE_TASK_LIMIT = 256;
|
|
@@ -53,6 +70,19 @@ function plain(value) {
|
|
|
53
70
|
&& Object.getPrototypeOf(value) === Object.prototype;
|
|
54
71
|
}
|
|
55
72
|
|
|
73
|
+
/**
|
|
74
|
+
* 既知の旧independence artifactを、単なるschema文字列だけでなく版をまたいで不変な
|
|
75
|
+
* identity fieldの型までで識別する。本体構造や自己digestは旧契約validatorの再実装に
|
|
76
|
+
* なるため、ここでは検査しない。
|
|
77
|
+
*/
|
|
78
|
+
export function isTodoIndependenceLegacyArtifactIdentity(value) {
|
|
79
|
+
return plain(value) && TODO_INDEPENDENCE_LEGACY_SCHEMAS.includes(value.schema)
|
|
80
|
+
&& isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
|
|
81
|
+
&& isTodoIdentifier(value.plan_version) && isTodoDigest(value.topology_digest)
|
|
82
|
+
&& isTodoDigest(value.witness_set_digest) && isTodoDigest(value.result_digest)
|
|
83
|
+
&& isGitSha(value.base_sha);
|
|
84
|
+
}
|
|
85
|
+
|
|
56
86
|
function boundedList(value, validator, limit = TODO_INDEPENDENCE_LIST_LIMIT) {
|
|
57
87
|
return Array.isArray(value) && value.length <= limit && value.every(validator);
|
|
58
88
|
}
|
|
@@ -79,6 +109,10 @@ function boundedText(value, maximumBytes = 4_096) {
|
|
|
79
109
|
* manual witnessとsensor query setの判定正本は`explainRunRequest`だけが持つ(ADR 0123)。
|
|
80
110
|
* ここで同じ規則を書き直すと契約が二箇所へ分裂するため、検証もcompileもこの合成を通す。
|
|
81
111
|
* `requestId`はplan identityとwitness digestから導出され、run lifecycleへは登録されない。
|
|
112
|
+
*
|
|
113
|
+
* `concern_anchors`はここで落とす。並列可否の判定はこの合成requestだけを入力にするので、
|
|
114
|
+
* 落としておけばconcern宣言が判定へ影響しないことが構造で保証される(testの主張ではない)。
|
|
115
|
+
* 宣言はseam束縛の入力であり、witness setから直接読む。
|
|
82
116
|
*/
|
|
83
117
|
export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId }) {
|
|
84
118
|
const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
|
|
@@ -88,7 +122,10 @@ export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId })
|
|
|
88
122
|
repo: { base_sha: baseSha, root_kind: 'git' },
|
|
89
123
|
capacity: witnessSet.capacity,
|
|
90
124
|
todos: taskIds.map((taskId) => ({ todo_id: taskId })),
|
|
91
|
-
manual_witness:
|
|
125
|
+
manual_witness: Object.fromEntries(taskIds.map((taskId) => {
|
|
126
|
+
const { concern_anchors: _concernAnchors, ...boundary } = witnessSet.manual_witness[taskId];
|
|
127
|
+
return [taskId, boundary];
|
|
128
|
+
})),
|
|
92
129
|
sensor_query_set: witnessSet.sensor_query_set,
|
|
93
130
|
executor_capability: { adapters: ['todo-independence'] },
|
|
94
131
|
claim_mode: RUN_REQUEST_CLAIM_MODE,
|
|
@@ -99,10 +136,57 @@ export function synthesizeWitnessRunRequest(witnessSet, { baseSha, requestId })
|
|
|
99
136
|
}
|
|
100
137
|
|
|
101
138
|
/**
|
|
102
|
-
*
|
|
139
|
+
* 1 taskのconcern anchor宣言を検査する。
|
|
140
|
+
*
|
|
141
|
+
* `within`はそのtask自身が`owns`で主張している資源に限る。所有していない資源の内側に
|
|
142
|
+
* 担当を主張させない。symbol名の実在・資源内包含・task間排他はsensorとcompile側が見る。
|
|
143
|
+
*/
|
|
144
|
+
function explainConcernAnchors(anchors, owns, at) {
|
|
145
|
+
const reject = (reason, path) => ({ valid: false, reason, path });
|
|
146
|
+
if (!Array.isArray(anchors) || anchors.length > TODO_CONCERN_ANCHOR_LIMIT) {
|
|
147
|
+
return reject('bounded_collection_violation', at);
|
|
148
|
+
}
|
|
149
|
+
const ownedKeys = new Set(owns.map((own) => `${own.kind}\0${own.target}`));
|
|
150
|
+
for (const [index, entry] of anchors.entries()) {
|
|
151
|
+
const entryAt = `${at}/${index}`;
|
|
152
|
+
if (!exactRecord(entry, ['within', 'symbols'])) {
|
|
153
|
+
return reject('unexpected_or_missing_keys', entryAt);
|
|
154
|
+
}
|
|
155
|
+
if (!exactRecord(entry.within, ['kind', 'target'])
|
|
156
|
+
|| !['symbol', 'path'].includes(entry.within.kind)) {
|
|
157
|
+
return reject('invalid_concern_anchor_resource', `${entryAt}/within`);
|
|
158
|
+
}
|
|
159
|
+
const targetValid = entry.within.kind === 'path'
|
|
160
|
+
? repoRelativeResourceTarget(entry.within.target)
|
|
161
|
+
: boundedText(entry.within.target, 1_024);
|
|
162
|
+
if (!targetValid) return reject('invalid_concern_anchor_resource', `${entryAt}/within`);
|
|
163
|
+
if (!ownedKeys.has(`${entry.within.kind}\0${entry.within.target}`)) {
|
|
164
|
+
return reject('concern_anchor_resource_not_owned', `${entryAt}/within`);
|
|
165
|
+
}
|
|
166
|
+
if (!Array.isArray(entry.symbols) || entry.symbols.length < 1
|
|
167
|
+
|| entry.symbols.length > TODO_CONCERN_ANCHOR_LIMIT) {
|
|
168
|
+
return reject('bounded_collection_violation', `${entryAt}/symbols`);
|
|
169
|
+
}
|
|
170
|
+
if (!entry.symbols.every((symbol) => boundedText(symbol, 1_024))) {
|
|
171
|
+
return reject('invalid_concern_anchor_symbol', `${entryAt}/symbols`);
|
|
172
|
+
}
|
|
173
|
+
if (!strictlySorted(entry.symbols)) {
|
|
174
|
+
return reject('unsorted_or_duplicate_collection', `${entryAt}/symbols`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (!strictlySorted(anchors, (entry) => `${entry.within.kind}\0${entry.within.target}`)) {
|
|
178
|
+
return reject('unsorted_or_duplicate_collection', at);
|
|
179
|
+
}
|
|
180
|
+
return { valid: true };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* `lattice.todo_witness_set.v2`(およびconcern anchorを持たない旧v1)を検証し、
|
|
185
|
+
* 拒否理由とpathを返す。
|
|
103
186
|
*
|
|
104
187
|
* 自分のshapeだけをここで見て、witness本体はprobe requestへ合成して
|
|
105
188
|
* `explainRunRequest`へ委譲する。probeのbase_shaは検証専用の定数であり永続化しない。
|
|
189
|
+
* `concern_anchors`はprobeから落ちるので、ここだけが判定正本になる。
|
|
106
190
|
*/
|
|
107
191
|
export function explainTodoWitnessSet(value) {
|
|
108
192
|
const reject = (reason, at = '') => ({ valid: false, reason, path: at });
|
|
@@ -111,7 +195,7 @@ export function explainTodoWitnessSet(value) {
|
|
|
111
195
|
'schema', 'project_id', 'plan_key', 'capacity', 'sensor_query_set',
|
|
112
196
|
'manual_witness', 'witness_set_digest',
|
|
113
197
|
])) return reject('unexpected_or_missing_top_level_keys');
|
|
114
|
-
if (value.schema
|
|
198
|
+
if (!TODO_WITNESS_SET_SCHEMAS.includes(value.schema)) return reject('schema_mismatch', '/schema');
|
|
115
199
|
if (!isTodoIdentifier(value.project_id)) return reject('invalid_identifier', '/project_id');
|
|
116
200
|
if (!isTodoIdentifier(value.plan_key)) return reject('invalid_identifier', '/plan_key');
|
|
117
201
|
if (!plain(value.manual_witness)) return reject('not_an_object', '/manual_witness');
|
|
@@ -128,6 +212,16 @@ export function explainTodoWitnessSet(value) {
|
|
|
128
212
|
});
|
|
129
213
|
const explained = explainRunRequest(probe);
|
|
130
214
|
if (!explained.valid) return reject(explained.reason, explained.path);
|
|
215
|
+
// concern anchorはprobeへ写していないので、ここが唯一の判定正本になる。
|
|
216
|
+
const legacy = TODO_WITNESS_SET_LEGACY_SCHEMAS.includes(value.schema);
|
|
217
|
+
for (const taskId of taskIds) {
|
|
218
|
+
const witness = value.manual_witness[taskId];
|
|
219
|
+
const at = `/manual_witness/${taskId}/concern_anchors`;
|
|
220
|
+
if (!Object.hasOwn(witness, 'concern_anchors')) continue;
|
|
221
|
+
if (legacy) return reject('concern_anchors_require_witness_set_v2', at);
|
|
222
|
+
const anchors = explainConcernAnchors(witness.concern_anchors, witness.owns, at);
|
|
223
|
+
if (!anchors.valid) return reject(anchors.reason, anchors.path);
|
|
224
|
+
}
|
|
131
225
|
return { valid: true };
|
|
132
226
|
} catch {
|
|
133
227
|
return reject('non_canonical_witness_set_bytes');
|
|
@@ -139,12 +233,39 @@ export function validateTodoWitnessSet(value) {
|
|
|
139
233
|
}
|
|
140
234
|
|
|
141
235
|
function conflictEntry(value) {
|
|
142
|
-
return exactRecord(value, ['task_ids', 'resource_id'
|
|
236
|
+
return exactRecord(value, ['task_ids', 'resource_id'])
|
|
143
237
|
&& Array.isArray(value.task_ids) && value.task_ids.length === 2
|
|
144
238
|
&& value.task_ids.every(isTodoIdentifier)
|
|
145
239
|
&& compareText(value.task_ids[0], value.task_ids[1]) < 0
|
|
146
|
-
&& boundedText(value.resource_id)
|
|
147
|
-
|
|
240
|
+
&& boundedText(value.resource_id);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function repoRelativeResourceTarget(value) {
|
|
244
|
+
if (!boundedText(value) || value.startsWith('/') || value.includes('\\')
|
|
245
|
+
|| /^[A-Za-z]:/u.test(value)) return false;
|
|
246
|
+
const body = value.endsWith('/') ? value.slice(0, -1) : value;
|
|
247
|
+
return body.length > 0 && body.split('/')
|
|
248
|
+
.every((segment) => segment !== '' && segment !== '.' && segment !== '..');
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function conflictResourceEntry(value) {
|
|
252
|
+
if (!exactRecord(value, ['resource_id', 'kind', 'target'])
|
|
253
|
+
|| !boundedText(value.resource_id)
|
|
254
|
+
|| !TODO_INDEPENDENCE_CONFLICT_KINDS.includes(value.kind)) return false;
|
|
255
|
+
return value.kind === 'path'
|
|
256
|
+
? repoRelativeResourceTarget(value.target)
|
|
257
|
+
: boundedText(value.target);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export function isTodoIndependenceLegacyMarker(value) {
|
|
261
|
+
return exactRecord(value, [
|
|
262
|
+
'schema', 'legacy_schema', 'project_id', 'plan_key', 'plan_version',
|
|
263
|
+
'topology_digest', 'base_sha',
|
|
264
|
+
])
|
|
265
|
+
&& value.schema === TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA
|
|
266
|
+
&& TODO_INDEPENDENCE_LEGACY_SCHEMAS.includes(value.legacy_schema)
|
|
267
|
+
&& isTodoIdentifier(value.project_id) && isTodoIdentifier(value.plan_key)
|
|
268
|
+
&& value.plan_version === null && value.topology_digest === null && value.base_sha === null;
|
|
148
269
|
}
|
|
149
270
|
|
|
150
271
|
/**
|
|
@@ -188,10 +309,10 @@ function wavePlan(value, taskIds) {
|
|
|
188
309
|
}
|
|
189
310
|
|
|
190
311
|
/**
|
|
191
|
-
* `lattice.todo_independence.
|
|
312
|
+
* `lattice.todo_independence.v3`。
|
|
192
313
|
*
|
|
193
314
|
* conflict/precedence/unknownはnormalized boundary graphから採る(ADR 0127 Decision 4)。
|
|
194
|
-
* conflict
|
|
315
|
+
* conflictは`conflict_resources`のresource idを参照し、kindとnormalized targetを一度だけ保持する。
|
|
195
316
|
* `task_boundaries`はtask別の宣言境界で、鮮度のdiff交差判定をartifactだけで閉じるために持つ。
|
|
196
317
|
* `wave_plan`はschedulability compileが`compiled`を返した時だけ持ち、unknownが残る間はnullになる。
|
|
197
318
|
* verdictに現れないペアをverified独立と読めるのは、両taskにunknownが無いときだけである。
|
|
@@ -200,7 +321,7 @@ export function validateTodoIndependence(value) {
|
|
|
200
321
|
try {
|
|
201
322
|
if (!exactRecord(value, [
|
|
202
323
|
'schema', 'project_id', 'plan_key', 'plan_version', 'topology_digest', 'base_sha',
|
|
203
|
-
'witness_set_digest', 'compiled_at', 'task_ids', 'task_boundaries', 'conflicts',
|
|
324
|
+
'witness_set_digest', 'compiled_at', 'task_ids', 'task_boundaries', 'conflict_resources', 'conflicts',
|
|
204
325
|
'precedences', 'unknowns', 'wave_plan', 'outcome', 'result_digest',
|
|
205
326
|
])) return false;
|
|
206
327
|
if (value.schema !== TODO_INDEPENDENCE_SCHEMA) return false;
|
|
@@ -221,10 +342,16 @@ export function validateTodoIndependence(value) {
|
|
|
221
342
|
|| !value.task_boundaries.every((entry, index) => entry.task_id === value.task_ids[index])) {
|
|
222
343
|
return false;
|
|
223
344
|
}
|
|
345
|
+
if (!boundedList(value.conflict_resources, conflictResourceEntry)
|
|
346
|
+
|| !strictlySorted(value.conflict_resources, (entry) => entry.resource_id)) return false;
|
|
347
|
+
const conflictResourceIds = new Set(value.conflict_resources.map(({ resource_id: id }) => id));
|
|
224
348
|
if (!boundedList(value.conflicts, conflictEntry)
|
|
225
349
|
|| !value.conflicts.every((entry) => entry.task_ids.every((taskId) => known.has(taskId)))
|
|
350
|
+
|| !value.conflicts.every((entry) => conflictResourceIds.has(entry.resource_id))
|
|
226
351
|
|| !strictlySorted(value.conflicts, (entry) => (
|
|
227
352
|
`${entry.task_ids[0]}\0${entry.task_ids[1]}\0${entry.resource_id}`))) return false;
|
|
353
|
+
const referencedResourceIds = new Set(value.conflicts.map(({ resource_id: id }) => id));
|
|
354
|
+
if (referencedResourceIds.size !== value.conflict_resources.length) return false;
|
|
228
355
|
if (!boundedList(value.precedences, precedenceEntry)
|
|
229
356
|
|| !value.precedences.every((entry) => known.has(entry.from_task_id) && known.has(entry.to_task_id))
|
|
230
357
|
|| !strictlySorted(value.precedences, (entry) => (
|
|
@@ -345,7 +472,9 @@ export function validateTodoIndependenceProjection(value) {
|
|
|
345
472
|
// 記録が無い状態でcompile済みidentityを名乗らない。
|
|
346
473
|
if (value.coverage === 'missing'
|
|
347
474
|
&& (value.compiled_base_sha !== null || value.topology_digest !== null)) return false;
|
|
348
|
-
if (value.coverage
|
|
475
|
+
if (['verified', 'stale'].includes(value.coverage) && value.compiled_base_sha === null) return false;
|
|
476
|
+
if (value.coverage === 'superseded' && value.compiled_base_sha === null
|
|
477
|
+
&& (value.plan_version !== null || value.topology_digest !== null)) return false;
|
|
349
478
|
for (const key of ['active_task_ids', 'uncovered_active_task_ids']) {
|
|
350
479
|
if (!Array.isArray(value[key]) || value[key].length > TODO_INDEPENDENCE_TASK_LIMIT
|
|
351
480
|
|| !value[key].every(isTodoIdentifier) || !strictlySorted(value[key])) return false;
|
|
@@ -12,6 +12,7 @@ export const TODO_INDEPENDENCE_GUIDANCE_CODES = Object.freeze([
|
|
|
12
12
|
'independence_no_ready_frontier',
|
|
13
13
|
'independence_unrecorded',
|
|
14
14
|
'independence_task_undeclared',
|
|
15
|
+
'independence_contract_superseded',
|
|
15
16
|
'independence_superseded',
|
|
16
17
|
'independence_stale_for_task',
|
|
17
18
|
'independence_conflict_with_active',
|
|
@@ -19,6 +20,13 @@ export const TODO_INDEPENDENCE_GUIDANCE_CODES = Object.freeze([
|
|
|
19
20
|
'independence_verified',
|
|
20
21
|
]);
|
|
21
22
|
|
|
23
|
+
export const SEAM_PROPOSAL_GUIDANCE_CODES = Object.freeze([
|
|
24
|
+
'seam_proposal_unrecorded',
|
|
25
|
+
'seam_proposal_superseded',
|
|
26
|
+
'seam_proposal_stale',
|
|
27
|
+
'seam_proposal_verified',
|
|
28
|
+
]);
|
|
29
|
+
|
|
22
30
|
const CATALOG = Object.freeze({
|
|
23
31
|
independence_no_ready_frontier: Object.freeze({
|
|
24
32
|
message: '着手候補が無いため、並列可否を述べる対象が無い。',
|
|
@@ -32,6 +40,10 @@ const CATALOG = Object.freeze({
|
|
|
32
40
|
message: 'この工程はwitness setで宣言されていないため、記録には含まれていない。',
|
|
33
41
|
next_action: 'add_task_to_witness_set_then_compile',
|
|
34
42
|
}),
|
|
43
|
+
independence_contract_superseded: Object.freeze({
|
|
44
|
+
message: '記録は旧契約versionで書かれており、現在の並列可否の判定としては読めない。現在の契約での再compileが次の一歩になる。',
|
|
45
|
+
next_action: 'recompile_independence',
|
|
46
|
+
}),
|
|
35
47
|
independence_superseded: Object.freeze({
|
|
36
48
|
message: 'planが改訂され、記録は別のtopologyについての判定になっている。',
|
|
37
49
|
next_action: 'migrate_witness_set_then_compile',
|
|
@@ -52,6 +64,22 @@ const CATALOG = Object.freeze({
|
|
|
52
64
|
message: '記録時点の宣言境界では、他のready工程と干渉しない。',
|
|
53
65
|
next_action: 'none',
|
|
54
66
|
}),
|
|
67
|
+
seam_proposal_unrecorded: Object.freeze({
|
|
68
|
+
message: 'このplanのseam提案はまだ生成していない。提案対象が無いのではなく、記録が存在しない。',
|
|
69
|
+
next_action: 'compile_seam_proposal',
|
|
70
|
+
}),
|
|
71
|
+
seam_proposal_superseded: Object.freeze({
|
|
72
|
+
message: '参照元のplanまたは並列可否記録が更新され、このseam提案は現在の競合についての記録ではない。',
|
|
73
|
+
next_action: 'compile_seam_proposal',
|
|
74
|
+
}),
|
|
75
|
+
seam_proposal_stale: Object.freeze({
|
|
76
|
+
message: 'seam提案の生成後にHEADが進み、記録時点の構造証拠は現在のcodeを指していない。',
|
|
77
|
+
next_action: 'compile_seam_proposal',
|
|
78
|
+
}),
|
|
79
|
+
seam_proposal_verified: Object.freeze({
|
|
80
|
+
message: 'seam提案の記録は現在のplan、並列可否記録、HEADと一致している。',
|
|
81
|
+
next_action: 'none',
|
|
82
|
+
}),
|
|
55
83
|
});
|
|
56
84
|
|
|
57
85
|
/** 切断可能性の言い換え。conflictの案内へ添える。 */
|
|
@@ -82,7 +110,7 @@ export function todoIndependenceGuidance(code, { severability = null } = {}) {
|
|
|
82
110
|
*/
|
|
83
111
|
export function selectIndependenceGuidance({
|
|
84
112
|
coverage, taskDeclared, taskStale, conflictWithActive = null, conflictBetweenReady = null,
|
|
85
|
-
readyCount = null,
|
|
113
|
+
contractSuperseded = false, readyCount = null,
|
|
86
114
|
}) {
|
|
87
115
|
// 着手候補が無いなら述べる対象が無い。ここを通さないと、readyが空のとき
|
|
88
116
|
// 「未検査taskが1件も無い」が空虚に真になり、記録が古くても検証済みへ倒れる。
|
|
@@ -97,6 +125,9 @@ export function selectIndependenceGuidance({
|
|
|
97
125
|
severability: conflictBetweenReady,
|
|
98
126
|
});
|
|
99
127
|
}
|
|
128
|
+
if (contractSuperseded) {
|
|
129
|
+
return todoIndependenceGuidance('independence_contract_superseded');
|
|
130
|
+
}
|
|
100
131
|
if (coverage === 'missing') return todoIndependenceGuidance('independence_unrecorded');
|
|
101
132
|
if (coverage === 'superseded') return todoIndependenceGuidance('independence_superseded');
|
|
102
133
|
if (!taskDeclared) return todoIndependenceGuidance('independence_task_undeclared');
|
|
@@ -104,6 +135,15 @@ export function selectIndependenceGuidance({
|
|
|
104
135
|
return todoIndependenceGuidance('independence_verified');
|
|
105
136
|
}
|
|
106
137
|
|
|
138
|
+
export function selectSeamProposalGuidance({ coverage }) {
|
|
139
|
+
const code = coverage === 'missing' ? 'seam_proposal_unrecorded'
|
|
140
|
+
: coverage === 'superseded' ? 'seam_proposal_superseded'
|
|
141
|
+
: coverage === 'stale' ? 'seam_proposal_stale'
|
|
142
|
+
: coverage === 'verified' ? 'seam_proposal_verified' : null;
|
|
143
|
+
if (code === null) throw new TypeError(`unknown seam proposal coverage: ${coverage}`);
|
|
144
|
+
return todoIndependenceGuidance(code);
|
|
145
|
+
}
|
|
146
|
+
|
|
107
147
|
/**
|
|
108
148
|
* 宣言からcompileを経て読むまでの順序。helpとMCP instructionsが同じ手順を語るための正本。
|
|
109
149
|
* 面ごとに手順を書き直すと、片方だけが古くなる。
|
|
@@ -2,6 +2,7 @@ import { digestTodoArtifact, isTodoIdentifier, todoSelfDigest } from './todo-con
|
|
|
2
2
|
import {
|
|
3
3
|
TODO_INDEPENDENCE_SCHEMA,
|
|
4
4
|
isGitSha,
|
|
5
|
+
isTodoIndependenceLegacyMarker,
|
|
5
6
|
severabilityOfConflictKind,
|
|
6
7
|
synthesizeWitnessRunRequest,
|
|
7
8
|
validateTodoIndependence,
|
|
@@ -50,34 +51,65 @@ export async function collectWitnessSensorEvidence({ cwd, witnessSet, execute =
|
|
|
50
51
|
}
|
|
51
52
|
|
|
52
53
|
/**
|
|
53
|
-
* conflict
|
|
54
|
+
* conflictから参照されるnormalized resourceを正規化してartifactへ載せる。
|
|
54
55
|
*
|
|
55
56
|
* `graph.conflicts`はresource_idしか持たず、宣言由来のstate resource idは任意文字列なので
|
|
56
57
|
* prefixからkindを復元できない。normalized resourceを引けない場合は、切断可能性を
|
|
57
58
|
* 不明のまま記録するのでなくtyped failで止める。
|
|
58
59
|
*/
|
|
59
60
|
function conflictsFrom(verdicts, resources) {
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
const resourceById = new Map();
|
|
62
|
+
for (const resource of Array.isArray(resources) ? resources : []) {
|
|
63
|
+
const resourceId = resource?.resource_id;
|
|
64
|
+
if (resourceById.has(resourceId)) {
|
|
65
|
+
const previous = resourceById.get(resourceId);
|
|
66
|
+
if (previous.kind !== resource?.kind) {
|
|
67
|
+
fail('INDEPENDENCE_RESOURCE_KIND_MISMATCH', 'conflict_resource_kind_mismatch', {
|
|
68
|
+
resource_id: resourceId ?? null,
|
|
69
|
+
observed_kinds: [previous.kind ?? null, resource?.kind ?? null],
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
if (previous.target !== resource?.target) {
|
|
73
|
+
fail('INDEPENDENCE_RESOURCE_TARGET_MISMATCH', 'conflict_resource_target_mismatch', {
|
|
74
|
+
resource_id: resourceId ?? null,
|
|
75
|
+
observed_targets: [previous.target ?? null, resource?.target ?? null],
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
fail('INDEPENDENCE_RESOURCE_DUPLICATE', 'conflict_resource_duplicate', {
|
|
79
|
+
resource_id: resourceId ?? null,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
resourceById.set(resourceId, { kind: resource?.kind, target: resource?.target });
|
|
83
|
+
}
|
|
84
|
+
const conflicts = verdicts
|
|
63
85
|
.filter((verdict) => verdict.type === 'conflict')
|
|
64
86
|
.map((verdict) => {
|
|
65
|
-
const
|
|
87
|
+
const resource = resourceById.get(verdict.resource_id);
|
|
88
|
+
const kind = resource?.kind;
|
|
66
89
|
if (!['symbol', 'path', 'state', 'effect'].includes(kind)) {
|
|
67
90
|
fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
|
|
68
91
|
resource_id: verdict.resource_id, observed_kind: kind ?? null,
|
|
69
92
|
});
|
|
70
93
|
}
|
|
94
|
+
if (typeof resource.target !== 'string' || resource.target.length === 0) {
|
|
95
|
+
fail('INDEPENDENCE_RESOURCE_TARGET_UNRESOLVED', 'conflict_resource_target_unresolved', {
|
|
96
|
+
resource_id: verdict.resource_id, observed_target: resource.target ?? null,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
71
99
|
return {
|
|
72
100
|
task_ids: [...verdict.todo_ids].sort(compareText),
|
|
73
101
|
resource_id: verdict.resource_id,
|
|
74
|
-
kind,
|
|
75
102
|
};
|
|
76
103
|
})
|
|
77
104
|
.sort((left, right) => compareText(
|
|
78
105
|
`${left.task_ids[0]}\0${left.task_ids[1]}\0${left.resource_id}`,
|
|
79
106
|
`${right.task_ids[0]}\0${right.task_ids[1]}\0${right.resource_id}`,
|
|
80
107
|
));
|
|
108
|
+
const used = new Set(conflicts.map(({ resource_id: resourceId }) => resourceId));
|
|
109
|
+
const conflictResources = [...used]
|
|
110
|
+
.map((resourceId) => ({ resource_id: resourceId, ...resourceById.get(resourceId) }))
|
|
111
|
+
.sort((left, right) => compareText(left.resource_id, right.resource_id));
|
|
112
|
+
return { conflicts, conflictResources };
|
|
81
113
|
}
|
|
82
114
|
|
|
83
115
|
/**
|
|
@@ -141,7 +173,7 @@ function unknownsFrom(detail) {
|
|
|
141
173
|
}
|
|
142
174
|
|
|
143
175
|
/**
|
|
144
|
-
* witness setとsensor evidenceから`lattice.todo_independence.
|
|
176
|
+
* witness setとsensor evidenceから`lattice.todo_independence.v3`を作る。
|
|
145
177
|
*
|
|
146
178
|
* compileは宣言済みtaskの部分集合へ閉じる(ADR 0127 Decision 4)。未宣言taskを混ぜると、
|
|
147
179
|
* unknownが1件でも出た時点で宣言済みtask同士の判定まで失われるためである。
|
|
@@ -187,6 +219,9 @@ export function compileTodoIndependence(options = {}) {
|
|
|
187
219
|
}
|
|
188
220
|
|
|
189
221
|
const dispatchable = compiled.outcome === 'dispatchable';
|
|
222
|
+
const conflictProjection = dispatchable
|
|
223
|
+
? conflictsFrom(compiled.pairwise_verdicts, compiled.resources)
|
|
224
|
+
: { conflicts: [], conflictResources: [] };
|
|
190
225
|
const artifact = {
|
|
191
226
|
schema: TODO_INDEPENDENCE_SCHEMA,
|
|
192
227
|
project_id: plan.project_id,
|
|
@@ -201,8 +236,8 @@ export function compileTodoIndependence(options = {}) {
|
|
|
201
236
|
task_id: taskId,
|
|
202
237
|
paths: boundaryPathsOf(witnessSet.manual_witness[taskId]),
|
|
203
238
|
})),
|
|
204
|
-
|
|
205
|
-
|
|
239
|
+
conflict_resources: conflictProjection.conflictResources,
|
|
240
|
+
conflicts: conflictProjection.conflicts,
|
|
206
241
|
precedences: dispatchable ? precedencesFrom(compiled.pairwise_verdicts) : [],
|
|
207
242
|
unknowns: dispatchable ? [] : unknownsFrom(compiled.detail),
|
|
208
243
|
wave_plan: dispatchable
|
|
@@ -290,6 +325,24 @@ export function projectIndependenceFrontier({
|
|
|
290
325
|
};
|
|
291
326
|
}
|
|
292
327
|
|
|
328
|
+
if (isTodoIndependenceLegacyMarker(artifact)) {
|
|
329
|
+
return {
|
|
330
|
+
coverage: 'superseded',
|
|
331
|
+
drift: null,
|
|
332
|
+
active_task_ids: active,
|
|
333
|
+
uncovered_active_task_ids: active,
|
|
334
|
+
frontier: {
|
|
335
|
+
parallel_groups: [],
|
|
336
|
+
serialize_pairs: [],
|
|
337
|
+
conflicts_with_active: [],
|
|
338
|
+
unknown: ready.map((taskId) => ({
|
|
339
|
+
task_id: taskId,
|
|
340
|
+
unknowns: [{ kind: 'record_superseded', ref: artifact.legacy_schema }],
|
|
341
|
+
})),
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
293
346
|
const superseded = artifact.plan_version !== plan.plan_version
|
|
294
347
|
|| artifact.topology_digest !== plan.topology_digest;
|
|
295
348
|
const coverage = superseded ? 'superseded'
|
|
@@ -382,9 +435,17 @@ export function projectIndependenceFrontier({
|
|
|
382
435
|
});
|
|
383
436
|
};
|
|
384
437
|
|
|
438
|
+
const conflictResourceById = new Map(artifact.conflict_resources
|
|
439
|
+
.map((resource) => [resource.resource_id, resource]));
|
|
385
440
|
for (const conflict of artifact.conflicts) {
|
|
441
|
+
const resource = conflictResourceById.get(conflict.resource_id);
|
|
442
|
+
if (resource === undefined) {
|
|
443
|
+
fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
|
|
444
|
+
resource_id: conflict.resource_id, observed_kind: null,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
386
447
|
notePair(conflict.task_ids[0], conflict.task_ids[1], 'conflict', conflict.resource_id,
|
|
387
|
-
|
|
448
|
+
resource.kind);
|
|
388
449
|
}
|
|
389
450
|
for (const precedence of artifact.precedences) {
|
|
390
451
|
notePair(precedence.from_task_id, precedence.to_task_id, 'precedence', precedence.reason, null);
|
package/src/todo-store.mjs
CHANGED
|
@@ -21,9 +21,12 @@ import {
|
|
|
21
21
|
validateTodoSnapshot,
|
|
22
22
|
} from './todo-contracts.mjs';
|
|
23
23
|
import {
|
|
24
|
+
TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA,
|
|
25
|
+
isTodoIndependenceLegacyArtifactIdentity,
|
|
24
26
|
validateTodoIndependence,
|
|
25
27
|
validateTodoWitnessSet,
|
|
26
28
|
} from './todo-independence-contracts.mjs';
|
|
29
|
+
import { validateSeamProposal } from './seam-proposal-contracts.mjs';
|
|
27
30
|
import { sha256Bytes, verifyLinearHashChain } from './hash-chain.mjs';
|
|
28
31
|
import {
|
|
29
32
|
parseTodoSourceRef,
|
|
@@ -3609,12 +3612,25 @@ export async function readTodoIndependenceArtifact(options = {}) {
|
|
|
3609
3612
|
const member = activeMember(store, options.planKey);
|
|
3610
3613
|
const ref = todoIndependenceRef(member.plan.plan_key, member.plan.plan_version);
|
|
3611
3614
|
try {
|
|
3612
|
-
|
|
3615
|
+
const artifact = await readArtifact(repoRoot, ref, {
|
|
3613
3616
|
code: 'INDEPENDENCE_ARTIFACT_INVALID',
|
|
3614
3617
|
maxBytes: INDEPENDENCE_ARTIFACT_BYTES,
|
|
3615
|
-
|
|
3618
|
+
// 旧契約は本体を信用せず、canonical JSONと版をまたいで不変なidentityの型だけを見る。
|
|
3619
|
+
// schema名だけの壊れた記録や現行v3をこの分岐へ逃がさず、旧版集合も明示したものだけに閉じる。
|
|
3620
|
+
validate: (value) => validateTodoIndependence(value)
|
|
3621
|
+
|| isTodoIndependenceLegacyArtifactIdentity(value),
|
|
3616
3622
|
missing: true,
|
|
3617
3623
|
});
|
|
3624
|
+
if (artifact === null || validateTodoIndependence(artifact)) return artifact;
|
|
3625
|
+
return {
|
|
3626
|
+
schema: TODO_INDEPENDENCE_LEGACY_MARKER_SCHEMA,
|
|
3627
|
+
legacy_schema: artifact.schema,
|
|
3628
|
+
project_id: member.plan.project_id,
|
|
3629
|
+
plan_key: member.plan.plan_key,
|
|
3630
|
+
plan_version: null,
|
|
3631
|
+
topology_digest: null,
|
|
3632
|
+
base_sha: null,
|
|
3633
|
+
};
|
|
3618
3634
|
} catch (error) {
|
|
3619
3635
|
// 読めない記録は握りつぶさない。ただしどのplanをどう直すかまで言わないと、
|
|
3620
3636
|
// 消費者は「壊れている」以上のことができない。
|
|
@@ -3630,6 +3646,91 @@ export async function readTodoIndependenceArtifact(options = {}) {
|
|
|
3630
3646
|
}
|
|
3631
3647
|
}
|
|
3632
3648
|
|
|
3649
|
+
const SEAM_PROPOSAL_ARTIFACT_NAME = 'seam-proposal.json';
|
|
3650
|
+
const SEAM_PROPOSAL_ARTIFACT_BYTES = 4_194_304;
|
|
3651
|
+
|
|
3652
|
+
/** independence.jsonと同じplan versionディレクトリへ並置し、manifestへ登録しない。 */
|
|
3653
|
+
export function todoSeamProposalRef(planKey, planVersion) {
|
|
3654
|
+
return `${STORE_ROOT_REF}/plans/${planKey}/${planVersion}/${SEAM_PROPOSAL_ARTIFACT_NAME}`;
|
|
3655
|
+
}
|
|
3656
|
+
|
|
3657
|
+
/**
|
|
3658
|
+
* seam proposal artifactをactive planと現在のindependence artifactへbindして書く。
|
|
3659
|
+
* planの正本とmanifestには触れない。
|
|
3660
|
+
*/
|
|
3661
|
+
export async function writeTodoSeamProposalArtifact(options = {}) {
|
|
3662
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
3663
|
+
const { artifact } = options;
|
|
3664
|
+
if (!validateSeamProposal(artifact)) {
|
|
3665
|
+
fail('SEAM_PROPOSAL_ARTIFACT_INVALID', 'seam_proposal_artifact_invalid');
|
|
3666
|
+
}
|
|
3667
|
+
return withLock(repoRoot, async () => {
|
|
3668
|
+
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
|
|
3669
|
+
if (store.project_id !== artifact.project_id) {
|
|
3670
|
+
fail('SEAM_PROPOSAL_BINDING_MISMATCH', 'project_id_mismatch', {
|
|
3671
|
+
expected: store.project_id, actual: artifact.project_id,
|
|
3672
|
+
});
|
|
3673
|
+
}
|
|
3674
|
+
const member = activeMember(store, artifact.plan_key);
|
|
3675
|
+
const binding = artifact.source_binding;
|
|
3676
|
+
if (member.plan.plan_version !== binding.plan_version) {
|
|
3677
|
+
fail('SEAM_PROPOSAL_BINDING_MISMATCH', 'plan_version_mismatch', {
|
|
3678
|
+
expected: member.plan.plan_version, actual: binding.plan_version,
|
|
3679
|
+
});
|
|
3680
|
+
}
|
|
3681
|
+
if (member.plan.topology_digest !== binding.topology_digest) {
|
|
3682
|
+
fail('SEAM_PROPOSAL_BINDING_MISMATCH', 'topology_digest_mismatch', {
|
|
3683
|
+
expected: member.plan.topology_digest, actual: binding.topology_digest,
|
|
3684
|
+
});
|
|
3685
|
+
}
|
|
3686
|
+
const independenceArtifact = await readTodoIndependenceArtifact({
|
|
3687
|
+
repoRoot, store, planKey: artifact.plan_key,
|
|
3688
|
+
});
|
|
3689
|
+
if (independenceArtifact === null
|
|
3690
|
+
|| !validateTodoIndependence(independenceArtifact)
|
|
3691
|
+
|| independenceArtifact.schema !== binding.independence_schema
|
|
3692
|
+
|| independenceArtifact.result_digest !== binding.independence_result_digest
|
|
3693
|
+
|| independenceArtifact.witness_set_digest !== binding.witness_set_digest
|
|
3694
|
+
|| independenceArtifact.plan_version !== binding.plan_version
|
|
3695
|
+
|| independenceArtifact.topology_digest !== binding.topology_digest
|
|
3696
|
+
|| independenceArtifact.base_sha !== binding.base_sha) {
|
|
3697
|
+
fail('SEAM_PROPOSAL_BINDING_MISMATCH', 'independence_binding_mismatch');
|
|
3698
|
+
}
|
|
3699
|
+
const ref = todoSeamProposalRef(artifact.plan_key, binding.plan_version);
|
|
3700
|
+
await atomicWrite(path.resolve(repoRoot, ref), canonicalLine(artifact));
|
|
3701
|
+
return { ref, artifact };
|
|
3702
|
+
});
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3705
|
+
/**
|
|
3706
|
+
* active planに並置されたseam proposal artifactを読む。
|
|
3707
|
+
* 無ければnull、壊れた記録はtyped failureとし、missingへ丸めない。
|
|
3708
|
+
*/
|
|
3709
|
+
export async function readTodoSeamProposalArtifact(options = {}) {
|
|
3710
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
3711
|
+
const store = options.store ?? await readTodoStore({ repoRoot, now: options.now });
|
|
3712
|
+
const member = activeMember(store, options.planKey);
|
|
3713
|
+
const ref = todoSeamProposalRef(member.plan.plan_key, member.plan.plan_version);
|
|
3714
|
+
try {
|
|
3715
|
+
return await readArtifact(repoRoot, ref, {
|
|
3716
|
+
code: 'SEAM_PROPOSAL_ARTIFACT_INVALID',
|
|
3717
|
+
maxBytes: SEAM_PROPOSAL_ARTIFACT_BYTES,
|
|
3718
|
+
validate: validateSeamProposal,
|
|
3719
|
+
missing: true,
|
|
3720
|
+
});
|
|
3721
|
+
} catch (error) {
|
|
3722
|
+
if (error instanceof TodoStoreError && error.code === 'SEAM_PROPOSAL_ARTIFACT_INVALID') {
|
|
3723
|
+
throw new TodoStoreError(error.code, error.detail.reason, undefined, {
|
|
3724
|
+
...error.detail,
|
|
3725
|
+
plan_key: member.plan.plan_key,
|
|
3726
|
+
artifact_ref: ref,
|
|
3727
|
+
next_action: 'recompile_seam_proposal_or_remove_stale_record',
|
|
3728
|
+
});
|
|
3729
|
+
}
|
|
3730
|
+
throw error;
|
|
3731
|
+
}
|
|
3732
|
+
}
|
|
3733
|
+
|
|
3633
3734
|
const WITNESS_SET_BYTES = 4_194_304;
|
|
3634
3735
|
|
|
3635
3736
|
/**
|