ec-web 0.0.1 → 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.
@@ -0,0 +1,903 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { resolvePaths } from '../paths.js';
4
+ import { getSessionsAunDir, listPeers, readJsonFile, readMessages, } from '../fs-utils.js';
5
+ import { resolveParentDistModule, toFileUrl } from './parent-package.js';
6
+ import { ipcQuery } from '../ipc-client.js';
7
+ async function execRoleMenu(aid, payload, auth) {
8
+ const response = await ipcQuery(resolvePaths().socket, {
9
+ type: 'menu.exec',
10
+ payload: { ...payload, name: 'role', agent: aid, args: { self: aid, ...(payload.args ?? {}) } },
11
+ auth: { source: 'ecweb', actorAid: auth.actorAid ?? null, localDirect: !!auth.localDirect },
12
+ }, 10_000);
13
+ if (!response?.ok || !response.response)
14
+ throw new Error(response?.error || 'Role Menu is unavailable');
15
+ return response.response;
16
+ }
17
+ function sendRoleMenuError(res, response) {
18
+ const error = response?.error ?? { code: 'INTERNAL_ERROR', message: 'Role Menu request failed' };
19
+ const status = error.code === 'NOT_FOUND' ? 404
20
+ : error.code === 'CONFLICT' ? 409
21
+ : error.code === 'PERMISSION_DENIED' || error.code === 'NOT_ALLOWED' ? 403
22
+ : error.code === 'TEMPORARILY_UNAVAILABLE' ? 503 : 400;
23
+ sendJson(res, status, { error: error.message, code: error.code, data: error.data });
24
+ }
25
+ async function getParentModules() {
26
+ const configPath = resolveParentDistModule('config', 'config-manager.js');
27
+ const rolesPath = resolveParentDistModule('config', 'roles.js');
28
+ const resolverPath = resolveParentDistModule('config', 'peer-role-resolver.js');
29
+ const config = await import(toFileUrl(configPath));
30
+ const roles = await import(toFileUrl(rolesPath));
31
+ const resolver = await import(toFileUrl(resolverPath));
32
+ return { config, roles, resolver };
33
+ }
34
+ async function getCommandModules() {
35
+ const permissionPath = resolveParentDistModule('core', 'command', 'command-permission.js');
36
+ const auditPath = resolveParentDistModule('core', 'command', 'command-audit.js');
37
+ const permission = await import(toFileUrl(permissionPath));
38
+ const audit = await import(toFileUrl(auditPath));
39
+ return { permission, audit };
40
+ }
41
+ export const ROLE_ASSIGNMENT_POLICY = {
42
+ owner: {
43
+ assign: 'user-roles',
44
+ revoke: '*',
45
+ },
46
+ admin: {
47
+ assign: 'user-roles',
48
+ revoke: 'user-roles',
49
+ },
50
+ };
51
+ function isManagementRoleName(role) {
52
+ return role === 'owner' || role === 'admin';
53
+ }
54
+ function isAssignableUserRole(role) {
55
+ return typeof role === 'string' && !!role && !isManagementRoleName(role);
56
+ }
57
+ export function canAssignRole(actorRole, targetRole) {
58
+ if (!isAssignableUserRole(targetRole))
59
+ return false;
60
+ return actorRole === 'owner' || actorRole === 'admin';
61
+ }
62
+ export function canRevokeRole(actorRole, targetRole) {
63
+ if (actorRole === 'owner')
64
+ return true;
65
+ if (actorRole !== 'admin' || !targetRole)
66
+ return false;
67
+ return isAssignableUserRole(targetRole);
68
+ }
69
+ async function getAgentsFromIpc() {
70
+ try {
71
+ const p = resolvePaths();
72
+ const { ipcQuery } = await import('../ipc-client.js');
73
+ const resp = await ipcQuery(p.socket, { type: 'evolagent.list' }, 3000);
74
+ return resp?.agents ?? [];
75
+ }
76
+ catch {
77
+ return [];
78
+ }
79
+ }
80
+ async function authorizeRoleAssignmentWrite(modules, aid, auth, operation, targetRole, args) {
81
+ const { permission, audit } = await getCommandModules();
82
+ const actorRole = auth.localDirect
83
+ ? 'owner'
84
+ : auth.actorAid
85
+ ? (modules.resolver.resolvePeerRoleDetail({
86
+ selfAid: aid,
87
+ channelType: 'aun',
88
+ chatType: 'private',
89
+ actorId: auth.actorAid,
90
+ conversationId: auth.actorAid,
91
+ }).effectiveRole || 'none')
92
+ : 'none';
93
+ const targetPeerId = typeof args.peerId === 'string' ? args.peerId : undefined;
94
+ if (targetPeerId && modules.resolver.isStaticAgentOwner?.(aid, targetPeerId)) {
95
+ const reason = `Static owner ${targetPeerId} is defined in agents/${aid}/config.json and cannot be changed by relation role assignments`;
96
+ await audit.auditCommandAuthorization({
97
+ ts: Date.now(),
98
+ source: 'ecweb',
99
+ operation,
100
+ scope: 'agent',
101
+ dangerous: false,
102
+ actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
103
+ selfAid: aid,
104
+ role: actorRole,
105
+ decision: 'deny',
106
+ code: 'IMMUTABLE_STATIC_OWNER',
107
+ reason,
108
+ argsSummary: { ...args, targetRole },
109
+ });
110
+ return {
111
+ allow: false,
112
+ status: 403,
113
+ error: 'forbidden: static owner cannot be changed by relation role assignments',
114
+ code: 'IMMUTABLE_STATIC_OWNER',
115
+ reason,
116
+ actorRole,
117
+ };
118
+ }
119
+ const decision = permission.authorizeCommand({
120
+ intent: {
121
+ operation,
122
+ scope: 'agent',
123
+ source: 'ecweb',
124
+ args: {
125
+ ...args,
126
+ targetRole,
127
+ },
128
+ },
129
+ actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
130
+ selfAid: aid,
131
+ role: actorRole,
132
+ source: 'ecweb',
133
+ });
134
+ if (!decision.allow) {
135
+ await audit.auditCommandAuthorization({
136
+ ts: Date.now(),
137
+ source: 'ecweb',
138
+ operation,
139
+ scope: 'agent',
140
+ dangerous: false,
141
+ actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
142
+ selfAid: aid,
143
+ role: actorRole,
144
+ decision: 'deny',
145
+ code: decision.code,
146
+ reason: decision.reason,
147
+ matchedRule: decision.matchedRule,
148
+ argsSummary: { ...args, targetRole },
149
+ });
150
+ return {
151
+ allow: false,
152
+ status: 403,
153
+ error: 'forbidden: role operation not allowed',
154
+ code: decision.code,
155
+ reason: decision.reason,
156
+ actorRole,
157
+ };
158
+ }
159
+ const policyAllowed = operation === 'role.assign'
160
+ ? !!targetRole && canAssignRole(actorRole, targetRole)
161
+ : canRevokeRole(actorRole, targetRole);
162
+ if (!policyAllowed) {
163
+ const reason = operation === 'role.assign'
164
+ ? `Role ${actorRole} cannot assign target role ${targetRole || '(missing)'}`
165
+ : `Role ${actorRole} cannot revoke target role ${targetRole || '(missing)'}`;
166
+ await audit.auditCommandAuthorization({
167
+ ts: Date.now(),
168
+ source: 'ecweb',
169
+ operation,
170
+ scope: 'agent',
171
+ dangerous: false,
172
+ actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
173
+ selfAid: aid,
174
+ role: actorRole,
175
+ decision: 'deny',
176
+ code: 'NOT_ALLOWED',
177
+ reason,
178
+ matchedRule: decision.matchedRule,
179
+ argsSummary: { ...args, targetRole },
180
+ });
181
+ return {
182
+ allow: false,
183
+ status: 403,
184
+ error: 'forbidden: target role not assignable',
185
+ code: 'NOT_ALLOWED',
186
+ reason,
187
+ actorRole,
188
+ };
189
+ }
190
+ await audit.auditCommandAuthorization({
191
+ ts: Date.now(),
192
+ source: 'ecweb',
193
+ operation,
194
+ scope: 'agent',
195
+ dangerous: false,
196
+ actorId: auth.localDirect ? 'local-direct' : auth.actorAid || undefined,
197
+ selfAid: aid,
198
+ role: actorRole,
199
+ decision: 'allow',
200
+ matchedRule: decision.matchedRule,
201
+ argsSummary: { ...args, targetRole },
202
+ });
203
+ return { allow: true, actorRole };
204
+ }
205
+ function roleExists(roles, aid, role) {
206
+ if (!isAssignableUserRole(role))
207
+ return false;
208
+ if (typeof roles.roleExists === 'function')
209
+ return !!roles.roleExists(role, aid);
210
+ return !!roles.readRolesConfig(aid).roles?.[role];
211
+ }
212
+ function parseBody(req) {
213
+ return new Promise((resolve, reject) => {
214
+ let body = '';
215
+ req.on('data', (chunk) => { body += chunk.toString(); });
216
+ req.on('end', () => {
217
+ if (!body.trim())
218
+ return resolve({});
219
+ try {
220
+ resolve(JSON.parse(body));
221
+ }
222
+ catch (err) {
223
+ reject(err);
224
+ }
225
+ });
226
+ });
227
+ }
228
+ function sendJson(res, status, data) {
229
+ res.writeHead(status, { 'Content-Type': 'application/json' });
230
+ res.end(JSON.stringify(data));
231
+ }
232
+ function sendAuthorizationDenied(res, decision) {
233
+ sendJson(res, decision.status ?? 403, {
234
+ error: decision.error || 'forbidden',
235
+ ...(decision.code ? { code: decision.code } : {}),
236
+ ...(decision.reason ? { reason: decision.reason } : {}),
237
+ ...(decision.actorRole ? { actorRole: decision.actorRole } : {}),
238
+ });
239
+ }
240
+ function assignmentFromPath(urlPath) {
241
+ const parts = urlPath.split('/').filter(Boolean);
242
+ if (parts.length < 5)
243
+ return null;
244
+ return {
245
+ aid: decodeURIComponent(parts[3]),
246
+ targetId: decodeURIComponent(parts.slice(4).join('/')),
247
+ };
248
+ }
249
+ function formatPeerKey(channelType, channelId) {
250
+ return `${channelType}#${encodeURIComponent(channelId)}`;
251
+ }
252
+ function looksLikePeerKey(value) {
253
+ return /^[a-z][a-z0-9_-]*#/i.test(value);
254
+ }
255
+ function parsePeerKey(peerKey) {
256
+ const idx = peerKey.indexOf('#');
257
+ if (idx <= 0)
258
+ return { channelType: 'aun', channelId: peerKey };
259
+ return {
260
+ channelType: peerKey.slice(0, idx),
261
+ channelId: decodeURIComponent(peerKey.slice(idx + 1)),
262
+ };
263
+ }
264
+ function relationTargetFromId(raw) {
265
+ const value = String(raw || '').trim();
266
+ if (!value)
267
+ throw new Error('target id is required');
268
+ if (looksLikePeerKey(value)) {
269
+ const parsed = parsePeerKey(value);
270
+ return { peerKey: formatPeerKey(parsed.channelType, parsed.channelId), channelId: parsed.channelId };
271
+ }
272
+ return { peerKey: formatPeerKey('aun', value), channelId: value };
273
+ }
274
+ function readActive(aunDir, aid, conversationId) {
275
+ return readJsonFile(path.join(aunDir, encodeSegmentForRoleSource(aid), encodeSegmentForRoleSource(conversationId), 'active.json'));
276
+ }
277
+ function encodeSegmentForRoleSource(s) {
278
+ return s.replace(/[<>:"/\\|?*\x00-\x1F%]/g, ch => '%' + ch.charCodeAt(0).toString(16).toUpperCase().padStart(2, '0'));
279
+ }
280
+ function frontmatterValue(frontmatter, key) {
281
+ const re = new RegExp(`^${key}:\\s*(?:"([^"]*)"|'([^']*)'|([^\\r\\n#]*))\\s*(?:#.*)?$`, 'm');
282
+ const match = frontmatter.match(re);
283
+ const value = match?.[1] ?? match?.[2] ?? match?.[3];
284
+ const trimmed = value?.trim();
285
+ return trimmed || undefined;
286
+ }
287
+ export function normalizeAgentPeerType(value) {
288
+ const raw = (value ?? '').trim().toLowerCase();
289
+ if (!raw || raw === 'unknown')
290
+ return undefined;
291
+ if (raw === 'human')
292
+ return 'human';
293
+ if (raw === 'system')
294
+ return 'system';
295
+ return 'ai';
296
+ }
297
+ export function parseAgentMdInfo(content) {
298
+ const match = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
299
+ if (!match)
300
+ return undefined;
301
+ const frontmatter = match[1];
302
+ const declaredType = frontmatterValue(frontmatter, 'type');
303
+ const name = frontmatterValue(frontmatter, 'name');
304
+ return {
305
+ ...(name ? { name } : {}),
306
+ ...(declaredType ? { declaredType, peerType: normalizeAgentPeerType(declaredType) } : {}),
307
+ };
308
+ }
309
+ function createLocalAgentMdLookup() {
310
+ const cache = new Map();
311
+ const aidsDir = path.join(resolvePaths().root, 'AIDs');
312
+ return (aid) => {
313
+ if (!aid || !/^[A-Za-z0-9._-]+$/.test(aid))
314
+ return undefined;
315
+ if (cache.has(aid))
316
+ return cache.get(aid);
317
+ let info;
318
+ try {
319
+ const agentMdPath = path.join(aidsDir, aid, 'agent.md');
320
+ if (fs.existsSync(agentMdPath)) {
321
+ info = parseAgentMdInfo(fs.readFileSync(agentMdPath, 'utf-8'));
322
+ }
323
+ }
324
+ catch {
325
+ info = undefined;
326
+ }
327
+ cache.set(aid, info);
328
+ return info;
329
+ };
330
+ }
331
+ function latestMessageAt(messages) {
332
+ let lastAt = 0;
333
+ for (const message of messages) {
334
+ if (message.ts > lastAt)
335
+ lastAt = message.ts;
336
+ }
337
+ return lastAt;
338
+ }
339
+ function messageCounts(messages) {
340
+ let inbound = 0;
341
+ let outbound = 0;
342
+ for (const message of messages) {
343
+ if (message.dir === 'in')
344
+ inbound++;
345
+ else
346
+ outbound++;
347
+ }
348
+ return { inbound, outbound };
349
+ }
350
+ function firstMessagePeerName(messages, peerId) {
351
+ for (let i = messages.length - 1; i >= 0; i--) {
352
+ const message = messages[i];
353
+ if (message.from === peerId && message.peerName)
354
+ return message.peerName;
355
+ }
356
+ return undefined;
357
+ }
358
+ function firstMessagePeerType(messages, peerId) {
359
+ for (let i = messages.length - 1; i >= 0; i--) {
360
+ const message = messages[i];
361
+ if (message.from === peerId && message.peerType)
362
+ return message.peerType;
363
+ }
364
+ return undefined;
365
+ }
366
+ function conversationSortName(seed) {
367
+ return (seed.name || seed.peerAid || seed.groupId || seed.conversationId).toLowerCase();
368
+ }
369
+ function groupMembersFromMessages(selfAid, messages, localAgentMdLookup) {
370
+ const members = new Map();
371
+ for (const message of messages) {
372
+ const sender = message.from;
373
+ if (!sender || sender === selfAid)
374
+ continue;
375
+ const current = members.get(sender) ?? {
376
+ peerId: sender,
377
+ peerAid: sender,
378
+ peerKey: sender,
379
+ lastAt: 0,
380
+ inbound: 0,
381
+ outbound: 0,
382
+ };
383
+ if (message.ts > (current.lastAt ?? 0))
384
+ current.lastAt = message.ts;
385
+ if (message.dir === 'in')
386
+ current.inbound = (current.inbound ?? 0) + 1;
387
+ else
388
+ current.outbound = (current.outbound ?? 0) + 1;
389
+ if (message.peerName)
390
+ current.peerName = message.peerName;
391
+ if (message.peerType)
392
+ current.peerType = normalizeAgentPeerType(message.peerType);
393
+ members.set(sender, current);
394
+ }
395
+ for (const member of members.values()) {
396
+ const localInfo = localAgentMdLookup(member.peerId);
397
+ member.peerName = localInfo?.name ?? member.peerName;
398
+ member.peerType = localInfo?.peerType ?? member.peerType;
399
+ }
400
+ return [...members.values()].sort((a, b) => {
401
+ const lastDiff = (b.lastAt ?? 0) - (a.lastAt ?? 0);
402
+ if (lastDiff !== 0)
403
+ return lastDiff;
404
+ return (a.peerName || a.peerAid).localeCompare(b.peerName || b.peerAid);
405
+ });
406
+ }
407
+ export function buildConversationSeeds(aid, peerInfos, localAgentMdLookup = () => undefined) {
408
+ const seeds = [];
409
+ for (const peer of peerInfos) {
410
+ const messages = peer.messages || [];
411
+ const active = peer.active;
412
+ const chatType = active?.chatType || messages.find(m => m.chatType)?.chatType || 'private';
413
+ const lastAt = active?.updatedAt || latestMessageAt(messages);
414
+ const counts = messageCounts(messages);
415
+ if (chatType === 'group') {
416
+ const groupId = active?.metadata?.groupId || messages.find(m => m.groupId)?.groupId || peer.conversationId;
417
+ const groupName = active?.metadata?.groupName || active?.name || groupId;
418
+ seeds.push({
419
+ self: aid,
420
+ channelType: 'aun',
421
+ chatType: 'group',
422
+ conversationId: groupId,
423
+ groupId,
424
+ groupName,
425
+ name: groupName,
426
+ lastAt,
427
+ inbound: counts.inbound,
428
+ outbound: counts.outbound,
429
+ members: groupMembersFromMessages(aid, messages, localAgentMdLookup),
430
+ });
431
+ continue;
432
+ }
433
+ const peerId = active?.metadata?.peerId || peer.conversationId;
434
+ const localInfo = localAgentMdLookup(peerId);
435
+ const metadataName = active?.metadata?.peerName || firstMessagePeerName(messages, peerId);
436
+ const metadataType = active?.metadata?.peerType || firstMessagePeerType(messages, peerId);
437
+ const peerName = localInfo?.name ?? metadataName ?? peerId;
438
+ const peerType = localInfo?.peerType ?? normalizeAgentPeerType(metadataType);
439
+ seeds.push({
440
+ self: aid,
441
+ channelType: 'aun',
442
+ chatType: 'private',
443
+ conversationId: peerId,
444
+ name: peerName,
445
+ peerId,
446
+ peerAid: peerId,
447
+ peerKey: peerId,
448
+ peerName,
449
+ peerType,
450
+ lastAt,
451
+ inbound: counts.inbound,
452
+ outbound: counts.outbound,
453
+ });
454
+ }
455
+ return seeds.sort((a, b) => {
456
+ const lastDiff = (b.lastAt ?? 0) - (a.lastAt ?? 0);
457
+ if (lastDiff !== 0)
458
+ return lastDiff;
459
+ if (a.chatType !== b.chatType)
460
+ return a.chatType === 'group' ? -1 : 1;
461
+ return conversationSortName(a).localeCompare(conversationSortName(b));
462
+ });
463
+ }
464
+ function loadConversationSeeds(aid) {
465
+ const aunDir = getSessionsAunDir();
466
+ const localAgentMdLookup = createLocalAgentMdLookup();
467
+ const peerInfos = listPeers(aunDir, aid).map(conversationId => ({
468
+ conversationId,
469
+ active: readActive(aunDir, aid, conversationId),
470
+ messages: readMessages(aunDir, aid, conversationId),
471
+ }));
472
+ return buildConversationSeeds(aid, peerInfos, localAgentMdLookup);
473
+ }
474
+ function readRelationConfig(config, aid, peerKey) {
475
+ return config.read(config.ConfigTarget.Relation, { self: aid, peerKey }, { cache: true }) || {};
476
+ }
477
+ function writeRelationConfig(config, aid, peerKey, value) {
478
+ config.write(config.ConfigTarget.Relation, value, { self: aid, peerKey });
479
+ }
480
+ function explicitAssignment(scope, peerKey, role, ids) {
481
+ if (!role)
482
+ return undefined;
483
+ return {
484
+ scope,
485
+ peerKey,
486
+ role,
487
+ ...(ids.peerId ? { peerId: ids.peerId } : {}),
488
+ ...(ids.groupId ? { groupId: ids.groupId } : {}),
489
+ source: 'relation-config',
490
+ };
491
+ }
492
+ function resolveGroupRoleDetail(modules, aid, groupId) {
493
+ const target = relationTargetFromId(groupId);
494
+ const relation = readRelationConfig(modules.config, aid, target.peerKey);
495
+ const assigned = typeof relation.roles?.assigned === 'string' && roleExists(modules.roles, aid, relation.roles.assigned)
496
+ ? relation.roles.assigned
497
+ : null;
498
+ if (assigned) {
499
+ return {
500
+ effectiveRole: assigned,
501
+ source: 'relation-assigned',
502
+ assignment: explicitAssignment('group', target.peerKey, assigned, { groupId: target.channelId }),
503
+ };
504
+ }
505
+ const defaultRole = modules.roles.readRolesConfig(aid).defaultRoles?.group ?? null;
506
+ const effective = defaultRole && roleExists(modules.roles, aid, defaultRole) ? defaultRole : null;
507
+ return { effectiveRole: effective, source: effective ? 'default' : 'none' };
508
+ }
509
+ function assignmentForDetail(detail, modules, seed, member) {
510
+ const role = detail?.effectiveRole;
511
+ if (!role)
512
+ return undefined;
513
+ if (seed.chatType === 'private' && detail.source === 'relation-assigned') {
514
+ const target = relationTargetFromId(seed.peerId || seed.conversationId);
515
+ return explicitAssignment('private', target.peerKey, role, { peerId: target.channelId });
516
+ }
517
+ const groupId = seed.groupId || seed.conversationId;
518
+ const groupTarget = relationTargetFromId(groupId);
519
+ if (member && detail.source === 'group-member') {
520
+ return explicitAssignment('group-member', groupTarget.peerKey, role, { groupId: groupTarget.channelId, peerId: member.peerId });
521
+ }
522
+ if (member && detail.source === 'private-inherited') {
523
+ const privateTarget = relationTargetFromId(member.peerId);
524
+ return explicitAssignment('private', privateTarget.peerKey, role, { peerId: member.peerId });
525
+ }
526
+ if (member && detail.source === 'group-default') {
527
+ return explicitAssignment('group', groupTarget.peerKey, role, { groupId: groupTarget.channelId });
528
+ }
529
+ void modules;
530
+ return undefined;
531
+ }
532
+ function withResolvedRoles(seed, modules) {
533
+ if (seed.chatType === 'private') {
534
+ const detail = modules.resolver.resolvePeerRoleDetail({
535
+ selfAid: seed.self,
536
+ channelType: seed.channelType,
537
+ chatType: 'private',
538
+ actorId: seed.peerId,
539
+ conversationId: seed.conversationId,
540
+ peerType: seed.peerType,
541
+ });
542
+ return {
543
+ ...seed,
544
+ role: detail.effectiveRole,
545
+ source: detail.source,
546
+ assignment: assignmentForDetail(detail, modules, seed),
547
+ };
548
+ }
549
+ const groupId = seed.groupId || seed.conversationId;
550
+ const groupDetail = resolveGroupRoleDetail(modules, seed.self, groupId);
551
+ const members = (seed.members || []).map(member => {
552
+ const detail = modules.resolver.resolvePeerRoleDetail({
553
+ selfAid: seed.self,
554
+ channelType: seed.channelType,
555
+ chatType: 'group',
556
+ actorId: member.peerId,
557
+ conversationId: groupId,
558
+ peerType: member.peerType,
559
+ });
560
+ return {
561
+ ...member,
562
+ role: detail.effectiveRole,
563
+ source: detail.source,
564
+ assignment: assignmentForDetail(detail, modules, seed, member),
565
+ };
566
+ });
567
+ return {
568
+ ...seed,
569
+ conversationId: groupId,
570
+ groupId,
571
+ role: groupDetail.effectiveRole,
572
+ source: groupDetail.source,
573
+ assignment: groupDetail.assignment,
574
+ members,
575
+ };
576
+ }
577
+ function listRelationRoleAssignments(config, aid) {
578
+ const out = [];
579
+ const relationsDir = path.join(resolvePaths().root, 'agents', aid, 'relations');
580
+ if (!fs.existsSync(relationsDir))
581
+ return out;
582
+ for (const entry of fs.readdirSync(relationsDir, { withFileTypes: true })) {
583
+ if (!entry.isDirectory())
584
+ continue;
585
+ const peerKey = entry.name;
586
+ const relation = readRelationConfig(config, aid, peerKey);
587
+ const roles = relation.roles;
588
+ if (!roles || typeof roles !== 'object')
589
+ continue;
590
+ const parsed = parsePeerKey(peerKey);
591
+ const hasMembers = roles.members && typeof roles.members === 'object' && Object.keys(roles.members).length > 0;
592
+ if (typeof roles.assigned === 'string') {
593
+ out.push({
594
+ scope: hasMembers ? 'group' : 'private',
595
+ peerKey,
596
+ role: roles.assigned,
597
+ ...(hasMembers ? { groupId: parsed.channelId } : { peerId: parsed.channelId }),
598
+ source: 'relation-config',
599
+ });
600
+ }
601
+ if (roles.members && typeof roles.members === 'object') {
602
+ for (const [peerId, role] of Object.entries(roles.members)) {
603
+ if (typeof role !== 'string')
604
+ continue;
605
+ out.push({
606
+ scope: 'group-member',
607
+ peerKey,
608
+ groupId: parsed.channelId,
609
+ peerId,
610
+ role,
611
+ source: 'relation-config',
612
+ });
613
+ }
614
+ }
615
+ }
616
+ return out;
617
+ }
618
+ async function buildSnapshot(auth = {}) {
619
+ const modules = await getParentModules();
620
+ const agents = await getAgentsFromIpc();
621
+ const snapshotAgents = [];
622
+ const conversations = [];
623
+ for (const agent of agents) {
624
+ const aid = agent.aid;
625
+ if (!aid)
626
+ continue;
627
+ const [roleOptions, defaults, assignmentResult] = await Promise.all([
628
+ execRoleMenu(aid, {
629
+ type: 'menu.options', id: `ecweb-relation-role-options-${Date.now()}`,
630
+ args: { option: 'roles' },
631
+ }, auth),
632
+ execRoleMenu(aid, {
633
+ type: 'menu.query', id: `ecweb-relation-role-defaults-${Date.now()}`,
634
+ args: { view: 'defaults' },
635
+ }, auth),
636
+ listAssignmentsThroughMenu(aid, auth),
637
+ ]);
638
+ if (roleOptions.error || defaults.error || assignmentResult.error)
639
+ continue;
640
+ snapshotAgents.push({
641
+ aid,
642
+ displayName: agent.displayName ?? agent.personalName,
643
+ name: agent.name,
644
+ roles: roleOptions.data.filter((item) => item.assignable).map((item) => item.value),
645
+ defaultRoles: defaults.data.defaultRoles,
646
+ assignments: assignmentResult.data.assignments,
647
+ });
648
+ for (const seed of loadConversationSeeds(aid)) {
649
+ conversations.push(withResolvedRoles(seed, modules));
650
+ }
651
+ }
652
+ return { agents: snapshotAgents, conversations };
653
+ }
654
+ export const relationRolesSource = {
655
+ kind: 'roles',
656
+ async snapshot(params = {}) {
657
+ try {
658
+ return await buildSnapshot(params.__menuAuth ?? {});
659
+ }
660
+ catch (err) {
661
+ return { agents: [], conversations: [], error: String(err) };
662
+ }
663
+ },
664
+ subscribe(_params, push) {
665
+ let lastJson = '';
666
+ let stopped = false;
667
+ const tick = async () => {
668
+ if (stopped)
669
+ return;
670
+ try {
671
+ const snap = await buildSnapshot(_params.__menuAuth ?? {});
672
+ const json = JSON.stringify(snap);
673
+ if (json !== lastJson) {
674
+ lastJson = json;
675
+ push(snap);
676
+ }
677
+ }
678
+ catch (err) {
679
+ console.error('[relation-roles] polling error:', err);
680
+ }
681
+ };
682
+ tick();
683
+ const timer = setInterval(tick, 2000);
684
+ return () => {
685
+ stopped = true;
686
+ clearInterval(timer);
687
+ };
688
+ },
689
+ };
690
+ function requireScope(body) {
691
+ if (body.scope !== 'private' && body.scope !== 'group' && body.scope !== 'group-member') {
692
+ throw new Error('scope must be private, group, or group-member');
693
+ }
694
+ return body.scope;
695
+ }
696
+ function scopedAssignmentArgs(body, fallbackTargetId) {
697
+ const scope = requireScope(body);
698
+ if (scope === 'private') {
699
+ const raw = body.peerKey || body.peerId || fallbackTargetId;
700
+ if (!raw)
701
+ throw new Error('peerId is required');
702
+ const target = relationTargetFromId(raw);
703
+ return { scope, peerId: target.channelId, peerKey: target.peerKey };
704
+ }
705
+ if (scope === 'group') {
706
+ const raw = body.peerKey || body.groupId || fallbackTargetId;
707
+ if (!raw)
708
+ throw new Error('groupId is required');
709
+ const target = relationTargetFromId(raw);
710
+ return { scope, groupId: target.channelId, peerKey: target.peerKey };
711
+ }
712
+ const groupRaw = body.groupKey || body.groupPeerKey || body.groupId;
713
+ const peerId = body.peerId || fallbackTargetId;
714
+ if (!groupRaw || !peerId)
715
+ throw new Error('groupId and peerId are required');
716
+ const target = relationTargetFromId(groupRaw);
717
+ return { scope, groupId: target.channelId, peerId, peerKey: target.peerKey };
718
+ }
719
+ function toMenuTarget(target) {
720
+ if (target.scope === 'private')
721
+ return { kind: 'private', peerAid: target.peerId };
722
+ if (target.scope === 'group')
723
+ return { kind: 'group', groupId: target.groupId };
724
+ return { kind: 'group-member', groupId: target.groupId, memberAid: target.peerId };
725
+ }
726
+ function toLegacyAssignment(target, role) {
727
+ if (target.scope === 'private')
728
+ return { scope: 'private', peerId: target.peerId, peerKey: target.peerKey, role, source: 'relation-config' };
729
+ if (target.scope === 'group')
730
+ return { scope: 'group', groupId: target.groupId, peerKey: target.peerKey, role, source: 'relation-config' };
731
+ return { scope: 'group-member', groupId: target.groupId, peerId: target.peerId, peerKey: target.peerKey, role, source: 'relation-config' };
732
+ }
733
+ async function updateAssignmentThroughMenu(aid, body, fallbackTargetId, role, auth) {
734
+ const target = scopedAssignmentArgs(body, fallbackTargetId);
735
+ const menuTarget = toMenuTarget(target);
736
+ const snapshot = await execRoleMenu(aid, {
737
+ type: 'menu.query', id: `ecweb-assignment-target-${Date.now()}`,
738
+ args: { view: 'target', target: menuTarget },
739
+ }, auth);
740
+ if (snapshot.error)
741
+ return { response: snapshot, target };
742
+ const response = await execRoleMenu(aid, {
743
+ type: 'menu.update', id: `ecweb-assignment-update-${Date.now()}`,
744
+ args: {
745
+ resource: 'assignment',
746
+ target: menuTarget,
747
+ expectedPolicyRevision: snapshot.data.policyRevision,
748
+ expectedRelationRevision: snapshot.data.relationRevision,
749
+ },
750
+ value: JSON.stringify(role),
751
+ }, auth);
752
+ return { response, target };
753
+ }
754
+ async function listAssignmentsThroughMenu(aid, auth) {
755
+ const items = [];
756
+ let cursor = null;
757
+ do {
758
+ const response = await execRoleMenu(aid, {
759
+ type: 'menu.query', id: `ecweb-assignment-list-${Date.now()}-${items.length}`,
760
+ args: { view: 'assignments', limit: 200, cursor },
761
+ }, auth);
762
+ if (response.error)
763
+ return response;
764
+ items.push(...response.data.items);
765
+ cursor = response.data.nextCursor;
766
+ } while (cursor);
767
+ const assignments = items
768
+ .filter((item) => item.target?.kind !== 'default' && item.target?.kind !== 'unknown')
769
+ .map((item) => {
770
+ const target = item.target;
771
+ if (target.kind === 'private')
772
+ return { scope: 'private', peerId: target.peerAid, peerKey: target.peerKey, role: item.roleId, source: 'relation-config' };
773
+ if (target.kind === 'group')
774
+ return { scope: 'group', groupId: target.groupId, peerKey: target.peerKey, role: item.roleId, source: 'relation-config' };
775
+ return { scope: 'group-member', groupId: target.groupId, peerId: target.memberAid, peerKey: target.peerKey, role: item.roleId, source: 'relation-config' };
776
+ });
777
+ return { data: { $schema_version: 1, assignments } };
778
+ }
779
+ function pruneEmptyRoles(config) {
780
+ const roles = config.roles;
781
+ if (!roles || typeof roles !== 'object')
782
+ return config;
783
+ if (roles.members && typeof roles.members === 'object' && Object.keys(roles.members).length === 0) {
784
+ delete roles.members;
785
+ }
786
+ if (!Object.prototype.hasOwnProperty.call(roles, 'assigned') && !roles.members) {
787
+ delete config.roles;
788
+ }
789
+ return config;
790
+ }
791
+ function setScopedAssignment(configMod, aid, body, fallbackTargetId) {
792
+ const target = scopedAssignmentArgs(body, fallbackTargetId);
793
+ const role = body.role;
794
+ if (!role)
795
+ throw new Error('role is required');
796
+ const config = readRelationConfig(configMod, aid, target.peerKey);
797
+ const roles = config.roles && typeof config.roles === 'object' ? { ...config.roles } : {};
798
+ if (target.scope === 'group-member') {
799
+ roles.members = roles.members && typeof roles.members === 'object' ? { ...roles.members } : {};
800
+ roles.members[target.peerId] = role;
801
+ }
802
+ else {
803
+ roles.assigned = role;
804
+ }
805
+ writeRelationConfig(configMod, aid, target.peerKey, { ...config, roles });
806
+ if (target.scope === 'private')
807
+ return { scope: 'private', peerId: target.peerId, peerKey: target.peerKey, role, source: 'relation-config' };
808
+ if (target.scope === 'group')
809
+ return { scope: 'group', groupId: target.groupId, peerKey: target.peerKey, role, source: 'relation-config' };
810
+ return { scope: 'group-member', groupId: target.groupId, peerId: target.peerId, peerKey: target.peerKey, role, source: 'relation-config' };
811
+ }
812
+ function getScopedAssignment(configMod, aid, body, fallbackTargetId) {
813
+ const target = scopedAssignmentArgs(body, fallbackTargetId);
814
+ const config = readRelationConfig(configMod, aid, target.peerKey);
815
+ if (target.scope === 'group-member') {
816
+ const role = config.roles?.members?.[target.peerId];
817
+ return explicitAssignment('group-member', target.peerKey, role, { groupId: target.groupId, peerId: target.peerId });
818
+ }
819
+ const role = config.roles?.assigned;
820
+ if (target.scope === 'private')
821
+ return explicitAssignment('private', target.peerKey, role, { peerId: target.peerId });
822
+ return explicitAssignment('group', target.peerKey, role, { groupId: target.groupId });
823
+ }
824
+ function deleteScopedAssignment(configMod, aid, body, fallbackTargetId) {
825
+ const target = scopedAssignmentArgs(body, fallbackTargetId);
826
+ const config = readRelationConfig(configMod, aid, target.peerKey);
827
+ if (!config.roles || typeof config.roles !== 'object')
828
+ return false;
829
+ const roles = { ...config.roles };
830
+ let deleted = false;
831
+ if (target.scope === 'group-member') {
832
+ const members = roles.members && typeof roles.members === 'object' ? { ...roles.members } : {};
833
+ deleted = Object.prototype.hasOwnProperty.call(members, target.peerId);
834
+ delete members[target.peerId];
835
+ roles.members = members;
836
+ }
837
+ else {
838
+ deleted = Object.prototype.hasOwnProperty.call(roles, 'assigned');
839
+ delete roles.assigned;
840
+ }
841
+ writeRelationConfig(configMod, aid, target.peerKey, pruneEmptyRoles({ ...config, roles }));
842
+ return deleted;
843
+ }
844
+ export async function handleRelationRolesApi(req, res, auth = {}) {
845
+ try {
846
+ const urlPath = (req.url || '').split('?')[0];
847
+ if (req.method === 'GET' && urlPath.startsWith('/api/roles/agent/')) {
848
+ const aid = decodeURIComponent(urlPath.split('/').filter(Boolean).pop() || '');
849
+ if (!aid)
850
+ return sendJson(res, 400, { error: 'missing aid' });
851
+ const result = await listAssignmentsThroughMenu(aid, auth);
852
+ if (result.error)
853
+ return sendRoleMenuError(res, result);
854
+ return sendJson(res, 200, result.data);
855
+ }
856
+ if ((req.method === 'POST' || req.method === 'PUT') && urlPath.startsWith('/api/roles/agent/')) {
857
+ const aid = decodeURIComponent(urlPath.split('/').filter(Boolean).pop() || '');
858
+ if (!aid)
859
+ return sendJson(res, 400, { error: 'missing aid' });
860
+ const body = await parseBody(req);
861
+ if (typeof body.role !== 'string' || !body.role) {
862
+ return sendJson(res, 400, { error: 'role is required' });
863
+ }
864
+ const result = await updateAssignmentThroughMenu(aid, body, undefined, body.role, auth);
865
+ if (result.response.error)
866
+ return sendRoleMenuError(res, result.response);
867
+ return sendJson(res, 200, { ok: true, assignment: toLegacyAssignment(result.target, body.role) });
868
+ }
869
+ return sendJson(res, 404, { error: 'not found' });
870
+ }
871
+ catch (err) {
872
+ return sendJson(res, 500, { error: err?.message || String(err) });
873
+ }
874
+ }
875
+ export async function handlePeerRoleApi(req, res, auth = {}) {
876
+ try {
877
+ const urlPath = (req.url || '').split('?')[0];
878
+ const target = assignmentFromPath(urlPath);
879
+ if (!target)
880
+ return sendJson(res, 400, { error: 'missing aid or target id' });
881
+ if (req.method === 'DELETE') {
882
+ const body = await parseBody(req);
883
+ const result = await updateAssignmentThroughMenu(target.aid, body, target.targetId, null, auth);
884
+ if (result.response.error)
885
+ return sendRoleMenuError(res, result.response);
886
+ return sendJson(res, 200, { ok: true, deleted: result.response.data.changed });
887
+ }
888
+ if (req.method === 'PUT') {
889
+ const body = await parseBody(req);
890
+ if (typeof body.role !== 'string' || !body.role) {
891
+ return sendJson(res, 400, { error: 'role is required' });
892
+ }
893
+ const result = await updateAssignmentThroughMenu(target.aid, body, target.targetId, body.role, auth);
894
+ if (result.response.error)
895
+ return sendRoleMenuError(res, result.response);
896
+ return sendJson(res, 200, { ok: true, assignment: toLegacyAssignment(result.target, body.role) });
897
+ }
898
+ return sendJson(res, 404, { error: 'not found' });
899
+ }
900
+ catch (err) {
901
+ return sendJson(res, 500, { error: err?.message || String(err) });
902
+ }
903
+ }