@nicknisi/pi-relay 0.0.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/LICENSE +21 -0
- package/README.md +71 -0
- package/dist/format.d.ts +14 -0
- package/dist/format.js +46 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +569 -0
- package/dist/mailbox.d.ts +63 -0
- package/dist/mailbox.js +194 -0
- package/dist/policy.d.ts +33 -0
- package/dist/policy.js +71 -0
- package/dist/registry.d.ts +55 -0
- package/dist/registry.js +149 -0
- package/dist/relay.test.d.ts +5 -0
- package/dist/relay.test.js +244 -0
- package/format.ts +60 -0
- package/index.ts +633 -0
- package/mailbox.ts +227 -0
- package/package.json +44 -0
- package/policy.ts +75 -0
- package/registry.ts +163 -0
package/mailbox.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Letter transport for the relay mailbox. Pi-free.
|
|
3
|
+
*
|
|
4
|
+
* Guarantees (each pinned by a test):
|
|
5
|
+
* - A reader never sees half a letter: writers rename into place; readers
|
|
6
|
+
* only look at `*.json`.
|
|
7
|
+
* - Nothing is delivered twice: a letter is unlinked as it is read, BEFORE
|
|
8
|
+
* the caller handles it.
|
|
9
|
+
* - A corrupt letter is discarded on read, so it cannot poison every drain.
|
|
10
|
+
* - Consumption is the receipt: the sender learns "delivered" only when the
|
|
11
|
+
* letter actually disappeared from the target's inbox.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import { asksDir, inboxDir } from './registry.js';
|
|
17
|
+
|
|
18
|
+
export type LetterKind = 'message' | 'ask' | 'reply' | 'cancel';
|
|
19
|
+
|
|
20
|
+
export interface Letter {
|
|
21
|
+
id: string;
|
|
22
|
+
from: { addr: string; name: string; cwd: string };
|
|
23
|
+
kind: LetterKind;
|
|
24
|
+
body: string;
|
|
25
|
+
replyTo?: string;
|
|
26
|
+
ts: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const MAX_BODY_CHARS = 32 * 1024;
|
|
30
|
+
|
|
31
|
+
function letterFileName(letter: Letter): string {
|
|
32
|
+
return `${letter.ts}-${letter.id.slice(0, 6)}.json`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Atomic deposit: write to a tmp sibling, then rename into place. */
|
|
36
|
+
export function deposit(root: string, toAddr: string, letter: Letter): void {
|
|
37
|
+
const dir = inboxDir(root, toAddr);
|
|
38
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
39
|
+
const name = letterFileName(letter);
|
|
40
|
+
const tmp = path.join(dir, `${name}.${process.pid}.tmp`);
|
|
41
|
+
fs.writeFileSync(tmp, JSON.stringify(letter), { mode: 0o600 });
|
|
42
|
+
fs.renameSync(tmp, path.join(dir, name));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Take every letter in the inbox, oldest first. Each letter is unlinked as
|
|
47
|
+
* it is read — before parsing — so it can never be delivered twice. The
|
|
48
|
+
* tradeoff: a drained letter that is never delivered would be lost, so the
|
|
49
|
+
* caller must re-deposit any letter it fails to deliver (checkInbox does).
|
|
50
|
+
*/
|
|
51
|
+
export function drain(root: string, addr: string): Letter[] {
|
|
52
|
+
const dir = inboxDir(root, addr);
|
|
53
|
+
let names: string[];
|
|
54
|
+
try {
|
|
55
|
+
names = fs
|
|
56
|
+
.readdirSync(dir)
|
|
57
|
+
.filter((f) => f.endsWith('.json'))
|
|
58
|
+
.sort();
|
|
59
|
+
} catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const out: Letter[] = [];
|
|
63
|
+
for (const name of names) {
|
|
64
|
+
const file = path.join(dir, name);
|
|
65
|
+
let raw: string;
|
|
66
|
+
try {
|
|
67
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
fs.unlinkSync(file);
|
|
73
|
+
} catch {
|
|
74
|
+
continue; // another drainer took it
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
const parsed = JSON.parse(raw);
|
|
78
|
+
if (parsed && typeof parsed.id === 'string' && typeof parsed.body === 'string') {
|
|
79
|
+
out.push(parsed as Letter);
|
|
80
|
+
}
|
|
81
|
+
} catch {
|
|
82
|
+
// corrupt letter — discarded, cannot fail future drains
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function unreadCount(root: string, addr: string): number {
|
|
89
|
+
try {
|
|
90
|
+
return fs.readdirSync(inboxDir(root, addr)).filter((f) => f.endsWith('.json')).length;
|
|
91
|
+
} catch {
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Watch an inbox for new mail. fs.watch with a 3s poll fallback (watch can
|
|
98
|
+
* miss events). The callback is expected to drain; it must tolerate empty.
|
|
99
|
+
* Returns an unwatch function. Timer/watcher are unref'd so they never keep
|
|
100
|
+
* a process alive.
|
|
101
|
+
*/
|
|
102
|
+
export function watchInbox(root: string, addr: string, onMail: () => void): () => void {
|
|
103
|
+
const dir = inboxDir(root, addr);
|
|
104
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
105
|
+
let watcher: fs.FSWatcher | undefined;
|
|
106
|
+
try {
|
|
107
|
+
watcher = fs.watch(dir, () => onMail());
|
|
108
|
+
watcher.unref();
|
|
109
|
+
} catch {
|
|
110
|
+
// poll-only fallback
|
|
111
|
+
}
|
|
112
|
+
const poll = setInterval(onMail, 3000);
|
|
113
|
+
poll.unref();
|
|
114
|
+
return () => {
|
|
115
|
+
watcher?.close();
|
|
116
|
+
clearInterval(poll);
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Consumption receipt: after depositing to a LIVE target, wait briefly for
|
|
124
|
+
* the letter to vanish. 'delivered' means the receiver's drainer took it —
|
|
125
|
+
* stronger than any transport ack.
|
|
126
|
+
*/
|
|
127
|
+
export async function awaitReceipt(
|
|
128
|
+
root: string,
|
|
129
|
+
toAddr: string,
|
|
130
|
+
letter: Letter,
|
|
131
|
+
timeoutMs = 1500,
|
|
132
|
+
): Promise<'delivered' | 'queued'> {
|
|
133
|
+
const file = path.join(inboxDir(root, toAddr), letterFileName(letter));
|
|
134
|
+
const deadline = Date.now() + timeoutMs;
|
|
135
|
+
while (Date.now() < deadline) {
|
|
136
|
+
if (!fs.existsSync(file)) return 'delivered';
|
|
137
|
+
await sleep(100);
|
|
138
|
+
}
|
|
139
|
+
return fs.existsSync(file) ? 'queued' : 'delivered';
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// ── Ask tracking ─────────────────────────────────────────────────────────
|
|
143
|
+
// Received asks live at <addr>.asks/<askId>.json until we reply; our
|
|
144
|
+
// outgoing asks live at <addr>.asks/out-<askId>.json until a reply/cancel
|
|
145
|
+
// arrives or we time out.
|
|
146
|
+
|
|
147
|
+
export interface OutAsk {
|
|
148
|
+
askId: string;
|
|
149
|
+
toAddr: string;
|
|
150
|
+
body: string;
|
|
151
|
+
ts: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function asksPath(root: string, addr: string, askId: string): string {
|
|
155
|
+
return path.join(asksDir(root, addr), `${askId}.json`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function outAskPath(root: string, addr: string, askId: string): string {
|
|
159
|
+
return path.join(asksDir(root, addr), `out-${askId}.json`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function writeJson0600(file: string, value: unknown): void {
|
|
163
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
164
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
165
|
+
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
|
166
|
+
fs.renameSync(tmp, file);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function trackIncomingAsk(root: string, addr: string, letter: Letter): void {
|
|
170
|
+
writeJson0600(asksPath(root, addr, letter.id), letter);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function trackOutgoingAsk(root: string, addr: string, out: OutAsk): void {
|
|
174
|
+
writeJson0600(outAskPath(root, addr, out.askId), out);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function readIncomingAsk(root: string, addr: string, askId: string): Letter | null {
|
|
178
|
+
try {
|
|
179
|
+
const parsed = JSON.parse(fs.readFileSync(asksPath(root, addr, askId), 'utf8'));
|
|
180
|
+
return parsed && typeof parsed.id === 'string' ? (parsed as Letter) : null;
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function readOutgoingAsk(root: string, addr: string, askId: string): OutAsk | null {
|
|
187
|
+
try {
|
|
188
|
+
const parsed = JSON.parse(fs.readFileSync(outAskPath(root, addr, askId), 'utf8'));
|
|
189
|
+
return parsed && typeof parsed.toAddr === 'string' ? (parsed as OutAsk) : null;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Remove both sides of an ask id (incoming and/or outgoing). Idempotent. */
|
|
196
|
+
export function clearAsk(root: string, addr: string, askId: string): void {
|
|
197
|
+
for (const file of [asksPath(root, addr, askId), outAskPath(root, addr, askId)]) {
|
|
198
|
+
try {
|
|
199
|
+
fs.unlinkSync(file);
|
|
200
|
+
} catch {
|
|
201
|
+
// already gone
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Asks we have received and not yet answered, oldest first. */
|
|
207
|
+
export function pendingAsks(root: string, addr: string): Letter[] {
|
|
208
|
+
let names: string[];
|
|
209
|
+
try {
|
|
210
|
+
names = fs
|
|
211
|
+
.readdirSync(asksDir(root, addr))
|
|
212
|
+
.filter((f) => f.endsWith('.json') && !f.startsWith('out-'))
|
|
213
|
+
.sort();
|
|
214
|
+
} catch {
|
|
215
|
+
return [];
|
|
216
|
+
}
|
|
217
|
+
const out: Letter[] = [];
|
|
218
|
+
for (const name of names) {
|
|
219
|
+
try {
|
|
220
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(asksDir(root, addr), name), 'utf8'));
|
|
221
|
+
if (parsed && typeof parsed.id === 'string') out.push(parsed as Letter);
|
|
222
|
+
} catch {
|
|
223
|
+
// skip corrupt entry
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return out;
|
|
227
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nicknisi/pi-relay",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"description": "Brokerless session-to-session messaging for pi — a file mailbox that outlives the process, with ask/reply coordination. Formerly @nicknisi/pi-intercom.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi",
|
|
7
|
+
"pi-coding-agent",
|
|
8
|
+
"pi-package"
|
|
9
|
+
],
|
|
10
|
+
"homepage": "https://github.com/nicknisi/pi-extensions/tree/main/packages/relay#readme",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/nicknisi/pi-extensions.git",
|
|
15
|
+
"directory": "packages/relay"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"index.ts",
|
|
20
|
+
"registry.ts",
|
|
21
|
+
"mailbox.ts",
|
|
22
|
+
"policy.ts",
|
|
23
|
+
"format.ts"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"typebox": "^1.1.0"
|
|
34
|
+
},
|
|
35
|
+
"peerDependencies": {
|
|
36
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
37
|
+
"@earendil-works/pi-tui": "*"
|
|
38
|
+
},
|
|
39
|
+
"pi": {
|
|
40
|
+
"extensions": [
|
|
41
|
+
"./index.ts"
|
|
42
|
+
]
|
|
43
|
+
}
|
|
44
|
+
}
|
package/policy.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loop-breaking and inbound policy. Pi-free.
|
|
3
|
+
*
|
|
4
|
+
* A loop between two agents must terminate independently of what either
|
|
5
|
+
* model decides: repeats, rate, and backlog are all capped here, at the
|
|
6
|
+
* transport, not in the prompts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { MAX_BODY_CHARS } from './mailbox.js';
|
|
10
|
+
|
|
11
|
+
export const DEDUPE_WINDOW_MS = 10_000;
|
|
12
|
+
export const RATE_LIMIT_MAX = 8;
|
|
13
|
+
export const RATE_LIMIT_WINDOW_MS = 30_000;
|
|
14
|
+
export const BACKLOG_CAP = 50;
|
|
15
|
+
|
|
16
|
+
export type OutboundVerdict = { ok: true } | { ok: false; reason: string };
|
|
17
|
+
|
|
18
|
+
export class OutboundPolicy {
|
|
19
|
+
private readonly sentAt: number[] = [];
|
|
20
|
+
private readonly recentBodies = new Map<string, number>();
|
|
21
|
+
|
|
22
|
+
constructor(private readonly now: () => number = Date.now) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Gate one outbound letter. `unreadBacklog` is the target's current
|
|
26
|
+
* unread count (report 0 when the target is idle — an idle agent has by
|
|
27
|
+
* definition worked through what it was handed).
|
|
28
|
+
*/
|
|
29
|
+
check(body: string, unreadBacklog: number): OutboundVerdict {
|
|
30
|
+
if (body.length > MAX_BODY_CHARS) {
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
reason: `Message is ${body.length} chars; cap is ${MAX_BODY_CHARS}. Send a summary and a path, not a payload.`,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (unreadBacklog >= BACKLOG_CAP) {
|
|
37
|
+
return {
|
|
38
|
+
ok: false,
|
|
39
|
+
reason: `Peer has ${unreadBacklog} unread messages (cap ${BACKLOG_CAP}); wait for it to drain.`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const now = this.now();
|
|
43
|
+
const lastSame = this.recentBodies.get(body);
|
|
44
|
+
if (lastSame !== undefined && now - lastSame < DEDUPE_WINDOW_MS) {
|
|
45
|
+
return { ok: false, reason: 'Identical message sent less than 10s ago — dropped to break loops.' };
|
|
46
|
+
}
|
|
47
|
+
const windowStart = now - RATE_LIMIT_WINDOW_MS;
|
|
48
|
+
while (this.sentAt.length > 0 && this.sentAt[0]! < windowStart) this.sentAt.shift();
|
|
49
|
+
if (this.sentAt.length >= RATE_LIMIT_MAX) {
|
|
50
|
+
return {
|
|
51
|
+
ok: false,
|
|
52
|
+
reason: `Rate limited: ${RATE_LIMIT_MAX} messages per ${RATE_LIMIT_WINDOW_MS / 1000}s per session.`,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
return { ok: true };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Record a successful send so dedupe/rate state stays current. */
|
|
59
|
+
recordSend(body: string): void {
|
|
60
|
+
this.sentAt.push(this.now());
|
|
61
|
+
this.recentBodies.set(body, this.now());
|
|
62
|
+
// bound the dedupe map
|
|
63
|
+
if (this.recentBodies.size > 200) {
|
|
64
|
+
const cutoff = this.now() - DEDUPE_WINDOW_MS;
|
|
65
|
+
for (const [key, ts] of this.recentBodies) {
|
|
66
|
+
if (ts < cutoff) this.recentBodies.delete(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Inbound guard: PI_RELAY_INBOUND=refuse drops all peer mail. */
|
|
73
|
+
export function inboundAccepts(env: string | undefined = process.env.PI_RELAY_INBOUND): boolean {
|
|
74
|
+
return env !== 'refuse';
|
|
75
|
+
}
|
package/registry.ts
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session registry for the relay mailbox: who is around, where, and
|
|
3
|
+
* whether they're reachable. Pi-free — importable from tests without pi's
|
|
4
|
+
* loader.
|
|
5
|
+
*
|
|
6
|
+
* Design (from pi-peer's ARCHITECTURE.md):
|
|
7
|
+
* - An address belongs to a CONVERSATION, not a process: hash of cwd + pi
|
|
8
|
+
* session id, so a resumed session (`pi -c`) answers to the same address
|
|
9
|
+
* and two sessions on one directory never share an inbox.
|
|
10
|
+
* - A record outlives the process that wrote it — that's what makes a
|
|
11
|
+
* session addressable while it's down (mail waits on disk).
|
|
12
|
+
* - Presence is a pid PLUS a heartbeat: pid alone can't tell wedged from
|
|
13
|
+
* healthy (and pids get reused); heartbeat alone can't tell crash from
|
|
14
|
+
* pause.
|
|
15
|
+
* - Listing has NO side effects: reading the directory never deletes.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as crypto from 'node:crypto';
|
|
19
|
+
import * as fs from 'node:fs';
|
|
20
|
+
import * as path from 'node:path';
|
|
21
|
+
|
|
22
|
+
export interface SessionRecord {
|
|
23
|
+
addr: string;
|
|
24
|
+
sessionId: string;
|
|
25
|
+
name: string;
|
|
26
|
+
cwd: string;
|
|
27
|
+
pid: number;
|
|
28
|
+
startedAt: number;
|
|
29
|
+
lastSeenAt: number;
|
|
30
|
+
status: 'idle' | 'working';
|
|
31
|
+
offline?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export type Presence = 'live' | 'stalled' | 'offline';
|
|
35
|
+
|
|
36
|
+
export const HEARTBEAT_STALE_MS = 45_000;
|
|
37
|
+
/** A mailbox holding undelivered mail is kept this long after last contact. */
|
|
38
|
+
export const SWEEP_MAIL_KEEP_MS = 30 * 24 * 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
export function deriveAddr(cwd: string, sessionId: string): string {
|
|
41
|
+
return crypto.createHash('sha256').update(`${cwd}${sessionId}`).digest('hex').slice(0, 12);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function ensureRoot(root: string): void {
|
|
45
|
+
fs.mkdirSync(root, { recursive: true, mode: 0o700 });
|
|
46
|
+
try {
|
|
47
|
+
fs.chmodSync(root, 0o700);
|
|
48
|
+
} catch {
|
|
49
|
+
// best-effort on filesystems that support modes
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function recordPath(root: string, addr: string): string {
|
|
54
|
+
return path.join(root, `${addr}.json`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function inboxDir(root: string, addr: string): string {
|
|
58
|
+
return path.join(root, `${addr}.inbox`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function asksDir(root: string, addr: string): string {
|
|
62
|
+
return path.join(root, `${addr}.asks`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function writeRecord(root: string, record: SessionRecord): void {
|
|
66
|
+
ensureRoot(root);
|
|
67
|
+
const file = recordPath(root, record.addr);
|
|
68
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
69
|
+
fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { mode: 0o600 });
|
|
70
|
+
fs.renameSync(tmp, file);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function readRecord(root: string, addr: string): SessionRecord | null {
|
|
74
|
+
try {
|
|
75
|
+
const parsed = JSON.parse(fs.readFileSync(recordPath(root, addr), 'utf8'));
|
|
76
|
+
if (parsed && typeof parsed.addr === 'string' && typeof parsed.pid === 'number') {
|
|
77
|
+
return parsed as SessionRecord;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Read-only listing, oldest first. Never mutates anything. */
|
|
86
|
+
export function listRecords(root: string): SessionRecord[] {
|
|
87
|
+
let entries: fs.Dirent[];
|
|
88
|
+
try {
|
|
89
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
90
|
+
} catch {
|
|
91
|
+
return [];
|
|
92
|
+
}
|
|
93
|
+
const out: SessionRecord[] = [];
|
|
94
|
+
for (const entry of entries) {
|
|
95
|
+
if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
|
|
96
|
+
const record = readRecord(root, entry.name.slice(0, -'.json'.length));
|
|
97
|
+
if (record) out.push(record);
|
|
98
|
+
}
|
|
99
|
+
return out.sort((a, b) => a.startedAt - b.startedAt);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function pidAlive(pid: number): boolean {
|
|
103
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
104
|
+
try {
|
|
105
|
+
process.kill(pid, 0);
|
|
106
|
+
return true;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
// EPERM means the process exists but isn't ours — still alive
|
|
109
|
+
return (err as NodeJS.ErrnoException)?.code === 'EPERM';
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function presenceOf(record: SessionRecord, now: number = Date.now()): Presence {
|
|
114
|
+
if (record.offline) return 'offline';
|
|
115
|
+
if (!pidAlive(record.pid)) return 'offline';
|
|
116
|
+
return now - record.lastSeenAt < HEARTBEAT_STALE_MS ? 'live' : 'stalled';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function dirHasJson(dir: string): boolean {
|
|
120
|
+
try {
|
|
121
|
+
return fs.readdirSync(dir).some((f) => f.endsWith('.json'));
|
|
122
|
+
} catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function rmrf(target: string): void {
|
|
128
|
+
try {
|
|
129
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
130
|
+
} catch {
|
|
131
|
+
// sweep is best-effort
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Reclaim dead sessions' files. Rules (mail outranks tidiness):
|
|
137
|
+
* - a running session is never touched;
|
|
138
|
+
* - a mailbox holding undelivered mail is kept for SWEEP_MAIL_KEEP_MS;
|
|
139
|
+
* - an offline but resumable session keeps its record (its address — new
|
|
140
|
+
* mail must remain deliverable while it's down);
|
|
141
|
+
* - only an empty mailbox of a session that can no longer be resumed is
|
|
142
|
+
* discarded promptly.
|
|
143
|
+
*
|
|
144
|
+
* `sessionExists(sessionId)` reports whether pi can still resume the
|
|
145
|
+
* session (its session file is present). When omitted, every offline
|
|
146
|
+
* session is treated as resumable (the conservative choice).
|
|
147
|
+
*/
|
|
148
|
+
export function sweep(root: string, now: number = Date.now(), sessionExists?: (sessionId: string) => boolean): void {
|
|
149
|
+
for (const record of listRecords(root)) {
|
|
150
|
+
if (presenceOf(record, now) !== 'offline') continue;
|
|
151
|
+
const hasMail = dirHasJson(inboxDir(root, record.addr)) || dirHasJson(asksDir(root, record.addr));
|
|
152
|
+
const expired = now - record.lastSeenAt >= SWEEP_MAIL_KEEP_MS;
|
|
153
|
+
if (hasMail && !expired) continue;
|
|
154
|
+
if (!expired && (sessionExists?.(record.sessionId) ?? true)) continue;
|
|
155
|
+
rmrf(inboxDir(root, record.addr));
|
|
156
|
+
rmrf(asksDir(root, record.addr));
|
|
157
|
+
try {
|
|
158
|
+
fs.unlinkSync(recordPath(root, record.addr));
|
|
159
|
+
} catch {
|
|
160
|
+
// already gone
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|