@wowyuarm/dsh-agent-team 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -0
- package/README.zh.md +13 -0
- package/package.json +52 -47
- package/packages/agent-team/README.md +4 -2
- package/packages/agent-team/README.zh.md +4 -2
- package/packages/agent-team/lib/context-management.js +40 -50
- package/packages/agent-team/lib/context-projection.js +10 -0
- package/packages/agent-team/lib/index.js +235 -112
- package/packages/agent-team/lib/invariant.js +38 -4
- package/packages/agent-team/lib/ledger.js +554 -271
- package/packages/agent-team/lib/member-context.js +6 -2
- package/packages/agent-team/lib/member-time-context.js +20 -5
- package/packages/agent-team/lib/pressure-policy.js +33 -9
- package/packages/agent-team/lib/session-event-cursor.js +116 -0
- package/packages/agent-team/lib/session-remediation.js +38 -26
- package/packages/agent-team/lib/spec.js +46 -34
- package/packages/agent-team/lib/stored-session-reader.js +138 -0
- package/packages/agent-team/lib/typert.host.js +107 -40
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
- package/packages/agent-team/lib/typert.remote-client.js +84 -32
- package/packages/agent-team/lib/types/context-management.d.ts +7 -0
- package/packages/agent-team/lib/types/context-management.d.ts.map +1 -1
- package/packages/agent-team/lib/types/context-projection.d.ts +8 -0
- package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -1
- package/packages/agent-team/lib/types/index.d.ts +78 -2
- package/packages/agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/types/invariant.d.ts.map +1 -1
- package/packages/agent-team/lib/types/ledger.d.ts +180 -7
- package/packages/agent-team/lib/types/ledger.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-context.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -1
- package/packages/agent-team/lib/types/pressure-policy.d.ts +18 -0
- package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -1
- package/packages/agent-team/lib/types/session-event-cursor.d.ts +119 -0
- package/packages/agent-team/lib/types/session-event-cursor.d.ts.map +1 -0
- package/packages/agent-team/lib/types/session-remediation.d.ts +23 -0
- package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -1
- package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
- package/packages/agent-team/lib/types/stored-session-reader.d.ts +92 -0
- package/packages/agent-team/lib/types/stored-session-reader.d.ts.map +1 -0
- package/packages/agent-team/lib/types/types/entities.d.ts +28 -2
- package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/operations.d.ts +45 -19
- package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts +44 -3
- package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
- package/packages/agent-team/preset/team-member/agent.cordis.yml +3 -1
- package/packages/client-agent-team/README.md +1 -1
- package/packages/client-agent-team/README.zh.md +1 -1
- package/packages/client-agent-team/lib/client.js +1068 -375
- package/packages/client-agent-team/lib/client.js.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +24 -4
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +42 -22
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +13 -24
- package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts +11 -5
- package/packages/client-agent-team/lib/types/client/TeamComposer.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamComposer.js +15 -9
- package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts +1 -1
- package/packages/client-agent-team/lib/types/client/TeamConversation.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamConversation.js +8 -1
- package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts +21 -0
- package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamInboxPage.js +71 -0
- package/packages/client-agent-team/lib/types/client/TeamMemberEditor.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamMemberEditor.js +8 -24
- package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts +9 -2
- package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamMessage.js +11 -4
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts +8 -0
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.js +21 -1
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +102 -28
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamWorkspaceBrowser.js +50 -7
- package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/index.js +17 -6
- package/packages/client-agent-team/lib/types/client/locales.d.ts +18 -0
- package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/locales.js +18 -0
- package/packages/client-agent-team/lib/types/client/navigation.d.ts +10 -0
- package/packages/client-agent-team/lib/types/client/navigation.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/navigation.js +18 -7
- package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts +41 -0
- package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/scope-coverage.js +64 -0
- package/packages/client-agent-team/lib/types/client/slots.d.ts +7 -1
- package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-changes.d.ts +26 -1
- package/packages/client-agent-team/lib/types/client/team-changes.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-changes.js +66 -15
- package/packages/client-agent-team/lib/types/client/team-dialog-save.d.ts +20 -0
- package/packages/client-agent-team/lib/types/client/team-dialog-save.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/team-dialog-save.js +36 -0
- package/packages/client-agent-team/lib/types/client/team-formatters.d.ts +9 -0
- package/packages/client-agent-team/lib/types/client/team-formatters.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-formatters.js +34 -6
- package/packages/tool-agent-team/README.md +1 -1
- package/packages/tool-agent-team/README.zh.md +1 -1
- package/packages/tool-agent-team/lib/context-tools.js +18 -14
- package/packages/tool-agent-team/lib/host-access.js +20 -0
- package/packages/tool-agent-team/lib/index.js +16 -17
- package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -1
- package/packages/tool-agent-team/lib/types/host-access.d.ts +14 -0
- package/packages/tool-agent-team/lib/types/host-access.d.ts.map +1 -0
- package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
|
@@ -16,9 +16,12 @@ export function agentTeamHumanActor() {
|
|
|
16
16
|
}
|
|
17
17
|
function emptyProjection() {
|
|
18
18
|
return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), memberships: new Map(),
|
|
19
|
-
claims: new Map(),
|
|
19
|
+
claims: new Map(), tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
|
|
20
20
|
orderedFacts: [], factsByThread: new Map(), channelRefByThread: new Map(), mentionsByMessage: new Map(), messageCountByThread: new Map(),
|
|
21
|
-
|
|
21
|
+
messagesByRef: new Map(),
|
|
22
|
+
attentionByThread: new Map(), previousSessions: new Map(), rolloverSeeds: new Map(),
|
|
23
|
+
anchorByThread: new Map(), taskNumberByTask: new Map(), taskCountByChannel: new Map(), attentionThreadsByMember: new Map(),
|
|
24
|
+
directMarkersByMember: new Map(), activityMarkersByMember: new Map(), observationsByThread: new Map() };
|
|
22
25
|
}
|
|
23
26
|
const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
|
|
24
27
|
progress: Object.freeze([]),
|
|
@@ -28,6 +31,40 @@ const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
|
|
|
28
31
|
function assertUnhandledKind(operation) {
|
|
29
32
|
throw new Error(`agent-team ledger does not handle operation kind '${operation.kind}'`);
|
|
30
33
|
}
|
|
34
|
+
/**
|
|
35
|
+
* Opening-line preview for a direct-only Human Inbox row: the Thread anchor's
|
|
36
|
+
* first line, trimmed, then capped at 120 characters with an explicit mark —
|
|
37
|
+
* the same bound the Thread page applies to its Task title, so the row and
|
|
38
|
+
* the page it opens never disagree about what a Thread is about.
|
|
39
|
+
*/
|
|
40
|
+
function boundedInboxPreview(body) {
|
|
41
|
+
const firstLine = body.split('\n', 1)[0]?.trim() ?? '';
|
|
42
|
+
return firstLine.length > 120 ? `${firstLine.slice(0, 119)}…` : firstLine;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Whether one operation's Inbox delta would change nothing. A read that finds
|
|
46
|
+
* no unread fact for its reader and no marker to consume produces exactly this
|
|
47
|
+
* delta, which is why such a read is a no-op rather than a durable operation.
|
|
48
|
+
*/
|
|
49
|
+
function isEmptyInboxDelta(delta) {
|
|
50
|
+
return delta.attention.set.length === 0 && delta.attention.removed.length === 0
|
|
51
|
+
&& delta.directMarkers.added.length === 0 && delta.directMarkers.removed.length === 0
|
|
52
|
+
&& delta.activityMarkers.added.length === 0 && delta.activityMarkers.removed.length === 0;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Which of the two durable Thread-read forms one record holds. Both schemas are
|
|
56
|
+
* strict and structurally disjoint — only the pre-receipt snapshot carries the
|
|
57
|
+
* Thread picture — so `thread` decides it for every stored record.
|
|
58
|
+
*/
|
|
59
|
+
export function isThreadReadSnapshot(data) {
|
|
60
|
+
return 'thread' in data;
|
|
61
|
+
}
|
|
62
|
+
/** The Thread and optional Task one read targeted, whichever form the record holds. */
|
|
63
|
+
export function threadReadTargetOf(data) {
|
|
64
|
+
return isThreadReadSnapshot(data)
|
|
65
|
+
? { threadRef: data.thread.threadRef, ...(data.task === undefined ? {} : { taskRef: data.task.taskRef }) }
|
|
66
|
+
: { threadRef: data.threadRef, ...(data.taskRef === undefined ? {} : { taskRef: data.taskRef }) };
|
|
67
|
+
}
|
|
31
68
|
/** Deep-freeze a Member capability overlay; absent stays absent. */
|
|
32
69
|
function freezeCapabilities(capabilities) {
|
|
33
70
|
if (capabilities === undefined)
|
|
@@ -50,12 +87,35 @@ export class AgentTeamLedger {
|
|
|
50
87
|
createOccurredAt;
|
|
51
88
|
createRef;
|
|
52
89
|
operationTail = Promise.resolve();
|
|
90
|
+
/**
|
|
91
|
+
* Head of the durable records the constructor's record-level replay
|
|
92
|
+
* validated, adoptable once by the invariant mount. See `validateAtMount`.
|
|
93
|
+
*/
|
|
94
|
+
bootValidation;
|
|
95
|
+
/**
|
|
96
|
+
* Sequence of the newest committed record that invalidated the shared
|
|
97
|
+
* projection — the durable position the Client's change cursors compare
|
|
98
|
+
* against, rebuilt by replay like every other index.
|
|
99
|
+
*
|
|
100
|
+
* Records whose commit is private read progress or audit-only
|
|
101
|
+
* (`changeScopesOf` derives no scope for them) never move it, so one
|
|
102
|
+
* Member's Thread read cannot make a parked Client believe that something it
|
|
103
|
+
* watches changed. Being a ledger position rather than a process counter, it
|
|
104
|
+
* also stays monotone across a restart.
|
|
105
|
+
*/
|
|
106
|
+
projectionVersion = 0;
|
|
53
107
|
constructor(table, options = {}) {
|
|
54
108
|
this.table = table;
|
|
55
109
|
this.createOperationId = options.operationId ?? (() => `operation:${randomUUID()}`);
|
|
56
110
|
this.createOccurredAt = options.occurredAt ?? (() => new Date().toISOString());
|
|
57
111
|
this.createRef = options.ref ?? (kind => `${kind}:${randomUUID()}`);
|
|
58
112
|
this.replay();
|
|
113
|
+
const head = this.state.ordered.at(-1);
|
|
114
|
+
this.bootValidation = Object.freeze({
|
|
115
|
+
records: this.state.ordered.length,
|
|
116
|
+
lastSequence: head?.sequence ?? 0,
|
|
117
|
+
lastOperationId: head?.operationId ?? null,
|
|
118
|
+
});
|
|
59
119
|
}
|
|
60
120
|
initialize(request = {
|
|
61
121
|
requestId: AGENT_TEAM_INITIALIZE_REQUEST_ID,
|
|
@@ -1025,8 +1085,19 @@ export class AgentTeamLedger {
|
|
|
1025
1085
|
const limit = request.limit ?? 50;
|
|
1026
1086
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
1027
1087
|
throw new Error('inbox limit must be an integer between 1 and 100');
|
|
1088
|
+
// The Human direct-only slice serves the Client's mention queue: rows show
|
|
1089
|
+
// only Threads with an unread mention, `totalUnreadCount` collapses to the
|
|
1090
|
+
// direct total so a badge on this call cannot bypass follow unread, and
|
|
1091
|
+
// each row carries its Channel display name plus the Thread's opening line
|
|
1092
|
+
// so the Client renders without a Channel view per row. The ordinary
|
|
1093
|
+
// (agent-facing) projection stays body-free.
|
|
1094
|
+
const directOnly = request.directOnly === true;
|
|
1095
|
+
const taskNumbers = directOnly ? this.taskNumbers(request.workspaceId) : undefined;
|
|
1028
1096
|
const items = [];
|
|
1029
|
-
for (const
|
|
1097
|
+
for (const threadRef of this.inboxCandidateThreads(authorized.memberId, directOnly)) {
|
|
1098
|
+
const thread = this.state.threads.get(threadRef);
|
|
1099
|
+
if (thread === undefined)
|
|
1100
|
+
continue;
|
|
1030
1101
|
const channelRef = this.channelRefForThread(thread.threadRef);
|
|
1031
1102
|
if (channelRef === undefined)
|
|
1032
1103
|
continue;
|
|
@@ -1039,20 +1110,47 @@ export class AgentTeamLedger {
|
|
|
1039
1110
|
continue;
|
|
1040
1111
|
const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
|
|
1041
1112
|
const directCount = unread.filter(item => item.direct).length;
|
|
1113
|
+
if (directOnly && directCount === 0)
|
|
1114
|
+
continue;
|
|
1042
1115
|
const attention = this.attentionFor(authorized.memberId, thread.threadRef);
|
|
1043
1116
|
// Same snapshot, same source as newestSequence: the instant hangs off
|
|
1044
1117
|
// the newest unread fact itself, never a second lookup that could
|
|
1045
1118
|
// observe a different commit between the two reads.
|
|
1046
1119
|
const newest = unread.at(-1).fact;
|
|
1047
|
-
|
|
1120
|
+
const taskNumber = directOnly && task !== undefined ? taskNumbers?.get(task.taskRef) : undefined;
|
|
1121
|
+
items.push(Object.freeze({ channelRef,
|
|
1122
|
+
...(directOnly ? { channelName: this.state.channels.get(channelRef)?.name ?? '' } : {}),
|
|
1123
|
+
...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
|
|
1124
|
+
unreadCount: unread.length, directCount,
|
|
1125
|
+
...(directOnly ? { previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body) } : {}),
|
|
1048
1126
|
newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt, ...(attention === undefined ? {} : { attention }) }));
|
|
1049
1127
|
}
|
|
1050
1128
|
items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
|
|
1051
1129
|
const selected = items.slice(0, limit);
|
|
1052
1130
|
return Object.freeze({ items: Object.freeze(selected),
|
|
1053
|
-
totalUnreadCount:
|
|
1131
|
+
totalUnreadCount: directOnly
|
|
1132
|
+
? items.reduce((sum, item) => sum + item.directCount, 0)
|
|
1133
|
+
: items.reduce((sum, item) => sum + item.unreadCount, 0),
|
|
1054
1134
|
totalDirectCount: items.reduce((sum, item) => sum + item.directCount, 0) });
|
|
1055
1135
|
}
|
|
1136
|
+
/**
|
|
1137
|
+
* Threads that can hold unread facts for one reader, from the per-reader
|
|
1138
|
+
* derived indexes: Attention follows plus direct and activity markers.
|
|
1139
|
+
* Every unread source is covered — ordinary unread requires Attention, and
|
|
1140
|
+
* marker unread requires a marker — so no full Thread scan is needed. The
|
|
1141
|
+
* direct-only slice needs only the direct-marker threads, since a row
|
|
1142
|
+
* requires at least one direct unread.
|
|
1143
|
+
*/
|
|
1144
|
+
inboxCandidateThreads(memberId, directOnly) {
|
|
1145
|
+
if (directOnly)
|
|
1146
|
+
return this.state.directMarkersByMember.get(memberId)?.keys() ?? [];
|
|
1147
|
+
const refs = new Set();
|
|
1148
|
+
for (const source of [this.state.attentionThreadsByMember.get(memberId), this.state.directMarkersByMember.get(memberId), this.state.activityMarkersByMember.get(memberId)]) {
|
|
1149
|
+
for (const threadRef of source?.keys() ?? [])
|
|
1150
|
+
refs.add(threadRef);
|
|
1151
|
+
}
|
|
1152
|
+
return refs;
|
|
1153
|
+
}
|
|
1056
1154
|
/** Model-visible notification material derived from the recipient's current durable unread state. */
|
|
1057
1155
|
notificationFacts(memberId, request) {
|
|
1058
1156
|
const member = this.requireMember(memberId);
|
|
@@ -1067,22 +1165,31 @@ export class AgentTeamLedger {
|
|
|
1067
1165
|
const existing = this.state.byRequest.get(request.requestId);
|
|
1068
1166
|
if (existing !== undefined) {
|
|
1069
1167
|
this.assertSameThreadRead(existing, request);
|
|
1070
|
-
|
|
1168
|
+
// A retry reports the original receipt with the picture the current
|
|
1169
|
+
// projection derives, and never advances a watermark a second time.
|
|
1170
|
+
const current = this.prepareRead(existing.data.memberId, existing.data.workspaceId, request);
|
|
1171
|
+
return Object.freeze({ value: Object.freeze({ receipt: this.receipt(existing), ...this.readPicture(current) }), committed: false });
|
|
1071
1172
|
}
|
|
1072
1173
|
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
1073
1174
|
const prepared = this.prepareRead(actor.memberId, request.workspaceId, request);
|
|
1175
|
+
// A read whose Inbox delta is empty writes nothing: the watermark does
|
|
1176
|
+
// not advance and no marker is consumed. It answers with the same picture
|
|
1177
|
+
// and no receipt, so the caller learns "no operation" from the result's
|
|
1178
|
+
// committed flag, never from a sequence this read did not write.
|
|
1179
|
+
if (isEmptyInboxDelta(prepared.inbox))
|
|
1180
|
+
return Object.freeze({ value: this.readPicture(prepared), committed: false });
|
|
1181
|
+
// The record carries progress, not the picture: the Thread, its facts,
|
|
1182
|
+
// the anchor and the reader's Attention are projection state every replay
|
|
1183
|
+
// already holds.
|
|
1184
|
+
const data = Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId,
|
|
1185
|
+
threadRef: prepared.thread.threadRef, ...(prepared.task === undefined ? {} : { taskRef: prepared.task.taskRef }),
|
|
1186
|
+
readThroughSequence: prepared.readThroughSequence, inbox: prepared.inbox });
|
|
1074
1187
|
const operation = Object.freeze({
|
|
1075
|
-
...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read',
|
|
1076
|
-
data: Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId,
|
|
1077
|
-
...(prepared.task === undefined ? {} : { task: prepared.task }),
|
|
1078
|
-
thread: prepared.thread, claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions,
|
|
1079
|
-
facts: prepared.facts,
|
|
1080
|
-
readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
|
|
1081
|
-
...(prepared.attention === undefined ? {} : { attention: prepared.attention }), inbox: prepared.inbox }),
|
|
1188
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read', data,
|
|
1082
1189
|
});
|
|
1083
1190
|
await this.table.put(operation.operationId, operation);
|
|
1084
1191
|
this.apply(operation);
|
|
1085
|
-
return
|
|
1192
|
+
return Object.freeze({ value: Object.freeze({ receipt: this.receipt(operation), ...this.readPicture(prepared) }), committed: true });
|
|
1086
1193
|
});
|
|
1087
1194
|
}
|
|
1088
1195
|
threadObservations(actor, request) {
|
|
@@ -1092,33 +1199,13 @@ export class AgentTeamLedger {
|
|
|
1092
1199
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
1093
1200
|
throw new Error('observation limit must be an integer between 1 and 100');
|
|
1094
1201
|
const threadRef = thread.threadRef;
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
if (removed.threadRef !== threadRef)
|
|
1103
|
-
continue;
|
|
1104
|
-
if (!current.delete(removed.memberId))
|
|
1105
|
-
continue;
|
|
1106
|
-
observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
|
|
1107
|
-
memberId: removed.memberId, action: 'unfollow' }));
|
|
1108
|
-
}
|
|
1109
|
-
for (const next of delta.attention.set) {
|
|
1110
|
-
if (next.threadRef !== threadRef)
|
|
1111
|
-
continue;
|
|
1112
|
-
const prior = current.get(next.memberId);
|
|
1113
|
-
current.set(next.memberId, next);
|
|
1114
|
-
// Task creation establishes initial Attention; reads only advance its watermark.
|
|
1115
|
-
if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
|
|
1116
|
-
continue;
|
|
1117
|
-
observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
|
|
1118
|
-
memberId: next.memberId, action: 'follow' }));
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
return Object.freeze({ items: Object.freeze(observations.slice(-limit)), followers: Object.freeze([...current.keys()]) });
|
|
1202
|
+
// Both slices are replay-derived: the observation log is appended by
|
|
1203
|
+
// committed Inbox deltas in ledger order, and the live follower set is
|
|
1204
|
+
// the same Attention index the follow history converges to.
|
|
1205
|
+
const observations = (this.state.observationsByThread.get(threadRef) ?? []).slice(-limit)
|
|
1206
|
+
.map(event => Object.freeze({ sequence: event.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
|
|
1207
|
+
memberId: event.memberId, action: event.action }));
|
|
1208
|
+
return Object.freeze({ items: Object.freeze(observations), followers: Object.freeze([...this.state.attentionByThread.get(threadRef) ?? []]) });
|
|
1122
1209
|
}
|
|
1123
1210
|
/** Every operation carrying an inbox delta drives the Inbox projection; the payload shape decides, not a per-kind list. */
|
|
1124
1211
|
attentionDelta(operation) {
|
|
@@ -1216,7 +1303,8 @@ export class AgentTeamLedger {
|
|
|
1216
1303
|
&& channel.state !== 'archived'
|
|
1217
1304
|
&& (memberId === undefined || this.isChannelMember(channel.channelRef, memberId)));
|
|
1218
1305
|
const channelRefs = new Set(channels.map(channel => channel.channelRef));
|
|
1219
|
-
const
|
|
1306
|
+
const before = request.cursor ?? this.state.ordered.length + 1;
|
|
1307
|
+
const matches = (fact) => {
|
|
1220
1308
|
const threadRef = fact.kind === 'message' ? fact.message.threadRef : fact.activity.threadRef;
|
|
1221
1309
|
const channelRef = this.channelRefForThread(threadRef);
|
|
1222
1310
|
if (channelRef === undefined || !channelRefs.has(channelRef))
|
|
@@ -1229,9 +1317,40 @@ export class AgentTeamLedger {
|
|
|
1229
1317
|
return false;
|
|
1230
1318
|
if (request.includeActivities === false && fact.kind === 'activity')
|
|
1231
1319
|
return false;
|
|
1232
|
-
return direction === 'before' ? fact.sequence <
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1320
|
+
return direction === 'before' ? fact.sequence < before : fact.sequence > cursor;
|
|
1321
|
+
};
|
|
1322
|
+
// Walk from the end the direction starts at and stop once `limit` matches
|
|
1323
|
+
// are collected plus one more proves `hasMore` — `limit` bounds only the
|
|
1324
|
+
// returned items, never the scan below a single extra match. A sidebar
|
|
1325
|
+
// asking for one item no longer sweeps the whole fact ledger.
|
|
1326
|
+
const selected = [];
|
|
1327
|
+
let hasMore = false;
|
|
1328
|
+
if (direction === 'before') {
|
|
1329
|
+
for (let index = this.state.orderedFacts.length - 1; index >= 0; index -= 1) {
|
|
1330
|
+
const fact = this.state.orderedFacts[index];
|
|
1331
|
+
if (!matches(fact))
|
|
1332
|
+
continue;
|
|
1333
|
+
if (selected.length < limit)
|
|
1334
|
+
selected.push(fact);
|
|
1335
|
+
else {
|
|
1336
|
+
hasMore = true;
|
|
1337
|
+
break;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
selected.reverse();
|
|
1341
|
+
}
|
|
1342
|
+
else {
|
|
1343
|
+
for (const fact of this.state.orderedFacts) {
|
|
1344
|
+
if (!matches(fact))
|
|
1345
|
+
continue;
|
|
1346
|
+
if (selected.length < limit)
|
|
1347
|
+
selected.push(fact);
|
|
1348
|
+
else {
|
|
1349
|
+
hasMore = true;
|
|
1350
|
+
break;
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1235
1354
|
const visibleTasks = [...this.state.tasks.values()].filter(task => channelRefs.has(task.channelRef)
|
|
1236
1355
|
&& (request.channelRef === undefined || task.channelRef === request.channelRef)
|
|
1237
1356
|
&& (request.threadRef === undefined || task.threadRef === request.threadRef));
|
|
@@ -1266,7 +1385,7 @@ export class AgentTeamLedger {
|
|
|
1266
1385
|
claims: this.claimsForVisibleTasks(visibleTasks),
|
|
1267
1386
|
activities: Object.freeze(selected.filter((fact) => fact.kind === 'activity').map(fact => fact.activity)),
|
|
1268
1387
|
cursor: nextCursor,
|
|
1269
|
-
hasMore
|
|
1388
|
+
hasMore,
|
|
1270
1389
|
});
|
|
1271
1390
|
}
|
|
1272
1391
|
status() {
|
|
@@ -1277,6 +1396,31 @@ export class AgentTeamLedger {
|
|
|
1277
1396
|
validate() {
|
|
1278
1397
|
this.validateRecords(this.sortedRecords());
|
|
1279
1398
|
}
|
|
1399
|
+
/**
|
|
1400
|
+
* Validate the durable ledger for the invariant's mount check, reusing the
|
|
1401
|
+
* constructor's record-level replay once instead of paying a second identical
|
|
1402
|
+
* one at startup.
|
|
1403
|
+
*
|
|
1404
|
+
* The adoption is gated on the operations table's single-writer identity —
|
|
1405
|
+
* this ledger's commit path is the only caller of `table.put` — so an
|
|
1406
|
+
* unchanged record count, head sequence and head operation id mean a
|
|
1407
|
+
* mount-time replay would re-derive precisely the records the constructor
|
|
1408
|
+
* already re-derived against its own scratch projection. Any commit between
|
|
1409
|
+
* construction and mount, and every later call, falls back to the full
|
|
1410
|
+
* replay. The check is never narrowed: the reused conclusion is still that
|
|
1411
|
+
* same independent replay of every durable record.
|
|
1412
|
+
*/
|
|
1413
|
+
validateAtMount() {
|
|
1414
|
+
const boot = this.bootValidation;
|
|
1415
|
+
this.bootValidation = undefined;
|
|
1416
|
+
const head = this.state.ordered.at(-1);
|
|
1417
|
+
if (boot !== undefined
|
|
1418
|
+
&& boot.records === this.state.ordered.length
|
|
1419
|
+
&& boot.lastSequence === (head?.sequence ?? 0)
|
|
1420
|
+
&& boot.lastOperationId === (head?.operationId ?? null))
|
|
1421
|
+
return;
|
|
1422
|
+
this.validate();
|
|
1423
|
+
}
|
|
1280
1424
|
hasCommitted(requestId) {
|
|
1281
1425
|
return this.state.byRequest.has(requestId);
|
|
1282
1426
|
}
|
|
@@ -1284,6 +1428,33 @@ export class AgentTeamLedger {
|
|
|
1284
1428
|
getOperation(operationId) {
|
|
1285
1429
|
return this.state.byOperation.get(operationId);
|
|
1286
1430
|
}
|
|
1431
|
+
/**
|
|
1432
|
+
* Claim releases are thread-visible facts: an open Channel page and every
|
|
1433
|
+
* affected Thread page must refetch alongside the workspace-wide change. Each
|
|
1434
|
+
* released activity contributes its own Thread scope plus the Channel that
|
|
1435
|
+
* owns its Task, deduplicated against the caller's initial scopes — which is
|
|
1436
|
+
* why the caller passes them in rather than the helper inventing them.
|
|
1437
|
+
*/
|
|
1438
|
+
withReleasedActivityScopes(scopes, tasks, activities) {
|
|
1439
|
+
const channelByTask = new Map(tasks.map(task => [task.taskRef, task.channelRef]));
|
|
1440
|
+
for (const activity of activities) {
|
|
1441
|
+
scopes.push({ kind: 'thread', threadRef: activity.threadRef });
|
|
1442
|
+
const channelRef = channelByTask.get(activity.taskRef);
|
|
1443
|
+
if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
|
|
1444
|
+
scopes.push({ kind: 'channel', channelRef });
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
return scopes;
|
|
1448
|
+
}
|
|
1449
|
+
/**
|
|
1450
|
+
* Durable position of the newest shared-projection commit: the version every
|
|
1451
|
+
* change waiter outside the presence scope observes. It only moves when a
|
|
1452
|
+
* commit has scopes somebody could refetch (`changeScopesOf`), never for
|
|
1453
|
+
* private read progress or an audit-only record.
|
|
1454
|
+
*/
|
|
1455
|
+
projectionSequence() {
|
|
1456
|
+
return this.projectionVersion;
|
|
1457
|
+
}
|
|
1287
1458
|
/** Scopes whose projections one committed operation invalidates; undefined wakes every waiter. */
|
|
1288
1459
|
changeScopesOf(operation) {
|
|
1289
1460
|
switch (operation.kind) {
|
|
@@ -1304,37 +1475,15 @@ export class AgentTeamLedger {
|
|
|
1304
1475
|
case 'team/member-updated':
|
|
1305
1476
|
case 'team/member-removed':
|
|
1306
1477
|
return [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
|
|
1307
|
-
case 'team/member-archived':
|
|
1308
|
-
|
|
1309
|
-
// pages refetch alongside the workspace-wide roster change.
|
|
1310
|
-
const channelByTask = new Map(operation.data.tasks.map(task => [task.taskRef, task.channelRef]));
|
|
1311
|
-
const scopes = [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
|
|
1312
|
-
for (const activity of operation.data.activities) {
|
|
1313
|
-
scopes.push({ kind: 'thread', threadRef: activity.threadRef });
|
|
1314
|
-
const channelRef = channelByTask.get(activity.taskRef);
|
|
1315
|
-
if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
|
|
1316
|
-
scopes.push({ kind: 'channel', channelRef });
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
return scopes;
|
|
1320
|
-
}
|
|
1478
|
+
case 'team/member-archived':
|
|
1479
|
+
return this.withReleasedActivityScopes([{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }], operation.data.tasks, operation.data.activities);
|
|
1321
1480
|
case 'team/channel-member-added':
|
|
1322
1481
|
return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }, { kind: 'channel', channelRef: operation.data.channelRef }];
|
|
1323
|
-
case 'team/channel-member-removed':
|
|
1324
|
-
|
|
1325
|
-
const scopes = [
|
|
1482
|
+
case 'team/channel-member-removed':
|
|
1483
|
+
return this.withReleasedActivityScopes([
|
|
1326
1484
|
{ kind: 'workspace', workspaceId: operation.data.workspaceId },
|
|
1327
1485
|
{ kind: 'channel', channelRef: operation.data.channelRef },
|
|
1328
|
-
];
|
|
1329
|
-
for (const activity of operation.data.activities) {
|
|
1330
|
-
scopes.push({ kind: 'thread', threadRef: activity.threadRef });
|
|
1331
|
-
const channelRef = channelByTask.get(activity.taskRef);
|
|
1332
|
-
if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
|
|
1333
|
-
scopes.push({ kind: 'channel', channelRef });
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
return scopes;
|
|
1337
|
-
}
|
|
1486
|
+
], operation.data.tasks, operation.data.activities);
|
|
1338
1487
|
case 'team/channel-archived': {
|
|
1339
1488
|
const scopes = [
|
|
1340
1489
|
{ kind: 'workspace', workspaceId: operation.data.workspaceId },
|
|
@@ -1632,7 +1781,7 @@ export class AgentTeamLedger {
|
|
|
1632
1781
|
|| operation.data.channel.createdAtSequence !== prior.createdAtSequence)
|
|
1633
1782
|
throw new Error('invalid Channel archival');
|
|
1634
1783
|
const threadRefs = new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === prior.channelRef));
|
|
1635
|
-
this.
|
|
1784
|
+
this.validateReleaseCleanup(operation.data, projection, undefined, threadRefs, operation.sequence, refs);
|
|
1636
1785
|
return;
|
|
1637
1786
|
}
|
|
1638
1787
|
if (operation.kind === 'team/member-removed') {
|
|
@@ -1683,20 +1832,38 @@ export class AgentTeamLedger {
|
|
|
1683
1832
|
assertMember();
|
|
1684
1833
|
else
|
|
1685
1834
|
assertHuman();
|
|
1686
|
-
|
|
1835
|
+
const data = operation.data;
|
|
1836
|
+
if (data.memberId !== operation.actor.memberId)
|
|
1687
1837
|
throw new Error('Thread read has wrong actor');
|
|
1688
|
-
const
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1838
|
+
const target = threadReadTargetOf(data);
|
|
1839
|
+
if (isThreadReadSnapshot(data)) {
|
|
1840
|
+
// The pre-receipt form froze the whole picture, so its expected value
|
|
1841
|
+
// is re-derived the same way it was written: from this record's prior
|
|
1842
|
+
// projection only, never from the live one.
|
|
1843
|
+
const expected = this.prepareReadFrom(projection, data.memberId, data.workspaceId, target);
|
|
1844
|
+
const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId,
|
|
1845
|
+
...(expected.task === undefined ? {} : { task: expected.task }), thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
|
|
1846
|
+
anchorMentions: expected.anchorMentions, facts: expected.facts,
|
|
1847
|
+
readThroughSequence: expected.readThroughSequence, remainingUnreadCount: expected.remainingUnreadCount,
|
|
1848
|
+
...(expected.attention === undefined ? {} : { attention: expected.attention }), inbox: expected.inbox });
|
|
1849
|
+
if (!isDeepStrictEqual(data, expectedData))
|
|
1850
|
+
throw new Error('invalid Thread read projection');
|
|
1851
|
+
}
|
|
1852
|
+
else {
|
|
1853
|
+
// A receipt claims progress and its Inbox delta, nothing else, so the
|
|
1854
|
+
// independent derivation stops there too: the picture and the unread
|
|
1855
|
+
// count left behind are projection state a replay re-derives for the
|
|
1856
|
+
// surfaces that need them, not content of this record. The Task ref is
|
|
1857
|
+
// derived rather than copied back, so a Task Thread read cannot drop
|
|
1858
|
+
// the Task it belongs to and shrink its own claim unchecked.
|
|
1859
|
+
const expected = this.prepareReadReceiptFrom(projection, data.memberId, data.workspaceId, target);
|
|
1860
|
+
const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId, threadRef: data.threadRef,
|
|
1861
|
+
...(expected.task === undefined ? {} : { taskRef: expected.task.taskRef }),
|
|
1862
|
+
readThroughSequence: expected.readThroughSequence, inbox: expected.inbox });
|
|
1863
|
+
if (!isDeepStrictEqual(data, expectedData))
|
|
1864
|
+
throw new Error('invalid Thread read receipt');
|
|
1865
|
+
}
|
|
1866
|
+
this.validateInboxDelta(data.inbox, projection, refs);
|
|
1700
1867
|
return;
|
|
1701
1868
|
}
|
|
1702
1869
|
if (operation.kind === 'team/thread-promoted') {
|
|
@@ -1874,11 +2041,23 @@ export class AgentTeamLedger {
|
|
|
1874
2041
|
}
|
|
1875
2042
|
assertUnhandledKind(operation);
|
|
1876
2043
|
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Validation of one operation's Inbox delta against the projection it was
|
|
2046
|
+
* derived from.
|
|
2047
|
+
*
|
|
2048
|
+
* Direct marker references resolve through the replay-derived Message index:
|
|
2049
|
+
* this runs once per inbox-carrying record, so a rescan of the Message list
|
|
2050
|
+
* here would make every record cost the whole ledger. The index is written by
|
|
2051
|
+
* the same fact appends that build `factsByThread`, so a lookup sees exactly
|
|
2052
|
+
* the records replayed before this one and can never reach a Message that
|
|
2053
|
+
* arrives later — which is why the operation's own entity, not yet in the
|
|
2054
|
+
* projection, needs the `additional*` fallbacks. The commit path resolved the
|
|
2055
|
+
* projection first and then its own record, and that precedence is kept.
|
|
2056
|
+
*/
|
|
1877
2057
|
validateInboxDelta(delta, projection, _refs, additionalThreadRefs = [], additionalMessages = [], additionalActivities = []) {
|
|
1878
2058
|
const attentionKeys = new Set();
|
|
1879
|
-
const knownThreadRefs = new Set([...projection.threads.keys(), ...additionalThreadRefs]);
|
|
1880
2059
|
for (const attention of delta.attention.set) {
|
|
1881
|
-
if (!
|
|
2060
|
+
if ((!projection.threads.has(attention.threadRef) && !additionalThreadRefs.includes(attention.threadRef)) || attention.startSequence < 1
|
|
1882
2061
|
|| attention.readThroughSequence < attention.startSequence - 1) {
|
|
1883
2062
|
throw new Error('invalid Attention delta');
|
|
1884
2063
|
}
|
|
@@ -1892,13 +2071,12 @@ export class AgentTeamLedger {
|
|
|
1892
2071
|
throw new Error('conflicting Attention delta');
|
|
1893
2072
|
}
|
|
1894
2073
|
const markerKeys = new Set();
|
|
1895
|
-
const messages = [...projection.messages, ...additionalMessages];
|
|
1896
2074
|
for (const marker of delta.directMarkers.added) {
|
|
1897
2075
|
const key = this.directMarkerKey(marker);
|
|
1898
2076
|
if (markerKeys.has(key) || projection.directMarkers.has(key))
|
|
1899
2077
|
throw new Error('invalid direct marker addition');
|
|
1900
2078
|
markerKeys.add(key);
|
|
1901
|
-
const message =
|
|
2079
|
+
const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
|
|
1902
2080
|
if (message === undefined || message.threadRef !== marker.threadRef || message.sequence !== marker.sequence
|
|
1903
2081
|
|| !this.validMentionTarget(projection, message.channelRef, marker.memberId))
|
|
1904
2082
|
throw new Error('invalid direct marker addition');
|
|
@@ -1909,17 +2087,20 @@ export class AgentTeamLedger {
|
|
|
1909
2087
|
throw new Error('invalid direct marker removal');
|
|
1910
2088
|
markerKeys.add(key);
|
|
1911
2089
|
const current = projection.directMarkers.get(key);
|
|
1912
|
-
const message =
|
|
2090
|
+
const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
|
|
1913
2091
|
if (!isDeepStrictEqual(current, marker) || message === undefined || message.threadRef !== marker.threadRef
|
|
1914
2092
|
|| message.sequence !== marker.sequence || !this.validMentionTarget(projection, message.channelRef, marker.memberId)) {
|
|
1915
2093
|
throw new Error('invalid direct marker removal');
|
|
1916
2094
|
}
|
|
1917
2095
|
}
|
|
1918
2096
|
const activityMarkerKeys = new Set();
|
|
1919
|
-
const activities = [...projection.orderedFacts.filter(fact => fact.kind === 'activity').map(fact => fact.activity), ...additionalActivities];
|
|
1920
2097
|
for (const marker of delta.activityMarkers.added) {
|
|
1921
2098
|
const key = this.activityMarkerKey(marker);
|
|
1922
|
-
|
|
2099
|
+
// Every activity marker is minted for the activity the same record
|
|
2100
|
+
// carries (promotion, close, reopen, acceptance), and a removal never
|
|
2101
|
+
// looks an activity up, so this record's own list is the whole
|
|
2102
|
+
// resolution space: scanning recorded activities would add nothing.
|
|
2103
|
+
const activity = additionalActivities.find(candidate => candidate.activityRef === marker.activityRef);
|
|
1923
2104
|
if (activityMarkerKeys.has(key) || projection.activityMarkers.has(key) || activity === undefined
|
|
1924
2105
|
|| activity.threadRef !== marker.threadRef || activity.sequence !== marker.sequence)
|
|
1925
2106
|
throw new Error('invalid activity marker addition');
|
|
@@ -1933,73 +2114,31 @@ export class AgentTeamLedger {
|
|
|
1933
2114
|
activityMarkerKeys.add(key);
|
|
1934
2115
|
}
|
|
1935
2116
|
}
|
|
2117
|
+
/**
|
|
2118
|
+
* Replay validation of one departure's release snapshot against the
|
|
2119
|
+
* projection it was derived from. `memberId` scopes everything to a single
|
|
2120
|
+
* departing Member; `undefined` validates every owner, which is what
|
|
2121
|
+
* archiving a whole Channel releases. The two scopes differ in exactly four
|
|
2122
|
+
* ways, all of them visible here rather than spread over two copies:
|
|
2123
|
+
*
|
|
2124
|
+
* 1. released Claims are owner-filtered for a Member, unfiltered for a Channel;
|
|
2125
|
+
* 2. Activities group per (owner, Thread) with owners sorted, which for a
|
|
2126
|
+
* single departing Member collapses to one Activity per Thread — the shape
|
|
2127
|
+
* the commit path writes;
|
|
2128
|
+
* 3. the inbox cleanup covers the departing Member's Attention and markers, or
|
|
2129
|
+
* every Member's on the archived Channel's Threads;
|
|
2130
|
+
* 4. the failure message names which of the two paths lost its cleanup.
|
|
2131
|
+
*/
|
|
1936
2132
|
validateReleaseCleanup(data, projection, memberId, threadRefs, sequence, refs) {
|
|
1937
2133
|
const releasedClaims = [...projection.claims.values()]
|
|
1938
|
-
.filter(claim => claim.owner === memberId && claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
1939
|
-
.map(claim => Object.freeze({ ...claim, state: 'released' }));
|
|
1940
|
-
if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
|
|
1941
|
-
const expected = releasedClaims[index];
|
|
1942
|
-
return expected === undefined || !this.sameClaim(expected, claim);
|
|
1943
|
-
}))
|
|
1944
|
-
throw new Error('invalid released Claim projection');
|
|
1945
|
-
const byThread = new Map();
|
|
1946
|
-
for (const claim of releasedClaims)
|
|
1947
|
-
byThread.set(claim.threadRef, [...(byThread.get(claim.threadRef) ?? []), claim]);
|
|
1948
|
-
const expectedActivities = [...byThread.entries()].map(([threadRef, claims]) => ({
|
|
1949
|
-
kind: 'claims_released', taskRef: claims[0].taskRef, threadRef, actor: memberId, sequence,
|
|
1950
|
-
claimRefs: claims.map(claim => claim.claimRef).sort(),
|
|
1951
|
-
}));
|
|
1952
|
-
if (data.activities.length !== expectedActivities.length || data.activities.some((activity, index) => {
|
|
1953
|
-
const expected = expectedActivities[index];
|
|
1954
|
-
return expected === undefined || activity.kind !== expected.kind || activity.taskRef !== expected.taskRef
|
|
1955
|
-
|| activity.threadRef !== expected.threadRef || activity.actor !== expected.actor || activity.sequence !== expected.sequence
|
|
1956
|
-
|| !this.sameList(activity.claimRefs, expected.claimRefs);
|
|
1957
|
-
}))
|
|
1958
|
-
throw new Error('invalid released Claim activities');
|
|
1959
|
-
for (const claim of data.claims) {
|
|
1960
|
-
const prior = projection.claims.get(claim.claimRef);
|
|
1961
|
-
if (prior === undefined)
|
|
1962
|
-
throw new Error('invalid released Claim reference');
|
|
1963
|
-
}
|
|
1964
|
-
for (const activity of data.activities)
|
|
1965
|
-
this.addRef(refs, activity.activityRef);
|
|
1966
|
-
const projectedClaims = new Map(projection.claims);
|
|
1967
|
-
for (const claim of releasedClaims)
|
|
1968
|
-
projectedClaims.set(claim.claimRef, claim);
|
|
1969
|
-
const expectedTasks = [...new Set(releasedClaims.map(claim => claim.taskRef))].map(taskRef => {
|
|
1970
|
-
const task = projection.tasks.get(taskRef);
|
|
1971
|
-
return Object.freeze({ ...task, status: this.deriveResolvedTaskStatus(task, projectedClaims.values()) });
|
|
1972
|
-
});
|
|
1973
|
-
const expectedThreads = expectedActivities.map(activity => {
|
|
1974
|
-
const thread = projection.threads.get(activity.threadRef);
|
|
1975
|
-
return Object.freeze({ ...thread, revision: sequence });
|
|
1976
|
-
});
|
|
1977
|
-
if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
|
|
1978
|
-
throw new Error('invalid released Claim Task or Thread projection');
|
|
1979
|
-
}
|
|
1980
|
-
const expectedAttention = [...projection.attention.values()]
|
|
1981
|
-
.filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
|
|
1982
|
-
.map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
|
|
1983
|
-
const expectedMarkers = [...projection.directMarkers.values()]
|
|
1984
|
-
.filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
1985
|
-
const expectedActivityMarkers = [...projection.activityMarkers.values()]
|
|
1986
|
-
.filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
1987
|
-
const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
|
|
1988
|
-
if (!isDeepStrictEqual(data.inbox, expectedInbox))
|
|
1989
|
-
throw new Error('invalid Member inbox cleanup');
|
|
1990
|
-
this.validateInboxDelta(data.inbox, projection, refs);
|
|
1991
|
-
}
|
|
1992
|
-
/** Replay validation of one Channel archival's release snapshot across every owner. */
|
|
1993
|
-
validateChannelArchivalCleanup(data, projection, threadRefs, sequence, refs) {
|
|
1994
|
-
const releasedClaims = [...projection.claims.values()]
|
|
1995
|
-
.filter(claim => claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
2134
|
+
.filter(claim => (memberId === undefined || claim.owner === memberId) && claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
1996
2135
|
.map(claim => Object.freeze({ ...claim, state: 'released' }));
|
|
1997
2136
|
if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
|
|
1998
2137
|
const expected = releasedClaims[index];
|
|
1999
2138
|
return expected === undefined || !this.sameClaim(expected, claim);
|
|
2000
2139
|
}))
|
|
2001
2140
|
throw new Error('invalid released Claim projection');
|
|
2002
|
-
// One
|
|
2141
|
+
// One Activity per (owner, Thread), owners sorted like the commit path.
|
|
2003
2142
|
const byOwner = new Map();
|
|
2004
2143
|
for (const claim of releasedClaims) {
|
|
2005
2144
|
const byThread = byOwner.get(claim.owner) ?? new Map();
|
|
@@ -2038,17 +2177,17 @@ export class AgentTeamLedger {
|
|
|
2038
2177
|
if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
|
|
2039
2178
|
throw new Error('invalid released Claim Task or Thread projection');
|
|
2040
2179
|
}
|
|
2041
|
-
// Attention and marker cleanup covers EVERY Member on the archived Threads.
|
|
2042
2180
|
const expectedAttention = [...projection.attention.values()]
|
|
2043
|
-
.filter(attention => threadRefs.has(attention.threadRef))
|
|
2181
|
+
.filter(attention => (memberId === undefined || attention.memberId === memberId) && threadRefs.has(attention.threadRef))
|
|
2044
2182
|
.map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
|
|
2045
2183
|
const expectedMarkers = [...projection.directMarkers.values()]
|
|
2046
|
-
.filter(marker => threadRefs.has(marker.threadRef));
|
|
2184
|
+
.filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
|
|
2047
2185
|
const expectedActivityMarkers = [...projection.activityMarkers.values()]
|
|
2048
|
-
.filter(marker => threadRefs.has(marker.threadRef));
|
|
2186
|
+
.filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
|
|
2049
2187
|
const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
|
|
2050
|
-
if (!isDeepStrictEqual(data.inbox, expectedInbox))
|
|
2051
|
-
throw new Error('invalid Channel archival inbox cleanup');
|
|
2188
|
+
if (!isDeepStrictEqual(data.inbox, expectedInbox)) {
|
|
2189
|
+
throw new Error(memberId === undefined ? 'invalid Channel archival inbox cleanup' : 'invalid Member inbox cleanup');
|
|
2190
|
+
}
|
|
2052
2191
|
this.validateInboxDelta(data.inbox, projection, refs);
|
|
2053
2192
|
}
|
|
2054
2193
|
validateMessageInbox(operation, projection) {
|
|
@@ -2088,6 +2227,12 @@ export class AgentTeamLedger {
|
|
|
2088
2227
|
}
|
|
2089
2228
|
apply(operation) {
|
|
2090
2229
|
this.applyTo(this.state, operation);
|
|
2230
|
+
// Live apply only: the record-validation replay re-derives each record
|
|
2231
|
+
// against its own scratch projection, so a scope derivation there would be
|
|
2232
|
+
// discarded.
|
|
2233
|
+
const scopes = this.changeScopesOf(operation);
|
|
2234
|
+
if (scopes === undefined || scopes.length !== 0)
|
|
2235
|
+
this.projectionVersion = operation.sequence;
|
|
2091
2236
|
}
|
|
2092
2237
|
applyTo(target, operation) {
|
|
2093
2238
|
target.ordered.push(operation);
|
|
@@ -2157,15 +2302,7 @@ export class AgentTeamLedger {
|
|
|
2157
2302
|
}
|
|
2158
2303
|
if (operation.kind === 'team/channel-member-removed') {
|
|
2159
2304
|
target.memberships.get(operation.data.channelRef)?.delete(operation.data.memberId);
|
|
2160
|
-
|
|
2161
|
-
target.claims.set(claim.claimRef, claim);
|
|
2162
|
-
for (const activity of operation.data.activities)
|
|
2163
|
-
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
2164
|
-
for (const task of operation.data.tasks)
|
|
2165
|
-
target.tasks.set(task.taskRef, task);
|
|
2166
|
-
for (const thread of operation.data.threads)
|
|
2167
|
-
target.threads.set(thread.threadRef, thread);
|
|
2168
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2305
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2169
2306
|
return;
|
|
2170
2307
|
}
|
|
2171
2308
|
if (operation.kind === 'team/channel-archived') {
|
|
@@ -2173,30 +2310,14 @@ export class AgentTeamLedger {
|
|
|
2173
2310
|
// restore returns every Member to the Channel. Visibility filters by
|
|
2174
2311
|
// channel state instead.
|
|
2175
2312
|
target.channels.set(operation.data.channel.channelRef, operation.data.channel);
|
|
2176
|
-
|
|
2177
|
-
target.claims.set(claim.claimRef, claim);
|
|
2178
|
-
for (const activity of operation.data.activities)
|
|
2179
|
-
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
2180
|
-
for (const task of operation.data.tasks)
|
|
2181
|
-
target.tasks.set(task.taskRef, task);
|
|
2182
|
-
for (const thread of operation.data.threads)
|
|
2183
|
-
target.threads.set(thread.threadRef, thread);
|
|
2184
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2313
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2185
2314
|
return;
|
|
2186
2315
|
}
|
|
2187
2316
|
if (operation.kind === 'team/member-removed') {
|
|
2188
2317
|
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
2189
2318
|
for (const membership of target.memberships.values())
|
|
2190
2319
|
membership.delete(operation.data.member.memberId);
|
|
2191
|
-
|
|
2192
|
-
target.claims.set(claim.claimRef, claim);
|
|
2193
|
-
for (const activity of operation.data.activities)
|
|
2194
|
-
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
2195
|
-
for (const task of operation.data.tasks)
|
|
2196
|
-
target.tasks.set(task.taskRef, task);
|
|
2197
|
-
for (const thread of operation.data.threads)
|
|
2198
|
-
target.threads.set(thread.threadRef, thread);
|
|
2199
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2320
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2200
2321
|
return;
|
|
2201
2322
|
}
|
|
2202
2323
|
if (operation.kind === 'team/member-archived') {
|
|
@@ -2204,34 +2325,37 @@ export class AgentTeamLedger {
|
|
|
2204
2325
|
// restore returns the Member to its Channels. Visibility filters by
|
|
2205
2326
|
// member state instead.
|
|
2206
2327
|
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
2207
|
-
|
|
2208
|
-
target.claims.set(claim.claimRef, claim);
|
|
2209
|
-
for (const activity of operation.data.activities)
|
|
2210
|
-
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
2211
|
-
for (const task of operation.data.tasks)
|
|
2212
|
-
target.tasks.set(task.taskRef, task);
|
|
2213
|
-
for (const thread of operation.data.threads)
|
|
2214
|
-
target.threads.set(thread.threadRef, thread);
|
|
2215
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2328
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2216
2329
|
return;
|
|
2217
2330
|
}
|
|
2218
2331
|
if (operation.kind === 'team/thread-promoted') {
|
|
2219
2332
|
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2220
2333
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2221
2334
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2335
|
+
this.recordTaskNumber(target, operation.data.task);
|
|
2336
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2222
2337
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2223
2338
|
return;
|
|
2224
2339
|
}
|
|
2225
2340
|
if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
|
|
2226
2341
|
const { message, mentions } = operation.data;
|
|
2227
|
-
target.messages.push(message);
|
|
2228
2342
|
target.mentionsByMessage.set(message.messageRef, Object.freeze([...mentions]));
|
|
2229
2343
|
this.appendMessageFact(target, message, mentions, message.occurredAt ?? operation.occurredAt);
|
|
2230
2344
|
if (operation.data.task !== undefined)
|
|
2231
2345
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2232
2346
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2233
|
-
if (message.topLevel)
|
|
2347
|
+
if (message.topLevel) {
|
|
2234
2348
|
target.channelRefByThread.set(message.threadRef, message.channelRef);
|
|
2349
|
+
// The anchor is the Thread's first topLevel Message; a later topLevel
|
|
2350
|
+
// Message (none today) must not displace it, matching the linear scan.
|
|
2351
|
+
if (!target.anchorByThread.has(message.threadRef))
|
|
2352
|
+
target.anchorByThread.set(message.threadRef, message);
|
|
2353
|
+
}
|
|
2354
|
+
// Only message-sent mints a Task ordinal; thread-replied may echo the
|
|
2355
|
+
// Task without renumbering it, like the operation scan it replaces.
|
|
2356
|
+
if (operation.kind === 'team/message-sent' && operation.data.task !== undefined)
|
|
2357
|
+
this.recordTaskNumber(target, operation.data.task);
|
|
2358
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2235
2359
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2236
2360
|
return;
|
|
2237
2361
|
}
|
|
@@ -2240,6 +2364,7 @@ export class AgentTeamLedger {
|
|
|
2240
2364
|
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2241
2365
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2242
2366
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2367
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2243
2368
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2244
2369
|
return;
|
|
2245
2370
|
}
|
|
@@ -2249,10 +2374,12 @@ export class AgentTeamLedger {
|
|
|
2249
2374
|
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2250
2375
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2251
2376
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2377
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2252
2378
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2253
2379
|
return;
|
|
2254
2380
|
}
|
|
2255
2381
|
if (operation.kind === 'team/thread-attention-changed' || operation.kind === 'team/thread-read') {
|
|
2382
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2256
2383
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2257
2384
|
return;
|
|
2258
2385
|
}
|
|
@@ -2263,6 +2390,25 @@ export class AgentTeamLedger {
|
|
|
2263
2390
|
}
|
|
2264
2391
|
assertUnhandledKind(operation);
|
|
2265
2392
|
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Replay the departure snapshot a releasing operation carries. The order is
|
|
2395
|
+
* replay semantics, not style: Claims land before the Activities that
|
|
2396
|
+
* reference them, and Tasks/Threads before the inbox delta that reads them.
|
|
2397
|
+
* Each caller still applies its own identifying writes (member, membership,
|
|
2398
|
+
* channel) around this call.
|
|
2399
|
+
*/
|
|
2400
|
+
applyReleaseSnapshot(target, operation, data, occurredAt) {
|
|
2401
|
+
for (const claim of data.claims)
|
|
2402
|
+
target.claims.set(claim.claimRef, claim);
|
|
2403
|
+
for (const activity of data.activities)
|
|
2404
|
+
this.appendActivityFact(target, activity, occurredAt);
|
|
2405
|
+
for (const task of data.tasks)
|
|
2406
|
+
target.tasks.set(task.taskRef, task);
|
|
2407
|
+
for (const thread of data.threads)
|
|
2408
|
+
target.threads.set(thread.threadRef, thread);
|
|
2409
|
+
this.recordAttentionObservations(target, operation, data.inbox);
|
|
2410
|
+
this.applyInboxDelta(target, data.inbox);
|
|
2411
|
+
}
|
|
2266
2412
|
/** Facts arrive in ledger sequence order, so global and per-thread lists stay sorted by append only. */
|
|
2267
2413
|
appendMessageFact(target, message, mentions, occurredAt) {
|
|
2268
2414
|
const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions, occurredAt });
|
|
@@ -2271,6 +2417,9 @@ export class AgentTeamLedger {
|
|
|
2271
2417
|
facts.push(fact);
|
|
2272
2418
|
target.factsByThread.set(message.threadRef, facts);
|
|
2273
2419
|
target.messageCountByThread.set(message.threadRef, (target.messageCountByThread.get(message.threadRef) ?? 0) + 1);
|
|
2420
|
+
// The ref index is written in this same step, so it is exactly the set of
|
|
2421
|
+
// replayed Messages: a validator lookup can never reach a later record.
|
|
2422
|
+
target.messagesByRef.set(message.messageRef, message);
|
|
2274
2423
|
}
|
|
2275
2424
|
appendActivityFact(target, activity, occurredAt) {
|
|
2276
2425
|
const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity, occurredAt });
|
|
@@ -2279,6 +2428,44 @@ export class AgentTeamLedger {
|
|
|
2279
2428
|
facts.push(fact);
|
|
2280
2429
|
target.factsByThread.set(activity.threadRef, facts);
|
|
2281
2430
|
}
|
|
2431
|
+
/** Display ordinals are a per-Channel creation counter; the workspace filter happens at read time. */
|
|
2432
|
+
recordTaskNumber(target, task) {
|
|
2433
|
+
const ordinal = (target.taskCountByChannel.get(task.channelRef) ?? 0) + 1;
|
|
2434
|
+
target.taskCountByChannel.set(task.channelRef, ordinal);
|
|
2435
|
+
target.taskNumberByTask.set(task.taskRef, ordinal);
|
|
2436
|
+
}
|
|
2437
|
+
/**
|
|
2438
|
+
* Replay-order Attention observations, appended from committed Inbox deltas
|
|
2439
|
+
* only (the hypothetical read projection applies its delta directly). The
|
|
2440
|
+
* follow/unfollow decision mirrors the full replay scan it replaces: a
|
|
2441
|
+
* removal records only when Attention existed, and a set records unless it
|
|
2442
|
+
* is the Thread-creating Message (initial Attention) or merely advances the
|
|
2443
|
+
* same follow's watermark. Removals are evaluated before sets, exactly like
|
|
2444
|
+
* the scan's loop order.
|
|
2445
|
+
*/
|
|
2446
|
+
recordAttentionObservations(target, operation, delta) {
|
|
2447
|
+
if (delta.attention.removed.length === 0 && delta.attention.set.length === 0)
|
|
2448
|
+
return;
|
|
2449
|
+
const retired = new Set();
|
|
2450
|
+
for (const key of delta.attention.removed) {
|
|
2451
|
+
retired.add(this.attentionKey(key.memberId, key.threadRef));
|
|
2452
|
+
if (!target.attention.has(this.attentionKey(key.memberId, key.threadRef)))
|
|
2453
|
+
continue;
|
|
2454
|
+
this.appendObservation(target, key.threadRef, { sequence: operation.sequence, memberId: key.memberId, action: 'unfollow' });
|
|
2455
|
+
}
|
|
2456
|
+
for (const next of delta.attention.set) {
|
|
2457
|
+
const key = this.attentionKey(next.memberId, next.threadRef);
|
|
2458
|
+
const prior = retired.has(key) ? undefined : target.attention.get(key);
|
|
2459
|
+
if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
|
|
2460
|
+
continue;
|
|
2461
|
+
this.appendObservation(target, next.threadRef, { sequence: operation.sequence, memberId: next.memberId, action: 'follow' });
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
appendObservation(target, threadRef, observation) {
|
|
2465
|
+
const observations = target.observationsByThread.get(threadRef) ?? [];
|
|
2466
|
+
observations.push(Object.freeze(observation));
|
|
2467
|
+
target.observationsByThread.set(threadRef, observations);
|
|
2468
|
+
}
|
|
2282
2469
|
applyInboxDelta(target, delta) {
|
|
2283
2470
|
for (const key of delta.attention.removed) {
|
|
2284
2471
|
target.attention.delete(this.attentionKey(key.memberId, key.threadRef));
|
|
@@ -2288,27 +2475,76 @@ export class AgentTeamLedger {
|
|
|
2288
2475
|
if (followers.size === 0)
|
|
2289
2476
|
target.attentionByThread.delete(key.threadRef);
|
|
2290
2477
|
}
|
|
2478
|
+
const threads = target.attentionThreadsByMember.get(key.memberId);
|
|
2479
|
+
if (threads !== undefined) {
|
|
2480
|
+
threads.delete(key.threadRef);
|
|
2481
|
+
if (threads.size === 0)
|
|
2482
|
+
target.attentionThreadsByMember.delete(key.memberId);
|
|
2483
|
+
}
|
|
2291
2484
|
}
|
|
2292
2485
|
for (const attention of delta.attention.set) {
|
|
2293
2486
|
target.attention.set(this.attentionKey(attention.memberId, attention.threadRef), attention);
|
|
2294
2487
|
const followers = target.attentionByThread.get(attention.threadRef) ?? new Set();
|
|
2295
2488
|
followers.add(attention.memberId);
|
|
2296
2489
|
target.attentionByThread.set(attention.threadRef, followers);
|
|
2490
|
+
const threads = target.attentionThreadsByMember.get(attention.memberId) ?? new Set();
|
|
2491
|
+
threads.add(attention.threadRef);
|
|
2492
|
+
target.attentionThreadsByMember.set(attention.memberId, threads);
|
|
2297
2493
|
}
|
|
2298
|
-
for (const marker of delta.directMarkers.removed)
|
|
2494
|
+
for (const marker of delta.directMarkers.removed) {
|
|
2299
2495
|
target.directMarkers.delete(this.directMarkerKey(marker));
|
|
2300
|
-
|
|
2496
|
+
this.removeBucketedMarker(target.directMarkersByMember, marker.memberId, marker.threadRef, this.directMarkerKey(marker), candidate => this.directMarkerKey(candidate));
|
|
2497
|
+
}
|
|
2498
|
+
for (const marker of delta.directMarkers.added) {
|
|
2301
2499
|
target.directMarkers.set(this.directMarkerKey(marker), marker);
|
|
2302
|
-
|
|
2500
|
+
this.insertBucketedMarker(target.directMarkersByMember, marker.memberId, marker.threadRef, marker);
|
|
2501
|
+
}
|
|
2502
|
+
for (const marker of delta.activityMarkers.removed) {
|
|
2303
2503
|
target.activityMarkers.delete(this.activityMarkerKey(marker));
|
|
2304
|
-
|
|
2504
|
+
this.removeBucketedMarker(target.activityMarkersByMember, marker.memberId, marker.threadRef, this.activityMarkerKey(marker), candidate => this.activityMarkerKey(candidate));
|
|
2505
|
+
}
|
|
2506
|
+
for (const marker of delta.activityMarkers.added) {
|
|
2305
2507
|
target.activityMarkers.set(this.activityMarkerKey(marker), marker);
|
|
2508
|
+
this.insertBucketedMarker(target.activityMarkersByMember, marker.memberId, marker.threadRef, marker);
|
|
2509
|
+
}
|
|
2510
|
+
}
|
|
2511
|
+
/** Insert into the Member's Thread bucket keeping the sequence order the read path must see. */
|
|
2512
|
+
insertBucketedMarker(index, memberId, threadRef, marker) {
|
|
2513
|
+
const threads = index.get(memberId) ?? new Map();
|
|
2514
|
+
index.set(memberId, threads);
|
|
2515
|
+
const bucket = threads.get(threadRef) ?? [];
|
|
2516
|
+
threads.set(threadRef, bucket);
|
|
2517
|
+
let position = bucket.length;
|
|
2518
|
+
while (position > 0 && bucket[position - 1].sequence > marker.sequence)
|
|
2519
|
+
position -= 1;
|
|
2520
|
+
bucket.splice(position, 0, marker);
|
|
2521
|
+
}
|
|
2522
|
+
removeBucketedMarker(index, memberId, threadRef, key, identityOf) {
|
|
2523
|
+
const threads = index.get(memberId);
|
|
2524
|
+
const bucket = threads?.get(threadRef);
|
|
2525
|
+
if (bucket === undefined)
|
|
2526
|
+
return;
|
|
2527
|
+
const position = bucket.findIndex(item => identityOf(item) === key);
|
|
2528
|
+
if (position >= 0)
|
|
2529
|
+
bucket.splice(position, 1);
|
|
2530
|
+
if (bucket.length === 0) {
|
|
2531
|
+
threads.delete(threadRef);
|
|
2532
|
+
if (threads.size === 0)
|
|
2533
|
+
index.delete(memberId);
|
|
2534
|
+
}
|
|
2306
2535
|
}
|
|
2307
2536
|
prepareRead(memberId, workspaceId, request) {
|
|
2308
2537
|
return this.prepareReadFrom(this.state, memberId, workspaceId, request);
|
|
2309
2538
|
}
|
|
2310
|
-
/**
|
|
2311
|
-
|
|
2539
|
+
/**
|
|
2540
|
+
* Derive the durable receipt of one Thread read from a prior projection: the
|
|
2541
|
+
* Thread it targeted, the watermark it reaches, the Attention row it writes
|
|
2542
|
+
* and the Inbox delta it consumes. This is the whole durable content of a
|
|
2543
|
+
* receipt-shaped read and the only thing `validateRecords` re-derives for
|
|
2544
|
+
* one; the picture derivation below builds on the same result, so the two can
|
|
2545
|
+
* never drift.
|
|
2546
|
+
*/
|
|
2547
|
+
prepareReadReceiptFrom(projection, memberId, workspaceId, request) {
|
|
2312
2548
|
const { task, thread, channelRef } = this.threadContextFrom(projection, workspaceId, request);
|
|
2313
2549
|
if (memberId !== AGENT_TEAM_HUMAN_MEMBER_ID) {
|
|
2314
2550
|
const member = projection.members.get(memberId);
|
|
@@ -2316,21 +2552,9 @@ export class AgentTeamLedger {
|
|
|
2316
2552
|
throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${channelRef}'`);
|
|
2317
2553
|
}
|
|
2318
2554
|
}
|
|
2319
|
-
const anchor = this.threadAnchorFrom(projection, thread.threadRef);
|
|
2320
2555
|
const attention = this.attentionForFrom(projection, memberId, thread.threadRef);
|
|
2321
2556
|
const unread = this.unreadForFrom(projection, memberId, thread.threadRef);
|
|
2322
2557
|
const unreadFacts = unread.slice(0, 20);
|
|
2323
|
-
const unreadFactKeys = new Set(unread.map(item => this.threadFactKey(item.fact)));
|
|
2324
|
-
const firstRead = attention !== undefined && attention.readThroughSequence < attention.startSequence;
|
|
2325
|
-
const background = firstRead
|
|
2326
|
-
? projection.messages.filter(message => message.threadRef === thread.threadRef && message.sequence < attention.startSequence)
|
|
2327
|
-
.map(message => Object.freeze({ kind: 'message', sequence: message.sequence, message,
|
|
2328
|
-
mentions: projection.mentionsByMessage.get(message.messageRef) ?? [],
|
|
2329
|
-
occurredAt: this.occurredAtForFactFrom(projection, message.sequence, message.occurredAt) }))
|
|
2330
|
-
.filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
|
|
2331
|
-
: [];
|
|
2332
|
-
const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...unreadFacts]
|
|
2333
|
-
.sort((left, right) => left.fact.sequence - right.fact.sequence);
|
|
2334
2558
|
// Direct markers are sparse acknowledgements, not part of the contiguous
|
|
2335
2559
|
// follower watermark. Consuming an old marker after a later follow must
|
|
2336
2560
|
// never move that watermark backwards.
|
|
@@ -2346,24 +2570,36 @@ export class AgentTeamLedger {
|
|
|
2346
2570
|
const activityMarkers = this.activityMarkersForFrom(projection, memberId, thread.threadRef)
|
|
2347
2571
|
.filter(marker => unreadFacts.some(item => item.fact.kind === 'activity' && item.fact.activity.activityRef === marker.activityRef));
|
|
2348
2572
|
const inbox = this.inboxDelta(nextAttention, [], [], consumed, [], activityMarkers);
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
const nextProjection = {
|
|
2353
|
-
...projection,
|
|
2354
|
-
attention: new Map(projection.attention),
|
|
2355
|
-
directMarkers: new Map(projection.directMarkers),
|
|
2356
|
-
activityMarkers: new Map(projection.activityMarkers),
|
|
2357
|
-
attentionByThread: new Map([...projection.attentionByThread].map(([threadRef, followers]) => [threadRef, new Set(followers)])),
|
|
2358
|
-
};
|
|
2359
|
-
this.applyInboxDelta(nextProjection, inbox);
|
|
2360
|
-
const remainingUnreadCount = this.unreadForFrom(nextProjection, memberId, thread.threadRef).length;
|
|
2361
|
-
return Object.freeze({ ...(task === undefined ? {} : { task }), thread, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
|
|
2362
|
-
anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
|
|
2363
|
-
facts: Object.freeze(combined),
|
|
2364
|
-
readThroughSequence, remainingUnreadCount, ...(attention === undefined ? {} : { attention: nextAttention[0] ?? attention }),
|
|
2573
|
+
return Object.freeze({ ...(task === undefined ? {} : { task }), thread, unread,
|
|
2574
|
+
...(attention === undefined ? {} : { attentionBefore: attention }), readThroughSequence,
|
|
2575
|
+
...(attention === undefined ? {} : { attention: nextAttention[0] ?? attention }),
|
|
2365
2576
|
consumedDirectMarkers: Object.freeze(consumed), inbox });
|
|
2366
2577
|
}
|
|
2578
|
+
/** Derive the only legal durable result of one Thread read from a prior projection. */
|
|
2579
|
+
prepareReadFrom(projection, memberId, workspaceId, request) {
|
|
2580
|
+
const receipt = this.prepareReadReceiptFrom(projection, memberId, workspaceId, request);
|
|
2581
|
+
const { task, thread } = receipt;
|
|
2582
|
+
const anchor = this.threadAnchorFrom(projection, thread.threadRef);
|
|
2583
|
+
// The first read of a Thread after following also shows the bounded
|
|
2584
|
+
// background that preceded the follow, so a reader sees what they joined.
|
|
2585
|
+
const firstRead = receipt.attentionBefore !== undefined && receipt.attentionBefore.readThroughSequence < receipt.attentionBefore.startSequence;
|
|
2586
|
+
const unreadFactKeys = new Set(receipt.unread.map(item => this.threadFactKey(item.fact)));
|
|
2587
|
+
const background = firstRead
|
|
2588
|
+
? this.threadFactsFrom(projection, thread.threadRef)
|
|
2589
|
+
.filter((fact) => fact.kind === 'message'
|
|
2590
|
+
&& fact.sequence < receipt.attentionBefore.startSequence)
|
|
2591
|
+
.map(fact => Object.freeze({ kind: 'message', sequence: fact.sequence, message: fact.message,
|
|
2592
|
+
mentions: projection.mentionsByMessage.get(fact.message.messageRef) ?? [],
|
|
2593
|
+
occurredAt: this.occurredAtForFactFrom(projection, fact.sequence, fact.message.occurredAt) }))
|
|
2594
|
+
.filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
|
|
2595
|
+
: [];
|
|
2596
|
+
const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...receipt.unread.slice(0, 20)]
|
|
2597
|
+
.sort((left, right) => left.fact.sequence - right.fact.sequence);
|
|
2598
|
+
const remainingUnreadCount = this.remainingUnreadAfter(projection, memberId, receipt);
|
|
2599
|
+
return Object.freeze({ ...receipt, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
|
|
2600
|
+
anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
|
|
2601
|
+
facts: Object.freeze(combined), remainingUnreadCount });
|
|
2602
|
+
}
|
|
2367
2603
|
readFactFrom(projection, memberId, fact, unread) {
|
|
2368
2604
|
return Object.freeze({ fact, unread, direct: fact.kind === 'message'
|
|
2369
2605
|
&& projection.directMarkers.has(this.directMarkerKey({ memberId, threadRef: fact.message.threadRef,
|
|
@@ -2374,23 +2610,60 @@ export class AgentTeamLedger {
|
|
|
2374
2610
|
}
|
|
2375
2611
|
unreadForFrom(projection, memberId, threadRef) {
|
|
2376
2612
|
const attention = this.attentionForFrom(projection, memberId, threadRef);
|
|
2377
|
-
const
|
|
2378
|
-
const direct = this.directMarkersForFrom(projection, memberId, threadRef);
|
|
2379
|
-
const directKeys = new Set(direct.map(marker => this.directMarkerKey(marker)));
|
|
2613
|
+
const directKeys = new Set(this.directMarkersForFrom(projection, memberId, threadRef).map(marker => this.directMarkerKey(marker)));
|
|
2380
2614
|
const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
|
|
2381
2615
|
const result = [];
|
|
2382
|
-
for (const fact of
|
|
2383
|
-
|
|
2384
|
-
messageRef: fact.message.messageRef, sequence: fact.sequence }));
|
|
2385
|
-
const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
|
|
2386
|
-
activityRef: fact.activity.activityRef, sequence: fact.sequence }));
|
|
2387
|
-
const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
|
|
2388
|
-
&& fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
|
|
2389
|
-
if (marker || activityMarker || ordinary)
|
|
2616
|
+
for (const fact of this.threadFactsFrom(projection, threadRef)) {
|
|
2617
|
+
if (this.isUnreadFact(fact, memberId, threadRef, attention, directKeys, activityKeys)) {
|
|
2390
2618
|
result.push(this.readFactFrom(projection, memberId, fact, true));
|
|
2619
|
+
}
|
|
2391
2620
|
}
|
|
2392
2621
|
return Object.freeze(result);
|
|
2393
2622
|
}
|
|
2623
|
+
/**
|
|
2624
|
+
* The single authority on "this fact is unread for this reader". The picture
|
|
2625
|
+
* derivation and the remaining-count derivation both go through it, so the
|
|
2626
|
+
* count a read reports can never disagree with the facts it lists. Marker
|
|
2627
|
+
* membership arrives as key sets: the caller may be looking at a state the
|
|
2628
|
+
* projection has not applied yet.
|
|
2629
|
+
*/
|
|
2630
|
+
isUnreadFact(fact, memberId, threadRef, attention, directKeys, activityKeys) {
|
|
2631
|
+
const marker = fact.kind === 'message' && directKeys.has(this.directMarkerKey({ memberId, threadRef,
|
|
2632
|
+
messageRef: fact.message.messageRef, sequence: fact.sequence }));
|
|
2633
|
+
const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
|
|
2634
|
+
activityRef: fact.activity.activityRef, sequence: fact.sequence }));
|
|
2635
|
+
const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
|
|
2636
|
+
&& fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
|
|
2637
|
+
return marker || activityMarker || ordinary;
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* Unread count once this read's own Inbox delta has been applied, derived
|
|
2641
|
+
* without materializing a hypothetical projection. The delta can only touch
|
|
2642
|
+
* the reader's own Attention row and its own marker keys in one Thread, and
|
|
2643
|
+
* every lookup in `isUnreadFact` is scoped to that same (member, Thread)
|
|
2644
|
+
* pair, so the post-delta state is the Attention row the receipt already
|
|
2645
|
+
* derived plus those marker keys with the delta's own removals and additions
|
|
2646
|
+
* applied.
|
|
2647
|
+
*/
|
|
2648
|
+
remainingUnreadAfter(projection, memberId, receipt) {
|
|
2649
|
+
const threadRef = receipt.thread.threadRef;
|
|
2650
|
+
const directKeys = new Set(this.directMarkersForFrom(projection, memberId, threadRef).map(marker => this.directMarkerKey(marker)));
|
|
2651
|
+
for (const marker of receipt.inbox.directMarkers.removed)
|
|
2652
|
+
directKeys.delete(this.directMarkerKey(marker));
|
|
2653
|
+
for (const marker of receipt.inbox.directMarkers.added)
|
|
2654
|
+
directKeys.add(this.directMarkerKey(marker));
|
|
2655
|
+
const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
|
|
2656
|
+
for (const marker of receipt.inbox.activityMarkers.removed)
|
|
2657
|
+
activityKeys.delete(this.activityMarkerKey(marker));
|
|
2658
|
+
for (const marker of receipt.inbox.activityMarkers.added)
|
|
2659
|
+
activityKeys.add(this.activityMarkerKey(marker));
|
|
2660
|
+
let count = 0;
|
|
2661
|
+
for (const fact of this.threadFactsFrom(projection, threadRef)) {
|
|
2662
|
+
if (this.isUnreadFact(fact, memberId, threadRef, receipt.attention, directKeys, activityKeys))
|
|
2663
|
+
count += 1;
|
|
2664
|
+
}
|
|
2665
|
+
return count;
|
|
2666
|
+
}
|
|
2394
2667
|
visibleToFollower(fact, memberId) {
|
|
2395
2668
|
const sender = fact.kind === 'message' ? fact.message.sender : fact.activity.actor;
|
|
2396
2669
|
return sender !== memberId;
|
|
@@ -2535,12 +2808,14 @@ export class AgentTeamLedger {
|
|
|
2535
2808
|
return this.directMarkersForFrom(this.state, memberId, threadRef);
|
|
2536
2809
|
}
|
|
2537
2810
|
directMarkersForFrom(projection, memberId, threadRef) {
|
|
2538
|
-
|
|
2539
|
-
|
|
2811
|
+
// A frozen copy, not the live bucket: callers may hand the array to an
|
|
2812
|
+
// Inbox delta that freezes it, which must not freeze the derived index.
|
|
2813
|
+
const markers = projection.directMarkersByMember.get(memberId)?.get(threadRef);
|
|
2814
|
+
return markers === undefined ? [] : Object.freeze([...markers]);
|
|
2540
2815
|
}
|
|
2541
2816
|
activityMarkersForFrom(projection, memberId, threadRef) {
|
|
2542
|
-
|
|
2543
|
-
|
|
2817
|
+
const markers = projection.activityMarkersByMember.get(memberId)?.get(threadRef);
|
|
2818
|
+
return markers === undefined ? [] : Object.freeze([...markers]);
|
|
2544
2819
|
}
|
|
2545
2820
|
issueConfirmation(actor, workspaceId, channelRef, body, recipients, task, thread) {
|
|
2546
2821
|
if (actor.kind !== 'human')
|
|
@@ -2651,7 +2926,7 @@ export class AgentTeamLedger {
|
|
|
2651
2926
|
return this.threadAnchorFrom(this.state, threadRef);
|
|
2652
2927
|
}
|
|
2653
2928
|
threadAnchorFrom(projection, threadRef) {
|
|
2654
|
-
const anchor = projection.
|
|
2929
|
+
const anchor = projection.anchorByThread.get(threadRef);
|
|
2655
2930
|
if (anchor === undefined)
|
|
2656
2931
|
throw new Error(`Thread '${threadRef}' has no anchor Message`);
|
|
2657
2932
|
return anchor;
|
|
@@ -2663,20 +2938,17 @@ export class AgentTeamLedger {
|
|
|
2663
2938
|
* Display numbers for Tasks: one counter per home Channel, in creation
|
|
2664
2939
|
* order. This is the single numbering authority — Channel cards, Thread
|
|
2665
2940
|
* headings, cross-channel ref resolution, and inbox renders all show the
|
|
2666
|
-
* ordinal the Task holds inside its own Channel.
|
|
2941
|
+
* ordinal the Task holds inside its own Channel. The ordinals are derived
|
|
2942
|
+
* at replay time (`taskNumberByTask`); this read only filters them to one
|
|
2943
|
+
* Workspace.
|
|
2667
2944
|
*/
|
|
2668
2945
|
taskNumbers(workspaceId) {
|
|
2669
2946
|
const numbers = new Map();
|
|
2670
|
-
const
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
if (task === undefined)
|
|
2674
|
-
continue;
|
|
2675
|
-
if (this.state.channels.get(task.channelRef)?.workspaceId !== workspaceId)
|
|
2947
|
+
for (const [taskRef, taskNumber] of this.state.taskNumberByTask) {
|
|
2948
|
+
const task = this.state.tasks.get(taskRef);
|
|
2949
|
+
if (task === undefined || this.state.channels.get(task.channelRef)?.workspaceId !== workspaceId)
|
|
2676
2950
|
continue;
|
|
2677
|
-
|
|
2678
|
-
next.set(task.channelRef, ordinal);
|
|
2679
|
-
numbers.set(task.taskRef, ordinal);
|
|
2951
|
+
numbers.set(taskRef, taskNumber);
|
|
2680
2952
|
}
|
|
2681
2953
|
return numbers;
|
|
2682
2954
|
}
|
|
@@ -3106,8 +3378,8 @@ export class AgentTeamLedger {
|
|
|
3106
3378
|
assertSameThreadRead(operation, request) {
|
|
3107
3379
|
if (operation.kind !== 'team/thread-read' || !this.sameActor(operation.actor, request.actor)
|
|
3108
3380
|
|| operation.data.workspaceId !== request.workspaceId
|
|
3109
|
-
|| (request.threadRef !== undefined && operation.data.
|
|
3110
|
-
|| (request.taskRef !== undefined && operation.data.
|
|
3381
|
+
|| (request.threadRef !== undefined && threadReadTargetOf(operation.data).threadRef !== request.threadRef)
|
|
3382
|
+
|| (request.taskRef !== undefined && threadReadTargetOf(operation.data).taskRef !== request.taskRef)
|
|
3111
3383
|
|| (request.threadRef === undefined && request.taskRef === undefined))
|
|
3112
3384
|
this.throwRequestCollision(request.requestId);
|
|
3113
3385
|
}
|
|
@@ -3212,14 +3484,18 @@ export class AgentTeamLedger {
|
|
|
3212
3484
|
return Object.freeze({ kind: 'committed', receipt: this.receipt(operation), activity: operation.data.activity,
|
|
3213
3485
|
task: operation.data.task, thread: operation.data.thread });
|
|
3214
3486
|
}
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3487
|
+
/**
|
|
3488
|
+
* The Thread picture a read answers with, derived from the projection the
|
|
3489
|
+
* read resolved against. Committed reads, no-op reads and retries all share
|
|
3490
|
+
* this one derivation, so a read that writes nothing still returns the same
|
|
3491
|
+
* Attention, facts, watermark and unread count a committed one would.
|
|
3492
|
+
*/
|
|
3493
|
+
readPicture(prepared) {
|
|
3494
|
+
return Object.freeze({ ...(prepared.task === undefined ? {} : { task: prepared.task }), thread: prepared.thread,
|
|
3495
|
+
claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions, facts: prepared.facts,
|
|
3496
|
+
readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
|
|
3497
|
+
...(prepared.attention === undefined ? {} : { attention: prepared.attention }),
|
|
3498
|
+
consumedDirectMarkers: prepared.inbox.directMarkers.removed });
|
|
3223
3499
|
}
|
|
3224
3500
|
receipt(operation) {
|
|
3225
3501
|
return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence, occurredAt: operation.occurredAt });
|
|
@@ -3300,10 +3576,17 @@ export class AgentTeamLedger {
|
|
|
3300
3576
|
legacyChannelThreadRefs(projection, channelRef) {
|
|
3301
3577
|
return new Set([...projection.tasks.values()].filter(task => task.channelRef === channelRef).map(task => task.threadRef));
|
|
3302
3578
|
}
|
|
3303
|
-
/**
|
|
3579
|
+
/**
|
|
3580
|
+
* Ledgers written before message occurredAt existed store bare messages;
|
|
3581
|
+
* snapshot-shaped Thread reads resolve instants from the originating
|
|
3582
|
+
* operations. A receipt-shaped read carries no message at all, so it passes
|
|
3583
|
+
* through untouched — the load path must never touch a shape it does not
|
|
3584
|
+
* have, and it never writes either form back.
|
|
3585
|
+
*/
|
|
3304
3586
|
normalizeOperation(operation, occurrences, instants) {
|
|
3305
|
-
if (operation.kind !== 'team/thread-read')
|
|
3587
|
+
if (operation.kind !== 'team/thread-read' || !isThreadReadSnapshot(operation.data))
|
|
3306
3588
|
return operation;
|
|
3589
|
+
const data = operation.data;
|
|
3307
3590
|
const stamp = (message) => (message.occurredAt === undefined
|
|
3308
3591
|
? { ...message, occurredAt: occurrences.get(message.messageRef) ?? operation.occurredAt }
|
|
3309
3592
|
: { ...message, occurredAt: message.occurredAt });
|
|
@@ -3312,8 +3595,8 @@ export class AgentTeamLedger {
|
|
|
3312
3595
|
occurredAt: envelope.occurredAt ?? envelope.message.occurredAt ?? occurrences.get(envelope.message.messageRef) ?? operation.occurredAt }
|
|
3313
3596
|
: { kind: 'activity', sequence: envelope.sequence, activity: envelope.activity,
|
|
3314
3597
|
occurredAt: envelope.occurredAt ?? instants.get(envelope.sequence) ?? operation.occurredAt };
|
|
3315
|
-
const facts =
|
|
3316
|
-
return { ...operation, data: { ...
|
|
3598
|
+
const facts = data.facts.map((fact) => ({ ...fact, fact: stampEnvelope(fact.fact) }));
|
|
3599
|
+
return { ...operation, data: { ...data, anchor: stamp(data.anchor), facts } };
|
|
3317
3600
|
}
|
|
3318
3601
|
enqueue(operation) {
|
|
3319
3602
|
const result = this.operationTail.then(operation);
|