@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
package/src/store.ts DELETED
@@ -1,230 +0,0 @@
1
- import { createHash, randomUUID } from 'node:crypto';
2
- import { constants } from 'node:fs';
3
- import { link, mkdir, open, readdir, rename, stat, unlink } from 'node:fs/promises';
4
- import { dirname, join } from 'node:path';
5
-
6
- /**
7
- * Single-record file storage with optimistic concurrency.
8
- *
9
- * Readers never take a lock: every publication renames a new inode into place,
10
- * so a concurrent read either sees the whole previous record or the whole next
11
- * one. Writers compare-and-swap on a monotonically increasing revision guarded
12
- * by a short-lived sibling lock file; conflicts fail fast with STALE_REVISION
13
- * or RECORD_LOCKED and callers retry against a fresh read. Each publication
14
- * hard-links its envelope into a bounded revisions/ history, which doubles as
15
- * recovery evidence for interrupted writes.
16
- */
17
- /**
18
- * `payloadJson` is the canonical `JSON.stringify(payload)`, carried when the
19
- * parser already had to compute it for the content hash. It is consistent by
20
- * construction, never derived from the raw file text, and always optional: a
21
- * pre-envelope record has none and callers fall back to serializing.
22
- */
23
- export type Record<T> = { revision: number; payload: T; payloadJson?: string };
24
- /** Parse raw file bytes into a record, throwing on corruption. Never deletes. */
25
- export type Normalize<T> = (raw: string) => Record<T>;
26
- /**
27
- * 'full' fsyncs file and directory; 'light' fsyncs the file only; 'none'
28
- * fsyncs nothing and relies on the atomic rename alone. Use 'none' only for
29
- * records that are rewritten on a timer and safe to lose, such as presence:
30
- * a lost write there makes a member look offline sooner, never alive longer.
31
- */
32
- export type Durability = 'full' | 'light' | 'none';
33
-
34
- const STALE_LOCK_MS = 10_000;
35
- const KEEP_REVISIONS = 32;
36
-
37
- export const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex');
38
-
39
- /**
40
- * Diagnostics for the polling loop. `reads` counts full record parses, which
41
- * happen once per attempted mutation and on a cache miss, never on a cache hit.
42
- * A joined but idle session should leave both of these flat.
43
- */
44
- export const counters = { reads: 0, publishes: 0 };
45
-
46
- /** Standard envelope parser: schema marker, revision, and content hash. */
47
- export function envelope<T>(raw: string): Record<T> {
48
- const parsed = JSON.parse(raw) as { schemaVersion?: unknown; revision?: unknown; contentHash?: unknown; payload?: unknown };
49
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || parsed.schemaVersion !== 1) {
50
- throw Object.assign(new Error('Unsupported record envelope.'), { code: 'UNSUPPORTED_SCHEMA' });
51
- }
52
- if (typeof parsed.revision !== 'number' || !Number.isSafeInteger(parsed.revision) || parsed.revision < 1) {
53
- throw Object.assign(new Error('Invalid record revision.'), { code: 'CORRUPT_RECORD' });
54
- }
55
- const payloadJson = JSON.stringify(parsed.payload);
56
- if (parsed.contentHash !== sha256(payloadJson)) {
57
- throw Object.assign(new Error('Record hash mismatch; preserved for manual recovery.'), { code: 'CORRUPT_RECORD' });
58
- }
59
- return { revision: parsed.revision, payload: parsed.payload as T, payloadJson };
60
- }
61
-
62
- function assertSafeFile(path: string, info: { isFile(): boolean; size: number; mode: number; uid: number }, maxBytes: number): void {
63
- if (!info.isFile() || info.size > maxBytes || (info.mode & 0o077) !== 0 ||
64
- (process.getuid && info.uid !== process.getuid())) {
65
- throw new Error(`Unsafe record file: ${path}. Expected a private file owned by this user.`);
66
- }
67
- }
68
-
69
- /** Resolve to `undefined` when the target is absent; other errors propagate. */
70
- async function absentAsUndefined<T>(work: Promise<T>): Promise<T | undefined> {
71
- try { return await work; }
72
- catch (error) {
73
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
74
- throw error;
75
- }
76
- }
77
-
78
- /** Lock-free read. Missing records stay missing; corrupt records throw. */
79
- export async function readRecord<T>(path: string, normalize: Normalize<T>, maxBytes: number): Promise<Record<T> | undefined> {
80
- const handle = await absentAsUndefined(open(path, constants.O_RDONLY | constants.O_NOFOLLOW));
81
- if (!handle) return undefined;
82
- try {
83
- assertSafeFile(path, await handle.stat(), maxBytes);
84
- counters.reads++;
85
- return normalize(await handle.readFile('utf8'));
86
- } finally { await handle.close(); }
87
- }
88
-
89
- // Stat-validated read cache for hot polling. Every publication renames a new
90
- // inode into place, so (ino, size, mtime) changes on each write, including
91
- // writes by other processes sharing the store.
92
- const cache = new Map<string, { ino: number; size: number; mtimeMs: number; record: Record<unknown> | undefined }>();
93
-
94
- export async function readRecordCached<T>(path: string, normalize: Normalize<T>, maxBytes: number): Promise<Record<T> | undefined> {
95
- const info = await absentAsUndefined(stat(path));
96
- if (!info) { cache.delete(path); return undefined; }
97
- const hit = cache.get(path);
98
- if (hit && hit.ino === info.ino && hit.size === info.size && hit.mtimeMs === info.mtimeMs) {
99
- return hit.record as Record<T> | undefined;
100
- }
101
- const record = await readRecord(path, normalize, maxBytes);
102
- cache.set(path, { ino: info.ino, size: info.size, mtimeMs: info.mtimeMs, record: record as Record<unknown> | undefined });
103
- return record;
104
- }
105
-
106
- async function syncDirectory(path: string): Promise<void> {
107
- if (process.platform === 'win32') return;
108
- const directory = await open(dirname(path), constants.O_RDONLY);
109
- try { await directory.sync(); } finally { await directory.close(); }
110
- }
111
-
112
- /** Atomic last-writer-wins write for records without revision history (presence). */
113
- export async function writeAtomic(path: string, text: string, durability: Durability = 'full', createParent = true): Promise<void> {
114
- if (createParent) await mkdir(dirname(path), { recursive: true, mode: 0o700 });
115
- const tmp = `${path}.${randomUUID()}.tmp`;
116
- const handle = await open(tmp, 'wx', 0o600);
117
- try {
118
- await handle.writeFile(text, 'utf8');
119
- if (durability !== 'none') await handle.sync();
120
- } finally { await handle.close(); }
121
- try {
122
- await rename(tmp, path);
123
- if (durability === 'full') await syncDirectory(path);
124
- return;
125
- } catch (error) {
126
- await unlink(tmp).catch(() => {});
127
- throw error;
128
- }
129
- }
130
-
131
- const locked = () => Object.assign(new Error('Another writer holds this record.'), { code: 'RECORD_LOCKED' });
132
-
133
- /** Resolve to `undefined` when the lock is already held; other errors propagate. */
134
- async function tryLock(lockPath: string) {
135
- try { return await open(lockPath, 'wx', 0o600); }
136
- catch (error) {
137
- if ((error as NodeJS.ErrnoException).code === 'EEXIST') return undefined;
138
- throw error;
139
- }
140
- }
141
-
142
- async function acquireLock(lockPath: string) {
143
- const held = await tryLock(lockPath);
144
- if (held) return held;
145
- // A crashed writer can leave its lock behind; publication takes
146
- // microseconds, so a lock older than STALE_LOCK_MS is safe to break.
147
- const info = await stat(lockPath).catch(() => undefined);
148
- if (!info || Date.now() - info.mtimeMs <= STALE_LOCK_MS) throw locked();
149
- await unlink(lockPath).catch(() => {});
150
- return await tryLock(lockPath) ?? (() => { throw locked(); })();
151
- }
152
-
153
- /** Run one storage operation while holding a caller-chosen private lock. */
154
- export async function withFileLock<T>(lockPath: string, action: () => Promise<T>): Promise<T> {
155
- await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
156
- const lock = await acquireLock(lockPath);
157
- try { return await action(); }
158
- finally {
159
- await lock.close();
160
- await unlink(lockPath).catch(() => {});
161
- }
162
- }
163
-
164
- async function pruneRevisions(dir: string, latest: number): Promise<void> {
165
- const revisionsDir = join(dir, 'revisions');
166
- const names = await readdir(revisionsDir).catch(() => [] as string[]);
167
- for (const name of names) {
168
- const match = /^(\d+)\.json$/.exec(name);
169
- if (match && Number(match[1]) <= latest - KEEP_REVISIONS) {
170
- await unlink(join(revisionsDir, name)).catch(() => {});
171
- }
172
- }
173
- }
174
-
175
- /**
176
- * Publication body for callers that already hold the record's lock. It still
177
- * checks the expected revision, but does not acquire or release a lock itself.
178
- */
179
- export async function publishLocked<T>(
180
- path: string, expectedRevision: number, payload: T, normalize: Normalize<T>,
181
- options: { maxBytes: number; durability?: Durability; payloadJson?: string; currentNormalize?: Normalize<T> },
182
- ): Promise<Record<T>> {
183
- const durability = options.durability ?? 'full';
184
- // Check the revision before creating a missing parent. A stale writer racing
185
- // a team deletion must fail rather than recreate an empty ghost directory.
186
- const current = await readRecord(path, options.currentNormalize ?? normalize, options.maxBytes);
187
- const revision = current?.revision ?? 0;
188
- if (revision !== expectedRevision) {
189
- throw Object.assign(new Error(`Record changed before the write; current revision is ${revision}.`), { code: 'STALE_REVISION' });
190
- }
191
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
192
- const next = revision + 1;
193
- // Supplied by callers that already serialized this exact object; it must
194
- // equal JSON.stringify(payload). The record stays self-consistent either
195
- // way, because the hash is taken over the string that gets embedded.
196
- const payloadJson = options.payloadJson ?? JSON.stringify(payload);
197
- const text = `{"schemaVersion":1,"revision":${next},"contentHash":"${sha256(payloadJson)}","payload":${payloadJson}}`;
198
- if (Buffer.byteLength(text) > options.maxBytes) throw new Error('Record size limit exceeded.');
199
- const historyPath = join(dirname(path), 'revisions', `${next}.json`);
200
- const previous = await readRecord(historyPath, (raw: string) => envelope<T>(raw), options.maxBytes);
201
- if (previous && (previous.revision !== next || (previous.payloadJson ?? JSON.stringify(previous.payload)) !== payloadJson)) {
202
- throw new Error('An interrupted publication owns this revision; explicit recovery is required.');
203
- }
204
- if (!previous) await writeAtomic(historyPath, text, durability);
205
- // Point `path` at the inode already holding the history copy: one write
206
- // per publication, and the current record shares bytes with its revision.
207
- const tmp = `${path}.${randomUUID()}.tmp`;
208
- await link(historyPath, tmp);
209
- try {
210
- await rename(tmp, path);
211
- if (durability === 'full') await syncDirectory(path);
212
- } finally { await unlink(tmp).catch(() => {}); }
213
- cache.delete(path);
214
- counters.publishes++;
215
- await pruneRevisions(dirname(path), next).catch(() => {});
216
- return { revision: next, payload, payloadJson };
217
- }
218
-
219
- /**
220
- * Compare-and-swap publication. Fails fast with STALE_REVISION when the record
221
- * moved since the caller's read, or RECORD_LOCKED while another writer holds
222
- * the lock; callers retry against a fresh read.
223
- */
224
- export async function publish<T>(
225
- path: string, expectedRevision: number, payload: T, normalize: Normalize<T>,
226
- options: { maxBytes: number; durability?: Durability; payloadJson?: string; lockPath?: string },
227
- ): Promise<Record<T>> {
228
- return withFileLock(options.lockPath ?? `${path}.lock`, () =>
229
- publishLocked(path, expectedRevision, payload, normalize, options));
230
- }