@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/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,6 +19,7 @@ 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;
|
|
@@ -25,6 +28,15 @@ export class Intercom {
|
|
|
25
28
|
private generation = 0;
|
|
26
29
|
private initialId = '';
|
|
27
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); }
|
|
28
40
|
constructor(readonly host: Host, readonly options: RuntimeOptions) {}
|
|
29
41
|
private id(): string {
|
|
30
42
|
const id = this.host.sessionId();
|
|
@@ -40,26 +52,36 @@ export class Intercom {
|
|
|
40
52
|
};
|
|
41
53
|
}
|
|
42
54
|
async start(): Promise<void> {
|
|
55
|
+
if (this.closing) await this.closing;
|
|
43
56
|
if (this.active) fail('runtime already active');
|
|
44
57
|
this.initialId = this.host.sessionId(); this.active = true;
|
|
45
58
|
const generation = ++this.generation, assertValid = this.validity();
|
|
59
|
+
let record = this.captureObserver();
|
|
46
60
|
try {
|
|
47
61
|
const discovered = await ConfigStore.discover(this.host.cwd);
|
|
48
62
|
assertValid();
|
|
49
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');
|
|
50
67
|
const previous = discovered ? await this.store.read() : undefined;
|
|
51
68
|
assertValid();
|
|
52
69
|
const own = previous?.agents.find(a => a.sessionId === this.id());
|
|
53
70
|
const endpoint = await (this.options.listen ?? listen)(own?.port, m => { assertValid(); return this.receive(m); });
|
|
54
71
|
try { assertValid(); } catch (error) { await endpoint.close(); throw error; }
|
|
55
72
|
this.endpoint = endpoint;
|
|
56
|
-
if (!previous)
|
|
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
|
+
}
|
|
57
77
|
const config = await this.store.read();
|
|
58
78
|
assertValid();
|
|
59
79
|
const me = config.agents.find(a => a.sessionId === this.id());
|
|
60
80
|
if (me?.coordinator) {
|
|
81
|
+
try { this.observer?.maintain?.(); } catch { /* No cleanup failure affects readiness. */ }
|
|
61
82
|
await this.store.update(this.id(), c => { requireCoordinator(c, this.id()).port = this.endpoint!.port; }, assertValid);
|
|
62
83
|
assertValid();
|
|
84
|
+
record('config.changed', { operation: 'port_update', outcome: 'written', port: this.endpoint.port });
|
|
63
85
|
try { await this.reload(); } catch (e) { assertValid(); this.host.notify(`Name/responsibility synchronization failed: ${String(e)}`); }
|
|
64
86
|
assertValid();
|
|
65
87
|
this.host.notify(`Intercom Coordinator ready at 127.0.0.1:${this.endpoint.port}; waiting for user input.`);
|
|
@@ -77,13 +99,39 @@ export class Intercom {
|
|
|
77
99
|
assertValid();
|
|
78
100
|
this.host.notify(`Intercom worker ready at 127.0.0.1:${this.endpoint.port}; waiting for explicit work.`);
|
|
79
101
|
}
|
|
80
|
-
|
|
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
|
+
}
|
|
81
110
|
}
|
|
82
111
|
async close(): Promise<void> {
|
|
112
|
+
if (this.closing) return this.closing;
|
|
113
|
+
this.recordObservation('runtime.closed');
|
|
114
|
+
const observer = this.observer; this.observer = undefined;
|
|
83
115
|
this.active = false; this.generation++;
|
|
84
116
|
const endpoint = this.endpoint; this.endpoint = undefined;
|
|
85
117
|
this.responsibility = undefined;
|
|
86
|
-
|
|
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; }
|
|
87
135
|
}
|
|
88
136
|
async state(): Promise<{ id: string; config: Config; me?: Agent }> {
|
|
89
137
|
const assertValid = this.validity();
|
|
@@ -91,7 +139,16 @@ export class Intercom {
|
|
|
91
139
|
assertValid();
|
|
92
140
|
return { id, config, me: config.agents.find(a => a.sessionId === id) };
|
|
93
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
|
+
}
|
|
94
150
|
async reload(): Promise<void> {
|
|
151
|
+
const record = this.captureObserver();
|
|
95
152
|
const assertValid = this.validity();
|
|
96
153
|
const { me } = await this.state();
|
|
97
154
|
assertValid();
|
|
@@ -99,14 +156,24 @@ export class Intercom {
|
|
|
99
156
|
this.responsibility = { ...me };
|
|
100
157
|
await this.host.setName(me.name);
|
|
101
158
|
assertValid();
|
|
159
|
+
record('config.reloaded', { role: me.coordinator ? 'coordinator' : 'worker' });
|
|
102
160
|
this.host.notify(`Intercom responsibility loaded for ${me.name}; no work turn started.`);
|
|
103
161
|
}
|
|
104
162
|
async transmit(to: string, kind: Kind, payload: Record<string, unknown> = {}): Promise<void> {
|
|
163
|
+
const record = this.captureObserver();
|
|
105
164
|
const assertValid = this.validity();
|
|
106
165
|
const { id, config } = await this.state();
|
|
107
166
|
assertValid();
|
|
108
|
-
const target = named(config, to);
|
|
109
|
-
|
|
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
|
+
}
|
|
110
177
|
}
|
|
111
178
|
async report(): Promise<void> {
|
|
112
179
|
const assertValid = this.validity();
|
|
@@ -117,15 +184,35 @@ export class Intercom {
|
|
|
117
184
|
await this.transmit(coordinator(config).name, 'status', { port: this.endpoint.port, busy: this.host.busy() });
|
|
118
185
|
}
|
|
119
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();
|
|
120
198
|
const assertValid = this.validity();
|
|
121
199
|
const { id, config, me } = await this.state();
|
|
122
200
|
assertValid();
|
|
123
201
|
if (message.to !== id) fail('recipient session ID mismatch (stale endpoint)');
|
|
124
202
|
const sender = config.agents.find(a => a.sessionId === message.from);
|
|
125
203
|
const busy = this.host.busy();
|
|
204
|
+
const metadata = { peerSessionId: message.from, peerName: sender?.name, correlationId: message.correlationId, kind: message.kind, busy };
|
|
126
205
|
const notify = (purpose: string, content: string) => {
|
|
127
206
|
assertValid();
|
|
128
|
-
|
|
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
|
+
}
|
|
129
216
|
};
|
|
130
217
|
if (message.kind === 'registration' || message.kind === 'status') {
|
|
131
218
|
requireCoordinator(config, id);
|
|
@@ -133,12 +220,15 @@ export class Intercom {
|
|
|
133
220
|
if (message.kind === 'registration') {
|
|
134
221
|
const projectDirectory = await directory(this.store.root, message.payload.projectDirectory as string);
|
|
135
222
|
// No pending map, placeholder or configuration mutation.
|
|
223
|
+
record('registration.received', { ...metadata, port: message.payload.port as number });
|
|
136
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.');
|
|
137
225
|
} else {
|
|
138
226
|
if (sender) await this.store.update(id, c => {
|
|
139
227
|
const entry = c.agents.find(a => a.sessionId === message.from && !a.coordinator);
|
|
140
228
|
if (entry) entry.port = message.payload.port as number;
|
|
141
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' });
|
|
142
232
|
notify('status', JSON.stringify({ sessionId: message.from, port: message.payload.port, busy: message.payload.busy, configured: !!sender }));
|
|
143
233
|
}
|
|
144
234
|
return;
|
|
@@ -166,12 +256,14 @@ export class Intercom {
|
|
|
166
256
|
fail('unsupported control');
|
|
167
257
|
}
|
|
168
258
|
async tool(operation: string, args: Record<string, unknown>): Promise<unknown> {
|
|
259
|
+
const record = this.captureObserver();
|
|
169
260
|
const assertValid = this.validity();
|
|
170
261
|
const { id, config, me } = await this.state();
|
|
171
262
|
assertValid();
|
|
172
263
|
if (operation === 'list') {
|
|
173
264
|
if (!me) fail('anonymous intercom_list permissions unresolved (TODO); no coordinator privileges');
|
|
174
|
-
|
|
265
|
+
const dashboardPort = coordinator(config).dashboardPort;
|
|
266
|
+
return { ...config, dashboardUrl: dashboardPort === undefined ? null : `http://127.0.0.1:${dashboardPort}/` };
|
|
175
267
|
}
|
|
176
268
|
if (operation === 'report_status') { await this.report(); return { accepted: true }; }
|
|
177
269
|
if (operation === 'send') {
|
|
@@ -182,15 +274,17 @@ export class Intercom {
|
|
|
182
274
|
if (operation === 'stop_worker' || operation === 'close_worker') fail(UNSUPPORTED_CANCELLATION);
|
|
183
275
|
if (operation === 'configure_worker') {
|
|
184
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' });
|
|
185
278
|
return { written: true, reloaded: false };
|
|
186
279
|
}
|
|
187
280
|
if (operation === 'set_multiplexer') {
|
|
188
|
-
await this.store.update(id, c => { c.multiplexer = args.multiplexer as Config['multiplexer']; }, assertValid);
|
|
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 };
|
|
189
283
|
}
|
|
190
284
|
if (operation === 'create_worker') {
|
|
191
285
|
const relative = await directory(this.store.root, (args.projectDirectory as string | undefined) ?? '.');
|
|
192
286
|
assertValid();
|
|
193
|
-
return this.
|
|
287
|
+
return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative) });
|
|
194
288
|
}
|
|
195
289
|
const target = named(config, args.to as string);
|
|
196
290
|
if (target.coordinator) fail('operation requires a worker target');
|
|
@@ -201,13 +295,27 @@ export class Intercom {
|
|
|
201
295
|
if (operation === 'resume_worker') {
|
|
202
296
|
const relative = await directory(this.store.root, target.projectDirectory);
|
|
203
297
|
assertValid();
|
|
204
|
-
return this.
|
|
298
|
+
return this.launchObserved({ multiplexer: config.multiplexer, cwd: path.resolve(this.store.root, relative), sessionId: target.sessionId });
|
|
205
299
|
}
|
|
206
300
|
if (operation === 'remove_worker') {
|
|
207
301
|
// No live process claim: the caller must explicitly ensure closure first.
|
|
208
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' });
|
|
209
304
|
return { removed: true, processStopped: false, sessionDeleted: false };
|
|
210
305
|
}
|
|
211
306
|
fail(`unknown operation ${operation}`);
|
|
212
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
|
+
}
|
|
213
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);
|