@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,175 @@
|
|
|
1
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { SYMPHONY_PROGRESS_FRAMES, withSymphonyLoader, withSymphonyProgress } from "./progress.ts";
|
|
4
|
+
|
|
5
|
+
const borderedLoaderMocks = vi.hoisted(() => ({
|
|
6
|
+
constructorCalls: [] as Array<{ tui: unknown; theme: unknown; message: string; options: unknown }>,
|
|
7
|
+
instances: [] as Array<{ signal: AbortSignal; onAbort: (() => void) | undefined; abort: () => void }>,
|
|
8
|
+
signals: [] as AbortSignal[],
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
12
|
+
BorderedLoader: class MockBorderedLoader {
|
|
13
|
+
readonly controller: AbortController;
|
|
14
|
+
readonly signal: AbortSignal;
|
|
15
|
+
onAbort: (() => void) | undefined;
|
|
16
|
+
|
|
17
|
+
constructor(tui: unknown, theme: unknown, message: string, options?: unknown) {
|
|
18
|
+
this.controller = new AbortController();
|
|
19
|
+
this.signal = this.controller.signal;
|
|
20
|
+
borderedLoaderMocks.constructorCalls.push({ tui, theme, message, options });
|
|
21
|
+
borderedLoaderMocks.instances.push(this);
|
|
22
|
+
borderedLoaderMocks.signals.push(this.signal);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
abort() {
|
|
26
|
+
this.controller.abort();
|
|
27
|
+
this.onAbort?.();
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
function commandContext(options: { hasUI?: boolean } = {}) {
|
|
33
|
+
let resolveCustom: ((value: unknown) => void) | undefined;
|
|
34
|
+
const setWorkingIndicator = vi.fn();
|
|
35
|
+
const setWorkingMessage = vi.fn();
|
|
36
|
+
const setStatus = vi.fn();
|
|
37
|
+
const hasUI = options.hasUI ?? true;
|
|
38
|
+
const custom = vi.fn(async (factory: Parameters<ExtensionCommandContext["ui"]["custom"]>[0]) => {
|
|
39
|
+
const resultPromise = new Promise((resolve) => {
|
|
40
|
+
resolveCustom = resolve;
|
|
41
|
+
});
|
|
42
|
+
await factory(
|
|
43
|
+
{} as Parameters<typeof factory>[0],
|
|
44
|
+
{} as Parameters<typeof factory>[1],
|
|
45
|
+
{} as Parameters<typeof factory>[2],
|
|
46
|
+
(result: unknown) => {
|
|
47
|
+
resolveCustom?.(result);
|
|
48
|
+
},
|
|
49
|
+
);
|
|
50
|
+
return resultPromise;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const ctx = {
|
|
54
|
+
hasUI,
|
|
55
|
+
ui: { setWorkingIndicator, setWorkingMessage, setStatus, custom },
|
|
56
|
+
} as unknown as ExtensionCommandContext;
|
|
57
|
+
|
|
58
|
+
return { ctx, setWorkingIndicator, setWorkingMessage, setStatus, custom };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe("withSymphonyProgress", () => {
|
|
62
|
+
it("sets indicator, message, and status, then restores after success", async () => {
|
|
63
|
+
const { ctx, setWorkingIndicator, setWorkingMessage, setStatus } = commandContext();
|
|
64
|
+
const restoreStatus = vi.fn();
|
|
65
|
+
|
|
66
|
+
const result = await withSymphonyProgress(ctx, { message: "Starting Symphony...", restoreStatus }, async () => "started");
|
|
67
|
+
|
|
68
|
+
expect(result).toBe("started");
|
|
69
|
+
expect(SYMPHONY_PROGRESS_FRAMES).toEqual(["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]);
|
|
70
|
+
expect(setWorkingIndicator).toHaveBeenNthCalledWith(1, { frames: SYMPHONY_PROGRESS_FRAMES, intervalMs: 120 });
|
|
71
|
+
expect(setWorkingMessage).toHaveBeenNthCalledWith(1, "Starting Symphony...");
|
|
72
|
+
expect(setStatus).toHaveBeenCalledWith("symphony", "Starting Symphony...");
|
|
73
|
+
expect(setWorkingIndicator).toHaveBeenNthCalledWith(2);
|
|
74
|
+
expect(setWorkingMessage).toHaveBeenNthCalledWith(2);
|
|
75
|
+
expect(restoreStatus).toHaveBeenCalledWith(ctx);
|
|
76
|
+
expect(setWorkingMessage.mock.invocationCallOrder[1]).toBeLessThan(restoreStatus.mock.invocationCallOrder[0]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("restores indicator, message, and status after failure", async () => {
|
|
80
|
+
const { ctx, setWorkingIndicator, setWorkingMessage } = commandContext();
|
|
81
|
+
const restoreStatus = vi.fn();
|
|
82
|
+
|
|
83
|
+
await expect(
|
|
84
|
+
withSymphonyProgress(ctx, { message: "Refreshing Symphony...", restoreStatus }, async () => {
|
|
85
|
+
throw new Error("refresh failed");
|
|
86
|
+
}),
|
|
87
|
+
).rejects.toThrow("refresh failed");
|
|
88
|
+
|
|
89
|
+
expect(setWorkingIndicator).toHaveBeenNthCalledWith(2);
|
|
90
|
+
expect(setWorkingMessage).toHaveBeenNthCalledWith(2);
|
|
91
|
+
expect(restoreStatus).toHaveBeenCalledWith(ctx);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("withSymphonyLoader", () => {
|
|
96
|
+
it("shows a bordered loader, passes its AbortSignal, restores status, and returns the operation result", async () => {
|
|
97
|
+
borderedLoaderMocks.constructorCalls.length = 0;
|
|
98
|
+
borderedLoaderMocks.instances.length = 0;
|
|
99
|
+
borderedLoaderMocks.signals.length = 0;
|
|
100
|
+
const { ctx, setStatus, custom } = commandContext();
|
|
101
|
+
const restoreStatus = vi.fn();
|
|
102
|
+
const operation = vi.fn(async (signal: AbortSignal) => {
|
|
103
|
+
expect(signal).toBe(borderedLoaderMocks.signals[0]);
|
|
104
|
+
return "attached";
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const result: string | undefined = await withSymphonyLoader(ctx, { message: "Attaching Symphony...", restoreStatus }, operation);
|
|
108
|
+
|
|
109
|
+
expect(result).toBe("attached");
|
|
110
|
+
expect(setStatus).toHaveBeenCalledWith("symphony", "Attaching Symphony...");
|
|
111
|
+
expect(custom).toHaveBeenCalledOnce();
|
|
112
|
+
expect(borderedLoaderMocks.constructorCalls).toHaveLength(1);
|
|
113
|
+
expect(borderedLoaderMocks.constructorCalls[0]?.message).toBe("Attaching Symphony...");
|
|
114
|
+
expect(operation).toHaveBeenCalledWith(expect.any(AbortSignal));
|
|
115
|
+
expect(restoreStatus).toHaveBeenCalledWith(ctx);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("closes the custom loader promptly when cancelled", async () => {
|
|
119
|
+
borderedLoaderMocks.constructorCalls.length = 0;
|
|
120
|
+
borderedLoaderMocks.instances.length = 0;
|
|
121
|
+
borderedLoaderMocks.signals.length = 0;
|
|
122
|
+
const { ctx } = commandContext();
|
|
123
|
+
const restoreStatus = vi.fn();
|
|
124
|
+
const operation = vi.fn(() => new Promise<string>(() => {}));
|
|
125
|
+
|
|
126
|
+
const resultPromise = withSymphonyLoader(ctx, { message: "Cancelling Symphony...", restoreStatus }, operation);
|
|
127
|
+
|
|
128
|
+
await vi.waitFor(() => expect(operation).toHaveBeenCalledOnce());
|
|
129
|
+
const loader = borderedLoaderMocks.instances[0];
|
|
130
|
+
expect(loader).toBeDefined();
|
|
131
|
+
|
|
132
|
+
loader?.abort();
|
|
133
|
+
|
|
134
|
+
const result = await resultPromise;
|
|
135
|
+
|
|
136
|
+
expect(result).toBeUndefined();
|
|
137
|
+
expect(operation).toHaveBeenCalledWith(loader?.signal);
|
|
138
|
+
expect(loader?.signal.aborted).toBe(true);
|
|
139
|
+
expect(restoreStatus).toHaveBeenCalledWith(ctx);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("runs the operation without calling custom when UI is unavailable", async () => {
|
|
143
|
+
const { ctx, setStatus, custom } = commandContext({ hasUI: false });
|
|
144
|
+
const restoreStatus = vi.fn();
|
|
145
|
+
const operation = vi.fn(async (signal: AbortSignal) => {
|
|
146
|
+
expect(signal).toBeInstanceOf(AbortSignal);
|
|
147
|
+
expect(signal.aborted).toBe(false);
|
|
148
|
+
return "initialized";
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const result = await withSymphonyLoader(ctx, { message: "Initializing Symphony...", restoreStatus }, operation);
|
|
152
|
+
|
|
153
|
+
expect(result).toBe("initialized");
|
|
154
|
+
expect(setStatus).toHaveBeenCalledWith("symphony", "Initializing Symphony...");
|
|
155
|
+
expect(custom).not.toHaveBeenCalled();
|
|
156
|
+
expect(operation).toHaveBeenCalledWith(expect.any(AbortSignal));
|
|
157
|
+
expect(restoreStatus).toHaveBeenCalledWith(ctx);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("restores status and propagates loader operation failures", async () => {
|
|
161
|
+
borderedLoaderMocks.constructorCalls.length = 0;
|
|
162
|
+
borderedLoaderMocks.instances.length = 0;
|
|
163
|
+
borderedLoaderMocks.signals.length = 0;
|
|
164
|
+
const { ctx } = commandContext();
|
|
165
|
+
const restoreStatus = vi.fn();
|
|
166
|
+
|
|
167
|
+
await expect(
|
|
168
|
+
withSymphonyLoader(ctx, { message: "Stopping Symphony...", restoreStatus }, async () => {
|
|
169
|
+
throw new Error("stop failed");
|
|
170
|
+
}),
|
|
171
|
+
).rejects.toThrow("stop failed");
|
|
172
|
+
|
|
173
|
+
expect(restoreStatus).toHaveBeenCalledWith(ctx);
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/progress.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { BorderedLoader, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export const SYMPHONY_PROGRESS_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
4
|
+
|
|
5
|
+
export interface SymphonyProgressOptions {
|
|
6
|
+
message: string;
|
|
7
|
+
restoreStatus: (ctx: ExtensionCommandContext) => void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function withSymphonyProgress<T>(
|
|
11
|
+
ctx: ExtensionCommandContext,
|
|
12
|
+
options: SymphonyProgressOptions,
|
|
13
|
+
fn: (signal: AbortSignal) => T | Promise<T>,
|
|
14
|
+
): Promise<T> {
|
|
15
|
+
const controller = new AbortController();
|
|
16
|
+
ctx.ui.setWorkingIndicator({ frames: SYMPHONY_PROGRESS_FRAMES, intervalMs: 120 });
|
|
17
|
+
ctx.ui.setWorkingMessage(options.message);
|
|
18
|
+
ctx.ui.setStatus("symphony", options.message);
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
return await fn(controller.signal);
|
|
22
|
+
} finally {
|
|
23
|
+
ctx.ui.setWorkingIndicator();
|
|
24
|
+
ctx.ui.setWorkingMessage();
|
|
25
|
+
options.restoreStatus(ctx);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function withSymphonyLoader<T>(
|
|
30
|
+
ctx: ExtensionCommandContext,
|
|
31
|
+
options: SymphonyProgressOptions,
|
|
32
|
+
fn: (signal: AbortSignal) => T | Promise<T>,
|
|
33
|
+
): Promise<T | undefined> {
|
|
34
|
+
ctx.ui.setStatus("symphony", options.message);
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
if (!ctx.hasUI) {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
return await fn(controller.signal);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let operationFailed = false;
|
|
43
|
+
let operationError: unknown;
|
|
44
|
+
|
|
45
|
+
const result = await ctx.ui.custom<T | undefined>((tui, theme, _keybindings, done) => {
|
|
46
|
+
const loader = new BorderedLoader(tui, theme, options.message);
|
|
47
|
+
let completed = false;
|
|
48
|
+
const complete = (value: T | undefined) => {
|
|
49
|
+
if (completed) return;
|
|
50
|
+
completed = true;
|
|
51
|
+
done(value);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
loader.onAbort = () => complete(undefined);
|
|
55
|
+
|
|
56
|
+
void Promise.resolve()
|
|
57
|
+
.then(() => fn(loader.signal))
|
|
58
|
+
.then(
|
|
59
|
+
(value) => complete(value),
|
|
60
|
+
(error: unknown) => {
|
|
61
|
+
if (completed) return;
|
|
62
|
+
operationFailed = true;
|
|
63
|
+
operationError = error;
|
|
64
|
+
complete(undefined);
|
|
65
|
+
},
|
|
66
|
+
);
|
|
67
|
+
return loader;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
if (operationFailed) throw operationError;
|
|
71
|
+
return result;
|
|
72
|
+
} finally {
|
|
73
|
+
options.restoreStatus(ctx);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SymphonyExtensionError } from "./errors.ts";
|
|
2
|
+
import type { SymphonyRuntime } from "./runtime.ts";
|
|
3
|
+
|
|
4
|
+
export async function cleanupAbortedStart(runtime: SymphonyRuntime, baseUrl: string): Promise<void> {
|
|
5
|
+
try {
|
|
6
|
+
await runtime.processManager.stopOwned();
|
|
7
|
+
} catch (error) {
|
|
8
|
+
if (!(error instanceof SymphonyExtensionError && error.kind === "not_owned")) throw error;
|
|
9
|
+
}
|
|
10
|
+
runtime.clearAttachmentIfBaseUrl(baseUrl);
|
|
11
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { SymphonyStateResponse } from "./http-client.ts";
|
|
4
|
+
import { SymphonyRuntime } from "./runtime.ts";
|
|
5
|
+
import { STATE_ENTRY_TYPE, type LastKnownSymphonyState } from "./state.ts";
|
|
6
|
+
|
|
7
|
+
function contextWithEntries(entries: unknown[]): ExtensionContext {
|
|
8
|
+
return {
|
|
9
|
+
sessionManager: {
|
|
10
|
+
getEntries: () => entries,
|
|
11
|
+
},
|
|
12
|
+
} as unknown as ExtensionContext;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function lastKnownState(baseUrl: string): LastKnownSymphonyState {
|
|
16
|
+
return {
|
|
17
|
+
baseUrl,
|
|
18
|
+
runningCount: 1,
|
|
19
|
+
retryCount: 0,
|
|
20
|
+
blockedCount: 0,
|
|
21
|
+
completedCount: 2,
|
|
22
|
+
pollingChecking: false,
|
|
23
|
+
nextPollInMs: 1000,
|
|
24
|
+
updatedAt: "2026-05-14T00:00:01.000Z",
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function stateResponse(overrides: Partial<SymphonyStateResponse> = {}): SymphonyStateResponse {
|
|
29
|
+
return {
|
|
30
|
+
running: {},
|
|
31
|
+
retry_queue: [],
|
|
32
|
+
blocked: [],
|
|
33
|
+
completed: [],
|
|
34
|
+
polling: { checking: false, next_poll_in_ms: 1000, poll_interval_ms: 30000 },
|
|
35
|
+
shared_context: { total_entries: 0, entries_by_scope: {}, oldest_entry_at: null, newest_entry_at: null },
|
|
36
|
+
supervisor: { active: true, steers_issued: 0, conflicts_detected: 0, patterns_detected: 0, escalations_created: 0 },
|
|
37
|
+
codex_totals: { input_tokens: 0, output_tokens: 0, total_tokens: 0, event_count: 0, seconds_running: 0 },
|
|
38
|
+
codex_rate_limits: null,
|
|
39
|
+
...overrides,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
describe("SymphonyRuntime", () => {
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
vi.unstubAllGlobals();
|
|
46
|
+
vi.restoreAllMocks();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("clears the active attachment and last known state", () => {
|
|
50
|
+
const runtime = new SymphonyRuntime();
|
|
51
|
+
const baseUrl = "http://127.0.0.1:8080";
|
|
52
|
+
runtime.state.attachedBaseUrl = baseUrl;
|
|
53
|
+
runtime.state.lastKnownState = lastKnownState(baseUrl);
|
|
54
|
+
runtime.client = {} as SymphonyRuntime["client"];
|
|
55
|
+
|
|
56
|
+
expect(runtime.clearAttachmentIfBaseUrl("http://127.0.0.1:8081")).toBe(false);
|
|
57
|
+
expect(runtime.state.attachedBaseUrl).toBe(baseUrl);
|
|
58
|
+
|
|
59
|
+
expect(runtime.clearAttachmentIfBaseUrl(baseUrl)).toBe(true);
|
|
60
|
+
expect(runtime.state.attachedBaseUrl).toBeUndefined();
|
|
61
|
+
expect(runtime.state.lastKnownState).toBeUndefined();
|
|
62
|
+
expect(runtime.client).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("keeps restored state shared with the process manager", async () => {
|
|
66
|
+
const runtime = new SymphonyRuntime();
|
|
67
|
+
|
|
68
|
+
runtime.restore(
|
|
69
|
+
contextWithEntries([
|
|
70
|
+
{
|
|
71
|
+
type: "custom",
|
|
72
|
+
customType: STATE_ENTRY_TYPE,
|
|
73
|
+
data: {
|
|
74
|
+
attachedBaseUrl: "http://127.0.0.1:8080",
|
|
75
|
+
ownedProcess: {
|
|
76
|
+
pid: 123,
|
|
77
|
+
command: "symphony --no-tui",
|
|
78
|
+
cwd: "/repo",
|
|
79
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
80
|
+
startedAt: "2026-05-14T00:00:00.000Z",
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
]),
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
expect(runtime.state.ownedProcess?.pid).toBe(123);
|
|
88
|
+
|
|
89
|
+
await expect(runtime.processManager.stopOwned()).rejects.toMatchObject({ kind: "not_owned" });
|
|
90
|
+
expect(runtime.state.ownedProcess).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("keeps the latest raw Symphony state after refresh", async () => {
|
|
94
|
+
const runtime = new SymphonyRuntime();
|
|
95
|
+
const response = stateResponse({ tracker_project_url: "https://linear.app/kata-sh/project/symphony" });
|
|
96
|
+
runtime.client = {
|
|
97
|
+
getState: vi.fn(async () => response),
|
|
98
|
+
toHealthSummary: vi.fn(() => lastKnownState("http://127.0.0.1:8080")),
|
|
99
|
+
} as unknown as SymphonyRuntime["client"];
|
|
100
|
+
|
|
101
|
+
await expect(runtime.refreshState()).resolves.toBe(response);
|
|
102
|
+
|
|
103
|
+
expect(runtime.lastState).toBe(response);
|
|
104
|
+
expect(runtime.state.lastKnownState?.runningCount).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("clears recent events when attaching to a new server", async () => {
|
|
108
|
+
const runtime = new SymphonyRuntime();
|
|
109
|
+
runtime.recordEvent({ version: "v1", sequence: 1, timestamp: "2026-05-14T12:00:00Z", kind: "worker", severity: "error", event: "worker_failed", payload: {} });
|
|
110
|
+
const response = stateResponse({ tracker_project_url: "https://linear.app/kata-sh/project/symphony" });
|
|
111
|
+
const fetchStub: typeof fetch = async () =>
|
|
112
|
+
({
|
|
113
|
+
ok: true,
|
|
114
|
+
status: 200,
|
|
115
|
+
text: async () => JSON.stringify(response),
|
|
116
|
+
}) as unknown as Response;
|
|
117
|
+
vi.stubGlobal("fetch", fetchStub);
|
|
118
|
+
|
|
119
|
+
await expect(runtime.attach("http://127.0.0.1:8080")).resolves.toEqual(response);
|
|
120
|
+
|
|
121
|
+
expect(runtime.recentEvents).toEqual([]);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("requests a refresh before fetching the latest state", async () => {
|
|
125
|
+
const runtime = new SymphonyRuntime();
|
|
126
|
+
const calls: string[] = [];
|
|
127
|
+
const response = stateResponse();
|
|
128
|
+
runtime.client = {
|
|
129
|
+
refresh: vi.fn(async () => {
|
|
130
|
+
calls.push("refresh");
|
|
131
|
+
return { queued: true, coalesced: false, pendingRequests: 1 };
|
|
132
|
+
}),
|
|
133
|
+
getState: vi.fn(async () => {
|
|
134
|
+
calls.push("getState");
|
|
135
|
+
return response;
|
|
136
|
+
}),
|
|
137
|
+
toHealthSummary: vi.fn(() => lastKnownState("http://127.0.0.1:8080")),
|
|
138
|
+
} as unknown as SymphonyRuntime["client"];
|
|
139
|
+
|
|
140
|
+
await runtime.requestRefresh();
|
|
141
|
+
|
|
142
|
+
expect(calls).toEqual(["refresh", "getState"]);
|
|
143
|
+
expect(runtime.lastState).toBe(response);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("steers a worker and refreshes state", async () => {
|
|
147
|
+
const runtime = new SymphonyRuntime();
|
|
148
|
+
const response = stateResponse();
|
|
149
|
+
runtime.client = {
|
|
150
|
+
steer: vi.fn(async () => ({ ok: true, issueId: "issue-123", issueIdentifier: "SIM-123", delivered: true, instructionPreview: "Use auth" })),
|
|
151
|
+
getState: vi.fn(async () => response),
|
|
152
|
+
toHealthSummary: vi.fn(() => lastKnownState("http://127.0.0.1:8080")),
|
|
153
|
+
} as unknown as SymphonyRuntime["client"];
|
|
154
|
+
|
|
155
|
+
await expect(runtime.steerWorker("SIM-123", "Use auth")).resolves.toMatchObject({ delivered: true });
|
|
156
|
+
|
|
157
|
+
expect(runtime.client?.steer).toHaveBeenCalledWith("SIM-123", "Use auth", undefined);
|
|
158
|
+
expect(runtime.client?.getState).toHaveBeenCalledOnce();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it("returns steer result even if post-steer refresh fails", async () => {
|
|
162
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
163
|
+
const runtime = new SymphonyRuntime();
|
|
164
|
+
runtime.client = {
|
|
165
|
+
steer: vi.fn(async () => ({ ok: true, issueId: "issue-123", issueIdentifier: "SIM-123", delivered: true, instructionPreview: "Use auth" })),
|
|
166
|
+
getState: vi.fn(async () => {
|
|
167
|
+
throw new Error("temporary state fetch failure");
|
|
168
|
+
}),
|
|
169
|
+
toHealthSummary: vi.fn(() => lastKnownState("http://127.0.0.1:8080")),
|
|
170
|
+
} as unknown as SymphonyRuntime["client"];
|
|
171
|
+
|
|
172
|
+
await expect(runtime.steerWorker("SIM-123", "Use auth")).resolves.toMatchObject({ delivered: true });
|
|
173
|
+
|
|
174
|
+
expect(runtime.client?.steer).toHaveBeenCalledWith("SIM-123", "Use auth", undefined);
|
|
175
|
+
expect(runtime.client?.getState).toHaveBeenCalledOnce();
|
|
176
|
+
expect(warn).toHaveBeenCalledWith("Symphony state refresh failed after steer", expect.any(Error));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("responds to an escalation and refreshes state", async () => {
|
|
180
|
+
const runtime = new SymphonyRuntime();
|
|
181
|
+
const response = stateResponse();
|
|
182
|
+
const escalationResponse = { ok: true };
|
|
183
|
+
const escalationDecision = { approved: true };
|
|
184
|
+
runtime.client = {
|
|
185
|
+
respondEscalation: vi.fn(async () => escalationResponse),
|
|
186
|
+
getState: vi.fn(async () => response),
|
|
187
|
+
toHealthSummary: vi.fn(() => lastKnownState("http://127.0.0.1:8080")),
|
|
188
|
+
} as unknown as SymphonyRuntime["client"];
|
|
189
|
+
|
|
190
|
+
await expect(runtime.respondToEscalation("esc-1", escalationDecision)).resolves.toEqual({ ok: true });
|
|
191
|
+
|
|
192
|
+
expect(runtime.client?.respondEscalation).toHaveBeenCalledWith("esc-1", escalationDecision, "pi-dashboard", undefined);
|
|
193
|
+
expect(runtime.client?.getState).toHaveBeenCalledOnce();
|
|
194
|
+
expect(runtime.lastState).toBe(response);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("returns escalation response result even if post-response refresh fails", async () => {
|
|
198
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
|
199
|
+
const runtime = new SymphonyRuntime();
|
|
200
|
+
runtime.client = {
|
|
201
|
+
respondEscalation: vi.fn(async () => ({ ok: true })),
|
|
202
|
+
getState: vi.fn(async () => {
|
|
203
|
+
throw new Error("temporary state fetch failure");
|
|
204
|
+
}),
|
|
205
|
+
toHealthSummary: vi.fn(() => lastKnownState("http://127.0.0.1:8080")),
|
|
206
|
+
} as unknown as SymphonyRuntime["client"];
|
|
207
|
+
|
|
208
|
+
await expect(runtime.respondToEscalation("esc-1", "approved")).resolves.toEqual({ ok: true });
|
|
209
|
+
|
|
210
|
+
expect(runtime.client?.respondEscalation).toHaveBeenCalledWith("esc-1", "approved", "pi-dashboard", undefined);
|
|
211
|
+
expect(runtime.client?.getState).toHaveBeenCalledOnce();
|
|
212
|
+
expect(warn).toHaveBeenCalledWith("Symphony state refresh failed after escalation response", expect.any(Error));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("retains recent escalation lifecycle events", () => {
|
|
216
|
+
const runtime = new SymphonyRuntime();
|
|
217
|
+
|
|
218
|
+
runtime.recordEvent({ version: "v1", sequence: 1, timestamp: "2026-05-14T12:00:00Z", kind: "escalation_created", severity: "info", event: "escalation_created", payload: {} });
|
|
219
|
+
runtime.recordEvent({ version: "v1", sequence: 2, timestamp: "2026-05-14T12:00:01Z", kind: "escalation_responded", severity: "info", event: "escalation_responded", payload: {} });
|
|
220
|
+
runtime.recordEvent({ version: "v1", sequence: 3, timestamp: "2026-05-14T12:00:02Z", kind: "heartbeat", severity: "info", event: "heartbeat", payload: {} });
|
|
221
|
+
|
|
222
|
+
expect(runtime.recentEvents.map((event) => event.kind)).toEqual(["escalation_created", "escalation_responded"]);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("retains the most recent worker and runtime events", () => {
|
|
226
|
+
const runtime = new SymphonyRuntime();
|
|
227
|
+
|
|
228
|
+
runtime.recordEvent({ version: "v1", sequence: 1, timestamp: "2026-05-14T12:00:00Z", kind: "heartbeat", severity: "info", event: "heartbeat", payload: {} });
|
|
229
|
+
for (let sequence = 2; sequence <= 27; sequence += 1) {
|
|
230
|
+
runtime.recordEvent({ version: "v1", sequence, timestamp: `2026-05-14T12:00:${String(sequence).padStart(2, "0")}Z`, kind: sequence % 2 === 0 ? "worker" : "runtime", severity: "info", event: "event", payload: {} });
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
expect(runtime.recentEvents).toHaveLength(20);
|
|
234
|
+
expect(runtime.recentEvents[0]?.sequence).toBe(8);
|
|
235
|
+
expect(runtime.recentEvents.at(-1)?.sequence).toBe(27);
|
|
236
|
+
});
|
|
237
|
+
});
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { resolveSymphonyBinary } from "./binary-resolver.ts";
|
|
3
|
+
import { formatError, SymphonyExtensionError } from "./errors.ts";
|
|
4
|
+
import { SymphonyHttpClient, type EscalationRespondResponse, type SteerResponse, type SymphonyEventEnvelope, type SymphonyStateResponse } from "./http-client.ts";
|
|
5
|
+
import { SymphonyProcessManager } from "./process-manager.ts";
|
|
6
|
+
import {
|
|
7
|
+
createDefaultState,
|
|
8
|
+
restoreStateFromEntries,
|
|
9
|
+
snapshotStateForPersistence,
|
|
10
|
+
STATE_ENTRY_TYPE,
|
|
11
|
+
type ExtensionState,
|
|
12
|
+
} from "./state.ts";
|
|
13
|
+
|
|
14
|
+
export class SymphonyRuntime {
|
|
15
|
+
state: ExtensionState = createDefaultState();
|
|
16
|
+
processManager = new SymphonyProcessManager(this.state);
|
|
17
|
+
client?: SymphonyHttpClient;
|
|
18
|
+
lastState?: SymphonyStateResponse;
|
|
19
|
+
recentEvents: SymphonyEventEnvelope[] = [];
|
|
20
|
+
|
|
21
|
+
restore(ctx: ExtensionContext): void {
|
|
22
|
+
this.state = restoreStateFromEntries(ctx.sessionManager.getEntries());
|
|
23
|
+
this.processManager = new SymphonyProcessManager(this.state);
|
|
24
|
+
this.client = this.state.attachedBaseUrl ? new SymphonyHttpClient(this.state.attachedBaseUrl) : undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
persist(pi: { appendEntry: (customType: string, data?: unknown) => void }): void {
|
|
28
|
+
pi.appendEntry(STATE_ENTRY_TYPE, snapshotStateForPersistence(this.state));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async resolveBinary(ctx: ExtensionContext): Promise<string> {
|
|
32
|
+
return resolveSymphonyBinary({
|
|
33
|
+
cwd: ctx.cwd,
|
|
34
|
+
state: this.state,
|
|
35
|
+
promptForPath: ctx.hasUI
|
|
36
|
+
? async () => ctx.ui.input("Symphony binary", "Absolute path to symphony executable")
|
|
37
|
+
: async () => undefined,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async attach(baseUrl: string, signal?: AbortSignal): Promise<SymphonyStateResponse> {
|
|
42
|
+
const client = new SymphonyHttpClient(baseUrl);
|
|
43
|
+
const state = await client.verify(signal);
|
|
44
|
+
this.client = client;
|
|
45
|
+
this.lastState = state;
|
|
46
|
+
this.recentEvents = [];
|
|
47
|
+
this.state.attachedBaseUrl = client.baseUrl;
|
|
48
|
+
this.state.lastKnownState = client.toHealthSummary(state);
|
|
49
|
+
return state;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
clearAttachment(): void {
|
|
53
|
+
this.client = undefined;
|
|
54
|
+
this.state.attachedBaseUrl = undefined;
|
|
55
|
+
this.state.lastKnownState = undefined;
|
|
56
|
+
this.lastState = undefined;
|
|
57
|
+
this.recentEvents = [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
clearAttachmentIfBaseUrl(baseUrl: string | undefined): boolean {
|
|
61
|
+
if (!baseUrl || this.state.attachedBaseUrl !== baseUrl) return false;
|
|
62
|
+
this.clearAttachment();
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async refreshState(signal?: AbortSignal): Promise<SymphonyStateResponse> {
|
|
67
|
+
if (!this.client) throw new SymphonyExtensionError("no_attachment", "No Symphony server is attached");
|
|
68
|
+
const state = await this.client.getState(signal);
|
|
69
|
+
this.lastState = state;
|
|
70
|
+
this.state.lastKnownState = this.client.toHealthSummary(state);
|
|
71
|
+
return state;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async requestRefresh(signal?: AbortSignal): Promise<SymphonyStateResponse> {
|
|
75
|
+
if (!this.client) throw new SymphonyExtensionError("no_attachment", "No Symphony server is attached");
|
|
76
|
+
await this.client.refresh(signal);
|
|
77
|
+
return this.refreshState(signal);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async steerWorker(issueIdentifier: string, instruction: string, signal?: AbortSignal): Promise<SteerResponse> {
|
|
81
|
+
if (!this.client) throw new SymphonyExtensionError("no_attachment", "No Symphony server is attached");
|
|
82
|
+
const result = await this.client.steer(issueIdentifier, instruction, signal);
|
|
83
|
+
try {
|
|
84
|
+
await this.refreshState(signal);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.warn("Symphony state refresh failed after steer", error);
|
|
87
|
+
}
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async respondToEscalation(requestId: string, response: unknown, signal?: AbortSignal): Promise<EscalationRespondResponse> {
|
|
92
|
+
if (!this.client) throw new SymphonyExtensionError("no_attachment", "No Symphony server is attached");
|
|
93
|
+
const result = await this.client.respondEscalation(requestId, response, "pi-dashboard", signal);
|
|
94
|
+
try {
|
|
95
|
+
await this.refreshState(signal);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
console.warn("Symphony state refresh failed after escalation response", error);
|
|
98
|
+
}
|
|
99
|
+
return result;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
recordEvent(event: SymphonyEventEnvelope): void {
|
|
103
|
+
if (event.kind !== "worker" && event.kind !== "runtime" && !event.kind.startsWith("escalation_")) return;
|
|
104
|
+
this.recentEvents = [...this.recentEvents, event].slice(-20);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
statusText(): string {
|
|
108
|
+
const attached = this.state.attachedBaseUrl ? `attached: ${this.state.attachedBaseUrl}` : "attached: no";
|
|
109
|
+
const owned = this.state.ownedProcess ? `owned pid: ${this.state.ownedProcess.pid}` : "owned pid: none";
|
|
110
|
+
const last = this.state.lastKnownState
|
|
111
|
+
? `running ${this.state.lastKnownState.runningCount}, retry ${this.state.lastKnownState.retryCount}, blocked ${this.state.lastKnownState.blockedCount}, completed ${this.state.lastKnownState.completedCount}`
|
|
112
|
+
: "state: unknown";
|
|
113
|
+
return `Symphony status\n${attached}\n${owned}\n${last}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
errorText(error: unknown): string {
|
|
117
|
+
return formatError(error);
|
|
118
|
+
}
|
|
119
|
+
}
|