better-symphony 1.0.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/CLAUDE.md +60 -0
- package/LICENSE +21 -0
- package/README.md +292 -0
- package/dist/web/app.css +2 -0
- package/dist/web/index.html +13 -0
- package/dist/web/main.js +235 -0
- package/package.json +62 -0
- package/src/agent/claude-runner.ts +576 -0
- package/src/agent/protocol.ts +2 -0
- package/src/agent/runner.ts +2 -0
- package/src/agent/session.ts +113 -0
- package/src/cli.ts +354 -0
- package/src/config/loader.ts +379 -0
- package/src/config/types.ts +382 -0
- package/src/index.ts +53 -0
- package/src/linear-cli.ts +414 -0
- package/src/logging/logger.ts +143 -0
- package/src/orchestrator/multi-orchestrator.ts +266 -0
- package/src/orchestrator/orchestrator.ts +1357 -0
- package/src/orchestrator/scheduler.ts +195 -0
- package/src/orchestrator/state.ts +201 -0
- package/src/prompts/github-system-prompt.md +51 -0
- package/src/prompts/linear-system-prompt.md +44 -0
- package/src/tracker/client.ts +577 -0
- package/src/tracker/github-issues-tracker.ts +280 -0
- package/src/tracker/github-pr-tracker.ts +298 -0
- package/src/tracker/index.ts +9 -0
- package/src/tracker/interface.ts +76 -0
- package/src/tracker/linear-tracker.ts +147 -0
- package/src/tracker/queries.ts +281 -0
- package/src/tracker/types.ts +125 -0
- package/src/tui/App.tsx +157 -0
- package/src/tui/LogView.tsx +120 -0
- package/src/tui/StatusBar.tsx +72 -0
- package/src/tui/TabBar.tsx +55 -0
- package/src/tui/sink.ts +47 -0
- package/src/tui/types.ts +6 -0
- package/src/tui/useOrchestrator.ts +244 -0
- package/src/web/server.ts +182 -0
- package/src/web/sink.ts +67 -0
- package/src/web-ui/App.tsx +60 -0
- package/src/web-ui/components/agent-table.tsx +57 -0
- package/src/web-ui/components/header.tsx +72 -0
- package/src/web-ui/components/log-stream.tsx +111 -0
- package/src/web-ui/components/retry-table.tsx +58 -0
- package/src/web-ui/components/stats-cards.tsx +142 -0
- package/src/web-ui/components/ui/badge.tsx +30 -0
- package/src/web-ui/components/ui/button.tsx +39 -0
- package/src/web-ui/components/ui/card.tsx +32 -0
- package/src/web-ui/globals.css +27 -0
- package/src/web-ui/index.html +13 -0
- package/src/web-ui/lib/use-sse.ts +98 -0
- package/src/web-ui/lib/utils.ts +25 -0
- package/src/web-ui/main.tsx +4 -0
- package/src/workspace/hooks.ts +97 -0
- package/src/workspace/manager.ts +211 -0
- package/src/workspace/render-hook.ts +13 -0
- package/workflows/dev.md +127 -0
- package/workflows/github-issues.md +107 -0
- package/workflows/pr-review.md +89 -0
- package/workflows/prd.md +170 -0
- package/workflows/ralph.md +95 -0
- package/workflows/smoke.md +66 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Symphony Linear CLI
|
|
4
|
+
* Standalone CLI for interacting with Linear from agent workspaces.
|
|
5
|
+
* Used by Claude agents instead of requiring external skills.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* symphony-linear get-issue SYM-123
|
|
9
|
+
* symphony-linear create-issue --parent SYM-123 --title "Task title" [--description "..."] [--priority 2]
|
|
10
|
+
* symphony-linear update-issue SYM-123 [--title "..."] [--description "..."] [--state "In Progress"]
|
|
11
|
+
* symphony-linear create-comment SYM-123 "Comment body"
|
|
12
|
+
* symphony-linear add-label SYM-123 "agent:prd:done"
|
|
13
|
+
* symphony-linear remove-label SYM-123 "agent:prd"
|
|
14
|
+
* symphony-linear swap-label SYM-123 --remove "agent:prd" --add "agent:prd:done"
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdirSync, writeFileSync } from "fs";
|
|
18
|
+
import { join, extname } from "path";
|
|
19
|
+
import { LinearClient } from "./tracker/client.js";
|
|
20
|
+
|
|
21
|
+
const LINEAR_ENDPOINT = "https://api.linear.app/graphql";
|
|
22
|
+
|
|
23
|
+
function getApiKey(): string {
|
|
24
|
+
const key = process.env.LINEAR_API_KEY;
|
|
25
|
+
if (!key) {
|
|
26
|
+
console.error("Error: LINEAR_API_KEY environment variable is required");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
|
29
|
+
return key;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function createClient(): LinearClient {
|
|
33
|
+
return new LinearClient(LINEAR_ENDPOINT, getApiKey());
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Extract uploaded file URLs from Markdown text (images, file links, HTML img tags) */
|
|
37
|
+
function extractUploadUrls(text: string): string[] {
|
|
38
|
+
const urls: Set<string> = new Set();
|
|
39
|
+
|
|
40
|
+
//  — images
|
|
41
|
+
const mdImageRe = /!\[[^\]]*\]\(([^)]+)\)/g;
|
|
42
|
+
for (const match of text.matchAll(mdImageRe)) {
|
|
43
|
+
urls.add(match[1]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// [text](url) — file links (only Linear uploads, not arbitrary links)
|
|
47
|
+
const mdLinkRe = /(?<!!)\[[^\]]*\]\(([^)]+)\)/g;
|
|
48
|
+
for (const match of text.matchAll(mdLinkRe)) {
|
|
49
|
+
const url = match[1];
|
|
50
|
+
if (url.includes("uploads.linear.app")) {
|
|
51
|
+
urls.add(url);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// <img src="url"> or <img src='url'>
|
|
56
|
+
const htmlImgRe = /<img[^>]+src=["']([^"']+)["'][^>]*>/gi;
|
|
57
|
+
for (const match of text.matchAll(htmlImgRe)) {
|
|
58
|
+
urls.add(match[1]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return [...urls];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Guess file extension from content-type header or URL */
|
|
65
|
+
function guessExtension(url: string, contentType: string | null): string {
|
|
66
|
+
const ctMap: Record<string, string> = {
|
|
67
|
+
"image/png": ".png",
|
|
68
|
+
"image/jpeg": ".jpg",
|
|
69
|
+
"image/gif": ".gif",
|
|
70
|
+
"image/webp": ".webp",
|
|
71
|
+
"image/svg+xml": ".svg",
|
|
72
|
+
"application/pdf": ".pdf",
|
|
73
|
+
"application/zip": ".zip",
|
|
74
|
+
"text/plain": ".txt",
|
|
75
|
+
};
|
|
76
|
+
if (contentType) {
|
|
77
|
+
const base = contentType.split(";")[0].trim().toLowerCase();
|
|
78
|
+
if (ctMap[base]) return ctMap[base];
|
|
79
|
+
}
|
|
80
|
+
// Fall back to URL extension
|
|
81
|
+
try {
|
|
82
|
+
const pathname = new URL(url).pathname;
|
|
83
|
+
const ext = extname(pathname).toLowerCase();
|
|
84
|
+
if (ext) return ext;
|
|
85
|
+
} catch {}
|
|
86
|
+
// Default to .bin for unknown types
|
|
87
|
+
return ".bin";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Extract a filename stem from a URL — use last UUID-like segment or last path segment */
|
|
91
|
+
function extractStem(url: string): string {
|
|
92
|
+
try {
|
|
93
|
+
const segments = new URL(url).pathname.split("/").filter(Boolean);
|
|
94
|
+
// Walk backwards to find a UUID-like segment
|
|
95
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
96
|
+
if (/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(segments[i])) {
|
|
97
|
+
return segments[i];
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
// Fall back to last segment without extension
|
|
101
|
+
const last = segments[segments.length - 1] || "image";
|
|
102
|
+
const dot = last.lastIndexOf(".");
|
|
103
|
+
return dot > 0 ? last.slice(0, dot) : last;
|
|
104
|
+
} catch {}
|
|
105
|
+
return `image-${Date.now()}`;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Download a single image, using auth for Linear-hosted URLs */
|
|
109
|
+
async function downloadImage(
|
|
110
|
+
url: string,
|
|
111
|
+
outputDir: string,
|
|
112
|
+
apiKey: string
|
|
113
|
+
): Promise<{ original_url: string; local_path: string } | { original_url: string; error: string }> {
|
|
114
|
+
try {
|
|
115
|
+
const headers: Record<string, string> = {};
|
|
116
|
+
if (url.includes("uploads.linear.app") || url.includes("linear.app")) {
|
|
117
|
+
headers["Authorization"] = apiKey;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const response = await fetch(url, { headers, redirect: "follow" });
|
|
121
|
+
if (!response.ok) {
|
|
122
|
+
return { original_url: url, error: `HTTP ${response.status}` };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const contentType = response.headers.get("content-type");
|
|
126
|
+
const ext = guessExtension(url, contentType);
|
|
127
|
+
const stem = extractStem(url);
|
|
128
|
+
const filename = `${stem}${ext}`;
|
|
129
|
+
const localPath = join(outputDir, filename);
|
|
130
|
+
|
|
131
|
+
const buffer = await response.arrayBuffer();
|
|
132
|
+
writeFileSync(localPath, Buffer.from(buffer));
|
|
133
|
+
|
|
134
|
+
return { original_url: url, local_path: localPath };
|
|
135
|
+
} catch (err) {
|
|
136
|
+
return { original_url: url, error: (err as Error).message };
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function usage(): void {
|
|
141
|
+
console.log(`Symphony Linear CLI
|
|
142
|
+
|
|
143
|
+
Commands:
|
|
144
|
+
get-issue <IDENTIFIER> Get issue details (JSON)
|
|
145
|
+
get-comments <IDENTIFIER> Get issue comments (JSON)
|
|
146
|
+
create-issue --parent <ID> --title "..." Create a child issue
|
|
147
|
+
[--description "..."] [--priority N]
|
|
148
|
+
update-issue <IDENTIFIER> Update an issue
|
|
149
|
+
[--title "..."] [--description "..."] [--state "..."]
|
|
150
|
+
create-comment <IDENTIFIER> "body" Post a comment
|
|
151
|
+
add-label <IDENTIFIER> "label-name" Add a label
|
|
152
|
+
remove-label <IDENTIFIER> "label-name" Remove a label
|
|
153
|
+
swap-label <IDENTIFIER> --remove "x" --add "y" Swap labels atomically
|
|
154
|
+
download-attachments <IDENTIFIER> [--output dir] Download all attachments from issue
|
|
155
|
+
|
|
156
|
+
Environment:
|
|
157
|
+
LINEAR_API_KEY Required. Linear API key.
|
|
158
|
+
|
|
159
|
+
Notes:
|
|
160
|
+
- <IDENTIFIER> can be issue identifier (SYM-123) or UUID
|
|
161
|
+
- For create-issue, --parent takes an identifier (SYM-123) and resolves it
|
|
162
|
+
- Priority: 1=urgent, 2=high, 3=medium, 4=low`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function parseArgs(args: string[]): { flags: Record<string, string>; positional: string[] } {
|
|
166
|
+
const flags: Record<string, string> = {};
|
|
167
|
+
const positional: string[] = [];
|
|
168
|
+
|
|
169
|
+
for (let i = 0; i < args.length; i++) {
|
|
170
|
+
const arg = args[i];
|
|
171
|
+
if (arg.startsWith("--")) {
|
|
172
|
+
const key = arg.slice(2);
|
|
173
|
+
const next = args[i + 1];
|
|
174
|
+
if (next && !next.startsWith("--")) {
|
|
175
|
+
flags[key] = next;
|
|
176
|
+
i++;
|
|
177
|
+
} else {
|
|
178
|
+
flags[key] = "true";
|
|
179
|
+
}
|
|
180
|
+
} else {
|
|
181
|
+
positional.push(arg);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { flags, positional };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function resolveIssue(client: LinearClient, identifier: string) {
|
|
189
|
+
const issue = await client.getIssue(identifier);
|
|
190
|
+
if (!issue) {
|
|
191
|
+
console.error(`Error: Issue ${identifier} not found`);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
}
|
|
194
|
+
return issue;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function main(): Promise<void> {
|
|
198
|
+
const args = process.argv.slice(2);
|
|
199
|
+
|
|
200
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
201
|
+
usage();
|
|
202
|
+
process.exit(0);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const command = args[0];
|
|
206
|
+
const { flags, positional } = parseArgs(args.slice(1));
|
|
207
|
+
const client = createClient();
|
|
208
|
+
|
|
209
|
+
switch (command) {
|
|
210
|
+
case "get-issue": {
|
|
211
|
+
const identifier = positional[0];
|
|
212
|
+
if (!identifier) {
|
|
213
|
+
console.error("Error: Issue identifier required");
|
|
214
|
+
process.exit(1);
|
|
215
|
+
}
|
|
216
|
+
const issue = await resolveIssue(client, identifier);
|
|
217
|
+
console.log(JSON.stringify(issue, null, 2));
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
case "get-comments": {
|
|
222
|
+
const identifier = positional[0];
|
|
223
|
+
if (!identifier) {
|
|
224
|
+
console.error("Error: Issue identifier required");
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
const comments = await client.getComments(identifier);
|
|
228
|
+
console.log(JSON.stringify(comments, null, 2));
|
|
229
|
+
break;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case "create-issue": {
|
|
233
|
+
const parentIdentifier = flags.parent;
|
|
234
|
+
const title = flags.title;
|
|
235
|
+
if (!parentIdentifier || !title) {
|
|
236
|
+
console.error("Error: --parent and --title are required");
|
|
237
|
+
process.exit(1);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const parent = await resolveIssue(client, parentIdentifier);
|
|
241
|
+
|
|
242
|
+
const input: Record<string, unknown> = {
|
|
243
|
+
teamId: parent.team.id,
|
|
244
|
+
parentId: parent.id,
|
|
245
|
+
title,
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
if (flags.description) input.description = flags.description;
|
|
249
|
+
if (flags.priority) input.priority = parseInt(flags.priority, 10);
|
|
250
|
+
|
|
251
|
+
// Set state to Todo if possible
|
|
252
|
+
try {
|
|
253
|
+
const stateId = await client.findStateId(parent.team.id, "Todo");
|
|
254
|
+
if (stateId) input.stateId = stateId;
|
|
255
|
+
} catch {}
|
|
256
|
+
|
|
257
|
+
const created = await client.createIssue(input);
|
|
258
|
+
console.log(JSON.stringify(created, null, 2));
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
case "update-issue": {
|
|
263
|
+
const identifier = positional[0];
|
|
264
|
+
if (!identifier) {
|
|
265
|
+
console.error("Error: Issue identifier required");
|
|
266
|
+
process.exit(1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const issue = await resolveIssue(client, identifier);
|
|
270
|
+
const input: Record<string, unknown> = {};
|
|
271
|
+
|
|
272
|
+
if (flags.title) input.title = flags.title;
|
|
273
|
+
if (flags.description) input.description = flags.description;
|
|
274
|
+
|
|
275
|
+
if (flags.state) {
|
|
276
|
+
const stateId = await client.findStateId(issue.team.id, flags.state);
|
|
277
|
+
if (stateId) {
|
|
278
|
+
input.stateId = stateId;
|
|
279
|
+
} else {
|
|
280
|
+
console.error(`Warning: State "${flags.state}" not found, skipping state update`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (Object.keys(input).length === 0) {
|
|
285
|
+
console.error("Error: At least one of --title, --description, or --state required");
|
|
286
|
+
process.exit(1);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
await client.updateIssue(issue.id, input);
|
|
290
|
+
console.log(JSON.stringify({ success: true, identifier: issue.identifier }));
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
case "create-comment": {
|
|
295
|
+
const identifier = positional[0];
|
|
296
|
+
const body = positional[1] || flags.body;
|
|
297
|
+
if (!identifier || !body) {
|
|
298
|
+
console.error("Error: Issue identifier and comment body required");
|
|
299
|
+
process.exit(1);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const issue = await resolveIssue(client, identifier);
|
|
303
|
+
const commentId = await client.createComment(issue.id, body);
|
|
304
|
+
console.log(JSON.stringify({ success: true, comment_id: commentId }));
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
case "add-label": {
|
|
309
|
+
const identifier = positional[0];
|
|
310
|
+
const labelName = positional[1] || flags.label;
|
|
311
|
+
if (!identifier || !labelName) {
|
|
312
|
+
console.error("Error: Issue identifier and label name required");
|
|
313
|
+
process.exit(1);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const issue = await resolveIssue(client, identifier);
|
|
317
|
+
await client.addLabel(issue.id, labelName, issue.team.id);
|
|
318
|
+
console.log(JSON.stringify({ success: true, label: labelName }));
|
|
319
|
+
break;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
case "remove-label": {
|
|
323
|
+
const identifier = positional[0];
|
|
324
|
+
const labelName = positional[1] || flags.label;
|
|
325
|
+
if (!identifier || !labelName) {
|
|
326
|
+
console.error("Error: Issue identifier and label name required");
|
|
327
|
+
process.exit(1);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const issue = await resolveIssue(client, identifier);
|
|
331
|
+
await client.removeLabel(issue.id, labelName);
|
|
332
|
+
console.log(JSON.stringify({ success: true, label: labelName }));
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
case "swap-label": {
|
|
337
|
+
const identifier = positional[0];
|
|
338
|
+
const removeName = flags.remove;
|
|
339
|
+
const addName = flags.add;
|
|
340
|
+
if (!identifier || !removeName || !addName) {
|
|
341
|
+
console.error("Error: Issue identifier, --remove, and --add required");
|
|
342
|
+
process.exit(1);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const issue = await resolveIssue(client, identifier);
|
|
346
|
+
await client.swapLabel(issue.id, removeName, addName, issue.team.id);
|
|
347
|
+
console.log(JSON.stringify({ success: true, removed: removeName, added: addName }));
|
|
348
|
+
break;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
case "download-attachments": {
|
|
352
|
+
const identifier = positional[0];
|
|
353
|
+
if (!identifier) {
|
|
354
|
+
console.error("Error: Issue identifier required");
|
|
355
|
+
process.exit(1);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const outputDir = flags.output || ".";
|
|
359
|
+
mkdirSync(outputDir, { recursive: true });
|
|
360
|
+
|
|
361
|
+
const issue = await resolveIssue(client, identifier);
|
|
362
|
+
|
|
363
|
+
// Collect image URLs from description and comments
|
|
364
|
+
const allUrls: Set<string> = new Set();
|
|
365
|
+
|
|
366
|
+
if (issue.description) {
|
|
367
|
+
for (const url of extractUploadUrls(issue.description)) {
|
|
368
|
+
allUrls.add(url);
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (issue.comments?.nodes) {
|
|
373
|
+
for (const comment of issue.comments.nodes) {
|
|
374
|
+
if (comment.body) {
|
|
375
|
+
for (const url of extractUploadUrls(comment.body)) {
|
|
376
|
+
allUrls.add(url);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// Also fetch attachments
|
|
383
|
+
const attachments = await client.getAttachments(issue.id);
|
|
384
|
+
for (const att of attachments) {
|
|
385
|
+
if (att.url) allUrls.add(att.url);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (allUrls.size === 0) {
|
|
389
|
+
console.log(JSON.stringify({ images: [], message: "No images found" }));
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
const apiKey = getApiKey();
|
|
394
|
+
const results = [];
|
|
395
|
+
for (const url of allUrls) {
|
|
396
|
+
const result = await downloadImage(url, outputDir, apiKey);
|
|
397
|
+
results.push(result);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
console.log(JSON.stringify({ images: results }, null, 2));
|
|
401
|
+
break;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
default:
|
|
405
|
+
console.error(`Unknown command: ${command}`);
|
|
406
|
+
usage();
|
|
407
|
+
process.exit(1);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
main().catch((err) => {
|
|
412
|
+
console.error(`Error: ${err.message}`);
|
|
413
|
+
process.exit(1);
|
|
414
|
+
});
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symphony Logger
|
|
3
|
+
* Structured logging with key=value format
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
7
|
+
|
|
8
|
+
export interface LogContext {
|
|
9
|
+
issue_id?: string;
|
|
10
|
+
issue_identifier?: string;
|
|
11
|
+
session_id?: string;
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface LogEntry {
|
|
16
|
+
level: LogLevel;
|
|
17
|
+
timestamp: Date;
|
|
18
|
+
message: string;
|
|
19
|
+
context: LogContext;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type LogSink = (entry: LogEntry) => void;
|
|
23
|
+
|
|
24
|
+
class Logger {
|
|
25
|
+
private sinks: LogSink[] = [];
|
|
26
|
+
private minLevel: LogLevel = "info";
|
|
27
|
+
|
|
28
|
+
private levelOrder: Record<LogLevel, number> = {
|
|
29
|
+
debug: 0,
|
|
30
|
+
info: 1,
|
|
31
|
+
warn: 2,
|
|
32
|
+
error: 3,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
addSink(sink: LogSink): void {
|
|
36
|
+
this.sinks.push(sink);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
removeSink(sink: LogSink): void {
|
|
40
|
+
const index = this.sinks.indexOf(sink);
|
|
41
|
+
if (index !== -1) {
|
|
42
|
+
this.sinks.splice(index, 1);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clearSinks(): void {
|
|
47
|
+
this.sinks.length = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
setMinLevel(level: LogLevel): void {
|
|
51
|
+
this.minLevel = level;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private shouldLog(level: LogLevel): boolean {
|
|
55
|
+
return this.levelOrder[level] >= this.levelOrder[this.minLevel];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
private emit(level: LogLevel, message: string, context: LogContext = {}): void {
|
|
59
|
+
if (!this.shouldLog(level)) return;
|
|
60
|
+
|
|
61
|
+
const entry: LogEntry = {
|
|
62
|
+
level,
|
|
63
|
+
timestamp: new Date(),
|
|
64
|
+
message,
|
|
65
|
+
context,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
for (const sink of this.sinks) {
|
|
69
|
+
try {
|
|
70
|
+
sink(entry);
|
|
71
|
+
} catch {
|
|
72
|
+
// Sink failure should not crash the service
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
debug(message: string, context?: LogContext): void {
|
|
78
|
+
this.emit("debug", message, context);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
info(message: string, context?: LogContext): void {
|
|
82
|
+
this.emit("info", message, context);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
warn(message: string, context?: LogContext): void {
|
|
86
|
+
this.emit("warn", message, context);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
error(message: string, context?: LogContext): void {
|
|
90
|
+
this.emit("error", message, context);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Default console sink with key=value format
|
|
95
|
+
export function createConsoleSink(): LogSink {
|
|
96
|
+
return (entry: LogEntry) => {
|
|
97
|
+
const ts = entry.timestamp.toISOString();
|
|
98
|
+
const level = entry.level.toUpperCase().padEnd(5);
|
|
99
|
+
|
|
100
|
+
let contextStr = "";
|
|
101
|
+
for (const [key, value] of Object.entries(entry.context)) {
|
|
102
|
+
if (value !== undefined && value !== null) {
|
|
103
|
+
contextStr += ` ${key}=${JSON.stringify(value)}`;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const output = `[${ts}] ${level} ${entry.message}${contextStr}`;
|
|
108
|
+
|
|
109
|
+
switch (entry.level) {
|
|
110
|
+
case "error":
|
|
111
|
+
console.error(output);
|
|
112
|
+
break;
|
|
113
|
+
case "warn":
|
|
114
|
+
console.warn(output);
|
|
115
|
+
break;
|
|
116
|
+
default:
|
|
117
|
+
console.log(output);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// File sink (appends to file)
|
|
123
|
+
export function createFileSink(filePath: string): LogSink {
|
|
124
|
+
const file = Bun.file(filePath);
|
|
125
|
+
const writer = file.writer();
|
|
126
|
+
|
|
127
|
+
return (entry: LogEntry) => {
|
|
128
|
+
const jsonLine = JSON.stringify({
|
|
129
|
+
timestamp: entry.timestamp.toISOString(),
|
|
130
|
+
level: entry.level,
|
|
131
|
+
message: entry.message,
|
|
132
|
+
...entry.context,
|
|
133
|
+
});
|
|
134
|
+
writer.write(jsonLine + "\n");
|
|
135
|
+
writer.flush();
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Global logger instance
|
|
140
|
+
export const logger = new Logger();
|
|
141
|
+
|
|
142
|
+
// Add default console sink
|
|
143
|
+
logger.addSink(createConsoleSink());
|