@wowyuarm/dsh-agent-team 0.1.8 → 0.1.10
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 +4 -11
- package/README.zh.md +4 -11
- package/package.json +54 -44
- package/packages/agent-team/README.md +7 -5
- package/packages/agent-team/README.zh.md +7 -4
- package/packages/agent-team/lib/attachments.js +14 -4
- package/packages/agent-team/lib/context-management.js +382 -0
- package/packages/agent-team/lib/context-projection.js +578 -0
- package/packages/agent-team/lib/context-source.js +212 -0
- package/packages/agent-team/lib/index.js +1272 -76
- package/packages/agent-team/lib/ledger.js +333 -44
- package/packages/agent-team/lib/member-context.js +6 -2
- package/packages/agent-team/lib/member-runtime.js +159 -4
- package/packages/agent-team/lib/member-time-context.js +126 -0
- package/packages/agent-team/lib/pressure-policy.js +199 -0
- package/packages/agent-team/lib/progress-nudge.js +12 -4
- package/packages/agent-team/lib/session-remediation.js +481 -0
- package/packages/agent-team/lib/spec.js +21 -2
- package/packages/agent-team/lib/time-format.js +56 -0
- package/packages/agent-team/lib/typert.host.js +168 -54
- package/packages/agent-team/lib/typert.remote-client.d.ts.map +1 -1
- package/packages/agent-team/lib/typert.remote-client.js +59 -25
- package/packages/agent-team/lib/types/attachments.d.ts +1 -1
- package/packages/agent-team/lib/types/attachments.d.ts.map +1 -1
- package/packages/agent-team/lib/types/context-management.d.ts +147 -0
- package/packages/agent-team/lib/types/context-management.d.ts.map +1 -0
- package/packages/agent-team/lib/types/context-projection.d.ts +219 -0
- package/packages/agent-team/lib/types/context-projection.d.ts.map +1 -0
- package/packages/agent-team/lib/types/context-source.d.ts +117 -0
- package/packages/agent-team/lib/types/context-source.d.ts.map +1 -0
- package/packages/agent-team/lib/types/index.d.ts +300 -2
- package/packages/agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/types/ledger.d.ts +119 -6
- 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 +30 -2
- package/packages/agent-team/lib/types/member-runtime.d.ts.map +1 -1
- package/packages/agent-team/lib/types/member-time-context.d.ts +83 -0
- package/packages/agent-team/lib/types/member-time-context.d.ts.map +1 -0
- package/packages/agent-team/lib/types/pressure-policy.d.ts +106 -0
- package/packages/agent-team/lib/types/pressure-policy.d.ts.map +1 -0
- package/packages/agent-team/lib/types/progress-nudge.d.ts.map +1 -1
- package/packages/agent-team/lib/types/session-remediation.d.ts +149 -0
- package/packages/agent-team/lib/types/session-remediation.d.ts.map +1 -0
- package/packages/agent-team/lib/types/spec.d.ts.map +1 -1
- package/packages/agent-team/lib/types/time-format.d.ts +28 -0
- package/packages/agent-team/lib/types/time-format.d.ts.map +1 -0
- package/packages/agent-team/lib/types/types/entities.d.ts +22 -1
- package/packages/agent-team/lib/types/types/entities.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/operations.d.ts +38 -3
- package/packages/agent-team/lib/types/types/operations.d.ts.map +1 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts +58 -1
- package/packages/agent-team/lib/types/types/requests-results.d.ts.map +1 -1
- package/packages/agent-team/preset/team-member/agent.cordis.yml +27 -4
- package/packages/client-agent-team/lib/client.js +369 -352
- package/packages/client-agent-team/lib/client.js.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts +1 -2
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/TeamAgentsPanel.js +61 -41
- package/packages/client-agent-team/lib/types/client/TeamMessage.js +2 -2
- package/packages/client-agent-team/lib/types/client/TeamThreadPage.js +4 -4
- 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 +2 -2
- package/packages/client-agent-team/lib/types/client/index.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/index.js +44 -16
- package/packages/client-agent-team/lib/types/client/locales.d.ts +0 -14
- package/packages/client-agent-team/lib/types/client/locales.d.ts.map +1 -1
- package/packages/client-agent-team/lib/types/client/locales.js +0 -14
- package/packages/client-agent-team/lib/types/client/slots.d.ts +6 -1
- package/packages/client-agent-team/lib/types/client/slots.d.ts.map +1 -1
- package/packages/tool-agent-team/README.md +11 -6
- package/packages/tool-agent-team/README.zh.md +11 -6
- package/packages/tool-agent-team/lib/context-tools.js +179 -0
- package/packages/tool-agent-team/lib/index.js +369 -58
- package/packages/tool-agent-team/lib/types/context-tools.d.ts +16 -0
- package/packages/tool-agent-team/lib/types/context-tools.d.ts.map +1 -0
- package/packages/tool-agent-team/lib/types/index.d.ts.map +1 -1
- package/packages/agent-team/lib/auto-compaction.js +0 -201
- package/packages/agent-team/lib/types/auto-compaction.d.ts +0 -47
- package/packages/agent-team/lib/types/auto-compaction.d.ts.map +0 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { isDeepStrictEqual } from 'node:util';
|
|
3
|
+
import { formatTeamTimestamp } from "./time-format.js";
|
|
3
4
|
/** Stable Human Member identity shared by every replay of one dshHome Team. */
|
|
4
5
|
export const AGENT_TEAM_HUMAN_MEMBER_ID = 'member:human';
|
|
5
6
|
/** Idempotency identity of the one Host bootstrap operation. */
|
|
@@ -17,7 +18,7 @@ function emptyProjection() {
|
|
|
17
18
|
return { byRequest: new Map(), byOperation: new Map(), ordered: [], channels: new Map(), members: new Map(), memberships: new Map(),
|
|
18
19
|
claims: new Map(), messages: [], tasks: new Map(), threads: new Map(), attention: new Map(), directMarkers: new Map(), activityMarkers: new Map(),
|
|
19
20
|
orderedFacts: [], factsByThread: new Map(), channelRefByThread: new Map(), mentionsByMessage: new Map(), messageCountByThread: new Map(),
|
|
20
|
-
attentionByThread: new Map() };
|
|
21
|
+
attentionByThread: new Map(), previousSessions: new Map(), rolloverSeeds: new Map() };
|
|
21
22
|
}
|
|
22
23
|
const EMPTY_PROGRESS_NUDGE_TARGETS = Object.freeze({
|
|
23
24
|
progress: Object.freeze([]),
|
|
@@ -247,9 +248,137 @@ export class AgentTeamLedger {
|
|
|
247
248
|
return this.committed(this.memberResult(operation));
|
|
248
249
|
});
|
|
249
250
|
}
|
|
251
|
+
/**
|
|
252
|
+
* Move one enabled Member onto its next context generation as itself. The
|
|
253
|
+
* actor is the calling Member — the Host executes the transition but is
|
|
254
|
+
* never the business actor — and the durable data records only the
|
|
255
|
+
* verifiable envelope (previous/new Session anchors, the successful handoff
|
|
256
|
+
* tool-result seq, checkpoint seed lineage, trigger). The private handoff
|
|
257
|
+
* prose stays in the Member's own Session log. An exact retry resolves to
|
|
258
|
+
* the recorded outcome; the same requestId with different data collides.
|
|
259
|
+
*/
|
|
260
|
+
rolloverMemberSession(request) {
|
|
261
|
+
return this.enqueue(async () => {
|
|
262
|
+
const existing = this.state.byRequest.get(request.requestId);
|
|
263
|
+
if (existing !== undefined) {
|
|
264
|
+
this.assertSameMemberSessionRolledOver(existing, request);
|
|
265
|
+
return this.resolved(this.memberResult(existing));
|
|
266
|
+
}
|
|
267
|
+
if (request.actor.kind !== 'member')
|
|
268
|
+
throw new Error('Member session rollover requires Member authority');
|
|
269
|
+
const prior = this.requireMember(request.memberId);
|
|
270
|
+
if (request.actor.memberId !== request.memberId)
|
|
271
|
+
throw new Error(`Agent Member '${prior.handle}' cannot roll over another Member's Session`);
|
|
272
|
+
if (prior.state !== 'enabled')
|
|
273
|
+
throw new Error(`Agent Member '${prior.handle}' is ${prior.state}; only enabled Members can roll over their Session`);
|
|
274
|
+
if (prior.sessionId === request.newSessionId)
|
|
275
|
+
throw new Error(`Agent Member '${prior.handle}' already runs Session '${request.newSessionId}'`);
|
|
276
|
+
if (prior.sessionId !== request.previousSessionId)
|
|
277
|
+
throw new Error(`Agent Member '${prior.handle}' is no longer bound to Session '${request.previousSessionId}'`);
|
|
278
|
+
if ((request.checkpointRef !== undefined) !== (request.sourceSessionId !== undefined))
|
|
279
|
+
throw new Error('rollover checkpoint fields must appear together');
|
|
280
|
+
if (request.sourceSessionId !== undefined && request.sourceThroughSeq === undefined)
|
|
281
|
+
throw new Error('rollover checkpoint seed requires a through sequence');
|
|
282
|
+
if (request.sourceThroughSeq !== undefined && request.sourceSessionId === undefined)
|
|
283
|
+
throw new Error('rollover through sequence requires a source Session');
|
|
284
|
+
const member = Object.freeze({ ...prior, sessionId: request.newSessionId });
|
|
285
|
+
const operation = Object.freeze({
|
|
286
|
+
...this.operationBase(request, this.nextSequence()), kind: 'team/member-session-rolled-over',
|
|
287
|
+
data: Object.freeze({
|
|
288
|
+
member,
|
|
289
|
+
previousSessionId: request.previousSessionId,
|
|
290
|
+
newSessionId: request.newSessionId,
|
|
291
|
+
handoffEventSeq: request.handoffEventSeq,
|
|
292
|
+
trigger: request.trigger,
|
|
293
|
+
...(request.sourceSessionId === undefined ? {} : { sourceSessionId: request.sourceSessionId }),
|
|
294
|
+
...(request.sourceThroughSeq === undefined ? {} : { sourceThroughSeq: request.sourceThroughSeq }),
|
|
295
|
+
...(request.checkpointRef === undefined ? {} : { checkpointRef: request.checkpointRef }),
|
|
296
|
+
}),
|
|
297
|
+
});
|
|
298
|
+
await this.table.put(operation.operationId, operation);
|
|
299
|
+
this.apply(operation);
|
|
300
|
+
return this.committed(this.memberResult(operation));
|
|
301
|
+
});
|
|
302
|
+
}
|
|
250
303
|
getMember(memberId) {
|
|
251
304
|
return this.state.members.get(memberId);
|
|
252
305
|
}
|
|
306
|
+
/**
|
|
307
|
+
* The retired Session this Member most recently left through a renewal or
|
|
308
|
+
* rollover — the lineage parent a crash between the durable binding commit
|
|
309
|
+
* and the new Session's activation must reconstruct a handoff from. Read
|
|
310
|
+
* from the audit projection; the Member record itself only names the
|
|
311
|
+
* current Session.
|
|
312
|
+
*/
|
|
313
|
+
previousSessionForMember(memberId) {
|
|
314
|
+
return this.state.previousSessions.get(memberId);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* The latest session transition this Member committed: the retired Session
|
|
318
|
+
* and the target it moved onto, through either a renewal or a rollover.
|
|
319
|
+
* Crash recovery binds carried-input redelivery to the recorded target —
|
|
320
|
+
* the CURRENT Session being that target proves the old generation's
|
|
321
|
+
* unconsumed input still belongs here, regardless of whether the handoff
|
|
322
|
+
* itself already landed.
|
|
323
|
+
*/
|
|
324
|
+
lastTransitionForMember(memberId) {
|
|
325
|
+
const previousSessionId = this.state.previousSessions.get(memberId);
|
|
326
|
+
const member = this.state.members.get(memberId);
|
|
327
|
+
if (previousSessionId === undefined || member === undefined)
|
|
328
|
+
return undefined;
|
|
329
|
+
return { previousSessionId, targetSessionId: member.sessionId };
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* The Thread a Task overlay lives on, or undefined for an unknown or
|
|
333
|
+
* archived-Channel Task. Read-only input for attributing claim-mutation
|
|
334
|
+
* boundaries to the Thread whose context they entered.
|
|
335
|
+
*/
|
|
336
|
+
threadForTask(taskRef) {
|
|
337
|
+
const task = this.state.tasks.get(taskRef);
|
|
338
|
+
if (task === undefined)
|
|
339
|
+
return undefined;
|
|
340
|
+
return this.state.channels.get(task.channelRef)?.state === 'archived' ? undefined : task.threadRef;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* The seed envelope of this Member's most recent rollover, when that
|
|
344
|
+
* rollover was a checkpoint return: the recorded source Session, the
|
|
345
|
+
* exclusive end of its seeded prefix, and the checkpoint ref. A fresh
|
|
346
|
+
* rollover clears the envelope. Crash recovery reads this to rebuild the
|
|
347
|
+
* child generation from the recorded seed instead of an empty context.
|
|
348
|
+
*/
|
|
349
|
+
rolloverSeedForMember(memberId, targetSessionId) {
|
|
350
|
+
const seed = this.state.rolloverSeeds.get(memberId);
|
|
351
|
+
// Bind the envelope to its recorded target: a later renewal or fresh
|
|
352
|
+
// rollover that reused or replaced the binding must never consume an
|
|
353
|
+
// older generation's seed.
|
|
354
|
+
return seed?.targetSessionId === targetSessionId ? seed : undefined;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Count one Member's active Claims on open Tasks in active Channels — the
|
|
358
|
+
* read-only input to the checkpoint single-Thread coverage guard: with
|
|
359
|
+
* more than one, a rewind cannot be proven to stay inside one Thread.
|
|
360
|
+
*/
|
|
361
|
+
activeClaimCountForMember(memberId) {
|
|
362
|
+
return this.activeClaimsForMember(memberId).length;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* One Member's active Claims on open Tasks in active Channels, oldest
|
|
366
|
+
* first — the read-only input for the pressure notice's Claim labels.
|
|
367
|
+
*/
|
|
368
|
+
activeClaimsForMember(memberId) {
|
|
369
|
+
const active = [];
|
|
370
|
+
for (const claim of this.state.claims.values()) {
|
|
371
|
+
if (claim.owner !== memberId || claim.state !== 'active')
|
|
372
|
+
continue;
|
|
373
|
+
const task = this.state.tasks.get(claim.taskRef);
|
|
374
|
+
if (task === undefined || task.resolution !== 'open')
|
|
375
|
+
continue;
|
|
376
|
+
if (this.state.channels.get(task.channelRef)?.state === 'archived')
|
|
377
|
+
continue;
|
|
378
|
+
active.push(claim);
|
|
379
|
+
}
|
|
380
|
+
return active.sort((a, b) => a.claimRef.localeCompare(b.claimRef));
|
|
381
|
+
}
|
|
253
382
|
/**
|
|
254
383
|
* Current progress-nudge candidacy for one Member: threads whose Thread
|
|
255
384
|
* progress reminder applies (active Claim, or following a taskless Thread)
|
|
@@ -666,6 +795,15 @@ export class AgentTeamLedger {
|
|
|
666
795
|
: claim);
|
|
667
796
|
const releasedClaims = claims.filter(claim => claim.state === 'released' && this.state.claims.get(claim.claimRef)?.state === 'active');
|
|
668
797
|
const completedClaims = claims.filter(claim => claim.state === 'done' && this.state.claims.get(claim.claimRef)?.state === 'active');
|
|
798
|
+
// Every done Claim at commit time — pre-finished and atomically
|
|
799
|
+
// completed alike — is the acceptance discriminator: the one list
|
|
800
|
+
// accept markers deliver from, so a normal accept no longer stays
|
|
801
|
+
// silent about the Members whose work it accepted. Sorted lexically
|
|
802
|
+
// so replay validation compares stably regardless of claim-ref
|
|
803
|
+
// generation order.
|
|
804
|
+
const acceptedClaims = request.action === 'accept'
|
|
805
|
+
? claims.filter(claim => claim.state === 'done').sort((left, right) => left.claimRef.localeCompare(right.claimRef))
|
|
806
|
+
: [];
|
|
669
807
|
const resolution = request.action === 'reopen' ? 'open' : request.action === 'accept' ? 'accepted' : 'closed';
|
|
670
808
|
const status = resolution === 'accepted' ? 'done' : resolution === 'closed' ? 'closed'
|
|
671
809
|
: this.deriveTaskStatus(task.taskRef, claims);
|
|
@@ -674,12 +812,13 @@ export class AgentTeamLedger {
|
|
|
674
812
|
const activity = Object.freeze({ activityRef: this.ref('activity'), kind: request.action,
|
|
675
813
|
taskRef: task.taskRef, threadRef: task.threadRef, actor: request.actor.memberId, sequence,
|
|
676
814
|
...(releasedClaims.length === 0 ? {} : { releasedClaimRefs: Object.freeze(releasedClaims.map(claim => claim.claimRef)) }),
|
|
677
|
-
...(completedClaims.length === 0 ? {} : { completedClaimRefs: Object.freeze(completedClaims.map(claim => claim.claimRef)) })
|
|
815
|
+
...(completedClaims.length === 0 ? {} : { completedClaimRefs: Object.freeze(completedClaims.map(claim => claim.claimRef)) }),
|
|
816
|
+
...(request.action === 'accept' ? { acceptedClaimRefs: Object.freeze(acceptedClaims.map(claim => claim.claimRef)) } : {}) });
|
|
678
817
|
const inbox = request.action === 'close'
|
|
679
818
|
? this.closeThreadInbox(activity, thread.threadRef)
|
|
680
819
|
: request.action === 'reopen'
|
|
681
820
|
? this.reopenThreadInbox(activity, thread.threadRef)
|
|
682
|
-
: this.acceptThreadInbox(activity, thread.threadRef,
|
|
821
|
+
: this.acceptThreadInbox(activity, thread.threadRef, acceptedClaims.map(claim => claim.owner));
|
|
683
822
|
const operation = Object.freeze({
|
|
684
823
|
...this.operationBase(request, sequence), kind: 'team/task-changed',
|
|
685
824
|
data: Object.freeze({ workspaceId: request.workspaceId, baseRevision: request.baseRevision,
|
|
@@ -871,7 +1010,7 @@ export class AgentTeamLedger {
|
|
|
871
1010
|
// mirror: the reader sent it to the other Member → (you → them).
|
|
872
1011
|
const direction = pair ? 'them → you' : 'you → them';
|
|
873
1012
|
const truncated = operation.data.body.length > 160 ? `${operation.data.body.slice(0, 160)}…` : operation.data.body;
|
|
874
|
-
return `(${direction}) ${truncated}`;
|
|
1013
|
+
return `(${direction}, at ${formatTeamTimestamp(operation.occurredAt)}) ${truncated}`;
|
|
875
1014
|
}
|
|
876
1015
|
return undefined;
|
|
877
1016
|
}
|
|
@@ -901,8 +1040,12 @@ export class AgentTeamLedger {
|
|
|
901
1040
|
const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
|
|
902
1041
|
const directCount = unread.filter(item => item.direct).length;
|
|
903
1042
|
const attention = this.attentionFor(authorized.memberId, thread.threadRef);
|
|
1043
|
+
// Same snapshot, same source as newestSequence: the instant hangs off
|
|
1044
|
+
// the newest unread fact itself, never a second lookup that could
|
|
1045
|
+
// observe a different commit between the two reads.
|
|
1046
|
+
const newest = unread.at(-1).fact;
|
|
904
1047
|
items.push(Object.freeze({ channelRef, ...(task === undefined ? {} : { task }), thread, unreadCount: unread.length, directCount,
|
|
905
|
-
newestSequence:
|
|
1048
|
+
newestSequence: newest.sequence, newestOccurredAt: newest.occurredAt, ...(attention === undefined ? {} : { attention }) }));
|
|
906
1049
|
}
|
|
907
1050
|
items.sort((left, right) => right.directCount - left.directCount || right.newestSequence - left.newestSequence || left.thread.threadRef.localeCompare(right.thread.threadRef));
|
|
908
1051
|
const selected = items.slice(0, limit);
|
|
@@ -1097,8 +1240,10 @@ export class AgentTeamLedger {
|
|
|
1097
1240
|
const message = fact.message;
|
|
1098
1241
|
const thread = this.requireThread(message.threadRef);
|
|
1099
1242
|
const task = thread.taskRef === undefined ? undefined : this.state.tasks.get(thread.taskRef);
|
|
1243
|
+
const facts = this.state.factsByThread.get(thread.threadRef) ?? [];
|
|
1100
1244
|
return Object.freeze({ message, mentions: fact.mentions, ...(task === undefined ? {} : { task, taskNumber: taskNumbers.get(task.taskRef) ?? 0 }), thread,
|
|
1101
|
-
messageCount: this.state.messageCountByThread.get(thread.threadRef) ?? 0
|
|
1245
|
+
messageCount: this.state.messageCountByThread.get(thread.threadRef) ?? 0,
|
|
1246
|
+
lastActivityAt: facts.at(-1)?.occurredAt ?? '' });
|
|
1102
1247
|
});
|
|
1103
1248
|
const initialization = this.initialization();
|
|
1104
1249
|
const nextCursor = selected.length === 0 ? cursor : direction === 'before' ? selected[0].sequence : selected.at(-1).sequence;
|
|
@@ -1155,6 +1300,7 @@ export class AgentTeamLedger {
|
|
|
1155
1300
|
case 'team/member-session-restarted':
|
|
1156
1301
|
case 'team/member-context-cleared':
|
|
1157
1302
|
case 'team/member-session-renewed':
|
|
1303
|
+
case 'team/member-session-rolled-over':
|
|
1158
1304
|
case 'team/member-updated':
|
|
1159
1305
|
case 'team/member-removed':
|
|
1160
1306
|
return [{ kind: 'workspace', workspaceId: operation.data.member.workspaceId }];
|
|
@@ -1275,6 +1421,7 @@ export class AgentTeamLedger {
|
|
|
1275
1421
|
case 'team/member-session-restarted':
|
|
1276
1422
|
case 'team/member-context-cleared':
|
|
1277
1423
|
case 'team/member-session-renewed':
|
|
1424
|
+
case 'team/member-session-rolled-over':
|
|
1278
1425
|
case 'team/member-updated':
|
|
1279
1426
|
case 'team/channel-member-added':
|
|
1280
1427
|
case 'team/thread-read':
|
|
@@ -1407,6 +1554,29 @@ export class AgentTeamLedger {
|
|
|
1407
1554
|
throw new Error('invalid Member session renewal');
|
|
1408
1555
|
return;
|
|
1409
1556
|
}
|
|
1557
|
+
if (operation.kind === 'team/member-session-rolled-over') {
|
|
1558
|
+
// The rollover's business actor is the calling Member itself; the Host
|
|
1559
|
+
// only executes the recorded transition. Authorization is self-scoped:
|
|
1560
|
+
// the actor must be the enabled target Member on its currently bound
|
|
1561
|
+
// Session, and the recorded transition moves exactly the sessionId.
|
|
1562
|
+
const actorMember = assertMember();
|
|
1563
|
+
const prior = projection.members.get(operation.data.member.memberId);
|
|
1564
|
+
if (actorMember.memberId !== operation.data.member.memberId)
|
|
1565
|
+
throw new Error('rolled-over Member must match its actor');
|
|
1566
|
+
if (prior === undefined || prior.state !== 'enabled'
|
|
1567
|
+
|| prior.sessionId !== operation.data.previousSessionId
|
|
1568
|
+
|| operation.data.member.sessionId !== operation.data.newSessionId
|
|
1569
|
+
|| operation.data.member.sessionId === operation.data.previousSessionId
|
|
1570
|
+
|| !this.sameMemberFacts(prior, operation.data.member))
|
|
1571
|
+
throw new Error('invalid Member session rollover');
|
|
1572
|
+
if (operation.data.sourceSessionId !== undefined && operation.data.sourceThroughSeq === undefined)
|
|
1573
|
+
throw new Error('rolled-over checkpoint seed requires a through sequence');
|
|
1574
|
+
if (operation.data.sourceThroughSeq !== undefined && operation.data.sourceSessionId === undefined)
|
|
1575
|
+
throw new Error('rolled-over through sequence requires a source Session');
|
|
1576
|
+
if ((operation.data.checkpointRef !== undefined) !== (operation.data.sourceSessionId !== undefined))
|
|
1577
|
+
throw new Error('rolled-over checkpoint fields must appear together');
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1410
1580
|
if (operation.kind === 'team/channel-updated') {
|
|
1411
1581
|
assertHuman();
|
|
1412
1582
|
const prior = projection.channels.get(operation.data.channel.channelRef);
|
|
@@ -1640,13 +1810,24 @@ export class AgentTeamLedger {
|
|
|
1640
1810
|
const expectedCompletedClaimRefs = operation.data.claims
|
|
1641
1811
|
.filter(claim => projection.claims.get(claim.claimRef)?.state === 'active' && claim.state === 'done')
|
|
1642
1812
|
.map(claim => claim.claimRef);
|
|
1813
|
+
// New-shape accepts carry the acceptance discriminator: every done
|
|
1814
|
+
// Claim at commit time, sorted. Legacy accepts predate it; the
|
|
1815
|
+
// recorded wake semantics for them stay completed-only (the old rule)
|
|
1816
|
+
// so every existing ledger replays unchanged.
|
|
1817
|
+
const expectedAcceptedClaimRefs = operation.data.claims
|
|
1818
|
+
.filter(claim => claim.state === 'done')
|
|
1819
|
+
.map(claim => claim.claimRef)
|
|
1820
|
+
.sort((left, right) => left.localeCompare(right));
|
|
1643
1821
|
if (task.resolution !== expectedResolution || task.status !== expectedStatus
|
|
1644
1822
|
|| (activity.kind !== 'close' && activity.releasedClaimRefs !== undefined)
|
|
1645
1823
|
|| (activity.kind !== 'accept' && activity.completedClaimRefs !== undefined)
|
|
1824
|
+
|| (activity.kind !== 'accept' && activity.acceptedClaimRefs !== undefined)
|
|
1646
1825
|
|| (activity.kind === 'close' && !this.sameList(activity.releasedClaimRefs ?? [], operation.data.claims
|
|
1647
1826
|
.filter(claim => projection.claims.get(claim.claimRef)?.state === 'active' && claim.state === 'released')
|
|
1648
1827
|
.map(claim => claim.claimRef)))
|
|
1649
|
-
|| !this.sameList(activity.completedClaimRefs ?? [], expectedCompletedClaimRefs)
|
|
1828
|
+
|| !this.sameList(activity.completedClaimRefs ?? [], expectedCompletedClaimRefs)
|
|
1829
|
+
|| (activity.kind === 'accept' && activity.acceptedClaimRefs !== undefined
|
|
1830
|
+
&& !this.sameList(activity.acceptedClaimRefs, expectedAcceptedClaimRefs))) {
|
|
1650
1831
|
throw new Error('invalid Task state transition');
|
|
1651
1832
|
}
|
|
1652
1833
|
const priorClaims = [...projection.claims.values()].filter(claim => claim.taskRef === task.taskRef).sort((left, right) => left.claimRef.localeCompare(right.claimRef));
|
|
@@ -1670,7 +1851,10 @@ export class AgentTeamLedger {
|
|
|
1670
1851
|
? this.closeThreadInboxFrom(projection, activity, thread.threadRef)
|
|
1671
1852
|
: activity.kind === 'reopen'
|
|
1672
1853
|
? this.reopenThreadInboxFrom(projection, activity, thread.threadRef)
|
|
1673
|
-
|
|
1854
|
+
// Presence of the discriminator picks the wake rule: new accepts
|
|
1855
|
+
// wake every done owner; legacy records keep the completed-only
|
|
1856
|
+
// delta exactly as recorded.
|
|
1857
|
+
: this.acceptThreadInboxFrom(projection, activity, thread.threadRef, activity.acceptedClaimRefs !== undefined ? activity.acceptedClaimRefs : expectedCompletedClaimRefs);
|
|
1674
1858
|
if (!isDeepStrictEqual(operation.data.inbox, expectedInbox))
|
|
1675
1859
|
throw new Error('invalid Task inbox projection');
|
|
1676
1860
|
this.validateInboxDelta(operation.data.inbox, projection, refs, [], [], [activity]);
|
|
@@ -1938,6 +2122,25 @@ export class AgentTeamLedger {
|
|
|
1938
2122
|
}
|
|
1939
2123
|
if (operation.kind === 'team/member-session-renewed') {
|
|
1940
2124
|
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
2125
|
+
target.previousSessions.set(operation.data.member.memberId, operation.data.previousSessionId);
|
|
2126
|
+
// A Human-side renewal is always a fresh start: a stale checkpoint
|
|
2127
|
+
// seed from an earlier rollover must never re-seed it.
|
|
2128
|
+
target.rolloverSeeds.delete(operation.data.member.memberId);
|
|
2129
|
+
return;
|
|
2130
|
+
}
|
|
2131
|
+
if (operation.kind === 'team/member-session-rolled-over') {
|
|
2132
|
+
target.members.set(operation.data.member.memberId, operation.data.member);
|
|
2133
|
+
target.previousSessions.set(operation.data.member.memberId, operation.data.previousSessionId);
|
|
2134
|
+
// A checkpoint return records its seed envelope: a crash between this
|
|
2135
|
+
// commit and the new Session's activation rebuilds the child from the
|
|
2136
|
+
// recorded source instead of an empty context.
|
|
2137
|
+
const { sourceSessionId, sourceThroughSeq, checkpointRef } = operation.data;
|
|
2138
|
+
if (sourceSessionId !== undefined && sourceThroughSeq !== undefined && checkpointRef !== undefined) {
|
|
2139
|
+
target.rolloverSeeds.set(operation.data.member.memberId, { targetSessionId: operation.data.newSessionId, sourceSessionId, sourceThroughSeq, checkpointRef });
|
|
2140
|
+
}
|
|
2141
|
+
else {
|
|
2142
|
+
target.rolloverSeeds.delete(operation.data.member.memberId);
|
|
2143
|
+
}
|
|
1941
2144
|
return;
|
|
1942
2145
|
}
|
|
1943
2146
|
if (operation.kind === 'team/channel-updated') {
|
|
@@ -1957,7 +2160,7 @@ export class AgentTeamLedger {
|
|
|
1957
2160
|
for (const claim of operation.data.claims)
|
|
1958
2161
|
target.claims.set(claim.claimRef, claim);
|
|
1959
2162
|
for (const activity of operation.data.activities)
|
|
1960
|
-
this.appendActivityFact(target, activity);
|
|
2163
|
+
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
1961
2164
|
for (const task of operation.data.tasks)
|
|
1962
2165
|
target.tasks.set(task.taskRef, task);
|
|
1963
2166
|
for (const thread of operation.data.threads)
|
|
@@ -1973,7 +2176,7 @@ export class AgentTeamLedger {
|
|
|
1973
2176
|
for (const claim of operation.data.claims)
|
|
1974
2177
|
target.claims.set(claim.claimRef, claim);
|
|
1975
2178
|
for (const activity of operation.data.activities)
|
|
1976
|
-
this.appendActivityFact(target, activity);
|
|
2179
|
+
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
1977
2180
|
for (const task of operation.data.tasks)
|
|
1978
2181
|
target.tasks.set(task.taskRef, task);
|
|
1979
2182
|
for (const thread of operation.data.threads)
|
|
@@ -1988,7 +2191,7 @@ export class AgentTeamLedger {
|
|
|
1988
2191
|
for (const claim of operation.data.claims)
|
|
1989
2192
|
target.claims.set(claim.claimRef, claim);
|
|
1990
2193
|
for (const activity of operation.data.activities)
|
|
1991
|
-
this.appendActivityFact(target, activity);
|
|
2194
|
+
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
1992
2195
|
for (const task of operation.data.tasks)
|
|
1993
2196
|
target.tasks.set(task.taskRef, task);
|
|
1994
2197
|
for (const thread of operation.data.threads)
|
|
@@ -2004,7 +2207,7 @@ export class AgentTeamLedger {
|
|
|
2004
2207
|
for (const claim of operation.data.claims)
|
|
2005
2208
|
target.claims.set(claim.claimRef, claim);
|
|
2006
2209
|
for (const activity of operation.data.activities)
|
|
2007
|
-
this.appendActivityFact(target, activity);
|
|
2210
|
+
this.appendActivityFact(target, activity, operation.occurredAt);
|
|
2008
2211
|
for (const task of operation.data.tasks)
|
|
2009
2212
|
target.tasks.set(task.taskRef, task);
|
|
2010
2213
|
for (const thread of operation.data.threads)
|
|
@@ -2013,7 +2216,7 @@ export class AgentTeamLedger {
|
|
|
2013
2216
|
return;
|
|
2014
2217
|
}
|
|
2015
2218
|
if (operation.kind === 'team/thread-promoted') {
|
|
2016
|
-
this.appendActivityFact(target, operation.data.activity);
|
|
2219
|
+
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2017
2220
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2018
2221
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2019
2222
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
@@ -2023,7 +2226,7 @@ export class AgentTeamLedger {
|
|
|
2023
2226
|
const { message, mentions } = operation.data;
|
|
2024
2227
|
target.messages.push(message);
|
|
2025
2228
|
target.mentionsByMessage.set(message.messageRef, Object.freeze([...mentions]));
|
|
2026
|
-
this.appendMessageFact(target, message, mentions);
|
|
2229
|
+
this.appendMessageFact(target, message, mentions, message.occurredAt ?? operation.occurredAt);
|
|
2027
2230
|
if (operation.data.task !== undefined)
|
|
2028
2231
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2029
2232
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
@@ -2034,7 +2237,7 @@ export class AgentTeamLedger {
|
|
|
2034
2237
|
}
|
|
2035
2238
|
if (operation.kind === 'team/claim-created' || operation.kind === 'team/claim-done' || operation.kind === 'team/claim-released') {
|
|
2036
2239
|
target.claims.set(operation.data.claim.claimRef, operation.data.claim);
|
|
2037
|
-
this.appendActivityFact(target, operation.data.activity);
|
|
2240
|
+
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2038
2241
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2039
2242
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2040
2243
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
@@ -2043,7 +2246,7 @@ export class AgentTeamLedger {
|
|
|
2043
2246
|
if (operation.kind === 'team/task-changed') {
|
|
2044
2247
|
for (const claim of operation.data.claims)
|
|
2045
2248
|
target.claims.set(claim.claimRef, claim);
|
|
2046
|
-
this.appendActivityFact(target, operation.data.activity);
|
|
2249
|
+
this.appendActivityFact(target, operation.data.activity, operation.occurredAt);
|
|
2047
2250
|
target.tasks.set(operation.data.task.taskRef, operation.data.task);
|
|
2048
2251
|
target.threads.set(operation.data.thread.threadRef, operation.data.thread);
|
|
2049
2252
|
this.applyInboxDelta(target, operation.data.inbox);
|
|
@@ -2061,16 +2264,16 @@ export class AgentTeamLedger {
|
|
|
2061
2264
|
assertUnhandledKind(operation);
|
|
2062
2265
|
}
|
|
2063
2266
|
/** Facts arrive in ledger sequence order, so global and per-thread lists stay sorted by append only. */
|
|
2064
|
-
appendMessageFact(target, message, mentions) {
|
|
2065
|
-
const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions });
|
|
2267
|
+
appendMessageFact(target, message, mentions, occurredAt) {
|
|
2268
|
+
const fact = Object.freeze({ kind: 'message', sequence: message.sequence, message, mentions, occurredAt });
|
|
2066
2269
|
target.orderedFacts.push(fact);
|
|
2067
2270
|
const facts = target.factsByThread.get(message.threadRef) ?? [];
|
|
2068
2271
|
facts.push(fact);
|
|
2069
2272
|
target.factsByThread.set(message.threadRef, facts);
|
|
2070
2273
|
target.messageCountByThread.set(message.threadRef, (target.messageCountByThread.get(message.threadRef) ?? 0) + 1);
|
|
2071
2274
|
}
|
|
2072
|
-
appendActivityFact(target, activity) {
|
|
2073
|
-
const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity });
|
|
2275
|
+
appendActivityFact(target, activity, occurredAt) {
|
|
2276
|
+
const fact = Object.freeze({ kind: 'activity', sequence: activity.sequence, activity, occurredAt });
|
|
2074
2277
|
target.orderedFacts.push(fact);
|
|
2075
2278
|
const facts = target.factsByThread.get(activity.threadRef) ?? [];
|
|
2076
2279
|
facts.push(fact);
|
|
@@ -2122,7 +2325,8 @@ export class AgentTeamLedger {
|
|
|
2122
2325
|
const background = firstRead
|
|
2123
2326
|
? projection.messages.filter(message => message.threadRef === thread.threadRef && message.sequence < attention.startSequence)
|
|
2124
2327
|
.map(message => Object.freeze({ kind: 'message', sequence: message.sequence, message,
|
|
2125
|
-
mentions: projection.mentionsByMessage.get(message.messageRef) ?? []
|
|
2328
|
+
mentions: projection.mentionsByMessage.get(message.messageRef) ?? [],
|
|
2329
|
+
occurredAt: this.occurredAtForFactFrom(projection, message.sequence, message.occurredAt) }))
|
|
2126
2330
|
.filter(fact => !unreadFactKeys.has(this.threadFactKey(fact))).slice(-12)
|
|
2127
2331
|
: [];
|
|
2128
2332
|
const combined = [...background.map(fact => this.readFactFrom(projection, memberId, fact, false)), ...unreadFacts]
|
|
@@ -2197,6 +2401,23 @@ export class AgentTeamLedger {
|
|
|
2197
2401
|
threadFactsFrom(projection, threadRef) {
|
|
2198
2402
|
return projection.factsByThread.get(threadRef) ?? [];
|
|
2199
2403
|
}
|
|
2404
|
+
/**
|
|
2405
|
+
* The one per-fact instant projection: a fact's wall-clock time is the
|
|
2406
|
+
* occurredAt of the ledger operation that committed it, looked up by
|
|
2407
|
+
* sequence inside the same read snapshot. Every agent-facing surface
|
|
2408
|
+
* (Thread facts, notifications, DM history, mutation results) resolves
|
|
2409
|
+
* through this single path, never a second parallel lookup.
|
|
2410
|
+
*/
|
|
2411
|
+
occurredAtForFactFrom(projection, sequence, fallback) {
|
|
2412
|
+
if (fallback !== undefined)
|
|
2413
|
+
return fallback;
|
|
2414
|
+
// Facts arrive in ledger sequence order and `ordered` is append-only, so
|
|
2415
|
+
// the entry at index sequence - 1 is the committing operation. The index
|
|
2416
|
+
// read is guarded for hypothetical projections whose prefix has not yet
|
|
2417
|
+
// caught up; those callers always pass an explicit fallback instead.
|
|
2418
|
+
const operation = projection.ordered[sequence - 1];
|
|
2419
|
+
return operation?.occurredAt ?? '';
|
|
2420
|
+
}
|
|
2200
2421
|
messageInboxDelta(message, sender, mentions, attention) {
|
|
2201
2422
|
const markers = mentions.map(memberId => Object.freeze({ memberId, threadRef: message.threadRef,
|
|
2202
2423
|
messageRef: message.messageRef, sequence: message.sequence }));
|
|
@@ -2216,22 +2437,24 @@ export class AgentTeamLedger {
|
|
|
2216
2437
|
return this.inboxDelta([], removed, [], [...projection.directMarkers.values()].filter(marker => marker.threadRef === threadRef), activityMarkers, [...projection.activityMarkers.values()].filter(marker => marker.threadRef === threadRef));
|
|
2217
2438
|
}
|
|
2218
2439
|
/**
|
|
2219
|
-
*
|
|
2220
|
-
*
|
|
2221
|
-
*
|
|
2440
|
+
* Acceptance notifies every done Claim owner — pre-finished (the normal
|
|
2441
|
+
* flow) and atomically completed (early acceptance) alike — through
|
|
2442
|
+
* activity markers regardless of whether they still follow the Thread.
|
|
2443
|
+
* The actor never notifies itself. Legacy records without the acceptance
|
|
2444
|
+
* discriminator keep the old completed-only wake semantics on replay.
|
|
2222
2445
|
*/
|
|
2223
|
-
acceptThreadInbox(activity, threadRef,
|
|
2224
|
-
if (
|
|
2446
|
+
acceptThreadInbox(activity, threadRef, acceptedOwners) {
|
|
2447
|
+
if (acceptedOwners.length === 0)
|
|
2225
2448
|
return this.inboxDelta();
|
|
2226
|
-
const recipients = [...new Set(
|
|
2449
|
+
const recipients = [...new Set(acceptedOwners)].filter(memberId => memberId !== activity.actor);
|
|
2227
2450
|
const markers = recipients.map(memberId => Object.freeze({ memberId, threadRef,
|
|
2228
2451
|
activityRef: activity.activityRef, sequence: activity.sequence }));
|
|
2229
2452
|
return this.inboxDelta([], [], [], [], markers);
|
|
2230
2453
|
}
|
|
2231
|
-
acceptThreadInboxFrom(projection, activity, threadRef,
|
|
2232
|
-
if (
|
|
2454
|
+
acceptThreadInboxFrom(projection, activity, threadRef, claimRefs) {
|
|
2455
|
+
if (claimRefs.length === 0)
|
|
2233
2456
|
return this.inboxDelta();
|
|
2234
|
-
const owners =
|
|
2457
|
+
const owners = claimRefs.map(claimRef => projection.claims.get(claimRef)?.owner).filter(owner => owner !== undefined);
|
|
2235
2458
|
return this.acceptThreadInbox(activity, threadRef, owners);
|
|
2236
2459
|
}
|
|
2237
2460
|
reopenThreadInbox(activity, threadRef) {
|
|
@@ -2256,18 +2479,24 @@ export class AgentTeamLedger {
|
|
|
2256
2479
|
return this.inboxDelta([], [], [], [], markers);
|
|
2257
2480
|
}
|
|
2258
2481
|
removeMemberThreadInbox(memberId, threadRefs) {
|
|
2259
|
-
|
|
2482
|
+
return this.removeMemberThreadInboxFrom(this.state, memberId, threadRefs);
|
|
2483
|
+
}
|
|
2484
|
+
removeMemberThreadInboxFrom(projection, memberId, threadRefs) {
|
|
2485
|
+
const removed = [...projection.attention.values()].filter(attention => attention.memberId === memberId && threadRefs.has(attention.threadRef))
|
|
2260
2486
|
.map(attention => Object.freeze({ memberId, threadRef: attention.threadRef }));
|
|
2261
|
-
const markers = [...
|
|
2262
|
-
const activityMarkers = [...
|
|
2487
|
+
const markers = [...projection.directMarkers.values()].filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
2488
|
+
const activityMarkers = [...projection.activityMarkers.values()].filter(marker => marker.memberId === memberId && threadRefs.has(marker.threadRef));
|
|
2263
2489
|
return this.inboxDelta([], removed, [], markers, [], activityMarkers);
|
|
2264
2490
|
}
|
|
2265
2491
|
/** Attention and marker cleanup for EVERY Member on the given Threads. */
|
|
2266
2492
|
channelArchivalInbox(threadRefs) {
|
|
2267
|
-
|
|
2493
|
+
return this.channelArchivalInboxFrom(this.state, threadRefs);
|
|
2494
|
+
}
|
|
2495
|
+
channelArchivalInboxFrom(projection, threadRefs) {
|
|
2496
|
+
const removed = [...projection.attention.values()].filter(attention => threadRefs.has(attention.threadRef))
|
|
2268
2497
|
.map(attention => Object.freeze({ memberId: attention.memberId, threadRef: attention.threadRef }));
|
|
2269
|
-
const markers = [...
|
|
2270
|
-
const activityMarkers = [...
|
|
2498
|
+
const markers = [...projection.directMarkers.values()].filter(marker => threadRefs.has(marker.threadRef));
|
|
2499
|
+
const activityMarkers = [...projection.activityMarkers.values()].filter(marker => threadRefs.has(marker.threadRef));
|
|
2271
2500
|
return this.inboxDelta([], removed, [], markers, [], activityMarkers);
|
|
2272
2501
|
}
|
|
2273
2502
|
inboxDelta(set = [], removed = [], addedMarkers = [], removedMarkers = [], addedActivityMarkers = [], removedActivityMarkers = []) {
|
|
@@ -2666,8 +2895,12 @@ export class AgentTeamLedger {
|
|
|
2666
2895
|
isChannelMemberFrom(projection, channelRef, memberId) {
|
|
2667
2896
|
return projection.memberships.get(channelRef)?.has(memberId) === true;
|
|
2668
2897
|
}
|
|
2898
|
+
/** Every Thread of the Channel, taskful or taskless — the scope both cleanup validators replay against. */
|
|
2669
2899
|
channelThreadRefs(channelRef) {
|
|
2670
|
-
return
|
|
2900
|
+
return this.channelThreadRefsFrom(this.state, channelRef);
|
|
2901
|
+
}
|
|
2902
|
+
channelThreadRefsFrom(projection, channelRef) {
|
|
2903
|
+
return new Set([...projection.threads.keys()].filter(threadRef => this.channelRefForThreadFrom(projection, threadRef) === channelRef));
|
|
2671
2904
|
}
|
|
2672
2905
|
assertJoinableMember(workspaceId, memberId) {
|
|
2673
2906
|
const member = this.requireMember(memberId);
|
|
@@ -2762,6 +2995,16 @@ export class AgentTeamLedger {
|
|
|
2762
2995
|
|| operation.data.member.memberId !== request.memberId || operation.data.member.sessionId !== request.sessionId)
|
|
2763
2996
|
this.throwRequestCollision(request.requestId);
|
|
2764
2997
|
}
|
|
2998
|
+
assertSameMemberSessionRolledOver(operation, request) {
|
|
2999
|
+
if (operation.kind !== 'team/member-session-rolled-over' || !this.sameActor(operation.actor, request.actor)
|
|
3000
|
+
|| operation.data.member.memberId !== request.memberId || operation.data.member.sessionId !== request.newSessionId
|
|
3001
|
+
|| operation.data.previousSessionId !== request.previousSessionId || operation.data.handoffEventSeq !== request.handoffEventSeq
|
|
3002
|
+
|| operation.data.trigger !== request.trigger
|
|
3003
|
+
|| operation.data.sourceSessionId !== request.sourceSessionId
|
|
3004
|
+
|| operation.data.sourceThroughSeq !== request.sourceThroughSeq
|
|
3005
|
+
|| operation.data.checkpointRef !== request.checkpointRef)
|
|
3006
|
+
this.throwRequestCollision(request.requestId);
|
|
3007
|
+
}
|
|
2765
3008
|
assertSameChannelUpdate(operation, request) {
|
|
2766
3009
|
if (operation.kind !== 'team/channel-updated' || !this.sameActor(operation.actor, request.actor)
|
|
2767
3010
|
|| operation.data.workspaceId !== request.workspaceId || operation.data.channel.channelRef !== request.channelRef
|
|
@@ -2979,7 +3222,7 @@ export class AgentTeamLedger {
|
|
|
2979
3222
|
consumedDirectMarkers: operation.data.inbox.directMarkers.removed });
|
|
2980
3223
|
}
|
|
2981
3224
|
receipt(operation) {
|
|
2982
|
-
return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence });
|
|
3225
|
+
return Object.freeze({ operationId: operation.operationId, requestId: operation.requestId, sequence: operation.sequence, occurredAt: operation.occurredAt });
|
|
2983
3226
|
}
|
|
2984
3227
|
removalResult(operation) {
|
|
2985
3228
|
return Object.freeze({ receipt: this.receipt(operation), member: operation.data.member,
|
|
@@ -3006,24 +3249,70 @@ export class AgentTeamLedger {
|
|
|
3006
3249
|
sortedRecords() {
|
|
3007
3250
|
const records = [...this.table.entries()].sort((left, right) => left[1].sequence - right[1].sequence);
|
|
3008
3251
|
const occurrences = new Map();
|
|
3252
|
+
const instants = new Map();
|
|
3009
3253
|
for (const [, operation] of records) {
|
|
3254
|
+
instants.set(operation.sequence, operation.occurredAt);
|
|
3010
3255
|
if (operation.kind === 'team/message-sent' || operation.kind === 'team/thread-replied') {
|
|
3011
3256
|
occurrences.set(operation.data.message.messageRef, operation.data.message.occurredAt ?? operation.occurredAt);
|
|
3012
3257
|
}
|
|
3013
3258
|
}
|
|
3014
|
-
|
|
3259
|
+
// Replay against a scratch projection while normalizing, so a legacy
|
|
3260
|
+
// cleanup repair sees the same state its validator will.
|
|
3261
|
+
const projection = emptyProjection();
|
|
3262
|
+
return records.map(([id, operation]) => {
|
|
3263
|
+
const normalized = this.repairLegacyChannelCleanup(this.normalizeOperation(operation, occurrences, instants), projection);
|
|
3264
|
+
this.applyTo(projection, normalized);
|
|
3265
|
+
return [id, normalized];
|
|
3266
|
+
});
|
|
3267
|
+
}
|
|
3268
|
+
/**
|
|
3269
|
+
* Releases up to 0.1.9 scoped Channel archival and Channel member-removal
|
|
3270
|
+
* inbox cleanup to taskful Threads only (the collector read the Task
|
|
3271
|
+
* projection), so archiving or member removal in a Channel holding a
|
|
3272
|
+
* taskless Thread with Attention or markers wrote an incomplete inbox and
|
|
3273
|
+
* every later load rejected the record, leaving the profile unable to boot.
|
|
3274
|
+
* Records carrying exactly that legacy cleanup are repaired in memory the
|
|
3275
|
+
* same way pre-envelope Thread reads are; any other inbox still fails
|
|
3276
|
+
* validation, so a forgery is not silently accepted.
|
|
3277
|
+
*/
|
|
3278
|
+
repairLegacyChannelCleanup(operation, projection) {
|
|
3279
|
+
if (operation.kind === 'team/channel-archived') {
|
|
3280
|
+
const channelRef = operation.data.channel.channelRef;
|
|
3281
|
+
const expected = this.channelArchivalInboxFrom(projection, this.channelThreadRefsFrom(projection, channelRef));
|
|
3282
|
+
if (!isDeepStrictEqual(operation.data.inbox, expected)
|
|
3283
|
+
&& isDeepStrictEqual(operation.data.inbox, this.channelArchivalInboxFrom(projection, this.legacyChannelThreadRefs(projection, channelRef)))) {
|
|
3284
|
+
return { ...operation, data: { ...operation.data, inbox: expected } };
|
|
3285
|
+
}
|
|
3286
|
+
return operation;
|
|
3287
|
+
}
|
|
3288
|
+
if (operation.kind === 'team/channel-member-removed') {
|
|
3289
|
+
const { channelRef, memberId } = operation.data;
|
|
3290
|
+
const expected = this.removeMemberThreadInboxFrom(projection, memberId, this.channelThreadRefsFrom(projection, channelRef));
|
|
3291
|
+
if (!isDeepStrictEqual(operation.data.inbox, expected)
|
|
3292
|
+
&& isDeepStrictEqual(operation.data.inbox, this.removeMemberThreadInboxFrom(projection, memberId, this.legacyChannelThreadRefs(projection, channelRef)))) {
|
|
3293
|
+
return { ...operation, data: { ...operation.data, inbox: expected } };
|
|
3294
|
+
}
|
|
3295
|
+
return operation;
|
|
3296
|
+
}
|
|
3297
|
+
return operation;
|
|
3298
|
+
}
|
|
3299
|
+
/** The pre-fix Channel Thread scope: only Threads carrying a Task. */
|
|
3300
|
+
legacyChannelThreadRefs(projection, channelRef) {
|
|
3301
|
+
return new Set([...projection.tasks.values()].filter(task => task.channelRef === channelRef).map(task => task.threadRef));
|
|
3015
3302
|
}
|
|
3016
3303
|
/** Ledgers written before message occurredAt existed store bare messages; Thread reads resolve instants from the originating operations. */
|
|
3017
|
-
normalizeOperation(operation, occurrences) {
|
|
3304
|
+
normalizeOperation(operation, occurrences, instants) {
|
|
3018
3305
|
if (operation.kind !== 'team/thread-read')
|
|
3019
3306
|
return operation;
|
|
3020
3307
|
const stamp = (message) => (message.occurredAt === undefined
|
|
3021
3308
|
? { ...message, occurredAt: occurrences.get(message.messageRef) ?? operation.occurredAt }
|
|
3022
3309
|
: { ...message, occurredAt: message.occurredAt });
|
|
3023
|
-
const
|
|
3024
|
-
? {
|
|
3025
|
-
|
|
3026
|
-
: {
|
|
3310
|
+
const stampEnvelope = (envelope) => envelope.kind === 'message'
|
|
3311
|
+
? { kind: 'message', sequence: envelope.sequence, message: stamp(envelope.message), mentions: envelope.mentions,
|
|
3312
|
+
occurredAt: envelope.occurredAt ?? envelope.message.occurredAt ?? occurrences.get(envelope.message.messageRef) ?? operation.occurredAt }
|
|
3313
|
+
: { kind: 'activity', sequence: envelope.sequence, activity: envelope.activity,
|
|
3314
|
+
occurredAt: envelope.occurredAt ?? instants.get(envelope.sequence) ?? operation.occurredAt };
|
|
3315
|
+
const facts = operation.data.facts.map((fact) => ({ ...fact, fact: stampEnvelope(fact.fact) }));
|
|
3027
3316
|
return { ...operation, data: { ...operation.data, anchor: stamp(operation.data.anchor), facts } };
|
|
3028
3317
|
}
|
|
3029
3318
|
enqueue(operation) {
|