@prjct.app/pi-team 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/CONTRIBUTING.md +2 -1
  3. package/README.md +23 -177
  4. package/docs/architecture.md +36 -168
  5. package/package.json +10 -4
  6. package/src/commands/team-command.ts +37 -0
  7. package/src/domain/lease.ts +54 -0
  8. package/src/domain/member.ts +58 -0
  9. package/src/domain/message.ts +91 -0
  10. package/src/domain/request.ts +67 -0
  11. package/src/domain/team.ts +71 -0
  12. package/src/dynamic/domain.ts +110 -0
  13. package/src/dynamic/memory.ts +38 -0
  14. package/src/dynamic/panel.ts +155 -0
  15. package/src/dynamic/peer-log.ts +39 -0
  16. package/src/dynamic/runner.ts +196 -0
  17. package/src/dynamic/service.ts +292 -0
  18. package/src/dynamic/store.ts +57 -0
  19. package/src/dynamic/view.ts +21 -0
  20. package/src/dynamic/worker.ts +210 -0
  21. package/src/dynamic/workspace.ts +43 -0
  22. package/src/index.ts +204 -679
  23. package/src/process-identity.ts +68 -0
  24. package/src/runtime/delivery.ts +326 -0
  25. package/src/runtime/membership.ts +212 -0
  26. package/src/runtime/presence.ts +98 -0
  27. package/src/runtime/purge.ts +39 -0
  28. package/src/runtime/reconciler.ts +112 -0
  29. package/src/runtime/requests.ts +353 -0
  30. package/src/runtime/resources.ts +117 -0
  31. package/src/runtime/team-runtime.ts +47 -0
  32. package/src/runtime/team-tool.ts +191 -0
  33. package/src/storage/atomic.ts +347 -0
  34. package/src/storage/inbox-store.ts +290 -0
  35. package/src/storage/lease-store.ts +158 -0
  36. package/src/storage/paths.ts +76 -0
  37. package/src/storage/receipt-store.ts +117 -0
  38. package/src/storage/team-store.ts +190 -0
  39. package/src/supervisor/control-protocol.ts +125 -0
  40. package/src/supervisor/runtime-store.ts +231 -0
  41. package/src/supervisor/shutdown.ts +141 -0
  42. package/src/supervisor/supervisor.ts +657 -0
  43. package/src/supervisor/tmux-adapter.ts +192 -0
  44. package/src/supervisor/worker-bootstrap.ts +43 -0
  45. package/src/supervisor/worker-client.ts +233 -0
  46. package/src/ui/team-dashboard.ts +179 -0
  47. package/src/mailbox.ts +0 -536
  48. package/src/schema.ts +0 -25
  49. package/src/store.ts +0 -230
