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,142 @@
|
|
|
1
|
+
import { useState, useMemo } from "react";
|
|
2
|
+
import { Card, CardHeader, CardTitle, CardContent } from "./ui/card";
|
|
3
|
+
import { Badge } from "./ui/badge";
|
|
4
|
+
import { formatDuration, formatElapsed } from "../lib/utils";
|
|
5
|
+
import type { RuntimeSnapshot } from "../lib/use-sse";
|
|
6
|
+
|
|
7
|
+
interface StatsCardsProps {
|
|
8
|
+
snapshot: RuntimeSnapshot | null;
|
|
9
|
+
onAgentClick?: (issueIdentifier: string) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function StatsCards({ snapshot, onAgentClick }: StatsCardsProps) {
|
|
13
|
+
const [showModal, setShowModal] = useState(false);
|
|
14
|
+
|
|
15
|
+
const running = snapshot?.running.length ?? 0;
|
|
16
|
+
const workflows = snapshot?.workflows ?? [];
|
|
17
|
+
const totalSlots = workflows.reduce((s, w) => s + w.max_concurrent_agents, 0);
|
|
18
|
+
const seconds = snapshot?.token_totals.seconds_running ?? 0;
|
|
19
|
+
|
|
20
|
+
// Group running agents by workflow name for the modal
|
|
21
|
+
const agentsByWorkflow = useMemo(() => {
|
|
22
|
+
if (!snapshot) return new Map<string, RuntimeSnapshot["running"]>();
|
|
23
|
+
const map = new Map<string, RuntimeSnapshot["running"]>();
|
|
24
|
+
for (const agent of snapshot.running) {
|
|
25
|
+
const key = agent.workflow ?? "default";
|
|
26
|
+
const list = map.get(key);
|
|
27
|
+
if (list) list.push(agent);
|
|
28
|
+
else map.set(key, [agent]);
|
|
29
|
+
}
|
|
30
|
+
return map;
|
|
31
|
+
}, [snapshot]);
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<>
|
|
35
|
+
<div className="grid grid-cols-3 gap-3 sm:gap-4">
|
|
36
|
+
<Card>
|
|
37
|
+
<CardHeader>
|
|
38
|
+
<CardTitle>Running Agents</CardTitle>
|
|
39
|
+
</CardHeader>
|
|
40
|
+
<CardContent>
|
|
41
|
+
<div className="text-2xl font-bold text-success">{running}</div>
|
|
42
|
+
</CardContent>
|
|
43
|
+
</Card>
|
|
44
|
+
|
|
45
|
+
<Card
|
|
46
|
+
className="cursor-pointer hover:border-muted-foreground transition-colors"
|
|
47
|
+
onClick={() => setShowModal(true)}
|
|
48
|
+
>
|
|
49
|
+
<CardHeader>
|
|
50
|
+
<CardTitle>Workflows</CardTitle>
|
|
51
|
+
</CardHeader>
|
|
52
|
+
<CardContent>
|
|
53
|
+
<div className="text-2xl font-bold">
|
|
54
|
+
{workflows.length}
|
|
55
|
+
<span className="text-muted-foreground text-base font-normal ml-1">
|
|
56
|
+
({totalSlots} slots)
|
|
57
|
+
</span>
|
|
58
|
+
</div>
|
|
59
|
+
</CardContent>
|
|
60
|
+
</Card>
|
|
61
|
+
|
|
62
|
+
<Card>
|
|
63
|
+
<CardHeader>
|
|
64
|
+
<CardTitle>Agent Runtime</CardTitle>
|
|
65
|
+
</CardHeader>
|
|
66
|
+
<CardContent>
|
|
67
|
+
<div className="text-2xl font-bold">{formatDuration(seconds)}</div>
|
|
68
|
+
</CardContent>
|
|
69
|
+
</Card>
|
|
70
|
+
</div>
|
|
71
|
+
|
|
72
|
+
{showModal && (
|
|
73
|
+
<div
|
|
74
|
+
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60"
|
|
75
|
+
onClick={() => setShowModal(false)}
|
|
76
|
+
>
|
|
77
|
+
<div
|
|
78
|
+
className="bg-card border border-border rounded-lg shadow-xl w-full max-w-lg mx-4"
|
|
79
|
+
onClick={(e) => e.stopPropagation()}
|
|
80
|
+
>
|
|
81
|
+
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
82
|
+
<h3 className="text-lg font-semibold">Workflows</h3>
|
|
83
|
+
<button
|
|
84
|
+
className="text-muted-foreground hover:text-foreground text-xl leading-none px-2"
|
|
85
|
+
onClick={() => setShowModal(false)}
|
|
86
|
+
>
|
|
87
|
+
×
|
|
88
|
+
</button>
|
|
89
|
+
</div>
|
|
90
|
+
|
|
91
|
+
<div className="p-4 space-y-3">
|
|
92
|
+
{workflows.length === 0 ? (
|
|
93
|
+
<div className="text-center text-muted-foreground py-4">No workflows loaded</div>
|
|
94
|
+
) : (
|
|
95
|
+
workflows.map((wf) => {
|
|
96
|
+
const available = wf.max_concurrent_agents - wf.running_count;
|
|
97
|
+
const agents = agentsByWorkflow.get(wf.name) ?? [];
|
|
98
|
+
return (
|
|
99
|
+
<div key={wf.name} className="bg-muted/20 rounded-lg p-3">
|
|
100
|
+
<div className="flex items-center justify-between mb-2">
|
|
101
|
+
<span className="font-medium">{wf.name}</span>
|
|
102
|
+
<Badge variant={wf.running_count > 0 ? "success" : "secondary"} className="text-xs">
|
|
103
|
+
{wf.running_count} / {wf.max_concurrent_agents} agents
|
|
104
|
+
</Badge>
|
|
105
|
+
</div>
|
|
106
|
+
<div className="w-full bg-muted rounded-full h-1.5">
|
|
107
|
+
<div
|
|
108
|
+
className="bg-success rounded-full h-1.5 transition-all"
|
|
109
|
+
style={{ width: `${wf.max_concurrent_agents > 0 ? (wf.running_count / wf.max_concurrent_agents) * 100 : 0}%` }}
|
|
110
|
+
/>
|
|
111
|
+
</div>
|
|
112
|
+
<div className="text-xs text-muted-foreground mt-1.5">
|
|
113
|
+
{available} slot{available !== 1 ? "s" : ""} available
|
|
114
|
+
</div>
|
|
115
|
+
|
|
116
|
+
{agents.map((agent) => (
|
|
117
|
+
<div
|
|
118
|
+
key={agent.issue_id}
|
|
119
|
+
className="flex items-center justify-between mt-2 bg-muted/20 rounded px-2.5 py-1.5 text-sm cursor-pointer hover:bg-muted/40 transition-colors"
|
|
120
|
+
onClick={() => {
|
|
121
|
+
onAgentClick?.(agent.issue_identifier);
|
|
122
|
+
setShowModal(false);
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
<span className="font-mono text-xs">{agent.issue_identifier}</span>
|
|
126
|
+
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
|
127
|
+
<span>{agent.turn_count} turns</span>
|
|
128
|
+
<span>{formatElapsed(agent.started_at)}</span>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
))}
|
|
132
|
+
</div>
|
|
133
|
+
);
|
|
134
|
+
})
|
|
135
|
+
)}
|
|
136
|
+
</div>
|
|
137
|
+
</div>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
</>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
|
|
5
|
+
const badgeVariants = cva(
|
|
6
|
+
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "border-transparent bg-primary text-primary-foreground",
|
|
11
|
+
secondary: "border-transparent bg-secondary text-secondary-foreground",
|
|
12
|
+
destructive: "border-transparent bg-destructive text-white",
|
|
13
|
+
outline: "text-foreground",
|
|
14
|
+
success: "border-transparent bg-success/20 text-success",
|
|
15
|
+
warning: "border-transparent bg-warning/20 text-warning",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
defaultVariants: {
|
|
19
|
+
variant: "default",
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
|
25
|
+
|
|
26
|
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
27
|
+
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export { Badge, badgeVariants };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "../../lib/utils";
|
|
4
|
+
|
|
5
|
+
const buttonVariants = cva(
|
|
6
|
+
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
|
11
|
+
destructive: "bg-destructive text-white hover:bg-destructive/90",
|
|
12
|
+
outline: "border border-border bg-transparent hover:bg-accent hover:text-accent-foreground",
|
|
13
|
+
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
14
|
+
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
15
|
+
},
|
|
16
|
+
size: {
|
|
17
|
+
default: "h-9 px-4 py-2",
|
|
18
|
+
sm: "h-8 rounded-md px-3 text-xs",
|
|
19
|
+
lg: "h-10 rounded-md px-8",
|
|
20
|
+
icon: "h-9 w-9",
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
defaultVariants: {
|
|
24
|
+
variant: "default",
|
|
25
|
+
size: "default",
|
|
26
|
+
},
|
|
27
|
+
}
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {}
|
|
31
|
+
|
|
32
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
33
|
+
({ className, variant, size, ...props }, ref) => (
|
|
34
|
+
<button className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
|
35
|
+
)
|
|
36
|
+
);
|
|
37
|
+
Button.displayName = "Button";
|
|
38
|
+
|
|
39
|
+
export { Button, buttonVariants };
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cn } from "../../lib/utils";
|
|
3
|
+
|
|
4
|
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
5
|
+
({ className, ...props }, ref) => (
|
|
6
|
+
<div ref={ref} className={cn("rounded-lg border border-border bg-card text-card-foreground shadow-sm", className)} {...props} />
|
|
7
|
+
)
|
|
8
|
+
);
|
|
9
|
+
Card.displayName = "Card";
|
|
10
|
+
|
|
11
|
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
12
|
+
({ className, ...props }, ref) => (
|
|
13
|
+
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-4", className)} {...props} />
|
|
14
|
+
)
|
|
15
|
+
);
|
|
16
|
+
CardHeader.displayName = "CardHeader";
|
|
17
|
+
|
|
18
|
+
const CardTitle = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
19
|
+
({ className, ...props }, ref) => (
|
|
20
|
+
<div ref={ref} className={cn("text-sm font-medium text-muted-foreground", className)} {...props} />
|
|
21
|
+
)
|
|
22
|
+
);
|
|
23
|
+
CardTitle.displayName = "CardTitle";
|
|
24
|
+
|
|
25
|
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
26
|
+
({ className, ...props }, ref) => (
|
|
27
|
+
<div ref={ref} className={cn("p-4 pt-0", className)} {...props} />
|
|
28
|
+
)
|
|
29
|
+
);
|
|
30
|
+
CardContent.displayName = "CardContent";
|
|
31
|
+
|
|
32
|
+
export { Card, CardHeader, CardTitle, CardContent };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
@import "tailwindcss";
|
|
2
|
+
|
|
3
|
+
@theme {
|
|
4
|
+
--color-background: oklch(0.145 0 0);
|
|
5
|
+
--color-foreground: oklch(0.985 0 0);
|
|
6
|
+
--color-card: oklch(0.17 0 0);
|
|
7
|
+
--color-card-foreground: oklch(0.985 0 0);
|
|
8
|
+
--color-muted: oklch(0.269 0 0);
|
|
9
|
+
--color-muted-foreground: oklch(0.708 0 0);
|
|
10
|
+
--color-border: oklch(0.3 0 0);
|
|
11
|
+
--color-accent: oklch(0.269 0 0);
|
|
12
|
+
--color-accent-foreground: oklch(0.985 0 0);
|
|
13
|
+
--color-destructive: oklch(0.577 0.245 27.325);
|
|
14
|
+
--color-primary: oklch(0.985 0 0);
|
|
15
|
+
--color-primary-foreground: oklch(0.205 0 0);
|
|
16
|
+
--color-secondary: oklch(0.269 0 0);
|
|
17
|
+
--color-secondary-foreground: oklch(0.985 0 0);
|
|
18
|
+
--color-ring: oklch(0.556 0 0);
|
|
19
|
+
--radius: 0.5rem;
|
|
20
|
+
|
|
21
|
+
--color-success: oklch(0.7 0.2 145);
|
|
22
|
+
--color-warning: oklch(0.75 0.18 85);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
body {
|
|
26
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", sans-serif;
|
|
27
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
6
|
+
<title>Better Symphony</title>
|
|
7
|
+
<link rel="stylesheet" href="/app.css">
|
|
8
|
+
</head>
|
|
9
|
+
<body class="bg-background text-foreground min-h-screen">
|
|
10
|
+
<div id="root"></div>
|
|
11
|
+
<script src="/main.js" type="module"></script>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback, useRef } from "react";
|
|
2
|
+
|
|
3
|
+
export interface LogLine {
|
|
4
|
+
source: string;
|
|
5
|
+
message: string;
|
|
6
|
+
type: "line" | "error" | "info" | "comment";
|
|
7
|
+
timestamp: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RuntimeSnapshot {
|
|
11
|
+
running: Array<{
|
|
12
|
+
issue_id: string;
|
|
13
|
+
issue_identifier: string;
|
|
14
|
+
state: string;
|
|
15
|
+
started_at: string;
|
|
16
|
+
turn_count: number;
|
|
17
|
+
session_id: string | null;
|
|
18
|
+
workflow: string | null;
|
|
19
|
+
}>;
|
|
20
|
+
retrying: Array<{
|
|
21
|
+
issue_id: string;
|
|
22
|
+
identifier: string;
|
|
23
|
+
attempt: number;
|
|
24
|
+
due_at: string;
|
|
25
|
+
error: string | null;
|
|
26
|
+
workflow: string | null;
|
|
27
|
+
}>;
|
|
28
|
+
workflows: Array<{
|
|
29
|
+
name: string;
|
|
30
|
+
max_concurrent_agents: number;
|
|
31
|
+
running_count: number;
|
|
32
|
+
}>;
|
|
33
|
+
token_totals: {
|
|
34
|
+
input_tokens: number;
|
|
35
|
+
output_tokens: number;
|
|
36
|
+
total_tokens: number;
|
|
37
|
+
seconds_running: number;
|
|
38
|
+
};
|
|
39
|
+
rate_limits: {
|
|
40
|
+
requests_remaining: number;
|
|
41
|
+
requests_limit: number;
|
|
42
|
+
tokens_remaining: number;
|
|
43
|
+
tokens_limit: number;
|
|
44
|
+
reset_at: string;
|
|
45
|
+
} | null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MAX_LOGS = 1000;
|
|
49
|
+
|
|
50
|
+
export function useSSE() {
|
|
51
|
+
const [snapshot, setSnapshot] = useState<RuntimeSnapshot | null>(null);
|
|
52
|
+
const [logs, setLogs] = useState<LogLine[]>([]);
|
|
53
|
+
const [connected, setConnected] = useState(false);
|
|
54
|
+
const startTimeRef = useRef(Date.now());
|
|
55
|
+
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
const es = new EventSource("/api/events");
|
|
58
|
+
|
|
59
|
+
es.onopen = () => setConnected(true);
|
|
60
|
+
|
|
61
|
+
es.onmessage = (e) => {
|
|
62
|
+
const data = JSON.parse(e.data);
|
|
63
|
+
setSnapshot(data.snapshot);
|
|
64
|
+
|
|
65
|
+
if (data.type === "initial") {
|
|
66
|
+
setLogs(data.logs ?? []);
|
|
67
|
+
} else if (data.logs?.length) {
|
|
68
|
+
setLogs((prev) => [...prev, ...data.logs].slice(-MAX_LOGS));
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
es.onerror = () => {
|
|
73
|
+
setConnected(false);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
return () => es.close();
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
const forcePoll = useCallback(async () => {
|
|
80
|
+
await fetch("/api/force-poll", { method: "POST" });
|
|
81
|
+
}, []);
|
|
82
|
+
|
|
83
|
+
const restart = useCallback(async () => {
|
|
84
|
+
await fetch("/api/restart", { method: "POST" });
|
|
85
|
+
}, []);
|
|
86
|
+
|
|
87
|
+
const shutdown = useCallback(async () => {
|
|
88
|
+
await fetch("/api/shutdown", { method: "POST" });
|
|
89
|
+
}, []);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
snapshot,
|
|
93
|
+
logs,
|
|
94
|
+
connected,
|
|
95
|
+
startTime: startTimeRef.current,
|
|
96
|
+
actions: { forcePoll, restart, shutdown },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type ClassValue, clsx } from "clsx";
|
|
2
|
+
import { twMerge } from "tailwind-merge";
|
|
3
|
+
|
|
4
|
+
export function cn(...inputs: ClassValue[]) {
|
|
5
|
+
return twMerge(clsx(inputs));
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function formatTokens(n: number): string {
|
|
9
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
10
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
11
|
+
return String(n);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatDuration(seconds: number): string {
|
|
15
|
+
if (seconds < 60) return `${Math.round(seconds)}s`;
|
|
16
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.round(seconds % 60)}s`;
|
|
17
|
+
const h = Math.floor(seconds / 3600);
|
|
18
|
+
const m = Math.floor((seconds % 3600) / 60);
|
|
19
|
+
return `${h}h ${m}m`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatElapsed(startedAt: string): string {
|
|
23
|
+
const ms = Date.now() - new Date(startedAt).getTime();
|
|
24
|
+
return formatDuration(ms / 1000);
|
|
25
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace Hooks
|
|
3
|
+
* Shell script execution for workspace lifecycle events
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn } from "child_process";
|
|
7
|
+
import { logger } from "../logging/logger.js";
|
|
8
|
+
|
|
9
|
+
export interface HookResult {
|
|
10
|
+
success: boolean;
|
|
11
|
+
stdout: string;
|
|
12
|
+
stderr: string;
|
|
13
|
+
exitCode: number | null;
|
|
14
|
+
timedOut: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Execute a hook script in a workspace directory
|
|
19
|
+
*/
|
|
20
|
+
export async function executeHook(
|
|
21
|
+
hookName: string,
|
|
22
|
+
script: string,
|
|
23
|
+
cwd: string,
|
|
24
|
+
timeoutMs: number
|
|
25
|
+
): Promise<HookResult> {
|
|
26
|
+
logger.debug(`Executing hook ${hookName}`, { hook: hookName, cwd });
|
|
27
|
+
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const proc = spawn("bash", ["-lc", script], {
|
|
30
|
+
cwd,
|
|
31
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
32
|
+
env: {
|
|
33
|
+
...process.env,
|
|
34
|
+
SYMPHONY_HOOK: hookName,
|
|
35
|
+
SYMPHONY_WORKSPACE: cwd,
|
|
36
|
+
},
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let stdout = "";
|
|
40
|
+
let stderr = "";
|
|
41
|
+
let timedOut = false;
|
|
42
|
+
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
timedOut = true;
|
|
45
|
+
proc.kill("SIGTERM");
|
|
46
|
+
setTimeout(() => {
|
|
47
|
+
if (!proc.killed) {
|
|
48
|
+
proc.kill("SIGKILL");
|
|
49
|
+
}
|
|
50
|
+
}, 5000);
|
|
51
|
+
}, timeoutMs);
|
|
52
|
+
|
|
53
|
+
proc.stdout?.on("data", (data) => {
|
|
54
|
+
stdout += data.toString();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
proc.stderr?.on("data", (data) => {
|
|
58
|
+
stderr += data.toString();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
proc.on("close", (exitCode) => {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
|
|
64
|
+
if (timedOut) {
|
|
65
|
+
logger.warn(`Hook ${hookName} timed out after ${timeoutMs}ms`, { hook: hookName });
|
|
66
|
+
} else if (exitCode !== 0) {
|
|
67
|
+
logger.warn(`Hook ${hookName} failed with exit code ${exitCode}`, {
|
|
68
|
+
hook: hookName,
|
|
69
|
+
exitCode,
|
|
70
|
+
stderr: stderr.slice(0, 500),
|
|
71
|
+
});
|
|
72
|
+
} else {
|
|
73
|
+
logger.debug(`Hook ${hookName} completed successfully`, { hook: hookName });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
resolve({
|
|
77
|
+
success: !timedOut && exitCode === 0,
|
|
78
|
+
stdout,
|
|
79
|
+
stderr,
|
|
80
|
+
exitCode,
|
|
81
|
+
timedOut,
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
proc.on("error", (err) => {
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
logger.error(`Hook ${hookName} failed to execute: ${err.message}`, { hook: hookName });
|
|
88
|
+
resolve({
|
|
89
|
+
success: false,
|
|
90
|
+
stdout: "",
|
|
91
|
+
stderr: err.message,
|
|
92
|
+
exitCode: null,
|
|
93
|
+
timedOut: false,
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
}
|