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,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Session
|
|
3
|
+
* Manages a single coding agent session lifecycle
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { LiveSession, TokenTotals, RateLimitInfo } from "../config/types.js";
|
|
7
|
+
|
|
8
|
+
export function createSession(threadId: string, turnId: string, pid: string | null): LiveSession {
|
|
9
|
+
return {
|
|
10
|
+
session_id: `${threadId}-${turnId}`,
|
|
11
|
+
thread_id: threadId,
|
|
12
|
+
turn_id: turnId,
|
|
13
|
+
process_pid: pid,
|
|
14
|
+
last_event: null,
|
|
15
|
+
last_activity_at: null,
|
|
16
|
+
last_message: null,
|
|
17
|
+
input_tokens: 0,
|
|
18
|
+
output_tokens: 0,
|
|
19
|
+
total_tokens: 0,
|
|
20
|
+
last_reported_input_tokens: 0,
|
|
21
|
+
last_reported_output_tokens: 0,
|
|
22
|
+
last_reported_total_tokens: 0,
|
|
23
|
+
turn_count: 1,
|
|
24
|
+
cost_usd: 0,
|
|
25
|
+
duration_ms: 0,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function updateSessionTurnId(session: LiveSession, turnId: string): void {
|
|
30
|
+
session.turn_id = turnId;
|
|
31
|
+
session.session_id = `${session.thread_id}-${turnId}`;
|
|
32
|
+
session.turn_count++;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function updateSessionEvent(
|
|
36
|
+
session: LiveSession,
|
|
37
|
+
event: string,
|
|
38
|
+
message?: string
|
|
39
|
+
): void {
|
|
40
|
+
session.last_event = event;
|
|
41
|
+
session.last_activity_at = new Date();
|
|
42
|
+
if (message) {
|
|
43
|
+
session.last_message = message.slice(0, 500);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function updateSessionTokens(
|
|
48
|
+
session: LiveSession,
|
|
49
|
+
usage: {
|
|
50
|
+
input_tokens?: number;
|
|
51
|
+
output_tokens?: number;
|
|
52
|
+
total_tokens?: number;
|
|
53
|
+
}
|
|
54
|
+
): {
|
|
55
|
+
delta_input: number;
|
|
56
|
+
delta_output: number;
|
|
57
|
+
delta_total: number;
|
|
58
|
+
} {
|
|
59
|
+
const inputTokens = usage.input_tokens ?? 0;
|
|
60
|
+
const outputTokens = usage.output_tokens ?? 0;
|
|
61
|
+
const totalTokens = usage.total_tokens ?? 0;
|
|
62
|
+
|
|
63
|
+
// Calculate deltas from last reported (for absolute totals)
|
|
64
|
+
const delta_input = Math.max(0, inputTokens - session.last_reported_input_tokens);
|
|
65
|
+
const delta_output = Math.max(0, outputTokens - session.last_reported_output_tokens);
|
|
66
|
+
const delta_total = Math.max(0, totalTokens - session.last_reported_total_tokens);
|
|
67
|
+
|
|
68
|
+
// Update session totals
|
|
69
|
+
session.input_tokens = inputTokens;
|
|
70
|
+
session.output_tokens = outputTokens;
|
|
71
|
+
session.total_tokens = totalTokens;
|
|
72
|
+
|
|
73
|
+
// Update last reported for delta tracking
|
|
74
|
+
session.last_reported_input_tokens = inputTokens;
|
|
75
|
+
session.last_reported_output_tokens = outputTokens;
|
|
76
|
+
session.last_reported_total_tokens = totalTokens;
|
|
77
|
+
|
|
78
|
+
return { delta_input, delta_output, delta_total };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function createEmptyTotals(): TokenTotals {
|
|
82
|
+
return {
|
|
83
|
+
input_tokens: 0,
|
|
84
|
+
output_tokens: 0,
|
|
85
|
+
total_tokens: 0,
|
|
86
|
+
seconds_running: 0,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function updateTotals(
|
|
91
|
+
totals: TokenTotals,
|
|
92
|
+
deltas: { delta_input: number; delta_output: number; delta_total: number }
|
|
93
|
+
): void {
|
|
94
|
+
totals.input_tokens += deltas.delta_input;
|
|
95
|
+
totals.output_tokens += deltas.delta_output;
|
|
96
|
+
totals.total_tokens += deltas.delta_total;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function parseRateLimits(payload: unknown): RateLimitInfo | null {
|
|
100
|
+
if (!payload || typeof payload !== "object") return null;
|
|
101
|
+
|
|
102
|
+
const info: RateLimitInfo = {};
|
|
103
|
+
const p = payload as Record<string, unknown>;
|
|
104
|
+
|
|
105
|
+
if (typeof p.requests_limit === "number") info.requests_limit = p.requests_limit;
|
|
106
|
+
if (typeof p.requests_remaining === "number") info.requests_remaining = p.requests_remaining;
|
|
107
|
+
if (typeof p.requests_reset === "number") info.requests_reset = p.requests_reset;
|
|
108
|
+
if (typeof p.tokens_limit === "number") info.tokens_limit = p.tokens_limit;
|
|
109
|
+
if (typeof p.tokens_remaining === "number") info.tokens_remaining = p.tokens_remaining;
|
|
110
|
+
if (typeof p.tokens_reset === "number") info.tokens_reset = p.tokens_reset;
|
|
111
|
+
|
|
112
|
+
return Object.keys(info).length > 0 ? info : null;
|
|
113
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Symphony CLI
|
|
4
|
+
* Entry point for the Symphony orchestrator service with TUI
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { resolve, join, basename } from "path";
|
|
8
|
+
import { existsSync, readdirSync } from "fs";
|
|
9
|
+
import { createCliRenderer } from "@opentui/core";
|
|
10
|
+
import { createRoot } from "@opentui/react";
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { App } from "./tui/App.js";
|
|
13
|
+
import { logger, createFileSink } from "./logging/logger.js";
|
|
14
|
+
import pkg from "../package.json";
|
|
15
|
+
|
|
16
|
+
// ── CLI Parsing ─────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
interface CLIOptions {
|
|
19
|
+
workflowPaths: string[];
|
|
20
|
+
filters: string[];
|
|
21
|
+
logFile?: string;
|
|
22
|
+
debug: boolean;
|
|
23
|
+
dryRun: boolean;
|
|
24
|
+
headless: boolean;
|
|
25
|
+
web: boolean;
|
|
26
|
+
webPort: number;
|
|
27
|
+
webHost: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Resolve paths relative to the caller's cwd, not the script's cwd
|
|
31
|
+
const callerCwd = process.env.SYMPHONY_CWD || process.cwd();
|
|
32
|
+
|
|
33
|
+
function parseArgs(): CLIOptions {
|
|
34
|
+
const args = process.argv.slice(2);
|
|
35
|
+
const options: CLIOptions = {
|
|
36
|
+
workflowPaths: [],
|
|
37
|
+
filters: [],
|
|
38
|
+
debug: false,
|
|
39
|
+
dryRun: false,
|
|
40
|
+
headless: false,
|
|
41
|
+
web: false,
|
|
42
|
+
webPort: 3000,
|
|
43
|
+
webHost: "0.0.0.0",
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < args.length; i++) {
|
|
47
|
+
const arg = args[i];
|
|
48
|
+
|
|
49
|
+
if (arg === "--workflow" || arg === "-w") {
|
|
50
|
+
// Consume all following non-flag arguments as workflow paths
|
|
51
|
+
while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
|
|
52
|
+
options.workflowPaths.push(resolve(callerCwd, args[++i]));
|
|
53
|
+
}
|
|
54
|
+
} else if (arg === "--filter" || arg === "-f") {
|
|
55
|
+
// Consume all following non-flag arguments as filter strings
|
|
56
|
+
while (i + 1 < args.length && !args[i + 1].startsWith("-")) {
|
|
57
|
+
options.filters.push(args[++i]);
|
|
58
|
+
}
|
|
59
|
+
} else if (arg === "--log" || arg === "-l") {
|
|
60
|
+
options.logFile = args[++i];
|
|
61
|
+
} else if (arg === "--debug" || arg === "-d") {
|
|
62
|
+
options.debug = true;
|
|
63
|
+
} else if (arg === "--dry-run") {
|
|
64
|
+
options.dryRun = true;
|
|
65
|
+
} else if (arg === "--headless") {
|
|
66
|
+
options.headless = true;
|
|
67
|
+
} else if (arg === "--web") {
|
|
68
|
+
options.web = true;
|
|
69
|
+
} else if (arg === "--web-port") {
|
|
70
|
+
options.webPort = parseInt(args[++i], 10);
|
|
71
|
+
} else if (arg === "--web-host") {
|
|
72
|
+
options.webHost = args[++i];
|
|
73
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
74
|
+
printHelp();
|
|
75
|
+
process.exit(0);
|
|
76
|
+
} else if (arg === "--version" || arg === "-v") {
|
|
77
|
+
console.log(`Symphony v${pkg.version}`);
|
|
78
|
+
process.exit(0);
|
|
79
|
+
} else if (!arg.startsWith("-")) {
|
|
80
|
+
options.workflowPaths.push(resolve(callerCwd, arg));
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Auto-detect workflows/*.md if no paths specified
|
|
85
|
+
if (options.workflowPaths.length === 0) {
|
|
86
|
+
const workflowsDir = resolve(callerCwd, "workflows");
|
|
87
|
+
if (existsSync(workflowsDir)) {
|
|
88
|
+
let mdFiles = readdirSync(workflowsDir)
|
|
89
|
+
.filter((f) => f.endsWith(".md"))
|
|
90
|
+
.sort()
|
|
91
|
+
.map((f) => join(workflowsDir, f));
|
|
92
|
+
|
|
93
|
+
// Apply filters if specified
|
|
94
|
+
if (options.filters.length > 0) {
|
|
95
|
+
const allFiles = mdFiles;
|
|
96
|
+
mdFiles = mdFiles.filter((f) => {
|
|
97
|
+
const name = basename(f).toLowerCase();
|
|
98
|
+
return options.filters.some((filter) => name.includes(filter.toLowerCase()));
|
|
99
|
+
});
|
|
100
|
+
if (mdFiles.length === 0) {
|
|
101
|
+
const available = allFiles.map((f) => basename(f)).join(", ");
|
|
102
|
+
console.error(`No workflows matched filter(s): ${options.filters.join(", ")}`);
|
|
103
|
+
console.error(`Available workflows: ${available}`);
|
|
104
|
+
process.exit(1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (mdFiles.length > 0) {
|
|
109
|
+
options.workflowPaths.push(...mdFiles);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (options.workflowPaths.length === 0) {
|
|
113
|
+
console.error("No workflow files found. Create a workflows/ directory with .md files, or specify paths explicitly.");
|
|
114
|
+
console.error("Run 'symphony --help' for usage information.");
|
|
115
|
+
process.exit(1);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return options;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function printHelp(): void {
|
|
123
|
+
console.log(`
|
|
124
|
+
Symphony - Coding Agent Orchestrator
|
|
125
|
+
|
|
126
|
+
Usage: symphony [options] [workflow-paths...]
|
|
127
|
+
|
|
128
|
+
Options:
|
|
129
|
+
-w, --workflow <paths...> Workflow file(s) (default: workflows/*.md)
|
|
130
|
+
-f, --filter <strings...> Filter auto-discovered workflows by filename substring
|
|
131
|
+
-l, --log <path> Log file path (appends JSON lines)
|
|
132
|
+
--dry-run Render prompts for matching issues and print them (no agent launched)
|
|
133
|
+
--headless Run without TUI (plain log output)
|
|
134
|
+
--web Start web dashboard (implies --headless)
|
|
135
|
+
--web-port <port> Web dashboard port (default: 3000)
|
|
136
|
+
--web-host <host> Web dashboard bind address (default: 0.0.0.0)
|
|
137
|
+
-d, --debug Debug mode: verbose logging + save prompts and agent transcripts to ~/.symphony/logs/
|
|
138
|
+
-h, --help Show this help message
|
|
139
|
+
-v, --version Show version
|
|
140
|
+
|
|
141
|
+
Examples:
|
|
142
|
+
symphony # Auto-detect workflows/*.md
|
|
143
|
+
symphony -f github # Only github-related workflows
|
|
144
|
+
symphony -f review -f dev # Review and dev workflows
|
|
145
|
+
symphony ./my-workflow.md # Run with custom workflow
|
|
146
|
+
symphony -w dev.md qa.md # Override with specific workflows
|
|
147
|
+
symphony --headless # Run without TUI
|
|
148
|
+
symphony --web # Run with web dashboard
|
|
149
|
+
symphony --web --web-port 8080 # Web dashboard on port 8080
|
|
150
|
+
symphony --dry-run # Preview rendered prompts
|
|
151
|
+
|
|
152
|
+
Environment Variables:
|
|
153
|
+
LINEAR_API_KEY Linear API key (required)
|
|
154
|
+
`);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// ── Main ────────────────────────────────────────────────────────
|
|
158
|
+
|
|
159
|
+
async function main(): Promise<void> {
|
|
160
|
+
const options = parseArgs();
|
|
161
|
+
|
|
162
|
+
// Validate all workflow files exist
|
|
163
|
+
for (const path of options.workflowPaths) {
|
|
164
|
+
if (!existsSync(path)) {
|
|
165
|
+
console.error(`Workflow file not found: ${path}`);
|
|
166
|
+
process.exit(1);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Dry run mode always runs headless (only supports single workflow)
|
|
171
|
+
if (options.dryRun) {
|
|
172
|
+
await runDryRun(options);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (options.web) {
|
|
177
|
+
await runWeb(options);
|
|
178
|
+
} else if (options.headless) {
|
|
179
|
+
await runHeadless(options);
|
|
180
|
+
} else {
|
|
181
|
+
await runTui(options);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ── TUI Mode ────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
async function runTui(options: CLIOptions): Promise<void> {
|
|
188
|
+
// Remove default console sink — TUI will handle all rendering
|
|
189
|
+
logger.clearSinks();
|
|
190
|
+
|
|
191
|
+
const renderer = await createCliRenderer({
|
|
192
|
+
exitOnCtrlC: false,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
createRoot(renderer).render(
|
|
196
|
+
React.createElement(App, {
|
|
197
|
+
workflowPaths: options.workflowPaths,
|
|
198
|
+
logFile: options.logFile,
|
|
199
|
+
debug: options.debug,
|
|
200
|
+
renderer,
|
|
201
|
+
})
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── Dry Run Mode ────────────────────────────────────────────────
|
|
206
|
+
|
|
207
|
+
async function runDryRun(options: CLIOptions): Promise<void> {
|
|
208
|
+
const { Orchestrator } = await import("./orchestrator/orchestrator.js");
|
|
209
|
+
|
|
210
|
+
if (options.debug) {
|
|
211
|
+
logger.setMinLevel("debug");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Dry run uses first workflow only
|
|
215
|
+
const orchestrator = new Orchestrator({
|
|
216
|
+
workflowPath: options.workflowPaths[0],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
await orchestrator.dryRun();
|
|
221
|
+
} catch (err) {
|
|
222
|
+
logger.error(`Dry run failed: ${(err as Error).message}`);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Shared Orchestrator Creation ─────────────────────────────────
|
|
228
|
+
|
|
229
|
+
interface OrchestratorHandle {
|
|
230
|
+
start(): Promise<void>;
|
|
231
|
+
stop(): Promise<void>;
|
|
232
|
+
forcePoll(): Promise<void>;
|
|
233
|
+
getSnapshot(): any;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function createOrchestrator(options: CLIOptions): Promise<OrchestratorHandle> {
|
|
237
|
+
if (options.workflowPaths.length > 1) {
|
|
238
|
+
const { MultiOrchestrator } = await import("./orchestrator/multi-orchestrator.js");
|
|
239
|
+
return new MultiOrchestrator({ workflowPaths: options.workflowPaths, debug: options.debug });
|
|
240
|
+
}
|
|
241
|
+
const { Orchestrator } = await import("./orchestrator/orchestrator.js");
|
|
242
|
+
return new Orchestrator({ workflowPath: options.workflowPaths[0], debug: options.debug });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ── Web Mode ────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
async function runWeb(options: CLIOptions): Promise<void> {
|
|
248
|
+
if (options.debug) {
|
|
249
|
+
logger.setMinLevel("debug");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (options.logFile) {
|
|
253
|
+
logger.addSink(createFileSink(options.logFile));
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const orchestrator = await createOrchestrator(options);
|
|
257
|
+
|
|
258
|
+
const { startWebServer } = await import("./web/server.js");
|
|
259
|
+
const webServer = startWebServer({
|
|
260
|
+
port: options.webPort,
|
|
261
|
+
host: options.webHost,
|
|
262
|
+
orchestrator,
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const shutdown = async (signal: string) => {
|
|
266
|
+
logger.info(`Received ${signal}, shutting down...`);
|
|
267
|
+
webServer.stop();
|
|
268
|
+
await orchestrator.stop();
|
|
269
|
+
process.exit(0);
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
273
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
274
|
+
|
|
275
|
+
process.on("uncaughtException", (err) => {
|
|
276
|
+
logger.error(`Uncaught exception: ${err.message}`, { stack: err.stack });
|
|
277
|
+
process.exit(1);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
process.on("unhandledRejection", (reason) => {
|
|
281
|
+
logger.error(`Unhandled rejection: ${reason}`);
|
|
282
|
+
process.exit(1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
try {
|
|
286
|
+
await orchestrator.start();
|
|
287
|
+
const label = options.workflowPaths.length > 1
|
|
288
|
+
? `${options.workflowPaths.length} workflows`
|
|
289
|
+
: "1 workflow";
|
|
290
|
+
logger.info(`Symphony is running (${label}) with web dashboard.`);
|
|
291
|
+
} catch (err) {
|
|
292
|
+
logger.error(`Failed to start: ${(err as Error).message}`);
|
|
293
|
+
webServer.stop();
|
|
294
|
+
process.exit(1);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ── Headless Mode ───────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
async function runHeadless(options: CLIOptions): Promise<void> {
|
|
301
|
+
if (options.debug) {
|
|
302
|
+
logger.setMinLevel("debug");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (options.logFile) {
|
|
306
|
+
logger.addSink(createFileSink(options.logFile));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const orchestrator = await createOrchestrator(options);
|
|
310
|
+
|
|
311
|
+
const shutdown = async (signal: string) => {
|
|
312
|
+
logger.info(`Received ${signal}, shutting down...`);
|
|
313
|
+
await orchestrator.stop();
|
|
314
|
+
process.exit(0);
|
|
315
|
+
};
|
|
316
|
+
|
|
317
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
318
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
319
|
+
|
|
320
|
+
process.on("uncaughtException", (err) => {
|
|
321
|
+
logger.error(`Uncaught exception: ${err.message}`, { stack: err.stack });
|
|
322
|
+
process.exit(1);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
process.on("unhandledRejection", (reason) => {
|
|
326
|
+
logger.error(`Unhandled rejection: ${reason}`);
|
|
327
|
+
process.exit(1);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
try {
|
|
331
|
+
await orchestrator.start();
|
|
332
|
+
const label = options.workflowPaths.length > 1
|
|
333
|
+
? `${options.workflowPaths.length} workflows`
|
|
334
|
+
: "1 workflow";
|
|
335
|
+
logger.info(`Symphony is running (${label}). Press Ctrl+C to stop.`);
|
|
336
|
+
|
|
337
|
+
setInterval(() => {
|
|
338
|
+
const snapshot = orchestrator.getSnapshot();
|
|
339
|
+
if (snapshot) {
|
|
340
|
+
logger.info("Status", {
|
|
341
|
+
running: snapshot.running.length,
|
|
342
|
+
retrying: snapshot.retrying.length,
|
|
343
|
+
total_tokens: snapshot.token_totals.total_tokens,
|
|
344
|
+
seconds_running: Math.round(snapshot.token_totals.seconds_running),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
}, 60000);
|
|
348
|
+
} catch (err) {
|
|
349
|
+
logger.error(`Failed to start: ${(err as Error).message}`);
|
|
350
|
+
process.exit(1);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
main();
|