@wowyuarm/dsh-agent-team 0.1.9 → 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 +17 -11
- package/README.zh.md +17 -11
- package/package.json +61 -47
- package/packages/agent-team/README.md +6 -4
- package/packages/agent-team/README.zh.md +6 -3
- package/packages/agent-team/lib/context-management.js +48 -55
- package/packages/agent-team/lib/context-projection.js +22 -8
- package/packages/agent-team/lib/context-source.js +163 -23
- package/packages/agent-team/lib/index.js +349 -176
- package/packages/agent-team/lib/invariant.js +38 -4
- package/packages/agent-team/lib/ledger.js +658 -294
- package/packages/agent-team/lib/member-context.js +6 -2
- package/packages/agent-team/lib/member-time-context.js +141 -0
- package/packages/agent-team/lib/pressure-policy.js +34 -11
- package/packages/agent-team/lib/progress-nudge.js +12 -4
- package/packages/agent-team/lib/session-event-cursor.js +116 -0
- package/packages/agent-team/lib/session-remediation.js +493 -0
- package/packages/agent-team/lib/spec.js +49 -36
- package/packages/agent-team/lib/stored-session-reader.js +138 -0
- package/packages/agent-team/lib/time-format.js +56 -0
- package/packages/agent-team/lib/typert.host.js +163 -63
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
- package/packages/agent-team/lib/typert.remote-client.js +105 -32
- package/packages/agent-team/lib/types/context-management.d.ts +13 -1
- 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/context-source.d.ts +70 -36
- package/packages/agent-team/lib/types/context-source.d.ts.map +1 -1
- package/packages/agent-team/lib/types/index.d.ts +110 -9
- 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 +205 -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 +83 -0
- package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -0
- 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/progress-nudge.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 +172 -0
- package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -0
- 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/time-format.d.ts +28 -0
- package/packages/agent-team/lib/types/time-format.d.ts.map +1 -0
- package/packages/agent-team/lib/types/types/entities.d.ts +36 -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 +50 -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 +21 -4
- 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 +1121 -402
- 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 +13 -6
- 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 +106 -32
- 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 +25 -9
- 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 +8 -2
- package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/team-changes.d.ts +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 +8 -6
- package/packages/tool-agent-team/README.zh.md +8 -6
- 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 +314 -123
- 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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { formatTeamTimestamp } from "./time-format.js";
|
|
3
4
|
/** Stable Human Member identity shared by every replay of one dshHome Team. */
|
|
4
5
|
export const AGENT_TEAM_HUMAN_MEMBER_ID = 'member:human';
|
|
5
6
|
/** Idempotency identity of the one Host bootstrap operation. */
|
|
@@ -15,9 +16,12 @@ export function agentTeamHumanActor() {
|
|
|
15
16
|
}
|
|
16
17
|
function emptyProjection() {
|
|
17
18
|
return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), memberships: new Map(),
|
|
18
|
-
claims: new Map(),
|
|
19
|
+
claims: new Map(), tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
|
|
19
20
|
orderedFacts: [], factsByThread: new Map(), channelRefByThread: new Map(), mentionsByMessage: new Map(), messageCountByThread: new Map(),
|
|
20
|
-
|
|
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() };
|
|
21
25
|
}
|
|
22
26
|
const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
|
|
23
27
|
progress: Object.freeze([]),
|
|
@@ -27,6 +31,40 @@ const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
|
|
|
27
31
|
function assertUnhandledKind(operation) {
|
|
28
32
|
throw new Error(`agent-team ledger does not handle operation kind '${operation.kind}'`);
|
|
29
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
|
+
}
|
|
30
68
|
/** Deep-freeze a Member capability overlay; absent stays absent. */
|
|
31
69
|
function freezeCapabilities(capabilities) {
|
|
32
70
|
if (capabilities === undefined)
|
|
@@ -49,12 +87,35 @@ export class AgentTeamLedger {
|
|
|
49
87
|
createOccurredAt;
|
|
50
88
|
createRef;
|
|
51
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;
|
|
52
107
|
constructor(table, options = {}) {
|
|
53
108
|
this.table = table;
|
|
54
109
|
this.createOperationId = options.operationId ?? (() => `operation:${randomUUID()}`);
|
|
55
110
|
this.createOccurredAt = options.occurredAt ?? (() => new Date().toISOString());
|
|
56
111
|
this.createRef = options.ref ?? (kind => `${kind}:${randomUUID()}`);
|
|
57
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
|
+
});
|
|
58
119
|
}
|
|
59
120
|
initialize(request = {
|
|
60
121
|
requestId: AGENT_TEAM_INITIALIZE_REQUEST_ID,
|
|
@@ -1009,7 +1070,7 @@ export class AgentTeamLedger {
|
|
|
1009
1070
|
// mirror: the reader sent it to the other Member → (you → them).
|
|
1010
1071
|
const direction = pair ? 'them → you' : 'you → them';
|
|
1011
1072
|
const truncated = operation.data.body.length > 160 ? `${operation.data.body.slice(0, 160)}…` : operation.data.body;
|
|
1012
|
-
return `(${direction}) ${truncated}`;
|
|
1073
|
+
return `(${direction}, at ${formatTeamTimestamp(operation.occurredAt)}) ${truncated}`;
|
|
1013
1074
|
}
|
|
1014
1075
|
return undefined;
|
|
1015
1076
|
}
|
|
@@ -1024,8 +1085,19 @@ export class AgentTeamLedger {
|
|
|
1024
1085
|
const limit = request.limit ?? 50;
|
|
1025
1086
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
1026
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;
|
|
1027
1096
|
const items = [];
|
|
1028
|
-
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;
|
|
1029
1101
|
const channelRef = this.channelRefForThread(thread.threadRef);
|
|
1030
1102
|
if (channelRef === undefined)
|
|
1031
1103
|
continue;
|
|
@@ -1038,16 +1110,47 @@ export class AgentTeamLedger {
|
|
|
1038
1110
|
continue;
|
|
1039
1111
|
const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
|
|
1040
1112
|
const directCount = unread.filter(item => item.direct).length;
|
|
1113
|
+
if (directOnly && directCount === 0)
|
|
1114
|
+
continue;
|
|
1041
1115
|
const attention = this.attentionFor(authorized.memberId, thread.threadRef);
|
|
1042
|
-
|
|
1043
|
-
|
|
1116
|
+
// Same snapshot, same source as newestSequence: the instant hangs off
|
|
1117
|
+
// the newest unread fact itself, never a second lookup that could
|
|
1118
|
+
// observe a different commit between the two reads.
|
|
1119
|
+
const newest = unread.at(-1).fact;
|
|
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) } : {}),
|
|
1126
|
+
newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt, ...(attention === undefined ? {} : { attention }) }));
|
|
1044
1127
|
}
|
|
1045
1128
|
items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
|
|
1046
1129
|
const selected = items.slice(0, limit);
|
|
1047
1130
|
return Object.freeze({ items: Object.freeze(selected),
|
|
1048
|
-
totalUnreadCount:
|
|
1131
|
+
totalUnreadCount: directOnly
|
|
1132
|
+
? items.reduce((sum, item) => sum + item.directCount, 0)
|
|
1133
|
+
: items.reduce((sum, item) => sum + item.unreadCount, 0),
|
|
1049
1134
|
totalDirectCount: items.reduce((sum, item) => sum + item.directCount, 0) });
|
|
1050
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
|
+
}
|
|
1051
1154
|
/** Model-visible notification material derived from the recipient's current durable unread state. */
|
|
1052
1155
|
notificationFacts(memberId, request) {
|
|
1053
1156
|
const member = this.requireMember(memberId);
|
|
@@ -1062,22 +1165,31 @@ export class AgentTeamLedger {
|
|
|
1062
1165
|
const existing = this.state.byRequest.get(request.requestId);
|
|
1063
1166
|
if (existing !== undefined) {
|
|
1064
1167
|
this.assertSameThreadRead(existing, request);
|
|
1065
|
-
|
|
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 });
|
|
1066
1172
|
}
|
|
1067
1173
|
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
1068
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 });
|
|
1069
1187
|
const operation = Object.freeze({
|
|
1070
|
-
...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read',
|
|
1071
|
-
data: Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId,
|
|
1072
|
-
...(prepared.task === undefined ? {} : { task: prepared.task }),
|
|
1073
|
-
thread: prepared.thread, claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions,
|
|
1074
|
-
facts: prepared.facts,
|
|
1075
|
-
readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
|
|
1076
|
-
...(prepared.attention === undefined ? {} : { attention: prepared.attention }), inbox: prepared.inbox }),
|
|
1188
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read', data,
|
|
1077
1189
|
});
|
|
1078
1190
|
await this.table.put(operation.operationId, operation);
|
|
1079
1191
|
this.apply(operation);
|
|
1080
|
-
return
|
|
1192
|
+
return Object.freeze({ value: Object.freeze({ receipt: this.receipt(operation), ...this.readPicture(prepared) }), committed: true });
|
|
1081
1193
|
});
|
|
1082
1194
|
}
|
|
1083
1195
|
threadObservations(actor, request) {
|
|
@@ -1087,33 +1199,13 @@ export class AgentTeamLedger {
|
|
|
1087
1199
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
1088
1200
|
throw new Error('observation limit must be an integer between 1 and 100');
|
|
1089
1201
|
const threadRef = thread.threadRef;
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
if (removed.threadRef !== threadRef)
|
|
1098
|
-
continue;
|
|
1099
|
-
if (!current.delete(removed.memberId))
|
|
1100
|
-
continue;
|
|
1101
|
-
observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
|
|
1102
|
-
memberId: removed.memberId, action: 'unfollow' }));
|
|
1103
|
-
}
|
|
1104
|
-
for (const next of delta.attention.set) {
|
|
1105
|
-
if (next.threadRef !== threadRef)
|
|
1106
|
-
continue;
|
|
1107
|
-
const prior = current.get(next.memberId);
|
|
1108
|
-
current.set(next.memberId, next);
|
|
1109
|
-
// Task creation establishes initial Attention; reads only advance its watermark.
|
|
1110
|
-
if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
|
|
1111
|
-
continue;
|
|
1112
|
-
observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
|
|
1113
|
-
memberId: next.memberId, action: 'follow' }));
|
|
1114
|
-
}
|
|
1115
|
-
}
|
|
1116
|
-
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) ?? []]) });
|
|
1117
1209
|
}
|
|
1118
1210
|
/** Every operation carrying an inbox delta drives the Inbox projection; the payload shape decides, not a per-kind list. */
|
|
1119
1211
|
attentionDelta(operation) {
|
|
@@ -1211,7 +1303,8 @@ export class AgentTeamLedger {
|
|
|
1211
1303
|
&& channel.state !== 'archived'
|
|
1212
1304
|
&& (memberId === undefined || this.isChannelMember(channel.channelRef, memberId)));
|
|
1213
1305
|
const channelRefs = new Set(channels.map(channel => channel.channelRef));
|
|
1214
|
-
const
|
|
1306
|
+
const before = request.cursor ?? this.state.ordered.length + 1;
|
|
1307
|
+
const matches = (fact) => {
|
|
1215
1308
|
const threadRef = fact.kind === 'message' ? fact.message.threadRef : fact.activity.threadRef;
|
|
1216
1309
|
const channelRef = this.channelRefForThread(threadRef);
|
|
1217
1310
|
if (channelRef === undefined || !channelRefs.has(channelRef))
|
|
@@ -1224,9 +1317,40 @@ export class AgentTeamLedger {
|
|
|
1224
1317
|
return false;
|
|
1225
1318
|
if (request.includeActivities === false && fact.kind === 'activity')
|
|
1226
1319
|
return false;
|
|
1227
|
-
return direction === 'before' ? fact.sequence <
|
|
1228
|
-
}
|
|
1229
|
-
|
|
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
|
+
}
|
|
1230
1354
|
const visibleTasks = [...this.state.tasks.values()].filter(task => channelRefs.has(task.channelRef)
|
|
1231
1355
|
&& (request.channelRef === undefined || task.channelRef === request.channelRef)
|
|
1232
1356
|
&& (request.threadRef === undefined || task.threadRef === request.threadRef));
|
|
@@ -1235,8 +1359,10 @@ export class AgentTeamLedger {
|
|
|
1235
1359
|
const message = fact.message;
|
|
1236
1360
|
const thread = this.requireThread(message.threadRef);
|
|
1237
1361
|
const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
|
|
1362
|
+
const facts = this.state.factsByThread.get(thread.threadRef) ?? [];
|
|
1238
1363
|
return Object.freeze({ message, mentions: fact.mentions, ...(task === undefined ? {} : { task, taskNumber: taskNumbers.get(task.taskRef) ?? 0 }), thread,
|
|
1239
|
-
messageCount: this.state.messageCountByThread.get(thread.threadRef) ?? 0
|
|
1364
|
+
messageCount: this.state.messageCountByThread.get(thread.threadRef) ?? 0,
|
|
1365
|
+
lastActivityAt: facts.at(-1)?.occurredAt ?? '' });
|
|
1240
1366
|
});
|
|
1241
1367
|
const initialization = this.initialization();
|
|
1242
1368
|
const nextCursor = selected.length === 0 ? cursor : direction === 'before' ? selected[0].sequence : selected.at(-1).sequence;
|
|
@@ -1259,7 +1385,7 @@ export class AgentTeamLedger {
|
|
|
1259
1385
|
claims: this.claimsForVisibleTasks(visibleTasks),
|
|
1260
1386
|
activities: Object.freeze(selected.filter((fact) => fact.kind === 'activity').map(fact => fact.activity)),
|
|
1261
1387
|
cursor: nextCursor,
|
|
1262
|
-
hasMore
|
|
1388
|
+
hasMore,
|
|
1263
1389
|
});
|
|
1264
1390
|
}
|
|
1265
1391
|
status() {
|
|
@@ -1270,6 +1396,31 @@ export class AgentTeamLedger {
|
|
|
1270
1396
|
validate() {
|
|
1271
1397
|
this.validateRecords(this.sortedRecords());
|
|
1272
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
|
+
}
|
|
1273
1424
|
hasCommitted(requestId) {
|
|
1274
1425
|
return this.state.byRequest.has(requestId);
|
|
1275
1426
|
}
|
|
@@ -1277,6 +1428,33 @@ export class AgentTeamLedger {
|
|
|
1277
1428
|
getOperation(operationId) {
|
|
1278
1429
|
return this.state.byOperation.get(operationId);
|
|
1279
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
|
+
}
|
|
1280
1458
|
/** Scopes whose projections one committed operation invalidates; undefined wakes every waiter. */
|
|
1281
1459
|
changeScopesOf(operation) {
|
|
1282
1460
|
switch (operation.kind) {
|
|
@@ -1297,37 +1475,15 @@ export class AgentTeamLedger {
|
|
|
1297
1475
|
case 'team/member-updated':
|
|
1298
1476
|
case 'team/member-removed':
|
|
1299
1477
|
return [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
|
|
1300
|
-
case 'team/member-archived':
|
|
1301
|
-
|
|
1302
|
-
// pages refetch alongside the workspace-wide roster change.
|
|
1303
|
-
const channelByTask = new Map(operation.data.tasks.map(task => [task.taskRef, task.channelRef]));
|
|
1304
|
-
const scopes = [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
|
|
1305
|
-
for (const activity of operation.data.activities) {
|
|
1306
|
-
scopes.push({ kind: 'thread', threadRef: activity.threadRef });
|
|
1307
|
-
const channelRef = channelByTask.get(activity.taskRef);
|
|
1308
|
-
if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
|
|
1309
|
-
scopes.push({ kind: 'channel', channelRef });
|
|
1310
|
-
}
|
|
1311
|
-
}
|
|
1312
|
-
return scopes;
|
|
1313
|
-
}
|
|
1478
|
+
case 'team/member-archived':
|
|
1479
|
+
return this.withReleasedActivityScopes([{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }], operation.data.tasks, operation.data.activities);
|
|
1314
1480
|
case 'team/channel-member-added':
|
|
1315
1481
|
return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }, { kind: 'channel', channelRef: operation.data.channelRef }];
|
|
1316
|
-
case 'team/channel-member-removed':
|
|
1317
|
-
|
|
1318
|
-
const scopes = [
|
|
1482
|
+
case 'team/channel-member-removed':
|
|
1483
|
+
return this.withReleasedActivityScopes([
|
|
1319
1484
|
{ kind: 'workspace', workspaceId: operation.data.workspaceId },
|
|
1320
1485
|
{ kind: 'channel', channelRef: operation.data.channelRef },
|
|
1321
|
-
];
|
|
1322
|
-
for (const activity of operation.data.activities) {
|
|
1323
|
-
scopes.push({ kind: 'thread', threadRef: activity.threadRef });
|
|
1324
|
-
const channelRef = channelByTask.get(activity.taskRef);
|
|
1325
|
-
if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
|
|
1326
|
-
scopes.push({ kind: 'channel', channelRef });
|
|
1327
|
-
}
|
|
1328
|
-
}
|
|
1329
|
-
return scopes;
|
|
1330
|
-
}
|
|
1486
|
+
], operation.data.tasks, operation.data.activities);
|
|
1331
1487
|
case 'team/channel-archived': {
|
|
1332
1488
|
const scopes = [
|
|
1333
1489
|
{ kind: 'workspace', workspaceId: operation.data.workspaceId },
|
|
@@ -1625,7 +1781,7 @@ export class AgentTeamLedger {
|
|
|
1625
1781
|
|| operation.data.channel.createdAtSequence !== prior.createdAtSequence)
|
|
1626
1782
|
throw new Error('invalid Channel archival');
|
|
1627
1783
|
const threadRefs = new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === prior.channelRef));
|
|
1628
|
-
this.
|
|
1784
|
+
this.validateReleaseCleanup(operation.data, projection, undefined, threadRefs, operation.sequence, refs);
|
|
1629
1785
|
return;
|
|
1630
1786
|
}
|
|
1631
1787
|
if (operation.kind === 'team/member-removed') {
|
|
@@ -1676,20 +1832,38 @@ export class AgentTeamLedger {
|
|
|
1676
1832
|
assertMember();
|
|
1677
1833
|
else
|
|
1678
1834
|
assertHuman();
|
|
1679
|
-
|
|
1835
|
+
const data = operation.data;
|
|
1836
|
+
if (data.memberId !== operation.actor.memberId)
|
|
1680
1837
|
throw new Error('Thread read has wrong actor');
|
|
1681
|
-
const
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
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);
|
|
1693
1867
|
return;
|
|
1694
1868
|
}
|
|
1695
1869
|
if (operation.kind === 'team/thread-promoted') {
|
|
@@ -1867,11 +2041,23 @@ export class AgentTeamLedger {
|
|
|
1867
2041
|
}
|
|
1868
2042
|
assertUnhandledKind(operation);
|
|
1869
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
|
+
*/
|
|
1870
2057
|
validateInboxDelta(delta, projection, _refs, additionalThreadRefs = [], additionalMessages = [], additionalActivities = []) {
|
|
1871
2058
|
const attentionKeys = new Set();
|
|
1872
|
-
const knownThreadRefs = new Set([...projection.threads.keys(), ...additionalThreadRefs]);
|
|
1873
2059
|
for (const attention of delta.attention.set) {
|
|
1874
|
-
if (!
|
|
2060
|
+
if ((!projection.threads.has(attention.threadRef) && !additionalThreadRefs.includes(attention.threadRef)) || attention.startSequence < 1
|
|
1875
2061
|
|| attention.readThroughSequence < attention.startSequence - 1) {
|
|
1876
2062
|
throw new Error('invalid Attention delta');
|
|
1877
2063
|
}
|
|
@@ -1885,13 +2071,12 @@ export class AgentTeamLedger {
|
|
|
1885
2071
|
throw new Error('conflicting Attention delta');
|
|
1886
2072
|
}
|
|
1887
2073
|
const markerKeys = new Set();
|
|
1888
|
-
const messages = [...projection.messages, ...additionalMessages];
|
|
1889
2074
|
for (const marker of delta.directMarkers.added) {
|
|
1890
2075
|
const key = this.directMarkerKey(marker);
|
|
1891
2076
|
if (markerKeys.has(key) || projection.directMarkers.has(key))
|
|
1892
2077
|
throw new Error('invalid direct marker addition');
|
|
1893
2078
|
markerKeys.add(key);
|
|
1894
|
-
const message =
|
|
2079
|
+
const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
|
|
1895
2080
|
if (message === undefined || message.threadRef !== marker.threadRef || message.sequence !== marker.sequence
|
|
1896
2081
|
|| !this.validMentionTarget(projection, message.channelRef, marker.memberId))
|
|
1897
2082
|
throw new Error('invalid direct marker addition');
|
|
@@ -1902,17 +2087,20 @@ export class AgentTeamLedger {
|
|
|
1902
2087
|
throw new Error('invalid direct marker removal');
|
|
1903
2088
|
markerKeys.add(key);
|
|
1904
2089
|
const current = projection.directMarkers.get(key);
|
|
1905
|
-
const message =
|
|
2090
|
+
const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
|
|
1906
2091
|
if (!isDeepStrictEqual(current, marker) || message === undefined || message.threadRef !== marker.threadRef
|
|
1907
2092
|
|| message.sequence !== marker.sequence || !this.validMentionTarget(projection, message.channelRef, marker.memberId)) {
|
|
1908
2093
|
throw new Error('invalid direct marker removal');
|
|
1909
2094
|
}
|
|
1910
2095
|
}
|
|
1911
2096
|
const activityMarkerKeys = new Set();
|
|
1912
|
-
const activities = [...projection.orderedFacts.filter(fact => fact.kind === 'activity').map(fact => fact.activity), ...additionalActivities];
|
|
1913
2097
|
for (const marker of delta.activityMarkers.added) {
|
|
1914
2098
|
const key = this.activityMarkerKey(marker);
|
|
1915
|
-
|
|
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);
|
|
1916
2104
|
if (activityMarkerKeys.has(key) || projection.activityMarkers.has(key) || activity === undefined
|
|
1917
2105
|
|| activity.threadRef !== marker.threadRef || activity.sequence !== marker.sequence)
|
|
1918
2106
|
throw new Error('invalid activity marker addition');
|
|
@@ -1926,73 +2114,31 @@ export class AgentTeamLedger {
|
|
|
1926
2114
|
activityMarkerKeys.add(key);
|
|
1927
2115
|
}
|
|
1928
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
|
+
*/
|
|
1929
2132
|
validateReleaseCleanup(data, projection, memberId, threadRefs, sequence, refs) {
|
|
1930
2133
|
const releasedClaims = [...projection.claims.values()]
|
|
1931
|
-
.filter(claim => claim.owner === memberId && claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
2134
|
+
.filter(claim => (memberId === undefined || claim.owner === memberId) && claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
1932
2135
|
.map(claim => Object.freeze({ ...claim, state: 'released' }));
|
|
1933
2136
|
if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
|
|
1934
2137
|
const expected = releasedClaims[index];
|
|
1935
2138
|
return expected === undefined || !this.sameClaim(expected, claim);
|
|
1936
2139
|
}))
|
|
1937
2140
|
throw new Error('invalid released Claim projection');
|
|
1938
|
-
|
|
1939
|
-
for (const claim of releasedClaims)
|
|
1940
|
-
byThread.set(claim.threadRef, [...(byThread.get(claim.threadRef) ?? []), claim]);
|
|
1941
|
-
const expectedActivities = [...byThread.entries()].map(([threadRef, claims]) => ({
|
|
1942
|
-
kind: 'claims_released', taskRef: claims[0].taskRef, threadRef, actor: memberId, sequence,
|
|
1943
|
-
claimRefs: claims.map(claim => claim.claimRef).sort(),
|
|
1944
|
-
}));
|
|
1945
|
-
if (data.activities.length !== expectedActivities.length || data.activities.some((activity, index) => {
|
|
1946
|
-
const expected = expectedActivities[index];
|
|
1947
|
-
return expected === undefined || activity.kind !== expected.kind || activity.taskRef !== expected.taskRef
|
|
1948
|
-
|| activity.threadRef !== expected.threadRef || activity.actor !== expected.actor || activity.sequence !== expected.sequence
|
|
1949
|
-
|| !this.sameList(activity.claimRefs, expected.claimRefs);
|
|
1950
|
-
}))
|
|
1951
|
-
throw new Error('invalid released Claim activities');
|
|
1952
|
-
for (const claim of data.claims) {
|
|
1953
|
-
const prior = projection.claims.get(claim.claimRef);
|
|
1954
|
-
if (prior === undefined)
|
|
1955
|
-
throw new Error('invalid released Claim reference');
|
|
1956
|
-
}
|
|
1957
|
-
for (const activity of data.activities)
|
|
1958
|
-
this.addRef(refs, activity.activityRef);
|
|
1959
|
-
const projectedClaims = new Map(projection.claims);
|
|
1960
|
-
for (const claim of releasedClaims)
|
|
1961
|
-
projectedClaims.set(claim.claimRef, claim);
|
|
1962
|
-
const expectedTasks = [...new Set(releasedClaims.map(claim => claim.taskRef))].map(taskRef => {
|
|
1963
|
-
const task = projection.tasks.get(taskRef);
|
|
1964
|
-
return Object.freeze({ ...task, status: this.deriveResolvedTaskStatus(task, projectedClaims.values()) });
|
|
1965
|
-
});
|
|
1966
|
-
const expectedThreads = expectedActivities.map(activity => {
|
|
1967
|
-
const thread = projection.threads.get(activity.threadRef);
|
|
1968
|
-
return Object.freeze({ ...thread, revision: sequence });
|
|
1969
|
-
});
|
|
1970
|
-
if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
|
|
1971
|
-
throw new Error('invalid released Claim Task or Thread projection');
|
|
1972
|
-
}
|
|
1973
|
-
const expectedAttention = [...projection.attention.values()]
|
|
1974
|
-
.filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
|
|
1975
|
-
.map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
|
|
1976
|
-
const expectedMarkers = [...projection.directMarkers.values()]
|
|
1977
|
-
.filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
1978
|
-
const expectedActivityMarkers = [...projection.activityMarkers.values()]
|
|
1979
|
-
.filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
1980
|
-
const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
|
|
1981
|
-
if (!isDeepStrictEqual(data.inbox, expectedInbox))
|
|
1982
|
-
throw new Error('invalid Member inbox cleanup');
|
|
1983
|
-
this.validateInboxDelta(data.inbox, projection, refs);
|
|
1984
|
-
}
|
|
1985
|
-
/** Replay validation of one Channel archival's release snapshot across every owner. */
|
|
1986
|
-
validateChannelArchivalCleanup(data, projection, threadRefs, sequence, refs) {
|
|
1987
|
-
const releasedClaims = [...projection.claims.values()]
|
|
1988
|
-
.filter(claim => claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
1989
|
-
.map(claim => Object.freeze({ ...claim, state: 'released' }));
|
|
1990
|
-
if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
|
|
1991
|
-
const expected = releasedClaims[index];
|
|
1992
|
-
return expected === undefined || !this.sameClaim(expected, claim);
|
|
1993
|
-
}))
|
|
1994
|
-
throw new Error('invalid released Claim projection');
|
|
1995
|
-
// One activity per (owner, Thread), owners sorted like the commit path.
|
|
2141
|
+
// One Activity per (owner, Thread), owners sorted like the commit path.
|
|
1996
2142
|
const byOwner = new Map();
|
|
1997
2143
|
for (const claim of releasedClaims) {
|
|
1998
2144
|
const byThread = byOwner.get(claim.owner) ?? new Map();
|
|
@@ -2031,17 +2177,17 @@ export class AgentTeamLedger {
|
|
|
2031
2177
|
if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
|
|
2032
2178
|
throw new Error('invalid released Claim Task or Thread projection');
|
|
2033
2179
|
}
|
|
2034
|
-
// Attention and marker cleanup covers EVERY Member on the archived Threads.
|
|
2035
2180
|
const expectedAttention = [...projection.attention.values()]
|
|
2036
|
-
.filter(attention => threadRefs.has(attention.threadRef))
|
|
2181
|
+
.filter(attention => (memberId === undefined || attention.memberId === memberId) && threadRefs.has(attention.threadRef))
|
|
2037
2182
|
.map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
|
|
2038
2183
|
const expectedMarkers = [...projection.directMarkers.values()]
|
|
2039
|
-
.filter(marker => threadRefs.has(marker.threadRef));
|
|
2184
|
+
.filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
|
|
2040
2185
|
const expectedActivityMarkers = [...projection.activityMarkers.values()]
|
|
2041
|
-
.filter(marker => threadRefs.has(marker.threadRef));
|
|
2186
|
+
.filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
|
|
2042
2187
|
const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
|
|
2043
|
-
if (!isDeepStrictEqual(data.inbox, expectedInbox))
|
|
2044
|
-
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
|
+
}
|
|
2045
2191
|
this.validateInboxDelta(data.inbox, projection, refs);
|
|
2046
2192
|
}
|
|
2047
2193
|
validateMessageInbox(operation, projection) {
|
|
@@ -2081,6 +2227,12 @@ export class AgentTeamLedger {
|
|
|
2081
2227
|
}
|
|
2082
2228
|
apply(operation) {
|
|
2083
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;
|
|
2084
2236
|
}
|
|
2085
2237
|
applyTo(target, operation) {
|
|
2086
2238
|
target.ordered.push(operation);
|
|
@@ -2150,15 +2302,7 @@ export class AgentTeamLedger {
|
|
|
2150
2302
|
}
|
|
2151
2303
|
if (operation.kind === 'team/channel-member-removed') {
|
|
2152
2304
|
target.memberships.get(operation.data.channelRef)?.delete(operation.data.memberId);
|
|
2153
|
-
|
|
2154
|
-
target.claims.set(claim.claimRef, claim);
|
|
2155
|
-
for (const activity of operation.data.activities)
|
|
2156
|
-
this.appendActivityFact(target, activity);
|
|
2157
|
-
for (const task of operation.data.tasks)
|
|
2158
|
-
target.tasks.set(task.taskRef, task);
|
|
2159
|
-
for (const thread of operation.data.threads)
|
|
2160
|
-
target.threads.set(thread.threadRef, thread);
|
|
2161
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2305
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2162
2306
|
return;
|
|
2163
2307
|
}
|
|
2164
2308
|
if (operation.kind === 'team/channel-archived') {
|
|
@@ -2166,30 +2310,14 @@ export class AgentTeamLedger {
|
|
|
2166
2310
|
// restore returns every Member to the Channel. Visibility filters by
|
|
2167
2311
|
// channel state instead.
|
|
2168
2312
|
target.channels.set(operation.data.channel.channelRef, operation.data.channel);
|
|
2169
|
-
|
|
2170
|
-
target.claims.set(claim.claimRef, claim);
|
|
2171
|
-
for (const activity of operation.data.activities)
|
|
2172
|
-
this.appendActivityFact(target, activity);
|
|
2173
|
-
for (const task of operation.data.tasks)
|
|
2174
|
-
target.tasks.set(task.taskRef, task);
|
|
2175
|
-
for (const thread of operation.data.threads)
|
|
2176
|
-
target.threads.set(thread.threadRef, thread);
|
|
2177
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2313
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2178
2314
|
return;
|
|
2179
2315
|
}
|
|
2180
2316
|
if (operation.kind === 'team/member-removed') {
|
|
2181
2317
|
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
2182
2318
|
for (const membership of target.memberships.values())
|
|
2183
2319
|
membership.delete(operation.data.member.memberId);
|
|
2184
|
-
|
|
2185
|
-
target.claims.set(claim.claimRef, claim);
|
|
2186
|
-
for (const activity of operation.data.activities)
|
|
2187
|
-
this.appendActivityFact(target, activity);
|
|
2188
|
-
for (const task of operation.data.tasks)
|
|
2189
|
-
target.tasks.set(task.taskRef, task);
|
|
2190
|
-
for (const thread of operation.data.threads)
|
|
2191
|
-
target.threads.set(thread.threadRef, thread);
|
|
2192
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2320
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2193
2321
|
return;
|
|
2194
2322
|
}
|
|
2195
2323
|
if (operation.kind === 'team/member-archived') {
|
|
@@ -2197,55 +2325,61 @@ export class AgentTeamLedger {
|
|
|
2197
2325
|
// restore returns the Member to its Channels. Visibility filters by
|
|
2198
2326
|
// member state instead.
|
|
2199
2327
|
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
2200
|
-
|
|
2201
|
-
target.claims.set(claim.claimRef, claim);
|
|
2202
|
-
for (const activity of operation.data.activities)
|
|
2203
|
-
this.appendActivityFact(target, activity);
|
|
2204
|
-
for (const task of operation.data.tasks)
|
|
2205
|
-
target.tasks.set(task.taskRef, task);
|
|
2206
|
-
for (const thread of operation.data.threads)
|
|
2207
|
-
target.threads.set(thread.threadRef, thread);
|
|
2208
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2328
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2209
2329
|
return;
|
|
2210
2330
|
}
|
|
2211
2331
|
if (operation.kind === 'team/thread-promoted') {
|
|
2212
|
-
this.appendActivityFact(target, operation.data.activity);
|
|
2332
|
+
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2213
2333
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2214
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);
|
|
2215
2337
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2216
2338
|
return;
|
|
2217
2339
|
}
|
|
2218
2340
|
if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
|
|
2219
2341
|
const { message, mentions } = operation.data;
|
|
2220
|
-
target.messages.push(message);
|
|
2221
2342
|
target.mentionsByMessage.set(message.messageRef, Object.freeze([...mentions]));
|
|
2222
|
-
this.appendMessageFact(target, message, mentions);
|
|
2343
|
+
this.appendMessageFact(target, message, mentions, message.occurredAt ?? operation.occurredAt);
|
|
2223
2344
|
if (operation.data.task !== undefined)
|
|
2224
2345
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2225
2346
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2226
|
-
if (message.topLevel)
|
|
2347
|
+
if (message.topLevel) {
|
|
2227
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);
|
|
2228
2359
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2229
2360
|
return;
|
|
2230
2361
|
}
|
|
2231
2362
|
if (operation.kind === 'team/claim-created' || operation.kind === 'team/claim-done' || operation.kind === 'team/claim-released') {
|
|
2232
2363
|
target.claims.set(operation.data.claim.claimRef, operation.data.claim);
|
|
2233
|
-
this.appendActivityFact(target, operation.data.activity);
|
|
2364
|
+
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2234
2365
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2235
2366
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2367
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2236
2368
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2237
2369
|
return;
|
|
2238
2370
|
}
|
|
2239
2371
|
if (operation.kind === 'team/task-changed') {
|
|
2240
2372
|
for (const claim of operation.data.claims)
|
|
2241
2373
|
target.claims.set(claim.claimRef, claim);
|
|
2242
|
-
this.appendActivityFact(target, operation.data.activity);
|
|
2374
|
+
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2243
2375
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2244
2376
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2377
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2245
2378
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2246
2379
|
return;
|
|
2247
2380
|
}
|
|
2248
2381
|
if (operation.kind === 'team/thread-attention-changed' || operation.kind === 'team/thread-read') {
|
|
2382
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2249
2383
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2250
2384
|
return;
|
|
2251
2385
|
}
|
|
@@ -2256,22 +2390,82 @@ export class AgentTeamLedger {
|
|
|
2256
2390
|
}
|
|
2257
2391
|
assertUnhandledKind(operation);
|
|
2258
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
|
+
}
|
|
2259
2412
|
/** Facts arrive in ledger sequence order, so global and per-thread lists stay sorted by append only. */
|
|
2260
|
-
appendMessageFact(target, message, mentions) {
|
|
2261
|
-
const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions });
|
|
2413
|
+
appendMessageFact(target, message, mentions, occurredAt) {
|
|
2414
|
+
const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions, occurredAt });
|
|
2262
2415
|
target.orderedFacts.push(fact);
|
|
2263
2416
|
const facts = target.factsByThread.get(message.threadRef) ?? [];
|
|
2264
2417
|
facts.push(fact);
|
|
2265
2418
|
target.factsByThread.set(message.threadRef, facts);
|
|
2266
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);
|
|
2267
2423
|
}
|
|
2268
|
-
appendActivityFact(target, activity) {
|
|
2269
|
-
const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity });
|
|
2424
|
+
appendActivityFact(target, activity, occurredAt) {
|
|
2425
|
+
const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity, occurredAt });
|
|
2270
2426
|
target.orderedFacts.push(fact);
|
|
2271
2427
|
const facts = target.factsByThread.get(activity.threadRef) ?? [];
|
|
2272
2428
|
facts.push(fact);
|
|
2273
2429
|
target.factsByThread.set(activity.threadRef, facts);
|
|
2274
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
|
+
}
|
|
2275
2469
|
applyInboxDelta(target, delta) {
|
|
2276
2470
|
for (const key of delta.attention.removed) {
|
|
2277
2471
|
target.attention.delete(this.attentionKey(key.memberId, key.threadRef));
|
|
@@ -2281,27 +2475,76 @@ export class AgentTeamLedger {
|
|
|
2281
2475
|
if (followers.size === 0)
|
|
2282
2476
|
target.attentionByThread.delete(key.threadRef);
|
|
2283
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
|
+
}
|
|
2284
2484
|
}
|
|
2285
2485
|
for (const attention of delta.attention.set) {
|
|
2286
2486
|
target.attention.set(this.attentionKey(attention.memberId, attention.threadRef), attention);
|
|
2287
2487
|
const followers = target.attentionByThread.get(attention.threadRef) ?? new Set();
|
|
2288
2488
|
followers.add(attention.memberId);
|
|
2289
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);
|
|
2290
2493
|
}
|
|
2291
|
-
for (const marker of delta.directMarkers.removed)
|
|
2494
|
+
for (const marker of delta.directMarkers.removed) {
|
|
2292
2495
|
target.directMarkers.delete(this.directMarkerKey(marker));
|
|
2293
|
-
|
|
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) {
|
|
2294
2499
|
target.directMarkers.set(this.directMarkerKey(marker), marker);
|
|
2295
|
-
|
|
2500
|
+
this.insertBucketedMarker(target.directMarkersByMember, marker.memberId, marker.threadRef, marker);
|
|
2501
|
+
}
|
|
2502
|
+
for (const marker of delta.activityMarkers.removed) {
|
|
2296
2503
|
target.activityMarkers.delete(this.activityMarkerKey(marker));
|
|
2297
|
-
|
|
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) {
|
|
2298
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
|
+
}
|
|
2299
2535
|
}
|
|
2300
2536
|
prepareRead(memberId, workspaceId, request) {
|
|
2301
2537
|
return this.prepareReadFrom(this.state, memberId, workspaceId, request);
|
|
2302
2538
|
}
|
|
2303
|
-
/**
|
|
2304
|
-
|
|
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) {
|
|
2305
2548
|
const { task, thread, channelRef } = this.threadContextFrom(projection, workspaceId, request);
|
|
2306
2549
|
if (memberId !== AGENT_TEAM_HUMAN_MEMBER_ID) {
|
|
2307
2550
|
const member = projection.members.get(memberId);
|
|
@@ -2309,20 +2552,9 @@ export class AgentTeamLedger {
|
|
|
2309
2552
|
throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${channelRef}'`);
|
|
2310
2553
|
}
|
|
2311
2554
|
}
|
|
2312
|
-
const anchor = this.threadAnchorFrom(projection, thread.threadRef);
|
|
2313
2555
|
const attention = this.attentionForFrom(projection, memberId, thread.threadRef);
|
|
2314
2556
|
const unread = this.unreadForFrom(projection, memberId, thread.threadRef);
|
|
2315
2557
|
const unreadFacts = unread.slice(0, 20);
|
|
2316
|
-
const unreadFactKeys = new Set(unread.map(item => this.threadFactKey(item.fact)));
|
|
2317
|
-
const firstRead = attention !== undefined && attention.readThroughSequence < attention.startSequence;
|
|
2318
|
-
const background = firstRead
|
|
2319
|
-
? projection.messages.filter(message => message.threadRef === thread.threadRef && message.sequence < attention.startSequence)
|
|
2320
|
-
.map(message => Object.freeze({ kind: 'message', sequence: message.sequence, message,
|
|
2321
|
-
mentions: projection.mentionsByMessage.get(message.messageRef) ?? [] }))
|
|
2322
|
-
.filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
|
|
2323
|
-
: [];
|
|
2324
|
-
const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...unreadFacts]
|
|
2325
|
-
.sort((left, right) => left.fact.sequence - right.fact.sequence);
|
|
2326
2558
|
// Direct markers are sparse acknowledgements, not part of the contiguous
|
|
2327
2559
|
// follower watermark. Consuming an old marker after a later follow must
|
|
2328
2560
|
// never move that watermark backwards.
|
|
@@ -2338,24 +2570,36 @@ export class AgentTeamLedger {
|
|
|
2338
2570
|
const activityMarkers = this.activityMarkersForFrom(projection, memberId, thread.threadRef)
|
|
2339
2571
|
.filter(marker => unreadFacts.some(item => item.fact.kind === 'activity' && item.fact.activity.activityRef === marker.activityRef));
|
|
2340
2572
|
const inbox = this.inboxDelta(nextAttention, [], [], consumed, [], activityMarkers);
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
2344
|
-
const nextProjection = {
|
|
2345
|
-
...projection,
|
|
2346
|
-
attention: new Map(projection.attention),
|
|
2347
|
-
directMarkers: new Map(projection.directMarkers),
|
|
2348
|
-
activityMarkers: new Map(projection.activityMarkers),
|
|
2349
|
-
attentionByThread: new Map([...projection.attentionByThread].map(([threadRef, followers]) => [threadRef, new Set(followers)])),
|
|
2350
|
-
};
|
|
2351
|
-
this.applyInboxDelta(nextProjection, inbox);
|
|
2352
|
-
const remainingUnreadCount = this.unreadForFrom(nextProjection, memberId, thread.threadRef).length;
|
|
2353
|
-
return Object.freeze({ ...(task === undefined ? {} : { task }), thread, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
|
|
2354
|
-
anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
|
|
2355
|
-
facts: Object.freeze(combined),
|
|
2356
|
-
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 }),
|
|
2357
2576
|
consumedDirectMarkers: Object.freeze(consumed), inbox });
|
|
2358
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
|
+
}
|
|
2359
2603
|
readFactFrom(projection, memberId, fact, unread) {
|
|
2360
2604
|
return Object.freeze({ fact, unread, direct: fact.kind === 'message'
|
|
2361
2605
|
&& projection.directMarkers.has(this.directMarkerKey({ memberId, threadRef: fact.message.threadRef,
|
|
@@ -2366,23 +2610,60 @@ export class AgentTeamLedger {
|
|
|
2366
2610
|
}
|
|
2367
2611
|
unreadForFrom(projection, memberId, threadRef) {
|
|
2368
2612
|
const attention = this.attentionForFrom(projection, memberId, threadRef);
|
|
2369
|
-
const
|
|
2370
|
-
const direct = this.directMarkersForFrom(projection, memberId, threadRef);
|
|
2371
|
-
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)));
|
|
2372
2614
|
const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
|
|
2373
2615
|
const result = [];
|
|
2374
|
-
for (const fact of
|
|
2375
|
-
|
|
2376
|
-
messageRef: fact.message.messageRef, sequence: fact.sequence }));
|
|
2377
|
-
const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
|
|
2378
|
-
activityRef: fact.activity.activityRef, sequence: fact.sequence }));
|
|
2379
|
-
const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
|
|
2380
|
-
&& fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
|
|
2381
|
-
if (marker || activityMarker || ordinary)
|
|
2616
|
+
for (const fact of this.threadFactsFrom(projection, threadRef)) {
|
|
2617
|
+
if (this.isUnreadFact(fact, memberId, threadRef, attention, directKeys, activityKeys)) {
|
|
2382
2618
|
result.push(this.readFactFrom(projection, memberId, fact, true));
|
|
2619
|
+
}
|
|
2383
2620
|
}
|
|
2384
2621
|
return Object.freeze(result);
|
|
2385
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
|
+
}
|
|
2386
2667
|
visibleToFollower(fact, memberId) {
|
|
2387
2668
|
const sender = fact.kind === 'message' ? fact.message.sender : fact.activity.actor;
|
|
2388
2669
|
return sender !== memberId;
|
|
@@ -2393,6 +2674,23 @@ export class AgentTeamLedger {
|
|
|
2393
2674
|
threadFactsFrom(projection, threadRef) {
|
|
2394
2675
|
return projection.factsByThread.get(threadRef) ?? [];
|
|
2395
2676
|
}
|
|
2677
|
+
/**
|
|
2678
|
+
* The one per-fact instant projection: a fact's wall-clock time is the
|
|
2679
|
+
* occurredAt of the ledger operation that committed it, looked up by
|
|
2680
|
+
* sequence inside the same read snapshot. Every agent-facing surface
|
|
2681
|
+
* (Thread facts, notifications, DM history, mutation results) resolves
|
|
2682
|
+
* through this single path, never a second parallel lookup.
|
|
2683
|
+
*/
|
|
2684
|
+
occurredAtForFactFrom(projection, sequence, fallback) {
|
|
2685
|
+
if (fallback !== undefined)
|
|
2686
|
+
return fallback;
|
|
2687
|
+
// Facts arrive in ledger sequence order and `ordered` is append-only, so
|
|
2688
|
+
// the entry at index sequence - 1 is the committing operation. The index
|
|
2689
|
+
// read is guarded for hypothetical projections whose prefix has not yet
|
|
2690
|
+
// caught up; those callers always pass an explicit fallback instead.
|
|
2691
|
+
const operation = projection.ordered[sequence - 1];
|
|
2692
|
+
return operation?.occurredAt ?? '';
|
|
2693
|
+
}
|
|
2396
2694
|
messageInboxDelta(message, sender, mentions, attention) {
|
|
2397
2695
|
const markers = mentions.map(memberId => Object.freeze({ memberId, threadRef: message.threadRef,
|
|
2398
2696
|
messageRef: message.messageRef, sequence: message.sequence }));
|
|
@@ -2454,18 +2752,24 @@ export class AgentTeamLedger {
|
|
|
2454
2752
|
return this.inboxDelta([], [], [], [], markers);
|
|
2455
2753
|
}
|
|
2456
2754
|
removeMemberThreadInbox(memberId, threadRefs) {
|
|
2457
|
-
|
|
2755
|
+
return this.removeMemberThreadInboxFrom(this.state, memberId, threadRefs);
|
|
2756
|
+
}
|
|
2757
|
+
removeMemberThreadInboxFrom(projection, memberId, threadRefs) {
|
|
2758
|
+
const removed = [...projection.attention.values()].filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
|
|
2458
2759
|
.map(attention => Object.freeze({ memberId, threadRef: attention.threadRef }));
|
|
2459
|
-
const markers = [...
|
|
2460
|
-
const activityMarkers = [...
|
|
2760
|
+
const markers = [...projection.directMarkers.values()].filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
2761
|
+
const activityMarkers = [...projection.activityMarkers.values()].filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
2461
2762
|
return this.inboxDelta([], removed, [], markers, [], activityMarkers);
|
|
2462
2763
|
}
|
|
2463
2764
|
/** Attention and marker cleanup for EVERY Member on the given Threads. */
|
|
2464
2765
|
channelArchivalInbox(threadRefs) {
|
|
2465
|
-
|
|
2766
|
+
return this.channelArchivalInboxFrom(this.state, threadRefs);
|
|
2767
|
+
}
|
|
2768
|
+
channelArchivalInboxFrom(projection, threadRefs) {
|
|
2769
|
+
const removed = [...projection.attention.values()].filter(attention => threadRefs.has(attention.threadRef))
|
|
2466
2770
|
.map(attention => Object.freeze({ memberId: attention.memberId, threadRef: attention.threadRef }));
|
|
2467
|
-
const markers = [...
|
|
2468
|
-
const activityMarkers = [...
|
|
2771
|
+
const markers = [...projection.directMarkers.values()].filter(marker => threadRefs.has(marker.threadRef));
|
|
2772
|
+
const activityMarkers = [...projection.activityMarkers.values()].filter(marker => threadRefs.has(marker.threadRef));
|
|
2469
2773
|
return this.inboxDelta([], removed, [], markers, [], activityMarkers);
|
|
2470
2774
|
}
|
|
2471
2775
|
inboxDelta(set = [], removed = [], addedMarkers = [], removedMarkers = [], addedActivityMarkers = [], removedActivityMarkers = []) {
|
|
@@ -2504,12 +2808,14 @@ export class AgentTeamLedger {
|
|
|
2504
2808
|
return this.directMarkersForFrom(this.state, memberId, threadRef);
|
|
2505
2809
|
}
|
|
2506
2810
|
directMarkersForFrom(projection, memberId, threadRef) {
|
|
2507
|
-
|
|
2508
|
-
|
|
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]);
|
|
2509
2815
|
}
|
|
2510
2816
|
activityMarkersForFrom(projection, memberId, threadRef) {
|
|
2511
|
-
|
|
2512
|
-
|
|
2817
|
+
const markers = projection.activityMarkersByMember.get(memberId)?.get(threadRef);
|
|
2818
|
+
return markers === undefined ? [] : Object.freeze([...markers]);
|
|
2513
2819
|
}
|
|
2514
2820
|
issueConfirmation(actor, workspaceId, channelRef, body, recipients, task, thread) {
|
|
2515
2821
|
if (actor.kind !== 'human')
|
|
@@ -2620,7 +2926,7 @@ export class AgentTeamLedger {
|
|
|
2620
2926
|
return this.threadAnchorFrom(this.state, threadRef);
|
|
2621
2927
|
}
|
|
2622
2928
|
threadAnchorFrom(projection, threadRef) {
|
|
2623
|
-
const anchor = projection.
|
|
2929
|
+
const anchor = projection.anchorByThread.get(threadRef);
|
|
2624
2930
|
if (anchor === undefined)
|
|
2625
2931
|
throw new Error(`Thread '${threadRef}' has no anchor Message`);
|
|
2626
2932
|
return anchor;
|
|
@@ -2632,20 +2938,17 @@ export class AgentTeamLedger {
|
|
|
2632
2938
|
* Display numbers for Tasks: one counter per home Channel, in creation
|
|
2633
2939
|
* order. This is the single numbering authority — Channel cards, Thread
|
|
2634
2940
|
* headings, cross-channel ref resolution, and inbox renders all show the
|
|
2635
|
-
* 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.
|
|
2636
2944
|
*/
|
|
2637
2945
|
taskNumbers(workspaceId) {
|
|
2638
2946
|
const numbers = new Map();
|
|
2639
|
-
const
|
|
2640
|
-
|
|
2641
|
-
|
|
2642
|
-
if (task === undefined)
|
|
2643
|
-
continue;
|
|
2644
|
-
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)
|
|
2645
2950
|
continue;
|
|
2646
|
-
|
|
2647
|
-
next.set(task.channelRef, ordinal);
|
|
2648
|
-
numbers.set(task.taskRef, ordinal);
|
|
2951
|
+
numbers.set(taskRef, taskNumber);
|
|
2649
2952
|
}
|
|
2650
2953
|
return numbers;
|
|
2651
2954
|
}
|
|
@@ -2864,8 +3167,12 @@ export class AgentTeamLedger {
|
|
|
2864
3167
|
isChannelMemberFrom(projection, channelRef, memberId) {
|
|
2865
3168
|
return projection.memberships.get(channelRef)?.has(memberId) === true;
|
|
2866
3169
|
}
|
|
3170
|
+
/** Every Thread of the Channel, taskful or taskless — the scope both cleanup validators replay against. */
|
|
2867
3171
|
channelThreadRefs(channelRef) {
|
|
2868
|
-
return
|
|
3172
|
+
return this.channelThreadRefsFrom(this.state, channelRef);
|
|
3173
|
+
}
|
|
3174
|
+
channelThreadRefsFrom(projection, channelRef) {
|
|
3175
|
+
return new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === channelRef));
|
|
2869
3176
|
}
|
|
2870
3177
|
assertJoinableMember(workspaceId, memberId) {
|
|
2871
3178
|
const member = this.requireMember(memberId);
|
|
@@ -3071,8 +3378,8 @@ export class AgentTeamLedger {
|
|
|
3071
3378
|
assertSameThreadRead(operation, request) {
|
|
3072
3379
|
if (operation.kind !== 'team/thread-read' || !this.sameActor(operation.actor, request.actor)
|
|
3073
3380
|
|| operation.data.workspaceId !== request.workspaceId
|
|
3074
|
-
|| (request.threadRef !== undefined && operation.data.
|
|
3075
|
-
|| (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)
|
|
3076
3383
|
|| (request.threadRef === undefined && request.taskRef === undefined))
|
|
3077
3384
|
this.throwRequestCollision(request.requestId);
|
|
3078
3385
|
}
|
|
@@ -3177,17 +3484,21 @@ export class AgentTeamLedger {
|
|
|
3177
3484
|
return Object.freeze({ kind: 'committed', receipt: this.receipt(operation), activity: operation.data.activity,
|
|
3178
3485
|
task: operation.data.task, thread: operation.data.thread });
|
|
3179
3486
|
}
|
|
3180
|
-
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
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 });
|
|
3188
3499
|
}
|
|
3189
3500
|
receipt(operation) {
|
|
3190
|
-
return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence });
|
|
3501
|
+
return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence, occurredAt: operation.occurredAt });
|
|
3191
3502
|
}
|
|
3192
3503
|
removalResult(operation) {
|
|
3193
3504
|
return Object.freeze({ receipt: this.receipt(operation), member: operation.data.member,
|
|
@@ -3214,25 +3525,78 @@ export class AgentTeamLedger {
|
|
|
3214
3525
|
sortedRecords() {
|
|
3215
3526
|
const records = [...this.table.entries()].sort((left, right) => left[1].sequence - right[1].sequence);
|
|
3216
3527
|
const occurrences = new Map();
|
|
3528
|
+
const instants = new Map();
|
|
3217
3529
|
for (const [, operation] of records) {
|
|
3530
|
+
instants.set(operation.sequence, operation.occurredAt);
|
|
3218
3531
|
if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
|
|
3219
3532
|
occurrences.set(operation.data.message.messageRef, operation.data.message.occurredAt ?? operation.occurredAt);
|
|
3220
3533
|
}
|
|
3221
3534
|
}
|
|
3222
|
-
|
|
3535
|
+
// Replay against a scratch projection while normalizing, so a legacy
|
|
3536
|
+
// cleanup repair sees the same state its validator will.
|
|
3537
|
+
const projection = emptyProjection();
|
|
3538
|
+
return records.map(([id, operation]) => {
|
|
3539
|
+
const normalized = this.repairLegacyChannelCleanup(this.normalizeOperation(operation, occurrences, instants), projection);
|
|
3540
|
+
this.applyTo(projection, normalized);
|
|
3541
|
+
return [id, normalized];
|
|
3542
|
+
});
|
|
3543
|
+
}
|
|
3544
|
+
/**
|
|
3545
|
+
* Releases up to 0.1.9 scoped Channel archival and Channel member-removal
|
|
3546
|
+
* inbox cleanup to taskful Threads only (the collector read the Task
|
|
3547
|
+
* projection), so archiving or member removal in a Channel holding a
|
|
3548
|
+
* taskless Thread with Attention or markers wrote an incomplete inbox and
|
|
3549
|
+
* every later load rejected the record, leaving the profile unable to boot.
|
|
3550
|
+
* Records carrying exactly that legacy cleanup are repaired in memory the
|
|
3551
|
+
* same way pre-envelope Thread reads are; any other inbox still fails
|
|
3552
|
+
* validation, so a forgery is not silently accepted.
|
|
3553
|
+
*/
|
|
3554
|
+
repairLegacyChannelCleanup(operation, projection) {
|
|
3555
|
+
if (operation.kind === 'team/channel-archived') {
|
|
3556
|
+
const channelRef = operation.data.channel.channelRef;
|
|
3557
|
+
const expected = this.channelArchivalInboxFrom(projection, this.channelThreadRefsFrom(projection, channelRef));
|
|
3558
|
+
if (!isDeepStrictEqual(operation.data.inbox, expected)
|
|
3559
|
+
&& isDeepStrictEqual(operation.data.inbox, this.channelArchivalInboxFrom(projection, this.legacyChannelThreadRefs(projection, channelRef)))) {
|
|
3560
|
+
return { ...operation, data: { ...operation.data, inbox: expected } };
|
|
3561
|
+
}
|
|
3562
|
+
return operation;
|
|
3563
|
+
}
|
|
3564
|
+
if (operation.kind === 'team/channel-member-removed') {
|
|
3565
|
+
const { channelRef, memberId } = operation.data;
|
|
3566
|
+
const expected = this.removeMemberThreadInboxFrom(projection, memberId, this.channelThreadRefsFrom(projection, channelRef));
|
|
3567
|
+
if (!isDeepStrictEqual(operation.data.inbox, expected)
|
|
3568
|
+
&& isDeepStrictEqual(operation.data.inbox, this.removeMemberThreadInboxFrom(projection, memberId, this.legacyChannelThreadRefs(projection, channelRef)))) {
|
|
3569
|
+
return { ...operation, data: { ...operation.data, inbox: expected } };
|
|
3570
|
+
}
|
|
3571
|
+
return operation;
|
|
3572
|
+
}
|
|
3573
|
+
return operation;
|
|
3223
3574
|
}
|
|
3224
|
-
/**
|
|
3225
|
-
|
|
3226
|
-
|
|
3575
|
+
/** The pre-fix Channel Thread scope: only Threads carrying a Task. */
|
|
3576
|
+
legacyChannelThreadRefs(projection, channelRef) {
|
|
3577
|
+
return new Set([...projection.tasks.values()].filter(task => task.channelRef === channelRef).map(task => task.threadRef));
|
|
3578
|
+
}
|
|
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
|
+
*/
|
|
3586
|
+
normalizeOperation(operation, occurrences, instants) {
|
|
3587
|
+
if (operation.kind !== 'team/thread-read' || !isThreadReadSnapshot(operation.data))
|
|
3227
3588
|
return operation;
|
|
3589
|
+
const data = operation.data;
|
|
3228
3590
|
const stamp = (message) => (message.occurredAt === undefined
|
|
3229
3591
|
? { ...message, occurredAt: occurrences.get(message.messageRef) ?? operation.occurredAt }
|
|
3230
3592
|
: { ...message, occurredAt: message.occurredAt });
|
|
3231
|
-
const
|
|
3232
|
-
? {
|
|
3233
|
-
|
|
3234
|
-
: {
|
|
3235
|
-
|
|
3593
|
+
const stampEnvelope = (envelope) => envelope.kind === 'message'
|
|
3594
|
+
? { kind: 'message', sequence: envelope.sequence, message: stamp(envelope.message), mentions: envelope.mentions,
|
|
3595
|
+
occurredAt: envelope.occurredAt ?? envelope.message.occurredAt ?? occurrences.get(envelope.message.messageRef) ?? operation.occurredAt }
|
|
3596
|
+
: { kind: 'activity', sequence: envelope.sequence, activity: envelope.activity,
|
|
3597
|
+
occurredAt: envelope.occurredAt ?? instants.get(envelope.sequence) ?? operation.occurredAt };
|
|
3598
|
+
const facts = data.facts.map((fact) => ({ ...fact, fact: stampEnvelope(fact.fact) }));
|
|
3599
|
+
return { ...operation, data: { ...data, anchor: stamp(data.anchor), facts } };
|
|
3236
3600
|
}
|
|
3237
3601
|
enqueue(operation) {
|
|
3238
3602
|
const result = this.operationTail.then(operation);
|