@comput/pi-intercom 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +83 -24
- 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/implementation-blocker.md +2 -2
- package/references/observability.md +104 -0
- package/references/v1-specification.md +16 -14
- package/src/config.ts +40 -15
- package/src/dashboard.ts +158 -0
- package/src/index.ts +57 -7
- package/src/observability.ts +169 -0
- package/src/runtime.ts +176 -24
- package/src/transport.ts +3 -0
|
@@ -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
|
+
}
|
package/src/runtime.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { LocalObserver, LOG_LIMITS, observationError, type Observer, type EventType, type EventMetadata } from './observability.js';
|
|
4
|
+
import { ConfigStore, coordinator, directory, fail, named, port, requireCoordinator, type Agent, type Config } from './config.js';
|
|
3
5
|
import { listen, send, type Endpoint, type Envelope, type Kind } from './transport.js';
|
|
4
6
|
|
|
5
7
|
export const UNSUPPORTED_CANCELLATION = 'Unsupported Pi host: stop_worker and close_worker are disabled. Pi 0.84.4 extension abort does not cancel retry backoff/continuations; graceful shutdown cannot guarantee no queued work restarts. No cancellation or shutdown was performed. See references/implementation-blocker.md. A verified supported host API is required (no version-only override).';
|
|
@@ -17,81 +19,200 @@ export interface RuntimeOptions {
|
|
|
17
19
|
listen?: typeof listen;
|
|
18
20
|
send?: typeof send;
|
|
19
21
|
store?: (root: string) => ConfigStore;
|
|
22
|
+
observe?: (root: string, sessionId: string) => Observer;
|
|
20
23
|
}
|
|
21
24
|
export class Intercom {
|
|
22
25
|
store!: ConfigStore;
|
|
23
26
|
endpoint?: Endpoint;
|
|
24
27
|
private active = false;
|
|
28
|
+
private generation = 0;
|
|
25
29
|
private initialId = '';
|
|
26
30
|
responsibility?: Agent;
|
|
31
|
+
private observer?: Observer;
|
|
32
|
+
private closing?: Promise<void>;
|
|
33
|
+
private captureObserver(): (event: EventType, metadata?: EventMetadata) => void {
|
|
34
|
+
const observer = this.observer;
|
|
35
|
+
return (event, metadata = {}) => {
|
|
36
|
+
try { observer?.record(event, metadata); } catch { /* Logging cannot affect communication. */ }
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
recordObservation(event: EventType, metadata: EventMetadata = {}): void { this.captureObserver()(event, metadata); }
|
|
27
40
|
constructor(readonly host: Host, readonly options: RuntimeOptions) {}
|
|
28
41
|
private id(): string {
|
|
29
42
|
const id = this.host.sessionId();
|
|
30
43
|
if (!this.active || id !== this.initialId) fail('session is inactive or replaced');
|
|
31
44
|
return id;
|
|
32
45
|
}
|
|
46
|
+
private validity(): () => void {
|
|
47
|
+
const generation = this.generation;
|
|
48
|
+
this.id();
|
|
49
|
+
return () => {
|
|
50
|
+
this.id();
|
|
51
|
+
if (generation !== this.generation) fail('runtime lifecycle replaced');
|
|
52
|
+
};
|
|
53
|
+
}
|
|
33
54
|
async start(): Promise<void> {
|
|
55
|
+
if (this.closing) await this.closing;
|
|
56
|
+
if (this.active) fail('runtime already active');
|
|
34
57
|
this.initialId = this.host.sessionId(); this.active = true;
|
|
58
|
+
const generation = ++this.generation, assertValid = this.validity();
|
|
59
|
+
let record = this.captureObserver();
|
|
35
60
|
try {
|
|
36
61
|
const discovered = await ConfigStore.discover(this.host.cwd);
|
|
62
|
+
assertValid();
|
|
37
63
|
this.store = this.options.store?.(discovered?.root ?? this.host.cwd) ?? discovered ?? new ConfigStore(this.host.cwd);
|
|
64
|
+
try { this.observer = this.options.observe ? this.options.observe(this.store.root, this.initialId) : new LocalObserver(this.store.root, this.initialId); } catch { /* Logging is optional on failure. */ }
|
|
65
|
+
record = this.captureObserver();
|
|
66
|
+
record('runtime.starting');
|
|
38
67
|
const previous = discovered ? await this.store.read() : undefined;
|
|
68
|
+
assertValid();
|
|
39
69
|
const own = previous?.agents.find(a => a.sessionId === this.id());
|
|
40
|
-
|
|
41
|
-
|
|
70
|
+
const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); });
|
|
71
|
+
try { assertValid(); } catch (error) { await endpoint.close(); throw error; }
|
|
72
|
+
this.endpoint = endpoint;
|
|
73
|
+
if (!previous) {
|
|
74
|
+
const initialized = await this.store.initialize(this.id(), this.endpoint.port, assertValid);
|
|
75
|
+
if (initialized) record('config.changed', { operation: 'initialize', outcome: 'written' });
|
|
76
|
+
}
|
|
42
77
|
const config = await this.store.read();
|
|
78
|
+
assertValid();
|
|
43
79
|
const me = config.agents.find(a => a.sessionId === this.id());
|
|
44
80
|
if (me?.coordinator) {
|
|
45
|
-
|
|
46
|
-
|
|
81
|
+
try { this.observer?.maintain?.(); } catch { /* No cleanup failure affects readiness. */ }
|
|
82
|
+
await this.store.update(this.id(), c => { requireCoordinator(c, this.id()).port = this.endpoint!.port; }, assertValid);
|
|
83
|
+
assertValid();
|
|
84
|
+
record('config.changed', { operation: 'port_update', outcome: 'written', port: this.endpoint.port });
|
|
85
|
+
try { await this.reload(); } catch (e) { assertValid(); this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); }
|
|
86
|
+
assertValid();
|
|
47
87
|
this.host.notify(`Intercom Coordinator ready at 127.0.0.1:${this.endpoint.port}; waiting for user input.`);
|
|
48
88
|
} else {
|
|
49
|
-
if (me) { try { await this.reload(); } catch (e) { this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); } }
|
|
89
|
+
if (me) { try { await this.reload(); } catch (e) { assertValid(); this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); } }
|
|
90
|
+
assertValid();
|
|
50
91
|
try {
|
|
51
92
|
if (me) await this.report();
|
|
52
|
-
else
|
|
53
|
-
|
|
93
|
+
else {
|
|
94
|
+
const projectDirectory = await directory(this.store.root, this.host.cwd);
|
|
95
|
+
assertValid();
|
|
96
|
+
await this.transmit(coordinator(config).name, 'registration', { port: this.endpoint.port, projectDirectory });
|
|
97
|
+
}
|
|
98
|
+
} catch (e) { assertValid(); this.host.notify(`Coordinator unreachable/registration failed; worker remains reachable: ${String(e)}. No retry. Anonymous registration repeat is an unresolved contract; use explicit Pi /reload to restart this extension, not report_status.`); }
|
|
99
|
+
assertValid();
|
|
54
100
|
this.host.notify(`Intercom worker ready at 127.0.0.1:${this.endpoint.port}; waiting for explicit work.`);
|
|
55
101
|
}
|
|
56
|
-
|
|
102
|
+
record('runtime.ready', { port: this.endpoint.port, role: me?.coordinator ? 'coordinator' : me ? 'worker' : 'anonymous', busy: this.host.busy(), outcome: 'ready' });
|
|
103
|
+
} catch (e) {
|
|
104
|
+
if (generation === this.generation) {
|
|
105
|
+
record('runtime.failed', { errorCode: observationError(e), outcome: 'failed' });
|
|
106
|
+
await this.close();
|
|
107
|
+
}
|
|
108
|
+
throw e;
|
|
109
|
+
}
|
|
57
110
|
}
|
|
58
111
|
async close(): Promise<void> {
|
|
59
|
-
this.
|
|
112
|
+
if (this.closing) return this.closing;
|
|
113
|
+
this.recordObservation('runtime.closed');
|
|
114
|
+
const observer = this.observer; this.observer = undefined;
|
|
115
|
+
this.active = false; this.generation++;
|
|
60
116
|
const endpoint = this.endpoint; this.endpoint = undefined;
|
|
61
|
-
if (endpoint) await endpoint.close();
|
|
62
117
|
this.responsibility = undefined;
|
|
118
|
+
const closing = (async () => {
|
|
119
|
+
try { if (endpoint) await endpoint.close(); }
|
|
120
|
+
finally {
|
|
121
|
+
if (observer) {
|
|
122
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
123
|
+
try {
|
|
124
|
+
// Only shutdown waits, and only for this bounded deadline. Already-issued I/O cannot be cancelled.
|
|
125
|
+
await Promise.race([
|
|
126
|
+
Promise.resolve().then(() => observer.close()).catch(() => {}),
|
|
127
|
+
new Promise<void>(resolve => { timer = setTimeout(resolve, LOG_LIMITS.closeTimeoutMs); }),
|
|
128
|
+
]);
|
|
129
|
+
} finally { if (timer) clearTimeout(timer); }
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
})();
|
|
133
|
+
this.closing = closing;
|
|
134
|
+
try { await closing; } finally { if (this.closing === closing) this.closing = undefined; }
|
|
63
135
|
}
|
|
64
136
|
async state(): Promise<{ id: string; config: Config; me?: Agent }> {
|
|
137
|
+
const assertValid = this.validity();
|
|
65
138
|
const id = this.id(), config = await this.store.read();
|
|
66
|
-
|
|
139
|
+
assertValid();
|
|
67
140
|
return { id, config, me: config.agents.find(a => a.sessionId === id) };
|
|
68
141
|
}
|
|
142
|
+
/** Persist an actual dashboard bind; saved discovery metadata, not a live-status assertion. */
|
|
143
|
+
async recordDashboardPort(boundPort: number): Promise<void> {
|
|
144
|
+
const record = this.captureObserver(), assertValid = this.validity();
|
|
145
|
+
const { id, config } = await this.state();
|
|
146
|
+
assertValid(); requireCoordinator(config, id); port(boundPort);
|
|
147
|
+
await this.store.update(id, c => { requireCoordinator(c, id).dashboardPort = boundPort; }, assertValid);
|
|
148
|
+
record('config.changed', { outcome: 'written' });
|
|
149
|
+
}
|
|
69
150
|
async reload(): Promise<void> {
|
|
151
|
+
const record = this.captureObserver();
|
|
152
|
+
const assertValid = this.validity();
|
|
70
153
|
const { me } = await this.state();
|
|
154
|
+
assertValid();
|
|
71
155
|
if (!me) fail('worker not configured; configure_worker must run before reload_worker');
|
|
72
156
|
this.responsibility = { ...me };
|
|
73
157
|
await this.host.setName(me.name);
|
|
158
|
+
assertValid();
|
|
159
|
+
record('config.reloaded', { role: me.coordinator ? 'coordinator' : 'worker' });
|
|
74
160
|
this.host.notify(`Intercom responsibility loaded for ${me.name}; no work turn started.`);
|
|
75
161
|
}
|
|
76
162
|
async transmit(to: string, kind: Kind, payload: Record<string, unknown> = {}): Promise<void> {
|
|
163
|
+
const record = this.captureObserver();
|
|
164
|
+
const assertValid = this.validity();
|
|
77
165
|
const { id, config } = await this.state();
|
|
78
|
-
|
|
79
|
-
|
|
166
|
+
assertValid();
|
|
167
|
+
const target = named(config, to), correlationId = randomUUID();
|
|
168
|
+
const metadata = { peerSessionId: target.sessionId, peerName: target.name, correlationId, kind };
|
|
169
|
+
record('transport.send', { ...metadata, outcome: 'attempted' });
|
|
170
|
+
try {
|
|
171
|
+
await (this.options.send ?? send)(target.port, { version: 1, from: id, to: target.sessionId, kind, payload, correlationId });
|
|
172
|
+
record('transport.receipt', { ...metadata, outcome: 'http_receipt' });
|
|
173
|
+
} catch (error) {
|
|
174
|
+
record('transport.failed', { ...metadata, outcome: 'failed', errorCode: observationError(error) });
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
80
177
|
}
|
|
81
178
|
async report(): Promise<void> {
|
|
179
|
+
const assertValid = this.validity();
|
|
82
180
|
const { config, me } = await this.state();
|
|
181
|
+
assertValid();
|
|
83
182
|
if (me?.coordinator) fail('report_status is worker-only');
|
|
84
183
|
if (!this.endpoint) fail('endpoint unavailable');
|
|
85
184
|
await this.transmit(coordinator(config).name, 'status', { port: this.endpoint.port, busy: this.host.busy() });
|
|
86
185
|
}
|
|
87
186
|
async receive(message: Envelope): Promise<void> {
|
|
187
|
+
const record = this.captureObserver();
|
|
188
|
+
const metadata = { peerSessionId: message.from, correlationId: message.correlationId, kind: message.kind };
|
|
189
|
+
record('transport.received', metadata);
|
|
190
|
+
try { await this.handleReceive(message); }
|
|
191
|
+
catch (error) {
|
|
192
|
+
record('transport.rejected', { ...metadata, outcome: 'handler_failed', errorCode: observationError(error) });
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
private async handleReceive(message: Envelope): Promise<void> {
|
|
197
|
+
const record = this.captureObserver();
|
|
198
|
+
const assertValid = this.validity();
|
|
88
199
|
const { id, config, me } = await this.state();
|
|
200
|
+
assertValid();
|
|
89
201
|
if (message.to !== id) fail('recipient session ID mismatch (stale endpoint)');
|
|
90
202
|
const sender = config.agents.find(a => a.sessionId === message.from);
|
|
91
203
|
const busy = this.host.busy();
|
|
204
|
+
const metadata = { peerSessionId: message.from, peerName: sender?.name, correlationId: message.correlationId, kind: message.kind, busy };
|
|
92
205
|
const notify = (purpose: string, content: string) => {
|
|
93
|
-
|
|
94
|
-
|
|
206
|
+
assertValid();
|
|
207
|
+
record('host.submission', { ...metadata, outcome: 'attempted' });
|
|
208
|
+
try {
|
|
209
|
+
this.host.deliver(`[Intercom ${purpose} from ${sender?.name ?? 'unconfigured worker'} (${message.from})]\n${content}`, busy);
|
|
210
|
+
// A synchronous return is not Pi queue acceptance or model completion.
|
|
211
|
+
record('host.submission', { ...metadata, outcome: 'returned' });
|
|
212
|
+
} catch (error) {
|
|
213
|
+
record('host.submission', { ...metadata, outcome: 'failed', errorCode: observationError(error) });
|
|
214
|
+
throw error;
|
|
215
|
+
}
|
|
95
216
|
};
|
|
96
217
|
if (message.kind === 'registration' || message.kind === 'status') {
|
|
97
218
|
requireCoordinator(config, id);
|
|
@@ -99,12 +220,15 @@ export class Intercom {
|
|
|
99
220
|
if (message.kind === 'registration') {
|
|
100
221
|
const projectDirectory = await directory(this.store.root, message.payload.projectDirectory as string);
|
|
101
222
|
// No pending map, placeholder or configuration mutation.
|
|
223
|
+
record('registration.received', { ...metadata, port: message.payload.port as number });
|
|
102
224
|
notify('registration', JSON.stringify({ sessionId: message.from, port: message.payload.port, projectDirectory }) + '\nThis is connection information, not a work assignment. Configure explicitly, then reload separately.');
|
|
103
225
|
} else {
|
|
104
226
|
if (sender) await this.store.update(id, c => {
|
|
105
227
|
const entry = c.agents.find(a => a.sessionId === message.from && !a.coordinator);
|
|
106
228
|
if (entry) entry.port = message.payload.port as number;
|
|
107
|
-
});
|
|
229
|
+
}, assertValid);
|
|
230
|
+
record('status.received', { ...metadata, busy: message.payload.busy as boolean, port: message.payload.port as number });
|
|
231
|
+
if (sender) record('config.changed', { operation: 'port_update', peerSessionId: message.from, port: message.payload.port as number, outcome: 'written' });
|
|
108
232
|
notify('status', JSON.stringify({ sessionId: message.from, port: message.payload.port, busy: message.payload.busy, configured: !!sender }));
|
|
109
233
|
}
|
|
110
234
|
return;
|
|
@@ -120,16 +244,26 @@ export class Intercom {
|
|
|
120
244
|
if (message.kind === 'reload') { await this.reload(); return; }
|
|
121
245
|
if (message.kind === 'request_status') {
|
|
122
246
|
// Independent one-way report, never a synchronous status in the HTTP response.
|
|
123
|
-
setImmediate(() => {
|
|
247
|
+
setImmediate(() => {
|
|
248
|
+
try { assertValid(); } catch { return; } // Closed/replaced requests cannot report for a new lifecycle.
|
|
249
|
+
void this.report().catch(e => {
|
|
250
|
+
try { assertValid(); } catch { return; }
|
|
251
|
+
this.host.notify(`Status report failed: ${String(e)}`);
|
|
252
|
+
});
|
|
253
|
+
});
|
|
124
254
|
return;
|
|
125
255
|
}
|
|
126
256
|
fail('unsupported control');
|
|
127
257
|
}
|
|
128
258
|
async tool(operation: string, args: Record<string, unknown>): Promise<unknown> {
|
|
259
|
+
const record = this.captureObserver();
|
|
260
|
+
const assertValid = this.validity();
|
|
129
261
|
const { id, config, me } = await this.state();
|
|
262
|
+
assertValid();
|
|
130
263
|
if (operation === 'list') {
|
|
131
264
|
if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
|
|
132
|
-
|
|
265
|
+
const dashboardPort = coordinator(config).dashboardPort;
|
|
266
|
+
return { ...config, dashboardUrl: dashboardPort === undefined ? null : `http://127.0.0.1:${dashboardPort}/` };
|
|
133
267
|
}
|
|
134
268
|
if (operation === 'report_status') { await this.report(); return { accepted: true }; }
|
|
135
269
|
if (operation === 'send') {
|
|
@@ -139,15 +273,18 @@ export class Intercom {
|
|
|
139
273
|
requireCoordinator(config, id);
|
|
140
274
|
if (operation === 'stop_worker' || operation === 'close_worker') fail(UNSUPPORTED_CANCELLATION);
|
|
141
275
|
if (operation === 'configure_worker') {
|
|
142
|
-
await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'
|
|
276
|
+
await this.store.configure(id, args as unknown as Omit<Agent, 'coordinator'>, assertValid);
|
|
277
|
+
record('config.changed', { operation: 'configure_worker', peerSessionId: args.sessionId as string, peerName: args.name as string, outcome: 'written' });
|
|
143
278
|
return { written: true, reloaded: false };
|
|
144
279
|
}
|
|
145
280
|
if (operation === 'set_multiplexer') {
|
|
146
|
-
await this.store.update(id, c => { c.multiplexer = args.multiplexer as Config['multiplexer']; });
|
|
281
|
+
await this.store.update(id, c => { c.multiplexer = args.multiplexer as Config['multiplexer']; }, assertValid);
|
|
282
|
+
record('config.changed', { operation: 'set_multiplexer', outcome: 'written' }); return { written: true };
|
|
147
283
|
}
|
|
148
284
|
if (operation === 'create_worker') {
|
|
149
285
|
const relative = await directory(this.store.root, (args.projectDirectory as string | undefined) ?? '.');
|
|
150
|
-
|
|
286
|
+
assertValid();
|
|
287
|
+
return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative) });
|
|
151
288
|
}
|
|
152
289
|
const target = named(config, args.to as string);
|
|
153
290
|
if (target.coordinator) fail('operation requires a worker target');
|
|
@@ -157,13 +294,28 @@ export class Intercom {
|
|
|
157
294
|
}
|
|
158
295
|
if (operation === 'resume_worker') {
|
|
159
296
|
const relative = await directory(this.store.root, target.projectDirectory);
|
|
160
|
-
|
|
297
|
+
assertValid();
|
|
298
|
+
return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), sessionId: target.sessionId });
|
|
161
299
|
}
|
|
162
300
|
if (operation === 'remove_worker') {
|
|
163
301
|
// No live process claim: the caller must explicitly ensure closure first.
|
|
164
|
-
await this.store.update(id, c => { c.agents = c.agents.filter(a => a.sessionId !== target.sessionId); });
|
|
302
|
+
await this.store.update(id, c => { c.agents = c.agents.filter(a => a.sessionId !== target.sessionId); }, assertValid);
|
|
303
|
+
record('config.changed', { operation: 'remove_worker', peerSessionId: target.sessionId, peerName: target.name, outcome: 'removed' });
|
|
165
304
|
return { removed: true, processStopped: false, sessionDeleted: false };
|
|
166
305
|
}
|
|
167
306
|
fail(`unknown operation ${operation}`);
|
|
168
307
|
}
|
|
308
|
+
private async launchObserved(request: LaunchRequest): Promise<unknown> {
|
|
309
|
+
const record = this.captureObserver();
|
|
310
|
+
const metadata: EventMetadata = { operation: request.sessionId ? 'resume_worker' : 'create_worker', peerSessionId: request.sessionId };
|
|
311
|
+
record('launch.result', { ...metadata, outcome: 'attempted' });
|
|
312
|
+
try {
|
|
313
|
+
const result = await this.options.launch(request);
|
|
314
|
+
record('launch.result', { ...metadata, outcome: 'returned' });
|
|
315
|
+
return result;
|
|
316
|
+
} catch (error) {
|
|
317
|
+
record('launch.result', { ...metadata, outcome: 'failed', errorCode: observationError(error) });
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
169
321
|
}
|
package/src/transport.ts
CHANGED
|
@@ -10,11 +10,14 @@ export interface Envelope {
|
|
|
10
10
|
from: string;
|
|
11
11
|
to: string;
|
|
12
12
|
payload: Record<string, unknown>;
|
|
13
|
+
/** Optional diagnostics correlation only; never authentication or agent identity. */
|
|
14
|
+
correlationId?: string;
|
|
13
15
|
}
|
|
14
16
|
export function envelope(value: unknown): Envelope {
|
|
15
17
|
const m = value as Envelope;
|
|
16
18
|
if (!m || m.version !== 1 || !['message', 'registration', 'status', 'request_status', 'reload', 'stop', 'close'].includes(m.kind)) fail('invalid wire schema/version/kind');
|
|
17
19
|
text(m.from, 'sender sessionId', 256); text(m.to, 'recipient sessionId', 256);
|
|
20
|
+
if (m.correlationId !== undefined && (typeof m.correlationId !== 'string' || !/^[a-zA-Z0-9-]{1,128}$/.test(m.correlationId))) fail('invalid correlation ID');
|
|
18
21
|
if (!m.payload || Array.isArray(m.payload) || typeof m.payload !== 'object') fail('invalid payload');
|
|
19
22
|
if (m.kind === 'message') text(m.payload.message, 'message', 48000);
|
|
20
23
|
if (m.kind === 'registration' || m.kind === 'status') port(m.payload.port);
|