@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
package/src/tools.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Type } from "@earendil-works/pi-ai";
|
|
2
|
+
import { defineTool, type AgentToolUpdateCallback, type ExtensionAPI, type ToolExecutionMode } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { assertLoopbackAttachUrl, resolveAttachUrl } from "./attach-url-policy.ts";
|
|
4
|
+
import { setSymphonyStatus } from "./commands.ts";
|
|
5
|
+
import { formatError, SymphonyExtensionError } from "./errors.ts";
|
|
6
|
+
import type { SymphonyRuntime } from "./runtime.ts";
|
|
7
|
+
import { cleanupAbortedStart } from "./runtime-helpers.ts";
|
|
8
|
+
import { resolveStartWorkflow } from "./workflow-resolver.ts";
|
|
9
|
+
|
|
10
|
+
const SYMPHONY_TOOL_EXECUTION_MODE = "sequential" as ToolExecutionMode;
|
|
11
|
+
|
|
12
|
+
export function registerSymphonyTools(pi: ExtensionAPI, runtime: SymphonyRuntime): void {
|
|
13
|
+
pi.registerTool(defineTool({
|
|
14
|
+
name: "symphony_help",
|
|
15
|
+
label: "Symphony Help",
|
|
16
|
+
description: "Show Symphony Pi extension commands and tool capabilities.",
|
|
17
|
+
parameters: Type.Object({}),
|
|
18
|
+
executionMode: SYMPHONY_TOOL_EXECUTION_MODE,
|
|
19
|
+
async execute() {
|
|
20
|
+
return toolOk("Symphony tools: symphony_init, symphony_doctor, symphony_start, symphony_attach, symphony_status, symphony_refresh, symphony_steer, symphony_stop, symphony_help", {
|
|
21
|
+
attachedBaseUrl: runtime.state.attachedBaseUrl,
|
|
22
|
+
ownedProcess: runtime.state.ownedProcess,
|
|
23
|
+
});
|
|
24
|
+
},
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
pi.registerTool(defineTool({
|
|
28
|
+
name: "symphony_init",
|
|
29
|
+
label: "Symphony Init",
|
|
30
|
+
description: "Run symphony init in Pi's current working directory.",
|
|
31
|
+
parameters: Type.Object({ force: Type.Optional(Type.Boolean()) }),
|
|
32
|
+
executionMode: SYMPHONY_TOOL_EXECUTION_MODE,
|
|
33
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
34
|
+
try {
|
|
35
|
+
updateProgress(onUpdate, "Initializing Symphony...");
|
|
36
|
+
const binary = await runtime.resolveBinary(ctx);
|
|
37
|
+
const result = await pi.exec(binary, params.force ? ["init", "--force"] : ["init"], { cwd: ctx.cwd, signal });
|
|
38
|
+
if (result.code !== 0) throw new SymphonyExtensionError("command_failed", "symphony init failed", { cwd: ctx.cwd, code: result.code, stderr: result.stderr });
|
|
39
|
+
runtime.persist(pi);
|
|
40
|
+
return toolOk(result.stdout.trim() || "symphony init completed", { code: result.code, cwd: ctx.cwd });
|
|
41
|
+
} catch (error) {
|
|
42
|
+
throw new Error(formatError(error));
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
}));
|
|
46
|
+
|
|
47
|
+
pi.registerTool(defineTool({
|
|
48
|
+
name: "symphony_doctor",
|
|
49
|
+
label: "Symphony Doctor",
|
|
50
|
+
description: "Run symphony doctor with an optional workflow path.",
|
|
51
|
+
parameters: Type.Object({ workflow: Type.Optional(Type.String()) }),
|
|
52
|
+
executionMode: SYMPHONY_TOOL_EXECUTION_MODE,
|
|
53
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
54
|
+
try {
|
|
55
|
+
updateProgress(onUpdate, "Running Symphony doctor...");
|
|
56
|
+
const binary = await runtime.resolveBinary(ctx);
|
|
57
|
+
const args = params.workflow ? ["doctor", params.workflow] : ["doctor"];
|
|
58
|
+
const result = await pi.exec(binary, args, { cwd: ctx.cwd, signal });
|
|
59
|
+
if (result.code !== 0) throw new SymphonyExtensionError("command_failed", "symphony doctor failed", { cwd: ctx.cwd, code: result.code, stderr: result.stderr });
|
|
60
|
+
runtime.persist(pi);
|
|
61
|
+
return toolOk(result.stdout.trim() || "symphony doctor completed", { code: result.code, cwd: ctx.cwd });
|
|
62
|
+
} catch (error) {
|
|
63
|
+
throw new Error(formatError(error));
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
}));
|
|
67
|
+
|
|
68
|
+
pi.registerTool(defineTool({
|
|
69
|
+
name: "symphony_start",
|
|
70
|
+
label: "Symphony Start",
|
|
71
|
+
description: "Start Symphony headlessly from Pi's current working directory and attach to its HTTP API.",
|
|
72
|
+
parameters: Type.Object({ workflow: Type.Optional(Type.String()) }),
|
|
73
|
+
executionMode: SYMPHONY_TOOL_EXECUTION_MODE,
|
|
74
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
75
|
+
let startedBaseUrl: string | undefined;
|
|
76
|
+
try {
|
|
77
|
+
updateProgress(onUpdate, "Starting Symphony...");
|
|
78
|
+
const workflow = await resolveStartWorkflow(ctx.cwd, params.workflow);
|
|
79
|
+
const binary = await runtime.resolveBinary(ctx);
|
|
80
|
+
const started = await runtime.processManager.start({ binary, cwd: ctx.cwd, workflow, signal });
|
|
81
|
+
startedBaseUrl = started.baseUrl;
|
|
82
|
+
await runtime.attach(started.baseUrl, signal);
|
|
83
|
+
runtime.persist(pi);
|
|
84
|
+
setSymphonyStatus(ctx, runtime);
|
|
85
|
+
return toolOk(`Symphony started at ${started.baseUrl}`, { ...started, state: runtime.state.lastKnownState });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (signal?.aborted && startedBaseUrl) await cleanupAbortedStart(runtime, startedBaseUrl);
|
|
88
|
+
throw new Error(formatError(error));
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
pi.registerTool(defineTool({
|
|
94
|
+
name: "symphony_attach",
|
|
95
|
+
label: "Symphony Attach",
|
|
96
|
+
description: "Attach to an existing Symphony HTTP server after verifying GET /api/v1/state, or to the Pi-owned server when no URL is provided.",
|
|
97
|
+
parameters: Type.Object({ url: Type.Optional(Type.String()) }),
|
|
98
|
+
executionMode: SYMPHONY_TOOL_EXECUTION_MODE,
|
|
99
|
+
async execute(_id, params, signal, onUpdate, ctx) {
|
|
100
|
+
try {
|
|
101
|
+
updateProgress(onUpdate, "Attaching to Symphony...");
|
|
102
|
+
const url = resolveAttachUrl(params.url, runtime.state.ownedProcess);
|
|
103
|
+
assertLoopbackAttachUrl(url);
|
|
104
|
+
await runtime.attach(url, signal);
|
|
105
|
+
runtime.persist(pi);
|
|
106
|
+
setSymphonyStatus(ctx, runtime);
|
|
107
|
+
return toolOk(`Attached to Symphony at ${runtime.state.attachedBaseUrl}`, { state: runtime.state.lastKnownState });
|
|
108
|
+
} catch (error) {
|
|
109
|
+
throw new Error(formatError(error));
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
}));
|
|
113
|
+
|
|
114
|
+
pi.registerTool(defineTool({
|
|
115
|
+
name: "symphony_status",
|
|
116
|
+
label: "Symphony Status",
|
|
117
|
+
description: "Return current Symphony attachment, process, and health summary.",
|
|
118
|
+
parameters: Type.Object({}),
|
|
119
|
+
executionMode: SYMPHONY_TOOL_EXECUTION_MODE,
|
|
120
|
+
async execute(_id, _params, signal) {
|
|
121
|
+
try {
|
|
122
|
+
if (runtime.client) await runtime.refreshState(signal);
|
|
123
|
+
runtime.persist(pi);
|
|
124
|
+
return toolOk(runtime.statusText(), { state: runtime.state });
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(formatError(error));
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
pi.registerTool(defineTool({
|
|
132
|
+
name: "symphony_refresh",
|
|
133
|
+
label: "Symphony Refresh",
|
|
134
|
+
description: "Request an immediate Symphony poll refresh and return the updated health summary.",
|
|
135
|
+
parameters: Type.Object({}),
|
|
136
|
+
executionMode: SYMPHONY_TOOL_EXECUTION_MODE,
|
|
137
|
+
async execute(_id, _params, signal, onUpdate) {
|
|
138
|
+
try {
|
|
139
|
+
updateProgress(onUpdate, "Refreshing Symphony...");
|
|
140
|
+
await runtime.requestRefresh(signal);
|
|
141
|
+
runtime.persist(pi);
|
|
142
|
+
return toolOk("Symphony refresh requested", { state: runtime.state.lastKnownState });
|
|
143
|
+
} catch (error) {
|
|
144
|
+
throw new Error(formatError(error));
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
}));
|
|
148
|
+
|
|
149
|
+
pi.registerTool(defineTool({
|
|
150
|
+
name: "symphony_steer",
|
|
151
|
+
label: "Symphony Steer",
|
|
152
|
+
description: "Send an operator instruction to a running Symphony worker.",
|
|
153
|
+
parameters: Type.Object({ issueIdentifier: Type.String(), instruction: Type.String() }),
|
|
154
|
+
executionMode: SYMPHONY_TOOL_EXECUTION_MODE,
|
|
155
|
+
async execute(_id, params, signal, onUpdate) {
|
|
156
|
+
try {
|
|
157
|
+
updateProgress(onUpdate, "Sending steer instruction...");
|
|
158
|
+
const result = await runtime.steerWorker(params.issueIdentifier, params.instruction, signal);
|
|
159
|
+
runtime.persist(pi);
|
|
160
|
+
return toolOk(`Steer delivered to ${result.issueIdentifier}: ${result.instructionPreview}`, { result, state: runtime.state.lastKnownState });
|
|
161
|
+
} catch (error) {
|
|
162
|
+
throw new Error(formatError(error));
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
pi.registerTool(defineTool({
|
|
168
|
+
name: "symphony_stop",
|
|
169
|
+
label: "Symphony Stop",
|
|
170
|
+
description: "Stop only a Symphony process started by this Pi extension.",
|
|
171
|
+
parameters: Type.Object({}),
|
|
172
|
+
executionMode: SYMPHONY_TOOL_EXECUTION_MODE,
|
|
173
|
+
async execute(_id, _params, _signal, onUpdate, ctx) {
|
|
174
|
+
try {
|
|
175
|
+
updateProgress(onUpdate, "Stopping Symphony...");
|
|
176
|
+
const ownedBaseUrl = runtime.state.ownedProcess?.baseUrl;
|
|
177
|
+
await runtime.processManager.stopOwned();
|
|
178
|
+
runtime.clearAttachmentIfBaseUrl(ownedBaseUrl);
|
|
179
|
+
runtime.persist(pi);
|
|
180
|
+
setSymphonyStatus(ctx, runtime);
|
|
181
|
+
return toolOk("Stopped owned Symphony process", { ownedProcess: runtime.state.ownedProcess });
|
|
182
|
+
} catch (error) {
|
|
183
|
+
throw new Error(formatError(error));
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
}));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function updateProgress(onUpdate: AgentToolUpdateCallback | undefined, text: string): void {
|
|
190
|
+
onUpdate?.({
|
|
191
|
+
content: [{ type: "text" as const, text }],
|
|
192
|
+
details: { status: "working" },
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function toolOk(text: string, details: Record<string, unknown>) {
|
|
197
|
+
return {
|
|
198
|
+
content: [{ type: "text" as const, text }],
|
|
199
|
+
details,
|
|
200
|
+
};
|
|
201
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
|
|
2
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
3
|
+
|
|
4
|
+
let child: ChildProcessWithoutNullStreams | undefined;
|
|
5
|
+
|
|
6
|
+
afterEach(() => {
|
|
7
|
+
child?.kill();
|
|
8
|
+
child = undefined;
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
describe("wave3 mock server script", () => {
|
|
12
|
+
it("serves Wave 3 state and accepts escalation responses", async () => {
|
|
13
|
+
const baseUrl = await startMockServer();
|
|
14
|
+
|
|
15
|
+
const stateResponse = await fetch(`${baseUrl}/api/v1/state`);
|
|
16
|
+
const state = await stateResponse.json() as Record<string, unknown>;
|
|
17
|
+
expect(stateResponse.status).toBe(200);
|
|
18
|
+
expect(state).toMatchObject({
|
|
19
|
+
retry_queue: [expect.objectContaining({ identifier: "SIM-200", due_in_ms: 90000 })],
|
|
20
|
+
blocked: [expect.objectContaining({ identifier: "SIM-300", blocker_identifiers: ["SIM-100", "SIM-101"] })],
|
|
21
|
+
completed: [expect.objectContaining({ identifier: "SIM-400", completed_at: "2026-05-14T13:00:00Z" })],
|
|
22
|
+
pending_escalations: [expect.objectContaining({ request_id: "esc-1", preview: "Approve cargo test?" })],
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const escalationResponse = await fetch(`${baseUrl}/api/v1/escalations/esc-1/respond`, {
|
|
26
|
+
method: "POST",
|
|
27
|
+
headers: { "content-type": "application/json" },
|
|
28
|
+
body: JSON.stringify({ response: { approved: true }, responder_id: "pi-dashboard" }),
|
|
29
|
+
});
|
|
30
|
+
await expect(escalationResponse.json()).resolves.toEqual({ ok: true });
|
|
31
|
+
|
|
32
|
+
const afterResponse = await fetch(`${baseUrl}/api/v1/escalations`);
|
|
33
|
+
await expect(afterResponse.json()).resolves.toEqual({ pending: [] });
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns 400 for malformed escalation IDs without crashing", async () => {
|
|
37
|
+
const baseUrl = await startMockServer();
|
|
38
|
+
|
|
39
|
+
const response = await fetch(`${baseUrl}/api/v1/escalations/%E0%A4%A/respond`, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: { "content-type": "application/json" },
|
|
42
|
+
body: JSON.stringify({ response: { approved: true }, responder_id: "pi-dashboard" }),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(response.status).toBe(400);
|
|
46
|
+
await expect(response.json()).resolves.toEqual({ error: "invalid_escalation_id" });
|
|
47
|
+
|
|
48
|
+
const stateResponse = await fetch(`${baseUrl}/api/v1/state`);
|
|
49
|
+
expect(stateResponse.status).toBe(200);
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
async function startMockServer(): Promise<string> {
|
|
54
|
+
child = spawn(process.execPath, ["scripts/wave3-mock-server.mjs", "--port", "0"], {
|
|
55
|
+
cwd: new URL("..", import.meta.url),
|
|
56
|
+
env: { ...process.env, NO_COLOR: "1" },
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let stderr = "";
|
|
60
|
+
let stdout = "";
|
|
61
|
+
child.stderr.on("data", (chunk) => {
|
|
62
|
+
stderr += String(chunk);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
return await new Promise((resolve, reject) => {
|
|
66
|
+
const timeout = setTimeout(() => {
|
|
67
|
+
reject(new Error(`mock server did not start. stderr: ${stderr}`));
|
|
68
|
+
}, 5000);
|
|
69
|
+
|
|
70
|
+
child?.stdout.on("data", (chunk) => {
|
|
71
|
+
stdout = (stdout + String(chunk)).slice(-2000);
|
|
72
|
+
const match = stdout.match(/Mock Symphony server: (http:\/\/[^\s]+)/);
|
|
73
|
+
if (!match) return;
|
|
74
|
+
clearTimeout(timeout);
|
|
75
|
+
resolve(match[1]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
child?.on("exit", (code) => {
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
reject(new Error(`mock server exited with code ${code}. stderr: ${stderr}`));
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { access } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { SymphonyExtensionError } from "./errors.ts";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_SYMPHONY_WORKFLOW = ".symphony/WORKFLOW.md";
|
|
6
|
+
const LEGACY_SYMPHONY_WORKFLOW = "WORKFLOW.md";
|
|
7
|
+
|
|
8
|
+
export async function resolveStartWorkflow(cwd: string, workflow: string | undefined): Promise<string> {
|
|
9
|
+
const explicitWorkflow = workflow?.trim();
|
|
10
|
+
if (explicitWorkflow) {
|
|
11
|
+
await assertWorkflowExists(cwd, explicitWorkflow);
|
|
12
|
+
return explicitWorkflow;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
for (const defaultWorkflow of [DEFAULT_SYMPHONY_WORKFLOW, LEGACY_SYMPHONY_WORKFLOW]) {
|
|
16
|
+
if (await workflowExists(cwd, defaultWorkflow)) return defaultWorkflow;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
throw new SymphonyExtensionError("missing_workflow", `Symphony workflow file not found: ${DEFAULT_SYMPHONY_WORKFLOW} or ${LEGACY_SYMPHONY_WORKFLOW}. Run /symphony:init first or pass a workflow path to /symphony:start <workflow>.`, {
|
|
20
|
+
cwd,
|
|
21
|
+
workflow: DEFAULT_SYMPHONY_WORKFLOW,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function assertWorkflowExists(cwd: string, workflow: string): Promise<void> {
|
|
26
|
+
if (await workflowExists(cwd, workflow)) return;
|
|
27
|
+
throw new SymphonyExtensionError("missing_workflow", `Symphony workflow file not found: ${workflow}. Run /symphony:init first or pass a workflow path to /symphony:start <workflow>.`, {
|
|
28
|
+
cwd,
|
|
29
|
+
workflow,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function workflowExists(cwd: string, workflow: string): Promise<boolean> {
|
|
34
|
+
try {
|
|
35
|
+
await access(resolve(cwd, workflow));
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|