pi-session-mail 0.1.0 → 0.2.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.
package/src/settle.ts ADDED
@@ -0,0 +1,82 @@
1
+ export type SettleInput = {
2
+ requests: ReadonlyMap<string, string[]>;
3
+ asks: ReadonlyMap<string, string[]>;
4
+ seen: ReadonlySet<string>;
5
+ held: ReadonlySet<string>;
6
+ end: { kind: "done" } | { kind: "stopped" } | { kind: "failed"; error: string };
7
+ answer: string | undefined;
8
+ };
9
+ export type Reply = { to: string; ids: string[]; body: string; status: "done" | "stopped" | "failed" };
10
+ export type SettleOutput = { replies: Reply[]; owed: Map<string, string[]>; held: Set<string> };
11
+
12
+ /** Reply body when the run ended with no assistant text. */
13
+ export const NO_ANSWER = "(The session settled with no answer text.)";
14
+ /** Reply body of a run the user stopped, with any partial answer text after it. */
15
+ export const STOPPED = "(The user stopped this run before it finished; the text that follows, if any, is partial.)";
16
+ /** Opening line of the reply body to a request held across a stop, before the answer of the run that completed it. */
17
+ export const TOOK_OVER =
18
+ "(The user stopped an earlier run partway and took over; the answer that follows is from the run that completed after that.)";
19
+ /** Opening line of the reply body of a run that ended on an error. */
20
+ export const ERRORED = (error: string): string =>
21
+ `(The run ended on an error before it finished: ${error}. The text that follows, if any, is partial.)`;
22
+ /** Body of a `failed` reply to requests that never entered the conversation. */
23
+ export const UNSEEN =
24
+ "(The session was stopped before it read the message. Nothing was done; send it again if it is still needed.)";
25
+
26
+ /** The body and status of the reply to a run that ended this way: how it ended, then what it had. */
27
+ function describeEnd(end: SettleInput["end"], answer: string | undefined): { body: string; status: Reply["status"] } {
28
+ const text = answer ?? NO_ANSWER;
29
+ const withOpening = (opening: string): string => (answer === undefined ? opening : `${opening}\n\n${answer}`);
30
+ if (end.kind === "stopped") return { body: withOpening(STOPPED), status: "stopped" };
31
+ if (end.kind === "failed") return { body: withOpening(ERRORED(end.error)), status: "failed" };
32
+ return { body: text, status: "done" };
33
+ }
34
+
35
+ /** The replies to the asks: read ones get the run's answer, the rest fail as unseen. */
36
+ function askReplies(
37
+ asks: SettleInput["asks"],
38
+ seen: SettleInput["seen"],
39
+ run: Pick<Reply, "body" | "status">,
40
+ ): Reply[] {
41
+ const replies: Reply[] = [];
42
+ for (const [to, ids] of asks) {
43
+ const read = ids.filter((id) => seen.has(id));
44
+ const unread = ids.filter((id) => !seen.has(id));
45
+ if (read.length > 0) replies.push({ to, ids: read, ...run });
46
+ if (unread.length > 0) replies.push({ to, ids: unread, body: UNSEEN, status: "failed" });
47
+ }
48
+ return replies;
49
+ }
50
+
51
+ /**
52
+ * Decide the replies when a run settles, and which requests stay owed.
53
+ *
54
+ * A request the user stopped a run on is held: it stays owed, through stops
55
+ * and failed runs alike, until a run completes, and is then answered "took
56
+ * over". A request or ask never read is failed as unseen. Asks are never held;
57
+ * their asker is blocked waiting.
58
+ */
59
+ export function settle(input: SettleInput): SettleOutput {
60
+ const { end, answer, seen, held: wasHeld } = input;
61
+ const stopped = end.kind === "stopped";
62
+ const failed = end.kind === "failed";
63
+ const { body, status } = describeEnd(end, answer);
64
+ const tookOverBody = `${TOOK_OVER}\n\n${answer ?? NO_ANSWER}`;
65
+ const replies: Reply[] = [];
66
+ const owed = new Map<string, string[]>();
67
+ const held = new Set<string>();
68
+ for (const [to, ids] of input.requests) {
69
+ const unseen = ids.filter((id) => !seen.has(id) && !wasHeld.has(id));
70
+ const keep = ids.filter((id) => !unseen.includes(id) && (stopped || (failed && wasHeld.has(id))));
71
+ const answered = ids.filter((id) => !unseen.includes(id) && !keep.includes(id));
72
+ if (keep.length > 0) owed.set(to, keep);
73
+ for (const id of keep) held.add(id);
74
+ const plain = answered.filter((id) => !wasHeld.has(id));
75
+ const tookOver = answered.filter((id) => wasHeld.has(id));
76
+ if (plain.length > 0) replies.push({ to, ids: plain, body, status });
77
+ if (tookOver.length > 0) replies.push({ to, ids: tookOver, body: tookOverBody, status: "done" });
78
+ if (unseen.length > 0) replies.push({ to, ids: unseen, body: UNSEEN, status: "failed" });
79
+ }
80
+ replies.push(...askReplies(input.asks, seen, { body, status }));
81
+ return { replies, owed, held };
82
+ }
package/src/store.ts ADDED
@@ -0,0 +1,412 @@
1
+ // The mailbox's directories and envelopes on disk. Internal to `pi-session-mail`:
2
+ // tests reach it only through the extension's registration function (`settle.ts` is
3
+ // the one module tested directly).
4
+ //
5
+ // Layout: <root>/<address>/{tmp,new,cur,sent}/<ms>-<id>.json, where the root
6
+ // is `$XDG_STATE_HOME/pi-session-mail/` (or `~/.local/state/pi-session-mail/`)
7
+ // and is shared by every Pi agent directory on this machine.
8
+ import { randomUUID } from "node:crypto";
9
+ import { chmodSync, lstatSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { isAbsolute, join } from "node:path";
12
+
13
+ /** What an envelope is: answered at settle, waited on, plain, or an answer. */
14
+ export type Kind = "request" | "ask" | "message" | "reply";
15
+ const KINDS: readonly Kind[] = ["request", "ask", "message", "reply"];
16
+
17
+ export type Envelope = {
18
+ id: string;
19
+ from: string;
20
+ to: string;
21
+ kind: Kind;
22
+ /** How many times this chain of mail has woken or steered a session with no person typing. */
23
+ hops: number;
24
+ /** Request ids this envelope answers; empty for a request. */
25
+ in_reply_to: string[];
26
+ status: string;
27
+ ts: string;
28
+ body: string;
29
+ };
30
+
31
+ type Box = "tmp" | "new" | "cur" | "sent";
32
+ const BOXES: Box[] = ["tmp", "new", "cur", "sent"];
33
+
34
+ const ADDRESS = /^[A-Za-z0-9_-]+$/;
35
+
36
+ /** The most requests one reply may name; an envelope over this is not read. */
37
+ const MAX_REPLY_IDS = 50;
38
+
39
+ /** An address is a single safe path segment: letters, digits, `-` and `_`. */
40
+ export const isAddress = (value: unknown): value is string => typeof value === "string" && ADDRESS.test(value);
41
+
42
+ /**
43
+ * The mail root, read at call time so a changed `XDG_STATE_HOME` is honoured.
44
+ * A relative `XDG_STATE_HOME` is ignored, as the XDG spec requires.
45
+ */
46
+ export function mailRoot(): string {
47
+ const state = process.env.XDG_STATE_HOME;
48
+ const base = state !== undefined && isAbsolute(state) ? state : join(homedir(), ".local", "state");
49
+ return join(base, "pi-session-mail");
50
+ }
51
+
52
+ export function boxPath(address: string, box: Box): string {
53
+ if (!isAddress(address)) throw new Error(`invalid mailbox address: ${JSON.stringify(address)}`);
54
+ return join(mailRoot(), address, box);
55
+ }
56
+
57
+ /** Create the root and `address`'s boxes; the root is made or set owner-only, and so is every directory made here. */
58
+ export function ensureBoxes(address: string): void {
59
+ mkdirSync(mailRoot(), { recursive: true, mode: 0o700 });
60
+ // mkdirSync leaves a directory that already exists as it was. A root this
61
+ // user cannot chmod (another owner, a read-only mount) is used as it is.
62
+ try {
63
+ chmodSync(mailRoot(), 0o700);
64
+ } catch {
65
+ // best effort
66
+ }
67
+ for (const box of BOXES) mkdirSync(boxPath(address, box), { recursive: true, mode: 0o700 });
68
+ }
69
+
70
+ // Names sort in send order: a strictly increasing millisecond stamp in this
71
+ // process, zero-padded, then the id.
72
+ let lastMs = 0;
73
+ function stamp(): number {
74
+ lastMs = Math.max(Date.now(), lastMs + 1);
75
+ return lastMs;
76
+ }
77
+
78
+ const fileName = (ms: number, id: string) => `${String(ms).padStart(15, "0")}-${id}.json`;
79
+
80
+ /** How `send` writes an envelope. A reply names what it answers in `inReplyTo`. */
81
+ export type SendOptions = { kind: Kind; hops: number; inReplyTo?: string[]; status?: string };
82
+
83
+ /**
84
+ * Write an envelope to `to`'s inbox: into its `tmp/`, then renamed into its
85
+ * `new/`. A request is also copied into the sender's `sent/`, first; if any
86
+ * step fails the copy is removed again and the send throws, so a thrown send
87
+ * has delivered nothing. Synchronous,
88
+ * so a `pi.events` caller sees the result as soon as `emit` returns.
89
+ * A reply's `status` is `done` unless given; any other kind's is empty.
90
+ */
91
+ export function send(from: string, to: string, body: string, options: SendOptions): Envelope {
92
+ if (!isAddress(from)) throw new Error(`invalid sender address: ${JSON.stringify(from)}`);
93
+ if (!isAddress(to)) throw new Error(`invalid address: ${JSON.stringify(to)} (expected a session id)`);
94
+ if (!KINDS.includes(options.kind)) throw new Error(`invalid kind: ${JSON.stringify(options.kind)}`);
95
+ if (!isHops(options.hops)) throw new Error(`invalid hops: ${JSON.stringify(options.hops)}`);
96
+ const inReplyTo = options.inReplyTo ?? [];
97
+ const ms = stamp();
98
+ const envelope: Envelope = {
99
+ id: randomUUID(),
100
+ from,
101
+ to,
102
+ kind: options.kind,
103
+ hops: options.hops,
104
+ in_reply_to: inReplyTo,
105
+ status: options.kind === "reply" ? (options.status ?? "done") : "",
106
+ ts: new Date(ms).toISOString(),
107
+ body,
108
+ };
109
+ const name = fileName(ms, envelope.id);
110
+ const text = `${JSON.stringify(envelope, null, 2)}\n`;
111
+ ensureBoxes(to);
112
+ const tmp = join(boxPath(to, "tmp"), name);
113
+ // The sender's copy goes first, so a send that throws has delivered nothing:
114
+ // a failure after it removes the copy and the `tmp/` file again.
115
+ let copy: string | undefined;
116
+ try {
117
+ if (options.kind !== "reply") {
118
+ ensureBoxes(from);
119
+ copy = join(boxPath(from, "sent"), name);
120
+ writeFileSync(copy, text, { mode: 0o600 });
121
+ }
122
+ writeFileSync(tmp, text, { mode: 0o600 });
123
+ renameSync(tmp, join(boxPath(to, "new"), name));
124
+ } catch (err) {
125
+ for (const path of [copy, tmp]) if (path !== undefined) rmSync(path, { force: true });
126
+ throw err;
127
+ }
128
+ return envelope;
129
+ }
130
+
131
+ /** A regular file, the only kind of name worth reading; a FIFO, directory or symlink is not. */
132
+ function isFile(path: string): boolean {
133
+ try {
134
+ return lstatSync(path).isFile();
135
+ } catch {
136
+ return false;
137
+ }
138
+ }
139
+
140
+ /** `.json` names in `box` that are regular files, in readdir order. */
141
+ function jsonFiles(address: string, box: Box): string[] {
142
+ const dir = boxPath(address, box);
143
+ let names: string[];
144
+ try {
145
+ names = readdirSync(dir);
146
+ } catch {
147
+ return [];
148
+ }
149
+ return names.filter((name) => name.endsWith(".json") && isFile(join(dir, name)));
150
+ }
151
+
152
+ /** Regular `.json` files waiting in `new/`, in send order. */
153
+ export function listNew(address: string): string[] {
154
+ return jsonFiles(address, "new").sort();
155
+ }
156
+
157
+ /** Claim a file by renaming it from `new/` into `cur/`; null when another scanner got it first. */
158
+ export function claim(address: string, name: string): string | null {
159
+ const target = join(boxPath(address, "cur"), name);
160
+ try {
161
+ renameSync(join(boxPath(address, "new"), name), target);
162
+ return target;
163
+ } catch {
164
+ return null;
165
+ }
166
+ }
167
+
168
+ /** A request's copy in its sender's `sent/`, with its path. */
169
+ export type SentCopy = { envelope: Envelope; path: string };
170
+
171
+ const isHops = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0;
172
+
173
+ /**
174
+ * Parse an envelope file; throws when it is not a well-formed envelope from a
175
+ * valid address. An envelope written before `kind` and `hops` existed is read
176
+ * as a reply when it answers something and as a request otherwise, with hops 0.
177
+ * A `kind` this version does not know is read as `message`, so mail from a
178
+ * newer copy is delivered rather than stranded; the original value is dropped.
179
+ */
180
+ export function readEnvelope(path: string): Envelope {
181
+ const value = JSON.parse(readFileSync(path, "utf8")) as Partial<Envelope>;
182
+ if (typeof value !== "object" || value === null) throw new Error("not an object");
183
+ if (typeof value.id !== "string" || value.id === "") throw new Error("missing id");
184
+ if (!isAddress(value.from)) throw new Error(`invalid from address ${JSON.stringify(value.from)}`);
185
+ if (!Array.isArray(value.in_reply_to) || !value.in_reply_to.every((id) => typeof id === "string"))
186
+ throw new Error("in_reply_to is not a list of ids");
187
+ if (value.in_reply_to.length > MAX_REPLY_IDS)
188
+ throw new Error(`in_reply_to names more than ${MAX_REPLY_IDS} requests`);
189
+ if (typeof value.body !== "string") throw new Error("missing body");
190
+ if (value.kind === undefined) value.kind = value.in_reply_to.length > 0 ? "reply" : "request";
191
+ else if (!KINDS.includes(value.kind)) value.kind = "message";
192
+ if (value.hops === undefined) value.hops = 0;
193
+ else if (!isHops(value.hops)) throw new Error(`invalid hops ${JSON.stringify(value.hops)}`);
194
+ return value as Envelope;
195
+ }
196
+
197
+ /**
198
+ * This session's `sent/` copies of the requests with envelope ids `ids`, keyed
199
+ * by id; an id with no readable copy is absent. `sent/` is listed once, because
200
+ * it grows with the address's age (until the folder is pruned, which removes
201
+ * it too) and one reply may name many requests. Only names matching an id are read.
202
+ */
203
+ export function findSent(address: string, ids: readonly string[]): Map<string, SentCopy> {
204
+ const wanted = new Set(ids);
205
+ const found = new Map<string, SentCopy>();
206
+ if (wanted.size === 0) return found;
207
+ const dir = boxPath(address, "sent");
208
+ let names: string[];
209
+ try {
210
+ names = readdirSync(dir);
211
+ } catch {
212
+ return found;
213
+ }
214
+ for (const name of names) {
215
+ const id = ids.find((candidate) => name.endsWith(`-${candidate}.json`));
216
+ if (id === undefined || found.has(id)) continue;
217
+ const path = join(dir, name);
218
+ if (!isFile(path)) continue;
219
+ try {
220
+ const envelope = readEnvelope(path);
221
+ if (envelope.id === id) found.set(id, { envelope, path });
222
+ } catch {
223
+ // an unreadable copy is no copy
224
+ }
225
+ }
226
+ return found;
227
+ }
228
+
229
+ function envelopesIn(address: string, box: Box): Partial<Envelope>[] {
230
+ const dir = boxPath(address, box);
231
+ const out: Partial<Envelope>[] = [];
232
+ for (const name of jsonFiles(address, box)) {
233
+ try {
234
+ out.push(JSON.parse(readFileSync(join(dir, name), "utf8")));
235
+ } catch {
236
+ out.push({});
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /**
243
+ * Counts derived from disk: files still in `new/`, envelopes in `cur/`, and
244
+ * requests (not messages) in `sent/` that no envelope in `cur/` answers.
245
+ */
246
+ export function diskCounts(address: string): { unclaimed: number; read: number; awaiting: number } {
247
+ const cur = envelopesIn(address, "cur");
248
+ const answered = new Set(cur.flatMap((e) => (Array.isArray(e.in_reply_to) ? e.in_reply_to : [])));
249
+ // A message expects no answer, so it never counts as awaiting one.
250
+ const awaiting = envelopesIn(address, "sent").filter(
251
+ (e) => typeof e.id === "string" && e.kind !== "message" && !answered.has(e.id),
252
+ ).length;
253
+ return { unclaimed: listNew(address).length, read: cur.length, awaiting };
254
+ }
255
+
256
+ // ---------------------------------------------------------------------------
257
+ // Pruning
258
+
259
+ /** What decides whether an address folder is pruned. */
260
+ export type FolderFacts = {
261
+ own: boolean;
262
+ running: boolean;
263
+ /** Anything at all waiting in `new/`. */
264
+ unread: boolean;
265
+ /** The newest modification time among the folder and its four boxes, in ms. */
266
+ lastActivityMs: number;
267
+ };
268
+
269
+ /**
270
+ * Whether a folder may be removed: it is not this session's, no live session
271
+ * has it, nothing waits in `new/`, and (given a `cutoffMs`) its last activity
272
+ * is older than the cutoff. Without a cutoff age does not matter.
273
+ */
274
+ export function shouldPrune(facts: FolderFacts, cutoffMs?: number): boolean {
275
+ if (facts.own || facts.running || facts.unread) return false;
276
+ return cutoffMs === undefined || facts.lastActivityMs < cutoffMs;
277
+ }
278
+
279
+ /** The directory `running/` holds the running-session records in; it is never pruned. */
280
+ const RUNNING = "running";
281
+
282
+ /** A folder being pruned is first renamed to this, which is no address, so a send makes a fresh folder. */
283
+ const ASIDE = /^\.pruning\.([A-Za-z0-9_-]+)\.[0-9a-f-]{36}$/;
284
+
285
+ const isDirectory = (path: string): boolean => {
286
+ try {
287
+ return lstatSync(path).isDirectory();
288
+ } catch {
289
+ return false;
290
+ }
291
+ };
292
+
293
+ /** Names in `aside/<box>` when it is a real directory; a symlink or file there lists nothing. Undefined when it cannot be listed. */
294
+ const boxNames = (aside: string, box: Box): string[] | undefined =>
295
+ isDirectory(join(aside, box)) ? namesIn(join(aside, box)) : [];
296
+
297
+ /** Rename `from` to `to`; if a concurrent prune renamed the recreated folder away (ENOENT), make the boxes again and retry once. */
298
+ function moveBack(from: string, to: string, address: string): void {
299
+ try {
300
+ renameSync(from, to);
301
+ } catch (err) {
302
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
303
+ ensureBoxes(address);
304
+ renameSync(from, to);
305
+ }
306
+ }
307
+
308
+ /** Names in `path`; undefined when it cannot be listed. */
309
+ function namesIn(path: string): string[] | undefined {
310
+ try {
311
+ return readdirSync(path);
312
+ } catch (err) {
313
+ return (err as NodeJS.ErrnoException).code === "ENOENT" ? [] : undefined;
314
+ }
315
+ }
316
+
317
+ function lastActivity(folder: string): number {
318
+ let newest = 0;
319
+ for (const path of [folder, ...BOXES.map((box) => join(folder, box))]) {
320
+ try {
321
+ newest = Math.max(newest, lstatSync(path).mtimeMs);
322
+ } catch {
323
+ // a missing box has no activity
324
+ }
325
+ }
326
+ return newest;
327
+ }
328
+
329
+ /**
330
+ * Remove a folder that was renamed aside, unless mail reached its `new/`
331
+ * after the check: then everything in its boxes is moved back into the
332
+ * address's own boxes (made again if need be) and the folder is kept. True
333
+ * when the folder was removed.
334
+ */
335
+ function finishAside(aside: string, address: string): boolean {
336
+ const waiting = boxNames(aside, "new");
337
+ if (waiting === undefined) return false;
338
+ if (waiting.length === 0) {
339
+ rmSync(aside, { recursive: true, force: true });
340
+ return true;
341
+ }
342
+ ensureBoxes(address);
343
+ // `new/` last: if a move fails part-way, `new/` still holds mail, so the next
344
+ // prune restores the rest instead of taking the folder for empty and removing it.
345
+ for (const box of ["cur", "sent", "tmp", "new"] as const)
346
+ for (const name of boxNames(aside, box) ?? [])
347
+ moveBack(join(aside, box, name), join(boxPath(address, box), name), address);
348
+ rmSync(aside, { recursive: true, force: true });
349
+ return false;
350
+ }
351
+
352
+ /** What `pruneClosed` is given. */
353
+ export type PruneOptions = {
354
+ /** This session's address, which is never pruned. */
355
+ own?: string;
356
+ /** Whether a live session has this address; asked again for a candidate just before it is moved aside. */
357
+ isRunning: (address: string) => boolean;
358
+ /** Only folders with no activity since this time (ms since the epoch) are removed; none means any age. */
359
+ cutoffMs?: number;
360
+ /** A test seam, called between a folder's check and its removal, to reach that window. */
361
+ beforeRemove?: (address: string) => void;
362
+ };
363
+
364
+ /**
365
+ * Remove the folders of closed sessions that hold no unread mail, and return
366
+ * how many were removed. Only real directories named like an address are
367
+ * considered (never `running/`, a symlink, or a file). A folder is first
368
+ * renamed to a name that is no address, atomically, so a send arriving after
369
+ * the check recreates a fresh folder instead of writing into one about to
370
+ * go; mail that reached its `new/` before the rename is moved back and the
371
+ * folder is kept. A folder left aside by a crash is finished the same way.
372
+ * Liveness is checked again just before the rename, so a session that started
373
+ * after the first check keeps its folder. An error on one folder skips that
374
+ * folder, which is then not counted; the pass goes on.
375
+ */
376
+ export function pruneClosed(options: PruneOptions): number {
377
+ const root = mailRoot();
378
+ const names = namesIn(root);
379
+ if (names === undefined) throw new Error(`cannot list ${root}`);
380
+ return names.filter((name) => {
381
+ try {
382
+ return pruneOne(root, name, options);
383
+ } catch {
384
+ return false; // one bad folder must not stop the pass
385
+ }
386
+ }).length;
387
+ }
388
+
389
+ /** Prune the entry `name` of the mail root if it is a candidate; true when a folder was removed. */
390
+ function pruneOne(root: string, name: string, options: PruneOptions): boolean {
391
+ const path = join(root, name);
392
+ const leftover = ASIDE.exec(name);
393
+ if (leftover !== null) return isDirectory(path) && finishAside(path, leftover[1]);
394
+ if (name === RUNNING || !isAddress(name) || !isDirectory(path)) return false;
395
+ const unread = namesIn(join(path, "new"));
396
+ const facts: FolderFacts = {
397
+ own: name === options.own,
398
+ running: options.isRunning(name),
399
+ unread: unread === undefined || unread.length > 0,
400
+ lastActivityMs: lastActivity(path),
401
+ };
402
+ if (!shouldPrune(facts, options.cutoffMs)) return false;
403
+ options.beforeRemove?.(name);
404
+ if (options.isRunning(name)) return false; // a session may have started since the first check
405
+ const aside = join(root, `.pruning.${name}.${randomUUID()}`);
406
+ try {
407
+ renameSync(path, aside);
408
+ } catch {
409
+ return false; // gone or busy: leave it
410
+ }
411
+ return finishAside(aside, name);
412
+ }