@quolu/lattice 0.52.2 → 0.52.4
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 +1 -1
- package/src/artifact-contracts-v2.mjs +325 -325
- package/src/artifact-contracts.mjs +895 -895
- package/src/boundary-compiler.mjs +712 -712
- package/src/boundary-observation-compiler-v2.mjs +344 -344
- package/src/bounded-seam.mjs +230 -230
- package/src/bridge-address.mjs +107 -107
- package/src/bridge-cli.mjs +297 -297
- package/src/bridge-config.mjs +346 -346
- package/src/bridge-daemon.mjs +378 -378
- package/src/bridge-hub-protocol.mjs +198 -0
- package/src/bridge-launch-agent.mjs +323 -323
- package/src/bridge-registrar.mjs +102 -102
- package/src/bridge-server.mjs +376 -376
- package/src/cli-help.mjs +308 -308
- package/src/cli-stdio.mjs +40 -40
- package/src/control-compiler.mjs +532 -532
- package/src/dag-chain.mjs +261 -261
- package/src/factory-diagnostics.mjs +188 -188
- package/src/git-process.mjs +72 -0
- package/src/hash-chain.mjs +76 -76
- package/src/hooks-cli.mjs +1057 -1057
- package/src/isolation-runner.mjs +409 -409
- package/src/node-version-guard.mjs +44 -44
- package/src/project-cli.mjs +699 -697
- package/src/project-identity.mjs +130 -130
- package/src/rc1-black-box-oracle.mjs +906 -906
- package/src/rc1-comparison.mjs +154 -154
- package/src/rc1-evidence-bundle.mjs +456 -456
- package/src/rc1-v4-campaign.mjs +708 -708
- package/src/rc1-v4-transform.mjs +467 -467
- package/src/rc1-v5-artifact-set.mjs +855 -855
- package/src/rc1-v5-behavior-evidence.mjs +594 -594
- package/src/rc1-v5-campaign.mjs +797 -797
- package/src/rc1-v5-transform.mjs +421 -421
- package/src/rc1-v6-artifact-set.mjs +807 -807
- package/src/rc1-v6-behavior-evidence.mjs +273 -273
- package/src/rc1-v6-campaign.mjs +623 -623
- package/src/rc1-v6-causal-binding.mjs +473 -473
- package/src/rc1-v6-measurement.mjs +313 -313
- package/src/rc2-artifact-set.mjs +1584 -1584
- package/src/rc2-campaign.mjs +1506 -1506
- package/src/rc2-delivery-policy-front-end.mjs +1079 -1079
- package/src/rc2-delivery-policy-oracle.mjs +134 -134
- package/src/rc2-delivery-policy-transform.mjs +1127 -1127
- package/src/rc2-rc1-transfer-front-end.mjs +511 -511
- package/src/rc3-actual-dogfood.mjs +652 -652
- package/src/rc3-dogfood-scaffold.mjs +315 -315
- package/src/rc3-scripted-campaign.mjs +1383 -1383
- package/src/rc4-stage1-dogfood.mjs +673 -673
- package/src/runtime-adapter-registry.mjs +524 -524
- package/src/runtime-cli.mjs +4588 -4588
- package/src/runtime-contracts.mjs +824 -824
- package/src/runtime-control-store.mjs +604 -604
- package/src/runtime-controller-protocol.mjs +587 -587
- package/src/runtime-decision-verifier.mjs +701 -701
- package/src/runtime-diff-observer.mjs +361 -361
- package/src/runtime-direct-os-observer.mjs +301 -301
- package/src/runtime-driver-state.mjs +166 -166
- package/src/runtime-engine.mjs +779 -779
- package/src/runtime-errors.mjs +356 -356
- package/src/runtime-event-store.mjs +189 -189
- package/src/runtime-front-end.mjs +925 -925
- package/src/runtime-gate-store.mjs +481 -481
- package/src/runtime-hold-recompile.mjs +916 -916
- package/src/runtime-io-sentinel.mjs +391 -391
- package/src/runtime-lifecycle-lock.mjs +294 -294
- package/src/runtime-managed-supervisor.mjs +1490 -1490
- package/src/runtime-multi-epoch-store.mjs +838 -838
- package/src/runtime-projection.mjs +269 -269
- package/src/runtime-pull-intake.mjs +1192 -1192
- package/src/runtime-scripted-adapter-controller.mjs +1160 -1160
- package/src/runtime-scripted-executor.mjs +163 -163
- package/src/runtime-scripted-worktree.mjs +104 -104
- package/src/runtime-seam-resolve.mjs +428 -428
- package/src/runtime-seam-treatment.mjs +173 -173
- package/src/runtime-socket-owner.mjs +125 -125
- package/src/runtime-work-order-contracts.mjs +91 -91
- package/src/runtime-work-order-controller.mjs +1171 -1171
- package/src/runtime-worktree-executor.mjs +199 -199
- package/src/schedulability-compiler-v2.mjs +303 -303
- package/src/schedulability-verifier-v2.mjs +317 -317
- package/src/seam-apply.mjs +549 -549
- package/src/seam-commit-shared.mjs +22 -22
- package/src/seam-commit-transform.mjs +81 -81
- package/src/seam-commit.mjs +18 -18
- package/src/seam-cost.mjs +322 -322
- package/src/seam-derivation.mjs +188 -188
- package/src/seam-gate.mjs +146 -146
- package/src/seam-proposal-contracts.mjs +446 -446
- package/src/seam-proposal-queries.mjs +521 -521
- package/src/seam-proposal.mjs +2011 -2011
- package/src/seam-ref.mjs +33 -33
- package/src/seam-rewrite.mjs +286 -286
- package/src/seam-transform.mjs +554 -554
- package/src/seam-verification.mjs +260 -260
- package/src/sensor-adapter.mjs +432 -432
- package/src/sensor-cli.mjs +139 -139
- package/src/sensor-diff.mjs +661 -661
- package/src/sensor-node-runtime.mjs +53 -53
- package/src/sensor-runtime.mjs +52 -52
- package/src/timestamp-contract.mjs +8 -8
- package/src/todo-audit-pending.mjs +91 -91
- package/src/todo-chain.mjs +178 -178
- package/src/todo-cli.mjs +3143 -3141
- package/src/todo-contracts.mjs +728 -728
- package/src/todo-dashboard-registry.mjs +573 -573
- package/src/todo-dispatch-shape.mjs +190 -190
- package/src/todo-gantt-html-independence.mjs +239 -239
- package/src/todo-gantt-html-shared.mjs +226 -226
- package/src/todo-gantt-html-style.mjs +131 -131
- package/src/todo-gantt-html.mjs +248 -248
- package/src/todo-gantt-layout.mjs +974 -974
- package/src/todo-gantt-live.mjs +361 -361
- package/src/todo-gantt-nested.mjs +263 -263
- package/src/todo-gantt-presentation.mjs +217 -217
- package/src/todo-gantt-scope.mjs +123 -123
- package/src/todo-gantt-svg.mjs +353 -353
- package/src/todo-independence-contracts.mjs +595 -595
- package/src/todo-independence-guidance.mjs +322 -322
- package/src/todo-independence.mjs +640 -640
- package/src/todo-markdown-renderer.mjs +260 -260
- package/src/todo-migration.mjs +448 -448
- package/src/todo-narrative-anchor.mjs +130 -130
- package/src/todo-note-store.mjs +629 -629
- package/src/todo-parallel-candidates.mjs +114 -114
- package/src/todo-revision.mjs +995 -995
- package/src/todo-split.mjs +472 -472
- package/src/todo-status.mjs +690 -690
- package/src/todo-store-git-transaction.mjs +418 -418
- package/src/todo-store.mjs +4360 -4322
- package/src/treatment-compiler.mjs +728 -728
- package/src/treatment-runner.mjs +656 -656
- package/src/witness-scaffold.mjs +180 -180
package/src/todo-split.mjs
CHANGED
|
@@ -1,472 +1,472 @@
|
|
|
1
|
-
import { createHash } from 'node:crypto';
|
|
2
|
-
import {
|
|
3
|
-
lstat, readFile, realpath,
|
|
4
|
-
} from 'node:fs/promises';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
|
|
7
|
-
import {
|
|
8
|
-
canonicalizeTodoArtifact,
|
|
9
|
-
digestTodoArtifact,
|
|
10
|
-
exactRecord,
|
|
11
|
-
isTodoDesignMemo,
|
|
12
|
-
isTodoDigest,
|
|
13
|
-
isTodoIdentifier,
|
|
14
|
-
isTodoRef,
|
|
15
|
-
todoSelfDigest,
|
|
16
|
-
} from './todo-contracts.mjs';
|
|
17
|
-
import { migrateWitnessSetTaskIds } from './todo-independence.mjs';
|
|
18
|
-
import { buildTodoPlan } from './todo-store.mjs';
|
|
19
|
-
import {
|
|
20
|
-
parseTodoSourceRef,
|
|
21
|
-
phaseTodoRevisionPlanVersion,
|
|
22
|
-
todoCutoverArchiveSourceRef,
|
|
23
|
-
todoLegacyReconciliationDigest,
|
|
24
|
-
todoReconciliationDigest,
|
|
25
|
-
todoRevisionPlanVersion,
|
|
26
|
-
todoSourceInventoryDigest,
|
|
27
|
-
todoTaskMigrationDigest,
|
|
28
|
-
validatePhaseTodoRevision,
|
|
29
|
-
validateTodoRevision,
|
|
30
|
-
} from './todo-revision.mjs';
|
|
31
|
-
|
|
32
|
-
const TODO_SPLIT_SCHEMA = 'lattice.todo_split.v1';
|
|
33
|
-
const SUPPORTED_PLAN_SCHEMAS = new Set(['lattice.todo_plan.v6', 'lattice.todo_plan.v7']);
|
|
34
|
-
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
35
|
-
|
|
36
|
-
export class TodoSplitError extends Error {
|
|
37
|
-
constructor(message, detail = {}) {
|
|
38
|
-
super(message);
|
|
39
|
-
this.name = 'TodoSplitError';
|
|
40
|
-
this.code = 'TODO_SPLIT_INVALID';
|
|
41
|
-
this.detail = detail;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
const boundedText = (value) => typeof value === 'string' && value.trim().length > 0
|
|
46
|
-
&& Buffer.byteLength(value, 'utf8') <= 16_384 && !CONTROL.test(value);
|
|
47
|
-
|
|
48
|
-
function invalid(reason, pointer = '') {
|
|
49
|
-
throw new TodoSplitError(reason, { reason, pointer });
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export function validateTodoSplitProposal(value) {
|
|
53
|
-
try {
|
|
54
|
-
if (!exactRecord(value, [
|
|
55
|
-
'schema', 'project_id', 'plan_key', 'task_id', 'reason', 'evidence_digests',
|
|
56
|
-
'archive_ref', 'residual', 'extracted_tasks',
|
|
57
|
-
]) || value.schema !== TODO_SPLIT_SCHEMA
|
|
58
|
-
|| !isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
|
|
59
|
-
|| !isTodoIdentifier(value.task_id) || !boundedText(value.reason)
|
|
60
|
-
|| !isTodoRef(value.archive_ref) || !value.archive_ref.endsWith('.md')
|
|
61
|
-
|| parseTodoSourceRef(value.archive_ref) !== null
|
|
62
|
-
|| !exactRecord(value.residual, ['title', 'lane', 'design_memo'])
|
|
63
|
-
|| !boundedText(value.residual.title) || !isTodoIdentifier(value.residual.lane)
|
|
64
|
-
|| !isTodoDesignMemo(value.residual.design_memo)
|
|
65
|
-
|| !Array.isArray(value.evidence_digests) || value.evidence_digests.length < 1
|
|
66
|
-
|| value.evidence_digests.length > 512 || !value.evidence_digests.every(isTodoDigest)
|
|
67
|
-
|| new Set(value.evidence_digests).size !== value.evidence_digests.length
|
|
68
|
-
|| !Array.isArray(value.extracted_tasks) || value.extracted_tasks.length < 1
|
|
69
|
-
|| value.extracted_tasks.length > 511) return false;
|
|
70
|
-
const childIds = new Set();
|
|
71
|
-
const sourceRefs = new Set();
|
|
72
|
-
for (const child of value.extracted_tasks) {
|
|
73
|
-
if (!exactRecord(child, [
|
|
74
|
-
'task_id', 'title', 'lane', 'design_memo', 'source_ref', 'depends_on',
|
|
75
|
-
]) || !isTodoIdentifier(child.task_id) || child.task_id === value.task_id
|
|
76
|
-
|| childIds.has(child.task_id) || !boundedText(child.title)
|
|
77
|
-
|| !isTodoIdentifier(child.lane) || !isTodoDesignMemo(child.design_memo)
|
|
78
|
-
|| parseTodoSourceRef(child.source_ref) === null || sourceRefs.has(child.source_ref)
|
|
79
|
-
|| !Array.isArray(child.depends_on) || child.depends_on.length > 511
|
|
80
|
-
|| !child.depends_on.every(isTodoIdentifier)
|
|
81
|
-
|| new Set(child.depends_on).size !== child.depends_on.length
|
|
82
|
-
|| child.depends_on.includes(child.task_id)) return false;
|
|
83
|
-
childIds.add(child.task_id);
|
|
84
|
-
sourceRefs.add(child.source_ref);
|
|
85
|
-
}
|
|
86
|
-
return value.extracted_tasks.every(({ depends_on: dependencies }) => (
|
|
87
|
-
dependencies.every((taskId) => childIds.has(taskId))
|
|
88
|
-
));
|
|
89
|
-
} catch {
|
|
90
|
-
return false;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function within(root, target) {
|
|
95
|
-
const relative = path.relative(root, target);
|
|
96
|
-
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`)
|
|
97
|
-
&& !path.isAbsolute(relative);
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function splitLines(bytes) {
|
|
101
|
-
const lines = [];
|
|
102
|
-
let start = 0;
|
|
103
|
-
for (let index = 0; index <= bytes.length; index += 1) {
|
|
104
|
-
if (index === bytes.length || bytes[index] === 0x0a) {
|
|
105
|
-
lines.push(bytes.subarray(start, index));
|
|
106
|
-
start = index + 1;
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
return lines;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
async function sourceLine(repoRoot, sourceRef, { checkbox = false } = {}) {
|
|
113
|
-
const parsed = parseTodoSourceRef(sourceRef);
|
|
114
|
-
if (parsed === null) invalid('source_ref_invalid', '/extracted_tasks/source_ref');
|
|
115
|
-
const canonicalRoot = await realpath(repoRoot);
|
|
116
|
-
const absolute = path.resolve(canonicalRoot, parsed.path);
|
|
117
|
-
if (!within(canonicalRoot, absolute)) invalid('source_outside_repository', sourceRef);
|
|
118
|
-
let metadata;
|
|
119
|
-
try { metadata = await lstat(absolute); } catch { invalid('source_missing', sourceRef); }
|
|
120
|
-
if (metadata.isSymbolicLink() || !metadata.isFile()) invalid('source_path_unsafe', sourceRef);
|
|
121
|
-
const resolved = await realpath(absolute);
|
|
122
|
-
if (resolved !== absolute || !within(canonicalRoot, resolved)) invalid('source_path_alias', sourceRef);
|
|
123
|
-
const line = splitLines(await readFile(resolved))[parsed.line - 1];
|
|
124
|
-
if (line === undefined) invalid('source_line_missing', sourceRef);
|
|
125
|
-
let text;
|
|
126
|
-
try { text = new TextDecoder('utf-8', { fatal: true }).decode(line); }
|
|
127
|
-
catch { invalid('source_invalid_utf8', sourceRef); }
|
|
128
|
-
const match = /^([\t ]*)([-+*]|\d+[A-Za-z]?\.|\d+\))[\t ]+\[[ xX]\](?:[\t ]+.*)?\r?$/u.exec(text);
|
|
129
|
-
if (checkbox && match === null) invalid('source_item_not_todo', sourceRef);
|
|
130
|
-
return {
|
|
131
|
-
bytes: line,
|
|
132
|
-
digest: createHash('sha256').update(line).digest('hex'),
|
|
133
|
-
marker: match === null ? null : `${match[1]}${match[2]}`,
|
|
134
|
-
};
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
const compareCanonical = (left, right) => {
|
|
138
|
-
const leftBytes = canonicalizeTodoArtifact(left);
|
|
139
|
-
const rightBytes = canonicalizeTodoArtifact(right);
|
|
140
|
-
return leftBytes < rightBytes ? -1 : leftBytes > rightBytes ? 1 : 0;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
function predecessorReconciliationDigest(member, phasePlan) {
|
|
144
|
-
const genesis = member.journal.events[0];
|
|
145
|
-
if (genesis.schema === 'lattice.todo_event.v2') return genesis.reconciliation_digest;
|
|
146
|
-
if (phasePlan && isTodoDigest(member.revision?.reconciliation?.reconciliation_digest)) {
|
|
147
|
-
return member.revision.reconciliation.reconciliation_digest;
|
|
148
|
-
}
|
|
149
|
-
return todoLegacyReconciliationDigest({
|
|
150
|
-
planDigest: member.plan.plan_digest,
|
|
151
|
-
journalHeadDigest: member.journal.events.at(-1).event_digest,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
async function existingSourceInventory(repoRoot, member) {
|
|
156
|
-
const inheritedInventory = member.revision?.source_inventory !== undefined;
|
|
157
|
-
const inherited = new Map((member.revision?.source_inventory?.active ?? [])
|
|
158
|
-
.map((entry) => [entry.task_id, structuredClone(entry)]));
|
|
159
|
-
const active = [];
|
|
160
|
-
for (const task of member.plan.tasks) {
|
|
161
|
-
const entry = inherited.get(task.task_id);
|
|
162
|
-
if (entry !== undefined) {
|
|
163
|
-
active.push(entry);
|
|
164
|
-
continue;
|
|
165
|
-
}
|
|
166
|
-
if (typeof task.narrative_ref !== 'string' || parseTodoSourceRef(task.narrative_ref) === null) {
|
|
167
|
-
invalid('predecessor_source_inventory_unavailable', `/tasks/${task.task_id}/narrative_ref`);
|
|
168
|
-
}
|
|
169
|
-
const source = await sourceLine(repoRoot, task.narrative_ref);
|
|
170
|
-
active.push({ task_id: task.task_id, source_ref: task.narrative_ref, source_digest: source.digest });
|
|
171
|
-
}
|
|
172
|
-
return {
|
|
173
|
-
active,
|
|
174
|
-
excluded_tombstones: structuredClone(
|
|
175
|
-
member.revision?.source_inventory?.excluded_tombstones ?? [],
|
|
176
|
-
),
|
|
177
|
-
inherited: inheritedInventory,
|
|
178
|
-
};
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function localTaskRef(plan, taskId) {
|
|
182
|
-
return { project_id: plan.project_id, plan_key: plan.plan_key, task_id: taskId };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
async function compileSourceCutover(repoRoot, proposal, {
|
|
186
|
-
existingTasks = [], reservedSourceRefs = [],
|
|
187
|
-
} = {}) {
|
|
188
|
-
const definitions = [
|
|
189
|
-
...existingTasks.map((task) => ({
|
|
190
|
-
task_id: task.task_id,
|
|
191
|
-
title: task.task_id === proposal.task_id ? proposal.residual.title : task.title,
|
|
192
|
-
source_ref: task.narrative_ref,
|
|
193
|
-
})),
|
|
194
|
-
...proposal.extracted_tasks,
|
|
195
|
-
];
|
|
196
|
-
const occupiedSourceRefs = new Set(reservedSourceRefs);
|
|
197
|
-
const rows = [];
|
|
198
|
-
for (const definition of definitions) {
|
|
199
|
-
if (occupiedSourceRefs.has(definition.source_ref)) {
|
|
200
|
-
invalid('source_ref_conflicts_with_predecessor', definition.source_ref);
|
|
201
|
-
}
|
|
202
|
-
occupiedSourceRefs.add(definition.source_ref);
|
|
203
|
-
const line = await sourceLine(repoRoot, definition.source_ref, { checkbox: true });
|
|
204
|
-
rows.push({ definition, line });
|
|
205
|
-
}
|
|
206
|
-
rows.sort((left, right) => left.definition.source_ref.localeCompare(right.definition.source_ref));
|
|
207
|
-
const batch = {
|
|
208
|
-
batch_id: `split-${digestTodoArtifact(proposal).slice(0, 24)}`,
|
|
209
|
-
archive_ref: proposal.archive_ref,
|
|
210
|
-
operations: rows.map(({ definition, line }) => ({
|
|
211
|
-
task_id: definition.task_id,
|
|
212
|
-
disposition: 'active',
|
|
213
|
-
source_ref: definition.source_ref,
|
|
214
|
-
source_digest: line.digest,
|
|
215
|
-
live_replacement: `${line.marker} Lattice todo splitへ移行済み(${definition.task_id}): ${definition.title}`,
|
|
216
|
-
})),
|
|
217
|
-
batch_digest: '',
|
|
218
|
-
};
|
|
219
|
-
batch.batch_digest = todoSelfDigest(batch, 'batch_digest');
|
|
220
|
-
return { batch, archivedByTask: new Map(rows.map(({ definition }, index) => [
|
|
221
|
-
definition.task_id,
|
|
222
|
-
{ source_ref: todoCutoverArchiveSourceRef(batch, index),
|
|
223
|
-
source_digest: batch.operations[index].source_digest },
|
|
224
|
-
])) };
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function compileTasks(plan, proposal, archivedByTask) {
|
|
228
|
-
const residual = plan.tasks.find(({ task_id: taskId }) => taskId === proposal.task_id);
|
|
229
|
-
const phasePlan = plan.schema === 'lattice.todo_plan.v7';
|
|
230
|
-
const existing = plan.tasks.map((task) => {
|
|
231
|
-
const updated = task.task_id === proposal.task_id ? {
|
|
232
|
-
...task,
|
|
233
|
-
title: proposal.residual.title,
|
|
234
|
-
lane: proposal.residual.lane,
|
|
235
|
-
design_memo: proposal.residual.design_memo,
|
|
236
|
-
} : task;
|
|
237
|
-
const archived = archivedByTask.get(task.task_id);
|
|
238
|
-
return archived === undefined ? updated : { ...updated, narrative_ref: archived.source_ref };
|
|
239
|
-
});
|
|
240
|
-
const children = [...proposal.extracted_tasks]
|
|
241
|
-
.sort((left, right) => left.task_id.localeCompare(right.task_id))
|
|
242
|
-
.map((child) => ({
|
|
243
|
-
task_id: child.task_id,
|
|
244
|
-
title: child.title,
|
|
245
|
-
lane: child.lane,
|
|
246
|
-
design_memo: child.design_memo,
|
|
247
|
-
narrative_ref: archivedByTask.get(child.task_id).source_ref,
|
|
248
|
-
narrative_anchor: null,
|
|
249
|
-
compile_binding: null,
|
|
250
|
-
parent_task_id: proposal.task_id,
|
|
251
|
-
...(phasePlan ? { phase_id: residual.phase_id } : {}),
|
|
252
|
-
}));
|
|
253
|
-
return [...existing, ...children]
|
|
254
|
-
.sort((left, right) => left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0);
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function compileDependencies(plan, proposal) {
|
|
258
|
-
const edges = [...plan.hard_dependencies];
|
|
259
|
-
for (const child of proposal.extracted_tasks) {
|
|
260
|
-
for (const predecessorId of child.depends_on) {
|
|
261
|
-
edges.push({
|
|
262
|
-
from: localTaskRef(plan, predecessorId),
|
|
263
|
-
to: localTaskRef(plan, child.task_id),
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
edges.push({
|
|
267
|
-
from: localTaskRef(plan, child.task_id),
|
|
268
|
-
to: localTaskRef(plan, proposal.task_id),
|
|
269
|
-
});
|
|
270
|
-
}
|
|
271
|
-
edges.sort(compareCanonical);
|
|
272
|
-
if (new Set(edges.map(canonicalizeTodoArtifact)).size !== edges.length) {
|
|
273
|
-
invalid('duplicate_dependency', '/extracted_tasks/depends_on');
|
|
274
|
-
}
|
|
275
|
-
return edges;
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
function compileTaskMigration(plan, taskId, reconciledTaskIds = new Set()) {
|
|
279
|
-
return plan.tasks.map(({ task_id: currentTaskId }) => ({
|
|
280
|
-
from_task_id: currentTaskId,
|
|
281
|
-
to_task_id: currentTaskId,
|
|
282
|
-
state_policy: currentTaskId === taskId ? 'reset_pending'
|
|
283
|
-
: reconciledTaskIds.has(currentTaskId) ? 'carry_reconciled_metadata' : 'carry',
|
|
284
|
-
})).sort((left, right) => left.from_task_id.localeCompare(right.from_task_id));
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
function compileRuntimeTaskMigration(plan, proposal) {
|
|
288
|
-
const children = proposal.extracted_tasks.map(({ task_id: taskId }) => taskId).sort();
|
|
289
|
-
const evidenceDigests = [...proposal.evidence_digests].sort();
|
|
290
|
-
const value = {
|
|
291
|
-
schema: 'lattice.runtime_task_migration.v1',
|
|
292
|
-
entries: plan.tasks.map(({ task_id: taskId }) => taskId === proposal.task_id ? {
|
|
293
|
-
predecessor_task_id: taskId,
|
|
294
|
-
disposition: 'split',
|
|
295
|
-
successor_task_ids: [taskId, ...children],
|
|
296
|
-
reason: proposal.reason,
|
|
297
|
-
evidence_digests: evidenceDigests,
|
|
298
|
-
} : {
|
|
299
|
-
predecessor_task_id: taskId,
|
|
300
|
-
disposition: 'carry',
|
|
301
|
-
successor_task_ids: [taskId],
|
|
302
|
-
reason: 'splitの影響を受けないため状態を維持する',
|
|
303
|
-
evidence_digests: evidenceDigests,
|
|
304
|
-
}).sort((left, right) => left.predecessor_task_id.localeCompare(right.predecessor_task_id)),
|
|
305
|
-
migration_digest: '',
|
|
306
|
-
};
|
|
307
|
-
value.migration_digest = todoSelfDigest(value, 'migration_digest');
|
|
308
|
-
return value;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export async function compileTodoSplit({ repoRoot, member, proposal }) {
|
|
312
|
-
if (!validateTodoSplitProposal(proposal)) invalid('proposal_schema_invalid');
|
|
313
|
-
const plan = member?.plan;
|
|
314
|
-
if (plan?.project_id !== proposal.project_id || plan?.plan_key !== proposal.plan_key) {
|
|
315
|
-
invalid('proposal_plan_binding_mismatch');
|
|
316
|
-
}
|
|
317
|
-
if (!SUPPORTED_PLAN_SCHEMAS.has(plan.schema)) invalid('successor_schema_not_supported');
|
|
318
|
-
const sourceTask = plan.tasks.find(({ task_id: taskId }) => taskId === proposal.task_id);
|
|
319
|
-
if (sourceTask === undefined) invalid('source_task_not_active', '/task_id');
|
|
320
|
-
const runtimeTask = member.tasks.find(({ task_id: taskId }) => taskId === proposal.task_id);
|
|
321
|
-
if (runtimeTask?.status !== 'in-progress') invalid('source_task_not_in_progress', '/task_id');
|
|
322
|
-
const existingIds = new Set(plan.tasks.map(({ task_id: taskId }) => taskId));
|
|
323
|
-
const collision = proposal.extracted_tasks.find(({ task_id: taskId }) => existingIds.has(taskId));
|
|
324
|
-
if (collision !== undefined) invalid('successor_task_id_already_active', `/extracted_tasks/${collision.task_id}`);
|
|
325
|
-
|
|
326
|
-
const predecessor = {
|
|
327
|
-
plan_digest: plan.plan_digest,
|
|
328
|
-
journal_head_digest: member.journal.events.at(-1).event_digest,
|
|
329
|
-
plan_version: plan.plan_version,
|
|
330
|
-
};
|
|
331
|
-
const previousInventory = await existingSourceInventory(repoRoot, member);
|
|
332
|
-
const phasePlan = plan.schema === 'lattice.todo_plan.v7';
|
|
333
|
-
// phase revisionのgenesisではpredecessor inventoryがまだrevisionへ保存されていない。
|
|
334
|
-
// apply側は全既存sourceのarchive移送を差分として要求するため、既存taskも同じbatchへ載せる。
|
|
335
|
-
const cutoverExistingTasks = phasePlan && !previousInventory.inherited ? plan.tasks : [];
|
|
336
|
-
const reservedSourceRefs = cutoverExistingTasks.length === 0
|
|
337
|
-
? previousInventory.active.map(({ source_ref: sourceRef }) => sourceRef)
|
|
338
|
-
: [];
|
|
339
|
-
const { batch: sourceCutoverBatch, archivedByTask } = await compileSourceCutover(
|
|
340
|
-
repoRoot, proposal, { existingTasks: cutoverExistingTasks, reservedSourceRefs },
|
|
341
|
-
);
|
|
342
|
-
const tasks = compileTasks(plan, proposal, archivedByTask);
|
|
343
|
-
const hardDependencies = compileDependencies(plan, proposal);
|
|
344
|
-
const taskMigration = compileTaskMigration(plan, proposal.task_id,
|
|
345
|
-
new Set(cutoverExistingTasks.map(({ task_id: taskId }) => taskId)));
|
|
346
|
-
const sourceInventory = {
|
|
347
|
-
active: tasks.map(({ task_id: taskId }) => {
|
|
348
|
-
const archived = archivedByTask.get(taskId);
|
|
349
|
-
if (archived !== undefined) return { task_id: taskId, ...archived };
|
|
350
|
-
return previousInventory.active
|
|
351
|
-
.find(({ task_id: previousTaskId }) => previousTaskId === taskId);
|
|
352
|
-
}),
|
|
353
|
-
excluded_tombstones: previousInventory.excluded_tombstones,
|
|
354
|
-
};
|
|
355
|
-
const desiredSeed = {
|
|
356
|
-
schema: plan.schema,
|
|
357
|
-
project_id: plan.project_id,
|
|
358
|
-
plan_key: plan.plan_key,
|
|
359
|
-
predecessor_plan_digest: plan.plan_digest,
|
|
360
|
-
tasks,
|
|
361
|
-
hard_dependencies: hardDependencies,
|
|
362
|
-
joins: plan.joins,
|
|
363
|
-
...(phasePlan ? {
|
|
364
|
-
phases: plan.phases,
|
|
365
|
-
phase_accept_dependencies: plan.phase_accept_dependencies,
|
|
366
|
-
} : {}),
|
|
367
|
-
};
|
|
368
|
-
|
|
369
|
-
if (!phasePlan) {
|
|
370
|
-
const planVersion = todoRevisionPlanVersion({
|
|
371
|
-
projectId: plan.project_id,
|
|
372
|
-
planKey: plan.plan_key,
|
|
373
|
-
predecessor,
|
|
374
|
-
desiredPlan: desiredSeed,
|
|
375
|
-
taskMigration,
|
|
376
|
-
sourceInventory,
|
|
377
|
-
sourceCutoverBatch,
|
|
378
|
-
});
|
|
379
|
-
const desiredPlan = buildTodoPlan({ ...desiredSeed, plan_version: planVersion });
|
|
380
|
-
const reconciliation = {
|
|
381
|
-
predecessor_reconciliation_digest: predecessorReconciliationDigest(member, false),
|
|
382
|
-
source_inventory_digest: todoSourceInventoryDigest(sourceInventory),
|
|
383
|
-
reconciliation_digest: '',
|
|
384
|
-
};
|
|
385
|
-
reconciliation.reconciliation_digest = todoReconciliationDigest({
|
|
386
|
-
predecessorReconciliationDigest: reconciliation.predecessor_reconciliation_digest,
|
|
387
|
-
sourceInventoryDigest: reconciliation.source_inventory_digest,
|
|
388
|
-
predecessor,
|
|
389
|
-
desiredPlanDigest: desiredPlan.plan_digest,
|
|
390
|
-
taskMigration,
|
|
391
|
-
sourceCutoverBatch,
|
|
392
|
-
});
|
|
393
|
-
const revision = {
|
|
394
|
-
schema: 'lattice.todo_revision.v2',
|
|
395
|
-
project_id: plan.project_id,
|
|
396
|
-
plan_key: plan.plan_key,
|
|
397
|
-
predecessor,
|
|
398
|
-
desired_plan: desiredPlan,
|
|
399
|
-
task_migration: taskMigration,
|
|
400
|
-
source_inventory: sourceInventory,
|
|
401
|
-
reconciliation,
|
|
402
|
-
source_cutover_batch: sourceCutoverBatch,
|
|
403
|
-
revision_digest: '',
|
|
404
|
-
};
|
|
405
|
-
revision.revision_digest = todoSelfDigest(revision, 'revision_digest');
|
|
406
|
-
if (!validateTodoRevision(revision)) invalid('compiled_revision_invalid');
|
|
407
|
-
return { revision, extracted_task_ids: proposal.extracted_tasks
|
|
408
|
-
.map(({ task_id: taskId }) => taskId).sort() };
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
const runtimeTaskMigration = compileRuntimeTaskMigration(plan, proposal);
|
|
412
|
-
const phaseMigration = plan.phases.map(({ phase_id: phaseId }) => ({
|
|
413
|
-
from_phase_id: phaseId,
|
|
414
|
-
to_phase_id: phaseId,
|
|
415
|
-
state_policy: phaseId === sourceTask.phase_id ? 'reset' : 'carry',
|
|
416
|
-
}));
|
|
417
|
-
const planVersion = phaseTodoRevisionPlanVersion({
|
|
418
|
-
projectId: plan.project_id,
|
|
419
|
-
planKey: plan.plan_key,
|
|
420
|
-
predecessor,
|
|
421
|
-
desiredPlan: desiredSeed,
|
|
422
|
-
taskMigration,
|
|
423
|
-
phaseMigration,
|
|
424
|
-
});
|
|
425
|
-
const desiredPlan = buildTodoPlan({ ...desiredSeed, plan_version: planVersion });
|
|
426
|
-
const reconciliation = {
|
|
427
|
-
predecessor_reconciliation_digest: predecessorReconciliationDigest(member, true),
|
|
428
|
-
source_inventory_digest: todoSourceInventoryDigest(sourceInventory),
|
|
429
|
-
desired_plan_digest: desiredPlan.plan_digest,
|
|
430
|
-
runtime_task_migration_digest: runtimeTaskMigration.migration_digest,
|
|
431
|
-
task_migration_digest: todoTaskMigrationDigest(taskMigration),
|
|
432
|
-
phase_migration_digest: digestTodoArtifact(phaseMigration),
|
|
433
|
-
source_cutover_batch_digest: sourceCutoverBatch.batch_digest,
|
|
434
|
-
reconciliation_digest: '',
|
|
435
|
-
};
|
|
436
|
-
reconciliation.reconciliation_digest = todoSelfDigest(reconciliation, 'reconciliation_digest');
|
|
437
|
-
const revision = {
|
|
438
|
-
schema: 'lattice.phase_todo_revision.v3',
|
|
439
|
-
project_id: plan.project_id,
|
|
440
|
-
plan_key: plan.plan_key,
|
|
441
|
-
predecessor,
|
|
442
|
-
desired_plan: desiredPlan,
|
|
443
|
-
runtime_task_migration: runtimeTaskMigration,
|
|
444
|
-
task_migration: taskMigration,
|
|
445
|
-
phase_migration: phaseMigration,
|
|
446
|
-
source_inventory: sourceInventory,
|
|
447
|
-
reconciliation,
|
|
448
|
-
source_cutover_batch: sourceCutoverBatch,
|
|
449
|
-
revision_digest: '',
|
|
450
|
-
};
|
|
451
|
-
revision.revision_digest = todoSelfDigest(revision, 'revision_digest');
|
|
452
|
-
if (!validatePhaseTodoRevision(revision)) invalid('compiled_phase_revision_invalid');
|
|
453
|
-
return { revision, extracted_task_ids: proposal.extracted_tasks
|
|
454
|
-
.map(({ task_id: taskId }) => taskId).sort() };
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* splitは既存taskを同じidへ写し、子taskは新規追加するだけなので、既存witness bytesを変えない。
|
|
459
|
-
* apply前にこの不変条件を検査し、解決不能な宣言や将来の非identity migrationをstore更新前に止める。
|
|
460
|
-
*/
|
|
461
|
-
export function prepareTodoSplitWitnessMigration({ witnessSet, revision }) {
|
|
462
|
-
const migration = migrateWitnessSetTaskIds({
|
|
463
|
-
witnessSet,
|
|
464
|
-
taskMigration: revision.task_migration,
|
|
465
|
-
planTaskIds: revision.desired_plan.tasks.map(({ task_id: taskId }) => taskId),
|
|
466
|
-
});
|
|
467
|
-
if (migration.witnessSet.witness_set_digest !== witnessSet.witness_set_digest
|
|
468
|
-
|| canonicalizeTodoArtifact(migration.witnessSet) !== canonicalizeTodoArtifact(witnessSet)) {
|
|
469
|
-
invalid('split_witness_migration_not_identity', '/task_migration');
|
|
470
|
-
}
|
|
471
|
-
return migration;
|
|
472
|
-
}
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
lstat, readFile, realpath,
|
|
4
|
+
} from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
canonicalizeTodoArtifact,
|
|
9
|
+
digestTodoArtifact,
|
|
10
|
+
exactRecord,
|
|
11
|
+
isTodoDesignMemo,
|
|
12
|
+
isTodoDigest,
|
|
13
|
+
isTodoIdentifier,
|
|
14
|
+
isTodoRef,
|
|
15
|
+
todoSelfDigest,
|
|
16
|
+
} from './todo-contracts.mjs';
|
|
17
|
+
import { migrateWitnessSetTaskIds } from './todo-independence.mjs';
|
|
18
|
+
import { buildTodoPlan } from './todo-store.mjs';
|
|
19
|
+
import {
|
|
20
|
+
parseTodoSourceRef,
|
|
21
|
+
phaseTodoRevisionPlanVersion,
|
|
22
|
+
todoCutoverArchiveSourceRef,
|
|
23
|
+
todoLegacyReconciliationDigest,
|
|
24
|
+
todoReconciliationDigest,
|
|
25
|
+
todoRevisionPlanVersion,
|
|
26
|
+
todoSourceInventoryDigest,
|
|
27
|
+
todoTaskMigrationDigest,
|
|
28
|
+
validatePhaseTodoRevision,
|
|
29
|
+
validateTodoRevision,
|
|
30
|
+
} from './todo-revision.mjs';
|
|
31
|
+
|
|
32
|
+
const TODO_SPLIT_SCHEMA = 'lattice.todo_split.v1';
|
|
33
|
+
const SUPPORTED_PLAN_SCHEMAS = new Set(['lattice.todo_plan.v6', 'lattice.todo_plan.v7']);
|
|
34
|
+
const CONTROL = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u;
|
|
35
|
+
|
|
36
|
+
export class TodoSplitError extends Error {
|
|
37
|
+
constructor(message, detail = {}) {
|
|
38
|
+
super(message);
|
|
39
|
+
this.name = 'TodoSplitError';
|
|
40
|
+
this.code = 'TODO_SPLIT_INVALID';
|
|
41
|
+
this.detail = detail;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const boundedText = (value) => typeof value === 'string' && value.trim().length > 0
|
|
46
|
+
&& Buffer.byteLength(value, 'utf8') <= 16_384 && !CONTROL.test(value);
|
|
47
|
+
|
|
48
|
+
function invalid(reason, pointer = '') {
|
|
49
|
+
throw new TodoSplitError(reason, { reason, pointer });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function validateTodoSplitProposal(value) {
|
|
53
|
+
try {
|
|
54
|
+
if (!exactRecord(value, [
|
|
55
|
+
'schema', 'project_id', 'plan_key', 'task_id', 'reason', 'evidence_digests',
|
|
56
|
+
'archive_ref', 'residual', 'extracted_tasks',
|
|
57
|
+
]) || value.schema !== TODO_SPLIT_SCHEMA
|
|
58
|
+
|| !isTodoIdentifier(value.project_id) || !isTodoIdentifier(value.plan_key)
|
|
59
|
+
|| !isTodoIdentifier(value.task_id) || !boundedText(value.reason)
|
|
60
|
+
|| !isTodoRef(value.archive_ref) || !value.archive_ref.endsWith('.md')
|
|
61
|
+
|| parseTodoSourceRef(value.archive_ref) !== null
|
|
62
|
+
|| !exactRecord(value.residual, ['title', 'lane', 'design_memo'])
|
|
63
|
+
|| !boundedText(value.residual.title) || !isTodoIdentifier(value.residual.lane)
|
|
64
|
+
|| !isTodoDesignMemo(value.residual.design_memo)
|
|
65
|
+
|| !Array.isArray(value.evidence_digests) || value.evidence_digests.length < 1
|
|
66
|
+
|| value.evidence_digests.length > 512 || !value.evidence_digests.every(isTodoDigest)
|
|
67
|
+
|| new Set(value.evidence_digests).size !== value.evidence_digests.length
|
|
68
|
+
|| !Array.isArray(value.extracted_tasks) || value.extracted_tasks.length < 1
|
|
69
|
+
|| value.extracted_tasks.length > 511) return false;
|
|
70
|
+
const childIds = new Set();
|
|
71
|
+
const sourceRefs = new Set();
|
|
72
|
+
for (const child of value.extracted_tasks) {
|
|
73
|
+
if (!exactRecord(child, [
|
|
74
|
+
'task_id', 'title', 'lane', 'design_memo', 'source_ref', 'depends_on',
|
|
75
|
+
]) || !isTodoIdentifier(child.task_id) || child.task_id === value.task_id
|
|
76
|
+
|| childIds.has(child.task_id) || !boundedText(child.title)
|
|
77
|
+
|| !isTodoIdentifier(child.lane) || !isTodoDesignMemo(child.design_memo)
|
|
78
|
+
|| parseTodoSourceRef(child.source_ref) === null || sourceRefs.has(child.source_ref)
|
|
79
|
+
|| !Array.isArray(child.depends_on) || child.depends_on.length > 511
|
|
80
|
+
|| !child.depends_on.every(isTodoIdentifier)
|
|
81
|
+
|| new Set(child.depends_on).size !== child.depends_on.length
|
|
82
|
+
|| child.depends_on.includes(child.task_id)) return false;
|
|
83
|
+
childIds.add(child.task_id);
|
|
84
|
+
sourceRefs.add(child.source_ref);
|
|
85
|
+
}
|
|
86
|
+
return value.extracted_tasks.every(({ depends_on: dependencies }) => (
|
|
87
|
+
dependencies.every((taskId) => childIds.has(taskId))
|
|
88
|
+
));
|
|
89
|
+
} catch {
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function within(root, target) {
|
|
95
|
+
const relative = path.relative(root, target);
|
|
96
|
+
return relative !== '' && relative !== '..' && !relative.startsWith(`..${path.sep}`)
|
|
97
|
+
&& !path.isAbsolute(relative);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function splitLines(bytes) {
|
|
101
|
+
const lines = [];
|
|
102
|
+
let start = 0;
|
|
103
|
+
for (let index = 0; index <= bytes.length; index += 1) {
|
|
104
|
+
if (index === bytes.length || bytes[index] === 0x0a) {
|
|
105
|
+
lines.push(bytes.subarray(start, index));
|
|
106
|
+
start = index + 1;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return lines;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function sourceLine(repoRoot, sourceRef, { checkbox = false } = {}) {
|
|
113
|
+
const parsed = parseTodoSourceRef(sourceRef);
|
|
114
|
+
if (parsed === null) invalid('source_ref_invalid', '/extracted_tasks/source_ref');
|
|
115
|
+
const canonicalRoot = await realpath(repoRoot);
|
|
116
|
+
const absolute = path.resolve(canonicalRoot, parsed.path);
|
|
117
|
+
if (!within(canonicalRoot, absolute)) invalid('source_outside_repository', sourceRef);
|
|
118
|
+
let metadata;
|
|
119
|
+
try { metadata = await lstat(absolute); } catch { invalid('source_missing', sourceRef); }
|
|
120
|
+
if (metadata.isSymbolicLink() || !metadata.isFile()) invalid('source_path_unsafe', sourceRef);
|
|
121
|
+
const resolved = await realpath(absolute);
|
|
122
|
+
if (resolved !== absolute || !within(canonicalRoot, resolved)) invalid('source_path_alias', sourceRef);
|
|
123
|
+
const line = splitLines(await readFile(resolved))[parsed.line - 1];
|
|
124
|
+
if (line === undefined) invalid('source_line_missing', sourceRef);
|
|
125
|
+
let text;
|
|
126
|
+
try { text = new TextDecoder('utf-8', { fatal: true }).decode(line); }
|
|
127
|
+
catch { invalid('source_invalid_utf8', sourceRef); }
|
|
128
|
+
const match = /^([\t ]*)([-+*]|\d+[A-Za-z]?\.|\d+\))[\t ]+\[[ xX]\](?:[\t ]+.*)?\r?$/u.exec(text);
|
|
129
|
+
if (checkbox && match === null) invalid('source_item_not_todo', sourceRef);
|
|
130
|
+
return {
|
|
131
|
+
bytes: line,
|
|
132
|
+
digest: createHash('sha256').update(line).digest('hex'),
|
|
133
|
+
marker: match === null ? null : `${match[1]}${match[2]}`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const compareCanonical = (left, right) => {
|
|
138
|
+
const leftBytes = canonicalizeTodoArtifact(left);
|
|
139
|
+
const rightBytes = canonicalizeTodoArtifact(right);
|
|
140
|
+
return leftBytes < rightBytes ? -1 : leftBytes > rightBytes ? 1 : 0;
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
function predecessorReconciliationDigest(member, phasePlan) {
|
|
144
|
+
const genesis = member.journal.events[0];
|
|
145
|
+
if (genesis.schema === 'lattice.todo_event.v2') return genesis.reconciliation_digest;
|
|
146
|
+
if (phasePlan && isTodoDigest(member.revision?.reconciliation?.reconciliation_digest)) {
|
|
147
|
+
return member.revision.reconciliation.reconciliation_digest;
|
|
148
|
+
}
|
|
149
|
+
return todoLegacyReconciliationDigest({
|
|
150
|
+
planDigest: member.plan.plan_digest,
|
|
151
|
+
journalHeadDigest: member.journal.events.at(-1).event_digest,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function existingSourceInventory(repoRoot, member) {
|
|
156
|
+
const inheritedInventory = member.revision?.source_inventory !== undefined;
|
|
157
|
+
const inherited = new Map((member.revision?.source_inventory?.active ?? [])
|
|
158
|
+
.map((entry) => [entry.task_id, structuredClone(entry)]));
|
|
159
|
+
const active = [];
|
|
160
|
+
for (const task of member.plan.tasks) {
|
|
161
|
+
const entry = inherited.get(task.task_id);
|
|
162
|
+
if (entry !== undefined) {
|
|
163
|
+
active.push(entry);
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (typeof task.narrative_ref !== 'string' || parseTodoSourceRef(task.narrative_ref) === null) {
|
|
167
|
+
invalid('predecessor_source_inventory_unavailable', `/tasks/${task.task_id}/narrative_ref`);
|
|
168
|
+
}
|
|
169
|
+
const source = await sourceLine(repoRoot, task.narrative_ref);
|
|
170
|
+
active.push({ task_id: task.task_id, source_ref: task.narrative_ref, source_digest: source.digest });
|
|
171
|
+
}
|
|
172
|
+
return {
|
|
173
|
+
active,
|
|
174
|
+
excluded_tombstones: structuredClone(
|
|
175
|
+
member.revision?.source_inventory?.excluded_tombstones ?? [],
|
|
176
|
+
),
|
|
177
|
+
inherited: inheritedInventory,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function localTaskRef(plan, taskId) {
|
|
182
|
+
return { project_id: plan.project_id, plan_key: plan.plan_key, task_id: taskId };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function compileSourceCutover(repoRoot, proposal, {
|
|
186
|
+
existingTasks = [], reservedSourceRefs = [],
|
|
187
|
+
} = {}) {
|
|
188
|
+
const definitions = [
|
|
189
|
+
...existingTasks.map((task) => ({
|
|
190
|
+
task_id: task.task_id,
|
|
191
|
+
title: task.task_id === proposal.task_id ? proposal.residual.title : task.title,
|
|
192
|
+
source_ref: task.narrative_ref,
|
|
193
|
+
})),
|
|
194
|
+
...proposal.extracted_tasks,
|
|
195
|
+
];
|
|
196
|
+
const occupiedSourceRefs = new Set(reservedSourceRefs);
|
|
197
|
+
const rows = [];
|
|
198
|
+
for (const definition of definitions) {
|
|
199
|
+
if (occupiedSourceRefs.has(definition.source_ref)) {
|
|
200
|
+
invalid('source_ref_conflicts_with_predecessor', definition.source_ref);
|
|
201
|
+
}
|
|
202
|
+
occupiedSourceRefs.add(definition.source_ref);
|
|
203
|
+
const line = await sourceLine(repoRoot, definition.source_ref, { checkbox: true });
|
|
204
|
+
rows.push({ definition, line });
|
|
205
|
+
}
|
|
206
|
+
rows.sort((left, right) => left.definition.source_ref.localeCompare(right.definition.source_ref));
|
|
207
|
+
const batch = {
|
|
208
|
+
batch_id: `split-${digestTodoArtifact(proposal).slice(0, 24)}`,
|
|
209
|
+
archive_ref: proposal.archive_ref,
|
|
210
|
+
operations: rows.map(({ definition, line }) => ({
|
|
211
|
+
task_id: definition.task_id,
|
|
212
|
+
disposition: 'active',
|
|
213
|
+
source_ref: definition.source_ref,
|
|
214
|
+
source_digest: line.digest,
|
|
215
|
+
live_replacement: `${line.marker} Lattice todo splitへ移行済み(${definition.task_id}): ${definition.title}`,
|
|
216
|
+
})),
|
|
217
|
+
batch_digest: '',
|
|
218
|
+
};
|
|
219
|
+
batch.batch_digest = todoSelfDigest(batch, 'batch_digest');
|
|
220
|
+
return { batch, archivedByTask: new Map(rows.map(({ definition }, index) => [
|
|
221
|
+
definition.task_id,
|
|
222
|
+
{ source_ref: todoCutoverArchiveSourceRef(batch, index),
|
|
223
|
+
source_digest: batch.operations[index].source_digest },
|
|
224
|
+
])) };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function compileTasks(plan, proposal, archivedByTask) {
|
|
228
|
+
const residual = plan.tasks.find(({ task_id: taskId }) => taskId === proposal.task_id);
|
|
229
|
+
const phasePlan = plan.schema === 'lattice.todo_plan.v7';
|
|
230
|
+
const existing = plan.tasks.map((task) => {
|
|
231
|
+
const updated = task.task_id === proposal.task_id ? {
|
|
232
|
+
...task,
|
|
233
|
+
title: proposal.residual.title,
|
|
234
|
+
lane: proposal.residual.lane,
|
|
235
|
+
design_memo: proposal.residual.design_memo,
|
|
236
|
+
} : task;
|
|
237
|
+
const archived = archivedByTask.get(task.task_id);
|
|
238
|
+
return archived === undefined ? updated : { ...updated, narrative_ref: archived.source_ref };
|
|
239
|
+
});
|
|
240
|
+
const children = [...proposal.extracted_tasks]
|
|
241
|
+
.sort((left, right) => left.task_id.localeCompare(right.task_id))
|
|
242
|
+
.map((child) => ({
|
|
243
|
+
task_id: child.task_id,
|
|
244
|
+
title: child.title,
|
|
245
|
+
lane: child.lane,
|
|
246
|
+
design_memo: child.design_memo,
|
|
247
|
+
narrative_ref: archivedByTask.get(child.task_id).source_ref,
|
|
248
|
+
narrative_anchor: null,
|
|
249
|
+
compile_binding: null,
|
|
250
|
+
parent_task_id: proposal.task_id,
|
|
251
|
+
...(phasePlan ? { phase_id: residual.phase_id } : {}),
|
|
252
|
+
}));
|
|
253
|
+
return [...existing, ...children]
|
|
254
|
+
.sort((left, right) => left.task_id < right.task_id ? -1 : left.task_id > right.task_id ? 1 : 0);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function compileDependencies(plan, proposal) {
|
|
258
|
+
const edges = [...plan.hard_dependencies];
|
|
259
|
+
for (const child of proposal.extracted_tasks) {
|
|
260
|
+
for (const predecessorId of child.depends_on) {
|
|
261
|
+
edges.push({
|
|
262
|
+
from: localTaskRef(plan, predecessorId),
|
|
263
|
+
to: localTaskRef(plan, child.task_id),
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
edges.push({
|
|
267
|
+
from: localTaskRef(plan, child.task_id),
|
|
268
|
+
to: localTaskRef(plan, proposal.task_id),
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
edges.sort(compareCanonical);
|
|
272
|
+
if (new Set(edges.map(canonicalizeTodoArtifact)).size !== edges.length) {
|
|
273
|
+
invalid('duplicate_dependency', '/extracted_tasks/depends_on');
|
|
274
|
+
}
|
|
275
|
+
return edges;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function compileTaskMigration(plan, taskId, reconciledTaskIds = new Set()) {
|
|
279
|
+
return plan.tasks.map(({ task_id: currentTaskId }) => ({
|
|
280
|
+
from_task_id: currentTaskId,
|
|
281
|
+
to_task_id: currentTaskId,
|
|
282
|
+
state_policy: currentTaskId === taskId ? 'reset_pending'
|
|
283
|
+
: reconciledTaskIds.has(currentTaskId) ? 'carry_reconciled_metadata' : 'carry',
|
|
284
|
+
})).sort((left, right) => left.from_task_id.localeCompare(right.from_task_id));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function compileRuntimeTaskMigration(plan, proposal) {
|
|
288
|
+
const children = proposal.extracted_tasks.map(({ task_id: taskId }) => taskId).sort();
|
|
289
|
+
const evidenceDigests = [...proposal.evidence_digests].sort();
|
|
290
|
+
const value = {
|
|
291
|
+
schema: 'lattice.runtime_task_migration.v1',
|
|
292
|
+
entries: plan.tasks.map(({ task_id: taskId }) => taskId === proposal.task_id ? {
|
|
293
|
+
predecessor_task_id: taskId,
|
|
294
|
+
disposition: 'split',
|
|
295
|
+
successor_task_ids: [taskId, ...children],
|
|
296
|
+
reason: proposal.reason,
|
|
297
|
+
evidence_digests: evidenceDigests,
|
|
298
|
+
} : {
|
|
299
|
+
predecessor_task_id: taskId,
|
|
300
|
+
disposition: 'carry',
|
|
301
|
+
successor_task_ids: [taskId],
|
|
302
|
+
reason: 'splitの影響を受けないため状態を維持する',
|
|
303
|
+
evidence_digests: evidenceDigests,
|
|
304
|
+
}).sort((left, right) => left.predecessor_task_id.localeCompare(right.predecessor_task_id)),
|
|
305
|
+
migration_digest: '',
|
|
306
|
+
};
|
|
307
|
+
value.migration_digest = todoSelfDigest(value, 'migration_digest');
|
|
308
|
+
return value;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export async function compileTodoSplit({ repoRoot, member, proposal }) {
|
|
312
|
+
if (!validateTodoSplitProposal(proposal)) invalid('proposal_schema_invalid');
|
|
313
|
+
const plan = member?.plan;
|
|
314
|
+
if (plan?.project_id !== proposal.project_id || plan?.plan_key !== proposal.plan_key) {
|
|
315
|
+
invalid('proposal_plan_binding_mismatch');
|
|
316
|
+
}
|
|
317
|
+
if (!SUPPORTED_PLAN_SCHEMAS.has(plan.schema)) invalid('successor_schema_not_supported');
|
|
318
|
+
const sourceTask = plan.tasks.find(({ task_id: taskId }) => taskId === proposal.task_id);
|
|
319
|
+
if (sourceTask === undefined) invalid('source_task_not_active', '/task_id');
|
|
320
|
+
const runtimeTask = member.tasks.find(({ task_id: taskId }) => taskId === proposal.task_id);
|
|
321
|
+
if (runtimeTask?.status !== 'in-progress') invalid('source_task_not_in_progress', '/task_id');
|
|
322
|
+
const existingIds = new Set(plan.tasks.map(({ task_id: taskId }) => taskId));
|
|
323
|
+
const collision = proposal.extracted_tasks.find(({ task_id: taskId }) => existingIds.has(taskId));
|
|
324
|
+
if (collision !== undefined) invalid('successor_task_id_already_active', `/extracted_tasks/${collision.task_id}`);
|
|
325
|
+
|
|
326
|
+
const predecessor = {
|
|
327
|
+
plan_digest: plan.plan_digest,
|
|
328
|
+
journal_head_digest: member.journal.events.at(-1).event_digest,
|
|
329
|
+
plan_version: plan.plan_version,
|
|
330
|
+
};
|
|
331
|
+
const previousInventory = await existingSourceInventory(repoRoot, member);
|
|
332
|
+
const phasePlan = plan.schema === 'lattice.todo_plan.v7';
|
|
333
|
+
// phase revisionのgenesisではpredecessor inventoryがまだrevisionへ保存されていない。
|
|
334
|
+
// apply側は全既存sourceのarchive移送を差分として要求するため、既存taskも同じbatchへ載せる。
|
|
335
|
+
const cutoverExistingTasks = phasePlan && !previousInventory.inherited ? plan.tasks : [];
|
|
336
|
+
const reservedSourceRefs = cutoverExistingTasks.length === 0
|
|
337
|
+
? previousInventory.active.map(({ source_ref: sourceRef }) => sourceRef)
|
|
338
|
+
: [];
|
|
339
|
+
const { batch: sourceCutoverBatch, archivedByTask } = await compileSourceCutover(
|
|
340
|
+
repoRoot, proposal, { existingTasks: cutoverExistingTasks, reservedSourceRefs },
|
|
341
|
+
);
|
|
342
|
+
const tasks = compileTasks(plan, proposal, archivedByTask);
|
|
343
|
+
const hardDependencies = compileDependencies(plan, proposal);
|
|
344
|
+
const taskMigration = compileTaskMigration(plan, proposal.task_id,
|
|
345
|
+
new Set(cutoverExistingTasks.map(({ task_id: taskId }) => taskId)));
|
|
346
|
+
const sourceInventory = {
|
|
347
|
+
active: tasks.map(({ task_id: taskId }) => {
|
|
348
|
+
const archived = archivedByTask.get(taskId);
|
|
349
|
+
if (archived !== undefined) return { task_id: taskId, ...archived };
|
|
350
|
+
return previousInventory.active
|
|
351
|
+
.find(({ task_id: previousTaskId }) => previousTaskId === taskId);
|
|
352
|
+
}),
|
|
353
|
+
excluded_tombstones: previousInventory.excluded_tombstones,
|
|
354
|
+
};
|
|
355
|
+
const desiredSeed = {
|
|
356
|
+
schema: plan.schema,
|
|
357
|
+
project_id: plan.project_id,
|
|
358
|
+
plan_key: plan.plan_key,
|
|
359
|
+
predecessor_plan_digest: plan.plan_digest,
|
|
360
|
+
tasks,
|
|
361
|
+
hard_dependencies: hardDependencies,
|
|
362
|
+
joins: plan.joins,
|
|
363
|
+
...(phasePlan ? {
|
|
364
|
+
phases: plan.phases,
|
|
365
|
+
phase_accept_dependencies: plan.phase_accept_dependencies,
|
|
366
|
+
} : {}),
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
if (!phasePlan) {
|
|
370
|
+
const planVersion = todoRevisionPlanVersion({
|
|
371
|
+
projectId: plan.project_id,
|
|
372
|
+
planKey: plan.plan_key,
|
|
373
|
+
predecessor,
|
|
374
|
+
desiredPlan: desiredSeed,
|
|
375
|
+
taskMigration,
|
|
376
|
+
sourceInventory,
|
|
377
|
+
sourceCutoverBatch,
|
|
378
|
+
});
|
|
379
|
+
const desiredPlan = buildTodoPlan({ ...desiredSeed, plan_version: planVersion });
|
|
380
|
+
const reconciliation = {
|
|
381
|
+
predecessor_reconciliation_digest: predecessorReconciliationDigest(member, false),
|
|
382
|
+
source_inventory_digest: todoSourceInventoryDigest(sourceInventory),
|
|
383
|
+
reconciliation_digest: '',
|
|
384
|
+
};
|
|
385
|
+
reconciliation.reconciliation_digest = todoReconciliationDigest({
|
|
386
|
+
predecessorReconciliationDigest: reconciliation.predecessor_reconciliation_digest,
|
|
387
|
+
sourceInventoryDigest: reconciliation.source_inventory_digest,
|
|
388
|
+
predecessor,
|
|
389
|
+
desiredPlanDigest: desiredPlan.plan_digest,
|
|
390
|
+
taskMigration,
|
|
391
|
+
sourceCutoverBatch,
|
|
392
|
+
});
|
|
393
|
+
const revision = {
|
|
394
|
+
schema: 'lattice.todo_revision.v2',
|
|
395
|
+
project_id: plan.project_id,
|
|
396
|
+
plan_key: plan.plan_key,
|
|
397
|
+
predecessor,
|
|
398
|
+
desired_plan: desiredPlan,
|
|
399
|
+
task_migration: taskMigration,
|
|
400
|
+
source_inventory: sourceInventory,
|
|
401
|
+
reconciliation,
|
|
402
|
+
source_cutover_batch: sourceCutoverBatch,
|
|
403
|
+
revision_digest: '',
|
|
404
|
+
};
|
|
405
|
+
revision.revision_digest = todoSelfDigest(revision, 'revision_digest');
|
|
406
|
+
if (!validateTodoRevision(revision)) invalid('compiled_revision_invalid');
|
|
407
|
+
return { revision, extracted_task_ids: proposal.extracted_tasks
|
|
408
|
+
.map(({ task_id: taskId }) => taskId).sort() };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const runtimeTaskMigration = compileRuntimeTaskMigration(plan, proposal);
|
|
412
|
+
const phaseMigration = plan.phases.map(({ phase_id: phaseId }) => ({
|
|
413
|
+
from_phase_id: phaseId,
|
|
414
|
+
to_phase_id: phaseId,
|
|
415
|
+
state_policy: phaseId === sourceTask.phase_id ? 'reset' : 'carry',
|
|
416
|
+
}));
|
|
417
|
+
const planVersion = phaseTodoRevisionPlanVersion({
|
|
418
|
+
projectId: plan.project_id,
|
|
419
|
+
planKey: plan.plan_key,
|
|
420
|
+
predecessor,
|
|
421
|
+
desiredPlan: desiredSeed,
|
|
422
|
+
taskMigration,
|
|
423
|
+
phaseMigration,
|
|
424
|
+
});
|
|
425
|
+
const desiredPlan = buildTodoPlan({ ...desiredSeed, plan_version: planVersion });
|
|
426
|
+
const reconciliation = {
|
|
427
|
+
predecessor_reconciliation_digest: predecessorReconciliationDigest(member, true),
|
|
428
|
+
source_inventory_digest: todoSourceInventoryDigest(sourceInventory),
|
|
429
|
+
desired_plan_digest: desiredPlan.plan_digest,
|
|
430
|
+
runtime_task_migration_digest: runtimeTaskMigration.migration_digest,
|
|
431
|
+
task_migration_digest: todoTaskMigrationDigest(taskMigration),
|
|
432
|
+
phase_migration_digest: digestTodoArtifact(phaseMigration),
|
|
433
|
+
source_cutover_batch_digest: sourceCutoverBatch.batch_digest,
|
|
434
|
+
reconciliation_digest: '',
|
|
435
|
+
};
|
|
436
|
+
reconciliation.reconciliation_digest = todoSelfDigest(reconciliation, 'reconciliation_digest');
|
|
437
|
+
const revision = {
|
|
438
|
+
schema: 'lattice.phase_todo_revision.v3',
|
|
439
|
+
project_id: plan.project_id,
|
|
440
|
+
plan_key: plan.plan_key,
|
|
441
|
+
predecessor,
|
|
442
|
+
desired_plan: desiredPlan,
|
|
443
|
+
runtime_task_migration: runtimeTaskMigration,
|
|
444
|
+
task_migration: taskMigration,
|
|
445
|
+
phase_migration: phaseMigration,
|
|
446
|
+
source_inventory: sourceInventory,
|
|
447
|
+
reconciliation,
|
|
448
|
+
source_cutover_batch: sourceCutoverBatch,
|
|
449
|
+
revision_digest: '',
|
|
450
|
+
};
|
|
451
|
+
revision.revision_digest = todoSelfDigest(revision, 'revision_digest');
|
|
452
|
+
if (!validatePhaseTodoRevision(revision)) invalid('compiled_phase_revision_invalid');
|
|
453
|
+
return { revision, extracted_task_ids: proposal.extracted_tasks
|
|
454
|
+
.map(({ task_id: taskId }) => taskId).sort() };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* splitは既存taskを同じidへ写し、子taskは新規追加するだけなので、既存witness bytesを変えない。
|
|
459
|
+
* apply前にこの不変条件を検査し、解決不能な宣言や将来の非identity migrationをstore更新前に止める。
|
|
460
|
+
*/
|
|
461
|
+
export function prepareTodoSplitWitnessMigration({ witnessSet, revision }) {
|
|
462
|
+
const migration = migrateWitnessSetTaskIds({
|
|
463
|
+
witnessSet,
|
|
464
|
+
taskMigration: revision.task_migration,
|
|
465
|
+
planTaskIds: revision.desired_plan.tasks.map(({ task_id: taskId }) => taskId),
|
|
466
|
+
});
|
|
467
|
+
if (migration.witnessSet.witness_set_digest !== witnessSet.witness_set_digest
|
|
468
|
+
|| canonicalizeTodoArtifact(migration.witnessSet) !== canonicalizeTodoArtifact(witnessSet)) {
|
|
469
|
+
invalid('split_witness_migration_not_identity', '/task_migration');
|
|
470
|
+
}
|
|
471
|
+
return migration;
|
|
472
|
+
}
|