@comput/pi-intercom 0.1.3 → 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/README.md +32 -4
- package/dashboard/app.js +182 -0
- package/dashboard/index.html +41 -0
- package/dashboard/style.css +82 -0
- package/package.json +4 -2
- package/references/observability.md +104 -0
- package/references/v1-specification.md +2 -2
- package/src/config.ts +6 -0
- package/src/dashboard.ts +158 -0
- package/src/index.ts +44 -4
- package/src/observability.ts +169 -0
- package/src/runtime.ts +119 -11
- package/src/transport.ts +3 -0
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, open, opendir, realpath } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { validateConfig, type Config } from './config.js';
|
|
7
|
+
import { LOG_DIRECTORY, LOG_FILE_PATTERN, sanitizeObservation, type Observation } from './observability.js';
|
|
8
|
+
|
|
9
|
+
const CONFIG_BYTES = 1024 * 1024, TAIL_BYTES = 128 * 1024, TOTAL_BYTES = 2 * 1024 * 1024;
|
|
10
|
+
const MAX_FILES = 32, MAX_ENTRIES = 512, MAX_EVENTS = 500, MAX_AGENTS = 256;
|
|
11
|
+
export interface DashboardSnapshot {
|
|
12
|
+
version: 1;
|
|
13
|
+
generatedAt: string;
|
|
14
|
+
staleAfterMs: number;
|
|
15
|
+
config: Pick<Config, 'multiplexer' | 'agents'> | null;
|
|
16
|
+
events: Observation[];
|
|
17
|
+
truncated: boolean;
|
|
18
|
+
errors: string[];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Only fixed, extension-owned paths are read. Reject symlink/junction components
|
|
22
|
+
// rather than allowing a local log/config link to expose an unrelated file.
|
|
23
|
+
async function confined(root: string, relative: string): Promise<string> {
|
|
24
|
+
const base = await realpath(root);
|
|
25
|
+
let current = base;
|
|
26
|
+
for (const segment of relative.split(/[\\/]/)) {
|
|
27
|
+
if (!segment || segment === '.' || segment === '..') throw new Error('invalid path');
|
|
28
|
+
current = path.join(current, segment);
|
|
29
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error('linked path');
|
|
30
|
+
if (path.relative(current, await realpath(current)) !== '') throw new Error('redirected path');
|
|
31
|
+
}
|
|
32
|
+
return current;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function boundedRead(file: string, limit: number, tail = false): Promise<{ text: string; truncated: boolean; bytes: number }> {
|
|
36
|
+
if (!(await lstat(file)).isFile()) throw new Error('not a file');
|
|
37
|
+
const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0));
|
|
38
|
+
try {
|
|
39
|
+
const stat = await handle.stat();
|
|
40
|
+
if (!stat.isFile()) throw new Error('not a file');
|
|
41
|
+
if (!tail && stat.size > limit) throw new Error('file too large');
|
|
42
|
+
const size = Math.min(stat.size, limit), start = tail ? Math.max(0, stat.size - size) : 0;
|
|
43
|
+
const buffer = Buffer.alloc(size);
|
|
44
|
+
const { bytesRead } = await handle.read(buffer, 0, size, start);
|
|
45
|
+
let text = buffer.subarray(0, bytesRead).toString('utf8');
|
|
46
|
+
// Drop potentially partial UTF-8/JSON at both tail boundaries. Writers append
|
|
47
|
+
// complete newline-terminated records; incomplete records are not evidence.
|
|
48
|
+
if (tail) {
|
|
49
|
+
if (start > 0) text = text.slice(text.indexOf('\n') + 1);
|
|
50
|
+
text = text.slice(0, text.lastIndexOf('\n') + 1);
|
|
51
|
+
}
|
|
52
|
+
return { text, truncated: stat.size > limit, bytes: bytesRead };
|
|
53
|
+
} finally { await handle.close(); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function readDashboardSnapshot(root: string): Promise<DashboardSnapshot> {
|
|
57
|
+
const result: DashboardSnapshot = { version: 1, generatedAt: new Date().toISOString(), staleAfterMs: 60000, config: null, events: [], truncated: false, errors: [] };
|
|
58
|
+
try {
|
|
59
|
+
const file = await confined(root, '.pi-intercom/config.json');
|
|
60
|
+
const config = validateConfig(JSON.parse((await boundedRead(file, CONFIG_BYTES)).text));
|
|
61
|
+
result.truncated ||= config.agents.length > MAX_AGENTS;
|
|
62
|
+
result.config = { multiplexer: config.multiplexer, agents: config.agents.slice(0, MAX_AGENTS).map(a => ({
|
|
63
|
+
sessionId: a.sessionId, name: a.name, coordinator: a.coordinator, description: a.description,
|
|
64
|
+
projectDirectory: a.projectDirectory, port: a.port,
|
|
65
|
+
...(a.dashboardPort === undefined ? {} : { dashboardPort: a.dashboardPort }),
|
|
66
|
+
})) };
|
|
67
|
+
} catch { result.errors.push('config_unavailable'); }
|
|
68
|
+
try {
|
|
69
|
+
const directory = await confined(root, LOG_DIRECTORY);
|
|
70
|
+
const files: { name: string; modified: number }[] = [];
|
|
71
|
+
const entries = await opendir(directory);
|
|
72
|
+
let scanned = 0;
|
|
73
|
+
for await (const entry of entries) {
|
|
74
|
+
if (++scanned > MAX_ENTRIES) { result.truncated = true; break; }
|
|
75
|
+
if (!entry.isFile() || !LOG_FILE_PATTERN.test(entry.name)) continue;
|
|
76
|
+
try {
|
|
77
|
+
const file = await confined(root, `${LOG_DIRECTORY}/${entry.name}`);
|
|
78
|
+
files.push({ name: entry.name, modified: (await lstat(file)).mtimeMs });
|
|
79
|
+
} catch { /* Rotations can remove a file while scanning. */ }
|
|
80
|
+
}
|
|
81
|
+
files.sort((a, b) => b.modified - a.modified || a.name.localeCompare(b.name));
|
|
82
|
+
result.truncated ||= files.length > MAX_FILES;
|
|
83
|
+
let budget = TOTAL_BYTES;
|
|
84
|
+
for (const file of files.slice(0, MAX_FILES)) {
|
|
85
|
+
if (budget <= 0) { result.truncated = true; break; }
|
|
86
|
+
try {
|
|
87
|
+
const resolved = await confined(root, `${LOG_DIRECTORY}/${file.name}`);
|
|
88
|
+
const read = await boundedRead(resolved, Math.min(TAIL_BYTES, budget), true);
|
|
89
|
+
budget -= read.bytes;
|
|
90
|
+
result.truncated ||= read.truncated;
|
|
91
|
+
for (const line of read.text.split('\n')) {
|
|
92
|
+
if (!line) continue;
|
|
93
|
+
try {
|
|
94
|
+
const event = sanitizeObservation(JSON.parse(line));
|
|
95
|
+
if (event) result.events.push(event);
|
|
96
|
+
} catch { /* Ignore malformed/partial/foreign records, never expose raw data. */ }
|
|
97
|
+
}
|
|
98
|
+
} catch { if (!result.errors.includes('log_read_failed')) result.errors.push('log_read_failed'); }
|
|
99
|
+
}
|
|
100
|
+
result.events.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
101
|
+
if (result.events.length > MAX_EVENTS) { result.events = result.events.slice(-MAX_EVENTS); result.truncated = true; }
|
|
102
|
+
} catch { result.errors.push('logs_unavailable'); }
|
|
103
|
+
return result;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface Dashboard { url: string; port: number; close(): Promise<void> }
|
|
107
|
+
export async function startDashboard(root: string, options: { assetDirectory?: string } = {}): Promise<Dashboard> {
|
|
108
|
+
const assets = options.assetDirectory ?? fileURLToPath(new URL('../dashboard/', import.meta.url));
|
|
109
|
+
const routes: Record<string, [string, string]> = {
|
|
110
|
+
'/': ['index.html', 'text/html; charset=utf-8'],
|
|
111
|
+
'/app.js': ['app.js', 'text/javascript; charset=utf-8'],
|
|
112
|
+
'/style.css': ['style.css', 'text/css; charset=utf-8'],
|
|
113
|
+
};
|
|
114
|
+
let port = 0, active = 0, closing = false;
|
|
115
|
+
let pending: Promise<DashboardSnapshot> | undefined;
|
|
116
|
+
const server = createServer(async (request, response) => {
|
|
117
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
118
|
+
response.setHeader('X-Content-Type-Options', 'nosniff');
|
|
119
|
+
response.setHeader('Content-Security-Policy', "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; frame-ancestors 'none'");
|
|
120
|
+
response.setHeader('Referrer-Policy', 'no-referrer');
|
|
121
|
+
const end = (code: number, body: string, type = 'text/plain; charset=utf-8') => {
|
|
122
|
+
response.writeHead(code, { 'Content-Type': type }); response.end(body);
|
|
123
|
+
};
|
|
124
|
+
const hosts = [`127.0.0.1:${port}`, `localhost:${port}`];
|
|
125
|
+
if (!hosts.includes(request.headers.host ?? '') ||
|
|
126
|
+
(request.headers.origin !== undefined && !hosts.some(host => request.headers.origin === `http://${host}`)) ||
|
|
127
|
+
request.headers['sec-fetch-site'] === 'cross-site') return end(403, 'Forbidden');
|
|
128
|
+
if (request.method !== 'GET') { response.setHeader('Allow', 'GET'); return end(405, 'GET only'); }
|
|
129
|
+
if (closing || active >= 8) return end(503, 'Unavailable');
|
|
130
|
+
active++;
|
|
131
|
+
try {
|
|
132
|
+
if (request.url === '/api/snapshot') {
|
|
133
|
+
pending ??= readDashboardSnapshot(root).finally(() => { pending = undefined; });
|
|
134
|
+
return end(200, JSON.stringify(await pending), 'application/json; charset=utf-8');
|
|
135
|
+
}
|
|
136
|
+
const route = Object.hasOwn(routes, request.url ?? '') ? routes[request.url!] : undefined;
|
|
137
|
+
if (!route) return end(404, 'Not found');
|
|
138
|
+
const file = await confined(assets, route[0]);
|
|
139
|
+
return end(200, (await boundedRead(file, 256 * 1024)).text, route[1]);
|
|
140
|
+
} catch { return end(503, 'Unavailable'); }
|
|
141
|
+
finally { active--; }
|
|
142
|
+
});
|
|
143
|
+
server.requestTimeout = 5000; server.headersTimeout = 5000; server.keepAliveTimeout = 1000;
|
|
144
|
+
server.maxConnections = 16;
|
|
145
|
+
await new Promise<void>((resolve, reject) => {
|
|
146
|
+
server.once('error', reject);
|
|
147
|
+
server.listen(0, '127.0.0.1', () => { server.off('error', reject); resolve(); });
|
|
148
|
+
});
|
|
149
|
+
port = (server.address() as { port: number }).port;
|
|
150
|
+
let closed: Promise<void> | undefined;
|
|
151
|
+
return { port, url: `http://127.0.0.1:${port}/`, close() {
|
|
152
|
+
closing = true;
|
|
153
|
+
return closed ??= new Promise<void>((resolve, reject) => {
|
|
154
|
+
server.close(error => error ? reject(error) : resolve());
|
|
155
|
+
server.closeAllConnections();
|
|
156
|
+
});
|
|
157
|
+
} };
|
|
158
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { getAgentDir, SessionManager, SettingsManager, truncateHead, withFileMut
|
|
|
5
5
|
import { ConfigStore } from './config.js';
|
|
6
6
|
import { Intercom, UNSUPPORTED_CANCELLATION } from './runtime.js';
|
|
7
7
|
import { launchers } from './launcher.js';
|
|
8
|
+
import { startDashboard, type Dashboard } from './dashboard.js';
|
|
8
9
|
|
|
9
10
|
const to = Type.Object({ to: Type.String({ minLength: 1, maxLength: 128 }) });
|
|
10
11
|
const tools: [string, string, TSchema][] = [
|
|
@@ -42,11 +43,15 @@ class PiConfigStore extends ConfigStore {
|
|
|
42
43
|
export default function intercomExtension(pi: ExtensionAPI): void {
|
|
43
44
|
let runtime: Intercom | undefined;
|
|
44
45
|
let context: ExtensionContext | undefined;
|
|
46
|
+
let dashboard: Dashboard | undefined;
|
|
47
|
+
let generation = 0;
|
|
45
48
|
const launcher = launchers({
|
|
46
49
|
extension: fileURLToPath(import.meta.url),
|
|
47
50
|
sessionExists: async (cwd, id) => (await SessionManager.list(cwd, resumeSessionDirectory(cwd))).some(s => s.id === id),
|
|
48
51
|
});
|
|
49
52
|
pi.on('session_start', async (_event, ctx) => {
|
|
53
|
+
const started = ++generation;
|
|
54
|
+
await dashboard?.close(); dashboard = undefined;
|
|
50
55
|
await runtime?.close(); context = ctx;
|
|
51
56
|
if (process.platform !== 'win32' || ctx.mode !== 'tui') throw new Error('PiIntercom V1 requires Windows interactive Pi. No resources started.');
|
|
52
57
|
if (!ctx.isProjectTrusted()) throw new Error('PiIntercom requires project trust before honoring shared project configuration.');
|
|
@@ -54,14 +59,49 @@ export default function intercomExtension(pi: ExtensionAPI): void {
|
|
|
54
59
|
cwd: ctx.cwd,
|
|
55
60
|
sessionId: () => context!.sessionManager.getSessionId(),
|
|
56
61
|
busy: () => !context!.isIdle(),
|
|
57
|
-
|
|
62
|
+
// Always supply the supported busy mode: the arrival snapshot can become
|
|
63
|
+
// stale before Pi checks it. Pi still starts a normal turn when idle.
|
|
64
|
+
deliver: content => pi.sendUserMessage(content, { deliverAs: 'steer' }),
|
|
58
65
|
setName: async name => { if (pi.getSessionName() !== name) pi.setSessionName(name); await launcher.syncName(name); },
|
|
59
66
|
notify: text => ctx.ui.notify(text, 'info'),
|
|
60
67
|
}, { launch: launcher.launch, store: root => new PiConfigStore(root) });
|
|
61
|
-
|
|
62
|
-
|
|
68
|
+
const current = runtime;
|
|
69
|
+
try { await current.start(); }
|
|
70
|
+
catch (e) { if (runtime === current) runtime = undefined; ctx.ui.notify(String(e), 'error'); return; }
|
|
71
|
+
if (started !== generation) return;
|
|
72
|
+
let server: Dashboard | undefined;
|
|
73
|
+
let savingPort = false;
|
|
74
|
+
try {
|
|
75
|
+
if ((await current.state()).me?.coordinator) {
|
|
76
|
+
server = await startDashboard(current.store.root);
|
|
77
|
+
if (started !== generation) { await server.close(); return; }
|
|
78
|
+
savingPort = true;
|
|
79
|
+
await current.recordDashboardPort(server.port);
|
|
80
|
+
if (started !== generation) { await server.close(); return; }
|
|
81
|
+
dashboard = server;
|
|
82
|
+
ctx.ui.notify(`Intercom read-only dashboard: ${server.url} (saved URL is last-known, not live availability)`, 'info');
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
await server?.close().catch(() => {});
|
|
86
|
+
if (started !== generation) return;
|
|
87
|
+
ctx.ui.notify(savingPort
|
|
88
|
+
? 'Intercom dashboard port could not be saved; the new dashboard was closed. Any saved URL is last-known only. Messaging remains available.'
|
|
89
|
+
: 'Intercom dashboard unavailable; communication remains independent.', 'error');
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
pi.on('session_shutdown', async () => {
|
|
93
|
+
generation++;
|
|
94
|
+
await dashboard?.close(); dashboard = undefined;
|
|
95
|
+
await runtime?.close(); runtime = undefined; context = undefined;
|
|
96
|
+
});
|
|
97
|
+
pi.on('agent_start', async (_event, ctx) => {
|
|
98
|
+
context = ctx;
|
|
99
|
+
runtime?.recordObservation('host.activity', { busy: !ctx.isIdle(), outcome: 'started' });
|
|
100
|
+
});
|
|
101
|
+
pi.on('agent_settled', async (_event, ctx) => {
|
|
102
|
+
context = ctx;
|
|
103
|
+
runtime?.recordObservation('host.activity', { busy: !ctx.isIdle(), outcome: 'settled' });
|
|
63
104
|
});
|
|
64
|
-
pi.on('session_shutdown', async () => { await runtime?.close(); runtime = undefined; context = undefined; });
|
|
65
105
|
pi.on('before_agent_start', async (event, ctx) => {
|
|
66
106
|
context = ctx;
|
|
67
107
|
if (!runtime) return;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { appendFile, lstat, mkdir, opendir, rename, stat, unlink } from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
export const LOG_DIRECTORY = '.pi-intercom/logs';
|
|
6
|
+
export const LOG_FILE_PATTERN = /^writer-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.jsonl(?:\.[12]|\.closed)?$/;
|
|
7
|
+
export const LOG_LIMITS = { fileBytes: 1024 * 1024, filesPerInstance: 3, queueEntries: 256, entryBytes: 4096, retentionMs: 7 * 24 * 60 * 60 * 1000, directoryBytes: 32 * 1024 * 1024, scanEntries: 4096, closeTimeoutMs: 250 } as const;
|
|
8
|
+
export const EVENT_TYPES = ['runtime.starting', 'runtime.ready', 'runtime.closed', 'runtime.failed', 'config.changed', 'config.reloaded', 'transport.send', 'transport.receipt', 'transport.failed', 'transport.received', 'transport.rejected', 'registration.received', 'status.received', 'launch.result', 'host.submission', 'host.activity', 'host.ui_prompt'] as const;
|
|
9
|
+
export type EventType = typeof EVENT_TYPES[number];
|
|
10
|
+
const OUTCOMES = ['attempted', 'returned', 'failed', 'http_receipt', 'handler_failed', 'started', 'settled', 'snapshot', 'ended', 'written', 'removed', 'ready'] as const;
|
|
11
|
+
const ERROR_CODES = ['operation_failed', 'ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EADDRINUSE', 'EACCES', 'EPERM', 'ENOENT', 'ENOSPC'] as const;
|
|
12
|
+
const KINDS = ['message', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'] as const;
|
|
13
|
+
const OPERATIONS = ['configure_worker', 'set_multiplexer', 'remove_worker', 'create_worker', 'resume_worker', 'port_update', 'initialize'] as const;
|
|
14
|
+
export interface EventMetadata {
|
|
15
|
+
peerSessionId?: string;
|
|
16
|
+
peerName?: string;
|
|
17
|
+
correlationId?: string;
|
|
18
|
+
kind?: typeof KINDS[number];
|
|
19
|
+
busy?: boolean;
|
|
20
|
+
port?: number;
|
|
21
|
+
role?: 'coordinator' | 'worker' | 'anonymous';
|
|
22
|
+
outcome?: typeof OUTCOMES[number];
|
|
23
|
+
errorCode?: typeof ERROR_CODES[number];
|
|
24
|
+
operation?: typeof OPERATIONS[number];
|
|
25
|
+
}
|
|
26
|
+
export interface Observation extends EventMetadata {
|
|
27
|
+
version: 1;
|
|
28
|
+
timestamp: string;
|
|
29
|
+
/** Log-file writer identity only: never a transport or agent identity. */
|
|
30
|
+
writerId: string;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
event: EventType;
|
|
33
|
+
}
|
|
34
|
+
export interface Observer { record(event: EventType, metadata?: EventMetadata): void; close(): Promise<void>; maintain?(): void }
|
|
35
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
36
|
+
function safeText(value: unknown, max: number): value is string {
|
|
37
|
+
return typeof value === 'string' && value.length > 0 && value.length <= max && !/[\x00-\x1f\x7f]/.test(value);
|
|
38
|
+
}
|
|
39
|
+
function includes<T extends string>(list: readonly T[], value: unknown): value is T { return typeof value === 'string' && list.includes(value as T); }
|
|
40
|
+
/** Project only explicitly allowed metadata, including when reading untrusted on-disk logs. */
|
|
41
|
+
export function sanitizeObservation(value: unknown): Observation | undefined {
|
|
42
|
+
if (!value || typeof value !== 'object') return;
|
|
43
|
+
const v = value as Record<string, unknown>;
|
|
44
|
+
if (v.version !== 1 || !includes(EVENT_TYPES, v.event) || !safeText(v.sessionId, 256) || typeof v.writerId !== 'string' || !uuid.test(v.writerId)) return;
|
|
45
|
+
if (typeof v.timestamp !== 'string' || !/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/.test(v.timestamp) || !Number.isFinite(Date.parse(v.timestamp))) return;
|
|
46
|
+
const result: Observation = { version: 1, timestamp: v.timestamp, writerId: v.writerId, sessionId: v.sessionId, event: v.event };
|
|
47
|
+
if (safeText(v.peerSessionId, 256)) result.peerSessionId = v.peerSessionId;
|
|
48
|
+
if (safeText(v.peerName, 128)) result.peerName = v.peerName;
|
|
49
|
+
if (safeText(v.correlationId, 128) && /^[a-zA-Z0-9-]+$/.test(v.correlationId)) result.correlationId = v.correlationId;
|
|
50
|
+
if (includes(KINDS, v.kind)) result.kind = v.kind;
|
|
51
|
+
if (typeof v.busy === 'boolean') result.busy = v.busy;
|
|
52
|
+
if (Number.isInteger(v.port) && Number(v.port) >= 1 && Number(v.port) <= 65535) result.port = Number(v.port);
|
|
53
|
+
if (includes(['coordinator', 'worker', 'anonymous'], v.role)) result.role = v.role;
|
|
54
|
+
if (includes(OUTCOMES, v.outcome)) result.outcome = v.outcome;
|
|
55
|
+
if (includes(ERROR_CODES, v.errorCode)) result.errorCode = v.errorCode;
|
|
56
|
+
if (includes(OPERATIONS, v.operation)) result.operation = v.operation;
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
/** Never inspect error.message/stack, response bodies, paths, or arbitrary error codes. */
|
|
60
|
+
export function observationError(error: unknown): EventMetadata['errorCode'] {
|
|
61
|
+
try {
|
|
62
|
+
const code = error && typeof error === 'object' ? (error as { code?: unknown }).code : undefined;
|
|
63
|
+
return includes(ERROR_CODES, code) ? code : 'operation_failed';
|
|
64
|
+
} catch { return 'operation_failed'; }
|
|
65
|
+
}
|
|
66
|
+
export class LocalObserver implements Observer {
|
|
67
|
+
readonly writerId = randomUUID();
|
|
68
|
+
readonly directory: string;
|
|
69
|
+
readonly file: string;
|
|
70
|
+
private queue: string[] = [];
|
|
71
|
+
private pending?: Promise<void>;
|
|
72
|
+
private initialized = false;
|
|
73
|
+
private bytes = 0;
|
|
74
|
+
private closed = false;
|
|
75
|
+
private disabled = false;
|
|
76
|
+
private maintenance = false;
|
|
77
|
+
private timer?: ReturnType<typeof setInterval>;
|
|
78
|
+
dropped = 0;
|
|
79
|
+
constructor(root: string, readonly sessionId: string, private readonly limits: { fileBytes?: number; queueEntries?: number } = {}) {
|
|
80
|
+
this.directory = path.join(root, LOG_DIRECTORY);
|
|
81
|
+
this.file = path.join(this.directory, `writer-${this.writerId}.jsonl`);
|
|
82
|
+
}
|
|
83
|
+
record(event: EventType, metadata: EventMetadata = {}): void {
|
|
84
|
+
try {
|
|
85
|
+
if (this.closed || this.disabled) return;
|
|
86
|
+
const observation = sanitizeObservation({ ...metadata, version: 1, timestamp: new Date().toISOString(), writerId: this.writerId, sessionId: this.sessionId, event });
|
|
87
|
+
if (!observation) return;
|
|
88
|
+
const line = JSON.stringify(observation) + '\n';
|
|
89
|
+
const capacity = Math.max(1, Math.min(LOG_LIMITS.queueEntries, this.limits.queueEntries ?? LOG_LIMITS.queueEntries));
|
|
90
|
+
if (Buffer.byteLength(line) > LOG_LIMITS.entryBytes || this.queue.length >= capacity) { this.dropped++; return; }
|
|
91
|
+
this.queue.push(line);
|
|
92
|
+
this.schedule();
|
|
93
|
+
} catch { /* Observability must never throw into communication. */ }
|
|
94
|
+
}
|
|
95
|
+
private schedule(): void {
|
|
96
|
+
if (this.pending || this.disabled) return;
|
|
97
|
+
this.pending = Promise.resolve().then(() => this.drain()).catch(() => {
|
|
98
|
+
// Disable this writer after an I/O failure; no retry queue and no communication failure.
|
|
99
|
+
this.disabled = true; this.dropped += this.queue.length; this.queue = [];
|
|
100
|
+
}).finally(() => {
|
|
101
|
+
this.pending = undefined;
|
|
102
|
+
if (this.queue.length && !this.disabled) this.schedule();
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
/** Runtime calls only on the coordinator. No process probes or activity inference. */
|
|
106
|
+
maintain(): void {
|
|
107
|
+
if (this.closed || this.disabled || this.timer) return;
|
|
108
|
+
const request = () => { this.maintenance = true; this.schedule(); };
|
|
109
|
+
this.timer = setInterval(request, 60_000); this.timer.unref(); request();
|
|
110
|
+
}
|
|
111
|
+
protected append(file: string, line: string): Promise<void> { return appendFile(file, line, { encoding: 'utf8', mode: 0o600 }); }
|
|
112
|
+
private async prune(): Promise<void> {
|
|
113
|
+
const files: { file: string; base: string; mtime: number; bytes: number }[] = [];
|
|
114
|
+
const closed = new Set<string>();
|
|
115
|
+
let scanned = 0;
|
|
116
|
+
for await (const entry of await opendir(this.directory)) {
|
|
117
|
+
if (++scanned > LOG_LIMITS.scanEntries) break;
|
|
118
|
+
if (!entry.isFile() || !LOG_FILE_PATTERN.test(entry.name)) continue;
|
|
119
|
+
const file = path.join(this.directory, entry.name);
|
|
120
|
+
const base = file.replace(/\.(?:closed|[12])$/, '');
|
|
121
|
+
if (file.endsWith('.closed')) closed.add(base);
|
|
122
|
+
const info = await stat(file).catch(() => undefined);
|
|
123
|
+
if (info) files.push({ file, base, mtime: info.mtimeMs, bytes: info.size });
|
|
124
|
+
}
|
|
125
|
+
let total = files.reduce((sum, file) => sum + file.bytes, 0);
|
|
126
|
+
const groups = [...closed].map(base => {
|
|
127
|
+
const members = files.filter(file => file.base === base);
|
|
128
|
+
return { members, newest: Math.max(...members.map(file => file.mtime)) };
|
|
129
|
+
}).sort((a, b) => a.newest - b.newest);
|
|
130
|
+
for (const group of groups) {
|
|
131
|
+
// Only gracefully closed writer groups are eligible. Active/crashed writers stay untouched.
|
|
132
|
+
if (Date.now() - group.newest > LOG_LIMITS.retentionMs || total > LOG_LIMITS.directoryBytes) {
|
|
133
|
+
// Remove the closed marker last, retaining eligibility if an earlier unlink fails.
|
|
134
|
+
for (const file of group.members.sort((a, b) => Number(a.file.endsWith('.closed')) - Number(b.file.endsWith('.closed')))) {
|
|
135
|
+
try { await unlink(file.file); total -= file.bytes; } catch { break; }
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
private async drain(): Promise<void> {
|
|
141
|
+
if (!this.initialized) {
|
|
142
|
+
const parent = path.dirname(this.directory);
|
|
143
|
+
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
144
|
+
if ((await lstat(parent)).isSymbolicLink()) throw new Error('log parent must not be a symlink');
|
|
145
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
146
|
+
if ((await lstat(this.directory)).isSymbolicLink()) throw new Error('log directory must not be a symlink');
|
|
147
|
+
this.initialized = true;
|
|
148
|
+
}
|
|
149
|
+
if (this.maintenance) { this.maintenance = false; await this.prune().catch(() => {}); }
|
|
150
|
+
const maxBytes = Math.max(LOG_LIMITS.entryBytes, Math.min(LOG_LIMITS.fileBytes, this.limits.fileBytes ?? LOG_LIMITS.fileBytes));
|
|
151
|
+
while (this.queue.length) {
|
|
152
|
+
const line = this.queue.shift()!, size = Buffer.byteLength(line);
|
|
153
|
+
if (this.bytes + size > maxBytes) {
|
|
154
|
+
await unlink(`${this.file}.2`).catch(e => { if (e.code !== 'ENOENT') throw e; });
|
|
155
|
+
await rename(`${this.file}.1`, `${this.file}.2`).catch(e => { if (e.code !== 'ENOENT') throw e; });
|
|
156
|
+
await rename(this.file, `${this.file}.1`).catch(e => { if (e.code !== 'ENOENT') throw e; });
|
|
157
|
+
this.bytes = 0;
|
|
158
|
+
}
|
|
159
|
+
await this.append(this.file, line); this.bytes += size;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async close(): Promise<void> {
|
|
163
|
+
if (this.timer) { clearInterval(this.timer); this.timer = undefined; }
|
|
164
|
+
this.closed = true;
|
|
165
|
+
while (this.pending) await this.pending;
|
|
166
|
+
// An atomic suffix change is the sole inactive-writer signal. Crashes leave no such signal.
|
|
167
|
+
if (this.initialized && !this.disabled) await rename(this.file, `${this.file}.closed`).catch(() => {});
|
|
168
|
+
}
|
|
169
|
+
}
|