gcf-board 0.1.0 → 0.2.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.
package/README.md CHANGED
@@ -6,7 +6,8 @@ A local web board for game projects on [GCF](../gcf-aif-ext): the items by statu
6
6
 
7
7
  - Node.js ≥ 22.18
8
8
  - [`ai-factory`](https://www.npmjs.com/package/ai-factory) installed globally, and in each project [`gcf-aif-ext`](../gcf-aif-ext) ≥ 0.6 (`gcf board --json` with `schema: 1`)
9
- - for runs: [`gcf-harness-aif-ext`](../gcf-harness-aif-ext) ≥ 0.7 and Claude Code (`claude -p`)
9
+ - for runs: [`gcf-harness-aif-ext`](../gcf-harness-aif-ext) ≥ 0.8 and Claude Code (`claude -p`)
10
+ - gcf-board 0.2 needs `gcf-aif-ext` ≥ 0.7 (priority, questions, session marks, `run_mode`)
10
11
 
11
12
  ## Run
12
13
 
@@ -16,7 +17,15 @@ gcf-board # http://127.0.0.1:4317
16
17
  gcf-board --port 4400
17
18
  ```
18
19
 
19
- It prints `http://127.0.0.1:4317/?t=<token>`: open that URL once. It sets a cookie, and the plain address works after that. Add a project by its folder (one that has `gcf/`) under **Projects**.
20
+ 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**.
21
+
22
+ **Desktop shortcut**: run once
23
+
24
+ ```bash
25
+ gcf-board shortcut # or: gcf-board shortcut --port 4400
26
+ ```
27
+
28
+ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console starts minimized; macOS: `GCF Board.command`; Linux: an application entry). Double-click it: the board starts and opens, or opens if it already runs. Run the command again after moving Node.js.
20
29
 
21
30
  ## What it does
22
31
 
@@ -28,7 +37,14 @@ It prints `http://127.0.0.1:4317/?t=<token>`: open that URL once. It sets a cook
28
37
  - a card onto **graveyard** drops it.
29
38
 
30
39
  Every other move is a button.
