gcf-board 0.2.1 → 0.2.5
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 +4 -2
- package/dist/server/app.js +24 -8
- package/dist/server/cli.js +26 -3
- package/dist/server/desktop.js +3 -1
- package/dist/server/main.js +41 -7
- package/dist/server/registry.js +7 -1
- package/dist/server/runs.js +35 -9
- package/dist/web/assets/index-CCqgZN0Y.js +31 -0
- package/dist/web/assets/{index-k853kqAl.css → index-Cpbbv8DY.css} +1 -1
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DIdkEhB_.js +0 -28
package/README.md
CHANGED
|
@@ -15,6 +15,7 @@ A local web board for game projects on [GCF](../gcf-aif-ext): the items by statu
|
|
|
15
15
|
npm i -g gcf-board
|
|
16
16
|
gcf-board # http://127.0.0.1:4317
|
|
17
17
|
gcf-board --port 4400
|
|
18
|
+
gcf-board --version # the installed version
|
|
18
19
|
```
|
|
19
20
|
|
|
20
21
|
It opens the board in your browser (the URL with the token, `http://127.0.0.1:4317/?t=<token>`, is printed too; `--no-open` skips the browser). Started again while it runs, it just opens the running board. Add a project by its folder (one that has `gcf/`) under **Projects**.
|
|
@@ -41,7 +42,7 @@ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console s
|
|
|
41
42
|
- `run_mode: worktree`: one worktree and one agent session per item, all at once, then the integrator;
|
|
42
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.
|
|
43
44
|
|
|
44
|
-
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
|
|
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): run it again and the run resumes its work. When a run ends, a toast lists each item's result. The queue lives in the board's memory: a board restart forgets it.
|
|
45
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.
|
|
46
47
|
- **Priority**: drop a ready card on another to put it before; the column's ranks are committed (`gcf prioritize`).
|
|
47
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.
|
|
@@ -57,7 +58,8 @@ The page updates by itself: the server watches each project's `gcf/` and `.gcf-s
|
|
|
57
58
|
The board starts agents that write code and commit: whoever can talk to it runs code on this machine.
|
|
58
59
|
|
|
59
60
|
- It listens on `127.0.0.1` only.
|
|
60
|
-
-
|
|
61
|
+
- The token, kept in `~/.gcf/board-token`, opens the board once per server start; delete that file to make a new one. The browser then holds a session cookie that dies with the server: a browser sends a `127.0.0.1` cookie to every local port, so the cookie is never the token.
|
|
62
|
+
- Started while another program holds its port, it refuses rather than hand the token to that program.
|
|
61
63
|
- A request must also name host `127.0.0.1` or `localhost` with the board's port.
|
|
62
64
|
- A change must come from the board's own page (`Origin`). This blocks another site in the same browser.
|
|
63
65
|
- Commands run without a shell: the operator's text reaches `ai-factory` as one argument, never as a command line.
|
package/dist/server/app.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The board's HTTP server: the page, and an API that is a thin layer over `ai-factory gcf`.
|
|
2
2
|
// Reads: projects, the board, a shown file, a run log, the playtest session. Writes: a project
|
|
3
3
|
// on the board, a move, a run. Every write is a command of the framework.
|
|
4
|
+
import { randomBytes } from 'node:crypto';
|
|
4
5
|
import { createServer } from 'node:http';
|
|
5
|
-
import { existsSync } from 'node:fs';
|
|
6
|
+
import { existsSync, statSync } from 'node:fs';
|
|
6
7
|
import { readFile } from 'node:fs/promises';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { BOARD_SCHEMA } from '../shared/board.js';
|
|
@@ -13,7 +14,7 @@ import { followLog, readShown } from './files.js';
|
|
|
13
14
|
import { HttpError } from './http-error.js';
|
|
14
15
|
import { moveArgs, runIds } from './moves.js';
|
|
15
16
|
import { addProject, listProjects, projectOf, removeProject } from './registry.js';
|
|
16
|
-
import { runsOf, startRun, unqueue } from './runs.js';
|
|
17
|
+
import { resumeQueue, runsOf, startRun, unqueue } from './runs.js';
|
|
17
18
|
const send = (res, status, body) => {
|
|
18
19
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
|
|
19
20
|
res.end(JSON.stringify(body));
|
|
@@ -67,9 +68,12 @@ async function sendPage(res, web, pathname) {
|
|
|
67
68
|
throw new HttpError(400, 'a path that is not valid percent-encoding');
|
|
68
69
|
}
|
|
69
70
|
const rel = path.normalize(decoded).replace(/^([/\\])+/, '');
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
71
|
+
const root = path.resolve(web);
|
|
72
|
+
let file = path.resolve(root, rel);
|
|
73
|
+
const inside = path.relative(root, file);
|
|
74
|
+
const out = !inside || inside.startsWith('..') || path.isAbsolute(inside);
|
|
75
|
+
if (out || !existsSync(file) || statSync(file).isDirectory())
|
|
76
|
+
file = path.join(root, 'index.html');
|
|
73
77
|
if (!existsSync(file))
|
|
74
78
|
throw new HttpError(500, `the page is not built: ${web} has no index.html`);
|
|
75
79
|
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] ?? 'application/octet-stream' });
|
|
@@ -205,6 +209,14 @@ export function createApp(o) {
|
|
|
205
209
|
send(res, 200, { ok: r.ok, out: r.out.trim(), err: briefError(r.err) });
|
|
206
210
|
},
|
|
207
211
|
],
|
|
212
|
+
[
|
|
213
|
+
'POST',
|
|
214
|
+
/^\/api\/p\/([^/]+)\/queue\/resume$/,
|
|
215
|
+
async (_req, res, m) => {
|
|
216
|
+
resumeQueue((await projectOf(m[1])).name);
|
|
217
|
+
send(res, 200, { ok: true });
|
|
218
|
+
},
|
|
219
|
+
],
|
|
208
220
|
[
|
|
209
221
|
'DELETE',
|
|
210
222
|
/^\/api\/p\/([^/]+)\/queue\/([a-z0-9][a-z0-9-]*)$/,
|
|
@@ -214,19 +226,22 @@ export function createApp(o) {
|
|
|
214
226
|
},
|
|
215
227
|
],
|
|
216
228
|
];
|
|
229
|
+
// The cookie holds this process's own session, never the token: a browser sends a cookie of
|
|
230
|
+
// 127.0.0.1 to every local port, and a session dies with the server, the token does not.
|
|
231
|
+
const session = randomBytes(24).toString('hex');
|
|
217
232
|
const handle = async (req, res) => {
|
|
218
233
|
const url = new URL(req.url ?? '/', `http://${req.headers.host ?? 'localhost'}`);
|
|
219
234
|
const visit = req.method === 'GET' && !url.pathname.startsWith('/api/');
|
|
220
235
|
if (visit && hostAllowed(req, o.port) && tokenInUrl(url, o.token)) {
|
|
221
236
|
res.writeHead(302, {
|
|
222
|
-
'set-cookie': `${COOKIE}=${
|
|
237
|
+
'set-cookie': `${COOKIE}=${session}; HttpOnly; SameSite=Strict; Path=/`,
|
|
223
238
|
// Always the root: a path like `//elsewhere` would send the browser off the board.
|
|
224
239
|
location: '/',
|
|
225
240
|
});
|
|
226
241
|
res.end();
|
|
227
242
|
return;
|
|
228
243
|
}
|
|
229
|
-
const gate = admit(req, o.port,
|
|
244
|
+
const gate = admit(req, o.port, session);
|
|
230
245
|
if (!gate.ok) {
|
|
231
246
|
send(res, gate.status, { error: gate.message });
|
|
232
247
|
return;
|
|
@@ -247,7 +262,8 @@ export function createApp(o) {
|
|
|
247
262
|
send(res, e instanceof HttpError ? e.status : 500, { error: e.message });
|
|
248
263
|
});
|
|
249
264
|
});
|
|
250
|
-
|
|
265
|
+
// A broken projects.json must not take the server down: the page shows the error instead.
|
|
266
|
+
server.on('listening', () => watchAll().catch((e) => console.error(`gcf-board: ${e.message}`)));
|
|
251
267
|
server.on('close', () => {
|
|
252
268
|
for (const stop of watchers.values())
|
|
253
269
|
stop();
|
package/dist/server/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// The board runs the same `ai-factory` the operator runs, never a shell: the arguments carry the
|
|
2
2
|
// operator's own words (a reason, a note), and node gets them one by one.
|
|
3
3
|
import { execSync, spawn, spawnSync } from 'node:child_process';
|
|
4
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
/**
|
|
7
7
|
* The JS entry of the `ai-factory` CLI: GCF_BOARD_AIF, or the bin of the global package. The
|
|
@@ -11,14 +11,37 @@ import path from 'node:path';
|
|
|
11
11
|
export function aiFactoryEntry() {
|
|
12
12
|
if (process.env.GCF_BOARD_AIF)
|
|
13
13
|
return process.env.GCF_BOARD_AIF;
|
|
14
|
-
const
|
|
15
|
-
|
|
14
|
+
const dir = globalRoots()
|
|
15
|
+
.map((root) => path.join(root, 'ai-factory'))
|
|
16
|
+
.find((d) => existsSync(path.join(d, 'package.json')));
|
|
17
|
+
if (!dir)
|
|
18
|
+
throw new Error('ai-factory is not installed globally: npm i -g ai-factory');
|
|
16
19
|
const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
17
20
|
const bin = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.['ai-factory'];
|
|
18
21
|
if (!bin)
|
|
19
22
|
throw new Error(`${dir} has no ai-factory bin: install it with npm i -g ai-factory`);
|
|
20
23
|
return path.join(dir, bin);
|
|
21
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* Where global packages live: what `npm root -g` says, then the folders beside this node. A
|
|
27
|
+
* shortcut starts the board with a PATH that may have no npm (fnm, volta, nvm); the node it
|
|
28
|
+
* runs keeps its global packages next to itself.
|
|
29
|
+
*/
|
|
30
|
+
function globalRoots() {
|
|
31
|
+
const roots = [];
|
|
32
|
+
try {
|
|
33
|
+
roots.push(execSync('npm root -g', { encoding: 'utf8', stdio: 'pipe' }).trim());
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
// No npm on PATH: the folders beside this node still hold the global packages.
|
|
37
|
+
}
|
|
38
|
+
const bin = path.dirname(process.execPath);
|
|
39
|
+
roots.push(path.join(bin, 'node_modules'), path.join(bin, '..', 'lib', 'node_modules'));
|
|
40
|
+
// npm's default prefix on Windows.
|
|
41
|
+
if (process.env.APPDATA)
|
|
42
|
+
roots.push(path.join(process.env.APPDATA, 'npm', 'node_modules'));
|
|
43
|
+
return roots;
|
|
44
|
+
}
|
|
22
45
|
/**
|
|
23
46
|
* The line of a refusal an operator reads: the core throws, and node prints the stack under
|
|
24
47
|
* an `Error: <message>` line. Anything else is shown as it came.
|
package/dist/server/desktop.js
CHANGED
|
@@ -56,8 +56,10 @@ export function createShortcut(main, port) {
|
|
|
56
56
|
const r = spawnSync('powershell', ['-NoProfile', '-Command', plan.powershell], {
|
|
57
57
|
encoding: 'utf8',
|
|
58
58
|
});
|
|
59
|
+
if (r.error)
|
|
60
|
+
throw new Error(`the shortcut needs PowerShell: ${r.error.message}`);
|
|
59
61
|
if (r.status !== 0)
|
|
60
|
-
throw new Error(`the shortcut was not made: ${r.stderr.trim()}`);
|
|
62
|
+
throw new Error(`the shortcut was not made: ${(r.stderr ?? '').trim()}`);
|
|
61
63
|
return r.stdout.trim();
|
|
62
64
|
}
|
|
63
65
|
mkdirSync(path.dirname(plan.file), { recursive: true });
|
package/dist/server/main.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// `gcf-board [--port <n>] [--no-open]`: the board on http://127.0.0.1:<port>, 4317 by default,
|
|
2
|
+
// `gcf-board [--port <n>] [--no-open] [--version]`: the board on http://127.0.0.1:<port>, 4317 by default,
|
|
3
3
|
// opened in the browser with this machine's token. A board already on the port is opened, not
|
|
4
4
|
// started twice. `gcf-board shortcut [--port <n>]` puts a desktop shortcut that does the same.
|
|
5
|
-
import { existsSync } from 'node:fs';
|
|
5
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import { fileURLToPath } from 'node:url';
|
|
8
8
|
import { parseArgs } from 'node:util';
|
|
@@ -21,14 +21,41 @@ function webDir() {
|
|
|
21
21
|
? fromSource
|
|
22
22
|
: path.resolve(HERE, '..', 'web');
|
|
23
23
|
}
|
|
24
|
+
/** This package's version: package.json sits above `server/` in the source, above `dist/server/` when installed. */
|
|
25
|
+
function version() {
|
|
26
|
+
for (const up of ['..', path.join('..', '..')]) {
|
|
27
|
+
const file = path.resolve(HERE, up, 'package.json');
|
|
28
|
+
if (existsSync(file))
|
|
29
|
+
return JSON.parse(readFileSync(file, 'utf8')).version;
|
|
30
|
+
}
|
|
31
|
+
return 'unknown';
|
|
32
|
+
}
|
|
33
|
+
/** Whether a gcf-board answers on the port: it refuses a request without its cookie, by name. */
|
|
34
|
+
async function isBoard(port) {
|
|
35
|
+
try {
|
|
36
|
+
const res = await fetch(`http://127.0.0.1:${port}/api/projects`, {
|
|
37
|
+
signal: AbortSignal.timeout(2000),
|
|
38
|
+
});
|
|
39
|
+
const body = (await res.json().catch(() => ({})));
|
|
40
|
+
return res.status === 401 && /gcf-board/.test(String(body.error));
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
24
46
|
async function main() {
|
|
25
47
|
const { values, positionals } = parseArgs({
|
|
26
48
|
allowPositionals: true,
|
|
27
49
|
options: {
|
|
28
50
|
port: { type: 'string', default: '4317' },
|
|
29
51
|
'no-open': { type: 'boolean', default: false },
|
|
52
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
30
53
|
},
|
|
31
54
|
});
|
|
55
|
+
if (values.version) {
|
|
56
|
+
console.log(version());
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
32
59
|
const port = Number(values.port);
|
|
33
60
|
if (!Number.isInteger(port) || port < 1 || port > 65535)
|
|
34
61
|
throw new Error('--port is a number from 1 to 65535');
|
|
@@ -42,16 +69,23 @@ async function main() {
|
|
|
42
69
|
const url = `http://127.0.0.1:${port}/?t=${token}`;
|
|
43
70
|
const open = () => !values['no-open'] && openInBrowser(url);
|
|
44
71
|
const app = createApp({ entry: aiFactoryEntry(), token, port, web: webDir() });
|
|
45
|
-
app.on('error', (e) => {
|
|
46
|
-
if (e.code !== 'EADDRINUSE')
|
|
47
|
-
|
|
48
|
-
|
|
72
|
+
app.on('error', async (e) => {
|
|
73
|
+
if (e.code !== 'EADDRINUSE') {
|
|
74
|
+
console.error(`gcf-board: cannot listen on port ${port}: ${e.message}`);
|
|
75
|
+
process.exit(1);
|
|
76
|
+
}
|
|
77
|
+
// A second start, from the shortcut most likely: the board runs already, show it. Another
|
|
78
|
+
// program on the port must not get the token in a URL.
|
|
79
|
+
if (!(await isBoard(port))) {
|
|
80
|
+
console.error(`gcf-board: port ${port} is taken by another program: gcf-board --port <n>`);
|
|
81
|
+
process.exit(1);
|
|
82
|
+
}
|
|
49
83
|
console.log(`gcf-board: already running on port ${port}, opening it`);
|
|
50
84
|
open();
|
|
51
85
|
setTimeout(() => process.exit(0), 500);
|
|
52
86
|
});
|
|
53
87
|
app.listen(port, '127.0.0.1', () => {
|
|
54
|
-
console.log(`gcf-board: ${url}`);
|
|
88
|
+
console.log(`gcf-board ${version()}: ${url}`);
|
|
55
89
|
open();
|
|
56
90
|
});
|
|
57
91
|
}
|
package/dist/server/registry.js
CHANGED
|
@@ -13,7 +13,13 @@ const NAME = /^[a-z0-9][a-z0-9-]*$/;
|
|
|
13
13
|
export async function listProjects() {
|
|
14
14
|
if (!existsSync(registryFile()))
|
|
15
15
|
return [];
|
|
16
|
-
|
|
16
|
+
let data;
|
|
17
|
+
try {
|
|
18
|
+
data = JSON.parse(await readFile(registryFile(), 'utf8'));
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
throw new HttpError(500, `${registryFile()} is not JSON: fix it or delete it`);
|
|
22
|
+
}
|
|
17
23
|
return Array.isArray(data?.projects) ? data.projects : [];
|
|
18
24
|
}
|
|
19
25
|
async function save(projects) {
|
package/dist/server/runs.js
CHANGED
|
@@ -4,17 +4,35 @@
|
|
|
4
4
|
// In `run_mode: main` the checkout takes one run at a time: a run asked for meanwhile waits in
|
|
5
5
|
// the project's queue and starts when the one before it ends.
|
|
6
6
|
import { spawn } from 'node:child_process';
|
|
7
|
+
import { briefError } from './cli.js';
|
|
7
8
|
import { broadcast } from './events.js';
|
|
8
9
|
import { HttpError } from './http-error.js';
|
|
9
10
|
// A board that stops leaves its runs going: killing one would lose its integrate step.
|
|
10
11
|
const active = new Set();
|
|
11
12
|
const queues = new Map();
|
|
13
|
+
// Projects whose queue waits after a failure the next run would repeat (`stalled`).
|
|
14
|
+
const paused = new Set();
|
|
12
15
|
export const activeRuns = (project) => [...active].filter((r) => r.project === project);
|
|
13
|
-
/** What runs and what waits, for a project
|
|
16
|
+
/** What runs and what waits, for a project; a paused queue waits for `resumeQueue`. */
|
|
14
17
|
export const runsOf = (project) => ({
|
|
15
18
|
active: activeRuns(project),
|
|
16
19
|
queued: (queues.get(project) ?? []).flatMap((q) => q.ids),
|
|
20
|
+
paused: paused.has(project),
|
|
17
21
|
});
|
|
22
|
+
/** Start the next queued batch when the checkout is free: after a run, or on the operator's resume. */
|
|
23
|
+
function advance(project) {
|
|
24
|
+
if (paused.has(project) || activeRuns(project).some((r) => r.mode === 'main'))
|
|
25
|
+
return;
|
|
26
|
+
const next = queues.get(project)?.shift();
|
|
27
|
+
if (next)
|
|
28
|
+
spawnRun(next, next.mode);
|
|
29
|
+
}
|
|
30
|
+
/** The operator resumes a paused queue (after fixing what stopped it). */
|
|
31
|
+
export function resumeQueue(project) {
|
|
32
|
+
paused.delete(project);
|
|
33
|
+
advance(project);
|
|
34
|
+
broadcast({ type: 'board', project });
|
|
35
|
+
}
|
|
18
36
|
/** Take an item out of the queue before its run starts. */
|
|
19
37
|
export function unqueue(project, id) {
|
|
20
38
|
const queue = (queues.get(project) ?? [])
|
|
@@ -26,8 +44,7 @@ export function unqueue(project, id) {
|
|
|
26
44
|
/**
|
|
27
45
|
* Whether a main run ended in a way the next one would repeat: no report at all (the harness
|
|
28
46
|
* did not start), or every item refused because another run works the checkout. The queue
|
|
29
|
-
* then waits instead of draining into the same error
|
|
30
|
-
* resumes it.
|
|
47
|
+
* then waits instead of draining into the same error, until the operator resumes it.
|
|
31
48
|
*/
|
|
32
49
|
export function stalled(ok, result) {
|
|
33
50
|
if (!ok && result === null)
|
|
@@ -60,13 +77,22 @@ function spawnRun({ entry, p, ids }, mode) {
|
|
|
60
77
|
catch {
|
|
61
78
|
// No report: the error says why.
|
|
62
79
|
}
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
80
|
+
const stop = mode === 'main' && queues.get(p.name)?.length && stalled(ok, result);
|
|
81
|
+
if (stop)
|
|
82
|
+
paused.add(p.name);
|
|
83
|
+
const why = stop ? '\nthe queue is paused: fix the cause, then resume it on the board' : '';
|
|
84
|
+
const brief = err && briefError(err);
|
|
85
|
+
broadcast({
|
|
86
|
+
type: 'run',
|
|
87
|
+
project: p.name,
|
|
88
|
+
ids,
|
|
89
|
+
ok,
|
|
90
|
+
result,
|
|
91
|
+
err: (brief + message + why).trim(),
|
|
92
|
+
});
|
|
93
|
+
if (mode === 'main')
|
|
94
|
+
advance(p.name);
|
|
66
95
|
broadcast({ type: 'board', project: p.name });
|
|
67
|
-
const next = mode === 'main' && !paused ? queues.get(p.name)?.shift() : undefined;
|
|
68
|
-
if (next)
|
|
69
|
-
spawnRun(next, next.mode);
|
|
70
96
|
};
|
|
71
97
|
child.on('error', (e) => end(false, e.message));
|
|
72
98
|
child.on('close', (code) => end(code === 0));
|