@quolu/lattice 0.57.2 → 0.58.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/README.ja.md +32 -0
- package/README.md +33 -0
- package/docs/schemas/lattice.todo_structure_binding.v1.schema.json +47 -0
- package/docs/schemas/lattice.todo_structure_realization.v1.schema.json +55 -0
- package/docs/schemas/lattice.todo_structure_set.v1.schema.json +264 -0
- package/package.json +4 -1
- package/sensor/dist/bin/exact-traversal.d.ts +20 -0
- package/sensor/dist/bin/exact-traversal.d.ts.map +1 -0
- package/sensor/dist/bin/exact-traversal.js +17 -0
- package/sensor/dist/bin/exact-traversal.js.map +1 -0
- package/sensor/dist/bin/lattice-sensor.js +27 -8
- package/sensor/package.json +1 -1
- package/src/cli-help.mjs +16 -3
- package/src/dag-chain.mjs +37 -0
- package/src/project-cli.mjs +9 -3
- package/src/runtime-pull-intake.mjs +8 -4
- package/src/sensor-adapter.mjs +8 -2
- package/src/todo-chain.mjs +19 -5
- package/src/todo-cli.mjs +612 -7
- package/src/todo-gantt-html-independence.mjs +9 -1
- package/src/todo-gantt-html-style.mjs +8 -0
- package/src/todo-gantt-html.mjs +14 -4
- package/src/todo-gantt-structure.mjs +134 -0
- package/src/todo-status.mjs +32 -6
- package/src/todo-store.mjs +645 -27
- package/src/todo-structure-contracts.mjs +706 -0
- package/src/todo-structure-git-adapter.mjs +452 -0
- package/src/todo-structure-overlay.mjs +599 -0
- package/src/todo-structure-presentation.mjs +163 -0
- package/src/todo-structure-source-adapter.mjs +417 -0
- package/src/todo-structure-store.mjs +475 -0
|
@@ -0,0 +1,475 @@
|
|
|
1
|
+
import {
|
|
2
|
+
canonicalizeTodoArtifact, exactRecord, todoSelfDigest, validateTodoPlan,
|
|
3
|
+
} from './todo-contracts.mjs';
|
|
4
|
+
import { gitSync } from './git-process.mjs';
|
|
5
|
+
import {
|
|
6
|
+
TODO_STRUCTURE_COMPILE_ARTIFACT_SCHEMA,
|
|
7
|
+
TODO_STRUCTURE_PROFILE,
|
|
8
|
+
digestTodoStructureRealizationHeads,
|
|
9
|
+
digestTodoStructureTransform,
|
|
10
|
+
explainTodoStructureCompileArtifact,
|
|
11
|
+
explainTodoStructureRealization,
|
|
12
|
+
explainTodoStructureSet,
|
|
13
|
+
} from './todo-structure-contracts.mjs';
|
|
14
|
+
import {
|
|
15
|
+
readTodoStore,
|
|
16
|
+
readTodoStructureBinding,
|
|
17
|
+
readTodoStructureCompileArtifact,
|
|
18
|
+
readTodoStructureFinalization,
|
|
19
|
+
readTodoStructureRealizationChain,
|
|
20
|
+
readTodoStructureSource,
|
|
21
|
+
} from './todo-store.mjs';
|
|
22
|
+
|
|
23
|
+
const SHA = /^[0-9a-f]{40}$/u;
|
|
24
|
+
const compareText = (left, right) => left < right ? -1 : left > right ? 1 : 0;
|
|
25
|
+
|
|
26
|
+
export class TodoStructureStoreError extends Error {
|
|
27
|
+
constructor(code, reason, detail = {}) {
|
|
28
|
+
super(reason);
|
|
29
|
+
this.name = 'TodoStructureStoreError';
|
|
30
|
+
this.code = code;
|
|
31
|
+
this.detail = { reason, ...detail };
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fail(code, reason, detail = {}) {
|
|
36
|
+
throw new TodoStructureStoreError(code, reason, detail);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function latestRealizationHeads(structureSet, realizations) {
|
|
40
|
+
if (!Array.isArray(realizations)) fail('STRUCTURE_COMPILE_INPUT_INVALID', 'realizations_invalid');
|
|
41
|
+
const grouped = new Map();
|
|
42
|
+
for (const realization of realizations) {
|
|
43
|
+
const entries = grouped.get(realization?.task_id) ?? [];
|
|
44
|
+
entries.push(realization); grouped.set(realization?.task_id, entries);
|
|
45
|
+
}
|
|
46
|
+
const heads = [];
|
|
47
|
+
for (const [taskId, unsorted] of grouped) {
|
|
48
|
+
const entries = [...unsorted].sort((left, right) => left.sequence - right.sequence);
|
|
49
|
+
let previous = null; const priorDigests = new Set();
|
|
50
|
+
for (const realization of entries) {
|
|
51
|
+
const result = explainTodoStructureRealization(realization, {
|
|
52
|
+
structureSet, previous, priorDigests,
|
|
53
|
+
});
|
|
54
|
+
if (!result.valid || realization.task_id !== taskId) {
|
|
55
|
+
fail('STRUCTURE_COMPILE_INPUT_INVALID', result.valid ? 'task_id_mismatch' : result.reason, {
|
|
56
|
+
task_id: taskId ?? null, path: result.path ?? '/task_id',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
previous = realization; priorDigests.add(realization.realization_digest);
|
|
60
|
+
}
|
|
61
|
+
if (previous !== null) heads.push({
|
|
62
|
+
task_id: taskId, sequence: previous.sequence,
|
|
63
|
+
realization_digest: previous.realization_digest,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return heads.sort((left, right) => compareText(left.task_id, right.task_id));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** compilerの各bounded出力を一つのself-digested artifactへ束縛する。 */
|
|
70
|
+
export function buildTodoStructureCompileArtifact({
|
|
71
|
+
structureSet, sourceProjection, gitProvenance, overlay, realizations = [], compiledAt, actor,
|
|
72
|
+
} = {}) {
|
|
73
|
+
const structure = explainTodoStructureSet(structureSet);
|
|
74
|
+
if (!structure.valid) {
|
|
75
|
+
fail('STRUCTURE_COMPILE_INPUT_INVALID', structure.reason, { path: structure.path });
|
|
76
|
+
}
|
|
77
|
+
const heads = latestRealizationHeads(structureSet, realizations);
|
|
78
|
+
const artifact = {
|
|
79
|
+
schema: TODO_STRUCTURE_COMPILE_ARTIFACT_SCHEMA,
|
|
80
|
+
project_id: structureSet.project_id,
|
|
81
|
+
plan_key: structureSet.plan_key,
|
|
82
|
+
plan_version: structureSet.plan_version,
|
|
83
|
+
topology_digest: structureSet.topology_digest,
|
|
84
|
+
profile: TODO_STRUCTURE_PROFILE,
|
|
85
|
+
baseline_sha: structureSet.baseline_sha,
|
|
86
|
+
current_head_sha: gitProvenance?.head_sha ?? null,
|
|
87
|
+
structure_set_digest: structureSet.structure_set_digest,
|
|
88
|
+
source_projection: structuredClone(sourceProjection),
|
|
89
|
+
git_provenance: structuredClone(gitProvenance),
|
|
90
|
+
realization_heads: heads,
|
|
91
|
+
realization_head_digest: digestTodoStructureRealizationHeads(heads),
|
|
92
|
+
overlay: structuredClone(overlay),
|
|
93
|
+
compiled_at: compiledAt,
|
|
94
|
+
actor: structuredClone(actor),
|
|
95
|
+
artifact_digest: '',
|
|
96
|
+
};
|
|
97
|
+
artifact.artifact_digest = todoSelfDigest(artifact, 'artifact_digest');
|
|
98
|
+
const explained = explainTodoStructureCompileArtifact(artifact);
|
|
99
|
+
if (!explained.valid) {
|
|
100
|
+
fail('STRUCTURE_COMPILE_INPUT_INVALID', explained.reason, { path: explained.path });
|
|
101
|
+
}
|
|
102
|
+
return artifact;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** plannedを残したまま、最新realizationをeffectiveへ投影するdiagnostic面。 */
|
|
106
|
+
export function projectTodoStructureEffective({ structureSet, realizations = [] } = {}) {
|
|
107
|
+
const structure = explainTodoStructureSet(structureSet);
|
|
108
|
+
if (!structure.valid) fail('STRUCTURE_EFFECTIVE_INPUT_INVALID', structure.reason, {
|
|
109
|
+
path: structure.path,
|
|
110
|
+
});
|
|
111
|
+
const heads = latestRealizationHeads(structureSet, realizations);
|
|
112
|
+
const headDigests = new Map(heads.map((head) => [head.task_id, head.realization_digest]));
|
|
113
|
+
const latest = new Map(realizations
|
|
114
|
+
.filter((entry) => headDigests.get(entry.task_id) === entry.realization_digest)
|
|
115
|
+
.map((entry) => [entry.task_id, entry]));
|
|
116
|
+
const transformFields = [
|
|
117
|
+
'outcome', 'inputs', 'operations', 'outputs', 'code_anchors', 'failures',
|
|
118
|
+
'first_live_e2e', 'non_goals',
|
|
119
|
+
];
|
|
120
|
+
const tasks = structureSet.tasks.map((task) => {
|
|
121
|
+
if (task.applicability === 'excluded') return {
|
|
122
|
+
task_id: task.task_id, applicability: 'excluded', form: 'excluded',
|
|
123
|
+
planned_digest: null, realization_digest: null,
|
|
124
|
+
changed_fields: [], effective: null,
|
|
125
|
+
};
|
|
126
|
+
const realization = latest.get(task.task_id) ?? null;
|
|
127
|
+
const changedFields = realization === null ? [] : transformFields.filter((field) => (
|
|
128
|
+
canonicalizeTodoArtifact(task.planned[field])
|
|
129
|
+
!== canonicalizeTodoArtifact(realization.realized[field])
|
|
130
|
+
));
|
|
131
|
+
return {
|
|
132
|
+
task_id: task.task_id, applicability: 'graph',
|
|
133
|
+
form: realization === null ? 'planned' : 'realized',
|
|
134
|
+
planned_digest: digestTodoStructureTransform(task.planned),
|
|
135
|
+
realization_digest: realization?.realization_digest ?? null,
|
|
136
|
+
changed_fields: changedFields,
|
|
137
|
+
effective: structuredClone(realization?.realized ?? task.planned),
|
|
138
|
+
};
|
|
139
|
+
});
|
|
140
|
+
const history = [...realizations]
|
|
141
|
+
.sort((left, right) => compareText(left.task_id, right.task_id) || left.sequence - right.sequence)
|
|
142
|
+
.map((entry) => ({
|
|
143
|
+
task_id: entry.task_id, sequence: entry.sequence,
|
|
144
|
+
realization_digest: entry.realization_digest, supersedes: entry.supersedes,
|
|
145
|
+
head_sha: entry.head_sha, commit_oids: entry.commit_oids,
|
|
146
|
+
}));
|
|
147
|
+
const projection = {
|
|
148
|
+
schema: 'lattice.todo_structure_effective.v1',
|
|
149
|
+
structure_set_digest: structureSet.structure_set_digest,
|
|
150
|
+
realization_head_digest: digestTodoStructureRealizationHeads(heads),
|
|
151
|
+
tasks, history, projection_digest: '',
|
|
152
|
+
};
|
|
153
|
+
projection.projection_digest = todoSelfDigest(projection, 'projection_digest');
|
|
154
|
+
return projection;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resolveCurrentHead(repoRoot, resolveHead) {
|
|
158
|
+
let value;
|
|
159
|
+
try {
|
|
160
|
+
value = resolveHead === undefined
|
|
161
|
+
? gitSync(['rev-parse', '--verify', 'HEAD^{commit}'], {
|
|
162
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
163
|
+
}).trim()
|
|
164
|
+
: resolveHead(repoRoot);
|
|
165
|
+
} catch (error) {
|
|
166
|
+
fail('STRUCTURE_GIT_HEAD_UNAVAILABLE', 'current_head_unavailable', {
|
|
167
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
if (!SHA.test(value)) fail('STRUCTURE_GIT_HEAD_UNAVAILABLE', 'current_head_invalid');
|
|
171
|
+
return value;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function currentRealizationHeads(repoRoot, structureSet) {
|
|
175
|
+
const heads = [];
|
|
176
|
+
for (const task of structureSet.tasks) {
|
|
177
|
+
if (task.applicability !== 'graph') continue;
|
|
178
|
+
const chain = await readTodoStructureRealizationChain({
|
|
179
|
+
repoRoot, structureSet, taskId: task.task_id,
|
|
180
|
+
});
|
|
181
|
+
const head = chain.at(-1);
|
|
182
|
+
if (head !== undefined) heads.push({
|
|
183
|
+
task_id: task.task_id, sequence: head.sequence, realization_digest: head.realization_digest,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return heads;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function projectState({ status, reason, plan, source = null, binding = null, artifact = null,
|
|
190
|
+
staleReasons = [] }) {
|
|
191
|
+
return {
|
|
192
|
+
schema: 'lattice.todo_structure_state.v1',
|
|
193
|
+
status,
|
|
194
|
+
reason,
|
|
195
|
+
plan_key: plan.plan_key,
|
|
196
|
+
plan_version: plan.plan_version,
|
|
197
|
+
topology_digest: plan.topology_digest,
|
|
198
|
+
structure_set_digest: source?.structure_set_digest ?? null,
|
|
199
|
+
binding_digest: binding?.binding_digest ?? null,
|
|
200
|
+
artifact_digest: artifact?.artifact_digest ?? null,
|
|
201
|
+
compiled_verdict: artifact?.overlay?.verdict ?? null,
|
|
202
|
+
effective_verdict: status === 'fresh' ? artifact.overlay.verdict : null,
|
|
203
|
+
stale_reasons: [...staleReasons].sort(compareText),
|
|
204
|
+
artifact,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* 保存artifactを読むだけのprojection。sensor/Git diffは起動せず、GitはHEAD identityだけ読む。
|
|
210
|
+
* stale artifactのcompiled verdictは履歴として残すが、effective verdictには絶対に昇格させない。
|
|
211
|
+
*/
|
|
212
|
+
export async function readTodoStructureState(options = {}) {
|
|
213
|
+
const repoRoot = options.repoRoot ?? process.cwd();
|
|
214
|
+
const store = options.store ?? await readTodoStore({ repoRoot, now: options.now });
|
|
215
|
+
const member = store.members.find(({ plan }) => plan.plan_key === options.planKey);
|
|
216
|
+
if (member === undefined) fail('STRUCTURE_PLAN_NOT_FOUND', 'plan_not_active', {
|
|
217
|
+
plan_key: options.planKey,
|
|
218
|
+
});
|
|
219
|
+
const plan = member.plan;
|
|
220
|
+
const source = await readTodoStructureSource({ repoRoot, planKey: plan.plan_key });
|
|
221
|
+
const binding = await readTodoStructureBinding({
|
|
222
|
+
repoRoot, planKey: plan.plan_key, planVersion: plan.plan_version,
|
|
223
|
+
});
|
|
224
|
+
const artifact = await readTodoStructureCompileArtifact({
|
|
225
|
+
repoRoot, planKey: plan.plan_key, planVersion: plan.plan_version,
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
if (source === null) {
|
|
229
|
+
if (binding !== null || artifact !== null) {
|
|
230
|
+
fail('STRUCTURE_SOURCE_MISSING', 'activated_planned_source_missing', {
|
|
231
|
+
plan_key: plan.plan_key, plan_version: plan.plan_version,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
return projectState({ status: 'missing', reason: 'structure_source_missing', plan });
|
|
235
|
+
}
|
|
236
|
+
if (source.project_id !== plan.project_id || source.plan_key !== plan.plan_key) {
|
|
237
|
+
fail('STRUCTURE_SOURCE_CORRUPT', 'planned_source_identity_mismatch');
|
|
238
|
+
}
|
|
239
|
+
if (source.plan_version !== plan.plan_version) {
|
|
240
|
+
const oldBinding = await readTodoStructureBinding({
|
|
241
|
+
repoRoot, planKey: source.plan_key, planVersion: source.plan_version,
|
|
242
|
+
});
|
|
243
|
+
const oldArtifact = await readTodoStructureCompileArtifact({
|
|
244
|
+
repoRoot, planKey: source.plan_key, planVersion: source.plan_version,
|
|
245
|
+
});
|
|
246
|
+
return projectState({
|
|
247
|
+
status: 'superseded', reason: 'plan_revision_changed', plan, source,
|
|
248
|
+
binding: oldBinding, artifact: oldArtifact,
|
|
249
|
+
staleReasons: ['plan_version'],
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (binding === null) {
|
|
253
|
+
return projectState({
|
|
254
|
+
status: 'missing', reason: artifact === null
|
|
255
|
+
? 'compile_artifact_missing' : 'activation_binding_missing',
|
|
256
|
+
plan, source, artifact,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
if (artifact === null) {
|
|
260
|
+
fail('STRUCTURE_COMPILE_ARTIFACT_MISSING', 'activated_compile_artifact_missing', {
|
|
261
|
+
plan_key: plan.plan_key, plan_version: plan.plan_version,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
if (binding.project_id !== plan.project_id || binding.plan_key !== plan.plan_key
|
|
265
|
+
|| binding.plan_version !== plan.plan_version || binding.topology_digest !== plan.topology_digest
|
|
266
|
+
|| binding.compile_artifact_digest !== artifact.artifact_digest
|
|
267
|
+
|| binding.compiled_head_sha !== artifact.current_head_sha
|
|
268
|
+
|| binding.structure_set_digest !== artifact.structure_set_digest) {
|
|
269
|
+
fail('STRUCTURE_BINDING_CORRUPT', 'binding_source_artifact_mismatch');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const currentHeadSha = resolveCurrentHead(repoRoot, options.resolveHead);
|
|
273
|
+
const realizationHeads = await currentRealizationHeads(repoRoot, source);
|
|
274
|
+
const realizationHeadDigest = digestTodoStructureRealizationHeads(realizationHeads);
|
|
275
|
+
const staleReasons = [];
|
|
276
|
+
if (artifact.current_head_sha !== currentHeadSha) staleReasons.push('current_head_sha');
|
|
277
|
+
if (artifact.topology_digest !== plan.topology_digest) staleReasons.push('topology_digest');
|
|
278
|
+
if (artifact.structure_set_digest !== source.structure_set_digest) staleReasons.push('structure_set_digest');
|
|
279
|
+
if (artifact.realization_head_digest !== realizationHeadDigest) staleReasons.push('realization_head_digest');
|
|
280
|
+
return projectState({
|
|
281
|
+
status: staleReasons.length === 0 ? 'fresh' : 'stale',
|
|
282
|
+
reason: staleReasons.length === 0 ? null : 'freshness_key_changed',
|
|
283
|
+
plan, source, binding, artifact, staleReasons,
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* terminal gateが読むfinalizationの鮮度。sensorやdiffを再実行せず、保存artifactを
|
|
289
|
+
* active plan/source、現在HEAD、現在realization headsへexactに照合する。
|
|
290
|
+
*/
|
|
291
|
+
export async function readTodoStructureFinalizationState(options = {}) {
|
|
292
|
+
const repoRoot = options.repoRoot ?? process.cwd();
|
|
293
|
+
const store = options.store ?? await readTodoStore({ repoRoot, now: options.now });
|
|
294
|
+
const member = store.members.find(({ plan }) => plan.plan_key === options.planKey);
|
|
295
|
+
if (member === undefined) fail('STRUCTURE_PLAN_NOT_FOUND', 'plan_not_active', {
|
|
296
|
+
plan_key: options.planKey,
|
|
297
|
+
});
|
|
298
|
+
const plan = member.plan;
|
|
299
|
+
const binding = await readTodoStructureBinding({
|
|
300
|
+
repoRoot, planKey: plan.plan_key, planVersion: plan.plan_version,
|
|
301
|
+
});
|
|
302
|
+
if (binding === null) return {
|
|
303
|
+
schema: 'lattice.todo_structure_finalization_state.v1',
|
|
304
|
+
plan_key: plan.plan_key, plan_version: plan.plan_version,
|
|
305
|
+
enabled: false, required: false, status: 'not_applicable', reason: 'structure_not_enabled',
|
|
306
|
+
finalization_digest: null, stale_reasons: [], artifact: null,
|
|
307
|
+
};
|
|
308
|
+
const source = await readTodoStructureSource({ repoRoot, planKey: plan.plan_key });
|
|
309
|
+
if (source === null || source.structure_set_digest !== binding.structure_set_digest) {
|
|
310
|
+
fail('STRUCTURE_SOURCE_MISSING', 'activated_planned_source_missing', {
|
|
311
|
+
plan_key: plan.plan_key, plan_version: plan.plan_version,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
const terminalClosed = Array.isArray(member.phases) && member.phases.length > 0
|
|
315
|
+
&& member.phases.every(({ status }) => ['accepted', 'closed_unaudited'].includes(status));
|
|
316
|
+
const required = member.tasks.every(({ status }) => status === 'done') && !terminalClosed;
|
|
317
|
+
const artifact = await readTodoStructureFinalization({
|
|
318
|
+
repoRoot, planKey: plan.plan_key, planVersion: plan.plan_version,
|
|
319
|
+
});
|
|
320
|
+
if (artifact === null) return {
|
|
321
|
+
schema: 'lattice.todo_structure_finalization_state.v1',
|
|
322
|
+
plan_key: plan.plan_key, plan_version: plan.plan_version,
|
|
323
|
+
enabled: true, required,
|
|
324
|
+
status: required ? 'missing' : terminalClosed ? 'complete' : 'not_ready',
|
|
325
|
+
reason: required ? 'finalization_missing' : terminalClosed ? 'terminal_closed' : 'tasks_incomplete',
|
|
326
|
+
finalization_digest: null, stale_reasons: [], artifact: null,
|
|
327
|
+
};
|
|
328
|
+
const currentHeadSha = resolveCurrentHead(repoRoot, options.resolveHead);
|
|
329
|
+
const realizationHeads = await currentRealizationHeads(repoRoot, source);
|
|
330
|
+
const staleReasons = [];
|
|
331
|
+
if (artifact.project_id !== plan.project_id) staleReasons.push('project_id');
|
|
332
|
+
if (artifact.plan_key !== plan.plan_key) staleReasons.push('plan_key');
|
|
333
|
+
if (artifact.plan_version !== plan.plan_version) staleReasons.push('plan_version');
|
|
334
|
+
if (artifact.topology_digest !== plan.topology_digest) staleReasons.push('topology_digest');
|
|
335
|
+
if (artifact.structure_set_digest !== source.structure_set_digest) {
|
|
336
|
+
staleReasons.push('structure_set_digest');
|
|
337
|
+
}
|
|
338
|
+
if (artifact.current_head_sha !== currentHeadSha) staleReasons.push('current_head_sha');
|
|
339
|
+
if (artifact.realization_head_digest
|
|
340
|
+
!== digestTodoStructureRealizationHeads(realizationHeads)) {
|
|
341
|
+
staleReasons.push('realization_head_digest');
|
|
342
|
+
}
|
|
343
|
+
if (artifact.overlay.verdict !== 'consistent') staleReasons.push('verdict');
|
|
344
|
+
return {
|
|
345
|
+
schema: 'lattice.todo_structure_finalization_state.v1',
|
|
346
|
+
plan_key: plan.plan_key, plan_version: plan.plan_version,
|
|
347
|
+
enabled: true, required,
|
|
348
|
+
status: staleReasons.length === 0 ? 'fresh' : 'stale',
|
|
349
|
+
reason: staleReasons.length === 0 ? null : 'freshness_key_changed',
|
|
350
|
+
finalization_digest: artifact.artifact_digest,
|
|
351
|
+
stale_reasons: staleReasons.sort(compareText), artifact,
|
|
352
|
+
};
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** todo/project statusへ載せる、終端を保留しているplanだけのbounded入力。 */
|
|
356
|
+
export async function readTodoStructureFinalizationsForStatus(options = {}) {
|
|
357
|
+
const repoRoot = options.repoRoot ?? process.cwd();
|
|
358
|
+
const store = options.store ?? await readTodoStore({ repoRoot, now: options.now });
|
|
359
|
+
const entries = [];
|
|
360
|
+
for (const member of store.members) {
|
|
361
|
+
const state = await readTodoStructureFinalizationState({
|
|
362
|
+
repoRoot, store, planKey: member.plan.plan_key,
|
|
363
|
+
});
|
|
364
|
+
if (!state.required || state.status === 'fresh') continue;
|
|
365
|
+
entries.push({
|
|
366
|
+
plan_key: member.plan.plan_key,
|
|
367
|
+
status: state.status,
|
|
368
|
+
reason: state.reason,
|
|
369
|
+
stale_reasons: state.stale_reasons,
|
|
370
|
+
next_commands: [`lattice todo structure finalize --plan ${member.plan.plan_key} --json`],
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
return entries.sort((left, right) => compareText(left.plan_key, right.plan_key));
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function validateTaskMigration(taskMigration) {
|
|
377
|
+
if (!Array.isArray(taskMigration) || taskMigration.length === 0) {
|
|
378
|
+
fail('STRUCTURE_MIGRATION_INVALID', 'task_migration_missing');
|
|
379
|
+
}
|
|
380
|
+
const from = new Set(); const to = new Set();
|
|
381
|
+
for (const entry of taskMigration) {
|
|
382
|
+
if (!exactRecord(entry, ['from_task_id', 'to_task_id', 'state_policy'])
|
|
383
|
+
|| typeof entry.from_task_id !== 'string' || typeof entry.to_task_id !== 'string'
|
|
384
|
+
|| from.has(entry.from_task_id)
|
|
385
|
+
|| (entry.to_task_id !== 'removed' && to.has(entry.to_task_id))) {
|
|
386
|
+
fail('STRUCTURE_MIGRATION_INVALID', 'task_migration_invalid');
|
|
387
|
+
}
|
|
388
|
+
from.add(entry.from_task_id);
|
|
389
|
+
if (entry.to_task_id !== 'removed') to.add(entry.to_task_id);
|
|
390
|
+
}
|
|
391
|
+
return new Map(taskMigration.map((entry) => [entry.from_task_id, entry.to_task_id]));
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/**
|
|
395
|
+
* plan revisionの一対一task migrationをstructure sourceへ機械転記する。
|
|
396
|
+
* ID以外は維持するが、意味が新planでも妥当だという判定は返さない。
|
|
397
|
+
*/
|
|
398
|
+
export function migrateTodoStructureSetTaskIds({ structureSet, taskMigration, successorPlan } = {}) {
|
|
399
|
+
const source = explainTodoStructureSet(structureSet);
|
|
400
|
+
if (!source.valid) fail('STRUCTURE_MIGRATION_INVALID', source.reason, { path: source.path });
|
|
401
|
+
if (!validateTodoPlan(successorPlan)
|
|
402
|
+
|| successorPlan.project_id !== structureSet.project_id
|
|
403
|
+
|| successorPlan.plan_key !== structureSet.plan_key) {
|
|
404
|
+
fail('STRUCTURE_MIGRATION_INVALID', 'successor_plan_invalid');
|
|
405
|
+
}
|
|
406
|
+
const mapping = validateTaskMigration(taskMigration);
|
|
407
|
+
const successorIds = new Set(successorPlan.tasks.map(({ task_id: id }) => id));
|
|
408
|
+
const resolve = (taskId) => {
|
|
409
|
+
const target = mapping.get(taskId) ?? (successorIds.has(taskId) ? taskId : undefined);
|
|
410
|
+
if (target === undefined || target === 'removed') {
|
|
411
|
+
fail('STRUCTURE_MIGRATION_UNRESOLVED', 'task_reference_removed_or_unresolved', {
|
|
412
|
+
task_id: taskId,
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
if (!successorIds.has(target)) {
|
|
416
|
+
fail('STRUCTURE_MIGRATION_UNRESOLVED', 'task_target_absent', { task_id: target });
|
|
417
|
+
}
|
|
418
|
+
return target;
|
|
419
|
+
};
|
|
420
|
+
const tasks = [];
|
|
421
|
+
for (const task of structureSet.tasks) {
|
|
422
|
+
const target = mapping.get(task.task_id) ?? (successorIds.has(task.task_id) ? task.task_id : undefined);
|
|
423
|
+
if (target === 'removed') continue;
|
|
424
|
+
if (target === undefined || !successorIds.has(target)) {
|
|
425
|
+
fail('STRUCTURE_MIGRATION_UNRESOLVED', 'structure_task_unresolved', { task_id: task.task_id });
|
|
426
|
+
}
|
|
427
|
+
const migrated = structuredClone(task);
|
|
428
|
+
migrated.task_id = target;
|
|
429
|
+
if (migrated.applicability === 'graph') {
|
|
430
|
+
for (const input of migrated.planned.inputs) {
|
|
431
|
+
if (input.source.kind === 'task_output') input.source.task_id = resolve(input.source.task_id);
|
|
432
|
+
}
|
|
433
|
+
for (const output of migrated.planned.outputs) {
|
|
434
|
+
for (const sink of output.sinks) {
|
|
435
|
+
if (sink.kind === 'task') sink.task_id = resolve(sink.task_id);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
tasks.push(migrated);
|
|
440
|
+
}
|
|
441
|
+
tasks.sort((left, right) => compareText(left.task_id, right.task_id));
|
|
442
|
+
if (new Set(tasks.map(({ task_id: id }) => id)).size !== tasks.length || tasks.length === 0) {
|
|
443
|
+
fail('STRUCTURE_MIGRATION_INVALID', 'migrated_task_set_invalid');
|
|
444
|
+
}
|
|
445
|
+
const migrated = {
|
|
446
|
+
...structuredClone(structureSet),
|
|
447
|
+
plan_version: successorPlan.plan_version,
|
|
448
|
+
topology_digest: successorPlan.topology_digest,
|
|
449
|
+
tasks,
|
|
450
|
+
structure_set_digest: '',
|
|
451
|
+
};
|
|
452
|
+
migrated.structure_set_digest = todoSelfDigest(migrated, 'structure_set_digest');
|
|
453
|
+
const explained = explainTodoStructureSet(migrated);
|
|
454
|
+
if (!explained.valid) {
|
|
455
|
+
fail('STRUCTURE_MIGRATION_INVALID', explained.reason, { path: explained.path });
|
|
456
|
+
}
|
|
457
|
+
return {
|
|
458
|
+
structure_set: migrated,
|
|
459
|
+
semantic_validation: 'required',
|
|
460
|
+
copied_task_ids: tasks.map(({ task_id: id }) => id),
|
|
461
|
+
source_structure_set_digest: structureSet.structure_set_digest,
|
|
462
|
+
task_migration_digest: todoSelfDigest({
|
|
463
|
+
schema: 'lattice.todo_structure_task_migration.v1',
|
|
464
|
+
task_migration: taskMigration,
|
|
465
|
+
task_migration_digest: '',
|
|
466
|
+
}, 'task_migration_digest'),
|
|
467
|
+
result_digest: todoSelfDigest({
|
|
468
|
+
schema: 'lattice.todo_structure_migration_result.v1',
|
|
469
|
+
structure_set_digest: migrated.structure_set_digest,
|
|
470
|
+
semantic_validation: 'required',
|
|
471
|
+
task_migration: taskMigration,
|
|
472
|
+
result_digest: '',
|
|
473
|
+
}, 'result_digest'),
|
|
474
|
+
};
|
|
475
|
+
}
|