evolcore 0.0.5 → 0.0.6
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 +43 -0
- package/README.md +4 -12
- package/dist/agents/baseagent.js +39 -0
- package/dist/agents/claude-runner.js +10 -2
- package/dist/agents/codex-runner.js +37 -8
- package/dist/agents/ecagent-runner.js +1159 -0
- package/dist/agents/gemini-runner.js +4 -1
- package/dist/agents/runner-types.js +20 -4
- package/dist/aun/msg/history.js +32 -9
- package/dist/aun/outbox.js +4 -9
- package/dist/channels/aun.js +32 -6
- package/dist/channels/daemon.js +7 -4
- package/dist/channels/dingtalk.js +3 -5
- package/dist/channels/feishu.js +16 -108
- package/dist/channels/wechat.js +4 -4
- package/dist/channels/wecom.js +4 -6
- package/dist/cli/agent-command.js +1 -1
- package/dist/cli/agent.js +42 -11
- package/dist/cli/aun-commands.js +50 -3
- package/dist/cli/daemon-commands.js +81 -6
- package/dist/cli/data-command.js +132 -0
- package/dist/cli/index.js +11 -3
- package/dist/cli/init.js +76 -17
- package/dist/cli/net-check.js +9 -20
- package/dist/cli/restart-monitor.js +11 -2
- package/dist/cli/trigger-command.js +1 -1
- package/dist/cli/watch-msg.js +12 -25
- package/dist/config/builtin-roles.js +3 -1
- package/dist/config/config-field-policy.js +1 -1
- package/dist/config/gateway-config.js +21 -4
- package/dist/config/resolved-config-op.js +10 -0
- package/dist/core/command/command-handler.js +2 -2
- package/dist/core/command/menu-handler.js +11 -9
- package/dist/core/command/role-menu.js +11 -0
- package/dist/core/command/slash-handler.js +18 -14
- package/dist/core/data-migration.js +1434 -0
- package/dist/core/handoff/store.js +14 -4
- package/dist/core/inference/text-inference.js +42 -1
- package/dist/core/message/pending-hints.js +1 -1
- package/dist/core/message/response-engine.js +406 -167
- package/dist/core/message/retry-scheduler.js +71 -0
- package/dist/core/model/model-catalog.js +7 -2
- package/dist/core/model/model-fallback.js +81 -0
- package/dist/core/protected-paths.js +41 -8
- package/dist/core/session/adapters/ecagent-session-file-adapter.js +112 -0
- package/dist/core/session/session-fs-store.js +283 -59
- package/dist/core/session/session-manager.js +19 -21
- package/dist/core/session/session-title.js +1 -0
- package/dist/core/session/session-turn-coordinator.js +8 -1
- package/dist/core/system-channels.js +1 -0
- package/dist/index.js +10 -9
- package/dist/paths.js +35 -27
- package/dist/trigger/feedback.js +16 -0
- package/dist/trigger/legacy-session-history.js +19 -0
- package/dist/trigger/manager.js +12 -1
- package/dist/trigger/parser.js +4 -2
- package/dist/trigger/scheduler.js +185 -15
- package/dist/trigger/session-lock.js +62 -0
- package/dist/trigger/state.js +64 -0
- package/dist/trigger/validation.js +32 -2
- package/dist/utils/aid-bind.js +1 -1
- package/dist/utils/error-utils.js +9 -0
- package/dist/utils/welcome.js +2 -3
- package/ecagent/LICENSE +21 -0
- package/ecagent/NOTICE +11 -0
- package/ecagent/README.md +61 -0
- package/ecagent/dist/agent-loop.d.ts +24 -0
- package/ecagent/dist/agent-loop.d.ts.map +1 -0
- package/ecagent/dist/agent-loop.js +547 -0
- package/ecagent/dist/agent-loop.js.map +1 -0
- package/ecagent/dist/agent.d.ts +127 -0
- package/ecagent/dist/agent.d.ts.map +1 -0
- package/ecagent/dist/agent.js +386 -0
- package/ecagent/dist/agent.js.map +1 -0
- package/ecagent/dist/harness/agent-harness.d.ts +95 -0
- package/ecagent/dist/harness/agent-harness.d.ts.map +1 -0
- package/ecagent/dist/harness/agent-harness.js +926 -0
- package/ecagent/dist/harness/agent-harness.js.map +1 -0
- package/ecagent/dist/harness/compaction/branch-summarization.d.ts +51 -0
- package/ecagent/dist/harness/compaction/branch-summarization.d.ts.map +1 -0
- package/ecagent/dist/harness/compaction/branch-summarization.js +174 -0
- package/ecagent/dist/harness/compaction/branch-summarization.js.map +1 -0
- package/ecagent/dist/harness/compaction/compaction.d.ts +95 -0
- package/ecagent/dist/harness/compaction/compaction.d.ts.map +1 -0
- package/ecagent/dist/harness/compaction/compaction.js +528 -0
- package/ecagent/dist/harness/compaction/compaction.js.map +1 -0
- package/ecagent/dist/harness/compaction/utils.d.ts +25 -0
- package/ecagent/dist/harness/compaction/utils.d.ts.map +1 -0
- package/ecagent/dist/harness/compaction/utils.js +131 -0
- package/ecagent/dist/harness/compaction/utils.js.map +1 -0
- package/ecagent/dist/harness/env/nodejs.d.ts +51 -0
- package/ecagent/dist/harness/env/nodejs.d.ts.map +1 -0
- package/ecagent/dist/harness/env/nodejs.js +513 -0
- package/ecagent/dist/harness/env/nodejs.js.map +1 -0
- package/ecagent/dist/harness/messages.d.ts +51 -0
- package/ecagent/dist/harness/messages.d.ts.map +1 -0
- package/ecagent/dist/harness/messages.js +102 -0
- package/ecagent/dist/harness/messages.js.map +1 -0
- package/ecagent/dist/harness/prompt-templates.d.ts +48 -0
- package/ecagent/dist/harness/prompt-templates.d.ts.map +1 -0
- package/ecagent/dist/harness/prompt-templates.js +230 -0
- package/ecagent/dist/harness/prompt-templates.js.map +1 -0
- package/ecagent/dist/harness/session/jsonl-repo.d.ts +26 -0
- package/ecagent/dist/harness/session/jsonl-repo.d.ts.map +1 -0
- package/ecagent/dist/harness/session/jsonl-repo.js +100 -0
- package/ecagent/dist/harness/session/jsonl-repo.js.map +1 -0
- package/ecagent/dist/harness/session/jsonl-storage.d.ts +34 -0
- package/ecagent/dist/harness/session/jsonl-storage.d.ts.map +1 -0
- package/ecagent/dist/harness/session/jsonl-storage.js +234 -0
- package/ecagent/dist/harness/session/jsonl-storage.js.map +1 -0
- package/ecagent/dist/harness/session/memory-repo.d.ts +18 -0
- package/ecagent/dist/harness/session/memory-repo.d.ts.map +1 -0
- package/ecagent/dist/harness/session/memory-repo.js +44 -0
- package/ecagent/dist/harness/session/memory-repo.js.map +1 -0
- package/ecagent/dist/harness/session/memory-storage.d.ts +25 -0
- package/ecagent/dist/harness/session/memory-storage.d.ts.map +1 -0
- package/ecagent/dist/harness/session/memory-storage.js +111 -0
- package/ecagent/dist/harness/session/memory-storage.js.map +1 -0
- package/ecagent/dist/harness/session/repo-utils.d.ts +11 -0
- package/ecagent/dist/harness/session/repo-utils.d.ts.map +1 -0
- package/ecagent/dist/harness/session/repo-utils.js +39 -0
- package/ecagent/dist/harness/session/repo-utils.js.map +1 -0
- package/ecagent/dist/harness/session/session.d.ts +47 -0
- package/ecagent/dist/harness/session/session.d.ts.map +1 -0
- package/ecagent/dist/harness/session/session.js +245 -0
- package/ecagent/dist/harness/session/session.js.map +1 -0
- package/ecagent/dist/harness/session/uuid.d.ts +2 -0
- package/ecagent/dist/harness/session/uuid.d.ts.map +1 -0
- package/ecagent/dist/harness/session/uuid.js +50 -0
- package/ecagent/dist/harness/session/uuid.js.map +1 -0
- package/ecagent/dist/harness/skills.d.ts +44 -0
- package/ecagent/dist/harness/skills.d.ts.map +1 -0
- package/ecagent/dist/harness/skills.js +311 -0
- package/ecagent/dist/harness/skills.js.map +1 -0
- package/ecagent/dist/harness/system-prompt.d.ts +3 -0
- package/ecagent/dist/harness/system-prompt.d.ts.map +1 -0
- package/ecagent/dist/harness/system-prompt.js +30 -0
- package/ecagent/dist/harness/system-prompt.js.map +1 -0
- package/ecagent/dist/harness/types.d.ts +619 -0
- package/ecagent/dist/harness/types.d.ts.map +1 -0
- package/ecagent/dist/harness/types.js +81 -0
- package/ecagent/dist/harness/types.js.map +1 -0
- package/ecagent/dist/harness/utils/shell-output.d.ts +14 -0
- package/ecagent/dist/harness/utils/shell-output.d.ts.map +1 -0
- package/ecagent/dist/harness/utils/shell-output.js +126 -0
- package/ecagent/dist/harness/utils/shell-output.js.map +1 -0
- package/ecagent/dist/harness/utils/truncate.d.ts +70 -0
- package/ecagent/dist/harness/utils/truncate.d.ts.map +1 -0
- package/ecagent/dist/harness/utils/truncate.js +290 -0
- package/ecagent/dist/harness/utils/truncate.js.map +1 -0
- package/ecagent/dist/index.d.ts +22 -0
- package/ecagent/dist/index.d.ts.map +1 -0
- package/ecagent/dist/index.js +27 -0
- package/ecagent/dist/index.js.map +1 -0
- package/ecagent/dist/node.d.ts +3 -0
- package/ecagent/dist/node.d.ts.map +1 -0
- package/ecagent/dist/node.js +3 -0
- package/ecagent/dist/node.js.map +1 -0
- package/ecagent/dist/proxy.d.ts +69 -0
- package/ecagent/dist/proxy.d.ts.map +1 -0
- package/ecagent/dist/proxy.js +278 -0
- package/ecagent/dist/proxy.js.map +1 -0
- package/ecagent/dist/runtime/api/lazy.d.ts +15 -0
- package/ecagent/dist/runtime/api/lazy.d.ts.map +1 -0
- package/ecagent/dist/runtime/api/lazy.js +59 -0
- package/ecagent/dist/runtime/api/lazy.js.map +1 -0
- package/ecagent/dist/runtime/api/pi-messages.d.ts +97 -0
- package/ecagent/dist/runtime/api/pi-messages.d.ts.map +1 -0
- package/ecagent/dist/runtime/api/pi-messages.js +307 -0
- package/ecagent/dist/runtime/api/pi-messages.js.map +1 -0
- package/ecagent/dist/runtime/api/pi-messages.lazy.d.ts +3 -0
- package/ecagent/dist/runtime/api/pi-messages.lazy.d.ts.map +1 -0
- package/ecagent/dist/runtime/api/pi-messages.lazy.js +3 -0
- package/ecagent/dist/runtime/api/pi-messages.lazy.js.map +1 -0
- package/ecagent/dist/runtime/auth/context.d.ts +7 -0
- package/ecagent/dist/runtime/auth/context.d.ts.map +1 -0
- package/ecagent/dist/runtime/auth/context.js +42 -0
- package/ecagent/dist/runtime/auth/context.js.map +1 -0
- package/ecagent/dist/runtime/auth/credential-store.d.ts +16 -0
- package/ecagent/dist/runtime/auth/credential-store.d.ts.map +1 -0
- package/ecagent/dist/runtime/auth/credential-store.js +39 -0
- package/ecagent/dist/runtime/auth/credential-store.js.map +1 -0
- package/ecagent/dist/runtime/auth/helpers.d.ts +20 -0
- package/ecagent/dist/runtime/auth/helpers.d.ts.map +1 -0
- package/ecagent/dist/runtime/auth/helpers.js +46 -0
- package/ecagent/dist/runtime/auth/helpers.js.map +1 -0
- package/ecagent/dist/runtime/auth/resolve.d.ts +26 -0
- package/ecagent/dist/runtime/auth/resolve.d.ts.map +1 -0
- package/ecagent/dist/runtime/auth/resolve.js +100 -0
- package/ecagent/dist/runtime/auth/resolve.js.map +1 -0
- package/ecagent/dist/runtime/auth/types.d.ts +180 -0
- package/ecagent/dist/runtime/auth/types.d.ts.map +1 -0
- package/ecagent/dist/runtime/auth/types.js +2 -0
- package/ecagent/dist/runtime/auth/types.js.map +1 -0
- package/ecagent/dist/runtime/compat.d.ts +28 -0
- package/ecagent/dist/runtime/compat.d.ts.map +1 -0
- package/ecagent/dist/runtime/compat.js +86 -0
- package/ecagent/dist/runtime/compat.js.map +1 -0
- package/ecagent/dist/runtime/index.d.ts +20 -0
- package/ecagent/dist/runtime/index.d.ts.map +1 -0
- package/ecagent/dist/runtime/index.js +17 -0
- package/ecagent/dist/runtime/index.js.map +1 -0
- package/ecagent/dist/runtime/models.d.ts +142 -0
- package/ecagent/dist/runtime/models.d.ts.map +1 -0
- package/ecagent/dist/runtime/models.js +245 -0
- package/ecagent/dist/runtime/models.js.map +1 -0
- package/ecagent/dist/runtime/oauth.d.ts +2 -0
- package/ecagent/dist/runtime/oauth.d.ts.map +1 -0
- package/ecagent/dist/runtime/oauth.js +2 -0
- package/ecagent/dist/runtime/oauth.js.map +1 -0
- package/ecagent/dist/runtime/providers/faux.d.ts +97 -0
- package/ecagent/dist/runtime/providers/faux.d.ts.map +1 -0
- package/ecagent/dist/runtime/providers/faux.js +395 -0
- package/ecagent/dist/runtime/providers/faux.js.map +1 -0
- package/ecagent/dist/runtime/types.d.ts +607 -0
- package/ecagent/dist/runtime/types.d.ts.map +1 -0
- package/ecagent/dist/runtime/types.js +2 -0
- package/ecagent/dist/runtime/types.js.map +1 -0
- package/ecagent/dist/runtime/utils/diagnostics.d.ts +19 -0
- package/ecagent/dist/runtime/utils/diagnostics.d.ts.map +1 -0
- package/ecagent/dist/runtime/utils/diagnostics.js +25 -0
- package/ecagent/dist/runtime/utils/diagnostics.js.map +1 -0
- package/ecagent/dist/runtime/utils/event-stream.d.ts +21 -0
- package/ecagent/dist/runtime/utils/event-stream.d.ts.map +1 -0
- package/ecagent/dist/runtime/utils/event-stream.js +77 -0
- package/ecagent/dist/runtime/utils/event-stream.js.map +1 -0
- package/ecagent/dist/runtime/utils/headers.d.ts +4 -0
- package/ecagent/dist/runtime/utils/headers.d.ts.map +1 -0
- package/ecagent/dist/runtime/utils/headers.js +18 -0
- package/ecagent/dist/runtime/utils/headers.js.map +1 -0
- package/ecagent/dist/runtime/utils/json-parse.d.ts +16 -0
- package/ecagent/dist/runtime/utils/json-parse.d.ts.map +1 -0
- package/ecagent/dist/runtime/utils/json-parse.js +113 -0
- package/ecagent/dist/runtime/utils/json-parse.js.map +1 -0
- package/ecagent/dist/runtime/utils/oauth/types.d.ts +64 -0
- package/ecagent/dist/runtime/utils/oauth/types.d.ts.map +1 -0
- package/ecagent/dist/runtime/utils/oauth/types.js +2 -0
- package/ecagent/dist/runtime/utils/oauth/types.js.map +1 -0
- package/ecagent/dist/runtime/utils/overflow.d.ts +58 -0
- package/ecagent/dist/runtime/utils/overflow.d.ts.map +1 -0
- package/ecagent/dist/runtime/utils/overflow.js +158 -0
- package/ecagent/dist/runtime/utils/overflow.js.map +1 -0
- package/ecagent/dist/runtime/utils/provider-env.d.ts +7 -0
- package/ecagent/dist/runtime/utils/provider-env.d.ts.map +1 -0
- package/ecagent/dist/runtime/utils/provider-env.js +44 -0
- package/ecagent/dist/runtime/utils/provider-env.js.map +1 -0
- package/ecagent/dist/runtime/utils/retry.d.ts +12 -0
- package/ecagent/dist/runtime/utils/retry.d.ts.map +1 -0
- package/ecagent/dist/runtime/utils/retry.js +90 -0
- package/ecagent/dist/runtime/utils/retry.js.map +1 -0
- package/ecagent/dist/runtime/utils/typebox-helpers.d.ts +17 -0
- package/ecagent/dist/runtime/utils/typebox-helpers.d.ts.map +1 -0
- package/ecagent/dist/runtime/utils/typebox-helpers.js +21 -0
- package/ecagent/dist/runtime/utils/typebox-helpers.js.map +1 -0
- package/ecagent/dist/runtime/utils/validation.d.ts +18 -0
- package/ecagent/dist/runtime/utils/validation.d.ts.map +1 -0
- package/ecagent/dist/runtime/utils/validation.js +269 -0
- package/ecagent/dist/runtime/utils/validation.js.map +1 -0
- package/ecagent/dist/types.d.ts +401 -0
- package/ecagent/dist/types.d.ts.map +1 -0
- package/ecagent/dist/types.js +2 -0
- package/ecagent/dist/types.js.map +1 -0
- package/ecagent/package.json +93 -0
- package/ecagent/src/agent-loop.ts +792 -0
- package/ecagent/src/agent.ts +575 -0
- package/ecagent/src/harness/agent-harness.ts +1029 -0
- package/ecagent/src/harness/compaction/branch-summarization.ts +261 -0
- package/ecagent/src/harness/compaction/compaction.ts +753 -0
- package/ecagent/src/harness/compaction/utils.ts +144 -0
- package/ecagent/src/harness/env/nodejs.ts +569 -0
- package/ecagent/src/harness/messages.ts +164 -0
- package/ecagent/src/harness/prompt-templates.ts +267 -0
- package/ecagent/src/harness/session/jsonl-repo.ts +179 -0
- package/ecagent/src/harness/session/jsonl-storage.ts +314 -0
- package/ecagent/src/harness/session/memory-repo.ts +50 -0
- package/ecagent/src/harness/session/memory-storage.ts +133 -0
- package/ecagent/src/harness/session/repo-utils.ts +51 -0
- package/ecagent/src/harness/session/session.ts +338 -0
- package/ecagent/src/harness/session/uuid.ts +54 -0
- package/ecagent/src/harness/skills.ts +375 -0
- package/ecagent/src/harness/system-prompt.ts +34 -0
- package/ecagent/src/harness/types.ts +838 -0
- package/ecagent/src/harness/utils/shell-output.ts +135 -0
- package/ecagent/src/harness/utils/truncate.ts +344 -0
- package/ecagent/src/index.ts +46 -0
- package/ecagent/src/node.ts +2 -0
- package/ecagent/src/proxy.ts +367 -0
- package/ecagent/src/runtime/api/lazy.ts +70 -0
- package/ecagent/src/runtime/api/pi-messages.lazy.ts +4 -0
- package/ecagent/src/runtime/api/pi-messages.ts +436 -0
- package/ecagent/src/runtime/auth/context.ts +45 -0
- package/ecagent/src/runtime/auth/credential-store.ts +47 -0
- package/ecagent/src/runtime/auth/helpers.ts +46 -0
- package/ecagent/src/runtime/auth/resolve.ts +141 -0
- package/ecagent/src/runtime/auth/types.ts +182 -0
- package/ecagent/src/runtime/compat.ts +158 -0
- package/ecagent/src/runtime/index.ts +31 -0
- package/ecagent/src/runtime/models.ts +452 -0
- package/ecagent/src/runtime/oauth.ts +1 -0
- package/ecagent/src/runtime/providers/faux.ts +538 -0
- package/ecagent/src/runtime/types.ts +718 -0
- package/ecagent/src/runtime/utils/diagnostics.ts +45 -0
- package/ecagent/src/runtime/utils/event-stream.ts +88 -0
- package/ecagent/src/runtime/utils/headers.ts +18 -0
- package/ecagent/src/runtime/utils/json-parse.ts +124 -0
- package/ecagent/src/runtime/utils/oauth/types.ts +79 -0
- package/ecagent/src/runtime/utils/overflow.ts +165 -0
- package/ecagent/src/runtime/utils/provider-env.ts +52 -0
- package/ecagent/src/runtime/utils/retry.ts +101 -0
- package/ecagent/src/runtime/utils/typebox-helpers.ts +24 -0
- package/ecagent/src/runtime/utils/validation.ts +310 -0
- package/ecagent/src/types.ts +430 -0
- package/ecagent/tsconfig.build.json +32 -0
- package/kits/schemas/_meta.json +7 -4
- package/kits/schemas/agent-config.schema.10.json +370 -0
- package/kits/schemas/defaults.schema.4.json +93 -0
- package/kits/schemas/relation-config.schema.8.json +69 -0
- package/kits/templates/roles/admin.json +1 -1
- package/package.json +22 -6
- package/skills/eclink/SKILL.md +70 -0
- package/skills/eclink/agents/openai.yaml +4 -0
- package/assets/.env.template +0 -4
|
@@ -0,0 +1,1434 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { formatChannelSessionKey, scanLegacyChatDirs, scanMetaFiles, scanNestedAgentChatDirs, scanNonCanonicalAgentChatDirs, } from './session/session-fs-store.js';
|
|
5
|
+
import { rewriteLegacyTriggerSessionPaths } from '../trigger/legacy-session-history.js';
|
|
6
|
+
const SESSION_SEEN_FILE = 'seen-messages.jsonl';
|
|
7
|
+
const CHANNEL_TYPES = new Set(['aun', 'feishu', 'wechat', 'dingtalk', 'qqbot', 'wecom']);
|
|
8
|
+
const DATA_MIGRATION_PLAN_VERSION = 3;
|
|
9
|
+
function encodeSegment(value) {
|
|
10
|
+
return value.replace(/[<>:"/\\|?*\x00-\x1F%]/g, ch => '%' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
|
|
11
|
+
}
|
|
12
|
+
function migrationDir(root) {
|
|
13
|
+
return path.join(root, 'data', 'daemon', 'migrations');
|
|
14
|
+
}
|
|
15
|
+
function manifestPath(root, id) {
|
|
16
|
+
return path.join(migrationDir(root), `${id}.json`);
|
|
17
|
+
}
|
|
18
|
+
function migrationStagingDir(root, id) {
|
|
19
|
+
return path.join(migrationDir(root), `${id}.staging`);
|
|
20
|
+
}
|
|
21
|
+
function migrationPlanSignature(operations) {
|
|
22
|
+
const topology = operations.map(operation => ({
|
|
23
|
+
kind: operation.kind,
|
|
24
|
+
source: operation.source,
|
|
25
|
+
sources: [...(operation.sources ?? [])].sort(),
|
|
26
|
+
destination: operation.destination,
|
|
27
|
+
destinations: [...(operation.destinations ?? [])].sort(),
|
|
28
|
+
exclude: [...(operation.exclude ?? [])].sort(),
|
|
29
|
+
transform: operation.transform,
|
|
30
|
+
sessionChannelKey: operation.sessionChannelKey,
|
|
31
|
+
}));
|
|
32
|
+
return crypto.createHash('sha256')
|
|
33
|
+
.update(JSON.stringify({ version: DATA_MIGRATION_PLAN_VERSION, topology }), 'utf8')
|
|
34
|
+
.digest('hex');
|
|
35
|
+
}
|
|
36
|
+
function operationTopologyKey(operation) {
|
|
37
|
+
return JSON.stringify({
|
|
38
|
+
kind: operation.kind,
|
|
39
|
+
source: operation.source,
|
|
40
|
+
sources: [...(operation.sources ?? [])].sort(),
|
|
41
|
+
destination: operation.destination,
|
|
42
|
+
destinations: [...(operation.destinations ?? [])].sort(),
|
|
43
|
+
exclude: [...(operation.exclude ?? [])].sort(),
|
|
44
|
+
transform: operation.transform,
|
|
45
|
+
sessionChannelKey: operation.sessionChannelKey,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
function sessionDestination(root, aid, channelKey, channelId) {
|
|
49
|
+
const channelType = channelKey === 'daemon' ? 'daemon' : channelKey.split('#', 1)[0]?.trim();
|
|
50
|
+
if (!channelType)
|
|
51
|
+
throw new Error(`Cannot derive session channel type from key: ${channelKey}`);
|
|
52
|
+
return path.join(root, 'agents', aid, 'sessions', formatChannelSessionKey(channelType, channelId, channelKey));
|
|
53
|
+
}
|
|
54
|
+
function channelStateDestination(root, channelKey, ...parts) {
|
|
55
|
+
return path.join(root, 'data', 'channel-state', encodeURIComponent(channelKey), ...parts);
|
|
56
|
+
}
|
|
57
|
+
function triggerDestination(root, aid, ...parts) {
|
|
58
|
+
return path.join(root, 'agents', aid, 'triggers', ...parts);
|
|
59
|
+
}
|
|
60
|
+
function readJson(filePath) {
|
|
61
|
+
try {
|
|
62
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A plan extension must not replay an already verified legacy copy: the
|
|
70
|
+
* destination is now the live runtime data and may legitimately have grown.
|
|
71
|
+
* Reuse only a completed proof with an identical source hash and topology.
|
|
72
|
+
*/
|
|
73
|
+
function acknowledgeCompletedCopies(root, operations) {
|
|
74
|
+
const proofs = new Map();
|
|
75
|
+
const currentPlanSignature = migrationPlanSignature(operations);
|
|
76
|
+
try {
|
|
77
|
+
const manifests = fs.readdirSync(migrationDir(root), { withFileTypes: true })
|
|
78
|
+
.filter(entry => entry.isFile() && entry.name.endsWith('.json'))
|
|
79
|
+
.map(entry => ({
|
|
80
|
+
path: path.join(migrationDir(root), entry.name),
|
|
81
|
+
mtime: fs.statSync(path.join(migrationDir(root), entry.name)).mtimeMs,
|
|
82
|
+
}))
|
|
83
|
+
.sort((left, right) => right.mtime - left.mtime);
|
|
84
|
+
for (const candidate of manifests) {
|
|
85
|
+
const manifest = readJson(candidate.path);
|
|
86
|
+
if (manifest?.state !== 'completed')
|
|
87
|
+
continue;
|
|
88
|
+
// An unchanged plan is a normal re-run and must retain its conflict
|
|
89
|
+
// checks. Acknowledgement is only for extending a prior plan.
|
|
90
|
+
if (manifest.planSignature === currentPlanSignature)
|
|
91
|
+
return;
|
|
92
|
+
for (const operation of manifest.operations ?? []) {
|
|
93
|
+
if (operation.kind !== 'copy' || (operation.status !== 'committed' && operation.status !== 'skipped') || !operation.sourceHash)
|
|
94
|
+
continue;
|
|
95
|
+
const key = operationTopologyKey(operation);
|
|
96
|
+
if (!proofs.has(key))
|
|
97
|
+
proofs.set(key, { migrationId: manifest.id, sourceHash: operation.sourceHash });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch { }
|
|
102
|
+
for (const operation of operations) {
|
|
103
|
+
if (operation.kind !== 'copy' || !operation.destination || !fs.existsSync(operation.destination))
|
|
104
|
+
continue;
|
|
105
|
+
const proof = proofs.get(operationTopologyKey(operation));
|
|
106
|
+
if (!proof)
|
|
107
|
+
continue;
|
|
108
|
+
try {
|
|
109
|
+
if (treeHash(operation.source, operation.exclude) !== proof.sourceHash)
|
|
110
|
+
continue;
|
|
111
|
+
operation.status = 'acknowledged';
|
|
112
|
+
operation.acknowledgedBy = proof.migrationId;
|
|
113
|
+
}
|
|
114
|
+
catch { }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function readLatestSession(chatDir) {
|
|
118
|
+
const candidates = [path.join(chatDir, 'active.json')];
|
|
119
|
+
for (const name of scanMetaFiles(chatDir))
|
|
120
|
+
candidates.push(path.join(chatDir, name));
|
|
121
|
+
for (const name of scanMetaFiles(path.join(chatDir, '_threads')))
|
|
122
|
+
candidates.push(path.join(chatDir, '_threads', name));
|
|
123
|
+
for (const candidate of candidates) {
|
|
124
|
+
try {
|
|
125
|
+
const lines = fs.readFileSync(candidate, 'utf8').trim().split('\n').filter(Boolean);
|
|
126
|
+
const value = candidate.endsWith('.json') ? JSON.parse(lines.join('\n')) : JSON.parse(lines.at(-1));
|
|
127
|
+
if (value && typeof value === 'object')
|
|
128
|
+
return value;
|
|
129
|
+
}
|
|
130
|
+
catch { }
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
function normalizeLegacyChannelKey(value) {
|
|
135
|
+
if (typeof value !== 'string')
|
|
136
|
+
return undefined;
|
|
137
|
+
const [first, second, third, ...rest] = value.split('#');
|
|
138
|
+
if (rest.length > 0 || !first || !second || !third)
|
|
139
|
+
return undefined;
|
|
140
|
+
return CHANNEL_TYPES.has(second) && first.includes('.') ? `${second}#${first}#${third}` : value;
|
|
141
|
+
}
|
|
142
|
+
function parseChannelKey(value) {
|
|
143
|
+
const normalized = normalizeLegacyChannelKey(value);
|
|
144
|
+
if (!normalized)
|
|
145
|
+
return undefined;
|
|
146
|
+
const parts = normalized.split('#');
|
|
147
|
+
if (parts.length !== 3 || !CHANNEL_TYPES.has(parts[0]) || !parts[1])
|
|
148
|
+
return undefined;
|
|
149
|
+
return { type: parts[0], aid: parts[1], key: normalized };
|
|
150
|
+
}
|
|
151
|
+
function listChannelInstances(root) {
|
|
152
|
+
const results = [];
|
|
153
|
+
const agentsDir = path.join(root, 'agents');
|
|
154
|
+
let entries;
|
|
155
|
+
try {
|
|
156
|
+
entries = fs.readdirSync(agentsDir, { withFileTypes: true });
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
return results;
|
|
160
|
+
}
|
|
161
|
+
for (const entry of entries) {
|
|
162
|
+
if (!entry.isDirectory())
|
|
163
|
+
continue;
|
|
164
|
+
const config = readJson(path.join(agentsDir, entry.name, 'config.json'));
|
|
165
|
+
if (!config)
|
|
166
|
+
continue;
|
|
167
|
+
for (const channel of Array.isArray(config.channels) ? config.channels : []) {
|
|
168
|
+
if (!channel || typeof channel.type !== 'string' || typeof channel.name !== 'string')
|
|
169
|
+
continue;
|
|
170
|
+
results.push({
|
|
171
|
+
aid: entry.name,
|
|
172
|
+
type: channel.type,
|
|
173
|
+
rawName: channel.name,
|
|
174
|
+
key: `${channel.type}#${entry.name}#${channel.name}`,
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return results;
|
|
179
|
+
}
|
|
180
|
+
function configuredAids(root) {
|
|
181
|
+
return new Set(listChannelInstances(root).map(instance => instance.aid).concat((() => {
|
|
182
|
+
try {
|
|
183
|
+
return fs.readdirSync(path.join(root, 'agents'), { withFileTypes: true })
|
|
184
|
+
.filter(entry => entry.isDirectory() && fs.existsSync(path.join(root, 'agents', entry.name, 'config.json')))
|
|
185
|
+
.map(entry => entry.name);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
})()));
|
|
191
|
+
}
|
|
192
|
+
function controlIdentity(root) {
|
|
193
|
+
const config = readJson(path.join(root, 'daemon.json'));
|
|
194
|
+
const aid = typeof config?.aid === 'string' && config.aid.trim() && config.aid !== '_unknown'
|
|
195
|
+
? config.aid.trim()
|
|
196
|
+
: undefined;
|
|
197
|
+
const owners = new Set();
|
|
198
|
+
if (Array.isArray(config?.owners)) {
|
|
199
|
+
for (const owner of config.owners) {
|
|
200
|
+
if (typeof owner === 'string' && owner.trim())
|
|
201
|
+
owners.add(owner);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { aids: new Set(aid ? [aid] : []), owners };
|
|
205
|
+
}
|
|
206
|
+
function inferUnknownAunOwner(chat, control) {
|
|
207
|
+
if (chat.channelType !== 'aun' || chat.selfAID !== '_unknown' || control.aids.size !== 1)
|
|
208
|
+
return undefined;
|
|
209
|
+
// Old message-only AUN directories use `_unknown` for the local identity.
|
|
210
|
+
// The control identity is recoverable only when the peer is one of its
|
|
211
|
+
// configured owners; arbitrary unknown directories remain untouched.
|
|
212
|
+
if (!control.owners.has(chat.channelId))
|
|
213
|
+
return undefined;
|
|
214
|
+
return control.aids.values().next().value;
|
|
215
|
+
}
|
|
216
|
+
function isOwnedTriggerDefinition(definition, aid, triggerId) {
|
|
217
|
+
return !!definition
|
|
218
|
+
&& typeof definition === 'object'
|
|
219
|
+
&& definition.id === triggerId
|
|
220
|
+
&& definition.agentAid === aid;
|
|
221
|
+
}
|
|
222
|
+
function listTriggerStorageRoots(root, layout) {
|
|
223
|
+
const roots = [];
|
|
224
|
+
if (layout === 'legacy') {
|
|
225
|
+
const legacyRoot = path.join(root, 'data', 'triggers');
|
|
226
|
+
try {
|
|
227
|
+
for (const entry of fs.readdirSync(legacyRoot, { withFileTypes: true })) {
|
|
228
|
+
if (entry.isDirectory())
|
|
229
|
+
roots.push({ aid: entry.name, directory: path.join(legacyRoot, entry.name) });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
catch { }
|
|
233
|
+
return roots;
|
|
234
|
+
}
|
|
235
|
+
const agentsRoot = path.join(root, 'agents');
|
|
236
|
+
try {
|
|
237
|
+
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
|
|
238
|
+
if (!entry.isDirectory())
|
|
239
|
+
continue;
|
|
240
|
+
const directory = path.join(agentsRoot, entry.name, 'triggers');
|
|
241
|
+
if (fs.existsSync(directory))
|
|
242
|
+
roots.push({ aid: entry.name, directory });
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch { }
|
|
246
|
+
return roots;
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Trigger definitions are the authority for daemon-session ownership. Search
|
|
250
|
+
* both layouts while a completed migration retains the immutable legacy copy.
|
|
251
|
+
*/
|
|
252
|
+
function listTriggerOwners(root) {
|
|
253
|
+
const ownerAids = new Map();
|
|
254
|
+
for (const layout of ['legacy', 'agent']) {
|
|
255
|
+
for (const { aid, directory } of listTriggerStorageRoots(root, layout)) {
|
|
256
|
+
let triggerEntries;
|
|
257
|
+
try {
|
|
258
|
+
triggerEntries = fs.readdirSync(directory, { withFileTypes: true });
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
for (const triggerEntry of triggerEntries) {
|
|
264
|
+
if (!triggerEntry.isDirectory())
|
|
265
|
+
continue;
|
|
266
|
+
const definition = readJson(path.join(directory, triggerEntry.name, 'trigger.json'));
|
|
267
|
+
if (!isOwnedTriggerDefinition(definition, aid, triggerEntry.name))
|
|
268
|
+
continue;
|
|
269
|
+
const owners = ownerAids.get(triggerEntry.name) ?? new Set();
|
|
270
|
+
owners.add(aid);
|
|
271
|
+
ownerAids.set(triggerEntry.name, owners);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return new Map([...ownerAids].map(([triggerId, aids]) => [
|
|
276
|
+
triggerId,
|
|
277
|
+
[...aids].sort().map(aid => ({ aid })),
|
|
278
|
+
]));
|
|
279
|
+
}
|
|
280
|
+
function resolveDaemonTriggerOwner(chat, record, triggerOwners) {
|
|
281
|
+
if (chat.channelType !== 'daemon') {
|
|
282
|
+
return { warning: `Non-Trigger daemon session retained: ${chat.channelId}` };
|
|
283
|
+
}
|
|
284
|
+
const triggerIds = new Set();
|
|
285
|
+
if (chat.channelId.startsWith('trigger:'))
|
|
286
|
+
triggerIds.add(chat.channelId.slice('trigger:'.length));
|
|
287
|
+
for (const value of [record?.metadata?.peerId, record?.metadata?.triggerId, record?.triggerId]) {
|
|
288
|
+
if (typeof value === 'string' && value.startsWith('trigger:'))
|
|
289
|
+
triggerIds.add(value.slice('trigger:'.length));
|
|
290
|
+
}
|
|
291
|
+
triggerIds.delete('');
|
|
292
|
+
if (triggerIds.size === 0)
|
|
293
|
+
return { warning: `Non-Trigger daemon session retained: ${chat.channelId}` };
|
|
294
|
+
if (triggerIds.size > 1) {
|
|
295
|
+
return { warning: `Daemon session references multiple Trigger IDs and was retained: ${chat.channelId}` };
|
|
296
|
+
}
|
|
297
|
+
const triggerId = [...triggerIds][0];
|
|
298
|
+
const candidates = triggerOwners.get(triggerId) ?? [];
|
|
299
|
+
const declaredOwner = typeof record?.selfAID === 'string' && record.selfAID !== '_unknown'
|
|
300
|
+
? record.selfAID
|
|
301
|
+
: undefined;
|
|
302
|
+
const ownedCandidates = declaredOwner
|
|
303
|
+
? candidates.filter(candidate => candidate.aid === declaredOwner)
|
|
304
|
+
: candidates;
|
|
305
|
+
if (ownedCandidates.length === 1)
|
|
306
|
+
return { aid: ownedCandidates[0].aid };
|
|
307
|
+
if (declaredOwner && candidates.length > 0) {
|
|
308
|
+
return { warning: `Daemon Trigger session owner conflicts with its definition and was retained: ${chat.channelId}` };
|
|
309
|
+
}
|
|
310
|
+
if (candidates.length === 0) {
|
|
311
|
+
return { warning: `Daemon Trigger session has no matching definition and was retained: ${chat.channelId}` };
|
|
312
|
+
}
|
|
313
|
+
return { warning: `Daemon Trigger session has ambiguous ownership and was retained: ${chat.channelId}` };
|
|
314
|
+
}
|
|
315
|
+
function matchingChannel(instances, type, legacyName) {
|
|
316
|
+
const matches = instances.filter(instance => instance.type === type && (instance.key === legacyName || instance.rawName === legacyName));
|
|
317
|
+
return matches.length === 1 ? matches[0].key : undefined;
|
|
318
|
+
}
|
|
319
|
+
function matchingAgentChannel(instances, type, aid, legacyName) {
|
|
320
|
+
if (typeof legacyName !== 'string')
|
|
321
|
+
return undefined;
|
|
322
|
+
const matches = instances.filter(instance => instance.aid === aid && instance.type === type && (instance.key === legacyName
|
|
323
|
+
|| instance.rawName === legacyName
|
|
324
|
+
|| `${type}_${instance.rawName}` === legacyName));
|
|
325
|
+
return matches.length === 1 ? matches[0].key : undefined;
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* A stored full channel key may name an instance removed from the current
|
|
329
|
+
* Agent configuration. Rebind only when the current same-type instance is
|
|
330
|
+
* unique; with multiple candidates, preserving the old route is safer.
|
|
331
|
+
*/
|
|
332
|
+
function configuredChannelKey(instances, type, aid, ...storedKeys) {
|
|
333
|
+
if (type === 'aun')
|
|
334
|
+
return `aun#${aid}#main`;
|
|
335
|
+
const candidates = instances.filter(instance => instance.type === type && instance.aid === aid);
|
|
336
|
+
for (const storedKey of storedKeys) {
|
|
337
|
+
const parsed = parseChannelKey(storedKey);
|
|
338
|
+
if (!parsed || parsed.type !== type || parsed.aid !== aid)
|
|
339
|
+
continue;
|
|
340
|
+
if (candidates.some(candidate => candidate.key === parsed.key))
|
|
341
|
+
return parsed.key;
|
|
342
|
+
if (candidates.length === 1)
|
|
343
|
+
return candidates[0].key;
|
|
344
|
+
}
|
|
345
|
+
for (const storedKey of storedKeys) {
|
|
346
|
+
const matched = matchingAgentChannel(instances, type, aid, storedKey);
|
|
347
|
+
if (matched)
|
|
348
|
+
return matched;
|
|
349
|
+
}
|
|
350
|
+
return candidates.length === 1 ? candidates[0].key : undefined;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Existing Agent-owned session directories already have an authoritative
|
|
354
|
+
* parent AID. Prefer the currently configured route when it is unambiguous;
|
|
355
|
+
* otherwise retain a valid stored key so flattening never discards history.
|
|
356
|
+
*/
|
|
357
|
+
function channelKeyForNestedAgentSession(chat, record, instances) {
|
|
358
|
+
if (chat.channelType === 'daemon')
|
|
359
|
+
return 'daemon';
|
|
360
|
+
const storedKeys = [record?.channel, record?.metadata?.channelKey, chat.channelKey];
|
|
361
|
+
const configured = configuredChannelKey(instances, chat.channelType, chat.selfAID, ...storedKeys);
|
|
362
|
+
if (configured)
|
|
363
|
+
return configured;
|
|
364
|
+
for (const storedKey of storedKeys) {
|
|
365
|
+
const parsed = parseChannelKey(storedKey);
|
|
366
|
+
if (parsed?.type === chat.channelType && parsed.aid === chat.selfAID)
|
|
367
|
+
return parsed.key;
|
|
368
|
+
}
|
|
369
|
+
return undefined;
|
|
370
|
+
}
|
|
371
|
+
function nestedSessionSourcePriority(chat, record, destinationChannelKey) {
|
|
372
|
+
if (!destinationChannelKey)
|
|
373
|
+
return 2;
|
|
374
|
+
for (const value of [record?.channel, record?.metadata?.channelKey, chat.channelKey]) {
|
|
375
|
+
if (parseChannelKey(value)?.key === destinationChannelKey)
|
|
376
|
+
return 0;
|
|
377
|
+
}
|
|
378
|
+
return 1;
|
|
379
|
+
}
|
|
380
|
+
function addCopy(operations, id, source, destination, staging, exclude, transform, sessionChannelKey) {
|
|
381
|
+
if (!fs.existsSync(source))
|
|
382
|
+
return;
|
|
383
|
+
operations.push({ id, kind: 'copy', source, destination, staging, exclude, transform, sessionChannelKey, status: 'planned' });
|
|
384
|
+
}
|
|
385
|
+
/**
|
|
386
|
+
* Historic AUN data can contain an early `_unknown` transcript and a later
|
|
387
|
+
* identified transcript for the same peer. They are one logical session, so
|
|
388
|
+
* retain both instead of allowing the second copy to collide with the first.
|
|
389
|
+
*/
|
|
390
|
+
function addSessionCopy(operations, id, source, destination, staging, exclude, transform, sessionChannelKey) {
|
|
391
|
+
if (!fs.existsSync(source))
|
|
392
|
+
return;
|
|
393
|
+
const existing = operations.find(operation => operation.destination === destination
|
|
394
|
+
&& (operation.kind === 'copy' || operation.kind === 'merge-copy'));
|
|
395
|
+
if (existing) {
|
|
396
|
+
existing.kind = 'merge-copy';
|
|
397
|
+
existing.sources = [...new Set([...(existing.sources ?? []), source])];
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
operations.push({ id, kind: 'copy', source, destination, staging, exclude, transform, sessionChannelKey, status: 'planned' });
|
|
401
|
+
}
|
|
402
|
+
/** A rebinding can meet existing migrated state; merge it conservatively. */
|
|
403
|
+
function addMergeCopy(operations, id, source, destination, staging, exclude, transform, sessionChannelKey) {
|
|
404
|
+
if (!fs.existsSync(source))
|
|
405
|
+
return;
|
|
406
|
+
const existing = operations.find(operation => operation.destination === destination
|
|
407
|
+
&& (operation.kind === 'copy' || operation.kind === 'merge-copy'));
|
|
408
|
+
if (existing) {
|
|
409
|
+
existing.kind = 'merge-copy';
|
|
410
|
+
existing.sources = [...new Set([...(existing.sources ?? []), source])];
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
operations.push({ id, kind: 'merge-copy', source, destination, staging, exclude, transform, sessionChannelKey, status: 'planned' });
|
|
414
|
+
}
|
|
415
|
+
/** Build a read-only plan for the explicit, copy-based user-data migration. */
|
|
416
|
+
export function planDataMigration(root) {
|
|
417
|
+
const operations = [];
|
|
418
|
+
const warnings = [];
|
|
419
|
+
const id = `data-${Date.now().toString(36)}-${crypto.randomBytes(3).toString('hex')}`;
|
|
420
|
+
const stagingDir = migrationStagingDir(root, id);
|
|
421
|
+
const instances = listChannelInstances(root);
|
|
422
|
+
const aids = configuredAids(root);
|
|
423
|
+
const control = controlIdentity(root);
|
|
424
|
+
const triggerOwners = listTriggerOwners(root);
|
|
425
|
+
const legacySessions = path.join(root, 'data', 'sessions');
|
|
426
|
+
const legacyFeishuChats = new Map();
|
|
427
|
+
let sequence = 0;
|
|
428
|
+
const nextId = (prefix) => `${prefix}-${String(++sequence).padStart(5, '0')}`;
|
|
429
|
+
const stageFor = (operationId) => path.join(stagingDir, operationId, 'payload');
|
|
430
|
+
const liveSessionDestinations = new Set();
|
|
431
|
+
const nestedSessionDestinations = new Set();
|
|
432
|
+
const rememberFeishuChat = (channelKey, channelId) => {
|
|
433
|
+
const chats = legacyFeishuChats.get(channelKey) ?? new Set();
|
|
434
|
+
chats.add(channelId);
|
|
435
|
+
legacyFeishuChats.set(channelKey, chats);
|
|
436
|
+
};
|
|
437
|
+
const addFeishuSeen = (source, channelKey, channelId, prefix) => {
|
|
438
|
+
if (!fs.existsSync(source))
|
|
439
|
+
return;
|
|
440
|
+
const destination = channelStateDestination(root, channelKey, 'dedup', 'feishu', `${encodeURIComponent(channelId)}.jsonl`);
|
|
441
|
+
const operationId = nextId(prefix);
|
|
442
|
+
if (operations.some(operation => operation.destination === destination)) {
|
|
443
|
+
addMergeCopy(operations, operationId, source, destination, stageFor(operationId));
|
|
444
|
+
}
|
|
445
|
+
else {
|
|
446
|
+
addCopy(operations, operationId, source, destination, stageFor(operationId));
|
|
447
|
+
}
|
|
448
|
+
};
|
|
449
|
+
// Trigger data is Agent-owned. Migrate each valid Trigger independently so
|
|
450
|
+
// one malformed legacy definition does not block unrelated Agent data.
|
|
451
|
+
for (const { aid, directory } of listTriggerStorageRoots(root, 'legacy')) {
|
|
452
|
+
let entries;
|
|
453
|
+
try {
|
|
454
|
+
entries = fs.readdirSync(directory, { withFileTypes: true });
|
|
455
|
+
}
|
|
456
|
+
catch {
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
for (const entry of entries) {
|
|
460
|
+
const source = path.join(directory, entry.name);
|
|
461
|
+
if (entry.isDirectory()) {
|
|
462
|
+
if (entry.name.startsWith('.')) {
|
|
463
|
+
const operationId = nextId('trigger-metadata');
|
|
464
|
+
addCopy(operations, operationId, source, triggerDestination(root, aid, entry.name), stageFor(operationId));
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
const definition = readJson(path.join(source, 'trigger.json'));
|
|
468
|
+
if (!isOwnedTriggerDefinition(definition, aid, entry.name)) {
|
|
469
|
+
warnings.push(`Invalid Trigger definition retained: ${source}`);
|
|
470
|
+
continue;
|
|
471
|
+
}
|
|
472
|
+
const operationId = nextId('trigger');
|
|
473
|
+
addCopy(operations, operationId, source, triggerDestination(root, aid, entry.name), stageFor(operationId), undefined, 'rewrite-trigger-session-paths');
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (entry.isFile() && (entry.name === 'history.jsonl' || /^triggers\.legacy\.migrated\.\d+\.json$/.test(entry.name))) {
|
|
477
|
+
const operationId = nextId(entry.name === 'history.jsonl' ? 'trigger-history' : 'trigger-legacy-stats');
|
|
478
|
+
addCopy(operations, operationId, source, triggerDestination(root, aid, entry.name), stageFor(operationId));
|
|
479
|
+
continue;
|
|
480
|
+
}
|
|
481
|
+
warnings.push(`Unrecognized Trigger data retained: ${source}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
// Canonicalize single-level keys written by an earlier v3 build. These are
|
|
485
|
+
// live Agent-owned copies and therefore take precedence over retained source
|
|
486
|
+
// layouts when both resolve to the same destination.
|
|
487
|
+
for (const chat of scanNonCanonicalAgentChatDirs(path.join(root, 'agents'))) {
|
|
488
|
+
const record = readLatestSession(chat.dirPath);
|
|
489
|
+
const channelKey = channelKeyForNestedAgentSession(chat, record, instances);
|
|
490
|
+
if (!channelKey) {
|
|
491
|
+
warnings.push(`Unresolved non-canonical Agent session retained: ${chat.dirPath}`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
let destination;
|
|
495
|
+
try {
|
|
496
|
+
destination = sessionDestination(root, chat.selfAID, channelKey, chat.channelId);
|
|
497
|
+
}
|
|
498
|
+
catch (error) {
|
|
499
|
+
warnings.push(`Unmigratable Agent session retained: ${chat.dirPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
500
|
+
continue;
|
|
501
|
+
}
|
|
502
|
+
liveSessionDestinations.add(destination);
|
|
503
|
+
const sessionId = nextId('canonical-session');
|
|
504
|
+
addSessionCopy(operations, sessionId, chat.dirPath, destination, stageFor(sessionId), chat.channelType === 'feishu' ? [SESSION_SEEN_FILE] : undefined, channelKey === 'daemon' ? undefined : 'normalize-session-channel-key', channelKey === 'daemon' ? undefined : channelKey);
|
|
505
|
+
if (chat.channelType === 'feishu') {
|
|
506
|
+
rememberFeishuChat(channelKey, chat.channelId);
|
|
507
|
+
addFeishuSeen(path.join(chat.dirPath, SESSION_SEEN_FILE), channelKey, chat.channelId, 'canonical-feishu-seen');
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
// Version 3 flattens the previous Agent-owned two-level session layout.
|
|
511
|
+
// These directories are the most recent runtime copies, so when they and a
|
|
512
|
+
// retained data/sessions source resolve to the same destination, this copy
|
|
513
|
+
// takes precedence and avoids replaying an older transcript.
|
|
514
|
+
const nestedChats = scanNestedAgentChatDirs(path.join(root, 'agents'))
|
|
515
|
+
.map(chat => {
|
|
516
|
+
const record = readLatestSession(chat.dirPath);
|
|
517
|
+
const channelKey = channelKeyForNestedAgentSession(chat, record, instances);
|
|
518
|
+
return { chat, record, channelKey };
|
|
519
|
+
})
|
|
520
|
+
.sort((left, right) => {
|
|
521
|
+
const priority = nestedSessionSourcePriority(left.chat, left.record, left.channelKey)
|
|
522
|
+
- nestedSessionSourcePriority(right.chat, right.record, right.channelKey);
|
|
523
|
+
if (priority)
|
|
524
|
+
return priority;
|
|
525
|
+
return left.chat.dirPath < right.chat.dirPath ? -1 : left.chat.dirPath > right.chat.dirPath ? 1 : 0;
|
|
526
|
+
});
|
|
527
|
+
for (const { chat, channelKey } of nestedChats) {
|
|
528
|
+
if (!channelKey) {
|
|
529
|
+
warnings.push(`Unresolved Agent session channel retained: ${chat.dirPath}`);
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
let destination;
|
|
533
|
+
try {
|
|
534
|
+
destination = sessionDestination(root, chat.selfAID, channelKey, chat.channelId);
|
|
535
|
+
}
|
|
536
|
+
catch (error) {
|
|
537
|
+
warnings.push(`Unmigratable Agent session retained: ${chat.dirPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
538
|
+
continue;
|
|
539
|
+
}
|
|
540
|
+
if (liveSessionDestinations.has(destination))
|
|
541
|
+
continue;
|
|
542
|
+
nestedSessionDestinations.add(destination);
|
|
543
|
+
const sessionId = nextId('flatten-session');
|
|
544
|
+
addSessionCopy(operations, sessionId, chat.dirPath, destination, stageFor(sessionId), chat.channelType === 'feishu' ? [SESSION_SEEN_FILE] : undefined, channelKey === 'daemon' ? undefined : 'normalize-session-channel-key', channelKey === 'daemon' ? undefined : channelKey);
|
|
545
|
+
if (chat.channelType === 'feishu') {
|
|
546
|
+
rememberFeishuChat(channelKey, chat.channelId);
|
|
547
|
+
addFeishuSeen(path.join(chat.dirPath, SESSION_SEEN_FILE), channelKey, chat.channelId, 'flatten-feishu-seen');
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
for (const chat of scanLegacyChatDirs(legacySessions)) {
|
|
551
|
+
const record = readLatestSession(chat.dirPath);
|
|
552
|
+
if (chat.channelType === 'daemon') {
|
|
553
|
+
const triggerOwner = resolveDaemonTriggerOwner(chat, record, triggerOwners);
|
|
554
|
+
if (!triggerOwner.aid) {
|
|
555
|
+
warnings.push(`${triggerOwner.warning ?? 'Unresolved daemon session retained'}: ${chat.dirPath}`);
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
let destination;
|
|
559
|
+
try {
|
|
560
|
+
destination = sessionDestination(root, triggerOwner.aid, 'daemon', chat.channelId);
|
|
561
|
+
}
|
|
562
|
+
catch (error) {
|
|
563
|
+
warnings.push(`Unmigratable daemon session retained: ${chat.dirPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
if (!liveSessionDestinations.has(destination) && !nestedSessionDestinations.has(destination)) {
|
|
567
|
+
const sessionId = nextId('trigger-session');
|
|
568
|
+
addSessionCopy(operations, sessionId, chat.dirPath, destination, stageFor(sessionId));
|
|
569
|
+
}
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const fromChannel = parseChannelKey(record?.channel ?? record?.metadata?.channelKey);
|
|
573
|
+
const aid = chat.selfAID && chat.selfAID !== '_unknown'
|
|
574
|
+
? chat.selfAID
|
|
575
|
+
: (typeof record?.selfAID === 'string' && record.selfAID !== '_unknown'
|
|
576
|
+
? record.selfAID
|
|
577
|
+
: fromChannel?.aid ?? inferUnknownAunOwner(chat, control));
|
|
578
|
+
if (!aid || aid === '_unknown') {
|
|
579
|
+
warnings.push(`Unresolved session owner retained: ${chat.dirPath}`);
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
if (!aids.has(aid) && !control.aids.has(aid)) {
|
|
583
|
+
warnings.push(`Session owner has no Agent config and was retained: ${chat.dirPath}`);
|
|
584
|
+
continue;
|
|
585
|
+
}
|
|
586
|
+
const channelKey = configuredChannelKey(instances, chat.channelType, aid, record?.channel, record?.metadata?.channelKey, chat.channelKey);
|
|
587
|
+
if (!channelKey) {
|
|
588
|
+
warnings.push(`Unresolved session channel retained: ${chat.dirPath}`);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
let destination;
|
|
592
|
+
try {
|
|
593
|
+
destination = sessionDestination(root, aid, channelKey, chat.channelId);
|
|
594
|
+
}
|
|
595
|
+
catch (error) {
|
|
596
|
+
warnings.push(`Unmigratable session retained: ${chat.dirPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
597
|
+
continue;
|
|
598
|
+
}
|
|
599
|
+
if (!liveSessionDestinations.has(destination) && !nestedSessionDestinations.has(destination)) {
|
|
600
|
+
const sessionId = nextId('session');
|
|
601
|
+
addSessionCopy(operations, sessionId, chat.dirPath, destination, stageFor(sessionId), [SESSION_SEEN_FILE], 'normalize-session-channel-key', channelKey);
|
|
602
|
+
}
|
|
603
|
+
if (chat.channelType === 'feishu') {
|
|
604
|
+
rememberFeishuChat(channelKey, chat.channelId);
|
|
605
|
+
addFeishuSeen(path.join(chat.dirPath, SESSION_SEEN_FILE), channelKey, chat.channelId, 'feishu-seen');
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
// Rebind connector state written under a removed unique channel instance.
|
|
609
|
+
// The source stays intact; a merge-copy handles an already-created target.
|
|
610
|
+
try {
|
|
611
|
+
for (const entry of fs.readdirSync(path.join(root, 'data', 'channel-state'), { withFileTypes: true })) {
|
|
612
|
+
if (!entry.isDirectory())
|
|
613
|
+
continue;
|
|
614
|
+
let oldChannelKey;
|
|
615
|
+
try {
|
|
616
|
+
oldChannelKey = decodeURIComponent(entry.name);
|
|
617
|
+
}
|
|
618
|
+
catch {
|
|
619
|
+
warnings.push(`Unreadable channel-state key retained: ${entry.name}`);
|
|
620
|
+
continue;
|
|
621
|
+
}
|
|
622
|
+
const parsed = parseChannelKey(oldChannelKey);
|
|
623
|
+
if (!parsed || parsed.type === 'aun' || parsed.type === 'daemon')
|
|
624
|
+
continue;
|
|
625
|
+
const channelKey = configuredChannelKey(instances, parsed.type, parsed.aid, parsed.key);
|
|
626
|
+
if (!channelKey || channelKey === parsed.key)
|
|
627
|
+
continue;
|
|
628
|
+
const operationId = nextId('rebound-channel-state');
|
|
629
|
+
addMergeCopy(operations, operationId, path.join(root, 'data', 'channel-state', entry.name), channelStateDestination(root, channelKey), stageFor(operationId));
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
catch { }
|
|
633
|
+
const legacyHandoff = path.join(root, 'data', 'handoff');
|
|
634
|
+
try {
|
|
635
|
+
for (const entry of fs.readdirSync(legacyHandoff, { withFileTypes: true })) {
|
|
636
|
+
if (!entry.isDirectory())
|
|
637
|
+
continue;
|
|
638
|
+
if (!aids.has(entry.name)) {
|
|
639
|
+
warnings.push(`Unresolved handoff owner retained: ${entry.name}`);
|
|
640
|
+
continue;
|
|
641
|
+
}
|
|
642
|
+
const operationId = nextId('handoff');
|
|
643
|
+
addCopy(operations, operationId, path.join(legacyHandoff, entry.name), path.join(root, 'agents', entry.name, 'data', 'handoff'), stageFor(operationId));
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
catch { }
|
|
647
|
+
const legacyOutbox = path.join(root, 'data', 'outbox');
|
|
648
|
+
try {
|
|
649
|
+
for (const entry of fs.readdirSync(legacyOutbox, { withFileTypes: true })) {
|
|
650
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl'))
|
|
651
|
+
continue;
|
|
652
|
+
const aid = entry.name.slice(0, -'.jsonl'.length);
|
|
653
|
+
if (!aids.has(aid)) {
|
|
654
|
+
warnings.push(`Unresolved outbox owner retained: ${entry.name}`);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
const operationId = nextId('outbox');
|
|
658
|
+
addCopy(operations, operationId, path.join(legacyOutbox, entry.name), path.join(root, 'agents', aid, 'data', 'outbox.jsonl'), stageFor(operationId));
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
catch { }
|
|
662
|
+
const contactAudit = path.join(root, 'data', 'contact-book-audit.jsonl');
|
|
663
|
+
if (fs.existsSync(contactAudit)) {
|
|
664
|
+
const operationId = nextId('contact-audit');
|
|
665
|
+
operations.push({ id: operationId, kind: 'contact-audit-partition', source: contactAudit, staging: path.join(stagingDir, operationId), status: 'planned' });
|
|
666
|
+
}
|
|
667
|
+
for (const [prefix, source, destination] of [
|
|
668
|
+
['queue', path.join(root, 'data', 'message-queue.json'), path.join(root, 'data', 'daemon', 'message-queue.json')],
|
|
669
|
+
['restart-pending', path.join(root, 'data', 'restart-pending.json'), path.join(root, 'data', 'daemon', 'control', 'restart-pending.json')],
|
|
670
|
+
]) {
|
|
671
|
+
const operationId = nextId(prefix);
|
|
672
|
+
addCopy(operations, operationId, source, destination, stageFor(operationId));
|
|
673
|
+
}
|
|
674
|
+
try {
|
|
675
|
+
for (const entry of fs.readdirSync(path.join(root, 'data'), { withFileTypes: true })) {
|
|
676
|
+
if (entry.isFile() && /^restart-confirm-.*\.json$/.test(entry.name)) {
|
|
677
|
+
const operationId = nextId('restart-confirm');
|
|
678
|
+
addCopy(operations, operationId, path.join(root, 'data', entry.name), path.join(root, 'data', 'daemon', 'control', entry.name), stageFor(operationId));
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
catch { }
|
|
683
|
+
// Connector state whose filename embeds a full effective channel key.
|
|
684
|
+
try {
|
|
685
|
+
for (const entry of fs.readdirSync(path.join(root, 'data'), { withFileTypes: true })) {
|
|
686
|
+
const welcome = entry.name.match(/^([a-z0-9_-]+)-greeted-(.+)\.jsonl$/i);
|
|
687
|
+
if (entry.isFile() && welcome) {
|
|
688
|
+
const channelKey = matchingChannel(instances, welcome[1], welcome[2]);
|
|
689
|
+
if (channelKey) {
|
|
690
|
+
const operationId = nextId('welcome');
|
|
691
|
+
addCopy(operations, operationId, path.join(root, 'data', entry.name), channelStateDestination(root, channelKey, 'welcome.jsonl'), stageFor(operationId));
|
|
692
|
+
}
|
|
693
|
+
else
|
|
694
|
+
warnings.push(`Unresolved welcome state retained: ${entry.name}`);
|
|
695
|
+
}
|
|
696
|
+
const globalFeishuSeen = entry.name.match(/^feishu-seen-(.+)\.jsonl$/);
|
|
697
|
+
if (entry.isFile() && globalFeishuSeen) {
|
|
698
|
+
const channelKey = matchingChannel(instances, 'feishu', globalFeishuSeen[1]);
|
|
699
|
+
const chatIds = channelKey ? [...(legacyFeishuChats.get(channelKey) ?? [])] : [];
|
|
700
|
+
if (!channelKey) {
|
|
701
|
+
warnings.push(`Unresolved Feishu instance dedup retained: ${entry.name}`);
|
|
702
|
+
}
|
|
703
|
+
else if (chatIds.length === 0) {
|
|
704
|
+
warnings.push(`Feishu instance dedup retained because no legacy chat maps to ${channelKey}: ${entry.name}`);
|
|
705
|
+
}
|
|
706
|
+
else {
|
|
707
|
+
for (const chatId of chatIds) {
|
|
708
|
+
const operationId = nextId('feishu-global-seen');
|
|
709
|
+
addCopy(operations, operationId, path.join(root, 'data', entry.name), channelStateDestination(root, channelKey, 'dedup', 'feishu', `${encodeURIComponent(chatId)}.jsonl`), stageFor(operationId));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
catch { }
|
|
716
|
+
// Legacy WeCom state includes its full channel key in the parent path and file.
|
|
717
|
+
for (const instance of instances.filter(item => item.type === 'wecom')) {
|
|
718
|
+
const oldDir = path.join(root, 'agents', instance.aid, 'data', 'channels', encodeURIComponent(instance.key));
|
|
719
|
+
for (const [prefix, file, target] of [
|
|
720
|
+
['wecom-dedup', 'wecom-message-dedup.json', 'dedup.json'],
|
|
721
|
+
['wecom-cards', 'wecom-cards.json', 'cards.json'],
|
|
722
|
+
]) {
|
|
723
|
+
const operationId = nextId(prefix);
|
|
724
|
+
addCopy(operations, operationId, path.join(oldDir, file), channelStateDestination(root, instance.key, target), stageFor(operationId));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
const wechatInstances = instances.filter(item => item.type === 'wechat');
|
|
728
|
+
if (wechatInstances.length === 1) {
|
|
729
|
+
const channelKey = wechatInstances[0].key;
|
|
730
|
+
for (const [prefix, source, target] of [
|
|
731
|
+
['wechat-context', path.join(root, 'data', 'wechat-context-tokens.json'), 'context-tokens.json'],
|
|
732
|
+
['wechat-cursor', path.join(root, 'data', 'wechat-sync-buf.txt'), 'sync-buffer.txt'],
|
|
733
|
+
]) {
|
|
734
|
+
const operationId = nextId(prefix);
|
|
735
|
+
addCopy(operations, operationId, source, channelStateDestination(root, channelKey, target), stageFor(operationId));
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
else if (fs.existsSync(path.join(root, 'data', 'wechat-context-tokens.json')) || fs.existsSync(path.join(root, 'data', 'wechat-sync-buf.txt'))) {
|
|
739
|
+
warnings.push('Wechat global state retained because it does not map to exactly one configured instance.');
|
|
740
|
+
}
|
|
741
|
+
const dingtalkByHash = new Map();
|
|
742
|
+
for (const instance of instances.filter(item => item.type === 'dingtalk')) {
|
|
743
|
+
const hash = crypto.createHash('sha256').update(`${instance.aid}:${instance.key}`).digest('hex').slice(0, 16);
|
|
744
|
+
dingtalkByHash.set(hash, instance);
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
for (const entry of fs.readdirSync(path.join(root, 'data'), { withFileTypes: true })) {
|
|
748
|
+
if (!entry.isFile())
|
|
749
|
+
continue;
|
|
750
|
+
const match = entry.name.match(/^dingtalk-(seen|routes)-([0-9a-f]{16})\.(jsonl|json)$/);
|
|
751
|
+
if (!match)
|
|
752
|
+
continue;
|
|
753
|
+
const instance = dingtalkByHash.get(match[2]);
|
|
754
|
+
if (!instance) {
|
|
755
|
+
warnings.push(`Unresolved DingTalk state retained: ${entry.name}`);
|
|
756
|
+
continue;
|
|
757
|
+
}
|
|
758
|
+
const targetName = match[1] === 'seen' ? 'dingtalk-seen.jsonl' : 'routes.json';
|
|
759
|
+
const operationId = nextId('dingtalk');
|
|
760
|
+
addCopy(operations, operationId, path.join(root, 'data', entry.name), channelStateDestination(root, instance.key, targetName), stageFor(operationId));
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
catch { }
|
|
764
|
+
acknowledgeCompletedCopies(root, operations);
|
|
765
|
+
const now = new Date().toISOString();
|
|
766
|
+
return {
|
|
767
|
+
schemaVersion: 1,
|
|
768
|
+
id,
|
|
769
|
+
root,
|
|
770
|
+
createdAt: now,
|
|
771
|
+
updatedAt: now,
|
|
772
|
+
state: 'planned',
|
|
773
|
+
operations,
|
|
774
|
+
warnings,
|
|
775
|
+
planSignature: migrationPlanSignature(operations),
|
|
776
|
+
};
|
|
777
|
+
}
|
|
778
|
+
function strictJsonValidation(filePath) {
|
|
779
|
+
if (filePath.endsWith('.json')) {
|
|
780
|
+
JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
781
|
+
return;
|
|
782
|
+
}
|
|
783
|
+
if (!filePath.endsWith('.jsonl'))
|
|
784
|
+
return;
|
|
785
|
+
const lines = fs.readFileSync(filePath, 'utf8').split('\n');
|
|
786
|
+
for (const [index, line] of lines.entries()) {
|
|
787
|
+
if (line.trim())
|
|
788
|
+
JSON.parse(line);
|
|
789
|
+
if (index > Number.MAX_SAFE_INTEGER)
|
|
790
|
+
throw new Error('unreachable');
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
function validateTree(filePath) {
|
|
794
|
+
const stat = fs.statSync(filePath);
|
|
795
|
+
if (stat.isFile())
|
|
796
|
+
return strictJsonValidation(filePath);
|
|
797
|
+
for (const entry of fs.readdirSync(filePath, { withFileTypes: true })) {
|
|
798
|
+
const child = path.join(filePath, entry.name);
|
|
799
|
+
if (entry.isDirectory())
|
|
800
|
+
validateTree(child);
|
|
801
|
+
else if (entry.isFile())
|
|
802
|
+
strictJsonValidation(child);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
function verifyRequiredTriggerPayload(source, destination, relative = '') {
|
|
806
|
+
const sourceStat = fs.statSync(source);
|
|
807
|
+
let destinationStat;
|
|
808
|
+
try {
|
|
809
|
+
destinationStat = fs.statSync(destination);
|
|
810
|
+
}
|
|
811
|
+
catch {
|
|
812
|
+
throw new Error(`destination conflict: missing Trigger payload ${relative || '.'}`);
|
|
813
|
+
}
|
|
814
|
+
if (sourceStat.isFile()) {
|
|
815
|
+
if (!destinationStat.isFile())
|
|
816
|
+
throw new Error(`destination conflict: incompatible Trigger payload ${relative}`);
|
|
817
|
+
if (relative === 'active.json') {
|
|
818
|
+
strictJsonValidation(destination);
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (relative === 'trigger.json') {
|
|
822
|
+
strictJsonValidation(destination);
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
if (!fs.readFileSync(source).equals(fs.readFileSync(destination))) {
|
|
826
|
+
throw new Error(`destination conflict: changed Trigger payload ${relative}`);
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (!destinationStat.isDirectory())
|
|
831
|
+
throw new Error(`destination conflict: incompatible Trigger directory ${relative || '.'}`);
|
|
832
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
833
|
+
if (!entry.isDirectory() && !entry.isFile())
|
|
834
|
+
continue;
|
|
835
|
+
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
836
|
+
verifyRequiredTriggerPayload(path.join(source, entry.name), path.join(destination, entry.name), childRelative);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function verifyLiveTriggerDestination(operation) {
|
|
840
|
+
if (operation.transform !== 'rewrite-trigger-session-paths' || !operation.destination) {
|
|
841
|
+
throw new Error('operation is not a Trigger live-state reconciliation');
|
|
842
|
+
}
|
|
843
|
+
verifyRequiredTriggerPayload(operation.source, operation.destination);
|
|
844
|
+
const sourceDefinition = readJson(path.join(operation.source, 'trigger.json'));
|
|
845
|
+
const targetPath = path.join(operation.destination, 'trigger.json');
|
|
846
|
+
const targetDefinition = readJson(targetPath);
|
|
847
|
+
const aid = typeof sourceDefinition?.agentAid === 'string' ? sourceDefinition.agentAid : undefined;
|
|
848
|
+
const id = typeof sourceDefinition?.id === 'string' ? sourceDefinition.id : undefined;
|
|
849
|
+
if (!aid || !id || !isOwnedTriggerDefinition(targetDefinition, aid, id)) {
|
|
850
|
+
throw new Error('destination conflict: Trigger definition owner or id changed');
|
|
851
|
+
}
|
|
852
|
+
const content = fs.readFileSync(targetPath, 'utf8');
|
|
853
|
+
const transformed = transformedFileContent(targetPath, 'trigger.json', content, operation.transform, operation.sessionChannelKey);
|
|
854
|
+
if (transformed !== content)
|
|
855
|
+
throw new Error('destination conflict: Trigger definition still has legacy session paths');
|
|
856
|
+
validateTree(operation.destination);
|
|
857
|
+
}
|
|
858
|
+
function reconcileLiveTriggerDestination(operation) {
|
|
859
|
+
if (!operation.destination)
|
|
860
|
+
throw new Error('Trigger live-state reconciliation has no destination');
|
|
861
|
+
verifyRequiredTriggerPayload(operation.source, operation.destination);
|
|
862
|
+
const targetPath = path.join(operation.destination, 'trigger.json');
|
|
863
|
+
const content = fs.readFileSync(targetPath, 'utf8');
|
|
864
|
+
const transformed = transformedFileContent(targetPath, 'trigger.json', content, operation.transform, operation.sessionChannelKey);
|
|
865
|
+
if (transformed !== content)
|
|
866
|
+
atomicWriteText(targetPath, transformed);
|
|
867
|
+
verifyLiveTriggerDestination(operation);
|
|
868
|
+
}
|
|
869
|
+
function normalizeSessionRecordChannelKeys(record, channelKey, forceCurrentRoute = false) {
|
|
870
|
+
if (!record || typeof record !== 'object')
|
|
871
|
+
return false;
|
|
872
|
+
if (!channelKey)
|
|
873
|
+
throw new Error('session channel-key transform has no canonical target');
|
|
874
|
+
let changed = false;
|
|
875
|
+
const normalize = (value) => {
|
|
876
|
+
if (typeof value !== 'string')
|
|
877
|
+
return undefined;
|
|
878
|
+
const parsed = parseChannelKey(value);
|
|
879
|
+
// A single archived chat may span prior Agent/channel assignments. The
|
|
880
|
+
// destination path is authoritative for the current route; historical
|
|
881
|
+
// metadata must stay readable rather than making the entire copy fail.
|
|
882
|
+
// Only rewrite the unambiguous legacy key ordering for this same route.
|
|
883
|
+
if (!parsed)
|
|
884
|
+
return undefined;
|
|
885
|
+
if (forceCurrentRoute)
|
|
886
|
+
return value === channelKey ? undefined : channelKey;
|
|
887
|
+
return parsed.key === channelKey && parsed.key !== value ? channelKey : undefined;
|
|
888
|
+
};
|
|
889
|
+
const channel = normalize(record.channel);
|
|
890
|
+
if (channel && channel !== record.channel) {
|
|
891
|
+
record.channel = channel;
|
|
892
|
+
changed = true;
|
|
893
|
+
}
|
|
894
|
+
const metadataChannel = normalize(record.metadata?.channelKey);
|
|
895
|
+
if (metadataChannel && metadataChannel !== record.metadata.channelKey) {
|
|
896
|
+
record.metadata.channelKey = metadataChannel;
|
|
897
|
+
changed = true;
|
|
898
|
+
}
|
|
899
|
+
return changed;
|
|
900
|
+
}
|
|
901
|
+
function transformedFileContent(filePath, relative, content, transform, sessionChannelKey) {
|
|
902
|
+
if (transform === 'rewrite-trigger-session-paths') {
|
|
903
|
+
if (path.basename(filePath) !== 'trigger.json')
|
|
904
|
+
return content;
|
|
905
|
+
const definition = JSON.parse(content);
|
|
906
|
+
const prompt = definition?.execution?.prompt;
|
|
907
|
+
if (typeof prompt !== 'string')
|
|
908
|
+
return content;
|
|
909
|
+
const rewritten = rewriteLegacyTriggerSessionPaths(prompt, definition.agentAid);
|
|
910
|
+
return rewritten === prompt ? content : JSON.stringify({ ...definition, execution: { ...definition.execution, prompt: rewritten } }, null, 2) + '\n';
|
|
911
|
+
}
|
|
912
|
+
if (transform !== 'normalize-session-channel-key')
|
|
913
|
+
return content;
|
|
914
|
+
const baseName = path.basename(filePath);
|
|
915
|
+
if (relative === 'active.json') {
|
|
916
|
+
const record = JSON.parse(content);
|
|
917
|
+
return normalizeSessionRecordChannelKeys(record, sessionChannelKey, true) ? JSON.stringify(record, null, 2) + '\n' : content;
|
|
918
|
+
}
|
|
919
|
+
if (!/^meta_.*\.jsonl$/.test(baseName))
|
|
920
|
+
return content;
|
|
921
|
+
const lines = content.split('\n');
|
|
922
|
+
let changed = false;
|
|
923
|
+
for (let index = 0; index < lines.length; index++) {
|
|
924
|
+
const line = lines[index];
|
|
925
|
+
if (!line.trim())
|
|
926
|
+
continue;
|
|
927
|
+
const record = JSON.parse(line);
|
|
928
|
+
if (normalizeSessionRecordChannelKeys(record, sessionChannelKey)) {
|
|
929
|
+
lines[index] = JSON.stringify(record);
|
|
930
|
+
changed = true;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return changed ? lines.join('\n') : content;
|
|
934
|
+
}
|
|
935
|
+
function transformTree(filePath, transform, sessionChannelKey, relative = '') {
|
|
936
|
+
if (!transform)
|
|
937
|
+
return;
|
|
938
|
+
const stat = fs.statSync(filePath);
|
|
939
|
+
if (stat.isFile()) {
|
|
940
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
941
|
+
const transformed = transformedFileContent(filePath, relative, content, transform, sessionChannelKey);
|
|
942
|
+
if (transformed !== content)
|
|
943
|
+
fs.writeFileSync(filePath, transformed);
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
for (const entry of fs.readdirSync(filePath, { withFileTypes: true })) {
|
|
947
|
+
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
948
|
+
if (entry.isDirectory() || entry.isFile())
|
|
949
|
+
transformTree(path.join(filePath, entry.name), transform, sessionChannelKey, childRelative);
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
function treeHash(filePath, exclude = [], relative = '', transform, sessionChannelKey) {
|
|
953
|
+
const hash = crypto.createHash('sha256');
|
|
954
|
+
const visit = (current, rel) => {
|
|
955
|
+
const stat = fs.statSync(current);
|
|
956
|
+
if (stat.isFile()) {
|
|
957
|
+
if (exclude.includes(rel))
|
|
958
|
+
return;
|
|
959
|
+
hash.update(`F\0${rel}\0`);
|
|
960
|
+
const raw = fs.readFileSync(current);
|
|
961
|
+
if (!transform) {
|
|
962
|
+
hash.update(raw);
|
|
963
|
+
}
|
|
964
|
+
else {
|
|
965
|
+
const rawText = raw.toString('utf8');
|
|
966
|
+
const transformed = transformedFileContent(current, rel, rawText, transform, sessionChannelKey);
|
|
967
|
+
hash.update(transformed === rawText ? raw : transformed);
|
|
968
|
+
}
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
hash.update(`D\0${rel}\0`);
|
|
972
|
+
for (const entry of fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
973
|
+
visit(path.join(current, entry.name), rel ? path.join(rel, entry.name) : entry.name);
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
visit(filePath, relative);
|
|
977
|
+
return hash.digest('hex');
|
|
978
|
+
}
|
|
979
|
+
function writeManifest(manifest) {
|
|
980
|
+
fs.mkdirSync(migrationDir(manifest.root), { recursive: true });
|
|
981
|
+
manifest.updatedAt = new Date().toISOString();
|
|
982
|
+
const target = manifestPath(manifest.root, manifest.id);
|
|
983
|
+
const temporary = `${target}.tmp`;
|
|
984
|
+
fs.writeFileSync(temporary, JSON.stringify(manifest, null, 2) + '\n');
|
|
985
|
+
fs.renameSync(temporary, target);
|
|
986
|
+
}
|
|
987
|
+
function atomicWriteText(target, content) {
|
|
988
|
+
const temporary = `${target}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
989
|
+
fs.writeFileSync(temporary, content);
|
|
990
|
+
fs.renameSync(temporary, target);
|
|
991
|
+
}
|
|
992
|
+
function hashContent(content) {
|
|
993
|
+
return crypto.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
994
|
+
}
|
|
995
|
+
function appendIssue(operation, issue) {
|
|
996
|
+
const issues = operation.issues ?? [];
|
|
997
|
+
if (!issues.includes(issue))
|
|
998
|
+
issues.push(issue);
|
|
999
|
+
operation.issues = issues;
|
|
1000
|
+
}
|
|
1001
|
+
function executeContactAudit(manifest, operation, checkpoint) {
|
|
1002
|
+
const groups = new Map();
|
|
1003
|
+
const sourceHash = treeHash(operation.source);
|
|
1004
|
+
if (operation.sourceHash && operation.sourceHash !== sourceHash) {
|
|
1005
|
+
throw new Error(`source changed since migration began: ${operation.source}`);
|
|
1006
|
+
}
|
|
1007
|
+
operation.sourceHash = sourceHash;
|
|
1008
|
+
operation.destinations = [];
|
|
1009
|
+
operation.destinationHashes = {};
|
|
1010
|
+
operation.issues = [];
|
|
1011
|
+
for (const [index, line] of fs.readFileSync(operation.source, 'utf8').split('\n').entries()) {
|
|
1012
|
+
if (!line.trim())
|
|
1013
|
+
continue;
|
|
1014
|
+
let record;
|
|
1015
|
+
try {
|
|
1016
|
+
record = JSON.parse(line);
|
|
1017
|
+
}
|
|
1018
|
+
catch {
|
|
1019
|
+
appendIssue(operation, `contact audit line ${index + 1} is not valid JSON`);
|
|
1020
|
+
continue;
|
|
1021
|
+
}
|
|
1022
|
+
if (typeof record.selfAid !== 'string' || !fs.existsSync(path.join(manifest.root, 'agents', record.selfAid, 'config.json'))) {
|
|
1023
|
+
appendIssue(operation, `contact audit line ${index + 1} has no configured selfAid`);
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
const records = groups.get(record.selfAid) ?? [];
|
|
1027
|
+
records.push(line);
|
|
1028
|
+
groups.set(record.selfAid, records);
|
|
1029
|
+
}
|
|
1030
|
+
for (const [aid, lines] of groups) {
|
|
1031
|
+
const destination = path.join(manifest.root, 'agents', aid, 'data', 'contact-audit.jsonl');
|
|
1032
|
+
const content = lines.join('\n') + '\n';
|
|
1033
|
+
const expectedHash = hashContent(content);
|
|
1034
|
+
operation.destinations.push(destination);
|
|
1035
|
+
operation.destinationHashes[destination] = expectedHash;
|
|
1036
|
+
if (fs.existsSync(destination)) {
|
|
1037
|
+
const existing = fs.readFileSync(destination, 'utf8');
|
|
1038
|
+
if (hashContent(existing) !== expectedHash) {
|
|
1039
|
+
// Audit logs are append-only. An exact existing prefix is a partial
|
|
1040
|
+
// prior copy, so replacing it with the verified complete partition is
|
|
1041
|
+
// lossless; every other mismatch remains a manual conflict.
|
|
1042
|
+
if (!content.startsWith(existing)) {
|
|
1043
|
+
appendIssue(operation, `destination conflict: ${destination}`);
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
atomicWriteText(destination, content);
|
|
1047
|
+
}
|
|
1048
|
+
continue;
|
|
1049
|
+
}
|
|
1050
|
+
if (!operation.staging)
|
|
1051
|
+
throw new Error('contact audit operation has no staging path');
|
|
1052
|
+
const stage = path.join(operation.staging, `${encodeSegment(aid)}.jsonl`);
|
|
1053
|
+
if (!fs.existsSync(stage)) {
|
|
1054
|
+
fs.mkdirSync(path.dirname(stage), { recursive: true });
|
|
1055
|
+
const temporary = `${stage}.tmp`;
|
|
1056
|
+
fs.writeFileSync(temporary, content);
|
|
1057
|
+
fs.renameSync(temporary, stage);
|
|
1058
|
+
operation.status = 'staged';
|
|
1059
|
+
checkpoint();
|
|
1060
|
+
}
|
|
1061
|
+
strictJsonValidation(stage);
|
|
1062
|
+
if (hashContent(fs.readFileSync(stage, 'utf8')) !== expectedHash) {
|
|
1063
|
+
appendIssue(operation, `staging content mismatch: ${stage}`);
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
operation.status = 'verified';
|
|
1067
|
+
checkpoint();
|
|
1068
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
1069
|
+
fs.renameSync(stage, destination);
|
|
1070
|
+
checkpoint();
|
|
1071
|
+
}
|
|
1072
|
+
if (operation.issues.length > 0) {
|
|
1073
|
+
operation.status = 'error';
|
|
1074
|
+
operation.error = operation.issues.join('; ');
|
|
1075
|
+
}
|
|
1076
|
+
else {
|
|
1077
|
+
operation.status = operation.destinations.length > 0 ? 'committed' : 'skipped';
|
|
1078
|
+
operation.error = undefined;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
function executeCopy(operation, checkpoint) {
|
|
1082
|
+
if (!operation.destination)
|
|
1083
|
+
throw new Error('copy operation has no destination');
|
|
1084
|
+
const sourceHash = treeHash(operation.source, operation.exclude);
|
|
1085
|
+
if (operation.sourceHash && operation.sourceHash !== sourceHash) {
|
|
1086
|
+
throw new Error(`source changed since migration began: ${operation.source}`);
|
|
1087
|
+
}
|
|
1088
|
+
operation.sourceHash = sourceHash;
|
|
1089
|
+
const expectedHash = treeHash(operation.source, operation.exclude, '', operation.transform, operation.sessionChannelKey);
|
|
1090
|
+
if (operation.expectedHash && operation.expectedHash !== expectedHash) {
|
|
1091
|
+
throw new Error(`expected output changed since migration began: ${operation.source}`);
|
|
1092
|
+
}
|
|
1093
|
+
operation.expectedHash = expectedHash;
|
|
1094
|
+
if (fs.existsSync(operation.destination)) {
|
|
1095
|
+
operation.destinationHash = treeHash(operation.destination);
|
|
1096
|
+
if (operation.destinationHash === expectedHash) {
|
|
1097
|
+
operation.status = 'skipped';
|
|
1098
|
+
operation.error = undefined;
|
|
1099
|
+
operation.verificationError = undefined;
|
|
1100
|
+
return;
|
|
1101
|
+
}
|
|
1102
|
+
if (operation.transform === 'rewrite-trigger-session-paths') {
|
|
1103
|
+
reconcileLiveTriggerDestination(operation);
|
|
1104
|
+
operation.preservedLiveTrigger = true;
|
|
1105
|
+
operation.destinationHash = treeHash(operation.destination);
|
|
1106
|
+
operation.status = 'committed';
|
|
1107
|
+
operation.error = undefined;
|
|
1108
|
+
operation.verificationError = undefined;
|
|
1109
|
+
checkpoint();
|
|
1110
|
+
return;
|
|
1111
|
+
}
|
|
1112
|
+
throw new Error(`destination conflict: ${operation.destination}`);
|
|
1113
|
+
}
|
|
1114
|
+
if (!operation.staging)
|
|
1115
|
+
throw new Error('copy operation has no staging path');
|
|
1116
|
+
if (!fs.existsSync(operation.staging)) {
|
|
1117
|
+
fs.mkdirSync(path.dirname(operation.staging), { recursive: true });
|
|
1118
|
+
fs.cpSync(operation.source, operation.staging, {
|
|
1119
|
+
recursive: true,
|
|
1120
|
+
filter: source => !operation.exclude?.includes(path.relative(operation.source, source)),
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1123
|
+
transformTree(operation.staging, operation.transform, operation.sessionChannelKey);
|
|
1124
|
+
operation.status = 'staged';
|
|
1125
|
+
operation.stagedHash = treeHash(operation.staging);
|
|
1126
|
+
checkpoint();
|
|
1127
|
+
validateTree(operation.staging);
|
|
1128
|
+
operation.stagedHash = treeHash(operation.staging);
|
|
1129
|
+
if (operation.stagedHash !== expectedHash)
|
|
1130
|
+
throw new Error(`staging hash mismatch: ${operation.staging}`);
|
|
1131
|
+
operation.status = 'verified';
|
|
1132
|
+
checkpoint();
|
|
1133
|
+
fs.mkdirSync(path.dirname(operation.destination), { recursive: true });
|
|
1134
|
+
fs.renameSync(operation.staging, operation.destination);
|
|
1135
|
+
operation.destinationHash = treeHash(operation.destination);
|
|
1136
|
+
if (operation.destinationHash !== expectedHash)
|
|
1137
|
+
throw new Error('content hash mismatch after commit');
|
|
1138
|
+
operation.status = 'committed';
|
|
1139
|
+
operation.error = undefined;
|
|
1140
|
+
operation.verificationError = undefined;
|
|
1141
|
+
}
|
|
1142
|
+
function operationSources(operation) {
|
|
1143
|
+
return [...new Set([operation.source, ...(operation.sources ?? [])])];
|
|
1144
|
+
}
|
|
1145
|
+
function trackMergeSources(operation) {
|
|
1146
|
+
const sources = operationSources(operation);
|
|
1147
|
+
const hashes = {};
|
|
1148
|
+
for (const source of sources) {
|
|
1149
|
+
const sourceHash = treeHash(source, operation.exclude);
|
|
1150
|
+
if (operation.sourceHashes?.[source] && operation.sourceHashes[source] !== sourceHash) {
|
|
1151
|
+
throw new Error(`source changed since migration began: ${source}`);
|
|
1152
|
+
}
|
|
1153
|
+
hashes[source] = sourceHash;
|
|
1154
|
+
}
|
|
1155
|
+
operation.sourceHashes = hashes;
|
|
1156
|
+
operation.sourceHash = hashes[operation.source];
|
|
1157
|
+
return sources;
|
|
1158
|
+
}
|
|
1159
|
+
function mergeJsonl(target, source) {
|
|
1160
|
+
const lines = new Set(fs.readFileSync(target, 'utf8').split('\n').filter(line => line.trim()));
|
|
1161
|
+
const merged = [...lines];
|
|
1162
|
+
for (const line of fs.readFileSync(source, 'utf8').split('\n')) {
|
|
1163
|
+
if (!line.trim() || lines.has(line))
|
|
1164
|
+
continue;
|
|
1165
|
+
lines.add(line);
|
|
1166
|
+
merged.push(line);
|
|
1167
|
+
}
|
|
1168
|
+
fs.writeFileSync(target, merged.length ? `${merged.join('\n')}\n` : '');
|
|
1169
|
+
}
|
|
1170
|
+
function copyTree(source, destination) {
|
|
1171
|
+
const stat = fs.statSync(source);
|
|
1172
|
+
if (stat.isFile()) {
|
|
1173
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
1174
|
+
fs.copyFileSync(source, destination);
|
|
1175
|
+
return;
|
|
1176
|
+
}
|
|
1177
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
1178
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true })) {
|
|
1179
|
+
if (entry.isDirectory() || entry.isFile())
|
|
1180
|
+
copyTree(path.join(source, entry.name), path.join(destination, entry.name));
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
function mergeTree(source, destination, stagingRoot, relative = '') {
|
|
1184
|
+
const stat = fs.statSync(source);
|
|
1185
|
+
if (stat.isFile()) {
|
|
1186
|
+
if (!fs.existsSync(destination)) {
|
|
1187
|
+
copyTree(source, destination);
|
|
1188
|
+
return;
|
|
1189
|
+
}
|
|
1190
|
+
const sourceContent = fs.readFileSync(source);
|
|
1191
|
+
const destinationContent = fs.readFileSync(destination);
|
|
1192
|
+
if (sourceContent.equals(destinationContent))
|
|
1193
|
+
return;
|
|
1194
|
+
if (source.endsWith('.jsonl')) {
|
|
1195
|
+
mergeJsonl(destination, source);
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
// Both payloads remain recoverable even when a singleton session file has
|
|
1199
|
+
// incompatible historic versions. The canonical first copy stays in place.
|
|
1200
|
+
const version = crypto.createHash('sha256').update(sourceContent).digest('hex').slice(0, 16);
|
|
1201
|
+
copyTree(source, path.join(stagingRoot, '_legacy-conflicts', version, relative));
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
fs.mkdirSync(destination, { recursive: true });
|
|
1205
|
+
for (const entry of fs.readdirSync(source, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
1206
|
+
if (!entry.isDirectory() && !entry.isFile())
|
|
1207
|
+
continue;
|
|
1208
|
+
const childRelative = relative ? path.join(relative, entry.name) : entry.name;
|
|
1209
|
+
mergeTree(path.join(source, entry.name), path.join(destination, entry.name), stagingRoot, childRelative);
|
|
1210
|
+
}
|
|
1211
|
+
}
|
|
1212
|
+
function executeMergeCopy(operation, checkpoint) {
|
|
1213
|
+
if (!operation.destination || !operation.staging)
|
|
1214
|
+
throw new Error('merge-copy operation is incomplete');
|
|
1215
|
+
const sources = trackMergeSources(operation);
|
|
1216
|
+
const sourceOutputHashes = sources.map(source => treeHash(source, operation.exclude, '', operation.transform, operation.sessionChannelKey));
|
|
1217
|
+
// The legacy copies remain authoritative, so a partially built stage may
|
|
1218
|
+
// always be recreated safely. No destination is touched before validation.
|
|
1219
|
+
if (fs.existsSync(operation.staging))
|
|
1220
|
+
fs.rmSync(operation.staging, { recursive: true, force: true });
|
|
1221
|
+
for (const source of sources)
|
|
1222
|
+
mergeTree(source, operation.staging, operation.staging);
|
|
1223
|
+
transformTree(operation.staging, operation.transform, operation.sessionChannelKey);
|
|
1224
|
+
operation.status = 'staged';
|
|
1225
|
+
operation.stagedHash = treeHash(operation.staging);
|
|
1226
|
+
checkpoint();
|
|
1227
|
+
validateTree(operation.staging);
|
|
1228
|
+
const expectedHash = treeHash(operation.staging);
|
|
1229
|
+
if (operation.expectedHash && operation.expectedHash !== expectedHash) {
|
|
1230
|
+
throw new Error(`expected output changed since migration began: ${operation.source}`);
|
|
1231
|
+
}
|
|
1232
|
+
operation.expectedHash = expectedHash;
|
|
1233
|
+
operation.status = 'verified';
|
|
1234
|
+
checkpoint();
|
|
1235
|
+
if (fs.existsSync(operation.destination)) {
|
|
1236
|
+
const destinationHash = treeHash(operation.destination);
|
|
1237
|
+
if (destinationHash !== expectedHash && !sourceOutputHashes.includes(destinationHash)) {
|
|
1238
|
+
throw new Error(`destination conflict: ${operation.destination}`);
|
|
1239
|
+
}
|
|
1240
|
+
if (destinationHash !== expectedHash)
|
|
1241
|
+
copyTree(operation.staging, operation.destination);
|
|
1242
|
+
}
|
|
1243
|
+
else {
|
|
1244
|
+
fs.mkdirSync(path.dirname(operation.destination), { recursive: true });
|
|
1245
|
+
fs.renameSync(operation.staging, operation.destination);
|
|
1246
|
+
}
|
|
1247
|
+
operation.destinationHash = treeHash(operation.destination);
|
|
1248
|
+
if (operation.destinationHash !== expectedHash)
|
|
1249
|
+
throw new Error('content hash mismatch after commit');
|
|
1250
|
+
operation.status = 'committed';
|
|
1251
|
+
operation.error = undefined;
|
|
1252
|
+
operation.verificationError = undefined;
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* Copy each planned item independently. Failed items remain in the manifest;
|
|
1256
|
+
* later items still run and a subsequent `--resume` only retries failures.
|
|
1257
|
+
*/
|
|
1258
|
+
export function applyDataMigration(manifest) {
|
|
1259
|
+
manifest.state = 'running';
|
|
1260
|
+
writeManifest(manifest);
|
|
1261
|
+
for (const operation of manifest.operations) {
|
|
1262
|
+
if (operation.status === 'committed' || operation.status === 'skipped' || operation.status === 'acknowledged')
|
|
1263
|
+
continue;
|
|
1264
|
+
try {
|
|
1265
|
+
const checkpoint = () => writeManifest(manifest);
|
|
1266
|
+
if (operation.kind === 'copy')
|
|
1267
|
+
executeCopy(operation, checkpoint);
|
|
1268
|
+
else if (operation.kind === 'merge-copy')
|
|
1269
|
+
executeMergeCopy(operation, checkpoint);
|
|
1270
|
+
else
|
|
1271
|
+
executeContactAudit(manifest, operation, checkpoint);
|
|
1272
|
+
}
|
|
1273
|
+
catch (error) {
|
|
1274
|
+
operation.status = 'error';
|
|
1275
|
+
operation.error = error instanceof Error ? error.message : String(error);
|
|
1276
|
+
operation.verificationError = undefined;
|
|
1277
|
+
}
|
|
1278
|
+
writeManifest(manifest);
|
|
1279
|
+
}
|
|
1280
|
+
manifest.state = manifest.operations.some(operation => operation.status === 'error' || operation.issues?.length)
|
|
1281
|
+
? 'completed_with_errors'
|
|
1282
|
+
: 'completed';
|
|
1283
|
+
writeManifest(manifest);
|
|
1284
|
+
return manifest;
|
|
1285
|
+
}
|
|
1286
|
+
export function readDataMigration(root, id) {
|
|
1287
|
+
const manifest = readJson(manifestPath(root, id));
|
|
1288
|
+
if (!manifest || manifest.schemaVersion !== 1)
|
|
1289
|
+
throw new Error(`migration manifest not found: ${id}`);
|
|
1290
|
+
return manifest;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Startup gate for the explicit migration workflow. Legacy source files are
|
|
1294
|
+
* deliberately retained after success, so their mere presence is not enough
|
|
1295
|
+
* to block startup; only a changed or unacknowledged plan requires action.
|
|
1296
|
+
*/
|
|
1297
|
+
export function inspectDataMigrationRequirement(root) {
|
|
1298
|
+
const plan = planDataMigration(root);
|
|
1299
|
+
const planSignature = plan.planSignature ?? migrationPlanSignature(plan.operations);
|
|
1300
|
+
if (plan.operations.length === 0) {
|
|
1301
|
+
return { required: false, operationCount: 0, planSignature };
|
|
1302
|
+
}
|
|
1303
|
+
let completedMigrationId;
|
|
1304
|
+
try {
|
|
1305
|
+
const manifests = fs.readdirSync(migrationDir(root), { withFileTypes: true });
|
|
1306
|
+
for (const entry of manifests) {
|
|
1307
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
1308
|
+
continue;
|
|
1309
|
+
const manifest = readJson(path.join(migrationDir(root), entry.name));
|
|
1310
|
+
if (manifest?.state !== 'completed' || manifest.planSignature !== planSignature)
|
|
1311
|
+
continue;
|
|
1312
|
+
completedMigrationId = manifest.id;
|
|
1313
|
+
break;
|
|
1314
|
+
}
|
|
1315
|
+
}
|
|
1316
|
+
catch { }
|
|
1317
|
+
return {
|
|
1318
|
+
required: !completedMigrationId,
|
|
1319
|
+
operationCount: plan.operations.length,
|
|
1320
|
+
planSignature,
|
|
1321
|
+
completedMigrationId,
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
export function verifyDataMigration(manifest, { strictTargetContent = true } = {}) {
|
|
1325
|
+
for (const operation of manifest.operations) {
|
|
1326
|
+
if (operation.status === 'acknowledged')
|
|
1327
|
+
continue;
|
|
1328
|
+
if (operation.preservedLiveTrigger) {
|
|
1329
|
+
try {
|
|
1330
|
+
verifyLiveTriggerDestination(operation);
|
|
1331
|
+
operation.destinationHash = treeHash(operation.destination);
|
|
1332
|
+
operation.status = 'committed';
|
|
1333
|
+
operation.error = undefined;
|
|
1334
|
+
operation.verificationError = undefined;
|
|
1335
|
+
}
|
|
1336
|
+
catch (error) {
|
|
1337
|
+
operation.status = 'error';
|
|
1338
|
+
operation.verificationError = error instanceof Error ? error.message : String(error);
|
|
1339
|
+
operation.error ??= operation.verificationError;
|
|
1340
|
+
}
|
|
1341
|
+
continue;
|
|
1342
|
+
}
|
|
1343
|
+
if (operation.kind === 'contact-audit-partition') {
|
|
1344
|
+
try {
|
|
1345
|
+
const sourceHash = treeHash(operation.source);
|
|
1346
|
+
if (operation.sourceHash && operation.sourceHash !== sourceHash)
|
|
1347
|
+
throw new Error('source changed since migration began');
|
|
1348
|
+
operation.sourceHash = sourceHash;
|
|
1349
|
+
for (const destination of operation.destinations ?? []) {
|
|
1350
|
+
if (!fs.existsSync(destination))
|
|
1351
|
+
throw new Error(`destination missing: ${destination}`);
|
|
1352
|
+
strictJsonValidation(destination);
|
|
1353
|
+
const expectedHash = operation.destinationHashes?.[destination];
|
|
1354
|
+
if (strictTargetContent && expectedHash && hashContent(fs.readFileSync(destination, 'utf8')) !== expectedHash) {
|
|
1355
|
+
throw new Error(`content hash mismatch: ${destination}`);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
if (operation.issues?.length)
|
|
1359
|
+
throw new Error(operation.issues.join('; '));
|
|
1360
|
+
operation.status = operation.destinations?.length ? 'committed' : 'skipped';
|
|
1361
|
+
operation.error = undefined;
|
|
1362
|
+
}
|
|
1363
|
+
catch (error) {
|
|
1364
|
+
operation.status = 'error';
|
|
1365
|
+
operation.verificationError = error instanceof Error ? error.message : String(error);
|
|
1366
|
+
operation.error ??= operation.verificationError;
|
|
1367
|
+
}
|
|
1368
|
+
continue;
|
|
1369
|
+
}
|
|
1370
|
+
if (operation.kind === 'merge-copy') {
|
|
1371
|
+
try {
|
|
1372
|
+
if (!operation.destination || !fs.existsSync(operation.destination))
|
|
1373
|
+
throw new Error('destination missing');
|
|
1374
|
+
trackMergeSources(operation);
|
|
1375
|
+
validateTree(operation.destination);
|
|
1376
|
+
const destinationHash = treeHash(operation.destination);
|
|
1377
|
+
operation.destinationHash = destinationHash;
|
|
1378
|
+
if (!operation.expectedHash)
|
|
1379
|
+
throw new Error('expected output hash missing');
|
|
1380
|
+
if (strictTargetContent && operation.expectedHash !== destinationHash)
|
|
1381
|
+
throw new Error('content hash mismatch');
|
|
1382
|
+
operation.status = 'committed';
|
|
1383
|
+
operation.error = undefined;
|
|
1384
|
+
operation.verificationError = undefined;
|
|
1385
|
+
}
|
|
1386
|
+
catch (error) {
|
|
1387
|
+
operation.status = 'error';
|
|
1388
|
+
operation.verificationError = error instanceof Error ? error.message : String(error);
|
|
1389
|
+
operation.error ??= operation.verificationError;
|
|
1390
|
+
}
|
|
1391
|
+
continue;
|
|
1392
|
+
}
|
|
1393
|
+
if (!operation.destination || !fs.existsSync(operation.destination)) {
|
|
1394
|
+
operation.status = 'error';
|
|
1395
|
+
operation.verificationError = 'destination missing';
|
|
1396
|
+
operation.error ??= operation.verificationError;
|
|
1397
|
+
continue;
|
|
1398
|
+
}
|
|
1399
|
+
try {
|
|
1400
|
+
validateTree(operation.destination);
|
|
1401
|
+
const sourceHash = treeHash(operation.source, operation.exclude);
|
|
1402
|
+
if (operation.sourceHash && operation.sourceHash !== sourceHash) {
|
|
1403
|
+
throw new Error('source changed since migration began');
|
|
1404
|
+
}
|
|
1405
|
+
const expectedHash = treeHash(operation.source, operation.exclude, '', operation.transform, operation.sessionChannelKey);
|
|
1406
|
+
if (operation.expectedHash && operation.expectedHash !== expectedHash) {
|
|
1407
|
+
throw new Error('expected output changed since migration began');
|
|
1408
|
+
}
|
|
1409
|
+
const destinationHash = treeHash(operation.destination);
|
|
1410
|
+
operation.sourceHash = sourceHash;
|
|
1411
|
+
operation.expectedHash = expectedHash;
|
|
1412
|
+
operation.destinationHash = destinationHash;
|
|
1413
|
+
if (!strictTargetContent && operation.status === 'error' && operation.error?.startsWith('destination conflict') && expectedHash !== destinationHash) {
|
|
1414
|
+
throw new Error(operation.error ?? 'content hash mismatch');
|
|
1415
|
+
}
|
|
1416
|
+
if (strictTargetContent && expectedHash !== destinationHash)
|
|
1417
|
+
throw new Error('content hash mismatch');
|
|
1418
|
+
if (operation.status !== 'skipped')
|
|
1419
|
+
operation.status = 'committed';
|
|
1420
|
+
operation.error = undefined;
|
|
1421
|
+
operation.verificationError = undefined;
|
|
1422
|
+
}
|
|
1423
|
+
catch (error) {
|
|
1424
|
+
operation.status = 'error';
|
|
1425
|
+
operation.verificationError = error instanceof Error ? error.message : String(error);
|
|
1426
|
+
operation.error ??= operation.verificationError;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
manifest.state = manifest.operations.some(operation => operation.status === 'error' || operation.issues?.length)
|
|
1430
|
+
? 'completed_with_errors'
|
|
1431
|
+
: 'completed';
|
|
1432
|
+
writeManifest(manifest);
|
|
1433
|
+
return manifest;
|
|
1434
|
+
}
|