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.
- package/CHANGELOG.md +25 -0
- package/README.md +3 -3
- package/dist/agents/baseagent.js +6 -5
- package/dist/agents/claude-runner.js +1 -0
- package/dist/agents/codex-app-server-client.js +6 -2
- package/dist/agents/codex-runner.js +8 -3
- package/dist/aun/aid/control-aid.js +40 -27
- package/dist/aun/aid/domain.js +23 -0
- package/dist/channels/aun.js +26 -21
- package/dist/cli/bench.js +4 -3
- package/dist/cli/daemon-commands.js +196 -66
- package/dist/cli/data-command.js +62 -35
- package/dist/cli/init-channel.js +1 -1
- package/dist/cli/init.js +9 -13
- package/dist/cli/restart-monitor.js +116 -22
- package/dist/config/config-manager.js +34 -3
- package/dist/config/gateway-config.js +80 -35
- package/dist/config-store.js +15 -3
- package/dist/core/baseagent-loader.js +5 -3
- package/dist/core/capability/providers/codex-capability-provider.js +2 -2
- package/dist/core/channel-loader.js +10 -1
- package/dist/core/command/menu-handler.js +17 -6
- package/dist/core/data-migration.js +517 -24
- package/dist/core/protected-paths.js +12 -1
- package/dist/index.js +755 -701
- package/dist/ipc.js +2 -0
- package/dist/utils/codex-cli.js +39 -0
- package/dist/utils/cross-platform.js +147 -18
- package/dist/utils/instance-registry.js +45 -8
- package/dist/utils/process-introspect.js +7 -3
- package/kits/rules/01-overview.md +3 -2
- package/kits/schemas/_meta.json +2 -1
- package/kits/schemas/daemon.schema.4.json +132 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
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
|
|
1217
|
-
|
|
1218
|
-
|
|
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 !==
|
|
1238
|
-
throw new Error(`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
|
-
|
|
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:
|
|
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
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
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
|
-
|
|
1406
|
-
|
|
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
|
-
|
|
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) {
|