31
- - **Run**: **run** on an item, or select ready cards and **Run selected**. It starts `ai-factory gcf-harness run <ids> --json`: one worktree and one agent session per item, then the integrator. A run asks first: it spends the subscription.
40
+ - **Run**: **run** on an item, or select ready cards and **Run selected**. It starts `ai-factory gcf-harness run <ids> --json` in the project's run mode:
41
+ - `run_mode: worktree`: one worktree and one agent session per item, all at once, then the integrator;
42
+ - `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
+ 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 next run that works resumes it. The queue lives in the board's memory: a board restart forgets it.
45
+ - **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
+ - **Priority**: drop a ready card on another to put it before; the column's ranks are committed (`gcf prioritize`).
47
+ - **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.
32
48
  - **New item**: `gcf item new … --commit`. The item's `model` and `effort` go to its run.
33
49
  - **Inbox**: add, erase, or turn an entry into an item. Open questions are listed there too.
34
50
  - **Playtest**: `gcf playtest session` with the checklist grouped by subsystem; keep, tune or kill each item from the list. **Build** runs `dod.build`. A note goes to the inbox as `--source playtest`.
@@ -13,7 +13,7 @@ import { followLog, readShown } from './files.js';
13
13
  import { HttpError } from './http-error.js';
14
14
  import { moveArgs, runIds } from './moves.js';
15
15
  import { addProject, listProjects, projectOf, removeProject } from './registry.js';
16
- import { activeRuns, startRun } from './runs.js';
16
+ import { runsOf, startRun, unqueue } from './runs.js';
17
17
  const send = (res, status, body) => {
18
18
  res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' });
19
19
  res.end(JSON.stringify(body));
@@ -83,6 +83,14 @@ export function createApp(o) {
83
83
  watchers.set(p.name, watchProject(p));
84
84
  };
85
85
  const cli = (p, args, timeout) => runCli(o.entry, p.path, args, timeout);
86
+ /** `gcf board --json` of a project, refused when its schema is not the one this board reads. */
87
+ const boardOf = async (p) => {
88
+ const board = jsonOf(await cli(p, ['gcf', 'board', '--json']), 'gcf board');
89
+ if (board.schema !== BOARD_SCHEMA)
90
+ throw new HttpError(502, `gcf board gives schema ${board.schema ?? 'none'}, this board reads ${BOARD_SCHEMA}: ` +
91
+ 'update gcf-aif-ext and gcf-board together');
92
+ return board;
93
+ };
86
94
  const routes = [
87
95
  ['GET', /^\/api\/events$/, async (_req, res) => subscribe(res)],
88
96
  ['GET', /^\/api\/projects$/, async (_req, res) => send(res, 200, await listProjects())],
@@ -113,11 +121,9 @@ export function createApp(o) {
113
121
  'GET',
114
122
  /^\/api\/p\/([^/]+)\/board$/,
115
123
  async (_req, res, m) => {
116
- const board = jsonOf(await cli(await projectOf(m[1]), ['gcf', 'board', '--json']), 'gcf board');
117
- if (board.schema !== BOARD_SCHEMA)
118
- throw new HttpError(502, `gcf board gives schema ${board.schema ?? 'none'}, this board reads ${BOARD_SCHEMA}: ` +
119
- 'update gcf-aif-ext and gcf-board together');
120
- send(res, 200, board);
124
+ const p = await projectOf(m[1]);
125
+ // The board's own runs ride along: what it started and what waits in its queue.
126
+ send(res, 200, { ...(await boardOf(p)), runs: runsOf(p.name) });
121
127
  },
122
128
  ],
123
129
  [
@@ -167,14 +173,44 @@ export function createApp(o) {
167
173
  [
168
174
  'GET',
169
175
  /^\/api\/p\/([^/]+)\/runs$/,
170
- async (_req, res, m) => send(res, 200, activeRuns((await projectOf(m[1])).name)),
176
+ async (_req, res, m) => send(res, 200, runsOf((await projectOf(m[1])).name)),
171
177
  ],
172
178
  [
173
179
  'POST',
174
180
  /^\/api\/p\/([^/]+)\/run$/,
175
181
  async (req, res, m) => {
176
182
  const p = await projectOf(m[1]);
177
- send(res, 202, startRun(o.entry, p, runIds(await bodyOf(req))));
183
+ const ids = runIds(await bodyOf(req));
184
+ send(res, 202, startRun(o.entry, p, ids, (await boardOf(p)).run_mode));
185
+ },
186
+ ],
187
+ [
188
+ // The gate's pick: which ready items can run together now, by priority.
189
+ 'GET',
190
+ /^\/api\/p\/([^/]+)\/next$/,
191
+ async (_req, res, m, url) => {
192
+ const count = String(Math.max(1, Math.min(20, Number(url.searchParams.get('count')) || 1)));
193
+ const r = await cli(await projectOf(m[1]), ['gcf-harness', 'next', count, '--json']);
194
+ send(res, 200, jsonOf(r, 'gcf-harness next'));
195
+ },
196
+ ],
197
+ [
198
+ 'POST',
199
+ /^\/api\/p\/([^/]+)\/stop$/,
200
+ async (req, res, m) => {
201
+ const p = await projectOf(m[1]);
202
+ const [id] = runIds({ ids: [(await bodyOf(req)).id] });
203
+ const r = await cli(p, ['gcf-harness', 'stop', id]);
204
+ broadcast({ type: 'board', project: p.name });
205
+ send(res, 200, { ok: r.ok, out: r.out.trim(), err: briefError(r.err) });
206
+ },
207
+ ],
208
+ [
209
+ 'DELETE',
210
+ /^\/api\/p\/([^/]+)\/queue\/([a-z0-9][a-z0-9-]*)$/,
211
+ async (_req, res, m) => {
212
+ unqueue((await projectOf(m[1])).name, m[2]);
213
+ send(res, 200, { ok: true });
178
214
  },
179
215
  ],
180
216
  ];
@@ -0,0 +1,67 @@
1
+ // The board without a console: open it in the browser, and a desktop shortcut that starts it.
2
+ import { chmodSync, mkdirSync, writeFileSync } from 'node:fs';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ const NAME = 'GCF Board';
7
+ /** Open a URL in the default browser; best effort, the URL is printed anyway. */
8
+ export function openInBrowser(url) {
9
+ const [cmd, args] = process.platform === 'win32'
10
+ ? ['cmd', ['/c', 'start', '""', url]]
11
+ : process.platform === 'darwin'
12
+ ? ['open', [url]]
13
+ : ['xdg-open', [url]];
14
+ try {
15
+ spawn(cmd, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
16
+ }
17
+ catch {
18
+ // No browser to open: the operator opens the printed URL.
19
+ }
20
+ }
21
+ /** A PowerShell single-quoted string: a quote inside is doubled. */
22
+ const psQuote = (s) => `'${s.replaceAll("'", "''")}'`;
23
+ export function shortcutPlan(platform, node, main, port) {
24
+ const args = ['--port', String(port)];
25
+ if (platform === 'win32')
26
+ return {
27
+ // A .lnk is a binary file: WScript.Shell writes it. Minimized: the server's console stays out
28
+ // of the way.
29
+ powershell: [
30
+ `$d = [Environment]::GetFolderPath('Desktop')`,
31
+ `$s = (New-Object -ComObject WScript.Shell).CreateShortcut((Join-Path $d '${NAME}.lnk'))`,
32
+ `$s.TargetPath = ${psQuote(node)}`,
33
+ `$s.Arguments = ${psQuote(`"${main}" ${args.join(' ')}`)}`,
34
+ `$s.WindowStyle = 7`,
35
+ `$s.Description = 'Start the GCF board and open it in the browser'`,
36
+ `$s.Save()`,
37
+ `Join-Path $d '${NAME}.lnk'`,
38
+ ].join('; '),
39
+ };
40
+ const command = `"${node}" "${main}" ${args.join(' ')}`;
41
+ if (platform === 'darwin')
42
+ return {
43
+ file: path.join(os.homedir(), 'Desktop', `${NAME}.command`),
44
+ text: `#!/bin/sh\nexec ${command}\n`,
45
+ };
46
+ return {
47
+ file: path.join(os.homedir(), '.local', 'share', 'applications', 'gcf-board.desktop'),
48
+ text: `[Desktop Entry]\nType=Application\nName=${NAME}\nExec=${command}\nTerminal=false\n` +
49
+ 'Categories=Development;\n',
50
+ };
51
+ }
52
+ /** Write the shortcut for this machine; returns where it is. */
53
+ export function createShortcut(main, port) {
54
+ const plan = shortcutPlan(process.platform, process.execPath, main, port);
55
+ if ('powershell' in plan) {
56
+ const r = spawnSync('powershell', ['-NoProfile', '-Command', plan.powershell], {
57
+ encoding: 'utf8',
58
+ });
59
+ if (r.status !== 0)
60
+ throw new Error(`the shortcut was not made: ${r.stderr.trim()}`);
61
+ return r.stdout.trim();
62
+ }
63
+ mkdirSync(path.dirname(plan.file), { recursive: true });
64
+ writeFileSync(plan.file, plan.text);
65
+ chmodSync(plan.file, 0o755);
66
+ return plan.file;
67
+ }
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
- // `gcf-board [--port <n>]`: the board on http://127.0.0.1:<port>, 4317 by default. It prints the
3
- // URL with this machine's token; open that one, the plain address answers 401.
2
+ // `gcf-board [--port <n>] [--no-open]`: the board on http://127.0.0.1:<port>, 4317 by default,
3
+ // opened in the browser with this machine's token. A board already on the port is opened, not
4
+ // started twice. `gcf-board shortcut [--port <n>]` puts a desktop shortcut that does the same.
4
5
  import { existsSync } from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
