evolcore 0.0.6 → 0.0.8

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.
@@ -1,4 +1,5 @@
1
1
  import crypto from 'crypto';
2
+ import { execFileSync } from 'child_process';
2
3
  import fs from 'fs';
3
4
  import path from 'path';
4
5
  import { formatChannelSessionKey, scanLegacyChatDirs, scanMetaFiles, scanNestedAgentChatDirs, scanNonCanonicalAgentChatDirs, } from './session/session-fs-store.js';
@@ -18,6 +19,14 @@ function manifestPath(root, id) {
18
19
  function migrationStagingDir(root, id) {
19
20
  return path.join(migrationDir(root), `${id}.staging`);
20
21
  }
22
+ function finalizationDir(root) {
23
+ // Keep receipts out of migrationDir's top level: readers there intentionally
24
+ // treat every *.json file as a migration manifest.
25
+ return path.join(migrationDir(root), 'finalized');
26
+ }
27
+ function finalizationPath(root, id) {
28
+ return path.join(finalizationDir(root), `${id}.json`);
29
+ }
21
30
  function migrationPlanSignature(operations) {
22
31
  const topology = operations.map(operation => ({
23
32
  kind: operation.kind,
@@ -45,6 +54,32 @@ function operationTopologyKey(operation) {
45
54
  sessionChannelKey: operation.sessionChannelKey,
46
55
  });
47
56
  }
57
+ /**
58
+ * Manifests written before allowExistingDestinationMerge was introduced still
59
+ * need safe recovery. Re-derive that narrow permission from their immutable
60
+ * source topology and the current owner-evidenced control identity; no other
61
+ * destination conflict is relaxed.
62
+ */
63
+ function restoreLegacyControlMergeEligibility(manifest) {
64
+ const control = controlIdentity(manifest.root);
65
+ if (control.aids.size !== 1 || control.legacyAids.size === 0)
66
+ return;
67
+ const currentAid = control.aids.values().next().value;
68
+ const legacySessionsRoot = path.join(manifest.root, 'data', 'sessions', 'aun');
69
+ const expectedChannelKey = `aun#${currentAid}#main`;
70
+ const destinationRoot = path.join(manifest.root, 'agents', currentAid, 'sessions');
71
+ for (const operation of manifest.operations) {
72
+ if (operation.allowExistingDestinationMerge || operation.transform !== 'normalize-session-channel-key')
73
+ continue;
74
+ const relative = path.relative(legacySessionsRoot, operation.source);
75
+ const [legacyAid] = relative.split(path.sep);
76
+ if (!legacyAid || relative.startsWith(`..${path.sep}`) || !control.legacyAids.has(legacyAid))
77
+ continue;
78
+ if (operation.sessionChannelKey !== expectedChannelKey || !operation.destination || !isWithin(destinationRoot, operation.destination))
79
+ continue;
80
+ operation.allowExistingDestinationMerge = true;
81
+ }
82
+ }
48
83
  function sessionDestination(root, aid, channelKey, channelId) {
49
84
  const channelType = channelKey === 'daemon' ? 'daemon' : channelKey.split('#', 1)[0]?.trim();
50
85
  if (!channelType)
@@ -201,7 +236,27 @@ function controlIdentity(root) {
201
236
  owners.add(owner);
202
237
  }
203
238
  }
204
- return { aids: new Set(aid ? [aid] : []), owners };
239
+ // A previous `ecNNNNN.<domain>` control identity has no Agent config, so
240
+ // its legacy AUN session root cannot be attributed through configuredAids.
241
+ // It is safe to recognize only when that root contains at least one current
242
+ // daemon owner: the whole root then belongs to the same process control
243
+ // plane, including sessions with its non-owner Agents.
244
+ const legacyAids = new Set();
245
+ if (aid && owners.size > 0) {
246
+ try {
247
+ for (const entry of fs.readdirSync(path.join(root, 'data', 'sessions', 'aun'), { withFileTypes: true })) {
248
+ if (!entry.isDirectory() || entry.name === aid || !/^ec\d{5}\.[^.]+(?:\..+)+$/i.test(entry.name))
249
+ continue;
250
+ const sessionRoot = path.join(root, 'data', 'sessions', 'aun', entry.name);
251
+ const hasCurrentOwner = fs.readdirSync(sessionRoot, { withFileTypes: true })
252
+ .some(child => child.isDirectory() && owners.has(child.name));
253
+ if (hasCurrentOwner)
254
+ legacyAids.add(entry.name);
255
+ }
256
+ }
257
+ catch { }
258
+ }
259
+ return { aids: new Set(aid ? [aid] : []), owners, legacyAids };
205
260
  }
206
261
  function inferUnknownAunOwner(chat, control) {
207
262
  if (chat.channelType !== 'aun' || chat.selfAID !== '_unknown' || control.aids.size !== 1)
@@ -213,6 +268,11 @@ function inferUnknownAunOwner(chat, control) {
213
268
  return undefined;
214
269
  return control.aids.values().next().value;
215
270
  }
271
+ function remapLegacyControlAunOwner(aid, control) {
272
+ if (control.aids.size !== 1 || !control.legacyAids.has(aid))
273
+ return undefined;
274
+ return control.aids.values().next().value;
275
+ }
216
276
  function isOwnedTriggerDefinition(definition, aid, triggerId) {
217
277
  return !!definition
218
278
  && typeof definition === 'object'
@@ -416,6 +476,7 @@ function addMergeCopy(operations, id, source, destination, staging, exclude, tra
416
476
  export function planDataMigration(root) {
417
477
  const operations = [];
418
478
  const warnings = [];
479
+ const supersededSourceHashes = {};
419
480
  const id = `data-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
420
481
  const stagingDir = migrationStagingDir(root, id);
421
482
  const instances = listChannelInstances(root);
@@ -434,6 +495,12 @@ export function planDataMigration(root) {
434
495
  chats.add(channelId);
435
496
  legacyFeishuChats.set(channelKey, chats);
436
497
  };
498
+ const rememberSupersededSource = (source) => {
499
+ try {
500
+ supersededSourceHashes[source] = treeHash(source);
501
+ }
502
+ catch { }
503
+ };
437
504
  const addFeishuSeen = (source, channelKey, channelId, prefix) => {
438
505
  if (!fs.existsSync(source))
439
506
  return;
@@ -567,14 +634,21 @@ export function planDataMigration(root) {
567
634
  const sessionId = nextId('trigger-session');
568
635
  addSessionCopy(operations, sessionId, chat.dirPath, destination, stageFor(sessionId));
569
636
  }
637
+ else {
638
+ rememberSupersededSource(chat.dirPath);
639
+ }
570
640
  continue;
571
641
  }
572
642
  const fromChannel = parseChannelKey(record?.channel ?? record?.metadata?.channelKey);
573
- const aid = chat.selfAID && chat.selfAID !== '_unknown'
643
+ const detectedAid = chat.selfAID && chat.selfAID !== '_unknown'
574
644
  ? chat.selfAID
575
645
  : (typeof record?.selfAID === 'string' && record.selfAID !== '_unknown'
576
646
  ? record.selfAID
577
647
  : fromChannel?.aid ?? inferUnknownAunOwner(chat, control));
648
+ const remappedControlAid = detectedAid && !aids.has(detectedAid) && !control.aids.has(detectedAid)
649
+ ? remapLegacyControlAunOwner(detectedAid, control)
650
+ : undefined;
651
+ const aid = remappedControlAid ?? detectedAid;
578
652
  if (!aid || aid === '_unknown') {
579
653
  warnings.push(`Unresolved session owner retained: ${chat.dirPath}`);
580
654
  continue;
@@ -599,6 +673,18 @@ export function planDataMigration(root) {
599
673
  if (!liveSessionDestinations.has(destination) && !nestedSessionDestinations.has(destination)) {
600
674
  const sessionId = nextId('session');
601
675
  addSessionCopy(operations, sessionId, chat.dirPath, destination, stageFor(sessionId), [SESSION_SEEN_FILE], 'normalize-session-channel-key', channelKey);
676
+ if (remappedControlAid) {
677
+ const operation = operations.find(item => item.destination === destination);
678
+ if (operation)
679
+ operation.allowExistingDestinationMerge = true;
680
+ }
681
+ }
682
+ else {
683
+ // This retained data/sessions copy is older than an Agent-owned source
684
+ // selected for the same canonical destination. It must not be replayed,
685
+ // but --apply can archive and remove it as a verified
686
+ // superseded source instead of exposing it as a fresh migration again.
687
+ rememberSupersededSource(chat.dirPath);
602
688
  }
603
689
  if (chat.channelType === 'feishu') {
604
690
  rememberFeishuChat(channelKey, chat.channelId);
@@ -772,6 +858,7 @@ export function planDataMigration(root) {
772
858
  state: 'planned',
773
859
  operations,
774
860
  warnings,
861
+ ...(Object.keys(supersededSourceHashes).length ? { supersededSourceHashes } : {}),
775
862
  planSignature: migrationPlanSignature(operations),
776
863
  };
777
864
  }
@@ -866,6 +953,84 @@ function reconcileLiveTriggerDestination(operation) {
866
953
  atomicWriteText(targetPath, transformed);
867
954
  verifyLiveTriggerDestination(operation);
868
955
  }
956
+ function sessionIdentity(directory) {
957
+ const active = readJson(path.join(directory, 'active.json'));
958
+ if (!active || typeof active !== 'object')
959
+ return undefined;
960
+ const record = active;
961
+ const metadata = record.metadata && typeof record.metadata === 'object'
962
+ ? record.metadata
963
+ : undefined;
964
+ return {
965
+ channelType: typeof record.channelType === 'string' ? record.channelType : undefined,
966
+ channelId: typeof record.channelId === 'string' ? record.channelId : undefined,
967
+ selfAID: typeof record.selfAID === 'string' ? record.selfAID : undefined,
968
+ peerId: typeof metadata?.peerId === 'string' ? metadata.peerId : undefined,
969
+ };
970
+ }
971
+ function relativeSessionFiles(directory, relative = '') {
972
+ const files = [];
973
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
974
+ if (!entry.isDirectory() && !entry.isFile())
975
+ continue;
976
+ const child = relative ? path.join(relative, entry.name) : entry.name;
977
+ const fullPath = path.join(directory, entry.name);
978
+ if (entry.isDirectory())
979
+ files.push(...relativeSessionFiles(fullPath, child));
980
+ else
981
+ files.push(child);
982
+ }
983
+ return files;
984
+ }
985
+ /**
986
+ * An existing canonical session may have been written by a live runtime while
987
+ * the legacy copy still contains older metadata. Merge only when both active
988
+ * records identify the same session; unrelated destination collisions remain
989
+ * hard errors and require operator review.
990
+ */
991
+ function canMergeSessionDestination(operation) {
992
+ if (operation.transform !== 'normalize-session-channel-key' || !operation.destination)
993
+ return false;
994
+ const source = sessionIdentity(operation.source);
995
+ const destination = sessionIdentity(operation.destination);
996
+ if (!source || !destination)
997
+ return false;
998
+ for (const key of ['channelType', 'channelId', 'selfAID', 'peerId']) {
999
+ if (source[key] !== undefined && destination[key] !== undefined && source[key] !== destination[key])
1000
+ return false;
1001
+ }
1002
+ if (!(source.channelId || destination.channelId))
1003
+ return false;
1004
+ // Do not turn an arbitrary directory collision into a merge. Session logs
1005
+ // are appendable, and active.json is metadata; an unknown destination-only
1006
+ // payload still indicates that the target may belong to another layout.
1007
+ const sourceFiles = new Set(relativeSessionFiles(operation.source));
1008
+ const destinationFiles = new Set(relativeSessionFiles(operation.destination));
1009
+ const excluded = new Set(operation.exclude ?? []);
1010
+ for (const relative of new Set([...sourceFiles, ...destinationFiles])) {
1011
+ if (excluded.has(relative))
1012
+ continue;
1013
+ const sourceExists = sourceFiles.has(relative);
1014
+ const destinationExists = destinationFiles.has(relative);
1015
+ if (!sourceExists)
1016
+ return false;
1017
+ if (!destinationExists) {
1018
+ if (relative !== 'active.json' && !relative.endsWith('.jsonl'))
1019
+ return false;
1020
+ continue;
1021
+ }
1022
+ if (relative === 'active.json' || relative.endsWith('.jsonl'))
1023
+ continue;
1024
+ try {
1025
+ if (!fs.readFileSync(path.join(operation.source, relative)).equals(fs.readFileSync(path.join(operation.destination, relative))))
1026
+ return false;
1027
+ }
1028
+ catch {
1029
+ return false;
1030
+ }
1031
+ }
1032
+ return true;
1033
+ }
869
1034
  function normalizeSessionRecordChannelKeys(record, channelKey, forceCurrentRoute = false) {
870
1035
  if (!record || typeof record !== 'object')
871
1036
  return false;
@@ -984,6 +1149,268 @@ function writeManifest(manifest) {
984
1149
  fs.writeFileSync(temporary, JSON.stringify(manifest, null, 2) + '\n');
985
1150
  fs.renameSync(temporary, target);
986
1151
  }
1152
+ function writeFinalization(finalization) {
1153
+ fs.mkdirSync(finalizationDir(finalization.root), { recursive: true });
1154
+ const target = finalizationPath(finalization.root, finalization.migrationId);
1155
+ const temporary = `${target}.tmp`;
1156
+ fs.writeFileSync(temporary, JSON.stringify(finalization, null, 2) + '\n');
1157
+ fs.renameSync(temporary, target);
1158
+ }
1159
+ function isWithin(parent, candidate) {
1160
+ const relative = path.relative(parent, candidate);
1161
+ return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));
1162
+ }
1163
+ function rootRelative(root, target) {
1164
+ const relative = path.relative(root, target);
1165
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
1166
+ throw new Error(`migration source is outside EvolCore root: ${target}`);
1167
+ }
1168
+ return relative;
1169
+ }
1170
+ function completedCopyProof(root, operation) {
1171
+ try {
1172
+ const manifests = fs.readdirSync(migrationDir(root), { withFileTypes: true })
1173
+ .filter(entry => entry.isFile() && entry.name.endsWith('.json'))
1174
+ .map(entry => ({
1175
+ manifest: readJson(path.join(migrationDir(root), entry.name)),
1176
+ mtime: fs.statSync(path.join(migrationDir(root), entry.name)).mtimeMs,
1177
+ }))
1178
+ .sort((left, right) => right.mtime - left.mtime);
1179
+ for (const { manifest } of manifests) {
1180
+ if (manifest?.state !== 'completed')
1181
+ continue;
1182
+ const proof = manifest.operations?.find(candidate => operationTopologyKey(candidate) === operationTopologyKey(operation)
1183
+ && (candidate.status === 'committed' || candidate.status === 'skipped')
1184
+ && typeof candidate.sourceHash === 'string');
1185
+ if (proof)
1186
+ return proof;
1187
+ }
1188
+ }
1189
+ catch { }
1190
+ return undefined;
1191
+ }
1192
+ function assertFinalizableOperation(root, operation) {
1193
+ const checkSource = (source, expectedHash) => {
1194
+ if (!expectedHash)
1195
+ throw new Error(`migration source has no verified hash: ${source}`);
1196
+ if (!fs.existsSync(source))
1197
+ throw new Error(`migration source is missing: ${source}`);
1198
+ if (treeHash(source, operation.exclude) !== expectedHash) {
1199
+ throw new Error(`migration source changed after verification: ${source}`);
1200
+ }
1201
+ };
1202
+ if (operation.status === 'acknowledged') {
1203
+ // Acknowledged operations deliberately do not copy a live destination a
1204
+ // second time. Resolve the earlier completed manifest that proved the
1205
+ // identical source and topology before making that source removable.
1206
+ if (operation.kind !== 'copy')
1207
+ throw new Error(`unsupported acknowledged migration operation: ${operation.id}`);
1208
+ const proof = completedCopyProof(root, operation);
1209
+ checkSource(operation.source, proof?.sourceHash);
1210
+ return;
1211
+ }
1212
+ if (operation.kind === 'merge-copy') {
1213
+ for (const source of operationSources(operation)) {
1214
+ checkSource(source, operation.sourceHashes?.[source]);
1215
+ }
1216
+ return;
1217
+ }
1218
+ checkSource(operation.source, operation.sourceHash);
1219
+ }
1220
+ function finalizationSourcePaths(root, manifest) {
1221
+ if (manifest.state !== 'completed') {
1222
+ throw new Error(`only a completed migration can be finalized: ${manifest.id}`);
1223
+ }
1224
+ const allSources = new Set();
1225
+ for (const operation of manifest.operations) {
1226
+ if (!['committed', 'skipped', 'acknowledged'].includes(operation.status)) {
1227
+ throw new Error(`migration has an unfinished operation: ${operation.id}`);
1228
+ }
1229
+ assertFinalizableOperation(root, operation);
1230
+ for (const source of operationSources(operation)) {
1231
+ rootRelative(root, source);
1232
+ allSources.add(path.resolve(source));
1233
+ }
1234
+ }
1235
+ for (const [source, expectedHash] of Object.entries(manifest.supersededSourceHashes ?? {})) {
1236
+ const absolute = path.resolve(source);
1237
+ rootRelative(root, absolute);
1238
+ if (!fs.existsSync(absolute))
1239
+ throw new Error(`superseded migration source is missing: ${absolute}`);
1240
+ if (treeHash(absolute) !== expectedHash) {
1241
+ throw new Error(`superseded migration source changed after planning: ${absolute}`);
1242
+ }
1243
+ allSources.add(absolute);
1244
+ }
1245
+ // A parent copy may intentionally omit a payload (currently Feishu dedup).
1246
+ // Do not let purging that parent remove an omitted file unless an independent
1247
+ // completed operation has verified that payload too.
1248
+ for (const operation of manifest.operations) {
1249
+ for (const excluded of operation.exclude ?? []) {
1250
+ const excludedPath = path.resolve(operation.source, excluded);
1251
+ if (!fs.existsSync(excludedPath))
1252
+ continue;
1253
+ const independentlyVerified = [...allSources].some(source => isWithin(source, excludedPath));
1254
+ if (!independentlyVerified) {
1255
+ throw new Error(`migration excluded payload has no verified removal path: ${excludedPath}`);
1256
+ }
1257
+ }
1258
+ }
1259
+ const roots = [...allSources]
1260
+ .filter(source => ![...allSources].some(other => other !== source && isWithin(other, source)))
1261
+ .sort((left, right) => left.localeCompare(right));
1262
+ const destinations = manifest.operations.flatMap(operation => [
1263
+ ...(operation.destination ? [operation.destination] : []),
1264
+ ...(operation.destinations ?? []),
1265
+ ]);
1266
+ for (const source of roots) {
1267
+ if (destinations.some(destination => isWithin(source, destination))) {
1268
+ throw new Error(`migration destination is inside a legacy source and cannot be purged: ${source}`);
1269
+ }
1270
+ }
1271
+ return roots;
1272
+ }
1273
+ function archiveHash(filePath) {
1274
+ return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
1275
+ }
1276
+ function verifyArchiveContains(archivePath, sourcePaths) {
1277
+ const tar = process.platform === 'win32' ? 'tar.exe' : 'tar';
1278
+ const normalizeArchivePath = (value) => value
1279
+ .trim()
1280
+ .replace(/\\/g, '/')
1281
+ .replace(/\/+$/, '');
1282
+ let entries;
1283
+ try {
1284
+ entries = new Set(execFileSync(tar, ['-tzf', archivePath], { encoding: 'utf8' })
1285
+ .split('\n')
1286
+ .map(normalizeArchivePath)
1287
+ .filter(Boolean));
1288
+ }
1289
+ catch (error) {
1290
+ throw new Error(`cannot verify migration archive: ${error instanceof Error ? error.message : String(error)}`);
1291
+ }
1292
+ for (const source of sourcePaths) {
1293
+ if (!entries.has(normalizeArchivePath(source))) {
1294
+ throw new Error(`migration archive is missing source: ${source}`);
1295
+ }
1296
+ }
1297
+ }
1298
+ function pruneEmptyLegacyParents(root, sourcePaths) {
1299
+ for (const source of sourcePaths) {
1300
+ let current = path.dirname(source);
1301
+ while (current !== root && isWithin(root, current)) {
1302
+ const relative = path.relative(root, current);
1303
+ // Never remove the two top-level runtime roots themselves.
1304
+ if (relative === 'data' || relative === 'agents')
1305
+ break;
1306
+ let entries;
1307
+ try {
1308
+ entries = fs.readdirSync(current);
1309
+ }
1310
+ catch {
1311
+ break;
1312
+ }
1313
+ if (entries.length > 0)
1314
+ break;
1315
+ fs.rmdirSync(current);
1316
+ current = path.dirname(current);
1317
+ }
1318
+ }
1319
+ }
1320
+ function pruneEmptyLegacyDirectories(root) {
1321
+ // Some legacy roots contain empty per-Agent directories that never produced
1322
+ // a migration operation. Remove only empty children of known legacy roots;
1323
+ // unresolved directories containing data are left untouched.
1324
+ const roots = [path.join(root, 'data', 'triggers')];
1325
+ const pruneChildren = (directory) => {
1326
+ let entries;
1327
+ try {
1328
+ entries = fs.readdirSync(directory, { withFileTypes: true });
1329
+ }
1330
+ catch {
1331
+ return;
1332
+ }
1333
+ for (const entry of entries) {
1334
+ if (!entry.isDirectory())
1335
+ continue;
1336
+ const child = path.join(directory, entry.name);
1337
+ pruneChildren(child);
1338
+ try {
1339
+ if (fs.readdirSync(child).length === 0)
1340
+ fs.rmdirSync(child);
1341
+ }
1342
+ catch { }
1343
+ }
1344
+ };
1345
+ for (const directory of roots)
1346
+ pruneChildren(directory);
1347
+ }
1348
+ function removeMigrationStaging(manifest) {
1349
+ try {
1350
+ fs.rmSync(migrationStagingDir(manifest.root, manifest.id), { recursive: true, force: true });
1351
+ }
1352
+ catch { }
1353
+ }
1354
+ /**
1355
+ * Archive and remove only source paths already proven by a completed migration.
1356
+ * The CLI invokes this as the final phase of --apply. An archive is
1357
+ * verified before any source is removed.
1358
+ */
1359
+ export function finalizeDataMigration(root, manifest) {
1360
+ if (path.resolve(manifest.root) !== path.resolve(root)) {
1361
+ throw new Error(`migration root does not match requested root: ${manifest.id}`);
1362
+ }
1363
+ const existing = readJson(finalizationPath(root, manifest.id));
1364
+ if (existing?.schemaVersion === 1 && existing.migrationId === manifest.id) {
1365
+ if (!fs.existsSync(existing.archivePath) || archiveHash(existing.archivePath) !== existing.archiveSha256) {
1366
+ throw new Error(`existing migration archive is missing or corrupt: ${existing.archivePath}`);
1367
+ }
1368
+ pruneEmptyLegacyDirectories(root);
1369
+ removeMigrationStaging(manifest);
1370
+ return existing;
1371
+ }
1372
+ const absoluteSources = finalizationSourcePaths(root, manifest);
1373
+ const sourcePaths = absoluteSources.map(source => rootRelative(root, source));
1374
+ const archivePath = path.join(root, 'backups', 'data-migration', `${manifest.id}-legacy-sources.tar.gz`);
1375
+ if (fs.existsSync(archivePath)) {
1376
+ throw new Error(`migration archive already exists without a finalization receipt: ${archivePath}`);
1377
+ }
1378
+ fs.mkdirSync(path.dirname(archivePath), { recursive: true });
1379
+ const temporary = `${archivePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
1380
+ const tar = process.platform === 'win32' ? 'tar.exe' : 'tar';
1381
+ try {
1382
+ execFileSync(tar, ['-czf', temporary, ...sourcePaths], { cwd: root, stdio: 'pipe' });
1383
+ verifyArchiveContains(temporary, sourcePaths);
1384
+ fs.renameSync(temporary, archivePath);
1385
+ }
1386
+ catch (error) {
1387
+ try {
1388
+ fs.rmSync(temporary, { force: true });
1389
+ }
1390
+ catch { }
1391
+ throw new Error(`cannot create migration archive: ${error instanceof Error ? error.message : String(error)}`);
1392
+ }
1393
+ // Recheck immediately before deletion in case another writer appeared in
1394
+ // the maintenance window after the archive was created.
1395
+ for (const operation of manifest.operations)
1396
+ assertFinalizableOperation(root, operation);
1397
+ for (const source of absoluteSources)
1398
+ fs.rmSync(source, { recursive: true, force: false });
1399
+ pruneEmptyLegacyParents(root, absoluteSources);
1400
+ pruneEmptyLegacyDirectories(root);
1401
+ const finalization = {
1402
+ schemaVersion: 1,
1403
+ migrationId: manifest.id,
1404
+ root,
1405
+ finalizedAt: new Date().toISOString(),
1406
+ archivePath,
1407
+ archiveSha256: archiveHash(archivePath),
1408
+ sourcePaths,
1409
+ };
1410
+ writeFinalization(finalization);
1411
+ removeMigrationStaging(manifest);
1412
+ return finalization;
1413
+ }
987
1414
  function atomicWriteText(target, content) {
988
1415
  const temporary = `${target}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
989
1416
  fs.writeFileSync(temporary, content);
@@ -1109,6 +1536,22 @@ function executeCopy(operation, checkpoint) {
1109
1536
  checkpoint();
1110
1537
  return;
1111
1538
  }
1539
+ if (operation.transform === 'normalize-session-channel-key'
1540
+ && (operation.allowExistingDestinationMerge || canMergeSessionDestination(operation))) {
1541
+ // A control-AID rotation can legitimately converge an old transcript
1542
+ // onto a newer live transcript for the same peer. The same checked
1543
+ // transaction also handles a live destination that differs only in
1544
+ // historical active-session metadata.
1545
+ operation.allowExistingDestinationMerge = true;
1546
+ operation.kind = 'merge-copy';
1547
+ operation.expectedHash = undefined;
1548
+ operation.stagedHash = undefined;
1549
+ operation.destinationHash = undefined;
1550
+ operation.error = undefined;
1551
+ operation.verificationError = undefined;
1552
+ executeMergeCopy(operation, checkpoint);
1553
+ return;
1554
+ }
1112
1555
  throw new Error(`destination conflict: ${operation.destination}`);
1113
1556
  }
1114
1557
  if (!operation.staging)
@@ -1180,7 +1623,9 @@ function copyTree(source, destination) {
1180
1623
  copyTree(path.join(source, entry.name), path.join(destination, entry.name));
1181
1624
  }
1182
1625
  }
1183
- function mergeTree(source, destination, stagingRoot, relative = '') {
1626
+ function mergeTree(source, destination, stagingRoot, relative = '', exclude = []) {
1627
+ if (relative && exclude.includes(relative))
1628
+ return;
1184
1629
  const stat = fs.statSync(source);
1185
1630
  if (stat.isFile()) {
1186
1631
  if (!fs.existsSync(destination)) {
@@ -1206,20 +1651,26 @@ function mergeTree(source, destination, stagingRoot, relative = '') {
1206
1651
  if (!entry.isDirectory() && !entry.isFile())
1207
1652
  continue;
1208
1653
  const childRelative = relative ? path.join(relative, entry.name) : entry.name;
1209
- mergeTree(path.join(source, entry.name), path.join(destination, entry.name), stagingRoot, childRelative);
1654
+ mergeTree(path.join(source, entry.name), path.join(destination, entry.name), stagingRoot, childRelative, exclude);
1210
1655
  }
1211
1656
  }
1212
1657
  function executeMergeCopy(operation, checkpoint) {
1213
1658
  if (!operation.destination || !operation.staging)
1214
1659
  throw new Error('merge-copy operation is incomplete');
1215
1660
  const sources = trackMergeSources(operation);
1216
- const sourceOutputHashes = sources.map(source => treeHash(source, operation.exclude, '', operation.transform, operation.sessionChannelKey));
1217
- // The legacy copies remain authoritative, so a partially built stage may
1218
- // always be recreated safely. No destination is touched before validation.
1661
+ const destinationBeforeHash = fs.existsSync(operation.destination)
1662
+ ? treeHash(operation.destination)
1663
+ : undefined;
1664
+ // A partially built stage may always be recreated safely. Seed it from the
1665
+ // current destination first, then merge legacy sources into that snapshot;
1666
+ // JSONL is de-duplicated while conflicting singleton files stay recoverable
1667
+ // below _legacy-conflicts. No destination is touched before validation.
1219
1668
  if (fs.existsSync(operation.staging))
1220
1669
  fs.rmSync(operation.staging, { recursive: true, force: true });
1670
+ if (destinationBeforeHash !== undefined)
1671
+ copyTree(operation.destination, operation.staging);
1221
1672
  for (const source of sources)
1222
- mergeTree(source, operation.staging, operation.staging);
1673
+ mergeTree(source, operation.staging, operation.staging, '', operation.exclude);
1223
1674
  transformTree(operation.staging, operation.transform, operation.sessionChannelKey);
1224
1675
  operation.status = 'staged';
1225
1676
  operation.stagedHash = treeHash(operation.staging);
@@ -1234,8 +1685,8 @@ function executeMergeCopy(operation, checkpoint) {
1234
1685
  checkpoint();
1235
1686
  if (fs.existsSync(operation.destination)) {
1236
1687
  const destinationHash = treeHash(operation.destination);
1237
- if (destinationHash !== expectedHash && !sourceOutputHashes.includes(destinationHash)) {
1238
- throw new Error(`destination conflict: ${operation.destination}`);
1688
+ if (destinationHash !== destinationBeforeHash) {
1689
+ throw new Error(`destination changed during migration: ${operation.destination}`);
1239
1690
  }
1240
1691
  if (destinationHash !== expectedHash)
1241
1692
  copyTree(operation.staging, operation.destination);
@@ -1256,6 +1707,7 @@ function executeMergeCopy(operation, checkpoint) {
1256
1707
  * later items still run and a subsequent `--resume` only retries failures.
1257
1708
  */
1258
1709
  export function applyDataMigration(manifest) {
1710
+ restoreLegacyControlMergeEligibility(manifest);
1259
1711
  manifest.state = 'running';
1260
1712
  writeManifest(manifest);
1261
1713
  for (const operation of manifest.operations) {
@@ -1297,7 +1749,11 @@ export function readDataMigration(root, id) {
1297
1749
  export function inspectDataMigrationRequirement(root) {
1298
1750
  const plan = planDataMigration(root);
1299
1751
  const planSignature = plan.planSignature ?? migrationPlanSignature(plan.operations);
1300
- if (plan.operations.length === 0) {
1752
+ // A plan may retain already-proven source copies while a later plan grows.
1753
+ // Only operations that still require a copy/merge/audit warrant a startup
1754
+ // gate; acknowledged entries are historical evidence, not pending work.
1755
+ const pendingOperations = plan.operations.filter(operation => operation.status !== 'acknowledged');
1756
+ if (pendingOperations.length === 0) {
1301
1757
  return { required: false, operationCount: 0, planSignature };
1302
1758
  }
1303
1759
  let completedMigrationId;
@@ -1316,12 +1772,27 @@ export function inspectDataMigrationRequirement(root) {
1316
1772
  catch { }
1317
1773
  return {
1318
1774
  required: !completedMigrationId,
1319
- operationCount: plan.operations.length,
1775
+ operationCount: completedMigrationId ? 0 : pendingOperations.length,
1320
1776
  planSignature,
1321
1777
  completedMigrationId,
1322
1778
  };
1323
1779
  }
1780
+ function finalizedSources(manifest) {
1781
+ const receipt = readJson(finalizationPath(manifest.root, manifest.id));
1782
+ if (!receipt || receipt.schemaVersion !== 1 || receipt.migrationId !== manifest.id)
1783
+ return new Set();
1784
+ try {
1785
+ if (!fs.existsSync(receipt.archivePath) || archiveHash(receipt.archivePath) !== receipt.archiveSha256)
1786
+ return new Set();
1787
+ }
1788
+ catch {
1789
+ return new Set();
1790
+ }
1791
+ return new Set(receipt.sourcePaths.map(source => path.resolve(manifest.root, source)));
1792
+ }
1324
1793
  export function verifyDataMigration(manifest, { strictTargetContent = true } = {}) {
1794
+ const archivedSources = finalizedSources(manifest);
1795
+ const sourceArchived = (source) => archivedSources.has(path.resolve(source));
1325
1796
  for (const operation of manifest.operations) {
1326
1797
  if (operation.status === 'acknowledged')
1327
1798
  continue;
@@ -1342,10 +1813,15 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
1342
1813
  }
1343
1814
  if (operation.kind === 'contact-audit-partition') {
1344
1815
  try {
1345
- const sourceHash = treeHash(operation.source);
1346
- if (operation.sourceHash && operation.sourceHash !== sourceHash)
1347
- throw new Error('source changed since migration began');
1348
- operation.sourceHash = sourceHash;
1816
+ if (fs.existsSync(operation.source)) {
1817
+ const sourceHash = treeHash(operation.source);
1818
+ if (operation.sourceHash && operation.sourceHash !== sourceHash)
1819
+ throw new Error('source changed since migration began');
1820
+ operation.sourceHash = sourceHash;
1821
+ }
1822
+ else if (!sourceArchived(operation.source)) {
1823
+ throw new Error(`source missing: ${operation.source}`);
1824
+ }
1349
1825
  for (const destination of operation.destinations ?? []) {
1350
1826
  if (!fs.existsSync(destination))
1351
1827
  throw new Error(`destination missing: ${destination}`);
@@ -1371,7 +1847,14 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
1371
1847
  try {
1372
1848
  if (!operation.destination || !fs.existsSync(operation.destination))
1373
1849
  throw new Error('destination missing');
1374
- trackMergeSources(operation);
1850
+ const sources = operationSources(operation);
1851
+ const liveSources = sources.filter(source => fs.existsSync(source));
1852
+ const removedSources = sources.filter(source => !fs.existsSync(source));
1853
+ if (removedSources.some(source => !sourceArchived(source))) {
1854
+ throw new Error(`source missing: ${removedSources.find(source => !sourceArchived(source))}`);
1855
+ }
1856
+ if (liveSources.length > 0)
1857
+ trackMergeSources(operation);
1375
1858
  validateTree(operation.destination);
1376
1859
  const destinationHash = treeHash(operation.destination);
1377
1860
  operation.destinationHash = destinationHash;
@@ -1398,16 +1881,26 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
1398
1881
  }
1399
1882
  try {
1400
1883
  validateTree(operation.destination);
1401
- const sourceHash = treeHash(operation.source, operation.exclude);
1402
- if (operation.sourceHash && operation.sourceHash !== sourceHash) {
1403
- throw new Error('source changed since migration began');
1884
+ let sourceHash = operation.sourceHash;
1885
+ let expectedHash = operation.expectedHash;
1886
+ if (fs.existsSync(operation.source)) {
1887
+ sourceHash = treeHash(operation.source, operation.exclude);
1888
+ if (operation.sourceHash && operation.sourceHash !== sourceHash) {
1889
+ throw new Error('source changed since migration began');
1890
+ }
1891
+ expectedHash = treeHash(operation.source, operation.exclude, '', operation.transform, operation.sessionChannelKey);
1892
+ if (operation.expectedHash && operation.expectedHash !== expectedHash) {
1893
+ throw new Error('expected output changed since migration began');
1894
+ }
1404
1895
  }
1405
- const expectedHash = treeHash(operation.source, operation.exclude, '', operation.transform, operation.sessionChannelKey);
1406
- if (operation.expectedHash && operation.expectedHash !== expectedHash) {
1407
- throw new Error('expected output changed since migration began');
1896
+ else if (!sourceArchived(operation.source)) {
1897
+ throw new Error(`source missing: ${operation.source}`);
1408
1898
  }
1899
+ if (!expectedHash)
1900
+ throw new Error('expected output hash missing');
1409
1901
  const destinationHash = treeHash(operation.destination);
1410
- operation.sourceHash = sourceHash;
1902
+ if (sourceHash)
1903
+ operation.sourceHash = sourceHash;
1411
1904
  operation.expectedHash = expectedHash;
1412
1905
  operation.destinationHash = destinationHash;
1413
1906
  if (!strictTargetContent && operation.status === 'error' && operation.error?.startsWith('destination conflict') && expectedHash !== destinationHash) {