@quolu/lattice 0.59.0 → 0.60.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.mjs +20 -0
- package/docs/schemas/lattice.executor_packet.v1.schema.json +2 -2
- package/docs/schemas/lattice.plan_create_input.v4.schema.json +1 -1
- package/docs/schemas/lattice.plan_scope_review.v1.schema.json +55 -0
- package/docs/schemas/lattice.todo_extraction.v3.schema.json +6 -2
- package/docs/schemas/lattice.todo_extraction.v4.schema.json +161 -0
- package/package.json +3 -1
- package/src/cli-help.mjs +3 -2
- package/src/plan-scope-review.mjs +214 -0
- package/src/project-cli.mjs +26 -3
- package/src/runtime-contracts.mjs +1 -1
- package/src/runtime-engine.mjs +3 -11
- package/src/runtime-pull-intake.mjs +34 -4
- package/src/runtime-work-order-contracts.mjs +1 -1
- package/src/todo-cli.mjs +106 -42
- package/src/todo-independence-authoritative-observation.mjs +116 -0
- package/src/todo-independence-guidance.mjs +25 -0
- package/src/todo-migration.mjs +124 -48
- package/src/todo-store.mjs +517 -40
- package/src/todo-structure-store.mjs +28 -0
package/src/todo-store.mjs
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
isStrictTodoTimestamp,
|
|
14
14
|
isTodoDigest,
|
|
15
15
|
isTodoIdentifier,
|
|
16
|
+
isTodoRef,
|
|
16
17
|
todoSelfDigest,
|
|
17
18
|
validateEvidenceDescriptor,
|
|
18
19
|
validateTodoImportSource,
|
|
@@ -53,6 +54,9 @@ import {
|
|
|
53
54
|
|
|
54
55
|
const STORE_ROOT_REF = '.lattice/todo';
|
|
55
56
|
const MANIFEST_REF = `${STORE_ROOT_REF}/manifest.json`;
|
|
57
|
+
const CROSS_PLAN_IMPORT_TRANSACTIONS_REF = `${STORE_ROOT_REF}/transactions/imports`;
|
|
58
|
+
const CROSS_PLAN_RECOVERY_CLAIMS_REF = `${STORE_ROOT_REF}/.cross-plan-recovery`;
|
|
59
|
+
const CROSS_PLAN_IMPORT_TRANSACTION_SCHEMA = 'lattice.todo_cross_plan_import_transaction.v1';
|
|
56
60
|
const SOURCE_CUTOVER_BARRIER_REF = `${STORE_ROOT_REF}/source-cutover-recovery.json`;
|
|
57
61
|
const SOURCE_CUTOVER_RECOVERY_CAPABILITY = Symbol('lattice.todo.source-cutover-recovery');
|
|
58
62
|
const MAX_FUTURE_SKEW_MS = 5 * 60 * 1_000;
|
|
@@ -132,10 +136,11 @@ function decodeUtf8(bytes, code, reason) {
|
|
|
132
136
|
function parseCanonicalJsonLine(bytes, { code, reason, maxBytes, validate }) {
|
|
133
137
|
if (bytes.length === 0 || bytes.length > maxBytes) fail(code, bytes.length > maxBytes ? 'size_limit_exceeded' : reason);
|
|
134
138
|
const text = decodeUtf8(bytes, code, 'invalid_utf8');
|
|
135
|
-
if (
|
|
136
|
-
|
|
139
|
+
if (text.includes('\r') || text.startsWith('\uFEFF')) fail(code, reason);
|
|
140
|
+
const body = text.endsWith('\n') ? text.slice(0, -1) : text;
|
|
137
141
|
let value;
|
|
138
|
-
try { value = JSON.parse(
|
|
142
|
+
try { value = JSON.parse(body); } catch { fail(code, reason); }
|
|
143
|
+
if (!text.endsWith('\n')) fail(code, reason);
|
|
139
144
|
let expected;
|
|
140
145
|
try { expected = `${canonicalizeTodoArtifact(value)}\n`; } catch { fail(code, reason); }
|
|
141
146
|
if (text !== expected) fail(code, 'non_canonical_or_duplicate_key');
|
|
@@ -908,7 +913,10 @@ function validateCrossPlanDependencyTransition(store, owner, input) {
|
|
|
908
913
|
}
|
|
909
914
|
const source = crossPlanDependencyTask(store, from);
|
|
910
915
|
const target = crossPlanDependencyTask(store, to);
|
|
911
|
-
|
|
916
|
+
// A completed source already satisfies the prerequisite. Keep the existing
|
|
917
|
+
// completion record as the proof instead of creating a second ledger entry.
|
|
918
|
+
// The dependency event still records the topology edge for the target and
|
|
919
|
+
// must pass all of the usual binding, duplicate, and cycle checks below.
|
|
912
920
|
if (target.task.status === 'done') fail('DEPENDENCY_INVALID', 'dependency_target_terminal');
|
|
913
921
|
const existing = projectTodoCrossPlanDependencies(store.members);
|
|
914
922
|
if (existing.some((dependency) => mergedTaskKey(dependency.from) === mergedTaskKey(from)
|
|
@@ -1296,6 +1304,7 @@ export async function readTodoStore(options = {}) {
|
|
|
1296
1304
|
fail('SOURCE_CUTOVER_RECOVERY_REQUIRED', 'source_cutover_recovery_required');
|
|
1297
1305
|
}
|
|
1298
1306
|
}
|
|
1307
|
+
await assertNoCrossPlanImportRecoveryNeeded(repoRoot);
|
|
1299
1308
|
const pinnedSourceCache = { commits: new Set(), blobs: new Map() };
|
|
1300
1309
|
const manifest = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
1301
1310
|
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
@@ -1502,13 +1511,100 @@ async function fsyncDirectory(absolute) {
|
|
|
1502
1511
|
} finally { await directory.close(); }
|
|
1503
1512
|
}
|
|
1504
1513
|
|
|
1505
|
-
async function
|
|
1514
|
+
async function createWriteLock(lockRef, { recordOwner = false } = {}) {
|
|
1515
|
+
const handle = await open(lockRef, 'wx', 0o600);
|
|
1516
|
+
if (!recordOwner) return handle;
|
|
1517
|
+
try {
|
|
1518
|
+
await handle.writeFile(canonicalLine({ pid: process.pid }));
|
|
1519
|
+
await handle.sync();
|
|
1520
|
+
return handle;
|
|
1521
|
+
} catch (error) {
|
|
1522
|
+
await handle.close();
|
|
1523
|
+
await rm(lockRef, { force: true });
|
|
1524
|
+
throw error;
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
|
|
1528
|
+
async function staleWriteLockOwner(lockRef) {
|
|
1529
|
+
let record;
|
|
1530
|
+
try { record = JSON.parse((await readFile(lockRef)).toString('utf8')); }
|
|
1531
|
+
catch { return false; }
|
|
1532
|
+
if (!exactRecord(record, ['pid']) || !Number.isSafeInteger(record.pid) || record.pid <= 0) return false;
|
|
1533
|
+
try {
|
|
1534
|
+
process.kill(record.pid, 0);
|
|
1535
|
+
return false;
|
|
1536
|
+
} catch (error) {
|
|
1537
|
+
return error?.code === 'ESRCH';
|
|
1538
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
function processIsDead(pid) {
|
|
1542
|
+
try {
|
|
1543
|
+
process.kill(pid, 0);
|
|
1544
|
+
return false;
|
|
1545
|
+
} catch (error) {
|
|
1546
|
+
return error?.code === 'ESRCH';
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
async function acquireCrossPlanRecoveryClaim(recoveryClaimsRef) {
|
|
1551
|
+
await mkdir(recoveryClaimsRef, { recursive: true, mode: 0o700 });
|
|
1552
|
+
const claimName = `${process.pid}-${randomBytes(8).toString('hex')}`;
|
|
1553
|
+
const claimRef = path.join(recoveryClaimsRef, claimName);
|
|
1554
|
+
await mkdir(claimRef, { mode: 0o700 });
|
|
1555
|
+
for (const entry of await readdir(recoveryClaimsRef, { withFileTypes: true })) {
|
|
1556
|
+
if (!entry.isDirectory() || entry.name === claimName) continue;
|
|
1557
|
+
const pid = Number(entry.name.split('-', 1)[0]);
|
|
1558
|
+
if (Number.isSafeInteger(pid) && pid > 0 && processIsDead(pid)) {
|
|
1559
|
+
await rm(path.join(recoveryClaimsRef, entry.name), { recursive: true, force: true });
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
const candidates = await Promise.all((await readdir(recoveryClaimsRef)).map(async (name) => ({
|
|
1563
|
+
name,
|
|
1564
|
+
mtimeNs: (await lstat(path.join(recoveryClaimsRef, name), { bigint: true })).mtimeNs,
|
|
1565
|
+
})));
|
|
1566
|
+
candidates.sort((left, right) => left.mtimeNs < right.mtimeNs ? -1
|
|
1567
|
+
: left.mtimeNs > right.mtimeNs ? 1 : left.name < right.name ? -1 : 1);
|
|
1568
|
+
if (candidates[0]?.name === claimName) return claimRef;
|
|
1569
|
+
await rmdir(claimRef);
|
|
1570
|
+
return null;
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1573
|
+
async function recoverCrossPlanWriteLock(lockRef, recoveryClaimsRef, onProtocolStage) {
|
|
1574
|
+
const claimRef = await acquireCrossPlanRecoveryClaim(recoveryClaimsRef);
|
|
1575
|
+
if (claimRef === null) return undefined;
|
|
1576
|
+
try {
|
|
1577
|
+
if (typeof onProtocolStage === 'function') await onProtocolStage('cross_plan_lock_recovery_mutex_acquired');
|
|
1578
|
+
if (!await staleWriteLockOwner(lockRef)) return undefined;
|
|
1579
|
+
await rm(lockRef, { force: true });
|
|
1580
|
+
try { return await createWriteLock(lockRef, { recordOwner: true }); }
|
|
1581
|
+
catch (error) {
|
|
1582
|
+
if (error?.code === 'EEXIST') return undefined;
|
|
1583
|
+
throw error;
|
|
1584
|
+
}
|
|
1585
|
+
} finally {
|
|
1586
|
+
await rm(claimRef, { recursive: true, force: true });
|
|
1587
|
+
try { await rmdir(recoveryClaimsRef); }
|
|
1588
|
+
catch (error) {
|
|
1589
|
+
if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error?.code)) throw error;
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
}
|
|
1593
|
+
|
|
1594
|
+
async function withLock(repoRoot, callback, { recoverDeadLock = false, onProtocolStage = null } = {}) {
|
|
1506
1595
|
const root = path.join(repoRoot, STORE_ROOT_REF);
|
|
1507
1596
|
await mkdir(root, { recursive: true });
|
|
1508
1597
|
const lockRef = path.join(root, '.write.lock');
|
|
1598
|
+
const recoveryClaimsRef = path.join(repoRoot, CROSS_PLAN_RECOVERY_CLAIMS_REF);
|
|
1509
1599
|
let handle;
|
|
1510
|
-
try { handle = await
|
|
1511
|
-
catch (error) {
|
|
1600
|
+
try { handle = await createWriteLock(lockRef, { recordOwner: recoverDeadLock }); }
|
|
1601
|
+
catch (error) {
|
|
1602
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
1603
|
+
if (recoverDeadLock) {
|
|
1604
|
+
handle = await recoverCrossPlanWriteLock(lockRef, recoveryClaimsRef, onProtocolStage);
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
if (handle === undefined) fail('STORE_WRITE_CONFLICT', 'store_locked');
|
|
1512
1608
|
try { return await callback(); }
|
|
1513
1609
|
finally { await handle.close(); await rm(lockRef, { force: true }); }
|
|
1514
1610
|
}
|
|
@@ -1807,7 +1903,7 @@ export async function appendTodoEvent(options = {}) {
|
|
|
1807
1903
|
* 「この planの lifecycleがどこまで進んだか」という意味を、方式の宣言で動かさない
|
|
1808
1904
|
* (合成すると型も値域も同じまま意味だけずれ、消費者はexact検証を通してしまう)。
|
|
1809
1905
|
*/
|
|
1810
|
-
|
|
1906
|
+
function buildPlanScopedEvent(member, input) {
|
|
1811
1907
|
const previous = member.plan_scoped.events.at(-1) ?? null;
|
|
1812
1908
|
const event = {
|
|
1813
1909
|
schema: 'lattice.todo_event.v3',
|
|
@@ -1823,7 +1919,10 @@ async function appendPlanScopedEvent({ repoRoot, member, input }) {
|
|
|
1823
1919
|
};
|
|
1824
1920
|
event.event_digest = todoSelfDigest(event, 'event_digest');
|
|
1825
1921
|
if (!validateTodoEvent(event)) throw new TypeError('todo event input violates its declared schema');
|
|
1922
|
+
return event;
|
|
1923
|
+
}
|
|
1826
1924
|
|
|
1925
|
+
function planScopedEventBytes(member, event) {
|
|
1827
1926
|
const bytes = canonicalLine(event);
|
|
1828
1927
|
if (member.plan_scoped.activeBytes.length + bytes.length > TODO_LIMITS.journalSegmentBytes) {
|
|
1829
1928
|
// 宣言は1 planあたり数件の想定なので封緘機構は持たない。上限へ達したら黙って捨てず、
|
|
@@ -1832,6 +1931,12 @@ async function appendPlanScopedEvent({ repoRoot, member, input }) {
|
|
|
1832
1931
|
ref: member.plan_scoped.ref, limit: TODO_LIMITS.journalSegmentBytes,
|
|
1833
1932
|
});
|
|
1834
1933
|
}
|
|
1934
|
+
return bytes;
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
async function appendPlanScopedEvent({ repoRoot, member, input }) {
|
|
1938
|
+
const event = buildPlanScopedEvent(member, input);
|
|
1939
|
+
const bytes = planScopedEventBytes(member, event);
|
|
1835
1940
|
await atomicWrite(path.resolve(repoRoot, member.plan_scoped.ref),
|
|
1836
1941
|
Buffer.concat([member.plan_scoped.activeBytes, bytes]));
|
|
1837
1942
|
const events = [...member.plan_scoped.events, event];
|
|
@@ -2070,9 +2175,305 @@ async function protocolStage(options, stage) {
|
|
|
2070
2175
|
if (typeof options.onProtocolStage === 'function') await options.onProtocolStage(stage);
|
|
2071
2176
|
}
|
|
2072
2177
|
|
|
2178
|
+
function cloneImportedDependencyMember(member) {
|
|
2179
|
+
return {
|
|
2180
|
+
...member,
|
|
2181
|
+
plan_scoped: {
|
|
2182
|
+
...(member.plan_scoped ?? {}),
|
|
2183
|
+
events: [...(member.plan_scoped?.events ?? [])],
|
|
2184
|
+
activeBytes: Buffer.from(member.plan_scoped?.activeBytes ?? Buffer.alloc(0)),
|
|
2185
|
+
},
|
|
2186
|
+
};
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
function importedDependencyRef(ref, plan) {
|
|
2190
|
+
if (ref.project_id !== plan.project_id || ref.plan_key !== plan.plan_key
|
|
2191
|
+
|| ref.expected_topology_digest !== undefined) return ref;
|
|
2192
|
+
return { ...ref, expected_topology_digest: plan.topology_digest };
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function prepareCrossPlanDependencies({ store, dependencies, genesis, imported = null }) {
|
|
2196
|
+
if (!Array.isArray(dependencies) || dependencies.length === 0) {
|
|
2197
|
+
return { events: [], artifacts: [] };
|
|
2198
|
+
}
|
|
2199
|
+
const members = [...store.members.map(cloneImportedDependencyMember)];
|
|
2200
|
+
if (imported !== null) {
|
|
2201
|
+
members.push({
|
|
2202
|
+
plan: imported.plan, tasks: imported.tasks,
|
|
2203
|
+
plan_scoped: {
|
|
2204
|
+
ref: planScopedJournalRef(imported.journalRef), events: [], activeBytes: Buffer.alloc(0),
|
|
2205
|
+
},
|
|
2206
|
+
});
|
|
2207
|
+
}
|
|
2208
|
+
const artifacts = new Map();
|
|
2209
|
+
const events = [];
|
|
2210
|
+
for (const dependency of dependencies) {
|
|
2211
|
+
const from = imported === null ? dependency.from : importedDependencyRef(dependency.from, imported.plan);
|
|
2212
|
+
const to = imported === null ? dependency.to : importedDependencyRef(dependency.to, imported.plan);
|
|
2213
|
+
const target = members.find(({ plan: targetPlan }) => targetPlan.project_id === to.project_id
|
|
2214
|
+
&& targetPlan.plan_key === to.plan_key);
|
|
2215
|
+
if (target === undefined) {
|
|
2216
|
+
// Reuse the existing dependency presence diagnostic for an unknown target plan.
|
|
2217
|
+
crossPlanDependencyTask({ ...store, members }, to);
|
|
2218
|
+
}
|
|
2219
|
+
const input = {
|
|
2220
|
+
kind: 'cross_plan_dependency', actor: genesis.actor, recorded_at: genesis.recorded_at,
|
|
2221
|
+
provenance: genesis.provenance ?? null,
|
|
2222
|
+
payload: { from, to, reason: dependency.reason },
|
|
2223
|
+
};
|
|
2224
|
+
const validationStore = { ...store, members };
|
|
2225
|
+
validateCrossPlanDependencyTransition(validationStore, target, input);
|
|
2226
|
+
const event = buildPlanScopedEvent(target, input);
|
|
2227
|
+
const bytes = planScopedEventBytes(target, event);
|
|
2228
|
+
const key = target.plan_scoped.ref;
|
|
2229
|
+
const artifact = artifacts.get(key) ?? {
|
|
2230
|
+
ref: key,
|
|
2231
|
+
originalBytes: Buffer.from(target.plan_scoped.activeBytes),
|
|
2232
|
+
finalBytes: Buffer.from(target.plan_scoped.activeBytes),
|
|
2233
|
+
events: [],
|
|
2234
|
+
};
|
|
2235
|
+
artifact.finalBytes = Buffer.concat([artifact.finalBytes, bytes]);
|
|
2236
|
+
artifact.events.push(event);
|
|
2237
|
+
artifacts.set(key, artifact);
|
|
2238
|
+
target.plan_scoped.events.push(event);
|
|
2239
|
+
target.plan_scoped.activeBytes = Buffer.concat([target.plan_scoped.activeBytes, bytes]);
|
|
2240
|
+
events.push(event);
|
|
2241
|
+
}
|
|
2242
|
+
return { events, artifacts: [...artifacts.values()] };
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
function prepareImportedCrossPlanDependencies({ store, plan, tasks, dependencies, genesis, journalRef }) {
|
|
2246
|
+
return prepareCrossPlanDependencies({
|
|
2247
|
+
store, dependencies, genesis, imported: { plan, tasks, journalRef },
|
|
2248
|
+
});
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function importedTransactionDescriptor(value) {
|
|
2252
|
+
const v1Keys = [
|
|
2253
|
+
'plan_key', 'active_plan_version', 'plan_ref', 'journal_ref', 'snapshot_ref',
|
|
2254
|
+
'topology_digest', 'journal_head_digest',
|
|
2255
|
+
];
|
|
2256
|
+
const v2Keys = [...v1Keys, 'active_revision_digest'];
|
|
2257
|
+
return (exactRecord(value, v1Keys) || exactRecord(value, v2Keys))
|
|
2258
|
+
&& isTodoIdentifier(value.plan_key) && isTodoIdentifier(value.active_plan_version)
|
|
2259
|
+
&& [value.plan_ref, value.journal_ref, value.snapshot_ref].every((ref) => isTodoRef(ref)
|
|
2260
|
+
&& ref.startsWith(`${STORE_ROOT_REF}/plans/`))
|
|
2261
|
+
&& [value.topology_digest, value.journal_head_digest,
|
|
2262
|
+
...(Object.hasOwn(value, 'active_revision_digest') ? [value.active_revision_digest] : [])]
|
|
2263
|
+
.every(isTodoDigest);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
function transactionStoreRef(value) {
|
|
2267
|
+
return isTodoRef(value) && value.startsWith(`${STORE_ROOT_REF}/`);
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
function validateCrossPlanImportTransaction(value) {
|
|
2271
|
+
return exactRecord(value, [
|
|
2272
|
+
'schema', 'transaction_digest', 'descriptor', 'artifacts', 'events',
|
|
2273
|
+
]) && value.schema === CROSS_PLAN_IMPORT_TRANSACTION_SCHEMA
|
|
2274
|
+
&& isTodoDigest(value.transaction_digest) && importedTransactionDescriptor(value.descriptor)
|
|
2275
|
+
&& Array.isArray(value.artifacts) && value.artifacts.length === 1
|
|
2276
|
+
&& value.artifacts.every((artifact) => exactRecord(artifact, [
|
|
2277
|
+
'ref', 'staged_ref', 'bytes_digest',
|
|
2278
|
+
]) && transactionStoreRef(artifact.ref) && transactionStoreRef(artifact.staged_ref)
|
|
2279
|
+
&& artifact.staged_ref.startsWith(`${CROSS_PLAN_IMPORT_TRANSACTIONS_REF}/`)
|
|
2280
|
+
&& isTodoDigest(artifact.bytes_digest))
|
|
2281
|
+
&& Array.isArray(value.events) && value.events.length === 1
|
|
2282
|
+
&& value.events.every((event) => validateTodoEvent(event)
|
|
2283
|
+
&& event.kind === 'cross_plan_dependency');
|
|
2284
|
+
}
|
|
2285
|
+
|
|
2286
|
+
function importedPlanTransactionDigest(options) {
|
|
2287
|
+
const input = {
|
|
2288
|
+
schema: 'lattice.todo_cross_plan_import_request.v1', plan: options.plan,
|
|
2289
|
+
genesis: options.genesis, cross_plan_dependencies: options.crossPlanDependencies ?? [],
|
|
2290
|
+
narrative_anchor_sources: options.narrativeAnchorSources ?? [],
|
|
2291
|
+
completed_tasks: options.completedTasks ?? [], in_progress_tasks: options.inProgressTasks ?? [],
|
|
2292
|
+
transaction_digest: '',
|
|
2293
|
+
};
|
|
2294
|
+
return todoSelfDigest(input, 'transaction_digest');
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
function importedCrossPlanTransaction({ transactionDigest, descriptor, transactionRef, importedCrossPlan }) {
|
|
2298
|
+
return {
|
|
2299
|
+
schema: CROSS_PLAN_IMPORT_TRANSACTION_SCHEMA,
|
|
2300
|
+
transaction_digest: transactionDigest,
|
|
2301
|
+
descriptor: structuredClone(descriptor),
|
|
2302
|
+
artifacts: importedCrossPlan.artifacts.map((artifact, index) => ({
|
|
2303
|
+
ref: artifact.ref,
|
|
2304
|
+
staged_ref: path.posix.join(transactionRef, 'plan-scoped', `${index}.jsonl`),
|
|
2305
|
+
bytes_digest: sha256Bytes(artifact.finalBytes),
|
|
2306
|
+
})),
|
|
2307
|
+
events: structuredClone(importedCrossPlan.events),
|
|
2308
|
+
};
|
|
2309
|
+
}
|
|
2310
|
+
|
|
2311
|
+
async function crossPlanImportTransactions(repoRoot) {
|
|
2312
|
+
const root = path.resolve(repoRoot, CROSS_PLAN_IMPORT_TRANSACTIONS_REF);
|
|
2313
|
+
let names;
|
|
2314
|
+
try { names = (await readdir(root)).sort(); }
|
|
2315
|
+
catch (error) {
|
|
2316
|
+
if (error?.code === 'ENOENT') return [];
|
|
2317
|
+
throw error;
|
|
2318
|
+
}
|
|
2319
|
+
const transactions = [];
|
|
2320
|
+
for (const name of names) {
|
|
2321
|
+
if (!isTodoIdentifier(name)) fail('STORE_INCONSISTENT', 'cross_plan_transaction_name_invalid', { name });
|
|
2322
|
+
const transactionRef = path.posix.join(CROSS_PLAN_IMPORT_TRANSACTIONS_REF, name);
|
|
2323
|
+
const markerRef = path.posix.join(transactionRef, 'transaction.json');
|
|
2324
|
+
const marker = await readArtifact(repoRoot, markerRef, {
|
|
2325
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes,
|
|
2326
|
+
validate: validateCrossPlanImportTransaction, missing: true,
|
|
2327
|
+
});
|
|
2328
|
+
// marker durable前にprocessが止まったdirectoryはstoreへ一切公開されていない。
|
|
2329
|
+
// 同一入力の次回writerがtransaction pathを作り直すまで、通常readからは無視する。
|
|
2330
|
+
if (marker === null) continue;
|
|
2331
|
+
transactions.push({ transactionRef, marker });
|
|
2332
|
+
}
|
|
2333
|
+
return transactions;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2336
|
+
function transactionManifestMember(manifest, marker) {
|
|
2337
|
+
const member = manifest.members.find(({ plan_key: planKey }) => planKey === marker.descriptor.plan_key);
|
|
2338
|
+
if (member === undefined) return null;
|
|
2339
|
+
if (canonicalizeTodoArtifact(member) !== canonicalizeTodoArtifact(marker.descriptor)) {
|
|
2340
|
+
fail('STORE_INCONSISTENT', 'cross_plan_transaction_manifest_conflict', {
|
|
2341
|
+
plan_key: marker.descriptor.plan_key,
|
|
2342
|
+
});
|
|
2343
|
+
}
|
|
2344
|
+
return member;
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
async function finalizeVisibleCrossPlanImport(repoRoot, transaction, { removeTransaction }) {
|
|
2348
|
+
for (const artifact of transaction.marker.artifacts) {
|
|
2349
|
+
const state = await pathState(repoRoot, artifact.staged_ref, 'STORE_INCONSISTENT');
|
|
2350
|
+
const bytes = await readFile(state.absolute);
|
|
2351
|
+
if (sha256Bytes(bytes) !== artifact.bytes_digest) {
|
|
2352
|
+
fail('STORE_INCONSISTENT', 'cross_plan_transaction_staging_digest_mismatch', {
|
|
2353
|
+
ref: artifact.staged_ref,
|
|
2354
|
+
});
|
|
2355
|
+
}
|
|
2356
|
+
await atomicWrite(path.resolve(repoRoot, artifact.ref), bytes);
|
|
2357
|
+
}
|
|
2358
|
+
if (removeTransaction) {
|
|
2359
|
+
await rm(path.resolve(repoRoot, transaction.transactionRef), { recursive: true, force: true });
|
|
2360
|
+
}
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
async function assertNoCrossPlanImportRecoveryNeeded(repoRoot) {
|
|
2364
|
+
const transactions = await crossPlanImportTransactions(repoRoot);
|
|
2365
|
+
if (transactions.length === 0) return;
|
|
2366
|
+
let manifest;
|
|
2367
|
+
try {
|
|
2368
|
+
manifest = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
2369
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
2370
|
+
});
|
|
2371
|
+
} catch (error) {
|
|
2372
|
+
if (error instanceof TodoStoreError && error.detail.reason === 'artifact_missing') manifest = null;
|
|
2373
|
+
else throw error;
|
|
2374
|
+
}
|
|
2375
|
+
const transaction = transactions[0];
|
|
2376
|
+
const visible = manifest !== null
|
|
2377
|
+
&& transactionManifestMember(manifest, transaction.marker) !== null;
|
|
2378
|
+
fail('STORE_RECOVERY_REQUIRED', 'cross_plan_import_recovery_required', {
|
|
2379
|
+
plan_key: transaction.marker.descriptor.plan_key,
|
|
2380
|
+
transaction_digest: transaction.marker.transaction_digest,
|
|
2381
|
+
state: visible ? 'manifest_activated' : 'pre_activation',
|
|
2382
|
+
next_action: 'rerun_the_same_todo_migrate_input',
|
|
2383
|
+
});
|
|
2384
|
+
}
|
|
2385
|
+
|
|
2386
|
+
async function recoverCrossPlanImports(repoRoot, { expectedTransactionDigest }) {
|
|
2387
|
+
const transactions = await crossPlanImportTransactions(repoRoot);
|
|
2388
|
+
if (transactions.length === 0) return [];
|
|
2389
|
+
if (!transactions.every(({ marker }) => marker.transaction_digest === expectedTransactionDigest)) {
|
|
2390
|
+
await assertNoCrossPlanImportRecoveryNeeded(repoRoot);
|
|
2391
|
+
}
|
|
2392
|
+
let manifest;
|
|
2393
|
+
try {
|
|
2394
|
+
manifest = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
2395
|
+
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
2396
|
+
});
|
|
2397
|
+
} catch (error) {
|
|
2398
|
+
if (error instanceof TodoStoreError && error.detail.reason === 'artifact_missing') manifest = null;
|
|
2399
|
+
else throw error;
|
|
2400
|
+
}
|
|
2401
|
+
const recovered = [];
|
|
2402
|
+
for (const transaction of transactions) {
|
|
2403
|
+
const member = manifest === null ? null : transactionManifestMember(manifest, transaction.marker);
|
|
2404
|
+
if (member !== null) {
|
|
2405
|
+
await finalizeVisibleCrossPlanImport(repoRoot, transaction, {
|
|
2406
|
+
removeTransaction: true,
|
|
2407
|
+
});
|
|
2408
|
+
recovered.push(transaction.marker);
|
|
2409
|
+
continue;
|
|
2410
|
+
}
|
|
2411
|
+
await rm(path.dirname(path.resolve(repoRoot, transaction.marker.descriptor.plan_ref)), {
|
|
2412
|
+
recursive: true, force: true,
|
|
2413
|
+
});
|
|
2414
|
+
await rm(path.resolve(repoRoot, transaction.transactionRef), { recursive: true, force: true });
|
|
2415
|
+
}
|
|
2416
|
+
return recovered;
|
|
2417
|
+
}
|
|
2418
|
+
|
|
2419
|
+
async function recoveredImportedPlanResult(repoRoot, marker, now) {
|
|
2420
|
+
const store = await readTodoStore({ repoRoot, forWrite: true, now });
|
|
2421
|
+
const member = store.members.find(({ descriptor }) => descriptor.plan_key === marker.descriptor.plan_key);
|
|
2422
|
+
if (member === undefined) fail('STORE_INCONSISTENT', 'cross_plan_transaction_recovery_missing_plan');
|
|
2423
|
+
return {
|
|
2424
|
+
plan: member.plan, genesis: member.journal.events[0], events: member.journal.events,
|
|
2425
|
+
snapshot: member.snapshot, descriptor: member.descriptor,
|
|
2426
|
+
crossPlanDependencies: marker.events, recovered: true,
|
|
2427
|
+
};
|
|
2428
|
+
}
|
|
2429
|
+
|
|
2430
|
+
async function appendExistingCrossPlanDependency(options) {
|
|
2431
|
+
if (!exactRecord(options.connectionPlan, ['project_id', 'plan_key', 'plan_version'])
|
|
2432
|
+
|| !isTodoIdentifier(options.connectionPlan.project_id)
|
|
2433
|
+
|| !isTodoIdentifier(options.connectionPlan.plan_key)
|
|
2434
|
+
|| !isTodoIdentifier(options.connectionPlan.plan_version)
|
|
2435
|
+
|| !Array.isArray(options.crossPlanDependencies) || options.crossPlanDependencies.length !== 1) {
|
|
2436
|
+
throw new TypeError('existing cross-plan dependency input invalid');
|
|
2437
|
+
}
|
|
2438
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
2439
|
+
return withLock(repoRoot, async () => {
|
|
2440
|
+
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
|
|
2441
|
+
const source = store.members.find(({ plan }) => plan.project_id === options.connectionPlan.project_id
|
|
2442
|
+
&& plan.plan_key === options.connectionPlan.plan_key);
|
|
2443
|
+
if (source === undefined) {
|
|
2444
|
+
fail('DEPENDENCY_INVALID', 'dependency_plan_not_found', { ref: options.connectionPlan });
|
|
2445
|
+
}
|
|
2446
|
+
if (source.plan.plan_version !== options.connectionPlan.plan_version) {
|
|
2447
|
+
fail('DEPENDENCY_STALE', 'connection_plan_version_stale', {
|
|
2448
|
+
expected_plan_version: options.connectionPlan.plan_version,
|
|
2449
|
+
actual_plan_version: source.plan.plan_version,
|
|
2450
|
+
});
|
|
2451
|
+
}
|
|
2452
|
+
const prepared = prepareCrossPlanDependencies({
|
|
2453
|
+
store, dependencies: options.crossPlanDependencies, genesis: options.genesis,
|
|
2454
|
+
});
|
|
2455
|
+
if (prepared.artifacts.length !== 1 || prepared.events.length !== 1) {
|
|
2456
|
+
throw new TypeError('existing cross-plan dependency must produce one target event');
|
|
2457
|
+
}
|
|
2458
|
+
const artifact = prepared.artifacts[0];
|
|
2459
|
+
await protocolStage(options, 'cross_plan_connection_validated');
|
|
2460
|
+
await atomicWrite(path.resolve(repoRoot, artifact.ref), artifact.finalBytes);
|
|
2461
|
+
await protocolStage(options, 'cross_plan_connection_activated');
|
|
2462
|
+
return {
|
|
2463
|
+
plan: source.plan, genesis: source.journal.events[0], events: source.journal.events,
|
|
2464
|
+
snapshot: source.snapshot, descriptor: source.descriptor,
|
|
2465
|
+
crossPlanDependencies: prepared.events, connectionOnly: true,
|
|
2466
|
+
};
|
|
2467
|
+
}, { recoverDeadLock: true, onProtocolStage: options.onProtocolStage });
|
|
2468
|
+
}
|
|
2469
|
+
|
|
2073
2470
|
/** G4-only atomic import, with optional all-or-nothing store bootstrap. */
|
|
2074
2471
|
export async function appendImportedPlan(options = {}) {
|
|
2075
2472
|
requireWriter(options.writer, 'g4-migration');
|
|
2473
|
+
if (options.connectionOnly === true) return appendExistingCrossPlanDependency(options);
|
|
2474
|
+
if (Array.isArray(options.crossPlanDependencies) && options.crossPlanDependencies.length > 1) {
|
|
2475
|
+
throw new TypeError('cross-plan migration accepts at most one dependency');
|
|
2476
|
+
}
|
|
2076
2477
|
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
2077
2478
|
if (options.initializeIfMissing !== undefined) {
|
|
2078
2479
|
try { await lstat(path.join(repoRoot, STORE_ROOT_REF)); }
|
|
@@ -2081,7 +2482,13 @@ export async function appendImportedPlan(options = {}) {
|
|
|
2081
2482
|
throw error;
|
|
2082
2483
|
}
|
|
2083
2484
|
}
|
|
2485
|
+
const transactionDigest = importedPlanTransactionDigest(options);
|
|
2084
2486
|
return withLock(repoRoot, async () => {
|
|
2487
|
+
const recovered = await recoverCrossPlanImports(repoRoot, { expectedTransactionDigest: transactionDigest });
|
|
2488
|
+
const recoveredOwnTransaction = recovered.find((marker) => marker.transaction_digest === transactionDigest);
|
|
2489
|
+
if (recoveredOwnTransaction !== undefined) {
|
|
2490
|
+
return recoveredImportedPlanResult(repoRoot, recoveredOwnTransaction, options.now);
|
|
2491
|
+
}
|
|
2085
2492
|
// 1. Validate canonical manifest bytes and every current member before preparing output.
|
|
2086
2493
|
const store = await readTodoStore({ repoRoot, forWrite: true, now: options.now });
|
|
2087
2494
|
const expectedManifestDigest = store.manifest.manifest_digest;
|
|
@@ -2095,15 +2502,22 @@ export async function appendImportedPlan(options = {}) {
|
|
|
2095
2502
|
}
|
|
2096
2503
|
await protocolStage(options, 'plan_key_absent');
|
|
2097
2504
|
|
|
2098
|
-
const
|
|
2505
|
+
const prepared = prepareImportedArtifacts(
|
|
2099
2506
|
repoRoot, options, store.project_id, store.members.map(({ plan: memberPlan }) => ({ plan: memberPlan })),
|
|
2100
2507
|
);
|
|
2508
|
+
const { plan, genesis, events, snapshot, tasks } = prepared;
|
|
2101
2509
|
|
|
2102
2510
|
const base = `${STORE_ROOT_REF}/plans/${plan.plan_key}/${plan.plan_version}`;
|
|
2103
2511
|
const planRef = `${base}/plan.json`;
|
|
2104
2512
|
const journalRef = `${base}/journal/active.jsonl`;
|
|
2105
2513
|
const snapshotRef = `${base}/snapshot.json`;
|
|
2106
|
-
const
|
|
2514
|
+
const importedCrossPlan = prepareImportedCrossPlanDependencies({
|
|
2515
|
+
store, plan, tasks, dependencies: options.crossPlanDependencies, genesis, journalRef,
|
|
2516
|
+
});
|
|
2517
|
+
const hasCrossPlanDependencies = importedCrossPlan.artifacts.length > 0;
|
|
2518
|
+
const transactionRef = hasCrossPlanDependencies
|
|
2519
|
+
? `${CROSS_PLAN_IMPORT_TRANSACTIONS_REF}/${transactionDigest}`
|
|
2520
|
+
: `${STORE_ROOT_REF}/transactions/${plan.plan_key}-${plan.plan_version}`;
|
|
2107
2521
|
const transactionAbsolute = path.resolve(repoRoot, transactionRef);
|
|
2108
2522
|
const finalBaseAbsolute = path.resolve(repoRoot, base);
|
|
2109
2523
|
// These paths can only be leftovers from a transaction whose plan key is still absent.
|
|
@@ -2111,47 +2525,103 @@ export async function appendImportedPlan(options = {}) {
|
|
|
2111
2525
|
await rm(finalBaseAbsolute, { recursive: true, force: true });
|
|
2112
2526
|
|
|
2113
2527
|
// 3. All future member artifacts are durable while still outside manifest membership.
|
|
2114
|
-
const
|
|
2115
|
-
const
|
|
2116
|
-
const
|
|
2528
|
+
const stagedBase = hasCrossPlanDependencies ? path.join(transactionAbsolute, 'plan') : transactionAbsolute;
|
|
2529
|
+
const stagedPlan = path.join(stagedBase, 'plan.json');
|
|
2530
|
+
const stagedJournal = hasCrossPlanDependencies
|
|
2531
|
+
? path.join(stagedBase, 'journal', 'active.jsonl') : path.join(stagedBase, 'active.jsonl');
|
|
2532
|
+
const stagedSnapshot = path.join(stagedBase, 'snapshot.json');
|
|
2117
2533
|
await atomicWrite(stagedPlan, canonicalLine(plan));
|
|
2118
2534
|
await atomicWrite(stagedJournal, Buffer.concat(events.map(canonicalLine)));
|
|
2119
2535
|
await atomicWrite(stagedSnapshot, canonicalLine(snapshot));
|
|
2536
|
+
for (const [index, artifact] of importedCrossPlan.artifacts.entries()) {
|
|
2537
|
+
artifact.stagedAbsolute = path.join(transactionAbsolute, 'plan-scoped', `${index}.jsonl`);
|
|
2538
|
+
await atomicWrite(artifact.stagedAbsolute, artifact.finalBytes);
|
|
2539
|
+
}
|
|
2540
|
+
if (importedCrossPlan.artifacts.length > 0) {
|
|
2541
|
+
await protocolStage(options, 'cross_plan_dependencies_staged');
|
|
2542
|
+
}
|
|
2120
2543
|
await protocolStage(options, 'staging_fsynced');
|
|
2121
2544
|
|
|
2122
2545
|
// 4. Re-read canonical manifest bytes under the lock and compare the captured digest.
|
|
2123
2546
|
const currentManifest = await readArtifact(repoRoot, MANIFEST_REF, {
|
|
2124
2547
|
code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
|
|
2125
2548
|
});
|
|
2549
|
+
const originalManifest = structuredClone(currentManifest);
|
|
2126
2550
|
if (currentManifest.manifest_digest !== expectedManifestDigest) {
|
|
2127
2551
|
fail('STORE_WRITE_CONFLICT', 'manifest_digest_changed');
|
|
2128
2552
|
}
|
|
2129
2553
|
await protocolStage(options, 'manifest_cas_matched');
|
|
2130
2554
|
|
|
2131
2555
|
// 5. Pre-activation paths remain invisible until the final manifest rename.
|
|
2132
|
-
await mkdir(path.dirname(path.resolve(repoRoot, planRef)), { recursive: true });
|
|
2133
|
-
await mkdir(path.dirname(path.resolve(repoRoot, journalRef)), { recursive: true });
|
|
2134
|
-
await rename(stagedPlan, path.resolve(repoRoot, planRef));
|
|
2135
|
-
await rename(stagedJournal, path.resolve(repoRoot, journalRef));
|
|
2136
|
-
await rename(stagedSnapshot, path.resolve(repoRoot, snapshotRef));
|
|
2137
|
-
await fsyncDirectory(path.dirname(path.resolve(repoRoot, planRef)));
|
|
2138
|
-
await fsyncDirectory(path.dirname(path.resolve(repoRoot, journalRef)));
|
|
2139
|
-
await protocolStage(options, 'pre_activation_renamed');
|
|
2140
|
-
|
|
2141
2556
|
const descriptor = { plan_key: plan.plan_key, active_plan_version: plan.plan_version,
|
|
2142
2557
|
plan_ref: planRef, journal_ref: journalRef, snapshot_ref: snapshotRef,
|
|
2143
2558
|
topology_digest: plan.topology_digest, journal_head_digest: events.at(-1).event_digest,
|
|
2144
2559
|
...(currentManifest.schema === 'lattice.todo_manifest.v2'
|
|
2145
2560
|
? { active_revision_digest: plan.plan_digest } : {}) };
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2561
|
+
if (hasCrossPlanDependencies) {
|
|
2562
|
+
const transaction = importedCrossPlanTransaction({
|
|
2563
|
+
transactionDigest, descriptor, transactionRef, importedCrossPlan,
|
|
2564
|
+
});
|
|
2565
|
+
await atomicWrite(path.join(transactionAbsolute, 'transaction.json'), canonicalLine(transaction));
|
|
2566
|
+
await protocolStage(options, 'cross_plan_transaction_durable');
|
|
2567
|
+
}
|
|
2568
|
+
try {
|
|
2569
|
+
if (hasCrossPlanDependencies) {
|
|
2570
|
+
await mkdir(path.dirname(finalBaseAbsolute), { recursive: true });
|
|
2571
|
+
await rename(stagedBase, finalBaseAbsolute);
|
|
2572
|
+
await fsyncDirectory(path.dirname(finalBaseAbsolute));
|
|
2573
|
+
await protocolStage(options, 'plan_directory_renamed');
|
|
2574
|
+
} else {
|
|
2575
|
+
await mkdir(path.dirname(path.resolve(repoRoot, planRef)), { recursive: true });
|
|
2576
|
+
await mkdir(path.dirname(path.resolve(repoRoot, journalRef)), { recursive: true });
|
|
2577
|
+
await rename(stagedPlan, path.resolve(repoRoot, planRef));
|
|
2578
|
+
await rename(stagedJournal, path.resolve(repoRoot, journalRef));
|
|
2579
|
+
await rename(stagedSnapshot, path.resolve(repoRoot, snapshotRef));
|
|
2580
|
+
await fsyncDirectory(path.dirname(path.resolve(repoRoot, planRef)));
|
|
2581
|
+
await fsyncDirectory(path.dirname(path.resolve(repoRoot, journalRef)));
|
|
2582
|
+
}
|
|
2583
|
+
await protocolStage(options, 'pre_activation_renamed');
|
|
2584
|
+
|
|
2585
|
+
currentManifest.members.push(descriptor);
|
|
2586
|
+
currentManifest.members.sort((left, right) => left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0);
|
|
2587
|
+
currentManifest.manifest_digest = todoSelfDigest(currentManifest, 'manifest_digest');
|
|
2588
|
+
if (!validateTodoManifest(currentManifest)) throw new TypeError('import activation manifest invalid');
|
|
2589
|
+
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(currentManifest));
|
|
2590
|
+
await protocolStage(options, 'manifest_activated');
|
|
2591
|
+
|
|
2592
|
+
for (const artifact of importedCrossPlan.artifacts) {
|
|
2593
|
+
await atomicWrite(path.resolve(repoRoot, artifact.ref), artifact.finalBytes);
|
|
2594
|
+
}
|
|
2595
|
+
if (importedCrossPlan.artifacts.length > 0) {
|
|
2596
|
+
await protocolStage(options, 'cross_plan_dependencies_activated');
|
|
2597
|
+
}
|
|
2598
|
+
await rm(transactionAbsolute, { recursive: true, force: true });
|
|
2599
|
+
return {
|
|
2600
|
+
plan, genesis, events, snapshot, descriptor,
|
|
2601
|
+
crossPlanDependencies: importedCrossPlan.events,
|
|
2602
|
+
};
|
|
2603
|
+
} catch (error) {
|
|
2604
|
+
// The normal historical-import recovery contract intentionally leaves a
|
|
2605
|
+
// manifest-activated plan for retry. Once a cross-plan edge is part of
|
|
2606
|
+
// the request, roll back every activated artifact so plan and edge never
|
|
2607
|
+
// become independently durable.
|
|
2608
|
+
if (importedCrossPlan.artifacts.length > 0) {
|
|
2609
|
+
for (const artifact of [...importedCrossPlan.artifacts].reverse()) {
|
|
2610
|
+
const targetAbsolute = path.resolve(repoRoot, artifact.ref);
|
|
2611
|
+
if (artifact.originalBytes.length > 0) {
|
|
2612
|
+
await atomicWrite(targetAbsolute, artifact.originalBytes);
|
|
2613
|
+
} else {
|
|
2614
|
+
await rm(targetAbsolute, { force: true });
|
|
2615
|
+
}
|
|
2616
|
+
}
|
|
2617
|
+
await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(originalManifest));
|
|
2618
|
+
await rm(finalBaseAbsolute, { recursive: true, force: true });
|
|
2619
|
+
await rm(transactionAbsolute, { recursive: true, force: true });
|
|
2620
|
+
}
|
|
2621
|
+
throw error;
|
|
2622
|
+
}
|
|
2623
|
+
}, { recoverDeadLock: Array.isArray(options.crossPlanDependencies)
|
|
2624
|
+
&& options.crossPlanDependencies.length > 0, onProtocolStage: options.onProtocolStage });
|
|
2155
2625
|
}
|
|
2156
2626
|
|
|
2157
2627
|
export async function initializeTodoStore(options = {}) {
|
|
@@ -4598,21 +5068,28 @@ export async function writeTodoStructureSource(options = {}) {
|
|
|
4598
5068
|
expected: member.plan.topology_digest, actual: structureSet.topology_digest,
|
|
4599
5069
|
});
|
|
4600
5070
|
}
|
|
4601
|
-
const expectedTaskIds = member.tasks.filter(({ status }) => status !== 'done')
|
|
4602
|
-
.map(({ task_id: taskId }) => taskId);
|
|
4603
|
-
const coverage = explainTodoStructureSet(structureSet, { expectedTaskIds });
|
|
4604
|
-
if (!coverage.valid) {
|
|
4605
|
-
fail('STRUCTURE_BINDING_MISMATCH', coverage.reason, {
|
|
4606
|
-
path: coverage.path, ...(coverage.detail ?? {}),
|
|
4607
|
-
});
|
|
4608
|
-
}
|
|
4609
5071
|
const bindingRef = todoStructureBindingRef(structureSet.plan_key, structureSet.plan_version);
|
|
4610
|
-
|
|
5072
|
+
const binding = await readTodoStructureBinding({
|
|
5073
|
+
repoRoot, planKey: structureSet.plan_key, planVersion: structureSet.plan_version,
|
|
5074
|
+
});
|
|
5075
|
+
if (binding !== null && binding.structure_set_digest !== structureSet.structure_set_digest) {
|
|
4611
5076
|
fail('STRUCTURE_ALREADY_ENABLED', 'immutable_binding_exists', {
|
|
4612
5077
|
binding_ref: bindingRef,
|
|
4613
5078
|
next_action: 'revise_the_plan_or_run_authoritative_compile_with_the_existing_source',
|
|
4614
5079
|
});
|
|
4615
5080
|
}
|
|
5081
|
+
// binding前は現在の未完taskをexact coverageする。binding後は進行によりtaskがdoneへ
|
|
5082
|
+
// 移っていても、bindingと同じlogical sourceのcanonical bytes復旧だけを許す。
|
|
5083
|
+
if (binding === null) {
|
|
5084
|
+
const expectedTaskIds = member.tasks.filter(({ status }) => status !== 'done')
|
|
5085
|
+
.map(({ task_id: taskId }) => taskId);
|
|
5086
|
+
const coverage = explainTodoStructureSet(structureSet, { expectedTaskIds });
|
|
5087
|
+
if (!coverage.valid) {
|
|
5088
|
+
fail('STRUCTURE_BINDING_MISMATCH', coverage.reason, {
|
|
5089
|
+
path: coverage.path, ...(coverage.detail ?? {}),
|
|
5090
|
+
});
|
|
5091
|
+
}
|
|
5092
|
+
}
|
|
4616
5093
|
const ref = todoStructureSourceRef(structureSet.plan_key);
|
|
4617
5094
|
try {
|
|
4618
5095
|
await ensureSafeStoreDirectory(repoRoot, path.dirname(path.resolve(repoRoot, ref)));
|