@wowyuarm/dsh-agent-team 0.1.0
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/LICENSE +21 -0
- package/README.md +103 -0
- package/README.zh.md +103 -0
- package/cordis.patch.yml +36 -0
- package/package.json +152 -0
- package/packages/agent-team/README.md +58 -0
- package/packages/agent-team/README.zh.md +57 -0
- package/packages/agent-team/lib/index.js +841 -0
- package/packages/agent-team/lib/invariant.js +24 -0
- package/packages/agent-team/lib/ledger.js +2161 -0
- package/packages/agent-team/lib/member-context.js +69 -0
- package/packages/agent-team/lib/preset-roster.js +14 -0
- package/packages/agent-team/lib/spec.js +347 -0
- package/packages/agent-team/lib/typert.host.d.ts +3 -0
- package/packages/agent-team/lib/typert.host.js +2084 -0
- package/packages/agent-team/lib/typert.remote-client.d.ts +54 -0
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -0
- package/packages/agent-team/lib/typert.remote-client.js +1218 -0
- package/packages/agent-team/lib/types/index.d.ts +156 -0
- package/packages/agent-team/lib/types/index.d.ts.map +1 -0
- package/packages/agent-team/lib/types/invariant.d.ts +13 -0
- package/packages/agent-team/lib/types/invariant.d.ts.map +1 -0
- package/packages/agent-team/lib/types/ledger.d.ts +271 -0
- package/packages/agent-team/lib/types/ledger.d.ts.map +1 -0
- package/packages/agent-team/lib/types/member-context.d.ts +7 -0
- package/packages/agent-team/lib/types/member-context.d.ts.map +1 -0
- package/packages/agent-team/lib/types/preset-roster.d.ts +5 -0
- package/packages/agent-team/lib/types/preset-roster.d.ts.map +1 -0
- package/packages/agent-team/lib/types/spec.d.ts +13 -0
- package/packages/agent-team/lib/types/spec.d.ts.map +1 -0
- package/packages/agent-team/lib/types/types.d.ts +818 -0
- package/packages/agent-team/lib/types/types.d.ts.map +1 -0
- package/packages/agent-team/lib/types.js +1 -0
- package/packages/agent-team/preset/team-member/agent.cordis.yml +89 -0
- package/packages/client-agent-team/README.md +39 -0
- package/packages/client-agent-team/README.zh.md +39 -0
- package/packages/client-agent-team/lib/client.js +9633 -0
- package/packages/client-agent-team/lib/client.js.map +1 -0
- package/packages/client-agent-team/lib/index.js +5 -0
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts +20 -0
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +274 -0
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts +19 -0
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +243 -0
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts +21 -0
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +196 -0
- package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts +15 -0
- package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamComposer.js +144 -0
- package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts +3 -0
- package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamConversation.js +20 -0
- package/packages/client-agent-team/lib/types/client/TeamFooterAction.d.ts +3 -0
- package/packages/client-agent-team/lib/types/client/TeamFooterAction.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamFooterAction.js +19 -0
- package/packages/client-agent-team/lib/types/client/TeamInboxPanel.d.ts +13 -0
- package/packages/client-agent-team/lib/types/client/TeamInboxPanel.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamInboxPanel.js +90 -0
- package/packages/client-agent-team/lib/types/client/TeamMemberAvatar.d.ts +12 -0
- package/packages/client-agent-team/lib/types/client/TeamMemberAvatar.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamMemberAvatar.js +15 -0
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts +5 -0
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.js +32 -0
- package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts +19 -0
- package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamMessage.js +24 -0
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts +11 -0
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.js +20 -0
- package/packages/client-agent-team/lib/types/client/TeamRowMenu.d.ts +15 -0
- package/packages/client-agent-team/lib/types/client/TeamRowMenu.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamRowMenu.js +23 -0
- package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts +3 -0
- package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamSettings.js +5 -0
- package/packages/client-agent-team/lib/types/client/TeamSidebarSection.d.ts +9 -0
- package/packages/client-agent-team/lib/types/client/TeamSidebarSection.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamSidebarSection.js +9 -0
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts +22 -0
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +464 -0
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +3 -0
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +39 -0
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceRow.d.ts +11 -0
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceRow.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceRow.js +6 -0
- package/packages/client-agent-team/lib/types/client/index.d.ts +19 -0
- package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/index.js +113 -0
- package/packages/client-agent-team/lib/types/client/locales.d.ts +224 -0
- package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/locales.js +222 -0
- package/packages/client-agent-team/lib/types/client/navigation.d.ts +39 -0
- package/packages/client-agent-team/lib/types/client/navigation.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/navigation.js +96 -0
- package/packages/client-agent-team/lib/types/client/slots.d.ts +74 -0
- package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/slots.js +1 -0
- package/packages/client-agent-team/lib/types/client/team-changes.d.ts +28 -0
- package/packages/client-agent-team/lib/types/client/team-changes.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/team-changes.js +70 -0
- package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +53 -0
- package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/team-formatters.js +128 -0
- package/packages/client-agent-team/lib/types/client/team-membership.d.ts +25 -0
- package/packages/client-agent-team/lib/types/client/team-membership.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/team-membership.js +39 -0
- package/packages/client-agent-team/lib/types/client/team-separators.d.ts +23 -0
- package/packages/client-agent-team/lib/types/client/team-separators.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/team-separators.js +52 -0
- package/packages/client-agent-team/lib/types/client/timeline-scroll.d.ts +18 -0
- package/packages/client-agent-team/lib/types/client/timeline-scroll.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/timeline-scroll.js +54 -0
- package/packages/client-agent-team/lib/types/index.d.ts +3 -0
- package/packages/client-agent-team/lib/types/index.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/index.js +2 -0
- package/packages/tool-agent-team/README.md +23 -0
- package/packages/tool-agent-team/README.zh.md +23 -0
- package/packages/tool-agent-team/lib/index.js +294 -0
- package/packages/tool-agent-team/lib/types/index.d.ts +5 -0
- package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -0
|
@@ -0,0 +1,2161 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
/** Stable Human Member identity shared by every replay of one dshHome Team. */
|
|
4
|
+
export const AGENT_TEAM_HUMAN_MEMBER_ID = 'member:human';
|
|
5
|
+
/** Idempotency identity of the one Host bootstrap operation. */
|
|
6
|
+
export const AGENT_TEAM_INITIALIZE_REQUEST_ID = 'agent-team:initialize:v1';
|
|
7
|
+
const HUMAN_ACTOR = Object.freeze({
|
|
8
|
+
kind: 'human',
|
|
9
|
+
memberId: AGENT_TEAM_HUMAN_MEMBER_ID,
|
|
10
|
+
handle: 'human',
|
|
11
|
+
});
|
|
12
|
+
/** Resolve the one Human authority owned by this Team. */
|
|
13
|
+
export function agentTeamHumanActor() {
|
|
14
|
+
return HUMAN_ACTOR;
|
|
15
|
+
}
|
|
16
|
+
function emptyProjection() {
|
|
17
|
+
return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), memberships: new Map(),
|
|
18
|
+
claims: new Map(), messages: [], tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
|
|
19
|
+
orderedFacts: [], factsByThread: new Map(), topLevelMessages: [], mentionsByMessage: new Map(), messageCountByThread: new Map(),
|
|
20
|
+
attentionByThread: new Map() };
|
|
21
|
+
}
|
|
22
|
+
/** Replay and append logic behind the Agent Team service interface. */
|
|
23
|
+
export class AgentTeamLedger {
|
|
24
|
+
table;
|
|
25
|
+
/** Live projection; record validation replays build independent Projection values instead. */
|
|
26
|
+
state = emptyProjection();
|
|
27
|
+
confirmations = new Map();
|
|
28
|
+
createOperationId;
|
|
29
|
+
createOccurredAt;
|
|
30
|
+
createRef;
|
|
31
|
+
operationTail = Promise.resolve();
|
|
32
|
+
constructor(table, options = {}) {
|
|
33
|
+
this.table = table;
|
|
34
|
+
this.createOperationId = options.operationId ?? (() => `operation:${randomUUID()}`);
|
|
35
|
+
this.createOccurredAt = options.occurredAt ?? (() => new Date().toISOString());
|
|
36
|
+
this.createRef = options.ref ?? (kind => `${kind}:${randomUUID()}`);
|
|
37
|
+
this.replay();
|
|
38
|
+
}
|
|
39
|
+
initialize(request = {
|
|
40
|
+
requestId: AGENT_TEAM_INITIALIZE_REQUEST_ID,
|
|
41
|
+
actor: HUMAN_ACTOR,
|
|
42
|
+
humanMemberId: AGENT_TEAM_HUMAN_MEMBER_ID,
|
|
43
|
+
}) {
|
|
44
|
+
return this.enqueue(async () => {
|
|
45
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
46
|
+
if (existing !== undefined) {
|
|
47
|
+
this.assertSameInitialization(existing, request);
|
|
48
|
+
return this.resolved(this.receipt(existing));
|
|
49
|
+
}
|
|
50
|
+
if (this.state.ordered.length !== 0)
|
|
51
|
+
throw new Error('agent-team ledger has operations but no initialization request');
|
|
52
|
+
const operation = Object.freeze({
|
|
53
|
+
sequence: 1,
|
|
54
|
+
operationId: this.createOperationId(),
|
|
55
|
+
requestId: request.requestId,
|
|
56
|
+
occurredAt: this.createOccurredAt(),
|
|
57
|
+
actor: Object.freeze({ ...request.actor }),
|
|
58
|
+
previousOperationId: null,
|
|
59
|
+
kind: 'team/initialized',
|
|
60
|
+
data: Object.freeze({ humanMemberId: request.humanMemberId }),
|
|
61
|
+
});
|
|
62
|
+
await this.table.put(operation.operationId, operation);
|
|
63
|
+
this.apply(operation);
|
|
64
|
+
return this.committed(this.receipt(operation));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
createChannel(request) {
|
|
68
|
+
return this.enqueue(async () => {
|
|
69
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
70
|
+
if (existing !== undefined) {
|
|
71
|
+
this.assertSameChannelCreation(existing, request, this.normalizeUnique(request.memberIds, 'initial Channel members'));
|
|
72
|
+
return this.resolved(this.channelResult(existing));
|
|
73
|
+
}
|
|
74
|
+
this.assertHumanActor(request.actor);
|
|
75
|
+
const name = request.name.trim();
|
|
76
|
+
const description = request.description.trim();
|
|
77
|
+
if (name === '')
|
|
78
|
+
throw new Error('channel name must not be empty');
|
|
79
|
+
if (description === '')
|
|
80
|
+
throw new Error('channel description must not be empty');
|
|
81
|
+
const memberIds = this.normalizeUnique(request.memberIds, 'initial Channel members');
|
|
82
|
+
for (const memberId of memberIds)
|
|
83
|
+
this.assertJoinableMember(request.workspaceId, memberId);
|
|
84
|
+
const sequence = this.nextSequence();
|
|
85
|
+
const channel = Object.freeze({
|
|
86
|
+
channelRef: this.ref('channel'), workspaceId: request.workspaceId, name, description, createdAtSequence: sequence,
|
|
87
|
+
});
|
|
88
|
+
const operation = Object.freeze({
|
|
89
|
+
...this.operationBase(request, sequence), kind: 'team/channel-created',
|
|
90
|
+
data: Object.freeze({ workspaceId: request.workspaceId, channel, memberIds }),
|
|
91
|
+
});
|
|
92
|
+
await this.table.put(operation.operationId, operation);
|
|
93
|
+
this.apply(operation);
|
|
94
|
+
return this.committed(this.channelResult(operation));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
/** Human rename of one Channel's display facts; identity refs are immutable. */
|
|
98
|
+
updateChannel(request) {
|
|
99
|
+
return this.enqueue(async () => {
|
|
100
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
101
|
+
if (existing !== undefined) {
|
|
102
|
+
this.assertSameChannelUpdate(existing, request);
|
|
103
|
+
return this.resolved(this.channelUpdateResult(existing));
|
|
104
|
+
}
|
|
105
|
+
this.assertHumanActor(request.actor);
|
|
106
|
+
const name = request.name.trim();
|
|
107
|
+
const description = request.description.trim();
|
|
108
|
+
if (name === '')
|
|
109
|
+
throw new Error('channel name must not be empty');
|
|
110
|
+
if (description === '')
|
|
111
|
+
throw new Error('channel description must not be empty');
|
|
112
|
+
const channel = Object.freeze({ ...this.requireChannel(request.workspaceId, request.channelRef), name, description });
|
|
113
|
+
const operation = Object.freeze({
|
|
114
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/channel-updated',
|
|
115
|
+
data: Object.freeze({ workspaceId: request.workspaceId, channel }),
|
|
116
|
+
});
|
|
117
|
+
await this.table.put(operation.operationId, operation);
|
|
118
|
+
this.apply(operation);
|
|
119
|
+
return this.committed(this.channelUpdateResult(operation));
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
addMember(request) {
|
|
123
|
+
return this.enqueue(async () => {
|
|
124
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
125
|
+
if (existing !== undefined) {
|
|
126
|
+
this.assertSameMemberAdd(existing, request);
|
|
127
|
+
return this.resolved(this.memberResult(existing));
|
|
128
|
+
}
|
|
129
|
+
this.assertHumanActor(request.actor);
|
|
130
|
+
const handle = request.handle.trim();
|
|
131
|
+
const description = request.description.trim();
|
|
132
|
+
const presetId = request.presetId.trim();
|
|
133
|
+
if (handle === '')
|
|
134
|
+
throw new Error('member handle must not be empty');
|
|
135
|
+
if (description === '')
|
|
136
|
+
throw new Error('member description must not be empty');
|
|
137
|
+
if (presetId === '')
|
|
138
|
+
throw new Error('member preset must not be empty');
|
|
139
|
+
const channelRefs = this.normalizeUnique(request.channelRefs, 'initial Member Channels');
|
|
140
|
+
if (channelRefs.length === 0)
|
|
141
|
+
throw new Error('Agent Member requires at least one initial Channel');
|
|
142
|
+
for (const channelRef of channelRefs)
|
|
143
|
+
this.requireChannel(request.workspaceId, channelRef);
|
|
144
|
+
this.assertHandleAvailable(request.workspaceId, handle);
|
|
145
|
+
this.assertModelSelection(request.member.model);
|
|
146
|
+
const member = Object.freeze({
|
|
147
|
+
...request.member, handle, description, presetId, state: 'enabled',
|
|
148
|
+
...(request.member.model === undefined ? {} : { model: Object.freeze({ ...request.member.model }) }),
|
|
149
|
+
});
|
|
150
|
+
const operation = Object.freeze({
|
|
151
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/member-added',
|
|
152
|
+
data: Object.freeze({ member, channelRefs }),
|
|
153
|
+
});
|
|
154
|
+
await this.table.put(operation.operationId, operation);
|
|
155
|
+
this.apply(operation);
|
|
156
|
+
return this.committed(this.memberResult(operation));
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
/** Human edit of one Member's mutable facts; identity, preset, and lifecycle state are preserved. */
|
|
160
|
+
updateMember(request) {
|
|
161
|
+
return this.enqueue(async () => {
|
|
162
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
163
|
+
if (existing !== undefined) {
|
|
164
|
+
this.assertSameMemberUpdate(existing, request);
|
|
165
|
+
return this.resolved(this.memberResult(existing));
|
|
166
|
+
}
|
|
167
|
+
this.assertHumanActor(request.actor);
|
|
168
|
+
const prior = this.requireMember(request.memberId);
|
|
169
|
+
if (prior.state === 'inactive')
|
|
170
|
+
throw new Error(`Agent Member '${prior.memberId}' is inactive and can no longer be edited`);
|
|
171
|
+
const handle = request.handle.trim();
|
|
172
|
+
const description = request.description.trim();
|
|
173
|
+
if (handle === '')
|
|
174
|
+
throw new Error('member handle must not be empty');
|
|
175
|
+
if (description === '')
|
|
176
|
+
throw new Error('member description must not be empty');
|
|
177
|
+
if (handle !== prior.handle)
|
|
178
|
+
this.assertHandleAvailable(prior.workspaceId, handle, prior.memberId);
|
|
179
|
+
this.assertModelSelection(request.model);
|
|
180
|
+
// An absent model must CLEAR any override (inherit the Host default);
|
|
181
|
+
// spreading `prior` verbatim would silently keep the pinned selection.
|
|
182
|
+
const { model: _priorModel, ...priorWithoutModel } = prior;
|
|
183
|
+
const member = Object.freeze({
|
|
184
|
+
...priorWithoutModel, handle, description,
|
|
185
|
+
...(request.model === undefined ? {} : { model: Object.freeze({ ...request.model }) }),
|
|
186
|
+
});
|
|
187
|
+
const operation = Object.freeze({
|
|
188
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/member-updated',
|
|
189
|
+
data: Object.freeze({ member }),
|
|
190
|
+
});
|
|
191
|
+
await this.table.put(operation.operationId, operation);
|
|
192
|
+
this.apply(operation);
|
|
193
|
+
return this.committed(this.memberResult(operation));
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
suspendMember(request) {
|
|
197
|
+
return this.setMemberState(request, 'suspended');
|
|
198
|
+
}
|
|
199
|
+
resumeMember(request) {
|
|
200
|
+
return this.setMemberState(request, 'enabled');
|
|
201
|
+
}
|
|
202
|
+
getMember(memberId) {
|
|
203
|
+
return this.state.members.get(memberId);
|
|
204
|
+
}
|
|
205
|
+
listMembers() {
|
|
206
|
+
return Object.freeze([...this.state.members.values()]);
|
|
207
|
+
}
|
|
208
|
+
joinChannel(request) {
|
|
209
|
+
return this.enqueue(async () => {
|
|
210
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
211
|
+
if (existing !== undefined) {
|
|
212
|
+
this.assertSameChannelJoin(existing, request);
|
|
213
|
+
return this.resolved(this.joinResult(existing));
|
|
214
|
+
}
|
|
215
|
+
this.assertHumanActor(request.actor);
|
|
216
|
+
const channel = this.requireChannel(request.workspaceId, request.channelRef);
|
|
217
|
+
const member = this.requireMember(request.memberId);
|
|
218
|
+
if (member.workspaceId !== request.workspaceId)
|
|
219
|
+
throw new Error('Member and Channel must belong to one Workspace');
|
|
220
|
+
if (member.state !== 'enabled')
|
|
221
|
+
throw new Error(`Agent Member '${member.memberId}' is ${member.state}; only enabled Members can join a Channel`);
|
|
222
|
+
if (this.isChannelMember(channel.channelRef, member.memberId))
|
|
223
|
+
throw new Error(`Agent Member '${member.memberId}' already belongs to Channel '${channel.channelRef}'`);
|
|
224
|
+
const operation = Object.freeze({
|
|
225
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/channel-member-added',
|
|
226
|
+
data: Object.freeze({ workspaceId: request.workspaceId, channelRef: channel.channelRef, memberId: member.memberId }),
|
|
227
|
+
});
|
|
228
|
+
await this.table.put(operation.operationId, operation);
|
|
229
|
+
this.apply(operation);
|
|
230
|
+
return this.committed(this.joinResult(operation));
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
removeChannelMember(request) {
|
|
234
|
+
return this.enqueue(async () => {
|
|
235
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
236
|
+
if (existing !== undefined) {
|
|
237
|
+
this.assertSameChannelMemberRemoval(existing, request);
|
|
238
|
+
return this.resolved(this.channelMemberRemovalResult(existing));
|
|
239
|
+
}
|
|
240
|
+
this.assertHumanActor(request.actor);
|
|
241
|
+
const channel = this.requireChannel(request.workspaceId, request.channelRef);
|
|
242
|
+
const member = this.requireMember(request.memberId);
|
|
243
|
+
if (member.workspaceId !== channel.workspaceId || !this.isChannelMember(channel.channelRef, member.memberId)) {
|
|
244
|
+
throw new Error(`Agent Member '${member.memberId}' is not a member of Channel '${channel.channelRef}'`);
|
|
245
|
+
}
|
|
246
|
+
const threadRefs = this.channelThreadRefs(channel.channelRef);
|
|
247
|
+
const releasedClaims = [...this.state.claims.values()]
|
|
248
|
+
.filter(claim => claim.owner === member.memberId && claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
249
|
+
.map(claim => Object.freeze({ ...claim, state: 'released' }));
|
|
250
|
+
const nextClaims = new Map(this.state.claims);
|
|
251
|
+
for (const claim of releasedClaims)
|
|
252
|
+
nextClaims.set(claim.claimRef, claim);
|
|
253
|
+
const sequence = this.nextSequence();
|
|
254
|
+
const activities = this.releaseSummaries(releasedClaims, member.memberId, sequence);
|
|
255
|
+
const threads = this.threadsForActivities(activities);
|
|
256
|
+
const tasks = this.tasksForClaims(releasedClaims, nextClaims);
|
|
257
|
+
const inbox = this.removeMemberThreadInbox(member.memberId, threadRefs);
|
|
258
|
+
const operation = Object.freeze({
|
|
259
|
+
...this.operationBase(request, sequence), kind: 'team/channel-member-removed',
|
|
260
|
+
data: Object.freeze({ workspaceId: request.workspaceId, channelRef: channel.channelRef, memberId: member.memberId,
|
|
261
|
+
claims: Object.freeze(releasedClaims), activities, tasks, threads, inbox }),
|
|
262
|
+
});
|
|
263
|
+
await this.table.put(operation.operationId, operation);
|
|
264
|
+
this.apply(operation);
|
|
265
|
+
return this.committed(this.channelMemberRemovalResult(operation));
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
sendMessage(request) {
|
|
269
|
+
return this.enqueue(async () => {
|
|
270
|
+
const recipients = this.normalizeRecipients(request.actor, request.recipients);
|
|
271
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
272
|
+
if (existing !== undefined) {
|
|
273
|
+
this.assertSameMessage(existing, request, recipients);
|
|
274
|
+
return this.resolved(this.messageResult(existing));
|
|
275
|
+
}
|
|
276
|
+
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
277
|
+
const channel = this.requireChannel(request.workspaceId, request.channelRef);
|
|
278
|
+
if (actor.kind === 'member')
|
|
279
|
+
this.requireMemberChannel(this.requireMember(actor.memberId), channel.channelRef);
|
|
280
|
+
const body = request.body.trim();
|
|
281
|
+
if (body === '')
|
|
282
|
+
throw new Error('message body must not be empty');
|
|
283
|
+
this.assertMentionTargets(channel, recipients);
|
|
284
|
+
// Top-level Task creation is open to every actor: mentioned Members join
|
|
285
|
+
// the new Thread as followers. Only existing-Thread invitations stay
|
|
286
|
+
// Human-gated (reply path).
|
|
287
|
+
const sequence = this.nextSequence();
|
|
288
|
+
const base = this.operationBase(request, sequence);
|
|
289
|
+
const taskRef = this.ref('task');
|
|
290
|
+
const threadRef = this.ref('thread');
|
|
291
|
+
const task = Object.freeze({ taskRef, channelRef: channel.channelRef, threadRef, status: 'todo', resolution: 'open' });
|
|
292
|
+
const thread = Object.freeze({ threadRef, taskRef, revision: sequence });
|
|
293
|
+
const message = Object.freeze({
|
|
294
|
+
messageRef: this.ref('message'), channelRef: channel.channelRef, threadRef, taskRef,
|
|
295
|
+
sender: request.actor.memberId, body, topLevel: true, sequence, occurredAt: base.occurredAt,
|
|
296
|
+
});
|
|
297
|
+
const started = [this.startAttention(request.actor.memberId, threadRef, sequence),
|
|
298
|
+
...recipients.filter(memberId => this.state.members.has(memberId))
|
|
299
|
+
.map(memberId => this.startAttention(memberId, threadRef, sequence))];
|
|
300
|
+
const inbox = this.messageInboxDelta(message, request.actor.memberId, recipients, started);
|
|
301
|
+
const operation = Object.freeze({
|
|
302
|
+
...base, kind: 'team/message-sent',
|
|
303
|
+
data: Object.freeze({ workspaceId: request.workspaceId, mentions: recipients, message, task, thread, inbox }),
|
|
304
|
+
});
|
|
305
|
+
await this.table.put(operation.operationId, operation);
|
|
306
|
+
this.apply(operation);
|
|
307
|
+
return this.committed(this.messageResult(operation));
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
reply(request) {
|
|
311
|
+
return this.enqueue(async () => {
|
|
312
|
+
const recipients = this.normalizeRecipients(request.actor, request.recipients);
|
|
313
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
314
|
+
if (existing !== undefined) {
|
|
315
|
+
this.assertSameReply(existing, request, recipients);
|
|
316
|
+
return this.resolved(this.replyResult(existing));
|
|
317
|
+
}
|
|
318
|
+
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
319
|
+
const task = this.requireTask(request.workspaceId, request.taskRef);
|
|
320
|
+
const thread = this.requireThread(task.threadRef);
|
|
321
|
+
if (actor.kind === 'member')
|
|
322
|
+
this.requireMemberChannel(this.requireMember(actor.memberId), task.channelRef);
|
|
323
|
+
const body = request.body.trim();
|
|
324
|
+
if (body === '')
|
|
325
|
+
throw new Error('message body must not be empty');
|
|
326
|
+
this.assertMentionTargets(this.requireChannel(request.workspaceId, task.channelRef), recipients);
|
|
327
|
+
const unread = this.unreadFor(actor.memberId, thread.threadRef);
|
|
328
|
+
if (unread.length > 0)
|
|
329
|
+
return this.resolved(this.unreadRequired(task, thread, unread));
|
|
330
|
+
if (request.baseRevision !== thread.revision)
|
|
331
|
+
return this.resolved(this.staleRevision(task, thread, request.baseRevision));
|
|
332
|
+
if (task.resolution === 'closed')
|
|
333
|
+
throw new Error(`Task '${task.taskRef}' is closed; reopen it before replying`);
|
|
334
|
+
const unfollowedAgents = recipients.filter(memberId => this.state.members.has(memberId) && !this.isFollowing(thread.threadRef, memberId));
|
|
335
|
+
if (unfollowedAgents.length > 0 && actor.kind === 'member') {
|
|
336
|
+
return this.resolved(this.memberNotFollowing(request.workspaceId, task.channelRef, unfollowedAgents, task, thread));
|
|
337
|
+
}
|
|
338
|
+
if (unfollowedAgents.length > 0 && request.confirmationToken === undefined) {
|
|
339
|
+
return this.resolved(this.issueConfirmation(request.actor, request.workspaceId, task.channelRef, body, recipients, task, thread));
|
|
340
|
+
}
|
|
341
|
+
if (request.confirmationToken !== undefined) {
|
|
342
|
+
this.consumeConfirmation(request.confirmationToken, request.actor, request.workspaceId, task.channelRef, task, thread, body, recipients);
|
|
343
|
+
}
|
|
344
|
+
const sequence = this.nextSequence();
|
|
345
|
+
const base = this.operationBase(request, sequence);
|
|
346
|
+
const message = Object.freeze({
|
|
347
|
+
messageRef: this.ref('message'), channelRef: task.channelRef, threadRef: task.threadRef,
|
|
348
|
+
taskRef: task.taskRef, sender: request.actor.memberId, body, topLevel: false, sequence, occurredAt: base.occurredAt,
|
|
349
|
+
});
|
|
350
|
+
const nextThread = Object.freeze({ ...thread, revision: sequence });
|
|
351
|
+
const started = unfollowedAgents.map(memberId => this.startAttention(memberId, thread.threadRef, sequence));
|
|
352
|
+
const inbox = this.messageInboxDelta(message, request.actor.memberId, recipients, started);
|
|
353
|
+
const operation = Object.freeze({
|
|
354
|
+
...base, kind: 'team/thread-replied',
|
|
355
|
+
data: Object.freeze({ workspaceId: request.workspaceId, baseRevision: request.baseRevision,
|
|
356
|
+
mentions: recipients, message, task, thread: nextThread, inbox }),
|
|
357
|
+
});
|
|
358
|
+
await this.table.put(operation.operationId, operation);
|
|
359
|
+
this.apply(operation);
|
|
360
|
+
return this.committed(this.replyResult(operation));
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
changeClaim(request) {
|
|
364
|
+
return this.enqueue(async () => {
|
|
365
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
366
|
+
if (existing !== undefined) {
|
|
367
|
+
this.assertSameClaim(existing, request);
|
|
368
|
+
return this.resolved(this.claimResult(existing));
|
|
369
|
+
}
|
|
370
|
+
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
371
|
+
const task = this.requireTask(request.workspaceId, request.taskRef);
|
|
372
|
+
const thread = this.requireThread(task.threadRef);
|
|
373
|
+
if (actor.kind === 'member')
|
|
374
|
+
this.requireMemberChannel(this.requireMember(actor.memberId), task.channelRef);
|
|
375
|
+
const unread = this.unreadFor(actor.memberId, thread.threadRef);
|
|
376
|
+
if (unread.length > 0)
|
|
377
|
+
return this.resolved(this.unreadRequired(task, thread, unread));
|
|
378
|
+
if (request.baseRevision !== thread.revision)
|
|
379
|
+
return this.resolved(this.staleRevision(task, thread, request.baseRevision));
|
|
380
|
+
if (task.resolution !== 'open')
|
|
381
|
+
throw new Error(`Task '${task.taskRef}' is ${task.status}; reopen it before changing Claims`);
|
|
382
|
+
if (actor.kind !== 'member')
|
|
383
|
+
throw new Error('Human cannot change an Agent Claim');
|
|
384
|
+
let claim;
|
|
385
|
+
let kind;
|
|
386
|
+
let activityKind;
|
|
387
|
+
if (request.action === 'claim') {
|
|
388
|
+
if (request.claimRef !== undefined)
|
|
389
|
+
throw new Error('claim action does not accept claimRef');
|
|
390
|
+
const direction = request.direction?.trim() ?? '';
|
|
391
|
+
const normalizedDirection = this.normalizeDirection(direction);
|
|
392
|
+
if (normalizedDirection === '')
|
|
393
|
+
throw new Error('claim direction must not be empty');
|
|
394
|
+
if ([...this.state.claims.values()].some(candidate => candidate.taskRef === task.taskRef
|
|
395
|
+
&& candidate.state === 'active' && candidate.normalizedDirection === normalizedDirection)) {
|
|
396
|
+
throw new Error(`Direction '${direction}' already has an active Claim`);
|
|
397
|
+
}
|
|
398
|
+
claim = Object.freeze({ claimRef: this.ref('claim'), taskRef: task.taskRef, threadRef: task.threadRef,
|
|
399
|
+
owner: actor.memberId, direction, normalizedDirection, state: 'active' });
|
|
400
|
+
kind = 'team/claim-created';
|
|
401
|
+
activityKind = 'claim';
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
if (request.direction !== undefined)
|
|
405
|
+
throw new Error(`${request.action} action does not accept direction`);
|
|
406
|
+
const previous = request.claimRef === undefined ? undefined : this.state.claims.get(request.claimRef);
|
|
407
|
+
if (previous === undefined) {
|
|
408
|
+
throw new Error(`unknown Claim '${request.claimRef ?? ''}'${request.claimRef === undefined ? '' : this.unknownRefHint(request.claimRef, 'claim', 'Claim')}`);
|
|
409
|
+
}
|
|
410
|
+
if (previous.taskRef !== task.taskRef || previous.owner !== actor.memberId) {
|
|
411
|
+
throw new Error('Member can modify only its own Claim on this Task');
|
|
412
|
+
}
|
|
413
|
+
if (previous.state !== 'active')
|
|
414
|
+
throw new Error(`Claim '${previous.claimRef}' is already ${previous.state}`);
|
|
415
|
+
claim = Object.freeze({ ...previous, state: request.action === 'done' ? 'done' : 'released' });
|
|
416
|
+
kind = request.action === 'done' ? 'team/claim-done' : 'team/claim-released';
|
|
417
|
+
activityKind = request.action;
|
|
418
|
+
}
|
|
419
|
+
const sequence = this.nextSequence();
|
|
420
|
+
const projected = new Map(this.state.claims).set(claim.claimRef, claim);
|
|
421
|
+
const nextTask = Object.freeze({ ...task, status: this.deriveTaskStatus(task.taskRef, projected.values()) });
|
|
422
|
+
const nextThread = Object.freeze({ ...thread, revision: sequence });
|
|
423
|
+
const activity = Object.freeze({
|
|
424
|
+
activityRef: this.ref('activity'), kind: activityKind, taskRef: task.taskRef,
|
|
425
|
+
threadRef: task.threadRef, actor: actor.memberId, claimRef: claim.claimRef, sequence,
|
|
426
|
+
});
|
|
427
|
+
const ownAttention = request.action === 'claim' && actor.kind === 'member' && !this.isFollowing(thread.threadRef, actor.memberId)
|
|
428
|
+
? [this.startAttention(actor.memberId, thread.threadRef, sequence)] : [];
|
|
429
|
+
const inbox = this.inboxDelta(ownAttention);
|
|
430
|
+
const operation = Object.freeze({
|
|
431
|
+
...this.operationBase(request, sequence), kind,
|
|
432
|
+
data: Object.freeze({ workspaceId: request.workspaceId, baseRevision: request.baseRevision,
|
|
433
|
+
activity, claim, task: nextTask, thread: nextThread, inbox }),
|
|
434
|
+
});
|
|
435
|
+
await this.table.put(operation.operationId, operation);
|
|
436
|
+
this.apply(operation);
|
|
437
|
+
return this.committed(this.claimResult(operation));
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
changeTask(request) {
|
|
441
|
+
return this.enqueue(async () => {
|
|
442
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
443
|
+
if (existing !== undefined) {
|
|
444
|
+
this.assertSameTask(existing, request);
|
|
445
|
+
return this.resolved(this.taskResult(existing));
|
|
446
|
+
}
|
|
447
|
+
this.assertHumanActor(request.actor);
|
|
448
|
+
const task = this.requireTask(request.workspaceId, request.taskRef);
|
|
449
|
+
const thread = this.requireThread(task.threadRef);
|
|
450
|
+
const unread = this.unreadFor(request.actor.memberId, thread.threadRef);
|
|
451
|
+
if (unread.length > 0)
|
|
452
|
+
return this.resolved(this.unreadRequired(task, thread, unread));
|
|
453
|
+
if (request.baseRevision !== thread.revision)
|
|
454
|
+
return this.resolved(this.staleRevision(task, thread, request.baseRevision));
|
|
455
|
+
if (request.action === 'accept' && task.resolution !== 'open')
|
|
456
|
+
throw new Error(`Task '${task.taskRef}' is already ${task.resolution}`);
|
|
457
|
+
if (request.action === 'close' && task.resolution === 'closed')
|
|
458
|
+
throw new Error(`Task '${task.taskRef}' is already closed`);
|
|
459
|
+
if (request.action === 'accept' && task.status !== 'in_review')
|
|
460
|
+
throw new Error(`Task '${task.taskRef}' must be in_review before acceptance`);
|
|
461
|
+
if (request.action === 'reopen' && task.resolution === 'open')
|
|
462
|
+
throw new Error(`Task '${task.taskRef}' is already open`);
|
|
463
|
+
const sequence = this.nextSequence();
|
|
464
|
+
const claims = [...this.state.claims.values()].filter(claim => claim.taskRef === task.taskRef).map(claim => request.action === 'close' && claim.state === 'active' ? Object.freeze({ ...claim, state: 'released' }) : claim);
|
|
465
|
+
const releasedClaims = claims.filter(claim => claim.state === 'released' && this.state.claims.get(claim.claimRef)?.state === 'active');
|
|
466
|
+
const resolution = request.action === 'reopen' ? 'open' : request.action === 'accept' ? 'accepted' : 'closed';
|
|
467
|
+
const status = resolution === 'accepted' ? 'done' : resolution === 'closed' ? 'closed'
|
|
468
|
+
: this.deriveTaskStatus(task.taskRef, claims);
|
|
469
|
+
const nextTask = Object.freeze({ ...task, resolution, status });
|
|
470
|
+
const nextThread = Object.freeze({ ...thread, revision: sequence });
|
|
471
|
+
const activity = Object.freeze({ activityRef: this.ref('activity'), kind: request.action,
|
|
472
|
+
taskRef: task.taskRef, threadRef: task.threadRef, actor: request.actor.memberId, sequence,
|
|
473
|
+
...(releasedClaims.length === 0 ? {} : { releasedClaimRefs: Object.freeze(releasedClaims.map(claim => claim.claimRef)) }) });
|
|
474
|
+
const inbox = request.action === 'close'
|
|
475
|
+
? this.closeThreadInbox(activity, thread.threadRef)
|
|
476
|
+
: request.action === 'reopen'
|
|
477
|
+
? this.reopenThreadInbox(activity, thread.threadRef)
|
|
478
|
+
: this.inboxDelta();
|
|
479
|
+
const operation = Object.freeze({
|
|
480
|
+
...this.operationBase(request, sequence), kind: 'team/task-changed',
|
|
481
|
+
data: Object.freeze({ workspaceId: request.workspaceId, baseRevision: request.baseRevision,
|
|
482
|
+
activity, task: nextTask, thread: nextThread, claims: Object.freeze(claims), inbox }),
|
|
483
|
+
});
|
|
484
|
+
await this.table.put(operation.operationId, operation);
|
|
485
|
+
this.apply(operation);
|
|
486
|
+
return this.committed(this.taskResult(operation));
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
removeMember(request) {
|
|
490
|
+
return this.enqueue(async () => {
|
|
491
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
492
|
+
if (existing !== undefined) {
|
|
493
|
+
this.assertSameRemoval(existing, request);
|
|
494
|
+
return this.resolved(this.removalResult(existing));
|
|
495
|
+
}
|
|
496
|
+
this.assertHumanActor(request.actor);
|
|
497
|
+
const member = this.requireMember(request.memberId);
|
|
498
|
+
if (member.state === 'inactive')
|
|
499
|
+
throw new Error(`Agent Member '${member.memberId}' is already inactive`);
|
|
500
|
+
const nextMember = Object.freeze({ ...member, state: 'inactive' });
|
|
501
|
+
const releasedClaims = [...this.state.claims.values()].filter(claim => claim.owner === member.memberId && claim.state === 'active')
|
|
502
|
+
.map(claim => Object.freeze({ ...claim, state: 'released' }));
|
|
503
|
+
const projectedClaims = new Map(this.state.claims);
|
|
504
|
+
for (const claim of releasedClaims)
|
|
505
|
+
projectedClaims.set(claim.claimRef, claim);
|
|
506
|
+
const sequence = this.nextSequence();
|
|
507
|
+
const activities = this.releaseSummaries(releasedClaims, member.memberId, sequence);
|
|
508
|
+
const threads = this.threadsForActivities(activities);
|
|
509
|
+
const tasks = this.tasksForClaims(releasedClaims, projectedClaims);
|
|
510
|
+
const threadRefs = new Set([...this.state.tasks.values()].map(task => task.threadRef));
|
|
511
|
+
const inbox = this.removeMemberThreadInbox(member.memberId, threadRefs);
|
|
512
|
+
const operation = Object.freeze({
|
|
513
|
+
...this.operationBase(request, sequence), kind: 'team/member-removed',
|
|
514
|
+
data: Object.freeze({ member: nextMember, claims: Object.freeze(releasedClaims), activities, tasks, threads, inbox }),
|
|
515
|
+
});
|
|
516
|
+
await this.table.put(operation.operationId, operation);
|
|
517
|
+
this.apply(operation);
|
|
518
|
+
this.confirmations.clear();
|
|
519
|
+
return this.committed(this.removalResult(operation));
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
changeAttention(request) {
|
|
523
|
+
return this.enqueue(async () => {
|
|
524
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
525
|
+
if (existing !== undefined) {
|
|
526
|
+
this.assertSameAttention(existing, request);
|
|
527
|
+
return this.resolved(this.attentionResult(existing));
|
|
528
|
+
}
|
|
529
|
+
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
530
|
+
const task = this.requireTask(request.workspaceId, request.taskRef);
|
|
531
|
+
const thread = this.requireThread(task.threadRef);
|
|
532
|
+
if (actor.kind === 'member')
|
|
533
|
+
this.requireMemberChannel(this.requireMember(actor.memberId), task.channelRef);
|
|
534
|
+
const current = this.attentionFor(actor.memberId, thread.threadRef);
|
|
535
|
+
if (request.action === 'follow') {
|
|
536
|
+
if (task.resolution === 'closed')
|
|
537
|
+
throw new Error(`Task '${task.taskRef}' is closed; reopen it before following`);
|
|
538
|
+
if (current !== undefined)
|
|
539
|
+
throw new Error(`Member is already following Thread '${thread.threadRef}'`);
|
|
540
|
+
const attention = this.followAttention(actor.memberId, thread.threadRef);
|
|
541
|
+
const operation = Object.freeze({
|
|
542
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/thread-attention-changed',
|
|
543
|
+
data: Object.freeze({ workspaceId: request.workspaceId, action: 'follow', memberId: actor.memberId,
|
|
544
|
+
task, thread, inbox: this.inboxDelta([attention]) }),
|
|
545
|
+
});
|
|
546
|
+
await this.table.put(operation.operationId, operation);
|
|
547
|
+
this.apply(operation);
|
|
548
|
+
return this.committed(this.attentionResult(operation));
|
|
549
|
+
}
|
|
550
|
+
if (current === undefined)
|
|
551
|
+
throw new Error(`Member is already unfollowed from Thread '${thread.threadRef}'`);
|
|
552
|
+
if (this.hasActiveClaim(actor.memberId, task.taskRef))
|
|
553
|
+
throw new Error('Member cannot unfollow while owning an active Claim');
|
|
554
|
+
const operation = Object.freeze({
|
|
555
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/thread-attention-changed',
|
|
556
|
+
data: Object.freeze({ workspaceId: request.workspaceId, action: 'unfollow', memberId: actor.memberId,
|
|
557
|
+
task, thread, inbox: this.inboxDelta([], [{ memberId: actor.memberId, threadRef: thread.threadRef }], [], this.directMarkersFor(actor.memberId, thread.threadRef)) }),
|
|
558
|
+
});
|
|
559
|
+
await this.table.put(operation.operationId, operation);
|
|
560
|
+
this.apply(operation);
|
|
561
|
+
return this.committed(this.attentionResult(operation));
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
attentionStatus(actor, request) {
|
|
565
|
+
const authorized = this.assertActorForWorkspace(actor, request.workspaceId);
|
|
566
|
+
const task = this.requireTask(request.workspaceId, request.taskRef);
|
|
567
|
+
if (authorized.kind === 'member')
|
|
568
|
+
this.requireMemberChannel(this.requireMember(authorized.memberId), task.channelRef);
|
|
569
|
+
const thread = this.requireThread(task.threadRef);
|
|
570
|
+
const attention = this.attentionFor(authorized.memberId, thread.threadRef);
|
|
571
|
+
return attention === undefined
|
|
572
|
+
? Object.freeze({ task, thread })
|
|
573
|
+
: Object.freeze({ task, thread, attention });
|
|
574
|
+
}
|
|
575
|
+
inbox(actor, request) {
|
|
576
|
+
const authorized = this.assertActorForWorkspace(actor, request.workspaceId);
|
|
577
|
+
const limit = request.limit ?? 50;
|
|
578
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
579
|
+
throw new Error('inbox limit must be an integer between 1 and 100');
|
|
580
|
+
const items = [];
|
|
581
|
+
for (const task of this.state.tasks.values()) {
|
|
582
|
+
if (this.state.channels.get(task.channelRef)?.workspaceId !== request.workspaceId)
|
|
583
|
+
continue;
|
|
584
|
+
if (authorized.kind === 'member' && !this.isChannelMember(task.channelRef, authorized.memberId))
|
|
585
|
+
continue;
|
|
586
|
+
const thread = this.requireThread(task.threadRef);
|
|
587
|
+
const unread = this.unreadFor(authorized.memberId, thread.threadRef);
|
|
588
|
+
if (unread.length === 0)
|
|
589
|
+
continue;
|
|
590
|
+
const directCount = unread.filter(item => item.direct).length;
|
|
591
|
+
const attention = this.attentionFor(authorized.memberId, thread.threadRef);
|
|
592
|
+
items.push(attention === undefined
|
|
593
|
+
? Object.freeze({ channelRef: task.channelRef, task, thread, unreadCount: unread.length, directCount,
|
|
594
|
+
newestSequence: unread.at(-1).fact.sequence })
|
|
595
|
+
: Object.freeze({ channelRef: task.channelRef, task, thread, unreadCount: unread.length, directCount,
|
|
596
|
+
newestSequence: unread.at(-1).fact.sequence, attention }));
|
|
597
|
+
}
|
|
598
|
+
items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
|
|
599
|
+
const selected = items.slice(0, limit);
|
|
600
|
+
return Object.freeze({ items: Object.freeze(selected),
|
|
601
|
+
totalUnreadCount: items.reduce((sum, item) => sum + item.unreadCount, 0),
|
|
602
|
+
totalDirectCount: items.reduce((sum, item) => sum + item.directCount, 0) });
|
|
603
|
+
}
|
|
604
|
+
/** Model-visible notification material derived from the recipient's current durable unread state. */
|
|
605
|
+
notificationFacts(memberId, request) {
|
|
606
|
+
const member = this.requireMember(memberId);
|
|
607
|
+
if (member.workspaceId !== request.workspaceId)
|
|
608
|
+
throw new Error('Member cannot inspect another Workspace');
|
|
609
|
+
const inbox = this.inbox({ kind: 'member', memberId, handle: member.handle }, request);
|
|
610
|
+
return Object.freeze(inbox.items.map(item => Object.freeze({ item,
|
|
611
|
+
facts: this.unreadFor(memberId, item.thread.threadRef) })));
|
|
612
|
+
}
|
|
613
|
+
readThread(request) {
|
|
614
|
+
return this.enqueue(async () => {
|
|
615
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
616
|
+
if (existing !== undefined) {
|
|
617
|
+
this.assertSameThreadRead(existing, request);
|
|
618
|
+
return this.resolved(this.threadReadResult(existing));
|
|
619
|
+
}
|
|
620
|
+
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
621
|
+
const prepared = this.prepareRead(actor.memberId, request.workspaceId, request.taskRef);
|
|
622
|
+
const operation = Object.freeze({
|
|
623
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read',
|
|
624
|
+
data: Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId, task: prepared.task,
|
|
625
|
+
thread: prepared.thread, claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions,
|
|
626
|
+
facts: prepared.facts,
|
|
627
|
+
readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
|
|
628
|
+
...(prepared.attention === undefined ? {} : { attention: prepared.attention }), inbox: prepared.inbox }),
|
|
629
|
+
});
|
|
630
|
+
await this.table.put(operation.operationId, operation);
|
|
631
|
+
this.apply(operation);
|
|
632
|
+
return this.committed(this.threadReadResult(operation));
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
threadObservations(actor, request) {
|
|
636
|
+
this.assertHumanActor(actor);
|
|
637
|
+
const task = this.requireTask(request.workspaceId, request.taskRef);
|
|
638
|
+
const limit = request.limit ?? 50;
|
|
639
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
640
|
+
throw new Error('observation limit must be an integer between 1 and 100');
|
|
641
|
+
const threadRef = task.threadRef;
|
|
642
|
+
const current = new Map();
|
|
643
|
+
const observations = [];
|
|
644
|
+
for (const operation of this.state.ordered) {
|
|
645
|
+
const delta = this.attentionDelta(operation);
|
|
646
|
+
if (delta === undefined)
|
|
647
|
+
continue;
|
|
648
|
+
for (const removed of delta.attention.removed) {
|
|
649
|
+
if (removed.threadRef !== threadRef)
|
|
650
|
+
continue;
|
|
651
|
+
if (!current.delete(removed.memberId))
|
|
652
|
+
continue;
|
|
653
|
+
observations.push(Object.freeze({ sequence: operation.sequence, threadRef, taskRef: task.taskRef,
|
|
654
|
+
memberId: removed.memberId, action: 'unfollow' }));
|
|
655
|
+
}
|
|
656
|
+
for (const next of delta.attention.set) {
|
|
657
|
+
if (next.threadRef !== threadRef)
|
|
658
|
+
continue;
|
|
659
|
+
const prior = current.get(next.memberId);
|
|
660
|
+
current.set(next.memberId, next);
|
|
661
|
+
// Task creation establishes initial Attention; reads only advance its watermark.
|
|
662
|
+
if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
|
|
663
|
+
continue;
|
|
664
|
+
observations.push(Object.freeze({ sequence: operation.sequence, threadRef, taskRef: task.taskRef,
|
|
665
|
+
memberId: next.memberId, action: 'follow' }));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
return Object.freeze({ items: Object.freeze(observations.slice(-limit)) });
|
|
669
|
+
}
|
|
670
|
+
attentionDelta(operation) {
|
|
671
|
+
switch (operation.kind) {
|
|
672
|
+
case 'team/channel-member-removed':
|
|
673
|
+
case 'team/message-sent':
|
|
674
|
+
case 'team/thread-replied':
|
|
675
|
+
case 'team/claim-created':
|
|
676
|
+
case 'team/claim-done':
|
|
677
|
+
case 'team/claim-released':
|
|
678
|
+
case 'team/task-changed':
|
|
679
|
+
case 'team/thread-attention-changed':
|
|
680
|
+
case 'team/thread-read':
|
|
681
|
+
case 'team/member-removed':
|
|
682
|
+
return operation.data.inbox;
|
|
683
|
+
default:
|
|
684
|
+
return undefined;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
threadHistory(actor, request) {
|
|
688
|
+
const authorized = this.assertActorForWorkspace(actor, request.workspaceId);
|
|
689
|
+
const task = this.requireTask(request.workspaceId, request.taskRef);
|
|
690
|
+
if (authorized.kind === 'member')
|
|
691
|
+
this.requireMemberChannel(this.requireMember(authorized.memberId), task.channelRef);
|
|
692
|
+
const thread = this.requireThread(task.threadRef);
|
|
693
|
+
const limit = request.limit ?? 20;
|
|
694
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
695
|
+
throw new Error('history limit must be an integer between 1 and 100');
|
|
696
|
+
const before = request.beforeSequence ?? this.currentTail(thread.threadRef) + 1;
|
|
697
|
+
if (!Number.isInteger(before) || before < 1)
|
|
698
|
+
throw new Error('beforeSequence must be a positive integer');
|
|
699
|
+
const all = this.threadFacts(thread.threadRef).filter(fact => fact.sequence < before);
|
|
700
|
+
const facts = all.slice(-limit);
|
|
701
|
+
const anchor = this.threadAnchor(task);
|
|
702
|
+
return Object.freeze({ task, thread, anchor, anchorMentions: this.state.mentionsByMessage.get(anchor.messageRef) ?? [],
|
|
703
|
+
claims: this.claimsForTask(task.taskRef), facts: Object.freeze(facts),
|
|
704
|
+
cursor: facts.length === 0 ? before : facts[0].sequence, hasMore: all.length > facts.length });
|
|
705
|
+
}
|
|
706
|
+
listClaims(actor, request) {
|
|
707
|
+
const authorized = this.assertActorForWorkspace(actor, request.workspaceId);
|
|
708
|
+
const task = this.requireTask(request.workspaceId, request.taskRef);
|
|
709
|
+
if (authorized.kind === 'member')
|
|
710
|
+
this.requireMemberChannel(this.requireMember(authorized.memberId), task.channelRef);
|
|
711
|
+
return Object.freeze({ task, thread: this.requireThread(task.threadRef), claims: this.claimsForTask(task.taskRef) });
|
|
712
|
+
}
|
|
713
|
+
getTask(taskRef) {
|
|
714
|
+
return this.state.tasks.get(taskRef);
|
|
715
|
+
}
|
|
716
|
+
view(request, memberId) {
|
|
717
|
+
const limit = request.limit ?? 20;
|
|
718
|
+
const direction = request.direction ?? 'after';
|
|
719
|
+
const cursor = request.cursor ?? 0;
|
|
720
|
+
if (direction !== 'after' && direction !== 'before')
|
|
721
|
+
throw new Error('direction must be after or before');
|
|
722
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
723
|
+
throw new Error('limit must be an integer between 1 and 100');
|
|
724
|
+
if (!Number.isInteger(cursor) || cursor < 0)
|
|
725
|
+
throw new Error('cursor must be a non-negative integer sequence');
|
|
726
|
+
if (memberId !== undefined) {
|
|
727
|
+
const member = this.requireMember(memberId);
|
|
728
|
+
if (member.workspaceId !== request.workspaceId)
|
|
729
|
+
throw new Error('Member cannot view another Workspace');
|
|
730
|
+
}
|
|
731
|
+
if (request.channelRef !== undefined) {
|
|
732
|
+
this.requireChannel(request.workspaceId, request.channelRef);
|
|
733
|
+
if (memberId !== undefined && !this.isChannelMember(request.channelRef, memberId))
|
|
734
|
+
throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${request.channelRef}'`);
|
|
735
|
+
}
|
|
736
|
+
if (request.threadRef !== undefined) {
|
|
737
|
+
const thread = this.requireThread(request.threadRef);
|
|
738
|
+
const task = this.requireTask(request.workspaceId, thread.taskRef);
|
|
739
|
+
if (request.channelRef !== undefined && task.channelRef !== request.channelRef)
|
|
740
|
+
throw new Error(`Thread '${request.threadRef}' does not belong to Channel '${request.channelRef}'`);
|
|
741
|
+
if (memberId !== undefined && !this.isChannelMember(task.channelRef, memberId))
|
|
742
|
+
throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${task.channelRef}'`);
|
|
743
|
+
}
|
|
744
|
+
const channels = [...this.state.channels.values()].filter(channel => channel.workspaceId === request.workspaceId
|
|
745
|
+
&& (memberId === undefined || this.isChannelMember(channel.channelRef, memberId)));
|
|
746
|
+
const channelRefs = new Set(channels.map(channel => channel.channelRef));
|
|
747
|
+
const allFacts = this.state.orderedFacts.filter(fact => {
|
|
748
|
+
const task = fact.kind === 'message' ? this.state.tasks.get(fact.message.taskRef) : this.state.tasks.get(fact.activity.taskRef);
|
|
749
|
+
if (task === undefined || !channelRefs.has(task.channelRef))
|
|
750
|
+
return false;
|
|
751
|
+
if (request.channelRef !== undefined && task.channelRef !== request.channelRef)
|
|
752
|
+
return false;
|
|
753
|
+
if (request.threadRef !== undefined && task.threadRef !== request.threadRef)
|
|
754
|
+
return false;
|
|
755
|
+
if (request.topLevelOnly && (fact.kind !== 'message' || !fact.message.topLevel))
|
|
756
|
+
return false;
|
|
757
|
+
if (request.includeActivities === false && fact.kind === 'activity')
|
|
758
|
+
return false;
|
|
759
|
+
return direction === 'before' ? fact.sequence < (request.cursor ?? this.state.ordered.length + 1) : fact.sequence > cursor;
|
|
760
|
+
});
|
|
761
|
+
const selected = direction === 'before' ? allFacts.slice(-limit) : allFacts.slice(0, limit);
|
|
762
|
+
const visibleTasks = [...this.state.tasks.values()].filter(task => channelRefs.has(task.channelRef)
|
|
763
|
+
&& (request.channelRef === undefined || task.channelRef === request.channelRef)
|
|
764
|
+
&& (request.threadRef === undefined || task.threadRef === request.threadRef));
|
|
765
|
+
const taskNumbers = this.taskNumbers(request.workspaceId, request.channelRef);
|
|
766
|
+
const items = selected.filter((fact) => fact.kind === 'message').map(fact => {
|
|
767
|
+
const message = fact.message;
|
|
768
|
+
const task = this.requireTask(request.workspaceId, message.taskRef);
|
|
769
|
+
const thread = this.requireThread(message.threadRef);
|
|
770
|
+
return Object.freeze({ message, mentions: fact.mentions, task, thread, taskNumber: taskNumbers.get(task.taskRef) ?? 0,
|
|
771
|
+
messageCount: this.state.messageCountByThread.get(thread.threadRef) ?? 0 });
|
|
772
|
+
});
|
|
773
|
+
const initialization = this.initialization();
|
|
774
|
+
const nextCursor = selected.length === 0 ? cursor : direction === 'before' ? selected[0].sequence : selected.at(-1).sequence;
|
|
775
|
+
return Object.freeze({
|
|
776
|
+
humanMemberId: initialization.data.humanMemberId,
|
|
777
|
+
channels: Object.freeze(channels),
|
|
778
|
+
members: Object.freeze([...this.state.memberships.entries()].filter(([channelRef]) => channelRefs.has(channelRef)).flatMap(([channelRef, ids]) => [...ids].filter(id => this.state.members.get(id)?.state !== 'inactive').sort().map(memberId => Object.freeze({ channelRef, memberId })))),
|
|
779
|
+
tasks: Object.freeze(visibleTasks),
|
|
780
|
+
threads: Object.freeze([...this.state.threads.values()].filter(thread => visibleTasks.some(task => task.taskRef === thread.taskRef))),
|
|
781
|
+
taskNumbers: Object.freeze(visibleTasks.map(task => Object.freeze({ taskRef: task.taskRef, taskNumber: taskNumbers.get(task.taskRef) ?? 0 }))),
|
|
782
|
+
items: Object.freeze(items),
|
|
783
|
+
claims: this.claimsForVisibleTasks(visibleTasks),
|
|
784
|
+
activities: Object.freeze(selected.filter((fact) => fact.kind === 'activity').map(fact => fact.activity)),
|
|
785
|
+
cursor: nextCursor,
|
|
786
|
+
hasMore: allFacts.length > selected.length,
|
|
787
|
+
});
|
|
788
|
+
}
|
|
789
|
+
status() {
|
|
790
|
+
const initialization = this.initialization();
|
|
791
|
+
return Object.freeze({ initialized: true, sequence: this.state.ordered.length, operationCount: this.state.ordered.length,
|
|
792
|
+
channelCount: this.state.channels.size, agentMemberCount: this.state.members.size, humanMemberId: initialization.data.humanMemberId });
|
|
793
|
+
}
|
|
794
|
+
validate() {
|
|
795
|
+
this.validateRecords(this.sortedRecords());
|
|
796
|
+
}
|
|
797
|
+
hasCommitted(requestId) {
|
|
798
|
+
return this.state.byRequest.has(requestId);
|
|
799
|
+
}
|
|
800
|
+
/** Return one stored committed operation by id. */
|
|
801
|
+
getOperation(operationId) {
|
|
802
|
+
return this.state.byOperation.get(operationId);
|
|
803
|
+
}
|
|
804
|
+
/** Scopes whose projections one committed operation invalidates; undefined wakes every waiter. */
|
|
805
|
+
changeScopesOf(operation) {
|
|
806
|
+
switch (operation.kind) {
|
|
807
|
+
case 'team/initialized':
|
|
808
|
+
return undefined;
|
|
809
|
+
case 'team/channel-created':
|
|
810
|
+
return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }];
|
|
811
|
+
case 'team/channel-updated':
|
|
812
|
+
// The rename is visible in the sidebar list and any open Channel/Thread header.
|
|
813
|
+
return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }, { kind: 'channel', channelRef: operation.data.channel.channelRef }];
|
|
814
|
+
case 'team/member-added':
|
|
815
|
+
case 'team/member-suspended':
|
|
816
|
+
case 'team/member-resumed':
|
|
817
|
+
case 'team/member-updated':
|
|
818
|
+
case 'team/member-removed':
|
|
819
|
+
return [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
|
|
820
|
+
case 'team/channel-member-added':
|
|
821
|
+
return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }, { kind: 'channel', channelRef: operation.data.channelRef }];
|
|
822
|
+
case 'team/channel-member-removed': {
|
|
823
|
+
const channelByTask = new Map(operation.data.tasks.map(task => [task.taskRef, task.channelRef]));
|
|
824
|
+
const scopes = [
|
|
825
|
+
{ kind: 'workspace', workspaceId: operation.data.workspaceId },
|
|
826
|
+
{ kind: 'channel', channelRef: operation.data.channelRef },
|
|
827
|
+
];
|
|
828
|
+
for (const activity of operation.data.activities) {
|
|
829
|
+
scopes.push({ kind: 'thread', threadRef: activity.threadRef });
|
|
830
|
+
const channelRef = channelByTask.get(activity.taskRef);
|
|
831
|
+
if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
|
|
832
|
+
scopes.push({ kind: 'channel', channelRef });
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return scopes;
|
|
836
|
+
}
|
|
837
|
+
case 'team/message-sent':
|
|
838
|
+
case 'team/thread-replied':
|
|
839
|
+
return [{ kind: 'channel', channelRef: operation.data.message.channelRef }, { kind: 'thread', threadRef: operation.data.message.threadRef }];
|
|
840
|
+
case 'team/claim-created':
|
|
841
|
+
case 'team/claim-done':
|
|
842
|
+
case 'team/claim-released':
|
|
843
|
+
case 'team/task-changed':
|
|
844
|
+
return [{ kind: 'channel', channelRef: operation.data.task.channelRef }, { kind: 'thread', threadRef: operation.data.thread.threadRef }];
|
|
845
|
+
case 'team/thread-attention-changed':
|
|
846
|
+
return [{ kind: 'thread', threadRef: operation.data.thread.threadRef }];
|
|
847
|
+
case 'team/thread-read':
|
|
848
|
+
// A read advances only the reader's private watermark; no projection
|
|
849
|
+
// visible to other participants changes, so nobody is woken.
|
|
850
|
+
return [];
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
/** Members whose Inbox projection may have changed; the Host notifies only live ones. */
|
|
854
|
+
affectedMembersOf(operation) {
|
|
855
|
+
const members = new Set();
|
|
856
|
+
const delta = this.attentionDelta(operation);
|
|
857
|
+
if (delta !== undefined) {
|
|
858
|
+
for (const attention of delta.attention.set)
|
|
859
|
+
members.add(attention.memberId);
|
|
860
|
+
for (const key of delta.attention.removed)
|
|
861
|
+
members.add(key.memberId);
|
|
862
|
+
for (const marker of delta.directMarkers.added)
|
|
863
|
+
members.add(marker.memberId);
|
|
864
|
+
for (const marker of delta.directMarkers.removed)
|
|
865
|
+
members.add(marker.memberId);
|
|
866
|
+
for (const marker of delta.activityMarkers.added)
|
|
867
|
+
members.add(marker.memberId);
|
|
868
|
+
for (const marker of delta.activityMarkers.removed)
|
|
869
|
+
members.add(marker.memberId);
|
|
870
|
+
}
|
|
871
|
+
for (const threadRef of this.touchedThreadRefs(operation)) {
|
|
872
|
+
for (const follower of this.state.attentionByThread.get(threadRef) ?? [])
|
|
873
|
+
members.add(follower);
|
|
874
|
+
}
|
|
875
|
+
if (operation.actor.kind === 'member')
|
|
876
|
+
members.add(operation.actor.memberId);
|
|
877
|
+
return [...members];
|
|
878
|
+
}
|
|
879
|
+
touchedThreadRefs(operation) {
|
|
880
|
+
switch (operation.kind) {
|
|
881
|
+
case 'team/message-sent':
|
|
882
|
+
case 'team/thread-replied':
|
|
883
|
+
return [operation.data.message.threadRef];
|
|
884
|
+
case 'team/claim-created':
|
|
885
|
+
case 'team/claim-done':
|
|
886
|
+
case 'team/claim-released':
|
|
887
|
+
case 'team/task-changed':
|
|
888
|
+
case 'team/thread-attention-changed':
|
|
889
|
+
return [operation.data.thread.threadRef];
|
|
890
|
+
case 'team/channel-member-removed':
|
|
891
|
+
case 'team/member-removed':
|
|
892
|
+
return operation.data.activities.map(activity => activity.threadRef);
|
|
893
|
+
default:
|
|
894
|
+
return [];
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
replay() {
|
|
898
|
+
const records = this.sortedRecords();
|
|
899
|
+
this.validateRecords(records);
|
|
900
|
+
for (const [, operation] of records)
|
|
901
|
+
this.apply(operation);
|
|
902
|
+
}
|
|
903
|
+
validateRecords(records) {
|
|
904
|
+
const projection = emptyProjection();
|
|
905
|
+
const operationIds = new Set();
|
|
906
|
+
const requestIds = new Set();
|
|
907
|
+
const refs = new Set();
|
|
908
|
+
let previous = null;
|
|
909
|
+
for (const [index, [key, operation]] of records.entries()) {
|
|
910
|
+
const sequence = index + 1;
|
|
911
|
+
if (key !== operation.operationId)
|
|
912
|
+
throw new Error(`agent-team operation key '${key}' differs from record id '${operation.operationId}'`);
|
|
913
|
+
if (operation.sequence !== sequence)
|
|
914
|
+
throw new Error(`agent-team ledger expected sequence ${sequence}, found ${operation.sequence}`);
|
|
915
|
+
if (operation.previousOperationId !== previous)
|
|
916
|
+
throw new Error(`agent-team operation ${sequence} has a broken previous-operation link`);
|
|
917
|
+
if (operationIds.has(operation.operationId))
|
|
918
|
+
throw new Error(`agent-team ledger repeats operation id '${operation.operationId}'`);
|
|
919
|
+
if (requestIds.has(operation.requestId))
|
|
920
|
+
throw new Error(`agent-team ledger repeats request id '${operation.requestId}'`);
|
|
921
|
+
if (sequence === 1)
|
|
922
|
+
this.assertInitializationRecord(operation);
|
|
923
|
+
else
|
|
924
|
+
this.validateOperation(operation, projection, refs);
|
|
925
|
+
this.applyTo(projection, operation);
|
|
926
|
+
operationIds.add(operation.operationId);
|
|
927
|
+
requestIds.add(operation.requestId);
|
|
928
|
+
previous = operation.operationId;
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
validateOperation(operation, projection, refs) {
|
|
932
|
+
const human = projection.ordered[0];
|
|
933
|
+
if (human === undefined || human.kind !== 'team/initialized')
|
|
934
|
+
throw new Error('agent-team ledger has no Human Member');
|
|
935
|
+
const humanMemberId = human.data.humanMemberId;
|
|
936
|
+
const assertHuman = () => {
|
|
937
|
+
if (operation.actor.kind !== 'human' || operation.actor.memberId !== humanMemberId || operation.actor.handle !== HUMAN_ACTOR.handle) {
|
|
938
|
+
throw new Error(`agent-team operation ${operation.sequence} has invalid Human authority`);
|
|
939
|
+
}
|
|
940
|
+
};
|
|
941
|
+
const assertMember = () => {
|
|
942
|
+
if (operation.actor.kind !== 'member')
|
|
943
|
+
throw new Error(`agent-team operation ${operation.sequence} requires Member authority`);
|
|
944
|
+
const member = projection.members.get(operation.actor.memberId);
|
|
945
|
+
if (member === undefined || member.state !== 'enabled' || member.handle !== operation.actor.handle) {
|
|
946
|
+
throw new Error(`agent-team operation ${operation.sequence} has invalid Member authority`);
|
|
947
|
+
}
|
|
948
|
+
return member;
|
|
949
|
+
};
|
|
950
|
+
if (operation.kind === 'team/initialized')
|
|
951
|
+
throw new Error('agent-team initialization must be first');
|
|
952
|
+
if (operation.kind === 'team/channel-created') {
|
|
953
|
+
assertHuman();
|
|
954
|
+
const { channel, memberIds } = operation.data;
|
|
955
|
+
if (projection.channels.has(channel.channelRef) || channel.createdAtSequence !== operation.sequence || channel.workspaceId !== operation.data.workspaceId)
|
|
956
|
+
throw new Error('invalid Channel creation');
|
|
957
|
+
this.addRef(refs, channel.channelRef);
|
|
958
|
+
const unique = new Set(memberIds);
|
|
959
|
+
if (unique.size !== memberIds.length)
|
|
960
|
+
throw new Error('invalid initial Channel members');
|
|
961
|
+
for (const memberId of memberIds) {
|
|
962
|
+
const member = projection.members.get(memberId);
|
|
963
|
+
if (member === undefined || member.workspaceId !== channel.workspaceId || member.state !== 'enabled')
|
|
964
|
+
throw new Error('invalid initial Channel Member');
|
|
965
|
+
}
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
if (operation.kind === 'team/member-added') {
|
|
969
|
+
assertHuman();
|
|
970
|
+
const { member, channelRefs } = operation.data;
|
|
971
|
+
if (projection.members.has(member.memberId) || channelRefs.length === 0 || new Set(channelRefs).size !== channelRefs.length)
|
|
972
|
+
throw new Error('invalid Member creation');
|
|
973
|
+
this.addRef(refs, member.memberId);
|
|
974
|
+
for (const channelRef of channelRefs) {
|
|
975
|
+
const channel = projection.channels.get(channelRef);
|
|
976
|
+
if (channel === undefined || channel.workspaceId !== member.workspaceId)
|
|
977
|
+
throw new Error('invalid initial Member Channel');
|
|
978
|
+
}
|
|
979
|
+
return;
|
|
980
|
+
}
|
|
981
|
+
if (operation.kind === 'team/member-suspended' || operation.kind === 'team/member-resumed') {
|
|
982
|
+
assertHuman();
|
|
983
|
+
const prior = projection.members.get(operation.data.member.memberId);
|
|
984
|
+
const expected = operation.kind === 'team/member-suspended' ? 'enabled' : 'suspended';
|
|
985
|
+
const next = operation.kind === 'team/member-suspended' ? 'suspended' : 'enabled';
|
|
986
|
+
if (prior === undefined || prior.state !== expected || operation.data.member.state !== next || !this.sameMemberIdentity(prior, operation.data.member))
|
|
987
|
+
throw new Error('invalid Member lifecycle transition');
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (operation.kind === 'team/channel-updated') {
|
|
991
|
+
assertHuman();
|
|
992
|
+
const prior = projection.channels.get(operation.data.channel.channelRef);
|
|
993
|
+
if (prior === undefined || prior.workspaceId !== operation.data.workspaceId
|
|
994
|
+
|| operation.data.channel.workspaceId !== prior.workspaceId || operation.data.channel.createdAtSequence !== prior.createdAtSequence) {
|
|
995
|
+
throw new Error('invalid Channel update');
|
|
996
|
+
}
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
if (operation.kind === 'team/member-updated') {
|
|
1000
|
+
assertHuman();
|
|
1001
|
+
const prior = projection.members.get(operation.data.member.memberId);
|
|
1002
|
+
if (prior === undefined || prior.state === 'inactive' || operation.data.member.state !== prior.state
|
|
1003
|
+
|| operation.data.member.sessionId !== prior.sessionId || operation.data.member.workspaceId !== prior.workspaceId
|
|
1004
|
+
|| operation.data.member.presetId !== prior.presetId
|
|
1005
|
+
|| operation.data.member.privateMemoryPath !== prior.privateMemoryPath)
|
|
1006
|
+
throw new Error('invalid Member update');
|
|
1007
|
+
// The renamed handle must stay unique among the workspace's other live Members.
|
|
1008
|
+
const normalized = operation.data.member.handle.normalize('NFKC').trim().toLowerCase();
|
|
1009
|
+
for (const other of projection.members.values()) {
|
|
1010
|
+
if (other.memberId !== prior.memberId && other.state !== 'inactive' && other.workspaceId === prior.workspaceId
|
|
1011
|
+
&& other.handle.normalize('NFKC').trim().toLowerCase() === normalized)
|
|
1012
|
+
throw new Error('invalid Member update handle');
|
|
1013
|
+
}
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
if (operation.kind === 'team/channel-member-added') {
|
|
1017
|
+
assertHuman();
|
|
1018
|
+
const channel = projection.channels.get(operation.data.channelRef);
|
|
1019
|
+
const member = projection.members.get(operation.data.memberId);
|
|
1020
|
+
if (channel === undefined || member === undefined || member.workspaceId !== channel.workspaceId || operation.data.workspaceId !== channel.workspaceId || projection.memberships.get(channel.channelRef)?.has(member.memberId))
|
|
1021
|
+
throw new Error('invalid Channel membership');
|
|
1022
|
+
return;
|
|
1023
|
+
}
|
|
1024
|
+
if (operation.kind === 'team/channel-member-removed') {
|
|
1025
|
+
assertHuman();
|
|
1026
|
+
const channel = projection.channels.get(operation.data.channelRef);
|
|
1027
|
+
const member = projection.members.get(operation.data.memberId);
|
|
1028
|
+
if (channel === undefined || member === undefined || operation.data.workspaceId !== channel.workspaceId
|
|
1029
|
+
|| member.workspaceId !== channel.workspaceId || !projection.memberships.get(channel.channelRef)?.has(member.memberId))
|
|
1030
|
+
throw new Error('invalid Channel membership removal');
|
|
1031
|
+
const threadRefs = new Set([...projection.tasks.values()].filter(task => task.channelRef === channel.channelRef).map(task => task.threadRef));
|
|
1032
|
+
this.validateReleaseCleanup(operation.data, projection, member.memberId, threadRefs, operation.sequence, refs);
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
if (operation.kind === 'team/member-removed') {
|
|
1036
|
+
assertHuman();
|
|
1037
|
+
const prior = projection.members.get(operation.data.member.memberId);
|
|
1038
|
+
if (prior === undefined || prior.state === 'inactive' || operation.data.member.state !== 'inactive' || !this.sameMemberIdentity(prior, operation.data.member))
|
|
1039
|
+
throw new Error('invalid Member removal');
|
|
1040
|
+
const threadRefs = new Set([...projection.tasks.values()].map(task => task.threadRef));
|
|
1041
|
+
this.validateReleaseCleanup(operation.data, projection, prior.memberId, threadRefs, operation.sequence, refs);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
if (operation.kind === 'team/thread-attention-changed') {
|
|
1045
|
+
const actor = operation.actor.kind === 'member' ? assertMember() : (assertHuman(), undefined);
|
|
1046
|
+
if (operation.data.memberId !== operation.actor.memberId)
|
|
1047
|
+
throw new Error('Attention operation has wrong actor');
|
|
1048
|
+
const task = projection.tasks.get(operation.data.task.taskRef);
|
|
1049
|
+
const thread = projection.threads.get(operation.data.thread.threadRef);
|
|
1050
|
+
if (task === undefined || thread === undefined || !this.sameTask(task, operation.data.task)
|
|
1051
|
+
|| !this.sameThread(thread, operation.data.thread)
|
|
1052
|
+
|| (actor !== undefined && !projection.memberships.get(task.channelRef)?.has(actor.memberId)))
|
|
1053
|
+
throw new Error('invalid Attention operation');
|
|
1054
|
+
const current = this.attentionForFrom(projection, operation.data.memberId, thread.threadRef);
|
|
1055
|
+
if ((operation.data.action === 'follow' && (task.resolution === 'closed' || current !== undefined))
|
|
1056
|
+
|| (operation.data.action === 'unfollow' && (current === undefined || this.hasActiveClaimFrom(projection, operation.data.memberId, task.taskRef)))) {
|
|
1057
|
+
throw new Error('invalid Attention transition');
|
|
1058
|
+
}
|
|
1059
|
+
const expected = operation.data.action === 'follow'
|
|
1060
|
+
? this.inboxDelta([this.followAttentionFrom(projection, operation.data.memberId, thread.threadRef)])
|
|
1061
|
+
: this.inboxDelta([], [{ memberId: operation.data.memberId, threadRef: thread.threadRef }], [], this.directMarkersForFrom(projection, operation.data.memberId, thread.threadRef));
|
|
1062
|
+
if (!isDeepStrictEqual(operation.data.inbox, expected))
|
|
1063
|
+
throw new Error('invalid Attention projection');
|
|
1064
|
+
this.validateInboxDelta(operation.data.inbox, projection, refs);
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
if (operation.kind === 'team/thread-read') {
|
|
1068
|
+
if (operation.actor.kind === 'member')
|
|
1069
|
+
assertMember();
|
|
1070
|
+
else
|
|
1071
|
+
assertHuman();
|
|
1072
|
+
if (operation.data.memberId !== operation.actor.memberId)
|
|
1073
|
+
throw new Error('Thread read has wrong actor');
|
|
1074
|
+
const expected = this.prepareReadFrom(projection, operation.data.memberId, operation.data.workspaceId, operation.data.task.taskRef);
|
|
1075
|
+
const expectedData = Object.freeze({ workspaceId: operation.data.workspaceId, memberId: operation.data.memberId,
|
|
1076
|
+
task: expected.task, thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
|
|
1077
|
+
anchorMentions: expected.anchorMentions, facts: expected.facts,
|
|
1078
|
+
readThroughSequence: expected.readThroughSequence, remainingUnreadCount: expected.remainingUnreadCount,
|
|
1079
|
+
...(expected.attention === undefined ? {} : { attention: expected.attention }), inbox: expected.inbox });
|
|
1080
|
+
if (!isDeepStrictEqual(operation.data, expectedData))
|
|
1081
|
+
throw new Error('invalid Thread read projection');
|
|
1082
|
+
this.validateInboxDelta(operation.data.inbox, projection, refs);
|
|
1083
|
+
return;
|
|
1084
|
+
}
|
|
1085
|
+
if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
|
|
1086
|
+
const actor = operation.actor.kind === 'member' ? assertMember() : (assertHuman(), undefined);
|
|
1087
|
+
const { message, task, thread } = operation.data;
|
|
1088
|
+
const channel = projection.channels.get(message.channelRef);
|
|
1089
|
+
if (channel === undefined || channel.workspaceId !== operation.data.workspaceId || message.sender !== operation.actor.memberId
|
|
1090
|
+
|| message.sequence !== operation.sequence || thread.revision !== operation.sequence || message.taskRef !== task.taskRef
|
|
1091
|
+
|| message.threadRef !== thread.threadRef || task.threadRef !== thread.threadRef || task.channelRef !== channel.channelRef
|
|
1092
|
+
|| (actor !== undefined && !projection.memberships.get(channel.channelRef)?.has(actor.memberId)))
|
|
1093
|
+
throw new Error('invalid Message operation');
|
|
1094
|
+
if (operation.kind === 'team/message-sent') {
|
|
1095
|
+
if (!message.topLevel || projection.tasks.has(task.taskRef) || projection.threads.has(thread.threadRef))
|
|
1096
|
+
throw new Error('invalid top-level Message operation');
|
|
1097
|
+
this.addRef(refs, task.taskRef);
|
|
1098
|
+
this.addRef(refs, thread.threadRef);
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
const priorTask = projection.tasks.get(task.taskRef);
|
|
1102
|
+
const priorThread = projection.threads.get(thread.threadRef);
|
|
1103
|
+
if (message.topLevel || priorTask === undefined || priorThread === undefined || !this.sameTask(priorTask, task)
|
|
1104
|
+
|| priorThread.taskRef !== thread.taskRef || operation.data.baseRevision !== priorThread.revision)
|
|
1105
|
+
throw new Error('invalid Thread reply');
|
|
1106
|
+
}
|
|
1107
|
+
this.addRef(refs, message.messageRef);
|
|
1108
|
+
this.validateMentions(operation.data.mentions, message, projection);
|
|
1109
|
+
this.validateInboxDelta(operation.data.inbox, projection, refs, [thread.threadRef], [message]);
|
|
1110
|
+
this.validateMessageInbox(operation, projection);
|
|
1111
|
+
return;
|
|
1112
|
+
}
|
|
1113
|
+
if (operation.kind === 'team/claim-created' || operation.kind === 'team/claim-done' || operation.kind === 'team/claim-released') {
|
|
1114
|
+
const actor = operation.actor.kind === 'member' ? assertMember() : (assertHuman(), undefined);
|
|
1115
|
+
const { task, thread, claim, activity } = operation.data;
|
|
1116
|
+
const priorTask = projection.tasks.get(task.taskRef);
|
|
1117
|
+
const priorThread = projection.threads.get(thread.threadRef);
|
|
1118
|
+
if (priorTask === undefined || priorThread === undefined || priorThread.revision !== operation.data.baseRevision
|
|
1119
|
+
|| !this.sameTaskIdentity(priorTask, task) || task.threadRef !== thread.threadRef || task.taskRef !== thread.taskRef
|
|
1120
|
+
|| thread.revision !== operation.sequence || activity.sequence !== operation.sequence || activity.actor !== operation.actor.memberId
|
|
1121
|
+
|| activity.taskRef !== task.taskRef || activity.threadRef !== thread.threadRef || activity.claimRef !== claim.claimRef
|
|
1122
|
+
|| (actor !== undefined && !projection.memberships.get(task.channelRef)?.has(actor.memberId)))
|
|
1123
|
+
throw new Error('invalid Claim operation');
|
|
1124
|
+
const creates = operation.kind === 'team/claim-created';
|
|
1125
|
+
const expectedKind = creates ? 'claim' : operation.kind === 'team/claim-done' ? 'done' : 'release';
|
|
1126
|
+
if (activity.kind !== expectedKind || claim.taskRef !== task.taskRef || claim.threadRef !== thread.threadRef)
|
|
1127
|
+
throw new Error('invalid Claim activity');
|
|
1128
|
+
if (operation.actor.kind !== 'member')
|
|
1129
|
+
throw new Error('invalid Claim authority');
|
|
1130
|
+
if ((creates && projection.claims.has(claim.claimRef))
|
|
1131
|
+
|| (creates && claim.owner !== operation.actor.memberId) || (creates && claim.state !== 'active'))
|
|
1132
|
+
throw new Error('invalid Claim creation');
|
|
1133
|
+
const previousClaim = projection.claims.get(claim.claimRef);
|
|
1134
|
+
if (!creates && (previousClaim === undefined || previousClaim.state !== 'active' || !this.sameClaimIdentity(previousClaim, claim)
|
|
1135
|
+
|| (operation.actor.kind === 'member' && previousClaim.owner !== operation.actor.memberId)
|
|
1136
|
+
|| claim.state !== (operation.kind === 'team/claim-done' ? 'done' : 'released')))
|
|
1137
|
+
throw new Error('invalid Claim transition');
|
|
1138
|
+
if (task.status !== this.deriveResolvedTaskStatus(task, new Map(projection.claims).set(claim.claimRef, claim).values()))
|
|
1139
|
+
throw new Error('invalid Claim Task status');
|
|
1140
|
+
this.addRef(refs, activity.activityRef);
|
|
1141
|
+
if (creates)
|
|
1142
|
+
this.addRef(refs, claim.claimRef);
|
|
1143
|
+
const expectedInbox = creates && !this.isFollowingFrom(projection, thread.threadRef, operation.actor.memberId)
|
|
1144
|
+
? this.inboxDelta([this.startAttention(operation.actor.memberId, thread.threadRef, operation.sequence)])
|
|
1145
|
+
: this.inboxDelta();
|
|
1146
|
+
if (!isDeepStrictEqual(operation.data.inbox, expectedInbox))
|
|
1147
|
+
throw new Error('invalid Claim inbox projection');
|
|
1148
|
+
this.validateInboxDelta(operation.data.inbox, projection, refs);
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
if (operation.kind === 'team/task-changed') {
|
|
1152
|
+
assertHuman();
|
|
1153
|
+
const { task, thread, activity } = operation.data;
|
|
1154
|
+
const priorTask = projection.tasks.get(task.taskRef);
|
|
1155
|
+
const priorThread = projection.threads.get(thread.threadRef);
|
|
1156
|
+
if (priorTask === undefined || priorThread === undefined || priorThread.revision !== operation.data.baseRevision
|
|
1157
|
+
|| task.threadRef !== thread.threadRef || !this.sameThread(thread, { ...priorThread, revision: operation.sequence })
|
|
1158
|
+
|| activity.sequence !== operation.sequence || activity.actor !== operation.actor.memberId
|
|
1159
|
+
|| activity.taskRef !== task.taskRef || activity.threadRef !== thread.threadRef)
|
|
1160
|
+
throw new Error('invalid Task operation');
|
|
1161
|
+
const expectedResolution = activity.kind === 'accept' ? 'accepted' : activity.kind === 'close' ? 'closed' : 'open';
|
|
1162
|
+
const expectedStatus = expectedResolution === 'accepted' ? 'done' : expectedResolution === 'closed' ? 'closed'
|
|
1163
|
+
: this.deriveTaskStatus(task.taskRef, operation.data.claims);
|
|
1164
|
+
if (task.resolution !== expectedResolution || task.status !== expectedStatus
|
|
1165
|
+
|| (activity.kind !== 'close' && activity.releasedClaimRefs !== undefined)
|
|
1166
|
+
|| (activity.kind === 'close' && !this.sameList(activity.releasedClaimRefs ?? [], operation.data.claims
|
|
1167
|
+
.filter(claim => projection.claims.get(claim.claimRef)?.state === 'active' && claim.state === 'released')
|
|
1168
|
+
.map(claim => claim.claimRef))))
|
|
1169
|
+
throw new Error('invalid Task state transition');
|
|
1170
|
+
const priorClaims = [...projection.claims.values()].filter(claim => claim.taskRef === task.taskRef).sort((left, right) => left.claimRef.localeCompare(right.claimRef));
|
|
1171
|
+
const operationClaims = [...operation.data.claims].sort((left, right) => left.claimRef.localeCompare(right.claimRef));
|
|
1172
|
+
if (priorClaims.length !== operationClaims.length || priorClaims.some((claim, index) => claim.claimRef !== operationClaims[index]?.claimRef))
|
|
1173
|
+
throw new Error('invalid Task Claim set');
|
|
1174
|
+
for (const claim of operationClaims) {
|
|
1175
|
+
const previousClaim = projection.claims.get(claim.claimRef);
|
|
1176
|
+
if (previousClaim === undefined || !this.sameClaimIdentity(previousClaim, claim))
|
|
1177
|
+
throw new Error('invalid Task Claim identity');
|
|
1178
|
+
const legal = activity.kind === 'close'
|
|
1179
|
+
? (previousClaim.state === 'active' ? claim.state === 'released' : claim.state === previousClaim.state)
|
|
1180
|
+
: claim.state === previousClaim.state;
|
|
1181
|
+
if (!legal)
|
|
1182
|
+
throw new Error('invalid Task Claim transition');
|
|
1183
|
+
}
|
|
1184
|
+
this.addRef(refs, activity.activityRef);
|
|
1185
|
+
const expectedInbox = activity.kind === 'close'
|
|
1186
|
+
? this.closeThreadInboxFrom(projection, activity, thread.threadRef)
|
|
1187
|
+
: activity.kind === 'reopen'
|
|
1188
|
+
? this.reopenThreadInboxFrom(projection, activity, thread.threadRef)
|
|
1189
|
+
: this.inboxDelta();
|
|
1190
|
+
if (!isDeepStrictEqual(operation.data.inbox, expectedInbox))
|
|
1191
|
+
throw new Error('invalid Task inbox projection');
|
|
1192
|
+
this.validateInboxDelta(operation.data.inbox, projection, refs, [], [], [activity]);
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
validateInboxDelta(delta, projection, _refs, additionalThreadRefs = [], additionalMessages = [], additionalActivities = []) {
|
|
1197
|
+
const attentionKeys = new Set();
|
|
1198
|
+
const knownThreadRefs = new Set([...projection.threads.keys(), ...additionalThreadRefs]);
|
|
1199
|
+
for (const attention of delta.attention.set) {
|
|
1200
|
+
if (!knownThreadRefs.has(attention.threadRef) || attention.startSequence < 1
|
|
1201
|
+
|| attention.readThroughSequence < attention.startSequence - 1) {
|
|
1202
|
+
throw new Error('invalid Attention delta');
|
|
1203
|
+
}
|
|
1204
|
+
const key = this.attentionKey(attention.memberId, attention.threadRef);
|
|
1205
|
+
if (attentionKeys.has(key))
|
|
1206
|
+
throw new Error('repeated Attention delta');
|
|
1207
|
+
attentionKeys.add(key);
|
|
1208
|
+
}
|
|
1209
|
+
for (const key of delta.attention.removed) {
|
|
1210
|
+
if (attentionKeys.has(this.attentionKey(key.memberId, key.threadRef)))
|
|
1211
|
+
throw new Error('conflicting Attention delta');
|
|
1212
|
+
}
|
|
1213
|
+
const markerKeys = new Set();
|
|
1214
|
+
const messages = [...projection.messages, ...additionalMessages];
|
|
1215
|
+
for (const marker of delta.directMarkers.added) {
|
|
1216
|
+
const key = this.directMarkerKey(marker);
|
|
1217
|
+
if (markerKeys.has(key) || projection.directMarkers.has(key))
|
|
1218
|
+
throw new Error('invalid direct marker addition');
|
|
1219
|
+
markerKeys.add(key);
|
|
1220
|
+
const message = messages.find(candidate => candidate.messageRef === marker.messageRef);
|
|
1221
|
+
if (message === undefined || message.threadRef !== marker.threadRef || message.sequence !== marker.sequence
|
|
1222
|
+
|| !this.validMentionTarget(projection, message.channelRef, marker.memberId))
|
|
1223
|
+
throw new Error('invalid direct marker addition');
|
|
1224
|
+
}
|
|
1225
|
+
for (const marker of delta.directMarkers.removed) {
|
|
1226
|
+
const key = this.directMarkerKey(marker);
|
|
1227
|
+
if (markerKeys.has(key) || !projection.directMarkers.has(key))
|
|
1228
|
+
throw new Error('invalid direct marker removal');
|
|
1229
|
+
markerKeys.add(key);
|
|
1230
|
+
const current = projection.directMarkers.get(key);
|
|
1231
|
+
const message = messages.find(candidate => candidate.messageRef === marker.messageRef);
|
|
1232
|
+
if (!isDeepStrictEqual(current, marker) || message === undefined || message.threadRef !== marker.threadRef
|
|
1233
|
+
|| message.sequence !== marker.sequence || !this.validMentionTarget(projection, message.channelRef, marker.memberId)) {
|
|
1234
|
+
throw new Error('invalid direct marker removal');
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
const activityMarkerKeys = new Set();
|
|
1238
|
+
const activities = [...projection.orderedFacts.filter(fact => fact.kind === 'activity').map(fact => fact.activity), ...additionalActivities];
|
|
1239
|
+
for (const marker of delta.activityMarkers.added) {
|
|
1240
|
+
const key = this.activityMarkerKey(marker);
|
|
1241
|
+
const activity = activities.find(candidate => candidate.activityRef === marker.activityRef);
|
|
1242
|
+
if (activityMarkerKeys.has(key) || projection.activityMarkers.has(key) || activity === undefined
|
|
1243
|
+
|| activity.threadRef !== marker.threadRef || activity.sequence !== marker.sequence)
|
|
1244
|
+
throw new Error('invalid activity marker addition');
|
|
1245
|
+
activityMarkerKeys.add(key);
|
|
1246
|
+
}
|
|
1247
|
+
for (const marker of delta.activityMarkers.removed) {
|
|
1248
|
+
const key = this.activityMarkerKey(marker);
|
|
1249
|
+
if (activityMarkerKeys.has(key) || !isDeepStrictEqual(projection.activityMarkers.get(key), marker)) {
|
|
1250
|
+
throw new Error('invalid activity marker removal');
|
|
1251
|
+
}
|
|
1252
|
+
activityMarkerKeys.add(key);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
validateReleaseCleanup(data, projection, memberId, threadRefs, sequence, refs) {
|
|
1256
|
+
const releasedClaims = [...projection.claims.values()]
|
|
1257
|
+
.filter(claim => claim.owner === memberId && claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
1258
|
+
.map(claim => Object.freeze({ ...claim, state: 'released' }));
|
|
1259
|
+
if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
|
|
1260
|
+
const expected = releasedClaims[index];
|
|
1261
|
+
return expected === undefined || !this.sameClaim(expected, claim);
|
|
1262
|
+
}))
|
|
1263
|
+
throw new Error('invalid released Claim projection');
|
|
1264
|
+
const byThread = new Map();
|
|
1265
|
+
for (const claim of releasedClaims)
|
|
1266
|
+
byThread.set(claim.threadRef, [...(byThread.get(claim.threadRef) ?? []), claim]);
|
|
1267
|
+
const expectedActivities = [...byThread.entries()].map(([threadRef, claims]) => ({
|
|
1268
|
+
kind: 'claims_released', taskRef: claims[0].taskRef, threadRef, actor: memberId, sequence,
|
|
1269
|
+
claimRefs: claims.map(claim => claim.claimRef).sort(),
|
|
1270
|
+
}));
|
|
1271
|
+
if (data.activities.length !== expectedActivities.length || data.activities.some((activity, index) => {
|
|
1272
|
+
const expected = expectedActivities[index];
|
|
1273
|
+
return expected === undefined || activity.kind !== expected.kind || activity.taskRef !== expected.taskRef
|
|
1274
|
+
|| activity.threadRef !== expected.threadRef || activity.actor !== expected.actor || activity.sequence !== expected.sequence
|
|
1275
|
+
|| !this.sameList(activity.claimRefs, expected.claimRefs);
|
|
1276
|
+
}))
|
|
1277
|
+
throw new Error('invalid released Claim activities');
|
|
1278
|
+
for (const claim of data.claims) {
|
|
1279
|
+
const prior = projection.claims.get(claim.claimRef);
|
|
1280
|
+
if (prior === undefined)
|
|
1281
|
+
throw new Error('invalid released Claim reference');
|
|
1282
|
+
}
|
|
1283
|
+
for (const activity of data.activities)
|
|
1284
|
+
this.addRef(refs, activity.activityRef);
|
|
1285
|
+
const projectedClaims = new Map(projection.claims);
|
|
1286
|
+
for (const claim of releasedClaims)
|
|
1287
|
+
projectedClaims.set(claim.claimRef, claim);
|
|
1288
|
+
const expectedTasks = [...new Set(releasedClaims.map(claim => claim.taskRef))].map(taskRef => {
|
|
1289
|
+
const task = projection.tasks.get(taskRef);
|
|
1290
|
+
return Object.freeze({ ...task, status: this.deriveResolvedTaskStatus(task, projectedClaims.values()) });
|
|
1291
|
+
});
|
|
1292
|
+
const expectedThreads = expectedActivities.map(activity => {
|
|
1293
|
+
const thread = projection.threads.get(activity.threadRef);
|
|
1294
|
+
return Object.freeze({ ...thread, revision: sequence });
|
|
1295
|
+
});
|
|
1296
|
+
if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
|
|
1297
|
+
throw new Error('invalid released Claim Task or Thread projection');
|
|
1298
|
+
}
|
|
1299
|
+
const expectedAttention = [...projection.attention.values()]
|
|
1300
|
+
.filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
|
|
1301
|
+
.map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
|
|
1302
|
+
const expectedMarkers = [...projection.directMarkers.values()]
|
|
1303
|
+
.filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
1304
|
+
const expectedActivityMarkers = [...projection.activityMarkers.values()]
|
|
1305
|
+
.filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
1306
|
+
const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
|
|
1307
|
+
if (!isDeepStrictEqual(data.inbox, expectedInbox))
|
|
1308
|
+
throw new Error('invalid Member inbox cleanup');
|
|
1309
|
+
this.validateInboxDelta(data.inbox, projection, refs);
|
|
1310
|
+
}
|
|
1311
|
+
validateMessageInbox(operation, projection) {
|
|
1312
|
+
const { message, mentions } = operation.data;
|
|
1313
|
+
const mentionedAgents = mentions.filter(memberId => projection.members.has(memberId));
|
|
1314
|
+
// Top-level mentions are open to every actor; only a Member reply may not
|
|
1315
|
+
// pull an unfollowed Member into an existing Thread.
|
|
1316
|
+
if (operation.actor.kind === 'member' && operation.kind === 'team/thread-replied'
|
|
1317
|
+
&& mentionedAgents.some(memberId => !this.isFollowingFrom(projection, message.threadRef, memberId))) {
|
|
1318
|
+
throw new Error('invalid Agent mention projection');
|
|
1319
|
+
}
|
|
1320
|
+
const started = operation.kind === 'team/message-sent'
|
|
1321
|
+
? [this.startAttention(message.sender, message.threadRef, message.sequence),
|
|
1322
|
+
...mentionedAgents.map(memberId => this.startAttention(memberId, message.threadRef, message.sequence))]
|
|
1323
|
+
: mentionedAgents.filter(memberId => !this.isFollowingFrom(projection, message.threadRef, memberId))
|
|
1324
|
+
.map(memberId => this.startAttention(memberId, message.threadRef, message.sequence));
|
|
1325
|
+
const markers = mentions.map(memberId => Object.freeze({ memberId, threadRef: message.threadRef,
|
|
1326
|
+
messageRef: message.messageRef, sequence: message.sequence }));
|
|
1327
|
+
const expected = this.inboxDelta(started, [], markers);
|
|
1328
|
+
if (!isDeepStrictEqual(operation.data.inbox, expected))
|
|
1329
|
+
throw new Error('invalid Message inbox projection');
|
|
1330
|
+
}
|
|
1331
|
+
validateMentions(mentions, message, projection) {
|
|
1332
|
+
if (mentions.includes(message.sender) || !this.sameList(mentions, [...new Set(mentions)].sort()))
|
|
1333
|
+
throw new Error('invalid Message mentions');
|
|
1334
|
+
for (const memberId of mentions)
|
|
1335
|
+
if (!this.validMentionTarget(projection, message.channelRef, memberId))
|
|
1336
|
+
throw new Error('invalid Message mention target');
|
|
1337
|
+
}
|
|
1338
|
+
validMentionTarget(projection, channelRef, memberId) {
|
|
1339
|
+
if (memberId === AGENT_TEAM_HUMAN_MEMBER_ID)
|
|
1340
|
+
return true;
|
|
1341
|
+
const member = projection.members.get(memberId);
|
|
1342
|
+
return member !== undefined && member.state !== 'inactive'
|
|
1343
|
+
&& projection.channels.get(channelRef)?.workspaceId === member.workspaceId
|
|
1344
|
+
&& this.isChannelMemberFrom(projection, channelRef, memberId);
|
|
1345
|
+
}
|
|
1346
|
+
apply(operation) {
|
|
1347
|
+
this.applyTo(this.state, operation);
|
|
1348
|
+
}
|
|
1349
|
+
applyTo(target, operation) {
|
|
1350
|
+
target.ordered.push(operation);
|
|
1351
|
+
target.byRequest.set(operation.requestId, operation);
|
|
1352
|
+
target.byOperation.set(operation.operationId, operation);
|
|
1353
|
+
if (operation.kind === 'team/initialized')
|
|
1354
|
+
return;
|
|
1355
|
+
if (operation.kind === 'team/channel-created') {
|
|
1356
|
+
target.channels.set(operation.data.channel.channelRef, operation.data.channel);
|
|
1357
|
+
target.memberships.set(operation.data.channel.channelRef, new Set(operation.data.memberIds));
|
|
1358
|
+
return;
|
|
1359
|
+
}
|
|
1360
|
+
if (operation.kind === 'team/member-added') {
|
|
1361
|
+
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
1362
|
+
for (const channelRef of operation.data.channelRefs)
|
|
1363
|
+
this.addMembership(target, channelRef, operation.data.member.memberId);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (operation.kind === 'team/member-suspended' || operation.kind === 'team/member-resumed') {
|
|
1367
|
+
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
if (operation.kind === 'team/channel-updated') {
|
|
1371
|
+
target.channels.set(operation.data.channel.channelRef, operation.data.channel);
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
if (operation.kind === 'team/member-updated') {
|
|
1375
|
+
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
1376
|
+
return;
|
|
1377
|
+
}
|
|
1378
|
+
if (operation.kind === 'team/channel-member-added') {
|
|
1379
|
+
this.addMembership(target, operation.data.channelRef, operation.data.memberId);
|
|
1380
|
+
return;
|
|
1381
|
+
}
|
|
1382
|
+
if (operation.kind === 'team/channel-member-removed') {
|
|
1383
|
+
target.memberships.get(operation.data.channelRef)?.delete(operation.data.memberId);
|
|
1384
|
+
for (const claim of operation.data.claims)
|
|
1385
|
+
target.claims.set(claim.claimRef, claim);
|
|
1386
|
+
for (const activity of operation.data.activities)
|
|
1387
|
+
this.appendActivityFact(target, activity);
|
|
1388
|
+
for (const task of operation.data.tasks)
|
|
1389
|
+
target.tasks.set(task.taskRef, task);
|
|
1390
|
+
for (const thread of operation.data.threads)
|
|
1391
|
+
target.threads.set(thread.threadRef, thread);
|
|
1392
|
+
this.applyInboxDelta(target, operation.data.inbox);
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
if (operation.kind === 'team/member-removed') {
|
|
1396
|
+
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
1397
|
+
for (const membership of target.memberships.values())
|
|
1398
|
+
membership.delete(operation.data.member.memberId);
|
|
1399
|
+
for (const claim of operation.data.claims)
|
|
1400
|
+
target.claims.set(claim.claimRef, claim);
|
|
1401
|
+
for (const activity of operation.data.activities)
|
|
1402
|
+
this.appendActivityFact(target, activity);
|
|
1403
|
+
for (const task of operation.data.tasks)
|
|
1404
|
+
target.tasks.set(task.taskRef, task);
|
|
1405
|
+
for (const thread of operation.data.threads)
|
|
1406
|
+
target.threads.set(thread.threadRef, thread);
|
|
1407
|
+
this.applyInboxDelta(target, operation.data.inbox);
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
|
|
1411
|
+
const { message, mentions } = operation.data;
|
|
1412
|
+
target.messages.push(message);
|
|
1413
|
+
target.mentionsByMessage.set(message.messageRef, Object.freeze([...mentions]));
|
|
1414
|
+
this.appendMessageFact(target, message, mentions);
|
|
1415
|
+
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
1416
|
+
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
1417
|
+
this.applyInboxDelta(target, operation.data.inbox);
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
if (operation.kind === 'team/claim-created' || operation.kind === 'team/claim-done' || operation.kind === 'team/claim-released') {
|
|
1421
|
+
target.claims.set(operation.data.claim.claimRef, operation.data.claim);
|
|
1422
|
+
this.appendActivityFact(target, operation.data.activity);
|
|
1423
|
+
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
1424
|
+
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
1425
|
+
this.applyInboxDelta(target, operation.data.inbox);
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
if (operation.kind === 'team/task-changed') {
|
|
1429
|
+
for (const claim of operation.data.claims)
|
|
1430
|
+
target.claims.set(claim.claimRef, claim);
|
|
1431
|
+
this.appendActivityFact(target, operation.data.activity);
|
|
1432
|
+
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
1433
|
+
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
1434
|
+
this.applyInboxDelta(target, operation.data.inbox);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
if (operation.kind === 'team/thread-attention-changed' || operation.kind === 'team/thread-read') {
|
|
1438
|
+
this.applyInboxDelta(target, operation.data.inbox);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
/** Facts arrive in ledger sequence order, so global and per-thread lists stay sorted by append only. */
|
|
1442
|
+
appendMessageFact(target, message, mentions) {
|
|
1443
|
+
const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions });
|
|
1444
|
+
target.orderedFacts.push(fact);
|
|
1445
|
+
const facts = target.factsByThread.get(message.threadRef) ?? [];
|
|
1446
|
+
facts.push(fact);
|
|
1447
|
+
target.factsByThread.set(message.threadRef, facts);
|
|
1448
|
+
if (message.topLevel)
|
|
1449
|
+
target.topLevelMessages.push(message);
|
|
1450
|
+
target.messageCountByThread.set(message.threadRef, (target.messageCountByThread.get(message.threadRef) ?? 0) + 1);
|
|
1451
|
+
}
|
|
1452
|
+
appendActivityFact(target, activity) {
|
|
1453
|
+
const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity });
|
|
1454
|
+
target.orderedFacts.push(fact);
|
|
1455
|
+
const facts = target.factsByThread.get(activity.threadRef) ?? [];
|
|
1456
|
+
facts.push(fact);
|
|
1457
|
+
target.factsByThread.set(activity.threadRef, facts);
|
|
1458
|
+
}
|
|
1459
|
+
applyInboxDelta(target, delta) {
|
|
1460
|
+
for (const key of delta.attention.removed) {
|
|
1461
|
+
target.attention.delete(this.attentionKey(key.memberId, key.threadRef));
|
|
1462
|
+
const followers = target.attentionByThread.get(key.threadRef);
|
|
1463
|
+
if (followers !== undefined) {
|
|
1464
|
+
followers.delete(key.memberId);
|
|
1465
|
+
if (followers.size === 0)
|
|
1466
|
+
target.attentionByThread.delete(key.threadRef);
|
|
1467
|
+
}
|
|
1468
|
+
}
|
|
1469
|
+
for (const attention of delta.attention.set) {
|
|
1470
|
+
target.attention.set(this.attentionKey(attention.memberId, attention.threadRef), attention);
|
|
1471
|
+
const followers = target.attentionByThread.get(attention.threadRef) ?? new Set();
|
|
1472
|
+
followers.add(attention.memberId);
|
|
1473
|
+
target.attentionByThread.set(attention.threadRef, followers);
|
|
1474
|
+
}
|
|
1475
|
+
for (const marker of delta.directMarkers.removed)
|
|
1476
|
+
target.directMarkers.delete(this.directMarkerKey(marker));
|
|
1477
|
+
for (const marker of delta.directMarkers.added)
|
|
1478
|
+
target.directMarkers.set(this.directMarkerKey(marker), marker);
|
|
1479
|
+
for (const marker of delta.activityMarkers.removed)
|
|
1480
|
+
target.activityMarkers.delete(this.activityMarkerKey(marker));
|
|
1481
|
+
for (const marker of delta.activityMarkers.added)
|
|
1482
|
+
target.activityMarkers.set(this.activityMarkerKey(marker), marker);
|
|
1483
|
+
}
|
|
1484
|
+
prepareRead(memberId, workspaceId, taskRef) {
|
|
1485
|
+
return this.prepareReadFrom(this.state, memberId, workspaceId, taskRef);
|
|
1486
|
+
}
|
|
1487
|
+
/** Derive the only legal durable result of one Thread read from a prior projection. */
|
|
1488
|
+
prepareReadFrom(projection, memberId, workspaceId, taskRef) {
|
|
1489
|
+
const task = projection.tasks.get(taskRef);
|
|
1490
|
+
if (task === undefined)
|
|
1491
|
+
throw new Error(`unknown Task ref '${taskRef}'${this.unknownRefHint(taskRef, 'task', 'Task')}`);
|
|
1492
|
+
if (projection.channels.get(task.channelRef)?.workspaceId !== workspaceId)
|
|
1493
|
+
throw new Error(`Task '${taskRef}' does not belong to Workspace '${workspaceId}'`);
|
|
1494
|
+
const thread = projection.threads.get(task.threadRef);
|
|
1495
|
+
if (thread === undefined)
|
|
1496
|
+
throw new Error(`unknown Thread ref '${task.threadRef}'`);
|
|
1497
|
+
if (memberId !== AGENT_TEAM_HUMAN_MEMBER_ID) {
|
|
1498
|
+
const member = projection.members.get(memberId);
|
|
1499
|
+
if (member === undefined || !projection.memberships.get(task.channelRef)?.has(member.memberId)) {
|
|
1500
|
+
throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${task.channelRef}'`);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
const anchor = this.threadAnchorFrom(projection, task);
|
|
1504
|
+
const attention = this.attentionForFrom(projection, memberId, thread.threadRef);
|
|
1505
|
+
const unread = this.unreadForFrom(projection, memberId, thread.threadRef);
|
|
1506
|
+
const unreadFacts = unread.slice(0, 20);
|
|
1507
|
+
const unreadFactKeys = new Set(unread.map(item => this.threadFactKey(item.fact)));
|
|
1508
|
+
const firstRead = attention !== undefined && attention.readThroughSequence < attention.startSequence;
|
|
1509
|
+
const background = firstRead
|
|
1510
|
+
? projection.messages.filter(message => message.threadRef === thread.threadRef && message.sequence < attention.startSequence)
|
|
1511
|
+
.map(message => Object.freeze({ kind: 'message', sequence: message.sequence, message,
|
|
1512
|
+
mentions: projection.mentionsByMessage.get(message.messageRef) ?? [] }))
|
|
1513
|
+
.filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
|
|
1514
|
+
: [];
|
|
1515
|
+
const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...unreadFacts]
|
|
1516
|
+
.sort((left, right) => left.fact.sequence - right.fact.sequence);
|
|
1517
|
+
// Direct markers are sparse acknowledgements, not part of the contiguous
|
|
1518
|
+
// follower watermark. Consuming an old marker after a later follow must
|
|
1519
|
+
// never move that watermark backwards.
|
|
1520
|
+
const ordinaryUnread = unreadFacts.filter(item => item.fact.sequence >= (attention?.startSequence ?? Number.MAX_SAFE_INTEGER)
|
|
1521
|
+
&& this.visibleToFollower(item.fact, memberId));
|
|
1522
|
+
const readThroughSequence = Math.max(attention?.readThroughSequence ?? 0, ordinaryUnread.at(-1)?.fact.sequence ?? 0);
|
|
1523
|
+
const nextAttention = attention === undefined || readThroughSequence === attention.readThroughSequence ? []
|
|
1524
|
+
: [Object.freeze({ ...attention, readThroughSequence })];
|
|
1525
|
+
const consumedDirectMarkers = new Set(unreadFacts.flatMap(item => item.direct && item.fact.kind === 'message'
|
|
1526
|
+
? [this.directMarkerKey({ memberId, threadRef: thread.threadRef, messageRef: item.fact.message.messageRef })] : []));
|
|
1527
|
+
const consumed = this.directMarkersForFrom(projection, memberId, thread.threadRef)
|
|
1528
|
+
.filter(marker => consumedDirectMarkers.has(this.directMarkerKey(marker)));
|
|
1529
|
+
const activityMarkers = this.activityMarkersForFrom(projection, memberId, thread.threadRef)
|
|
1530
|
+
.filter(marker => unreadFacts.some(item => item.fact.kind === 'activity' && item.fact.activity.activityRef === marker.activityRef));
|
|
1531
|
+
const inbox = this.inboxDelta(nextAttention, [], [], consumed, [], activityMarkers);
|
|
1532
|
+
// The hypothetical projection copies every map that its inbox delta can
|
|
1533
|
+
// mutate, including the follower sets inside attentionByThread; the fact
|
|
1534
|
+
// indexes are read-only here and stay shared.
|
|
1535
|
+
const nextProjection = {
|
|
1536
|
+
...projection,
|
|
1537
|
+
attention: new Map(projection.attention),
|
|
1538
|
+
directMarkers: new Map(projection.directMarkers),
|
|
1539
|
+
activityMarkers: new Map(projection.activityMarkers),
|
|
1540
|
+
attentionByThread: new Map([...projection.attentionByThread].map(([threadRef, followers]) => [threadRef, new Set(followers)])),
|
|
1541
|
+
};
|
|
1542
|
+
this.applyInboxDelta(nextProjection, inbox);
|
|
1543
|
+
const remainingUnreadCount = this.unreadForFrom(nextProjection, memberId, thread.threadRef).length;
|
|
1544
|
+
return Object.freeze({ task, thread, claims: this.claimsForTaskFrom(projection, task.taskRef), anchor,
|
|
1545
|
+
anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
|
|
1546
|
+
facts: Object.freeze(combined),
|
|
1547
|
+
readThroughSequence, remainingUnreadCount, ...(attention === undefined ? {} : { attention: nextAttention[0] ?? attention }),
|
|
1548
|
+
consumedDirectMarkers: Object.freeze(consumed), inbox });
|
|
1549
|
+
}
|
|
1550
|
+
readFactFrom(projection, memberId, fact, unread) {
|
|
1551
|
+
return Object.freeze({ fact, unread, direct: fact.kind === 'message'
|
|
1552
|
+
&& projection.directMarkers.has(this.directMarkerKey({ memberId, threadRef: fact.message.threadRef,
|
|
1553
|
+
messageRef: fact.message.messageRef, sequence: fact.sequence })) });
|
|
1554
|
+
}
|
|
1555
|
+
unreadFor(memberId, threadRef) {
|
|
1556
|
+
return this.unreadForFrom(this.state, memberId, threadRef);
|
|
1557
|
+
}
|
|
1558
|
+
unreadForFrom(projection, memberId, threadRef) {
|
|
1559
|
+
const attention = this.attentionForFrom(projection, memberId, threadRef);
|
|
1560
|
+
const facts = this.threadFactsFrom(projection, threadRef);
|
|
1561
|
+
const direct = this.directMarkersForFrom(projection, memberId, threadRef);
|
|
1562
|
+
const directKeys = new Set(direct.map(marker => this.directMarkerKey(marker)));
|
|
1563
|
+
const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
|
|
1564
|
+
const result = [];
|
|
1565
|
+
for (const fact of facts) {
|
|
1566
|
+
const marker = fact.kind === 'message' && directKeys.has(this.directMarkerKey({ memberId, threadRef,
|
|
1567
|
+
messageRef: fact.message.messageRef, sequence: fact.sequence }));
|
|
1568
|
+
const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
|
|
1569
|
+
activityRef: fact.activity.activityRef, sequence: fact.sequence }));
|
|
1570
|
+
const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
|
|
1571
|
+
&& fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
|
|
1572
|
+
if (marker || activityMarker || ordinary)
|
|
1573
|
+
result.push(this.readFactFrom(projection, memberId, fact, true));
|
|
1574
|
+
}
|
|
1575
|
+
return Object.freeze(result);
|
|
1576
|
+
}
|
|
1577
|
+
visibleToFollower(fact, memberId) {
|
|
1578
|
+
const sender = fact.kind === 'message' ? fact.message.sender : fact.activity.actor;
|
|
1579
|
+
return sender !== memberId;
|
|
1580
|
+
}
|
|
1581
|
+
threadFacts(threadRef) {
|
|
1582
|
+
return this.threadFactsFrom(this.state, threadRef);
|
|
1583
|
+
}
|
|
1584
|
+
threadFactsFrom(projection, threadRef) {
|
|
1585
|
+
return projection.factsByThread.get(threadRef) ?? [];
|
|
1586
|
+
}
|
|
1587
|
+
messageInboxDelta(message, sender, mentions, attention) {
|
|
1588
|
+
const markers = mentions.map(memberId => Object.freeze({ memberId, threadRef: message.threadRef,
|
|
1589
|
+
messageRef: message.messageRef, sequence: message.sequence }));
|
|
1590
|
+
return this.inboxDelta(attention, [], markers);
|
|
1591
|
+
}
|
|
1592
|
+
closeThreadInbox(activity, threadRef) {
|
|
1593
|
+
return this.closeThreadInboxFrom(this.state, activity, threadRef);
|
|
1594
|
+
}
|
|
1595
|
+
closeThreadInboxFrom(projection, activity, threadRef) {
|
|
1596
|
+
const recipients = [...projection.attention.values()]
|
|
1597
|
+
.filter(attention => attention.threadRef === threadRef && attention.memberId !== activity.actor)
|
|
1598
|
+
.map(attention => attention.memberId);
|
|
1599
|
+
const removed = [...projection.attention.values()].filter(attention => attention.threadRef === threadRef)
|
|
1600
|
+
.map(attention => Object.freeze({ memberId: attention.memberId, threadRef: attention.threadRef }));
|
|
1601
|
+
const activityMarkers = recipients.map(memberId => Object.freeze({ memberId, threadRef,
|
|
1602
|
+
activityRef: activity.activityRef, sequence: activity.sequence }));
|
|
1603
|
+
return this.inboxDelta([], removed, [], [...projection.directMarkers.values()].filter(marker => marker.threadRef === threadRef), activityMarkers, [...projection.activityMarkers.values()].filter(marker => marker.threadRef === threadRef));
|
|
1604
|
+
}
|
|
1605
|
+
reopenThreadInbox(activity, threadRef) {
|
|
1606
|
+
return this.reopenThreadInboxFrom(this.state, activity, threadRef);
|
|
1607
|
+
}
|
|
1608
|
+
reopenThreadInboxFrom(projection, activity, threadRef) {
|
|
1609
|
+
const recipients = new Set([...projection.activityMarkers.values()]
|
|
1610
|
+
.filter(marker => marker.threadRef === threadRef).map(marker => marker.memberId));
|
|
1611
|
+
const markers = [...recipients].map(memberId => Object.freeze({ memberId, threadRef,
|
|
1612
|
+
activityRef: activity.activityRef, sequence: activity.sequence }));
|
|
1613
|
+
return this.inboxDelta([], [], [], [], markers);
|
|
1614
|
+
}
|
|
1615
|
+
removeMemberThreadInbox(memberId, threadRefs) {
|
|
1616
|
+
const removed = [...this.state.attention.values()].filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
|
|
1617
|
+
.map(attention => Object.freeze({ memberId, threadRef: attention.threadRef }));
|
|
1618
|
+
const markers = [...this.state.directMarkers.values()].filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
1619
|
+
const activityMarkers = [...this.state.activityMarkers.values()].filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
1620
|
+
return this.inboxDelta([], removed, [], markers, [], activityMarkers);
|
|
1621
|
+
}
|
|
1622
|
+
inboxDelta(set = [], removed = [], addedMarkers = [], removedMarkers = [], addedActivityMarkers = [], removedActivityMarkers = []) {
|
|
1623
|
+
return Object.freeze({
|
|
1624
|
+
attention: Object.freeze({ set: Object.freeze(set), removed: Object.freeze(removed) }),
|
|
1625
|
+
directMarkers: Object.freeze({ added: Object.freeze(addedMarkers), removed: Object.freeze(removedMarkers) }),
|
|
1626
|
+
activityMarkers: Object.freeze({ added: Object.freeze(addedActivityMarkers), removed: Object.freeze(removedActivityMarkers) }),
|
|
1627
|
+
});
|
|
1628
|
+
}
|
|
1629
|
+
startAttention(memberId, threadRef, startSequence) {
|
|
1630
|
+
return Object.freeze({ memberId, threadRef, startSequence, readThroughSequence: Math.max(0, startSequence - 1) });
|
|
1631
|
+
}
|
|
1632
|
+
followAttention(memberId, threadRef) {
|
|
1633
|
+
return this.followAttentionFrom(this.state, memberId, threadRef);
|
|
1634
|
+
}
|
|
1635
|
+
followAttentionFrom(projection, memberId, threadRef) {
|
|
1636
|
+
const tail = this.threadFactsFrom(projection, threadRef).at(-1)?.sequence ?? 1;
|
|
1637
|
+
return Object.freeze({ memberId, threadRef, startSequence: tail + 1, readThroughSequence: tail });
|
|
1638
|
+
}
|
|
1639
|
+
currentTail(threadRef) {
|
|
1640
|
+
return this.threadFacts(threadRef).at(-1)?.sequence ?? 1;
|
|
1641
|
+
}
|
|
1642
|
+
attentionFor(memberId, threadRef) {
|
|
1643
|
+
return this.attentionForFrom(this.state, memberId, threadRef);
|
|
1644
|
+
}
|
|
1645
|
+
attentionForFrom(projection, memberId, threadRef) {
|
|
1646
|
+
return projection.attention.get(this.attentionKey(memberId, threadRef));
|
|
1647
|
+
}
|
|
1648
|
+
isFollowing(threadRef, memberId) {
|
|
1649
|
+
return this.attentionFor(memberId, threadRef) !== undefined;
|
|
1650
|
+
}
|
|
1651
|
+
isFollowingFrom(projection, threadRef, memberId) {
|
|
1652
|
+
return this.attentionForFrom(projection, memberId, threadRef) !== undefined;
|
|
1653
|
+
}
|
|
1654
|
+
directMarkersFor(memberId, threadRef) {
|
|
1655
|
+
return this.directMarkersForFrom(this.state, memberId, threadRef);
|
|
1656
|
+
}
|
|
1657
|
+
directMarkersForFrom(projection, memberId, threadRef) {
|
|
1658
|
+
return Object.freeze([...projection.directMarkers.values()].filter(marker => marker.memberId === memberId && marker.threadRef === threadRef)
|
|
1659
|
+
.sort((left, right) => left.sequence - right.sequence));
|
|
1660
|
+
}
|
|
1661
|
+
activityMarkersForFrom(projection, memberId, threadRef) {
|
|
1662
|
+
return Object.freeze([...projection.activityMarkers.values()].filter(marker => marker.memberId === memberId && marker.threadRef === threadRef)
|
|
1663
|
+
.sort((left, right) => left.sequence - right.sequence));
|
|
1664
|
+
}
|
|
1665
|
+
issueConfirmation(actor, workspaceId, channelRef, body, recipients, task, thread) {
|
|
1666
|
+
if (actor.kind !== 'human')
|
|
1667
|
+
throw new Error('only Human may invite an unfollowed Agent');
|
|
1668
|
+
for (const [token, confirmation] of this.confirmations)
|
|
1669
|
+
if (confirmation.actor === actor.memberId)
|
|
1670
|
+
this.confirmations.delete(token);
|
|
1671
|
+
const confirmationToken = `confirmation:${randomUUID()}`;
|
|
1672
|
+
this.confirmations.set(confirmationToken, Object.freeze({ actor: actor.memberId, workspaceId, channelRef,
|
|
1673
|
+
...(task === undefined ? {} : { taskRef: task.taskRef }), ...(thread === undefined ? {} : { threadRef: thread.threadRef }),
|
|
1674
|
+
body, recipients, attention: recipients.map(memberId => thread !== undefined && this.isFollowing(thread.threadRef, memberId)),
|
|
1675
|
+
memberStates: recipients.map(memberId => this.state.members.get(memberId)?.state ?? 'enabled') }));
|
|
1676
|
+
return Object.freeze({ kind: 'confirmation_required', confirmationToken, workspaceId, channelRef,
|
|
1677
|
+
recipients: Object.freeze(recipients.filter(memberId => this.state.members.has(memberId) && (thread === undefined || !this.isFollowing(thread.threadRef, memberId)))),
|
|
1678
|
+
...(task === undefined ? {} : { taskRef: task.taskRef }), ...(thread === undefined ? {} : { threadRef: thread.threadRef, revision: thread.revision }) });
|
|
1679
|
+
}
|
|
1680
|
+
consumeConfirmation(token, actor, workspaceId, channelRef, task, thread, body, recipients) {
|
|
1681
|
+
const confirmation = this.confirmations.get(token);
|
|
1682
|
+
this.confirmations.delete(token);
|
|
1683
|
+
const attention = recipients.map(memberId => thread !== undefined && this.isFollowing(thread.threadRef, memberId));
|
|
1684
|
+
const states = recipients.map(memberId => this.state.members.get(memberId)?.state ?? 'enabled');
|
|
1685
|
+
if (confirmation === undefined || confirmation.actor !== actor.memberId || confirmation.workspaceId !== workspaceId
|
|
1686
|
+
|| confirmation.channelRef !== channelRef || confirmation.taskRef !== task?.taskRef || confirmation.threadRef !== thread?.threadRef
|
|
1687
|
+
|| confirmation.body !== body || !this.sameList(confirmation.recipients, recipients)
|
|
1688
|
+
|| !this.sameList(confirmation.attention, attention) || !this.sameList(confirmation.memberStates, states)) {
|
|
1689
|
+
throw new Error('confirmation token is invalid or expired');
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
assertMentionTargets(channel, recipients) {
|
|
1693
|
+
for (const memberId of recipients) {
|
|
1694
|
+
if (memberId === AGENT_TEAM_HUMAN_MEMBER_ID)
|
|
1695
|
+
continue;
|
|
1696
|
+
const member = this.requireMember(memberId);
|
|
1697
|
+
if (member.state === 'inactive' || member.workspaceId !== channel.workspaceId || !this.isChannelMember(channel.channelRef, memberId)) {
|
|
1698
|
+
throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${channel.channelRef}'`);
|
|
1699
|
+
}
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
memberNotFollowing(workspaceId, channelRef, memberIds, task, thread) {
|
|
1703
|
+
return Object.freeze({ kind: 'member_not_following', workspaceId, channelRef, memberIds: Object.freeze(memberIds),
|
|
1704
|
+
...(task === undefined ? {} : { taskRef: task.taskRef }), ...(thread === undefined ? {} : { threadRef: thread.threadRef, revision: thread.revision }) });
|
|
1705
|
+
}
|
|
1706
|
+
unreadRequired(task, thread, unread) {
|
|
1707
|
+
return Object.freeze({ kind: 'unread_required', taskRef: task.taskRef, threadRef: thread.threadRef,
|
|
1708
|
+
revision: thread.revision, unreadCount: unread.length, directCount: unread.filter(item => item.direct).length });
|
|
1709
|
+
}
|
|
1710
|
+
staleRevision(task, thread, expectedRevision) {
|
|
1711
|
+
return Object.freeze({ kind: 'stale_revision', taskRef: task.taskRef, threadRef: thread.threadRef,
|
|
1712
|
+
expectedRevision, revision: thread.revision });
|
|
1713
|
+
}
|
|
1714
|
+
releaseSummaries(claims, actor, sequence) {
|
|
1715
|
+
const byThread = new Map();
|
|
1716
|
+
for (const claim of claims) {
|
|
1717
|
+
const grouped = byThread.get(claim.threadRef) ?? [];
|
|
1718
|
+
grouped.push(claim);
|
|
1719
|
+
byThread.set(claim.threadRef, grouped);
|
|
1720
|
+
}
|
|
1721
|
+
return Object.freeze([...byThread.entries()].map(([threadRef, grouped]) => Object.freeze({ activityRef: this.ref('activity'),
|
|
1722
|
+
kind: 'claims_released', taskRef: grouped[0].taskRef, threadRef, actor, sequence,
|
|
1723
|
+
claimRefs: Object.freeze(grouped.map(claim => claim.claimRef).sort()) })));
|
|
1724
|
+
}
|
|
1725
|
+
threadsForActivities(activities) {
|
|
1726
|
+
return Object.freeze(activities.map(activity => Object.freeze({ ...this.requireThread(activity.threadRef), revision: activity.sequence })));
|
|
1727
|
+
}
|
|
1728
|
+
claimsForTask(taskRef) {
|
|
1729
|
+
return this.claimsForTaskFrom(this.state, taskRef);
|
|
1730
|
+
}
|
|
1731
|
+
claimsForTaskFrom(projection, taskRef) {
|
|
1732
|
+
return Object.freeze([...projection.claims.values()].filter(claim => claim.taskRef === taskRef));
|
|
1733
|
+
}
|
|
1734
|
+
claimsForVisibleTasks(tasks) {
|
|
1735
|
+
const refs = new Set(tasks.map(task => task.taskRef));
|
|
1736
|
+
return Object.freeze([...this.state.claims.values()].filter(claim => refs.has(claim.taskRef)));
|
|
1737
|
+
}
|
|
1738
|
+
tasksForClaims(claims, projected) {
|
|
1739
|
+
return Object.freeze([...new Set(claims.map(claim => claim.taskRef))].map(taskRef => {
|
|
1740
|
+
const task = this.state.tasks.get(taskRef);
|
|
1741
|
+
return Object.freeze({ ...task, status: this.deriveResolvedTaskStatus(task, projected.values()) });
|
|
1742
|
+
}));
|
|
1743
|
+
}
|
|
1744
|
+
threadAnchor(task) {
|
|
1745
|
+
return this.threadAnchorFrom(this.state, task);
|
|
1746
|
+
}
|
|
1747
|
+
threadAnchorFrom(projection, task) {
|
|
1748
|
+
const anchor = projection.messages.find(message => message.taskRef === task.taskRef && message.topLevel);
|
|
1749
|
+
if (anchor === undefined)
|
|
1750
|
+
throw new Error(`Task '${task.taskRef}' has no anchor Message`);
|
|
1751
|
+
return anchor;
|
|
1752
|
+
}
|
|
1753
|
+
threadFactKey(fact) {
|
|
1754
|
+
return fact.kind === 'message' ? `message:${fact.message.messageRef}` : `activity:${fact.activity.activityRef}`;
|
|
1755
|
+
}
|
|
1756
|
+
taskNumbers(workspaceId, channelRef) {
|
|
1757
|
+
const numbers = new Map();
|
|
1758
|
+
let next = 1;
|
|
1759
|
+
for (const message of this.state.topLevelMessages) {
|
|
1760
|
+
if (this.state.channels.get(message.channelRef)?.workspaceId !== workspaceId)
|
|
1761
|
+
continue;
|
|
1762
|
+
if (channelRef !== undefined && message.channelRef !== channelRef)
|
|
1763
|
+
continue;
|
|
1764
|
+
numbers.set(message.taskRef, next);
|
|
1765
|
+
next += 1;
|
|
1766
|
+
}
|
|
1767
|
+
return numbers;
|
|
1768
|
+
}
|
|
1769
|
+
hasActiveClaim(memberId, taskRef) {
|
|
1770
|
+
return this.hasActiveClaimFrom(this.state, memberId, taskRef);
|
|
1771
|
+
}
|
|
1772
|
+
hasActiveClaimFrom(projection, memberId, taskRef) {
|
|
1773
|
+
return [...projection.claims.values()].some(claim => claim.owner === memberId && claim.taskRef === taskRef && claim.state === 'active');
|
|
1774
|
+
}
|
|
1775
|
+
deriveResolvedTaskStatus(task, claims) {
|
|
1776
|
+
return task.resolution === 'accepted' ? 'done' : task.resolution === 'closed' ? 'closed' : this.deriveTaskStatus(task.taskRef, claims);
|
|
1777
|
+
}
|
|
1778
|
+
deriveTaskStatus(taskRef, claims) {
|
|
1779
|
+
const relevant = [...claims].filter(claim => claim.taskRef === taskRef);
|
|
1780
|
+
if (relevant.some(claim => claim.state === 'active'))
|
|
1781
|
+
return 'in_progress';
|
|
1782
|
+
if (relevant.some(claim => claim.state === 'done'))
|
|
1783
|
+
return 'in_review';
|
|
1784
|
+
return 'todo';
|
|
1785
|
+
}
|
|
1786
|
+
setMemberState(request, state) {
|
|
1787
|
+
return this.enqueue(async () => {
|
|
1788
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
1789
|
+
if (existing !== undefined) {
|
|
1790
|
+
this.assertSameMemberState(existing, request, state);
|
|
1791
|
+
return this.resolved(this.memberResult(existing));
|
|
1792
|
+
}
|
|
1793
|
+
this.assertHumanActor(request.actor);
|
|
1794
|
+
const member = this.requireMember(request.memberId);
|
|
1795
|
+
const prior = state === 'suspended' ? 'enabled' : 'suspended';
|
|
1796
|
+
if (member.state !== prior)
|
|
1797
|
+
throw new Error(`Agent Member '${member.memberId}' is already ${member.state}`);
|
|
1798
|
+
const next = Object.freeze({ ...member, state });
|
|
1799
|
+
const operation = state === 'suspended'
|
|
1800
|
+
? Object.freeze({ ...this.operationBase(request, this.nextSequence()), kind: 'team/member-suspended', data: Object.freeze({ member: next }) })
|
|
1801
|
+
: Object.freeze({ ...this.operationBase(request, this.nextSequence()), kind: 'team/member-resumed', data: Object.freeze({ member: next }) });
|
|
1802
|
+
await this.table.put(operation.operationId, operation);
|
|
1803
|
+
this.apply(operation);
|
|
1804
|
+
this.confirmations.clear();
|
|
1805
|
+
return this.committed(this.memberResult(operation));
|
|
1806
|
+
});
|
|
1807
|
+
}
|
|
1808
|
+
assertActorForWorkspace(actor, workspaceId) {
|
|
1809
|
+
if (actor.kind === 'human') {
|
|
1810
|
+
this.assertHumanActor(actor);
|
|
1811
|
+
return actor;
|
|
1812
|
+
}
|
|
1813
|
+
const member = this.assertMemberActor(actor);
|
|
1814
|
+
if (member.workspaceId !== workspaceId)
|
|
1815
|
+
throw new Error('Member cannot mutate another Workspace');
|
|
1816
|
+
return actor;
|
|
1817
|
+
}
|
|
1818
|
+
assertHumanActor(actor) {
|
|
1819
|
+
const initialization = this.initialization();
|
|
1820
|
+
if (actor.kind !== 'human' || actor.memberId !== initialization.data.humanMemberId || actor.handle !== HUMAN_ACTOR.handle) {
|
|
1821
|
+
throw new Error('agent-team operation lacks Human authority');
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
assertMemberActor(actor) {
|
|
1825
|
+
const member = this.requireMember(actor.memberId);
|
|
1826
|
+
if (member.state !== 'enabled' || member.handle !== actor.handle)
|
|
1827
|
+
throw new Error('agent-team operation lacks enabled Member authority');
|
|
1828
|
+
return member;
|
|
1829
|
+
}
|
|
1830
|
+
/** Agents sometimes strip the branded prefix when echoing refs; point at the fix instead of a bare lookup failure. */
|
|
1831
|
+
unknownRefHint(ref, prefix, label) {
|
|
1832
|
+
return ref.startsWith(`${prefix}:`) ? ''
|
|
1833
|
+
: ` A ${label} ref must start with '${prefix}:'; reuse the full ref exactly as returned by Team tools ('${prefix}:${ref}').`;
|
|
1834
|
+
}
|
|
1835
|
+
requireTask(workspaceId, taskRef) {
|
|
1836
|
+
const task = this.state.tasks.get(taskRef);
|
|
1837
|
+
if (task === undefined)
|
|
1838
|
+
throw new Error(`unknown Task ref '${taskRef}'${this.unknownRefHint(taskRef, 'task', 'Task')}`);
|
|
1839
|
+
if (this.state.channels.get(task.channelRef)?.workspaceId !== workspaceId)
|
|
1840
|
+
throw new Error(`Task '${taskRef}' does not belong to Workspace '${workspaceId}'`);
|
|
1841
|
+
return task;
|
|
1842
|
+
}
|
|
1843
|
+
requireThread(threadRef) {
|
|
1844
|
+
const thread = this.state.threads.get(threadRef);
|
|
1845
|
+
if (thread === undefined)
|
|
1846
|
+
throw new Error(`unknown Thread ref '${threadRef}'${this.unknownRefHint(threadRef, 'thread', 'Thread')}`);
|
|
1847
|
+
return thread;
|
|
1848
|
+
}
|
|
1849
|
+
requireChannel(workspaceId, channelRef) {
|
|
1850
|
+
const channel = this.state.channels.get(channelRef);
|
|
1851
|
+
if (channel === undefined)
|
|
1852
|
+
throw new Error(`unknown Channel ref '${channelRef}'${this.unknownRefHint(channelRef, 'channel', 'Channel')}`);
|
|
1853
|
+
if (channel.workspaceId !== workspaceId)
|
|
1854
|
+
throw new Error(`Channel '${channelRef}' does not belong to Workspace '${workspaceId}'`);
|
|
1855
|
+
return channel;
|
|
1856
|
+
}
|
|
1857
|
+
requireMember(memberId) {
|
|
1858
|
+
const member = this.state.members.get(memberId);
|
|
1859
|
+
if (member === undefined)
|
|
1860
|
+
throw new Error(`unknown Agent Member '${memberId}'`);
|
|
1861
|
+
return member;
|
|
1862
|
+
}
|
|
1863
|
+
requireMemberChannel(member, channelRef) {
|
|
1864
|
+
if (!this.isChannelMember(channelRef, member.memberId))
|
|
1865
|
+
throw new Error(`Agent Member '${member.memberId}' is not authorized for Channel '${channelRef}'`);
|
|
1866
|
+
}
|
|
1867
|
+
isChannelMember(channelRef, memberId) {
|
|
1868
|
+
return this.state.memberships.get(channelRef)?.has(memberId) === true;
|
|
1869
|
+
}
|
|
1870
|
+
isChannelMemberFrom(projection, channelRef, memberId) {
|
|
1871
|
+
return projection.memberships.get(channelRef)?.has(memberId) === true;
|
|
1872
|
+
}
|
|
1873
|
+
channelThreadRefs(channelRef) {
|
|
1874
|
+
return new Set([...this.state.tasks.values()].filter(task => task.channelRef === channelRef).map(task => task.threadRef));
|
|
1875
|
+
}
|
|
1876
|
+
assertJoinableMember(workspaceId, memberId) {
|
|
1877
|
+
const member = this.requireMember(memberId);
|
|
1878
|
+
if (member.workspaceId !== workspaceId)
|
|
1879
|
+
throw new Error(`Agent Member '${memberId}' does not belong to Workspace '${workspaceId}'`);
|
|
1880
|
+
if (member.state !== 'enabled')
|
|
1881
|
+
throw new Error(`Agent Member '${memberId}' is ${member.state}; only enabled Members can join a Channel`);
|
|
1882
|
+
}
|
|
1883
|
+
normalizeRecipients(actor, recipients) {
|
|
1884
|
+
const normalized = this.normalizeUnique(recipients, 'recipient set');
|
|
1885
|
+
if (normalized.includes(actor.memberId))
|
|
1886
|
+
throw new Error('sender cannot be a recipient intent');
|
|
1887
|
+
return normalized;
|
|
1888
|
+
}
|
|
1889
|
+
normalizeUnique(values, label) {
|
|
1890
|
+
const unique = new Set(values ?? []);
|
|
1891
|
+
if (unique.size !== (values?.length ?? 0))
|
|
1892
|
+
throw new Error(`${label} contains duplicate Member refs`);
|
|
1893
|
+
return Object.freeze([...unique].sort());
|
|
1894
|
+
}
|
|
1895
|
+
normalizeDirection(direction) {
|
|
1896
|
+
return direction.normalize('NFKC').trim().replace(/\s+/gu, ' ').toLowerCase();
|
|
1897
|
+
}
|
|
1898
|
+
assertHandleAvailable(workspaceId, handle, exceptMemberId) {
|
|
1899
|
+
const normalized = handle.normalize('NFKC').trim().toLowerCase();
|
|
1900
|
+
if ([...this.state.members.values()].some(member => member.memberId !== exceptMemberId && member.state !== 'inactive'
|
|
1901
|
+
&& member.workspaceId === workspaceId
|
|
1902
|
+
&& member.handle.normalize('NFKC').trim().toLowerCase() === normalized)) {
|
|
1903
|
+
throw new Error(`Agent Member handle '${handle}' is already active in Workspace '${workspaceId}'`);
|
|
1904
|
+
}
|
|
1905
|
+
}
|
|
1906
|
+
/** Provider route and model id are exact identifiers; only whitespace-only values are rejected. */
|
|
1907
|
+
assertModelSelection(model) {
|
|
1908
|
+
if (model === undefined)
|
|
1909
|
+
return;
|
|
1910
|
+
if (model.provider.trim() === '' || model.model.trim() === '')
|
|
1911
|
+
throw new Error('member model selection must name a provider route and a model id');
|
|
1912
|
+
}
|
|
1913
|
+
initialization() {
|
|
1914
|
+
const operation = this.state.ordered[0];
|
|
1915
|
+
if (operation === undefined)
|
|
1916
|
+
throw new Error('agent-team ledger is not initialized');
|
|
1917
|
+
this.assertInitializationRecord(operation);
|
|
1918
|
+
return operation;
|
|
1919
|
+
}
|
|
1920
|
+
assertInitializationRecord(operation) {
|
|
1921
|
+
if (operation.kind !== 'team/initialized' || operation.sequence !== 1 || operation.previousOperationId !== null
|
|
1922
|
+
|| operation.actor.kind !== 'human' || operation.actor.memberId !== operation.data.humanMemberId) {
|
|
1923
|
+
throw new Error('agent-team initialization operation has an invalid payload');
|
|
1924
|
+
}
|
|
1925
|
+
}
|
|
1926
|
+
assertSameInitialization(operation, request) {
|
|
1927
|
+
if (operation.kind !== 'team/initialized' || !this.sameActor(operation.actor, request.actor) || operation.data.humanMemberId !== request.humanMemberId)
|
|
1928
|
+
this.throwRequestCollision(request.requestId);
|
|
1929
|
+
}
|
|
1930
|
+
assertSameChannelCreation(operation, request, memberIds) {
|
|
1931
|
+
if (operation.kind !== 'team/channel-created' || !this.sameActor(operation.actor, request.actor)
|
|
1932
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.channel.name !== request.name.trim()
|
|
1933
|
+
|| operation.data.channel.description !== request.description.trim() || !this.sameList(operation.data.memberIds, memberIds))
|
|
1934
|
+
this.throwRequestCollision(request.requestId);
|
|
1935
|
+
}
|
|
1936
|
+
assertSameMemberAdd(operation, request) {
|
|
1937
|
+
if (operation.kind !== 'team/member-added' || !this.sameActor(operation.actor, request.actor)
|
|
1938
|
+
|| operation.data.member.workspaceId !== request.workspaceId || operation.data.member.handle !== request.handle.trim()
|
|
1939
|
+
|| operation.data.member.description !== request.description.trim() || operation.data.member.presetId !== request.presetId.trim()
|
|
1940
|
+
|| !isDeepStrictEqual(operation.data.member.model ?? undefined, request.member.model ?? undefined)
|
|
1941
|
+
|| !this.sameList(operation.data.channelRefs, this.normalizeUnique(request.channelRefs, 'initial Member Channels')))
|
|
1942
|
+
this.throwRequestCollision(request.requestId);
|
|
1943
|
+
}
|
|
1944
|
+
assertSameMemberState(operation, request, state) {
|
|
1945
|
+
const kind = state === 'suspended' ? 'team/member-suspended' : 'team/member-resumed';
|
|
1946
|
+
if (operation.kind !== kind || !this.sameActor(operation.actor, request.actor) || operation.data.member.memberId !== request.memberId)
|
|
1947
|
+
this.throwRequestCollision(request.requestId);
|
|
1948
|
+
}
|
|
1949
|
+
assertSameChannelUpdate(operation, request) {
|
|
1950
|
+
if (operation.kind !== 'team/channel-updated' || !this.sameActor(operation.actor, request.actor)
|
|
1951
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.channel.channelRef !== request.channelRef
|
|
1952
|
+
|| operation.data.channel.name !== request.name.trim() || operation.data.channel.description !== request.description.trim())
|
|
1953
|
+
this.throwRequestCollision(request.requestId);
|
|
1954
|
+
}
|
|
1955
|
+
assertSameMemberUpdate(operation, request) {
|
|
1956
|
+
if (operation.kind !== 'team/member-updated' || !this.sameActor(operation.actor, request.actor)
|
|
1957
|
+
|| operation.data.member.memberId !== request.memberId || operation.data.member.handle !== request.handle.trim()
|
|
1958
|
+
|| operation.data.member.description !== request.description.trim()
|
|
1959
|
+
|| !isDeepStrictEqual(operation.data.member.model ?? undefined, request.model ?? undefined))
|
|
1960
|
+
this.throwRequestCollision(request.requestId);
|
|
1961
|
+
}
|
|
1962
|
+
assertSameChannelJoin(operation, request) {
|
|
1963
|
+
if (operation.kind !== 'team/channel-member-added' || !this.sameActor(operation.actor, request.actor)
|
|
1964
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.channelRef !== request.channelRef || operation.data.memberId !== request.memberId)
|
|
1965
|
+
this.throwRequestCollision(request.requestId);
|
|
1966
|
+
}
|
|
1967
|
+
assertSameChannelMemberRemoval(operation, request) {
|
|
1968
|
+
if (operation.kind !== 'team/channel-member-removed' || !this.sameActor(operation.actor, request.actor)
|
|
1969
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.channelRef !== request.channelRef || operation.data.memberId !== request.memberId)
|
|
1970
|
+
this.throwRequestCollision(request.requestId);
|
|
1971
|
+
}
|
|
1972
|
+
assertSameMessage(operation, request, recipients) {
|
|
1973
|
+
if (operation.kind !== 'team/message-sent' || !this.sameActor(operation.actor, request.actor)
|
|
1974
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.message.channelRef !== request.channelRef
|
|
1975
|
+
|| operation.data.message.body !== request.body.trim() || !this.sameList(operation.data.mentions, recipients))
|
|
1976
|
+
this.throwRequestCollision(request.requestId);
|
|
1977
|
+
}
|
|
1978
|
+
assertSameReply(operation, request, recipients) {
|
|
1979
|
+
if (operation.kind !== 'team/thread-replied' || !this.sameActor(operation.actor, request.actor)
|
|
1980
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.message.taskRef !== request.taskRef
|
|
1981
|
+
|| operation.data.message.body !== request.body.trim() || operation.data.baseRevision !== request.baseRevision
|
|
1982
|
+
|| !this.sameList(operation.data.mentions, recipients))
|
|
1983
|
+
this.throwRequestCollision(request.requestId);
|
|
1984
|
+
}
|
|
1985
|
+
assertSameClaim(operation, request) {
|
|
1986
|
+
const kind = request.action === 'claim' ? 'team/claim-created' : request.action === 'done' ? 'team/claim-done' : 'team/claim-released';
|
|
1987
|
+
if (operation.kind !== kind || !this.sameActor(operation.actor, request.actor) || operation.data.workspaceId !== request.workspaceId
|
|
1988
|
+
|| operation.data.task.taskRef !== request.taskRef || operation.data.baseRevision !== request.baseRevision
|
|
1989
|
+
|| (request.action === 'claim' ? operation.data.claim.direction !== request.direction?.trim() : operation.data.claim.claimRef !== request.claimRef))
|
|
1990
|
+
this.throwRequestCollision(request.requestId);
|
|
1991
|
+
}
|
|
1992
|
+
assertSameTask(operation, request) {
|
|
1993
|
+
if (operation.kind !== 'team/task-changed' || !this.sameActor(operation.actor, request.actor)
|
|
1994
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.task.taskRef !== request.taskRef
|
|
1995
|
+
|| operation.data.baseRevision !== request.baseRevision || operation.data.activity.kind !== request.action)
|
|
1996
|
+
this.throwRequestCollision(request.requestId);
|
|
1997
|
+
}
|
|
1998
|
+
assertSameRemoval(operation, request) {
|
|
1999
|
+
if (operation.kind !== 'team/member-removed' || !this.sameActor(operation.actor, request.actor)
|
|
2000
|
+
|| operation.data.member.memberId !== request.memberId)
|
|
2001
|
+
this.throwRequestCollision(request.requestId);
|
|
2002
|
+
}
|
|
2003
|
+
assertSameAttention(operation, request) {
|
|
2004
|
+
if (operation.kind !== 'team/thread-attention-changed' || !this.sameActor(operation.actor, request.actor)
|
|
2005
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.task.taskRef !== request.taskRef || operation.data.action !== request.action)
|
|
2006
|
+
this.throwRequestCollision(request.requestId);
|
|
2007
|
+
}
|
|
2008
|
+
assertSameThreadRead(operation, request) {
|
|
2009
|
+
if (operation.kind !== 'team/thread-read' || !this.sameActor(operation.actor, request.actor)
|
|
2010
|
+
|| operation.data.workspaceId !== request.workspaceId || operation.data.task.taskRef !== request.taskRef)
|
|
2011
|
+
this.throwRequestCollision(request.requestId);
|
|
2012
|
+
}
|
|
2013
|
+
sameTask(left, right) {
|
|
2014
|
+
return this.sameTaskIdentity(left, right) && left.status === right.status && left.resolution === right.resolution;
|
|
2015
|
+
}
|
|
2016
|
+
sameTaskIdentity(left, right) {
|
|
2017
|
+
return left.taskRef === right.taskRef && left.channelRef === right.channelRef && left.threadRef === right.threadRef;
|
|
2018
|
+
}
|
|
2019
|
+
sameClaim(left, right) {
|
|
2020
|
+
return this.sameClaimIdentity(left, right) && left.state === right.state;
|
|
2021
|
+
}
|
|
2022
|
+
sameClaimIdentity(left, right) {
|
|
2023
|
+
return left.claimRef === right.claimRef && left.taskRef === right.taskRef && left.threadRef === right.threadRef
|
|
2024
|
+
&& left.owner === right.owner && left.direction === right.direction && left.normalizedDirection === right.normalizedDirection;
|
|
2025
|
+
}
|
|
2026
|
+
sameThread(left, right) {
|
|
2027
|
+
return left.threadRef === right.threadRef && left.taskRef === right.taskRef && left.revision === right.revision;
|
|
2028
|
+
}
|
|
2029
|
+
sameMemberIdentity(left, right) {
|
|
2030
|
+
return left.memberId === right.memberId && left.sessionId === right.sessionId && left.workspaceId === right.workspaceId
|
|
2031
|
+
&& left.handle === right.handle && left.description === right.description && left.presetId === right.presetId
|
|
2032
|
+
&& isDeepStrictEqual(left.model ?? undefined, right.model ?? undefined)
|
|
2033
|
+
&& left.privateMemoryPath === right.privateMemoryPath;
|
|
2034
|
+
}
|
|
2035
|
+
sameActor(left, right) {
|
|
2036
|
+
return left.kind === right.kind && left.memberId === right.memberId && left.handle === right.handle;
|
|
2037
|
+
}
|
|
2038
|
+
sameList(left, right) {
|
|
2039
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
2040
|
+
}
|
|
2041
|
+
attentionKey(memberId, threadRef) {
|
|
2042
|
+
return `${memberId}\u0000${threadRef}`;
|
|
2043
|
+
}
|
|
2044
|
+
directMarkerKey(marker) {
|
|
2045
|
+
return `${marker.memberId}\u0000${marker.threadRef}\u0000${marker.messageRef}`;
|
|
2046
|
+
}
|
|
2047
|
+
activityMarkerKey(marker) {
|
|
2048
|
+
return `${marker.memberId}\u0000${marker.threadRef}\u0000${marker.activityRef}`;
|
|
2049
|
+
}
|
|
2050
|
+
addMembership(target, channelRef, memberId) {
|
|
2051
|
+
const members = target.memberships.get(channelRef) ?? new Set();
|
|
2052
|
+
members.add(memberId);
|
|
2053
|
+
target.memberships.set(channelRef, members);
|
|
2054
|
+
}
|
|
2055
|
+
addRef(refs, ref) {
|
|
2056
|
+
if (refs.has(ref))
|
|
2057
|
+
throw new Error(`agent-team ledger repeats entity ref '${ref}'`);
|
|
2058
|
+
refs.add(ref);
|
|
2059
|
+
}
|
|
2060
|
+
throwRequestCollision(requestId) {
|
|
2061
|
+
throw new Error(`agent-team request id '${requestId}' was reused with a different operation or payload`);
|
|
2062
|
+
}
|
|
2063
|
+
channelResult(operation) {
|
|
2064
|
+
return Object.freeze({ receipt: this.receipt(operation), channel: operation.data.channel, memberIds: operation.data.memberIds });
|
|
2065
|
+
}
|
|
2066
|
+
channelUpdateResult(operation) {
|
|
2067
|
+
return Object.freeze({ receipt: this.receipt(operation), channel: operation.data.channel });
|
|
2068
|
+
}
|
|
2069
|
+
memberResult(operation) {
|
|
2070
|
+
return Object.freeze({ receipt: this.receipt(operation), member: operation.data.member });
|
|
2071
|
+
}
|
|
2072
|
+
joinResult(operation) {
|
|
2073
|
+
return Object.freeze({ receipt: this.receipt(operation), channelRef: operation.data.channelRef, memberId: operation.data.memberId });
|
|
2074
|
+
}
|
|
2075
|
+
channelMemberRemovalResult(operation) {
|
|
2076
|
+
return Object.freeze({ receipt: this.receipt(operation), channelRef: operation.data.channelRef, memberId: operation.data.memberId,
|
|
2077
|
+
releasedClaims: operation.data.claims, removedAttention: operation.data.inbox.attention.removed });
|
|
2078
|
+
}
|
|
2079
|
+
messageResult(operation) {
|
|
2080
|
+
return Object.freeze({ kind: 'committed', receipt: this.receipt(operation), message: operation.data.message,
|
|
2081
|
+
task: operation.data.task, thread: operation.data.thread, attention: operation.data.inbox.attention.set,
|
|
2082
|
+
directMarkers: operation.data.inbox.directMarkers.added });
|
|
2083
|
+
}
|
|
2084
|
+
replyResult(operation) {
|
|
2085
|
+
return Object.freeze({ kind: 'committed', receipt: this.receipt(operation), message: operation.data.message,
|
|
2086
|
+
task: operation.data.task, thread: operation.data.thread, attention: operation.data.inbox.attention.set,
|
|
2087
|
+
directMarkers: operation.data.inbox.directMarkers.added });
|
|
2088
|
+
}
|
|
2089
|
+
claimResult(operation) {
|
|
2090
|
+
const attention = operation.data.inbox.attention.set.find(candidate => candidate.memberId === operation.data.claim.owner);
|
|
2091
|
+
const base = { kind: 'committed', receipt: this.receipt(operation), activity: operation.data.activity,
|
|
2092
|
+
claim: operation.data.claim, task: operation.data.task, thread: operation.data.thread };
|
|
2093
|
+
return attention === undefined ? Object.freeze(base) : Object.freeze({ ...base, attention });
|
|
2094
|
+
}
|
|
2095
|
+
taskResult(operation) {
|
|
2096
|
+
return Object.freeze({ kind: 'committed', receipt: this.receipt(operation), activity: operation.data.activity,
|
|
2097
|
+
task: operation.data.task, thread: operation.data.thread, claims: operation.data.claims });
|
|
2098
|
+
}
|
|
2099
|
+
attentionResult(operation) {
|
|
2100
|
+
return Object.freeze({ receipt: this.receipt(operation), task: operation.data.task, thread: operation.data.thread,
|
|
2101
|
+
...(operation.data.inbox.attention.set[0] === undefined ? {} : { attention: operation.data.inbox.attention.set[0] }) });
|
|
2102
|
+
}
|
|
2103
|
+
threadReadResult(operation) {
|
|
2104
|
+
// Loaded records are normalized by sortedRecords(); fresh writes always carry instants.
|
|
2105
|
+
const { anchor, facts } = operation.data;
|
|
2106
|
+
return Object.freeze({ receipt: this.receipt(operation), task: operation.data.task, thread: operation.data.thread,
|
|
2107
|
+
claims: operation.data.claims, anchor, anchorMentions: operation.data.anchorMentions, facts,
|
|
2108
|
+
readThroughSequence: operation.data.readThroughSequence, remainingUnreadCount: operation.data.remainingUnreadCount,
|
|
2109
|
+
...(operation.data.attention === undefined ? {} : { attention: operation.data.attention }),
|
|
2110
|
+
consumedDirectMarkers: operation.data.inbox.directMarkers.removed });
|
|
2111
|
+
}
|
|
2112
|
+
receipt(operation) {
|
|
2113
|
+
return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence });
|
|
2114
|
+
}
|
|
2115
|
+
removalResult(operation) {
|
|
2116
|
+
return Object.freeze({ receipt: this.receipt(operation), member: operation.data.member,
|
|
2117
|
+
releasedClaims: operation.data.claims, removedAttention: operation.data.inbox.attention.removed });
|
|
2118
|
+
}
|
|
2119
|
+
operationBase(request, sequence) {
|
|
2120
|
+
return { sequence, operationId: this.createOperationId(), requestId: request.requestId, occurredAt: this.createOccurredAt(),
|
|
2121
|
+
actor: Object.freeze({ ...request.actor }), previousOperationId: this.state.ordered.at(-1)?.operationId ?? null };
|
|
2122
|
+
}
|
|
2123
|
+
nextSequence() {
|
|
2124
|
+
if (this.state.ordered.length === 0)
|
|
2125
|
+
throw new Error('agent-team ledger is not initialized');
|
|
2126
|
+
return this.state.ordered.length + 1;
|
|
2127
|
+
}
|
|
2128
|
+
ref(kind) {
|
|
2129
|
+
return this.createRef(kind);
|
|
2130
|
+
}
|
|
2131
|
+
committed(value) { return Object.freeze({ value, committed: true }); }
|
|
2132
|
+
resolved(value) { return Object.freeze({ value, committed: false }); }
|
|
2133
|
+
sortedRecords() {
|
|
2134
|
+
const records = [...this.table.entries()].sort((left, right) => left[1].sequence - right[1].sequence);
|
|
2135
|
+
const occurrences = new Map();
|
|
2136
|
+
for (const [, operation] of records) {
|
|
2137
|
+
if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
|
|
2138
|
+
occurrences.set(operation.data.message.messageRef, operation.data.message.occurredAt ?? operation.occurredAt);
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
2141
|
+
return records.map(([id, operation]) => [id, this.normalizeOperation(operation, occurrences)]);
|
|
2142
|
+
}
|
|
2143
|
+
/** Ledgers written before message occurredAt existed store bare messages; Thread reads resolve instants from the originating operations. */
|
|
2144
|
+
normalizeOperation(operation, occurrences) {
|
|
2145
|
+
if (operation.kind !== 'team/thread-read')
|
|
2146
|
+
return operation;
|
|
2147
|
+
const stamp = (message) => (message.occurredAt === undefined
|
|
2148
|
+
? { ...message, occurredAt: occurrences.get(message.messageRef) ?? operation.occurredAt }
|
|
2149
|
+
: { ...message, occurredAt: message.occurredAt });
|
|
2150
|
+
const facts = operation.data.facts.map((fact) => (fact.fact.kind === 'message'
|
|
2151
|
+
? { ...fact, fact: { kind: 'message', sequence: fact.fact.sequence, message: stamp(fact.fact.message),
|
|
2152
|
+
mentions: fact.fact.mentions } }
|
|
2153
|
+
: { ...fact, fact: fact.fact }));
|
|
2154
|
+
return { ...operation, data: { ...operation.data, anchor: stamp(operation.data.anchor), facts } };
|
|
2155
|
+
}
|
|
2156
|
+
enqueue(operation) {
|
|
2157
|
+
const result = this.operationTail.then(operation);
|
|
2158
|
+
this.operationTail = result.then(() => { }, () => { });
|
|
2159
|
+
return result;
|
|
2160
|
+
}
|
|
2161
|
+
}
|