@dmsdc-ai/aigentry-telepty 0.1.87 → 0.1.89

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.
@@ -0,0 +1,384 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const fs = require('fs');
5
+ const {
6
+ acquireLock,
7
+ ensureSessionDir,
8
+ loadStates,
9
+ appendState,
10
+ loadMessages,
11
+ countPending,
12
+ compact,
13
+ appendJsonl,
14
+ readJsonl,
15
+ listSessionDirs,
16
+ } = require('./storage');
17
+ const { createConfig } = require('./config');
18
+
19
+ function unixNow() {
20
+ return Math.floor(Date.now() / 1000);
21
+ }
22
+
23
+ /**
24
+ * FileMailbox — JSONL-backed mailbox implementing MailboxProtocol.
25
+ *
26
+ * Thread-safe via per-session advisory file locks.
27
+ * Interoperable with Rust aigentry-mailbox crate (shared JSONL format).
28
+ */
29
+ class FileMailbox {
30
+ constructor(overrides = {}) {
31
+ this.config = createConfig(overrides);
32
+ fs.mkdirSync(this.config.root, { recursive: true, mode: 0o700 });
33
+ }
34
+
35
+ _sessionDir(sessionId) {
36
+ return path.join(this.config.root, sessionId);
37
+ }
38
+
39
+ /**
40
+ * Enqueue a message to a target session's inbox.
41
+ * Idempotent: re-enqueueing the same msg_id is a no-op (returns queued: false).
42
+ */
43
+ enqueue(msg) {
44
+ const sessionDir = ensureSessionDir(this.config.root, msg.to);
45
+ const release = acquireLock(sessionDir);
46
+ try {
47
+ // Idempotency check
48
+ const states = loadStates(sessionDir);
49
+ if (states.has(msg.msg_id)) {
50
+ const pending = countPending(sessionDir, unixNow());
51
+ return { msg_id: msg.msg_id, queued: false, pending };
52
+ }
53
+
54
+ // TTL check — reject already-expired messages
55
+ const now = unixNow();
56
+ if (msg.created_at + this.config.ttlSecs < now) {
57
+ throw new Error(`Message ${msg.msg_id} already expired (created_at: ${msg.created_at}, ttl: ${this.config.ttlSecs}s)`);
58
+ }
59
+
60
+ // Append to inbox.jsonl
61
+ appendJsonl(path.join(sessionDir, 'inbox.jsonl'), {
62
+ msg_id: msg.msg_id,
63
+ from: msg.from,
64
+ to: msg.to,
65
+ payload: msg.payload,
66
+ created_at: msg.created_at,
67
+ attempt: msg.attempt || 0,
68
+ });
69
+
70
+ // Append state: pending
71
+ appendState(sessionDir, msg.msg_id, 'pending', now);
72
+
73
+ const pending = countPending(sessionDir, now);
74
+ return { msg_id: msg.msg_id, queued: true, pending };
75
+ } finally {
76
+ release();
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Dequeue the next pending message for a session.
82
+ * Transitions it to in_flight. Returns null if no pending messages.
83
+ */
84
+ dequeue(sessionId) {
85
+ const sessionDir = this._sessionDir(sessionId);
86
+ if (!fs.existsSync(sessionDir)) return null;
87
+
88
+ const release = acquireLock(sessionDir);
89
+ try {
90
+ const states = loadStates(sessionDir);
91
+ const rawMessages = loadMessages(sessionDir);
92
+ const now = unixNow();
93
+
94
+ // Deduplicate by msg_id — keep highest attempt entry
95
+ const latest = new Map();
96
+ for (const msg of rawMessages) {
97
+ const existing = latest.get(msg.msg_id);
98
+ if (!existing || (msg.attempt || 0) > (existing.attempt || 0)) {
99
+ latest.set(msg.msg_id, msg);
100
+ }
101
+ }
102
+
103
+ // Find oldest pending message that is past its scheduled time
104
+ let next = null;
105
+ for (const msg of latest.values()) {
106
+ const st = states.get(msg.msg_id);
107
+ if (!st || st.state !== 'pending') continue;
108
+ // For retried messages, created_at may be set to a future time (backoff)
109
+ if (msg.created_at > now) continue;
110
+ if (!next || msg.created_at < next.created_at) {
111
+ next = msg;
112
+ }
113
+ }
114
+
115
+ if (!next) return null;
116
+
117
+ // Transition to in_flight
118
+ appendState(sessionDir, next.msg_id, 'in_flight', now);
119
+ return next;
120
+ } finally {
121
+ release();
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Acknowledge successful delivery of a message.
127
+ */
128
+ ack(sessionId, msgId) {
129
+ const sessionDir = this._sessionDir(sessionId);
130
+ if (!fs.existsSync(sessionDir)) return;
131
+
132
+ const release = acquireLock(sessionDir);
133
+ try {
134
+ const states = loadStates(sessionDir);
135
+ const st = states.get(msgId);
136
+ // Idempotent: already acked → no-op
137
+ if (st && st.state === 'acked') return;
138
+
139
+ appendState(sessionDir, msgId, 'acked', unixNow());
140
+ compact(sessionDir, this.config.compactionThreshold);
141
+ } finally {
142
+ release();
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Negative acknowledge — mark message as failed. Retry or dead-letter.
148
+ */
149
+ nack(sessionId, msgId, reason) {
150
+ const sessionDir = this._sessionDir(sessionId);
151
+ if (!fs.existsSync(sessionDir)) return;
152
+
153
+ const release = acquireLock(sessionDir);
154
+ try {
155
+ const states = loadStates(sessionDir);
156
+ const st = states.get(msgId);
157
+ // Idempotent: already dead_letter → no-op
158
+ if (st && st.state === 'dead_letter') return;
159
+
160
+ const messages = loadMessages(sessionDir);
161
+ // Find the LATEST entry for this msg_id (highest attempt number)
162
+ const candidates = messages.filter(m => m.msg_id === msgId);
163
+ const msg = candidates.reduce((latest, m) => {
164
+ if (!latest) return m;
165
+ return (m.attempt || 0) > (latest.attempt || 0) ? m : latest;
166
+ }, null);
167
+ if (!msg) return;
168
+
169
+ const now = unixNow();
170
+ const attempt = (msg.attempt || 0) + 1;
171
+
172
+ if (attempt >= this.config.maxRetries) {
173
+ // Dead letter
174
+ appendJsonl(path.join(sessionDir, 'dead-letter.jsonl'), {
175
+ msg_id: msg.msg_id,
176
+ from: msg.from,
177
+ to: msg.to,
178
+ payload: msg.payload,
179
+ reason: reason || 'max_retries exhausted',
180
+ failed_at: now,
181
+ attempts: attempt,
182
+ });
183
+ appendState(sessionDir, msgId, 'dead_letter', now);
184
+ } else {
185
+ // Re-enqueue with incremented attempt and backoff delay
186
+ const backoff = this.config.retryBackoffSecs * (1 << (attempt - 1));
187
+ appendState(sessionDir, msgId, 'nacked', now);
188
+
189
+ // Re-enqueue with future created_at for backoff scheduling
190
+ appendJsonl(path.join(sessionDir, 'inbox.jsonl'), {
191
+ msg_id: msg.msg_id,
192
+ from: msg.from,
193
+ to: msg.to,
194
+ payload: msg.payload,
195
+ created_at: now + backoff,
196
+ attempt,
197
+ });
198
+ appendState(sessionDir, msg.msg_id, 'pending', now);
199
+ }
200
+ } finally {
201
+ release();
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Peek at pending messages without dequeueing.
207
+ */
208
+ peek(sessionId) {
209
+ const sessionDir = this._sessionDir(sessionId);
210
+ if (!fs.existsSync(sessionDir)) return [];
211
+
212
+ const release = acquireLock(sessionDir);
213
+ try {
214
+ const states = loadStates(sessionDir);
215
+ const messages = loadMessages(sessionDir);
216
+ const results = [];
217
+
218
+ for (const msg of messages) {
219
+ const st = states.get(msg.msg_id);
220
+ if (!st) continue;
221
+ results.push({
222
+ msg_id: msg.msg_id,
223
+ from: msg.from,
224
+ created_at: msg.created_at,
225
+ attempt: msg.attempt || 0,
226
+ state: st.state,
227
+ });
228
+ }
229
+
230
+ // Deduplicate by msg_id (keep latest attempt)
231
+ const seen = new Map();
232
+ for (const entry of results) {
233
+ const existing = seen.get(entry.msg_id);
234
+ if (!existing || entry.attempt > existing.attempt) {
235
+ seen.set(entry.msg_id, entry);
236
+ }
237
+ }
238
+ return Array.from(seen.values());
239
+ } finally {
240
+ release();
241
+ }
242
+ }
243
+
244
+ /**
245
+ * Purge all messages for a session.
246
+ */
247
+ purge(sessionId) {
248
+ const sessionDir = this._sessionDir(sessionId);
249
+ if (!fs.existsSync(sessionDir)) return;
250
+
251
+ const release = acquireLock(sessionDir);
252
+ try {
253
+ for (const file of ['inbox.jsonl', 'state.jsonl']) {
254
+ const p = path.join(sessionDir, file);
255
+ try { fs.writeFileSync(p, ''); } catch {}
256
+ }
257
+ } finally {
258
+ release();
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Peek at dead letter entries.
264
+ */
265
+ peekDeadLetter(sessionId) {
266
+ const sessionDir = this._sessionDir(sessionId);
267
+ if (!fs.existsSync(sessionDir)) return [];
268
+ return readJsonl(path.join(sessionDir, 'dead-letter.jsonl'));
269
+ }
270
+
271
+ /**
272
+ * Purge dead letter entries.
273
+ */
274
+ purgeDeadLetter(sessionId) {
275
+ const sessionDir = this._sessionDir(sessionId);
276
+ if (!fs.existsSync(sessionDir)) return;
277
+ const p = path.join(sessionDir, 'dead-letter.jsonl');
278
+ try { fs.writeFileSync(p, ''); } catch {}
279
+ }
280
+
281
+ /**
282
+ * List all session IDs that have a mailbox directory.
283
+ */
284
+ listSessions() {
285
+ return listSessionDirs(this.config.root).map(d => d.sessionId);
286
+ }
287
+
288
+ /**
289
+ * Recover in-flight messages that timed out → auto-nack.
290
+ * Called by DeliveryEngine.
291
+ */
292
+ recoverInflight(sessionId) {
293
+ const sessionDir = this._sessionDir(sessionId);
294
+ if (!fs.existsSync(sessionDir)) return 0;
295
+
296
+ const release = acquireLock(sessionDir);
297
+ try {
298
+ const states = loadStates(sessionDir);
299
+ const now = unixNow();
300
+ let recovered = 0;
301
+
302
+ for (const [msgId, entry] of states) {
303
+ if (entry.state === 'in_flight' && (now - entry.ts) >= this.config.inflightTimeoutSecs) {
304
+ // Release lock, nack externally (nack acquires its own lock)
305
+ // Instead, inline the nack logic to avoid deadlock
306
+ const messages = loadMessages(sessionDir);
307
+ // Find latest entry for this msg_id (highest attempt)
308
+ const candidates = messages.filter(m => m.msg_id === msgId);
309
+ const msg = candidates.reduce((latest, m) => {
310
+ if (!latest) return m;
311
+ return (m.attempt || 0) > (latest.attempt || 0) ? m : latest;
312
+ }, null);
313
+ if (!msg) continue;
314
+
315
+ const attempt = (msg.attempt || 0) + 1;
316
+ if (attempt >= this.config.maxRetries) {
317
+ appendJsonl(path.join(sessionDir, 'dead-letter.jsonl'), {
318
+ msg_id: msg.msg_id,
319
+ from: msg.from,
320
+ to: msg.to,
321
+ payload: msg.payload,
322
+ reason: 'inflight_timeout',
323
+ failed_at: now,
324
+ attempts: attempt,
325
+ });
326
+ appendState(sessionDir, msgId, 'dead_letter', now);
327
+ } else {
328
+ const backoff = this.config.retryBackoffSecs * (1 << (attempt - 1));
329
+ appendState(sessionDir, msgId, 'nacked', now);
330
+ appendJsonl(path.join(sessionDir, 'inbox.jsonl'), {
331
+ msg_id: msg.msg_id,
332
+ from: msg.from,
333
+ to: msg.to,
334
+ payload: msg.payload,
335
+ created_at: now + backoff,
336
+ attempt,
337
+ });
338
+ appendState(sessionDir, msg.msg_id, 'pending', now);
339
+ }
340
+ recovered++;
341
+ }
342
+ }
343
+ return recovered;
344
+ } finally {
345
+ release();
346
+ }
347
+ }
348
+
349
+ /**
350
+ * Expire pending messages past TTL.
351
+ * Called by DeliveryEngine.
352
+ */
353
+ expireStale(sessionId) {
354
+ const sessionDir = this._sessionDir(sessionId);
355
+ if (!fs.existsSync(sessionDir)) return 0;
356
+
357
+ const release = acquireLock(sessionDir);
358
+ try {
359
+ const states = loadStates(sessionDir);
360
+ const messages = loadMessages(sessionDir);
361
+ const now = unixNow();
362
+ let expired = 0;
363
+
364
+ for (const msg of messages) {
365
+ const st = states.get(msg.msg_id);
366
+ if (!st) continue;
367
+ if ((st.state === 'pending' || st.state === 'in_flight') &&
368
+ (msg.created_at + this.config.ttlSecs < now)) {
369
+ appendState(sessionDir, msg.msg_id, 'expired', now);
370
+ expired++;
371
+ }
372
+ }
373
+
374
+ if (expired > 0) {
375
+ compact(sessionDir, this.config.compactionThreshold);
376
+ }
377
+ return expired;
378
+ } finally {
379
+ release();
380
+ }
381
+ }
382
+ }
383
+
384
+ module.exports = { FileMailbox };
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ const net = require('net');
4
+
5
+ /**
6
+ * UnixSocketNotifier — sends lightweight "wake" signals to aterm sessions
7
+ * after mailbox enqueue, so they poll immediately instead of waiting for
8
+ * their next delivery engine tick.
9
+ *
10
+ * Uses coalescing: multiple enqueues within `coalesceMs` result in a single notification.
11
+ */
12
+ class UnixSocketNotifier {
13
+ constructor(options = {}) {
14
+ this.coalesceMs = options.coalesceMs || 25;
15
+ this._pending = new Map(); // sessionId → timer
16
+ this._socketResolver = options.socketResolver || null; // (sessionId) => socketPath | null
17
+ }
18
+
19
+ /**
20
+ * Set the function that resolves a session ID to its UDS path.
21
+ * Called by daemon after sessions are available.
22
+ */
23
+ setSocketResolver(fn) {
24
+ this._socketResolver = fn;
25
+ }
26
+
27
+ /**
28
+ * Schedule a wake notification for a session.
29
+ * Coalesces multiple calls within coalesceMs into a single send.
30
+ */
31
+ notify(sessionId) {
32
+ if (this._pending.has(sessionId)) return; // already scheduled
33
+
34
+ const timer = setTimeout(() => {
35
+ this._pending.delete(sessionId);
36
+ this._sendWake(sessionId);
37
+ }, this.coalesceMs);
38
+
39
+ // Allow process to exit even if timer is pending
40
+ if (timer.unref) timer.unref();
41
+ this._pending.set(sessionId, timer);
42
+ }
43
+
44
+ /**
45
+ * Send wake signal immediately (bypass coalesce).
46
+ */
47
+ notifyImmediate(sessionId) {
48
+ const existing = this._pending.get(sessionId);
49
+ if (existing) {
50
+ clearTimeout(existing);
51
+ this._pending.delete(sessionId);
52
+ }
53
+ this._sendWake(sessionId);
54
+ }
55
+
56
+ /**
57
+ * Cancel pending notification for a session.
58
+ */
59
+ cancel(sessionId) {
60
+ const timer = this._pending.get(sessionId);
61
+ if (timer) {
62
+ clearTimeout(timer);
63
+ this._pending.delete(sessionId);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Cancel all pending notifications.
69
+ */
70
+ cancelAll() {
71
+ for (const timer of this._pending.values()) {
72
+ clearTimeout(timer);
73
+ }
74
+ this._pending.clear();
75
+ }
76
+
77
+ _sendWake(sessionId) {
78
+ if (!this._socketResolver) return;
79
+
80
+ const socketPath = this._socketResolver(sessionId);
81
+ if (!socketPath) return;
82
+
83
+ const payload = JSON.stringify({ action: 'MailboxWake', workspace: sessionId }) + '\n';
84
+
85
+ const sock = net.connect(socketPath, () => {
86
+ sock.end(payload);
87
+ });
88
+
89
+ sock.on('error', () => {
90
+ // Socket unreachable — aterm may be down. Delivery engine will retry via polling.
91
+ });
92
+
93
+ // Prevent socket from keeping process alive
94
+ sock.unref();
95
+
96
+ // Timeout safety
97
+ const timeout = setTimeout(() => sock.destroy(), 2000);
98
+ if (timeout.unref) timeout.unref();
99
+ sock.on('close', () => clearTimeout(timeout));
100
+ }
101
+ }
102
+
103
+ module.exports = { UnixSocketNotifier };
@@ -0,0 +1,185 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ // --- Advisory file locking (PID-based, per-session) ---
7
+
8
+ const LOCK_POLL_MS = 10;
9
+ const LOCK_TIMEOUT_MS = 500;
10
+
11
+ function isProcessAlive(pid) {
12
+ try {
13
+ process.kill(pid, 0);
14
+ return true;
15
+ } catch (e) {
16
+ return e.code === 'EPERM';
17
+ }
18
+ }
19
+
20
+ /**
21
+ * Acquire an advisory lock for a session directory.
22
+ * Returns a release function. Throws on timeout.
23
+ */
24
+ function acquireLock(sessionDir) {
25
+ const lockPath = path.join(sessionDir, '.lock');
26
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
27
+
28
+ while (Date.now() < deadline) {
29
+ try {
30
+ const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
31
+ fs.writeSync(fd, String(process.pid));
32
+ fs.closeSync(fd);
33
+ return () => {
34
+ try { fs.unlinkSync(lockPath); } catch {}
35
+ };
36
+ } catch (err) {
37
+ if (err.code !== 'EEXIST') throw err;
38
+
39
+ // Lock file exists — check for stale PID
40
+ try {
41
+ const content = fs.readFileSync(lockPath, 'utf8').trim();
42
+ const pid = Number(content);
43
+ if (pid > 0 && !isProcessAlive(pid)) {
44
+ // Stale lock — remove and retry
45
+ try { fs.unlinkSync(lockPath); } catch {}
46
+ continue;
47
+ }
48
+ } catch {
49
+ // Can't read lock file — remove and retry
50
+ try { fs.unlinkSync(lockPath); } catch {}
51
+ continue;
52
+ }
53
+
54
+ // Lock is held by a live process — wait
55
+ const buffer = new SharedArrayBuffer(4);
56
+ const view = new Int32Array(buffer);
57
+ Atomics.wait(view, 0, 0, LOCK_POLL_MS);
58
+ }
59
+ }
60
+
61
+ throw new Error(`Mailbox lock timeout for ${sessionDir}`);
62
+ }
63
+
64
+ // --- JSONL read/write ---
65
+
66
+ function readJsonl(filePath) {
67
+ if (!fs.existsSync(filePath)) return [];
68
+ const content = fs.readFileSync(filePath, 'utf8');
69
+ const results = [];
70
+ for (const line of content.split('\n')) {
71
+ const trimmed = line.trim();
72
+ if (!trimmed) continue;
73
+ try {
74
+ results.push(JSON.parse(trimmed));
75
+ } catch {
76
+ // Skip malformed lines
77
+ }
78
+ }
79
+ return results;
80
+ }
81
+
82
+ function appendJsonl(filePath, obj) {
83
+ fs.appendFileSync(filePath, JSON.stringify(obj) + '\n');
84
+ }
85
+
86
+ function writeJsonl(filePath, objects) {
87
+ const content = objects.map(o => JSON.stringify(o)).join('\n') + (objects.length > 0 ? '\n' : '');
88
+ fs.writeFileSync(filePath, content);
89
+ }
90
+
91
+ // --- Session directory helpers ---
92
+
93
+ function ensureSessionDir(root, sessionId) {
94
+ const dir = path.join(root, sessionId);
95
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
96
+ return dir;
97
+ }
98
+
99
+ function listSessionDirs(root) {
100
+ if (!fs.existsSync(root)) return [];
101
+ return fs.readdirSync(root, { withFileTypes: true })
102
+ .filter(d => d.isDirectory() && !d.name.startsWith('.'))
103
+ .map(d => ({ sessionId: d.name, dir: path.join(root, d.name) }));
104
+ }
105
+
106
+ // --- State helpers ---
107
+
108
+ /**
109
+ * Load the latest state for each msg_id from state.jsonl.
110
+ * Returns Map<msg_id, { state, ts, attempt? }>
111
+ */
112
+ function loadStates(sessionDir) {
113
+ const entries = readJsonl(path.join(sessionDir, 'state.jsonl'));
114
+ const states = new Map();
115
+ for (const entry of entries) {
116
+ states.set(entry.msg_id, entry);
117
+ }
118
+ return states;
119
+ }
120
+
121
+ function appendState(sessionDir, msgId, state, ts) {
122
+ appendJsonl(path.join(sessionDir, 'state.jsonl'), { msg_id: msgId, state, ts });
123
+ }
124
+
125
+ /**
126
+ * Load all messages from inbox.jsonl.
127
+ */
128
+ function loadMessages(sessionDir) {
129
+ return readJsonl(path.join(sessionDir, 'inbox.jsonl'));
130
+ }
131
+
132
+ /**
133
+ * Count pending messages (state === 'pending' and not yet past scheduled delivery time).
134
+ */
135
+ function countPending(sessionDir, nowSecs) {
136
+ const states = loadStates(sessionDir);
137
+ let count = 0;
138
+ for (const entry of states.values()) {
139
+ if (entry.state === 'pending') count++;
140
+ }
141
+ return count;
142
+ }
143
+
144
+ /**
145
+ * Compact inbox.jsonl and state.jsonl — remove acked/expired/dead_letter entries.
146
+ */
147
+ function compact(sessionDir, threshold) {
148
+ const states = loadStates(sessionDir);
149
+ const terminalStates = new Set(['acked', 'expired', 'dead_letter']);
150
+ let terminalCount = 0;
151
+ for (const entry of states.values()) {
152
+ if (terminalStates.has(entry.state)) terminalCount++;
153
+ }
154
+
155
+ if (terminalCount < threshold) return;
156
+
157
+ const messages = loadMessages(sessionDir);
158
+ const activeMessages = messages.filter(m => {
159
+ const st = states.get(m.msg_id);
160
+ return !st || !terminalStates.has(st.state);
161
+ });
162
+ writeJsonl(path.join(sessionDir, 'inbox.jsonl'), activeMessages);
163
+
164
+ const activeStates = [];
165
+ for (const entry of states.values()) {
166
+ if (!terminalStates.has(entry.state)) {
167
+ activeStates.push(entry);
168
+ }
169
+ }
170
+ writeJsonl(path.join(sessionDir, 'state.jsonl'), activeStates);
171
+ }
172
+
173
+ module.exports = {
174
+ acquireLock,
175
+ readJsonl,
176
+ appendJsonl,
177
+ writeJsonl,
178
+ ensureSessionDir,
179
+ listSessionDirs,
180
+ loadStates,
181
+ appendState,
182
+ loadMessages,
183
+ countPending,
184
+ compact,
185
+ };