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,454 @@
|
|
|
1
|
+
import crypto from 'crypto';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { isValidAid } from '../aun/aid/validation.js';
|
|
5
|
+
import { isExplicitGroupId } from '../aun/group-identity.js';
|
|
6
|
+
import { agentConfig, agentContactConfig, agentDir, resolvePaths } from '../paths.js';
|
|
7
|
+
import { atomicReadJson, atomicWrite, atomicWriteJson } from '../utils/atomic-write.js';
|
|
8
|
+
import { fileCache } from '../core/daemon-file-cache.js';
|
|
9
|
+
import { ConfigTarget, notifyConfigWrite, validateConfig } from './config-manager.js';
|
|
10
|
+
import { parseContactAlias } from './contact-alias.js';
|
|
11
|
+
import { clearRoleStoreCache } from './role-store.js';
|
|
12
|
+
const LOCK_NAME = '.contact-book-mutation.lock';
|
|
13
|
+
const JOURNAL_NAME = '.contact-book-mutation.json';
|
|
14
|
+
const LOCK_WAIT_MS = 5_000;
|
|
15
|
+
const LOCK_RETRY_MS = 20;
|
|
16
|
+
export const EMPTY_CONTACT_REVISION = crypto.createHash('sha256').update('').digest('hex');
|
|
17
|
+
export class ContactMutationError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
data;
|
|
20
|
+
constructor(code, message, data) {
|
|
21
|
+
super(message);
|
|
22
|
+
this.code = code;
|
|
23
|
+
this.data = data;
|
|
24
|
+
this.name = 'ContactMutationError';
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const mutationTails = new Map();
|
|
28
|
+
export function contactRevision(config) {
|
|
29
|
+
if (!config)
|
|
30
|
+
return EMPTY_CONTACT_REVISION;
|
|
31
|
+
return crypto.createHash('sha256').update(stableStringify(config)).digest('hex');
|
|
32
|
+
}
|
|
33
|
+
export function readContactRevision(selfAid) {
|
|
34
|
+
return contactRevision(readContactBookFromDisk(selfAid));
|
|
35
|
+
}
|
|
36
|
+
export function mutateContactBook(request) {
|
|
37
|
+
return enqueueContactMutation(request, []);
|
|
38
|
+
}
|
|
39
|
+
export function mutateContactBookTransaction(request, changes) {
|
|
40
|
+
return enqueueContactMutation(request, changes);
|
|
41
|
+
}
|
|
42
|
+
function enqueueContactMutation(request, changes) {
|
|
43
|
+
const selfAid = normalizeAid(request.selfAid, 'self AID');
|
|
44
|
+
const previous = mutationTails.get(selfAid) ?? Promise.resolve();
|
|
45
|
+
let release;
|
|
46
|
+
const tail = new Promise(resolve => { release = resolve; });
|
|
47
|
+
mutationTails.set(selfAid, tail);
|
|
48
|
+
return previous
|
|
49
|
+
.catch(() => { })
|
|
50
|
+
.then(() => mutateContactBookSyncInternal({ ...request, selfAid }, changes))
|
|
51
|
+
.finally(() => {
|
|
52
|
+
release();
|
|
53
|
+
if (mutationTails.get(selfAid) === tail)
|
|
54
|
+
mutationTails.delete(selfAid);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
/** Synchronous compatibility entry for channel bind flows. The critical section has no await. */
|
|
58
|
+
export function mutateContactBookSync(request) {
|
|
59
|
+
return mutateContactBookSyncInternal(request, []);
|
|
60
|
+
}
|
|
61
|
+
/** Recover every interrupted Contact transaction before startup reads config. */
|
|
62
|
+
export function recoverAllContactBookMutationsSync() {
|
|
63
|
+
const agentsRoot = resolvePaths().agentsDir;
|
|
64
|
+
if (!fs.existsSync(agentsRoot))
|
|
65
|
+
return 0;
|
|
66
|
+
let recovered = 0;
|
|
67
|
+
for (const entry of fs.readdirSync(agentsRoot, { withFileTypes: true })) {
|
|
68
|
+
if (!entry.isDirectory() || !isValidAid(entry.name))
|
|
69
|
+
continue;
|
|
70
|
+
if (!fs.existsSync(journalFile(entry.name)))
|
|
71
|
+
continue;
|
|
72
|
+
const release = acquireAgentLock(entry.name);
|
|
73
|
+
try {
|
|
74
|
+
if (recoverMutation(entry.name))
|
|
75
|
+
recovered += 1;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
release();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return recovered;
|
|
82
|
+
}
|
|
83
|
+
function mutateContactBookSyncInternal(request, transactionChanges) {
|
|
84
|
+
const selfAid = normalizeAid(request.selfAid, 'self AID');
|
|
85
|
+
const release = acquireAgentLock(selfAid);
|
|
86
|
+
try {
|
|
87
|
+
recoverMutation(selfAid);
|
|
88
|
+
const file = agentContactConfig(selfAid);
|
|
89
|
+
const current = readContactBookFromDisk(selfAid);
|
|
90
|
+
const previousRevision = contactRevision(current);
|
|
91
|
+
if (request.expectedContactRevision !== undefined
|
|
92
|
+
&& request.expectedContactRevision !== previousRevision) {
|
|
93
|
+
throw new ContactMutationError('CONFLICT', 'Contact book changed since it was read', {
|
|
94
|
+
expectedContactRevision: request.expectedContactRevision,
|
|
95
|
+
contactRevision: previousRevision,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const next = cloneBook(current);
|
|
99
|
+
applyMutation(next, request.mutation, selfAid);
|
|
100
|
+
validateContactBook(next, selfAid);
|
|
101
|
+
const nextRevision = contactRevision(next);
|
|
102
|
+
validateTransactionChanges(selfAid, transactionChanges);
|
|
103
|
+
if (nextRevision === previousRevision && transactionChanges.length === 0) {
|
|
104
|
+
return { changed: false, contactRevision: previousRevision, previousContactRevision: previousRevision, contact: next };
|
|
105
|
+
}
|
|
106
|
+
const before = readTextOrNull(file);
|
|
107
|
+
const after = `${JSON.stringify(next, null, 2)}\n`;
|
|
108
|
+
const changes = [
|
|
109
|
+
...(nextRevision === previousRevision ? [] : [{ file, before, after }]),
|
|
110
|
+
...transactionChanges,
|
|
111
|
+
];
|
|
112
|
+
writeJournal(selfAid, { schemaVersion: 1, state: 'prepared', selfAid, changes });
|
|
113
|
+
try {
|
|
114
|
+
for (const change of changes)
|
|
115
|
+
applyTransactionChange(change);
|
|
116
|
+
writeJournal(selfAid, { schemaVersion: 1, state: 'committed', selfAid, changes });
|
|
117
|
+
removeJournal(selfAid);
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
recoverMutation(selfAid);
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
if (nextRevision !== previousRevision) {
|
|
124
|
+
notifyConfigWrite(ConfigTarget.Contact, { self: selfAid });
|
|
125
|
+
appendAudit(selfAid, request, previousRevision, nextRevision);
|
|
126
|
+
}
|
|
127
|
+
for (const change of transactionChanges)
|
|
128
|
+
fileCache.invalidate(change.file);
|
|
129
|
+
if (transactionChanges.length)
|
|
130
|
+
clearRoleStoreCache(selfAid);
|
|
131
|
+
return {
|
|
132
|
+
changed: nextRevision !== previousRevision,
|
|
133
|
+
contactRevision: nextRevision,
|
|
134
|
+
previousContactRevision: previousRevision,
|
|
135
|
+
contact: next,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
finally {
|
|
139
|
+
release();
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function applyMutation(book, mutation, selfAid) {
|
|
143
|
+
const contacts = book.contacts;
|
|
144
|
+
const primaryId = normalizeContactAid(mutation.primaryId);
|
|
145
|
+
const existing = contacts[primaryId];
|
|
146
|
+
if (mutation.type === 'set-status') {
|
|
147
|
+
if (isOwner(selfAid, primaryId) && mutation.status === 'blocked') {
|
|
148
|
+
throw new ContactMutationError('OWNER_PROTECTED', 'An Agent owner cannot be blocked');
|
|
149
|
+
}
|
|
150
|
+
contacts[primaryId] = compactEntry({
|
|
151
|
+
...existing,
|
|
152
|
+
status: mutation.status === 'active' ? undefined : mutation.status,
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (mutation.type === 'set-display-name') {
|
|
157
|
+
contacts[primaryId] = compactEntry({ ...existing, displayName: mutation.displayName ?? undefined });
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (mutation.type === 'bind-alias') {
|
|
161
|
+
for (const [owner, entry] of Object.entries(contacts)) {
|
|
162
|
+
if (owner !== primaryId && entry.aliases?.includes(mutation.alias)) {
|
|
163
|
+
throw new ContactMutationError('ALIAS_CONFLICT', `Alias ${mutation.alias} is already bound to ${owner}`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
const aliases = Array.isArray(contacts[primaryId]?.aliases) ? [...contacts[primaryId].aliases] : [];
|
|
167
|
+
if (!aliases.includes(mutation.alias))
|
|
168
|
+
aliases.push(mutation.alias);
|
|
169
|
+
contacts[primaryId] = compactEntry({ ...contacts[primaryId], aliases });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (mutation.type === 'unbind-alias') {
|
|
173
|
+
removeAliasEverywhere(contacts, mutation.alias, primaryId);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
throw new ContactMutationError('INVALID_MUTATION', `Unsupported Contact Book mutation: ${String(mutation?.type)}`);
|
|
177
|
+
}
|
|
178
|
+
function validateContactBook(book, selfAid) {
|
|
179
|
+
const schemaErrors = validateConfig(ConfigTarget.Contact, book);
|
|
180
|
+
if (schemaErrors.length) {
|
|
181
|
+
throw new ContactMutationError('INVALID_CONTACT_BOOK', schemaErrors.join('; '));
|
|
182
|
+
}
|
|
183
|
+
const aliases = new Map();
|
|
184
|
+
const configuredChannelKeys = readConfiguredContactChannelKeys(selfAid);
|
|
185
|
+
for (const [primaryId, entry] of Object.entries(book.contacts)) {
|
|
186
|
+
if (!isValidAid(primaryId) || isExplicitGroupId(primaryId)) {
|
|
187
|
+
throw new ContactMutationError('INVALID_CONTACT_BOOK', `Invalid contact AID: ${primaryId}`);
|
|
188
|
+
}
|
|
189
|
+
if (entry.status === 'blocked' && isOwner(selfAid, primaryId)) {
|
|
190
|
+
throw new ContactMutationError('OWNER_PROTECTED', 'An Agent owner cannot be blocked');
|
|
191
|
+
}
|
|
192
|
+
for (const alias of entry.aliases ?? []) {
|
|
193
|
+
const parsed = parseContactAlias(alias, selfAid);
|
|
194
|
+
if (!parsed) {
|
|
195
|
+
throw new ContactMutationError('INVALID_CONTACT_BOOK', `Invalid contact alias for ${selfAid}: ${String(alias)}`);
|
|
196
|
+
}
|
|
197
|
+
if (!configuredChannelKeys.has(parsed.channelKey)) {
|
|
198
|
+
throw new ContactMutationError('INVALID_CONTACT_BOOK', `Contact alias references an unconfigured channel instance: ${alias}`);
|
|
199
|
+
}
|
|
200
|
+
const owner = aliases.get(alias);
|
|
201
|
+
if (owner && owner !== primaryId) {
|
|
202
|
+
throw new ContactMutationError('ALIAS_CONFLICT', `Alias ${alias} is bound to both ${owner} and ${primaryId}`);
|
|
203
|
+
}
|
|
204
|
+
aliases.set(alias, primaryId);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
function readConfiguredContactChannelKeys(selfAid) {
|
|
209
|
+
let agent;
|
|
210
|
+
try {
|
|
211
|
+
agent = atomicReadJson(agentConfig(selfAid));
|
|
212
|
+
}
|
|
213
|
+
catch (error) {
|
|
214
|
+
throw new ContactMutationError('INVALID_CONTACT_BOOK', `Cannot read Agent channel inventory: ${error instanceof Error ? error.message : String(error)}`);
|
|
215
|
+
}
|
|
216
|
+
return new Set((Array.isArray(agent?.channels) ? agent.channels : [])
|
|
217
|
+
.filter((channel) => channel && typeof channel === 'object' && String(channel.type || '').toLowerCase() !== 'aun')
|
|
218
|
+
.map((channel) => {
|
|
219
|
+
const type = String(channel.type || '').trim().toLowerCase();
|
|
220
|
+
const name = String(channel.name || '').trim();
|
|
221
|
+
return type && name ? `${type}#${selfAid}#${name}` : '';
|
|
222
|
+
})
|
|
223
|
+
.filter(Boolean));
|
|
224
|
+
}
|
|
225
|
+
function cloneBook(book) {
|
|
226
|
+
const contacts = {};
|
|
227
|
+
for (const [primaryId, entry] of Object.entries(book?.contacts ?? {})) {
|
|
228
|
+
contacts[primaryId] = { ...entry, ...(entry.aliases ? { aliases: [...entry.aliases] } : {}) };
|
|
229
|
+
}
|
|
230
|
+
return { $schema_version: 2, contacts };
|
|
231
|
+
}
|
|
232
|
+
function compactEntry(entry) {
|
|
233
|
+
return Object.fromEntries(Object.entries(entry).filter(([, value]) => value !== undefined));
|
|
234
|
+
}
|
|
235
|
+
function removeAliasEverywhere(contacts, alias, onlyOwner) {
|
|
236
|
+
for (const [primaryId, entry] of Object.entries(contacts)) {
|
|
237
|
+
if (onlyOwner && primaryId !== onlyOwner)
|
|
238
|
+
continue;
|
|
239
|
+
const aliases = (entry.aliases ?? []).filter(candidate => candidate !== alias);
|
|
240
|
+
if (aliases.length !== (entry.aliases ?? []).length)
|
|
241
|
+
contacts[primaryId] = compactEntry({ ...entry, aliases });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function readContactBookFromDisk(selfAid) {
|
|
245
|
+
try {
|
|
246
|
+
return atomicReadJson(agentContactConfig(selfAid));
|
|
247
|
+
}
|
|
248
|
+
catch (error) {
|
|
249
|
+
throw new ContactMutationError('INVALID_CONTACT_BOOK', `Cannot read contact book: ${error instanceof Error ? error.message : String(error)}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
function acquireAgentLock(selfAid) {
|
|
253
|
+
const lockDir = path.join(agentDir(selfAid), LOCK_NAME);
|
|
254
|
+
const ownerFile = path.join(lockDir, 'owner.json');
|
|
255
|
+
const lockToken = crypto.randomBytes(16).toString('hex');
|
|
256
|
+
const candidateDir = `${lockDir}.candidate-${process.pid}-${lockToken}`;
|
|
257
|
+
const candidateOwnerFile = path.join(candidateDir, 'owner.json');
|
|
258
|
+
fs.mkdirSync(path.dirname(lockDir), { recursive: true });
|
|
259
|
+
fs.mkdirSync(candidateDir);
|
|
260
|
+
fs.writeFileSync(candidateOwnerFile, `${JSON.stringify({
|
|
261
|
+
pid: process.pid,
|
|
262
|
+
processStartedAt: Date.now() - Math.floor(process.uptime() * 1000),
|
|
263
|
+
createdAt: new Date().toISOString(),
|
|
264
|
+
lockToken,
|
|
265
|
+
}, null, 2)}\n`, { mode: 0o600 });
|
|
266
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
267
|
+
let acquired = false;
|
|
268
|
+
try {
|
|
269
|
+
while (true) {
|
|
270
|
+
try {
|
|
271
|
+
fs.renameSync(candidateDir, lockDir);
|
|
272
|
+
acquired = true;
|
|
273
|
+
return () => releaseAgentLock(lockDir, ownerFile, lockToken);
|
|
274
|
+
}
|
|
275
|
+
catch (error) {
|
|
276
|
+
if (error?.code !== 'EEXIST' && error?.code !== 'ENOTEMPTY')
|
|
277
|
+
throw error;
|
|
278
|
+
if (removeStaleLock(lockDir, ownerFile))
|
|
279
|
+
continue;
|
|
280
|
+
if (Date.now() >= deadline) {
|
|
281
|
+
throw new ContactMutationError('LOCK_TIMEOUT', `Timed out waiting for contact mutation lock: ${lockDir}`);
|
|
282
|
+
}
|
|
283
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, LOCK_RETRY_MS);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
finally {
|
|
288
|
+
if (!acquired)
|
|
289
|
+
fs.rmSync(candidateDir, { recursive: true, force: true });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function removeStaleLock(lockDir, ownerFile) {
|
|
293
|
+
let rawOwner;
|
|
294
|
+
let pid;
|
|
295
|
+
try {
|
|
296
|
+
rawOwner = fs.readFileSync(ownerFile, 'utf8');
|
|
297
|
+
pid = Number(JSON.parse(rawOwner).pid);
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
return false;
|
|
301
|
+
}
|
|
302
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
303
|
+
return false;
|
|
304
|
+
try {
|
|
305
|
+
process.kill(pid, 0);
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
if (error?.code !== 'ESRCH')
|
|
310
|
+
return false;
|
|
311
|
+
const staleClaim = `${lockDir}.reclaimed-${crypto.createHash('sha256').update(rawOwner).digest('hex').slice(0, 16)}`;
|
|
312
|
+
try {
|
|
313
|
+
fs.renameSync(lockDir, staleClaim);
|
|
314
|
+
return true;
|
|
315
|
+
}
|
|
316
|
+
catch (renameError) {
|
|
317
|
+
if (renameError?.code === 'ENOENT')
|
|
318
|
+
return true;
|
|
319
|
+
if (renameError?.code === 'EEXIST' || renameError?.code === 'ENOTEMPTY')
|
|
320
|
+
return false;
|
|
321
|
+
throw renameError;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function releaseAgentLock(lockDir, ownerFile, lockToken) {
|
|
326
|
+
try {
|
|
327
|
+
const owner = JSON.parse(fs.readFileSync(ownerFile, 'utf8'));
|
|
328
|
+
if (owner.lockToken !== lockToken)
|
|
329
|
+
return;
|
|
330
|
+
fs.rmSync(lockDir, { recursive: true, force: true });
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
// A missing or replaced owner file means ownership can no longer be proven.
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
function recoverMutation(selfAid) {
|
|
337
|
+
const file = journalFile(selfAid);
|
|
338
|
+
if (!fs.existsSync(file))
|
|
339
|
+
return false;
|
|
340
|
+
const journal = atomicReadJson(file);
|
|
341
|
+
if (!journal || journal.schemaVersion !== 1 || journal.selfAid !== selfAid
|
|
342
|
+
|| !['prepared', 'committed'].includes(journal.state) || !Array.isArray(journal.changes)) {
|
|
343
|
+
throw new ContactMutationError('INVALID_JOURNAL', `Invalid contact mutation journal: ${file}`);
|
|
344
|
+
}
|
|
345
|
+
validateTransactionChanges(selfAid, journal.changes);
|
|
346
|
+
if (journal.state === 'prepared') {
|
|
347
|
+
for (const change of [...journal.changes].reverse()) {
|
|
348
|
+
applyTransactionChange({ ...change, after: change.before });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
for (const change of journal.changes)
|
|
352
|
+
fileCache.invalidate(change.file);
|
|
353
|
+
clearRoleStoreCache(selfAid);
|
|
354
|
+
removeJournal(selfAid);
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
function validateTransactionChanges(selfAid, changes) {
|
|
358
|
+
const root = path.resolve(agentDir(selfAid));
|
|
359
|
+
const seen = new Set();
|
|
360
|
+
for (const change of changes) {
|
|
361
|
+
if (!change || typeof change !== 'object' || typeof change.file !== 'string' || !change.file) {
|
|
362
|
+
throw new ContactMutationError('INVALID_TRANSACTION', 'Transaction changes must contain a file path');
|
|
363
|
+
}
|
|
364
|
+
const file = path.resolve(change.file);
|
|
365
|
+
const relative = path.relative(root, file);
|
|
366
|
+
if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
|
367
|
+
throw new ContactMutationError('UNSAFE_TRANSACTION', `Transaction file is outside the Agent directory: ${file}`);
|
|
368
|
+
}
|
|
369
|
+
if (seen.has(file)) {
|
|
370
|
+
throw new ContactMutationError('INVALID_TRANSACTION', `Transaction contains duplicate file changes: ${file}`);
|
|
371
|
+
}
|
|
372
|
+
seen.add(file);
|
|
373
|
+
if ((change.before !== null && typeof change.before !== 'string')
|
|
374
|
+
|| (change.after !== null && typeof change.after !== 'string')) {
|
|
375
|
+
throw new ContactMutationError('INVALID_TRANSACTION', `Invalid transaction content for ${file}`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
function applyTransactionChange(change) {
|
|
380
|
+
if (change.after === null) {
|
|
381
|
+
for (const candidate of [change.file, `${change.file}_`, `${change.file}__`]) {
|
|
382
|
+
fs.rmSync(candidate, { force: true });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else {
|
|
386
|
+
atomicWrite(change.file, change.after);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function writeJournal(selfAid, journal) {
|
|
390
|
+
atomicWriteJson(journalFile(selfAid), journal);
|
|
391
|
+
}
|
|
392
|
+
function removeJournal(selfAid) {
|
|
393
|
+
const file = journalFile(selfAid);
|
|
394
|
+
for (const candidate of [file, `${file}_`, `${file}__`])
|
|
395
|
+
fs.rmSync(candidate, { force: true });
|
|
396
|
+
}
|
|
397
|
+
function journalFile(selfAid) {
|
|
398
|
+
return path.join(agentDir(selfAid), JOURNAL_NAME);
|
|
399
|
+
}
|
|
400
|
+
function appendAudit(selfAid, request, previousContactRevision, contactRevisionValue) {
|
|
401
|
+
const file = path.join(resolvePaths().dataDir, 'contact-book-audit.jsonl');
|
|
402
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
403
|
+
fs.appendFileSync(file, `${JSON.stringify({
|
|
404
|
+
timestamp: new Date().toISOString(),
|
|
405
|
+
selfAid,
|
|
406
|
+
actor: request.actor ?? 'system',
|
|
407
|
+
mutation: request.mutation,
|
|
408
|
+
previousContactRevision,
|
|
409
|
+
contactRevision: contactRevisionValue,
|
|
410
|
+
})}\n`, { mode: 0o600 });
|
|
411
|
+
}
|
|
412
|
+
function isOwner(selfAid, primaryId) {
|
|
413
|
+
try {
|
|
414
|
+
const config = atomicReadJson(path.join(agentDir(selfAid), 'config.json'));
|
|
415
|
+
return config?.owners?.some(value => String(value || '').trim() === primaryId) ?? false;
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
return false;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
function normalizeAid(value, label) {
|
|
422
|
+
const aid = String(value || '').trim();
|
|
423
|
+
if (!isValidAid(aid))
|
|
424
|
+
throw new ContactMutationError('INVALID_AID', `Invalid ${label}: ${JSON.stringify(value)}`);
|
|
425
|
+
return aid;
|
|
426
|
+
}
|
|
427
|
+
function normalizeContactAid(value) {
|
|
428
|
+
const aid = normalizeAid(value, 'contact AID');
|
|
429
|
+
if (isExplicitGroupId(aid)) {
|
|
430
|
+
throw new ContactMutationError('INVALID_AID', `Contact Book does not support group AID: ${JSON.stringify(value)}`);
|
|
431
|
+
}
|
|
432
|
+
return aid;
|
|
433
|
+
}
|
|
434
|
+
function stableStringify(value) {
|
|
435
|
+
if (Array.isArray(value))
|
|
436
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
437
|
+
if (value && typeof value === 'object') {
|
|
438
|
+
return `{${Object.entries(value)
|
|
439
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
440
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`)
|
|
441
|
+
.join(',')}}`;
|
|
442
|
+
}
|
|
443
|
+
return JSON.stringify(value);
|
|
444
|
+
}
|
|
445
|
+
function readTextOrNull(file) {
|
|
446
|
+
try {
|
|
447
|
+
return fs.readFileSync(file, 'utf8');
|
|
448
|
+
}
|
|
449
|
+
catch (error) {
|
|
450
|
+
if (error?.code === 'ENOENT')
|
|
451
|
+
return null;
|
|
452
|
+
throw error;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'path';
|
|
2
|
+
import { pathToFileURL } from 'url';
|
|
3
|
+
import { getPackageRoot, resolvePaths } from '../paths.js';
|
|
4
|
+
import { assertContactBookV2Ready, ConfigError } from './config-manager.js';
|
|
5
|
+
import { recoverAllContactBookMutationsSync } from './contact-book-store.js';
|
|
6
|
+
export async function ensureContactBookV2OnStartup() {
|
|
7
|
+
try {
|
|
8
|
+
recoverAllContactBookMutationsSync();
|
|
9
|
+
}
|
|
10
|
+
catch (error) {
|
|
11
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
12
|
+
throw new ConfigError('CONTACT_BOOK_V2_RECOVERY_FAILED', `Interrupted contact transaction recovery failed: ${detail}`, { cause: error });
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
assertContactBookV2Ready();
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
if (!(error instanceof ConfigError) || error.code !== 'CONTACT_BOOK_V2_MIGRATION_REQUIRED')
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const home = resolvePaths().root;
|
|
23
|
+
const script = path.join(getPackageRoot(), 'kits', 'migrations', 'migrate-contact-book-v2.mjs');
|
|
24
|
+
try {
|
|
25
|
+
const migration = await import(pathToFileURL(script).href);
|
|
26
|
+
const result = migration.runMigration({ home, apply: true });
|
|
27
|
+
assertContactBookV2Ready();
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
32
|
+
throw new ConfigError('CONTACT_BOOK_V2_MIGRATION_FAILED', `Automatic contact book v2 migration failed: ${detail}\n` +
|
|
33
|
+
`node kits/migrations/migrate-contact-book-v2.mjs --apply --home ${JSON.stringify(home)}`, { cause: error });
|
|
34
|
+
}
|
|
35
|
+
}
|