@wowyuarm/dsh-agent-team 0.1.10 → 0.1.12
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 -4
- package/README.zh.md +17 -4
- package/package.json +53 -47
- package/packages/agent-team/README.md +6 -4
- package/packages/agent-team/README.zh.md +6 -4
- package/packages/agent-team/lib/context-management.js +40 -50
- package/packages/agent-team/lib/context-projection.js +15 -1
- package/packages/agent-team/lib/index.js +244 -154
- package/packages/agent-team/lib/invariant.js +38 -4
- package/packages/agent-team/lib/ledger.js +775 -383
- package/packages/agent-team/lib/member-context.js +6 -2
- package/packages/agent-team/lib/member-runtime.js +47 -9
- package/packages/agent-team/lib/member-time-context.js +20 -5
- package/packages/agent-team/lib/mentions.js +56 -0
- package/packages/agent-team/lib/pressure-policy.js +33 -9
- package/packages/agent-team/lib/session-event-cursor.js +116 -0
- package/packages/agent-team/lib/session-remediation.js +38 -26
- package/packages/agent-team/lib/spec.js +47 -34
- package/packages/agent-team/lib/stored-session-reader.js +138 -0
- package/packages/agent-team/lib/typert.host.js +157 -41
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
- package/packages/agent-team/lib/typert.remote-client.js +129 -32
- package/packages/agent-team/lib/types/context-management.d.ts +7 -0
- package/packages/agent-team/lib/types/context-management.d.ts.map +1 -1
- package/packages/agent-team/lib/types/context-projection.d.ts +8 -0
- package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -1
- package/packages/agent-team/lib/types/index.d.ts +77 -7
- 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 +251 -20
- 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-runtime.d.ts +2 -1
- package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -1
- package/packages/agent-team/lib/types/mentions.d.ts +34 -0
- package/packages/agent-team/lib/types/mentions.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/session-event-cursor.d.ts +119 -0
- package/packages/agent-team/lib/types/session-event-cursor.d.ts.map +1 -0
- package/packages/agent-team/lib/types/session-remediation.d.ts +23 -0
- package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -1
- package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
- package/packages/agent-team/lib/types/stored-session-reader.d.ts +92 -0
- package/packages/agent-team/lib/types/stored-session-reader.d.ts.map +1 -0
- package/packages/agent-team/lib/types/types/entities.d.ts +28 -20
- 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 +87 -4
- package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
- package/packages/agent-team/preset/team-member/agent.cordis.yml +49 -7
- 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 +2357 -1207
- 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 +27 -7
- package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts +18 -0
- package/packages/client-agent-team/lib/types/client/TeamAvatarStack.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamAvatarStack.js +22 -0
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts +3 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelPage.js +167 -35
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamChannelsPanel.js +23 -27
- 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 +26 -26
- 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 +9 -2
- package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts +20 -0
- package/packages/client-agent-team/lib/types/client/TeamCountBadge.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamCountBadge.js +25 -0
- package/packages/client-agent-team/lib/types/client/TeamInboxPage.d.ts +25 -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 +139 -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/TeamMemberRow.d.ts +43 -0
- package/packages/client-agent-team/lib/types/client/TeamMemberRow.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/TeamMemberRow.js +27 -0
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts +2 -2
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamMembersAction.js +2 -2
- package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts +9 -2
- package/packages/client-agent-team/lib/types/client/TeamMessage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamMessage.js +11 -4
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts +8 -0
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamPresenceDot.js +21 -1
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +108 -30
- 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 +68 -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 +19 -9
- package/packages/client-agent-team/lib/types/client/locales.d.ts +40 -2
- package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/locales.js +40 -2
- package/packages/client-agent-team/lib/types/client/navigation.d.ts +10 -0
- package/packages/client-agent-team/lib/types/client/navigation.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/navigation.js +18 -7
- package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts +41 -0
- package/packages/client-agent-team/lib/types/client/scope-coverage.d.ts.map +1 -0
- package/packages/client-agent-team/lib/types/client/scope-coverage.js +64 -0
- package/packages/client-agent-team/lib/types/client/slots.d.ts +7 -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 +33 -2
- 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 +107 -25
- 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 +43 -1
- 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 +81 -6
- package/packages/tool-agent-team/README.md +2 -2
- package/packages/tool-agent-team/README.zh.md +2 -2
- 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 +44 -32
- 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
- package/packages/agent-team/lib/progress-nudge.js +0 -328
- package/packages/agent-team/lib/types/progress-nudge.d.ts +0 -136
- package/packages/agent-team/lib/types/progress-nudge.d.ts.map +0 -1
- package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts +0 -3
- package/packages/client-agent-team/lib/types/client/TeamSettings.d.ts.map +0 -1
- package/packages/client-agent-team/lib/types/client/TeamSettings.js +0 -5
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
3
|
import { formatTeamTimestamp } from "./time-format.js";
|
|
4
|
+
import { resolveBodyMentions } from "./mentions.js";
|
|
4
5
|
/** Stable Human Member identity shared by every replay of one dshHome Team. */
|
|
5
6
|
export const AGENT_TEAM_HUMAN_MEMBER_ID = 'member:human';
|
|
7
|
+
/**
|
|
8
|
+
* The handle the Human is addressed by in Message bodies. The body parser and
|
|
9
|
+
* the Member directory both read this one constant, so a configurable display
|
|
10
|
+
* name has a single place to land instead of a hard-coded literal per surface.
|
|
11
|
+
*/
|
|
12
|
+
export const AGENT_TEAM_HUMAN_HANDLE = 'human';
|
|
6
13
|
/** Idempotency identity of the one Host bootstrap operation. */
|
|
7
14
|
export const AGENT_TEAM_INITIALIZE_REQUEST_ID = 'agent-team:initialize:v1';
|
|
8
15
|
const HUMAN_ACTOR = Object.freeze({
|
|
9
16
|
kind: 'human',
|
|
10
17
|
memberId: AGENT_TEAM_HUMAN_MEMBER_ID,
|
|
11
|
-
handle:
|
|
18
|
+
handle: AGENT_TEAM_HUMAN_HANDLE,
|
|
12
19
|
});
|
|
13
20
|
/** Resolve the one Human authority owned by this Team. */
|
|
14
21
|
export function agentTeamHumanActor() {
|
|
@@ -16,18 +23,61 @@ export function agentTeamHumanActor() {
|
|
|
16
23
|
}
|
|
17
24
|
function emptyProjection() {
|
|
18
25
|
return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), memberships: new Map(),
|
|
19
|
-
claims: new Map(),
|
|
26
|
+
claims: new Map(), tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
|
|
20
27
|
orderedFacts: [], factsByThread: new Map(), channelRefByThread: new Map(), mentionsByMessage: new Map(), messageCountByThread: new Map(),
|
|
21
|
-
|
|
28
|
+
messagesByRef: new Map(),
|
|
29
|
+
attentionByThread: new Map(), previousSessions: new Map(), rolloverSeeds: new Map(),
|
|
30
|
+
anchorByThread: new Map(), taskNumberByTask: new Map(), taskCountByChannel: new Map(), attentionThreadsByMember: new Map(),
|
|
31
|
+
threadsByWriter: new Map(),
|
|
32
|
+
directMarkersByMember: new Map(), activityMarkersByMember: new Map(), observationsByThread: new Map() };
|
|
22
33
|
}
|
|
23
|
-
const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
|
|
24
|
-
progress: Object.freeze([]),
|
|
25
|
-
claim: Object.freeze([]),
|
|
26
|
-
});
|
|
27
34
|
/** Exhaustiveness guard for the closed operation union: adding a kind must update every dispatch. */
|
|
28
35
|
function assertUnhandledKind(operation) {
|
|
29
36
|
throw new Error(`agent-team ledger does not handle operation kind '${operation.kind}'`);
|
|
30
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Opening-line preview for a Human Inbox row: the Thread anchor's first line,
|
|
40
|
+
* trimmed, then capped at 120 characters with an explicit mark — the same bound
|
|
41
|
+
* the Thread page applies to its Task title, so the row and the page it opens
|
|
42
|
+
* never disagree about what a Thread is about.
|
|
43
|
+
*/
|
|
44
|
+
function boundedInboxPreview(body) {
|
|
45
|
+
const firstLine = body.split('\n', 1)[0]?.trim() ?? '';
|
|
46
|
+
return firstLine.length > 120 ? `${firstLine.slice(0, 119)}…` : firstLine;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* How many Threads one Workspace's 「最近活跃」 slice may carry. It bounds what
|
|
50
|
+
* the Host hands over, not what the reader sees: the Client merges every
|
|
51
|
+
* visible Workspace's slice and trims the merged list to its own visible bound
|
|
52
|
+
* (`RECENT_ROWS_LIMIT`), which must stay at or below this one so no Workspace
|
|
53
|
+
* is cut short before the merge. The unread slice keeps its own `limit`
|
|
54
|
+
* request field.
|
|
55
|
+
*/
|
|
56
|
+
const RECENT_INBOX_LIMIT = 10;
|
|
57
|
+
/**
|
|
58
|
+
* Whether one operation's Inbox delta would change nothing. A read that finds
|
|
59
|
+
* no unread fact for its reader and no marker to consume produces exactly this
|
|
60
|
+
* delta, which is why such a read is a no-op rather than a durable operation.
|
|
61
|
+
*/
|
|
62
|
+
function isEmptyInboxDelta(delta) {
|
|
63
|
+
return delta.attention.set.length === 0 && delta.attention.removed.length === 0
|
|
64
|
+
&& delta.directMarkers.added.length === 0 && delta.directMarkers.removed.length === 0
|
|
65
|
+
&& delta.activityMarkers.added.length === 0 && delta.activityMarkers.removed.length === 0;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Which of the two durable Thread-read forms one record holds. Both schemas are
|
|
69
|
+
* strict and structurally disjoint — only the pre-receipt snapshot carries the
|
|
70
|
+
* Thread picture — so `thread` decides it for every stored record.
|
|
71
|
+
*/
|
|
72
|
+
export function isThreadReadSnapshot(data) {
|
|
73
|
+
return 'thread' in data;
|
|
74
|
+
}
|
|
75
|
+
/** The Thread and optional Task one read targeted, whichever form the record holds. */
|
|
76
|
+
export function threadReadTargetOf(data) {
|
|
77
|
+
return isThreadReadSnapshot(data)
|
|
78
|
+
? { threadRef: data.thread.threadRef, ...(data.task === undefined ? {} : { taskRef: data.task.taskRef }) }
|
|
79
|
+
: { threadRef: data.threadRef, ...(data.taskRef === undefined ? {} : { taskRef: data.taskRef }) };
|
|
80
|
+
}
|
|
31
81
|
/** Deep-freeze a Member capability overlay; absent stays absent. */
|
|
32
82
|
function freezeCapabilities(capabilities) {
|
|
33
83
|
if (capabilities === undefined)
|
|
@@ -50,12 +100,35 @@ export class AgentTeamLedger {
|
|
|
50
100
|
createOccurredAt;
|
|
51
101
|
createRef;
|
|
52
102
|
operationTail = Promise.resolve();
|
|
103
|
+
/**
|
|
104
|
+
* Head of the durable records the constructor's record-level replay
|
|
105
|
+
* validated, adoptable once by the invariant mount. See `validateAtMount`.
|
|
106
|
+
*/
|
|
107
|
+
bootValidation;
|
|
108
|
+
/**
|
|
109
|
+
* Sequence of the newest committed record that invalidated the shared
|
|
110
|
+
* projection — the durable position the Client's change cursors compare
|
|
111
|
+
* against, rebuilt by replay like every other index.
|
|
112
|
+
*
|
|
113
|
+
* Records whose commit is private read progress or audit-only
|
|
114
|
+
* (`changeScopesOf` derives no scope for them) never move it, so one
|
|
115
|
+
* Member's Thread read cannot make a parked Client believe that something it
|
|
116
|
+
* watches changed. Being a ledger position rather than a process counter, it
|
|
117
|
+
* also stays monotone across a restart.
|
|
118
|
+
*/
|
|
119
|
+
projectionVersion = 0;
|
|
53
120
|
constructor(table, options = {}) {
|
|
54
121
|
this.table = table;
|
|
55
122
|
this.createOperationId = options.operationId ?? (() => `operation:${randomUUID()}`);
|
|
56
123
|
this.createOccurredAt = options.occurredAt ?? (() => new Date().toISOString());
|
|
57
124
|
this.createRef = options.ref ?? (kind => `${kind}:${randomUUID()}`);
|
|
58
125
|
this.replay();
|
|
126
|
+
const head = this.state.ordered.at(-1);
|
|
127
|
+
this.bootValidation = Object.freeze({
|
|
128
|
+
records: this.state.ordered.length,
|
|
129
|
+
lastSequence: head?.sequence ?? 0,
|
|
130
|
+
lastOperationId: head?.operationId ?? null,
|
|
131
|
+
});
|
|
59
132
|
}
|
|
60
133
|
initialize(request = {
|
|
61
134
|
requestId: AGENT_TEAM_INITIALIZE_REQUEST_ID,
|
|
@@ -379,80 +452,6 @@ export class AgentTeamLedger {
|
|
|
379
452
|
}
|
|
380
453
|
return active.sort((a, b) => a.claimRef.localeCompare(b.claimRef));
|
|
381
454
|
}
|
|
382
|
-
/**
|
|
383
|
-
* Current progress-nudge candidacy for one Member: threads whose Thread
|
|
384
|
-
* progress reminder applies (active Claim, or following a taskless Thread)
|
|
385
|
-
* and tasks where the Claim reminder applies (following a still-`todo` Task
|
|
386
|
-
* with no Claim history for this Member). A deep read-only projection — the
|
|
387
|
-
* caller never re-derives attention, claim, task-resolution, or channel
|
|
388
|
-
* archive state. Archived channels and resolved tasks produce no candidacy;
|
|
389
|
-
* results are ordered by Thread revision descending, then threadRef.
|
|
390
|
-
*/
|
|
391
|
-
progressNudgeTargets(memberId) {
|
|
392
|
-
const member = this.state.members.get(memberId);
|
|
393
|
-
if (member === undefined || member.state !== 'enabled')
|
|
394
|
-
return EMPTY_PROGRESS_NUDGE_TARGETS;
|
|
395
|
-
const progress = [];
|
|
396
|
-
const claim = [];
|
|
397
|
-
const seenProgress = new Set();
|
|
398
|
-
for (const claimRecord of this.state.claims.values()) {
|
|
399
|
-
if (claimRecord.owner !== memberId || claimRecord.state !== 'active')
|
|
400
|
-
continue;
|
|
401
|
-
const task = this.state.tasks.get(claimRecord.taskRef);
|
|
402
|
-
const thread = this.state.threads.get(claimRecord.threadRef);
|
|
403
|
-
if (task === undefined || thread === undefined || task.resolution !== 'open')
|
|
404
|
-
continue;
|
|
405
|
-
if (this.state.channels.get(task.channelRef)?.state === 'archived')
|
|
406
|
-
continue;
|
|
407
|
-
if (seenProgress.has(claimRecord.threadRef))
|
|
408
|
-
continue;
|
|
409
|
-
seenProgress.add(claimRecord.threadRef);
|
|
410
|
-
progress.push({ reason: 'active-claim', threadRef: claimRecord.threadRef, taskRef: claimRecord.taskRef });
|
|
411
|
-
}
|
|
412
|
-
for (const [threadRef, followers] of this.state.attentionByThread) {
|
|
413
|
-
if (!followers.has(memberId))
|
|
414
|
-
continue;
|
|
415
|
-
const thread = this.state.threads.get(threadRef);
|
|
416
|
-
if (thread === undefined)
|
|
417
|
-
continue;
|
|
418
|
-
const channelRef = this.state.channelRefByThread.get(threadRef);
|
|
419
|
-
if (channelRef === undefined || this.state.channels.get(channelRef)?.state === 'archived')
|
|
420
|
-
continue;
|
|
421
|
-
if (thread.taskRef === undefined) {
|
|
422
|
-
if (!seenProgress.has(threadRef)) {
|
|
423
|
-
seenProgress.add(threadRef);
|
|
424
|
-
progress.push({ reason: 'taskless-follower', threadRef });
|
|
425
|
-
}
|
|
426
|
-
continue;
|
|
427
|
-
}
|
|
428
|
-
// Claim reminder: only still-`todo` tasks with zero lifetime claims by
|
|
429
|
-
// this Member; once claimed (any state) the Member moved into the
|
|
430
|
-
// progress-reminder regime.
|
|
431
|
-
if (this.hasLifetimeClaimOnTask(memberId, thread.taskRef))
|
|
432
|
-
continue;
|
|
433
|
-
const task = this.state.tasks.get(thread.taskRef);
|
|
434
|
-
if (task === undefined || task.resolution !== 'open' || this.deriveTaskStatus(task.taskRef, this.state.claims.values()) !== 'todo')
|
|
435
|
-
continue;
|
|
436
|
-
claim.push({ threadRef, taskRef: thread.taskRef });
|
|
437
|
-
}
|
|
438
|
-
const byThreadRevision = (left, right) => {
|
|
439
|
-
const leftRevision = this.state.threads.get(left.threadRef)?.revision ?? 0;
|
|
440
|
-
const rightRevision = this.state.threads.get(right.threadRef)?.revision ?? 0;
|
|
441
|
-
return rightRevision - leftRevision || (left.threadRef < right.threadRef ? -1 : left.threadRef > right.threadRef ? 1 : 0);
|
|
442
|
-
};
|
|
443
|
-
return Object.freeze({
|
|
444
|
-
progress: Object.freeze(progress.sort(byThreadRevision)),
|
|
445
|
-
claim: Object.freeze(claim.sort(byThreadRevision)),
|
|
446
|
-
});
|
|
447
|
-
}
|
|
448
|
-
/** Whether this Member has ever held a Claim on the Task, in any state. */
|
|
449
|
-
hasLifetimeClaimOnTask(memberId, taskRef) {
|
|
450
|
-
for (const claim of this.state.claims.values()) {
|
|
451
|
-
if (claim.owner === memberId && claim.taskRef === taskRef)
|
|
452
|
-
return true;
|
|
453
|
-
}
|
|
454
|
-
return false;
|
|
455
|
-
}
|
|
456
455
|
listMembers() {
|
|
457
456
|
return Object.freeze([...this.state.members.values()]);
|
|
458
457
|
}
|
|
@@ -560,12 +559,6 @@ export class AgentTeamLedger {
|
|
|
560
559
|
}
|
|
561
560
|
sendMessage(request) {
|
|
562
561
|
return this.enqueue(async () => {
|
|
563
|
-
const recipients = this.normalizeRecipients(request.actor, request.recipients);
|
|
564
|
-
const existing = this.state.byRequest.get(request.requestId);
|
|
565
|
-
if (existing !== undefined) {
|
|
566
|
-
this.assertSameMessage(existing, request, recipients);
|
|
567
|
-
return this.resolved(this.messageResult(existing));
|
|
568
|
-
}
|
|
569
562
|
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
570
563
|
const channel = this.requireActiveChannel(request.workspaceId, request.channelRef);
|
|
571
564
|
if (actor.kind === 'member')
|
|
@@ -573,6 +566,15 @@ export class AgentTeamLedger {
|
|
|
573
566
|
const body = request.body.trim();
|
|
574
567
|
if (body === '')
|
|
575
568
|
throw new Error('message body must not be empty');
|
|
569
|
+
// Recipients are resolved before the request-id lookup: the stored
|
|
570
|
+
// operation carries the merged set, so a retry has to re-derive the same
|
|
571
|
+
// list for the collision check to prove it is the same request.
|
|
572
|
+
const recipients = this.mergeBodyMentions(actor.memberId, channel.channelRef, body, this.normalizeRecipients(request.actor, request.recipients));
|
|
573
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
574
|
+
if (existing !== undefined) {
|
|
575
|
+
this.assertSameMessage(existing, request, recipients);
|
|
576
|
+
return this.resolved(this.messageResult(existing));
|
|
577
|
+
}
|
|
576
578
|
this.assertMentionTargets(channel, recipients);
|
|
577
579
|
// Top-level Task creation is open to every actor: mentioned Members join
|
|
578
580
|
// the new Thread as followers. Only existing-Thread invitations stay
|
|
@@ -607,32 +609,38 @@ export class AgentTeamLedger {
|
|
|
607
609
|
}
|
|
608
610
|
reply(request) {
|
|
609
611
|
return this.enqueue(async () => {
|
|
610
|
-
const recipients = this.normalizeRecipients(request.actor, request.recipients);
|
|
611
|
-
const existing = this.state.byRequest.get(request.requestId);
|
|
612
|
-
if (existing !== undefined) {
|
|
613
|
-
this.assertSameReply(existing, request, recipients);
|
|
614
|
-
return this.resolved(this.replyResult(existing));
|
|
615
|
-
}
|
|
616
612
|
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
617
613
|
const { task, thread, channelRef } = this.threadContextForActor(actor, request.workspaceId, request);
|
|
618
614
|
const body = request.body.trim();
|
|
619
615
|
if (body === '')
|
|
620
616
|
throw new Error('message body must not be empty');
|
|
621
|
-
this.
|
|
617
|
+
const channel = this.requireChannel(request.workspaceId, channelRef);
|
|
618
|
+
const recipients = this.mergeBodyMentions(actor.memberId, channelRef, body, this.normalizeRecipients(actor, request.recipients));
|
|
619
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
620
|
+
if (existing !== undefined) {
|
|
621
|
+
this.assertSameReply(existing, request, recipients);
|
|
622
|
+
return this.resolved(this.replyResult(existing, this.undeliverableRecipients(actor, thread.threadRef, recipients)));
|
|
623
|
+
}
|
|
624
|
+
this.assertMentionTargets(channel, recipients);
|
|
622
625
|
const deferred = this.deferredThreadWrite(actor.memberId, task, thread, request.baseRevision);
|
|
623
626
|
if (deferred !== undefined)
|
|
624
627
|
return this.resolved(deferred);
|
|
625
628
|
if (task?.resolution === 'closed')
|
|
626
629
|
throw new Error(`Task '${task.taskRef}' is closed; reopen it before replying`);
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
630
|
+
// An Agent cannot invite into an existing Thread, so a body mention the
|
|
631
|
+
// Thread has never carried is dropped and reported back rather than
|
|
632
|
+
// failing the send. The Human keeps the confirmation step, which is what
|
|
633
|
+
// actually grants the invitation.
|
|
634
|
+
const undelivered = this.undeliverableRecipients(actor, thread.threadRef, recipients);
|
|
635
|
+
const delivered = undelivered.length === 0 ? recipients : recipients.filter(memberId => !undelivered.includes(memberId));
|
|
636
|
+
const unfollowedAgents = delivered.filter(memberId => this.state.members.has(memberId) && !this.isFollowing(thread.threadRef, memberId));
|
|
637
|
+
// A Member that once took part in this Thread is delivered to directly:
|
|
638
|
+
// re-joining a Thread it already belongs to is not an invitation.
|
|
639
|
+
if (unfollowedAgents.length > 0 && actor.kind === 'human' && request.confirmationToken === undefined) {
|
|
640
|
+
return this.resolved(this.issueConfirmation(request.actor, request.workspaceId, channelRef, body, delivered, task, thread));
|
|
633
641
|
}
|
|
634
642
|
if (request.confirmationToken !== undefined) {
|
|
635
|
-
this.consumeConfirmation(request.confirmationToken, request.actor, request.workspaceId, channelRef, task, thread, body,
|
|
643
|
+
this.consumeConfirmation(request.confirmationToken, request.actor, request.workspaceId, channelRef, task, thread, body, delivered);
|
|
636
644
|
}
|
|
637
645
|
const sequence = this.nextSequence();
|
|
638
646
|
const base = this.operationBase(request, sequence);
|
|
@@ -644,15 +652,15 @@ export class AgentTeamLedger {
|
|
|
644
652
|
});
|
|
645
653
|
const nextThread = Object.freeze({ ...thread, revision: sequence });
|
|
646
654
|
const started = unfollowedAgents.map(memberId => this.startAttention(memberId, thread.threadRef, sequence));
|
|
647
|
-
const inbox = this.messageInboxDelta(message, request.actor.memberId,
|
|
655
|
+
const inbox = this.messageInboxDelta(message, request.actor.memberId, delivered, started);
|
|
648
656
|
const operation = Object.freeze({
|
|
649
657
|
...base, kind: 'team/thread-replied',
|
|
650
658
|
data: Object.freeze({ workspaceId: request.workspaceId, baseRevision: request.baseRevision,
|
|
651
|
-
mentions:
|
|
659
|
+
mentions: delivered, message, ...(task === undefined ? {} : { task }), thread: nextThread, inbox }),
|
|
652
660
|
});
|
|
653
661
|
await this.table.put(operation.operationId, operation);
|
|
654
662
|
this.apply(operation);
|
|
655
|
-
return this.committed(this.replyResult(operation));
|
|
663
|
+
return this.committed(this.replyResult(operation, undelivered));
|
|
656
664
|
});
|
|
657
665
|
}
|
|
658
666
|
promoteThread(request) {
|
|
@@ -1025,8 +1033,24 @@ export class AgentTeamLedger {
|
|
|
1025
1033
|
const limit = request.limit ?? 50;
|
|
1026
1034
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
1027
1035
|
throw new Error('inbox limit must be an integer between 1 and 100');
|
|
1036
|
+
// One unread slice serves every reader: Threads the reader follows or
|
|
1037
|
+
// holds a marker on, admitted whenever they hold an unread fact — mentions
|
|
1038
|
+
// are counted inside it (`directCount`), never a separate admission rule.
|
|
1039
|
+
// Every row carries its rendering material — Channel display name, Task
|
|
1040
|
+
// ordinal, opening line, and the person the row's instant came from — so the
|
|
1041
|
+
// Client renders a row without a Channel or Member view per row. Carrying
|
|
1042
|
+
// the opening line on the agent-facing projection is
|
|
1043
|
+
// safe: model-visible notifications read the refs and the facts, never this.
|
|
1044
|
+
// A Human reader additionally receives the separate 「最近活跃」 slice below;
|
|
1045
|
+
// `items` stays exactly the unread queue for every reader, so the badge
|
|
1046
|
+
// totals and the agent-facing `team_inbox` result cannot drift with it.
|
|
1047
|
+
const taskNumbers = this.taskNumbers(request.workspaceId);
|
|
1028
1048
|
const items = [];
|
|
1029
|
-
|
|
1049
|
+
const unreadThreads = new Set();
|
|
1050
|
+
for (const threadRef of this.inboxCandidateThreads(authorized.memberId)) {
|
|
1051
|
+
const thread = this.state.threads.get(threadRef);
|
|
1052
|
+
if (thread === undefined)
|
|
1053
|
+
continue;
|
|
1030
1054
|
const channelRef = this.channelRefForThread(thread.threadRef);
|
|
1031
1055
|
if (channelRef === undefined)
|
|
1032
1056
|
continue;
|
|
@@ -1037,6 +1061,7 @@ export class AgentTeamLedger {
|
|
|
1037
1061
|
const unread = this.unreadFor(authorized.memberId, thread.threadRef);
|
|
1038
1062
|
if (unread.length === 0)
|
|
1039
1063
|
continue;
|
|
1064
|
+
unreadThreads.add(thread.threadRef);
|
|
1040
1065
|
const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
|
|
1041
1066
|
const directCount = unread.filter(item => item.direct).length;
|
|
1042
1067
|
const attention = this.attentionFor(authorized.memberId, thread.threadRef);
|
|
@@ -1044,15 +1069,117 @@ export class AgentTeamLedger {
|
|
|
1044
1069
|
// the newest unread fact itself, never a second lookup that could
|
|
1045
1070
|
// observe a different commit between the two reads.
|
|
1046
1071
|
const newest = unread.at(-1).fact;
|
|
1047
|
-
|
|
1048
|
-
|
|
1072
|
+
const taskNumber = task === undefined ? undefined : taskNumbers.get(task.taskRef);
|
|
1073
|
+
items.push(Object.freeze({ channelRef,
|
|
1074
|
+
channelName: this.state.channels.get(channelRef)?.name ?? '',
|
|
1075
|
+
...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
|
|
1076
|
+
unreadCount: unread.length, directCount,
|
|
1077
|
+
previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body),
|
|
1078
|
+
newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt,
|
|
1079
|
+
newestActor: this.inboxActorFor(newest), claimOwners: this.liveClaimOwners(task),
|
|
1080
|
+
...(attention === undefined ? {} : { attention }) }));
|
|
1049
1081
|
}
|
|
1050
1082
|
items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
|
|
1051
1083
|
const selected = items.slice(0, limit);
|
|
1052
|
-
|
|
1084
|
+
const recent = authorized.kind === 'human'
|
|
1085
|
+
? this.recentInboxItems(authorized.memberId, request.workspaceId, unreadThreads, taskNumbers)
|
|
1086
|
+
: Object.freeze([]);
|
|
1087
|
+
return Object.freeze({ items: Object.freeze(selected), recent,
|
|
1053
1088
|
totalUnreadCount: items.reduce((sum, item) => sum + item.unreadCount, 0),
|
|
1054
1089
|
totalDirectCount: items.reduce((sum, item) => sum + item.directCount, 0) });
|
|
1055
1090
|
}
|
|
1091
|
+
/**
|
|
1092
|
+
* The one person a row names: whoever committed the fact that row's instant
|
|
1093
|
+
* came from — a Message's sender, an activity's actor — resolved to the handle
|
|
1094
|
+
* the row draws. Resolving it here is what spares every Client row a Member
|
|
1095
|
+
* view of its own, and a Member the roster no longer names still reads as
|
|
1096
|
+
* themselves through their raw id rather than as nobody.
|
|
1097
|
+
*/
|
|
1098
|
+
inboxActorFor(fact) {
|
|
1099
|
+
return this.memberActor(fact.kind === 'message' ? fact.message.sender : fact.activity.actor);
|
|
1100
|
+
}
|
|
1101
|
+
/** One Member as a row draws them: the id carries the identity hue, the handle the initial. */
|
|
1102
|
+
memberActor(memberId) {
|
|
1103
|
+
return Object.freeze({ memberId, name: this.state.members.get(memberId)?.handle ?? memberId });
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* The people a Task still has on it, resolved the way a row draws them: owners
|
|
1107
|
+
* of its live Claims, in claim order, deduped. A released Claim is not work,
|
|
1108
|
+
* and a done or closed Task keeps its Claims as history rather than as
|
|
1109
|
+
* presence — deliberately the same rule the Channel feed applies to the same
|
|
1110
|
+
* Task, so 「谁在这个 Task 上」 never acquires a second definition.
|
|
1111
|
+
*/
|
|
1112
|
+
liveClaimOwners(task) {
|
|
1113
|
+
if (task === undefined || (task.status !== 'in_progress' && task.status !== 'in_review'))
|
|
1114
|
+
return Object.freeze([]);
|
|
1115
|
+
const owners = [];
|
|
1116
|
+
for (const claim of this.claimsForTask(task.taskRef)) {
|
|
1117
|
+
if (claim.state === 'released' || owners.includes(claim.owner))
|
|
1118
|
+
continue;
|
|
1119
|
+
owners.push(claim.owner);
|
|
1120
|
+
}
|
|
1121
|
+
return Object.freeze(owners.map(owner => this.memberActor(owner)));
|
|
1122
|
+
}
|
|
1123
|
+
/**
|
|
1124
|
+
* The Human Inbox's 「最近活跃」 slice: the Threads this reader has written in
|
|
1125
|
+
* — the durable way back into work instead of a mention-only queue.
|
|
1126
|
+
* Participation is the whole admission rule: a reader who replied to somebody
|
|
1127
|
+
* else's Thread is here whether or not they follow it, and so is one who
|
|
1128
|
+
* started a Thread nobody has answered yet, because starting one is writing
|
|
1129
|
+
* its anchor. Attention deliberately plays no part — it decides what notifies
|
|
1130
|
+
* a reader (a reply does not implicitly follow a Thread), and reading it here
|
|
1131
|
+
* as well is what once hid every Thread a reader had replied to without
|
|
1132
|
+
* following. A Thread the reader later unfollowed stays: they did take part
|
|
1133
|
+
* in it, and unfollowing stops the notifications, not the record. Every
|
|
1134
|
+
* Thread still holding unread is excluded because the queue above already
|
|
1135
|
+
* carries it, and the slice is newest-activity first, bounded by
|
|
1136
|
+
* `RECENT_INBOX_LIMIT`. Rows are the same shape as unread rows with every
|
|
1137
|
+
* count at zero — they are the same row to render.
|
|
1138
|
+
*/
|
|
1139
|
+
recentInboxItems(memberId, workspaceId, unreadThreads, taskNumbers) {
|
|
1140
|
+
const recent = [];
|
|
1141
|
+
for (const threadRef of this.state.threadsByWriter.get(memberId) ?? []) {
|
|
1142
|
+
if (unreadThreads.has(threadRef))
|
|
1143
|
+
continue;
|
|
1144
|
+
const thread = this.state.threads.get(threadRef);
|
|
1145
|
+
if (thread === undefined)
|
|
1146
|
+
continue;
|
|
1147
|
+
const channelRef = this.channelRefForThread(thread.threadRef);
|
|
1148
|
+
if (channelRef === undefined)
|
|
1149
|
+
continue;
|
|
1150
|
+
if (this.state.channels.get(channelRef)?.workspaceId !== workspaceId)
|
|
1151
|
+
continue;
|
|
1152
|
+
const facts = this.state.factsByThread.get(thread.threadRef) ?? [];
|
|
1153
|
+
const newest = facts.at(-1);
|
|
1154
|
+
if (newest === undefined)
|
|
1155
|
+
continue;
|
|
1156
|
+
const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
|
|
1157
|
+
const taskNumber = task === undefined ? undefined : taskNumbers.get(task.taskRef);
|
|
1158
|
+
recent.push(Object.freeze({ channelRef,
|
|
1159
|
+
channelName: this.state.channels.get(channelRef)?.name ?? '',
|
|
1160
|
+
...(task === undefined ? {} : { task }), ...(taskNumber === undefined ? {} : { taskNumber }), thread,
|
|
1161
|
+
unreadCount: 0, directCount: 0,
|
|
1162
|
+
previewText: boundedInboxPreview(this.threadAnchor(thread.threadRef).body),
|
|
1163
|
+
newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt,
|
|
1164
|
+
newestActor: this.inboxActorFor(newest), claimOwners: this.liveClaimOwners(task) }));
|
|
1165
|
+
}
|
|
1166
|
+
recent.sort((left, right) => right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
|
|
1167
|
+
return Object.freeze(recent.slice(0, RECENT_INBOX_LIMIT));
|
|
1168
|
+
}
|
|
1169
|
+
/**
|
|
1170
|
+
* Threads that can hold unread facts for one reader, from the per-reader
|
|
1171
|
+
* derived indexes: Attention follows plus direct and activity markers.
|
|
1172
|
+
* Every unread source is covered — ordinary unread requires Attention, and
|
|
1173
|
+
* marker unread requires a marker — so no full Thread scan is needed.
|
|
1174
|
+
*/
|
|
1175
|
+
inboxCandidateThreads(memberId) {
|
|
1176
|
+
const refs = new Set();
|
|
1177
|
+
for (const source of [this.state.attentionThreadsByMember.get(memberId), this.state.directMarkersByMember.get(memberId), this.state.activityMarkersByMember.get(memberId)]) {
|
|
1178
|
+
for (const threadRef of source?.keys() ?? [])
|
|
1179
|
+
refs.add(threadRef);
|
|
1180
|
+
}
|
|
1181
|
+
return refs;
|
|
1182
|
+
}
|
|
1056
1183
|
/** Model-visible notification material derived from the recipient's current durable unread state. */
|
|
1057
1184
|
notificationFacts(memberId, request) {
|
|
1058
1185
|
const member = this.requireMember(memberId);
|
|
@@ -1067,22 +1194,31 @@ export class AgentTeamLedger {
|
|
|
1067
1194
|
const existing = this.state.byRequest.get(request.requestId);
|
|
1068
1195
|
if (existing !== undefined) {
|
|
1069
1196
|
this.assertSameThreadRead(existing, request);
|
|
1070
|
-
|
|
1197
|
+
// A retry reports the original receipt with the picture the current
|
|
1198
|
+
// projection derives, and never advances a watermark a second time.
|
|
1199
|
+
const current = this.prepareRead(existing.data.memberId, existing.data.workspaceId, request);
|
|
1200
|
+
return Object.freeze({ value: Object.freeze({ receipt: this.receipt(existing), ...this.readPicture(current) }), committed: false });
|
|
1071
1201
|
}
|
|
1072
1202
|
const actor = this.assertActorForWorkspace(request.actor, request.workspaceId);
|
|
1073
1203
|
const prepared = this.prepareRead(actor.memberId, request.workspaceId, request);
|
|
1204
|
+
// A read whose Inbox delta is empty writes nothing: the watermark does
|
|
1205
|
+
// not advance and no marker is consumed. It answers with the same picture
|
|
1206
|
+
// and no receipt, so the caller learns "no operation" from the result's
|
|
1207
|
+
// committed flag, never from a sequence this read did not write.
|
|
1208
|
+
if (isEmptyInboxDelta(prepared.inbox))
|
|
1209
|
+
return Object.freeze({ value: this.readPicture(prepared), committed: false });
|
|
1210
|
+
// The record carries progress, not the picture: the Thread, its facts,
|
|
1211
|
+
// the anchor and the reader's Attention are projection state every replay
|
|
1212
|
+
// already holds.
|
|
1213
|
+
const data = Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId,
|
|
1214
|
+
threadRef: prepared.thread.threadRef, ...(prepared.task === undefined ? {} : { taskRef: prepared.task.taskRef }),
|
|
1215
|
+
readThroughSequence: prepared.readThroughSequence, inbox: prepared.inbox });
|
|
1074
1216
|
const operation = Object.freeze({
|
|
1075
|
-
...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read',
|
|
1076
|
-
data: Object.freeze({ workspaceId: request.workspaceId, memberId: actor.memberId,
|
|
1077
|
-
...(prepared.task === undefined ? {} : { task: prepared.task }),
|
|
1078
|
-
thread: prepared.thread, claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions,
|
|
1079
|
-
facts: prepared.facts,
|
|
1080
|
-
readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
|
|
1081
|
-
...(prepared.attention === undefined ? {} : { attention: prepared.attention }), inbox: prepared.inbox }),
|
|
1217
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/thread-read', data,
|
|
1082
1218
|
});
|
|
1083
1219
|
await this.table.put(operation.operationId, operation);
|
|
1084
1220
|
this.apply(operation);
|
|
1085
|
-
return
|
|
1221
|
+
return Object.freeze({ value: Object.freeze({ receipt: this.receipt(operation), ...this.readPicture(prepared) }), committed: true });
|
|
1086
1222
|
});
|
|
1087
1223
|
}
|
|
1088
1224
|
threadObservations(actor, request) {
|
|
@@ -1092,33 +1228,13 @@ export class AgentTeamLedger {
|
|
|
1092
1228
|
if (!Number.isInteger(limit) || limit < 1 || limit > 100)
|
|
1093
1229
|
throw new Error('observation limit must be an integer between 1 and 100');
|
|
1094
1230
|
const threadRef = thread.threadRef;
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
if (removed.threadRef !== threadRef)
|
|
1103
|
-
continue;
|
|
1104
|
-
if (!current.delete(removed.memberId))
|
|
1105
|
-
continue;
|
|
1106
|
-
observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
|
|
1107
|
-
memberId: removed.memberId, action: 'unfollow' }));
|
|
1108
|
-
}
|
|
1109
|
-
for (const next of delta.attention.set) {
|
|
1110
|
-
if (next.threadRef !== threadRef)
|
|
1111
|
-
continue;
|
|
1112
|
-
const prior = current.get(next.memberId);
|
|
1113
|
-
current.set(next.memberId, next);
|
|
1114
|
-
// Task creation establishes initial Attention; reads only advance its watermark.
|
|
1115
|
-
if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
|
|
1116
|
-
continue;
|
|
1117
|
-
observations.push(Object.freeze({ sequence: operation.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
|
|
1118
|
-
memberId: next.memberId, action: 'follow' }));
|
|
1119
|
-
}
|
|
1120
|
-
}
|
|
1121
|
-
return Object.freeze({ items: Object.freeze(observations.slice(-limit)), followers: Object.freeze([...current.keys()]) });
|
|
1231
|
+
// Both slices are replay-derived: the observation log is appended by
|
|
1232
|
+
// committed Inbox deltas in ledger order, and the live follower set is
|
|
1233
|
+
// the same Attention index the follow history converges to.
|
|
1234
|
+
const observations = (this.state.observationsByThread.get(threadRef) ?? []).slice(-limit)
|
|
1235
|
+
.map(event => Object.freeze({ sequence: event.sequence, threadRef, ...(task === undefined ? {} : { taskRef: task.taskRef }),
|
|
1236
|
+
memberId: event.memberId, action: event.action }));
|
|
1237
|
+
return Object.freeze({ items: Object.freeze(observations), followers: Object.freeze([...this.state.attentionByThread.get(threadRef) ?? []]) });
|
|
1122
1238
|
}
|
|
1123
1239
|
/** Every operation carrying an inbox delta drives the Inbox projection; the payload shape decides, not a per-kind list. */
|
|
1124
1240
|
attentionDelta(operation) {
|
|
@@ -1216,7 +1332,8 @@ export class AgentTeamLedger {
|
|
|
1216
1332
|
&& channel.state !== 'archived'
|
|
1217
1333
|
&& (memberId === undefined || this.isChannelMember(channel.channelRef, memberId)));
|
|
1218
1334
|
const channelRefs = new Set(channels.map(channel => channel.channelRef));
|
|
1219
|
-
const
|
|
1335
|
+
const before = request.cursor ?? this.state.ordered.length + 1;
|
|
1336
|
+
const matches = (fact) => {
|
|
1220
1337
|
const threadRef = fact.kind === 'message' ? fact.message.threadRef : fact.activity.threadRef;
|
|
1221
1338
|
const channelRef = this.channelRefForThread(threadRef);
|
|
1222
1339
|
if (channelRef === undefined || !channelRefs.has(channelRef))
|
|
@@ -1229,9 +1346,40 @@ export class AgentTeamLedger {
|
|
|
1229
1346
|
return false;
|
|
1230
1347
|
if (request.includeActivities === false && fact.kind === 'activity')
|
|
1231
1348
|
return false;
|
|
1232
|
-
return direction === 'before' ? fact.sequence <
|
|
1233
|
-
}
|
|
1234
|
-
|
|
1349
|
+
return direction === 'before' ? fact.sequence < before : fact.sequence > cursor;
|
|
1350
|
+
};
|
|
1351
|
+
// Walk from the end the direction starts at and stop once `limit` matches
|
|
1352
|
+
// are collected plus one more proves `hasMore` — `limit` bounds only the
|
|
1353
|
+
// returned items, never the scan below a single extra match. A sidebar
|
|
1354
|
+
// asking for one item no longer sweeps the whole fact ledger.
|
|
1355
|
+
const selected = [];
|
|
1356
|
+
let hasMore = false;
|
|
1357
|
+
if (direction === 'before') {
|
|
1358
|
+
for (let index = this.state.orderedFacts.length - 1; index >= 0; index -= 1) {
|
|
1359
|
+
const fact = this.state.orderedFacts[index];
|
|
1360
|
+
if (!matches(fact))
|
|
1361
|
+
continue;
|
|
1362
|
+
if (selected.length < limit)
|
|
1363
|
+
selected.push(fact);
|
|
1364
|
+
else {
|
|
1365
|
+
hasMore = true;
|
|
1366
|
+
break;
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
selected.reverse();
|
|
1370
|
+
}
|
|
1371
|
+
else {
|
|
1372
|
+
for (const fact of this.state.orderedFacts) {
|
|
1373
|
+
if (!matches(fact))
|
|
1374
|
+
continue;
|
|
1375
|
+
if (selected.length < limit)
|
|
1376
|
+
selected.push(fact);
|
|
1377
|
+
else {
|
|
1378
|
+
hasMore = true;
|
|
1379
|
+
break;
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1235
1383
|
const visibleTasks = [...this.state.tasks.values()].filter(task => channelRefs.has(task.channelRef)
|
|
1236
1384
|
&& (request.channelRef === undefined || task.channelRef === request.channelRef)
|
|
1237
1385
|
&& (request.threadRef === undefined || task.threadRef === request.threadRef));
|
|
@@ -1266,7 +1414,7 @@ export class AgentTeamLedger {
|
|
|
1266
1414
|
claims: this.claimsForVisibleTasks(visibleTasks),
|
|
1267
1415
|
activities: Object.freeze(selected.filter((fact) => fact.kind === 'activity').map(fact => fact.activity)),
|
|
1268
1416
|
cursor: nextCursor,
|
|
1269
|
-
hasMore
|
|
1417
|
+
hasMore,
|
|
1270
1418
|
});
|
|
1271
1419
|
}
|
|
1272
1420
|
status() {
|
|
@@ -1277,6 +1425,31 @@ export class AgentTeamLedger {
|
|
|
1277
1425
|
validate() {
|
|
1278
1426
|
this.validateRecords(this.sortedRecords());
|
|
1279
1427
|
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Validate the durable ledger for the invariant's mount check, reusing the
|
|
1430
|
+
* constructor's record-level replay once instead of paying a second identical
|
|
1431
|
+
* one at startup.
|
|
1432
|
+
*
|
|
1433
|
+
* The adoption is gated on the operations table's single-writer identity —
|
|
1434
|
+
* this ledger's commit path is the only caller of `table.put` — so an
|
|
1435
|
+
* unchanged record count, head sequence and head operation id mean a
|
|
1436
|
+
* mount-time replay would re-derive precisely the records the constructor
|
|
1437
|
+
* already re-derived against its own scratch projection. Any commit between
|
|
1438
|
+
* construction and mount, and every later call, falls back to the full
|
|
1439
|
+
* replay. The check is never narrowed: the reused conclusion is still that
|
|
1440
|
+
* same independent replay of every durable record.
|
|
1441
|
+
*/
|
|
1442
|
+
validateAtMount() {
|
|
1443
|
+
const boot = this.bootValidation;
|
|
1444
|
+
this.bootValidation = undefined;
|
|
1445
|
+
const head = this.state.ordered.at(-1);
|
|
1446
|
+
if (boot !== undefined
|
|
1447
|
+
&& boot.records === this.state.ordered.length
|
|
1448
|
+
&& boot.lastSequence === (head?.sequence ?? 0)
|
|
1449
|
+
&& boot.lastOperationId === (head?.operationId ?? null))
|
|
1450
|
+
return;
|
|
1451
|
+
this.validate();
|
|
1452
|
+
}
|
|
1280
1453
|
hasCommitted(requestId) {
|
|
1281
1454
|
return this.state.byRequest.has(requestId);
|
|
1282
1455
|
}
|
|
@@ -1284,6 +1457,33 @@ export class AgentTeamLedger {
|
|
|
1284
1457
|
getOperation(operationId) {
|
|
1285
1458
|
return this.state.byOperation.get(operationId);
|
|
1286
1459
|
}
|
|
1460
|
+
/**
|
|
1461
|
+
* Claim releases are thread-visible facts: an open Channel page and every
|
|
1462
|
+
* affected Thread page must refetch alongside the workspace-wide change. Each
|
|
1463
|
+
* released activity contributes its own Thread scope plus the Channel that
|
|
1464
|
+
* owns its Task, deduplicated against the caller's initial scopes — which is
|
|
1465
|
+
* why the caller passes them in rather than the helper inventing them.
|
|
1466
|
+
*/
|
|
1467
|
+
withReleasedActivityScopes(scopes, tasks, activities) {
|
|
1468
|
+
const channelByTask = new Map(tasks.map(task => [task.taskRef, task.channelRef]));
|
|
1469
|
+
for (const activity of activities) {
|
|
1470
|
+
scopes.push({ kind: 'thread', threadRef: activity.threadRef });
|
|
1471
|
+
const channelRef = channelByTask.get(activity.taskRef);
|
|
1472
|
+
if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
|
|
1473
|
+
scopes.push({ kind: 'channel', channelRef });
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
return scopes;
|
|
1477
|
+
}
|
|
1478
|
+
/**
|
|
1479
|
+
* Durable position of the newest shared-projection commit: the version every
|
|
1480
|
+
* change waiter outside the presence scope observes. It only moves when a
|
|
1481
|
+
* commit has scopes somebody could refetch (`changeScopesOf`), never for
|
|
1482
|
+
* private read progress or an audit-only record.
|
|
1483
|
+
*/
|
|
1484
|
+
projectionSequence() {
|
|
1485
|
+
return this.projectionVersion;
|
|
1486
|
+
}
|
|
1287
1487
|
/** Scopes whose projections one committed operation invalidates; undefined wakes every waiter. */
|
|
1288
1488
|
changeScopesOf(operation) {
|
|
1289
1489
|
switch (operation.kind) {
|
|
@@ -1304,37 +1504,15 @@ export class AgentTeamLedger {
|
|
|
1304
1504
|
case 'team/member-updated':
|
|
1305
1505
|
case 'team/member-removed':
|
|
1306
1506
|
return [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
|
|
1307
|
-
case 'team/member-archived':
|
|
1308
|
-
|
|
1309
|
-
// pages refetch alongside the workspace-wide roster change.
|
|
1310
|
-
const channelByTask = new Map(operation.data.tasks.map(task => [task.taskRef, task.channelRef]));
|
|
1311
|
-
const scopes = [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
|
|
1312
|
-
for (const activity of operation.data.activities) {
|
|
1313
|
-
scopes.push({ kind: 'thread', threadRef: activity.threadRef });
|
|
1314
|
-
const channelRef = channelByTask.get(activity.taskRef);
|
|
1315
|
-
if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
|
|
1316
|
-
scopes.push({ kind: 'channel', channelRef });
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
return scopes;
|
|
1320
|
-
}
|
|
1507
|
+
case 'team/member-archived':
|
|
1508
|
+
return this.withReleasedActivityScopes([{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }], operation.data.tasks, operation.data.activities);
|
|
1321
1509
|
case 'team/channel-member-added':
|
|
1322
1510
|
return [{ kind: 'workspace', workspaceId: operation.data.workspaceId }, { kind: 'channel', channelRef: operation.data.channelRef }];
|
|
1323
|
-
case 'team/channel-member-removed':
|
|
1324
|
-
|
|
1325
|
-
const scopes = [
|
|
1511
|
+
case 'team/channel-member-removed':
|
|
1512
|
+
return this.withReleasedActivityScopes([
|
|
1326
1513
|
{ kind: 'workspace', workspaceId: operation.data.workspaceId },
|
|
1327
1514
|
{ kind: 'channel', channelRef: operation.data.channelRef },
|
|
1328
|
-
];
|
|
1329
|
-
for (const activity of operation.data.activities) {
|
|
1330
|
-
scopes.push({ kind: 'thread', threadRef: activity.threadRef });
|
|
1331
|
-
const channelRef = channelByTask.get(activity.taskRef);
|
|
1332
|
-
if (channelRef !== undefined && !scopes.some(scope => scope.kind === 'channel' && scope.channelRef === channelRef)) {
|
|
1333
|
-
scopes.push({ kind: 'channel', channelRef });
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
return scopes;
|
|
1337
|
-
}
|
|
1515
|
+
], operation.data.tasks, operation.data.activities);
|
|
1338
1516
|
case 'team/channel-archived': {
|
|
1339
1517
|
const scopes = [
|
|
1340
1518
|
{ kind: 'workspace', workspaceId: operation.data.workspaceId },
|
|
@@ -1632,7 +1810,7 @@ export class AgentTeamLedger {
|
|
|
1632
1810
|
|| operation.data.channel.createdAtSequence !== prior.createdAtSequence)
|
|
1633
1811
|
throw new Error('invalid Channel archival');
|
|
1634
1812
|
const threadRefs = new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === prior.channelRef));
|
|
1635
|
-
this.
|
|
1813
|
+
this.validateReleaseCleanup(operation.data, projection, undefined, threadRefs, operation.sequence, refs);
|
|
1636
1814
|
return;
|
|
1637
1815
|
}
|
|
1638
1816
|
if (operation.kind === 'team/member-removed') {
|
|
@@ -1683,20 +1861,41 @@ export class AgentTeamLedger {
|
|
|
1683
1861
|
assertMember();
|
|
1684
1862
|
else
|
|
1685
1863
|
assertHuman();
|
|
1686
|
-
|
|
1864
|
+
const data = operation.data;
|
|
1865
|
+
if (data.memberId !== operation.actor.memberId)
|
|
1687
1866
|
throw new Error('Thread read has wrong actor');
|
|
1688
|
-
const
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1867
|
+
const target = threadReadTargetOf(data);
|
|
1868
|
+
if (isThreadReadSnapshot(data)) {
|
|
1869
|
+
// The pre-receipt form froze the whole picture, so its expected value
|
|
1870
|
+
// is re-derived the same way it was written: from this record's prior
|
|
1871
|
+
// projection only, never from the live one. That shape stays frozen —
|
|
1872
|
+
// a field added after it, like the orientation count, is not compared
|
|
1873
|
+
// here, because no stored legacy record carries it and inventing one
|
|
1874
|
+
// would reject every upgrade of a ledger written before the field.
|
|
1875
|
+
const expected = this.prepareReadFrom(projection, data.memberId, data.workspaceId, target);
|
|
1876
|
+
const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId,
|
|
1877
|
+
...(expected.task === undefined ? {} : { task: expected.task }), thread: expected.thread, claims: expected.claims, anchor: expected.anchor,
|
|
1878
|
+
anchorMentions: expected.anchorMentions, facts: expected.facts,
|
|
1879
|
+
readThroughSequence: expected.readThroughSequence, remainingUnreadCount: expected.remainingUnreadCount,
|
|
1880
|
+
...(expected.attention === undefined ? {} : { attention: expected.attention }), inbox: expected.inbox });
|
|
1881
|
+
if (!isDeepStrictEqual(data, expectedData))
|
|
1882
|
+
throw new Error('invalid Thread read projection');
|
|
1883
|
+
}
|
|
1884
|
+
else {
|
|
1885
|
+
// A receipt claims progress and its Inbox delta, nothing else, so the
|
|
1886
|
+
// independent derivation stops there too: the picture and the unread
|
|
1887
|
+
// count left behind are projection state a replay re-derives for the
|
|
1888
|
+
// surfaces that need them, not content of this record. The Task ref is
|
|
1889
|
+
// derived rather than copied back, so a Task Thread read cannot drop
|
|
1890
|
+
// the Task it belongs to and shrink its own claim unchecked.
|
|
1891
|
+
const expected = this.prepareReadReceiptFrom(projection, data.memberId, data.workspaceId, target);
|
|
1892
|
+
const expectedData = Object.freeze({ workspaceId: data.workspaceId, memberId: data.memberId, threadRef: data.threadRef,
|
|
1893
|
+
...(expected.task === undefined ? {} : { taskRef: expected.task.taskRef }),
|
|
1894
|
+
readThroughSequence: expected.readThroughSequence, inbox: expected.inbox });
|
|
1895
|
+
if (!isDeepStrictEqual(data, expectedData))
|
|
1896
|
+
throw new Error('invalid Thread read receipt');
|
|
1897
|
+
}
|
|
1898
|
+
this.validateInboxDelta(data.inbox, projection, refs);
|
|
1700
1899
|
return;
|
|
1701
1900
|
}
|
|
1702
1901
|
if (operation.kind === 'team/thread-promoted') {
|
|
@@ -1874,11 +2073,23 @@ export class AgentTeamLedger {
|
|
|
1874
2073
|
}
|
|
1875
2074
|
assertUnhandledKind(operation);
|
|
1876
2075
|
}
|
|
2076
|
+
/**
|
|
2077
|
+
* Validation of one operation's Inbox delta against the projection it was
|
|
2078
|
+
* derived from.
|
|
2079
|
+
*
|
|
2080
|
+
* Direct marker references resolve through the replay-derived Message index:
|
|
2081
|
+
* this runs once per inbox-carrying record, so a rescan of the Message list
|
|
2082
|
+
* here would make every record cost the whole ledger. The index is written by
|
|
2083
|
+
* the same fact appends that build `factsByThread`, so a lookup sees exactly
|
|
2084
|
+
* the records replayed before this one and can never reach a Message that
|
|
2085
|
+
* arrives later — which is why the operation's own entity, not yet in the
|
|
2086
|
+
* projection, needs the `additional*` fallbacks. The commit path resolved the
|
|
2087
|
+
* projection first and then its own record, and that precedence is kept.
|
|
2088
|
+
*/
|
|
1877
2089
|
validateInboxDelta(delta, projection, _refs, additionalThreadRefs = [], additionalMessages = [], additionalActivities = []) {
|
|
1878
2090
|
const attentionKeys = new Set();
|
|
1879
|
-
const knownThreadRefs = new Set([...projection.threads.keys(), ...additionalThreadRefs]);
|
|
1880
2091
|
for (const attention of delta.attention.set) {
|
|
1881
|
-
if (!
|
|
2092
|
+
if ((!projection.threads.has(attention.threadRef) && !additionalThreadRefs.includes(attention.threadRef)) || attention.startSequence < 1
|
|
1882
2093
|
|| attention.readThroughSequence < attention.startSequence - 1) {
|
|
1883
2094
|
throw new Error('invalid Attention delta');
|
|
1884
2095
|
}
|
|
@@ -1892,13 +2103,12 @@ export class AgentTeamLedger {
|
|
|
1892
2103
|
throw new Error('conflicting Attention delta');
|
|
1893
2104
|
}
|
|
1894
2105
|
const markerKeys = new Set();
|
|
1895
|
-
const messages = [...projection.messages, ...additionalMessages];
|
|
1896
2106
|
for (const marker of delta.directMarkers.added) {
|
|
1897
2107
|
const key = this.directMarkerKey(marker);
|
|
1898
2108
|
if (markerKeys.has(key) || projection.directMarkers.has(key))
|
|
1899
2109
|
throw new Error('invalid direct marker addition');
|
|
1900
2110
|
markerKeys.add(key);
|
|
1901
|
-
const message =
|
|
2111
|
+
const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
|
|
1902
2112
|
if (message === undefined || message.threadRef !== marker.threadRef || message.sequence !== marker.sequence
|
|
1903
2113
|
|| !this.validMentionTarget(projection, message.channelRef, marker.memberId))
|
|
1904
2114
|
throw new Error('invalid direct marker addition');
|
|
@@ -1909,17 +2119,20 @@ export class AgentTeamLedger {
|
|
|
1909
2119
|
throw new Error('invalid direct marker removal');
|
|
1910
2120
|
markerKeys.add(key);
|
|
1911
2121
|
const current = projection.directMarkers.get(key);
|
|
1912
|
-
const message =
|
|
2122
|
+
const message = projection.messagesByRef.get(marker.messageRef) ?? additionalMessages.find(candidate => candidate.messageRef === marker.messageRef);
|
|
1913
2123
|
if (!isDeepStrictEqual(current, marker) || message === undefined || message.threadRef !== marker.threadRef
|
|
1914
2124
|
|| message.sequence !== marker.sequence || !this.validMentionTarget(projection, message.channelRef, marker.memberId)) {
|
|
1915
2125
|
throw new Error('invalid direct marker removal');
|
|
1916
2126
|
}
|
|
1917
2127
|
}
|
|
1918
2128
|
const activityMarkerKeys = new Set();
|
|
1919
|
-
const activities = [...projection.orderedFacts.filter(fact => fact.kind === 'activity').map(fact => fact.activity), ...additionalActivities];
|
|
1920
2129
|
for (const marker of delta.activityMarkers.added) {
|
|
1921
2130
|
const key = this.activityMarkerKey(marker);
|
|
1922
|
-
|
|
2131
|
+
// Every activity marker is minted for the activity the same record
|
|
2132
|
+
// carries (promotion, close, reopen, acceptance), and a removal never
|
|
2133
|
+
// looks an activity up, so this record's own list is the whole
|
|
2134
|
+
// resolution space: scanning recorded activities would add nothing.
|
|
2135
|
+
const activity = additionalActivities.find(candidate => candidate.activityRef === marker.activityRef);
|
|
1923
2136
|
if (activityMarkerKeys.has(key) || projection.activityMarkers.has(key) || activity === undefined
|
|
1924
2137
|
|| activity.threadRef !== marker.threadRef || activity.sequence !== marker.sequence)
|
|
1925
2138
|
throw new Error('invalid activity marker addition');
|
|
@@ -1933,73 +2146,31 @@ export class AgentTeamLedger {
|
|
|
1933
2146
|
activityMarkerKeys.add(key);
|
|
1934
2147
|
}
|
|
1935
2148
|
}
|
|
2149
|
+
/**
|
|
2150
|
+
* Replay validation of one departure's release snapshot against the
|
|
2151
|
+
* projection it was derived from. `memberId` scopes everything to a single
|
|
2152
|
+
* departing Member; `undefined` validates every owner, which is what
|
|
2153
|
+
* archiving a whole Channel releases. The two scopes differ in exactly four
|
|
2154
|
+
* ways, all of them visible here rather than spread over two copies:
|
|
2155
|
+
*
|
|
2156
|
+
* 1. released Claims are owner-filtered for a Member, unfiltered for a Channel;
|
|
2157
|
+
* 2. Activities group per (owner, Thread) with owners sorted, which for a
|
|
2158
|
+
* single departing Member collapses to one Activity per Thread — the shape
|
|
2159
|
+
* the commit path writes;
|
|
2160
|
+
* 3. the inbox cleanup covers the departing Member's Attention and markers, or
|
|
2161
|
+
* every Member's on the archived Channel's Threads;
|
|
2162
|
+
* 4. the failure message names which of the two paths lost its cleanup.
|
|
2163
|
+
*/
|
|
1936
2164
|
validateReleaseCleanup(data, projection, memberId, threadRefs, sequence, refs) {
|
|
1937
2165
|
const releasedClaims = [...projection.claims.values()]
|
|
1938
|
-
.filter(claim => claim.owner === memberId && claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
1939
|
-
.map(claim => Object.freeze({ ...claim, state: 'released' }));
|
|
1940
|
-
if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
|
|
1941
|
-
const expected = releasedClaims[index];
|
|
1942
|
-
return expected === undefined || !this.sameClaim(expected, claim);
|
|
1943
|
-
}))
|
|
1944
|
-
throw new Error('invalid released Claim projection');
|
|
1945
|
-
const byThread = new Map();
|
|
1946
|
-
for (const claim of releasedClaims)
|
|
1947
|
-
byThread.set(claim.threadRef, [...(byThread.get(claim.threadRef) ?? []), claim]);
|
|
1948
|
-
const expectedActivities = [...byThread.entries()].map(([threadRef, claims]) => ({
|
|
1949
|
-
kind: 'claims_released', taskRef: claims[0].taskRef, threadRef, actor: memberId, sequence,
|
|
1950
|
-
claimRefs: claims.map(claim => claim.claimRef).sort(),
|
|
1951
|
-
}));
|
|
1952
|
-
if (data.activities.length !== expectedActivities.length || data.activities.some((activity, index) => {
|
|
1953
|
-
const expected = expectedActivities[index];
|
|
1954
|
-
return expected === undefined || activity.kind !== expected.kind || activity.taskRef !== expected.taskRef
|
|
1955
|
-
|| activity.threadRef !== expected.threadRef || activity.actor !== expected.actor || activity.sequence !== expected.sequence
|
|
1956
|
-
|| !this.sameList(activity.claimRefs, expected.claimRefs);
|
|
1957
|
-
}))
|
|
1958
|
-
throw new Error('invalid released Claim activities');
|
|
1959
|
-
for (const claim of data.claims) {
|
|
1960
|
-
const prior = projection.claims.get(claim.claimRef);
|
|
1961
|
-
if (prior === undefined)
|
|
1962
|
-
throw new Error('invalid released Claim reference');
|
|
1963
|
-
}
|
|
1964
|
-
for (const activity of data.activities)
|
|
1965
|
-
this.addRef(refs, activity.activityRef);
|
|
1966
|
-
const projectedClaims = new Map(projection.claims);
|
|
1967
|
-
for (const claim of releasedClaims)
|
|
1968
|
-
projectedClaims.set(claim.claimRef, claim);
|
|
1969
|
-
const expectedTasks = [...new Set(releasedClaims.map(claim => claim.taskRef))].map(taskRef => {
|
|
1970
|
-
const task = projection.tasks.get(taskRef);
|
|
1971
|
-
return Object.freeze({ ...task, status: this.deriveResolvedTaskStatus(task, projectedClaims.values()) });
|
|
1972
|
-
});
|
|
1973
|
-
const expectedThreads = expectedActivities.map(activity => {
|
|
1974
|
-
const thread = projection.threads.get(activity.threadRef);
|
|
1975
|
-
return Object.freeze({ ...thread, revision: sequence });
|
|
1976
|
-
});
|
|
1977
|
-
if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
|
|
1978
|
-
throw new Error('invalid released Claim Task or Thread projection');
|
|
1979
|
-
}
|
|
1980
|
-
const expectedAttention = [...projection.attention.values()]
|
|
1981
|
-
.filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
|
|
1982
|
-
.map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
|
|
1983
|
-
const expectedMarkers = [...projection.directMarkers.values()]
|
|
1984
|
-
.filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
1985
|
-
const expectedActivityMarkers = [...projection.activityMarkers.values()]
|
|
1986
|
-
.filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
1987
|
-
const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
|
|
1988
|
-
if (!isDeepStrictEqual(data.inbox, expectedInbox))
|
|
1989
|
-
throw new Error('invalid Member inbox cleanup');
|
|
1990
|
-
this.validateInboxDelta(data.inbox, projection, refs);
|
|
1991
|
-
}
|
|
1992
|
-
/** Replay validation of one Channel archival's release snapshot across every owner. */
|
|
1993
|
-
validateChannelArchivalCleanup(data, projection, threadRefs, sequence, refs) {
|
|
1994
|
-
const releasedClaims = [...projection.claims.values()]
|
|
1995
|
-
.filter(claim => claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
2166
|
+
.filter(claim => (memberId === undefined || claim.owner === memberId) && claim.state === 'active' && threadRefs.has(claim.threadRef))
|
|
1996
2167
|
.map(claim => Object.freeze({ ...claim, state: 'released' }));
|
|
1997
2168
|
if (data.claims.length !== releasedClaims.length || data.claims.some((claim, index) => {
|
|
1998
2169
|
const expected = releasedClaims[index];
|
|
1999
2170
|
return expected === undefined || !this.sameClaim(expected, claim);
|
|
2000
2171
|
}))
|
|
2001
2172
|
throw new Error('invalid released Claim projection');
|
|
2002
|
-
// One
|
|
2173
|
+
// One Activity per (owner, Thread), owners sorted like the commit path.
|
|
2003
2174
|
const byOwner = new Map();
|
|
2004
2175
|
for (const claim of releasedClaims) {
|
|
2005
2176
|
const byThread = byOwner.get(claim.owner) ?? new Map();
|
|
@@ -2038,17 +2209,17 @@ export class AgentTeamLedger {
|
|
|
2038
2209
|
if (!isDeepStrictEqual(data.tasks, expectedTasks) || !isDeepStrictEqual(data.threads, expectedThreads)) {
|
|
2039
2210
|
throw new Error('invalid released Claim Task or Thread projection');
|
|
2040
2211
|
}
|
|
2041
|
-
// Attention and marker cleanup covers EVERY Member on the archived Threads.
|
|
2042
2212
|
const expectedAttention = [...projection.attention.values()]
|
|
2043
|
-
.filter(attention => threadRefs.has(attention.threadRef))
|
|
2213
|
+
.filter(attention => (memberId === undefined || attention.memberId === memberId) && threadRefs.has(attention.threadRef))
|
|
2044
2214
|
.map(attention => ({ memberId: attention.memberId, threadRef: attention.threadRef }));
|
|
2045
2215
|
const expectedMarkers = [...projection.directMarkers.values()]
|
|
2046
|
-
.filter(marker => threadRefs.has(marker.threadRef));
|
|
2216
|
+
.filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
|
|
2047
2217
|
const expectedActivityMarkers = [...projection.activityMarkers.values()]
|
|
2048
|
-
.filter(marker => threadRefs.has(marker.threadRef));
|
|
2218
|
+
.filter(marker => (memberId === undefined || marker.memberId === memberId) && threadRefs.has(marker.threadRef));
|
|
2049
2219
|
const expectedInbox = this.inboxDelta([], expectedAttention, [], expectedMarkers, [], expectedActivityMarkers);
|
|
2050
|
-
if (!isDeepStrictEqual(data.inbox, expectedInbox))
|
|
2051
|
-
throw new Error('invalid Channel archival inbox cleanup');
|
|
2220
|
+
if (!isDeepStrictEqual(data.inbox, expectedInbox)) {
|
|
2221
|
+
throw new Error(memberId === undefined ? 'invalid Channel archival inbox cleanup' : 'invalid Member inbox cleanup');
|
|
2222
|
+
}
|
|
2052
2223
|
this.validateInboxDelta(data.inbox, projection, refs);
|
|
2053
2224
|
}
|
|
2054
2225
|
validateMessageInbox(operation, projection) {
|
|
@@ -2057,7 +2228,7 @@ export class AgentTeamLedger {
|
|
|
2057
2228
|
// Top-level mentions are open to every actor; only a Member reply may not
|
|
2058
2229
|
// pull an unfollowed Member into an existing Thread.
|
|
2059
2230
|
if (operation.actor.kind === 'member' && operation.kind === 'team/thread-replied'
|
|
2060
|
-
&& mentionedAgents.some(memberId => !this.
|
|
2231
|
+
&& mentionedAgents.some(memberId => !this.everParticipatedFrom(projection, message.threadRef, memberId))) {
|
|
2061
2232
|
throw new Error('invalid Agent mention projection');
|
|
2062
2233
|
}
|
|
2063
2234
|
const started = operation.kind === 'team/message-sent'
|
|
@@ -2088,6 +2259,12 @@ export class AgentTeamLedger {
|
|
|
2088
2259
|
}
|
|
2089
2260
|
apply(operation) {
|
|
2090
2261
|
this.applyTo(this.state, operation);
|
|
2262
|
+
// Live apply only: the record-validation replay re-derives each record
|
|
2263
|
+
// against its own scratch projection, so a scope derivation there would be
|
|
2264
|
+
// discarded.
|
|
2265
|
+
const scopes = this.changeScopesOf(operation);
|
|
2266
|
+
if (scopes === undefined || scopes.length !== 0)
|
|
2267
|
+
this.projectionVersion = operation.sequence;
|
|
2091
2268
|
}
|
|
2092
2269
|
applyTo(target, operation) {
|
|
2093
2270
|
target.ordered.push(operation);
|
|
@@ -2157,15 +2334,7 @@ export class AgentTeamLedger {
|
|
|
2157
2334
|
}
|
|
2158
2335
|
if (operation.kind === 'team/channel-member-removed') {
|
|
2159
2336
|
target.memberships.get(operation.data.channelRef)?.delete(operation.data.memberId);
|
|
2160
|
-
|
|
2161
|
-
target.claims.set(claim.claimRef, claim);
|
|
2162
|
-
for (const activity of operation.data.activities)
|
|
2163
|
-
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
2164
|
-
for (const task of operation.data.tasks)
|
|
2165
|
-
target.tasks.set(task.taskRef, task);
|
|
2166
|
-
for (const thread of operation.data.threads)
|
|
2167
|
-
target.threads.set(thread.threadRef, thread);
|
|
2168
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2337
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2169
2338
|
return;
|
|
2170
2339
|
}
|
|
2171
2340
|
if (operation.kind === 'team/channel-archived') {
|
|
@@ -2173,30 +2342,14 @@ export class AgentTeamLedger {
|
|
|
2173
2342
|
// restore returns every Member to the Channel. Visibility filters by
|
|
2174
2343
|
// channel state instead.
|
|
2175
2344
|
target.channels.set(operation.data.channel.channelRef, operation.data.channel);
|
|
2176
|
-
|
|
2177
|
-
target.claims.set(claim.claimRef, claim);
|
|
2178
|
-
for (const activity of operation.data.activities)
|
|
2179
|
-
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
2180
|
-
for (const task of operation.data.tasks)
|
|
2181
|
-
target.tasks.set(task.taskRef, task);
|
|
2182
|
-
for (const thread of operation.data.threads)
|
|
2183
|
-
target.threads.set(thread.threadRef, thread);
|
|
2184
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2345
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2185
2346
|
return;
|
|
2186
2347
|
}
|
|
2187
2348
|
if (operation.kind === 'team/member-removed') {
|
|
2188
2349
|
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
2189
2350
|
for (const membership of target.memberships.values())
|
|
2190
2351
|
membership.delete(operation.data.member.memberId);
|
|
2191
|
-
|
|
2192
|
-
target.claims.set(claim.claimRef, claim);
|
|
2193
|
-
for (const activity of operation.data.activities)
|
|
2194
|
-
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
2195
|
-
for (const task of operation.data.tasks)
|
|
2196
|
-
target.tasks.set(task.taskRef, task);
|
|
2197
|
-
for (const thread of operation.data.threads)
|
|
2198
|
-
target.threads.set(thread.threadRef, thread);
|
|
2199
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2352
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2200
2353
|
return;
|
|
2201
2354
|
}
|
|
2202
2355
|
if (operation.kind === 'team/member-archived') {
|
|
@@ -2204,34 +2357,37 @@ export class AgentTeamLedger {
|
|
|
2204
2357
|
// restore returns the Member to its Channels. Visibility filters by
|
|
2205
2358
|
// member state instead.
|
|
2206
2359
|
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
2207
|
-
|
|
2208
|
-
target.claims.set(claim.claimRef, claim);
|
|
2209
|
-
for (const activity of operation.data.activities)
|
|
2210
|
-
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
2211
|
-
for (const task of operation.data.tasks)
|
|
2212
|
-
target.tasks.set(task.taskRef, task);
|
|
2213
|
-
for (const thread of operation.data.threads)
|
|
2214
|
-
target.threads.set(thread.threadRef, thread);
|
|
2215
|
-
this.applyInboxDelta(target, operation.data.inbox);
|
|
2360
|
+
this.applyReleaseSnapshot(target, operation, operation.data, operation.occurredAt);
|
|
2216
2361
|
return;
|
|
2217
2362
|
}
|
|
2218
2363
|
if (operation.kind === 'team/thread-promoted') {
|
|
2219
2364
|
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2220
2365
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2221
2366
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2367
|
+
this.recordTaskNumber(target, operation.data.task);
|
|
2368
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2222
2369
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2223
2370
|
return;
|
|
2224
2371
|
}
|
|
2225
2372
|
if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
|
|
2226
2373
|
const { message, mentions } = operation.data;
|
|
2227
|
-
target.messages.push(message);
|
|
2228
2374
|
target.mentionsByMessage.set(message.messageRef, Object.freeze([...mentions]));
|
|
2229
2375
|
this.appendMessageFact(target, message, mentions, message.occurredAt ?? operation.occurredAt);
|
|
2230
2376
|
if (operation.data.task !== undefined)
|
|
2231
2377
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2232
2378
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2233
|
-
if (message.topLevel)
|
|
2379
|
+
if (message.topLevel) {
|
|
2234
2380
|
target.channelRefByThread.set(message.threadRef, message.channelRef);
|
|
2381
|
+
// The anchor is the Thread's first topLevel Message; a later topLevel
|
|
2382
|
+
// Message (none today) must not displace it, matching the linear scan.
|
|
2383
|
+
if (!target.anchorByThread.has(message.threadRef))
|
|
2384
|
+
target.anchorByThread.set(message.threadRef, message);
|
|
2385
|
+
}
|
|
2386
|
+
// Only message-sent mints a Task ordinal; thread-replied may echo the
|
|
2387
|
+
// Task without renumbering it, like the operation scan it replaces.
|
|
2388
|
+
if (operation.kind === 'team/message-sent' && operation.data.task !== undefined)
|
|
2389
|
+
this.recordTaskNumber(target, operation.data.task);
|
|
2390
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2235
2391
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2236
2392
|
return;
|
|
2237
2393
|
}
|
|
@@ -2240,6 +2396,7 @@ export class AgentTeamLedger {
|
|
|
2240
2396
|
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2241
2397
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2242
2398
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2399
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2243
2400
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2244
2401
|
return;
|
|
2245
2402
|
}
|
|
@@ -2249,10 +2406,12 @@ export class AgentTeamLedger {
|
|
|
2249
2406
|
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2250
2407
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2251
2408
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2409
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2252
2410
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2253
2411
|
return;
|
|
2254
2412
|
}
|
|
2255
2413
|
if (operation.kind === 'team/thread-attention-changed' || operation.kind === 'team/thread-read') {
|
|
2414
|
+
this.recordAttentionObservations(target, operation, operation.data.inbox);
|
|
2256
2415
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
2257
2416
|
return;
|
|
2258
2417
|
}
|
|
@@ -2263,6 +2422,25 @@ export class AgentTeamLedger {
|
|
|
2263
2422
|
}
|
|
2264
2423
|
assertUnhandledKind(operation);
|
|
2265
2424
|
}
|
|
2425
|
+
/**
|
|
2426
|
+
* Replay the departure snapshot a releasing operation carries. The order is
|
|
2427
|
+
* replay semantics, not style: Claims land before the Activities that
|
|
2428
|
+
* reference them, and Tasks/Threads before the inbox delta that reads them.
|
|
2429
|
+
* Each caller still applies its own identifying writes (member, membership,
|
|
2430
|
+
* channel) around this call.
|
|
2431
|
+
*/
|
|
2432
|
+
applyReleaseSnapshot(target, operation, data, occurredAt) {
|
|
2433
|
+
for (const claim of data.claims)
|
|
2434
|
+
target.claims.set(claim.claimRef, claim);
|
|
2435
|
+
for (const activity of data.activities)
|
|
2436
|
+
this.appendActivityFact(target, activity, occurredAt);
|
|
2437
|
+
for (const task of data.tasks)
|
|
2438
|
+
target.tasks.set(task.taskRef, task);
|
|
2439
|
+
for (const thread of data.threads)
|
|
2440
|
+
target.threads.set(thread.threadRef, thread);
|
|
2441
|
+
this.recordAttentionObservations(target, operation, data.inbox);
|
|
2442
|
+
this.applyInboxDelta(target, data.inbox);
|
|
2443
|
+
}
|
|
2266
2444
|
/** Facts arrive in ledger sequence order, so global and per-thread lists stay sorted by append only. */
|
|
2267
2445
|
appendMessageFact(target, message, mentions, occurredAt) {
|
|
2268
2446
|
const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions, occurredAt });
|
|
@@ -2271,6 +2449,14 @@ export class AgentTeamLedger {
|
|
|
2271
2449
|
facts.push(fact);
|
|
2272
2450
|
target.factsByThread.set(message.threadRef, facts);
|
|
2273
2451
|
target.messageCountByThread.set(message.threadRef, (target.messageCountByThread.get(message.threadRef) ?? 0) + 1);
|
|
2452
|
+
// The ref index is written in this same step, so it is exactly the set of
|
|
2453
|
+
// replayed Messages: a validator lookup can never reach a later record.
|
|
2454
|
+
target.messagesByRef.set(message.messageRef, message);
|
|
2455
|
+
// Participation is indexed per writer as the fact lands, so the Human
|
|
2456
|
+
// recent slice asks who wrote where without scanning the whole ledger.
|
|
2457
|
+
const written = target.threadsByWriter.get(message.sender) ?? new Set();
|
|
2458
|
+
written.add(message.threadRef);
|
|
2459
|
+
target.threadsByWriter.set(message.sender, written);
|
|
2274
2460
|
}
|
|
2275
2461
|
appendActivityFact(target, activity, occurredAt) {
|
|
2276
2462
|
const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity, occurredAt });
|
|
@@ -2279,6 +2465,44 @@ export class AgentTeamLedger {
|
|
|
2279
2465
|
facts.push(fact);
|
|
2280
2466
|
target.factsByThread.set(activity.threadRef, facts);
|
|
2281
2467
|
}
|
|
2468
|
+
/** Display ordinals are a per-Channel creation counter; the workspace filter happens at read time. */
|
|
2469
|
+
recordTaskNumber(target, task) {
|
|
2470
|
+
const ordinal = (target.taskCountByChannel.get(task.channelRef) ?? 0) + 1;
|
|
2471
|
+
target.taskCountByChannel.set(task.channelRef, ordinal);
|
|
2472
|
+
target.taskNumberByTask.set(task.taskRef, ordinal);
|
|
2473
|
+
}
|
|
2474
|
+
/**
|
|
2475
|
+
* Replay-order Attention observations, appended from committed Inbox deltas
|
|
2476
|
+
* only (the hypothetical read projection applies its delta directly). The
|
|
2477
|
+
* follow/unfollow decision mirrors the full replay scan it replaces: a
|
|
2478
|
+
* removal records only when Attention existed, and a set records unless it
|
|
2479
|
+
* is the Thread-creating Message (initial Attention) or merely advances the
|
|
2480
|
+
* same follow's watermark. Removals are evaluated before sets, exactly like
|
|
2481
|
+
* the scan's loop order.
|
|
2482
|
+
*/
|
|
2483
|
+
recordAttentionObservations(target, operation, delta) {
|
|
2484
|
+
if (delta.attention.removed.length === 0 && delta.attention.set.length === 0)
|
|
2485
|
+
return;
|
|
2486
|
+
const retired = new Set();
|
|
2487
|
+
for (const key of delta.attention.removed) {
|
|
2488
|
+
retired.add(this.attentionKey(key.memberId, key.threadRef));
|
|
2489
|
+
if (!target.attention.has(this.attentionKey(key.memberId, key.threadRef)))
|
|
2490
|
+
continue;
|
|
2491
|
+
this.appendObservation(target, key.threadRef, { sequence: operation.sequence, memberId: key.memberId, action: 'unfollow' });
|
|
2492
|
+
}
|
|
2493
|
+
for (const next of delta.attention.set) {
|
|
2494
|
+
const key = this.attentionKey(next.memberId, next.threadRef);
|
|
2495
|
+
const prior = retired.has(key) ? undefined : target.attention.get(key);
|
|
2496
|
+
if (operation.kind === 'team/message-sent' || (prior !== undefined && prior.startSequence === next.startSequence))
|
|
2497
|
+
continue;
|
|
2498
|
+
this.appendObservation(target, next.threadRef, { sequence: operation.sequence, memberId: next.memberId, action: 'follow' });
|
|
2499
|
+
}
|
|
2500
|
+
}
|
|
2501
|
+
appendObservation(target, threadRef, observation) {
|
|
2502
|
+
const observations = target.observationsByThread.get(threadRef) ?? [];
|
|
2503
|
+
observations.push(Object.freeze(observation));
|
|
2504
|
+
target.observationsByThread.set(threadRef, observations);
|
|
2505
|
+
}
|
|
2282
2506
|
applyInboxDelta(target, delta) {
|
|
2283
2507
|
for (const key of delta.attention.removed) {
|
|
2284
2508
|
target.attention.delete(this.attentionKey(key.memberId, key.threadRef));
|
|
@@ -2288,27 +2512,76 @@ export class AgentTeamLedger {
|
|
|
2288
2512
|
if (followers.size === 0)
|
|
2289
2513
|
target.attentionByThread.delete(key.threadRef);
|
|
2290
2514
|
}
|
|
2515
|
+
const threads = target.attentionThreadsByMember.get(key.memberId);
|
|
2516
|
+
if (threads !== undefined) {
|
|
2517
|
+
threads.delete(key.threadRef);
|
|
2518
|
+
if (threads.size === 0)
|
|
2519
|
+
target.attentionThreadsByMember.delete(key.memberId);
|
|
2520
|
+
}
|
|
2291
2521
|
}
|
|
2292
2522
|
for (const attention of delta.attention.set) {
|
|
2293
2523
|
target.attention.set(this.attentionKey(attention.memberId, attention.threadRef), attention);
|
|
2294
2524
|
const followers = target.attentionByThread.get(attention.threadRef) ?? new Set();
|
|
2295
2525
|
followers.add(attention.memberId);
|
|
2296
2526
|
target.attentionByThread.set(attention.threadRef, followers);
|
|
2527
|
+
const threads = target.attentionThreadsByMember.get(attention.memberId) ?? new Set();
|
|
2528
|
+
threads.add(attention.threadRef);
|
|
2529
|
+
target.attentionThreadsByMember.set(attention.memberId, threads);
|
|
2297
2530
|
}
|
|
2298
|
-
for (const marker of delta.directMarkers.removed)
|
|
2531
|
+
for (const marker of delta.directMarkers.removed) {
|
|
2299
2532
|
target.directMarkers.delete(this.directMarkerKey(marker));
|
|
2300
|
-
|
|
2533
|
+
this.removeBucketedMarker(target.directMarkersByMember, marker.memberId, marker.threadRef, this.directMarkerKey(marker), candidate => this.directMarkerKey(candidate));
|
|
2534
|
+
}
|
|
2535
|
+
for (const marker of delta.directMarkers.added) {
|
|
2301
2536
|
target.directMarkers.set(this.directMarkerKey(marker), marker);
|
|
2302
|
-
|
|
2537
|
+
this.insertBucketedMarker(target.directMarkersByMember, marker.memberId, marker.threadRef, marker);
|
|
2538
|
+
}
|
|
2539
|
+
for (const marker of delta.activityMarkers.removed) {
|
|
2303
2540
|
target.activityMarkers.delete(this.activityMarkerKey(marker));
|
|
2304
|
-
|
|
2541
|
+
this.removeBucketedMarker(target.activityMarkersByMember, marker.memberId, marker.threadRef, this.activityMarkerKey(marker), candidate => this.activityMarkerKey(candidate));
|
|
2542
|
+
}
|
|
2543
|
+
for (const marker of delta.activityMarkers.added) {
|
|
2305
2544
|
target.activityMarkers.set(this.activityMarkerKey(marker), marker);
|
|
2545
|
+
this.insertBucketedMarker(target.activityMarkersByMember, marker.memberId, marker.threadRef, marker);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
/** Insert into the Member's Thread bucket keeping the sequence order the read path must see. */
|
|
2549
|
+
insertBucketedMarker(index, memberId, threadRef, marker) {
|
|
2550
|
+
const threads = index.get(memberId) ?? new Map();
|
|
2551
|
+
index.set(memberId, threads);
|
|
2552
|
+
const bucket = threads.get(threadRef) ?? [];
|
|
2553
|
+
threads.set(threadRef, bucket);
|
|
2554
|
+
let position = bucket.length;
|
|
2555
|
+
while (position > 0 && bucket[position - 1].sequence > marker.sequence)
|
|
2556
|
+
position -= 1;
|
|
2557
|
+
bucket.splice(position, 0, marker);
|
|
2558
|
+
}
|
|
2559
|
+
removeBucketedMarker(index, memberId, threadRef, key, identityOf) {
|
|
2560
|
+
const threads = index.get(memberId);
|
|
2561
|
+
const bucket = threads?.get(threadRef);
|
|
2562
|
+
if (bucket === undefined)
|
|
2563
|
+
return;
|
|
2564
|
+
const position = bucket.findIndex(item => identityOf(item) === key);
|
|
2565
|
+
if (position >= 0)
|
|
2566
|
+
bucket.splice(position, 1);
|
|
2567
|
+
if (bucket.length === 0) {
|
|
2568
|
+
threads.delete(threadRef);
|
|
2569
|
+
if (threads.size === 0)
|
|
2570
|
+
index.delete(memberId);
|
|
2571
|
+
}
|
|
2306
2572
|
}
|
|
2307
2573
|
prepareRead(memberId, workspaceId, request) {
|
|
2308
2574
|
return this.prepareReadFrom(this.state, memberId, workspaceId, request);
|
|
2309
2575
|
}
|
|
2310
|
-
/**
|
|
2311
|
-
|
|
2576
|
+
/**
|
|
2577
|
+
* Derive the durable receipt of one Thread read from a prior projection: the
|
|
2578
|
+
* Thread it targeted, the watermark it reaches, the Attention row it writes
|
|
2579
|
+
* and the Inbox delta it consumes. This is the whole durable content of a
|
|
2580
|
+
* receipt-shaped read and the only thing `validateRecords` re-derives for
|
|
2581
|
+
* one; the picture derivation below builds on the same result, so the two can
|
|
2582
|
+
* never drift.
|
|
2583
|
+
*/
|
|
2584
|
+
prepareReadReceiptFrom(projection, memberId, workspaceId, request) {
|
|
2312
2585
|
const { task, thread, channelRef } = this.threadContextFrom(projection, workspaceId, request);
|
|
2313
2586
|
if (memberId !== AGENT_TEAM_HUMAN_MEMBER_ID) {
|
|
2314
2587
|
const member = projection.members.get(memberId);
|
|
@@ -2316,21 +2589,9 @@ export class AgentTeamLedger {
|
|
|
2316
2589
|
throw new Error(`Agent Member '${memberId}' is not authorized for Channel '${channelRef}'`);
|
|
2317
2590
|
}
|
|
2318
2591
|
}
|
|
2319
|
-
const anchor = this.threadAnchorFrom(projection, thread.threadRef);
|
|
2320
2592
|
const attention = this.attentionForFrom(projection, memberId, thread.threadRef);
|
|
2321
2593
|
const unread = this.unreadForFrom(projection, memberId, thread.threadRef);
|
|
2322
2594
|
const unreadFacts = unread.slice(0, 20);
|
|
2323
|
-
const unreadFactKeys = new Set(unread.map(item => this.threadFactKey(item.fact)));
|
|
2324
|
-
const firstRead = attention !== undefined && attention.readThroughSequence < attention.startSequence;
|
|
2325
|
-
const background = firstRead
|
|
2326
|
-
? projection.messages.filter(message => message.threadRef === thread.threadRef && message.sequence < attention.startSequence)
|
|
2327
|
-
.map(message => Object.freeze({ kind: 'message', sequence: message.sequence, message,
|
|
2328
|
-
mentions: projection.mentionsByMessage.get(message.messageRef) ?? [],
|
|
2329
|
-
occurredAt: this.occurredAtForFactFrom(projection, message.sequence, message.occurredAt) }))
|
|
2330
|
-
.filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
|
|
2331
|
-
: [];
|
|
2332
|
-
const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...unreadFacts]
|
|
2333
|
-
.sort((left, right) => left.fact.sequence - right.fact.sequence);
|
|
2334
2595
|
// Direct markers are sparse acknowledgements, not part of the contiguous
|
|
2335
2596
|
// follower watermark. Consuming an old marker after a later follow must
|
|
2336
2597
|
// never move that watermark backwards.
|
|
@@ -2346,24 +2607,48 @@ export class AgentTeamLedger {
|
|
|
2346
2607
|
const activityMarkers = this.activityMarkersForFrom(projection, memberId, thread.threadRef)
|
|
2347
2608
|
.filter(marker => unreadFacts.some(item => item.fact.kind === 'activity' && item.fact.activity.activityRef === marker.activityRef));
|
|
2348
2609
|
const inbox = this.inboxDelta(nextAttention, [], [], consumed, [], activityMarkers);
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
const nextProjection = {
|
|
2353
|
-
...projection,
|
|
2354
|
-
attention: new Map(projection.attention),
|
|
2355
|
-
directMarkers: new Map(projection.directMarkers),
|
|
2356
|
-
activityMarkers: new Map(projection.activityMarkers),
|
|
2357
|
-
attentionByThread: new Map([...projection.attentionByThread].map(([threadRef, followers]) => [threadRef, new Set(followers)])),
|
|
2358
|
-
};
|
|
2359
|
-
this.applyInboxDelta(nextProjection, inbox);
|
|
2360
|
-
const remainingUnreadCount = this.unreadForFrom(nextProjection, memberId, thread.threadRef).length;
|
|
2361
|
-
return Object.freeze({ ...(task === undefined ? {} : { task }), thread, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
|
|
2362
|
-
anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
|
|
2363
|
-
facts: Object.freeze(combined),
|
|
2364
|
-
readThroughSequence, remainingUnreadCount, ...(attention === undefined ? {} : { attention: nextAttention[0] ?? attention }),
|
|
2610
|
+
return Object.freeze({ ...(task === undefined ? {} : { task }), thread, unread,
|
|
2611
|
+
...(attention === undefined ? {} : { attentionBefore: attention }), readThroughSequence,
|
|
2612
|
+
...(attention === undefined ? {} : { attention: nextAttention[0] ?? attention }),
|
|
2365
2613
|
consumedDirectMarkers: Object.freeze(consumed), inbox });
|
|
2366
2614
|
}
|
|
2615
|
+
/** Derive the only legal durable result of one Thread read from a prior projection. */
|
|
2616
|
+
prepareReadFrom(projection, memberId, workspaceId, request) {
|
|
2617
|
+
const receipt = this.prepareReadReceiptFrom(projection, memberId, workspaceId, request);
|
|
2618
|
+
const { task, thread } = receipt;
|
|
2619
|
+
const anchor = this.threadAnchorFrom(projection, thread.threadRef);
|
|
2620
|
+
// The first read of a Thread after following also shows the bounded
|
|
2621
|
+
// background that preceded the follow, so a reader sees what they joined.
|
|
2622
|
+
const firstRead = receipt.attentionBefore !== undefined && receipt.attentionBefore.readThroughSequence < receipt.attentionBefore.startSequence;
|
|
2623
|
+
const unreadFactKeys = new Set(receipt.unread.map(item => this.threadFactKey(item.fact)));
|
|
2624
|
+
const background = firstRead
|
|
2625
|
+
? this.threadFactsFrom(projection, thread.threadRef)
|
|
2626
|
+
.filter((fact) => fact.kind === 'message'
|
|
2627
|
+
&& fact.sequence < receipt.attentionBefore.startSequence)
|
|
2628
|
+
.map(fact => Object.freeze({ kind: 'message', sequence: fact.sequence, message: fact.message,
|
|
2629
|
+
mentions: projection.mentionsByMessage.get(fact.message.messageRef) ?? [],
|
|
2630
|
+
occurredAt: this.occurredAtForFactFrom(projection, fact.sequence, fact.message.occurredAt) }))
|
|
2631
|
+
.filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
|
|
2632
|
+
: [];
|
|
2633
|
+
const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...receipt.unread.slice(0, 20)]
|
|
2634
|
+
.sort((left, right) => left.fact.sequence - right.fact.sequence);
|
|
2635
|
+
const remainingUnreadCount = this.remainingUnreadAfter(projection, memberId, receipt);
|
|
2636
|
+
// What this bounded read leaves behind it: the Thread facts that precede the
|
|
2637
|
+
// watermark it reaches, which is where this reader now stands in the Thread
|
|
2638
|
+
// and how much of it that position has never shown it. Anchoring on the
|
|
2639
|
+
// watermark rather than on the response's own oldest fact is what keeps a
|
|
2640
|
+
// returning reader honest — the background window starts at the Thread's
|
|
2641
|
+
// first fact whenever the Thread is short enough to fit it, so measuring
|
|
2642
|
+
// from the response would report nothing behind a reader that has in fact
|
|
2643
|
+
// never read the Thread. Derived from the projection the read resolved
|
|
2644
|
+
// against, like every other field of this picture, so a replay re-derives
|
|
2645
|
+
// the same number.
|
|
2646
|
+
const earlierFactCount = this.threadFactsFrom(projection, thread.threadRef)
|
|
2647
|
+
.filter(fact => fact.sequence < receipt.readThroughSequence).length;
|
|
2648
|
+
return Object.freeze({ ...receipt, claims: task === undefined ? Object.freeze([]) : this.claimsForTaskFrom(projection, task.taskRef), anchor,
|
|
2649
|
+
anchorMentions: projection.mentionsByMessage.get(anchor.messageRef) ?? [],
|
|
2650
|
+
facts: Object.freeze(combined), remainingUnreadCount, earlierFactCount });
|
|
2651
|
+
}
|
|
2367
2652
|
readFactFrom(projection, memberId, fact, unread) {
|
|
2368
2653
|
return Object.freeze({ fact, unread, direct: fact.kind === 'message'
|
|
2369
2654
|
&& projection.directMarkers.has(this.directMarkerKey({ memberId, threadRef: fact.message.threadRef,
|
|
@@ -2374,23 +2659,60 @@ export class AgentTeamLedger {
|
|
|
2374
2659
|
}
|
|
2375
2660
|
unreadForFrom(projection, memberId, threadRef) {
|
|
2376
2661
|
const attention = this.attentionForFrom(projection, memberId, threadRef);
|
|
2377
|
-
const
|
|
2378
|
-
const direct = this.directMarkersForFrom(projection, memberId, threadRef);
|
|
2379
|
-
const directKeys = new Set(direct.map(marker => this.directMarkerKey(marker)));
|
|
2662
|
+
const directKeys = new Set(this.directMarkersForFrom(projection, memberId, threadRef).map(marker => this.directMarkerKey(marker)));
|
|
2380
2663
|
const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
|
|
2381
2664
|
const result = [];
|
|
2382
|
-
for (const fact of
|
|
2383
|
-
|
|
2384
|
-
messageRef: fact.message.messageRef, sequence: fact.sequence }));
|
|
2385
|
-
const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
|
|
2386
|
-
activityRef: fact.activity.activityRef, sequence: fact.sequence }));
|
|
2387
|
-
const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
|
|
2388
|
-
&& fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
|
|
2389
|
-
if (marker || activityMarker || ordinary)
|
|
2665
|
+
for (const fact of this.threadFactsFrom(projection, threadRef)) {
|
|
2666
|
+
if (this.isUnreadFact(fact, memberId, threadRef, attention, directKeys, activityKeys)) {
|
|
2390
2667
|
result.push(this.readFactFrom(projection, memberId, fact, true));
|
|
2668
|
+
}
|
|
2391
2669
|
}
|
|
2392
2670
|
return Object.freeze(result);
|
|
2393
2671
|
}
|
|
2672
|
+
/**
|
|
2673
|
+
* The single authority on "this fact is unread for this reader". The picture
|
|
2674
|
+
* derivation and the remaining-count derivation both go through it, so the
|
|
2675
|
+
* count a read reports can never disagree with the facts it lists. Marker
|
|
2676
|
+
* membership arrives as key sets: the caller may be looking at a state the
|
|
2677
|
+
* projection has not applied yet.
|
|
2678
|
+
*/
|
|
2679
|
+
isUnreadFact(fact, memberId, threadRef, attention, directKeys, activityKeys) {
|
|
2680
|
+
const marker = fact.kind === 'message' && directKeys.has(this.directMarkerKey({ memberId, threadRef,
|
|
2681
|
+
messageRef: fact.message.messageRef, sequence: fact.sequence }));
|
|
2682
|
+
const activityMarker = fact.kind === 'activity' && activityKeys.has(this.activityMarkerKey({ memberId, threadRef,
|
|
2683
|
+
activityRef: fact.activity.activityRef, sequence: fact.sequence }));
|
|
2684
|
+
const ordinary = attention !== undefined && fact.sequence >= attention.startSequence
|
|
2685
|
+
&& fact.sequence > attention.readThroughSequence && this.visibleToFollower(fact, memberId);
|
|
2686
|
+
return marker || activityMarker || ordinary;
|
|
2687
|
+
}
|
|
2688
|
+
/**
|
|
2689
|
+
* Unread count once this read's own Inbox delta has been applied, derived
|
|
2690
|
+
* without materializing a hypothetical projection. The delta can only touch
|
|
2691
|
+
* the reader's own Attention row and its own marker keys in one Thread, and
|
|
2692
|
+
* every lookup in `isUnreadFact` is scoped to that same (member, Thread)
|
|
2693
|
+
* pair, so the post-delta state is the Attention row the receipt already
|
|
2694
|
+
* derived plus those marker keys with the delta's own removals and additions
|
|
2695
|
+
* applied.
|
|
2696
|
+
*/
|
|
2697
|
+
remainingUnreadAfter(projection, memberId, receipt) {
|
|
2698
|
+
const threadRef = receipt.thread.threadRef;
|
|
2699
|
+
const directKeys = new Set(this.directMarkersForFrom(projection, memberId, threadRef).map(marker => this.directMarkerKey(marker)));
|
|
2700
|
+
for (const marker of receipt.inbox.directMarkers.removed)
|
|
2701
|
+
directKeys.delete(this.directMarkerKey(marker));
|
|
2702
|
+
for (const marker of receipt.inbox.directMarkers.added)
|
|
2703
|
+
directKeys.add(this.directMarkerKey(marker));
|
|
2704
|
+
const activityKeys = new Set(this.activityMarkersForFrom(projection, memberId, threadRef).map(marker => this.activityMarkerKey(marker)));
|
|
2705
|
+
for (const marker of receipt.inbox.activityMarkers.removed)
|
|
2706
|
+
activityKeys.delete(this.activityMarkerKey(marker));
|
|
2707
|
+
for (const marker of receipt.inbox.activityMarkers.added)
|
|
2708
|
+
activityKeys.add(this.activityMarkerKey(marker));
|
|
2709
|
+
let count = 0;
|
|
2710
|
+
for (const fact of this.threadFactsFrom(projection, threadRef)) {
|
|
2711
|
+
if (this.isUnreadFact(fact, memberId, threadRef, receipt.attention, directKeys, activityKeys))
|
|
2712
|
+
count += 1;
|
|
2713
|
+
}
|
|
2714
|
+
return count;
|
|
2715
|
+
}
|
|
2394
2716
|
visibleToFollower(fact, memberId) {
|
|
2395
2717
|
const sender = fact.kind === 'message' ? fact.message.sender : fact.activity.actor;
|
|
2396
2718
|
return sender !== memberId;
|
|
@@ -2535,12 +2857,14 @@ export class AgentTeamLedger {
|
|
|
2535
2857
|
return this.directMarkersForFrom(this.state, memberId, threadRef);
|
|
2536
2858
|
}
|
|
2537
2859
|
directMarkersForFrom(projection, memberId, threadRef) {
|
|
2538
|
-
|
|
2539
|
-
|
|
2860
|
+
// A frozen copy, not the live bucket: callers may hand the array to an
|
|
2861
|
+
// Inbox delta that freezes it, which must not freeze the derived index.
|
|
2862
|
+
const markers = projection.directMarkersByMember.get(memberId)?.get(threadRef);
|
|
2863
|
+
return markers === undefined ? [] : Object.freeze([...markers]);
|
|
2540
2864
|
}
|
|
2541
2865
|
activityMarkersForFrom(projection, memberId, threadRef) {
|
|
2542
|
-
|
|
2543
|
-
|
|
2866
|
+
const markers = projection.activityMarkersByMember.get(memberId)?.get(threadRef);
|
|
2867
|
+
return markers === undefined ? [] : Object.freeze([...markers]);
|
|
2544
2868
|
}
|
|
2545
2869
|
issueConfirmation(actor, workspaceId, channelRef, body, recipients, task, thread) {
|
|
2546
2870
|
if (actor.kind !== 'human')
|
|
@@ -2579,10 +2903,6 @@ export class AgentTeamLedger {
|
|
|
2579
2903
|
}
|
|
2580
2904
|
}
|
|
2581
2905
|
}
|
|
2582
|
-
memberNotFollowing(workspaceId, channelRef, memberIds, task, thread) {
|
|
2583
|
-
return Object.freeze({ kind: 'member_not_following', workspaceId, channelRef, memberIds: Object.freeze(memberIds),
|
|
2584
|
-
...(task === undefined ? {} : { taskRef: task.taskRef }), ...(thread === undefined ? {} : { threadRef: thread.threadRef, revision: thread.revision }) });
|
|
2585
|
-
}
|
|
2586
2906
|
unreadRequired(task, thread, unread) {
|
|
2587
2907
|
return Object.freeze({ kind: 'unread_required', ...(task === undefined ? {} : { taskRef: task.taskRef }), threadRef: thread.threadRef,
|
|
2588
2908
|
revision: thread.revision, unreadCount: unread.length, directCount: unread.filter(item => item.direct).length });
|
|
@@ -2642,16 +2962,17 @@ export class AgentTeamLedger {
|
|
|
2642
2962
|
return undefined;
|
|
2643
2963
|
}
|
|
2644
2964
|
/** Commit projections shared by the Message-sent and Thread-replied results. */
|
|
2645
|
-
committedMessageResult(operation) {
|
|
2965
|
+
committedMessageResult(operation, undelivered = []) {
|
|
2646
2966
|
return { kind: 'committed', receipt: this.receipt(operation), message: operation.data.message,
|
|
2647
2967
|
...(operation.data.task === undefined ? {} : { task: operation.data.task }), thread: operation.data.thread, attention: operation.data.inbox.attention.set,
|
|
2648
|
-
directMarkers: operation.data.inbox.directMarkers.added
|
|
2968
|
+
directMarkers: operation.data.inbox.directMarkers.added,
|
|
2969
|
+
...(undelivered.length === 0 ? {} : { undeliveredMentions: undelivered }) };
|
|
2649
2970
|
}
|
|
2650
2971
|
threadAnchor(threadRef) {
|
|
2651
2972
|
return this.threadAnchorFrom(this.state, threadRef);
|
|
2652
2973
|
}
|
|
2653
2974
|
threadAnchorFrom(projection, threadRef) {
|
|
2654
|
-
const anchor = projection.
|
|
2975
|
+
const anchor = projection.anchorByThread.get(threadRef);
|
|
2655
2976
|
if (anchor === undefined)
|
|
2656
2977
|
throw new Error(`Thread '${threadRef}' has no anchor Message`);
|
|
2657
2978
|
return anchor;
|
|
@@ -2663,20 +2984,17 @@ export class AgentTeamLedger {
|
|
|
2663
2984
|
* Display numbers for Tasks: one counter per home Channel, in creation
|
|
2664
2985
|
* order. This is the single numbering authority — Channel cards, Thread
|
|
2665
2986
|
* headings, cross-channel ref resolution, and inbox renders all show the
|
|
2666
|
-
* ordinal the Task holds inside its own Channel.
|
|
2987
|
+
* ordinal the Task holds inside its own Channel. The ordinals are derived
|
|
2988
|
+
* at replay time (`taskNumberByTask`); this read only filters them to one
|
|
2989
|
+
* Workspace.
|
|
2667
2990
|
*/
|
|
2668
2991
|
taskNumbers(workspaceId) {
|
|
2669
2992
|
const numbers = new Map();
|
|
2670
|
-
const
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
if (task === undefined)
|
|
2674
|
-
continue;
|
|
2675
|
-
if (this.state.channels.get(task.channelRef)?.workspaceId !== workspaceId)
|
|
2993
|
+
for (const [taskRef, taskNumber] of this.state.taskNumberByTask) {
|
|
2994
|
+
const task = this.state.tasks.get(taskRef);
|
|
2995
|
+
if (task === undefined || this.state.channels.get(task.channelRef)?.workspaceId !== workspaceId)
|
|
2676
2996
|
continue;
|
|
2677
|
-
|
|
2678
|
-
next.set(task.channelRef, ordinal);
|
|
2679
|
-
numbers.set(task.taskRef, ordinal);
|
|
2997
|
+
numbers.set(taskRef, taskNumber);
|
|
2680
2998
|
}
|
|
2681
2999
|
return numbers;
|
|
2682
3000
|
}
|
|
@@ -2921,6 +3239,68 @@ export class AgentTeamLedger {
|
|
|
2921
3239
|
throw new Error(`${label} contains duplicate Member refs`);
|
|
2922
3240
|
return Object.freeze([...unique].sort());
|
|
2923
3241
|
}
|
|
3242
|
+
/**
|
|
3243
|
+
* Names a Message body may address in one Channel: every live Member of that
|
|
3244
|
+
* Channel plus the Human. A name outside this set stays prose, which is what
|
|
3245
|
+
* keeps an incidental name-drop from reaching someone the Channel cannot
|
|
3246
|
+
* deliver to.
|
|
3247
|
+
*/
|
|
3248
|
+
mentionCandidatesFor(channelRef) {
|
|
3249
|
+
const candidates = [{ memberId: AGENT_TEAM_HUMAN_MEMBER_ID, handle: AGENT_TEAM_HUMAN_HANDLE }];
|
|
3250
|
+
for (const member of this.state.members.values()) {
|
|
3251
|
+
if (member.state === 'inactive' || member.state === 'archived')
|
|
3252
|
+
continue;
|
|
3253
|
+
if (!this.isChannelMember(channelRef, member.memberId))
|
|
3254
|
+
continue;
|
|
3255
|
+
candidates.push({ memberId: member.memberId, handle: member.handle });
|
|
3256
|
+
}
|
|
3257
|
+
return Object.freeze(candidates);
|
|
3258
|
+
}
|
|
3259
|
+
/**
|
|
3260
|
+
* Merge the `@Handle` mentions authored in `body` into an explicit recipient
|
|
3261
|
+
* set. Body mentions are the primary channel now: an Agent has no recipient
|
|
3262
|
+
* parameter to forget, and the same scan serves Human input typed by hand.
|
|
3263
|
+
* The result stays a plain recipient set, so every downstream projection —
|
|
3264
|
+
* delivery markers, chip rendering, confirmation — is unchanged.
|
|
3265
|
+
*/
|
|
3266
|
+
mergeBodyMentions(sender, channelRef, body, explicit) {
|
|
3267
|
+
const candidates = this.mentionCandidatesFor(channelRef);
|
|
3268
|
+
const resolution = resolveBodyMentions(body, candidates, sender);
|
|
3269
|
+
// `@all` stands for its expansion as of this write: the Member set is
|
|
3270
|
+
// snapshotted into the recipient list, so a later roster change cannot
|
|
3271
|
+
// retroactively alter what this operation delivered.
|
|
3272
|
+
const authored = resolution.all ? candidates.map(candidate => candidate.memberId) : resolution.memberIds;
|
|
3273
|
+
const merged = new Set([...explicit, ...authored]);
|
|
3274
|
+
merged.delete(sender);
|
|
3275
|
+
return Object.freeze([...merged].sort());
|
|
3276
|
+
}
|
|
3277
|
+
/**
|
|
3278
|
+
* Whether one Member has ever held Attention on one Thread. Committed Inbox
|
|
3279
|
+
* deltas append a follow/unfollow observation, and the live index covers the
|
|
3280
|
+
* Thread-creating Message whose initial Attention is never observed — so the
|
|
3281
|
+
* two together answer "was this Member ever part of this Thread" without
|
|
3282
|
+
* adding a second durable authority.
|
|
3283
|
+
*/
|
|
3284
|
+
everParticipated(threadRef, memberId) {
|
|
3285
|
+
return this.everParticipatedFrom(this.state, threadRef, memberId);
|
|
3286
|
+
}
|
|
3287
|
+
everParticipatedFrom(projection, threadRef, memberId) {
|
|
3288
|
+
if (this.isFollowingFrom(projection, threadRef, memberId))
|
|
3289
|
+
return true;
|
|
3290
|
+
return (projection.observationsByThread.get(threadRef) ?? []).some(observation => observation.memberId === memberId);
|
|
3291
|
+
}
|
|
3292
|
+
/**
|
|
3293
|
+
* Split the Agent recipients an existing Thread cannot deliver to: those the
|
|
3294
|
+
* body named that the Thread has never carried. The send still commits — a
|
|
3295
|
+
* text mention must never fail the write — and the author is told through the
|
|
3296
|
+
* result, because inviting a Member into an existing Thread stays a Human
|
|
3297
|
+
* decision.
|
|
3298
|
+
*/
|
|
3299
|
+
undeliverableRecipients(actor, threadRef, recipients) {
|
|
3300
|
+
if (actor.kind !== 'member')
|
|
3301
|
+
return Object.freeze([]);
|
|
3302
|
+
return Object.freeze(recipients.filter(memberId => this.state.members.has(memberId) && !this.everParticipated(threadRef, memberId)));
|
|
3303
|
+
}
|
|
2924
3304
|
normalizeDirection(direction) {
|
|
2925
3305
|
return direction.normalize('NFKC').trim().replace(/\s+/gu, ' ').toLowerCase();
|
|
2926
3306
|
}
|
|
@@ -3106,8 +3486,8 @@ export class AgentTeamLedger {
|
|
|
3106
3486
|
assertSameThreadRead(operation, request) {
|
|
3107
3487
|
if (operation.kind !== 'team/thread-read' || !this.sameActor(operation.actor, request.actor)
|
|
3108
3488
|
|| operation.data.workspaceId !== request.workspaceId
|
|
3109
|
-
|| (request.threadRef !== undefined && operation.data.
|
|
3110
|
-
|| (request.taskRef !== undefined && operation.data.
|
|
3489
|
+
|| (request.threadRef !== undefined && threadReadTargetOf(operation.data).threadRef !== request.threadRef)
|
|
3490
|
+
|| (request.taskRef !== undefined && threadReadTargetOf(operation.data).taskRef !== request.taskRef)
|
|
3111
3491
|
|| (request.threadRef === undefined && request.taskRef === undefined))
|
|
3112
3492
|
this.throwRequestCollision(request.requestId);
|
|
3113
3493
|
}
|
|
@@ -3191,8 +3571,8 @@ export class AgentTeamLedger {
|
|
|
3191
3571
|
messageResult(operation) {
|
|
3192
3572
|
return Object.freeze(this.committedMessageResult(operation));
|
|
3193
3573
|
}
|
|
3194
|
-
replyResult(operation) {
|
|
3195
|
-
return Object.freeze(this.committedMessageResult(operation));
|
|
3574
|
+
replyResult(operation, undelivered = []) {
|
|
3575
|
+
return Object.freeze(this.committedMessageResult(operation, undelivered));
|
|
3196
3576
|
}
|
|
3197
3577
|
claimResult(operation) {
|
|
3198
3578
|
const attention = operation.data.inbox.attention.set.find(candidate => candidate.memberId === operation.data.claim.owner);
|
|
@@ -3212,14 +3592,19 @@ export class AgentTeamLedger {
|
|
|
3212
3592
|
return Object.freeze({ kind: 'committed', receipt: this.receipt(operation), activity: operation.data.activity,
|
|
3213
3593
|
task: operation.data.task, thread: operation.data.thread });
|
|
3214
3594
|
}
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3595
|
+
/**
|
|
3596
|
+
* The Thread picture a read answers with, derived from the projection the
|
|
3597
|
+
* read resolved against. Committed reads, no-op reads and retries all share
|
|
3598
|
+
* this one derivation, so a read that writes nothing still returns the same
|
|
3599
|
+
* Attention, facts, watermark and unread count a committed one would.
|
|
3600
|
+
*/
|
|
3601
|
+
readPicture(prepared) {
|
|
3602
|
+
return Object.freeze({ ...(prepared.task === undefined ? {} : { task: prepared.task }), thread: prepared.thread,
|
|
3603
|
+
claims: prepared.claims, anchor: prepared.anchor, anchorMentions: prepared.anchorMentions, facts: prepared.facts,
|
|
3604
|
+
readThroughSequence: prepared.readThroughSequence, remainingUnreadCount: prepared.remainingUnreadCount,
|
|
3605
|
+
earlierFactCount: prepared.earlierFactCount,
|
|
3606
|
+
...(prepared.attention === undefined ? {} : { attention: prepared.attention }),
|
|
3607
|
+
consumedDirectMarkers: prepared.inbox.directMarkers.removed });
|
|
3223
3608
|
}
|
|
3224
3609
|
receipt(operation) {
|
|
3225
3610
|
return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence, occurredAt: operation.occurredAt });
|
|
@@ -3300,10 +3685,17 @@ export class AgentTeamLedger {
|
|
|
3300
3685
|
legacyChannelThreadRefs(projection, channelRef) {
|
|
3301
3686
|
return new Set([...projection.tasks.values()].filter(task => task.channelRef === channelRef).map(task => task.threadRef));
|
|
3302
3687
|
}
|
|
3303
|
-
/**
|
|
3688
|
+
/**
|
|
3689
|
+
* Ledgers written before message occurredAt existed store bare messages;
|
|
3690
|
+
* snapshot-shaped Thread reads resolve instants from the originating
|
|
3691
|
+
* operations. A receipt-shaped read carries no message at all, so it passes
|
|
3692
|
+
* through untouched — the load path must never touch a shape it does not
|
|
3693
|
+
* have, and it never writes either form back.
|
|
3694
|
+
*/
|
|
3304
3695
|
normalizeOperation(operation, occurrences, instants) {
|
|
3305
|
-
if (operation.kind !== 'team/thread-read')
|
|
3696
|
+
if (operation.kind !== 'team/thread-read' || !isThreadReadSnapshot(operation.data))
|
|
3306
3697
|
return operation;
|
|
3698
|
+
const data = operation.data;
|
|
3307
3699
|
const stamp = (message) => (message.occurredAt === undefined
|
|
3308
3700
|
? { ...message, occurredAt: occurrences.get(message.messageRef) ?? operation.occurredAt }
|
|
3309
3701
|
: { ...message, occurredAt: message.occurredAt });
|
|
@@ -3312,8 +3704,8 @@ export class AgentTeamLedger {
|
|
|
3312
3704
|
occurredAt: envelope.occurredAt ?? envelope.message.occurredAt ?? occurrences.get(envelope.message.messageRef) ?? operation.occurredAt }
|
|
3313
3705
|
: { kind: 'activity', sequence: envelope.sequence, activity: envelope.activity,
|
|
3314
3706
|
occurredAt: envelope.occurredAt ?? instants.get(envelope.sequence) ?? operation.occurredAt };
|
|
3315
|
-
const facts =
|
|
3316
|
-
return { ...operation, data: { ...
|
|
3707
|
+
const facts = data.facts.map((fact) => ({ ...fact, fact: stampEnvelope(fact.fact) }));
|
|
3708
|
+
return { ...operation, data: { ...data, anchor: stamp(data.anchor), facts } };
|
|
3317
3709
|
}
|
|
3318
3710
|
enqueue(operation) {
|
|
3319
3711
|
const result = this.operationTail.then(operation);
|