@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/README.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
# @kata-sh/pi-symphony-extension
|
|
2
|
+
|
|
3
|
+
Pi extension for initializing, launching, attaching to, and monitoring Kata Symphony.
|
|
4
|
+
|
|
5
|
+
## Local development
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
pnpm --dir apps/symphony/pi-extension test
|
|
9
|
+
pnpm --dir apps/symphony/pi-extension typecheck
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Run it locally with `pi -e ./apps/symphony/pi-extension`.
|
|
13
|
+
|
|
14
|
+
## Commands through Wave 3
|
|
15
|
+
|
|
16
|
+
- `/symphony:help`
|
|
17
|
+
- `/symphony:init [--force]`
|
|
18
|
+
- `/symphony:doctor [workflow]`
|
|
19
|
+
- `/symphony:start [workflow]`
|
|
20
|
+
- `/symphony:attach [url]`
|
|
21
|
+
- `/symphony:detach`
|
|
22
|
+
- `/symphony:console`
|
|
23
|
+
- `/symphony:status`
|
|
24
|
+
- `/symphony:refresh`
|
|
25
|
+
- `/symphony:steer <ISSUE> <instruction>`
|
|
26
|
+
- `/symphony:stop`
|
|
27
|
+
|
|
28
|
+
## Console keys through Wave 3
|
|
29
|
+
|
|
30
|
+
- `↑` / `↓` selects running workers, retry entries, blocked issues, completed issues, and pending escalations.
|
|
31
|
+
- `r` requests an immediate Symphony refresh and reloads state.
|
|
32
|
+
- `s` prompts for a steer instruction when the selected item is a running worker.
|
|
33
|
+
- `e` prompts for a response when the selected item is a pending escalation. Valid JSON is sent as JSON; other input is sent as a string response.
|
|
34
|
+
- `d` toggles selected-item details.
|
|
35
|
+
- `q` or Escape closes the console and leaves Symphony running.
|
|
36
|
+
|
|
37
|
+
## Progress feedback
|
|
38
|
+
|
|
39
|
+
Commands that can take a few seconds show Pi-native progress feedback while they run:
|
|
40
|
+
|
|
41
|
+
- `/symphony:refresh`, `/symphony:attach [url]`, and `/symphony:stop` use inline working text plus the Symphony footer status.
|
|
42
|
+
- `/symphony:start`, `/symphony:init`, and `/symphony:doctor` use a blocking loader panel.
|
|
43
|
+
- Symphony tools emit partial progress updates before returning their final result.
|
|
44
|
+
|
|
45
|
+
## Slice 1 verification
|
|
46
|
+
|
|
47
|
+
Verified commands:
|
|
48
|
+
|
|
49
|
+
```text
|
|
50
|
+
/symphony:help
|
|
51
|
+
/symphony:status
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Package checks:
|
|
55
|
+
|
|
56
|
+
```sh
|
|
57
|
+
pnpm --dir apps/symphony/pi-extension test
|
|
58
|
+
pnpm --dir apps/symphony/pi-extension typecheck
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Wave 3 manual verification
|
|
62
|
+
|
|
63
|
+
1. Start Pi with the local extension:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
pi -e ./apps/symphony/pi-extension
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Expected: Pi starts with Symphony commands available.
|
|
70
|
+
|
|
71
|
+
2. Start or attach to a Symphony server:
|
|
72
|
+
|
|
73
|
+
Start path:
|
|
74
|
+
|
|
75
|
+
```text
|
|
76
|
+
/symphony:start .symphony/WORKFLOW.md
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Expected: Symphony starts, the console opens, and the status block shows the attached base URL.
|
|
80
|
+
|
|
81
|
+
Attach path:
|
|
82
|
+
|
|
83
|
+
```text
|
|
84
|
+
/symphony:attach http://127.0.0.1:<port>
|
|
85
|
+
/symphony:console
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
Expected: attach updates the Symphony attachment, then `/symphony:console` opens the console with the attached base URL in the status block.
|
|
89
|
+
|
|
90
|
+
3. Create or use a Symphony state with at least one retry entry, blocked issue, completed issue, and pending escalation.
|
|
91
|
+
|
|
92
|
+
For deterministic local testing, start the Wave 3 mock server in another terminal:
|
|
93
|
+
|
|
94
|
+
```sh
|
|
95
|
+
pnpm --dir apps/symphony/pi-extension run mock:wave3
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Then attach Pi to the printed URL:
|
|
99
|
+
|
|
100
|
+
```text
|
|
101
|
+
/symphony:attach http://127.0.0.1:8787
|
|
102
|
+
/symphony:console
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Expected: the console shows `Retry Queue`, `Blocked Issues`, `Completed Issues`, and `Pending Escalations` sections.
|
|
106
|
+
|
|
107
|
+
4. Use `↑` / `↓` to select each Wave 3 item type.
|
|
108
|
+
|
|
109
|
+
Expected: the detail panel shows running, retry, blocked, completed, and escalation-specific fields.
|
|
110
|
+
|
|
111
|
+
5. Select a pending escalation, press `e`, and enter `{"approved":true}`.
|
|
112
|
+
|
|
113
|
+
Expected: Pi reports `Escalation response sent for <request_id>` and the console refreshes.
|
|
114
|
+
|
|
115
|
+
6. Watch recent events after escalation creation and response.
|
|
116
|
+
|
|
117
|
+
Expected with a real or event-capable Symphony server: escalation lifecycle events appear in the `Events` section. The Wave 3 mock server supports state, refresh, steer, and escalation response checks, but it does not expose `/api/v1/events`.
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kata-sh/pi-symphony-extension",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension for launching, attaching to, and monitoring Kata Symphony",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"private": false,
|
|
7
|
+
"type": "module",
|
|
8
|
+
"keywords": ["pi-package", "symphony", "kata"],
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "git+https://github.com/gannonh/kata.git",
|
|
12
|
+
"directory": "apps/symphony/pi-extension"
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.ts",
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": ["src", "scripts", "README.md", "package.json"],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsc --noEmit",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "pnpm exec vitest run",
|
|
23
|
+
"test:watch": "pnpm exec vitest",
|
|
24
|
+
"mock:wave3": "node scripts/wave3-mock-server.mjs",
|
|
25
|
+
"lint": "eslint src/ --max-warnings=0"
|
|
26
|
+
},
|
|
27
|
+
"pi": {
|
|
28
|
+
"extensions": ["./src/index.ts"]
|
|
29
|
+
},
|
|
30
|
+
"peerDependencies": {
|
|
31
|
+
"@earendil-works/pi-ai": "*",
|
|
32
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
33
|
+
"@earendil-works/pi-tui": "*",
|
|
34
|
+
"typebox": "*"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@types/node": "^25.5.0",
|
|
38
|
+
"@types/ws": "^8.18.1",
|
|
39
|
+
"typescript": "^6.0.3",
|
|
40
|
+
"vitest": "^4.1.5"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=20.6.0"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"ws": "^8.20.1"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { createServer } from "node:http";
|
|
3
|
+
|
|
4
|
+
const options = parseArgs(process.argv.slice(2));
|
|
5
|
+
if (options.help) {
|
|
6
|
+
printHelp();
|
|
7
|
+
process.exit(0);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const state = createInitialState();
|
|
11
|
+
const responses = [];
|
|
12
|
+
|
|
13
|
+
const server = createServer((req, res) => {
|
|
14
|
+
let body = "";
|
|
15
|
+
req.on("data", (chunk) => {
|
|
16
|
+
body += String(chunk);
|
|
17
|
+
});
|
|
18
|
+
req.on("end", () => {
|
|
19
|
+
handleRequest(req, res, body);
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
server.listen(options.port, options.host, () => {
|
|
24
|
+
const address = server.address();
|
|
25
|
+
if (!address || typeof address === "string") {
|
|
26
|
+
throw new Error("Expected TCP server address");
|
|
27
|
+
}
|
|
28
|
+
const baseUrl = `http://${options.host}:${address.port}`;
|
|
29
|
+
console.log(`Mock Symphony server: ${baseUrl}`);
|
|
30
|
+
console.log("");
|
|
31
|
+
console.log("Attach from Pi:");
|
|
32
|
+
console.log(`/symphony:attach ${baseUrl}`);
|
|
33
|
+
console.log("/symphony:console");
|
|
34
|
+
console.log("");
|
|
35
|
+
console.log("Seeded state: running SIM-123, retry SIM-200, blocked SIM-300, completed SIM-400, escalation esc-1");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
process.on("SIGINT", shutdown);
|
|
39
|
+
process.on("SIGTERM", shutdown);
|
|
40
|
+
|
|
41
|
+
function handleRequest(req, res, body) {
|
|
42
|
+
setJson(res);
|
|
43
|
+
|
|
44
|
+
if (req.method === "GET" && req.url === "/api/v1/state") {
|
|
45
|
+
res.end(JSON.stringify(state));
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (req.method === "GET" && req.url === "/api/v1/escalations") {
|
|
50
|
+
res.end(JSON.stringify({ pending: state.pending_escalations }));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (req.method === "POST" && req.url === "/api/v1/refresh") {
|
|
55
|
+
res.statusCode = 202;
|
|
56
|
+
res.end(JSON.stringify({ queued: true, coalesced: false, pending_requests: 1 }));
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (req.method === "POST" && req.url === "/api/v1/steer") {
|
|
61
|
+
const payload = parseJsonBody(body);
|
|
62
|
+
const instruction = typeof payload?.instruction === "string" ? payload.instruction : "";
|
|
63
|
+
res.end(JSON.stringify({
|
|
64
|
+
ok: true,
|
|
65
|
+
issue_id: "issue-123",
|
|
66
|
+
issue_identifier: "SIM-123",
|
|
67
|
+
delivered: true,
|
|
68
|
+
instruction_preview: instruction.slice(0, 120),
|
|
69
|
+
}));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const escalationMatch = req.url?.match(/^\/api\/v1\/escalations\/([^/]+)\/respond$/);
|
|
74
|
+
if (req.method === "POST" && escalationMatch) {
|
|
75
|
+
let requestId;
|
|
76
|
+
try {
|
|
77
|
+
requestId = decodeURIComponent(escalationMatch[1]);
|
|
78
|
+
} catch {
|
|
79
|
+
res.statusCode = 400;
|
|
80
|
+
res.end(JSON.stringify({ error: "invalid_escalation_id" }));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const escalation = state.pending_escalations.find((entry) => entry.request_id === requestId);
|
|
84
|
+
if (!escalation) {
|
|
85
|
+
res.statusCode = 404;
|
|
86
|
+
res.end(JSON.stringify({ error: "escalation_not_found" }));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
responses.push({ request_id: requestId, body: parseJsonBody(body), received_at: new Date().toISOString() });
|
|
91
|
+
state.pending_escalations = state.pending_escalations.filter((entry) => entry.request_id !== requestId);
|
|
92
|
+
state.supervisor.escalations_created = state.pending_escalations.length;
|
|
93
|
+
res.end(JSON.stringify({ ok: true }));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (req.method === "GET" && req.url === "/debug/responses") {
|
|
98
|
+
res.end(JSON.stringify({ responses }));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
res.statusCode = 404;
|
|
103
|
+
res.end(JSON.stringify({ error: "not_found" }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function createInitialState() {
|
|
107
|
+
return {
|
|
108
|
+
poll_interval_ms: 30000,
|
|
109
|
+
max_concurrent_agents: 2,
|
|
110
|
+
tracker_project_url: "https://linear.app/kata-sh/project/symphony",
|
|
111
|
+
running: {
|
|
112
|
+
"issue-123": {
|
|
113
|
+
issue_id: "issue-123",
|
|
114
|
+
issue_identifier: "SIM-123",
|
|
115
|
+
issue_title: "Running worker",
|
|
116
|
+
attempt: 1,
|
|
117
|
+
workspace_path: "/tmp/symphony/issue-123",
|
|
118
|
+
started_at: "2026-05-14T12:00:00Z",
|
|
119
|
+
status: "running",
|
|
120
|
+
tracker_state: "In Progress",
|
|
121
|
+
worker_host: "local",
|
|
122
|
+
model: "test-model",
|
|
123
|
+
issue_url: "https://linear.app/kata-sh/issue/SIM-123/running-worker",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
running_sessions: {
|
|
127
|
+
"issue-123": {
|
|
128
|
+
turn_count: 3,
|
|
129
|
+
last_activity_at: "2026-05-14T12:04:00Z",
|
|
130
|
+
total_tokens: 1200,
|
|
131
|
+
last_event: "tool_call_completed",
|
|
132
|
+
last_event_message: "running cargo test",
|
|
133
|
+
session_id: "session-123",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
running_session_info: {
|
|
137
|
+
"issue-123": {
|
|
138
|
+
turn_count: 3,
|
|
139
|
+
max_turns: 20,
|
|
140
|
+
last_activity_ms: Date.parse("2026-05-14T12:04:00Z"),
|
|
141
|
+
session_tokens: { input_tokens: 800, output_tokens: 400, total_tokens: 1200 },
|
|
142
|
+
last_error: null,
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
claimed: [],
|
|
146
|
+
retry_queue: [
|
|
147
|
+
{
|
|
148
|
+
issue_id: "issue-retry",
|
|
149
|
+
identifier: "SIM-200",
|
|
150
|
+
attempt: 3,
|
|
151
|
+
due_in_ms: 90000,
|
|
152
|
+
error: "rate limit",
|
|
153
|
+
worker_host: "host-b",
|
|
154
|
+
workspace_path: "/tmp/retry",
|
|
155
|
+
},
|
|
156
|
+
],
|
|
157
|
+
blocked: [
|
|
158
|
+
{
|
|
159
|
+
issue_id: "issue-blocked",
|
|
160
|
+
identifier: "SIM-300",
|
|
161
|
+
title: "Blocked work",
|
|
162
|
+
state: "Todo",
|
|
163
|
+
blocker_identifiers: ["SIM-100", "SIM-101"],
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
completed: [
|
|
167
|
+
{
|
|
168
|
+
issue_id: "issue-done",
|
|
169
|
+
identifier: "SIM-400",
|
|
170
|
+
title: "Done work",
|
|
171
|
+
completed_at: "2026-05-14T13:00:00Z",
|
|
172
|
+
},
|
|
173
|
+
],
|
|
174
|
+
pending_escalations: [
|
|
175
|
+
{
|
|
176
|
+
request_id: "esc-1",
|
|
177
|
+
issue_id: "issue-123",
|
|
178
|
+
issue_identifier: "SIM-123",
|
|
179
|
+
method: "approval",
|
|
180
|
+
preview: "Approve cargo test?",
|
|
181
|
+
created_at: "2026-05-14T12:06:00Z",
|
|
182
|
+
timeout_ms: 600000,
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
shared_context: { total_entries: 0, entries_by_scope: {}, oldest_entry_at: null, newest_entry_at: null },
|
|
186
|
+
supervisor: { active: true, steers_issued: 0, conflicts_detected: 0, patterns_detected: 0, escalations_created: 1 },
|
|
187
|
+
codex_totals: { input_tokens: 800, output_tokens: 400, total_tokens: 1200, event_count: 2, seconds_running: 60 },
|
|
188
|
+
codex_rate_limits: null,
|
|
189
|
+
polling: { checking: false, next_poll_in_ms: 1000, poll_interval_ms: 30000, poll_count: 1, last_poll_at: "2026-05-14T12:05:00Z" },
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parseArgs(args) {
|
|
194
|
+
const parsed = { host: "127.0.0.1", port: 8787, help: false };
|
|
195
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
196
|
+
const arg = args[index];
|
|
197
|
+
if (arg === "--") {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (arg === "--help" || arg === "-h") {
|
|
201
|
+
parsed.help = true;
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
if (arg === "--host") {
|
|
205
|
+
parsed.host = readValue(args, index, arg);
|
|
206
|
+
index += 1;
|
|
207
|
+
continue;
|
|
208
|
+
}
|
|
209
|
+
if (arg === "--port") {
|
|
210
|
+
const rawPort = readValue(args, index, arg);
|
|
211
|
+
const port = Number(rawPort);
|
|
212
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
213
|
+
throw new Error(`Invalid --port value: ${rawPort}`);
|
|
214
|
+
}
|
|
215
|
+
parsed.port = port;
|
|
216
|
+
index += 1;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
throw new Error(`Unknown argument: ${arg}`);
|
|
220
|
+
}
|
|
221
|
+
return parsed;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function readValue(args, index, flag) {
|
|
225
|
+
const value = args[index + 1];
|
|
226
|
+
if (!value) throw new Error(`${flag} requires a value`);
|
|
227
|
+
return value;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function parseJsonBody(body) {
|
|
231
|
+
if (!body.trim()) return null;
|
|
232
|
+
try {
|
|
233
|
+
return JSON.parse(body);
|
|
234
|
+
} catch {
|
|
235
|
+
return { raw: body };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function setJson(res) {
|
|
240
|
+
res.setHeader("content-type", "application/json");
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function shutdown() {
|
|
244
|
+
server.close(() => process.exit(0));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function printHelp() {
|
|
248
|
+
console.log(`Usage: node apps/symphony/pi-extension/scripts/wave3-mock-server.mjs [--host 127.0.0.1] [--port 8787]\n\nStarts a local mock Symphony HTTP API seeded with Wave 3 dashboard state.\n\nEndpoints:\n GET /api/v1/state\n GET /api/v1/escalations\n POST /api/v1/escalations/esc-1/respond\n POST /api/v1/refresh\n POST /api/v1/steer\n\nAttach from Pi with /symphony:attach http://127.0.0.1:<port>.`);
|
|
249
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { resolveAttachUrl } from "./attach-url-policy.ts";
|
|
3
|
+
|
|
4
|
+
const ownedProcess = {
|
|
5
|
+
pid: 123,
|
|
6
|
+
command: "symphony --no-tui",
|
|
7
|
+
cwd: "/repo",
|
|
8
|
+
baseUrl: "http://127.0.0.1:8080",
|
|
9
|
+
startedAt: "2026-05-14T00:00:00.000Z",
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
describe("resolveAttachUrl", () => {
|
|
13
|
+
it("treats whitespace-only URLs as absent so owned process fallback is preserved", () => {
|
|
14
|
+
expect(resolveAttachUrl(" ", ownedProcess)).toBe("http://127.0.0.1:8080");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("returns trimmed explicit URLs", () => {
|
|
18
|
+
expect(resolveAttachUrl(" http://localhost:8080 ", ownedProcess)).toBe("http://localhost:8080");
|
|
19
|
+
});
|
|
20
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { SymphonyExtensionError } from "./errors.ts";
|
|
2
|
+
import type { OwnedProcessMetadata } from "./state.ts";
|
|
3
|
+
|
|
4
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1"]);
|
|
5
|
+
|
|
6
|
+
export function resolveAttachUrl(url: string | undefined, ownedProcess: OwnedProcessMetadata | undefined): string {
|
|
7
|
+
const trimmedUrl = url?.trim();
|
|
8
|
+
if (trimmedUrl) return trimmedUrl;
|
|
9
|
+
if (ownedProcess?.baseUrl) return ownedProcess.baseUrl;
|
|
10
|
+
throw new SymphonyExtensionError(
|
|
11
|
+
"no_attachment",
|
|
12
|
+
"No Symphony URL provided and no Pi-owned Symphony server is running. Use /symphony:start or /symphony:attach <url>.",
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function assertLoopbackAttachUrl(url: string): void {
|
|
17
|
+
const parsed = new URL(url.trim());
|
|
18
|
+
const hostname = parsed.hostname.replace(/^\[|\]$/g, "");
|
|
19
|
+
if ((parsed.protocol === "http:" || parsed.protocol === "https:") && LOOPBACK_HOSTS.has(hostname)) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
throw new Error("Symphony attach URL must use http or https on a loopback host: 127.0.0.1, localhost, or ::1");
|
|
24
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { chmod, mkdir, mkdtemp, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { describe, expect, it } from "vitest";
|
|
5
|
+
import { resolveSymphonyBinary, validateBinaryPath } from "./binary-resolver.ts";
|
|
6
|
+
import { createDefaultState } from "./state.ts";
|
|
7
|
+
|
|
8
|
+
const symphonyBinaryName = process.platform === "win32" ? "symphony.exe" : "symphony";
|
|
9
|
+
|
|
10
|
+
async function executable(path: string): Promise<string> {
|
|
11
|
+
await mkdir(resolve(path, ".."), { recursive: true });
|
|
12
|
+
await writeFile(path, "#!/bin/sh\necho symphony\n", "utf8");
|
|
13
|
+
await chmod(path, 0o755);
|
|
14
|
+
return path;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe("Symphony binary resolver", () => {
|
|
18
|
+
it("uses SYMPHONY_BIN first", async () => {
|
|
19
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-symphony-bin-"));
|
|
20
|
+
const binary = await executable(join(dir, "custom-symphony"));
|
|
21
|
+
const state = createDefaultState();
|
|
22
|
+
|
|
23
|
+
const resolved = await resolveSymphonyBinary({
|
|
24
|
+
cwd: dir,
|
|
25
|
+
state,
|
|
26
|
+
env: { SYMPHONY_BIN: binary, PATH: "" },
|
|
27
|
+
promptForPath: async () => undefined,
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(resolved).toBe(binary);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("finds repo-local target release binary by walking upward", async () => {
|
|
34
|
+
const root = await mkdtemp(join(tmpdir(), "pi-symphony-repo-"));
|
|
35
|
+
const nested = join(root, "packages", "x");
|
|
36
|
+
const binary = await executable(join(root, "apps", "symphony", "target", "release", symphonyBinaryName));
|
|
37
|
+
await mkdir(nested, { recursive: true });
|
|
38
|
+
|
|
39
|
+
const resolved = await resolveSymphonyBinary({
|
|
40
|
+
cwd: nested,
|
|
41
|
+
state: createDefaultState(),
|
|
42
|
+
env: { PATH: "" },
|
|
43
|
+
promptForPath: async () => undefined,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
expect(resolved).toBe(binary);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("falls back to PATH when SYMPHONY_BIN is invalid", async () => {
|
|
50
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-symphony-fallback-"));
|
|
51
|
+
const invalid = join(dir, "missing-symphony");
|
|
52
|
+
const binary = await executable(join(dir, symphonyBinaryName));
|
|
53
|
+
|
|
54
|
+
await expect(
|
|
55
|
+
resolveSymphonyBinary({
|
|
56
|
+
cwd: dir,
|
|
57
|
+
state: createDefaultState(),
|
|
58
|
+
env: { SYMPHONY_BIN: invalid, PATH: dir },
|
|
59
|
+
promptForPath: async () => undefined,
|
|
60
|
+
}),
|
|
61
|
+
).resolves.toBe(binary);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("uses persisted user path after built-in checks fail", async () => {
|
|
65
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-symphony-persisted-"));
|
|
66
|
+
const binary = await executable(join(dir, "persisted"));
|
|
67
|
+
const state = createDefaultState();
|
|
68
|
+
state.binaryPath = binary;
|
|
69
|
+
|
|
70
|
+
await expect(resolveSymphonyBinary({ cwd: dir, state, env: { PATH: "" }, promptForPath: async () => undefined })).resolves.toBe(binary);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("prompts for an absolute path and persists it", async () => {
|
|
74
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-symphony-prompt-"));
|
|
75
|
+
const binary = await executable(join(dir, "prompted"));
|
|
76
|
+
const state = createDefaultState();
|
|
77
|
+
|
|
78
|
+
const resolved = await resolveSymphonyBinary({
|
|
79
|
+
cwd: dir,
|
|
80
|
+
state,
|
|
81
|
+
env: { PATH: "" },
|
|
82
|
+
promptForPath: async () => binary,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
expect(resolved).toBe(binary);
|
|
86
|
+
expect(state.binaryPath).toBe(binary);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects relative binary paths", async () => {
|
|
90
|
+
await expect(validateBinaryPath("relative/symphony")).rejects.toThrow("Symphony binary path must be absolute");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("rejects directories", async () => {
|
|
94
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-symphony-dir-"));
|
|
95
|
+
|
|
96
|
+
await expect(validateBinaryPath(dir)).rejects.toThrow("Symphony binary path is not a file");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("reports a missing binary when no candidate is valid", async () => {
|
|
100
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-symphony-missing-"));
|
|
101
|
+
|
|
102
|
+
await expect(
|
|
103
|
+
resolveSymphonyBinary({
|
|
104
|
+
cwd: dir,
|
|
105
|
+
state: createDefaultState(),
|
|
106
|
+
env: { PATH: "" },
|
|
107
|
+
promptForPath: async () => undefined,
|
|
108
|
+
}),
|
|
109
|
+
).rejects.toMatchObject({
|
|
110
|
+
kind: "missing_binary",
|
|
111
|
+
message: expect.stringContaining("Could not find Symphony binary"),
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, stat } from "node:fs/promises";
|
|
3
|
+
import { delimiter, dirname, isAbsolute, join } from "node:path";
|
|
4
|
+
import { SymphonyExtensionError } from "./errors.ts";
|
|
5
|
+
import type { ExtensionState } from "./state.ts";
|
|
6
|
+
|
|
7
|
+
export interface ResolveBinaryOptions {
|
|
8
|
+
cwd: string;
|
|
9
|
+
state: ExtensionState;
|
|
10
|
+
env?: NodeJS.ProcessEnv;
|
|
11
|
+
promptForPath?: () => Promise<string | undefined>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function resolveSymphonyBinary(options: ResolveBinaryOptions): Promise<string> {
|
|
15
|
+
const env = options.env ?? process.env;
|
|
16
|
+
const candidates: string[] = [];
|
|
17
|
+
|
|
18
|
+
if (env.SYMPHONY_BIN) candidates.push(env.SYMPHONY_BIN);
|
|
19
|
+
|
|
20
|
+
const repoLocal = await findRepoLocalBinary(options.cwd);
|
|
21
|
+
if (repoLocal) candidates.push(repoLocal);
|
|
22
|
+
|
|
23
|
+
const pathBinary = await findOnPath("symphony", env);
|
|
24
|
+
if (pathBinary) candidates.push(pathBinary);
|
|
25
|
+
|
|
26
|
+
if (options.state.binaryPath) candidates.push(options.state.binaryPath);
|
|
27
|
+
|
|
28
|
+
for (const candidate of candidates) {
|
|
29
|
+
try {
|
|
30
|
+
return await validateBinaryPath(candidate);
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const prompted = await options.promptForPath?.();
|
|
37
|
+
if (prompted) {
|
|
38
|
+
const validated = await validateBinaryPath(prompted);
|
|
39
|
+
options.state.binaryPath = validated;
|
|
40
|
+
return validated;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
throw new SymphonyExtensionError(
|
|
44
|
+
"missing_binary",
|
|
45
|
+
"Could not find Symphony binary. Set SYMPHONY_BIN, build apps/symphony/target/release/symphony, add symphony to PATH, or provide an absolute path.",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function validateBinaryPath(path: string): Promise<string> {
|
|
50
|
+
if (!isAbsolute(path)) {
|
|
51
|
+
throw new SymphonyExtensionError("invalid_binary", "Symphony binary path must be absolute", { path });
|
|
52
|
+
}
|
|
53
|
+
let isFile: boolean;
|
|
54
|
+
try {
|
|
55
|
+
isFile = (await stat(path)).isFile();
|
|
56
|
+
} catch (error) {
|
|
57
|
+
throw new SymphonyExtensionError("invalid_binary", "Symphony binary path is not executable", {
|
|
58
|
+
path,
|
|
59
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
if (!isFile) {
|
|
63
|
+
throw new SymphonyExtensionError("invalid_binary", "Symphony binary path is not a file", { path });
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await access(path, constants.X_OK);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
throw new SymphonyExtensionError("invalid_binary", "Symphony binary path is not executable", {
|
|
69
|
+
path,
|
|
70
|
+
cause: error instanceof Error ? error.message : String(error),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
return path;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function findRepoLocalBinary(cwd: string): Promise<string | undefined> {
|
|
77
|
+
let current = cwd;
|
|
78
|
+
while (true) {
|
|
79
|
+
const candidate = join(current, "apps", "symphony", "target", "release", process.platform === "win32" ? "symphony.exe" : "symphony");
|
|
80
|
+
try {
|
|
81
|
+
await validateBinaryPath(candidate);
|
|
82
|
+
return candidate;
|
|
83
|
+
} catch {
|
|
84
|
+
const parent = dirname(current);
|
|
85
|
+
if (parent === current) return undefined;
|
|
86
|
+
current = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function findOnPath(command: string, env: NodeJS.ProcessEnv): Promise<string | undefined> {
|
|
92
|
+
const commandNames = pathCommandNames(command, env);
|
|
93
|
+
for (const dir of (env.PATH ?? "").split(delimiter).filter(Boolean)) {
|
|
94
|
+
for (const commandName of commandNames) {
|
|
95
|
+
const candidate = join(dir, commandName);
|
|
96
|
+
try {
|
|
97
|
+
await validateBinaryPath(candidate);
|
|
98
|
+
return candidate;
|
|
99
|
+
} catch {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function pathCommandNames(command: string, env: NodeJS.ProcessEnv): string[] {
|
|
108
|
+
if (process.platform !== "win32") return [command];
|
|
109
|
+
|
|
110
|
+
const extensions = (env.PATHEXT ?? ".COM;.EXE;.BAT;.CMD")
|
|
111
|
+
.split(";")
|
|
112
|
+
.filter(Boolean)
|
|
113
|
+
.map((extension) => (extension.startsWith(".") ? extension : `.${extension}`));
|
|
114
|
+
return [command, ...extensions.map((extension) => `${command}${extension}`)];
|
|
115
|
+
}
|