@@ -8,6 +9,7 @@ import { parseArgs } from 'node:util';
8
9
  import { boardToken } from './auth.js';
9
10
  import { aiFactoryEntry } from './cli.js';
10
11
  import { createApp } from './app.js';
12
+ import { createShortcut, openInBrowser } from './desktop.js';
11
13
  const HERE = path.dirname(fileURLToPath(import.meta.url));
12
14
  /**
13
15
  * The built page: `dist/web` beside `server/` when run from the source (the source `web/` is
@@ -20,14 +22,37 @@ function webDir() {
20
22
  : path.resolve(HERE, '..', 'web');
21
23
  }
22
24
  async function main() {
23
- const { values } = parseArgs({ options: { port: { type: 'string', default: '4317' } } });
25
+ const { values, positionals } = parseArgs({
26
+ allowPositionals: true,
27
+ options: {
28
+ port: { type: 'string', default: '4317' },
29
+ 'no-open': { type: 'boolean', default: false },
30
+ },
31
+ });
24
32
  const port = Number(values.port);
25
33
  if (!Number.isInteger(port) || port < 1 || port > 65535)
26
34
  throw new Error('--port is a number from 1 to 65535');
35
+ if (positionals[0] === 'shortcut') {
36
+ console.log(`gcf-board: shortcut ${createShortcut(fileURLToPath(import.meta.url), port)}`);
37
+ return;
38
+ }
39
+ if (positionals.length)
40
+ throw new Error(`unknown command: ${positionals.join(' ')}`);
27
41
  const token = await boardToken();
42
+ const url = `http://127.0.0.1:${port}/?t=${token}`;
43
+ const open = () => !values['no-open'] && openInBrowser(url);
28
44
  const app = createApp({ entry: aiFactoryEntry(), token, port, web: webDir() });
45
+ app.on('error', (e) => {
46
+ if (e.code !== 'EADDRINUSE')
47
+ throw e;
48
+ // A second start, from the shortcut most likely: the board runs already, show it.
49
+ console.log(`gcf-board: already running on port ${port}, opening it`);
50
+ open();
51
+ setTimeout(() => process.exit(0), 500);
52
+ });
29
53
  app.listen(port, '127.0.0.1', () => {
30
- console.log(`gcf-board: http://127.0.0.1:${port}/?t=${token}`);
54
+ console.log(`gcf-board: ${url}`);
55
+ open();
31
56
  });
32
57
  }
33
58
  main().catch((e) => {
@@ -105,6 +105,9 @@ const MOVES = {
105
105
  ],
106
106
  'inbox-drop': (b) => ['inbox', 'drop', id(b), '--commit'],
107
107
  'phase-next': (b) => ['phase', 'next', ...opt(b, 'reason')],
108
+ // A column's new order after a drag: ranks 1, 2, 3… in one commit.
109
+ prioritize: (b) => ['prioritize', ...runIds(b)],
110
+ 'question-answer': (b) => ['question', 'answer', id(b), `--text=${required(b, 'text')}`],
108
111
  };
109
112
  /** The `ai-factory` arguments of a move, or a 400 naming what is wrong. */
