gcf-board 0.0.0-stage → 0.2.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.
@@ -0,0 +1,36 @@
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.
4
+ import { existsSync } from 'node:fs';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { parseArgs } from 'node:util';
8
+ import { boardToken } from './auth.js';
9
+ import { aiFactoryEntry } from './cli.js';
10
+ import { createApp } from './app.js';
11
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
12
+ /**
13
+ * The built page: `dist/web` beside `server/` when run from the source (the source `web/` is
14
+ * not built), `web/` beside `dist/server/` in the package.
15
+ */
16
+ function webDir() {
17
+ const fromSource = path.resolve(HERE, '..', 'dist', 'web');
18
+ return existsSync(path.join(fromSource, 'index.html'))
19
+ ? fromSource
20
+ : path.resolve(HERE, '..', 'web');
21
+ }
22
+ async function main() {
23
+ const { values } = parseArgs({ options: { port: { type: 'string', default: '4317' } } });
24
+ const port = Number(values.port);
25
+ if (!Number.isInteger(port) || port < 1 || port > 65535)
26
+ throw new Error('--port is a number from 1 to 65535');
27
+ const token = await boardToken();
28
+ const app = createApp({ entry: aiFactoryEntry(), token, port, web: webDir() });
29
+ app.listen(port, '127.0.0.1', () => {
30
+ console.log(`gcf-board: http://127.0.0.1:${port}/?t=${token}`);
31
+ });
32
+ }
33
+ main().catch((e) => {
34
+ console.error(`gcf-board: ${e.message}`);
35
+ process.exit(1);
36
+ });
@@ -0,0 +1,125 @@
1
+ // The moves a board may run, each one `ai-factory gcf` command (Methodology §4.1, §11). Anything
2
+ // else is refused: the board is a view, the commands own every rule. An operator's text goes
3
+ // as one `--flag=value` argument, so no word of it reads as a flag.
4
+ import { HttpError } from './http-error.js';
5
+ const SLUG = /^[a-z0-9][a-z0-9-]*$/;
6
+ function id(b, key = 'id') {
7
+ const v = b[key];
8
+ if (typeof v !== 'string' || !SLUG.test(v))
9
+ throw new HttpError(400, `${key} must be a lowercase hyphen slug`);
10
+ return v;
11
+ }
12
+ function text(b, key) {
13
+ const v = b[key];
14
+ if (v === undefined || v === null || v === '')
15
+ return undefined;
16
+ if (typeof v !== 'string')
17
+ throw new HttpError(400, `${key} must be text`);
18
+ return v;
19
+ }
20
+ function required(b, key) {
21
+ const v = text(b, key)?.trim();
22
+ if (!v)
23
+ throw new HttpError(400, `${key} is required`);
24
+ return v;
25
+ }
26
+ function oneOf(b, key, allowed) {
27
+ const v = b[key];
28
+ if (typeof v !== 'string' || !allowed.includes(v))
29
+ throw new HttpError(400, `${key} must be ${allowed.join(' | ')}`);
30
+ return v;
31
+ }
32
+ const opt = (b, key, flag = key) => {
33
+ const v = text(b, key);
34
+ return v === undefined ? [] : [`--${flag}=${v}`];
35
+ };
36
+ const many = (b, key) => {
37
+ const v = b[key];
38
+ if (v === undefined)
39
+ return [];
40
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
41
+ throw new HttpError(400, `${key} must be a list of text`);
42
+ return v.filter(Boolean).map((x) => `--${key}=${x}`);
43
+ };
44
+ /** `--remove` / `--no-remove` when the operator chose; the command's default otherwise. */
45
+ const remove = (b) => b.remove === true ? ['--remove'] : b.remove === false ? ['--no-remove'] : [];
46
+ const NEW_ITEM_OPTIONS = [
47
+ 'gate',
48
+ 'title',
49
+ 'parent',
50
+ 'system',
51
+ 'depends',
52
+ 'severity',
53
+ 'blocked',
54
+ 'from',
55
+ 'reopens',
56
+ 'milestone',
57
+ 'release',
58
+ 'channel',
59
+ 'model',
60
+ 'effort',
61
+ ];
62
+ const MOVES = {
63
+ submit: (b) => ['submit', id(b)],
64
+ close: (b) => ['close', id(b), ...many(b, 'constraint')],
65
+ verdict: (b) => [
66
+ 'verdict',
67
+ id(b),
68
+ oneOf(b, 'verdict', ['keep', 'tune', 'kill']),
69
+ ...opt(b, 'note'),
70
+ ...opt(b, 'tags'),
71
+ ...many(b, 'constraint'),
72
+ ...remove(b),
73
+ ],
74
+ decide: (b) => [
75
+ 'decide',
76
+ id(b),
77
+ oneOf(b, 'choice', ['go', 'no', 'wontfix', 'known', 'close', 'continue', 'kill']),
78
+ ...opt(b, 'reason'),
79
+ ...remove(b),
80
+ ],
81
+ block: (b) => ['block', id(b), `--reason=${required(b, 'reason')}`],
82
+ unblock: (b) => ['unblock', id(b), ...opt(b, 'note')],
83
+ drop: (b) => [
84
+ 'drop',
85
+ id(b),
86
+ `--reason=${required(b, 'reason')}`,
87
+ ...opt(b, 'tags'),
88
+ ...remove(b),
89
+ ],
90
+ 'item-new': (b) => [
91
+ 'item',
92
+ 'new',
93
+ id(b),
94
+ `--type=${required(b, 'type')}`,
95
+ ...NEW_ITEM_OPTIONS.flatMap((k) => opt(b, k)),
96
+ '--commit',
97
+ ],
98
+ // `--` does not hold through ai-factory's nested commands: the text goes as one `--text=`.
99
+ 'inbox-add': (b) => [
100
+ 'inbox',
101
+ 'add',
102
+ ...opt(b, 'source'),
103
+ '--commit',
104
+ `--text=${required(b, 'text')}`,
105
+ ],
106
+ 'inbox-drop': (b) => ['inbox', 'drop', id(b), '--commit'],
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')}`],
111
+ };
112
+ /** The `ai-factory` arguments of a move, or a 400 naming what is wrong. */
113
+ export function moveArgs(b) {
114
+ const cmd = typeof b.cmd === 'string' && Object.hasOwn(MOVES, b.cmd) ? b.cmd : null;
115
+ if (!cmd)
116
+ throw new HttpError(400, `unknown move: ${String(b.cmd)}`);
117
+ return ['gcf', ...MOVES[cmd](b)];
118
+ }
119
+ /** The ids of a run: one slug each, at least one. */
120
+ export function runIds(b) {
121
+ const ids = b.ids;
122
+ if (!Array.isArray(ids) || !ids.length)
123
+ throw new HttpError(400, 'ids must be a non-empty list');
124
+ return [...new Set(ids.map((v) => id({ id: v })))];
125
+ }
@@ -0,0 +1,65 @@
1
+ // The projects a board shows: `~/.gcf/projects.json`, `{projects: [{name, path}]}`. The name is
2
+ // the project's address in the API; the path is taken only here, never from a later request.
3
+ import { existsSync } from 'node:fs';
4
+ import { mkdir, readFile } from 'node:fs/promises';
5
+ import os from 'node:os';
6
+ import path from 'node:path';
7
+ import { writeAtomic } from './atomic.js';
8
+ import { HttpError } from './http-error.js';
9
+ /** The board's own directory; GCF_BOARD_HOME moves it (tests, a second board). */
10
+ export const homeDir = () => process.env.GCF_BOARD_HOME ?? path.join(os.homedir(), '.gcf');
11
+ const registryFile = () => path.join(homeDir(), 'projects.json');
12
+ const NAME = /^[a-z0-9][a-z0-9-]*$/;
13
+ export async function listProjects() {
14
+ if (!existsSync(registryFile()))
15
+ return [];
16
+ const data = JSON.parse(await readFile(registryFile(), 'utf8'));
17
+ return Array.isArray(data?.projects) ? data.projects : [];
18
+ }
19
+ async function save(projects) {
20
+ await mkdir(homeDir(), { recursive: true });
21
+ await writeAtomic(registryFile(), `${JSON.stringify({ projects }, null, 2)}\n`);
22
+ }
23
+ // Changes run one after another: two adds at once would each write the list without the other.
24
+ let queue = Promise.resolve();
25
+ const serial = (change) => {
26
+ const next = queue.then(change, change);
27
+ queue = next.catch(() => { });
28
+ return next;
29
+ };
30
+ const nameOf = (dir) => path
31
+ .basename(dir)
32
+ .toLowerCase()
33
+ .replace(/[^a-z0-9]+/g, '-')
34
+ .replace(/^-+|-+$/g, '') || 'project';
35
+ /** Register a project that has `gcf/`; its name defaults to its folder's. */
36
+ export const addProject = (dir, name) => serial(() => add(dir, name));
37
+ export const removeProject = (name) => serial(() => remove(name));
38
+ async function add(dir, name) {
39
+ const abs = path.resolve(dir);
40
+ if (!existsSync(path.join(abs, 'gcf')))
41
+ throw new HttpError(400, `${abs} has no gcf/: run ai-factory gcf init there first`);
42
+ const projects = await listProjects();
43
+ const same = (p) => path.relative(p.path, abs) === '';
44
+ if (projects.some(same))
45
+ throw new HttpError(409, `${abs} is on the board already`);
46
+ const project = { name: name ?? nameOf(abs), path: abs };
47
+ if (!NAME.test(project.name))
48
+ throw new HttpError(400, 'a project name is a lowercase hyphen slug, e.g. zombie-puzzle');
49
+ if (projects.some((p) => p.name === project.name))
50
+ throw new HttpError(409, `the name ${project.name} is taken: give another`);
51
+ await save([...projects, project]);
52
+ return project;
53
+ }
54
+ async function remove(name) {
55
+ const projects = await listProjects();
56
+ if (!projects.some((p) => p.name === name))
57
+ throw new HttpError(404, `no project ${name}`);
58
+ await save(projects.filter((p) => p.name !== name));
59
+ }
60
+ export async function projectOf(name) {
61
+ const p = (await listProjects()).find((x) => x.name === name);
62
+ if (!p)
63
+ throw new HttpError(404, `no project ${name}`);
64
+ return p;
65
+ }
@@ -0,0 +1,91 @@
1
+ // Runs the board started: `ai-factory gcf-harness run <ids> --json`, one process per batch, held
2
+ // by the server until every agent session ends. A run started from a console shows on the
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.
6
+ import { spawn } from 'node:child_process';
7
+ import { broadcast } from './events.js';
8
+ import { HttpError } from './http-error.js';
9
+ // A board that stops leaves its runs going: killing one would lose its integrate step.
10
+ const active = new Set();
11
+ const queues = new Map();
12
+ export const activeRuns = (project) => [...active].filter((r) => r.project === project);
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 };
40
+ active.add(run);
41
+ const child = spawn(process.execPath, [entry, 'gcf-harness', 'run', ...ids, '--json'], {
42
+ cwd: p.path,
43
+ shell: false,
44
+ stdio: ['ignore', 'pipe', 'pipe'],
45
+ });
46
+ child.stdout.setEncoding('utf8');
47
+ child.stderr.setEncoding('utf8');
48
+ let out = '';
49
+ let err = '';
50
+ child.stdout.on('data', (d) => (out += d));
51
+ child.stderr.on('data', (d) => (err += d));
52
+ const end = (ok, message = '') => {
53
+ // A spawn that fails fires both error and close: one end only.
54
+ if (!active.delete(run))
55
+ return;
56
+ let result = null;
57
+ try {
58
+ result = JSON.parse(out);
59
+ }
60
+ catch {
61
+ // No report: the error says why.
62
+ }
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() });
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);
70
+ };
71
+ child.on('error', (e) => end(false, e.message));
72
+ child.on('close', (code) => end(code === 0));
73
+ return run;
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
+ }
@@ -0,0 +1,11 @@
1
+ // The read model of `ai-factory gcf board --json` (Methodology §11): the board refuses a schema
2
+ // it does not know. Types only, plus the version this board reads.
3
+ export const BOARD_SCHEMA = 1;
4
+ export const STATUSES = [
5
+ 'ready',
6
+ 'blocked',
7
+ 'awaiting-verdict',
8
+ 'awaiting-decision',
9
+ 'unplayed',
10
+ 'parked',
11
+ ];