@@ -0,0 +1,98 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { leaseExpired, sameLeaseOwner, type Lease } from '../domain/lease.ts';
3
+ import type { Member } from '../domain/member.ts';
4
+ import { LeaseStore } from '../storage/lease-store.ts';
5
+ import { TeamStore } from '../storage/team-store.ts';
6
+
7
+ export const DEFAULT_PRESENCE_TTL_MS = 30_000;
8
+
9
+ export type PresenceOwner = {
10
+ readonly teamId: string;
11
+ readonly memberId: string;
12
+ readonly memberGeneration: number;
13
+ readonly leaseToken: string;
14
+ readonly leaseGeneration: number;
15
+ };
16
+
17
+ export class PresenceService {
18
+ private readonly ttlMs: number;
19
+ private readonly now: () => number;
20
+
21
+ constructor(
22
+ private readonly teams: TeamStore,
23
+ private readonly leases: LeaseStore,
24
+ options: { readonly ttlMs?: number; readonly now?: () => number } = {},
25
+ ) {
26
+ this.ttlMs = options.ttlMs ?? DEFAULT_PRESENCE_TTL_MS;
27
+ this.now = options.now ?? Date.now;
28
+ if (!Number.isFinite(this.ttlMs) || this.ttlMs <= 0) throw new Error('Invalid presence TTL.');
29
+ }
30
+
31
+ leaseId(memberId: string): string {
32
+ return `presence-${createHash('sha256').update(memberId).digest('hex')}`;
33
+ }
34
+ holderId(memberId: string, generation: number): string {
35
+ return `member-${createHash('sha256').update(`${memberId}:${generation}`).digest('hex')}`;
36
+ }
37
+
38
+ async acquire(teamId: string, memberId: string, memberGeneration: number): Promise<PresenceOwner> {
39
+ const lease = await this.leases.acquire({
40
+ teamId,
41
+ leaseId: this.leaseId(memberId),
42
+ kind: 'presence',
43
+ holderId: this.holderId(memberId, memberGeneration),
44
+ resourceId: memberId,
45
+ ttlMs: this.ttlMs,
46
+ });
47
+ return {
48
+ teamId,
49
+ memberId,
50
+ memberGeneration,
51
+ leaseToken: lease.token,
52
+ leaseGeneration: lease.generation,
53
+ };
54
+ }
55
+
56
+ async assertOwner(owner: PresenceOwner): Promise<{ readonly member: Member; readonly lease: Lease }> {
57
+ const [member, lease] = await Promise.all([
58
+ this.teams.readMember(owner.teamId, owner.memberId),
59
+ this.leases.read(owner.teamId, this.leaseId(owner.memberId)),
60
+ ]);
61
+ if (!member || member.state !== 'active' || member.generation !== owner.memberGeneration ||
62
+ !lease || lease.kind !== 'presence' || lease.resourceId !== owner.memberId ||
63
+ lease.holderId !== this.holderId(owner.memberId, owner.memberGeneration) ||
64
+ !sameLeaseOwner(lease, owner.leaseToken, owner.leaseGeneration) || leaseExpired(lease, this.now())) {
65
+ throw Object.assign(new Error('Membership ownership has been fenced.'), { code: 'FENCED' });
66
+ }
67
+ return { member, lease };
68
+ }
69
+
70
+ async renew(owner: PresenceOwner): Promise<Lease> {
71
+ await this.assertOwner(owner);
72
+ return this.leases.renew(
73
+ owner.teamId,
74
+ this.leaseId(owner.memberId),
75
+ this.holderId(owner.memberId, owner.memberGeneration),
76
+ owner.leaseToken,
77
+ owner.leaseGeneration,
78
+ this.ttlMs,
79
+ );
80
+ }
81
+
82
+ async release(owner: PresenceOwner): Promise<void> {
83
+ await this.leases.release(
84
+ owner.teamId,
85
+ this.leaseId(owner.memberId),
86
+ this.holderId(owner.memberId, owner.memberGeneration),
87
+ owner.leaseToken,
88
+ owner.leaseGeneration,
89
+ );
90
+ }
91
+
92
+ async online(member: Member): Promise<boolean> {
93
+ if (member.state !== 'active') return false;
94
+ const lease = await this.leases.read(member.teamId, this.leaseId(member.memberId));
95
+ return !!lease && lease.kind === 'presence' && lease.resourceId === member.memberId &&
96
+ lease.holderId === this.holderId(member.memberId, member.generation) && !leaseExpired(lease, this.now());
97
+ }
98
+ }
@@ -0,0 +1,39 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { ensurePrivateDirectory, moveAtomic, withStorageLock } from '../storage/atomic.ts';
5
+ import { TeamPaths } from '../storage/paths.ts';
6
+ import { RuntimeStore } from '../supervisor/runtime-store.ts';
7
+ import { TeamStore } from '../storage/team-store.ts';
8
+
9
+ /** Human-command primitive: permanently remove a fully closed, inactive v2 team. */
10
+ export async function purgeClosedTeam(
11
+ paths: TeamPaths,
12
+ teams: TeamStore,
13
+ runtimes: RuntimeStore,
14
+ teamId: string,
15
+ ): Promise<void> {
16
+ await withStorageLock(paths.teamLock(teamId), async () => {
17
+ const team = await teams.read(teamId);
18
+ if (!team) throw Object.assign(new Error(`Unknown team "${teamId}".`), { code: 'NOT_FOUND' });
19
+ if (team.state !== 'closed') throw new Error('Only a closed Team can be purged.');
20
+ const [members, runtimeRecords] = await Promise.all([
21
+ teams.listMembers(teamId),
22
+ runtimes.list(teamId),
23
+ ]);
24
+ if (members.some(member => member.state !== 'left')) throw new Error('Every member must have left before purge.');
25
+ if (runtimeRecords.some(runtime => runtime.state !== 'terminated')) throw new Error('Every supervised runtime must be terminated before purge.');
26
+ await ensurePrivateDirectory(paths.team(teamId), false);
27
+ for (const child of ['members', 'runtimes', 'inbox', 'receipts', 'leases']) {
28
+ await ensurePrivateDirectory(join(paths.team(teamId), child), false);
29
+ }
30
+ const before = await lstat(paths.team(teamId));
31
+ const tombstone = join(paths.teams(), `.purge-${teamId}-${randomUUID()}`);
32
+ await moveAtomic(paths.team(teamId), tombstone);
33
+ const info = await lstat(tombstone);
34
+ if (!info.isDirectory() || info.isSymbolicLink() || info.dev !== before.dev || info.ino !== before.ino) {
35
+ throw new Error('Purge tombstone identity changed.');
36
+ }
37
+ await rm(tombstone, { recursive: true });
38
+ });
39
+ }
@@ -0,0 +1,112 @@
1
+ import { messageExpired, type Envelope } from '../domain/message.ts';
2
+ import { withStorageLock } from '../storage/atomic.ts';
3
+ import { InboxStore, type InboxPage } from '../storage/inbox-store.ts';
4
+ import { TeamPaths } from '../storage/paths.ts';
5
+ import { ReceiptStore } from '../storage/receipt-store.ts';
6
+ import { TeamStore } from '../storage/team-store.ts';
7
+ import { DeliveryService } from './delivery.ts';
8
+ import { PresenceService } from './presence.ts';
9
+ import { RequestService } from './requests.ts';
10
+
11
+ export type ReconcileResult = {
12
+ readonly offlineMembers: number;
13
+ readonly releasedClaims: number;
14
+ readonly failedClaims: number;
15
+ readonly expiredMessages: number;
16
+ readonly expiredReceipts: number;
17
+ };
18
+
19
+ export class TeamReconciler {
20
+ private readonly now: () => number;
21
+
22
+ constructor(
23
+ private readonly paths: TeamPaths,
24
+ private readonly teams: TeamStore,
25
+ private readonly inbox: InboxStore,
26
+ private readonly receipts: ReceiptStore,
27
+ private readonly presence: PresenceService,
28
+ private readonly delivery: DeliveryService,
29
+ private readonly requests: RequestService,
30
+ now: () => number = Date.now,
31
+ ) {
32
+ this.now = now;
33
+ }
34
+
35
+ private async pageAll(load: (cursor?: string) => Promise<InboxPage>, cursor?: string): Promise<readonly Envelope[]> {
36
+ const page = await load(cursor);
37
+ return page.nextCursor
38
+ ? [...page.messages, ...await this.pageAll(load, page.nextCursor)]
39
+ : page.messages;
40
+ }
41
+
42
+ async reconcile(teamId: string): Promise<ReconcileResult> {
43
+ return withStorageLock(this.paths.lock(`membership-${teamId}`), async () => {
44
+ const members = await this.teams.listMembers(teamId);
45
+ const staleMembers = (await Promise.all(members.map(async member =>
46
+ member.state === 'active' && !await this.presence.online(member) ? member : undefined)))
47
+ .filter((member): member is NonNullable<typeof member> => member !== undefined);
48
+ const timestamp = new Date(this.now()).toISOString();
49
+ await Promise.all(staleMembers.map(member => this.teams.updateMember(teamId, member.memberId, member.generation, current => ({
50
+ ...current,
51
+ state: 'left',
52
+ leftAt: timestamp,
53
+ updatedAt: timestamp,
54
+ }))));
55
+
56
+ const claimedByMember = await Promise.all(members.map(async member => ({
57
+ member,
58
+ messages: await this.pageAll(cursor => this.inbox.listClaimed(teamId, member.memberId, 100, cursor)),
59
+ })));
60
+ const claimed = claimedByMember.flatMap(entry => entry.messages.map(message => ({ member: entry.member, message })));
61
+ const pendingByMember = await Promise.all(members.map(async member => ({
62
+ member,
63
+ messages: await this.pageAll(cursor => this.inbox.listPending(teamId, member.memberId, 100, cursor)),
64
+ })));
65
+ const pending = pendingByMember.flatMap(entry => entry.messages);
66
+ const staleOwners = new Set(staleMembers.map(member => `${member.memberId}:${member.generation}`));
67
+ const staleRequests = [...claimed.map(entry => entry.message), ...pending]
68
+ .filter(message => message.kind === 'request' && staleOwners.has(`${message.fromMemberId}:${message.senderGeneration}`));
69
+ await Promise.all(staleRequests.map(message => this.requests.cancelAbandoned(message)));
70
+ await Promise.all([
71
+ ...claimed.map(async entry => {
72
+ if (entry.message.kind === 'request') await this.requests.repairCancellation(entry.message, 'claimed');
73
+ if (entry.message.kind === 'reply') await this.requests.repairReplyOutcome(entry.message);
74
+ }),
75
+ ...pending.map(async message => {
76
+ if (message.kind === 'request') await this.requests.repairCancellation(message, 'pending');
77
+ if (message.kind === 'reply') await this.requests.repairReplyOutcome(message);
78
+ }),
79
+ ]);
80
+ const settled = await Promise.all(claimed.map(entry =>
81
+ this.delivery.reconcileClaim(teamId, entry.member.memberId, entry.message.messageId)));
82
+ const expired = pendingByMember.flatMap(entry => entry.messages
83
+ .filter(message => messageExpired(message, this.now()))
84
+ .map(message => ({ member: entry.member, message })));
85
+ await Promise.all(expired.map(async entry => {
86
+ if (entry.message.kind === 'request') {
87
+ const receipt = await this.receipts.read(teamId, entry.member.memberId, entry.message.messageId);
88
+ if (!receipt) {
89
+ await this.receipts.record({
90
+ schemaVersion: 2,
91
+ teamId,
92
+ messageId: entry.message.messageId,
93
+ recipientId: entry.member.memberId,
94
+ status: 'expired',
95
+ at: timestamp,
96
+ });
97
+ }
98
+ }
99
+ await this.inbox.removePending(teamId, entry.member.memberId, entry.message.messageId);
100
+ }));
101
+ const expiredReceipts = (await Promise.all(members.map(member =>
102
+ this.receipts.purgeExpired(teamId, member.memberId)))).reduce((sum, count) => sum + count, 0);
103
+ return {
104
+ offlineMembers: staleMembers.length,
105
+ releasedClaims: settled.filter(result => result === 'released').length,
106
+ failedClaims: settled.filter(result => result === 'failed').length,
107
+ expiredMessages: expired.length + settled.filter(result => result === 'expired').length,
108
+ expiredReceipts,
109
+ };
110
+ });
111
+ }
112
+ }
@@ -0,0 +1,353 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import type { Envelope, MessageKind, Receipt } from '../domain/message.ts';
3
+ import { MAX_MESSAGE_TTL_MS, messageExpired } from '../domain/message.ts';
4
+ import type { RequestState } from '../domain/request.ts';
5
+ import { withStorageLock } from '../storage/atomic.ts';
6
+ import { InboxStore, type InboxPage } from '../storage/inbox-store.ts';
7
+ import { TeamPaths } from '../storage/paths.ts';
8
+ import { ReceiptStore } from '../storage/receipt-store.ts';
9
+ import { TeamStore } from '../storage/team-store.ts';
10
+ import { DeliveryService } from './delivery.ts';
11
+ import { MembershipService, type Membership } from './membership.ts';
12
+
13
+ const sendKinds = new Set<MessageKind>(['info', 'question', 'proposal', 'handoff', 'blocker', 'request']);
14
+ export type RequestRuntimeState = RequestState | 'failed';
15
+ const terminalRequestStates = new Set<RequestRuntimeState>(['replied', 'cancelled', 'expired', 'failed']);
16
+
17
+ export type ReplyResult = {
18
+ readonly accepted: boolean;
19
+ readonly message?: Envelope;
20
+ readonly reason?: 'cancelled' | 'expired' | 'failed' | 'sender-replaced';
21
+ };
22
+
23
+ function errorCode(error: unknown): string | undefined {
24
+ return (error as { code?: string }).code;
25
+ }
26
+
27
+ export class RequestService {
28
+ private readonly now: () => number;
29
+
30
+ constructor(
31
+ private readonly paths: TeamPaths,
32
+ private readonly teams: TeamStore,
33
+ private readonly memberships: MembershipService,
34
+ private readonly delivery: DeliveryService,
35
+ private readonly inbox: InboxStore,
36
+ private readonly receipts: ReceiptStore,
37
+ now: () => number = Date.now,
38
+ ) {
39
+ this.now = now;
40
+ }
41
+
42
+ private lockPath(teamId: string, requestId: string): string {
43
+ return this.paths.lock(`request-${createHash('sha256').update(`${teamId}\0${requestId}`).digest('hex')}`);
44
+ }
45
+
46
+ private async enqueueIdempotently(message: Envelope): Promise<void> {
47
+ try { await this.inbox.enqueue(message); }
48
+ catch (error) {
49
+ if (errorCode(error) !== 'ALREADY_EXISTS') throw error;
50
+ }
51
+ }
52
+
53
+ private state(receipt: Receipt | undefined, claimed: boolean): RequestRuntimeState {
54
+ if (!receipt) return claimed ? 'delivered' : 'queued';
55
+ if (receipt.status === 'delivered') return 'delivered';
56
+ if (receipt.status === 'read') return 'accepted';
57
+ if (receipt.status === 'replied') return 'replied';
58
+ if (receipt.status === 'cancelled') return 'cancelled';
59
+ if (receipt.status === 'expired') return 'expired';
60
+ if (receipt.status === 'failed') return 'failed';
61
+ return 'delivered';
62
+ }
63
+
64
+ async send(membership: Membership, input: {
65
+ readonly to: string;
66
+ readonly kind: Exclude<MessageKind, 'reply' | 'cancel'>;
67
+ readonly body: string;
68
+ readonly threadId?: string;
69
+ readonly ttlMs?: number;
70
+ readonly signal?: AbortSignal;
71
+ }): Promise<Envelope> {
72
+ await this.memberships.assertOwner(membership);
73
+ if (!sendKinds.has(input.kind)) throw new Error('Invalid outbound message kind.');
74
+ const peer = await this.memberships.resolveAlias(membership, input.to);
75
+ input.signal?.throwIfAborted();
76
+ if (peer.memberId === membership.memberId) throw new Error('Cannot send a message to yourself.');
77
+ const ttlMs = input.ttlMs ?? MAX_MESSAGE_TTL_MS;
78
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > MAX_MESSAGE_TTL_MS) throw new Error('Invalid message TTL.');
79
+ const messageId = randomUUID();
80
+ const timestamp = this.now();
81
+ const message: Envelope = {
82
+ schemaVersion: 2,
83
+ messageId,
84
+ teamId: membership.teamId,
85
+ threadId: input.threadId ?? messageId,
86
+ ...(input.kind === 'request' ? { requestId: messageId } : {}),
87
+ kind: input.kind,
88
+ fromMemberId: membership.memberId,
89
+ toMemberId: peer.memberId,
90
+ senderGeneration: membership.memberGeneration,
91
+ ...(peer.status === 'online' ? { recipientGeneration: peer.generation } : {}),
92
+ createdAt: new Date(timestamp).toISOString(),
93
+ expiresAt: new Date(timestamp + ttlMs).toISOString(),
94
+ body: input.body,
95
+ };
96
+ input.signal?.throwIfAborted();
97
+ await this.inbox.enqueue(message);
98
+ return message;
99
+ }
100
+
101
+ private async pageAll(load: (cursor?: string) => Promise<InboxPage>, cursor?: string): Promise<readonly Envelope[]> {
102
+ const page = await load(cursor);
103
+ return page.nextCursor
104
+ ? [...page.messages, ...await this.pageAll(load, page.nextCursor)]
105
+ : page.messages;
106
+ }
107
+
108
+ private async findRequest(teamId: string, requestId: string): Promise<{
109
+ readonly message: Envelope;
110
+ readonly location: 'pending' | 'claimed';
111
+ } | undefined> {
112
+ const members = await this.teams.listMembers(teamId);
113
+ const matches = await Promise.all(members.map(async member => {
114
+ const pending = await this.inbox.readPending(teamId, member.memberId, requestId);
115
+ if (pending) return { message: pending, location: 'pending' as const };
116
+ const claimed = await this.inbox.readClaimed(teamId, member.memberId, requestId);
117
+ return claimed ? { message: claimed, location: 'claimed' as const } : undefined;
118
+ }));
119
+ return matches.find((match): match is NonNullable<typeof match> => match !== undefined);
120
+ }
121
+
122
+ async requestState(membership: Membership, requestId: string): Promise<RequestRuntimeState> {
123
+ await this.memberships.assertOwner(membership);
124
+ const found = await this.findRequest(membership.teamId, requestId);
125
+ if (found && found.message.kind === 'request' && found.message.fromMemberId === membership.memberId) {
126
+ const receipt = await this.receipts.read(membership.teamId, found.message.toMemberId, requestId);
127
+ return this.state(receipt, found.location === 'claimed');
128
+ }
129
+ const members = await this.teams.listMembers(membership.teamId);
130
+ const receipts = await Promise.all(members.map(member =>
131
+ this.receipts.read(membership.teamId, member.memberId, requestId)));
132
+ const terminal = receipts.find(receipt => receipt && ['replied', 'cancelled', 'expired', 'failed'].includes(receipt.status));
133
+ if (terminal) return this.state(terminal, false);
134
+ throw Object.assign(new Error(`Unknown outgoing request "${requestId}".`), { code: 'NOT_FOUND' });
135
+ }
136
+
137
+ async cancel(membership: Membership, requestId: string, body = 'The sender cancelled this request.'): Promise<boolean> {
138
+ await this.memberships.assertOwner(membership);
139
+ return withStorageLock(this.lockPath(membership.teamId, requestId), async () => {
140
+ await this.memberships.assertOwner(membership);
141
+ const found = await this.findRequest(membership.teamId, requestId);
142
+ if (!found || found.message.kind !== 'request' || found.message.fromMemberId !== membership.memberId ||
143
+ found.message.senderGeneration !== membership.memberGeneration) {
144
+ throw Object.assign(new Error(`Unknown owned request "${requestId}".`), { code: 'NOT_FOUND' });
145
+ }
146
+ const current = await this.receipts.read(membership.teamId, found.message.toMemberId, requestId);
147
+ const state = this.state(current, found.location === 'claimed');
148
+ if (terminalRequestStates.has(state)) return false;
149
+ await this.receipts.record({
150
+ schemaVersion: 2,
151
+ teamId: membership.teamId,
152
+ messageId: requestId,
153
+ recipientId: found.message.toMemberId,
154
+ status: 'cancelled',
155
+ at: new Date(this.now()).toISOString(),
156
+ });
157
+ const timestamp = this.now();
158
+ const cancellation: Envelope = {
159
+ schemaVersion: 2,
160
+ messageId: `cancel-${requestId}`,
161
+ teamId: membership.teamId,
162
+ threadId: found.message.threadId,
163
+ requestId,
164
+ kind: 'cancel',
165
+ fromMemberId: membership.memberId,
166
+ toMemberId: found.message.toMemberId,
167
+ senderGeneration: membership.memberGeneration,
168
+ recipientGeneration: found.message.recipientGeneration,
169
+ createdAt: new Date(timestamp).toISOString(),
170
+ expiresAt: new Date(timestamp + MAX_MESSAGE_TTL_MS).toISOString(),
171
+ body,
172
+ };
173
+ try {
174
+ await this.enqueueIdempotently(cancellation);
175
+ if (found.location === 'pending') {
176
+ await this.inbox.removePending(membership.teamId, found.message.toMemberId, requestId);
177
+ }
178
+ } catch (error) {
179
+ if (!['TEAM_CLOSED', 'QUOTA_EXCEEDED'].includes(errorCode(error) ?? '')) throw error;
180
+ }
181
+ return true;
182
+ });
183
+ }
184
+
185
+ async isCancelled(membership: Membership, requestId: string): Promise<boolean> {
186
+ await this.memberships.assertOwner(membership);
187
+ const receipt = await this.receipts.read(membership.teamId, membership.memberId, requestId);
188
+ return receipt?.status === 'cancelled';
189
+ }
190
+
191
+ async reply(membership: Membership, requestId: string, body: string, signal?: AbortSignal): Promise<ReplyResult> {
192
+ await this.memberships.assertOwner(membership);
193
+ return withStorageLock(this.lockPath(membership.teamId, requestId), () =>
194
+ this.delivery.settle(membership, requestId, async original => {
195
+ if (original.kind !== 'request' || original.requestId !== requestId) {
196
+ throw Object.assign(new Error(`Unknown claimed request "${requestId}".`), { code: 'NOT_FOUND' });
197
+ }
198
+ const receipt = await this.receipts.read(membership.teamId, membership.memberId, requestId);
199
+ if (receipt?.status === 'cancelled' || receipt?.status === 'expired' || receipt?.status === 'failed') {
200
+ return { accepted: false, reason: receipt.status };
201
+ }
202
+ if (!receipt || !['delivered', 'read'].includes(receipt.status)) throw new Error('Read the claimed request before replying.');
203
+ if (messageExpired(original, this.now())) {
204
+ await this.receipts.record({ ...receipt, status: 'expired', at: new Date(this.now()).toISOString() });
205
+ return { accepted: false, reason: 'expired' };
206
+ }
207
+ const sender = await this.teams.readMember(membership.teamId, original.fromMemberId);
208
+ if (!sender || sender.state !== 'active' || sender.generation !== original.senderGeneration) {
209
+ await this.receipts.record({ ...receipt, status: 'failed', at: new Date(this.now()).toISOString() });
210
+ return { accepted: false, reason: 'sender-replaced' };
211
+ }
212
+ signal?.throwIfAborted();
213
+ const timestamp = this.now();
214
+ const reply: Envelope = {
215
+ schemaVersion: 2,
216
+ messageId: `reply-${requestId}`,
217
+ teamId: membership.teamId,
218
+ threadId: original.threadId,
219
+ requestId,
220
+ kind: 'reply',
221
+ fromMemberId: membership.memberId,
222
+ toMemberId: original.fromMemberId,
223
+ senderGeneration: membership.memberGeneration,
224
+ recipientGeneration: original.senderGeneration,
225
+ createdAt: new Date(timestamp).toISOString(),
226
+ expiresAt: new Date(timestamp + MAX_MESSAGE_TTL_MS).toISOString(),
227
+ body,
228
+ };
229
+ try { await this.enqueueIdempotently(reply); }
230
+ catch (error) {
231
+ if (errorCode(error) !== 'FENCED') throw error;
232
+ await this.receipts.record({ ...receipt, status: 'failed', at: new Date(this.now()).toISOString() });
233
+ return { accepted: false, reason: 'sender-replaced' };
234
+ }
235
+ await this.receipts.record({ ...receipt, status: 'replied', at: new Date(this.now()).toISOString() });
236
+ return { accepted: true, message: reply };
237
+ }));
238
+ }
239
+
240
+ async receive(membership: Membership, messageId: string, signal?: AbortSignal): Promise<{ readonly message?: Envelope; readonly discarded: boolean }> {
241
+ signal?.throwIfAborted();
242
+ const pending = await this.inbox.readPending(membership.teamId, membership.memberId, messageId);
243
+ if (pending) await this.delivery.claim(membership, messageId, signal);
244
+ return this.read(membership, messageId, signal);
245
+ }
246
+
247
+ async read(membership: Membership, messageId: string, signal?: AbortSignal): Promise<{ readonly message?: Envelope; readonly discarded: boolean }> {
248
+ const message = await this.delivery.read(membership, messageId, signal);
249
+ if (message.kind === 'request') return { message, discarded: false };
250
+ if (message.kind === 'reply' && message.requestId) {
251
+ const original = await this.receipts.read(membership.teamId, message.fromMemberId, message.requestId);
252
+ if (original && ['cancelled', 'expired', 'failed'].includes(original.status)) {
253
+ await this.delivery.fail(membership, messageId);
254
+ return { discarded: true };
255
+ }
256
+ }
257
+ const receipt = await this.receipts.read(membership.teamId, membership.memberId, messageId);
258
+ if (receipt && receipt.status === 'read') {
259
+ await this.receipts.record({ ...receipt, status: 'replied', at: new Date(this.now()).toISOString() });
260
+ }
261
+ await this.delivery.finish(membership, messageId);
262
+ return { message, discarded: false };
263
+ }
264
+
265
+ async cancelAbandoned(original: Envelope): Promise<boolean> {
266
+ if (original.kind !== 'request' || original.requestId !== original.messageId) return false;
267
+ return withStorageLock(this.lockPath(original.teamId, original.messageId), async () => {
268
+ const receipt = await this.receipts.read(original.teamId, original.toMemberId, original.messageId);
269
+ if (receipt && ['replied', 'cancelled', 'expired', 'failed'].includes(receipt.status)) return false;
270
+ await this.receipts.record(receipt ? {
271
+ ...receipt,
272
+ status: 'cancelled',
273
+ at: new Date(this.now()).toISOString(),
274
+ } : {
275
+ schemaVersion: 2,
276
+ teamId: original.teamId,
277
+ messageId: original.messageId,
278
+ recipientId: original.toMemberId,
279
+ status: 'cancelled',
280
+ at: new Date(this.now()).toISOString(),
281
+ });
282
+ return true;
283
+ });
284
+ }
285
+
286
+ async repairCancellation(original: Envelope, location: 'pending' | 'claimed'): Promise<boolean> {
287
+ if (original.kind !== 'request' || original.requestId !== original.messageId) return false;
288
+ return withStorageLock(this.lockPath(original.teamId, original.messageId), async () => {
289
+ const receipt = await this.receipts.read(original.teamId, original.toMemberId, original.messageId);
290
+ if (receipt?.status !== 'cancelled') return false;
291
+ const timestamp = this.now();
292
+ const cancellation: Envelope = {
293
+ schemaVersion: 2,
294
+ messageId: `cancel-${original.messageId}`,
295
+ teamId: original.teamId,
296
+ threadId: original.threadId,
297
+ requestId: original.messageId,
298
+ kind: 'cancel',
299
+ fromMemberId: original.fromMemberId,
300
+ toMemberId: original.toMemberId,
301
+ senderGeneration: original.senderGeneration,
302
+ recipientGeneration: original.recipientGeneration,
303
+ createdAt: new Date(timestamp).toISOString(),
304
+ expiresAt: new Date(timestamp + MAX_MESSAGE_TTL_MS).toISOString(),
305
+ body: 'The sender cancelled this request.',
306
+ };
307
+ try {
308
+ await this.enqueueIdempotently(cancellation);
309
+ if (location === 'pending') {
310
+ await this.inbox.removePending(original.teamId, original.toMemberId, original.messageId);
311
+ }
312
+ return true;
313
+ } catch (error) {
314
+ if (['TEAM_CLOSED', 'QUOTA_EXCEEDED', 'FENCED'].includes(errorCode(error) ?? '')) return false;
315
+ throw error;
316
+ }
317
+ });
318
+ }
319
+
320
+ async repairReplyOutcome(reply: Envelope): Promise<boolean> {
321
+ if (reply.kind !== 'reply' || !reply.requestId) return false;
322
+ return withStorageLock(this.lockPath(reply.teamId, reply.requestId), async () => {
323
+ const original = await this.inbox.readClaimed(reply.teamId, reply.fromMemberId, reply.requestId!);
324
+ if (!original || original.kind !== 'request' || original.fromMemberId !== reply.toMemberId ||
325
+ original.toMemberId !== reply.fromMemberId || original.threadId !== reply.threadId) return false;
326
+ const receipt = await this.receipts.read(reply.teamId, reply.fromMemberId, reply.requestId!);
327
+ if (!receipt || !['delivered', 'read'].includes(receipt.status)) return false;
328
+ await this.receipts.record({ ...receipt, status: 'replied', at: new Date(this.now()).toISOString() });
329
+ return true;
330
+ });
331
+ }
332
+
333
+ async leave(membership: Membership): Promise<number> {
334
+ const cancelled = await this.cancelOutgoing(membership);
335
+ await this.memberships.leave(membership);
336
+ return cancelled;
337
+ }
338
+
339
+ async cancelOutgoing(membership: Membership): Promise<number> {
340
+ await this.memberships.assertOwner(membership);
341
+ const members = await this.teams.listMembers(membership.teamId);
342
+ const mail = (await Promise.all(members.map(async member => [
343
+ ...await this.pageAll(cursor => this.inbox.listPending(membership.teamId, member.memberId, 100, cursor)),
344
+ ...await this.pageAll(cursor => this.inbox.listClaimed(membership.teamId, member.memberId, 100, cursor)),
345
+ ]))).flat();
346
+ const requests = mail.filter(message => message.kind === 'request' && message.fromMemberId === membership.memberId &&
347
+ message.senderGeneration === membership.memberGeneration && message.requestId === message.messageId);
348
+ const outcomes = await Promise.allSettled(requests.map(request => this.cancel(membership, request.messageId)));
349
+ const failures = outcomes.filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected');
350
+ if (failures.length > 0) throw new AggregateError(failures.map(failure => failure.reason), 'Some outgoing requests could not be cancelled.');
351
+ return outcomes.filter(outcome => outcome.status === 'fulfilled' && outcome.value).length;
352
+ }
353
+ }