@smithers-orchestrator/pi-plugin 0.16.9

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,528 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { WebSocket } from "ws";
3
+ import { SmithersError } from "@smithers-orchestrator/errors/SmithersError";
4
+ import type { DevToolsDelta, DevToolsSnapshot } from "@smithers-orchestrator/protocol";
5
+
6
+ type RequestOptions = {
7
+ baseUrl?: string;
8
+ apiKey?: string;
9
+ clientId?: string;
10
+ clientVersion?: string;
11
+ };
12
+
13
+ type ResponseFrame = {
14
+ type: "res";
15
+ id: string;
16
+ ok: boolean;
17
+ payload?: unknown;
18
+ error?: {
19
+ code?: string;
20
+ message?: string;
21
+ };
22
+ };
23
+
24
+ type EventFrame = {
25
+ type: "event";
26
+ event: string;
27
+ payload?: unknown;
28
+ };
29
+
30
+ type DevToolsGapResync = {
31
+ fromSeq: number;
32
+ toSeq: number;
33
+ };
34
+
35
+ type DevToolsRuntimeEvent =
36
+ | { version: 1; kind: "snapshot"; snapshot: DevToolsSnapshot & { runState?: RunStateView } }
37
+ | { version: 1; kind: "delta"; delta: DevToolsDelta }
38
+ | { version: 1; kind: "gapResync"; gapResync: DevToolsGapResync };
39
+
40
+ type RunStateView = {
41
+ runId?: string;
42
+ run_id?: string;
43
+ state?: string;
44
+ computedAt?: string;
45
+ computed_at?: string;
46
+ engineHeartbeatAtMs?: number;
47
+ engine_heartbeat_at_ms?: number;
48
+ engineHeartbeatMs?: number;
49
+ engine_heartbeat_ms?: number;
50
+ viewersHeartbeatAtMs?: number;
51
+ viewers_heartbeat_at_ms?: number;
52
+ uiHeartbeatAtMs?: number;
53
+ ui_heartbeat_at_ms?: number;
54
+ viewersHeartbeatMs?: number;
55
+ viewers_heartbeat_ms?: number;
56
+ uiHeartbeatMs?: number;
57
+ ui_heartbeat_ms?: number;
58
+ engineHeartbeatAt?: string;
59
+ engine_heartbeat_at?: string;
60
+ viewersHeartbeatAt?: string;
61
+ viewers_heartbeat_at?: string;
62
+ uiHeartbeatAt?: string;
63
+ ui_heartbeat_at?: string;
64
+ blocked?: unknown;
65
+ unhealthy?: unknown;
66
+ };
67
+
68
+ type GatewayMutationResult = {
69
+ auditRowId?: string;
70
+ };
71
+
72
+ type PendingRequest = {
73
+ resolve: (frame: ResponseFrame) => void;
74
+ reject: (error: Error) => void;
75
+ };
76
+
77
+ const DEFAULT_BASE = "http://127.0.0.1:7331";
78
+ const AUDIT_ROW_ID_KEYS = new Set([
79
+ "auditRowId",
80
+ "audit_row_id",
81
+ "auditId",
82
+ "audit_id",
83
+ "auditLogId",
84
+ "audit_log_id",
85
+ ]);
86
+ const NESTED_AUDIT_CONTAINERS = ["result", "data", "mutation", "ack", "payload", "meta"];
87
+
88
+ function toWsUrl(baseUrl: string) {
89
+ const url = new URL(baseUrl);
90
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
91
+ url.pathname = url.pathname === "/" ? "/" : url.pathname;
92
+ url.search = "";
93
+ return url.toString();
94
+ }
95
+
96
+ function isRecord(value: unknown): value is Record<string, unknown> {
97
+ return typeof value === "object" && value !== null && !Array.isArray(value);
98
+ }
99
+
100
+ function eventSeq(event: DevToolsRuntimeEvent) {
101
+ switch (event.kind) {
102
+ case "snapshot":
103
+ return event.snapshot.seq;
104
+ case "delta":
105
+ return event.delta.seq;
106
+ case "gapResync":
107
+ return event.gapResync.toSeq;
108
+ }
109
+ }
110
+
111
+ function normalizeEvent(raw: unknown): DevToolsRuntimeEvent {
112
+ if (!isRecord(raw)) {
113
+ throw new SmithersError("PI_DEVTOOLS_DECODE_ERROR", "DevTools event must be an object.");
114
+ }
115
+ const kind = typeof raw.kind === "string" ? raw.kind.toLowerCase() : undefined;
116
+ const type = typeof raw.type === "string" ? raw.type.toLowerCase() : undefined;
117
+ if (kind === "snapshot" && isRecord(raw.snapshot)) {
118
+ return { version: 1, kind: "snapshot", snapshot: raw.snapshot as DevToolsSnapshot };
119
+ }
120
+ if (kind === "delta" && isRecord(raw.delta)) {
121
+ return { version: 1, kind: "delta", delta: raw.delta as DevToolsDelta };
122
+ }
123
+ if ((kind === "gapresync" || kind === "gap_resync") && isRecord(raw.gapResync)) {
124
+ return {
125
+ version: 1,
126
+ kind: "gapResync",
127
+ gapResync: raw.gapResync as DevToolsGapResync,
128
+ };
129
+ }
130
+ if (type === "snapshot") {
131
+ return { version: 1, kind: "snapshot", snapshot: raw as DevToolsSnapshot };
132
+ }
133
+ if (type === "delta") {
134
+ return { version: 1, kind: "delta", delta: raw as DevToolsDelta };
135
+ }
136
+ if (type === "gapresync" || type === "gap_resync") {
137
+ return { version: 1, kind: "gapResync", gapResync: raw as DevToolsGapResync };
138
+ }
139
+ throw new SmithersError("PI_DEVTOOLS_DECODE_ERROR", "Unknown DevTools event kind.");
140
+ }
141
+
142
+ function unsupportedRpc(error: unknown) {
143
+ const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
144
+ return [
145
+ "method not found",
146
+ "unknown method",
147
+ "unsupported method",
148
+ "not implemented",
149
+ "unrecognized method",
150
+ "not_found",
151
+ ].some((phrase) => message.includes(phrase));
152
+ }
153
+
154
+ function auditRowId(value: unknown): string | undefined {
155
+ if (Array.isArray(value)) {
156
+ for (const entry of value) {
157
+ const id = auditRowId(entry);
158
+ if (id) {
159
+ return id;
160
+ }
161
+ }
162
+ return undefined;
163
+ }
164
+ if (!isRecord(value)) {
165
+ return undefined;
166
+ }
167
+ for (const key of AUDIT_ROW_ID_KEYS) {
168
+ const id = value[key];
169
+ if (typeof id === "string" && id.length > 0) {
170
+ return id;
171
+ }
172
+ }
173
+ for (const key of NESTED_AUDIT_CONTAINERS) {
174
+ const id = auditRowId(value[key]);
175
+ if (id) {
176
+ return id;
177
+ }
178
+ }
179
+ return undefined;
180
+ }
181
+
182
+ class GatewayWsConnection {
183
+ private readonly pending = new Map<string, PendingRequest>();
184
+ private readonly messages: EventFrame[] = [];
185
+ private readonly waiters: Array<(message: EventFrame | undefined) => void> = [];
186
+ private closed = false;
187
+
188
+ private constructor(private readonly ws: WebSocket) {
189
+ ws.on("message", (raw) => this.handleMessage(raw));
190
+ ws.on("close", () => this.closeWaiters());
191
+ ws.on("error", (error) => this.rejectAll(error instanceof Error ? error : new Error(String(error))));
192
+ }
193
+
194
+ static async open(url: string) {
195
+ const ws = new WebSocket(url);
196
+ const connection = new GatewayWsConnection(ws);
197
+ await new Promise<void>((resolve, reject) => {
198
+ ws.once("open", () => resolve());
199
+ ws.once("error", reject);
200
+ });
201
+ return connection;
202
+ }
203
+
204
+ async connect(params: Record<string, unknown>) {
205
+ await this.waitForEvent("connect.challenge", 5_000);
206
+ return this.request("connect", params);
207
+ }
208
+
209
+ request(method: string, params?: unknown) {
210
+ const id = `${method}-${randomUUID()}`;
211
+ const frame = { type: "req", id, method, params };
212
+ return new Promise<ResponseFrame>((resolve, reject) => {
213
+ this.pending.set(id, { resolve, reject });
214
+ this.ws.send(JSON.stringify(frame), (error) => {
215
+ if (error) {
216
+ this.pending.delete(id);
217
+ reject(error);
218
+ }
219
+ });
220
+ });
221
+ }
222
+
223
+ async nextEvent() {
224
+ if (this.messages.length > 0) {
225
+ return this.messages.shift();
226
+ }
227
+ if (this.closed) {
228
+ return undefined;
229
+ }
230
+ return new Promise<EventFrame | undefined>((resolve) => {
231
+ this.waiters.push(resolve);
232
+ });
233
+ }
234
+
235
+ close() {
236
+ this.closed = true;
237
+ this.closeWaiters();
238
+ if (this.ws.readyState === this.ws.OPEN || this.ws.readyState === this.ws.CONNECTING) {
239
+ this.ws.close();
240
+ }
241
+ }
242
+
243
+ private async waitForEvent(event: string, timeoutMs: number) {
244
+ const timeoutAt = Date.now() + timeoutMs;
245
+ while (Date.now() < timeoutAt) {
246
+ const frame = await this.nextEvent();
247
+ if (!frame) {
248
+ break;
249
+ }
250
+ if (frame.event === event) {
251
+ return frame;
252
+ }
253
+ }
254
+ throw new SmithersError("PI_GATEWAY_TIMEOUT", `Timed out waiting for ${event}.`);
255
+ }
256
+
257
+ private handleMessage(raw: WebSocket.RawData) {
258
+ let message: unknown;
259
+ try {
260
+ message = JSON.parse(String(raw));
261
+ } catch (error) {
262
+ this.rejectAll(error instanceof Error ? error : new Error(String(error)));
263
+ return;
264
+ }
265
+ if (!isRecord(message)) {
266
+ return;
267
+ }
268
+ if (message.type === "res" && typeof message.id === "string") {
269
+ const pending = this.pending.get(message.id);
270
+ if (pending) {
271
+ this.pending.delete(message.id);
272
+ pending.resolve(message as ResponseFrame);
273
+ }
274
+ return;
275
+ }
276
+ if (message.type === "event" && typeof message.event === "string") {
277
+ const frame = message as EventFrame;
278
+ const waiter = this.waiters.shift();
279
+ if (waiter) {
280
+ waiter(frame);
281
+ } else {
282
+ this.messages.push(frame);
283
+ }
284
+ }
285
+ }
286
+
287
+ private rejectAll(error: Error) {
288
+ for (const pending of this.pending.values()) {
289
+ pending.reject(error);
290
+ }
291
+ this.pending.clear();
292
+ this.closeWaiters();
293
+ }
294
+
295
+ private closeWaiters() {
296
+ this.closed = true;
297
+ while (this.waiters.length > 0) {
298
+ this.waiters.shift()?.(undefined);
299
+ }
300
+ }
301
+ }
302
+
303
+ export class DevToolsClient {
304
+ readonly baseUrl: string;
305
+ readonly apiKey: string | undefined;
306
+ private readonly clientId: string;
307
+ private readonly clientVersion: string;
308
+ private readonly lastSeqSeenByRunId = new Map<string, number>();
309
+
310
+ constructor(opts: RequestOptions = {}) {
311
+ this.baseUrl = opts.baseUrl ?? DEFAULT_BASE;
312
+ this.apiKey = opts.apiKey;
313
+ this.clientId = opts.clientId ?? "smithers-pi-plugin";
314
+ this.clientVersion = opts.clientVersion ?? "1.0.0";
315
+ }
316
+
317
+ lastSeqSeen(runId: string) {
318
+ return this.lastSeqSeenByRunId.get(runId);
319
+ }
320
+
321
+ async *streamDevTools(
322
+ runId: string,
323
+ afterSeq?: number,
324
+ signal?: AbortSignal,
325
+ ): AsyncGenerator<DevToolsRuntimeEvent> {
326
+ let afterSeqCursor = afterSeq ?? this.lastSeqSeenByRunId.get(runId);
327
+ while (!signal?.aborted) {
328
+ const connection = await GatewayWsConnection.open(toWsUrl(this.baseUrl));
329
+ const abort = () => connection.close();
330
+ signal?.addEventListener("abort", abort, { once: true });
331
+ try {
332
+ const hello = await connection.connect({
333
+ minProtocol: 1,
334
+ maxProtocol: 1,
335
+ client: {
336
+ id: this.clientId,
337
+ version: this.clientVersion,
338
+ platform: "pi",
339
+ },
340
+ auth: this.apiKey ? { token: this.apiKey } : undefined,
341
+ subscribe: [runId],
342
+ });
343
+ this.assertOk(hello, "connect");
344
+
345
+ const subscribed = await connection.request("streamDevTools", {
346
+ runId,
347
+ ...(typeof afterSeqCursor === "number" ? { afterSeq: afterSeqCursor } : {}),
348
+ });
349
+ if (!subscribed.ok) {
350
+ const code = subscribed.error?.code;
351
+ if (code === "SeqOutOfRange" && typeof afterSeqCursor === "number") {
352
+ yield {
353
+ version: 1,
354
+ kind: "gapResync",
355
+ gapResync: { fromSeq: afterSeqCursor, toSeq: afterSeqCursor },
356
+ };
357
+ afterSeqCursor = undefined;
358
+ continue;
359
+ }
360
+ this.assertOk(subscribed, "streamDevTools");
361
+ }
362
+
363
+ const payload = isRecord(subscribed.payload) ? subscribed.payload : {};
364
+ const streamId = typeof payload.streamId === "string" ? payload.streamId : undefined;
365
+ while (!signal?.aborted) {
366
+ const message = await connection.nextEvent();
367
+ if (!message) {
368
+ return;
369
+ }
370
+ if (message.event === "devtools.error" && isRecord(message.payload)) {
371
+ if (!streamId || message.payload.streamId === streamId) {
372
+ const error = isRecord(message.payload.error) ? message.payload.error : {};
373
+ throw new SmithersError(
374
+ String(error.code ?? "PI_DEVTOOLS_STREAM_ERROR"),
375
+ String(error.message ?? "DevTools stream failed."),
376
+ { runId, streamId },
377
+ );
378
+ }
379
+ }
380
+ if (message.event !== "devtools.event" || !isRecord(message.payload)) {
381
+ continue;
382
+ }
383
+ if (streamId && message.payload.streamId !== streamId) {
384
+ continue;
385
+ }
386
+ const event = normalizeEvent(message.payload.event);
387
+ this.lastSeqSeenByRunId.set(
388
+ runId,
389
+ Math.max(this.lastSeqSeenByRunId.get(runId) ?? 0, eventSeq(event)),
390
+ );
391
+ yield event;
392
+ }
393
+ } finally {
394
+ signal?.removeEventListener("abort", abort);
395
+ connection.close();
396
+ }
397
+ }
398
+ }
399
+
400
+ async getDevToolsSnapshot(runId: string, frameNo?: number) {
401
+ const snapshot = await this.rpc("getDevToolsSnapshot", {
402
+ runId,
403
+ ...(typeof frameNo === "number" ? { frameNo } : {}),
404
+ });
405
+ if (isRecord(snapshot) && typeof snapshot.seq === "number") {
406
+ this.lastSeqSeenByRunId.set(runId, Math.max(this.lastSeqSeenByRunId.get(runId) ?? 0, snapshot.seq));
407
+ }
408
+ return snapshot as DevToolsSnapshot & { runState?: RunStateView };
409
+ }
410
+
411
+ async getNodeOutput(runId: string, nodeId: string, iteration?: number) {
412
+ return this.rpc("devtools.getNodeOutput", {
413
+ runId,
414
+ nodeId,
415
+ ...(typeof iteration === "number" ? { iteration } : {}),
416
+ });
417
+ }
418
+
419
+ async getNodeDiff(runId: string, nodeId: string, iteration?: number) {
420
+ return this.rpc("devtools.getNodeDiff", {
421
+ runId,
422
+ nodeId,
423
+ ...(typeof iteration === "number" ? { iteration } : {}),
424
+ });
425
+ }
426
+
427
+ async approve(runId: string, nodeId: string, iteration = 0, note?: string) {
428
+ const payload = await this.rpc("approvals.decide", {
429
+ runId,
430
+ nodeId,
431
+ iteration,
432
+ approved: true,
433
+ note,
434
+ });
435
+ return { auditRowId: auditRowId(payload) } satisfies GatewayMutationResult;
436
+ }
437
+
438
+ async deny(runId: string, nodeId: string, iteration = 0, note?: string) {
439
+ const payload = await this.rpc("approvals.decide", {
440
+ runId,
441
+ nodeId,
442
+ iteration,
443
+ approved: false,
444
+ note,
445
+ });
446
+ return { auditRowId: auditRowId(payload) } satisfies GatewayMutationResult;
447
+ }
448
+
449
+ async signal(runId: string, signal: string, payload?: unknown, correlationId?: string) {
450
+ const response = await this.rpc("signals.send", {
451
+ runId,
452
+ signalName: signal,
453
+ data: payload ?? {},
454
+ correlationId,
455
+ });
456
+ return { auditRowId: auditRowId(response) } satisfies GatewayMutationResult;
457
+ }
458
+
459
+ async cancel(runId: string) {
460
+ const payload = await this.rpc("runs.cancel", { runId });
461
+ return { auditRowId: auditRowId(payload) } satisfies GatewayMutationResult;
462
+ }
463
+
464
+ async resume(runId: string) {
465
+ return this.performMutation(["runs.resume", "workflowRuns.resume"], { runId });
466
+ }
467
+
468
+ async rewind(runId: string, frameNo: number, confirm = true) {
469
+ const payload = await this.rpc("devtools.jumpToFrame", { runId, frameNo, confirm });
470
+ return {
471
+ ...(isRecord(payload) ? payload : {}),
472
+ auditRowId: auditRowId(payload),
473
+ };
474
+ }
475
+
476
+ private async performMutation(methods: string[], params: Record<string, unknown>) {
477
+ let lastUnsupportedError: unknown;
478
+ for (const method of methods) {
479
+ try {
480
+ const payload = await this.rpc(method, params);
481
+ return { auditRowId: auditRowId(payload) } satisfies GatewayMutationResult;
482
+ } catch (error) {
483
+ if (unsupportedRpc(error)) {
484
+ lastUnsupportedError = error;
485
+ continue;
486
+ }
487
+ throw error;
488
+ }
489
+ }
490
+ throw lastUnsupportedError instanceof Error
491
+ ? lastUnsupportedError
492
+ : new SmithersError("PI_UNSUPPORTED_MUTATION", "No supported gateway mutation RPC found.");
493
+ }
494
+
495
+ private async rpc(method: string, params?: unknown) {
496
+ const id = `${method}-${randomUUID()}`;
497
+ const headers: Record<string, string> = { "Content-Type": "application/json" };
498
+ if (this.apiKey) {
499
+ headers.Authorization = `Bearer ${this.apiKey}`;
500
+ }
501
+ const response = await fetch(`${this.baseUrl}/rpc`, {
502
+ method: "POST",
503
+ headers,
504
+ body: JSON.stringify({ type: "req", id, method, params }),
505
+ });
506
+ if (!response.ok) {
507
+ const text = await response.text().catch(() => "");
508
+ throw new SmithersError("PI_GATEWAY_HTTP_ERROR", `Gateway HTTP ${response.status}: ${text}`, {
509
+ method,
510
+ status: response.status,
511
+ });
512
+ }
513
+ const frame = (await response.json()) as ResponseFrame;
514
+ this.assertOk(frame, method);
515
+ return frame.payload;
516
+ }
517
+
518
+ private assertOk(frame: ResponseFrame, method: string): asserts frame is ResponseFrame & { ok: true } {
519
+ if (frame.ok) {
520
+ return;
521
+ }
522
+ throw new SmithersError(
523
+ String(frame.error?.code ?? "PI_GATEWAY_RPC_ERROR"),
524
+ frame.error?.message ?? `${method} failed.`,
525
+ { method },
526
+ );
527
+ }
528
+ }