prodboard 0.2.3 → 0.3.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 +16 -0
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/agents/claude.ts +7 -6
- package/src/agents/types.ts +6 -4
- package/src/commands/schedules.ts +1 -0
- package/src/mcp.ts +59 -1
- package/src/queries/runs.ts +9 -2
- package/src/scheduler.ts +9 -2
- package/src/webui/components/board.tsx +1 -1
- package/src/webui/routes/issues.tsx +79 -1
- package/src/webui/routes/runs.tsx +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# prodboard
|
|
2
2
|
|
|
3
|
+
## 0.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#17](https://github.com/G4brym/prodboard/pull/17) [`20f3860`](https://github.com/G4brym/prodboard/commit/20f38608b1a5a9d986bcf1d04d4dfb579fa7ec18) Thanks [@G4brym](https://github.com/G4brym)! - Add `trigger_schedule` MCP tool to manually trigger a schedule run
|
|
8
|
+
|
|
9
|
+
Adds a new MCP tool that allows agents and users to trigger a schedule to run immediately without waiting for the cron interval. The run is started asynchronously and returns the run ID so callers can check status via `list_runs`. Disabled schedules are rejected, and the concurrent run limit is enforced.
|
|
10
|
+
|
|
11
|
+
### Patch Changes
|
|
12
|
+
|
|
13
|
+
- [#20](https://github.com/G4brym/prodboard/pull/20) [`53c5d4d`](https://github.com/G4brym/prodboard/commit/53c5d4d1ee7331e50f4b4cc5fb1aa8e37836967e) Thanks [@G4brym](https://github.com/G4brym)! - Fix schedules with identical cron patterns — all matching schedules now fire
|
|
14
|
+
|
|
15
|
+
Snapshot the running-run count once before the tick loop instead of re-querying
|
|
16
|
+
inside the loop. This prevents a run created for schedule A from counting against
|
|
17
|
+
schedule B's concurrency check when both share the same cron expression.
|
|
18
|
+
|
|
3
19
|
## 0.2.3
|
|
4
20
|
|
|
5
21
|
### Patch Changes
|
package/README.md
CHANGED
|
@@ -197,6 +197,7 @@ These are the tools Claude Code sees when connected to the board:
|
|
|
197
197
|
| `create_schedule` | Set up a new cron job |
|
|
198
198
|
| `update_schedule` | Modify a schedule |
|
|
199
199
|
| `delete_schedule` | Remove a schedule |
|
|
200
|
+
| `trigger_schedule` | Trigger a schedule to run immediately |
|
|
200
201
|
| `list_runs` | Check run history and results |
|
|
201
202
|
|
|
202
203
|
MCP resources: `prodboard://issues` (board summary) and `prodboard://schedules` (active schedules).
|
package/package.json
CHANGED
package/src/agents/claude.ts
CHANGED
|
@@ -86,13 +86,14 @@ export class ClaudeDriver implements AgentDriver {
|
|
|
86
86
|
}
|
|
87
87
|
}
|
|
88
88
|
if (event.type === "result") {
|
|
89
|
-
if (event.
|
|
90
|
-
|
|
91
|
-
|
|
89
|
+
if (event.usage) {
|
|
90
|
+
tokens_in = (event.usage.input_tokens ?? 0)
|
|
91
|
+
+ (event.usage.cache_read_input_tokens ?? 0)
|
|
92
|
+
+ (event.usage.cache_creation_input_tokens ?? 0);
|
|
93
|
+
tokens_out = event.usage.output_tokens ?? 0;
|
|
94
|
+
}
|
|
95
|
+
if (event.total_cost_usd) cost_usd = event.total_cost_usd;
|
|
92
96
|
}
|
|
93
|
-
if (event.tokens_in) tokens_in = event.tokens_in;
|
|
94
|
-
if (event.tokens_out) tokens_out = event.tokens_out;
|
|
95
|
-
if (event.cost_usd) cost_usd = event.cost_usd;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
return {
|
package/src/agents/types.ts
CHANGED
|
@@ -25,10 +25,12 @@ export interface StreamEvent {
|
|
|
25
25
|
session_id?: string;
|
|
26
26
|
tool?: string;
|
|
27
27
|
tool_input?: any;
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
28
|
+
total_cost_usd?: number;
|
|
29
|
+
usage?: {
|
|
30
|
+
input_tokens?: number;
|
|
31
|
+
output_tokens?: number;
|
|
32
|
+
cache_read_input_tokens?: number;
|
|
33
|
+
cache_creation_input_tokens?: number;
|
|
32
34
|
};
|
|
33
35
|
[key: string]: any;
|
|
34
36
|
}
|
|
@@ -205,6 +205,7 @@ export async function scheduleLogs(args: string[], dbOverride?: Database): Promi
|
|
|
205
205
|
schedule_id: (flags.schedule ?? flags.s) as string | undefined,
|
|
206
206
|
status: flags.status as string | undefined,
|
|
207
207
|
limit: safeParseInt(flags.limit ?? flags.n),
|
|
208
|
+
include_output: isJson,
|
|
208
209
|
});
|
|
209
210
|
|
|
210
211
|
if (isJson) {
|
package/src/mcp.ts
CHANGED
|
@@ -200,16 +200,28 @@ const TOOLS = [
|
|
|
200
200
|
},
|
|
201
201
|
{
|
|
202
202
|
name: "list_runs",
|
|
203
|
-
description: "List run history for schedules.",
|
|
203
|
+
description: "List run history for schedules. By default excludes large fields (stdout_tail, prompt_used) for compact output.",
|
|
204
204
|
inputSchema: {
|
|
205
205
|
type: "object" as const,
|
|
206
206
|
properties: {
|
|
207
207
|
schedule_id: { type: "string" as const, description: "Filter by schedule ID" },
|
|
208
208
|
status: { type: "string" as const, description: "Filter by run status" },
|
|
209
209
|
limit: { type: "number" as const, description: "Max runs to return" },
|
|
210
|
+
include_output: { type: "boolean" as const, description: "Include stdout_tail and prompt_used fields (default: false)" },
|
|
210
211
|
},
|
|
211
212
|
},
|
|
212
213
|
},
|
|
214
|
+
{
|
|
215
|
+
name: "trigger_schedule",
|
|
216
|
+
description: "Manually trigger a schedule to run immediately. The run is started asynchronously — returns the run ID so you can check status later via list_runs.",
|
|
217
|
+
inputSchema: {
|
|
218
|
+
type: "object" as const,
|
|
219
|
+
properties: {
|
|
220
|
+
id: { type: "string" as const, description: "Schedule ID or unique prefix" },
|
|
221
|
+
},
|
|
222
|
+
required: ["id"],
|
|
223
|
+
},
|
|
224
|
+
},
|
|
213
225
|
];
|
|
214
226
|
|
|
215
227
|
const RESOURCES = [
|
|
@@ -429,7 +441,50 @@ export async function handleListRuns(db: Database, params: any) {
|
|
|
429
441
|
schedule_id: params?.schedule_id,
|
|
430
442
|
status: params?.status,
|
|
431
443
|
limit: params?.limit,
|
|
444
|
+
include_output: params?.include_output,
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export async function handleTriggerSchedule(db: Database, config: Config, params: any) {
|
|
449
|
+
const sq = await getScheduleQueries();
|
|
450
|
+
if (!sq) throw new Error("Schedule module not available");
|
|
451
|
+
const rq = await getRunQueries();
|
|
452
|
+
if (!rq) throw new Error("Run module not available");
|
|
453
|
+
|
|
454
|
+
const schedule = sq.getScheduleByPrefix(db, params.id);
|
|
455
|
+
|
|
456
|
+
if (!schedule.enabled) {
|
|
457
|
+
throw new Error(`Schedule "${schedule.name}" is disabled. Enable it first via update_schedule.`);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Respect concurrent run limit
|
|
461
|
+
const runningRuns = rq.getRunningRuns(db);
|
|
462
|
+
if (runningRuns.length >= config.daemon.maxConcurrentRuns) {
|
|
463
|
+
throw new Error(
|
|
464
|
+
`Concurrent run limit reached (${runningRuns.length}/${config.daemon.maxConcurrentRuns}). Wait for a running schedule to finish or increase maxConcurrentRuns.`
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
const run = rq.createRun(db, {
|
|
469
|
+
schedule_id: schedule.id,
|
|
470
|
+
prompt_used: schedule.prompt,
|
|
471
|
+
agent: config.daemon.agent,
|
|
432
472
|
});
|
|
473
|
+
|
|
474
|
+
// Fire-and-forget: start execution asynchronously
|
|
475
|
+
// Note: ExecutionManager created without tmux/worktree managers — MCP-triggered runs
|
|
476
|
+
// use direct execution only. Daemon-triggered runs get full tmux/worktree support.
|
|
477
|
+
const { ExecutionManager } = await import("./scheduler.ts");
|
|
478
|
+
const em = new ExecutionManager(db, config);
|
|
479
|
+
em.executeRun(schedule, run).catch(() => {});
|
|
480
|
+
|
|
481
|
+
return {
|
|
482
|
+
run_id: run.id,
|
|
483
|
+
schedule_id: schedule.id,
|
|
484
|
+
schedule_name: schedule.name,
|
|
485
|
+
status: "started",
|
|
486
|
+
message: "Run started asynchronously. Use list_runs to check status.",
|
|
487
|
+
};
|
|
433
488
|
}
|
|
434
489
|
|
|
435
490
|
export async function startMcpServer(): Promise<void> {
|
|
@@ -497,6 +552,9 @@ export async function startMcpServer(): Promise<void> {
|
|
|
497
552
|
case "list_runs":
|
|
498
553
|
result = await handleListRuns(db, params ?? {});
|
|
499
554
|
break;
|
|
555
|
+
case "trigger_schedule":
|
|
556
|
+
result = await handleTriggerSchedule(db, config, params ?? {});
|
|
557
|
+
break;
|
|
500
558
|
default:
|
|
501
559
|
return {
|
|
502
560
|
content: [{ type: "text", text: `Unknown tool: ${name}` }],
|
package/src/queries/runs.ts
CHANGED
|
@@ -50,7 +50,7 @@ export function updateRun(
|
|
|
50
50
|
|
|
51
51
|
export function listRuns(
|
|
52
52
|
db: Database,
|
|
53
|
-
opts?: { schedule_id?: string; status?: string; limit?: number }
|
|
53
|
+
opts?: { schedule_id?: string; status?: string; limit?: number; include_output?: boolean }
|
|
54
54
|
): Run[] {
|
|
55
55
|
const conditions: string[] = [];
|
|
56
56
|
const params: any[] = [];
|
|
@@ -67,8 +67,15 @@ export function listRuns(
|
|
|
67
67
|
const where = conditions.length > 0 ? "WHERE " + conditions.join(" AND ") : "";
|
|
68
68
|
const limit = opts?.limit ?? 50;
|
|
69
69
|
|
|
70
|
+
const columns = opts?.include_output
|
|
71
|
+
? "r.*"
|
|
72
|
+
: `r.id, r.schedule_id, r.status, r.pid, r.started_at, r.finished_at,
|
|
73
|
+
r.exit_code, r.stderr_tail, r.session_id, r.worktree_path,
|
|
74
|
+
r.tokens_in, r.tokens_out, r.cost_usd, r.tools_used,
|
|
75
|
+
r.issues_touched, r.tmux_session, r.agent`;
|
|
76
|
+
|
|
70
77
|
return db.query(`
|
|
71
|
-
SELECT
|
|
78
|
+
SELECT ${columns}, s.name as schedule_name
|
|
72
79
|
FROM runs r
|
|
73
80
|
LEFT JOIN schedules s ON r.schedule_id = s.id
|
|
74
81
|
${where}
|
package/src/scheduler.ts
CHANGED
|
@@ -282,6 +282,13 @@ export class CronLoop {
|
|
|
282
282
|
|
|
283
283
|
const schedules = listSchedules(this.db);
|
|
284
284
|
|
|
285
|
+
// Snapshot running count once before creating any new runs this tick.
|
|
286
|
+
// Without this, createRun() for schedule A inserts a 'running' row that
|
|
287
|
+
// getRunningRuns() would count when evaluating schedule B, causing
|
|
288
|
+
// schedules with identical cron patterns to block each other.
|
|
289
|
+
const runningCount = getRunningRuns(this.db).length;
|
|
290
|
+
let newRunsThisTick = 0;
|
|
291
|
+
|
|
285
292
|
for (const schedule of schedules) {
|
|
286
293
|
try {
|
|
287
294
|
if (!shouldFire(schedule.cron, now)) continue;
|
|
@@ -289,8 +296,7 @@ export class CronLoop {
|
|
|
289
296
|
const lastFiredMinute = this.lastFired.get(schedule.id);
|
|
290
297
|
if (lastFiredMinute === minuteTs) continue;
|
|
291
298
|
|
|
292
|
-
|
|
293
|
-
if (runningRuns.length >= this.config.daemon.maxConcurrentRuns) continue;
|
|
299
|
+
if (runningCount + newRunsThisTick >= this.config.daemon.maxConcurrentRuns) break;
|
|
294
300
|
|
|
295
301
|
const run = createRun(this.db, {
|
|
296
302
|
schedule_id: schedule.id,
|
|
@@ -299,6 +305,7 @@ export class CronLoop {
|
|
|
299
305
|
});
|
|
300
306
|
|
|
301
307
|
this.lastFired.set(schedule.id, minuteTs);
|
|
308
|
+
newRunsThisTick++;
|
|
302
309
|
|
|
303
310
|
this.executionManager.executeRun(schedule, run).catch(() => {});
|
|
304
311
|
} catch (err) {
|
|
@@ -14,7 +14,7 @@ export const Board: FC<{ issues: Issue[]; statuses: string[] }> = ({ issues, sta
|
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
return (
|
|
17
|
-
<div class="flex gap-4 overflow-x-auto pb-4">
|
|
17
|
+
<div id="board" data-statuses={JSON.stringify(statuses.filter((s) => s !== "archived"))} class="flex gap-4 overflow-x-auto pb-4">
|
|
18
18
|
{statuses.filter((s) => s !== "archived").map((status) => (
|
|
19
19
|
<div class="flex-1 min-w-[220px]" key={status}>
|
|
20
20
|
<div class="flex items-center gap-2 mb-3 px-1">
|
|
@@ -21,7 +21,7 @@ export function issueRoutes(db: Database, config: Config) {
|
|
|
21
21
|
<div class="flex items-center justify-between mb-6">
|
|
22
22
|
<div>
|
|
23
23
|
<h1 class="text-xl font-semibold">Issues</h1>
|
|
24
|
-
<p class="text-sm text-muted-foreground mt-0.5">{issues.length} total</p>
|
|
24
|
+
<p id="board-total" class="text-sm text-muted-foreground mt-0.5">{issues.length} total</p>
|
|
25
25
|
</div>
|
|
26
26
|
<button
|
|
27
27
|
onclick="document.getElementById('new-issue-form').classList.toggle('hidden')"
|
|
@@ -76,6 +76,84 @@ export function issueRoutes(db: Database, config: Config) {
|
|
|
76
76
|
</div>
|
|
77
77
|
|
|
78
78
|
<Board issues={issues} statuses={config.general.statuses} />
|
|
79
|
+
|
|
80
|
+
<p id="board-updated" class="text-xs text-muted-foreground/50 mt-2 text-right"></p>
|
|
81
|
+
|
|
82
|
+
<script dangerouslySetInnerHTML={{ __html: `
|
|
83
|
+
(function() {
|
|
84
|
+
var INTERVAL = 30000;
|
|
85
|
+
var board = document.getElementById('board');
|
|
86
|
+
var updatedEl = document.getElementById('board-updated');
|
|
87
|
+
var statuses = JSON.parse(board.dataset.statuses);
|
|
88
|
+
var lastUpdate = Date.now();
|
|
89
|
+
|
|
90
|
+
var STATUS_STYLES = {
|
|
91
|
+
'todo': 'bg-zinc-700/50 text-zinc-300 border-zinc-600',
|
|
92
|
+
'in-progress': 'bg-blue-500/15 text-blue-400 border-blue-500/30',
|
|
93
|
+
'review': 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
|
94
|
+
'done': 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
|
|
95
|
+
'human-approval': 'bg-amber-500/15 text-amber-400 border-amber-500/30',
|
|
96
|
+
'running': 'bg-blue-500/15 text-blue-400 border-blue-500/30'
|
|
97
|
+
};
|
|
98
|
+
var DEFAULT_STYLE = 'bg-zinc-700/50 text-zinc-300 border-zinc-600';
|
|
99
|
+
|
|
100
|
+
function escapeHtml(s) {
|
|
101
|
+
var d = document.createElement('div');
|
|
102
|
+
d.textContent = s;
|
|
103
|
+
return d.innerHTML;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function renderBoard(issues) {
|
|
107
|
+
var grouped = {};
|
|
108
|
+
statuses.forEach(function(s) { grouped[s] = []; });
|
|
109
|
+
issues.forEach(function(issue) {
|
|
110
|
+
if (grouped[issue.status]) grouped[issue.status].push(issue);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
var html = '';
|
|
114
|
+
statuses.forEach(function(status) {
|
|
115
|
+
var items = grouped[status] || [];
|
|
116
|
+
html += '<div class="flex-1 min-w-[220px]">';
|
|
117
|
+
html += '<div class="flex items-center gap-2 mb-3 px-1">';
|
|
118
|
+
html += '<h3 class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">' + escapeHtml(status) + '</h3>';
|
|
119
|
+
html += '<span class="text-xs text-muted-foreground/60">' + items.length + '</span>';
|
|
120
|
+
html += '</div>';
|
|
121
|
+
html += '<div class="space-y-2">';
|
|
122
|
+
items.forEach(function(issue) {
|
|
123
|
+
html += '<a href="/issues/' + escapeHtml(issue.id) + '" class="block rounded-lg border border-border bg-card p-3 hover:bg-accent transition-colors group">';
|
|
124
|
+
html += '<div class="text-sm font-medium text-card-foreground group-hover:text-foreground">' + escapeHtml(issue.title) + '</div>';
|
|
125
|
+
html += '<div class="text-xs text-muted-foreground mt-1 font-mono">' + escapeHtml(issue.id.slice(0, 8)) + '</div>';
|
|
126
|
+
html += '</a>';
|
|
127
|
+
});
|
|
128
|
+
html += '</div></div>';
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
board.innerHTML = html;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function updateTimestamp() {
|
|
135
|
+
var secs = Math.round((Date.now() - lastUpdate) / 1000);
|
|
136
|
+
updatedEl.textContent = 'Updated ' + secs + 's ago';
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function poll() {
|
|
140
|
+
fetch('/api/issues')
|
|
141
|
+
.then(function(r) { return r.ok ? r.json() : null; })
|
|
142
|
+
.then(function(issues) {
|
|
143
|
+
if (!issues) return;
|
|
144
|
+
renderBoard(issues);
|
|
145
|
+
var countEl = document.getElementById('board-total');
|
|
146
|
+
if (countEl) countEl.textContent = issues.length + ' total';
|
|
147
|
+
lastUpdate = Date.now();
|
|
148
|
+
updateTimestamp();
|
|
149
|
+
})
|
|
150
|
+
.catch(function() {});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
setInterval(poll, INTERVAL);
|
|
154
|
+
setInterval(updateTimestamp, 5000);
|
|
155
|
+
})();
|
|
156
|
+
` }} />
|
|
79
157
|
</Layout>
|
|
80
158
|
);
|
|
81
159
|
});
|
|
@@ -5,6 +5,7 @@ import type { Config } from "../../types.ts";
|
|
|
5
5
|
import { Layout } from "../components/layout.tsx";
|
|
6
6
|
import { StatusBadge } from "../components/status-badge.tsx";
|
|
7
7
|
import { listRuns, getRunningRuns } from "../../queries/runs.ts";
|
|
8
|
+
import { listIssues } from "../../queries/issues.ts";
|
|
8
9
|
import type { Run } from "../../types.ts";
|
|
9
10
|
|
|
10
11
|
export function runRoutes(db: Database, _config: Config) {
|
|
@@ -137,5 +138,10 @@ export function apiRoutes(db: Database, _config: Config) {
|
|
|
137
138
|
});
|
|
138
139
|
});
|
|
139
140
|
|
|
141
|
+
app.get("/issues", (c) => {
|
|
142
|
+
const { issues } = listIssues(db, { includeArchived: true, limit: 500 });
|
|
143
|
+
return c.json(issues);
|
|
144
|
+
});
|
|
145
|
+
|
|
140
146
|
return app;
|
|
141
147
|
}
|