@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,158 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { assertLease, leaseExpired, sameLeaseOwner, type Lease, type LeaseKind } from '../domain/lease.ts';
3
+ import { assertEntityId, assertTeam, assertTeamId, type Team } from '../domain/team.ts';
4
+ import {
5
+ createAtomicJson, ensurePrivateDirectory, ensurePrivateTree, jsonFileNames, readJson, replaceAtomicJson, withStorageLock,
6
+ } from './atomic.ts';
7
+ import { TeamPaths } from './paths.ts';
8
+
9
+ const LEASE_MAX_BYTES = 16 * 1024;
10
+ const TEAM_MAX_BYTES = 32 * 1024;
11
+ const DEFAULT_MAX_TTL_MS = 24 * 60 * 60 * 1000;
12
+
13
+ export type AcquireLease = {
14
+ readonly teamId: string;
15
+ readonly leaseId: string;
16
+ readonly kind: LeaseKind;
17
+ readonly holderId: string;
18
+ readonly resourceId: string;
19
+ readonly ttlMs: number;
20
+ };
21
+
22
+ export type LeaseStoreOptions = {
23
+ readonly maxTtlMs?: number;
24
+ readonly now?: () => number;
25
+ };
26
+
27
+ export class LeaseStore {
28
+ private readonly maxTtlMs: number;
29
+ private readonly now: () => number;
30
+
31
+ constructor(readonly paths: TeamPaths, options: LeaseStoreOptions = {}) {
32
+ this.maxTtlMs = options.maxTtlMs ?? DEFAULT_MAX_TTL_MS;
33
+ this.now = options.now ?? Date.now;
34
+ if (!Number.isFinite(this.maxTtlMs) || this.maxTtlMs <= 0) throw new Error('Invalid maximum lease TTL.');
35
+ }
36
+
37
+ private lockPath(teamId: string): string { return this.paths.lock(`leases-${assertTeamId(teamId)}`); }
38
+
39
+ private async prepare(): Promise<void> {
40
+ await ensurePrivateTree(this.paths.root, 'teams');
41
+ await ensurePrivateTree(this.paths.root, 'control');
42
+ }
43
+
44
+ private validateInput(input: AcquireLease): void {
45
+ assertTeamId(input.teamId);
46
+ assertEntityId(input.leaseId, 'lease ID');
47
+ assertEntityId(input.holderId, 'lease holder ID');
48
+ if (!['presence', 'delivery', 'resource'].includes(input.kind)) throw new Error('Invalid lease kind.');
49
+ if (!input.resourceId || input.resourceId.length > 4096 || input.resourceId.includes('\0')) throw new Error('Invalid lease resource ID.');
50
+ if (!Number.isFinite(input.ttlMs) || input.ttlMs <= 0 || input.ttlMs > this.maxTtlMs) throw new Error('Invalid lease TTL.');
51
+ }
52
+
53
+ private async requireTeam(teamId: string): Promise<Team> {
54
+ try {
55
+ await ensurePrivateDirectory(this.paths.team(teamId), false);
56
+ await ensurePrivateDirectory(this.paths.leases(teamId), false);
57
+ } catch (error) {
58
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
59
+ throw Object.assign(new Error(`Unknown team "${teamId}".`), { code: 'NOT_FOUND' });
60
+ }
61
+ throw error;
62
+ }
63
+ const team = await readJson(this.paths.teamRecord(teamId), assertTeam, TEAM_MAX_BYTES);
64
+ if (!team) throw Object.assign(new Error(`Team "${teamId}" is incomplete.`), { code: 'NOT_FOUND' });
65
+ return team;
66
+ }
67
+
68
+ async read(teamId: string, leaseId: string): Promise<Lease | undefined> {
69
+ await this.prepare();
70
+ await this.requireTeam(teamId);
71
+ const lease = await readJson(this.paths.lease(teamId, leaseId), assertLease, LEASE_MAX_BYTES);
72
+ if (lease && (lease.teamId !== teamId || lease.leaseId !== leaseId)) {
73
+ throw Object.assign(new Error('Lease identity does not match its storage path.'), { code: 'CORRUPT_RECORD' });
74
+ }
75
+ return lease;
76
+ }
77
+
78
+ async acquire(input: AcquireLease): Promise<Lease> {
79
+ this.validateInput(input);
80
+ await this.prepare();
81
+ return withStorageLock(this.lockPath(input.teamId), async () => {
82
+ const team = await this.requireTeam(input.teamId);
83
+ if (team.state !== 'open') throw Object.assign(new Error(`Team "${input.teamId}" is ${team.state}.`), { code: 'TEAM_CLOSED' });
84
+ const current = await this.read(input.teamId, input.leaseId);
85
+ const now = this.now();
86
+ if (current && !leaseExpired(current, now)) {
87
+ throw Object.assign(new Error(`Lease "${input.leaseId}" is held.`), { code: 'LEASE_HELD' });
88
+ }
89
+ const lease: Lease = {
90
+ schemaVersion: 2,
91
+ teamId: input.teamId,
92
+ leaseId: input.leaseId,
93
+ kind: input.kind,
94
+ holderId: input.holderId,
95
+ resourceId: input.resourceId,
96
+ token: randomUUID(),
97
+ generation: (current?.generation ?? 0) + 1,
98
+ acquiredAt: new Date(now).toISOString(),
99
+ expiresAt: new Date(now + input.ttlMs).toISOString(),
100
+ };
101
+ assertLease(lease);
102
+ if (current) await replaceAtomicJson(this.paths.lease(input.teamId, input.leaseId), lease, { maxBytes: LEASE_MAX_BYTES });
103
+ else await createAtomicJson(this.paths.lease(input.teamId, input.leaseId), lease, LEASE_MAX_BYTES);
104
+ return lease;
105
+ });
106
+ }
107
+
108
+ async renew(
109
+ teamId: string,
110
+ leaseId: string,
111
+ holderId: string,
112
+ token: string,
113
+ generation: number,
114
+ ttlMs: number,
115
+ ): Promise<Lease> {
116
+ assertEntityId(holderId, 'lease holder ID');
117
+ if (!Number.isFinite(ttlMs) || ttlMs <= 0 || ttlMs > this.maxTtlMs) throw new Error('Invalid lease TTL.');
118
+ await this.prepare();
119
+ return withStorageLock(this.lockPath(teamId), async () => {
120
+ await this.requireTeam(teamId);
121
+ const current = await this.read(teamId, leaseId);
122
+ if (!current || current.holderId !== holderId || !sameLeaseOwner(current, token, generation)) {
123
+ throw Object.assign(new Error('Lease ownership has been fenced.'), { code: 'FENCED' });
124
+ }
125
+ const now = this.now();
126
+ if (leaseExpired(current, now)) throw Object.assign(new Error('Lease has expired.'), { code: 'EXPIRED' });
127
+ const renewed = { ...current, expiresAt: new Date(now + ttlMs).toISOString() } satisfies Lease;
128
+ assertLease(renewed);
129
+ await replaceAtomicJson(this.paths.lease(teamId, leaseId), renewed, { maxBytes: LEASE_MAX_BYTES });
130
+ return renewed;
131
+ });
132
+ }
133
+
134
+ async release(teamId: string, leaseId: string, holderId: string, token: string, generation: number): Promise<void> {
135
+ assertEntityId(holderId, 'lease holder ID');
136
+ await this.prepare();
137
+ await withStorageLock(this.lockPath(teamId), async () => {
138
+ await this.requireTeam(teamId);
139
+ const current = await this.read(teamId, leaseId);
140
+ if (!current || current.holderId !== holderId || !sameLeaseOwner(current, token, generation)) {
141
+ throw Object.assign(new Error('Lease ownership has been fenced.'), { code: 'FENCED' });
142
+ }
143
+ const releasedAt = Math.max(this.now(), Date.parse(current.acquiredAt));
144
+ const released = { ...current, releasedAt: new Date(releasedAt).toISOString() } satisfies Lease;
145
+ assertLease(released);
146
+ await replaceAtomicJson(this.paths.lease(teamId, leaseId), released, { maxBytes: LEASE_MAX_BYTES });
147
+ });
148
+ }
149
+
150
+ async list(teamId: string): Promise<readonly Lease[]> {
151
+ assertTeamId(teamId);
152
+ await this.prepare();
153
+ await this.requireTeam(teamId);
154
+ const ids = await jsonFileNames(this.paths.leases(teamId), false);
155
+ const leases = await Promise.all(ids.map(id => this.read(teamId, id)));
156
+ return leases.filter((lease): lease is Lease => lease !== undefined);
157
+ }
158
+ }
@@ -0,0 +1,76 @@
1
+ import { lstat } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import { isAbsolute, join, resolve } from 'node:path';
4
+ import { assertEntityId, assertTeamId } from '../domain/team.ts';
5
+
6
+ export type LegacyDetection = {
7
+ readonly path: string;
8
+ readonly present: boolean;
9
+ readonly kind: 'directory' | 'symlink' | 'other' | 'absent';
10
+ };
11
+
12
+ export function defaultStorageRoot(environment: NodeJS.ProcessEnv = process.env): string {
13
+ const projectHome = environment.PRJCT_HOME?.trim();
14
+ const base = projectHome ? (isAbsolute(projectHome) ? projectHome : resolve(projectHome)) : join(homedir(), '.prjct');
15
+ return join(base, 'pi-team');
16
+ }
17
+
18
+ export async function detectLegacyRoot(path = join(homedir(), '.pi', 'agent', 'teams')): Promise<LegacyDetection> {
19
+ const info = await lstat(path).catch(error => {
20
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
21
+ throw error;
22
+ });
23
+ if (!info) return { path, present: false, kind: 'absent' };
24
+ return {
25
+ path,
26
+ present: true,
27
+ kind: info.isSymbolicLink() ? 'symlink' : info.isDirectory() ? 'directory' : 'other',
28
+ };
29
+ }
30
+
31
+ export class TeamPaths {
32
+ readonly root: string;
33
+
34
+ constructor(root = defaultStorageRoot()) {
35
+ this.root = resolve(root);
36
+ }
37
+
38
+ teams(): string { return join(this.root, 'teams'); }
39
+ control(): string { return join(this.root, 'control'); }
40
+ team(teamId: string): string { return join(this.teams(), assertTeamId(teamId)); }
41
+ teamRecord(teamId: string): string { return join(this.team(teamId), 'team.json'); }
42
+ members(teamId: string): string { return join(this.team(teamId), 'members'); }
43
+ member(teamId: string, memberId: string): string {
44
+ return join(this.members(teamId), `${assertEntityId(memberId, 'member ID')}.json`);
45
+ }
46
+ runtimes(teamId: string): string { return join(this.team(teamId), 'runtimes'); }
47
+ runtime(teamId: string, runtimeId: string): string {
48
+ return join(this.runtimes(teamId), `${assertEntityId(runtimeId, 'runtime ID')}.json`);
49
+ }
50
+ inbox(teamId: string): string { return join(this.team(teamId), 'inbox'); }
51
+ memberInbox(teamId: string, memberId: string): string {
52
+ return join(this.inbox(teamId), assertEntityId(memberId, 'member ID'));
53
+ }
54
+ pending(teamId: string, memberId: string): string { return join(this.memberInbox(teamId, memberId), 'pending'); }
55
+ claimed(teamId: string, memberId: string): string { return join(this.memberInbox(teamId, memberId), 'claimed'); }
56
+ pendingMessage(teamId: string, memberId: string, messageId: string): string {
57
+ return join(this.pending(teamId, memberId), `${assertEntityId(messageId, 'message ID')}.json`);
58
+ }
59
+ claimedMessage(teamId: string, memberId: string, messageId: string): string {
60
+ return join(this.claimed(teamId, memberId), `${assertEntityId(messageId, 'message ID')}.json`);
61
+ }
62
+ receipts(teamId: string): string { return join(this.team(teamId), 'receipts'); }
63
+ recipientReceipts(teamId: string, recipientId: string): string {
64
+ return join(this.receipts(teamId), assertEntityId(recipientId, 'recipient ID'));
65
+ }
66
+ receipt(teamId: string, recipientId: string, messageId: string): string {
67
+ return join(this.recipientReceipts(teamId, recipientId), `${assertEntityId(messageId, 'message ID')}.json`);
68
+ }
69
+ leases(teamId: string): string { return join(this.team(teamId), 'leases'); }
70
+ lease(teamId: string, leaseId: string): string {
71
+ return join(this.leases(teamId), `${assertEntityId(leaseId, 'lease ID')}.json`);
72
+ }
73
+ lock(name: string): string { return join(this.control(), `${assertEntityId(name, 'lock ID')}.lock`); }
74
+ teamLock(teamId: string): string { return this.lock(`team-${assertTeamId(teamId)}`); }
75
+ inboxLock(teamId: string): string { return this.lock(`inbox-${assertTeamId(teamId)}`); }
76
+ }
@@ -0,0 +1,117 @@
1
+ import { assertReceipt, type Receipt, type ReceiptStatus } from '../domain/message.ts';
2
+ import { assertEntityId, assertTeamId, timestampMillis } from '../domain/team.ts';
3
+ import {
4
+ createAtomicJson, ensurePrivateDirectory, ensurePrivateTree, jsonFileNames, readJson, removeAtomic, replaceAtomicJson,
5
+ withStorageLock,
6
+ } from './atomic.ts';
7
+ import { TeamPaths } from './paths.ts';
8
+
9
+ const RECEIPT_MAX_BYTES = 16 * 1024;
10
+ const TERMINAL_RECEIPT_TTL_MS = 24 * 60 * 60 * 1000;
11
+ const terminal = new Set<ReceiptStatus>(['replied', 'cancelled', 'expired', 'failed']);
12
+ const transitions: Readonly<Record<ReceiptStatus, readonly ReceiptStatus[]>> = {
13
+ delivered: ['read', 'replied', 'cancelled', 'expired', 'failed'],
14
+ read: ['replied', 'cancelled', 'expired', 'failed'],
15
+ replied: [],
16
+ cancelled: [],
17
+ expired: [],
18
+ failed: [],
19
+ };
20
+
21
+ export type ReceiptStoreOptions = {
22
+ readonly terminalTtlMs?: number;
23
+ readonly now?: () => number;
24
+ };
25
+
26
+ export class ReceiptStore {
27
+ private readonly terminalTtlMs: number;
28
+ private readonly now: () => number;
29
+
30
+ constructor(readonly paths: TeamPaths, options: ReceiptStoreOptions = {}) {
31
+ this.terminalTtlMs = options.terminalTtlMs ?? TERMINAL_RECEIPT_TTL_MS;
32
+ this.now = options.now ?? Date.now;
33
+ if (!Number.isFinite(this.terminalTtlMs) || this.terminalTtlMs <= 0) throw new Error('Invalid receipt TTL.');
34
+ }
35
+
36
+ private lockPath(teamId: string): string { return this.paths.lock(`receipts-${assertTeamId(teamId)}`); }
37
+
38
+ private async prepare(): Promise<void> {
39
+ await ensurePrivateTree(this.paths.root, 'teams');
40
+ await ensurePrivateTree(this.paths.root, 'control');
41
+ }
42
+
43
+ private async prepareRecipient(teamId: string, recipientId: string, create: boolean): Promise<boolean> {
44
+ try {
45
+ await ensurePrivateDirectory(this.paths.team(teamId), false);
46
+ await ensurePrivateDirectory(this.paths.receipts(teamId), false);
47
+ await ensurePrivateDirectory(this.paths.recipientReceipts(teamId, recipientId), create);
48
+ return true;
49
+ } catch (error) {
50
+ if (!create && (error as NodeJS.ErrnoException).code === 'ENOENT') return false;
51
+ throw error;
52
+ }
53
+ }
54
+
55
+ private assertStoredReceipt(receipt: Receipt, teamId: string, recipientId: string, messageId: string): void {
56
+ if (receipt.teamId !== teamId || receipt.recipientId !== recipientId || receipt.messageId !== messageId) {
57
+ throw Object.assign(new Error('Receipt identity does not match its storage path.'), { code: 'CORRUPT_RECORD' });
58
+ }
59
+ }
60
+
61
+ async read(teamId: string, recipientId: string, messageId: string): Promise<Receipt | undefined> {
62
+ await this.prepare();
63
+ if (!await this.prepareRecipient(teamId, recipientId, false)) return undefined;
64
+ const receipt = await readJson(this.paths.receipt(teamId, recipientId, messageId), assertReceipt, RECEIPT_MAX_BYTES);
65
+ if (receipt) this.assertStoredReceipt(receipt, teamId, recipientId, messageId);
66
+ return receipt;
67
+ }
68
+
69
+ async record(receipt: Receipt): Promise<void> {
70
+ assertReceipt(receipt);
71
+ await this.prepare();
72
+ await withStorageLock(this.lockPath(receipt.teamId), async () => {
73
+ await this.prepareRecipient(receipt.teamId, receipt.recipientId, true);
74
+ const path = this.paths.receipt(receipt.teamId, receipt.recipientId, receipt.messageId);
75
+ const current = await readJson(path, assertReceipt, RECEIPT_MAX_BYTES);
76
+ if (!current) {
77
+ await createAtomicJson(path, receipt, RECEIPT_MAX_BYTES);
78
+ return;
79
+ }
80
+ this.assertStoredReceipt(current, receipt.teamId, receipt.recipientId, receipt.messageId);
81
+ if (current.status === receipt.status) return;
82
+ if (!transitions[current.status].includes(receipt.status)) {
83
+ throw Object.assign(new Error(`Invalid receipt transition: ${current.status} → ${receipt.status}.`), { code: 'INVALID_TRANSITION' });
84
+ }
85
+ if (timestampMillis(receipt.at, 'receipt timestamp') < timestampMillis(current.at, 'receipt timestamp')) {
86
+ throw new Error('Receipt timestamp moved backwards.');
87
+ }
88
+ await replaceAtomicJson(path, receipt, { maxBytes: RECEIPT_MAX_BYTES });
89
+ });
90
+ }
91
+
92
+ async list(teamId: string, recipientId: string, limit = 100, cursor?: string): Promise<readonly Receipt[]> {
93
+ assertTeamId(teamId);
94
+ assertEntityId(recipientId, 'recipient ID');
95
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('Receipt page limit must be between 1 and 100.');
96
+ if (cursor !== undefined) assertEntityId(cursor, 'receipt cursor');
97
+ await this.prepare();
98
+ if (!await this.prepareRecipient(teamId, recipientId, false)) return [];
99
+ const ids = await jsonFileNames(this.paths.recipientReceipts(teamId, recipientId), false);
100
+ const page = (cursor === undefined ? ids : ids.filter(id => id > cursor)).slice(0, limit);
101
+ const records = await Promise.all(page.map(id => this.read(teamId, recipientId, id)));
102
+ return records.filter((receipt): receipt is Receipt => receipt !== undefined);
103
+ }
104
+
105
+ async purgeExpired(teamId: string, recipientId: string): Promise<number> {
106
+ await this.prepare();
107
+ return withStorageLock(this.lockPath(teamId), async () => {
108
+ if (!await this.prepareRecipient(teamId, recipientId, false)) return 0;
109
+ const ids = await jsonFileNames(this.paths.recipientReceipts(teamId, recipientId), false);
110
+ const receipts = await Promise.all(ids.map(id => this.read(teamId, recipientId, id)));
111
+ const expired = receipts.filter((receipt): receipt is Receipt => receipt !== undefined && terminal.has(receipt.status) &&
112
+ this.now() - timestampMillis(receipt.at, 'receipt timestamp') >= this.terminalTtlMs);
113
+ await Promise.all(expired.map(receipt => removeAtomic(this.paths.receipt(teamId, recipientId, receipt.messageId))));
114
+ return expired.length;
115
+ });
116
+ }
117
+ }
@@ -0,0 +1,190 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { lstat, mkdir, readdir, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { assertMember, memberBelongsToTeam, type Member } from '../domain/member.ts';
5
+ import { assertTeam, assertTeamId, assertTeamTransition, type Team } from '../domain/team.ts';
6
+ import {
7
+ createAtomicJson, ensurePrivateDirectory, ensurePrivateTree, jsonFileNames, moveAtomic, readJson,
8
+ replaceAtomicJson, withStorageLock,
9
+ } from './atomic.ts';
10
+ import { TeamPaths } from './paths.ts';
11
+
12
+ const TEAM_MAX_BYTES = 32 * 1024;
13
+ const MEMBER_MAX_BYTES = 64 * 1024;
14
+
15
+ export type TeamStoreOptions = {
16
+ readonly maxMembers?: number;
17
+ };
18
+
19
+ export class TeamStore {
20
+ readonly paths: TeamPaths;
21
+ private readonly maxMembers: number;
22
+
23
+ constructor(paths: TeamPaths, options: TeamStoreOptions = {}) {
24
+ this.paths = paths;
25
+ this.maxMembers = options.maxMembers ?? 100;
26
+ if (!Number.isSafeInteger(this.maxMembers) || this.maxMembers < 1) throw new Error('Invalid member quota.');
27
+ }
28
+
29
+ private async prepare(): Promise<void> {
30
+ await ensurePrivateTree(this.paths.root, 'teams');
31
+ await ensurePrivateTree(this.paths.root, 'control');
32
+ }
33
+
34
+ private async requireTeamDirectory(teamId: string): Promise<void> {
35
+ try {
36
+ await ensurePrivateDirectory(this.paths.team(teamId), false);
37
+ await ensurePrivateDirectory(this.paths.members(teamId), false);
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
40
+ throw Object.assign(new Error(`Unknown team "${teamId}".`), { code: 'NOT_FOUND' });
41
+ }
42
+ throw error;
43
+ }
44
+ }
45
+
46
+ async create(team: Team): Promise<void> {
47
+ assertTeam(team);
48
+ await this.prepare();
49
+ await withStorageLock(this.paths.teamLock(team.teamId), async () => {
50
+ const target = this.paths.team(team.teamId);
51
+ const existing = await lstat(target).catch(error => {
52
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
53
+ throw error;
54
+ });
55
+ if (existing) throw Object.assign(new Error(`Team "${team.teamId}" already exists.`), { code: 'ALREADY_EXISTS' });
56
+ const temporary = join(this.paths.teams(), `.${team.teamId}.${randomUUID()}.tmp`);
57
+ await mkdir(temporary, { mode: 0o700 });
58
+ try {
59
+ await Promise.all(['members', 'runtimes', 'inbox', 'receipts', 'leases'].map(
60
+ name => ensurePrivateDirectory(join(temporary, name)),
61
+ ));
62
+ await createAtomicJson(join(temporary, 'team.json'), team, TEAM_MAX_BYTES);
63
+ await moveAtomic(temporary, target);
64
+ } catch (error) {
65
+ await rm(temporary, { recursive: true, force: true });
66
+ throw error;
67
+ }
68
+ });
69
+ }
70
+
71
+ async list(): Promise<string[]> {
72
+ await this.prepare();
73
+ const entries = await readdir(this.paths.teams(), { withFileTypes: true });
74
+ return entries
75
+ .filter(entry => entry.isDirectory() && !entry.isSymbolicLink())
76
+ .map(entry => entry.name)
77
+ .filter(name => {
78
+ try { assertTeamId(name); return true; }
79
+ catch { return false; }
80
+ })
81
+ .sort();
82
+ }
83
+
84
+ async read(teamId: string): Promise<Team | undefined> {
85
+ assertTeamId(teamId);
86
+ await this.prepare();
87
+ try { await this.requireTeamDirectory(teamId); }
88
+ catch (error) {
89
+ if ((error as { code?: string }).code === 'NOT_FOUND') return undefined;
90
+ throw error;
91
+ }
92
+ const team = await readJson(this.paths.teamRecord(teamId), assertTeam, TEAM_MAX_BYTES);
93
+ if (team && team.teamId !== teamId) throw Object.assign(new Error('Team ID does not match its storage path.'), { code: 'CORRUPT_RECORD' });
94
+ return team;
95
+ }
96
+
97
+ async update(teamId: string, change: (team: Team) => Team): Promise<Team> {
98
+ assertTeamId(teamId);
99
+ await this.prepare();
100
+ return withStorageLock(this.paths.teamLock(teamId), async () => {
101
+ await this.requireTeamDirectory(teamId);
102
+ const current = await readJson(this.paths.teamRecord(teamId), assertTeam, TEAM_MAX_BYTES);
103
+ if (!current) throw Object.assign(new Error(`Team "${teamId}" is incomplete or missing.`), { code: 'NOT_FOUND' });
104
+ const next = change(current);
105
+ assertTeam(next);
106
+ if (next.teamId !== current.teamId || next.createdAt !== current.createdAt) {
107
+ throw new Error('Team identity and creation timestamp are immutable.');
108
+ }
109
+ if (Date.parse(next.updatedAt) < Date.parse(current.updatedAt)) throw new Error('Team updatedAt moved backwards.');
110
+ assertTeamTransition(current.state, next.state);
111
+ await replaceAtomicJson(this.paths.teamRecord(teamId), next, { maxBytes: TEAM_MAX_BYTES, previous: true });
112
+ return next;
113
+ });
114
+ }
115
+
116
+ async createMember(member: Member): Promise<void> {
117
+ assertMember(member);
118
+ await this.prepare();
119
+ await withStorageLock(this.paths.teamLock(member.teamId), async () => {
120
+ await this.requireTeamDirectory(member.teamId);
121
+ const team = await readJson(this.paths.teamRecord(member.teamId), assertTeam, TEAM_MAX_BYTES);
122
+ if (!team) throw Object.assign(new Error(`Unknown team "${member.teamId}".`), { code: 'NOT_FOUND' });
123
+ if (team.state !== 'open') throw Object.assign(new Error(`Team "${member.teamId}" is ${team.state}.`), { code: 'TEAM_CLOSED' });
124
+ memberBelongsToTeam(member, team);
125
+ await ensurePrivateDirectory(this.paths.members(member.teamId));
126
+ const ids = await jsonFileNames(this.paths.members(member.teamId));
127
+ if (ids.length >= this.maxMembers) throw Object.assign(new Error(`Team member quota reached (${this.maxMembers}).`), { code: 'QUOTA_EXCEEDED' });
128
+ const members = await Promise.all(ids.map(id => readJson(this.paths.member(member.teamId, id), assertMember, MEMBER_MAX_BYTES)));
129
+ if (members.some(existing => existing?.alias === member.alias && existing.state === 'active')) {
130
+ throw Object.assign(new Error(`Member alias "${member.alias}" is already active.`), { code: 'ALREADY_EXISTS' });
131
+ }
132
+ await createAtomicJson(this.paths.member(member.teamId, member.memberId), member, MEMBER_MAX_BYTES);
133
+ });
134
+ }
135
+
136
+ async readMember(teamId: string, memberId: string): Promise<Member | undefined> {
137
+ await this.prepare();
138
+ try { await this.requireTeamDirectory(teamId); }
139
+ catch (error) {
140
+ if ((error as { code?: string }).code === 'NOT_FOUND') return undefined;
141
+ throw error;
142
+ }
143
+ const member = await readJson(this.paths.member(teamId, memberId), assertMember, MEMBER_MAX_BYTES);
144
+ if (member && (member.teamId !== teamId || member.memberId !== memberId)) {
145
+ throw Object.assign(new Error('Member identity does not match its storage path.'), { code: 'CORRUPT_RECORD' });
146
+ }
147
+ return member;
148
+ }
149
+
150
+ async listMembers(teamId: string): Promise<Member[]> {
151
+ assertTeamId(teamId);
152
+ await this.prepare();
153
+ await this.requireTeamDirectory(teamId);
154
+ const ids = await jsonFileNames(this.paths.members(teamId));
155
+ const members = await Promise.all(ids.map(id => this.readMember(teamId, id)));
156
+ return members.filter((member): member is Member => member !== undefined);
157
+ }
158
+
159
+ async updateMember(
160
+ teamId: string,
161
+ memberId: string,
162
+ expectedGeneration: number,
163
+ change: (member: Member) => Member,
164
+ ): Promise<Member> {
165
+ await this.prepare();
166
+ return withStorageLock(this.paths.teamLock(teamId), async () => {
167
+ await this.requireTeamDirectory(teamId);
168
+ const current = await this.readMember(teamId, memberId);
169
+ if (!current) throw Object.assign(new Error(`Unknown member "${memberId}".`), { code: 'NOT_FOUND' });
170
+ if (current.generation !== expectedGeneration) {
171
+ throw Object.assign(new Error('Member generation has changed.'), { code: 'FENCED' });
172
+ }
173
+ const next = change(current);
174
+ assertMember(next);
175
+ if (next.teamId !== current.teamId || next.memberId !== current.memberId || next.joinedAt !== current.joinedAt ||
176
+ next.generation < current.generation || next.generation > current.generation + 1) {
177
+ throw new Error('Invalid member identity or generation update.');
178
+ }
179
+ if (Date.parse(next.updatedAt) < Date.parse(current.updatedAt)) throw new Error('Member updatedAt moved backwards.');
180
+ if (next.state === 'active' && (next.alias !== current.alias || current.state !== 'active')) {
181
+ const members = await this.listMembers(teamId);
182
+ if (members.some(member => member.memberId !== memberId && member.alias === next.alias && member.state === 'active')) {
183
+ throw Object.assign(new Error(`Member alias "${next.alias}" is already active.`), { code: 'ALREADY_EXISTS' });
184
+ }
185
+ }
186
+ await replaceAtomicJson(this.paths.member(teamId, memberId), next, { maxBytes: MEMBER_MAX_BYTES, previous: true });
187
+ return next;
188
+ });
189
+ }
190
+ }
@@ -0,0 +1,125 @@
1
+ import { timingSafeEqual } from 'node:crypto';
2
+ import { Type } from 'typebox';
3
+ import { Value } from 'typebox/value';
4
+ import { EntityIdSchema } from '../domain/team.ts';
5
+ import type { OwnerIdentity } from './runtime-store.ts';
6
+
7
+ export const CONTROL_PROTOCOL_VERSION = 1;
8
+ export const MAX_CONTROL_FRAME_BYTES = 4 * 1024;
9
+ export const MAX_CONTROL_BUFFER_BYTES = 8 * 1024;
10
+
11
+ const BaseSchema = {
12
+ version: Type.Literal(CONTROL_PROTOCOL_VERSION),
13
+ runtimeId: EntityIdSchema,
14
+ seq: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
15
+ };
16
+
17
+ const OwnedWorkerSchema = {
18
+ ...BaseSchema,
19
+ ownerEpoch: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
20
+ };
21
+
22
+ const OwnerSchema = Type.Object({
23
+ ownerSessionId: EntityIdSchema,
24
+ ownerInstanceId: EntityIdSchema,
25
+ ownerProcessNonce: Type.String({ pattern: '^[a-f0-9]{64}$' }),
26
+ ownerEpoch: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
27
+ }, { additionalProperties: false });
28
+
29
+ export const WorkerFrameSchema = Type.Union([
30
+ Type.Object({ ...BaseSchema, type: Type.Literal('hello'), token: Type.String({ minLength: 64, maxLength: 128 }), ownerProcessNonce: Type.String({ pattern: '^[a-f0-9]{64}$' }) }, { additionalProperties: false }),
31
+ Type.Object({ ...OwnedWorkerSchema, type: Type.Literal('ready') }, { additionalProperties: false }),
32
+ Type.Object({ ...OwnedWorkerSchema, type: Type.Literal('state'), state: Type.Union([Type.Literal('ready'), Type.Literal('busy')]), requestId: Type.Optional(EntityIdSchema) }, { additionalProperties: false }),
33
+ Type.Object({ ...OwnedWorkerSchema, type: Type.Literal('pong'), nonce: EntityIdSchema }, { additionalProperties: false }),
34
+ Type.Object({ ...OwnedWorkerSchema, type: Type.Literal('shutdown_ack') }, { additionalProperties: false }),
35
+ ]);
36
+
37
+ export const SupervisorFrameSchema = Type.Union([
38
+ Type.Object({ ...BaseSchema, type: Type.Literal('hello_ack'), owner: OwnerSchema }, { additionalProperties: false }),
39
+ Type.Object({ ...BaseSchema, type: Type.Literal('ping'), nonce: EntityIdSchema }, { additionalProperties: false }),
40
+ Type.Object({ ...BaseSchema, type: Type.Literal('cancel_request'), requestId: EntityIdSchema }, { additionalProperties: false }),
41
+ Type.Object({ ...BaseSchema, type: Type.Literal('prepare_shutdown'), reason: Type.Union([Type.Literal('stop'), Type.Literal('close'), Type.Literal('reload_failed'), Type.Literal('owner_lost')]), deadlineAt: Type.String({ format: 'date-time' }) }, { additionalProperties: false }),
42
+ ]);
43
+
44
+ export type WorkerFrame =
45
+ | { readonly version: 1; readonly type: 'hello'; readonly runtimeId: string; readonly seq: number; readonly token: string; readonly ownerProcessNonce: string }
46
+ | { readonly version: 1; readonly type: 'ready'; readonly runtimeId: string; readonly seq: number; readonly ownerEpoch: number }
47
+ | { readonly version: 1; readonly type: 'state'; readonly runtimeId: string; readonly seq: number; readonly ownerEpoch: number; readonly state: 'ready' | 'busy'; readonly requestId?: string }
48
+ | { readonly version: 1; readonly type: 'pong'; readonly runtimeId: string; readonly seq: number; readonly ownerEpoch: number; readonly nonce: string }
49
+ | { readonly version: 1; readonly type: 'shutdown_ack'; readonly runtimeId: string; readonly seq: number; readonly ownerEpoch: number };
50
+
51
+ export type SupervisorFrame =
52
+ | { readonly version: 1; readonly type: 'hello_ack'; readonly runtimeId: string; readonly seq: number; readonly owner: OwnerIdentity }
53
+ | { readonly version: 1; readonly type: 'ping'; readonly runtimeId: string; readonly seq: number; readonly nonce: string }
54
+ | { readonly version: 1; readonly type: 'cancel_request'; readonly runtimeId: string; readonly seq: number; readonly requestId: string }
55
+ | { readonly version: 1; readonly type: 'prepare_shutdown'; readonly runtimeId: string; readonly seq: number; readonly reason: 'stop' | 'close' | 'reload_failed' | 'owner_lost'; readonly deadlineAt: string };
56
+
57
+ export type WorkerFramePayload = WorkerFrame extends infer Frame
58
+ ? Frame extends WorkerFrame ? Omit<Frame, 'version' | 'runtimeId' | 'seq' | 'ownerEpoch'> : never
59
+ : never;
60
+ export type SupervisorFramePayload = SupervisorFrame extends infer Frame
61
+ ? Frame extends SupervisorFrame ? Omit<Frame, 'version' | 'runtimeId' | 'seq'> : never
62
+ : never;
63
+
64
+ export function assertWorkerFrame(value: unknown): asserts value is WorkerFrame {
65
+ if (!Value.Check(WorkerFrameSchema, value)) throw Object.assign(new Error('Invalid worker control frame.'), { code: 'INVALID_FRAME' });
66
+ const frame = value as WorkerFrame;
67
+ if (frame.type === 'state' && (frame.state === 'busy') !== (frame.requestId !== undefined)) {
68
+ throw Object.assign(new Error('Busy worker state requires exactly one request ID.'), { code: 'INVALID_FRAME' });
69
+ }
70
+ }
71
+
72
+ export function assertSupervisorFrame(value: unknown): asserts value is SupervisorFrame {
73
+ if (!Value.Check(SupervisorFrameSchema, value)) throw Object.assign(new Error('Invalid supervisor control frame.'), { code: 'INVALID_FRAME' });
74
+ }
75
+
76
+ export function controlTokenMatches(expected: string, received: string): boolean {
77
+ const left = Buffer.from(expected, 'utf8');
78
+ const right = Buffer.from(received, 'utf8');
79
+ return left.length === right.length && timingSafeEqual(left, right);
80
+ }
81
+
82
+ export function encodeControlFrame(frame: WorkerFrame | SupervisorFrame): Buffer {
83
+ if (frame.type === 'hello' || frame.type === 'ready' || frame.type === 'state' || frame.type === 'pong' || frame.type === 'shutdown_ack') {
84
+ assertWorkerFrame(frame);
85
+ } else {
86
+ assertSupervisorFrame(frame);
87
+ }
88
+ const text = `${JSON.stringify(frame)}\n`;
89
+ if (Buffer.byteLength(text, 'utf8') > MAX_CONTROL_FRAME_BYTES) {
90
+ throw Object.assign(new Error('Control frame is too large.'), { code: 'FRAME_TOO_LARGE' });
91
+ }
92
+ return Buffer.from(text, 'utf8');
93
+ }
94
+
95
+ export class NdjsonFrameDecoder<T> {
96
+ private buffer = Buffer.alloc(0);
97
+
98
+ constructor(private readonly validate: (value: unknown) => asserts value is T) {}
99
+
100
+ push(chunk: Buffer): readonly T[] {
101
+ this.buffer = Buffer.concat([this.buffer, chunk]);
102
+ if (this.buffer.length > MAX_CONTROL_BUFFER_BYTES) {
103
+ this.buffer = Buffer.alloc(0);
104
+ throw Object.assign(new Error('Control frame buffer is too large.'), { code: 'FRAME_TOO_LARGE' });
105
+ }
106
+ return this.drain([]);
107
+ }
108
+
109
+ private drain(frames: readonly T[]): readonly T[] {
110
+ const newline = this.buffer.indexOf(0x0a);
111
+ if (newline < 0) return frames;
112
+ const line = this.buffer.subarray(0, newline);
113
+ this.buffer = this.buffer.subarray(newline + 1);
114
+ if (line.length === 0) return this.drain(frames);
115
+ if (line.length + 1 > MAX_CONTROL_FRAME_BYTES) {
116
+ throw Object.assign(new Error('Control frame is too large.'), { code: 'FRAME_TOO_LARGE' });
117
+ }
118
+ const parsed = (() => {
119
+ try { return JSON.parse(line.toString('utf8')) as unknown; }
120
+ catch { throw Object.assign(new Error('Malformed control frame JSON.'), { code: 'INVALID_FRAME' }); }
121
+ })();
122
+ this.validate(parsed);
123
+ return this.drain([...frames, parsed]);
124
+ }
125
+ }