prodboard 0.0.0 → 0.1.1

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.
@@ -0,0 +1,411 @@
1
+ import { Database } from "bun:sqlite";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import type { Config, Schedule, Run } from "./types.ts";
5
+ import { PRODBOARD_DIR } from "./config.ts";
6
+ import { shouldFire } from "./cron.ts";
7
+ import { detectEnvironment, buildInvocation } from "./invocation.ts";
8
+ import { listSchedules } from "./queries/schedules.ts";
9
+ import { createRun, updateRun, getRunningRuns, pruneOldRuns } from "./queries/runs.ts";
10
+ import { resolveTemplate, buildTemplateContext } from "./templates.ts";
11
+
12
+ // Stream JSON event types
13
+ export interface StreamEvent {
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
+ }
84
+
85
+ class RingBuffer {
86
+ private buffer: string[] = [];
87
+ constructor(private maxSize: number) {}
88
+
89
+ push(line: string): void {
90
+ this.buffer.push(line);
91
+ if (this.buffer.length > this.maxSize) {
92
+ this.buffer.shift();
93
+ }
94
+ }
95
+
96
+ toString(): string {
97
+ return this.buffer.join("\n");
98
+ }
99
+
100
+ get lines(): string[] {
101
+ return [...this.buffer];
102
+ }
103
+ }
104
+
105
+ export class ExecutionManager {
106
+ constructor(private db: Database, private config: Config) {}
107
+
108
+ async executeRun(schedule: Schedule, run: Run): Promise<void> {
109
+ const env = detectEnvironment(schedule.workdir, this.config);
110
+
111
+ // Resolve prompt templates
112
+ let resolvedPrompt = schedule.prompt;
113
+ try {
114
+ const context = buildTemplateContext(this.db, schedule.name);
115
+ resolvedPrompt = resolveTemplate(schedule.prompt, context);
116
+
117
+ if (schedule.inject_context) {
118
+ resolvedPrompt = `[prodboard: ${context.boardSummary}]\n\n${resolvedPrompt}`;
119
+ }
120
+ } catch {}
121
+
122
+ const args = buildInvocation(schedule, run, this.config, env, resolvedPrompt, this.db);
123
+
124
+ // Update run with PID (will be set after spawn)
125
+ const stdoutBuffer = new RingBuffer(500);
126
+ const stderrBuffer = new RingBuffer(100);
127
+ const events: StreamEvent[] = [];
128
+
129
+ let proc: any;
130
+ let timeoutId: Timer | undefined;
131
+ try {
132
+ proc = Bun.spawn(args, {
133
+ cwd: schedule.workdir,
134
+ stdout: "pipe",
135
+ stderr: "pipe",
136
+ env: process.env,
137
+ });
138
+
139
+ updateRun(this.db, run.id, { pid: proc.pid });
140
+
141
+ // Set up timeout
142
+ const timeoutMs = this.config.daemon.runTimeoutSeconds * 1000;
143
+ timeoutId = setTimeout(() => {
144
+ try {
145
+ proc.kill("SIGTERM");
146
+ setTimeout(() => {
147
+ try { proc.kill("SIGKILL"); } catch {}
148
+ }, 10000);
149
+ } catch {}
150
+ }, timeoutMs);
151
+
152
+ // Read stdout line by line
153
+ if (proc.stdout) {
154
+ const reader = proc.stdout.getReader();
155
+ let buffer = "";
156
+ try {
157
+ while (true) {
158
+ const { value, done } = await reader.read();
159
+ if (done) break;
160
+ buffer += new TextDecoder().decode(value);
161
+ const lines = buffer.split("\n");
162
+ buffer = lines.pop() ?? "";
163
+ for (const line of lines) {
164
+ if (line.trim()) {
165
+ stdoutBuffer.push(line);
166
+ const event = parseStreamJson(line);
167
+ if (event) events.push(event);
168
+ }
169
+ }
170
+ }
171
+ } catch {}
172
+ reader.releaseLock();
173
+ }
174
+
175
+ // Read stderr
176
+ if (proc.stderr) {
177
+ const stderrText = await new Response(proc.stderr).text();
178
+ for (const line of stderrText.split("\n")) {
179
+ if (line.trim()) stderrBuffer.push(line);
180
+ }
181
+ }
182
+
183
+ const exitCode = await proc.exited;
184
+
185
+ const costData = extractCostData(events);
186
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
187
+
188
+ let status: string;
189
+ if (exitCode === 0) {
190
+ status = "success";
191
+ } else if (exitCode === null) {
192
+ status = "timeout";
193
+ } else {
194
+ status = "failed";
195
+ }
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
+ } catch (err: any) {
211
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
212
+ updateRun(this.db, run.id, {
213
+ status: "failed",
214
+ finished_at: now,
215
+ stderr_tail: err instanceof Error ? err.message : String(err),
216
+ });
217
+ } finally {
218
+ if (timeoutId !== undefined) clearTimeout(timeoutId);
219
+ }
220
+ }
221
+ }
222
+
223
+ export class CronLoop {
224
+ private interval: Timer | null = null;
225
+ private lastFired: Map<string, number> = new Map();
226
+ private isRunning = false;
227
+
228
+ constructor(
229
+ private db: Database,
230
+ private config: Config,
231
+ private executionManager: ExecutionManager
232
+ ) {}
233
+
234
+ start(): void {
235
+ this.interval = setInterval(() => this.tick(), 30_000);
236
+ // Also tick immediately
237
+ this.tick();
238
+ }
239
+
240
+ stop(): void {
241
+ if (this.interval) {
242
+ clearInterval(this.interval);
243
+ this.interval = null;
244
+ }
245
+ }
246
+
247
+ async tick(): Promise<void> {
248
+ if (this.isRunning) return;
249
+ this.isRunning = true;
250
+ try {
251
+ const now = new Date();
252
+ const minuteTs = Math.floor(now.getTime() / 60000);
253
+
254
+ const schedules = listSchedules(this.db);
255
+
256
+ for (const schedule of schedules) {
257
+ try {
258
+ if (!shouldFire(schedule.cron, now)) continue;
259
+
260
+ // Prevent double-fire within same minute
261
+ const lastFiredMinute = this.lastFired.get(schedule.id);
262
+ if (lastFiredMinute === minuteTs) continue;
263
+
264
+ // Check concurrent limit
265
+ const runningRuns = getRunningRuns(this.db);
266
+ if (runningRuns.length >= this.config.daemon.maxConcurrentRuns) continue;
267
+
268
+ const run = createRun(this.db, {
269
+ schedule_id: schedule.id,
270
+ prompt_used: schedule.prompt,
271
+ });
272
+
273
+ // Mark fired only after successful run creation
274
+ this.lastFired.set(schedule.id, minuteTs);
275
+
276
+ // Execute async - don't block the loop
277
+ this.executionManager.executeRun(schedule, run).catch(() => {});
278
+ } catch (err) {
279
+ console.error(`[prodboard] Error evaluating schedule ${schedule.id}:`, err instanceof Error ? err.message : String(err));
280
+ }
281
+ }
282
+ } catch (err) {
283
+ console.error("[prodboard] Error in tick:", err instanceof Error ? err.message : String(err));
284
+ } finally {
285
+ this.isRunning = false;
286
+ }
287
+ }
288
+ }
289
+
290
+ export class CleanupWorker {
291
+ private interval: Timer | null = null;
292
+
293
+ constructor(private db: Database, private config: Config) {}
294
+
295
+ start(): void {
296
+ this.interval = setInterval(() => this.cleanup(), 3600_000); // 1 hour
297
+ }
298
+
299
+ stop(): void {
300
+ if (this.interval) {
301
+ clearInterval(this.interval);
302
+ this.interval = null;
303
+ }
304
+ }
305
+
306
+ async cleanup(): Promise<void> {
307
+ try {
308
+ const pruned = pruneOldRuns(this.db, this.config.daemon.runRetentionDays);
309
+ if (pruned > 0) {
310
+ console.error(`[prodboard] Cleaned up ${pruned} old runs`);
311
+ }
312
+ } catch {}
313
+ }
314
+ }
315
+
316
+ export class Daemon {
317
+ private cronLoop: CronLoop;
318
+ private cleanupWorker: CleanupWorker;
319
+ private executionManager: ExecutionManager;
320
+
321
+ constructor(private db: Database, private config: Config) {
322
+ this.executionManager = new ExecutionManager(db, config);
323
+ this.cronLoop = new CronLoop(db, config, this.executionManager);
324
+ this.cleanupWorker = new CleanupWorker(db, config);
325
+ }
326
+
327
+ async start(): Promise<void> {
328
+ this.recoverCrashedRuns();
329
+ this.writePidFile();
330
+ this.cronLoop.start();
331
+ this.cleanupWorker.start();
332
+
333
+ process.on("SIGTERM", () => this.stop());
334
+ process.on("SIGINT", () => this.stop());
335
+
336
+ console.error(`[prodboard] Daemon started (PID ${process.pid})`);
337
+ }
338
+
339
+ async stop(): Promise<void> {
340
+ console.error("[prodboard] Shutting down...");
341
+ this.cronLoop.stop();
342
+ this.cleanupWorker.stop();
343
+
344
+ // Wait for running processes
345
+ const running = getRunningRuns(this.db);
346
+ if (running.length > 0) {
347
+ console.error(`[prodboard] Waiting for ${running.length} running process(es)...`);
348
+ // Give them 30 seconds
349
+ const deadline = Date.now() + 30_000;
350
+ while (Date.now() < deadline) {
351
+ const still = getRunningRuns(this.db);
352
+ if (still.length === 0) break;
353
+ await new Promise((resolve) => setTimeout(resolve, 1000));
354
+ }
355
+
356
+ // Kill remaining processes first, then mark cancelled
357
+ const stillRunning = getRunningRuns(this.db);
358
+ for (const run of stillRunning) {
359
+ if (run.pid) {
360
+ try { process.kill(run.pid, "SIGTERM"); } catch {}
361
+ }
362
+ }
363
+ // Brief wait for processes to exit after SIGTERM
364
+ await new Promise((resolve) => setTimeout(resolve, 2000));
365
+ for (const run of stillRunning) {
366
+ if (run.pid) {
367
+ try { process.kill(run.pid, "SIGKILL"); } catch {}
368
+ }
369
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
370
+ updateRun(this.db, run.id, { status: "cancelled", finished_at: now });
371
+ }
372
+ }
373
+
374
+ this.removePidFile();
375
+ process.exit(0);
376
+ }
377
+
378
+ private writePidFile(): void {
379
+ const pidFile = path.join(PRODBOARD_DIR, "daemon.pid");
380
+ fs.writeFileSync(pidFile, String(process.pid));
381
+ }
382
+
383
+ private removePidFile(): void {
384
+ try {
385
+ const pidFile = path.join(PRODBOARD_DIR, "daemon.pid");
386
+ fs.unlinkSync(pidFile);
387
+ } catch {}
388
+ }
389
+
390
+ private recoverCrashedRuns(): void {
391
+ const running = getRunningRuns(this.db);
392
+ for (const run of running) {
393
+ let alive = false;
394
+ if (run.pid) {
395
+ try {
396
+ process.kill(run.pid, 0);
397
+ alive = true;
398
+ } catch {}
399
+ }
400
+
401
+ if (!alive) {
402
+ const now = new Date().toISOString().replace("T", " ").slice(0, 19);
403
+ updateRun(this.db, run.id, {
404
+ status: "failed",
405
+ finished_at: now,
406
+ stderr_tail: "Recovered from crash — process not found",
407
+ });
408
+ }
409
+ }
410
+ }
411
+ }
@@ -0,0 +1,43 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { getIssueCounts } from "./queries/issues.ts";
3
+
4
+ export interface TemplateContext {
5
+ boardSummary: string;
6
+ todoCount: number;
7
+ inProgressCount: number;
8
+ datetime: string;
9
+ scheduleName: string;
10
+ }
11
+
12
+ export function resolveTemplate(template: string, context: TemplateContext): string {
13
+ return template
14
+ .replace(/\{\{board_summary\}\}/g, context.boardSummary)
15
+ .replace(/\{\{todo_count\}\}/g, String(context.todoCount))
16
+ .replace(/\{\{in_progress_count\}\}/g, String(context.inProgressCount))
17
+ .replace(/\{\{datetime\}\}/g, context.datetime)
18
+ .replace(/\{\{schedule_name\}\}/g, context.scheduleName);
19
+ }
20
+
21
+ export function buildBoardSummaryLine(db: Database): string {
22
+ const counts = getIssueCounts(db);
23
+ const parts: string[] = [];
24
+
25
+ const statuses = ["todo", "in-progress", "review", "done"];
26
+ for (const status of statuses) {
27
+ const count = counts[status] ?? 0;
28
+ parts.push(`${count} ${status}`);
29
+ }
30
+
31
+ return parts.join(", ");
32
+ }
33
+
34
+ export function buildTemplateContext(db: Database, scheduleName: string): TemplateContext {
35
+ const counts = getIssueCounts(db);
36
+ return {
37
+ boardSummary: buildBoardSummaryLine(db),
38
+ todoCount: counts.todo ?? 0,
39
+ inProgressCount: counts["in-progress"] ?? 0,
40
+ datetime: new Date().toISOString(),
41
+ scheduleName,
42
+ };
43
+ }
package/src/types.ts ADDED
@@ -0,0 +1,82 @@
1
+ export interface Config {
2
+ general: {
3
+ statuses: string[];
4
+ defaultStatus: string;
5
+ idPrefix: string;
6
+ };
7
+ daemon: {
8
+ maxConcurrentRuns: number;
9
+ maxTurns: number;
10
+ hardMaxTurns: number;
11
+ runTimeoutSeconds: number;
12
+ runRetentionDays: number;
13
+ logLevel: string;
14
+ logMaxSizeMb: number;
15
+ logMaxFiles: number;
16
+ defaultAllowedTools: string[];
17
+ nonGitDefaultAllowedTools: string[];
18
+ useWorktrees: "auto" | "always" | "never";
19
+ };
20
+ }
21
+
22
+ export interface Issue {
23
+ id: string;
24
+ title: string;
25
+ description: string;
26
+ status: string;
27
+ created_at: string;
28
+ updated_at: string;
29
+ }
30
+
31
+ export interface Comment {
32
+ id: string;
33
+ issue_id: string;
34
+ body: string;
35
+ author: string;
36
+ created_at: string;
37
+ }
38
+
39
+ export interface Schedule {
40
+ id: string;
41
+ name: string;
42
+ cron: string;
43
+ prompt: string;
44
+ workdir: string;
45
+ enabled: number;
46
+ max_turns: number | null;
47
+ allowed_tools: string | null;
48
+ use_worktree: number;
49
+ inject_context: number;
50
+ persist_session: number;
51
+ agents_json: string | null;
52
+ source: string;
53
+ created_at: string;
54
+ updated_at: string;
55
+ }
56
+
57
+ export interface Run {
58
+ id: string;
59
+ schedule_id: string;
60
+ status: string;
61
+ prompt_used: string;
62
+ pid: number | null;
63
+ started_at: string;
64
+ finished_at: string | null;
65
+ exit_code: number | null;
66
+ stdout_tail: string | null;
67
+ stderr_tail: string | null;
68
+ session_id: string | null;
69
+ worktree_path: string | null;
70
+ tokens_in: number | null;
71
+ tokens_out: number | null;
72
+ cost_usd: number | null;
73
+ tools_used: string | null;
74
+ issues_touched: string | null;
75
+ schedule_name?: string;
76
+ }
77
+
78
+ export interface EnvironmentInfo {
79
+ hasGit: boolean;
80
+ hasClaude: boolean;
81
+ worktreeSupported: boolean;
82
+ }
@@ -0,0 +1,12 @@
1
+ # prodboard
2
+
3
+ This project uses prodboard for issue tracking. The prodboard MCP server is configured and available.
4
+
5
+ ## Issue Workflow
6
+ - Check `board_summary` at the start of each session
7
+ - Use `pick_next_issue` to claim work
8
+ - Add comments to track progress
9
+ - Call `complete_issue` when done
10
+
11
+ ## Statuses
12
+ todo → in-progress → review → done → archived
@@ -0,0 +1,38 @@
1
+ {
2
+ // prodboard configuration
3
+ // See config.schema.json for full documentation
4
+
5
+ "general": {
6
+ // Issue statuses (order matters for board display)
7
+ // "statuses": ["todo", "in-progress", "review", "done", "archived"],
8
+
9
+ // Default status for new issues
10
+ // "defaultStatus": "todo",
11
+
12
+ // Optional prefix for issue IDs (e.g., "PB-")
13
+ // "idPrefix": ""
14
+ },
15
+
16
+ "daemon": {
17
+ // Maximum concurrent scheduled runs
18
+ // "maxConcurrentRuns": 2,
19
+
20
+ // Default max turns for Claude invocations
21
+ // "maxTurns": 50,
22
+
23
+ // Absolute maximum turns (cannot be overridden by schedules)
24
+ // "hardMaxTurns": 200,
25
+
26
+ // Timeout per run in seconds (default: 30 minutes)
27
+ // "runTimeoutSeconds": 1800,
28
+
29
+ // Days to retain run history
30
+ // "runRetentionDays": 30,
31
+
32
+ // Log level: "debug", "info", "warn", "error"
33
+ // "logLevel": "info",
34
+
35
+ // Worktree usage: "auto", "always", "never"
36
+ // "useWorktrees": "auto"
37
+ }
38
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "mcpServers": {
3
+ "prodboard": {
4
+ "command": "bunx",
5
+ "args": ["prodboard", "mcp"]
6
+ }
7
+ }
8
+ }
@@ -0,0 +1,33 @@
1
+ # prodboard — Issue Tracker Context
2
+
3
+ You have access to a prodboard MCP server for issue tracking. Use these tools to manage work:
4
+
5
+ ## Workflow
6
+ 1. At the start of a session, call `board_summary` to see the current state
7
+ 2. Use `pick_next_issue` to claim work (moves issue to in-progress)
8
+ 3. Work on the issue, adding comments with `add_comment` to track progress
9
+ 4. When done, call `complete_issue` with a summary of what was accomplished
10
+
11
+ ## Tools Available
12
+ - `board_summary` — Overview of all issues and their statuses
13
+ - `list_issues` — List issues with optional status/search filters
14
+ - `get_issue` — Get full issue details including comments
15
+ - `create_issue` — Create a new issue
16
+ - `update_issue` — Update issue fields (title, description, status)
17
+ - `delete_issue` — Delete an issue
18
+ - `add_comment` — Add a comment to an issue
19
+ - `pick_next_issue` — Pick the next todo issue and start working on it
20
+ - `complete_issue` — Mark an issue as done with optional completion comment
21
+
22
+ ## Guidelines
23
+ - Always check the board before starting work
24
+ - Add comments to track progress and decisions
25
+ - Move issues through statuses: todo → in-progress → review → done
26
+ - Create new issues for discovered work or bugs
27
+ - Use descriptive titles and add context in descriptions
28
+
29
+ ## Non-Git Environment
30
+ - This environment does not have git version control
31
+ - Focus on file-based operations using Read, Edit, Write, Glob, Grep, and Bash tools
32
+ - Be extra careful with file modifications since there is no version history
33
+ - Consider creating backups before making significant changes
@@ -0,0 +1,31 @@
1
+ # prodboard — Issue Tracker Context
2
+
3
+ You have access to a prodboard MCP server for issue tracking. Use these tools to manage work:
4
+
5
+ ## Workflow
6
+ 1. At the start of a session, call `board_summary` to see the current state
7
+ 2. Use `pick_next_issue` to claim work (moves issue to in-progress)
8
+ 3. Work on the issue, adding comments with `add_comment` to track progress
9
+ 4. When done, call `complete_issue` with a summary of what was accomplished
10
+
11
+ ## Tools Available
12
+ - `board_summary` — Overview of all issues and their statuses
13
+ - `list_issues` — List issues with optional status/search filters
14
+ - `get_issue` — Get full issue details including comments
15
+ - `create_issue` — Create a new issue
16
+ - `update_issue` — Update issue fields (title, description, status)
17
+ - `delete_issue` — Delete an issue
18
+ - `add_comment` — Add a comment to an issue
19
+ - `pick_next_issue` — Pick the next todo issue and start working on it
20
+ - `complete_issue` — Mark an issue as done with optional completion comment
21
+
22
+ ## Guidelines
23
+ - Always check the board before starting work
24
+ - Add comments to track progress and decisions
25
+ - Move issues through statuses: todo → in-progress → review → done
26
+ - Create new issues for discovered work or bugs
27
+ - Use descriptive titles and add context in descriptions
28
+
29
+ ## Git Integration
30
+ - Commit related changes together with descriptive messages
31
+ - Reference issue IDs in commit messages when relevant