pi-session-mail 0.1.0 → 0.2.1
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/CHANGELOG.md +96 -1
- package/README.md +280 -157
- package/package.json +3 -6
- package/src/config.ts +46 -0
- package/{index.ts → src/index.ts} +144 -135
- package/{running.ts → src/running.ts} +118 -10
- package/src/settle.ts +82 -0
- package/src/store.ts +412 -0
- package/{tools.ts → src/tools.ts} +80 -40
- package/store.ts +0 -222
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
// Running-session records and `to` resolution. Internal to `pi-session-mail`: tests
|
|
2
|
-
// reach it
|
|
2
|
+
// reach it through the extension's registration function, apart from the start-token
|
|
3
|
+
// probe and its parsers, which `test/start-token.test.ts` tests directly.
|
|
3
4
|
//
|
|
4
5
|
// Each running session announces itself in `<root>/running/<address>.json`:
|
|
5
6
|
// its address, Pi session name, working directory, process id, idle or busy,
|
|
6
7
|
// the address it waits on, and when the record last changed. A record whose
|
|
7
|
-
// process is gone
|
|
8
|
-
//
|
|
8
|
+
// process is gone, or whose pid now belongs to a process that started at
|
|
9
|
+
// another time, counts as not running, and the reader that finds it deletes it.
|
|
10
|
+
// The root is the mail root, so every agent directory sees every session.
|
|
11
|
+
import { execFileSync } from "node:child_process";
|
|
9
12
|
import { mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
10
13
|
import { join } from "node:path";
|
|
11
14
|
|
|
@@ -19,6 +22,11 @@ export type RunningRecord = {
|
|
|
19
22
|
name?: string;
|
|
20
23
|
cwd: string;
|
|
21
24
|
pid: number;
|
|
25
|
+
/**
|
|
26
|
+
* An opaque token for the process's start time, so a reused pid is told from the
|
|
27
|
+
* original process. Absent in records written by 0.1.0 and where no probe works.
|
|
28
|
+
*/
|
|
29
|
+
started?: string;
|
|
22
30
|
state: State;
|
|
23
31
|
/** The address this session's pending ask waits on; empty when none. */
|
|
24
32
|
waitingOn: string;
|
|
@@ -27,12 +35,14 @@ export type RunningRecord = {
|
|
|
27
35
|
};
|
|
28
36
|
|
|
29
37
|
/** How many leading characters of a session id name it in lists and errors. */
|
|
30
|
-
|
|
38
|
+
const SHORT_ID = 8;
|
|
31
39
|
|
|
32
|
-
|
|
40
|
+
const shortId = (address: string) => address.slice(0, SHORT_ID);
|
|
33
41
|
|
|
34
42
|
/** A record's display label: its session name, or its short id when it has none. */
|
|
35
|
-
export
|
|
43
|
+
export function label(record: Pick<RunningRecord, "address" | "name">): string {
|
|
44
|
+
return record.name ?? shortId(record.address);
|
|
45
|
+
}
|
|
36
46
|
|
|
37
47
|
const runningDir = () => join(mailRoot(), "running");
|
|
38
48
|
|
|
@@ -41,8 +51,78 @@ const recordPath = (address: string) => {
|
|
|
41
51
|
return join(runningDir(), `${address}.json`);
|
|
42
52
|
};
|
|
43
53
|
|
|
44
|
-
/**
|
|
45
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The start-time token in a `/proc/<pid>/stat` line: field 22. The command name
|
|
56
|
+
* (field 2) is in parentheses and may itself hold spaces and parentheses, so
|
|
57
|
+
* the fields are counted from the last `)`.
|
|
58
|
+
*/
|
|
59
|
+
export function parseProcStat(line: string): string | undefined {
|
|
60
|
+
const rest = line.slice(line.lastIndexOf(")") + 1).trim();
|
|
61
|
+
if (rest === "" || !line.includes(")")) return undefined;
|
|
62
|
+
const token = rest.split(/\s+/)[19]; // field 3 is the first after the name
|
|
63
|
+
return token !== undefined && /^\d+$/.test(token) ? token : undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parse `ps -o pid=,lstart=` output into a pid -> start token map; unparseable lines are skipped. */
|
|
67
|
+
export function parsePsStarts(output: string): Map<number, string> {
|
|
68
|
+
const out = new Map<number, string>();
|
|
69
|
+
for (const line of output.split("\n")) {
|
|
70
|
+
const match = /^\s*(\d+)\s+(\S.*?)\s*$/.exec(line);
|
|
71
|
+
if (match !== null) out.set(Number(match[1]), match[2]);
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The start tokens of `pids`, for those the probe could read. Linux reads
|
|
78
|
+
* `/proc`; macOS runs one `ps` for all of them, in UTC and the C locale so
|
|
79
|
+
* every session renders a start time the same way; elsewhere, or on any error,
|
|
80
|
+
* the result lacks the pid, which callers treat as unknown.
|
|
81
|
+
*/
|
|
82
|
+
export function startTokens(pids: readonly number[]): Map<number, string> {
|
|
83
|
+
const out = new Map<number, string>();
|
|
84
|
+
if (pids.length === 0) return out;
|
|
85
|
+
if (process.platform === "linux") {
|
|
86
|
+
for (const pid of pids) {
|
|
87
|
+
try {
|
|
88
|
+
const token = parseProcStat(readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
89
|
+
if (token !== undefined) out.set(pid, token);
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
} else if (process.platform === "darwin") {
|
|
93
|
+
let stdout = "";
|
|
94
|
+
try {
|
|
95
|
+
stdout = execFileSync("ps", ["-o", "pid=,lstart=", "-p", pids.join(",")], {
|
|
96
|
+
encoding: "utf8",
|
|
97
|
+
timeout: 2000,
|
|
98
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
99
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" },
|
|
100
|
+
});
|
|
101
|
+
} catch (err) {
|
|
102
|
+
// ps exits 1 when some pid is gone but still prints the others.
|
|
103
|
+
const partial = (err as { stdout?: unknown }).stdout;
|
|
104
|
+
if (typeof partial === "string") stdout = partial;
|
|
105
|
+
}
|
|
106
|
+
for (const [pid, token] of parsePsStarts(stdout)) out.set(pid, token);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let ownToken: { value: string | undefined } | undefined;
|
|
112
|
+
|
|
113
|
+
/** This process's start token, probed once; undefined when it cannot be read. */
|
|
114
|
+
function ownStartToken(): string | undefined {
|
|
115
|
+
ownToken ??= { value: startTokens([process.pid]).get(process.pid) };
|
|
116
|
+
return ownToken.value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Write `record` owner-only, through a temporary file so a reader never sees half of it.
|
|
121
|
+
* A record for this process gets this process's start token.
|
|
122
|
+
*/
|
|
123
|
+
export function writeRecord(given: RunningRecord): void {
|
|
124
|
+
const started = given.pid === process.pid && given.started === undefined ? ownStartToken() : undefined;
|
|
125
|
+
const record = started === undefined ? given : { ...given, started };
|
|
46
126
|
mkdirSync(runningDir(), { recursive: true, mode: 0o700 });
|
|
47
127
|
const path = recordPath(record.address);
|
|
48
128
|
const tmp = `${path}.${process.pid}.tmp`;
|
|
@@ -79,6 +159,7 @@ function readRecord(path: string): RunningRecord | undefined {
|
|
|
79
159
|
...(typeof value.name === "string" && value.name !== "" ? { name: value.name } : {}),
|
|
80
160
|
cwd: value.cwd,
|
|
81
161
|
pid: value.pid,
|
|
162
|
+
...(typeof value.started === "string" && value.started !== "" ? { started: value.started } : {}),
|
|
82
163
|
state: value.state === "busy" ? "busy" : "idle",
|
|
83
164
|
waitingOn: typeof value.waitingOn === "string" ? value.waitingOn : "",
|
|
84
165
|
updated: typeof value.updated === "string" ? value.updated : "",
|
|
@@ -88,6 +169,24 @@ function readRecord(path: string): RunningRecord | undefined {
|
|
|
88
169
|
}
|
|
89
170
|
}
|
|
90
171
|
|
|
172
|
+
/**
|
|
173
|
+
* Whether `address` has a live running record, read fresh from its one file. The
|
|
174
|
+
* rules are `listRunning`'s: the pid is alive and, when both tokens are known,
|
|
175
|
+
* the start token matches. A missing or unreadable record is not running; a
|
|
176
|
+
* failed probe falls back to the pid alone. Never throws and deletes nothing.
|
|
177
|
+
*/
|
|
178
|
+
export function isRunning(address: string): boolean {
|
|
179
|
+
try {
|
|
180
|
+
const record = readRecord(recordPath(address));
|
|
181
|
+
if (record === undefined || record.address !== address || !alive(record.pid)) return false;
|
|
182
|
+
if (record.started === undefined) return true;
|
|
183
|
+
const live = startTokens([record.pid]).get(record.pid);
|
|
184
|
+
return live === undefined || live === record.started;
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
91
190
|
/** Every running session's record, sorted by label; records of dead processes are deleted. */
|
|
92
191
|
export function listRunning(): RunningRecord[] {
|
|
93
192
|
let names: string[];
|
|
@@ -96,12 +195,21 @@ export function listRunning(): RunningRecord[] {
|
|
|
96
195
|
} catch {
|
|
97
196
|
return [];
|
|
98
197
|
}
|
|
99
|
-
const
|
|
198
|
+
const found: { path: string; record: RunningRecord }[] = [];
|
|
100
199
|
for (const name of names) {
|
|
101
200
|
const path = join(runningDir(), name);
|
|
102
201
|
const record = readRecord(path);
|
|
103
202
|
if (record === undefined || `${record.address}.json` !== name) continue; // not ours to judge or delete
|
|
104
|
-
|
|
203
|
+
found.push({ path, record });
|
|
204
|
+
}
|
|
205
|
+
const tokens = startTokens(
|
|
206
|
+
found.filter(({ record }) => record.started !== undefined && alive(record.pid)).map(({ record }) => record.pid),
|
|
207
|
+
);
|
|
208
|
+
const out: RunningRecord[] = [];
|
|
209
|
+
for (const { path, record } of found) {
|
|
210
|
+
const live = tokens.get(record.pid);
|
|
211
|
+
// A token we cannot read is unknown, which falls back to the pid-only check.
|
|
212
|
+
if (!alive(record.pid) || (record.started !== undefined && live !== undefined && live !== record.started)) {
|
|
105
213
|
rmSync(path, { force: true });
|
|
106
214
|
continue;
|
|
107
215
|
}
|
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
|
+
}
|