evolcore 0.0.2 → 0.0.3
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 +44 -793
- package/dist/agents/claude-runner.js +197 -17
- package/dist/agents/codex-runner.js +46 -3
- package/dist/aun/outbox.js +8 -0
- package/dist/channels/aun.js +21 -4
- package/dist/channels/contact-bind-code.js +134 -0
- package/dist/channels/dingtalk.js +979 -149
- package/dist/channels/feishu.js +130 -54
- package/dist/channels/wecom-card.js +101 -0
- package/dist/channels/wecom-onboarding.js +82 -0
- package/dist/channels/wecom-state.js +191 -0
- package/dist/channels/wecom.js +755 -163
- package/dist/cli/agent-command.js +2 -1
- package/dist/cli/aun-commands.js +88 -33
- package/dist/cli/bench.js +2 -2
- package/dist/cli/contact.js +71 -0
- package/dist/cli/ctl-command.js +2 -2
- package/dist/cli/daemon-commands.js +77 -214
- package/dist/cli/handoff-command.js +2 -2
- package/dist/cli/help.js +9 -5
- package/dist/cli/index.js +132 -116
- package/dist/cli/init-channel.js +92 -97
- package/dist/cli/init.js +63 -26
- package/dist/cli/model.js +2 -1
- package/dist/cli/net-check.js +2 -2
- package/dist/cli/queue-command.js +30 -6
- package/dist/cli/raw-key-input.js +25 -0
- package/dist/cli/response.js +5 -6
- package/dist/cli/restart-monitor.js +25 -1
- package/dist/cli/stats.js +6 -4
- package/dist/cli/trigger-command.js +55 -15
- package/dist/cli/version.js +6 -1
- package/dist/config/builtin-role-templates.js +22 -10
- package/dist/config/builtin-roles.js +7 -1
- package/dist/config/config-manager.js +221 -17
- package/dist/config/contact-alias.js +68 -0
- package/dist/config/contact-book-store.js +454 -0
- package/dist/config/contact-book-v2-startup.js +35 -0
- package/dist/config/contact-book.js +156 -303
- package/dist/config/contact-operation-service.js +110 -0
- package/dist/config/peer-role-resolver.js +133 -54
- package/dist/config/role-ranks.js +18 -0
- package/dist/config/role-service.js +16 -19
- package/dist/config/role-store.js +16 -5
- package/dist/config/roles.js +10 -1
- package/dist/config-store.js +0 -2
- package/dist/core/auth/authorization-audit.js +10 -1
- package/dist/core/auth/operation-authorizer.js +2 -0
- package/dist/core/auth/operation-catalog.js +56 -0
- package/dist/core/command/command-handler.js +105 -10
- package/dist/core/command/connect-menu.js +374 -0
- package/dist/core/command/menu-handler.js +114 -16
- package/dist/core/command/role-menu.js +128 -29
- package/dist/core/command/slash-handler.js +28 -18
- package/dist/core/daemon-file-cache.js +12 -6
- package/dist/core/event-catalog.js +70 -0
- package/dist/core/evolagent-registry.js +0 -1
- package/dist/core/evolagent.js +20 -9
- package/dist/core/message/im-renderer.js +2 -0
- package/dist/core/message/message-bridge.js +79 -8
- package/dist/core/message/message-queue.js +10 -0
- package/dist/core/message/message-utils.js +8 -2
- package/dist/core/message/response-engine.js +269 -25
- package/dist/core/message/send-receipt.js +24 -0
- package/dist/core/message/stream-debouncer.js +11 -2
- package/dist/core/permission/tool-policy.js +47 -15
- package/dist/core/protected-paths.js +2 -0
- package/dist/core/session/session-fs-store.js +44 -3
- package/dist/core/session/session-manager.js +61 -5
- package/dist/index.js +62 -6
- package/dist/ipc.js +34 -5
- package/dist/stats/billing.js +20 -8
- package/dist/trigger/manager.js +3 -0
- package/dist/trigger/parser.js +77 -2
- package/dist/trigger/patch.js +8 -1
- package/dist/trigger/scheduler.js +3 -1
- package/dist/trigger/validation.js +13 -0
- package/dist/utils/aid-bind.js +43 -29
- package/dist/utils/instance-registry.js +14 -7
- package/dist/utils/log-writer.js +46 -0
- package/dist/utils/media-cache.js +4 -1
- package/dist/utils/model-prices.jsonl +6 -3
- package/dist/utils/restart-safety.js +31 -0
- package/dist/utils/system-memory.js +62 -0
- package/kits/docs/INDEX.md +2 -1
- package/kits/docs/evolcore/INDEX.md +5 -3
- package/kits/docs/evolcore/agent.md +9 -1
- package/kits/docs/evolcore/aid.md +5 -2
- package/kits/docs/evolcore/contact.md +57 -0
- package/kits/docs/evolcore/fs.md +9 -0
- package/kits/docs/evolcore/group.md +12 -3
- package/kits/docs/evolcore/model.md +4 -1
- package/kits/docs/evolcore/msg.md +9 -3
- package/kits/docs/evolcore/response.md +16 -21
- package/kits/docs/evolcore/rpc.md +2 -0
- package/kits/docs/evolcore/stats.md +15 -2
- package/kits/docs/evolcore/storage.md +1 -0
- package/kits/docs/evolcore/trigger.md +17 -2
- package/kits/eck_manifest.json +12 -0
- package/kits/migrations/migrate-contact-book-v2.mjs +747 -0
- package/kits/schemas/_meta.json +7 -4
- package/kits/schemas/agent-config.schema.6.json +322 -0
- package/kits/schemas/contact-book.schema.2.json +43 -0
- package/kits/schemas/relation-config.schema.5.json +47 -0
- package/kits/schemas/role-config.schema.1.json +1 -0
- package/kits/schemas/role-registry.schema.1.json +2 -2
- package/kits/templates/roles/admin.json +1 -0
- package/kits/templates/roles/member.json +1 -0
- package/kits/templates/roles/owner.json +1 -0
- package/kits/templates/roles/visitor.json +1 -0
- package/kits/templates/system-fragments/commands.md +3 -1
- package/package.json +4 -4
- package/assets/brand/evolcore/README.md +0 -19
- package/assets/brand/evolcore/evolcore-app-icon.png +0 -0
- package/assets/brand/evolcore/evolcore-app-icon.svg +0 -13
- package/assets/brand/evolcore/evolcore-brand-board.png +0 -0
- package/assets/brand/evolcore/evolcore-brand-board.svg +0 -126
- package/assets/brand/evolcore/evolcore-logo-kit.zip +0 -0
- package/assets/brand/evolcore/evolcore-logo-reverse.png +0 -0
- package/assets/brand/evolcore/evolcore-logo-reverse.svg +0 -14
- package/assets/brand/evolcore/evolcore-logo.png +0 -0
- package/assets/brand/evolcore/evolcore-logo.svg +0 -14
- package/assets/brand/evolcore/evolcore-mark.png +0 -0
- package/assets/brand/evolcore/evolcore-mark.svg +0 -10
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import os from 'os';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
import Ajv from 'ajv';
|
|
7
|
+
|
|
8
|
+
const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
9
|
+
const SCHEMA_DIR = path.resolve(SCRIPT_DIR, '..', 'schemas');
|
|
10
|
+
const AGENT_LOCK_NAME = '.contact-book-mutation.lock';
|
|
11
|
+
const RECEIPT_NAME = 'contact-book-v2-relation-role-authority.json';
|
|
12
|
+
const RECEIPT = Object.freeze({
|
|
13
|
+
schemaVersion: 1,
|
|
14
|
+
migration: 'contact-book-v2-relation-role-authority',
|
|
15
|
+
designVersion: 2,
|
|
16
|
+
});
|
|
17
|
+
const CONTACT_KEYS = new Set(['displayName', 'aliases', 'status', 'subjectType', 'role', 'blocked']);
|
|
18
|
+
const CONTACT_STATUSES = new Set(['active', 'blocked', 'pending']);
|
|
19
|
+
|
|
20
|
+
function parseArgs(argv) {
|
|
21
|
+
const options = { apply: false, json: false, home: undefined };
|
|
22
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
23
|
+
const arg = argv[index];
|
|
24
|
+
if (arg === '--apply') options.apply = true;
|
|
25
|
+
else if (arg === '--json') options.json = true;
|
|
26
|
+
else if (arg === '--home') options.home = argv[++index];
|
|
27
|
+
else if (arg === '--help' || arg === '-h') options.help = true;
|
|
28
|
+
else throw new Error(`Unknown argument: ${arg}`);
|
|
29
|
+
}
|
|
30
|
+
return options;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveHome(explicit) {
|
|
34
|
+
return path.resolve(explicit || process.env.EVOLCORE_HOME || path.join(os.homedir(), '.evolcore'));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function isRecord(value) {
|
|
38
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function isValidAid(name) {
|
|
42
|
+
if (typeof name !== 'string') return false;
|
|
43
|
+
const labels = name.split('.');
|
|
44
|
+
return labels.length >= 3 && labels.every(label => /^[a-zA-Z0-9](?:[a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(label));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isExplicitGroupId(value) {
|
|
48
|
+
const id = String(value || '').trim();
|
|
49
|
+
return (id.startsWith('group.') && id.includes('/'))
|
|
50
|
+
|| /^\d+\.[a-z0-9.-]+$/i.test(id)
|
|
51
|
+
|| /^grp_[a-z0-9_-]+$/i.test(id)
|
|
52
|
+
|| /^g-[a-z0-9_-]+(?:[.@][a-z0-9.-]+)?$/i.test(id)
|
|
53
|
+
|| /(?:^|\.)groupid\.pub$/i.test(id);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readJson(file) {
|
|
57
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
58
|
+
return { value: JSON.parse(text), text };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function loadValidators() {
|
|
62
|
+
const ajv = new Ajv({ allErrors: true, strict: false });
|
|
63
|
+
const load = name => JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, name), 'utf8'));
|
|
64
|
+
return {
|
|
65
|
+
agent: ajv.compile(load('agent-config.schema.6.json')),
|
|
66
|
+
contact: ajv.compile(load('contact-book.schema.2.json')),
|
|
67
|
+
relation: ajv.compile(load('relation-config.schema.5.json')),
|
|
68
|
+
registry: ajv.compile(load('role-registry.schema.1.json')),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function validateOrThrow(validate, value, file) {
|
|
73
|
+
if (validate(value)) return;
|
|
74
|
+
const detail = (validate.errors ?? [])
|
|
75
|
+
.map(error => `${error.instancePath || '/'} ${error.message}`)
|
|
76
|
+
.join('; ');
|
|
77
|
+
throw new Error(`${file}: ${detail}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parsePeerKey(peerKey, file) {
|
|
81
|
+
const separator = peerKey.indexOf('#');
|
|
82
|
+
if (separator <= 0 || separator === peerKey.length - 1) {
|
|
83
|
+
throw new Error(`${file}: invalid relation peerKey ${JSON.stringify(peerKey)}`);
|
|
84
|
+
}
|
|
85
|
+
let channelId;
|
|
86
|
+
try { channelId = decodeURIComponent(peerKey.slice(separator + 1)); }
|
|
87
|
+
catch { throw new Error(`${file}: relation peerKey has invalid URL encoding`); }
|
|
88
|
+
return { channelType: peerKey.slice(0, separator).toLowerCase(), channelId };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function canonicalPeerKey(channelType, channelId) {
|
|
92
|
+
return `${channelType.toLowerCase()}#${encodeURIComponent(channelId)}`;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function listDirectRelationFiles(relationsDir) {
|
|
96
|
+
if (!fs.existsSync(relationsDir)) return [];
|
|
97
|
+
const files = [];
|
|
98
|
+
for (const entry of fs.readdirSync(relationsDir, { withFileTypes: true })) {
|
|
99
|
+
if (!entry.isDirectory() || entry.name.startsWith('_')) continue;
|
|
100
|
+
const file = path.join(relationsDir, entry.name, 'config.json');
|
|
101
|
+
if (fs.existsSync(file)) files.push(file);
|
|
102
|
+
}
|
|
103
|
+
return files.sort();
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function jsonText(value) {
|
|
107
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function appendChange(changes, file, kind, before, value) {
|
|
111
|
+
const after = jsonText(value);
|
|
112
|
+
if (before === after) return false;
|
|
113
|
+
changes.push({ file, kind, before, after });
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function roleValue(value, roleIds, file, label) {
|
|
118
|
+
if (value === undefined || value === null) return null;
|
|
119
|
+
if (typeof value !== 'string' || !value) throw new Error(`${file}: ${label} must be a string or null`);
|
|
120
|
+
if (value === 'owner') throw new Error(`${file}: ${label} cannot be owner`);
|
|
121
|
+
if (!roleIds.has(value)) throw new Error(`${file}: ${label} references unknown role ${JSON.stringify(value)}`);
|
|
122
|
+
return value;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function canonicalChannelKey(channelType, aid, channelName) {
|
|
126
|
+
return `${channelType.toLowerCase()}#${aid}#${channelName}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function channelInventory(agent, aid, file) {
|
|
130
|
+
const keys = new Set();
|
|
131
|
+
const keysByName = new Map();
|
|
132
|
+
const keysByType = new Map();
|
|
133
|
+
for (const channel of agent.channels ?? []) {
|
|
134
|
+
if (!isRecord(channel)) continue;
|
|
135
|
+
const type = String(channel.type || '').trim().toLowerCase();
|
|
136
|
+
const name = String(channel.name || '').trim();
|
|
137
|
+
if (!type || !name) continue;
|
|
138
|
+
if (!/^[A-Za-z0-9_-]+$/.test(type)) throw new Error(`${file}: invalid channel type ${JSON.stringify(type)}`);
|
|
139
|
+
if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`${file}: invalid channel instance name ${JSON.stringify(name)}`);
|
|
140
|
+
if (type === 'aun') continue;
|
|
141
|
+
const key = canonicalChannelKey(type, aid, name);
|
|
142
|
+
if (keys.has(key)) throw new Error(`${file}: duplicate channel instance ${key}`);
|
|
143
|
+
keys.add(key);
|
|
144
|
+
const byName = keysByName.get(name) ?? new Set();
|
|
145
|
+
byName.add(key);
|
|
146
|
+
keysByName.set(name, byName);
|
|
147
|
+
const byType = keysByType.get(type) ?? new Set();
|
|
148
|
+
byType.add(key);
|
|
149
|
+
keysByType.set(type, byType);
|
|
150
|
+
}
|
|
151
|
+
return { keys, keysByName, keysByType };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function parseCanonicalChannelKey(channelKey, aid, inventory, file, alias) {
|
|
155
|
+
const parts = channelKey.split('#');
|
|
156
|
+
if (parts.length !== 3) throw new Error(`${file}: invalid channelKey in alias ${JSON.stringify(alias)}`);
|
|
157
|
+
const [type, selfAid, name] = parts;
|
|
158
|
+
if (!/^[A-Za-z0-9_-]+$/.test(type) || type !== type.toLowerCase()
|
|
159
|
+
|| selfAid !== aid || !/^[A-Za-z0-9_-]+$/.test(name)) {
|
|
160
|
+
throw new Error(`${file}: alias does not identify this Agent channel instance: ${JSON.stringify(alias)}`);
|
|
161
|
+
}
|
|
162
|
+
const canonical = canonicalChannelKey(type, aid, name);
|
|
163
|
+
if (type === 'aun') return null;
|
|
164
|
+
if (!inventory.keys.has(canonical)) {
|
|
165
|
+
throw new Error(`${file}: alias references an unconfigured channel instance: ${JSON.stringify(alias)}`);
|
|
166
|
+
}
|
|
167
|
+
return canonical;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function encodedPeerId(value, file, alias, alreadyEncoded = false) {
|
|
171
|
+
let peerId = value;
|
|
172
|
+
if (alreadyEncoded) {
|
|
173
|
+
try { peerId = decodeURIComponent(value); }
|
|
174
|
+
catch { throw new Error(`${file}: invalid URL encoding in alias ${JSON.stringify(alias)}`); }
|
|
175
|
+
if (encodeURIComponent(peerId) !== value) {
|
|
176
|
+
throw new Error(`${file}: non-canonical peer ID encoding in alias ${JSON.stringify(alias)}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!peerId) throw new Error(`${file}: alias has an empty peer ID: ${JSON.stringify(alias)}`);
|
|
180
|
+
return encodeURIComponent(peerId);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function parseLegacyScopedAlias(alias, aid, file) {
|
|
184
|
+
const encoded = alias.slice('channel-key:'.length);
|
|
185
|
+
const separator = encoded.indexOf(':');
|
|
186
|
+
if (separator <= 0 || separator === encoded.length - 1) {
|
|
187
|
+
throw new Error(`${file}: invalid scoped alias ${JSON.stringify(alias)}`);
|
|
188
|
+
}
|
|
189
|
+
let channelKey;
|
|
190
|
+
let actorId;
|
|
191
|
+
try {
|
|
192
|
+
channelKey = decodeURIComponent(encoded.slice(0, separator));
|
|
193
|
+
actorId = decodeURIComponent(encoded.slice(separator + 1));
|
|
194
|
+
} catch {
|
|
195
|
+
throw new Error(`${file}: invalid URL encoding in alias ${JSON.stringify(alias)}`);
|
|
196
|
+
}
|
|
197
|
+
return { channelKey, actorId };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function normalizeAlias(alias, aid, inventory, file) {
|
|
201
|
+
if (typeof alias !== 'string' || !alias.trim()) throw new Error(`${file}: aliases must contain non-empty strings`);
|
|
202
|
+
const text = alias.trim();
|
|
203
|
+
if (text.startsWith('channel-key:')) {
|
|
204
|
+
const legacy = parseLegacyScopedAlias(text, aid, file);
|
|
205
|
+
const channelKey = parseCanonicalChannelKey(legacy.channelKey, aid, inventory, file, text);
|
|
206
|
+
if (channelKey === null) return null;
|
|
207
|
+
return `${channelKey}:${encodedPeerId(legacy.actorId, file, text)}`;
|
|
208
|
+
}
|
|
209
|
+
const separator = text.indexOf(':');
|
|
210
|
+
if (separator <= 0 || separator === text.length - 1) throw new Error(`${file}: invalid alias ${JSON.stringify(alias)}`);
|
|
211
|
+
const prefix = text.slice(0, separator).trim();
|
|
212
|
+
const actorId = text.slice(separator + 1).trim();
|
|
213
|
+
if (prefix.includes('#')) {
|
|
214
|
+
const channelKey = parseCanonicalChannelKey(prefix, aid, inventory, file, text);
|
|
215
|
+
if (channelKey === null) return null;
|
|
216
|
+
return `${channelKey}:${encodedPeerId(actorId, file, text, true)}`;
|
|
217
|
+
}
|
|
218
|
+
if (!/^[A-Za-z0-9_-]+$/.test(prefix) || !actorId) throw new Error(`${file}: invalid alias ${JSON.stringify(alias)}`);
|
|
219
|
+
if (prefix.toLowerCase() === 'aun') return null;
|
|
220
|
+
const namedInstances = inventory.keysByName.get(prefix);
|
|
221
|
+
if (namedInstances?.size === 1) {
|
|
222
|
+
return `${[...namedInstances][0]}:${encodedPeerId(actorId, file, text)}`;
|
|
223
|
+
}
|
|
224
|
+
if (namedInstances && namedInstances.size > 1) {
|
|
225
|
+
throw new Error(`${file}: legacy alias ${JSON.stringify(alias)} is ambiguous across channel instances ${[...namedInstances].join(', ')}`);
|
|
226
|
+
}
|
|
227
|
+
const typedInstances = inventory.keysByType.get(prefix.toLowerCase());
|
|
228
|
+
if (!typedInstances || typedInstances.size === 0) {
|
|
229
|
+
throw new Error(`${file}: alias ${JSON.stringify(alias)} does not match a configured channel instance or channel type`);
|
|
230
|
+
}
|
|
231
|
+
if (typedInstances.size > 1) {
|
|
232
|
+
throw new Error(`${file}: legacy alias ${JSON.stringify(alias)} is ambiguous across channel instances ${[...typedInstances].join(', ')}`);
|
|
233
|
+
}
|
|
234
|
+
return `${[...typedInstances][0]}:${encodedPeerId(actorId, file, text)}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function relationBackupIndex(home) {
|
|
238
|
+
const result = new Map();
|
|
239
|
+
const root = path.join(home, 'backups', 'contact-book-v2-migration');
|
|
240
|
+
if (!fs.existsSync(root)) return result;
|
|
241
|
+
for (const stamp of fs.readdirSync(root, { withFileTypes: true })) {
|
|
242
|
+
if (!stamp.isDirectory()) continue;
|
|
243
|
+
try {
|
|
244
|
+
const manifest = readJson(path.join(root, stamp.name, 'manifest.json')).value;
|
|
245
|
+
if (manifest?.designVersion >= RECEIPT.designVersion) continue;
|
|
246
|
+
} catch {}
|
|
247
|
+
const agentsRoot = path.join(root, stamp.name, 'files', 'agents');
|
|
248
|
+
if (!fs.existsSync(agentsRoot)) continue;
|
|
249
|
+
for (const agent of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
|
|
250
|
+
if (!agent.isDirectory() || !isValidAid(agent.name)) continue;
|
|
251
|
+
const relationsDir = path.join(agentsRoot, agent.name, 'relations');
|
|
252
|
+
for (const relationFile of listDirectRelationFiles(relationsDir)) {
|
|
253
|
+
const peerKey = path.basename(path.dirname(relationFile));
|
|
254
|
+
const parsed = parsePeerKey(peerKey, relationFile);
|
|
255
|
+
let relation;
|
|
256
|
+
try { relation = readJson(relationFile).value; }
|
|
257
|
+
catch { continue; }
|
|
258
|
+
if (!isRecord(relation) || typeof relation.role !== 'string' || relation.role === 'owner') continue;
|
|
259
|
+
const agentMap = result.get(agent.name) ?? new Map();
|
|
260
|
+
const key = canonicalPeerKey(parsed.channelType, parsed.channelId);
|
|
261
|
+
const record = agentMap.get(key) ?? { roles: new Set(), targetKinds: new Set() };
|
|
262
|
+
record.roles.add(relation.role);
|
|
263
|
+
if (relation.targetKind === 'private' || relation.targetKind === 'group') record.targetKinds.add(relation.targetKind);
|
|
264
|
+
agentMap.set(key, record);
|
|
265
|
+
result.set(agent.name, agentMap);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return result;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function migrationReceiptPath(home) {
|
|
273
|
+
return path.join(home, 'data', RECEIPT_NAME);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function hasCurrentReceipt(home) {
|
|
277
|
+
try {
|
|
278
|
+
const value = readJson(migrationReceiptPath(home)).value;
|
|
279
|
+
return isRecord(value) && value.migration === RECEIPT.migration && value.designVersion === RECEIPT.designVersion;
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function planAgentMigration(home, aid, validators, changes, summary, backupRoles, allowBackupRestore) {
|
|
286
|
+
const directory = path.join(home, 'agents', aid);
|
|
287
|
+
const agentFile = path.join(directory, 'config.json');
|
|
288
|
+
if (!fs.existsSync(agentFile)) return false;
|
|
289
|
+
|
|
290
|
+
const { value: agentInput, text: agentText } = readJson(agentFile);
|
|
291
|
+
if (!isRecord(agentInput)) throw new Error(`${agentFile}: Agent config must be an object`);
|
|
292
|
+
if (![5, 6].includes(agentInput.$schema_version)) {
|
|
293
|
+
throw new Error(`${agentFile}: contact v2 migration requires Agent schema v5 or v6`);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const registryFile = path.join(directory, 'roles', 'index.json');
|
|
297
|
+
if (!fs.existsSync(registryFile)) throw new Error(`${registryFile}: role registry is required before contact migration`);
|
|
298
|
+
const registry = readJson(registryFile).value;
|
|
299
|
+
validateOrThrow(validators.registry, registry, registryFile);
|
|
300
|
+
const roleIds = new Set(registry.roles ?? []);
|
|
301
|
+
const owners = new Set(Array.isArray(agentInput.owners) ? agentInput.owners.map(value => String(value || '').trim()).filter(Boolean) : []);
|
|
302
|
+
const inventory = channelInventory(agentInput, aid, agentFile);
|
|
303
|
+
|
|
304
|
+
const relationsDir = path.join(directory, 'relations');
|
|
305
|
+
const relations = new Map();
|
|
306
|
+
for (const relationFile of listDirectRelationFiles(relationsDir)) {
|
|
307
|
+
const peerKey = path.basename(path.dirname(relationFile));
|
|
308
|
+
const parsed = parsePeerKey(peerKey, relationFile);
|
|
309
|
+
const key = canonicalPeerKey(parsed.channelType, parsed.channelId);
|
|
310
|
+
if (relations.has(key)) {
|
|
311
|
+
throw new Error(`${relationFile}: duplicate decoded relation target ${key}`);
|
|
312
|
+
}
|
|
313
|
+
const { value: relationInput, text: relationText } = readJson(relationFile);
|
|
314
|
+
if (!isRecord(relationInput)) throw new Error(`${relationFile}: relation config must be an object`);
|
|
315
|
+
if (![4, 5].includes(relationInput.$schema_version)) {
|
|
316
|
+
throw new Error(`${relationFile}: contact v2 migration requires Relation schema v4 or v5`);
|
|
317
|
+
}
|
|
318
|
+
const currentRole = roleValue(relationInput.role, roleIds, relationFile, 'relation role');
|
|
319
|
+
const value = { ...relationInput, $schema_version: 5 };
|
|
320
|
+
if (currentRole === null) delete value.role;
|
|
321
|
+
else value.role = currentRole;
|
|
322
|
+
relations.set(key, {
|
|
323
|
+
key,
|
|
324
|
+
file: relationFile,
|
|
325
|
+
before: relationText,
|
|
326
|
+
channelType: parsed.channelType,
|
|
327
|
+
channelId: parsed.channelId,
|
|
328
|
+
value,
|
|
329
|
+
roleSource: currentRole ? 'relation' : null,
|
|
330
|
+
});
|
|
331
|
+
if (currentRole) summary.relationRolesPreserved += 1;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
const ensureRelation = (channelType, channelId, targetKind) => {
|
|
335
|
+
const key = canonicalPeerKey(channelType, channelId);
|
|
336
|
+
let record = relations.get(key);
|
|
337
|
+
if (!record) {
|
|
338
|
+
const file = path.join(relationsDir, key, 'config.json');
|
|
339
|
+
record = {
|
|
340
|
+
key,
|
|
341
|
+
file,
|
|
342
|
+
before: null,
|
|
343
|
+
channelType,
|
|
344
|
+
channelId,
|
|
345
|
+
value: { $schema_version: 5, targetKind },
|
|
346
|
+
roleSource: null,
|
|
347
|
+
};
|
|
348
|
+
relations.set(key, record);
|
|
349
|
+
}
|
|
350
|
+
if (record.value.targetKind !== undefined && record.value.targetKind !== targetKind) {
|
|
351
|
+
throw new Error(`${record.file}: targetKind ${record.value.targetKind} conflicts with required ${targetKind}`);
|
|
352
|
+
}
|
|
353
|
+
record.value.targetKind = targetKind;
|
|
354
|
+
return record;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
let backupEvidence = false;
|
|
358
|
+
if (allowBackupRestore) {
|
|
359
|
+
for (const [key, backup] of backupRoles ?? []) {
|
|
360
|
+
const record = relations.get(key);
|
|
361
|
+
if (!record) continue;
|
|
362
|
+
backupEvidence = true;
|
|
363
|
+
if (record.value.role !== undefined) continue;
|
|
364
|
+
if (backup.roles.size !== 1) {
|
|
365
|
+
throw new Error(`${record.file}: backup history contains conflicting relation roles ${[...backup.roles].join(', ')}`);
|
|
366
|
+
}
|
|
367
|
+
const restoredRole = roleValue([...backup.roles][0], roleIds, record.file, 'backup relation role');
|
|
368
|
+
if (backup.targetKinds.size > 1) {
|
|
369
|
+
throw new Error(`${record.file}: backup history contains conflicting targetKind values`);
|
|
370
|
+
}
|
|
371
|
+
if (record.value.targetKind === undefined && backup.targetKinds.size === 1) {
|
|
372
|
+
record.value.targetKind = [...backup.targetKinds][0];
|
|
373
|
+
}
|
|
374
|
+
record.value.role = restoredRole;
|
|
375
|
+
record.roleSource = 'backup';
|
|
376
|
+
summary.relationRolesRestored += 1;
|
|
377
|
+
if (record.channelType !== 'aun') summary.nonAunRolesRestored += 1;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const contactFile = path.join(directory, 'contact.json');
|
|
382
|
+
const contactRead = fs.existsSync(contactFile) ? readJson(contactFile) : { value: null, text: null };
|
|
383
|
+
const contactInput = contactRead.value;
|
|
384
|
+
if (contactInput !== null && !isRecord(contactInput)) throw new Error(`${contactFile}: contact book must be an object`);
|
|
385
|
+
if (contactInput?.$schema_version !== undefined && ![1, 2].includes(contactInput.$schema_version)) {
|
|
386
|
+
throw new Error(`${contactFile}: unsupported contact schema version ${contactInput.$schema_version}`);
|
|
387
|
+
}
|
|
388
|
+
const rawContacts = contactInput?.contacts ?? {};
|
|
389
|
+
if (!isRecord(rawContacts)) throw new Error(`${contactFile}: contacts must be an object`);
|
|
390
|
+
const contacts = {};
|
|
391
|
+
const aliasOwners = new Map();
|
|
392
|
+
|
|
393
|
+
for (const [primaryId, rawEntry] of Object.entries(rawContacts)) {
|
|
394
|
+
if (!isValidAid(primaryId)) throw new Error(`${contactFile}: invalid contact primary AID ${JSON.stringify(primaryId)}`);
|
|
395
|
+
if (!isRecord(rawEntry)) throw new Error(`${contactFile}: contact ${primaryId} must be an object`);
|
|
396
|
+
const unknown = Object.keys(rawEntry).filter(key => !CONTACT_KEYS.has(key));
|
|
397
|
+
if (unknown.length) throw new Error(`${contactFile}: contact ${primaryId} has unsupported fields ${unknown.join(', ')}`);
|
|
398
|
+
const subjectType = rawEntry.subjectType ?? (isExplicitGroupId(primaryId) ? 'group' : 'principal');
|
|
399
|
+
if (subjectType !== 'principal' && subjectType !== 'group') {
|
|
400
|
+
throw new Error(`${contactFile}: contact ${primaryId} has invalid subjectType ${JSON.stringify(subjectType)}`);
|
|
401
|
+
}
|
|
402
|
+
const contactRole = roleValue(rawEntry.role, roleIds, contactFile, `contact ${primaryId} role`);
|
|
403
|
+
if (contactRole) {
|
|
404
|
+
const targetKind = subjectType === 'group' ? 'group' : 'private';
|
|
405
|
+
const relation = ensureRelation('aun', primaryId, targetKind);
|
|
406
|
+
if (relation.value.role === undefined) {
|
|
407
|
+
relation.value.role = contactRole;
|
|
408
|
+
relation.roleSource = 'contact';
|
|
409
|
+
summary.contactRolesMigrated += 1;
|
|
410
|
+
} else if (relation.value.role !== contactRole) {
|
|
411
|
+
summary.contactRoleConflictsIgnored += 1;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if (subjectType === 'group') {
|
|
415
|
+
summary.groupContactsRemoved += 1;
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const next = {};
|
|
420
|
+
if (rawEntry.displayName !== undefined) next.displayName = rawEntry.displayName;
|
|
421
|
+
if (rawEntry.aliases !== undefined) {
|
|
422
|
+
if (!Array.isArray(rawEntry.aliases)) throw new Error(`${contactFile}: contact ${primaryId} aliases must be an array`);
|
|
423
|
+
const aliases = [];
|
|
424
|
+
for (const rawAlias of rawEntry.aliases) {
|
|
425
|
+
const normalized = normalizeAlias(rawAlias, aid, inventory, contactFile);
|
|
426
|
+
if (normalized === null) {
|
|
427
|
+
summary.nativeAunAliasesRemoved += 1;
|
|
428
|
+
continue;
|
|
429
|
+
}
|
|
430
|
+
if (normalized !== rawAlias) summary.aliasesRewritten += 1;
|
|
431
|
+
const existing = aliasOwners.get(normalized);
|
|
432
|
+
if (existing && existing !== primaryId) {
|
|
433
|
+
throw new Error(`${contactFile}: alias ${JSON.stringify(normalized)} is assigned to both ${existing} and ${primaryId}`);
|
|
434
|
+
}
|
|
435
|
+
aliasOwners.set(normalized, primaryId);
|
|
436
|
+
if (!aliases.includes(normalized)) aliases.push(normalized);
|
|
437
|
+
}
|
|
438
|
+
if (aliases.length) next.aliases = aliases;
|
|
439
|
+
}
|
|
440
|
+
let status = rawEntry.status;
|
|
441
|
+
if (status !== undefined && !CONTACT_STATUSES.has(status)) {
|
|
442
|
+
throw new Error(`${contactFile}: contact ${primaryId} has invalid status ${JSON.stringify(status)}`);
|
|
443
|
+
}
|
|
444
|
+
if (rawEntry.blocked === true) {
|
|
445
|
+
if (status !== undefined && status !== 'blocked') {
|
|
446
|
+
throw new Error(`${contactFile}: contact ${primaryId} has conflicting blocked/status values`);
|
|
447
|
+
}
|
|
448
|
+
status = 'blocked';
|
|
449
|
+
summary.blockedStatusesMigrated += 1;
|
|
450
|
+
} else if (rawEntry.blocked !== undefined && rawEntry.blocked !== false) {
|
|
451
|
+
throw new Error(`${contactFile}: contact ${primaryId} blocked must be boolean`);
|
|
452
|
+
}
|
|
453
|
+
if (owners.has(primaryId) && status === 'blocked') {
|
|
454
|
+
throw new Error(`${contactFile}: owner contact ${primaryId} cannot be blocked`);
|
|
455
|
+
}
|
|
456
|
+
if (status && status !== 'active') next.status = status;
|
|
457
|
+
contacts[primaryId] = next;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
for (const rawAdmin of Array.isArray(agentInput.admins) ? agentInput.admins : []) {
|
|
461
|
+
const admin = String(rawAdmin || '').trim();
|
|
462
|
+
if (!isValidAid(admin)) throw new Error(`${agentFile}: invalid admin AID ${JSON.stringify(rawAdmin)}`);
|
|
463
|
+
if (owners.has(admin)) continue;
|
|
464
|
+
const relation = ensureRelation('aun', admin, 'private');
|
|
465
|
+
if (relation.value.role !== 'admin') summary.adminsMigrated += 1;
|
|
466
|
+
relation.value.role = 'admin';
|
|
467
|
+
relation.roleSource = 'agent-admin';
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
for (const relation of relations.values()) {
|
|
471
|
+
validateOrThrow(validators.relation, relation.value, relation.file);
|
|
472
|
+
appendChange(changes, relation.file, 'relation', relation.before, relation.value);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const contactOutput = { $schema_version: 2, contacts };
|
|
476
|
+
validateOrThrow(validators.contact, contactOutput, contactFile);
|
|
477
|
+
if (contactRead.text !== null || Object.keys(contacts).length > 0) {
|
|
478
|
+
appendChange(changes, contactFile, 'contact', contactRead.text, contactOutput);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const agentOutput = { ...agentInput, $schema_version: 6 };
|
|
482
|
+
delete agentOutput.admins;
|
|
483
|
+
validateOrThrow(validators.agent, agentOutput, agentFile);
|
|
484
|
+
appendChange(changes, agentFile, 'agent', agentText, agentOutput);
|
|
485
|
+
return { migrated: true, backupEvidence };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function planMigration(home) {
|
|
489
|
+
const validators = loadValidators();
|
|
490
|
+
const agentsRoot = path.join(home, 'agents');
|
|
491
|
+
const changes = [];
|
|
492
|
+
const summary = {
|
|
493
|
+
agentsScanned: 0,
|
|
494
|
+
agentsMigrated: 0,
|
|
495
|
+
relationRolesPreserved: 0,
|
|
496
|
+
relationRolesRestored: 0,
|
|
497
|
+
nonAunRolesRestored: 0,
|
|
498
|
+
contactRolesMigrated: 0,
|
|
499
|
+
contactRoleConflictsIgnored: 0,
|
|
500
|
+
adminsMigrated: 0,
|
|
501
|
+
groupContactsRemoved: 0,
|
|
502
|
+
aliasesRewritten: 0,
|
|
503
|
+
nativeAunAliasesRemoved: 0,
|
|
504
|
+
blockedStatusesMigrated: 0,
|
|
505
|
+
};
|
|
506
|
+
if (!fs.existsSync(agentsRoot)) return { home, changes, summary };
|
|
507
|
+
const receiptCurrent = hasCurrentReceipt(home);
|
|
508
|
+
const backups = receiptCurrent ? new Map() : relationBackupIndex(home);
|
|
509
|
+
let backupEvidence = false;
|
|
510
|
+
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
511
|
+
if (!entry.isDirectory() || !isValidAid(entry.name)) continue;
|
|
512
|
+
summary.agentsScanned += 1;
|
|
513
|
+
const result = planAgentMigration(home, entry.name, validators, changes, summary, backups.get(entry.name), !receiptCurrent);
|
|
514
|
+
if (result?.migrated) summary.agentsMigrated += 1;
|
|
515
|
+
if (result?.backupEvidence) backupEvidence = true;
|
|
516
|
+
}
|
|
517
|
+
if (!receiptCurrent && backupEvidence) {
|
|
518
|
+
const receiptFile = migrationReceiptPath(home);
|
|
519
|
+
const before = fs.existsSync(receiptFile) ? fs.readFileSync(receiptFile, 'utf8') : null;
|
|
520
|
+
appendChange(changes, receiptFile, 'receipt', before, RECEIPT);
|
|
521
|
+
}
|
|
522
|
+
return { home, changes, summary };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function assertDaemonStopped(home) {
|
|
526
|
+
const candidates = [];
|
|
527
|
+
const pidFile = path.join(home, 'daemon.pid');
|
|
528
|
+
if (fs.existsSync(pidFile)) candidates.push({ pid: Number(fs.readFileSync(pidFile, 'utf8').trim()), source: pidFile });
|
|
529
|
+
const instanceDir = path.join(home, 'data', 'instance');
|
|
530
|
+
if (fs.existsSync(instanceDir)) {
|
|
531
|
+
for (const name of fs.readdirSync(instanceDir)) {
|
|
532
|
+
if (!/^main-\d+\.json$/.test(name)) continue;
|
|
533
|
+
const file = path.join(instanceDir, name);
|
|
534
|
+
try { candidates.push({ pid: Number(JSON.parse(fs.readFileSync(file, 'utf8'))?.pid), source: file }); } catch {}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
for (const { pid, source } of candidates) {
|
|
538
|
+
if (!Number.isInteger(pid) || pid <= 0) continue;
|
|
539
|
+
try { process.kill(pid, 0); }
|
|
540
|
+
catch (error) {
|
|
541
|
+
if (error?.code === 'ESRCH') continue;
|
|
542
|
+
if (error?.code !== 'EPERM') throw error;
|
|
543
|
+
}
|
|
544
|
+
throw new Error(`EvolCore daemon appears to be running (pid=${pid}, record=${source}); stop it before migration`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function acquireDirectoryLock(lockDir, label) {
|
|
549
|
+
const ownerFile = path.join(lockDir, 'owner.json');
|
|
550
|
+
const lockToken = crypto.randomBytes(16).toString('hex');
|
|
551
|
+
const candidateDir = `${lockDir}.candidate-${process.pid}-${lockToken}`;
|
|
552
|
+
const candidateOwnerFile = path.join(candidateDir, 'owner.json');
|
|
553
|
+
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
554
|
+
fs.mkdirSync(candidateDir);
|
|
555
|
+
fs.writeFileSync(candidateOwnerFile, jsonText({
|
|
556
|
+
pid: process.pid,
|
|
557
|
+
processStartedAt: Date.now() - Math.floor(process.uptime() * 1000),
|
|
558
|
+
createdAt: new Date().toISOString(),
|
|
559
|
+
lockToken,
|
|
560
|
+
}));
|
|
561
|
+
let acquired = false;
|
|
562
|
+
try {
|
|
563
|
+
while (true) {
|
|
564
|
+
try {
|
|
565
|
+
fs.renameSync(candidateDir, lockDir);
|
|
566
|
+
acquired = true;
|
|
567
|
+
return () => releaseDirectoryLock(lockDir, ownerFile, lockToken);
|
|
568
|
+
} catch (error) {
|
|
569
|
+
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY') throw error;
|
|
570
|
+
}
|
|
571
|
+
let rawOwner;
|
|
572
|
+
let ownerPid;
|
|
573
|
+
try {
|
|
574
|
+
rawOwner = fs.readFileSync(ownerFile, 'utf8');
|
|
575
|
+
ownerPid = Number(JSON.parse(rawOwner)?.pid);
|
|
576
|
+
} catch {
|
|
577
|
+
throw new Error(`${label} lock is unreadable: ${lockDir}`);
|
|
578
|
+
}
|
|
579
|
+
if (!Number.isInteger(ownerPid) || ownerPid <= 0) throw new Error(`${label} lock has an invalid owner: ${lockDir}`);
|
|
580
|
+
try {
|
|
581
|
+
process.kill(ownerPid, 0);
|
|
582
|
+
throw new Error(`${label} is already running (pid=${ownerPid})`);
|
|
583
|
+
} catch (ownerError) {
|
|
584
|
+
if (ownerError?.code === 'EPERM') throw new Error(`${label} is already running (pid=${ownerPid})`);
|
|
585
|
+
if (ownerError?.code !== 'ESRCH') throw ownerError;
|
|
586
|
+
}
|
|
587
|
+
const staleClaim = `${lockDir}.reclaimed-${crypto.createHash('sha256').update(rawOwner).digest('hex').slice(0, 16)}`;
|
|
588
|
+
try {
|
|
589
|
+
fs.renameSync(lockDir, staleClaim);
|
|
590
|
+
} catch (error) {
|
|
591
|
+
if (error?.code === 'ENOENT') continue;
|
|
592
|
+
if (error?.code === 'EEXIST' || error?.code === 'ENOTEMPTY') {
|
|
593
|
+
throw new Error(`${label} lock changed while recovering stale ownership: ${lockDir}`);
|
|
594
|
+
}
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
} finally {
|
|
599
|
+
if (!acquired) fs.rmSync(candidateDir, { recursive: true, force: true });
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function releaseDirectoryLock(lockDir, ownerFile, lockToken) {
|
|
604
|
+
try {
|
|
605
|
+
const owner = JSON.parse(fs.readFileSync(ownerFile, 'utf8'));
|
|
606
|
+
if (owner?.lockToken !== lockToken) return;
|
|
607
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
608
|
+
} catch {
|
|
609
|
+
// Ownership cannot be proven after an external replacement.
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function acquireMigrationLocks(home) {
|
|
614
|
+
const releases = [];
|
|
615
|
+
try {
|
|
616
|
+
releases.push(acquireDirectoryLock(path.join(home, 'data', 'contact-book-v2-migration.lock'), 'contact migration'));
|
|
617
|
+
const agentsRoot = path.join(home, 'agents');
|
|
618
|
+
if (fs.existsSync(agentsRoot)) {
|
|
619
|
+
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true }).sort((left, right) => left.name.localeCompare(right.name))) {
|
|
620
|
+
if (!entry.isDirectory() || !isValidAid(entry.name)) continue;
|
|
621
|
+
releases.push(acquireDirectoryLock(path.join(agentsRoot, entry.name, AGENT_LOCK_NAME), `contact mutation for ${entry.name}`));
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return () => { for (const release of releases.reverse()) release(); };
|
|
625
|
+
} catch (error) {
|
|
626
|
+
for (const release of releases.reverse()) release();
|
|
627
|
+
throw error;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function atomicWrite(file, content) {
|
|
632
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
633
|
+
const mode = fs.existsSync(file) ? fs.statSync(file).mode & 0o777 : 0o600;
|
|
634
|
+
const temp = `${file}.contact-v2-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`;
|
|
635
|
+
fs.writeFileSync(temp, content, { mode });
|
|
636
|
+
fs.renameSync(temp, file);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function journalPath(home) {
|
|
640
|
+
return path.join(home, 'data', 'contact-book-v2-migration.json');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function writeJournal(plan, state) {
|
|
644
|
+
atomicWrite(journalPath(plan.home), jsonText({ schemaVersion: 1, state, home: plan.home, changes: plan.changes }));
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function removeJournal(home) {
|
|
648
|
+
fs.rmSync(journalPath(home), { force: true });
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function recoverJournal(home) {
|
|
652
|
+
const file = journalPath(home);
|
|
653
|
+
if (!fs.existsSync(file)) return;
|
|
654
|
+
let journal;
|
|
655
|
+
try { journal = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
656
|
+
catch { throw new Error(`Contact migration journal is unreadable: ${file}`); }
|
|
657
|
+
if (journal?.schemaVersion !== 1 || !['prepared', 'committed'].includes(journal.state)
|
|
658
|
+
|| path.resolve(journal.home) !== home || !Array.isArray(journal.changes)) {
|
|
659
|
+
throw new Error(`Contact migration journal is invalid: ${file}`);
|
|
660
|
+
}
|
|
661
|
+
const rootPrefix = home + path.sep;
|
|
662
|
+
for (const item of journal.changes) {
|
|
663
|
+
const target = path.resolve(item?.file || '');
|
|
664
|
+
if (!target.startsWith(rootPrefix) || (item.before !== null && typeof item.before !== 'string')
|
|
665
|
+
|| typeof item.after !== 'string') {
|
|
666
|
+
throw new Error(`Contact migration journal contains an unsafe change: ${file}`);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (journal.state === 'prepared') {
|
|
670
|
+
for (const item of [...journal.changes].reverse()) {
|
|
671
|
+
if (item.before === null) fs.rmSync(item.file, { force: true });
|
|
672
|
+
else atomicWrite(item.file, item.before);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
removeJournal(home);
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function applyMigration(plan) {
|
|
679
|
+
if (!plan.changes.length) return null;
|
|
680
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
681
|
+
const backup = path.join(plan.home, 'backups', 'contact-book-v2-migration', stamp);
|
|
682
|
+
fs.mkdirSync(backup, { recursive: true });
|
|
683
|
+
for (const item of plan.changes) {
|
|
684
|
+
if (item.before === null) continue;
|
|
685
|
+
const destination = path.join(backup, 'files', path.relative(plan.home, item.file));
|
|
686
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
687
|
+
fs.writeFileSync(destination, item.before);
|
|
688
|
+
}
|
|
689
|
+
fs.writeFileSync(path.join(backup, 'manifest.json'), jsonText({
|
|
690
|
+
schemaVersion: 2,
|
|
691
|
+
designVersion: RECEIPT.designVersion,
|
|
692
|
+
createdAt: new Date().toISOString(),
|
|
693
|
+
summary: plan.summary,
|
|
694
|
+
files: plan.changes.map(item => ({ path: path.relative(plan.home, item.file), kind: item.kind })),
|
|
695
|
+
}));
|
|
696
|
+
writeJournal(plan, 'prepared');
|
|
697
|
+
try {
|
|
698
|
+
for (const item of plan.changes) atomicWrite(item.file, item.after);
|
|
699
|
+
writeJournal(plan, 'committed');
|
|
700
|
+
removeJournal(plan.home);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
recoverJournal(plan.home);
|
|
703
|
+
throw error;
|
|
704
|
+
}
|
|
705
|
+
return backup;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export function runMigration(options = {}) {
|
|
709
|
+
const home = resolveHome(options.home);
|
|
710
|
+
const releaseLocks = acquireMigrationLocks(home);
|
|
711
|
+
try {
|
|
712
|
+
assertDaemonStopped(home);
|
|
713
|
+
recoverJournal(home);
|
|
714
|
+
const plan = planMigration(home);
|
|
715
|
+
const backup = options.apply ? applyMigration(plan) : null;
|
|
716
|
+
return {
|
|
717
|
+
ok: true,
|
|
718
|
+
mode: options.apply ? 'applied' : 'dry-run',
|
|
719
|
+
home,
|
|
720
|
+
changedFiles: plan.changes.length,
|
|
721
|
+
backup,
|
|
722
|
+
summary: plan.summary,
|
|
723
|
+
};
|
|
724
|
+
} finally {
|
|
725
|
+
releaseLocks();
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function main() {
|
|
730
|
+
try {
|
|
731
|
+
const options = parseArgs(process.argv.slice(2));
|
|
732
|
+
if (options.help) {
|
|
733
|
+
console.log('Usage: node kits/migrations/migrate-contact-book-v2.mjs [--apply] [--home <path>] [--json]');
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
const result = runMigration(options);
|
|
737
|
+
if (options.json) console.log(JSON.stringify(result));
|
|
738
|
+
else console.log(`Contact book v2 migration (${result.mode}): ${result.changedFiles} file(s)`);
|
|
739
|
+
} catch (error) {
|
|
740
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
741
|
+
if (process.argv.includes('--json')) console.log(JSON.stringify({ ok: false, error: message }));
|
|
742
|
+
else console.error(`Migration failed: ${message}`);
|
|
743
|
+
process.exitCode = 1;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
|