prodboard 0.1.2 → 0.2.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/CHANGELOG.md +12 -0
- package/README.md +120 -13
- package/package.json +4 -2
- package/src/agents/claude.ts +107 -0
- package/src/agents/index.ts +18 -0
- package/src/agents/opencode.ts +108 -0
- package/src/agents/types.ts +41 -0
- package/src/commands/install.ts +3 -2
- package/src/commands/schedules.ts +1 -0
- package/src/config.ts +14 -0
- package/src/db.ts +7 -0
- package/src/index.ts +0 -1
- package/src/invocation.ts +13 -66
- package/src/mcp.ts +6 -1
- package/src/opencode-server.ts +54 -0
- package/src/queries/runs.ts +5 -4
- package/src/scheduler.ts +240 -155
- package/src/tmux.ts +68 -0
- package/src/types.ts +17 -0
- package/src/webui/components/board.tsx +32 -0
- package/src/webui/components/layout.tsx +28 -0
- package/src/webui/components/status-badge.tsx +23 -0
- package/src/webui/index.ts +85 -0
- package/src/webui/routes/auth.tsx +57 -0
- package/src/webui/routes/issues.tsx +162 -0
- package/src/webui/routes/runs.tsx +103 -0
- package/src/webui/routes/schedules.tsx +144 -0
- package/src/webui/static/style.ts +47 -0
- package/src/worktree.ts +75 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/config.jsonc +43 -1
package/src/tmux.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
|
|
3
|
+
export class TmuxManager {
|
|
4
|
+
private available: boolean | null = null;
|
|
5
|
+
|
|
6
|
+
isAvailable(): boolean {
|
|
7
|
+
if (this.available !== null) return this.available;
|
|
8
|
+
try {
|
|
9
|
+
const result = Bun.spawnSync(["tmux", "-V"], {
|
|
10
|
+
stdout: "pipe",
|
|
11
|
+
stderr: "pipe",
|
|
12
|
+
});
|
|
13
|
+
this.available = result.exitCode === 0;
|
|
14
|
+
} catch {
|
|
15
|
+
this.available = false;
|
|
16
|
+
}
|
|
17
|
+
return this.available;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
sessionName(runId: string): string {
|
|
21
|
+
return "prodboard-" + runId.slice(0, 8);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
wrapCommand(sessionName: string, cmd: string[], jsonlPath: string): string[] {
|
|
25
|
+
const escaped = cmd.map((arg) => shellEscape(arg)).join(" ");
|
|
26
|
+
const bashCmd = `${escaped} > ${shellEscape(jsonlPath)}; echo $? > ${shellEscape(jsonlPath)}.exit`;
|
|
27
|
+
return [
|
|
28
|
+
"tmux", "new-session", "-d", "-s", sessionName,
|
|
29
|
+
"bash", "-c", bashCmd,
|
|
30
|
+
];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async waitForCompletion(sessionName: string, jsonlPath: string): Promise<number> {
|
|
34
|
+
// Poll until tmux session ends
|
|
35
|
+
while (true) {
|
|
36
|
+
const result = Bun.spawnSync(["tmux", "has-session", "-t", sessionName], {
|
|
37
|
+
stdout: "pipe",
|
|
38
|
+
stderr: "pipe",
|
|
39
|
+
});
|
|
40
|
+
if (result.exitCode !== 0) break;
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Read exit code from .exit file
|
|
45
|
+
const exitFile = `${jsonlPath}.exit`;
|
|
46
|
+
try {
|
|
47
|
+
const code = fs.readFileSync(exitFile, "utf-8").trim();
|
|
48
|
+
const parsed = parseInt(code, 10);
|
|
49
|
+
return Number.isNaN(parsed) ? 1 : parsed;
|
|
50
|
+
} catch {
|
|
51
|
+
return 1;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
killSession(sessionName: string): void {
|
|
56
|
+
try {
|
|
57
|
+
Bun.spawnSync(["tmux", "kill-session", "-t", sessionName], {
|
|
58
|
+
stdout: "pipe",
|
|
59
|
+
stderr: "pipe",
|
|
60
|
+
});
|
|
61
|
+
} catch {}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function shellEscape(arg: string): string {
|
|
66
|
+
if (/^[a-zA-Z0-9_./:=@,-]+$/.test(arg)) return arg;
|
|
67
|
+
return "'" + arg.replace(/'/g, "'\\''") + "'";
|
|
68
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -5,6 +5,14 @@ export interface Config {
|
|
|
5
5
|
idPrefix: string;
|
|
6
6
|
};
|
|
7
7
|
daemon: {
|
|
8
|
+
agent: "claude" | "opencode";
|
|
9
|
+
basePath: string | null;
|
|
10
|
+
useTmux: boolean;
|
|
11
|
+
opencode: {
|
|
12
|
+
serverUrl: string | null;
|
|
13
|
+
model: string | null;
|
|
14
|
+
agent: string | null;
|
|
15
|
+
};
|
|
8
16
|
maxConcurrentRuns: number;
|
|
9
17
|
maxTurns: number;
|
|
10
18
|
hardMaxTurns: number;
|
|
@@ -17,6 +25,12 @@ export interface Config {
|
|
|
17
25
|
nonGitDefaultAllowedTools: string[];
|
|
18
26
|
useWorktrees: "auto" | "always" | "never";
|
|
19
27
|
};
|
|
28
|
+
webui: {
|
|
29
|
+
enabled: boolean;
|
|
30
|
+
port: number;
|
|
31
|
+
hostname: string;
|
|
32
|
+
password: string | null;
|
|
33
|
+
};
|
|
20
34
|
}
|
|
21
35
|
|
|
22
36
|
export interface Issue {
|
|
@@ -72,11 +86,14 @@ export interface Run {
|
|
|
72
86
|
cost_usd: number | null;
|
|
73
87
|
tools_used: string | null;
|
|
74
88
|
issues_touched: string | null;
|
|
89
|
+
tmux_session: string | null;
|
|
90
|
+
agent: string;
|
|
75
91
|
schedule_name?: string;
|
|
76
92
|
}
|
|
77
93
|
|
|
78
94
|
export interface EnvironmentInfo {
|
|
79
95
|
hasGit: boolean;
|
|
80
96
|
hasClaude: boolean;
|
|
97
|
+
hasOpencode: boolean;
|
|
81
98
|
worktreeSupported: boolean;
|
|
82
99
|
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FC } from "hono/jsx";
|
|
2
|
+
import type { Issue } from "../../types.ts";
|
|
3
|
+
import { StatusBadge } from "./status-badge.tsx";
|
|
4
|
+
|
|
5
|
+
export const Board: FC<{ issues: Issue[]; statuses: string[] }> = ({ issues, statuses }) => {
|
|
6
|
+
const grouped: Record<string, Issue[]> = {};
|
|
7
|
+
for (const s of statuses) {
|
|
8
|
+
grouped[s] = [];
|
|
9
|
+
}
|
|
10
|
+
for (const issue of issues) {
|
|
11
|
+
if (!grouped[issue.status]) grouped[issue.status] = [];
|
|
12
|
+
grouped[issue.status].push(issue);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<div class="board">
|
|
17
|
+
{statuses.filter((s) => s !== "archived").map((status) => (
|
|
18
|
+
<div class="board-column" key={status}>
|
|
19
|
+
<h3>
|
|
20
|
+
{status} <span class="count">({(grouped[status] || []).length})</span>
|
|
21
|
+
</h3>
|
|
22
|
+
{(grouped[status] || []).map((issue) => (
|
|
23
|
+
<a href={`/issues/${issue.id}`} class="card" key={issue.id}>
|
|
24
|
+
<div class="card-title">{issue.title}</div>
|
|
25
|
+
<div class="card-meta">{issue.id.slice(0, 8)}</div>
|
|
26
|
+
</a>
|
|
27
|
+
))}
|
|
28
|
+
</div>
|
|
29
|
+
))}
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { FC } from "hono/jsx";
|
|
2
|
+
import { STYLES } from "../static/style.ts";
|
|
3
|
+
|
|
4
|
+
export const Layout: FC<{ title?: string; children: any }> = ({ title, children }) => {
|
|
5
|
+
return (
|
|
6
|
+
<html lang="en">
|
|
7
|
+
<head>
|
|
8
|
+
<meta charset="utf-8" />
|
|
9
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
10
|
+
<title>{title ? `${title} - prodboard` : "prodboard"}</title>
|
|
11
|
+
<style>{STYLES}</style>
|
|
12
|
+
</head>
|
|
13
|
+
<body>
|
|
14
|
+
<nav>
|
|
15
|
+
<div class="nav-inner">
|
|
16
|
+
<a href="/" class="logo">prodboard</a>
|
|
17
|
+
<div class="nav-links">
|
|
18
|
+
<a href="/issues">Issues</a>
|
|
19
|
+
<a href="/schedules">Schedules</a>
|
|
20
|
+
<a href="/runs">Runs</a>
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
</nav>
|
|
24
|
+
<main>{children}</main>
|
|
25
|
+
</body>
|
|
26
|
+
</html>
|
|
27
|
+
);
|
|
28
|
+
};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { FC } from "hono/jsx";
|
|
2
|
+
|
|
3
|
+
const STATUS_COLORS: Record<string, string> = {
|
|
4
|
+
"todo": "#6b7280",
|
|
5
|
+
"in-progress": "#3b82f6",
|
|
6
|
+
"review": "#f59e0b",
|
|
7
|
+
"done": "#10b981",
|
|
8
|
+
"archived": "#9ca3af",
|
|
9
|
+
"running": "#3b82f6",
|
|
10
|
+
"success": "#10b981",
|
|
11
|
+
"failed": "#ef4444",
|
|
12
|
+
"timeout": "#f59e0b",
|
|
13
|
+
"cancelled": "#9ca3af",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const StatusBadge: FC<{ status: string }> = ({ status }) => {
|
|
17
|
+
const color = STATUS_COLORS[status] ?? "#6b7280";
|
|
18
|
+
return (
|
|
19
|
+
<span class="badge" style={`background:${color}`}>
|
|
20
|
+
{status}
|
|
21
|
+
</span>
|
|
22
|
+
);
|
|
23
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { getCookie } from "hono/cookie";
|
|
3
|
+
import { secureHeaders } from "hono/secure-headers";
|
|
4
|
+
import { csrf } from "hono/csrf";
|
|
5
|
+
import crypto from "crypto";
|
|
6
|
+
import type { Database } from "bun:sqlite";
|
|
7
|
+
import type { Config } from "../types.ts";
|
|
8
|
+
import { issueRoutes } from "./routes/issues.tsx";
|
|
9
|
+
import { scheduleRoutes } from "./routes/schedules.tsx";
|
|
10
|
+
import { runRoutes, apiRoutes } from "./routes/runs.tsx";
|
|
11
|
+
import { authRoutes } from "./routes/auth.tsx";
|
|
12
|
+
|
|
13
|
+
function timingSafeCompare(a: string, b: string): boolean {
|
|
14
|
+
const bufA = Buffer.from(a);
|
|
15
|
+
const bufB = Buffer.from(b);
|
|
16
|
+
if (bufA.length !== bufB.length) return false;
|
|
17
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function generateAuthToken(password: string, salt: string): string {
|
|
21
|
+
return crypto.createHmac("sha256", salt).update(password).digest("hex");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createApp(db: Database, config: Config, authSalt?: string): Hono {
|
|
25
|
+
const app = new Hono();
|
|
26
|
+
const salt = authSalt ?? crypto.randomBytes(32).toString("hex");
|
|
27
|
+
|
|
28
|
+
// Security headers
|
|
29
|
+
app.use("*", secureHeaders());
|
|
30
|
+
|
|
31
|
+
// CSRF protection
|
|
32
|
+
app.use("*", csrf());
|
|
33
|
+
|
|
34
|
+
// Error handler
|
|
35
|
+
app.onError((err, c) => {
|
|
36
|
+
if (err.message?.includes("not found") || err.message?.includes("No issue") || err.message?.includes("No schedule")) {
|
|
37
|
+
return c.text("Not found", 404);
|
|
38
|
+
}
|
|
39
|
+
if (err.message?.includes("Invalid status") || err.message?.includes("Invalid cron")) {
|
|
40
|
+
return c.text(err.message, 400);
|
|
41
|
+
}
|
|
42
|
+
console.error("[prodboard] Web UI error:", err.message);
|
|
43
|
+
return c.text("Internal server error", 500);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Auth middleware
|
|
47
|
+
if (config.webui.password !== null) {
|
|
48
|
+
const expectedToken = generateAuthToken(config.webui.password, salt);
|
|
49
|
+
|
|
50
|
+
app.use("*", async (c, next) => {
|
|
51
|
+
const path = c.req.path;
|
|
52
|
+
if (path === "/login" || path === "/logout") {
|
|
53
|
+
return next();
|
|
54
|
+
}
|
|
55
|
+
const cookie = getCookie(c, "prodboard_auth");
|
|
56
|
+
if (!cookie || !timingSafeCompare(cookie, expectedToken)) {
|
|
57
|
+
return c.redirect("/login");
|
|
58
|
+
}
|
|
59
|
+
return next();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Mount routes
|
|
64
|
+
app.route("/", authRoutes(db, config, salt));
|
|
65
|
+
app.route("/issues", issueRoutes(db, config));
|
|
66
|
+
app.route("/schedules", scheduleRoutes(db, config));
|
|
67
|
+
app.route("/runs", runRoutes(db, config));
|
|
68
|
+
app.route("/api", apiRoutes(db, config));
|
|
69
|
+
|
|
70
|
+
// Root redirect
|
|
71
|
+
app.get("/", (c) => c.redirect("/issues"));
|
|
72
|
+
|
|
73
|
+
return app;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function startWebUI(db: Database, config: Config): Promise<any> {
|
|
77
|
+
const app = createApp(db, config);
|
|
78
|
+
const server = Bun.serve({
|
|
79
|
+
fetch: app.fetch,
|
|
80
|
+
port: config.webui.port,
|
|
81
|
+
hostname: config.webui.hostname,
|
|
82
|
+
});
|
|
83
|
+
console.error(`[prodboard] Web UI started at http://${config.webui.hostname}:${config.webui.port}`);
|
|
84
|
+
return server;
|
|
85
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import crypto from "crypto";
|
|
3
|
+
import { Layout } from "../components/layout.tsx";
|
|
4
|
+
import type { Database } from "bun:sqlite";
|
|
5
|
+
import type { Config } from "../../types.ts";
|
|
6
|
+
|
|
7
|
+
function timingSafeCompare(a: string, b: string): boolean {
|
|
8
|
+
const bufA = Buffer.from(a);
|
|
9
|
+
const bufB = Buffer.from(b);
|
|
10
|
+
if (bufA.length !== bufB.length) return false;
|
|
11
|
+
return crypto.timingSafeEqual(bufA, bufB);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function generateAuthToken(password: string, salt: string): string {
|
|
15
|
+
return crypto.createHmac("sha256", salt).update(password).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function authRoutes(_db: Database, _config: Config, authSalt: string) {
|
|
19
|
+
const app = new Hono();
|
|
20
|
+
|
|
21
|
+
app.get("/login", (c) => {
|
|
22
|
+
const error = c.req.query("error");
|
|
23
|
+
return c.html(
|
|
24
|
+
<Layout title="Login">
|
|
25
|
+
<div class="login-box">
|
|
26
|
+
<h1>Login</h1>
|
|
27
|
+
{error && <div class="flash">Invalid password</div>}
|
|
28
|
+
<form method="post" action="/login">
|
|
29
|
+
<div class="form-row">
|
|
30
|
+
<label for="password">Password</label>
|
|
31
|
+
<input type="password" name="password" id="password" required autofocus />
|
|
32
|
+
</div>
|
|
33
|
+
<button type="submit" class="btn btn-primary">Login</button>
|
|
34
|
+
</form>
|
|
35
|
+
</div>
|
|
36
|
+
</Layout>
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
app.post("/login", async (c) => {
|
|
41
|
+
const body = await c.req.parseBody();
|
|
42
|
+
const password = body.password as string;
|
|
43
|
+
if (timingSafeCompare(password, _config.webui.password!)) {
|
|
44
|
+
const token = generateAuthToken(password, authSalt);
|
|
45
|
+
c.header("Set-Cookie", `prodboard_auth=${token}; Path=/; HttpOnly; SameSite=Strict`);
|
|
46
|
+
return c.redirect("/");
|
|
47
|
+
}
|
|
48
|
+
return c.redirect("/login?error=1");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
app.post("/logout", (c) => {
|
|
52
|
+
c.header("Set-Cookie", `prodboard_auth=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0`);
|
|
53
|
+
return c.redirect("/login");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
return app;
|
|
57
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Database } from "bun:sqlite";
|
|
3
|
+
import type { Config } from "../../types.ts";
|
|
4
|
+
import { Layout } from "../components/layout.tsx";
|
|
5
|
+
import { Board } from "../components/board.tsx";
|
|
6
|
+
import { StatusBadge } from "../components/status-badge.tsx";
|
|
7
|
+
import {
|
|
8
|
+
listIssues, createIssue, getIssueByPrefix,
|
|
9
|
+
updateIssue, deleteIssue, validateStatus,
|
|
10
|
+
} from "../../queries/issues.ts";
|
|
11
|
+
import { createComment, listComments } from "../../queries/comments.ts";
|
|
12
|
+
|
|
13
|
+
export function issueRoutes(db: Database, config: Config) {
|
|
14
|
+
const app = new Hono();
|
|
15
|
+
|
|
16
|
+
app.get("/", (c) => {
|
|
17
|
+
const { issues } = listIssues(db, { includeArchived: true, limit: 500 });
|
|
18
|
+
return c.html(
|
|
19
|
+
<Layout title="Issues">
|
|
20
|
+
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem">
|
|
21
|
+
<h1>Issues</h1>
|
|
22
|
+
<a href="#new-issue" class="btn btn-primary btn-sm" onclick="document.getElementById('new-issue-form').style.display='block'">New Issue</a>
|
|
23
|
+
</div>
|
|
24
|
+
<div id="new-issue-form" style="display:none;margin-bottom:1rem" class="detail">
|
|
25
|
+
<h2>New Issue</h2>
|
|
26
|
+
<form method="post" action="/issues">
|
|
27
|
+
<div class="form-row">
|
|
28
|
+
<label for="title">Title</label>
|
|
29
|
+
<input type="text" name="title" id="title" required />
|
|
30
|
+
</div>
|
|
31
|
+
<div class="form-row">
|
|
32
|
+
<label for="description">Description</label>
|
|
33
|
+
<textarea name="description" id="description"></textarea>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="form-row">
|
|
36
|
+
<label for="status">Status</label>
|
|
37
|
+
<select name="status" id="status">
|
|
38
|
+
{config.general.statuses.map((s) => (
|
|
39
|
+
<option value={s} selected={s === config.general.defaultStatus}>{s}</option>
|
|
40
|
+
))}
|
|
41
|
+
</select>
|
|
42
|
+
</div>
|
|
43
|
+
<button type="submit" class="btn btn-primary">Create</button>
|
|
44
|
+
</form>
|
|
45
|
+
</div>
|
|
46
|
+
<Board issues={issues} statuses={config.general.statuses} />
|
|
47
|
+
</Layout>
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
app.post("/", async (c) => {
|
|
52
|
+
const body = await c.req.parseBody();
|
|
53
|
+
const title = (body.title as string || "").trim();
|
|
54
|
+
if (!title) return c.text("Title is required", 400);
|
|
55
|
+
const status = (body.status as string) || config.general.defaultStatus;
|
|
56
|
+
validateStatus(status, config);
|
|
57
|
+
createIssue(db, {
|
|
58
|
+
title,
|
|
59
|
+
description: (body.description as string) || "",
|
|
60
|
+
status,
|
|
61
|
+
});
|
|
62
|
+
return c.redirect("/issues");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
app.get("/:id", (c) => {
|
|
66
|
+
const id = c.req.param("id");
|
|
67
|
+
const issue = getIssueByPrefix(db, id);
|
|
68
|
+
const comments = listComments(db, issue.id);
|
|
69
|
+
return c.html(
|
|
70
|
+
<Layout title={issue.title}>
|
|
71
|
+
<div class="detail">
|
|
72
|
+
<h1>
|
|
73
|
+
<StatusBadge status={issue.status} />
|
|
74
|
+
{issue.title}
|
|
75
|
+
</h1>
|
|
76
|
+
<div class="detail-meta">
|
|
77
|
+
ID: {issue.id} | Created: {issue.created_at} | Updated: {issue.updated_at}
|
|
78
|
+
</div>
|
|
79
|
+
{issue.description && <div class="description">{issue.description}</div>}
|
|
80
|
+
<div class="actions">
|
|
81
|
+
<form method="post" action={`/issues/${issue.id}/move`} style="display:flex;gap:0.5rem;align-items:center">
|
|
82
|
+
<select name="status">
|
|
83
|
+
{config.general.statuses.map((s) => (
|
|
84
|
+
<option value={s} selected={s === issue.status}>{s}</option>
|
|
85
|
+
))}
|
|
86
|
+
</select>
|
|
87
|
+
<button type="submit" class="btn btn-primary btn-sm">Move</button>
|
|
88
|
+
</form>
|
|
89
|
+
<form method="post" action={`/issues/${issue.id}/delete`} onsubmit="return confirm('Delete this issue?')">
|
|
90
|
+
<button type="submit" class="btn btn-danger btn-sm">Delete</button>
|
|
91
|
+
</form>
|
|
92
|
+
</div>
|
|
93
|
+
</div>
|
|
94
|
+
|
|
95
|
+
<div class="detail">
|
|
96
|
+
<h2>Comments ({comments.length})</h2>
|
|
97
|
+
{comments.map((comment) => (
|
|
98
|
+
<div class="comment" key={comment.id}>
|
|
99
|
+
<div>
|
|
100
|
+
<span class="comment-author">{comment.author}</span>
|
|
101
|
+
<span class="comment-date"> - {comment.created_at}</span>
|
|
102
|
+
</div>
|
|
103
|
+
<div>{comment.body}</div>
|
|
104
|
+
</div>
|
|
105
|
+
))}
|
|
106
|
+
<form method="post" action={`/issues/${issue.id}/comment`} style="margin-top:1rem">
|
|
107
|
+
<div class="form-row">
|
|
108
|
+
<textarea name="body" placeholder="Add a comment..." required></textarea>
|
|
109
|
+
</div>
|
|
110
|
+
<button type="submit" class="btn btn-primary btn-sm">Comment</button>
|
|
111
|
+
</form>
|
|
112
|
+
</div>
|
|
113
|
+
</Layout>
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
app.post("/:id", async (c) => {
|
|
118
|
+
const id = c.req.param("id");
|
|
119
|
+
const issue = getIssueByPrefix(db, id);
|
|
120
|
+
const body = await c.req.parseBody();
|
|
121
|
+
const fields: any = {};
|
|
122
|
+
if (body.title) fields.title = body.title as string;
|
|
123
|
+
if (body.description !== undefined) fields.description = body.description as string;
|
|
124
|
+
if (body.status) {
|
|
125
|
+
validateStatus(body.status as string, config);
|
|
126
|
+
fields.status = body.status as string;
|
|
127
|
+
}
|
|
128
|
+
updateIssue(db, issue.id, fields);
|
|
129
|
+
return c.redirect(`/issues/${issue.id}`);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
app.post("/:id/move", async (c) => {
|
|
133
|
+
const id = c.req.param("id");
|
|
134
|
+
const issue = getIssueByPrefix(db, id);
|
|
135
|
+
const body = await c.req.parseBody();
|
|
136
|
+
const status = body.status as string;
|
|
137
|
+
validateStatus(status, config);
|
|
138
|
+
updateIssue(db, issue.id, { status });
|
|
139
|
+
return c.redirect(`/issues/${issue.id}`);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
app.post("/:id/delete", (c) => {
|
|
143
|
+
const id = c.req.param("id");
|
|
144
|
+
const issue = getIssueByPrefix(db, id);
|
|
145
|
+
deleteIssue(db, issue.id);
|
|
146
|
+
return c.redirect("/issues");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
app.post("/:id/comment", async (c) => {
|
|
150
|
+
const id = c.req.param("id");
|
|
151
|
+
const issue = getIssueByPrefix(db, id);
|
|
152
|
+
const body = await c.req.parseBody();
|
|
153
|
+
createComment(db, {
|
|
154
|
+
issue_id: issue.id,
|
|
155
|
+
body: body.body as string,
|
|
156
|
+
author: "webui",
|
|
157
|
+
});
|
|
158
|
+
return c.redirect(`/issues/${issue.id}`);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return app;
|
|
162
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { Database } from "bun:sqlite";
|
|
3
|
+
import type { Config } from "../../types.ts";
|
|
4
|
+
import { Layout } from "../components/layout.tsx";
|
|
5
|
+
import { StatusBadge } from "../components/status-badge.tsx";
|
|
6
|
+
import { listRuns, getRunningRuns } from "../../queries/runs.ts";
|
|
7
|
+
import type { Run } from "../../types.ts";
|
|
8
|
+
|
|
9
|
+
export function runRoutes(db: Database, _config: Config) {
|
|
10
|
+
const app = new Hono();
|
|
11
|
+
|
|
12
|
+
app.get("/", (c) => {
|
|
13
|
+
const runs = listRuns(db, { limit: 50 });
|
|
14
|
+
return c.html(
|
|
15
|
+
<Layout title="Runs">
|
|
16
|
+
<h1>Runs</h1>
|
|
17
|
+
<table>
|
|
18
|
+
<thead>
|
|
19
|
+
<tr>
|
|
20
|
+
<th>ID</th>
|
|
21
|
+
<th>Schedule</th>
|
|
22
|
+
<th>Agent</th>
|
|
23
|
+
<th>Status</th>
|
|
24
|
+
<th>Started</th>
|
|
25
|
+
<th>Cost</th>
|
|
26
|
+
</tr>
|
|
27
|
+
</thead>
|
|
28
|
+
<tbody>
|
|
29
|
+
{runs.map((run) => (
|
|
30
|
+
<tr key={run.id}>
|
|
31
|
+
<td><a href={`/runs/${run.id}`}>{run.id.slice(0, 8)}</a></td>
|
|
32
|
+
<td>{run.schedule_name ?? run.schedule_id.slice(0, 8)}</td>
|
|
33
|
+
<td>{run.agent}</td>
|
|
34
|
+
<td><StatusBadge status={run.status} /></td>
|
|
35
|
+
<td>{run.started_at}</td>
|
|
36
|
+
<td>{run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "-"}</td>
|
|
37
|
+
</tr>
|
|
38
|
+
))}
|
|
39
|
+
</tbody>
|
|
40
|
+
</table>
|
|
41
|
+
</Layout>
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
app.get("/:id", (c) => {
|
|
46
|
+
const id = c.req.param("id");
|
|
47
|
+
const runs = listRuns(db, { limit: 500 });
|
|
48
|
+
const run = runs.find((r) => r.id === id || r.id.startsWith(id));
|
|
49
|
+
if (!run) return c.text("Run not found", 404);
|
|
50
|
+
|
|
51
|
+
return c.html(
|
|
52
|
+
<Layout title={`Run ${run.id.slice(0, 8)}`}>
|
|
53
|
+
<div class="detail">
|
|
54
|
+
<h1>
|
|
55
|
+
<StatusBadge status={run.status} />
|
|
56
|
+
Run {run.id.slice(0, 8)}
|
|
57
|
+
</h1>
|
|
58
|
+
<div class="detail-meta">
|
|
59
|
+
Schedule: {run.schedule_name ?? run.schedule_id.slice(0, 8)} |
|
|
60
|
+
Agent: {run.agent} |
|
|
61
|
+
Started: {run.started_at}
|
|
62
|
+
{run.finished_at && ` | Finished: ${run.finished_at}`}
|
|
63
|
+
</div>
|
|
64
|
+
<table>
|
|
65
|
+
<tbody>
|
|
66
|
+
<tr><td><strong>Exit Code</strong></td><td>{run.exit_code ?? "-"}</td></tr>
|
|
67
|
+
<tr><td><strong>Tokens In</strong></td><td>{run.tokens_in ?? "-"}</td></tr>
|
|
68
|
+
<tr><td><strong>Tokens Out</strong></td><td>{run.tokens_out ?? "-"}</td></tr>
|
|
69
|
+
<tr><td><strong>Cost</strong></td><td>{run.cost_usd != null ? `$${run.cost_usd.toFixed(4)}` : "-"}</td></tr>
|
|
70
|
+
<tr><td><strong>Session ID</strong></td><td>{run.session_id ?? "-"}</td></tr>
|
|
71
|
+
<tr><td><strong>tmux Session</strong></td><td>{run.tmux_session ?? "-"}</td></tr>
|
|
72
|
+
<tr><td><strong>Tools Used</strong></td><td>{run.tools_used ?? "-"}</td></tr>
|
|
73
|
+
<tr><td><strong>Issues Touched</strong></td><td>{run.issues_touched ?? "-"}</td></tr>
|
|
74
|
+
</tbody>
|
|
75
|
+
</table>
|
|
76
|
+
{run.stderr_tail && (
|
|
77
|
+
<div>
|
|
78
|
+
<h3 style="margin-top:1rem">Stderr</h3>
|
|
79
|
+
<pre class="description">{run.stderr_tail}</pre>
|
|
80
|
+
</div>
|
|
81
|
+
)}
|
|
82
|
+
</div>
|
|
83
|
+
</Layout>
|
|
84
|
+
);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
return app;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function apiRoutes(db: Database, _config: Config) {
|
|
91
|
+
const app = new Hono();
|
|
92
|
+
|
|
93
|
+
app.get("/status", (c) => {
|
|
94
|
+
const running = getRunningRuns(db);
|
|
95
|
+
const recent = listRuns(db, { limit: 5 });
|
|
96
|
+
return c.json({
|
|
97
|
+
active_runs: running.length,
|
|
98
|
+
recent_runs: recent.length,
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
return app;
|
|
103
|
+
}
|