@kata-sh/pi-symphony-extension 0.1.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 +117 -0
- package/package.json +48 -0
- package/scripts/wave3-mock-server.mjs +249 -0
- package/src/attach-url-policy.test.ts +20 -0
- package/src/attach-url-policy.ts +24 -0
- package/src/binary-resolver.test.ts +114 -0
- package/src/binary-resolver.ts +115 -0
- package/src/command-args.test.ts +33 -0
- package/src/command-args.ts +58 -0
- package/src/commands.test.ts +679 -0
- package/src/commands.ts +218 -0
- package/src/console-model.test.ts +252 -0
- package/src/console-model.ts +234 -0
- package/src/console.test.ts +873 -0
- package/src/console.ts +552 -0
- package/src/errors.ts +33 -0
- package/src/event-stream.test.ts +145 -0
- package/src/event-stream.ts +107 -0
- package/src/http-client.test.ts +580 -0
- package/src/http-client.ts +856 -0
- package/src/index.test.ts +80 -0
- package/src/index.ts +27 -0
- package/src/package-smoke.test.ts +24 -0
- package/src/process-manager.test.ts +232 -0
- package/src/process-manager.ts +290 -0
- package/src/progress.test.ts +175 -0
- package/src/progress.ts +75 -0
- package/src/runtime-helpers.ts +11 -0
- package/src/runtime.test.ts +237 -0
- package/src/runtime.ts +119 -0
- package/src/state.test.ts +173 -0
- package/src/state.ts +146 -0
- package/src/tools.test.ts +359 -0
- package/src/tools.ts +201 -0
- package/src/wave3-mock-server.test.ts +83 -0
- package/src/workflow-resolver.ts +40 -0
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { createServer, type Server } from "node:http";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { WebSocketServer } from "ws";
|
|
4
|
+
import { eventStreamUrl, startSymphonyEventStream } from "./event-stream.ts";
|
|
5
|
+
|
|
6
|
+
let server: Server | undefined;
|
|
7
|
+
let socketServer: WebSocketServer | undefined;
|
|
8
|
+
|
|
9
|
+
afterEach(async () => {
|
|
10
|
+
socketServer?.close();
|
|
11
|
+
socketServer = undefined;
|
|
12
|
+
if (server) {
|
|
13
|
+
await new Promise<void>((resolve, reject) => server!.close((error) => (error ? reject(error) : resolve())));
|
|
14
|
+
server = undefined;
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
async function serveWebSocket(): Promise<{ baseUrl: string; socketServer: WebSocketServer }> {
|
|
19
|
+
server = createServer();
|
|
20
|
+
socketServer = new WebSocketServer({ server, path: "/api/v1/events" });
|
|
21
|
+
await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
|
|
22
|
+
const address = server.address();
|
|
23
|
+
if (!address || typeof address === "string") throw new Error("expected TCP address");
|
|
24
|
+
return { baseUrl: `http://127.0.0.1:${address.port}`, socketServer };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
describe("event stream", () => {
|
|
28
|
+
it("converts HTTP API base URLs into WebSocket event URLs", () => {
|
|
29
|
+
expect(eventStreamUrl("http://127.0.0.1:8080")).toBe("ws://127.0.0.1:8080/api/v1/events");
|
|
30
|
+
expect(eventStreamUrl("https://example.test/base")).toBe("wss://example.test/base/api/v1/events");
|
|
31
|
+
expect(eventStreamUrl("wss://example.test/base")).toBe("wss://example.test/base/api/v1/events");
|
|
32
|
+
expect(() => eventStreamUrl("ftp://example.test/base")).toThrow("Unsupported Symphony base URL protocol: ftp:");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("delivers parsed Symphony event envelopes", async () => {
|
|
36
|
+
const { baseUrl, socketServer } = await serveWebSocket();
|
|
37
|
+
socketServer.on("connection", (socket) => {
|
|
38
|
+
socket.send(JSON.stringify({
|
|
39
|
+
version: "v1",
|
|
40
|
+
sequence: 7,
|
|
41
|
+
timestamp: "2026-05-14T12:00:00Z",
|
|
42
|
+
kind: "worker",
|
|
43
|
+
severity: "info",
|
|
44
|
+
issue: "SIM-123",
|
|
45
|
+
event: "worker_completed",
|
|
46
|
+
payload: { summary: "done" },
|
|
47
|
+
}));
|
|
48
|
+
});
|
|
49
|
+
const received = new Promise<unknown>((resolve) => {
|
|
50
|
+
const handle = startSymphonyEventStream({
|
|
51
|
+
baseUrl,
|
|
52
|
+
onEvent: (event) => {
|
|
53
|
+
handle.close();
|
|
54
|
+
resolve(event);
|
|
55
|
+
},
|
|
56
|
+
onError: (error) => resolve(error),
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
await expect(received).resolves.toMatchObject({ sequence: 7, kind: "worker", event: "worker_completed" });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("reports malformed event stream messages", async () => {
|
|
64
|
+
const { baseUrl, socketServer } = await serveWebSocket();
|
|
65
|
+
const onError = vi.fn();
|
|
66
|
+
const reported = new Promise<void>((resolve) => {
|
|
67
|
+
onError.mockImplementation(() => resolve());
|
|
68
|
+
});
|
|
69
|
+
socketServer.on("connection", (socket) => {
|
|
70
|
+
socket.send("not-json");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const handle = startSymphonyEventStream({ baseUrl, onEvent: () => undefined, onError });
|
|
74
|
+
|
|
75
|
+
await reported;
|
|
76
|
+
handle.close();
|
|
77
|
+
|
|
78
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("invalid Symphony event") }));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("does not report an error when intentionally closed before connecting", async () => {
|
|
82
|
+
const onError = vi.fn();
|
|
83
|
+
const handle = startSymphonyEventStream({
|
|
84
|
+
baseUrl: "http://127.0.0.1:9",
|
|
85
|
+
onEvent: () => undefined,
|
|
86
|
+
onError,
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
handle.close();
|
|
90
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
91
|
+
|
|
92
|
+
expect(onError).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("reports an error when the server closes the stream", async () => {
|
|
96
|
+
const { baseUrl, socketServer } = await serveWebSocket();
|
|
97
|
+
const onError = vi.fn();
|
|
98
|
+
socketServer.on("connection", (socket) => {
|
|
99
|
+
socket.close(1000, "server shutdown");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const handle = startSymphonyEventStream({ baseUrl, onEvent: () => undefined, onError });
|
|
103
|
+
|
|
104
|
+
await expect.poll(() => onError.mock.calls.length, { interval: 10, timeout: 1000 }).toBe(1);
|
|
105
|
+
handle.close();
|
|
106
|
+
|
|
107
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Symphony event stream closed") }));
|
|
108
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("server shutdown") }));
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("reconnects after an unexpected server close", async () => {
|
|
112
|
+
const { baseUrl, socketServer } = await serveWebSocket();
|
|
113
|
+
const onError = vi.fn();
|
|
114
|
+
let connectionCount = 0;
|
|
115
|
+
socketServer.on("connection", (socket) => {
|
|
116
|
+
connectionCount += 1;
|
|
117
|
+
if (connectionCount === 1) {
|
|
118
|
+
socket.close(1000, "restart");
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
socket.send(JSON.stringify({
|
|
122
|
+
version: "v1",
|
|
123
|
+
sequence: 8,
|
|
124
|
+
timestamp: "2026-05-14T12:00:00Z",
|
|
125
|
+
kind: "runtime",
|
|
126
|
+
severity: "info",
|
|
127
|
+
event: "poll_completed",
|
|
128
|
+
payload: { summary: "reconnected" },
|
|
129
|
+
}));
|
|
130
|
+
});
|
|
131
|
+
const received = new Promise<unknown>((resolve) => {
|
|
132
|
+
const handle = startSymphonyEventStream({
|
|
133
|
+
baseUrl,
|
|
134
|
+
onEvent: (event) => {
|
|
135
|
+
handle.close();
|
|
136
|
+
resolve(event);
|
|
137
|
+
},
|
|
138
|
+
onError,
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
await expect(received).resolves.toMatchObject({ sequence: 8, event: "poll_completed" });
|
|
143
|
+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: expect.stringContaining("Symphony event stream closed") }));
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { WebSocket } from "ws";
|
|
2
|
+
import type { SymphonyEventEnvelope } from "./http-client.ts";
|
|
3
|
+
|
|
4
|
+
export interface EventStreamOptions {
|
|
5
|
+
baseUrl: string;
|
|
6
|
+
onEvent: (event: SymphonyEventEnvelope) => void;
|
|
7
|
+
onError: (error: Error) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface EventStreamHandle {
|
|
11
|
+
close: () => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function eventStreamUrl(baseUrl: string): string {
|
|
15
|
+
const url = new URL(baseUrl);
|
|
16
|
+
if (url.protocol === "https:" || url.protocol === "wss:") {
|
|
17
|
+
url.protocol = "wss:";
|
|
18
|
+
} else if (url.protocol === "http:" || url.protocol === "ws:") {
|
|
19
|
+
url.protocol = "ws:";
|
|
20
|
+
} else {
|
|
21
|
+
throw new Error(`Unsupported Symphony base URL protocol: ${url.protocol}`);
|
|
22
|
+
}
|
|
23
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/api/v1/events`;
|
|
24
|
+
url.search = "";
|
|
25
|
+
url.hash = "";
|
|
26
|
+
return url.toString();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function startSymphonyEventStream(options: EventStreamOptions): EventStreamHandle {
|
|
30
|
+
let socket: WebSocket | undefined;
|
|
31
|
+
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
|
|
32
|
+
let intentionallyClosed = false;
|
|
33
|
+
let reconnectDelayMs = 100;
|
|
34
|
+
const maxReconnectDelayMs = 5000;
|
|
35
|
+
|
|
36
|
+
const connect = () => {
|
|
37
|
+
socket = new WebSocket(eventStreamUrl(options.baseUrl));
|
|
38
|
+
|
|
39
|
+
socket.on("open", () => {
|
|
40
|
+
reconnectDelayMs = 100;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
socket.on("message", (data) => {
|
|
44
|
+
try {
|
|
45
|
+
options.onEvent(parseEventEnvelope(data.toString()));
|
|
46
|
+
} catch (error) {
|
|
47
|
+
options.onError(error instanceof Error ? error : new Error(String(error)));
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
socket.on("error", (error) => {
|
|
52
|
+
if (intentionallyClosed) return;
|
|
53
|
+
options.onError(error instanceof Error ? error : new Error(String(error)));
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
socket.on("close", (code, reason) => {
|
|
57
|
+
if (intentionallyClosed) return;
|
|
58
|
+
const suffix = reason.length > 0 ? `: ${reason.toString()}` : ` with code ${code}`;
|
|
59
|
+
options.onError(new Error(`Symphony event stream closed${suffix}`));
|
|
60
|
+
reconnectTimer = setTimeout(connect, reconnectDelayMs);
|
|
61
|
+
reconnectDelayMs = Math.min(reconnectDelayMs * 2, maxReconnectDelayMs);
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
connect();
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
close: () => {
|
|
69
|
+
intentionallyClosed = true;
|
|
70
|
+
if (reconnectTimer) clearTimeout(reconnectTimer);
|
|
71
|
+
socket?.close();
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseEventEnvelope(text: string): SymphonyEventEnvelope {
|
|
77
|
+
let value: unknown;
|
|
78
|
+
try {
|
|
79
|
+
value = JSON.parse(text);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
throw new Error(`invalid Symphony event JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!isRecord(value)) throw new Error("invalid Symphony event: envelope was not an object");
|
|
85
|
+
if (value.version !== "v1") throw new Error("invalid Symphony event: version was not v1");
|
|
86
|
+
if (typeof value.sequence !== "number") throw new Error("invalid Symphony event: sequence was not a number");
|
|
87
|
+
if (typeof value.timestamp !== "string") throw new Error("invalid Symphony event: timestamp was not a string");
|
|
88
|
+
if (typeof value.kind !== "string") throw new Error("invalid Symphony event: kind was not a string");
|
|
89
|
+
if (typeof value.severity !== "string") throw new Error("invalid Symphony event: severity was not a string");
|
|
90
|
+
if (value.issue !== undefined && typeof value.issue !== "string") throw new Error("invalid Symphony event: issue was not a string");
|
|
91
|
+
if (typeof value.event !== "string") throw new Error("invalid Symphony event: event was not a string");
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
version: value.version,
|
|
95
|
+
sequence: value.sequence,
|
|
96
|
+
timestamp: value.timestamp,
|
|
97
|
+
kind: value.kind,
|
|
98
|
+
severity: value.severity,
|
|
99
|
+
issue: value.issue,
|
|
100
|
+
event: value.event,
|
|
101
|
+
payload: value.payload,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
106
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
107
|
+
}
|