gcf-board 0.2.5 → 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 +42 -10
- package/dist/server/auth.js +6 -4
- package/dist/server/desktop.js +6 -2
- package/dist/server/events.js +90 -17
- package/dist/server/files.js +9 -7
- package/dist/server/runs.js +149 -16
- 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-CCqgZN0Y.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
|
@@ -7,14 +7,14 @@ import { existsSync, statSync } from 'node:fs';
|
|
|
7
7
|
import { readFile } from 'node:fs/promises';
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { BOARD_SCHEMA } from '../shared/board.js';
|
|
10
|
-
import { admit,
|
|
10
|
+
import { admit, cookieName, hostAllowed, tokenInUrl } from './auth.js';
|
|
11
11
|
import { briefError, runCli } from './cli.js';
|
|
12
|
-
import { broadcast, subscribe, watchProject } from './events.js';
|
|
13
|
-
import { followLog, readShown } from './files.js';
|
|
12
|
+
import { broadcast, planRev, subscribe, watchProject } from './events.js';
|
|
13
|
+
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 });
|
|
@@ -126,8 +136,15 @@ export function createApp(o) {
|
|
|
126
136
|
/^\/api\/p\/([^/]+)\/board$/,
|
|
127
137
|
async (_req, res, m) => {
|
|
128
138
|
const p = await projectOf(m[1]);
|
|
129
|
-
// The board's own runs ride along: what it started and what waits in its queue
|
|
130
|
-
|
|
139
|
+
// The board's own runs ride along: what it started and what waits in its queue; and the
|
|
140
|
+
// count of plan writes, which tells the page an open plan's text changed.
|
|
141
|
+
const own = { runs: runsOf(p.name), plans_rev: planRev(p.name) };
|
|
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 });
|
|
131
148
|
},
|
|
132
149
|
],
|
|
133
150
|
[
|
|
@@ -135,7 +152,12 @@ export function createApp(o) {
|
|
|
135
152
|
/^\/api\/p\/([^/]+)\/file$/,
|
|
136
153
|
async (_req, res, m, url) => {
|
|
137
154
|
const p = await projectOf(m[1]);
|
|
138
|
-
|
|
155
|
+
const rel = url.searchParams.get('path') ?? '';
|
|
156
|
+
// A plan an item links (`plan:`) may lie anywhere in the project: the core names it.
|
|
157
|
+
const linked = !shownShape(rel) &&
|
|
158
|
+
// A board the core cannot read links nothing: the path is refused, not a 500.
|
|
159
|
+
(await boardOf(p).catch(() => ({ items: [] }))).items.some((i) => i.plan_file === rel);
|
|
160
|
+
send(res, 200, { text: await readShown(p.path, rel, linked) });
|
|
139
161
|
},
|
|
140
162
|
],
|
|
141
163
|
[
|
|
@@ -184,8 +206,9 @@ export function createApp(o) {
|
|
|
184
206
|
/^\/api\/p\/([^/]+)\/run$/,
|
|
185
207
|
async (req, res, m) => {
|
|
186
208
|
const p = await projectOf(m[1]);
|
|
187
|
-
const
|
|
188
|
-
|
|
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));
|
|
189
212
|
},
|
|
190
213
|
],
|
|
191
214
|
[
|
|
@@ -217,6 +240,15 @@ export function createApp(o) {
|
|
|
217
240
|
send(res, 200, { ok: true });
|
|
218
241
|
},
|
|
219
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
|
+
],
|
|
220
252
|
[
|
|
221
253
|
'DELETE',
|
|
222
254
|
/^\/api\/p\/([^/]+)\/queue\/([a-z0-9][a-z0-9-]*)$/,
|
|
@@ -234,7 +266,7 @@ export function createApp(o) {
|
|
|
234
266
|
const visit = req.method === 'GET' && !url.pathname.startsWith('/api/');
|
|
235
267
|
if (visit && hostAllowed(req, o.port) && tokenInUrl(url, o.token)) {
|
|
236
268
|
res.writeHead(302, {
|
|
237
|
-
'set-cookie': `${
|
|
269
|
+
'set-cookie': `${cookieName(o.port)}=${session}; HttpOnly; SameSite=Strict; Path=/`,
|
|
238
270
|
// Always the root: a path like `//elsewhere` would send the browser off the board.
|
|
239
271
|
location: '/',
|
|
240
272
|
});
|
package/dist/server/auth.js
CHANGED
|
@@ -8,7 +8,9 @@ import { mkdir, readFile } from 'node:fs/promises';
|
|
|
8
8
|
import path from 'node:path';
|
|
9
9
|
import { writeAtomic } from './atomic.js';
|
|
10
10
|
import { homeDir } from './registry.js';
|
|
11
|
-
|
|
11
|
+
// A browser keeps one cookie per name for all of 127.0.0.1: the port in the name lets two boards
|
|
12
|
+
// on two ports stay signed in side by side.
|
|
13
|
+
export const cookieName = (port) => `gcf_board_${port}`;
|
|
12
14
|
// An empty or short token would let everyone in: such a file is replaced.
|
|
13
15
|
const STRONG = /^[0-9a-f]{48}$/;
|
|
14
16
|
/** The token of this machine's board: made once, kept in `~/.gcf/board-token`. */
|
|
@@ -23,10 +25,10 @@ export async function boardToken() {
|
|
|
23
25
|
return token;
|
|
24
26
|
}
|
|
25
27
|
const same = (a, b) => b.length > 0 && a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
26
|
-
function cookieOf(req) {
|
|
28
|
+
function cookieOf(req, name) {
|
|
27
29
|
for (const part of (req.headers.cookie ?? '').split(';')) {
|
|
28
30
|
const [k, ...v] = part.trim().split('=');
|
|
29
|
-
if (k ===
|
|
31
|
+
if (k === name)
|
|
30
32
|
return v.join('=');
|
|
31
33
|
}
|
|
32
34
|
return '';
|
|
@@ -42,7 +44,7 @@ export function admit(req, port, token) {
|
|
|
42
44
|
const origin = req.headers.origin;
|
|
43
45
|
if (write && !(origin && hosts.some((h) => origin === `http://${h}`)))
|
|
44
46
|
return { ok: false, status: 403, message: 'a change comes from the board page only' };
|
|
45
|
-
if (!same(cookieOf(req), token))
|
|
47
|
+
if (!same(cookieOf(req, cookieName(port)), token))
|
|
46
48
|
return { ok: false, status: 401, message: 'open the URL gcf-board printed at start' };
|
|
47
49
|
return { ok: true };
|
|
48
50
|
}
|
package/dist/server/desktop.js
CHANGED
|
@@ -11,11 +11,15 @@ export function openInBrowser(url) {
|
|
|
11
11
|
: process.platform === 'darwin'
|
|
12
12
|
? ['open', [url]]
|
|
13
13
|
: ['xdg-open', [url]];
|
|
14
|
+
// No browser to open: the operator opens the printed URL. A missing opener fails as an event,
|
|
15
|
+
// which unheard would end the server.
|
|
14
16
|
try {
|
|
15
|
-
spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true })
|
|
17
|
+
spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true })
|
|
18
|
+
.on('error', () => { })
|
|
19
|
+
.unref();
|
|
16
20
|
}
|
|
17
21
|
catch {
|
|
18
|
-
//
|
|
22
|
+
// Refused at once: the same.
|
|
19
23
|
}
|
|
20
24
|
}
|
|
21
25
|
/** A PowerShell single-quoted string: a quote inside is doubled. */
|
package/dist/server/events.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Server-sent events: a project's `gcf
|
|
2
|
-
// The page reads the board again on each one; the event carries no state of its own.
|
|
3
|
-
import {
|
|
1
|
+
// Server-sent events: a project's `gcf/`, its plans or its run marks changed, a run the board
|
|
2
|
+
// started ended. The page reads the board again on each one; the event carries no state of its own.
|
|
3
|
+
import { readdirSync, readFileSync, statSync, watch } from 'node:fs';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
const clients = new Set();
|
|
6
6
|
/** Open an event stream on `res`. */
|
|
@@ -25,10 +25,64 @@ export function broadcast(event) {
|
|
|
25
25
|
for (const c of clients)
|
|
26
26
|
c.write(line);
|
|
27
27
|
}
|
|
28
|
+
// The core's SESSION_TTL_MS (session-mark.js): a mark nobody refreshed for this long is a session
|
|
29
|
+
// that stopped. shortcut: a copy of the core's constant, read it from the board JSON once the core
|
|
30
|
+
// reports a mark's expiry.
|
|
31
|
+
const SESSION_TTL_MS = 120 * 60_000;
|
|
32
|
+
const LOG_STEP_MS = 10_000;
|
|
33
|
+
const alive = (pid) => {
|
|
34
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
35
|
+
return false;
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
catch (e) {
|
|
41
|
+
return e.code === 'EPERM';
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* What makes items `running` in a runs directory: its entries, whether each lock's process
|
|
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.
|
|
48
|
+
*/
|
|
49
|
+
export function runsState(dir, now = Date.now()) {
|
|
50
|
+
let names;
|
|
51
|
+
try {
|
|
52
|
+
names = readdirSync(dir).sort();
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
return names
|
|
58
|
+
.map((n) => {
|
|
59
|
+
const file = path.join(dir, n);
|
|
60
|
+
try {
|
|
61
|
+
if (n.endsWith('.lock'))
|
|
62
|
+
return `${n} ${alive(Number(readFileSync(file, 'utf8').split('\n')[0]))}`;
|
|
63
|
+
if (n.endsWith('.session'))
|
|
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)}`;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// Gone meanwhile: the next poll sees it.
|
|
72
|
+
}
|
|
73
|
+
return n;
|
|
74
|
+
})
|
|
75
|
+
.join('\n');
|
|
76
|
+
}
|
|
77
|
+
// Each project's plan writes, counted: a plan's text can change while its checkbox progress stays,
|
|
78
|
+
// so the page reads an open plan again when the count moves.
|
|
79
|
+
const planRevs = new Map();
|
|
80
|
+
export const planRev = (project) => planRevs.get(project) ?? 0;
|
|
28
81
|
/**
|
|
29
|
-
* Watch a project: `gcf/` (every move writes there)
|
|
30
|
-
*
|
|
31
|
-
*
|
|
82
|
+
* Watch a project: `gcf/` (every move writes there), `.ai-factory/plans` (a plan is written and
|
|
83
|
+
* edited) and `.gcf-session/runs` (a run's lock or session mark comes, goes or goes stale). The
|
|
84
|
+
* runs directory may not exist yet, so it is polled, not watched. Bursts of writes make one
|
|
85
|
+
* event. Returns the stop.
|
|
32
86
|
*/
|
|
33
87
|
export function watchProject(p, debounceMs = 300) {
|
|
34
88
|
let timer = null;
|
|
@@ -37,19 +91,38 @@ export function watchProject(p, debounceMs = 300) {
|
|
|
37
91
|
clearTimeout(timer);
|
|
38
92
|
timer = setTimeout(() => broadcast({ type: 'board', project: p.name }), debounceMs);
|
|
39
93
|
};
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
94
|
+
const watchers = [];
|
|
95
|
+
const watchDir = (dir, onChange) => {
|
|
96
|
+
try {
|
|
97
|
+
const w = watch(dir, { recursive: true }, (_e, file) => onChange(file));
|
|
98
|
+
w.on('error', () => w.close());
|
|
99
|
+
watchers.push(w);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
// Not there: the board read shows what is missing.
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
watchDir(path.join(p.path, 'gcf'), fire);
|
|
106
|
+
// shortcut: a plan an item links outside .ai-factory/plans shows its new text only when its
|
|
107
|
+
// checkbox count moves; watch those files too if operators edit such plans by hand.
|
|
108
|
+
watchDir(path.join(p.path, '.ai-factory'), (file) => {
|
|
109
|
+
if (!/^plans[\\/]/.test(file ?? ''))
|
|
110
|
+
return;
|
|
111
|
+
planRevs.set(p.name, planRev(p.name) + 1);
|
|
112
|
+
fire();
|
|
113
|
+
});
|
|
48
114
|
const runs = path.join(p.path, '.gcf-session', 'runs');
|
|
49
|
-
|
|
115
|
+
let last = runsState(runs);
|
|
116
|
+
const poll = setInterval(() => {
|
|
117
|
+
const now = runsState(runs);
|
|
118
|
+
if (now !== last)
|
|
119
|
+
fire();
|
|
120
|
+
last = now;
|
|
121
|
+
}, 1500);
|
|
50
122
|
return () => {
|
|
51
|
-
|
|
52
|
-
|
|
123
|
+
for (const w of watchers)
|
|
124
|
+
w.close();
|
|
125
|
+
clearInterval(poll);
|
|
53
126
|
if (timer)
|
|
54
127
|
clearTimeout(timer);
|
|
55
128
|
};
|
package/dist/server/files.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The files a board shows: an item, its plan, its run log. Only those shapes
|
|
2
|
-
// project, symlinks resolved: the API is not a way to read the disk.
|
|
1
|
+
// The files a board shows: an item, its plan, its run log. Only those shapes (or a plan an item
|
|
2
|
+
// links), only inside the project, symlinks resolved: the API is not a way to read the disk.
|
|
3
3
|
import { realpath, readFile, stat, open } from 'node:fs/promises';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { StringDecoder } from 'node:string_decoder';
|
|
@@ -11,9 +11,11 @@ const SHOWN = [
|
|
|
11
11
|
/^\.gcf-session\/runs\/[\w.-]+\.log$/,
|
|
12
12
|
];
|
|
13
13
|
const MAX = 1024 * 1024;
|
|
14
|
-
/**
|
|
15
|
-
export
|
|
16
|
-
|
|
14
|
+
/** Whether `rel` has a shape the board shows without asking the core. */
|
|
15
|
+
export const shownShape = (rel) => SHOWN.some((r) => r.test(rel));
|
|
16
|
+
/** The absolute path of a shown file (or a plan an item links, `linked`), or a 403. */
|
|
17
|
+
export async function shownFile(root, rel, linked = false) {
|
|
18
|
+
if (!(linked || shownShape(rel)) || rel.split('/').includes('..'))
|
|
17
19
|
throw new HttpError(403, `${rel} is not a file the board shows`);
|
|
18
20
|
const file = path.join(root, rel);
|
|
19
21
|
const real = await realpath(file).catch(() => {
|
|
@@ -25,8 +27,8 @@ export async function shownFile(root, rel) {
|
|
|
25
27
|
return real;
|
|
26
28
|
}
|
|
27
29
|
/** The text of a shown file; a large one is cut to its last megabyte. */
|
|
28
|
-
export async function readShown(root, rel) {
|
|
29
|
-
const file = await shownFile(root, rel);
|
|
30
|
+
export async function readShown(root, rel, linked = false) {
|
|
31
|
+
const file = await shownFile(root, rel, linked);
|
|
30
32
|
const { size } = await stat(file);
|
|
31
33
|
if (size <= MAX)
|
|
32
34
|
return readFile(file, 'utf8');
|
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,18 +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(', ')}`);
|
|
111
|
-
|
|
112
|
-
|
|
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
|
+
}
|
|
243
|
+
// A paused queue holds a new main run too: it would repeat the failure, and jump the queue.
|
|
244
|
+
if (mode === 'main' && (paused.has(p.name) || going.some((r) => r.mode === 'main'))) {
|
|
245
|
+
queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode, fresh }]);
|
|
113
246
|
broadcast({ type: 'board', project: p.name });
|
|
114
247
|
return { queued: ids };
|
|
115
248
|
}
|
|
116
|
-
return { started: spawnRun({ entry, p, ids, mode }, mode) };
|
|
249
|
+
return { started: spawnRun({ entry, p, ids, mode, fresh }, mode) };
|
|
117
250
|
}
|