110
113
  export function moveArgs(b) {
@@ -1,19 +1,42 @@
1
1
  // Runs the board started: `ai-factory gcf-harness run <ids> --json`, one process per batch, held
2
2
  // by the server until every agent session ends. A run started from a console shows on the
3
- // board too (its lock makes the item `running`); only its final report is not here.
3
+ // board too (its lock or session mark makes the item `running`); only its report is not here.
4
+ // In `run_mode: main` the checkout takes one run at a time: a run asked for meanwhile waits in
5
+ // the project's queue and starts when the one before it ends.
4
6
  import { spawn } from 'node:child_process';
5
7
  import { broadcast } from './events.js';
6
8
  import { HttpError } from './http-error.js';
7
9
  // A board that stops leaves its runs going: killing one would lose its integrate step.
8
10
  const active = new Set();
11
+ const queues = new Map();
9
12
  export const activeRuns = (project) => [...active].filter((r) => r.project === project);
10
- /** Start a batch; its end is a `run` event with the harness report, or what went wrong. */
11
- export function startRun(entry, p, ids) {
12
- // A second click on the same card: the harness would refuse it on its lock, noisily.
13
- const busy = ids.filter((id) => activeRuns(p.name).some((r) => r.ids.includes(id)));
14
- if (busy.length)
15
- throw new HttpError(409, `already running from this board: ${busy.join(', ')}`);
16
- const run = { project: p.name, ids, started: new Date().toISOString() };
13
+ /** What runs and what waits, for a project. */
14
+ export const runsOf = (project) => ({
15
+ active: activeRuns(project),
16
+ queued: (queues.get(project) ?? []).flatMap((q) => q.ids),
17
+ });
18
+ /** Take an item out of the queue before its run starts. */
19
+ export function unqueue(project, id) {
20
+ const queue = (queues.get(project) ?? [])
21
+ .map((q) => ({ ...q, ids: q.ids.filter((x) => x !== id) }))
22
+ .filter((q) => q.ids.length);
23
+ queues.set(project, queue);
24
+ broadcast({ type: 'board', project });
25
+ }
26
+ /**
27
+ * Whether a main run ended in a way the next one would repeat: no report at all (the harness
28
+ * 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; a run started by hand that works
30
+ * resumes it.
31
+ */
32
+ export function stalled(ok, result) {
33
+ if (!ok && result === null)
34
+ return true;
35
+ const items = result?.items ?? [];
36
+ return (items.length > 0 && items.every((i) => /another run works in the main/.test(i.error ?? '')));
37
+ }
38
+ function spawnRun({ entry, p, ids }, mode) {
39
+ const run = { project: p.name, ids, started: new Date().toISOString(), mode };
17
40
  active.add(run);
18
41
  const child = spawn(process.execPath, [entry, 'gcf-harness', 'run', ...ids, '--json'], {
19
42
  cwd: p.path,
@@ -37,10 +60,32 @@ export function startRun(entry, p, ids) {
37
60
  catch {
38
61
  // No report: the error says why.
39
62
  }
40
- broadcast({ type: 'run', project: p.name, ids, ok, result, err: (err + message).trim() });
63
+ const paused = mode === 'main' && queues.get(p.name)?.length && stalled(ok, result);
64
+ const why = paused ? '\nthe queue is paused: the next run starts after a run that works' : '';
65
+ broadcast({ type: 'run', project: p.name, ids, ok, result, err: (err + message + why).trim() });
41
66
  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);
42
70
  };
43
71
  child.on('error', (e) => end(false, e.message));
44
72
  child.on('close', (code) => end(code === 0));
45
73
  return run;
46
74
  }
75
+ /**
76
+ * Start a batch in the project's run mode, or queue it behind the main run going on. Its end
77
+ * is a `run` event with the harness report, or what went wrong.
78
+ */
79
+ export function startRun(entry, p, ids, mode = 'worktree') {
80
+ // A second click on the same card: the harness would refuse it on its lock, noisily.
81
+ const { active: going, queued } = runsOf(p.name);
82
+ const busy = ids.filter((id) => queued.includes(id) || going.some((r) => r.ids.includes(id)));
83
+ if (busy.length)
84
+ throw new HttpError(409, `already running from this board: ${busy.join(', ')}`);
85
+ if (mode === 'main' && going.some((r) => r.mode === 'main')) {
86
+ queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode }]);
87
+ broadcast({ type: 'board', project: p.name });
88
+ return { queued: ids };
89
+ }
90
+ return { started: spawnRun({ entry, p, ids, mode }, mode) };
91
+ }