evolcore 0.0.28 → 0.0.29
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 +12 -0
- package/bin/ec-safe-output.js +270 -54
- package/dist/agents/baseagent-availability.js +37 -1
- package/dist/agents/baseagent.js +19 -9
- package/dist/agents/claude-runner.js +171 -13
- package/dist/agents/codex/app-server-client.js +6 -2
- package/dist/agents/codex/diagnostics.js +4 -3
- package/dist/agents/codex-runner.js +396 -95
- package/dist/agents/gemini-runner.js +35 -17
- package/dist/aun/group/history.js +130 -90
- package/dist/aun/group/operations.js +1 -3
- package/dist/aun/menu/transport.js +5 -3
- package/dist/aun/msg/managed-operation.js +70 -0
- package/dist/aun/msg/p2p.js +1 -3
- package/dist/aun/msg/upload.js +2 -2
- package/dist/aun/outbox.js +1 -37
- package/dist/channels/aun.js +249 -89
- package/dist/channels/daemon.js +18 -1
- package/dist/channels/feishu.js +1 -2
- package/dist/cli/agent-command.js +23 -3
- package/dist/cli/agent-create.js +68 -2
- package/dist/cli/agent.js +12 -2
- package/dist/cli/aun-commands.js +87 -13
- package/dist/cli/cli-argv.js +26 -0
- package/dist/cli/daemon-commands.js +40 -10
- package/dist/cli/data-command.js +4 -4
- package/dist/cli/handoff-command.js +2 -0
- package/dist/cli/index.js +18 -1
- package/dist/cli/managed-command-guard.js +10 -0
- package/dist/cli/memory-command.js +441 -0
- package/dist/cli/restart-monitor.js +62 -6
- package/dist/cli/skill-command.js +41 -0
- package/dist/cli/task-context.js +2 -23
- package/dist/cli/trigger-command.js +9 -1
- package/dist/cli/watch-msg-diag.js +209 -0
- package/dist/config/config-field-policy.js +52 -3
- package/dist/config/config-manager.js +17 -4
- package/dist/config/contact-book-store.js +2 -2
- package/dist/config/mention-mode.js +7 -2
- package/dist/config/merge.js +28 -9
- package/dist/config/resolved-config-op.js +1 -1
- package/dist/config/runtime-env.js +137 -0
- package/dist/config/schema-registry.js +11 -1
- package/dist/config-store.js +10 -10
- package/dist/core/audit/event-key.js +3 -1
- package/dist/core/audit/log-integrity.js +4 -0
- package/dist/core/auth/agent-delegation.js +2 -1
- package/dist/core/auth/auth-gateway.js +13 -6
- package/dist/core/auth/authorization-audit.js +54 -19
- package/dist/core/auth/operation-authorizer.js +17 -0
- package/dist/core/auth/operation-catalog.js +9 -0
- package/dist/core/causation/context.js +5 -1
- package/dist/core/command/agent-control.js +1 -2
- package/dist/core/command/command-handler.js +51 -23
- package/dist/core/command/memory-slash.js +270 -0
- package/dist/core/command/menu-catalog.js +1 -0
- package/dist/core/command/menu-handler.js +315 -60
- package/dist/core/command/menu-protocol.js +42 -4
- package/dist/core/command/slash-gate.js +10 -10
- package/dist/core/command/slash-handler.js +206 -64
- package/dist/core/data-migration.js +282 -505
- package/dist/core/event-catalog.js +26 -0
- package/dist/core/evolagent-registry.js +6 -0
- package/dist/core/evolagent.js +0 -10
- package/dist/core/handoff/store.js +1 -20
- package/dist/core/inference/text-inference.js +57 -1
- package/dist/core/memory/consolidation-context.js +505 -0
- package/dist/core/memory/consolidation-ops-records.js +124 -0
- package/dist/core/memory/consolidation-trigger.js +394 -0
- package/dist/core/memory/index-manager.js +308 -0
- package/dist/core/memory/launch-consolidation.js +395 -0
- package/dist/core/memory/memory-admin.js +234 -0
- package/dist/core/memory/memory-document.js +100 -0
- package/dist/core/memory/memory-session-policy.js +41 -0
- package/dist/core/memory/memory-signature.js +257 -0
- package/dist/core/memory/memory-writer.js +432 -0
- package/dist/core/memory/staleness.js +117 -0
- package/dist/core/message/auto-mention-gate.js +454 -0
- package/dist/core/message/im-renderer.js +69 -16
- package/dist/core/message/message-bridge.js +287 -21
- package/dist/core/message/message-log.js +1 -0
- package/dist/core/message/message-queue.js +114 -27
- package/dist/core/message/pending-hints.js +329 -161
- package/dist/core/message/response-engine.js +567 -80
- package/dist/core/permission/ec-command-parser.js +1029 -80
- package/dist/core/permission/protected-paths.js +376 -126
- package/dist/core/permission/sandbox-runtime.js +40 -12
- package/dist/core/permission/tool-error-code.js +96 -13
- package/dist/core/permission/tool-policy.js +276 -23
- package/dist/core/role/runtime-policy.js +3 -7
- package/dist/core/session/{runner-task-history.js → runner-event-summary.js} +1 -1
- package/dist/core/session/session-fs-store.js +0 -76
- package/dist/core/session/session-idle-monitor.js +355 -0
- package/dist/core/session/session-manager.js +478 -17
- package/dist/core/session/session-mapper.js +33 -16
- package/dist/core/session/session-renew.js +217 -155
- package/dist/core/session/{session-turns.js → session-transcript.js} +35 -6
- package/dist/core/skill/global-watcher.js +282 -0
- package/dist/core/skill/index-generator.js +84 -0
- package/dist/core/skill/startup.js +73 -0
- package/dist/core/skill/switch-resolver.js +115 -0
- package/dist/core/task/task-activity-store.js +219 -0
- package/dist/eck/kit-renderer.js +30 -3
- package/dist/eck/manifest-engine.js +26 -4
- package/dist/index.js +708 -18
- package/dist/ipc.js +564 -2
- package/dist/paths.js +30 -9
- package/dist/response-system/engines/v1/proactive-flow.js +116 -15
- package/dist/response-system/modes/single-session/index.js +3 -0
- package/dist/trigger/feedback.js +1 -0
- package/dist/trigger/manager.js +0 -10
- package/dist/trigger/scheduler.js +11 -0
- package/dist/trigger/validation.js +22 -0
- package/dist/utils/avatar-upload.js +59 -18
- package/dist/utils/error-dict.json +6 -3
- package/dist/utils/error-utils.js +27 -0
- package/dist/utils/logger.js +32 -10
- package/dist/utils/media-cache.js +84 -0
- package/dist/utils/msg-diag.js +97 -0
- package/dist/utils/welcome.js +37 -8
- package/kits/docs/AUTHORING.md +314 -0
- package/kits/docs/GUIDE.md +2 -0
- package/kits/docs/INDEX.md +2 -0
- package/kits/docs/aun/CHEATSHEET.md +4 -0
- package/kits/docs/aun/SDK.md +60 -0
- package/kits/docs/context-assembly.md +10 -6
- package/kits/docs/evolcore/INDEX.md +17 -3
- package/kits/docs/evolcore/agent-create.md +24 -0
- package/kits/docs/evolcore/agent.md +5 -2
- package/kits/docs/evolcore/config.md +1 -1
- package/kits/docs/evolcore/ec-capabilities.md +364 -0
- package/kits/docs/evolcore/group.md +34 -18
- package/kits/docs/evolcore/memory-architecture.md +78 -0
- package/kits/docs/evolcore/memory-consolidation-batches.md +133 -0
- package/kits/docs/evolcore/memory-consolidation.md +263 -0
- package/kits/docs/evolcore/memory-tags.md +63 -0
- package/kits/docs/evolcore/memory.md +110 -0
- package/kits/docs/evolcore/menu-client.md +27 -11
- package/kits/{schemas → docs/evolcore}/menu-exec-schema-commands.md +12 -6
- package/kits/docs/evolcore/menu-protocol.md +213 -0
- package/kits/docs/evolcore/msg.md +2 -0
- package/kits/docs/evolcore/trigger.md +7 -5
- package/kits/docs/path-registry.md +6 -8
- package/kits/docs/prompt-loading-architecture.md +5 -3
- package/kits/docs/venues/group.md +5 -4
- package/kits/eck_manifest.json +294 -88
- package/kits/eck_manifest.memory.json +257 -0
- package/kits/rules/03-identity.md +8 -8
- package/kits/rules/05-venue.md +4 -2
- package/kits/rules/06-channel.md +1 -1
- package/kits/schemas/_meta.json +276 -47
- package/kits/schemas/agent-config.schema.12.json +38 -2
- package/kits/schemas/agent-config.schema.13.json +591 -0
- package/kits/schemas/daemon.schema.6.json +9 -9
- package/kits/schemas/defaults.schema.5.json +29 -0
- package/kits/schemas/relation-config.schema.8.json +14 -1
- package/kits/schemas/relation-config.schema.9.json +129 -0
- package/kits/schemas/role-config.schema.1.json +3 -3
- package/kits/skills/.switches.json +3 -0
- package/kits/skills/ec-skill/SKILL.md +217 -0
- package/kits/skills/ec-skill/scripts/cli.mjs +128 -0
- package/kits/skills/ec-skill/scripts/deps.mjs +107 -0
- package/kits/skills/ec-skill/scripts/frontmatter.mjs +104 -0
- package/kits/skills/ec-skill/scripts/skill.mjs +629 -0
- package/kits/skills/ec-skill/scripts/template/SKILL.md.tmpl +30 -0
- package/kits/templates/decision/auto-mention.md +25 -0
- package/kits/templates/decision/session-renew.md +22 -0
- package/kits/templates/memory-consolidation/forget.md +104 -0
- package/kits/templates/memory-consolidation/generalize.md +76 -0
- package/kits/templates/memory-consolidation/notes.md +133 -0
- package/kits/templates/memory-consolidation/profile.md +58 -0
- package/kits/templates/memory-consolidation/promote.md +72 -0
- package/kits/templates/memory-consolidation/workflow.md +75 -0
- package/kits/templates/system-fragments/bootstrap.md +14 -5
- package/kits/templates/system-fragments/commands.md +1 -0
- package/kits/templates/system-fragments/group-history.md +6 -0
- package/kits/templates/system-fragments/identity.md +0 -3
- package/kits/templates/system-fragments/memory.md +79 -0
- package/kits/templates/system-fragments/reply.md +1 -0
- package/kits/templates/system-fragments/venue.md +1 -1
- package/package.json +4 -6
- package/dist/trigger/legacy-session-history.js +0 -19
- package/kits/templates/system-fragments/group-unread-history.md +0 -7
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import crypto from 'crypto';
|
|
2
2
|
import { execFileSync } from 'child_process';
|
|
3
3
|
import fs from 'fs';
|
|
4
|
+
import os from 'os';
|
|
4
5
|
import path from 'path';
|
|
5
|
-
import { formatChannelSessionKey,
|
|
6
|
-
import { rewriteLegacyTriggerSessionPaths } from '../trigger/legacy-session-history.js';
|
|
6
|
+
import { formatChannelSessionKey, scanMetaFiles, scanNestedAgentChatDirs, scanNonCanonicalAgentChatDirs, } from './session/session-fs-store.js';
|
|
7
7
|
const SESSION_SEEN_FILE = 'seen-messages.jsonl';
|
|
8
8
|
const CHANNEL_TYPES = new Set(['aun', 'feishu', 'wechat', 'dingtalk', 'qqbot', 'wecom']);
|
|
9
|
-
const DATA_MIGRATION_PLAN_VERSION =
|
|
9
|
+
const DATA_MIGRATION_PLAN_VERSION = 5;
|
|
10
10
|
function encodeSegment(value) {
|
|
11
11
|
return value.replace(/[<>:"/\\|?*\x00-\x1F%]/g, ch => '%' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
|
|
12
12
|
}
|
|
@@ -62,32 +62,6 @@ function operationProofTopologyKey(operation) {
|
|
|
62
62
|
source: operation.source,
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
|
-
/**
|
|
66
|
-
* Manifests written before allowExistingDestinationMerge was introduced still
|
|
67
|
-
* need safe recovery. Re-derive that narrow permission from their immutable
|
|
68
|
-
* source topology and the current owner-evidenced control identity; no other
|
|
69
|
-
* destination conflict is relaxed.
|
|
70
|
-
*/
|
|
71
|
-
function restoreLegacyControlMergeEligibility(manifest) {
|
|
72
|
-
const control = controlIdentity(manifest.root);
|
|
73
|
-
if (control.aids.size !== 1 || control.legacyAids.size === 0)
|
|
74
|
-
return;
|
|
75
|
-
const currentAid = control.aids.values().next().value;
|
|
76
|
-
const legacySessionsRoot = path.join(manifest.root, 'data', 'sessions', 'aun');
|
|
77
|
-
const expectedChannelKey = `aun#${currentAid}#main`;
|
|
78
|
-
const destinationRoot = path.join(manifest.root, 'agents', currentAid, 'sessions');
|
|
79
|
-
for (const operation of manifest.operations) {
|
|
80
|
-
if (operation.allowExistingDestinationMerge || operation.transform !== 'normalize-session-channel-key')
|
|
81
|
-
continue;
|
|
82
|
-
const relative = path.relative(legacySessionsRoot, operation.source);
|
|
83
|
-
const [legacyAid] = relative.split(path.sep);
|
|
84
|
-
if (!legacyAid || relative.startsWith(`..${path.sep}`) || !control.legacyAids.has(legacyAid))
|
|
85
|
-
continue;
|
|
86
|
-
if (operation.sessionChannelKey !== expectedChannelKey || !operation.destination || !isWithin(destinationRoot, operation.destination))
|
|
87
|
-
continue;
|
|
88
|
-
operation.allowExistingDestinationMerge = true;
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
65
|
function sessionDestination(root, aid, channelKey, channelId) {
|
|
92
66
|
const channelType = channelKey === 'daemon' ? 'daemon' : channelKey.split('#', 1)[0]?.trim();
|
|
93
67
|
if (!channelType)
|
|
@@ -97,8 +71,20 @@ function sessionDestination(root, aid, channelKey, channelId) {
|
|
|
97
71
|
function channelStateDestination(root, channelKey, ...parts) {
|
|
98
72
|
return path.join(root, 'data', 'channel-state', encodeURIComponent(channelKey), ...parts);
|
|
99
73
|
}
|
|
100
|
-
function
|
|
101
|
-
return path.join(root, 'agents', aid, '
|
|
74
|
+
function agentDataDestination(root, aid, ...parts) {
|
|
75
|
+
return path.join(root, 'agents', aid, 'data', ...parts);
|
|
76
|
+
}
|
|
77
|
+
function contactOperationsDestination(root, aid) {
|
|
78
|
+
return agentDataDestination(root, aid, 'contact', 'operations.jsonl');
|
|
79
|
+
}
|
|
80
|
+
function contactAuditDestination(root, aid) {
|
|
81
|
+
return agentDataDestination(root, aid, 'contact', 'audit.jsonl');
|
|
82
|
+
}
|
|
83
|
+
function codexTokensDestination(root, aid) {
|
|
84
|
+
return agentDataDestination(root, aid, 'codex-tokens');
|
|
85
|
+
}
|
|
86
|
+
function outboxDestination(root, aid, queue) {
|
|
87
|
+
return agentDataDestination(root, aid, 'outbox', `${queue}.jsonl`);
|
|
102
88
|
}
|
|
103
89
|
function readJson(filePath) {
|
|
104
90
|
try {
|
|
@@ -126,15 +112,16 @@ function acknowledgeCompletedCopies(root, operations) {
|
|
|
126
112
|
.sort((left, right) => right.mtime - left.mtime);
|
|
127
113
|
for (const candidate of manifests) {
|
|
128
114
|
const manifest = readJson(candidate.path);
|
|
129
|
-
if (manifest?.state !== 'completed')
|
|
115
|
+
if (manifest?.state !== 'completed' && manifest?.state !== 'completed_with_errors')
|
|
130
116
|
continue;
|
|
131
|
-
//
|
|
132
|
-
// checks.
|
|
133
|
-
|
|
117
|
+
// A fully completed unchanged plan is a normal re-run and must retain
|
|
118
|
+
// its conflict checks. A partial manifest may contain committed items
|
|
119
|
+
// that need acknowledgement even when its topology is unchanged.
|
|
120
|
+
if (manifest.state === 'completed' && manifest.planSignature === currentPlanSignature)
|
|
134
121
|
return;
|
|
135
122
|
for (const operation of manifest.operations ?? []) {
|
|
136
|
-
if ((operation.kind !== 'copy' && operation.kind !== 'contact-audit-partition')
|
|
137
|
-
||
|
|
123
|
+
if ((operation.kind !== 'copy' && operation.kind !== 'merge-copy' && operation.kind !== 'contact-audit-partition')
|
|
124
|
+
|| !['committed', 'skipped', 'acknowledged'].includes(operation.status)
|
|
138
125
|
|| !operation.sourceHash)
|
|
139
126
|
continue;
|
|
140
127
|
const key = operationProofTopologyKey(operation);
|
|
@@ -142,6 +129,7 @@ function acknowledgeCompletedCopies(root, operations) {
|
|
|
142
129
|
proofs.set(key, {
|
|
143
130
|
migrationId: manifest.id,
|
|
144
131
|
sourceHash: operation.sourceHash,
|
|
132
|
+
sourceHashes: operation.sourceHashes,
|
|
145
133
|
destinations: operation.kind === 'contact-audit-partition'
|
|
146
134
|
? [...(operation.destinations ?? [])]
|
|
147
135
|
: operation.destination ? [operation.destination] : [],
|
|
@@ -152,21 +140,32 @@ function acknowledgeCompletedCopies(root, operations) {
|
|
|
152
140
|
}
|
|
153
141
|
catch { }
|
|
154
142
|
for (const operation of operations) {
|
|
155
|
-
if (operation.kind !== 'copy' && operation.kind !== 'contact-audit-partition')
|
|
143
|
+
if (operation.kind !== 'copy' && operation.kind !== 'merge-copy' && operation.kind !== 'contact-audit-partition')
|
|
156
144
|
continue;
|
|
157
145
|
const proof = proofs.get(operationProofTopologyKey(operation));
|
|
158
146
|
if (!proof)
|
|
159
147
|
continue;
|
|
160
|
-
if (operation.kind === 'copy'
|
|
148
|
+
if ((operation.kind === 'copy' || operation.kind === 'merge-copy')
|
|
149
|
+
&& (!operation.destination || !fs.existsSync(operation.destination)))
|
|
161
150
|
continue;
|
|
162
151
|
if (operation.kind === 'contact-audit-partition' && proof.destinations.some(destination => !fs.existsSync(destination)))
|
|
163
152
|
continue;
|
|
164
153
|
try {
|
|
165
|
-
if (
|
|
154
|
+
if (operation.kind === 'merge-copy') {
|
|
155
|
+
const sourceHashes = proof.sourceHashes ?? {};
|
|
156
|
+
if (operationSources(operation).some(source => {
|
|
157
|
+
const expected = sourceHashes[source] ?? (source === operation.source ? proof.sourceHash : undefined);
|
|
158
|
+
return !expected || treeHash(source, operation.exclude) !== expected;
|
|
159
|
+
}))
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
else if (treeHash(operation.source, operation.exclude) !== proof.sourceHash)
|
|
166
163
|
continue;
|
|
167
164
|
operation.status = 'acknowledged';
|
|
168
165
|
operation.acknowledgedBy = proof.migrationId;
|
|
169
166
|
operation.sourceHash = proof.sourceHash;
|
|
167
|
+
if (proof.sourceHashes)
|
|
168
|
+
operation.sourceHashes = proof.sourceHashes;
|
|
170
169
|
}
|
|
171
170
|
catch { }
|
|
172
171
|
}
|
|
@@ -246,154 +245,6 @@ function configuredAids(root) {
|
|
|
246
245
|
}
|
|
247
246
|
})()));
|
|
248
247
|
}
|
|
249
|
-
function controlIdentity(root) {
|
|
250
|
-
const config = readJson(path.join(root, 'daemon.json'));
|
|
251
|
-
const aid = typeof config?.aid === 'string' && config.aid.trim() && config.aid !== '_unknown'
|
|
252
|
-
? config.aid.trim()
|
|
253
|
-
: undefined;
|
|
254
|
-
const owners = new Set();
|
|
255
|
-
if (Array.isArray(config?.owners)) {
|
|
256
|
-
for (const owner of config.owners) {
|
|
257
|
-
if (typeof owner === 'string' && owner.trim())
|
|
258
|
-
owners.add(owner);
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
// A previous `ecNNNNN.<domain>` control identity has no Agent config, so
|
|
262
|
-
// its legacy AUN session root cannot be attributed through configuredAids.
|
|
263
|
-
// It is safe to recognize only when that root contains at least one current
|
|
264
|
-
// daemon owner: the whole root then belongs to the same process control
|
|
265
|
-
// plane, including sessions with its non-owner Agents.
|
|
266
|
-
const legacyAids = new Set();
|
|
267
|
-
if (aid && owners.size > 0) {
|
|
268
|
-
try {
|
|
269
|
-
for (const entry of fs.readdirSync(path.join(root, 'data', 'sessions', 'aun'), { withFileTypes: true })) {
|
|
270
|
-
if (!entry.isDirectory() || entry.name === aid || !/^ec\d{5}\.[^.]+(?:\..+)+$/i.test(entry.name))
|
|
271
|
-
continue;
|
|
272
|
-
const sessionRoot = path.join(root, 'data', 'sessions', 'aun', entry.name);
|
|
273
|
-
const hasCurrentOwner = fs.readdirSync(sessionRoot, { withFileTypes: true })
|
|
274
|
-
.some(child => child.isDirectory() && owners.has(child.name));
|
|
275
|
-
if (hasCurrentOwner)
|
|
276
|
-
legacyAids.add(entry.name);
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
catch { }
|
|
280
|
-
}
|
|
281
|
-
return { aids: new Set(aid ? [aid] : []), owners, legacyAids };
|
|
282
|
-
}
|
|
283
|
-
function inferUnknownAunOwner(chat, control) {
|
|
284
|
-
if (chat.channelType !== 'aun' || chat.selfAID !== '_unknown' || control.aids.size !== 1)
|
|
285
|
-
return undefined;
|
|
286
|
-
// Old message-only AUN directories use `_unknown` for the local identity.
|
|
287
|
-
// The control identity is recoverable only when the peer is one of its
|
|
288
|
-
// configured owners; arbitrary unknown directories remain untouched.
|
|
289
|
-
if (!control.owners.has(chat.channelId))
|
|
290
|
-
return undefined;
|
|
291
|
-
return control.aids.values().next().value;
|
|
292
|
-
}
|
|
293
|
-
function remapLegacyControlAunOwner(aid, control) {
|
|
294
|
-
if (control.aids.size !== 1 || !control.legacyAids.has(aid))
|
|
295
|
-
return undefined;
|
|
296
|
-
return control.aids.values().next().value;
|
|
297
|
-
}
|
|
298
|
-
function isOwnedTriggerDefinition(definition, aid, triggerId) {
|
|
299
|
-
return !!definition
|
|
300
|
-
&& typeof definition === 'object'
|
|
301
|
-
&& definition.id === triggerId
|
|
302
|
-
&& definition.agentAid === aid;
|
|
303
|
-
}
|
|
304
|
-
function listTriggerStorageRoots(root, layout) {
|
|
305
|
-
const roots = [];
|
|
306
|
-
if (layout === 'legacy') {
|
|
307
|
-
const legacyRoot = path.join(root, 'data', 'triggers');
|
|
308
|
-
try {
|
|
309
|
-
for (const entry of fs.readdirSync(legacyRoot, { withFileTypes: true })) {
|
|
310
|
-
if (entry.isDirectory())
|
|
311
|
-
roots.push({ aid: entry.name, directory: path.join(legacyRoot, entry.name) });
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
catch { }
|
|
315
|
-
return roots;
|
|
316
|
-
}
|
|
317
|
-
const agentsRoot = path.join(root, 'agents');
|
|
318
|
-
try {
|
|
319
|
-
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
|
|
320
|
-
if (!entry.isDirectory())
|
|
321
|
-
continue;
|
|
322
|
-
const directory = path.join(agentsRoot, entry.name, 'triggers');
|
|
323
|
-
if (fs.existsSync(directory))
|
|
324
|
-
roots.push({ aid: entry.name, directory });
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
catch { }
|
|
328
|
-
return roots;
|
|
329
|
-
}
|
|
330
|
-
/**
|
|
331
|
-
* Trigger definitions are the authority for daemon-session ownership. Search
|
|
332
|
-
* both layouts while a completed migration retains the immutable legacy copy.
|
|
333
|
-
*/
|
|
334
|
-
function listTriggerOwners(root) {
|
|
335
|
-
const ownerAids = new Map();
|
|
336
|
-
for (const layout of ['legacy', 'agent']) {
|
|
337
|
-
for (const { aid, directory } of listTriggerStorageRoots(root, layout)) {
|
|
338
|
-
let triggerEntries;
|
|
339
|
-
try {
|
|
340
|
-
triggerEntries = fs.readdirSync(directory, { withFileTypes: true });
|
|
341
|
-
}
|
|
342
|
-
catch {
|
|
343
|
-
continue;
|
|
344
|
-
}
|
|
345
|
-
for (const triggerEntry of triggerEntries) {
|
|
346
|
-
if (!triggerEntry.isDirectory())
|
|
347
|
-
continue;
|
|
348
|
-
const definition = readJson(path.join(directory, triggerEntry.name, 'trigger.json'));
|
|
349
|
-
if (!isOwnedTriggerDefinition(definition, aid, triggerEntry.name))
|
|
350
|
-
continue;
|
|
351
|
-
const owners = ownerAids.get(triggerEntry.name) ?? new Set();
|
|
352
|
-
owners.add(aid);
|
|
353
|
-
ownerAids.set(triggerEntry.name, owners);
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return new Map([...ownerAids].map(([triggerId, aids]) => [
|
|
358
|
-
triggerId,
|
|
359
|
-
[...aids].sort().map(aid => ({ aid })),
|
|
360
|
-
]));
|
|
361
|
-
}
|
|
362
|
-
function resolveDaemonTriggerOwner(chat, record, triggerOwners) {
|
|
363
|
-
if (chat.channelType !== 'daemon') {
|
|
364
|
-
return { warning: `Non-Trigger daemon session retained: ${chat.channelId}` };
|
|
365
|
-
}
|
|
366
|
-
const triggerIds = new Set();
|
|
367
|
-
if (chat.channelId.startsWith('trigger:'))
|
|
368
|
-
triggerIds.add(chat.channelId.slice('trigger:'.length));
|
|
369
|
-
for (const value of [record?.metadata?.peerId, record?.metadata?.triggerId, record?.triggerId]) {
|
|
370
|
-
if (typeof value === 'string' && value.startsWith('trigger:'))
|
|
371
|
-
triggerIds.add(value.slice('trigger:'.length));
|
|
372
|
-
}
|
|
373
|
-
triggerIds.delete('');
|
|
374
|
-
if (triggerIds.size === 0)
|
|
375
|
-
return { warning: `Non-Trigger daemon session retained: ${chat.channelId}` };
|
|
376
|
-
if (triggerIds.size > 1) {
|
|
377
|
-
return { warning: `Daemon session references multiple Trigger IDs and was retained: ${chat.channelId}` };
|
|
378
|
-
}
|
|
379
|
-
const triggerId = [...triggerIds][0];
|
|
380
|
-
const candidates = triggerOwners.get(triggerId) ?? [];
|
|
381
|
-
const declaredOwner = typeof record?.selfAID === 'string' && record.selfAID !== '_unknown'
|
|
382
|
-
? record.selfAID
|
|
383
|
-
: undefined;
|
|
384
|
-
const ownedCandidates = declaredOwner
|
|
385
|
-
? candidates.filter(candidate => candidate.aid === declaredOwner)
|
|
386
|
-
: candidates;
|
|
387
|
-
if (ownedCandidates.length === 1)
|
|
388
|
-
return { aid: ownedCandidates[0].aid };
|
|
389
|
-
if (declaredOwner && candidates.length > 0) {
|
|
390
|
-
return { warning: `Daemon Trigger session owner conflicts with its definition and was retained: ${chat.channelId}` };
|
|
391
|
-
}
|
|
392
|
-
if (candidates.length === 0) {
|
|
393
|
-
return { warning: `Daemon Trigger session has no matching definition and was retained: ${chat.channelId}` };
|
|
394
|
-
}
|
|
395
|
-
return { warning: `Daemon Trigger session has ambiguous ownership and was retained: ${chat.channelId}` };
|
|
396
|
-
}
|
|
397
248
|
function matchingChannel(instances, type, legacyName) {
|
|
398
249
|
const matches = instances.filter(instance => instance.type === type && (instance.key === legacyName || instance.rawName === legacyName));
|
|
399
250
|
return matches.length === 1 ? matches[0].key : undefined;
|
|
@@ -464,6 +315,11 @@ function addCopy(operations, id, source, destination, staging, exclude, transfor
|
|
|
464
315
|
return;
|
|
465
316
|
operations.push({ id, kind: 'copy', source, destination, staging, exclude, transform, sessionChannelKey, status: 'planned' });
|
|
466
317
|
}
|
|
318
|
+
function addDiscard(operations, id, source, reason) {
|
|
319
|
+
if (!fs.existsSync(source) || operations.some(operation => operation.kind === 'discard' && operation.source === source))
|
|
320
|
+
return;
|
|
321
|
+
operations.push({ id, kind: 'discard', source, status: 'planned', discardReason: reason });
|
|
322
|
+
}
|
|
467
323
|
/**
|
|
468
324
|
* Historic AUN data can contain an early `_unknown` transcript and a later
|
|
469
325
|
* identified transcript for the same peer. They are one logical session, so
|
|
@@ -498,14 +354,10 @@ function addMergeCopy(operations, id, source, destination, staging, exclude, tra
|
|
|
498
354
|
export function planDataMigration(root) {
|
|
499
355
|
const operations = [];
|
|
500
356
|
const warnings = [];
|
|
501
|
-
const supersededSourceHashes = {};
|
|
502
357
|
const id = `data-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
503
358
|
const stagingDir = migrationStagingDir(root, id);
|
|
504
359
|
const instances = listChannelInstances(root);
|
|
505
360
|
const aids = configuredAids(root);
|
|
506
|
-
const control = controlIdentity(root);
|
|
507
|
-
const triggerOwners = listTriggerOwners(root);
|
|
508
|
-
const legacySessions = path.join(root, 'data', 'sessions');
|
|
509
361
|
const legacyFeishuChats = new Map();
|
|
510
362
|
let sequence = 0;
|
|
511
363
|
const nextId = (prefix) => `${prefix}-${String(++sequence).padStart(5, '0')}`;
|
|
@@ -517,12 +369,6 @@ export function planDataMigration(root) {
|
|
|
517
369
|
chats.add(channelId);
|
|
518
370
|
legacyFeishuChats.set(channelKey, chats);
|
|
519
371
|
};
|
|
520
|
-
const rememberSupersededSource = (source) => {
|
|
521
|
-
try {
|
|
522
|
-
supersededSourceHashes[source] = treeHash(source);
|
|
523
|
-
}
|
|
524
|
-
catch { }
|
|
525
|
-
};
|
|
526
372
|
const addFeishuSeen = (source, channelKey, channelId, prefix) => {
|
|
527
373
|
if (!fs.existsSync(source))
|
|
528
374
|
return;
|
|
@@ -535,42 +381,7 @@ export function planDataMigration(root) {
|
|
|
535
381
|
addCopy(operations, operationId, source, destination, stageFor(operationId));
|
|
536
382
|
}
|
|
537
383
|
};
|
|
538
|
-
//
|
|
539
|
-
// one malformed legacy definition does not block unrelated Agent data.
|
|
540
|
-
for (const { aid, directory } of listTriggerStorageRoots(root, 'legacy')) {
|
|
541
|
-
let entries;
|
|
542
|
-
try {
|
|
543
|
-
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
544
|
-
}
|
|
545
|
-
catch {
|
|
546
|
-
continue;
|
|
547
|
-
}
|
|
548
|
-
for (const entry of entries) {
|
|
549
|
-
const source = path.join(directory, entry.name);
|
|
550
|
-
if (entry.isDirectory()) {
|
|
551
|
-
if (entry.name.startsWith('.')) {
|
|
552
|
-
const operationId = nextId('trigger-metadata');
|
|
553
|
-
addCopy(operations, operationId, source, triggerDestination(root, aid, entry.name), stageFor(operationId));
|
|
554
|
-
continue;
|
|
555
|
-
}
|
|
556
|
-
const definition = readJson(path.join(source, 'trigger.json'));
|
|
557
|
-
if (!isOwnedTriggerDefinition(definition, aid, entry.name)) {
|
|
558
|
-
warnings.push(`Invalid Trigger definition retained: ${source}`);
|
|
559
|
-
continue;
|
|
560
|
-
}
|
|
561
|
-
const operationId = nextId('trigger');
|
|
562
|
-
addCopy(operations, operationId, source, triggerDestination(root, aid, entry.name), stageFor(operationId), undefined, 'rewrite-trigger-session-paths');
|
|
563
|
-
continue;
|
|
564
|
-
}
|
|
565
|
-
if (entry.isFile() && (entry.name === 'history.jsonl' || /^triggers\.legacy\.migrated\.\d+\.json$/.test(entry.name))) {
|
|
566
|
-
const operationId = nextId(entry.name === 'history.jsonl' ? 'trigger-history' : 'trigger-legacy-stats');
|
|
567
|
-
addCopy(operations, operationId, source, triggerDestination(root, aid, entry.name), stageFor(operationId));
|
|
568
|
-
continue;
|
|
569
|
-
}
|
|
570
|
-
warnings.push(`Unrecognized Trigger data retained: ${source}`);
|
|
571
|
-
}
|
|
572
|
-
}
|
|
573
|
-
// Canonicalize single-level keys written by an earlier v3 build. These are
|
|
384
|
+
// Canonicalize single-level keys written by an earlier build. These are
|
|
574
385
|
// live Agent-owned copies and therefore take precedence over retained source
|
|
575
386
|
// layouts when both resolve to the same destination.
|
|
576
387
|
for (const chat of scanNonCanonicalAgentChatDirs(path.join(root, 'agents'))) {
|
|
@@ -596,10 +407,9 @@ export function planDataMigration(root) {
|
|
|
596
407
|
addFeishuSeen(path.join(chat.dirPath, SESSION_SEEN_FILE), channelKey, chat.channelId, 'canonical-feishu-seen');
|
|
597
408
|
}
|
|
598
409
|
}
|
|
599
|
-
//
|
|
600
|
-
//
|
|
601
|
-
//
|
|
602
|
-
// takes precedence and avoids replaying an older transcript.
|
|
410
|
+
// Flatten the previous Agent-owned two-level session layout. These
|
|
411
|
+
// directories are the most recent runtime copies and take precedence over
|
|
412
|
+
// any already-created canonical destination.
|
|
603
413
|
const nestedChats = scanNestedAgentChatDirs(path.join(root, 'agents'))
|
|
604
414
|
.map(chat => {
|
|
605
415
|
const record = readLatestSession(chat.dirPath);
|
|
@@ -636,83 +446,6 @@ export function planDataMigration(root) {
|
|
|
636
446
|
addFeishuSeen(path.join(chat.dirPath, SESSION_SEEN_FILE), channelKey, chat.channelId, 'flatten-feishu-seen');
|
|
637
447
|
}
|
|
638
448
|
}
|
|
639
|
-
for (const chat of scanLegacyChatDirs(legacySessions)) {
|
|
640
|
-
const record = readLatestSession(chat.dirPath);
|
|
641
|
-
if (chat.channelType === 'daemon') {
|
|
642
|
-
const triggerOwner = resolveDaemonTriggerOwner(chat, record, triggerOwners);
|
|
643
|
-
if (!triggerOwner.aid) {
|
|
644
|
-
warnings.push(`${triggerOwner.warning ?? 'Unresolved daemon session retained'}: ${chat.dirPath}`);
|
|
645
|
-
continue;
|
|
646
|
-
}
|
|
647
|
-
let destination;
|
|
648
|
-
try {
|
|
649
|
-
destination = sessionDestination(root, triggerOwner.aid, 'daemon', chat.channelId);
|
|
650
|
-
}
|
|
651
|
-
catch (error) {
|
|
652
|
-
warnings.push(`Unmigratable daemon session retained: ${chat.dirPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
653
|
-
continue;
|
|
654
|
-
}
|
|
655
|
-
if (!liveSessionDestinations.has(destination) && !nestedSessionDestinations.has(destination)) {
|
|
656
|
-
const sessionId = nextId('trigger-session');
|
|
657
|
-
addSessionCopy(operations, sessionId, chat.dirPath, destination, stageFor(sessionId));
|
|
658
|
-
}
|
|
659
|
-
else {
|
|
660
|
-
rememberSupersededSource(chat.dirPath);
|
|
661
|
-
}
|
|
662
|
-
continue;
|
|
663
|
-
}
|
|
664
|
-
const fromChannel = parseChannelKey(record?.channel ?? record?.metadata?.channelKey);
|
|
665
|
-
const detectedAid = chat.selfAID && chat.selfAID !== '_unknown'
|
|
666
|
-
? chat.selfAID
|
|
667
|
-
: (typeof record?.selfAID === 'string' && record.selfAID !== '_unknown'
|
|
668
|
-
? record.selfAID
|
|
669
|
-
: fromChannel?.aid ?? inferUnknownAunOwner(chat, control));
|
|
670
|
-
const remappedControlAid = detectedAid && !aids.has(detectedAid) && !control.aids.has(detectedAid)
|
|
671
|
-
? remapLegacyControlAunOwner(detectedAid, control)
|
|
672
|
-
: undefined;
|
|
673
|
-
const aid = remappedControlAid ?? detectedAid;
|
|
674
|
-
if (!aid || aid === '_unknown') {
|
|
675
|
-
warnings.push(`Unresolved session owner retained: ${chat.dirPath}`);
|
|
676
|
-
continue;
|
|
677
|
-
}
|
|
678
|
-
if (!aids.has(aid) && !control.aids.has(aid)) {
|
|
679
|
-
warnings.push(`Session owner has no Agent config and was retained: ${chat.dirPath}`);
|
|
680
|
-
continue;
|
|
681
|
-
}
|
|
682
|
-
const channelKey = configuredChannelKey(instances, chat.channelType, aid, record?.channel, record?.metadata?.channelKey, chat.channelKey);
|
|
683
|
-
if (!channelKey) {
|
|
684
|
-
warnings.push(`Unresolved session channel retained: ${chat.dirPath}`);
|
|
685
|
-
continue;
|
|
686
|
-
}
|
|
687
|
-
let destination;
|
|
688
|
-
try {
|
|
689
|
-
destination = sessionDestination(root, aid, channelKey, chat.channelId);
|
|
690
|
-
}
|
|
691
|
-
catch (error) {
|
|
692
|
-
warnings.push(`Unmigratable session retained: ${chat.dirPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
693
|
-
continue;
|
|
694
|
-
}
|
|
695
|
-
if (!liveSessionDestinations.has(destination) && !nestedSessionDestinations.has(destination)) {
|
|
696
|
-
const sessionId = nextId('session');
|
|
697
|
-
addSessionCopy(operations, sessionId, chat.dirPath, destination, stageFor(sessionId), [SESSION_SEEN_FILE], 'normalize-session-channel-key', channelKey);
|
|
698
|
-
if (remappedControlAid) {
|
|
699
|
-
const operation = operations.find(item => item.destination === destination);
|
|
700
|
-
if (operation)
|
|
701
|
-
operation.allowExistingDestinationMerge = true;
|
|
702
|
-
}
|
|
703
|
-
}
|
|
704
|
-
else {
|
|
705
|
-
// This retained data/sessions copy is older than an Agent-owned source
|
|
706
|
-
// selected for the same canonical destination. It must not be replayed,
|
|
707
|
-
// but --apply can archive and remove it as a verified
|
|
708
|
-
// superseded source instead of exposing it as a fresh migration again.
|
|
709
|
-
rememberSupersededSource(chat.dirPath);
|
|
710
|
-
}
|
|
711
|
-
if (chat.channelType === 'feishu') {
|
|
712
|
-
rememberFeishuChat(channelKey, chat.channelId);
|
|
713
|
-
addFeishuSeen(path.join(chat.dirPath, SESSION_SEEN_FILE), channelKey, chat.channelId, 'feishu-seen');
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
449
|
// Rebind connector state written under a removed unique channel instance.
|
|
717
450
|
// The source stays intact; a merge-copy handles an already-created target.
|
|
718
451
|
try {
|
|
@@ -738,25 +471,106 @@ export function planDataMigration(root) {
|
|
|
738
471
|
}
|
|
739
472
|
}
|
|
740
473
|
catch { }
|
|
474
|
+
// Agent-owned contact logs, Codex hook credentials, and outbox queues are
|
|
475
|
+
// migrated explicitly. Runtime code no longer imports any of these legacy
|
|
476
|
+
// paths, so a source is either copied here or reported for operator review.
|
|
477
|
+
for (const aid of [...aids].sort()) {
|
|
478
|
+
const contactOperationsId = nextId('contact-operations');
|
|
479
|
+
addCopy(operations, contactOperationsId, path.join(root, 'agents', aid, 'contact-operations.jsonl'), contactOperationsDestination(root, aid), stageFor(contactOperationsId));
|
|
480
|
+
const contactAuditId = nextId('contact-audit');
|
|
481
|
+
addCopy(operations, contactAuditId, path.join(root, 'agents', aid, 'data', 'contact-audit.jsonl'), contactAuditDestination(root, aid), stageFor(contactAuditId));
|
|
482
|
+
const codexTokensId = nextId('codex-tokens');
|
|
483
|
+
addCopy(operations, codexTokensId, path.join(root, 'agents', aid, 'sessions', 'codex-tokens'), codexTokensDestination(root, aid), stageFor(codexTokensId));
|
|
484
|
+
const legacySources = [
|
|
485
|
+
[path.join(root, 'data', 'outbox', encodeURIComponent(aid), 'outbox.jsonl'), 'delivery', 'outbox-shared'],
|
|
486
|
+
[path.join(root, 'data', 'outbox', encodeURIComponent(aid), 'activity-outbox.jsonl'), 'activity', 'activity-outbox-shared'],
|
|
487
|
+
[path.join(root, 'data', 'outbox', `${aid}.jsonl`), 'delivery', 'outbox-flat'],
|
|
488
|
+
[path.join(root, 'agents', aid, 'data', 'outbox.jsonl'), 'delivery', 'outbox-agent'],
|
|
489
|
+
[path.join(root, 'agents', aid, 'data', 'activity-outbox.jsonl'), 'activity', 'activity-outbox-agent'],
|
|
490
|
+
];
|
|
491
|
+
for (const [source, queue, prefix] of legacySources) {
|
|
492
|
+
const operationId = nextId(prefix);
|
|
493
|
+
addMergeCopy(operations, operationId, source, outboxDestination(root, aid, queue), stageFor(operationId));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
// Legacy outbox entries without a configured Agent have no safe destination.
|
|
497
|
+
// They are intentionally discarded during the stopped maintenance window.
|
|
498
|
+
try {
|
|
499
|
+
for (const entry of fs.readdirSync(path.join(root, 'agents'), { withFileTypes: true })) {
|
|
500
|
+
if (!entry.isDirectory() || aids.has(entry.name))
|
|
501
|
+
continue;
|
|
502
|
+
const agentRoot = path.join(root, 'agents', entry.name);
|
|
503
|
+
for (const candidate of [
|
|
504
|
+
path.join(agentRoot, 'data', 'outbox.jsonl'),
|
|
505
|
+
path.join(agentRoot, 'data', 'activity-outbox.jsonl'),
|
|
506
|
+
]) {
|
|
507
|
+
addDiscard(operations, nextId('discard-outbox'), candidate, 'legacy outbox owner is not configured');
|
|
508
|
+
}
|
|
509
|
+
for (const candidate of [
|
|
510
|
+
path.join(agentRoot, 'contact-operations.jsonl'),
|
|
511
|
+
path.join(agentRoot, 'data', 'contact-audit.jsonl'),
|
|
512
|
+
path.join(agentRoot, 'sessions', 'codex-tokens'),
|
|
513
|
+
]) {
|
|
514
|
+
if (fs.existsSync(candidate))
|
|
515
|
+
warnings.push(`Unresolved Agent-owned legacy source retained: ${candidate}`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
catch { }
|
|
520
|
+
// Shared outbox entries whose owner cannot be matched to a configured Agent
|
|
521
|
+
// are also intentionally discarded rather than retained indefinitely.
|
|
522
|
+
try {
|
|
523
|
+
const sharedOutbox = path.join(root, 'data', 'outbox');
|
|
524
|
+
for (const entry of fs.readdirSync(sharedOutbox, { withFileTypes: true })) {
|
|
525
|
+
if (entry.isDirectory()) {
|
|
526
|
+
let aid;
|
|
527
|
+
try {
|
|
528
|
+
aid = decodeURIComponent(entry.name);
|
|
529
|
+
}
|
|
530
|
+
catch {
|
|
531
|
+
aid = entry.name;
|
|
532
|
+
}
|
|
533
|
+
if (aids.has(aid))
|
|
534
|
+
continue;
|
|
535
|
+
addDiscard(operations, nextId('discard-outbox'), path.join(sharedOutbox, entry.name), 'shared outbox owner is not configured');
|
|
536
|
+
}
|
|
537
|
+
else if (entry.isFile() && /\.jsonl$/.test(entry.name)) {
|
|
538
|
+
const aid = entry.name.replace(/\.jsonl$/, '');
|
|
539
|
+
if (!aids.has(aid))
|
|
540
|
+
addDiscard(operations, nextId('discard-outbox'), path.join(sharedOutbox, entry.name), 'flat outbox owner is not configured');
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
catch { }
|
|
545
|
+
// Pre-v4 nested handoff snapshots are obsolete and intentionally discarded.
|
|
546
|
+
// The flat handoff files already used by the runtime remain untouched.
|
|
547
|
+
let agentEntries = [];
|
|
548
|
+
try {
|
|
549
|
+
agentEntries = fs.readdirSync(path.join(root, 'agents'), { withFileTypes: true });
|
|
550
|
+
}
|
|
551
|
+
catch { }
|
|
552
|
+
for (const entry of agentEntries.filter(item => item.isDirectory()).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
553
|
+
const nestedRoot = path.join(root, 'agents', entry.name, 'data', 'handoff', 'handoffs');
|
|
554
|
+
let entries;
|
|
555
|
+
try {
|
|
556
|
+
entries = fs.readdirSync(nestedRoot, { withFileTypes: true });
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
continue;
|
|
560
|
+
}
|
|
561
|
+
for (const entry of entries) {
|
|
562
|
+
addDiscard(operations, nextId('discard-handoff'), path.join(nestedRoot, entry.name), 'legacy nested handoff is obsolete');
|
|
563
|
+
}
|
|
564
|
+
}
|
|
741
565
|
const legacyHandoff = path.join(root, 'data', 'handoff');
|
|
742
566
|
try {
|
|
743
567
|
for (const entry of fs.readdirSync(legacyHandoff, { withFileTypes: true })) {
|
|
744
568
|
if (!entry.isDirectory())
|
|
745
569
|
continue;
|
|
746
|
-
|
|
747
|
-
warnings.push(`Unresolved handoff owner retained: ${entry.name}`);
|
|
748
|
-
continue;
|
|
749
|
-
}
|
|
750
|
-
const operationId = nextId('handoff');
|
|
751
|
-
addCopy(operations, operationId, path.join(legacyHandoff, entry.name), path.join(root, 'agents', entry.name, 'data', 'handoff'), stageFor(operationId));
|
|
570
|
+
addDiscard(operations, nextId('discard-handoff'), path.join(legacyHandoff, entry.name), aids.has(entry.name) ? 'legacy shared handoff is obsolete' : 'legacy handoff owner is not configured');
|
|
752
571
|
}
|
|
753
572
|
}
|
|
754
573
|
catch { }
|
|
755
|
-
// Outbox layouts are imported lazily by src/aun/outbox.ts when a queue is
|
|
756
|
-
// first read or written. They are append-only delivery state, so including
|
|
757
|
-
// them in the maintenance migration would unnecessarily block startup and
|
|
758
|
-
// could race a live connector. Keep the explicit migration reserved for
|
|
759
|
-
// data that needs operator review or an exclusive maintenance window.
|
|
760
574
|
const contactAudit = path.join(root, 'data', 'contact-book-audit.jsonl');
|
|
761
575
|
// The legacy audit file may be provisioned as an empty placeholder (for
|
|
762
576
|
// example by a protected-path sandbox). An empty source has nothing to
|
|
@@ -880,7 +694,6 @@ export function planDataMigration(root) {
|
|
|
880
694
|
state: 'planned',
|
|
881
695
|
operations,
|
|
882
696
|
warnings,
|
|
883
|
-
...(Object.keys(supersededSourceHashes).length ? { supersededSourceHashes } : {}),
|
|
884
697
|
planSignature: migrationPlanSignature(operations),
|
|
885
698
|
};
|
|
886
699
|
}
|
|
@@ -911,70 +724,6 @@ function validateTree(filePath) {
|
|
|
911
724
|
strictJsonValidation(child);
|
|
912
725
|
}
|
|
913
726
|
}
|
|
914
|
-
function verifyRequiredTriggerPayload(source, destination, relative = '') {
|
|
915
|
-
const sourceStat = fs.statSync(source);
|
|
916
|
-
let destinationStat;
|
|
917
|
-
try {
|
|
918
|
-
destinationStat = fs.statSync(destination);
|
|
919
|
-
}
|
|
920
|
-
catch {
|
|
921
|
-
throw new Error(`destination conflict: missing Trigger payload ${relative || '.'}`);
|
|
922
|
-
}
|
|
923
|
-
if (sourceStat.isFile()) {
|
|
924
|
-
if (!destinationStat.isFile())
|
|
925
|
-
throw new Error(`destination conflict: incompatible Trigger payload ${relative}`);
|
|
926
|
-
if (relative === 'active.json') {
|
|
927
|
-
strictJsonValidation(destination);
|
|
928
|
-
return;
|
|
929
|
-
}
|
|
930
|
-
if (relative === 'trigger.json') {
|
|
931
|
-
strictJsonValidation(destination);
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
if (!fs.readFileSync(source).equals(fs.readFileSync(destination))) {
|
|
935
|
-
throw new Error(`destination conflict: changed Trigger payload ${relative}`);
|
|
936
|
-
}
|
|
937
|
-
return;
|
|
938
|
-
}
|
|
939
|
-
if (!destinationStat.isDirectory())
|
|
940
|
-
throw new Error(`destination conflict: incompatible Trigger directory ${relative || '.'}`);
|
|
941
|
-
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
942
|
-
if (!entry.isDirectory() && !entry.isFile())
|
|
943
|
-
continue;
|
|
944
|
-
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
945
|
-
verifyRequiredTriggerPayload(path.join(source, entry.name), path.join(destination, entry.name), childRelative);
|
|
946
|
-
}
|
|
947
|
-
}
|
|
948
|
-
function verifyLiveTriggerDestination(operation) {
|
|
949
|
-
if (operation.transform !== 'rewrite-trigger-session-paths' || !operation.destination) {
|
|
950
|
-
throw new Error('operation is not a Trigger live-state reconciliation');
|
|
951
|
-
}
|
|
952
|
-
verifyRequiredTriggerPayload(operation.source, operation.destination);
|
|
953
|
-
const sourceDefinition = readJson(path.join(operation.source, 'trigger.json'));
|
|
954
|
-
const targetPath = path.join(operation.destination, 'trigger.json');
|
|
955
|
-
const targetDefinition = readJson(targetPath);
|
|
956
|
-
const aid = typeof sourceDefinition?.agentAid === 'string' ? sourceDefinition.agentAid : undefined;
|
|
957
|
-
const id = typeof sourceDefinition?.id === 'string' ? sourceDefinition.id : undefined;
|
|
958
|
-
if (!aid || !id || !isOwnedTriggerDefinition(targetDefinition, aid, id)) {
|
|
959
|
-
throw new Error('destination conflict: Trigger definition owner or id changed');
|
|
960
|
-
}
|
|
961
|
-
const content = fs.readFileSync(targetPath, 'utf8');
|
|
962
|
-
const transformed = transformedFileContent(targetPath, 'trigger.json', content, operation.transform, operation.sessionChannelKey);
|
|
963
|
-
if (transformed !== content)
|
|
964
|
-
throw new Error('destination conflict: Trigger definition still has legacy session paths');
|
|
965
|
-
validateTree(operation.destination);
|
|
966
|
-
}
|
|
967
|
-
function reconcileLiveTriggerDestination(operation) {
|
|
968
|
-
if (!operation.destination)
|
|
969
|
-
throw new Error('Trigger live-state reconciliation has no destination');
|
|
970
|
-
verifyRequiredTriggerPayload(operation.source, operation.destination);
|
|
971
|
-
const targetPath = path.join(operation.destination, 'trigger.json');
|
|
972
|
-
const content = fs.readFileSync(targetPath, 'utf8');
|
|
973
|
-
const transformed = transformedFileContent(targetPath, 'trigger.json', content, operation.transform, operation.sessionChannelKey);
|
|
974
|
-
if (transformed !== content)
|
|
975
|
-
atomicWriteText(targetPath, transformed);
|
|
976
|
-
verifyLiveTriggerDestination(operation);
|
|
977
|
-
}
|
|
978
727
|
function sessionIdentity(directory) {
|
|
979
728
|
const active = readJson(path.join(directory, 'active.json'));
|
|
980
729
|
if (!active || typeof active !== 'object')
|
|
@@ -1086,16 +835,6 @@ function normalizeSessionRecordChannelKeys(record, channelKey, forceCurrentRoute
|
|
|
1086
835
|
return changed;
|
|
1087
836
|
}
|
|
1088
837
|
function transformedFileContent(filePath, relative, content, transform, sessionChannelKey) {
|
|
1089
|
-
if (transform === 'rewrite-trigger-session-paths') {
|
|
1090
|
-
if (path.basename(filePath) !== 'trigger.json')
|
|
1091
|
-
return content;
|
|
1092
|
-
const definition = JSON.parse(content);
|
|
1093
|
-
const prompt = definition?.execution?.prompt;
|
|
1094
|
-
if (typeof prompt !== 'string')
|
|
1095
|
-
return content;
|
|
1096
|
-
const rewritten = rewriteLegacyTriggerSessionPaths(prompt, definition.agentAid);
|
|
1097
|
-
return rewritten === prompt ? content : JSON.stringify({ ...definition, execution: { ...definition.execution, prompt: rewritten } }, null, 2) + '\n';
|
|
1098
|
-
}
|
|
1099
838
|
if (transform !== 'normalize-session-channel-key')
|
|
1100
839
|
return content;
|
|
1101
840
|
const baseName = path.basename(filePath);
|
|
@@ -1199,10 +938,10 @@ function completedCopyProof(root, operation) {
|
|
|
1199
938
|
}))
|
|
1200
939
|
.sort((left, right) => right.mtime - left.mtime);
|
|
1201
940
|
for (const { manifest } of manifests) {
|
|
1202
|
-
if (manifest?.state !== 'completed')
|
|
941
|
+
if (manifest?.state !== 'completed' && manifest?.state !== 'completed_with_errors')
|
|
1203
942
|
continue;
|
|
1204
943
|
const proof = manifest.operations?.find(candidate => operationTopologyKey(candidate) === operationTopologyKey(operation)
|
|
1205
|
-
&&
|
|
944
|
+
&& ['committed', 'skipped', 'acknowledged'].includes(candidate.status)
|
|
1206
945
|
&& typeof candidate.sourceHash === 'string');
|
|
1207
946
|
if (proof)
|
|
1208
947
|
return proof;
|
|
@@ -1211,24 +950,41 @@ function completedCopyProof(root, operation) {
|
|
|
1211
950
|
catch { }
|
|
1212
951
|
return undefined;
|
|
1213
952
|
}
|
|
1214
|
-
function assertFinalizableOperation(root, operation) {
|
|
953
|
+
function assertFinalizableOperation(root, operation, archivedSources = new Set()) {
|
|
1215
954
|
const checkSource = (source, expectedHash) => {
|
|
1216
955
|
if (!expectedHash)
|
|
1217
956
|
throw new Error(`migration source has no verified hash: ${source}`);
|
|
1218
|
-
if (!fs.existsSync(source))
|
|
957
|
+
if (!fs.existsSync(source)) {
|
|
958
|
+
if (archivedSources.has(path.resolve(source)))
|
|
959
|
+
return;
|
|
1219
960
|
throw new Error(`migration source is missing: ${source}`);
|
|
961
|
+
}
|
|
1220
962
|
if (treeHash(source, operation.exclude) !== expectedHash) {
|
|
1221
963
|
throw new Error(`migration source changed after verification: ${source}`);
|
|
1222
964
|
}
|
|
1223
965
|
};
|
|
966
|
+
if (operation.kind === 'discard') {
|
|
967
|
+
if (fs.existsSync(operation.source))
|
|
968
|
+
throw new Error(`discard source remains: ${operation.source}`);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
1224
971
|
if (operation.status === 'acknowledged') {
|
|
1225
972
|
// Acknowledged operations deliberately do not copy a live destination a
|
|
1226
973
|
// second time. Resolve the earlier completed manifest that proved the
|
|
1227
974
|
// identical source and topology before making that source removable.
|
|
1228
|
-
if (operation.kind !== 'copy')
|
|
975
|
+
if (operation.kind !== 'copy' && operation.kind !== 'merge-copy' && operation.kind !== 'contact-audit-partition') {
|
|
1229
976
|
throw new Error(`unsupported acknowledged migration operation: ${operation.id}`);
|
|
977
|
+
}
|
|
1230
978
|
const proof = completedCopyProof(root, operation);
|
|
1231
|
-
|
|
979
|
+
if (operation.kind === 'merge-copy') {
|
|
980
|
+
for (const source of operationSources(operation)) {
|
|
981
|
+
checkSource(source, proof?.sourceHashes?.[source] ?? operation.sourceHashes?.[source]
|
|
982
|
+
?? (source === operation.source ? proof?.sourceHash ?? operation.sourceHash : undefined));
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
else {
|
|
986
|
+
checkSource(operation.source, proof?.sourceHash ?? operation.sourceHash);
|
|
987
|
+
}
|
|
1232
988
|
return;
|
|
1233
989
|
}
|
|
1234
990
|
if (operation.kind === 'merge-copy') {
|
|
@@ -1239,31 +995,29 @@ function assertFinalizableOperation(root, operation) {
|
|
|
1239
995
|
}
|
|
1240
996
|
checkSource(operation.source, operation.sourceHash);
|
|
1241
997
|
}
|
|
1242
|
-
function finalizationSourcePaths(root, manifest) {
|
|
1243
|
-
if (manifest.state !== 'completed') {
|
|
998
|
+
function finalizationSourcePaths(root, manifest, existing) {
|
|
999
|
+
if (manifest.state !== 'completed' && manifest.state !== 'completed_with_errors') {
|
|
1244
1000
|
throw new Error(`only a completed migration can be finalized: ${manifest.id}`);
|
|
1245
1001
|
}
|
|
1246
|
-
const
|
|
1002
|
+
const archivedSources = new Set((existing?.sourcePaths ?? []).map(source => path.resolve(root, source)));
|
|
1003
|
+
const allSources = new Set(archivedSources);
|
|
1247
1004
|
for (const operation of manifest.operations) {
|
|
1248
|
-
|
|
1249
|
-
|
|
1005
|
+
const terminal = ['committed', 'skipped', 'acknowledged'].includes(operation.status);
|
|
1006
|
+
if (!terminal) {
|
|
1007
|
+
if (manifest.state === 'completed')
|
|
1008
|
+
throw new Error(`migration has an unfinished operation: ${operation.id}`);
|
|
1009
|
+
continue;
|
|
1010
|
+
}
|
|
1011
|
+
if (operation.kind === 'discard') {
|
|
1012
|
+
assertFinalizableOperation(root, operation, archivedSources);
|
|
1013
|
+
continue;
|
|
1250
1014
|
}
|
|
1251
|
-
assertFinalizableOperation(root, operation);
|
|
1015
|
+
assertFinalizableOperation(root, operation, archivedSources);
|
|
1252
1016
|
for (const source of operationSources(operation)) {
|
|
1253
1017
|
rootRelative(root, source);
|
|
1254
1018
|
allSources.add(path.resolve(source));
|
|
1255
1019
|
}
|
|
1256
1020
|
}
|
|
1257
|
-
for (const [source, expectedHash] of Object.entries(manifest.supersededSourceHashes ?? {})) {
|
|
1258
|
-
const absolute = path.resolve(source);
|
|
1259
|
-
rootRelative(root, absolute);
|
|
1260
|
-
if (!fs.existsSync(absolute))
|
|
1261
|
-
throw new Error(`superseded migration source is missing: ${absolute}`);
|
|
1262
|
-
if (treeHash(absolute) !== expectedHash) {
|
|
1263
|
-
throw new Error(`superseded migration source changed after planning: ${absolute}`);
|
|
1264
|
-
}
|
|
1265
|
-
allSources.add(absolute);
|
|
1266
|
-
}
|
|
1267
1021
|
// A parent copy may intentionally omit a payload (currently Feishu dedup).
|
|
1268
1022
|
// Do not let purging that parent remove an omitted file unless an independent
|
|
1269
1023
|
// completed operation has verified that payload too.
|
|
@@ -1348,44 +1102,71 @@ function pruneEmptyLegacyParents(root, sourcePaths) {
|
|
|
1348
1102
|
}
|
|
1349
1103
|
}
|
|
1350
1104
|
}
|
|
1351
|
-
function pruneEmptyLegacyDirectories(root) {
|
|
1352
|
-
// Some legacy roots contain empty per-Agent directories that never produced
|
|
1353
|
-
// a migration operation. Remove only empty children of known legacy roots;
|
|
1354
|
-
// unresolved directories containing data are left untouched.
|
|
1355
|
-
const roots = [path.join(root, 'data', 'triggers')];
|
|
1356
|
-
const pruneChildren = (directory) => {
|
|
1357
|
-
let entries;
|
|
1358
|
-
try {
|
|
1359
|
-
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
1360
|
-
}
|
|
1361
|
-
catch {
|
|
1362
|
-
return;
|
|
1363
|
-
}
|
|
1364
|
-
for (const entry of entries) {
|
|
1365
|
-
if (!entry.isDirectory())
|
|
1366
|
-
continue;
|
|
1367
|
-
const child = path.join(directory, entry.name);
|
|
1368
|
-
pruneChildren(child);
|
|
1369
|
-
try {
|
|
1370
|
-
if (fs.readdirSync(child).length === 0)
|
|
1371
|
-
fs.rmdirSync(child);
|
|
1372
|
-
}
|
|
1373
|
-
catch { }
|
|
1374
|
-
}
|
|
1375
|
-
};
|
|
1376
|
-
for (const directory of roots)
|
|
1377
|
-
pruneChildren(directory);
|
|
1378
|
-
}
|
|
1379
1105
|
function removeMigrationStaging(manifest) {
|
|
1380
1106
|
try {
|
|
1381
1107
|
fs.rmSync(migrationStagingDir(manifest.root, manifest.id), { recursive: true, force: true });
|
|
1382
1108
|
}
|
|
1383
1109
|
catch { }
|
|
1384
1110
|
}
|
|
1111
|
+
function createMigrationArchive(root, archivePath, sourcePaths, existingArchivePath) {
|
|
1112
|
+
const archiveRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'evolcore-migration-'));
|
|
1113
|
+
const temporary = `${archivePath}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
1114
|
+
const tar = systemTar();
|
|
1115
|
+
try {
|
|
1116
|
+
if (existingArchivePath) {
|
|
1117
|
+
execFileSync(tar, ['-xzf', existingArchivePath, '-C', archiveRoot], {
|
|
1118
|
+
stdio: 'pipe',
|
|
1119
|
+
windowsHide: process.platform === 'win32',
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
for (const source of sourcePaths) {
|
|
1123
|
+
if (!fs.existsSync(source))
|
|
1124
|
+
continue;
|
|
1125
|
+
copyTree(source, path.join(archiveRoot, rootRelative(root, source)));
|
|
1126
|
+
}
|
|
1127
|
+
const relativeSources = sourcePaths.map(source => rootRelative(root, source));
|
|
1128
|
+
for (const relative of relativeSources) {
|
|
1129
|
+
if (!fs.existsSync(path.join(archiveRoot, relative))) {
|
|
1130
|
+
throw new Error(`migration archive source is missing: ${relative}`);
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
fs.mkdirSync(path.dirname(archivePath), { recursive: true });
|
|
1134
|
+
if (relativeSources.length > 0) {
|
|
1135
|
+
execFileSync(tar, ['-czf', temporary, ...relativeSources], {
|
|
1136
|
+
cwd: archiveRoot,
|
|
1137
|
+
stdio: 'pipe',
|
|
1138
|
+
windowsHide: process.platform === 'win32',
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
else {
|
|
1142
|
+
execFileSync(tar, ['-czf', temporary, '--files-from', '-'], {
|
|
1143
|
+
cwd: archiveRoot,
|
|
1144
|
+
input: '',
|
|
1145
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1146
|
+
windowsHide: process.platform === 'win32',
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
verifyArchiveContains(temporary, relativeSources, tar);
|
|
1150
|
+
fs.renameSync(temporary, archivePath);
|
|
1151
|
+
}
|
|
1152
|
+
catch (error) {
|
|
1153
|
+
try {
|
|
1154
|
+
fs.rmSync(temporary, { force: true });
|
|
1155
|
+
}
|
|
1156
|
+
catch { }
|
|
1157
|
+
throw new Error(`cannot create migration archive: ${error instanceof Error ? error.message : String(error)}`);
|
|
1158
|
+
}
|
|
1159
|
+
finally {
|
|
1160
|
+
try {
|
|
1161
|
+
fs.rmSync(archiveRoot, { recursive: true, force: true });
|
|
1162
|
+
}
|
|
1163
|
+
catch { }
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1385
1166
|
/**
|
|
1386
|
-
* Archive and remove
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1167
|
+
* Archive and remove source paths already proven by a migration. A manifest
|
|
1168
|
+
* with independent failures is finalized incrementally: successful sources
|
|
1169
|
+
* are archived and removed while failed sources remain for a later retry.
|
|
1389
1170
|
*/
|
|
1390
1171
|
export function finalizeDataMigration(root, manifest) {
|
|
1391
1172
|
if (path.resolve(manifest.root) !== path.resolve(root)) {
|
|
@@ -1396,43 +1177,27 @@ export function finalizeDataMigration(root, manifest) {
|
|
|
1396
1177
|
if (!fs.existsSync(existing.archivePath) || archiveHash(existing.archivePath) !== existing.archiveSha256) {
|
|
1397
1178
|
throw new Error(`existing migration archive is missing or corrupt: ${existing.archivePath}`);
|
|
1398
1179
|
}
|
|
1399
|
-
pruneEmptyLegacyDirectories(root);
|
|
1400
|
-
removeMigrationStaging(manifest);
|
|
1401
|
-
return existing;
|
|
1402
1180
|
}
|
|
1403
|
-
const absoluteSources = finalizationSourcePaths(root, manifest);
|
|
1181
|
+
const absoluteSources = finalizationSourcePaths(root, manifest, existing);
|
|
1404
1182
|
const sourcePaths = absoluteSources.map(source => rootRelative(root, source));
|
|
1405
|
-
const archivePath =
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
const tar = systemTar();
|
|
1412
|
-
try {
|
|
1413
|
-
execFileSync(tar, ['-czf', temporary, ...sourcePaths], {
|
|
1414
|
-
cwd: root,
|
|
1415
|
-
stdio: 'pipe',
|
|
1416
|
-
windowsHide: process.platform === 'win32',
|
|
1417
|
-
});
|
|
1418
|
-
verifyArchiveContains(temporary, sourcePaths, tar);
|
|
1419
|
-
fs.renameSync(temporary, archivePath);
|
|
1420
|
-
}
|
|
1421
|
-
catch (error) {
|
|
1422
|
-
try {
|
|
1423
|
-
fs.rmSync(temporary, { force: true });
|
|
1424
|
-
}
|
|
1425
|
-
catch { }
|
|
1426
|
-
throw new Error(`cannot create migration archive: ${error instanceof Error ? error.message : String(error)}`);
|
|
1183
|
+
const archivePath = existing?.archivePath
|
|
1184
|
+
?? path.join(root, 'backups', 'data-migration', `${manifest.id}-legacy-sources.tar.gz`);
|
|
1185
|
+
const liveSources = absoluteSources.filter(source => fs.existsSync(source));
|
|
1186
|
+
const archiveNeedsUpdate = !existing || liveSources.length > 0;
|
|
1187
|
+
if (archiveNeedsUpdate) {
|
|
1188
|
+
createMigrationArchive(root, archivePath, absoluteSources, existing?.archivePath);
|
|
1427
1189
|
}
|
|
1428
1190
|
// Recheck immediately before deletion in case another writer appeared in
|
|
1429
1191
|
// the maintenance window after the archive was created.
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1192
|
+
const archivedSources = new Set(sourcePaths.map(source => path.resolve(root, source)));
|
|
1193
|
+
for (const operation of manifest.operations) {
|
|
1194
|
+
if (['committed', 'skipped', 'acknowledged'].includes(operation.status)) {
|
|
1195
|
+
assertFinalizableOperation(root, operation, archivedSources);
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
for (const source of liveSources)
|
|
1433
1199
|
fs.rmSync(source, { recursive: true, force: false });
|
|
1434
|
-
pruneEmptyLegacyParents(root,
|
|
1435
|
-
pruneEmptyLegacyDirectories(root);
|
|
1200
|
+
pruneEmptyLegacyParents(root, liveSources);
|
|
1436
1201
|
const finalization = {
|
|
1437
1202
|
schemaVersion: 1,
|
|
1438
1203
|
migrationId: manifest.id,
|
|
@@ -1441,6 +1206,7 @@ export function finalizeDataMigration(root, manifest) {
|
|
|
1441
1206
|
archivePath,
|
|
1442
1207
|
archiveSha256: archiveHash(archivePath),
|
|
1443
1208
|
sourcePaths,
|
|
1209
|
+
partial: manifest.state !== 'completed',
|
|
1444
1210
|
};
|
|
1445
1211
|
writeFinalization(finalization);
|
|
1446
1212
|
removeMigrationStaging(manifest);
|
|
@@ -1502,7 +1268,7 @@ function executeContactAudit(manifest, operation, checkpoint) {
|
|
|
1502
1268
|
groups.set(record.selfAid, records);
|
|
1503
1269
|
}
|
|
1504
1270
|
for (const [aid, lines] of groups) {
|
|
1505
|
-
const destination =
|
|
1271
|
+
const destination = contactAuditDestination(manifest.root, aid);
|
|
1506
1272
|
const content = lines.join('\n') + '\n';
|
|
1507
1273
|
const existing = fs.existsSync(destination) ? fs.readFileSync(destination, 'utf8') : undefined;
|
|
1508
1274
|
if (existing !== undefined)
|
|
@@ -1571,16 +1337,6 @@ function executeCopy(operation, checkpoint) {
|
|
|
1571
1337
|
operation.verificationError = undefined;
|
|
1572
1338
|
return;
|
|
1573
1339
|
}
|
|
1574
|
-
if (operation.transform === 'rewrite-trigger-session-paths') {
|
|
1575
|
-
reconcileLiveTriggerDestination(operation);
|
|
1576
|
-
operation.preservedLiveTrigger = true;
|
|
1577
|
-
operation.destinationHash = treeHash(operation.destination);
|
|
1578
|
-
operation.status = 'committed';
|
|
1579
|
-
operation.error = undefined;
|
|
1580
|
-
operation.verificationError = undefined;
|
|
1581
|
-
checkpoint();
|
|
1582
|
-
return;
|
|
1583
|
-
}
|
|
1584
1340
|
if (operation.transform === 'normalize-session-channel-key'
|
|
1585
1341
|
&& (operation.allowExistingDestinationMerge || canMergeSessionDestination(operation))) {
|
|
1586
1342
|
// A control-AID rotation can legitimately converge an old transcript
|
|
@@ -1627,6 +1383,18 @@ function executeCopy(operation, checkpoint) {
|
|
|
1627
1383
|
operation.error = undefined;
|
|
1628
1384
|
operation.verificationError = undefined;
|
|
1629
1385
|
}
|
|
1386
|
+
function executeDiscard(operation, checkpoint) {
|
|
1387
|
+
if (fs.existsSync(operation.source)) {
|
|
1388
|
+
operation.sourceHash = treeHash(operation.source);
|
|
1389
|
+
checkpoint();
|
|
1390
|
+
fs.rmSync(operation.source, { recursive: true, force: false });
|
|
1391
|
+
}
|
|
1392
|
+
if (fs.existsSync(operation.source))
|
|
1393
|
+
throw new Error(`discard failed: ${operation.source}`);
|
|
1394
|
+
operation.status = 'skipped';
|
|
1395
|
+
operation.error = undefined;
|
|
1396
|
+
operation.verificationError = undefined;
|
|
1397
|
+
}
|
|
1630
1398
|
function operationSources(operation) {
|
|
1631
1399
|
return [...new Set([operation.source, ...(operation.sources ?? [])])];
|
|
1632
1400
|
}
|
|
@@ -1744,7 +1512,6 @@ function executeMergeCopy(operation, checkpoint) {
|
|
|
1744
1512
|
* later items still run and a subsequent `--resume` only retries failures.
|
|
1745
1513
|
*/
|
|
1746
1514
|
export function applyDataMigration(manifest) {
|
|
1747
|
-
restoreLegacyControlMergeEligibility(manifest);
|
|
1748
1515
|
manifest.state = 'running';
|
|
1749
1516
|
writeManifest(manifest);
|
|
1750
1517
|
for (const operation of manifest.operations) {
|
|
@@ -1756,8 +1523,10 @@ export function applyDataMigration(manifest) {
|
|
|
1756
1523
|
executeCopy(operation, checkpoint);
|
|
1757
1524
|
else if (operation.kind === 'merge-copy')
|
|
1758
1525
|
executeMergeCopy(operation, checkpoint);
|
|
1759
|
-
else
|
|
1526
|
+
else if (operation.kind === 'contact-audit-partition')
|
|
1760
1527
|
executeContactAudit(manifest, operation, checkpoint);
|
|
1528
|
+
else
|
|
1529
|
+
executeDiscard(operation, checkpoint);
|
|
1761
1530
|
}
|
|
1762
1531
|
catch (error) {
|
|
1763
1532
|
operation.status = 'error';
|
|
@@ -1861,11 +1630,11 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
|
|
|
1861
1630
|
for (const operation of manifest.operations) {
|
|
1862
1631
|
if (operation.status === 'acknowledged')
|
|
1863
1632
|
continue;
|
|
1864
|
-
if (operation.
|
|
1633
|
+
if (operation.kind === 'discard') {
|
|
1865
1634
|
try {
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
operation.status = '
|
|
1635
|
+
if (fs.existsSync(operation.source))
|
|
1636
|
+
throw new Error(`discard source remains: ${operation.source}`);
|
|
1637
|
+
operation.status = 'skipped';
|
|
1869
1638
|
operation.error = undefined;
|
|
1870
1639
|
operation.verificationError = undefined;
|
|
1871
1640
|
}
|
|
@@ -1892,7 +1661,8 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
|
|
|
1892
1661
|
throw new Error(`destination missing: ${destination}`);
|
|
1893
1662
|
strictJsonValidation(destination);
|
|
1894
1663
|
const expectedHash = operation.destinationHashes?.[destination];
|
|
1895
|
-
|
|
1664
|
+
const sourceWasArchived = !fs.existsSync(operation.source) && sourceArchived(operation.source);
|
|
1665
|
+
if (strictTargetContent && !sourceWasArchived && expectedHash && hashContent(fs.readFileSync(destination, 'utf8')) !== expectedHash) {
|
|
1896
1666
|
throw new Error(`content hash mismatch: ${destination}`);
|
|
1897
1667
|
}
|
|
1898
1668
|
}
|
|
@@ -1925,8 +1695,10 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
|
|
|
1925
1695
|
operation.destinationHash = destinationHash;
|
|
1926
1696
|
if (!operation.expectedHash)
|
|
1927
1697
|
throw new Error('expected output hash missing');
|
|
1928
|
-
|
|
1698
|
+
const allSourcesArchived = liveSources.length === 0 && removedSources.every(source => sourceArchived(source));
|
|
1699
|
+
if (strictTargetContent && !allSourcesArchived && operation.expectedHash !== destinationHash) {
|
|
1929
1700
|
throw new Error('content hash mismatch');
|
|
1701
|
+
}
|
|
1930
1702
|
operation.status = 'committed';
|
|
1931
1703
|
operation.error = undefined;
|
|
1932
1704
|
operation.verificationError = undefined;
|
|
@@ -1948,6 +1720,7 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
|
|
|
1948
1720
|
validateTree(operation.destination);
|
|
1949
1721
|
let sourceHash = operation.sourceHash;
|
|
1950
1722
|
let expectedHash = operation.expectedHash;
|
|
1723
|
+
let sourceWasArchived = false;
|
|
1951
1724
|
if (fs.existsSync(operation.source)) {
|
|
1952
1725
|
sourceHash = treeHash(operation.source, operation.exclude);
|
|
1953
1726
|
if (operation.sourceHash && operation.sourceHash !== sourceHash) {
|
|
@@ -1961,6 +1734,9 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
|
|
|
1961
1734
|
else if (!sourceArchived(operation.source)) {
|
|
1962
1735
|
throw new Error(`source missing: ${operation.source}`);
|
|
1963
1736
|
}
|
|
1737
|
+
else {
|
|
1738
|
+
sourceWasArchived = true;
|
|
1739
|
+
}
|
|
1964
1740
|
if (!expectedHash)
|
|
1965
1741
|
throw new Error('expected output hash missing');
|
|
1966
1742
|
const destinationHash = treeHash(operation.destination);
|
|
@@ -1971,8 +1747,9 @@ export function verifyDataMigration(manifest, { strictTargetContent = true } = {
|
|
|
1971
1747
|
if (!strictTargetContent && operation.status === 'error' && operation.error?.startsWith('destination conflict') && expectedHash !== destinationHash) {
|
|
1972
1748
|
throw new Error(operation.error ?? 'content hash mismatch');
|
|
1973
1749
|
}
|
|
1974
|
-
if (strictTargetContent && expectedHash !== destinationHash)
|
|
1750
|
+
if (strictTargetContent && !sourceWasArchived && expectedHash !== destinationHash) {
|
|
1975
1751
|
throw new Error('content hash mismatch');
|
|
1752
|
+
}
|
|
1976
1753
|
if (operation.status !== 'skipped')
|
|
1977
1754
|
operation.status = 'committed';
|
|
1978
1755
|
operation.error = undefined;
|