@prjct.app/pi-team 0.6.1 → 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 +65 -0
  2. package/CONTRIBUTING.md +2 -1
  3. package/README.md +23 -178
  4. package/docs/architecture.md +36 -173
  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 -247
@@ -0,0 +1,68 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { readFile } from 'node:fs/promises';
3
+
4
+ export type ProcessIdentity = Readonly<{
5
+ processPid: number;
6
+ processStartToken: string;
7
+ processGroupId: number;
8
+ }>;
9
+
10
+ export type ProcessController = Readonly<{
11
+ inspect(processPid: number): Promise<ProcessIdentity | undefined>;
12
+ signal(identity: ProcessIdentity, signal: NodeJS.Signals): Promise<boolean>;
13
+ delay(ms: number): Promise<void>;
14
+ }>;
15
+
16
+ function ps(args: readonly string[]): Promise<string> {
17
+ return new Promise((resolvePromise, reject) => {
18
+ execFile('ps', [...args], { env: process.env, timeout: 2_000 }, (error, stdout) => {
19
+ if (error) { reject(error); return; }
20
+ resolvePromise(String(stdout).trim());
21
+ });
22
+ });
23
+ }
24
+
25
+ async function inspectLinuxProcess(processPid: number): Promise<ProcessIdentity | undefined> {
26
+ const stat = await readFile(`/proc/${processPid}/stat`, 'utf8').catch(() => '');
27
+ const commandEnd = stat.lastIndexOf(') ');
28
+ const fields = commandEnd >= 0 ? stat.slice(commandEnd + 2).trim().split(/\s+/) : [];
29
+ const processGroupId = Number(fields[2]);
30
+ const startedAtClockTick = fields[19] ?? '';
31
+ if (!Number.isSafeInteger(processGroupId) || processGroupId <= 1 || !/^\d+$/.test(startedAtClockTick)) return undefined;
32
+ return { processPid, processGroupId, processStartToken: `linux:${startedAtClockTick}` };
33
+ }
34
+
35
+ async function inspectPortableProcess(processPid: number): Promise<ProcessIdentity | undefined> {
36
+ const output = await ps(['-o', 'pgid=', '-o', 'lstart=', '-p', String(processPid)]).catch(() => '');
37
+ const match = output.match(/^(\d+)\s+(.+)$/);
38
+ if (!match) return undefined;
39
+ const processGroupId = Number(match[1]);
40
+ const processStartToken = match[2]?.trim() ?? '';
41
+ if (!Number.isSafeInteger(processGroupId) || processGroupId <= 1 || !processStartToken) return undefined;
42
+ return { processPid, processStartToken: `ps:${processStartToken}`, processGroupId };
43
+ }
44
+
45
+ export async function inspectProcess(processPid: number): Promise<ProcessIdentity | undefined> {
46
+ if (!Number.isSafeInteger(processPid) || processPid <= 1) return undefined;
47
+ return process.platform === 'linux' ? inspectLinuxProcess(processPid) : inspectPortableProcess(processPid);
48
+ }
49
+
50
+ export function sameProcess(left: ProcessIdentity, right: ProcessIdentity | undefined): boolean {
51
+ return !!right && left.processPid === right.processPid && left.processStartToken === right.processStartToken &&
52
+ left.processGroupId === right.processGroupId;
53
+ }
54
+
55
+ async function signal(identity: ProcessIdentity, value: NodeJS.Signals): Promise<boolean> {
56
+ if (!sameProcess(identity, await inspectProcess(identity.processPid))) return false;
57
+ try { process.kill(-identity.processGroupId, value); return true; }
58
+ catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === 'ESRCH') return false;
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ export const defaultProcessController: ProcessController = {
65
+ inspect: inspectProcess,
66
+ signal,
67
+ delay: ms => new Promise(resolve => setTimeout(resolve, ms)),
68
+ };
@@ -0,0 +1,326 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { leaseExpired, type Lease } from '../domain/lease.ts';
3
+ import { messageExpired, type Envelope, type ReceiptStatus } from '../domain/message.ts';
4
+ import { withStorageLock } from '../storage/atomic.ts';
5
+ import { InboxStore } from '../storage/inbox-store.ts';
6
+ import { LeaseStore } from '../storage/lease-store.ts';
7
+ import { ReceiptStore } from '../storage/receipt-store.ts';
8
+ import { MembershipService, type Membership } from './membership.ts';
9
+
10
+ export const DEFAULT_DELIVERY_LEASE_MS = 30_000;
11
+ const terminalReceipts = new Set<ReceiptStatus>(['replied', 'cancelled', 'expired', 'failed']);
12
+
13
+ export type DeliveryClaim = {
14
+ readonly teamId: string;
15
+ readonly memberId: string;
16
+ readonly memberGeneration: number;
17
+ readonly messageId: string;
18
+ readonly token: string;
19
+ readonly generation: number;
20
+ };
21
+
22
+ export type InboxItem = {
23
+ readonly messageId: string;
24
+ readonly threadId: string;
25
+ readonly requestId?: string;
26
+ readonly kind: Envelope['kind'];
27
+ readonly fromMemberId: string;
28
+ readonly createdAt: string;
29
+ readonly expiresAt: string;
30
+ };
31
+
32
+ export type ReconciledClaim = 'active' | 'released' | 'settled' | 'failed' | 'expired' | 'missing';
33
+
34
+ export class DeliveryService {
35
+ private readonly leaseMs: number;
36
+ private readonly now: () => number;
37
+
38
+ constructor(
39
+ private readonly memberships: MembershipService,
40
+ private readonly inbox: InboxStore,
41
+ private readonly receipts: ReceiptStore,
42
+ private readonly leases: LeaseStore,
43
+ options: { readonly leaseMs?: number; readonly now?: () => number } = {},
44
+ ) {
45
+ this.leaseMs = options.leaseMs ?? DEFAULT_DELIVERY_LEASE_MS;
46
+ this.now = options.now ?? Date.now;
47
+ if (!Number.isFinite(this.leaseMs) || this.leaseMs <= 0) throw new Error('Invalid delivery lease duration.');
48
+ }
49
+
50
+ leaseId(messageId: string): string {
51
+ return `delivery-${createHash('sha256').update(messageId).digest('hex')}`;
52
+ }
53
+ holderId(membership: Membership): string {
54
+ return `member-${createHash('sha256').update(`${membership.memberId}:${membership.memberGeneration}`).digest('hex')}`;
55
+ }
56
+ private lockPath(teamId: string, messageId: string): string {
57
+ return this.leases.paths.lock(`delivery-op-${createHash('sha256').update(`${teamId}\0${messageId}`).digest('hex')}`);
58
+ }
59
+
60
+ private claimFromLease(membership: Membership, messageId: string, lease: Lease): DeliveryClaim {
61
+ return {
62
+ teamId: membership.teamId,
63
+ memberId: membership.memberId,
64
+ memberGeneration: membership.memberGeneration,
65
+ messageId,
66
+ token: lease.token,
67
+ generation: lease.generation,
68
+ };
69
+ }
70
+
71
+ private async ownedLease(membership: Membership, messageId: string): Promise<Lease> {
72
+ await this.memberships.assertOwner(membership);
73
+ const lease = await this.leases.read(membership.teamId, this.leaseId(messageId));
74
+ if (!lease || lease.kind !== 'delivery' || lease.resourceId !== messageId ||
75
+ lease.holderId !== this.holderId(membership) || leaseExpired(lease, this.now())) {
76
+ throw Object.assign(new Error('Delivery claim ownership has been fenced.'), { code: 'FENCED' });
77
+ }
78
+ return lease;
79
+ }
80
+
81
+ private async claimed(membership: Membership, messageId: string): Promise<Envelope> {
82
+ const message = await this.inbox.readClaimed(membership.teamId, membership.memberId, messageId);
83
+ if (!message) throw Object.assign(new Error(`Unknown claimed message "${messageId}".`), { code: 'NOT_FOUND' });
84
+ return message;
85
+ }
86
+
87
+ private async releaseLease(membership: Membership, messageId: string, lease: Lease): Promise<void> {
88
+ await this.leases.release(
89
+ membership.teamId, this.leaseId(messageId), this.holderId(membership), lease.token, lease.generation,
90
+ );
91
+ }
92
+
93
+ private async finishUnlocked(membership: Membership, messageId: string, lease: Lease): Promise<void> {
94
+ await this.inbox.removeClaimed(membership.teamId, membership.memberId, messageId);
95
+ await this.releaseLease(membership, messageId, lease).catch(() => {});
96
+ }
97
+
98
+ async inboxItems(membership: Membership, limit = 50, cursor?: string): Promise<{
99
+ readonly items: readonly InboxItem[];
100
+ readonly nextCursor?: string;
101
+ }> {
102
+ await this.memberships.assertOwner(membership);
103
+ const page = await this.inbox.listPending(membership.teamId, membership.memberId, limit, cursor);
104
+ return {
105
+ items: page.messages.map(({ body: _body, senderGeneration: _sender, recipientGeneration: _recipient, teamId: _team, schemaVersion: _schema, toMemberId: _to, ...item }) => item),
106
+ ...(page.nextCursor ? { nextCursor: page.nextCursor } : {}),
107
+ };
108
+ }
109
+
110
+ async claim(membership: Membership, messageId: string, signal?: AbortSignal): Promise<DeliveryClaim> {
111
+ return withStorageLock(this.lockPath(membership.teamId, messageId), async () => {
112
+ signal?.throwIfAborted();
113
+ await this.memberships.assertOwner(membership);
114
+ const message = await this.inbox.readPending(membership.teamId, membership.memberId, messageId);
115
+ if (!message) throw Object.assign(new Error(`Unknown pending message "${messageId}".`), { code: 'NOT_FOUND' });
116
+ const receipt = await this.receipts.read(membership.teamId, membership.memberId, messageId);
117
+ if (receipt && terminalReceipts.has(receipt.status)) {
118
+ await this.inbox.removePending(membership.teamId, membership.memberId, messageId);
119
+ throw Object.assign(new Error(`Message is already ${receipt.status}.`), { code: 'TERMINAL_MESSAGE' });
120
+ }
121
+ const sender = await this.memberships.member(message.teamId, message.fromMemberId);
122
+ const recipientFenced = message.recipientGeneration !== undefined &&
123
+ message.recipientGeneration !== membership.memberGeneration;
124
+ const senderFenced = message.kind !== 'cancel' &&
125
+ (!sender || sender.generation !== message.senderGeneration ||
126
+ (message.kind === 'request' && sender.state !== 'active'));
127
+ if (recipientFenced || senderFenced) {
128
+ await this.receipts.record({
129
+ schemaVersion: 2,
130
+ teamId: membership.teamId,
131
+ messageId,
132
+ recipientId: membership.memberId,
133
+ status: 'failed',
134
+ at: new Date(this.now()).toISOString(),
135
+ });
136
+ await this.inbox.removePending(membership.teamId, membership.memberId, messageId);
137
+ throw Object.assign(new Error('Message ownership generation has been fenced.'), { code: 'FENCED' });
138
+ }
139
+ signal?.throwIfAborted();
140
+ const lease = await this.leases.acquire({
141
+ teamId: membership.teamId,
142
+ leaseId: this.leaseId(messageId),
143
+ kind: 'delivery',
144
+ holderId: this.holderId(membership),
145
+ resourceId: messageId,
146
+ ttlMs: this.leaseMs,
147
+ });
148
+ try {
149
+ signal?.throwIfAborted();
150
+ await this.inbox.claim(membership.teamId, membership.memberId, messageId);
151
+ return this.claimFromLease(membership, messageId, lease);
152
+ } catch (error) {
153
+ await this.releaseLease(membership, messageId, lease).catch(() => {});
154
+ throw error;
155
+ }
156
+ });
157
+ }
158
+
159
+ async read(membership: Membership, messageId: string, signal?: AbortSignal): Promise<Envelope> {
160
+ return withStorageLock(this.lockPath(membership.teamId, messageId), async () => {
161
+ signal?.throwIfAborted();
162
+ const lease = await this.ownedLease(membership, messageId);
163
+ const message = await this.claimed(membership, messageId);
164
+ const receipt = await this.receipts.read(membership.teamId, membership.memberId, messageId);
165
+ if (messageExpired(message, this.now())) {
166
+ if (!receipt) {
167
+ await this.receipts.record({
168
+ schemaVersion: 2,
169
+ teamId: membership.teamId,
170
+ messageId,
171
+ recipientId: membership.memberId,
172
+ status: 'expired',
173
+ at: new Date(this.now()).toISOString(),
174
+ });
175
+ } else if (!terminalReceipts.has(receipt.status)) {
176
+ await this.receipts.record({ ...receipt, status: 'expired', at: new Date(this.now()).toISOString() });
177
+ }
178
+ await this.finishUnlocked(membership, messageId, lease);
179
+ throw Object.assign(new Error('Message has expired.'), { code: 'EXPIRED' });
180
+ }
181
+ if (receipt && terminalReceipts.has(receipt.status)) {
182
+ throw Object.assign(new Error(`Message is already ${receipt.status}.`), { code: 'TERMINAL_MESSAGE' });
183
+ }
184
+ if (message.kind === 'reply' && message.requestId) {
185
+ const original = await this.receipts.read(membership.teamId, message.fromMemberId, message.requestId);
186
+ if (!original || !terminalReceipts.has(original.status)) {
187
+ throw Object.assign(new Error('Reply outcome is not durable yet.'), { code: 'NOT_READY' });
188
+ }
189
+ }
190
+ signal?.throwIfAborted();
191
+ const timestamp = new Date(this.now()).toISOString();
192
+ if (!receipt) {
193
+ await this.receipts.record({
194
+ schemaVersion: 2,
195
+ teamId: membership.teamId,
196
+ messageId,
197
+ recipientId: membership.memberId,
198
+ status: 'delivered',
199
+ at: timestamp,
200
+ });
201
+ }
202
+ const delivered = receipt ?? await this.receipts.read(membership.teamId, membership.memberId, messageId);
203
+ if (delivered?.status === 'delivered') {
204
+ await this.receipts.record({ ...delivered, status: 'read', at: timestamp });
205
+ }
206
+ return message;
207
+ });
208
+ }
209
+
210
+ async renew(membership: Membership, messageId: string): Promise<DeliveryClaim> {
211
+ return withStorageLock(this.lockPath(membership.teamId, messageId), async () => {
212
+ const lease = await this.ownedLease(membership, messageId);
213
+ const renewed = await this.leases.renew(
214
+ membership.teamId,
215
+ lease.leaseId,
216
+ this.holderId(membership),
217
+ lease.token,
218
+ lease.generation,
219
+ this.leaseMs,
220
+ );
221
+ return this.claimFromLease(membership, messageId, renewed);
222
+ });
223
+ }
224
+
225
+ async release(membership: Membership, messageId: string): Promise<void> {
226
+ await withStorageLock(this.lockPath(membership.teamId, messageId), async () => {
227
+ const lease = await this.ownedLease(membership, messageId);
228
+ const receipt = await this.receipts.read(membership.teamId, membership.memberId, messageId);
229
+ if (receipt) throw new Error('A delivered message cannot be released for reinjection.');
230
+ await this.inbox.release(membership.teamId, membership.memberId, messageId);
231
+ await this.releaseLease(membership, messageId, lease);
232
+ });
233
+ }
234
+
235
+ async finish(membership: Membership, messageId: string): Promise<void> {
236
+ await withStorageLock(this.lockPath(membership.teamId, messageId), async () => {
237
+ const lease = await this.ownedLease(membership, messageId);
238
+ await this.finishUnlocked(membership, messageId, lease);
239
+ });
240
+ }
241
+
242
+ async settle<T>(
243
+ membership: Membership,
244
+ messageId: string,
245
+ operation: (message: Envelope) => Promise<T>,
246
+ ): Promise<T> {
247
+ return withStorageLock(this.lockPath(membership.teamId, messageId), async () => {
248
+ const lease = await this.ownedLease(membership, messageId);
249
+ const message = await this.claimed(membership, messageId);
250
+ const result = await operation(message);
251
+ await this.finishUnlocked(membership, messageId, lease);
252
+ return result;
253
+ });
254
+ }
255
+
256
+ async fail(membership: Membership, messageId: string): Promise<void> {
257
+ await withStorageLock(this.lockPath(membership.teamId, messageId), async () => {
258
+ const lease = await this.ownedLease(membership, messageId);
259
+ const receipt = await this.receipts.read(membership.teamId, membership.memberId, messageId);
260
+ if (receipt && !terminalReceipts.has(receipt.status)) {
261
+ await this.receipts.record({ ...receipt, status: 'failed', at: new Date(this.now()).toISOString() });
262
+ }
263
+ await this.finishUnlocked(membership, messageId, lease);
264
+ });
265
+ }
266
+
267
+ async reconcileClaim(teamId: string, recipientId: string, messageId: string): Promise<ReconciledClaim> {
268
+ return withStorageLock(this.lockPath(teamId, messageId), async () => {
269
+ const message = await this.inbox.readClaimed(teamId, recipientId, messageId);
270
+ if (!message) return 'missing';
271
+ const lease = await this.leases.read(teamId, this.leaseId(messageId));
272
+ const receipt = await this.receipts.read(teamId, recipientId, messageId);
273
+ const expired = messageExpired(message, this.now());
274
+ if (!expired && lease && !leaseExpired(lease, this.now()) &&
275
+ (!receipt || !terminalReceipts.has(receipt.status))) return 'active';
276
+ if (expired) {
277
+ if (!receipt) {
278
+ await this.receipts.record({
279
+ schemaVersion: 2,
280
+ teamId,
281
+ messageId,
282
+ recipientId,
283
+ status: 'expired',
284
+ at: new Date(this.now()).toISOString(),
285
+ });
286
+ } else if (!terminalReceipts.has(receipt.status)) {
287
+ await this.receipts.record({ ...receipt, status: 'expired', at: new Date(this.now()).toISOString() });
288
+ }
289
+ await this.inbox.removeClaimed(teamId, recipientId, messageId);
290
+ if (lease) await this.leases.release(teamId, lease.leaseId, lease.holderId, lease.token, lease.generation).catch(() => {});
291
+ return 'expired';
292
+ }
293
+ if (receipt) {
294
+ const terminal = terminalReceipts.has(receipt.status);
295
+ if (!terminal) {
296
+ await this.receipts.record({ ...receipt, status: 'failed', at: new Date(this.now()).toISOString() });
297
+ }
298
+ await this.inbox.removeClaimed(teamId, recipientId, messageId);
299
+ if (lease) await this.leases.release(teamId, lease.leaseId, lease.holderId, lease.token, lease.generation).catch(() => {});
300
+ return terminal ? 'settled' : 'failed';
301
+ }
302
+ await this.inbox.release(teamId, recipientId, messageId);
303
+ return 'released';
304
+ });
305
+ }
306
+
307
+ async deliverNextRequest(
308
+ membership: Membership,
309
+ humanEnabled: boolean,
310
+ inject: (message: Envelope) => Promise<void>,
311
+ ): Promise<Envelope | undefined> {
312
+ if (!humanEnabled || membership.kind !== 'supervised') return undefined;
313
+ const page = await this.inbox.listPending(membership.teamId, membership.memberId, 100);
314
+ const message = page.messages.find(candidate => candidate.kind === 'request');
315
+ if (!message) return undefined;
316
+ await this.claim(membership, message.messageId);
317
+ const delivered = await this.read(membership, message.messageId);
318
+ try {
319
+ await inject(delivered);
320
+ return delivered;
321
+ } catch (error) {
322
+ await this.fail(membership, message.messageId);
323
+ throw error;
324
+ }
325
+ }
326
+ }
@@ -0,0 +1,212 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import type { Member, MemberKind } from '../domain/member.ts';
3
+ import { assertEntityId, assertTeamId } from '../domain/team.ts';
4
+ import { withStorageLock } from '../storage/atomic.ts';
5
+ import { TeamPaths } from '../storage/paths.ts';
6
+ import { TeamStore } from '../storage/team-store.ts';
7
+ import { PresenceService, type PresenceOwner } from './presence.ts';
8
+
9
+ export type Membership = PresenceOwner & {
10
+ readonly alias: string;
11
+ readonly sessionId: string;
12
+ readonly cwd: string;
13
+ readonly kind: MemberKind;
14
+ };
15
+
16
+ export type PeerStatus = {
17
+ readonly memberId: string;
18
+ readonly alias: string;
19
+ readonly kind: MemberKind;
20
+ readonly generation: number;
21
+ readonly status: 'online' | 'offline';
22
+ };
23
+
24
+ export type PeerPage = {
25
+ readonly peers: readonly PeerStatus[];
26
+ readonly nextCursor?: string;
27
+ };
28
+
29
+ function positiveInteger(value: string | undefined, label: string): number {
30
+ const parsed = Number(value);
31
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`Invalid ${label} in supervised Team environment.`);
32
+ return parsed;
33
+ }
34
+
35
+ export function workerMembershipFromEnvironment(
36
+ environment: NodeJS.ProcessEnv = process.env,
37
+ cwd = process.cwd(),
38
+ ): Membership | undefined {
39
+ const names = [
40
+ 'PI_TEAM_TEAM_ID', 'PI_TEAM_MEMBER_ID', 'PI_TEAM_MEMBER_ALIAS', 'PI_TEAM_MEMBER_SESSION',
41
+ 'PI_TEAM_MEMBER_GENERATION', 'PI_TEAM_MEMBER_LEASE_TOKEN', 'PI_TEAM_MEMBER_LEASE_GENERATION',
42
+ ] as const;
43
+ const present = names.filter(name => environment[name] !== undefined);
44
+ if (present.length === 0) return undefined;
45
+ if (present.length !== names.length) throw new Error('Incomplete supervised Team membership environment.');
46
+ const teamId = assertTeamId(environment.PI_TEAM_TEAM_ID!);
47
+ const memberId = assertEntityId(environment.PI_TEAM_MEMBER_ID!, 'member ID');
48
+ const alias = assertTeamId(environment.PI_TEAM_MEMBER_ALIAS!);
49
+ const sessionId = assertEntityId(environment.PI_TEAM_MEMBER_SESSION!, 'session ID');
50
+ const leaseToken = environment.PI_TEAM_MEMBER_LEASE_TOKEN!;
51
+ // Same shape the lease store issues and the lease schema accepts (a UUID
52
+ // today). Requiring 64 hex characters rejected every real Expert at startup.
53
+ if (!/^[A-Za-z0-9_-]{32,256}$/.test(leaseToken)) throw new Error('Invalid supervised Team membership environment.');
54
+ return {
55
+ teamId,
56
+ memberId,
57
+ memberGeneration: positiveInteger(environment.PI_TEAM_MEMBER_GENERATION, 'member generation'),
58
+ leaseToken,
59
+ leaseGeneration: positiveInteger(environment.PI_TEAM_MEMBER_LEASE_GENERATION, 'lease generation'),
60
+ alias,
61
+ sessionId,
62
+ cwd,
63
+ kind: 'supervised',
64
+ };
65
+ }
66
+
67
+ export class MembershipService {
68
+ constructor(
69
+ private readonly paths: TeamPaths,
70
+ private readonly teams: TeamStore,
71
+ private readonly presence: PresenceService,
72
+ private readonly now: () => number = Date.now,
73
+ ) {}
74
+
75
+ private lockPath(teamId: string): string { return this.paths.lock(`membership-${assertTeamId(teamId)}`); }
76
+
77
+ private membership(member: Member, owner: PresenceOwner): Membership {
78
+ if (!member.sessionId) throw new Error('Joined member is missing its session identity.');
79
+ return {
80
+ ...owner,
81
+ alias: member.alias,
82
+ sessionId: member.sessionId,
83
+ cwd: member.cwd,
84
+ kind: member.kind,
85
+ };
86
+ }
87
+
88
+ async join(input: {
89
+ readonly teamId: string;
90
+ readonly alias: string;
91
+ readonly sessionId: string;
92
+ readonly cwd: string;
93
+ readonly kind: MemberKind;
94
+ }): Promise<Membership> {
95
+ return withStorageLock(this.lockPath(input.teamId), async () => {
96
+ const team = await this.teams.read(input.teamId);
97
+ if (!team) throw Object.assign(new Error(`Unknown team "${input.teamId}".`), { code: 'NOT_FOUND' });
98
+ if (team.state !== 'open') throw Object.assign(new Error(`Team "${input.teamId}" is ${team.state}.`), { code: 'TEAM_CLOSED' });
99
+ const matching = (await this.teams.listMembers(input.teamId)).filter(member => member.alias === input.alias);
100
+ const active = matching.find(member => member.state === 'active');
101
+ if (active && await this.presence.online(active)) {
102
+ throw Object.assign(new Error(`Alias "${input.alias}" is already active.`), { code: 'ALREADY_EXISTS' });
103
+ }
104
+ const previous = active ?? [...matching].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
105
+ const memberId = previous?.memberId ?? randomUUID();
106
+ const generation = (previous?.generation ?? 0) + 1;
107
+ const owner = await this.presence.acquire(input.teamId, memberId, generation);
108
+ const timestamp = new Date(this.now()).toISOString();
109
+ try {
110
+ if (!previous) {
111
+ const member: Member = {
112
+ schemaVersion: 2,
113
+ teamId: input.teamId,
114
+ memberId,
115
+ sessionId: input.sessionId,
116
+ alias: input.alias,
117
+ kind: input.kind,
118
+ generation,
119
+ state: 'active',
120
+ cwd: input.cwd,
121
+ joinedAt: timestamp,
122
+ updatedAt: timestamp,
123
+ };
124
+ await this.teams.createMember(member);
125
+ return this.membership(member, owner);
126
+ }
127
+ const member = await this.teams.updateMember(input.teamId, memberId, previous.generation, current => {
128
+ const { leftAt: _leftAt, ...retained } = current;
129
+ return {
130
+ ...retained,
131
+ sessionId: input.sessionId,
132
+ alias: input.alias,
133
+ kind: input.kind,
134
+ generation,
135
+ state: 'active',
136
+ cwd: input.cwd,
137
+ updatedAt: timestamp,
138
+ };
139
+ });
140
+ return this.membership(member, owner);
141
+ } catch (error) {
142
+ await this.presence.release(owner).catch(() => {});
143
+ throw error;
144
+ }
145
+ });
146
+ }
147
+
148
+ async assertOwner(membership: Membership): Promise<Member> {
149
+ const { member } = await this.presence.assertOwner(membership);
150
+ if (member.sessionId !== membership.sessionId || member.alias !== membership.alias) {
151
+ throw Object.assign(new Error('Membership identity has been replaced.'), { code: 'FENCED' });
152
+ }
153
+ return member;
154
+ }
155
+
156
+ async heartbeat(membership: Membership): Promise<void> {
157
+ await withStorageLock(this.lockPath(membership.teamId), async () => {
158
+ await this.assertOwner(membership);
159
+ await this.presence.renew(membership);
160
+ });
161
+ }
162
+
163
+ async leave(membership: Membership): Promise<void> {
164
+ await withStorageLock(this.lockPath(membership.teamId), async () => {
165
+ const member = await this.assertOwner(membership);
166
+ const timestamp = new Date(this.now()).toISOString();
167
+ await this.teams.updateMember(member.teamId, member.memberId, member.generation, current => ({
168
+ ...current,
169
+ state: 'left',
170
+ leftAt: timestamp,
171
+ updatedAt: timestamp,
172
+ }));
173
+ await this.presence.release(membership).catch(() => {});
174
+ });
175
+ }
176
+
177
+ async peers(membership: Membership): Promise<readonly PeerStatus[]> {
178
+ await this.assertOwner(membership);
179
+ const members = await this.teams.listMembers(membership.teamId);
180
+ const latest = [...members]
181
+ .sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))
182
+ .filter((member, index, all) => all.findIndex(candidate => candidate.alias === member.alias) === index);
183
+ return Promise.all(latest.map(async member => ({
184
+ memberId: member.memberId,
185
+ alias: member.alias,
186
+ kind: member.kind,
187
+ generation: member.generation,
188
+ status: await this.presence.online(member) ? 'online' as const : 'offline' as const,
189
+ })));
190
+ }
191
+
192
+ async member(teamId: string, memberId: string): Promise<Member | undefined> {
193
+ return this.teams.readMember(teamId, memberId);
194
+ }
195
+
196
+ async peerPage(membership: Membership, limit = 50, cursor?: string): Promise<PeerPage> {
197
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('Peer page limit must be between 1 and 100.');
198
+ const peers = [...await this.peers(membership)]
199
+ .filter(peer => peer.memberId !== membership.memberId)
200
+ .sort((a, b) => a.memberId.localeCompare(b.memberId));
201
+ const remaining = cursor === undefined ? peers : peers.filter(peer => peer.memberId > cursor);
202
+ const page = remaining.slice(0, limit);
203
+ const nextCursor = remaining.length > limit ? page.at(-1)?.memberId : undefined;
204
+ return { peers: page, ...(nextCursor ? { nextCursor } : {}) };
205
+ }
206
+
207
+ async resolveAlias(membership: Membership, alias: string): Promise<PeerStatus> {
208
+ const peer = (await this.peers(membership)).find(candidate => candidate.alias === alias);
209
+ if (!peer) throw Object.assign(new Error(`Unknown teammate "${alias}".`), { code: 'NOT_FOUND' });
210
+ return peer;
211
+ }
212
+ }