@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.
@@ -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 (!text.endsWith('\n') || text.includes('\r') || text.startsWith('\uFEFF')
136
- || text.slice(0, -1).includes('\n')) fail(code, reason);
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(text.slice(0, -1)); } catch { fail(code, reason); }
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
- if (source.task.status === 'done') fail('DEPENDENCY_INVALID', 'dependency_source_terminal');
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 withLock(repoRoot, callback) {
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 open(lockRef, 'wx', 0o600); }
1511
- catch (error) { if (error?.code === 'EEXIST') fail('STORE_WRITE_CONFLICT', 'store_locked'); throw 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
- async function appendPlanScopedEvent({ repoRoot, member, input }) {
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 { plan, genesis, events, snapshot } = prepareImportedArtifacts(
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 transactionRef = `${STORE_ROOT_REF}/transactions/${plan.plan_key}-${plan.plan_version}`;
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 stagedPlan = path.join(transactionAbsolute, 'plan.json');
2115
- const stagedJournal = path.join(transactionAbsolute, 'active.jsonl');
2116
- const stagedSnapshot = path.join(transactionAbsolute, 'snapshot.json');
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
- currentManifest.members.push(descriptor);
2147
- currentManifest.members.sort((left, right) => left.plan_key < right.plan_key ? -1 : left.plan_key > right.plan_key ? 1 : 0);
2148
- currentManifest.manifest_digest = todoSelfDigest(currentManifest, 'manifest_digest');
2149
- if (!validateTodoManifest(currentManifest)) throw new TypeError('import activation manifest invalid');
2150
- await atomicWrite(path.resolve(repoRoot, MANIFEST_REF), canonicalLine(currentManifest));
2151
- await protocolStage(options, 'manifest_activated');
2152
- await rm(transactionAbsolute, { recursive: true, force: true });
2153
- return { plan, genesis, events, snapshot, descriptor };
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
- if (await exactFileOrNull(path.resolve(repoRoot, bindingRef)) !== null) {
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)));