@wowyuarm/dsh-agent-team 0.1.11 → 0.1.13
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/README.md +6 -4
- package/README.zh.md +6 -4
- package/cordis.patch.yml +7 -3
- package/package.json +16 -3
- package/packages/agent-team/README.md +4 -4
- package/packages/agent-team/README.zh.md +4 -4
- package/packages/agent-team/lib/context-projection.js +5 -1
- package/packages/agent-team/lib/index.js +162 -113
- package/packages/agent-team/lib/ledger.js +496 -169
- package/packages/agent-team/lib/member-context.js +18 -1
- package/packages/agent-team/lib/member-runtime.js +48 -9
- package/packages/agent-team/lib/mentions.js +80 -0
- package/packages/agent-team/lib/spec.js +20 -0
- package/packages/agent-team/lib/typert.host.js +241 -46
- package/packages/agent-team/lib/typert.remote-client.d.ts +7 -3
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
- package/packages/agent-team/lib/typert.remote-client.js +178 -28
- package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -1
- package/packages/agent-team/lib/types/index.d.ts +33 -14
- package/packages/agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/types/ledger.d.ts +120 -18
- package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-context.d.ts +6 -0
- package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-runtime.d.ts +2 -1
- package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
- package/packages/agent-team/lib/types/mentions.d.ts +47 -0
- package/packages/agent-team/lib/types/mentions.d.ts.map +1 -0
- package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/entities.d.ts +3 -20
- package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/operations.d.ts +37 -1
- package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts +107 -25
- package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
- package/packages/agent-team/lib/types/vendor/storage-sqlite/index.d.ts +87 -0
- package/packages/agent-team/lib/types/vendor/storage-sqlite/index.d.ts.map +1 -0
- package/packages/agent-team/lib/types/vendor/storage-sqlite/schema.d.ts +59 -0
- package/packages/agent-team/lib/types/vendor/storage-sqlite/schema.d.ts.map +1 -0
- package/packages/agent-team/lib/types/vendor/storage-sqlite/unit.d.ts +57 -0
- package/packages/agent-team/lib/types/vendor/storage-sqlite/unit.d.ts.map +1 -0
- package/packages/agent-team/lib/vendor/storage-sqlite/index.js +139 -0
- package/packages/agent-team/lib/vendor/storage-sqlite/schema.js +120 -0
- package/packages/agent-team/lib/vendor/storage-sqlite/unit.js +132 -0
- package/packages/agent-team/preset/team-member/agent.cordis.yml +48 -8
- package/packages/client-agent-team/README.md +1 -1
- package/packages/client-agent-team/README.zh.md +1 -1
- package/packages/client-agent-team/lib/client.js +2123 -1406
- package/packages/client-agent-team/lib/client.js.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentImport.d.ts +12 -0
- package/packages/client-agent-team/lib/types/client/TeamAgentImport.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamAgentImport.js +69 -0
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts +3 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +31 -19
- package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts +18 -0
- package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamAvatarStack.js +22 -0
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts +3 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +111 -24
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +10 -3
- package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamComposer.js +11 -17
- package/packages/client-agent-team/lib/types/client/TeamConversation.js +1 -1
- package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts +20 -0
- package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamCountBadge.js +25 -0
- package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts +9 -5
- package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamInboxPage.js +92 -23
- package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts +43 -0
- package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamMemberRow.js +27 -0
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts +2 -2
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.js +2 -2
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts +1 -1
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +21 -31
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +29 -11
- package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/index.js +11 -5
- package/packages/client-agent-team/lib/types/client/locales.d.ts +54 -16
- package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/locales.js +54 -16
- package/packages/client-agent-team/lib/types/client/slots.d.ts +3 -2
- package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-changes.d.ts +15 -25
- package/packages/client-agent-team/lib/types/client/team-changes.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-changes.js +82 -80
- package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +45 -10
- package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-formatters.js +71 -30
- package/packages/tool-agent-team/README.md +3 -3
- package/packages/tool-agent-team/README.zh.md +3 -3
- package/packages/tool-agent-team/lib/host-access.js +19 -0
- package/packages/tool-agent-team/lib/index.js +71 -49
- package/packages/tool-agent-team/lib/types/host-access.d.ts +10 -0
- package/packages/tool-agent-team/lib/types/host-access.d.ts.map +1 -1
- package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/progress-nudge.js +0 -328
- package/packages/agent-team/lib/types/progress-nudge.d.ts +0 -136
- package/packages/agent-team/lib/types/progress-nudge.d.ts.map +0 -1
- package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts +0 -3
- package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts.map +0 -1
- package/packages/client-agent-team/lib/types/client/TeamSettings.js +0 -5
- package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts +0 -41
- package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts.map +0 -1
- package/packages/client-agent-team/lib/types/client/scope-coverage.js +0 -64
|
@@ -1,46 +1,59 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
3
|
import { formatTeamTimestamp } from "./time-format.js";
|
|
4
|
+
import { resolveBodyMentions } from "./mentions.js";
|
|
4
5
|
/** Stable Human Member identity shared by every replay of one dshHome Team. */
|
|
5
6
|
export const AGENT_TEAM_HUMAN_MEMBER_ID = 'member:human';
|
|
7
|
+
/**
|
|
8
|
+
* The handle the Human is addressed by in Message bodies. The body parser and
|
|
9
|
+
* the Member directory both read this one constant, so a configurable display
|
|
10
|
+
* name has a single place to land instead of a hard-coded literal per surface.
|
|
11
|
+
*/
|
|
12
|
+
export const AGENT_TEAM_HUMAN_HANDLE = 'human';
|
|
6
13
|
/** Idempotency identity of the one Host bootstrap operation. */
|
|
7
14
|
export const AGENT_TEAM_INITIALIZE_REQUEST_ID = 'agent-team:initialize:v1';
|
|
8
15
|
const HUMAN_ACTOR = Object.freeze({
|
|
9
16
|
kind: 'human',
|
|
10
17
|
memberId: AGENT_TEAM_HUMAN_MEMBER_ID,
|
|
11
|
-
handle:
|
|
18
|
+
handle: AGENT_TEAM_HUMAN_HANDLE,
|
|
12
19
|
});
|
|
13
20
|
/** Resolve the one Human authority owned by this Team. */
|
|
14
21
|
export function agentTeamHumanActor() {
|
|
15
22
|
return HUMAN_ACTOR;
|
|
16
23
|
}
|
|
17
24
|
function emptyProjection() {
|
|
18
|
-
return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), memberships: new Map(),
|
|
25
|
+
return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), participations: new Map(), memberships: new Map(),
|
|
19
26
|
claims: new Map(), tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
|
|
20
27
|
orderedFacts: [], factsByThread: new Map(), channelRefByThread: new Map(), mentionsByMessage: new Map(), messageCountByThread: new Map(),
|
|
21
28
|
messagesByRef: new Map(),
|
|
22
29
|
attentionByThread: new Map(), previousSessions: new Map(), rolloverSeeds: new Map(),
|
|
23
30
|
anchorByThread: new Map(), taskNumberByTask: new Map(), taskCountByChannel: new Map(), attentionThreadsByMember: new Map(),
|
|
31
|
+
threadsByWriter: new Map(),
|
|
24
32
|
directMarkersByMember: new Map(), activityMarkersByMember: new Map(), observationsByThread: new Map() };
|
|
25
33
|
}
|
|
26
|
-
const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
|
|
27
|
-
progress: Object.freeze([]),
|
|
28
|
-
claim: Object.freeze([]),
|
|
29
|
-
});
|
|
30
34
|
/** Exhaustiveness guard for the closed operation union: adding a kind must update every dispatch. */
|
|
31
35
|
function assertUnhandledKind(operation) {
|
|
32
36
|
throw new Error(`agent-team ledger does not handle operation kind '${operation.kind}'`);
|
|
33
37
|
}
|
|
34
38
|
/**
|
|
35
|
-
* Opening-line preview for a
|
|
36
|
-
*
|
|
37
|
-
* the
|
|
38
|
-
*
|
|
39
|
+
* Opening-line preview for a Human Inbox row: the Thread anchor's first line,
|
|
40
|
+
* trimmed, then capped at 120 characters with an explicit mark — the same bound
|
|
41
|
+
* the Thread page applies to its Task title, so the row and the page it opens
|
|
42
|
+
* never disagree about what a Thread is about.
|
|
39
43
|
*/
|
|
40
44
|
function boundedInboxPreview(body) {
|
|
41
45
|
const firstLine = body.split('\n', 1)[0]?.trim() ?? '';
|
|
42
46
|
return firstLine.length > 120 ? `${firstLine.slice(0, 119)}…` : firstLine;
|
|
43
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* How many Threads one Workspace's 「最近活跃」 slice may carry. It bounds what
|
|
50
|
+
* the Host hands over, not what the reader sees: the Client merges every
|
|
51
|
+
* visible Workspace's slice and trims the merged list to its own visible bound
|
|
52
|
+
* (`RECENT_ROWS_LIMIT`), which must stay at or below this one so no Workspace
|
|
53
|
+
* is cut short before the merge. The unread slice keeps its own `limit`
|
|
54
|
+
* request field.
|
|
55
|
+
*/
|
|
56
|
+
const RECENT_INBOX_LIMIT = 10;
|
|
44
57
|
/**
|
|
45
58
|
* Whether one operation's Inbox delta would change nothing. A read that finds
|
|
46
59
|
* no unread fact for its reader and no marker to consume produces exactly this
|
|
@@ -252,7 +265,8 @@ export class AgentTeamLedger {
|
|
|
252
265
|
if (handle === '')
|
|
253
266
|
throw new Error('member handle must not be empty');
|
|
254
267
|
if (handle !== prior.handle)
|
|
255
|
-
|
|
268
|
+
for (const workspaceId of this.workspacesOf(prior.memberId))
|
|
269
|
+
this.assertHandleAvailable(workspaceId, handle, prior.memberId);
|
|
256
270
|
this.assertModelSelection(request.model);
|
|
257
271
|
this.assertCapabilities(request.capabilities);
|
|
258
272
|
// An absent model or capabilities field must CLEAR any override
|
|
@@ -439,80 +453,6 @@ export class AgentTeamLedger {
|
|
|
439
453
|
}
|
|
440
454
|
return active.sort((a, b) => a.claimRef.localeCompare(b.claimRef));
|
|
441
455
|
}
|
|
442
|
-
/**
|
|
443
|
-
* Current progress-nudge candidacy for one Member: threads whose Thread
|
|
444
|
-
* progress reminder applies (active Claim, or following a taskless Thread)
|
|
445
|
-
* and tasks where the Claim reminder applies (following a still-`todo` Task
|
|
446
|
-
* with no Claim history for this Member). A deep read-only projection — the
|
|
447
|
-
* caller never re-derives attention, claim, task-resolution, or channel
|
|
448
|
-
* archive state. Archived channels and resolved tasks produce no candidacy;
|
|
449
|
-
* results are ordered by Thread revision descending, then threadRef.
|
|
450
|
-
*/
|
|
451
|
-
progressNudgeTargets(memberId) {
|
|
452
|
-
const member = this.state.members.get(memberId);
|
|
453
|
-
if (member === undefined || member.state !== 'enabled')
|
|
454
|
-
return EMPTY_PROGRESS_NUDGE_TARGETS;
|
|
455
|
-
const progress = [];
|
|
456
|
-
const claim = [];
|
|
457
|
-
const seenProgress = new Set();
|
|
458
|
-
for (const claimRecord of this.state.claims.values()) {
|
|
459
|
-
if (claimRecord.owner !== memberId || claimRecord.state !== 'active')
|
|
460
|
-
continue;
|
|
461
|
-
const task = this.state.tasks.get(claimRecord.taskRef);
|
|
462
|
-
const thread = this.state.threads.get(claimRecord.threadRef);
|
|
463
|
-
if (task === undefined || thread === undefined || task.resolution !== 'open')
|
|
464
|
-
continue;
|
|
465
|
-
if (this.state.channels.get(task.channelRef)?.state === 'archived')
|
|
466
|
-
continue;
|
|
467
|
-
if (seenProgress.has(claimRecord.threadRef))
|
|
468
|
-
continue;
|
|
469
|
-
seenProgress.add(claimRecord.threadRef);
|
|
470
|
-
progress.push({ reason: 'active-claim', threadRef: claimRecord.threadRef, taskRef: claimRecord.taskRef });
|
|
471
|
-
}
|
|
472
|
-
for (const [threadRef, followers] of this.state.attentionByThread) {
|
|
473
|
-
if (!followers.has(memberId))
|
|
474
|
-
continue;
|
|
475
|
-
const thread = this.state.threads.get(threadRef);
|
|
476
|
-
if (thread === undefined)
|
|
477
|
-
continue;
|
|
478
|
-
const channelRef = this.state.channelRefByThread.get(threadRef);
|
|
479
|
-
if (channelRef === undefined || this.state.channels.get(channelRef)?.state === 'archived')
|
|
480
|
-
continue;
|
|
481
|
-
if (thread.taskRef === undefined) {
|
|
482
|
-
if (!seenProgress.has(threadRef)) {
|
|
483
|
-
seenProgress.add(threadRef);
|
|
484
|
-
progress.push({ reason: 'taskless-follower', threadRef });
|
|
485
|
-
}
|
|
486
|
-
continue;
|
|
487
|
-
}
|
|
488
|
-
// Claim reminder: only still-`todo` tasks with zero lifetime claims by
|
|
489
|
-
// this Member; once claimed (any state) the Member moved into the
|
|
490
|
-
// progress-reminder regime.
|
|
491
|
-
if (this.hasLifetimeClaimOnTask(memberId, thread.taskRef))
|
|
492
|
-
continue;
|
|
493
|
-
const task = this.state.tasks.get(thread.taskRef);
|
|
494
|
-
if (task === undefined || task.resolution !== 'open' || this.deriveTaskStatus(task.taskRef, this.state.claims.values()) !== 'todo')
|
|
495
|
-
continue;
|
|
496
|
-
claim.push({ threadRef, taskRef: thread.taskRef });
|
|
497
|
-
}
|
|
498
|
-
const byThreadRevision = (left, right) => {
|
|
499
|
-
const leftRevision = this.state.threads.get(left.threadRef)?.revision ?? 0;
|
|
500
|
-
const rightRevision = this.state.threads.get(right.threadRef)?.revision ?? 0;
|
|
501
|
-
return rightRevision - leftRevision || (left.threadRef < right.threadRef ? -1 : left.threadRef > right.threadRef ? 1 : 0);
|
|
502
|
-
};
|
|
503
|
-
return Object.freeze({
|
|
504
|
-
progress: Object.freeze(progress.sort(byThreadRevision)),
|
|
505
|
-
claim: Object.freeze(claim.sort(byThreadRevision)),
|
|
506
|
-
});
|
|
507
|
-
}
|
|
508
|
-
/** Whether this Member has ever held a Claim on the Task, in any state. */
|
|
509
|
-
hasLifetimeClaimOnTask(memberId, taskRef) {
|
|
510
|
-
for (const claim of this.state.claims.values()) {
|
|
511
|
-
if (claim.owner === memberId && claim.taskRef === taskRef)
|
|
512
|
-
return true;
|
|
513
|
-
}
|
|
514
|
-
return false;
|
|
515
|
-
}
|
|
516
456
|
listMembers() {
|
|
517
457
|
return Object.freeze([...this.state.members.values()]);
|
|
518
458
|
}
|
|
@@ -526,8 +466,8 @@ export class AgentTeamLedger {
|
|
|
526
466
|
this.assertHumanActor(request.actor);
|
|
527
467
|
const channel = this.requireActiveChannel(request.workspaceId, request.channelRef);
|
|
528
468
|
const member = this.requireMember(request.memberId);
|
|
529
|
-
if (member.
|
|
530
|
-
throw new Error('Member
|
|
469
|
+
if (!this.participatesIn(member.memberId, request.workspaceId))
|
|
470
|
+
throw new Error('Agent Member does not participate in the Channel\'s Workspace');
|
|
531
471
|
if (member.state !== 'enabled')
|
|
532
472
|
throw new Error(`Agent Member '${member.memberId}' is ${member.state}; only enabled Members can join a Channel`);
|
|
533
473
|
if (this.isChannelMember(channel.channelRef, member.memberId))
|
|
@@ -551,7 +491,7 @@ export class AgentTeamLedger {
|
|
|
551
491
|
this.assertHumanActor(request.actor);
|
|
552
492
|
const channel = this.requireChannel(request.workspaceId, request.channelRef);
|
|
553
493
|
const member = this.requireMember(request.memberId);
|
|
554
|
-
if (member.
|
|
494
|
+
if (!this.participatesIn(member.memberId, channel.workspaceId) || !this.isChannelMember(channel.channelRef, member.memberId)) {
|
|
555
495
|
throw new Error(`Agent Member '${member.memberId}' is not a member of Channel '${channel.channelRef}'`);
|
|
556
496
|
}
|
|
557
497
|
const threadRefs = this.channelThreadRefs(channel.channelRef);
|
|
@@ -576,6 +516,86 @@ export class AgentTeamLedger {
|
|
|
576
516
|
return this.committed(this.channelMemberRemovalResult(operation));
|
|
577
517
|
});
|
|
578
518
|
}
|
|
519
|
+
/**
|
|
520
|
+
* Join one Agent Member to one additional Workspace. Participation is a
|
|
521
|
+
* pure relation — no Session is created or moved; the Member's Session
|
|
522
|
+
* stays rooted in its default Workspace and collaboration in the joined
|
|
523
|
+
* Workspace is ledger work addressed by the workspaceId.
|
|
524
|
+
*/
|
|
525
|
+
joinWorkspace(request) {
|
|
526
|
+
return this.enqueue(async () => {
|
|
527
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
528
|
+
if (existing !== undefined) {
|
|
529
|
+
this.assertSameWorkspaceJoin(existing, request);
|
|
530
|
+
return this.resolved(this.workspaceJoinResult(existing));
|
|
531
|
+
}
|
|
532
|
+
this.assertHumanActor(request.actor);
|
|
533
|
+
const member = this.requireMember(request.memberId);
|
|
534
|
+
if (member.state === 'inactive')
|
|
535
|
+
throw new Error(`Agent Member '${member.memberId}' is inactive and cannot join a Workspace`);
|
|
536
|
+
if (member.state === 'archived')
|
|
537
|
+
throw new Error(`Agent Member '${member.memberId}' is archived and cannot join a Workspace`);
|
|
538
|
+
if (this.participatesIn(member.memberId, request.workspaceId))
|
|
539
|
+
throw new Error(`Agent Member '${member.memberId}' already participates in Workspace '${request.workspaceId}'`);
|
|
540
|
+
// Handles resolve per Workspace: a second live Member with the same
|
|
541
|
+
// handle in the target would make mentions ambiguous there.
|
|
542
|
+
this.assertHandleAvailable(request.workspaceId, member.handle);
|
|
543
|
+
const operation = Object.freeze({
|
|
544
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/member-workspace-joined',
|
|
545
|
+
data: Object.freeze({ workspaceId: request.workspaceId, memberId: member.memberId }),
|
|
546
|
+
});
|
|
547
|
+
await this.table.put(operation.operationId, operation);
|
|
548
|
+
this.apply(operation);
|
|
549
|
+
return this.committed(this.workspaceJoinResult(operation));
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Withdraw one Agent Member from one of its non-default Workspaces. The
|
|
554
|
+
* default Workspace cannot be left — ending it is the archive path. Every
|
|
555
|
+
* active Claim the Member holds on the Workspace's Threads releases with
|
|
556
|
+
* public Activities, its Attention and markers on those Threads clear, and
|
|
557
|
+
* its Channel memberships there end; identity, Session, and remaining
|
|
558
|
+
* participations are untouched.
|
|
559
|
+
*/
|
|
560
|
+
leaveWorkspace(request) {
|
|
561
|
+
return this.enqueue(async () => {
|
|
562
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
563
|
+
if (existing !== undefined) {
|
|
564
|
+
this.assertSameWorkspaceLeave(existing, request);
|
|
565
|
+
return this.resolved(this.workspaceLeaveResult(existing));
|
|
566
|
+
}
|
|
567
|
+
this.assertHumanActor(request.actor);
|
|
568
|
+
const member = this.requireMember(request.memberId);
|
|
569
|
+
if (member.state === 'inactive')
|
|
570
|
+
throw new Error(`Agent Member '${member.memberId}' is already inactive`);
|
|
571
|
+
if (member.state === 'archived')
|
|
572
|
+
throw new Error(`Agent Member '${member.memberId}' is already archived`);
|
|
573
|
+
if (request.workspaceId === member.workspaceId)
|
|
574
|
+
throw new Error(`Agent Member '${member.memberId}' cannot leave its default Workspace '${request.workspaceId}'; archive the Member instead`);
|
|
575
|
+
if (!this.participatesIn(member.memberId, request.workspaceId))
|
|
576
|
+
throw new Error(`Agent Member '${member.memberId}' does not participate in Workspace '${request.workspaceId}'`);
|
|
577
|
+
const threadRefs = this.workspaceThreadRefs(request.workspaceId);
|
|
578
|
+
const releasedClaims = [...this.state.claims.values()]
|
|
579
|
+
.filter(claim => claim.owner === member.memberId && claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
580
|
+
.map(claim => Object.freeze({ ...claim, state: 'released' }));
|
|
581
|
+
const projectedClaims = new Map(this.state.claims);
|
|
582
|
+
for (const claim of releasedClaims)
|
|
583
|
+
projectedClaims.set(claim.claimRef, claim);
|
|
584
|
+
const sequence = this.nextSequence();
|
|
585
|
+
const activities = this.releaseSummaries(releasedClaims, member.memberId, sequence);
|
|
586
|
+
const threads = this.threadsForActivities(activities);
|
|
587
|
+
const tasks = this.tasksForClaims(releasedClaims, projectedClaims);
|
|
588
|
+
const inbox = this.removeMemberThreadInbox(member.memberId, threadRefs);
|
|
589
|
+
const operation = Object.freeze({
|
|
590
|
+
...this.operationBase(request, sequence), kind: 'team/member-workspace-left',
|
|
591
|
+
data: Object.freeze({ workspaceId: request.workspaceId, memberId: member.memberId,
|
|
592
|
+
claims: Object.freeze(releasedClaims), activities, tasks, threads, inbox }),
|
|
593
|
+
});
|
|
594
|
+
await this.table.put(operation.operationId, operation);
|
|
595
|
+
this.apply(operation);
|
|
596
|
+
return this.committed(this.workspaceLeaveResult(operation));
|
|
597
|
+
});
|
|
598
|
+
}
|
|
579
599
|
/**
|
|
580
600
|
* Archive one Channel: hidden from every surface, facts kept recoverable.
|
|
581
601
|
* Every active Claim on the Channel's Threads releases with one public
|
|
@@ -620,12 +640,6 @@ export class AgentTeamLedger {
|
|
|
620
640
|
}
|
|
621
641
|
sendMessage(request) {
|
|
622
642
|
return this.enqueue(async () => {
|
|
623
|
-
const recipients = this.normalizeRecipients(request.actor, request.recipients);
|
|
624
|
-
const existing = this.state.byRequest.get(request.requestId);
|
|
625
|
-
if (existing !== undefined) {
|
|
626
|
-
this.assertSameMessage(existing, request, recipients);
|
|
627
|
-
return this.resolved(this.messageResult(existing));
|
|
628
|
-
}
|
|
629
643
|
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
630
644
|
const channel = this.requireActiveChannel(request.workspaceId, request.channelRef);
|
|
631
645
|
if (actor.kind === 'member')
|
|
@@ -633,6 +647,15 @@ export class AgentTeamLedger {
|
|
|
633
647
|
const body = request.body.trim();
|
|
634
648
|
if (body === '')
|
|
635
649
|
throw new Error('message body must not be empty');
|
|
650
|
+
// Recipients are resolved before the request-id lookup: the stored
|
|
651
|
+
// operation carries the merged set, so a retry has to re-derive the same
|
|
652
|
+
// list for the collision check to prove it is the same request.
|
|
653
|
+
const recipients = this.mergeBodyMentions(actor.memberId, channel.channelRef, body, this.normalizeRecipients(request.actor, request.recipients));
|
|
654
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
655
|
+
if (existing !== undefined) {
|
|
656
|
+
this.assertSameMessage(existing, request, recipients);
|
|
657
|
+
return this.resolved(this.messageResult(existing));
|
|
658
|
+
}
|
|
636
659
|
this.assertMentionTargets(channel, recipients);
|
|
637
660
|
// Top-level Task creation is open to every actor: mentioned Members join
|
|
638
661
|
// the new Thread as followers. Only existing-Thread invitations stay
|
|
@@ -667,32 +690,38 @@ export class AgentTeamLedger {
|
|
|
667
690
|
}
|
|
668
691
|
reply(request) {
|
|
669
692
|
return this.enqueue(async () => {
|
|
670
|
-
const recipients = this.normalizeRecipients(request.actor, request.recipients);
|
|
671
|
-
const existing = this.state.byRequest.get(request.requestId);
|
|
672
|
-
if (existing !== undefined) {
|
|
673
|
-
this.assertSameReply(existing, request, recipients);
|
|
674
|
-
return this.resolved(this.replyResult(existing));
|
|
675
|
-
}
|
|
676
693
|
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
677
694
|
const { task, thread, channelRef } = this.threadContextForActor(actor, request.workspaceId, request);
|
|
678
695
|
const body = request.body.trim();
|
|
679
696
|
if (body === '')
|
|
680
697
|
throw new Error('message body must not be empty');
|
|
681
|
-
this.
|
|
698
|
+
const channel = this.requireChannel(request.workspaceId, channelRef);
|
|
699
|
+
const recipients = this.mergeBodyMentions(actor.memberId, channelRef, body, this.normalizeRecipients(actor, request.recipients));
|
|
700
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
701
|
+
if (existing !== undefined) {
|
|
702
|
+
this.assertSameReply(existing, request, recipients);
|
|
703
|
+
return this.resolved(this.replyResult(existing, this.undeliverableRecipients(actor, thread.threadRef, recipients)));
|
|
704
|
+
}
|
|
705
|
+
this.assertMentionTargets(channel, recipients);
|
|
682
706
|
const deferred = this.deferredThreadWrite(actor.memberId, task, thread, request.baseRevision);
|
|
683
707
|
if (deferred !== undefined)
|
|
684
708
|
return this.resolved(deferred);
|
|
685
709
|
if (task?.resolution === 'closed')
|
|
686
710
|
throw new Error(`Task '${task.taskRef}' is closed; reopen it before replying`);
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
711
|
+
// An Agent cannot invite into an existing Thread, so a body mention the
|
|
712
|
+
// Thread has never carried is dropped and reported back rather than
|
|
713
|
+
// failing the send. The Human keeps the confirmation step, which is what
|
|
714
|
+
// actually grants the invitation.
|
|
715
|
+
const undelivered = this.undeliverableRecipients(actor, thread.threadRef, recipients);
|
|
716
|
+
const delivered = undelivered.length === 0 ? recipients : recipients.filter(memberId => !undelivered.includes(memberId));
|
|
717
|
+
const unfollowedAgents = delivered.filter(memberId => this.state.members.has(memberId) && !this.isFollowing(thread.threadRef, memberId));
|
|
718
|
+
// A Member that once took part in this Thread is delivered to directly:
|
|
719
|
+
// re-joining a Thread it already belongs to is not an invitation.
|
|
720
|
+
if (unfollowedAgents.length > 0 && actor.kind === 'human' && request.confirmationToken === undefined) {
|
|
721
|
+
return this.resolved(this.issueConfirmation(request.actor, request.workspaceId, channelRef, body, delivered, task, thread));
|
|
693
722
|
}
|
|
694
723
|
if (request.confirmationToken !== undefined) {
|
|
695
|
-
this.consumeConfirmation(request.confirmationToken, request.actor, request.workspaceId, channelRef, task, thread, body,
|
|
724
|
+
this.consumeConfirmation(request.confirmationToken, request.actor, request.workspaceId, channelRef, task, thread, body, delivered);
|
|
696
725
|
}
|
|
697
726
|
const sequence = this.nextSequence();
|
|
698
727
|
const base = this.operationBase(request, sequence);
|
|
@@ -704,15 +733,15 @@ export class AgentTeamLedger {
|
|
|
704
733
|
});
|
|
705
734
|
const nextThread = Object.freeze({ ...thread, revision: sequence });
|
|
706
735
|
const started = unfollowedAgents.map(memberId => this.startAttention(memberId, thread.threadRef, sequence));
|
|
707
|
-
const inbox = this.messageInboxDelta(message, request.actor.memberId,
|
|
736
|
+
const inbox = this.messageInboxDelta(message, request.actor.memberId, delivered, started);
|
|
708
737
|
const operation = Object.freeze({
|
|
709
738
|
...base, kind: 'team/thread-replied',
|
|
710
739
|
data: Object.freeze({ workspaceId: request.workspaceId, baseRevision: request.baseRevision,
|
|
711
|
-
mentions:
|
|
740
|
+
mentions: delivered, message, ...(task === undefined ? {} : { task }), thread: nextThread, inbox }),
|
|
712
741
|
});
|
|
713
742
|
await this.table.put(operation.operationId, operation);
|
|
714
743
|
this.apply(operation);
|
|
715
|
-
return this.committed(this.replyResult(operation));
|
|
744
|
+
return this.committed(this.replyResult(operation, undelivered));
|
|
716
745
|
});
|
|
717
746
|
}
|
|
718
747
|
promoteThread(request) {
|
|
@@ -1027,7 +1056,7 @@ export class AgentTeamLedger {
|
|
|
1027
1056
|
if (body === '')
|
|
1028
1057
|
throw new Error('DM body must not be empty');
|
|
1029
1058
|
const recipient = this.requireMember(request.recipientMemberId);
|
|
1030
|
-
if (recipient.
|
|
1059
|
+
if (!this.participatesIn(recipient.memberId, request.workspaceId))
|
|
1031
1060
|
throw new Error(`Agent Member '${recipient.memberId}' is not in Workspace '${request.workspaceId}'`);
|
|
1032
1061
|
if (recipient.state !== 'enabled')
|
|
1033
1062
|
throw new Error(`Agent Member '${recipient.memberId}' is ${recipient.state}; DM delivery requires an enabled Member`);
|
|
@@ -1082,68 +1111,169 @@ export class AgentTeamLedger {
|
|
|
1082
1111
|
}
|
|
1083
1112
|
inbox(actor, request) {
|
|
1084
1113
|
const authorized = this.assertActorForWorkspace(actor, request.workspaceId);
|
|
1085
|
-
|
|
1114
|
+
return this.inboxForWorkspaces(authorized, [request.workspaceId], request.limit);
|
|
1115
|
+
}
|
|
1116
|
+
memberInbox(actor, request) {
|
|
1117
|
+
const workspaces = this.workspacesOf(actor.memberId);
|
|
1118
|
+
for (const workspaceId of workspaces)
|
|
1119
|
+
this.assertActorForWorkspace(actor, workspaceId);
|
|
1120
|
+
return this.inboxForWorkspaces(actor, workspaces, request.limit);
|
|
1121
|
+
}
|
|
1122
|
+
inboxForWorkspaces(authorized, workspaceIds, requestedLimit) {
|
|
1123
|
+
const limit = requestedLimit ?? 50;
|
|
1086
1124
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
1087
1125
|
throw new Error('inbox limit must be an integer between 1 and 100');
|
|
1088
|
-
//
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
//
|
|
1094
|
-
|
|
1095
|
-
|
|
1126
|
+
// One unread slice serves every reader: Threads the reader follows or
|
|
1127
|
+
// holds a marker on, admitted whenever they hold an unread fact — mentions
|
|
1128
|
+
// are counted inside it (`directCount`), never a separate admission rule.
|
|
1129
|
+
// Every row carries its rendering material — Channel display name, Task
|
|
1130
|
+
// ordinal, opening line, and the person the row's instant came from — so the
|
|
1131
|
+
// Client renders a row without a Channel or Member view per row. Carrying
|
|
1132
|
+
// the opening line on the agent-facing projection is
|
|
1133
|
+
// safe: model-visible notifications read the refs and the facts, never this.
|
|
1134
|
+
// A Human reader additionally receives the separate 「最近活跃」 slice below;
|
|
1135
|
+
// `items` stays exactly the unread queue for every reader, so the badge
|
|
1136
|
+
// totals and the agent-facing `team_inbox` result cannot drift with it.
|
|
1137
|
+
const taskNumbers = new Map(workspaceIds.flatMap(workspaceId => [...this.taskNumbers(workspaceId)]));
|
|
1096
1138
|
const items = [];
|
|
1097
|
-
|
|
1139
|
+
const unreadThreads = new Set();
|
|
1140
|
+
for (const threadRef of this.inboxCandidateThreads(authorized.memberId)) {
|
|
1098
1141
|
const thread = this.state.threads.get(threadRef);
|
|
1099
1142
|
if (thread === undefined)
|
|
1100
1143
|
continue;
|
|
1101
1144
|
const channelRef = this.channelRefForThread(thread.threadRef);
|
|
1102
1145
|
if (channelRef === undefined)
|
|
1103
1146
|
continue;
|
|
1104
|
-
|
|
1147
|
+
const channel = this.state.channels.get(channelRef);
|
|
1148
|
+
if (channel === undefined || !workspaceIds.includes(channel.workspaceId))
|
|
1105
1149
|
continue;
|
|
1150
|
+
// Archived Channels do not exist on Team API surfaces: their Threads
|
|
1151
|
+
// reach neither Inbox slice, matching ref resolution and the feed.
|
|
1152
|
+
if (channel.state === 'archived')
|
|
1153
|
+
continue;
|
|
1154
|
+
const workspaceId = channel.workspaceId;
|
|
1106
1155
|
if (authorized.kind === 'member' && !this.isChannelMember(channelRef, authorized.memberId))
|
|
1107
1156
|
continue;
|
|
1108
1157
|
const unread = this.unreadFor(authorized.memberId, thread.threadRef);
|
|
1109
1158
|
if (unread.length === 0)
|
|
1110
1159
|
continue;
|
|
1160
|
+
unreadThreads.add(thread.threadRef);
|
|
1111
1161
|
const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
|
|
1112
1162
|
const directCount = unread.filter(item => item.direct).length;
|
|
1113
|
-
if (directOnly && directCount === 0)
|
|
1114
|
-
continue;
|
|
1115
1163
|
const attention = this.attentionFor(authorized.memberId, thread.threadRef);
|
|
1116
1164
|
// Same snapshot, same source as newestSequence: the instant hangs off
|
|
1117
1165
|
// the newest unread fact itself, never a second lookup that could
|
|
1118
1166
|
// observe a different commit between the two reads.
|
|
1119
1167
|
const newest = unread.at(-1).fact;
|
|
1120
|
-
const taskNumber =
|
|
1121
|
-
items.push(Object.freeze({ channelRef,
|
|
1122
|
-
|
|
1168
|
+
const taskNumber = task === undefined ? undefined : taskNumbers.get(task.taskRef);
|
|
1169
|
+
items.push(Object.freeze({ workspaceId, channelRef,
|
|
1170
|
+
channelName: this.state.channels.get(channelRef)?.name ?? '',
|
|
1123
1171
|
...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
|
|
1124
1172
|
unreadCount: unread.length, directCount,
|
|
1125
|
-
|
|
1126
|
-
newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt,
|
|
1173
|
+
previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body),
|
|
1174
|
+
newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt,
|
|
1175
|
+
newestActor: this.inboxActorFor(newest), claimOwners: this.liveClaimOwners(task),
|
|
1176
|
+
...(attention === undefined ? {} : { attention }) }));
|
|
1127
1177
|
}
|
|
1128
1178
|
items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
|
|
1129
1179
|
const selected = items.slice(0, limit);
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1180
|
+
const recent = authorized.kind === 'human'
|
|
1181
|
+
? this.recentInboxItems(authorized.memberId, workspaceIds[0], unreadThreads, taskNumbers)
|
|
1182
|
+
: Object.freeze([]);
|
|
1183
|
+
return Object.freeze({ items: Object.freeze(selected), recent,
|
|
1184
|
+
totalUnreadCount: items.reduce((sum, item) => sum + item.unreadCount, 0),
|
|
1134
1185
|
totalDirectCount: items.reduce((sum, item) => sum + item.directCount, 0) });
|
|
1135
1186
|
}
|
|
1187
|
+
/**
|
|
1188
|
+
* The one person a row names: whoever committed the fact that row's instant
|
|
1189
|
+
* came from — a Message's sender, an activity's actor — resolved to the handle
|
|
1190
|
+
* the row draws. Resolving it here is what spares every Client row a Member
|
|
1191
|
+
* view of its own, and a Member the roster no longer names still reads as
|
|
1192
|
+
* themselves through their raw id rather than as nobody.
|
|
1193
|
+
*/
|
|
1194
|
+
inboxActorFor(fact) {
|
|
1195
|
+
return this.memberActor(fact.kind === 'message' ? fact.message.sender : fact.activity.actor);
|
|
1196
|
+
}
|
|
1197
|
+
/** One Member as a row draws them: the id carries the identity hue, the handle the initial. */
|
|
1198
|
+
memberActor(memberId) {
|
|
1199
|
+
return Object.freeze({ memberId, name: this.state.members.get(memberId)?.handle ?? memberId });
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* The people a Task still has on it, resolved the way a row draws them: owners
|
|
1203
|
+
* of its live Claims, in claim order, deduped. A released Claim is not work,
|
|
1204
|
+
* and a done or closed Task keeps its Claims as history rather than as
|
|
1205
|
+
* presence — deliberately the same rule the Channel feed applies to the same
|
|
1206
|
+
* Task, so 「谁在这个 Task 上」 never acquires a second definition.
|
|
1207
|
+
*/
|
|
1208
|
+
liveClaimOwners(task) {
|
|
1209
|
+
if (task === undefined || (task.status !== 'in_progress' && task.status !== 'in_review'))
|
|
1210
|
+
return Object.freeze([]);
|
|
1211
|
+
const owners = [];
|
|
1212
|
+
for (const claim of this.claimsForTask(task.taskRef)) {
|
|
1213
|
+
if (claim.state === 'released' || owners.includes(claim.owner))
|
|
1214
|
+
continue;
|
|
1215
|
+
owners.push(claim.owner);
|
|
1216
|
+
}
|
|
1217
|
+
return Object.freeze(owners.map(owner => this.memberActor(owner)));
|
|
1218
|
+
}
|
|
1219
|
+
/**
|
|
1220
|
+
* The Human Inbox's 「最近活跃」 slice: the Threads this reader has written in
|
|
1221
|
+
* — the durable way back into work instead of a mention-only queue.
|
|
1222
|
+
* Participation is the whole admission rule: a reader who replied to somebody
|
|
1223
|
+
* else's Thread is here whether or not they follow it, and so is one who
|
|
1224
|
+
* started a Thread nobody has answered yet, because starting one is writing
|
|
1225
|
+
* its anchor. Attention deliberately plays no part — it decides what notifies
|
|
1226
|
+
* a reader (a reply does not implicitly follow a Thread), and reading it here
|
|
1227
|
+
* as well is what once hid every Thread a reader had replied to without
|
|
1228
|
+
* following. A Thread the reader later unfollowed stays: they did take part
|
|
1229
|
+
* in it, and unfollowing stops the notifications, not the record. Every
|
|
1230
|
+
* Thread still holding unread is excluded because the queue above already
|
|
1231
|
+
* carries it, and the slice is newest-activity first, bounded by
|
|
1232
|
+
* `RECENT_INBOX_LIMIT`. Rows are the same shape as unread rows with every
|
|
1233
|
+
* count at zero — they are the same row to render.
|
|
1234
|
+
*/
|
|
1235
|
+
recentInboxItems(memberId, workspaceId, unreadThreads, taskNumbers) {
|
|
1236
|
+
const recent = [];
|
|
1237
|
+
for (const threadRef of this.state.threadsByWriter.get(memberId) ?? []) {
|
|
1238
|
+
if (unreadThreads.has(threadRef))
|
|
1239
|
+
continue;
|
|
1240
|
+
const thread = this.state.threads.get(threadRef);
|
|
1241
|
+
if (thread === undefined)
|
|
1242
|
+
continue;
|
|
1243
|
+
const channelRef = this.channelRefForThread(thread.threadRef);
|
|
1244
|
+
if (channelRef === undefined)
|
|
1245
|
+
continue;
|
|
1246
|
+
const channel = this.state.channels.get(channelRef);
|
|
1247
|
+
if (channel === undefined || channel.workspaceId !== workspaceId)
|
|
1248
|
+
continue;
|
|
1249
|
+
// Archived Channels do not exist on Team API surfaces, so their Threads
|
|
1250
|
+
// are no way back into work either — participation ends at archival.
|
|
1251
|
+
if (channel.state === 'archived')
|
|
1252
|
+
continue;
|
|
1253
|
+
const facts = this.state.factsByThread.get(thread.threadRef) ?? [];
|
|
1254
|
+
const newest = facts.at(-1);
|
|
1255
|
+
if (newest === undefined)
|
|
1256
|
+
continue;
|
|
1257
|
+
const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
|
|
1258
|
+
const taskNumber = task === undefined ? undefined : taskNumbers.get(task.taskRef);
|
|
1259
|
+
recent.push(Object.freeze({ workspaceId, channelRef,
|
|
1260
|
+
channelName: this.state.channels.get(channelRef)?.name ?? '',
|
|
1261
|
+
...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
|
|
1262
|
+
unreadCount: 0, directCount: 0,
|
|
1263
|
+
previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body),
|
|
1264
|
+
newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt,
|
|
1265
|
+
newestActor: this.inboxActorFor(newest), claimOwners: this.liveClaimOwners(task) }));
|
|
1266
|
+
}
|
|
1267
|
+
recent.sort((left, right) => right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
|
|
1268
|
+
return Object.freeze(recent.slice(0, RECENT_INBOX_LIMIT));
|
|
1269
|
+
}
|
|
1136
1270
|
/**
|
|
1137
1271
|
* Threads that can hold unread facts for one reader, from the per-reader
|
|
1138
1272
|
* derived indexes: Attention follows plus direct and activity markers.
|
|
1139
1273
|
* Every unread source is covered — ordinary unread requires Attention, and
|
|
1140
|
-
* marker unread requires a marker — so no full Thread scan is needed.
|
|
1141
|
-
* direct-only slice needs only the direct-marker threads, since a row
|
|
1142
|
-
* requires at least one direct unread.
|
|
1274
|
+
* marker unread requires a marker — so no full Thread scan is needed.
|
|
1143
1275
|
*/
|
|
1144
|
-
inboxCandidateThreads(memberId
|
|
1145
|
-
if (directOnly)
|
|
1146
|
-
return this.state.directMarkersByMember.get(memberId)?.keys() ?? [];
|
|
1276
|
+
inboxCandidateThreads(memberId) {
|
|
1147
1277
|
const refs = new Set();
|
|
1148
1278
|
for (const source of [this.state.attentionThreadsByMember.get(memberId), this.state.directMarkersByMember.get(memberId), this.state.activityMarkersByMember.get(memberId)]) {
|
|
1149
1279
|
for (const threadRef of source?.keys() ?? [])
|
|
@@ -1154,9 +1284,10 @@ export class AgentTeamLedger {
|
|
|
1154
1284
|
/** Model-visible notification material derived from the recipient's current durable unread state. */
|
|
1155
1285
|
notificationFacts(memberId, request) {
|
|
1156
1286
|
const member = this.requireMember(memberId);
|
|
1157
|
-
if (
|
|
1287
|
+
if (request !== undefined && !this.participatesIn(memberId, request.workspaceId))
|
|
1158
1288
|
throw new Error('Member cannot inspect another Workspace');
|
|
1159
|
-
const
|
|
1289
|
+
const actor = { kind: 'member', memberId, handle: member.handle };
|
|
1290
|
+
const inbox = request === undefined ? this.memberInbox(actor, {}) : this.inbox(actor, request);
|
|
1160
1291
|
return Object.freeze(inbox.items.map(item => Object.freeze({ item,
|
|
1161
1292
|
facts: this.unreadFor(memberId, item.thread.threadRef) })));
|
|
1162
1293
|
}
|
|
@@ -1278,7 +1409,7 @@ export class AgentTeamLedger {
|
|
|
1278
1409
|
throw new Error('cursor must be a non-negative integer sequence');
|
|
1279
1410
|
if (memberId !== undefined) {
|
|
1280
1411
|
const member = this.requireMember(memberId);
|
|
1281
|
-
if (member.
|
|
1412
|
+
if (!this.participatesIn(member.memberId, request.workspaceId))
|
|
1282
1413
|
throw new Error('Member cannot view another Workspace');
|
|
1283
1414
|
}
|
|
1284
1415
|
if (request.channelRef !== undefined) {
|
|
@@ -1361,6 +1492,7 @@ export class AgentTeamLedger {
|
|
|
1361
1492
|
const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
|
|
1362
1493
|
const facts = this.state.factsByThread.get(thread.threadRef) ?? [];
|
|
1363
1494
|
return Object.freeze({ message, mentions: fact.mentions, ...(task === undefined ? {} : { task, taskNumber: taskNumbers.get(task.taskRef) ?? 0 }), thread,
|
|
1495
|
+
claimOwners: this.liveClaimOwners(task),
|
|
1364
1496
|
messageCount: this.state.messageCountByThread.get(thread.threadRef) ?? 0,
|
|
1365
1497
|
lastActivityAt: facts.at(-1)?.occurredAt ?? '' });
|
|
1366
1498
|
});
|
|
@@ -1368,6 +1500,8 @@ export class AgentTeamLedger {
|
|
|
1368
1500
|
const nextCursor = selected.length === 0 ? cursor : direction === 'before' ? selected[0].sequence : selected.at(-1).sequence;
|
|
1369
1501
|
return Object.freeze({
|
|
1370
1502
|
humanMemberId: initialization.data.humanMemberId,
|
|
1503
|
+
workspaces: Object.freeze(memberId === undefined ? [] : this.workspacesOfFrom(this.state, memberId)
|
|
1504
|
+
.map(workspaceId => Object.freeze({ workspaceId, default: workspaceId === this.state.members.get(memberId)?.workspaceId }))),
|
|
1371
1505
|
channels: Object.freeze(channels),
|
|
1372
1506
|
members: Object.freeze([...this.state.memberships.entries()].filter(([channelRef]) => channelRefs.has(channelRef)).flatMap(([channelRef, ids]) => [...ids].filter(id => {
|
|
1373
1507
|
const state = this.state.members.get(id)?.state;
|
|
@@ -1446,6 +1580,10 @@ export class AgentTeamLedger {
|
|
|
1446
1580
|
}
|
|
1447
1581
|
return scopes;
|
|
1448
1582
|
}
|
|
1583
|
+
/** One workspace change scope per Workspace the Member participates in — member-level commits wake every panel that lists it. */
|
|
1584
|
+
memberWorkspaceScopes(memberId) {
|
|
1585
|
+
return [...(this.state.participations.get(memberId) ?? [])].map(workspaceId => ({ kind: 'workspace', workspaceId }));
|
|
1586
|
+
}
|
|
1449
1587
|
/**
|
|
1450
1588
|
* Durable position of the newest shared-projection commit: the version every
|
|
1451
1589
|
* change waiter outside the presence scope observes. It only moves when a
|
|
@@ -1474,9 +1612,13 @@ export class AgentTeamLedger {
|
|
|
1474
1612
|
case 'team/member-session-rolled-over':
|
|
1475
1613
|
case 'team/member-updated':
|
|
1476
1614
|
case 'team/member-removed':
|
|
1477
|
-
return
|
|
1615
|
+
return this.memberWorkspaceScopes(operation.data.member.memberId);
|
|
1478
1616
|
case 'team/member-archived':
|
|
1479
|
-
return this.withReleasedActivityScopes(
|
|
1617
|
+
return this.withReleasedActivityScopes(this.memberWorkspaceScopes(operation.data.member.memberId), operation.data.tasks, operation.data.activities);
|
|
1618
|
+
case 'team/member-workspace-joined':
|
|
1619
|
+
return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }];
|
|
1620
|
+
case 'team/member-workspace-left':
|
|
1621
|
+
return this.withReleasedActivityScopes([{ kind: 'workspace', workspaceId: operation.data.workspaceId }], operation.data.tasks, operation.data.activities);
|
|
1480
1622
|
case 'team/channel-member-added':
|
|
1481
1623
|
return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }, { kind: 'channel', channelRef: operation.data.channelRef }];
|
|
1482
1624
|
case 'team/channel-member-removed':
|
|
@@ -1539,6 +1681,8 @@ export class AgentTeamLedger {
|
|
|
1539
1681
|
for (const follower of this.state.attentionByThread.get(threadRef) ?? [])
|
|
1540
1682
|
members.add(follower);
|
|
1541
1683
|
}
|
|
1684
|
+
if (operation.kind === 'team/member-workspace-joined' || operation.kind === 'team/member-workspace-left')
|
|
1685
|
+
members.add(operation.data.memberId);
|
|
1542
1686
|
if (operation.actor.kind === 'member')
|
|
1543
1687
|
members.add(operation.actor.memberId);
|
|
1544
1688
|
return [...members];
|
|
@@ -1560,11 +1704,13 @@ export class AgentTeamLedger {
|
|
|
1560
1704
|
case 'team/channel-archived':
|
|
1561
1705
|
case 'team/member-removed':
|
|
1562
1706
|
case 'team/member-archived':
|
|
1707
|
+
case 'team/member-workspace-left':
|
|
1563
1708
|
return operation.data.activities.map(activity => activity.threadRef);
|
|
1564
1709
|
case 'team/initialized':
|
|
1565
1710
|
case 'team/channel-created':
|
|
1566
1711
|
case 'team/channel-updated':
|
|
1567
1712
|
case 'team/member-added':
|
|
1713
|
+
case 'team/member-workspace-joined':
|
|
1568
1714
|
case 'team/member-suspended':
|
|
1569
1715
|
case 'team/member-resumed':
|
|
1570
1716
|
case 'team/member-session-restarted':
|
|
@@ -1646,7 +1792,7 @@ export class AgentTeamLedger {
|
|
|
1646
1792
|
throw new Error('invalid initial Channel members');
|
|
1647
1793
|
for (const memberId of memberIds) {
|
|
1648
1794
|
const member = projection.members.get(memberId);
|
|
1649
|
-
if (member === undefined || member.
|
|
1795
|
+
if (member === undefined || !this.participatesInFrom(projection, member.memberId, channel.workspaceId) || member.state !== 'enabled')
|
|
1650
1796
|
throw new Error('invalid initial Channel Member');
|
|
1651
1797
|
}
|
|
1652
1798
|
return;
|
|
@@ -1744,10 +1890,10 @@ export class AgentTeamLedger {
|
|
|
1744
1890
|
|| operation.data.member.presetId !== prior.presetId
|
|
1745
1891
|
|| operation.data.member.privateMemoryPath !== prior.privateMemoryPath)
|
|
1746
1892
|
throw new Error('invalid Member update');
|
|
1747
|
-
// The renamed handle must stay unique among the
|
|
1893
|
+
// The renamed handle must stay unique among the live Members sharing any Workspace participation.
|
|
1748
1894
|
const normalized = operation.data.member.handle.normalize('NFKC').trim().toLowerCase();
|
|
1749
1895
|
for (const other of projection.members.values()) {
|
|
1750
|
-
if (other.memberId !== prior.memberId && other.state !== 'inactive' && other.
|
|
1896
|
+
if (other.memberId !== prior.memberId && other.state !== 'inactive' && this.participationOverlapFrom(projection, other.memberId, prior.memberId)
|
|
1751
1897
|
&& other.handle.normalize('NFKC').trim().toLowerCase() === normalized)
|
|
1752
1898
|
throw new Error('invalid Member update handle');
|
|
1753
1899
|
}
|
|
@@ -1757,7 +1903,7 @@ export class AgentTeamLedger {
|
|
|
1757
1903
|
assertHuman();
|
|
1758
1904
|
const channel = projection.channels.get(operation.data.channelRef);
|
|
1759
1905
|
const member = projection.members.get(operation.data.memberId);
|
|
1760
|
-
if (channel === undefined || member === undefined || member.
|
|
1906
|
+
if (channel === undefined || member === undefined || !this.participatesInFrom(projection, member.memberId, channel.workspaceId) || operation.data.workspaceId !== channel.workspaceId || projection.memberships.get(channel.channelRef)?.has(member.memberId))
|
|
1761
1907
|
throw new Error('invalid Channel membership');
|
|
1762
1908
|
return;
|
|
1763
1909
|
}
|
|
@@ -1766,12 +1912,32 @@ export class AgentTeamLedger {
|
|
|
1766
1912
|
const channel = projection.channels.get(operation.data.channelRef);
|
|
1767
1913
|
const member = projection.members.get(operation.data.memberId);
|
|
1768
1914
|
if (channel === undefined || member === undefined || operation.data.workspaceId !== channel.workspaceId
|
|
1769
|
-
|| member.
|
|
1915
|
+
|| !this.participatesInFrom(projection, member.memberId, channel.workspaceId) || !projection.memberships.get(channel.channelRef)?.has(member.memberId))
|
|
1770
1916
|
throw new Error('invalid Channel membership removal');
|
|
1771
1917
|
const threadRefs = new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === channel.channelRef));
|
|
1772
1918
|
this.validateReleaseCleanup(operation.data, projection, member.memberId, threadRefs, operation.sequence, refs);
|
|
1773
1919
|
return;
|
|
1774
1920
|
}
|
|
1921
|
+
if (operation.kind === 'team/member-workspace-joined') {
|
|
1922
|
+
assertHuman();
|
|
1923
|
+
const member = projection.members.get(operation.data.memberId);
|
|
1924
|
+
if (member === undefined || member.state === 'inactive' || member.state === 'archived'
|
|
1925
|
+
|| this.participatesInFrom(projection, member.memberId, operation.data.workspaceId))
|
|
1926
|
+
throw new Error('invalid Workspace join');
|
|
1927
|
+
this.assertHandleAvailableFrom(projection, operation.data.workspaceId, member.handle);
|
|
1928
|
+
return;
|
|
1929
|
+
}
|
|
1930
|
+
if (operation.kind === 'team/member-workspace-left') {
|
|
1931
|
+
assertHuman();
|
|
1932
|
+
const member = projection.members.get(operation.data.memberId);
|
|
1933
|
+
if (member === undefined || member.state === 'inactive' || member.state === 'archived'
|
|
1934
|
+
|| operation.data.workspaceId === member.workspaceId
|
|
1935
|
+
|| !this.participatesInFrom(projection, member.memberId, operation.data.workspaceId))
|
|
1936
|
+
throw new Error('invalid Workspace leave');
|
|
1937
|
+
const threadRefs = this.workspaceThreadRefsFrom(projection, operation.data.workspaceId);
|
|
1938
|
+
this.validateReleaseCleanup(operation.data, projection, member.memberId, threadRefs, operation.sequence, refs);
|
|
1939
|
+
return;
|
|
1940
|
+
}
|
|
1775
1941
|
if (operation.kind === 'team/channel-archived') {
|
|
1776
1942
|
assertHuman();
|
|
1777
1943
|
const prior = projection.channels.get(operation.data.channel.channelRef);
|
|
@@ -1839,7 +2005,10 @@ export class AgentTeamLedger {
|
|
|
1839
2005
|
if (isThreadReadSnapshot(data)) {
|
|
1840
2006
|
// The pre-receipt form froze the whole picture, so its expected value
|
|
1841
2007
|
// is re-derived the same way it was written: from this record's prior
|
|
1842
|
-
// projection only, never from the live one.
|
|
2008
|
+
// projection only, never from the live one. That shape stays frozen —
|
|
2009
|
+
// a field added after it, like the orientation count, is not compared
|
|
2010
|
+
// here, because no stored legacy record carries it and inventing one
|
|
2011
|
+
// would reject every upgrade of a ledger written before the field.
|
|
1843
2012
|
const expected = this.prepareReadFrom(projection, data.memberId, data.workspaceId, target);
|
|
1844
2013
|
const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId,
|
|
1845
2014
|
...(expected.task === undefined ? {} : { task: expected.task }), thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
|
|
@@ -2030,7 +2199,7 @@ export class AgentTeamLedger {
|
|
|
2030
2199
|
if (operation.kind === 'team/dm-sent') {
|
|
2031
2200
|
const sender = assertMember();
|
|
2032
2201
|
const recipient = projection.members.get(operation.data.recipientMemberId);
|
|
2033
|
-
if (recipient === undefined || recipient.
|
|
2202
|
+
if (recipient === undefined || !this.participatesInFrom(projection, recipient.memberId, operation.data.workspaceId)
|
|
2034
2203
|
|| recipient.state !== 'enabled' || recipient.memberId === AGENT_TEAM_HUMAN_MEMBER_ID
|
|
2035
2204
|
|| operation.data.senderMemberId !== sender.memberId
|
|
2036
2205
|
|| operation.data.recipientMemberId === sender.memberId
|
|
@@ -2196,7 +2365,7 @@ export class AgentTeamLedger {
|
|
|
2196
2365
|
// Top-level mentions are open to every actor; only a Member reply may not
|
|
2197
2366
|
// pull an unfollowed Member into an existing Thread.
|
|
2198
2367
|
if (operation.actor.kind === 'member' && operation.kind === 'team/thread-replied'
|
|
2199
|
-
&& mentionedAgents.some(memberId => !this.
|
|
2368
|
+
&& mentionedAgents.some(memberId => !this.everParticipatedFrom(projection, message.threadRef, memberId))) {
|
|
2200
2369
|
throw new Error('invalid Agent mention projection');
|
|
2201
2370
|
}
|
|
2202
2371
|
const started = operation.kind === 'team/message-sent'
|
|
@@ -2221,8 +2390,9 @@ export class AgentTeamLedger {
|
|
|
2221
2390
|
if (memberId === AGENT_TEAM_HUMAN_MEMBER_ID)
|
|
2222
2391
|
return true;
|
|
2223
2392
|
const member = projection.members.get(memberId);
|
|
2393
|
+
const channelWorkspaceId = projection.channels.get(channelRef)?.workspaceId;
|
|
2224
2394
|
return member !== undefined && member.state !== 'inactive' && member.state !== 'archived'
|
|
2225
|
-
&&
|
|
2395
|
+
&& channelWorkspaceId !== undefined && this.participatesInFrom(projection, memberId, channelWorkspaceId)
|
|
2226
2396
|
&& this.isChannelMemberFrom(projection, channelRef, memberId);
|
|
2227
2397
|
}
|
|
2228
2398
|
apply(operation) {
|
|
@@ -2247,6 +2417,9 @@ export class AgentTeamLedger {
|
|
|
2247
2417
|
}
|
|
2248
2418
|
if (operation.kind === 'team/member-added') {
|
|
2249
2419
|
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
2420
|
+
// Creation seeds the first Workspace participation; member.workspaceId
|
|
2421
|
+
// stays the default Workspace the Session roots in, immutable after add.
|
|
2422
|
+
target.participations.set(operation.data.member.memberId, new Set([operation.data.member.workspaceId]));
|
|
2250
2423
|
for (const channelRef of operation.data.channelRefs)
|
|
2251
2424
|
this.addMembership(target, channelRef, operation.data.member.memberId);
|
|
2252
2425
|
return;
|
|
@@ -2305,6 +2478,23 @@ export class AgentTeamLedger {
|
|
|
2305
2478
|
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2306
2479
|
return;
|
|
2307
2480
|
}
|
|
2481
|
+
if (operation.kind === 'team/member-workspace-joined') {
|
|
2482
|
+
const workspaces = target.participations.get(operation.data.memberId) ?? new Set();
|
|
2483
|
+
workspaces.add(operation.data.workspaceId);
|
|
2484
|
+
target.participations.set(operation.data.memberId, workspaces);
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
if (operation.kind === 'team/member-workspace-left') {
|
|
2488
|
+
target.participations.get(operation.data.memberId)?.delete(operation.data.workspaceId);
|
|
2489
|
+
// Departure ends Channel memberships inside the left Workspace — unlike
|
|
2490
|
+
// archival, which keeps them hidden for a possible restore.
|
|
2491
|
+
for (const channel of target.channels.values()) {
|
|
2492
|
+
if (channel.workspaceId === operation.data.workspaceId)
|
|
2493
|
+
target.memberships.get(channel.channelRef)?.delete(operation.data.memberId);
|
|
2494
|
+
}
|
|
2495
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2308
2498
|
if (operation.kind === 'team/channel-archived') {
|
|
2309
2499
|
// Archival keeps Memberships: hidden state, not departure — a future
|
|
2310
2500
|
// restore returns every Member to the Channel. Visibility filters by
|
|
@@ -2420,6 +2610,11 @@ export class AgentTeamLedger {
|
|
|
2420
2610
|
// The ref index is written in this same step, so it is exactly the set of
|
|
2421
2611
|
// replayed Messages: a validator lookup can never reach a later record.
|
|
2422
2612
|
target.messagesByRef.set(message.messageRef, message);
|
|
2613
|
+
// Participation is indexed per writer as the fact lands, so the Human
|
|
2614
|
+
// recent slice asks who wrote where without scanning the whole ledger.
|
|
2615
|
+
const written = target.threadsByWriter.get(message.sender) ?? new Set();
|
|
2616
|
+
written.add(message.threadRef);
|
|
2617
|
+
target.threadsByWriter.set(message.sender, written);
|
|
2423
2618
|
}
|
|
2424
2619
|
appendActivityFact(target, activity, occurredAt) {
|
|
2425
2620
|
const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity, occurredAt });
|
|
@@ -2596,9 +2791,21 @@ export class AgentTeamLedger {
|
|
|
2596
2791
|
const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...receipt.unread.slice(0, 20)]
|
|
2597
2792
|
.sort((left, right) => left.fact.sequence - right.fact.sequence);
|
|
2598
2793
|
const remainingUnreadCount = this.remainingUnreadAfter(projection, memberId, receipt);
|
|
2794
|
+
// What this bounded read leaves behind it: the Thread facts that precede the
|
|
2795
|
+
// watermark it reaches, which is where this reader now stands in the Thread
|
|
2796
|
+
// and how much of it that position has never shown it. Anchoring on the
|
|
2797
|
+
// watermark rather than on the response's own oldest fact is what keeps a
|
|
2798
|
+
// returning reader honest — the background window starts at the Thread's
|
|
2799
|
+
// first fact whenever the Thread is short enough to fit it, so measuring
|
|
2800
|
+
// from the response would report nothing behind a reader that has in fact
|
|
2801
|
+
// never read the Thread. Derived from the projection the read resolved
|
|
2802
|
+
// against, like every other field of this picture, so a replay re-derives
|
|
2803
|
+
// the same number.
|
|
2804
|
+
const earlierFactCount = this.threadFactsFrom(projection, thread.threadRef)
|
|
2805
|
+
.filter(fact => fact.sequence < receipt.readThroughSequence).length;
|
|
2599
2806
|
return Object.freeze({ ...receipt, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
|
|
2600
2807
|
anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
|
|
2601
|
-
facts: Object.freeze(combined), remainingUnreadCount });
|
|
2808
|
+
facts: Object.freeze(combined), remainingUnreadCount, earlierFactCount });
|
|
2602
2809
|
}
|
|
2603
2810
|
readFactFrom(projection, memberId, fact, unread) {
|
|
2604
2811
|
return Object.freeze({ fact, unread, direct: fact.kind === 'message'
|
|
@@ -2849,15 +3056,11 @@ export class AgentTeamLedger {
|
|
|
2849
3056
|
if (memberId === AGENT_TEAM_HUMAN_MEMBER_ID)
|
|
2850
3057
|
continue;
|
|
2851
3058
|
const member = this.requireMember(memberId);
|
|
2852
|
-
if (member.state === 'inactive' || member.state === 'archived' || member.
|
|
3059
|
+
if (member.state === 'inactive' || member.state === 'archived' || !this.participatesIn(member.memberId, channel.workspaceId) || !this.isChannelMember(channel.channelRef, memberId)) {
|
|
2853
3060
|
throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${channel.channelRef}'`);
|
|
2854
3061
|
}
|
|
2855
3062
|
}
|
|
2856
3063
|
}
|
|
2857
|
-
memberNotFollowing(workspaceId, channelRef, memberIds, task, thread) {
|
|
2858
|
-
return Object.freeze({ kind: 'member_not_following', workspaceId, channelRef, memberIds: Object.freeze(memberIds),
|
|
2859
|
-
...(task === undefined ? {} : { taskRef: task.taskRef }), ...(thread === undefined ? {} : { threadRef: thread.threadRef, revision: thread.revision }) });
|
|
2860
|
-
}
|
|
2861
3064
|
unreadRequired(task, thread, unread) {
|
|
2862
3065
|
return Object.freeze({ kind: 'unread_required', ...(task === undefined ? {} : { taskRef: task.taskRef }), threadRef: thread.threadRef,
|
|
2863
3066
|
revision: thread.revision, unreadCount: unread.length, directCount: unread.filter(item => item.direct).length });
|
|
@@ -2917,10 +3120,11 @@ export class AgentTeamLedger {
|
|
|
2917
3120
|
return undefined;
|
|
2918
3121
|
}
|
|
2919
3122
|
/** Commit projections shared by the Message-sent and Thread-replied results. */
|
|
2920
|
-
committedMessageResult(operation) {
|
|
3123
|
+
committedMessageResult(operation, undelivered = []) {
|
|
2921
3124
|
return { kind: 'committed', receipt: this.receipt(operation), message: operation.data.message,
|
|
2922
3125
|
...(operation.data.task === undefined ? {} : { task: operation.data.task }), thread: operation.data.thread, attention: operation.data.inbox.attention.set,
|
|
2923
|
-
directMarkers: operation.data.inbox.directMarkers.added
|
|
3126
|
+
directMarkers: operation.data.inbox.directMarkers.added,
|
|
3127
|
+
...(undelivered.length === 0 ? {} : { undeliveredMentions: undelivered }) };
|
|
2924
3128
|
}
|
|
2925
3129
|
threadAnchor(threadRef) {
|
|
2926
3130
|
return this.threadAnchorFrom(this.state, threadRef);
|
|
@@ -2997,7 +3201,7 @@ export class AgentTeamLedger {
|
|
|
2997
3201
|
return actor;
|
|
2998
3202
|
}
|
|
2999
3203
|
const member = this.assertMemberActor(actor);
|
|
3000
|
-
if (member.
|
|
3204
|
+
if (!this.participatesIn(member.memberId, workspaceId))
|
|
3001
3205
|
throw new Error('Member cannot mutate another Workspace');
|
|
3002
3206
|
return actor;
|
|
3003
3207
|
}
|
|
@@ -3174,10 +3378,50 @@ export class AgentTeamLedger {
|
|
|
3174
3378
|
channelThreadRefsFrom(projection, channelRef) {
|
|
3175
3379
|
return new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === channelRef));
|
|
3176
3380
|
}
|
|
3381
|
+
/** The Workspace authorization question: does this Member participate in this Workspace. */
|
|
3382
|
+
participatesIn(memberId, workspaceId) {
|
|
3383
|
+
return this.participatesInFrom(this.state, memberId, workspaceId);
|
|
3384
|
+
}
|
|
3385
|
+
participatesInFrom(projection, memberId, workspaceId) {
|
|
3386
|
+
return projection.participations.get(memberId)?.has(workspaceId) === true;
|
|
3387
|
+
}
|
|
3388
|
+
/** Every Workspace the Member participates in: the default first, then the rest sorted. */
|
|
3389
|
+
workspacesOf(memberId) {
|
|
3390
|
+
return Object.freeze(this.workspacesOfFrom(this.state, memberId));
|
|
3391
|
+
}
|
|
3392
|
+
workspacesOfFrom(projection, memberId) {
|
|
3393
|
+
const member = projection.members.get(memberId);
|
|
3394
|
+
if (member === undefined)
|
|
3395
|
+
return [];
|
|
3396
|
+
const rest = [...(projection.participations.get(memberId) ?? new Set())]
|
|
3397
|
+
.filter(workspaceId => workspaceId !== member.workspaceId).sort();
|
|
3398
|
+
return [member.workspaceId, ...rest];
|
|
3399
|
+
}
|
|
3400
|
+
/** Whether two Members share at least one Workspace participation — the handle-uniqueness scope. */
|
|
3401
|
+
participationOverlapFrom(projection, leftMemberId, rightMemberId) {
|
|
3402
|
+
const left = projection.participations.get(leftMemberId);
|
|
3403
|
+
const right = projection.participations.get(rightMemberId);
|
|
3404
|
+
if (left === undefined || right === undefined)
|
|
3405
|
+
return false;
|
|
3406
|
+
for (const workspaceId of left)
|
|
3407
|
+
if (right.has(workspaceId))
|
|
3408
|
+
return true;
|
|
3409
|
+
return false;
|
|
3410
|
+
}
|
|
3411
|
+
/** Every Thread in the Workspace — the scope a Workspace leave replays its cleanup against. */
|
|
3412
|
+
workspaceThreadRefs(workspaceId) {
|
|
3413
|
+
return this.workspaceThreadRefsFrom(this.state, workspaceId);
|
|
3414
|
+
}
|
|
3415
|
+
workspaceThreadRefsFrom(projection, workspaceId) {
|
|
3416
|
+
return new Set([...projection.threads.keys()].filter(threadRef => {
|
|
3417
|
+
const channelRef = this.channelRefForThreadFrom(projection, threadRef);
|
|
3418
|
+
return channelRef !== undefined && projection.channels.get(channelRef)?.workspaceId === workspaceId;
|
|
3419
|
+
}));
|
|
3420
|
+
}
|
|
3177
3421
|
assertJoinableMember(workspaceId, memberId) {
|
|
3178
3422
|
const member = this.requireMember(memberId);
|
|
3179
|
-
if (
|
|
3180
|
-
throw new Error(`Agent Member '${memberId}' does not
|
|
3423
|
+
if (!this.participatesIn(memberId, workspaceId))
|
|
3424
|
+
throw new Error(`Agent Member '${memberId}' does not participate in Workspace '${workspaceId}'`);
|
|
3181
3425
|
if (member.state !== 'enabled')
|
|
3182
3426
|
throw new Error(`Agent Member '${memberId}' is ${member.state}; only enabled Members can join a Channel`);
|
|
3183
3427
|
}
|
|
@@ -3193,13 +3437,78 @@ export class AgentTeamLedger {
|
|
|
3193
3437
|
throw new Error(`${label} contains duplicate Member refs`);
|
|
3194
3438
|
return Object.freeze([...unique].sort());
|
|
3195
3439
|
}
|
|
3440
|
+
/**
|
|
3441
|
+
* Names a Message body may address in one Channel: every live Member of that
|
|
3442
|
+
* Channel plus the Human. A name outside this set stays prose, which is what
|
|
3443
|
+
* keeps an incidental name-drop from reaching someone the Channel cannot
|
|
3444
|
+
* deliver to.
|
|
3445
|
+
*/
|
|
3446
|
+
mentionCandidatesFor(channelRef) {
|
|
3447
|
+
const candidates = [{ memberId: AGENT_TEAM_HUMAN_MEMBER_ID, handle: AGENT_TEAM_HUMAN_HANDLE }];
|
|
3448
|
+
for (const member of this.state.members.values()) {
|
|
3449
|
+
if (member.state === 'inactive' || member.state === 'archived')
|
|
3450
|
+
continue;
|
|
3451
|
+
if (!this.isChannelMember(channelRef, member.memberId))
|
|
3452
|
+
continue;
|
|
3453
|
+
candidates.push({ memberId: member.memberId, handle: member.handle });
|
|
3454
|
+
}
|
|
3455
|
+
return Object.freeze(candidates);
|
|
3456
|
+
}
|
|
3457
|
+
/**
|
|
3458
|
+
* Merge the `@Handle` mentions authored in `body` into an explicit recipient
|
|
3459
|
+
* set. Body mentions are the primary channel now: an Agent has no recipient
|
|
3460
|
+
* parameter to forget, and the same scan serves Human input typed by hand.
|
|
3461
|
+
* The result stays a plain recipient set, so every downstream projection —
|
|
3462
|
+
* delivery markers, chip rendering, confirmation — is unchanged.
|
|
3463
|
+
*/
|
|
3464
|
+
mergeBodyMentions(sender, channelRef, body, explicit) {
|
|
3465
|
+
const candidates = this.mentionCandidatesFor(channelRef);
|
|
3466
|
+
const resolution = resolveBodyMentions(body, candidates, sender);
|
|
3467
|
+
// `@all` stands for its expansion as of this write: the Member set is
|
|
3468
|
+
// snapshotted into the recipient list, so a later roster change cannot
|
|
3469
|
+
// retroactively alter what this operation delivered.
|
|
3470
|
+
const authored = resolution.all ? candidates.map(candidate => candidate.memberId) : resolution.memberIds;
|
|
3471
|
+
const merged = new Set([...explicit, ...authored]);
|
|
3472
|
+
merged.delete(sender);
|
|
3473
|
+
return Object.freeze([...merged].sort());
|
|
3474
|
+
}
|
|
3475
|
+
/**
|
|
3476
|
+
* Whether one Member has ever held Attention on one Thread. Committed Inbox
|
|
3477
|
+
* deltas append a follow/unfollow observation, and the live index covers the
|
|
3478
|
+
* Thread-creating Message whose initial Attention is never observed — so the
|
|
3479
|
+
* two together answer "was this Member ever part of this Thread" without
|
|
3480
|
+
* adding a second durable authority.
|
|
3481
|
+
*/
|
|
3482
|
+
everParticipated(threadRef, memberId) {
|
|
3483
|
+
return this.everParticipatedFrom(this.state, threadRef, memberId);
|
|
3484
|
+
}
|
|
3485
|
+
everParticipatedFrom(projection, threadRef, memberId) {
|
|
3486
|
+
if (this.isFollowingFrom(projection, threadRef, memberId))
|
|
3487
|
+
return true;
|
|
3488
|
+
return (projection.observationsByThread.get(threadRef) ?? []).some(observation => observation.memberId === memberId);
|
|
3489
|
+
}
|
|
3490
|
+
/**
|
|
3491
|
+
* Split the Agent recipients an existing Thread cannot deliver to: those the
|
|
3492
|
+
* body named that the Thread has never carried. The send still commits — a
|
|
3493
|
+
* text mention must never fail the write — and the author is told through the
|
|
3494
|
+
* result, because inviting a Member into an existing Thread stays a Human
|
|
3495
|
+
* decision.
|
|
3496
|
+
*/
|
|
3497
|
+
undeliverableRecipients(actor, threadRef, recipients) {
|
|
3498
|
+
if (actor.kind !== 'member')
|
|
3499
|
+
return Object.freeze([]);
|
|
3500
|
+
return Object.freeze(recipients.filter(memberId => this.state.members.has(memberId) && !this.everParticipated(threadRef, memberId)));
|
|
3501
|
+
}
|
|
3196
3502
|
normalizeDirection(direction) {
|
|
3197
3503
|
return direction.normalize('NFKC').trim().replace(/\s+/gu, ' ').toLowerCase();
|
|
3198
3504
|
}
|
|
3199
3505
|
assertHandleAvailable(workspaceId, handle, exceptMemberId) {
|
|
3506
|
+
this.assertHandleAvailableFrom(this.state, workspaceId, handle, exceptMemberId);
|
|
3507
|
+
}
|
|
3508
|
+
assertHandleAvailableFrom(projection, workspaceId, handle, exceptMemberId) {
|
|
3200
3509
|
const normalized = handle.normalize('NFKC').trim().toLowerCase();
|
|
3201
|
-
if ([...
|
|
3202
|
-
&& member.
|
|
3510
|
+
if ([...projection.members.values()].some(member => member.memberId !== exceptMemberId && member.state !== 'inactive'
|
|
3511
|
+
&& this.participatesInFrom(projection, member.memberId, workspaceId)
|
|
3203
3512
|
&& member.handle.normalize('NFKC').trim().toLowerCase() === normalized)) {
|
|
3204
3513
|
throw new Error(`Agent Member handle '${handle}' is already active in Workspace '${workspaceId}'`);
|
|
3205
3514
|
}
|
|
@@ -3301,6 +3610,16 @@ export class AgentTeamLedger {
|
|
|
3301
3610
|
|| operation.data.workspaceId !== request.workspaceId || operation.data.channelRef !== request.channelRef || operation.data.memberId !== request.memberId)
|
|
3302
3611
|
this.throwRequestCollision(request.requestId);
|
|
3303
3612
|
}
|
|
3613
|
+
assertSameWorkspaceJoin(operation, request) {
|
|
3614
|
+
if (operation.kind !== 'team/member-workspace-joined' || !this.sameActor(operation.actor, request.actor)
|
|
3615
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.memberId !== request.memberId)
|
|
3616
|
+
this.throwRequestCollision(request.requestId);
|
|
3617
|
+
}
|
|
3618
|
+
assertSameWorkspaceLeave(operation, request) {
|
|
3619
|
+
if (operation.kind !== 'team/member-workspace-left' || !this.sameActor(operation.actor, request.actor)
|
|
3620
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.memberId !== request.memberId)
|
|
3621
|
+
this.throwRequestCollision(request.requestId);
|
|
3622
|
+
}
|
|
3304
3623
|
assertSameChannelArchival(operation, request) {
|
|
3305
3624
|
if (operation.kind !== 'team/channel-archived' || !this.sameActor(operation.actor, request.actor)
|
|
3306
3625
|
|| operation.data.workspaceId !== request.workspaceId || operation.data.channel.channelRef !== request.channelRef)
|
|
@@ -3456,6 +3775,13 @@ export class AgentTeamLedger {
|
|
|
3456
3775
|
return Object.freeze({ receipt: this.receipt(operation), channelRef: operation.data.channelRef, memberId: operation.data.memberId,
|
|
3457
3776
|
releasedClaims: operation.data.claims, removedAttention: operation.data.inbox.attention.removed });
|
|
3458
3777
|
}
|
|
3778
|
+
workspaceJoinResult(operation) {
|
|
3779
|
+
return Object.freeze({ receipt: this.receipt(operation), memberId: operation.data.memberId, workspaceId: operation.data.workspaceId });
|
|
3780
|
+
}
|
|
3781
|
+
workspaceLeaveResult(operation) {
|
|
3782
|
+
return Object.freeze({ receipt: this.receipt(operation), memberId: operation.data.memberId, workspaceId: operation.data.workspaceId,
|
|
3783
|
+
releasedClaims: operation.data.claims, removedAttention: operation.data.inbox.attention.removed });
|
|
3784
|
+
}
|
|
3459
3785
|
channelArchivalResult(operation) {
|
|
3460
3786
|
return Object.freeze({ receipt: this.receipt(operation), channel: operation.data.channel,
|
|
3461
3787
|
releasedClaims: operation.data.claims });
|
|
@@ -3463,8 +3789,8 @@ export class AgentTeamLedger {
|
|
|
3463
3789
|
messageResult(operation) {
|
|
3464
3790
|
return Object.freeze(this.committedMessageResult(operation));
|
|
3465
3791
|
}
|
|
3466
|
-
replyResult(operation) {
|
|
3467
|
-
return Object.freeze(this.committedMessageResult(operation));
|
|
3792
|
+
replyResult(operation, undelivered = []) {
|
|
3793
|
+
return Object.freeze(this.committedMessageResult(operation, undelivered));
|
|
3468
3794
|
}
|
|
3469
3795
|
claimResult(operation) {
|
|
3470
3796
|
const attention = operation.data.inbox.attention.set.find(candidate => candidate.memberId === operation.data.claim.owner);
|
|
@@ -3494,6 +3820,7 @@ export class AgentTeamLedger {
|
|
|
3494
3820
|
return Object.freeze({ ...(prepared.task === undefined ? {} : { task: prepared.task }), thread: prepared.thread,
|
|
3495
3821
|
claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions, facts: prepared.facts,
|
|
3496
3822
|
readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
|
|
3823
|
+
earlierFactCount: prepared.earlierFactCount,
|
|
3497
3824
|
...(prepared.attention === undefined ? {} : { attention: prepared.attention }),
|
|
3498
3825
|
consumedDirectMarkers: prepared.inbox.directMarkers.removed });
|
|
3499
3826
|
}
|