@quolu/lattice 0.12.34 → 0.13.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-dashboard.mjs +8 -3
- package/package.json +2 -2
- package/sensor/dist/mcp/server-instructions.d.ts +1 -1
- package/sensor/dist/mcp/server-instructions.d.ts.map +1 -1
- package/sensor/dist/mcp/server-instructions.js +20 -0
- package/sensor/dist/mcp/server-instructions.js.map +1 -1
- package/src/cli-help.mjs +12 -0
- package/src/runtime-front-end.mjs +3 -0
- package/src/todo-cli.mjs +441 -14
- package/src/todo-gantt-html.mjs +92 -6
- package/src/todo-gantt-layout.mjs +69 -1
- package/src/todo-gantt-svg.mjs +23 -4
- package/src/todo-independence-contracts.mjs +380 -0
- package/src/todo-independence-guidance.mjs +107 -0
- package/src/todo-independence.mjs +485 -0
- package/src/todo-status.mjs +47 -12
- package/src/todo-store.mjs +132 -0
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
import { digestTodoArtifact, isTodoIdentifier, todoSelfDigest } from './todo-contracts.mjs';
|
|
2
|
+
import {
|
|
3
|
+
TODO_INDEPENDENCE_SCHEMA,
|
|
4
|
+
isGitSha,
|
|
5
|
+
severabilityOfConflictKind,
|
|
6
|
+
synthesizeWitnessRunRequest,
|
|
7
|
+
validateTodoIndependence,
|
|
8
|
+
validateTodoWitnessSet,
|
|
9
|
+
} from './todo-independence-contracts.mjs';
|
|
10
|
+
import { compileRuntimePlanV1, evidenceFromCollectedOutcomes } from './runtime-front-end.mjs';
|
|
11
|
+
import { collectSensorEvidence } from './sensor-adapter.mjs';
|
|
12
|
+
|
|
13
|
+
export class TodoIndependenceError extends Error {
|
|
14
|
+
constructor(code, reason, detail = {}) {
|
|
15
|
+
super(reason);
|
|
16
|
+
this.name = 'TodoIndependenceError';
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.detail = { reason, ...detail };
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function fail(code, reason, detail) {
|
|
23
|
+
throw new TodoIndependenceError(code, reason, detail);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* compile一回を識別するid。plan identityとwitness digestだけから決まるので、
|
|
30
|
+
* 同じ入力からは同じrequest bytesが出る。run lifecycleへは登録しない。
|
|
31
|
+
*/
|
|
32
|
+
export function witnessRequestId(witnessSet, planVersion) {
|
|
33
|
+
const digest = digestTodoArtifact({
|
|
34
|
+
schema: 'lattice.todo_independence_request_id.v1',
|
|
35
|
+
project_id: witnessSet.project_id,
|
|
36
|
+
plan_key: witnessSet.plan_key,
|
|
37
|
+
plan_version: planVersion,
|
|
38
|
+
witness_set_digest: witnessSet.witness_set_digest,
|
|
39
|
+
});
|
|
40
|
+
return `independence-${digest.slice(0, 24)}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** 実sensorを引いてfront-end evidence契約へ整形する。失敗をunknownへ丸めない。 */
|
|
44
|
+
export async function collectWitnessSensorEvidence({ cwd, witnessSet, execute = undefined }) {
|
|
45
|
+
const querySet = witnessSet.sensor_query_set;
|
|
46
|
+
const collected = await collectSensorEvidence({
|
|
47
|
+
cwd, querySet, ...(execute === undefined ? {} : { execute }),
|
|
48
|
+
});
|
|
49
|
+
return evidenceFromCollectedOutcomes({ querySet, collected });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* conflictへresource kindを載せる(ADR 0128 Decision 1)。
|
|
54
|
+
*
|
|
55
|
+
* `graph.conflicts`はresource_idしか持たず、宣言由来のstate resource idは任意文字列なので
|
|
56
|
+
* prefixからkindを復元できない。normalized resourceを引けない場合は、切断可能性を
|
|
57
|
+
* 不明のまま記録するのでなくtyped failで止める。
|
|
58
|
+
*/
|
|
59
|
+
function conflictsFrom(verdicts, resources) {
|
|
60
|
+
const kindByResourceId = new Map((Array.isArray(resources) ? resources : [])
|
|
61
|
+
.map((resource) => [resource?.resource_id, resource?.kind]));
|
|
62
|
+
return verdicts
|
|
63
|
+
.filter((verdict) => verdict.type === 'conflict')
|
|
64
|
+
.map((verdict) => {
|
|
65
|
+
const kind = kindByResourceId.get(verdict.resource_id);
|
|
66
|
+
if (!['symbol', 'path', 'state', 'effect'].includes(kind)) {
|
|
67
|
+
fail('INDEPENDENCE_RESOURCE_KIND_UNRESOLVED', 'conflict_resource_kind_unresolved', {
|
|
68
|
+
resource_id: verdict.resource_id, observed_kind: kind ?? null,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
task_ids: [...verdict.todo_ids].sort(compareText),
|
|
73
|
+
resource_id: verdict.resource_id,
|
|
74
|
+
kind,
|
|
75
|
+
};
|
|
76
|
+
})
|
|
77
|
+
.sort((left, right) => compareText(
|
|
78
|
+
`${left.task_ids[0]}\0${left.task_ids[1]}\0${left.resource_id}`,
|
|
79
|
+
`${right.task_ids[0]}\0${right.task_ids[1]}\0${right.resource_id}`,
|
|
80
|
+
));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* witnessが宣言した境界pathを集める。
|
|
85
|
+
*
|
|
86
|
+
* 鮮度のdiff交差判定に使うので、observationへ効くpathを漏れなく採る——所有path、
|
|
87
|
+
* 書き込み、読み取り、affected test、sensor queryのexpect path。symbol owns自体は
|
|
88
|
+
* pathを持たないが、それを裏取りするqueryのexpect pathが同じ効果を持つ。
|
|
89
|
+
*/
|
|
90
|
+
function boundaryPathsOf(witness) {
|
|
91
|
+
const paths = new Set();
|
|
92
|
+
for (const own of witness.owns) {
|
|
93
|
+
if (own.kind === 'path') paths.add(own.target);
|
|
94
|
+
}
|
|
95
|
+
for (const path of witness.writes) paths.add(path);
|
|
96
|
+
for (const path of witness.reads) paths.add(path);
|
|
97
|
+
for (const path of witness.affected_tests) paths.add(path);
|
|
98
|
+
for (const entry of witness.sensor_provenance.queries) paths.add(entry.expect.path);
|
|
99
|
+
return [...paths].sort(compareText);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function precedencesFrom(verdicts) {
|
|
103
|
+
return verdicts
|
|
104
|
+
.filter((verdict) => verdict.type === 'precedence')
|
|
105
|
+
.map((verdict) => ({
|
|
106
|
+
from_task_id: verdict.from_todo_id,
|
|
107
|
+
to_task_id: verdict.to_todo_id,
|
|
108
|
+
reason: verdict.reason,
|
|
109
|
+
}))
|
|
110
|
+
.sort((left, right) => compareText(
|
|
111
|
+
`${left.from_task_id}\0${left.to_task_id}\0${left.reason}`,
|
|
112
|
+
`${right.from_task_id}\0${right.to_task_id}\0${right.reason}`,
|
|
113
|
+
));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function unknownsFrom(detail) {
|
|
117
|
+
const entries = [
|
|
118
|
+
...(Array.isArray(detail?.unknowns) ? detail.unknowns : []),
|
|
119
|
+
...(Array.isArray(detail?.unresolved_witnesses) ? detail.unresolved_witnesses : []),
|
|
120
|
+
]
|
|
121
|
+
.filter((entry) => entry !== null && typeof entry === 'object'
|
|
122
|
+
&& typeof entry.todo_id === 'string' && typeof entry.kind === 'string'
|
|
123
|
+
&& typeof entry.ref === 'string')
|
|
124
|
+
.map((entry) => ({
|
|
125
|
+
task_id: entry.todo_id,
|
|
126
|
+
kind: entry.kind,
|
|
127
|
+
ref: entry.ref,
|
|
128
|
+
}));
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
return entries
|
|
131
|
+
.filter((entry) => {
|
|
132
|
+
const key = `${entry.task_id}\0${entry.kind}\0${entry.ref}`;
|
|
133
|
+
if (seen.has(key)) return false;
|
|
134
|
+
seen.add(key);
|
|
135
|
+
return true;
|
|
136
|
+
})
|
|
137
|
+
.sort((left, right) => compareText(
|
|
138
|
+
`${left.task_id}\0${left.kind}\0${left.ref}`,
|
|
139
|
+
`${right.task_id}\0${right.kind}\0${right.ref}`,
|
|
140
|
+
));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* witness setとsensor evidenceから`lattice.todo_independence.v1`を作る。
|
|
145
|
+
*
|
|
146
|
+
* compileは宣言済みtaskの部分集合へ閉じる(ADR 0127 Decision 4)。未宣言taskを混ぜると、
|
|
147
|
+
* unknownが1件でも出た時点で宣言済みtask同士の判定まで失われるためである。
|
|
148
|
+
* `dispatchable`ならpairwise verdictとwave planを写し、`BOUNDARY_UNKNOWN`なら
|
|
149
|
+
* unknownをtask単位で記録してwave planを持たない。それ以外のnon-dispatchableは
|
|
150
|
+
* 契約違反または観測不成立であり、artifactへ丸めずtyped errorで止める。
|
|
151
|
+
*/
|
|
152
|
+
export function compileTodoIndependence(options = {}) {
|
|
153
|
+
const { witnessSet, plan, baseSha, compiledAt, sensorEvidence } = options;
|
|
154
|
+
if (!validateTodoWitnessSet(witnessSet)) {
|
|
155
|
+
fail('INDEPENDENCE_WITNESS_INVALID', 'witness_set_invalid');
|
|
156
|
+
}
|
|
157
|
+
if (!isGitSha(baseSha)) fail('INDEPENDENCE_BASE_INVALID', 'base_sha_invalid');
|
|
158
|
+
if (witnessSet.project_id !== plan.project_id || witnessSet.plan_key !== plan.plan_key) {
|
|
159
|
+
fail('INDEPENDENCE_PLAN_MISMATCH', 'witness_set_plan_mismatch', {
|
|
160
|
+
witness_plan_key: witnessSet.plan_key, plan_key: plan.plan_key,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const planTaskIds = new Set(plan.tasks.map(({ task_id: taskId }) => taskId));
|
|
165
|
+
const taskIds = Object.keys(witnessSet.manual_witness).sort(compareText);
|
|
166
|
+
const absent = taskIds.filter((taskId) => !planTaskIds.has(taskId));
|
|
167
|
+
if (absent.length > 0) {
|
|
168
|
+
fail('INDEPENDENCE_TASK_ABSENT', 'witness_task_absent_from_plan', { task_ids: absent });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const request = synthesizeWitnessRunRequest(witnessSet, {
|
|
172
|
+
baseSha, requestId: witnessRequestId(witnessSet, plan.plan_version),
|
|
173
|
+
});
|
|
174
|
+
const compiled = compileRuntimePlanV1({
|
|
175
|
+
request,
|
|
176
|
+
sensorEvidence,
|
|
177
|
+
planRef: witnessRequestId(witnessSet, plan.plan_version),
|
|
178
|
+
planEpoch: 0,
|
|
179
|
+
predecessorRefs: [],
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
if (compiled.outcome !== 'dispatchable'
|
|
183
|
+
&& !(compiled.outcome === 'non_dispatchable' && compiled.code === 'BOUNDARY_UNKNOWN')) {
|
|
184
|
+
fail('INDEPENDENCE_COMPILE_FAILED', 'boundary_compile_not_dispatchable', {
|
|
185
|
+
code: compiled.code ?? null, compile_detail: compiled.detail ?? null,
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const dispatchable = compiled.outcome === 'dispatchable';
|
|
190
|
+
const artifact = {
|
|
191
|
+
schema: TODO_INDEPENDENCE_SCHEMA,
|
|
192
|
+
project_id: plan.project_id,
|
|
193
|
+
plan_key: plan.plan_key,
|
|
194
|
+
plan_version: plan.plan_version,
|
|
195
|
+
topology_digest: plan.topology_digest,
|
|
196
|
+
base_sha: baseSha,
|
|
197
|
+
witness_set_digest: witnessSet.witness_set_digest,
|
|
198
|
+
compiled_at: compiledAt,
|
|
199
|
+
task_ids: taskIds,
|
|
200
|
+
task_boundaries: taskIds.map((taskId) => ({
|
|
201
|
+
task_id: taskId,
|
|
202
|
+
paths: boundaryPathsOf(witnessSet.manual_witness[taskId]),
|
|
203
|
+
})),
|
|
204
|
+
conflicts: dispatchable
|
|
205
|
+
? conflictsFrom(compiled.pairwise_verdicts, compiled.resources) : [],
|
|
206
|
+
precedences: dispatchable ? precedencesFrom(compiled.pairwise_verdicts) : [],
|
|
207
|
+
unknowns: dispatchable ? [] : unknownsFrom(compiled.detail),
|
|
208
|
+
wave_plan: dispatchable
|
|
209
|
+
? {
|
|
210
|
+
waves: compiled.schedule.waves.map((wave) => ({
|
|
211
|
+
task_ids: [...wave.todo_ids].sort(compareText),
|
|
212
|
+
})),
|
|
213
|
+
minimum_feasible_waves: compiled.schedule.minimum_feasible_waves,
|
|
214
|
+
}
|
|
215
|
+
: null,
|
|
216
|
+
outcome: dispatchable ? 'compiled' : 'unknown',
|
|
217
|
+
result_digest: '',
|
|
218
|
+
};
|
|
219
|
+
artifact.result_digest = todoSelfDigest(artifact, 'result_digest');
|
|
220
|
+
|
|
221
|
+
// unknownを1件も持たないのに非dispatchableという状態は、原因を失った成功扱いになる。
|
|
222
|
+
if (!dispatchable && artifact.unknowns.length === 0) {
|
|
223
|
+
fail('INDEPENDENCE_COMPILE_FAILED', 'boundary_unknown_without_cause', {
|
|
224
|
+
compile_detail: compiled.detail ?? null,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
if (!validateTodoIndependence(artifact)) {
|
|
228
|
+
fail('INDEPENDENCE_ARTIFACT_INVALID', 'independence_artifact_invalid');
|
|
229
|
+
}
|
|
230
|
+
return artifact;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function pairKey(left, right) {
|
|
234
|
+
return left < right ? `${left}\0${right}` : `${right}\0${left}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 変更pathが宣言境界に触れたかを判定する。
|
|
239
|
+
*
|
|
240
|
+
* 完全一致か、宣言側が`dir/`形式のprefixで変更pathを覆う場合に交差とみなす
|
|
241
|
+
* (runtime diff observerのscope判定と同一規則)。
|
|
242
|
+
*/
|
|
243
|
+
function boundaryTouches(declaredPaths, changedPath) {
|
|
244
|
+
return declaredPaths.some((declared) => declared === changedPath
|
|
245
|
+
|| (declared.endsWith('/') && changedPath.startsWith(declared)));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* ready frontierを、記録済みartifactだけを根拠に
|
|
250
|
+
* 「検証済み独立」「直列化すべき組」「進行中との競合」「未検査」へ分けて投影する。
|
|
251
|
+
*
|
|
252
|
+
* sensorは引かない。artifactが無い/planが進んだ場合はcoverageで示す。
|
|
253
|
+
* HEADが動いた場合は`changedPaths`と宣言境界の交差を見て、交差したtaskだけを未検査へ落とす
|
|
254
|
+
* (ADR 0128 Decision 4)。宣言境界に触れないdiffは観測を変えないため、
|
|
255
|
+
* 交差しなかったtaskのverified独立は維持する。
|
|
256
|
+
*
|
|
257
|
+
* @param {object} options
|
|
258
|
+
* @param {object|null} options.artifact 記録済みindependence artifact(v2)
|
|
259
|
+
* @param {string[]} options.readyTaskIds 同一plan内のready task
|
|
260
|
+
* @param {string[]} options.activeTaskIds 同一plan内のin-progress task
|
|
261
|
+
* @param {object} options.plan plan_versionとtopology_digestを持つactive plan
|
|
262
|
+
* @param {string} options.currentBaseSha 現在のHEAD
|
|
263
|
+
* @param {string[]|null} options.changedPaths base_sha..HEADの変更path。
|
|
264
|
+
* nullはdiffを確定できなかったこと(base到達不能)を意味し、全taskを交差扱いにする。
|
|
265
|
+
*/
|
|
266
|
+
export function projectIndependenceFrontier({
|
|
267
|
+
artifact, readyTaskIds, activeTaskIds = [], plan, currentBaseSha, changedPaths = null,
|
|
268
|
+
}) {
|
|
269
|
+
const ready = [...readyTaskIds].sort(compareText);
|
|
270
|
+
const active = [...activeTaskIds].sort(compareText);
|
|
271
|
+
if (!isGitSha(currentBaseSha)) fail('INDEPENDENCE_BASE_INVALID', 'current_base_sha_invalid');
|
|
272
|
+
|
|
273
|
+
const emptyFrontier = () => ({
|
|
274
|
+
parallel_groups: [],
|
|
275
|
+
serialize_pairs: [],
|
|
276
|
+
conflicts_with_active: [],
|
|
277
|
+
unknown: ready.map((taskId) => ({
|
|
278
|
+
task_id: taskId,
|
|
279
|
+
unknowns: [{ kind: 'witness_missing', ref: 'no_independence_record' }],
|
|
280
|
+
})),
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
if (artifact === null) {
|
|
284
|
+
return {
|
|
285
|
+
coverage: 'missing',
|
|
286
|
+
drift: null,
|
|
287
|
+
active_task_ids: active,
|
|
288
|
+
uncovered_active_task_ids: active,
|
|
289
|
+
frontier: emptyFrontier(),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const superseded = artifact.plan_version !== plan.plan_version
|
|
294
|
+
|| artifact.topology_digest !== plan.topology_digest;
|
|
295
|
+
const coverage = superseded ? 'superseded'
|
|
296
|
+
: artifact.base_sha === currentBaseSha ? 'verified' : 'stale';
|
|
297
|
+
|
|
298
|
+
const covered = new Set(artifact.task_ids);
|
|
299
|
+
const boundariesByTask = new Map(artifact.task_boundaries
|
|
300
|
+
.map((entry) => [entry.task_id, entry.paths]));
|
|
301
|
+
|
|
302
|
+
// 宣言境界とdiffの交差。coverage==='stale'のときだけ意味を持つ。
|
|
303
|
+
let drift = null;
|
|
304
|
+
const intersecting = new Set();
|
|
305
|
+
if (coverage === 'stale') {
|
|
306
|
+
const baseReachable = changedPaths !== null;
|
|
307
|
+
if (!baseReachable) {
|
|
308
|
+
// 差分を確定できないなら、記録が今も有効だと主張する根拠が無い。
|
|
309
|
+
for (const taskId of artifact.task_ids) intersecting.add(taskId);
|
|
310
|
+
} else {
|
|
311
|
+
for (const taskId of artifact.task_ids) {
|
|
312
|
+
const declared = boundariesByTask.get(taskId) ?? [];
|
|
313
|
+
if (changedPaths.some((changed) => boundaryTouches(declared, changed))) {
|
|
314
|
+
intersecting.add(taskId);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
drift = {
|
|
319
|
+
base_reachable: baseReachable,
|
|
320
|
+
changed_path_count: baseReachable ? changedPaths.length : 0,
|
|
321
|
+
intersecting_task_ids: [...intersecting].sort(compareText),
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const unknownByTask = new Map();
|
|
326
|
+
const noteUnknown = (taskId, kind, ref) => {
|
|
327
|
+
if (!unknownByTask.has(taskId)) unknownByTask.set(taskId, []);
|
|
328
|
+
unknownByTask.get(taskId).push({ kind, ref });
|
|
329
|
+
};
|
|
330
|
+
for (const taskId of ready) {
|
|
331
|
+
if (!covered.has(taskId)) noteUnknown(taskId, 'witness_missing', 'task_not_in_witness_set');
|
|
332
|
+
}
|
|
333
|
+
for (const entry of artifact.unknowns) {
|
|
334
|
+
if (ready.includes(entry.task_id)) noteUnknown(entry.task_id, entry.kind, entry.ref);
|
|
335
|
+
}
|
|
336
|
+
// planが進んだ記録はtask単位に救えない(topology自体が別物)。
|
|
337
|
+
// HEADだけが進んだ場合は、宣言境界に触れたtaskだけを落とす。
|
|
338
|
+
if (coverage === 'superseded') {
|
|
339
|
+
for (const taskId of ready) {
|
|
340
|
+
if (covered.has(taskId)) noteUnknown(taskId, 'record_superseded', artifact.base_sha);
|
|
341
|
+
}
|
|
342
|
+
} else if (coverage === 'stale') {
|
|
343
|
+
for (const taskId of ready) {
|
|
344
|
+
if (covered.has(taskId) && intersecting.has(taskId)) {
|
|
345
|
+
noteUnknown(taskId, 'record_stale', artifact.base_sha);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// 記録が今のtaskへ有効か。無効な相手との競合は「競合なし」でなく「判定不能」であり、
|
|
351
|
+
// conflicts_with_activeではなくuncovered_activeとして示す。
|
|
352
|
+
const usable = (taskId) => covered.has(taskId)
|
|
353
|
+
&& !(coverage === 'superseded')
|
|
354
|
+
&& !(coverage === 'stale' && intersecting.has(taskId));
|
|
355
|
+
const uncoveredActive = active.filter((taskId) => !usable(taskId));
|
|
356
|
+
|
|
357
|
+
const readySet = new Set(ready);
|
|
358
|
+
const activeSet = new Set(active);
|
|
359
|
+
const serializePairs = [];
|
|
360
|
+
const activeConflicts = [];
|
|
361
|
+
const blocked = new Set();
|
|
362
|
+
|
|
363
|
+
const notePair = (left, right, type, detail, kind) => {
|
|
364
|
+
const severability = type === 'conflict' ? severabilityOfConflictKind(kind) : 'serial';
|
|
365
|
+
const pairKind = type === 'conflict' ? kind : null;
|
|
366
|
+
if (readySet.has(left) && readySet.has(right)) {
|
|
367
|
+
const pair = [left, right].sort(compareText);
|
|
368
|
+
serializePairs.push({
|
|
369
|
+
task_ids: pair, type, detail, kind: pairKind, severability,
|
|
370
|
+
});
|
|
371
|
+
blocked.add(pairKey(left, right));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// 片端がactiveなら着手時の警告面へ回す。v1はここで黙って捨てていた。
|
|
375
|
+
const readyId = readySet.has(left) ? left : readySet.has(right) ? right : null;
|
|
376
|
+
const activeId = activeSet.has(left) ? left : activeSet.has(right) ? right : null;
|
|
377
|
+
if (readyId === null || activeId === null || readyId === activeId) return;
|
|
378
|
+
if (!usable(readyId) || !usable(activeId)) return;
|
|
379
|
+
activeConflicts.push({
|
|
380
|
+
ready_task_id: readyId, active_task_id: activeId, type, detail,
|
|
381
|
+
kind: pairKind, severability,
|
|
382
|
+
});
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
for (const conflict of artifact.conflicts) {
|
|
386
|
+
notePair(conflict.task_ids[0], conflict.task_ids[1], 'conflict', conflict.resource_id,
|
|
387
|
+
conflict.kind);
|
|
388
|
+
}
|
|
389
|
+
for (const precedence of artifact.precedences) {
|
|
390
|
+
notePair(precedence.from_task_id, precedence.to_task_id, 'precedence', precedence.reason, null);
|
|
391
|
+
}
|
|
392
|
+
serializePairs.sort((left, right) => compareText(
|
|
393
|
+
`${left.task_ids[0]}\0${left.task_ids[1]}\0${left.type}\0${left.detail}`,
|
|
394
|
+
`${right.task_ids[0]}\0${right.task_ids[1]}\0${right.type}\0${right.detail}`,
|
|
395
|
+
));
|
|
396
|
+
activeConflicts.sort((left, right) => compareText(
|
|
397
|
+
`${left.ready_task_id}\0${left.active_task_id}\0${left.type}\0${left.detail}`,
|
|
398
|
+
`${right.ready_task_id}\0${right.active_task_id}\0${right.type}\0${right.detail}`,
|
|
399
|
+
));
|
|
400
|
+
|
|
401
|
+
// 未検査を並列グループへ入れない。verdictの不在を独立の証拠にできるのは、
|
|
402
|
+
// 両taskがcompile対象であり、どちらにもunknownが無いときだけである。
|
|
403
|
+
const eligible = ready.filter((taskId) => !unknownByTask.has(taskId));
|
|
404
|
+
const groups = [];
|
|
405
|
+
for (const taskId of eligible) {
|
|
406
|
+
const target = groups.find((group) => group.every((member) => !blocked.has(pairKey(member, taskId))));
|
|
407
|
+
if (target === undefined) groups.push([taskId]); else target.push(taskId);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
return {
|
|
411
|
+
coverage,
|
|
412
|
+
drift,
|
|
413
|
+
active_task_ids: active,
|
|
414
|
+
uncovered_active_task_ids: uncoveredActive,
|
|
415
|
+
frontier: {
|
|
416
|
+
parallel_groups: groups.map((task_ids) => ({ task_ids: [...task_ids].sort(compareText) }))
|
|
417
|
+
.sort((left, right) => compareText(left.task_ids[0], right.task_ids[0])),
|
|
418
|
+
serialize_pairs: serializePairs,
|
|
419
|
+
conflicts_with_active: activeConflicts,
|
|
420
|
+
unknown: [...unknownByTask.entries()]
|
|
421
|
+
.map(([taskId, unknowns]) => ({ task_id: taskId, unknowns }))
|
|
422
|
+
.sort((left, right) => compareText(left.task_id, right.task_id)),
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export function isIndependenceIdentifier(value) {
|
|
428
|
+
return isTodoIdentifier(value);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* revisionのtask migrationでwitness宣言のtask_idを写す(ADR 0128 Decision 6)。
|
|
433
|
+
*
|
|
434
|
+
* 写像だけを担い、宣言内容が改訂後も意味的に妥当かは主張しない——それは機械には判定できない。
|
|
435
|
+
* 解決できないIDはfail closedにする。既に新IDになっている宣言はそのまま通す(冪等)。
|
|
436
|
+
*
|
|
437
|
+
* @param {object} options
|
|
438
|
+
* @param {object} options.witnessSet 現在の宣言
|
|
439
|
+
* @param {Array<{from_task_id: string, to_task_id: string}>} options.taskMigration
|
|
440
|
+
* @param {string[]} options.planTaskIds 移行後planのtask_id集合
|
|
441
|
+
*/
|
|
442
|
+
export function migrateWitnessSetTaskIds({ witnessSet, taskMigration, planTaskIds }) {
|
|
443
|
+
const mapping = new Map(taskMigration.map((entry) => [entry.from_task_id, entry.to_task_id]));
|
|
444
|
+
const current = new Set(planTaskIds);
|
|
445
|
+
const migrated = {};
|
|
446
|
+
const unresolved = [];
|
|
447
|
+
let migratedCount = 0;
|
|
448
|
+
let removedCount = 0;
|
|
449
|
+
let unchangedCount = 0;
|
|
450
|
+
|
|
451
|
+
for (const taskId of Object.keys(witnessSet.manual_witness).sort(compareText)) {
|
|
452
|
+
const witness = witnessSet.manual_witness[taskId];
|
|
453
|
+
if (mapping.has(taskId)) {
|
|
454
|
+
const target = mapping.get(taskId);
|
|
455
|
+
if (target === 'removed') { removedCount += 1; continue; }
|
|
456
|
+
migrated[target] = witness;
|
|
457
|
+
if (target === taskId) unchangedCount += 1; else migratedCount += 1;
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
460
|
+
// 移行表に無くても、既に現planのtaskなら再実行とみなして通す。
|
|
461
|
+
if (current.has(taskId)) { migrated[taskId] = witness; unchangedCount += 1; continue; }
|
|
462
|
+
unresolved.push(taskId);
|
|
463
|
+
}
|
|
464
|
+
if (unresolved.length > 0) {
|
|
465
|
+
fail('WITNESS_MIGRATION_UNRESOLVED', 'witness_task_id_unresolved', {
|
|
466
|
+
task_ids: unresolved.sort(compareText),
|
|
467
|
+
next_action: 'update_witness_set_manually',
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
if (Object.keys(migrated).length === 0) {
|
|
471
|
+
fail('WITNESS_MIGRATION_EMPTY', 'witness_set_would_be_empty');
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const next = { ...witnessSet, manual_witness: migrated, witness_set_digest: '' };
|
|
475
|
+
next.witness_set_digest = todoSelfDigest(next, 'witness_set_digest');
|
|
476
|
+
if (!validateTodoWitnessSet(next)) {
|
|
477
|
+
fail('INVALID_TODO_WITNESS_SET', 'migrated_witness_set_invalid');
|
|
478
|
+
}
|
|
479
|
+
return {
|
|
480
|
+
witnessSet: next,
|
|
481
|
+
migrated_count: migratedCount,
|
|
482
|
+
removed_count: removedCount,
|
|
483
|
+
unchanged_count: unchangedCount,
|
|
484
|
+
};
|
|
485
|
+
}
|
package/src/todo-status.mjs
CHANGED
|
@@ -167,8 +167,13 @@ export function validateTodoStatusResult(value) {
|
|
|
167
167
|
}
|
|
168
168
|
}
|
|
169
169
|
|
|
170
|
-
/**
|
|
171
|
-
|
|
170
|
+
/**
|
|
171
|
+
* read modelからtask node・依存辺・phase gateを組み立てる。
|
|
172
|
+
*
|
|
173
|
+
* ready判定の唯一の計算元。`projectTodoStatus`と`computeReadyFrontier`が同じ結果を
|
|
174
|
+
* 出すために共有する(同じ規則を二箇所へ持たない)。
|
|
175
|
+
*/
|
|
176
|
+
function buildTodoGraph(readModel) {
|
|
172
177
|
if (!plain(readModel) || readModel.schema !== 'lattice.todo_store_read.v1'
|
|
173
178
|
|| !isTodoIdentifier(readModel.project_id) || !Array.isArray(readModel.members)) {
|
|
174
179
|
fail('TODO_STATUS_INVALID_INPUT', 'todo_status_read_model_invalid');
|
|
@@ -275,6 +280,45 @@ export function projectTodoStatus(readModel) {
|
|
|
275
280
|
}
|
|
276
281
|
}
|
|
277
282
|
|
|
283
|
+
return { nodes, incoming, phaseAcceptIncoming, phaseStatuses, memberHeads };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 先行完了とphase gateを満たしたpending taskだけがreadyになる。 */
|
|
287
|
+
function isNodeReady(node, { nodes, incoming, phaseAcceptIncoming, phaseStatuses }) {
|
|
288
|
+
return node.status === 'pending' && node.phase_ready
|
|
289
|
+
&& [...incoming.get(node.key)].every((key) => {
|
|
290
|
+
const predecessor = nodes.get(key);
|
|
291
|
+
return predecessor.status === 'done'
|
|
292
|
+
&& (predecessor.plan_schema !== 'lattice.todo_plan.v4'
|
|
293
|
+
|| predecessor.phase_id === null
|
|
294
|
+
|| (predecessor.plan_key === node.plan_key && predecessor.phase_id === node.phase_id)
|
|
295
|
+
|| predecessor.phase_status === 'accepted');
|
|
296
|
+
})
|
|
297
|
+
&& [...phaseAcceptIncoming.get(node.key)]
|
|
298
|
+
.every((key) => phaseStatuses.get(key) === 'accepted');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* ready frontierだけをread-only投影する。
|
|
303
|
+
*
|
|
304
|
+
* `todo status`の`next_ready`と同じ計算・同じ順序を返す。independence投影が
|
|
305
|
+
* ready集合を別実装で持たないための共有入口である。
|
|
306
|
+
*/
|
|
307
|
+
export function computeReadyFrontier(readModel) {
|
|
308
|
+
const graph = buildTodoGraph(readModel);
|
|
309
|
+
const ready = [...graph.nodes.values()]
|
|
310
|
+
.filter((node) => isNodeReady(node, graph))
|
|
311
|
+
.map((node) => ({ plan_key: node.plan_key, task_id: node.task_id, label: node.label }));
|
|
312
|
+
ready.sort(compareTaskEntries);
|
|
313
|
+
enforceListLimit('next_ready', ready);
|
|
314
|
+
return ready;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Canonical todo read modelからSessionStart向け現在地をread-only投影する。 */
|
|
318
|
+
export function projectTodoStatus(readModel) {
|
|
319
|
+
const graph = buildTodoGraph(readModel);
|
|
320
|
+
const { nodes, incoming, memberHeads } = graph;
|
|
321
|
+
|
|
278
322
|
const activeSet = [];
|
|
279
323
|
const nextReady = [];
|
|
280
324
|
const blocked = [];
|
|
@@ -291,16 +335,7 @@ export function projectTodoStatus(readModel) {
|
|
|
291
335
|
enforceListLimit('active_set.unmet_dependencies', unmetDependencies);
|
|
292
336
|
activeSet.push({ ...task, unmet_dependencies: unmetDependencies });
|
|
293
337
|
}
|
|
294
|
-
if (node
|
|
295
|
-
&& [...incoming.get(node.key)].every((key) => {
|
|
296
|
-
const predecessor = nodes.get(key);
|
|
297
|
-
return predecessor.status === 'done'
|
|
298
|
-
&& (predecessor.plan_schema !== 'lattice.todo_plan.v4'
|
|
299
|
-
|| predecessor.phase_id === null
|
|
300
|
-
|| (predecessor.plan_key === node.plan_key && predecessor.phase_id === node.phase_id)
|
|
301
|
-
|| predecessor.phase_status === 'accepted');
|
|
302
|
-
}) && [...phaseAcceptIncoming.get(node.key)]
|
|
303
|
-
.every((key) => phaseStatuses.get(key) === 'accepted')) nextReady.push(task);
|
|
338
|
+
if (isNodeReady(node, graph)) nextReady.push(task);
|
|
304
339
|
if (node.status === 'blocked') {
|
|
305
340
|
blocked.push({
|
|
306
341
|
plan_key: node.plan_key,
|