gcf-board 0.2.6 → 0.2.9
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/README.md +2 -2
- package/dist/server/app.js +29 -4
- package/dist/server/events.js +7 -2
- package/dist/server/runs.js +147 -15
- package/dist/web/assets/index-3JIv76Zh.js +31 -0
- package/dist/web/assets/{index-Cpbbv8DY.css → index-CJpKQHTx.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-CF-CscK4.js +0 -31
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console s
|
|
|
30
30
|
|
|
31
31
|
## What it does
|
|
32
32
|
|
|
33
|
-
- **Board**: columns ready, running, blocked, awaiting-verdict, awaiting-decision, unplayed, parked. A card shows the type, the gate, the plan progress (`2/5`), the severity, `verified`, the model.
|
|
33
|
+
- **Board**: columns ready, running, blocked, awaiting-verdict, awaiting-decision, unplayed, parked. A card shows the type, the gate, the plan progress (`2/5`), the severity, `verified`, the model. A running card shows when its run log was last written (`active 12 s ago`), and `quiet 6 min` when the log has been silent for 5 minutes or more. A run's end report gives each item's cost when the agent reported it.
|
|
34
34
|
- **An item**: its file, its plan and its run log as it grows, and the moves the core offers for it (`items[].moves`, Methodology §4.1): verdict, decide, block, unblock, drop, close, submit. The board keeps only each move's form; the core still checks the rest itself, and the board shows its answer.
|
|
35
35
|
- **Drag**:
|
|
36
36
|
- a ready card into **running** starts its run;
|
|
@@ -42,7 +42,7 @@ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console s
|
|
|
42
42
|
- `run_mode: worktree`: one worktree and one agent session per item, all at once, then the integrator;
|
|
43
43
|
- `run_mode: main`: one item at a time in the project folder. A run asked for meanwhile waits in the board's **queue**; **remove from queue** takes it out.
|
|
44
44
|
|
|
45
|
-
A run asks first: it spends the subscription. **Stop** on a running item ends its session; the item stays ready and its work is kept. A queued main run that the next one would repeat (the harness did not start, or another run works the checkout) pauses the queue: the header shows **queue paused — resume**; fix the cause, then resume. A card marked **interrupted** had a run that stopped (a timeout, a stop, a crash):
|
|
45
|
+
A run asks first: it spends the subscription. **Stop** on a running item ends its session; the item stays ready and its work is kept. A queued main run that the next one would repeat (the harness did not start, or another run works the checkout) pauses the queue: the header shows **queue paused — resume**; fix the cause, then resume. A card marked **interrupted** had a run that stopped (a timeout, a stop, a crash, the usage limit): **resume** continues its agent session with all its context, **start fresh** drops it. After the usage limit the card reads **paused: limit** and the board resumes it by itself at the reset time; the header shows **resumes HH:MM** with **cancel**. When a run ends, a toast lists each item's result. The queue lives in the board's memory: a board restart forgets it.
|
|
46
46
|
- **Run next N independent**: the framework's gate picks the ready items that can run together now, by priority, and the board runs them after asking.
|
|
47
47
|
- **Priority**: drop a ready card on another to put it before; the column's ranks are committed (`gcf prioritize`).
|
|
48
48
|
- **Questions**: each D1 shows its question and proposed answer; **Answer** writes yours into its record (`gcf question answer`), and the next agent on its units puts it in place.
|
package/dist/server/app.js
CHANGED
|
@@ -14,7 +14,7 @@ import { followLog, readShown, shownShape } from './files.js';
|
|
|
14
14
|
import { HttpError } from './http-error.js';
|
|
15
15
|
import { moveArgs, runIds } from './moves.js';
|
|
16
16
|
import { addProject, listProjects, projectOf, removeProject } from './registry.js';
|
|
17
|
-
import { resumeQueue, runsOf, startRun, unqueue } from './runs.js';
|
|
17
|
+
import { cancelResume, forgetProject, resumeQueue, runsOf, startRun, unqueue } from './runs.js';
|
|
18
18
|
const send = (res, status, body) => {
|
|
19
19
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
20
20
|
res.end(JSON.stringify(body));
|
|
@@ -49,6 +49,15 @@ function jsonOf(r, what) {
|
|
|
49
49
|
throw new HttpError(502, `ai-factory ${what} gave no report: ${(r.err || r.out).trim()}`);
|
|
50
50
|
}
|
|
51
51
|
}
|
|
52
|
+
/** The ISO time a file was last written, or null when it is not there. */
|
|
53
|
+
const activeAt = (file) => {
|
|
54
|
+
try {
|
|
55
|
+
return statSync(file).mtime.toISOString();
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
52
61
|
const TYPES = {
|
|
53
62
|
'.html': 'text/html; charset=utf-8',
|
|
54
63
|
'.js': 'text/javascript; charset=utf-8',
|
|
@@ -116,6 +125,7 @@ export function createApp(o) {
|
|
|
116
125
|
/^\/api\/projects\/([^/]+)$/,
|
|
117
126
|
async (_req, res, m) => {
|
|
118
127
|
await removeProject(m[1]);
|
|
128
|
+
forgetProject(m[1]);
|
|
119
129
|
watchers.get(m[1])?.();
|
|
120
130
|
watchers.delete(m[1]);
|
|
121
131
|
send(res, 200, { ok: true });
|
|
@@ -129,7 +139,12 @@ export function createApp(o) {
|
|
|
129
139
|
// The board's own runs ride along: what it started and what waits in its queue; and the
|
|
130
140
|
// count of plan writes, which tells the page an open plan's text changed.
|
|
131
141
|
const own = { runs: runsOf(p.name), plans_rev: planRev(p.name) };
|
|
132
|
-
|
|
142
|
+
const board = await boardOf(p);
|
|
143
|
+
// A running item's last log write tells a working session from a stuck one.
|
|
144
|
+
for (const i of board.items)
|
|
145
|
+
if (i.running)
|
|
146
|
+
i.active_at = i.log ? activeAt(path.join(p.path, i.log)) : null;
|
|
147
|
+
send(res, 200, { ...board, ...own });
|
|
133
148
|
},
|
|
134
149
|
],
|
|
135
150
|
[
|
|
@@ -191,8 +206,9 @@ export function createApp(o) {
|
|
|
191
206
|
/^\/api\/p\/([^/]+)\/run$/,
|
|
192
207
|
async (req, res, m) => {
|
|
193
208
|
const p = await projectOf(m[1]);
|
|
194
|
-
const
|
|
195
|
-
|
|
209
|
+
const body = await bodyOf(req);
|
|
210
|
+
const mode = (await boardOf(p)).run_mode;
|
|
211
|
+
send(res, 202, startRun(o.entry, p, runIds(body), mode, body.fresh === true));
|
|
196
212
|
},
|
|
197
213
|
],
|
|
198
214
|
[
|
|
@@ -224,6 +240,15 @@ export function createApp(o) {
|
|
|
224
240
|
send(res, 200, { ok: true });
|
|
225
241
|
},
|
|
226
242
|
],
|
|
243
|
+
[
|
|
244
|
+
// The resume a usage limit scheduled; the queue stays paused.
|
|
245
|
+
'DELETE',
|
|
246
|
+
/^\/api\/p\/([^/]+)\/schedule$/,
|
|
247
|
+
async (_req, res, m) => {
|
|
248
|
+
cancelResume((await projectOf(m[1])).name);
|
|
249
|
+
send(res, 200, { ok: true });
|
|
250
|
+
},
|
|
251
|
+
],
|
|
227
252
|
[
|
|
228
253
|
'DELETE',
|
|
229
254
|
/^\/api\/p\/([^/]+)\/queue\/([a-z0-9][a-z0-9-]*)$/,
|
package/dist/server/events.js
CHANGED
|
@@ -29,6 +29,7 @@ export function broadcast(event) {
|
|
|
29
29
|
// that stopped. shortcut: a copy of the core's constant, read it from the board JSON once the core
|
|
30
30
|
// reports a mark's expiry.
|
|
31
31
|
const SESSION_TTL_MS = 120 * 60_000;
|
|
32
|
+
const LOG_STEP_MS = 10_000;
|
|
32
33
|
const alive = (pid) => {
|
|
33
34
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
34
35
|
return false;
|
|
@@ -42,8 +43,8 @@ const alive = (pid) => {
|
|
|
42
43
|
};
|
|
43
44
|
/**
|
|
44
45
|
* What makes items `running` in a runs directory: its entries, whether each lock's process
|
|
45
|
-
* lives, whether each session mark is fresh
|
|
46
|
-
* it with no file written.
|
|
46
|
+
* lives, whether each session mark is fresh; and how recently each run log was written. A run
|
|
47
|
+
* that died or a session that expired changes it with no file written.
|
|
47
48
|
*/
|
|
48
49
|
export function runsState(dir, now = Date.now()) {
|
|
49
50
|
let names;
|
|
@@ -61,6 +62,10 @@ export function runsState(dir, now = Date.now()) {
|
|
|
61
62
|
return `${n} ${alive(Number(readFileSync(file, 'utf8').split('\n')[0]))}`;
|
|
62
63
|
if (n.endsWith('.session'))
|
|
63
64
|
return `${n} ${now - statSync(file).mtimeMs < SESSION_TTL_MS}`;
|
|
65
|
+
// A session writes its log many times a second: its card's activity time moves by steps
|
|
66
|
+
// of LOG_STEP_MS, each one a board read, not one per line.
|
|
67
|
+
if (n.endsWith('.log'))
|
|
68
|
+
return `${n} ${Math.floor(statSync(file).mtimeMs / LOG_STEP_MS)}`;
|
|
64
69
|
}
|
|
65
70
|
catch {
|
|
66
71
|
// Gone meanwhile: the next poll sees it.
|
package/dist/server/runs.js
CHANGED
|
@@ -12,13 +12,123 @@ const active = new Set();
|
|
|
12
12
|
const queues = new Map();
|
|
13
13
|
// Projects whose queue waits after a failure the next run would repeat (`stalled`).
|
|
14
14
|
const paused = new Set();
|
|
15
|
+
// shortcut: the scheduled resume after a usage limit lives in memory, like the queue: a board
|
|
16
|
+
// restart loses it, and the operator resumes by hand; persist it if restarts become common.
|
|
17
|
+
const scheduled = new Map();
|
|
18
|
+
// The paused projects whose pause the usage limit set: its reset may lift it, nothing else.
|
|
19
|
+
const limitPaused = new Set();
|
|
20
|
+
// Runs going on for a project the operator removed: their end touches no project state.
|
|
21
|
+
const forgotten = new WeakSet();
|
|
15
22
|
export const activeRuns = (project) => [...active].filter((r) => r.project === project);
|
|
16
23
|
/** What runs and what waits, for a project; a paused queue waits for `resumeQueue`. */
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
24
|
+
export function runsOf(project) {
|
|
25
|
+
const s = scheduled.get(project);
|
|
26
|
+
return {
|
|
27
|
+
active: activeRuns(project),
|
|
28
|
+
queued: (queues.get(project) ?? []).flatMap((q) => q.ids),
|
|
29
|
+
paused: paused.has(project),
|
|
30
|
+
// When the items a usage limit stopped run again by themselves, and the queue with them.
|
|
31
|
+
resume: s ? { at: s.at.toISOString(), ids: s.run.ids } : null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The time a usage limit resets, as Claude names it (`3am`, `3:30pm`, `15:00`, with or without
|
|
36
|
+
* `(Europe/Berlin)`): its next occurrence after `now`, a minute late to be safe; null when the
|
|
37
|
+
* text is no such time.
|
|
38
|
+
*/
|
|
39
|
+
export function resetAt(text, now = new Date()) {
|
|
40
|
+
// shortcut: the time is read on this machine's clock and the zone it names is ignored: the
|
|
41
|
+
// board runs where the operator's claude does; convert through Intl if a zone ever differs.
|
|
42
|
+
const m = /^\s*(\d{1,2})(?::(\d{2}))?\s*([ap]m)?\s*(\(.*\))?\s*$/i.exec(text);
|
|
43
|
+
if (!m || (!m[2] && !m[3]))
|
|
44
|
+
return null;
|
|
45
|
+
let hour = Number(m[1]);
|
|
46
|
+
const minute = Number(m[2] ?? 0);
|
|
47
|
+
if (m[3]) {
|
|
48
|
+
if (hour < 1 || hour > 12)
|
|
49
|
+
return null;
|
|
50
|
+
hour = (hour % 12) + (m[3].toLowerCase() === 'pm' ? 12 : 0);
|
|
51
|
+
}
|
|
52
|
+
if (hour > 23 || minute > 59)
|
|
53
|
+
return null;
|
|
54
|
+
const at = new Date(now);
|
|
55
|
+
at.setHours(hour, minute + 1, 0, 0);
|
|
56
|
+
if (at <= now)
|
|
57
|
+
at.setDate(at.getDate() + 1);
|
|
58
|
+
return at;
|
|
59
|
+
}
|
|
60
|
+
/** Drop the scheduled resume; the queue stays as it is. */
|
|
61
|
+
export function cancelResume(project) {
|
|
62
|
+
clearTimeout(scheduled.get(project)?.timer);
|
|
63
|
+
scheduled.delete(project);
|
|
64
|
+
broadcast({ type: 'board', project });
|
|
65
|
+
}
|
|
66
|
+
/** Lift a pause the usage limit set; a pause for another cause waits for the operator. */
|
|
67
|
+
function liftLimitPause(project) {
|
|
68
|
+
if (limitPaused.delete(project))
|
|
69
|
+
paused.delete(project);
|
|
70
|
+
}
|
|
71
|
+
/** A project left the board: nothing of it may run or fire any more. */
|
|
72
|
+
export function forgetProject(project) {
|
|
73
|
+
for (const r of activeRuns(project))
|
|
74
|
+
forgotten.add(r);
|
|
75
|
+
clearTimeout(scheduled.get(project)?.timer);
|
|
76
|
+
scheduled.delete(project);
|
|
77
|
+
queues.delete(project);
|
|
78
|
+
paused.delete(project);
|
|
79
|
+
limitPaused.delete(project);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* At the reset: run the stopped items again (the harness resumes their sessions), then the queue.
|
|
83
|
+
* A second limited run joins the first: all their items go, at the later reset.
|
|
84
|
+
*/
|
|
85
|
+
function schedule(run, at) {
|
|
86
|
+
const project = run.p.name;
|
|
87
|
+
const before = scheduled.get(project);
|
|
88
|
+
clearTimeout(before?.timer);
|
|
89
|
+
const ids = [...new Set([...(before?.run.ids ?? []), ...run.ids])];
|
|
90
|
+
const when = before && before.at > at ? before.at : at;
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
const s = scheduled.get(project);
|
|
93
|
+
scheduled.delete(project);
|
|
94
|
+
liftLimitPause(project);
|
|
95
|
+
try {
|
|
96
|
+
if (s)
|
|
97
|
+
startRun(s.run.entry, s.run.p, s.run.ids, s.run.mode);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
// Started from the board meanwhile: the queue still goes on after it.
|
|
101
|
+
advance(project);
|
|
102
|
+
}
|
|
103
|
+
broadcast({ type: 'board', project });
|
|
104
|
+
}, when.getTime() - Date.now());
|
|
105
|
+
timer.unref();
|
|
106
|
+
scheduled.set(project, { at: when, run: { ...run, ids }, timer });
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* A run some of whose items hit the usage limit: the main queue would hit it too, so it pauses,
|
|
110
|
+
* and those items run again at the reset when the message names a time. The reason, for its
|
|
111
|
+
* `run` event.
|
|
112
|
+
*/
|
|
113
|
+
function limited(run, result) {
|
|
114
|
+
const items = result
|
|
115
|
+
?.items;
|
|
116
|
+
const hit = (items ?? []).filter((i) => i.limit_resets !== undefined);
|
|
117
|
+
if (!hit.length)
|
|
118
|
+
return '';
|
|
119
|
+
const project = run.p.name;
|
|
120
|
+
if (run.mode === 'main') {
|
|
121
|
+
paused.add(project);
|
|
122
|
+
limitPaused.add(project);
|
|
123
|
+
}
|
|
124
|
+
const resets = hit.find((i) => i.limit_resets)?.limit_resets ?? '';
|
|
125
|
+
const at = resetAt(resets);
|
|
126
|
+
if (at)
|
|
127
|
+
schedule({ ...run, ids: hit.map((i) => i.id), fresh: false }, at);
|
|
128
|
+
const when = at ? `they resume at ${at.toTimeString().slice(0, 5)}` : 'run them after the reset';
|
|
129
|
+
const queue = run.mode === 'main' ? ', the queue is paused' : '';
|
|
130
|
+
return `usage limit${resets ? `, resets ${resets}` : ''}${queue}: ${when}`;
|
|
131
|
+
}
|
|
22
132
|
/** Start the next queued batch when the checkout is free: after a run, or on the operator's resume. */
|
|
23
133
|
function advance(project) {
|
|
24
134
|
if (paused.has(project) || activeRuns(project).some((r) => r.mode === 'main'))
|
|
@@ -30,6 +140,7 @@ function advance(project) {
|
|
|
30
140
|
/** The operator resumes a paused queue (after fixing what stopped it). */
|
|
31
141
|
export function resumeQueue(project) {
|
|
32
142
|
paused.delete(project);
|
|
143
|
+
limitPaused.delete(project);
|
|
33
144
|
advance(project);
|
|
34
145
|
broadcast({ type: 'board', project });
|
|
35
146
|
}
|
|
@@ -52,10 +163,12 @@ export function stalled(ok, result) {
|
|
|
52
163
|
const items = result?.items ?? [];
|
|
53
164
|
return (items.length > 0 && items.every((i) => /another run works in the main/.test(i.error ?? '')));
|
|
54
165
|
}
|
|
55
|
-
function spawnRun(
|
|
166
|
+
function spawnRun(q, mode) {
|
|
167
|
+
const { entry, p, ids } = q;
|
|
56
168
|
const run = { project: p.name, ids, started: new Date().toISOString(), mode };
|
|
57
169
|
active.add(run);
|
|
58
|
-
const
|
|
170
|
+
const args = [entry, 'gcf-harness', 'run', ...ids, '--json', ...(q.fresh ? ['--fresh'] : [])];
|
|
171
|
+
const child = spawn(process.execPath, args, {
|
|
59
172
|
cwd: p.path,
|
|
60
173
|
shell: false,
|
|
61
174
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -77,10 +190,17 @@ function spawnRun({ entry, p, ids }, mode) {
|
|
|
77
190
|
catch {
|
|
78
191
|
// No report: the error says why.
|
|
79
192
|
}
|
|
80
|
-
|
|
81
|
-
|
|
193
|
+
// A run of a project removed meanwhile only reports: it pauses and schedules nothing.
|
|
194
|
+
const kept = !forgotten.has(run);
|
|
195
|
+
const stop = kept && mode === 'main' && queues.get(p.name)?.length && stalled(ok, result);
|
|
196
|
+
if (stop) {
|
|
82
197
|
paused.add(p.name);
|
|
83
|
-
|
|
198
|
+
limitPaused.delete(p.name);
|
|
199
|
+
}
|
|
200
|
+
const note = !kept
|
|
201
|
+
? ''
|
|
202
|
+
: limited(q, result) ||
|
|
203
|
+
(stop ? 'the queue is paused: fix the cause, then resume it on the board' : '');
|
|
84
204
|
const brief = err && briefError(err);
|
|
85
205
|
broadcast({
|
|
86
206
|
type: 'run',
|
|
@@ -88,8 +208,10 @@ function spawnRun({ entry, p, ids }, mode) {
|
|
|
88
208
|
ids,
|
|
89
209
|
ok,
|
|
90
210
|
result,
|
|
91
|
-
err: (brief + message
|
|
211
|
+
err: (brief + message).trim(),
|
|
212
|
+
note,
|
|
92
213
|
});
|
|
214
|
+
// The checkout is free now, for whatever queue the name has: a project added back meanwhile.
|
|
93
215
|
if (mode === 'main')
|
|
94
216
|
advance(p.name);
|
|
95
217
|
broadcast({ type: 'board', project: p.name });
|
|
@@ -100,19 +222,29 @@ function spawnRun({ entry, p, ids }, mode) {
|
|
|
100
222
|
}
|
|
101
223
|
/**
|
|
102
224
|
* Start a batch in the project's run mode, or queue it behind the main run going on. Its end
|
|
103
|
-
* is a `run` event with the harness report, or what went wrong.
|
|
225
|
+
* is a `run` event with the harness report, or what went wrong. `fresh` starts new agent
|
|
226
|
+
* sessions where the harness would resume the interrupted ones.
|
|
104
227
|
*/
|
|
105
|
-
export function startRun(entry, p, ids, mode = 'worktree') {
|
|
228
|
+
export function startRun(entry, p, ids, mode = 'worktree', fresh = false) {
|
|
106
229
|
// A second click on the same card: the harness would refuse it on its lock, noisily.
|
|
107
230
|
const { active: going, queued } = runsOf(p.name);
|
|
108
231
|
const busy = ids.filter((id) => queued.includes(id) || going.some((r) => r.ids.includes(id)));
|
|
109
232
|
if (busy.length)
|
|
110
233
|
throw new HttpError(409, `already running from this board: ${busy.join(', ')}`);
|
|
234
|
+
// The operator runs an item the limit stopped before its reset: they know better, so it goes
|
|
235
|
+
// now, and the limit's pause with it; the rest of the schedule stays.
|
|
236
|
+
const s = scheduled.get(p.name);
|
|
237
|
+
if (s?.run.ids.some((id) => ids.includes(id))) {
|
|
238
|
+
s.run = { ...s.run, ids: s.run.ids.filter((id) => !ids.includes(id)) };
|
|
239
|
+
if (!s.run.ids.length)
|
|
240
|
+
cancelResume(p.name);
|
|
241
|
+
liftLimitPause(p.name);
|
|
242
|
+
}
|
|
111
243
|
// A paused queue holds a new main run too: it would repeat the failure, and jump the queue.
|
|
112
244
|
if (mode === 'main' && (paused.has(p.name) || going.some((r) => r.mode === 'main'))) {
|
|
113
|
-
queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode }]);
|
|
245
|
+
queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode, fresh }]);
|
|
114
246
|
broadcast({ type: 'board', project: p.name });
|
|
115
247
|
return { queued: ids };
|
|
116
248
|
}
|
|
117
|
-
return { started: spawnRun({ entry, p, ids, mode }, mode) };
|
|
249
|
+
return { started: spawnRun({ entry, p, ids, mode, fresh }, mode) };
|
|
118
250
|
}
|