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/mcp.ts
CHANGED
|
@@ -7,8 +7,9 @@ import {
|
|
|
7
7
|
ReadResourceRequestSchema,
|
|
8
8
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
9
9
|
import { Database } from "bun:sqlite";
|
|
10
|
+
import { existsSync } from "fs";
|
|
10
11
|
import { ensureDb } from "./db.ts";
|
|
11
|
-
import { loadConfig } from "./config.ts";
|
|
12
|
+
import { loadConfig, PRODBOARD_DIR } from "./config.ts";
|
|
12
13
|
import {
|
|
13
14
|
createIssue, getIssueByPrefix, listIssues, updateIssue,
|
|
14
15
|
deleteIssue, getIssueCounts, validateStatus, resolveIssueId,
|
|
@@ -432,6 +433,10 @@ export async function handleListRuns(db: Database, params: any) {
|
|
|
432
433
|
}
|
|
433
434
|
|
|
434
435
|
export async function startMcpServer(): Promise<void> {
|
|
436
|
+
if (!existsSync(PRODBOARD_DIR)) {
|
|
437
|
+
const { init } = await import("./commands/init.ts");
|
|
438
|
+
await init([]);
|
|
439
|
+
}
|
|
435
440
|
const db = ensureDb();
|
|
436
441
|
const config = loadConfig();
|
|
437
442
|
const pkg = await import("../package.json");
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Config } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
export class OpenCodeServerManager {
|
|
4
|
+
private serverProcess: any = null;
|
|
5
|
+
private _url: string;
|
|
6
|
+
|
|
7
|
+
constructor(config: Config) {
|
|
8
|
+
this._url = config.daemon.opencode.serverUrl ?? "http://localhost:4096";
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async isRunning(): Promise<boolean> {
|
|
12
|
+
try {
|
|
13
|
+
const controller = new AbortController();
|
|
14
|
+
const timeout = setTimeout(() => controller.abort(), 2000);
|
|
15
|
+
const res = await fetch(`${this._url}/global/health`, { signal: controller.signal });
|
|
16
|
+
clearTimeout(timeout);
|
|
17
|
+
return res.ok;
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async ensureRunning(): Promise<string> {
|
|
24
|
+
if (await this.isRunning()) return this._url;
|
|
25
|
+
|
|
26
|
+
this.serverProcess = Bun.spawn(["opencode", "serve"], {
|
|
27
|
+
stdout: "ignore",
|
|
28
|
+
stderr: "ignore",
|
|
29
|
+
env: process.env,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// Poll health endpoint for up to 30s
|
|
33
|
+
const deadline = Date.now() + 30_000;
|
|
34
|
+
while (Date.now() < deadline) {
|
|
35
|
+
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
36
|
+
if (await this.isRunning()) return this._url;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
throw new Error(`OpenCode server failed to start at ${this._url} within 30 seconds`);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async stop(): Promise<void> {
|
|
43
|
+
if (this.serverProcess) {
|
|
44
|
+
try {
|
|
45
|
+
this.serverProcess.kill("SIGTERM");
|
|
46
|
+
} catch {}
|
|
47
|
+
this.serverProcess = null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
get url(): string {
|
|
52
|
+
return this._url;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/src/queries/runs.ts
CHANGED
|
@@ -4,15 +4,15 @@ import { generateId } from "../ids.ts";
|
|
|
4
4
|
|
|
5
5
|
export function createRun(
|
|
6
6
|
db: Database,
|
|
7
|
-
opts: { schedule_id: string; prompt_used: string; pid?: number }
|
|
7
|
+
opts: { schedule_id: string; prompt_used: string; pid?: number; agent?: string }
|
|
8
8
|
): Run {
|
|
9
9
|
const id = generateId();
|
|
10
10
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
11
11
|
|
|
12
12
|
db.query(`
|
|
13
|
-
INSERT INTO runs (id, schedule_id, status, prompt_used, pid, started_at)
|
|
14
|
-
VALUES (?, ?, 'running', ?, ?, ?)
|
|
15
|
-
`).run(id, opts.schedule_id, opts.prompt_used, opts.pid ?? null, now);
|
|
13
|
+
INSERT INTO runs (id, schedule_id, status, prompt_used, pid, agent, started_at)
|
|
14
|
+
VALUES (?, ?, 'running', ?, ?, ?, ?)
|
|
15
|
+
`).run(id, opts.schedule_id, opts.prompt_used, opts.pid ?? null, opts.agent ?? "claude", now);
|
|
16
16
|
|
|
17
17
|
return db.query("SELECT * FROM runs WHERE id = ?").get(id) as Run;
|
|
18
18
|
}
|
|
@@ -31,6 +31,7 @@ export function updateRun(
|
|
|
31
31
|
session_id: "session_id", worktree_path: "worktree_path",
|
|
32
32
|
tokens_in: "tokens_in", tokens_out: "tokens_out", cost_usd: "cost_usd",
|
|
33
33
|
tools_used: "tools_used", issues_touched: "issues_touched",
|
|
34
|
+
tmux_session: "tmux_session", agent: "agent",
|
|
34
35
|
prompt_used: "prompt_used", pid: "pid",
|
|
35
36
|
};
|
|
36
37
|
|
package/src/scheduler.ts
CHANGED
|
@@ -4,83 +4,18 @@ import * as path from "path";
|
|
|
4
4
|
import type { Config, Schedule, Run } from "./types.ts";
|
|
5
5
|
import { PRODBOARD_DIR } from "./config.ts";
|
|
6
6
|
import { shouldFire } from "./cron.ts";
|
|
7
|
-
import { detectEnvironment
|
|
7
|
+
import { detectEnvironment } from "./invocation.ts";
|
|
8
8
|
import { listSchedules } from "./queries/schedules.ts";
|
|
9
9
|
import { createRun, updateRun, getRunningRuns, pruneOldRuns } from "./queries/runs.ts";
|
|
10
10
|
import { resolveTemplate, buildTemplateContext } from "./templates.ts";
|
|
11
|
+
import { createAgentDriver } from "./agents/index.ts";
|
|
12
|
+
import type { AgentDriver, StreamEvent } from "./agents/types.ts";
|
|
13
|
+
import { TmuxManager } from "./tmux.ts";
|
|
14
|
+
import { WorktreeManager } from "./worktree.ts";
|
|
15
|
+
import { OpenCodeServerManager } from "./opencode-server.ts";
|
|
11
16
|
|
|
12
|
-
//
|
|
13
|
-
export
|
|
14
|
-
type: string;
|
|
15
|
-
session_id?: string;
|
|
16
|
-
tool?: string;
|
|
17
|
-
tool_input?: any;
|
|
18
|
-
result?: {
|
|
19
|
-
tokens_in?: number;
|
|
20
|
-
tokens_out?: number;
|
|
21
|
-
cost_usd?: number;
|
|
22
|
-
};
|
|
23
|
-
[key: string]: any;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function parseStreamJson(line: string): StreamEvent | null {
|
|
27
|
-
try {
|
|
28
|
-
const parsed = JSON.parse(line.trim());
|
|
29
|
-
return parsed;
|
|
30
|
-
} catch {
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function extractCostData(events: StreamEvent[]): {
|
|
36
|
-
tokens_in: number;
|
|
37
|
-
tokens_out: number;
|
|
38
|
-
cost_usd: number;
|
|
39
|
-
session_id: string | null;
|
|
40
|
-
tools_used: string[];
|
|
41
|
-
issues_touched: string[];
|
|
42
|
-
} {
|
|
43
|
-
let tokens_in = 0;
|
|
44
|
-
let tokens_out = 0;
|
|
45
|
-
let cost_usd = 0;
|
|
46
|
-
let session_id: string | null = null;
|
|
47
|
-
const tools_used = new Set<string>();
|
|
48
|
-
const issues_touched = new Set<string>();
|
|
49
|
-
|
|
50
|
-
for (const event of events) {
|
|
51
|
-
if (event.type === "init" && event.session_id) {
|
|
52
|
-
session_id = event.session_id;
|
|
53
|
-
}
|
|
54
|
-
if (event.type === "tool_use" && event.tool) {
|
|
55
|
-
tools_used.add(event.tool);
|
|
56
|
-
// Track prodboard issue IDs from tool inputs
|
|
57
|
-
if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.id) {
|
|
58
|
-
issues_touched.add(event.tool_input.id);
|
|
59
|
-
}
|
|
60
|
-
if (event.tool.startsWith("mcp__prodboard__") && event.tool_input?.issue_id) {
|
|
61
|
-
issues_touched.add(event.tool_input.issue_id);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
if (event.type === "result") {
|
|
65
|
-
if (event.result?.tokens_in) tokens_in = event.result.tokens_in;
|
|
66
|
-
if (event.result?.tokens_out) tokens_out = event.result.tokens_out;
|
|
67
|
-
if (event.result?.cost_usd) cost_usd = event.result.cost_usd;
|
|
68
|
-
}
|
|
69
|
-
// Also handle top-level fields some stream formats use
|
|
70
|
-
if (event.tokens_in) tokens_in = event.tokens_in;
|
|
71
|
-
if (event.tokens_out) tokens_out = event.tokens_out;
|
|
72
|
-
if (event.cost_usd) cost_usd = event.cost_usd;
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return {
|
|
76
|
-
tokens_in,
|
|
77
|
-
tokens_out,
|
|
78
|
-
cost_usd,
|
|
79
|
-
session_id,
|
|
80
|
-
tools_used: [...tools_used],
|
|
81
|
-
issues_touched: [...issues_touched],
|
|
82
|
-
};
|
|
83
|
-
}
|
|
17
|
+
// Re-export for backward compatibility
|
|
18
|
+
export type { StreamEvent };
|
|
84
19
|
|
|
85
20
|
class RingBuffer {
|
|
86
21
|
private buffer: string[] = [];
|
|
@@ -103,10 +38,17 @@ class RingBuffer {
|
|
|
103
38
|
}
|
|
104
39
|
|
|
105
40
|
export class ExecutionManager {
|
|
106
|
-
constructor(
|
|
41
|
+
constructor(
|
|
42
|
+
private db: Database,
|
|
43
|
+
private config: Config,
|
|
44
|
+
private driver: AgentDriver = createAgentDriver(config),
|
|
45
|
+
private tmuxManager?: TmuxManager,
|
|
46
|
+
private worktreeManager?: WorktreeManager,
|
|
47
|
+
) {}
|
|
107
48
|
|
|
108
49
|
async executeRun(schedule: Schedule, run: Run): Promise<void> {
|
|
109
|
-
const
|
|
50
|
+
const baseWorkdir = this.config.daemon.basePath ?? schedule.workdir;
|
|
51
|
+
const env = detectEnvironment(baseWorkdir, this.config);
|
|
110
52
|
|
|
111
53
|
// Resolve prompt templates
|
|
112
54
|
let resolvedPrompt = schedule.prompt;
|
|
@@ -119,94 +61,171 @@ export class ExecutionManager {
|
|
|
119
61
|
}
|
|
120
62
|
} catch {}
|
|
121
63
|
|
|
122
|
-
|
|
64
|
+
// Create worktree if applicable
|
|
65
|
+
let worktreePath: string | null = null;
|
|
66
|
+
let effectiveWorkdir = baseWorkdir;
|
|
67
|
+
|
|
68
|
+
if (
|
|
69
|
+
this.worktreeManager &&
|
|
70
|
+
this.config.daemon.useWorktrees !== "never" &&
|
|
71
|
+
schedule.use_worktree !== 0 &&
|
|
72
|
+
this.worktreeManager.isGitRepo(baseWorkdir)
|
|
73
|
+
) {
|
|
74
|
+
try {
|
|
75
|
+
worktreePath = await this.worktreeManager.create(run.id, baseWorkdir);
|
|
76
|
+
effectiveWorkdir = worktreePath;
|
|
77
|
+
updateRun(this.db, run.id, { worktree_path: worktreePath });
|
|
78
|
+
} catch (err: any) {
|
|
79
|
+
console.error(`[prodboard] Warning: Failed to create worktree: ${err.message}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const args = this.driver.buildCommand({
|
|
84
|
+
schedule,
|
|
85
|
+
run,
|
|
86
|
+
config: this.config,
|
|
87
|
+
env,
|
|
88
|
+
resolvedPrompt,
|
|
89
|
+
workdir: effectiveWorkdir,
|
|
90
|
+
db: this.db,
|
|
91
|
+
});
|
|
123
92
|
|
|
124
|
-
// Update run with PID (will be set after spawn)
|
|
125
93
|
const stdoutBuffer = new RingBuffer(500);
|
|
126
94
|
const stderrBuffer = new RingBuffer(100);
|
|
127
95
|
const events: StreamEvent[] = [];
|
|
128
96
|
|
|
129
|
-
|
|
97
|
+
const useTmux = this.config.daemon.useTmux && this.tmuxManager?.isAvailable();
|
|
98
|
+
let tmuxSessionName: string | null = null;
|
|
99
|
+
let jsonlPath: string | null = null;
|
|
130
100
|
let timeoutId: Timer | undefined;
|
|
101
|
+
let timedOut = false;
|
|
102
|
+
|
|
131
103
|
try {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
104
|
+
if (useTmux && this.tmuxManager) {
|
|
105
|
+
// tmux path: spawn detached session, wait for completion, read events from file
|
|
106
|
+
tmuxSessionName = this.tmuxManager.sessionName(run.id);
|
|
107
|
+
jsonlPath = `/tmp/prodboard-${run.id}.jsonl`;
|
|
108
|
+
const wrappedArgs = this.tmuxManager.wrapCommand(tmuxSessionName, args, jsonlPath);
|
|
109
|
+
|
|
110
|
+
Bun.spawnSync(wrappedArgs, { cwd: effectiveWorkdir, env: process.env });
|
|
111
|
+
updateRun(this.db, run.id, { tmux_session: tmuxSessionName });
|
|
112
|
+
|
|
113
|
+
// Set up timeout
|
|
114
|
+
const timeoutMs = this.config.daemon.runTimeoutSeconds * 1000;
|
|
115
|
+
timeoutId = setTimeout(() => {
|
|
116
|
+
timedOut = true;
|
|
117
|
+
if (tmuxSessionName && this.tmuxManager) {
|
|
118
|
+
this.tmuxManager.killSession(tmuxSessionName);
|
|
119
|
+
}
|
|
120
|
+
}, timeoutMs);
|
|
138
121
|
|
|
139
|
-
|
|
122
|
+
const exitCode = await this.tmuxManager.waitForCompletion(tmuxSessionName, jsonlPath);
|
|
140
123
|
|
|
141
|
-
|
|
142
|
-
const timeoutMs = this.config.daemon.runTimeoutSeconds * 1000;
|
|
143
|
-
timeoutId = setTimeout(() => {
|
|
124
|
+
// Read JSONL file for events
|
|
144
125
|
try {
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
126
|
+
const content = fs.readFileSync(jsonlPath, "utf-8");
|
|
127
|
+
for (const line of content.split("\n")) {
|
|
128
|
+
if (line.trim()) {
|
|
129
|
+
stdoutBuffer.push(line);
|
|
130
|
+
const event = this.driver.parseEvent(line);
|
|
131
|
+
if (event) events.push(event);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
149
134
|
} catch {}
|
|
150
|
-
}, timeoutMs);
|
|
151
135
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
136
|
+
const result = this.driver.extractResult(events);
|
|
137
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
138
|
+
|
|
139
|
+
updateRun(this.db, run.id, {
|
|
140
|
+
status: timedOut ? "timeout" : exitCode === 0 ? "success" : "failed",
|
|
141
|
+
finished_at: now,
|
|
142
|
+
exit_code: exitCode,
|
|
143
|
+
stdout_tail: stdoutBuffer.toString(),
|
|
144
|
+
session_id: result.session_id,
|
|
145
|
+
tokens_in: result.tokens_in,
|
|
146
|
+
tokens_out: result.tokens_out,
|
|
147
|
+
cost_usd: result.cost_usd,
|
|
148
|
+
tools_used: result.tools_used.length > 0 ? JSON.stringify(result.tools_used) : null,
|
|
149
|
+
issues_touched: result.issues_touched.length > 0 ? JSON.stringify(result.issues_touched) : null,
|
|
150
|
+
});
|
|
151
|
+
} else {
|
|
152
|
+
// Direct spawn path (no tmux)
|
|
153
|
+
const proc = Bun.spawn(args, {
|
|
154
|
+
cwd: effectiveWorkdir,
|
|
155
|
+
stdout: "pipe",
|
|
156
|
+
stderr: "pipe",
|
|
157
|
+
env: process.env,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
updateRun(this.db, run.id, { pid: proc.pid });
|
|
161
|
+
|
|
162
|
+
const timeoutMs = this.config.daemon.runTimeoutSeconds * 1000;
|
|
163
|
+
timeoutId = setTimeout(() => {
|
|
164
|
+
try {
|
|
165
|
+
proc.kill("SIGTERM");
|
|
166
|
+
setTimeout(() => {
|
|
167
|
+
try { proc.kill("SIGKILL"); } catch {}
|
|
168
|
+
}, 10000);
|
|
169
|
+
} catch {}
|
|
170
|
+
}, timeoutMs);
|
|
171
|
+
|
|
172
|
+
if (proc.stdout) {
|
|
173
|
+
const reader = proc.stdout.getReader();
|
|
174
|
+
const decoder = new TextDecoder();
|
|
175
|
+
let buffer = "";
|
|
176
|
+
try {
|
|
177
|
+
while (true) {
|
|
178
|
+
const { value, done } = await reader.read();
|
|
179
|
+
if (done) break;
|
|
180
|
+
buffer += decoder.decode(value, { stream: true });
|
|
181
|
+
const lines = buffer.split("\n");
|
|
182
|
+
buffer = lines.pop() ?? "";
|
|
183
|
+
for (const line of lines) {
|
|
184
|
+
if (line.trim()) {
|
|
185
|
+
stdoutBuffer.push(line);
|
|
186
|
+
const event = this.driver.parseEvent(line);
|
|
187
|
+
if (event) events.push(event);
|
|
188
|
+
}
|
|
168
189
|
}
|
|
169
190
|
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
}
|
|
191
|
+
} catch {}
|
|
192
|
+
reader.releaseLock();
|
|
193
|
+
}
|
|
174
194
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
195
|
+
if (proc.stderr) {
|
|
196
|
+
const stderrText = await new Response(proc.stderr).text();
|
|
197
|
+
for (const line of stderrText.split("\n")) {
|
|
198
|
+
if (line.trim()) stderrBuffer.push(line);
|
|
199
|
+
}
|
|
180
200
|
}
|
|
181
|
-
}
|
|
182
201
|
|
|
183
|
-
|
|
202
|
+
const exitCode = await proc.exited;
|
|
203
|
+
const result = this.driver.extractResult(events);
|
|
204
|
+
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
184
205
|
|
|
185
|
-
|
|
186
|
-
|
|
206
|
+
let status: string;
|
|
207
|
+
if (exitCode === 0) {
|
|
208
|
+
status = "success";
|
|
209
|
+
} else if (exitCode === null) {
|
|
210
|
+
status = "timeout";
|
|
211
|
+
} else {
|
|
212
|
+
status = "failed";
|
|
213
|
+
}
|
|
187
214
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
215
|
+
updateRun(this.db, run.id, {
|
|
216
|
+
status,
|
|
217
|
+
finished_at: now,
|
|
218
|
+
exit_code: exitCode,
|
|
219
|
+
stdout_tail: stdoutBuffer.toString(),
|
|
220
|
+
stderr_tail: stderrBuffer.toString(),
|
|
221
|
+
session_id: result.session_id,
|
|
222
|
+
tokens_in: result.tokens_in,
|
|
223
|
+
tokens_out: result.tokens_out,
|
|
224
|
+
cost_usd: result.cost_usd,
|
|
225
|
+
tools_used: result.tools_used.length > 0 ? JSON.stringify(result.tools_used) : null,
|
|
226
|
+
issues_touched: result.issues_touched.length > 0 ? JSON.stringify(result.issues_touched) : null,
|
|
227
|
+
});
|
|
195
228
|
}
|
|
196
|
-
|
|
197
|
-
updateRun(this.db, run.id, {
|
|
198
|
-
status,
|
|
199
|
-
finished_at: now,
|
|
200
|
-
exit_code: exitCode,
|
|
201
|
-
stdout_tail: stdoutBuffer.toString(),
|
|
202
|
-
stderr_tail: stderrBuffer.toString(),
|
|
203
|
-
session_id: costData.session_id,
|
|
204
|
-
tokens_in: costData.tokens_in,
|
|
205
|
-
tokens_out: costData.tokens_out,
|
|
206
|
-
cost_usd: costData.cost_usd,
|
|
207
|
-
tools_used: costData.tools_used.length > 0 ? JSON.stringify(costData.tools_used) : null,
|
|
208
|
-
issues_touched: costData.issues_touched.length > 0 ? JSON.stringify(costData.issues_touched) : null,
|
|
209
|
-
});
|
|
210
229
|
} catch (err: any) {
|
|
211
230
|
const now = new Date().toISOString().replace("T", " ").slice(0, 19);
|
|
212
231
|
updateRun(this.db, run.id, {
|
|
@@ -216,6 +235,17 @@ export class ExecutionManager {
|
|
|
216
235
|
});
|
|
217
236
|
} finally {
|
|
218
237
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
238
|
+
|
|
239
|
+
// Clean up worktree
|
|
240
|
+
if (worktreePath && this.worktreeManager) {
|
|
241
|
+
try { await this.worktreeManager.remove(run.id); } catch {}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Clean up tmux JSONL temp file
|
|
245
|
+
if (jsonlPath) {
|
|
246
|
+
try { fs.unlinkSync(jsonlPath); } catch {}
|
|
247
|
+
try { fs.unlinkSync(`${jsonlPath}.exit`); } catch {}
|
|
248
|
+
}
|
|
219
249
|
}
|
|
220
250
|
}
|
|
221
251
|
}
|
|
@@ -233,7 +263,6 @@ export class CronLoop {
|
|
|
233
263
|
|
|
234
264
|
start(): void {
|
|
235
265
|
this.interval = setInterval(() => this.tick(), 30_000);
|
|
236
|
-
// Also tick immediately
|
|
237
266
|
this.tick();
|
|
238
267
|
}
|
|
239
268
|
|
|
@@ -257,23 +286,20 @@ export class CronLoop {
|
|
|
257
286
|
try {
|
|
258
287
|
if (!shouldFire(schedule.cron, now)) continue;
|
|
259
288
|
|
|
260
|
-
// Prevent double-fire within same minute
|
|
261
289
|
const lastFiredMinute = this.lastFired.get(schedule.id);
|
|
262
290
|
if (lastFiredMinute === minuteTs) continue;
|
|
263
291
|
|
|
264
|
-
// Check concurrent limit
|
|
265
292
|
const runningRuns = getRunningRuns(this.db);
|
|
266
293
|
if (runningRuns.length >= this.config.daemon.maxConcurrentRuns) continue;
|
|
267
294
|
|
|
268
295
|
const run = createRun(this.db, {
|
|
269
296
|
schedule_id: schedule.id,
|
|
270
297
|
prompt_used: schedule.prompt,
|
|
298
|
+
agent: this.config.daemon.agent,
|
|
271
299
|
});
|
|
272
300
|
|
|
273
|
-
// Mark fired only after successful run creation
|
|
274
301
|
this.lastFired.set(schedule.id, minuteTs);
|
|
275
302
|
|
|
276
|
-
// Execute async - don't block the loop
|
|
277
303
|
this.executionManager.executeRun(schedule, run).catch(() => {});
|
|
278
304
|
} catch (err) {
|
|
279
305
|
console.error(`[prodboard] Error evaluating schedule ${schedule.id}:`, err instanceof Error ? err.message : String(err));
|
|
@@ -293,7 +319,7 @@ export class CleanupWorker {
|
|
|
293
319
|
constructor(private db: Database, private config: Config) {}
|
|
294
320
|
|
|
295
321
|
start(): void {
|
|
296
|
-
this.interval = setInterval(() => this.cleanup(), 3600_000);
|
|
322
|
+
this.interval = setInterval(() => this.cleanup(), 3600_000);
|
|
297
323
|
}
|
|
298
324
|
|
|
299
325
|
stop(): void {
|
|
@@ -317,23 +343,63 @@ export class Daemon {
|
|
|
317
343
|
private cronLoop: CronLoop;
|
|
318
344
|
private cleanupWorker: CleanupWorker;
|
|
319
345
|
private executionManager: ExecutionManager;
|
|
346
|
+
private tmuxManager: TmuxManager;
|
|
347
|
+
private worktreeManager?: WorktreeManager;
|
|
348
|
+
private openCodeServer?: OpenCodeServerManager;
|
|
349
|
+
private webServer?: any;
|
|
320
350
|
|
|
321
351
|
constructor(private db: Database, private config: Config) {
|
|
322
|
-
|
|
352
|
+
const driver = createAgentDriver(config);
|
|
353
|
+
this.tmuxManager = new TmuxManager();
|
|
354
|
+
if (config.daemon.basePath) {
|
|
355
|
+
this.worktreeManager = new WorktreeManager(config.daemon.basePath);
|
|
356
|
+
}
|
|
357
|
+
this.executionManager = new ExecutionManager(db, config, driver, this.tmuxManager, this.worktreeManager);
|
|
323
358
|
this.cronLoop = new CronLoop(db, config, this.executionManager);
|
|
324
359
|
this.cleanupWorker = new CleanupWorker(db, config);
|
|
325
360
|
}
|
|
326
361
|
|
|
327
362
|
async start(): Promise<void> {
|
|
328
363
|
this.recoverCrashedRuns();
|
|
364
|
+
|
|
365
|
+
// Check tmux availability
|
|
366
|
+
if (this.config.daemon.useTmux) {
|
|
367
|
+
if (this.tmuxManager.isAvailable()) {
|
|
368
|
+
console.error("[prodboard] tmux available — sessions will be attachable");
|
|
369
|
+
} else {
|
|
370
|
+
console.error("[prodboard] Warning: useTmux is true but tmux is not installed");
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Start OpenCode server if needed
|
|
375
|
+
if (this.config.daemon.agent === "opencode") {
|
|
376
|
+
this.openCodeServer = new OpenCodeServerManager(this.config);
|
|
377
|
+
try {
|
|
378
|
+
const url = await this.openCodeServer.ensureRunning();
|
|
379
|
+
console.error(`[prodboard] OpenCode server running at ${url}`);
|
|
380
|
+
} catch (err: any) {
|
|
381
|
+
console.error(`[prodboard] Warning: Could not start OpenCode server: ${err.message}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
329
385
|
this.writePidFile();
|
|
330
386
|
this.cronLoop.start();
|
|
331
387
|
this.cleanupWorker.start();
|
|
332
388
|
|
|
389
|
+
// Start web UI if enabled
|
|
390
|
+
if (this.config.webui.enabled) {
|
|
391
|
+
try {
|
|
392
|
+
const { startWebUI } = await import("./webui/index.ts");
|
|
393
|
+
this.webServer = await startWebUI(this.db, this.config);
|
|
394
|
+
} catch (err: any) {
|
|
395
|
+
console.error(`[prodboard] Warning: Could not start web UI: ${err.message}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
333
399
|
process.on("SIGTERM", () => this.stop());
|
|
334
400
|
process.on("SIGINT", () => this.stop());
|
|
335
401
|
|
|
336
|
-
console.error(`[prodboard] Daemon started (PID ${process.pid})`);
|
|
402
|
+
console.error(`[prodboard] Daemon started (PID ${process.pid}, agent: ${this.config.daemon.agent})`);
|
|
337
403
|
}
|
|
338
404
|
|
|
339
405
|
async stop(): Promise<void> {
|
|
@@ -341,11 +407,16 @@ export class Daemon {
|
|
|
341
407
|
this.cronLoop.stop();
|
|
342
408
|
this.cleanupWorker.stop();
|
|
343
409
|
|
|
344
|
-
//
|
|
410
|
+
// Kill running tmux sessions
|
|
345
411
|
const running = getRunningRuns(this.db);
|
|
412
|
+
for (const run of running) {
|
|
413
|
+
if (run.tmux_session) {
|
|
414
|
+
this.tmuxManager.killSession(run.tmux_session);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
346
418
|
if (running.length > 0) {
|
|
347
419
|
console.error(`[prodboard] Waiting for ${running.length} running process(es)...`);
|
|
348
|
-
// Give them 30 seconds
|
|
349
420
|
const deadline = Date.now() + 30_000;
|
|
350
421
|
while (Date.now() < deadline) {
|
|
351
422
|
const still = getRunningRuns(this.db);
|
|
@@ -353,14 +424,12 @@ export class Daemon {
|
|
|
353
424
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
354
425
|
}
|
|
355
426
|
|
|
356
|
-
// Kill remaining processes first, then mark cancelled
|
|
357
427
|
const stillRunning = getRunningRuns(this.db);
|
|
358
428
|
for (const run of stillRunning) {
|
|
359
429
|
if (run.pid) {
|
|
360
430
|
try { process.kill(run.pid, "SIGTERM"); } catch {}
|
|
361
431
|
}
|
|
362
432
|
}
|
|
363
|
-
// Brief wait for processes to exit after SIGTERM
|
|
364
433
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
365
434
|
for (const run of stillRunning) {
|
|
366
435
|
if (run.pid) {
|
|
@@ -371,6 +440,16 @@ export class Daemon {
|
|
|
371
440
|
}
|
|
372
441
|
}
|
|
373
442
|
|
|
443
|
+
// Stop web server
|
|
444
|
+
if (this.webServer) {
|
|
445
|
+
try { this.webServer.stop(); } catch {}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Stop OpenCode server
|
|
449
|
+
if (this.openCodeServer) {
|
|
450
|
+
await this.openCodeServer.stop();
|
|
451
|
+
}
|
|
452
|
+
|
|
374
453
|
this.removePidFile();
|
|
375
454
|
process.exit(0);
|
|
376
455
|
}
|
|
@@ -396,6 +475,12 @@ export class Daemon {
|
|
|
396
475
|
process.kill(run.pid, 0);
|
|
397
476
|
alive = true;
|
|
398
477
|
} catch {}
|
|
478
|
+
} else if (run.tmux_session) {
|
|
479
|
+
const result = Bun.spawnSync(["tmux", "has-session", "-t", run.tmux_session], {
|
|
480
|
+
stdout: "pipe",
|
|
481
|
+
stderr: "pipe",
|
|
482
|
+
});
|
|
483
|
+
alive = result.exitCode === 0;
|
|
399
484
|
}
|
|
400
485
|
|
|
401
486
|
if (!alive) {
|