gcf-board 0.2.16 → 0.2.18

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
@@ -52,6 +52,7 @@ It puts **GCF Board** on the desktop (Windows: a shortcut whose server console s
52
52
  - **Lessons** (core 0.7.10, harness 0.9.2): what got in an agent's way, asked at the end of each session, lands in the inbox with its kind (`tool`, `rules`, `framework`, `template`, `other`) and its item. The inbox filters them; a card shows `N lessons`, and the drawer lists the item's lessons with the inbox's buttons. A run's report line ends with `, N lessons`, and with `— code written outside aif-implement` when a session skipped that skill.
53
53
  - **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`.
54
54
  - **Ended**: history (accepted) and graveyard (killed, dropped, won't-fix).
55
+ - **Metrics**: `gcf-harness metrics --json` of the project — how many runs reached submit or close, what they cost, handoffs, plan checks, DoD attempts, guard firings, block reasons — and, with harness 0.9.5, the cost per item over all its runs and per week and day.
55
56
 
56
57
  The page updates by itself: the server watches each project's `gcf/` and `.gcf-session/runs/`.
57
58
 
@@ -1,5 +1,6 @@
1
1
  // The board's HTTP server: the page, and an API that is a thin layer over `ai-factory gcf`.
2
- // Reads: projects, the board, a shown file, a run log, the playtest session. Writes: a project
2
+ // Reads: projects, the board, a shown file, a run log, the playtest session, the run metrics
3
+ // (`ai-factory gcf-harness metrics`). Writes: a project
3
4
  // on the board, a move, a run. Every write is a command of the framework.
4
5
  import { randomBytes } from 'node:crypto';
5
6
  import { createServer } from 'node:http';
@@ -90,8 +91,13 @@ async function sendPage(res, web, pathname) {
90
91
  }
91
92
  export function createApp(o) {
92
93
  const watchers = new Map();
94
+ let closed = false;
93
95
  const watchAll = async () => {
94
- for (const p of await listProjects())
96
+ const projects = await listProjects();
97
+ // A watcher set up after the server closed would keep the process alive.
98
+ if (closed)
99
+ return;
100
+ for (const p of projects)
95
101
  if (!watchers.has(p.name))
96
102
  watchers.set(p.name, watchProject(p));
97
103
  };
@@ -180,6 +186,14 @@ export function createApp(o) {
180
186
  send(res, 200, jsonOf(r, 'gcf playtest session'));
181
187
  },
182
188
  ],
189
+ [
190
+ 'GET',
191
+ /^\/api\/p\/([^/]+)\/metrics$/,
192
+ async (_req, res, m) => {
193
+ const r = await cli(await projectOf(m[1]), ['gcf-harness', 'metrics', '--json']);
194
+ send(res, 200, jsonOf(r, 'gcf-harness metrics'));
195
+ },
196
+ ],
183
197
  [
184
198
  // A build runs the project's own command: a write, so a POST with its Origin checked.
185
199
  'POST',
@@ -303,15 +317,18 @@ export function createApp(o) {
303
317
  const server = createServer((req, res) => {
304
318
  handle(req, res).catch((e) => {
305
319
  if (res.headersSent)
306
- return res.end();
307
- send(res, e instanceof HttpError ? e.status : 500, { error: e.message });
320
+ res.end();
321
+ else
322
+ send(res, e instanceof HttpError ? e.status : 500, { error: e.message });
308
323
  });
309
324
  });
310
325
  // A broken projects.json must not take the server down: the page shows the error instead.
311
326
  server.on('listening', () => watchAll().catch((e) => console.error(`gcf-board: ${e.message}`)));
312
327
  server.on('close', () => {
328
+ closed = true;
313
329
  for (const stop of watchers.values())
314
330
  stop();
331
+ watchers.clear();
315
332
  });
316
333
  return server;
317
334
  }
@@ -1,7 +1,8 @@
1
1
  // The board starts agents that commit and push: whoever talks to it runs code on this machine.
2
- // It listens on 127.0.0.1 only, and every request needs the token printed at start (kept in a
3
- // cookie after the first visit). Host and Origin are checked too: a page in another tab can
4
- // reach 127.0.0.1 (DNS rebinding, a cross-site POST), but it cannot fake those headers.
2
+ // It listens on 127.0.0.1 only. The URL printed at start carries the token; that visit gets a
3
+ // session cookie (one per server process, never the token itself), and every request needs it.
4
+ // Host and Origin are checked too: a page in another tab can reach 127.0.0.1 (DNS rebinding, a
5
+ // cross-site POST), but it cannot fake those headers.
5
6
  import { randomBytes, timingSafeEqual } from 'node:crypto';
6
7
  import { existsSync } from 'node:fs';
7
8
  import { mkdir, readFile } from 'node:fs/promises';
@@ -43,11 +43,13 @@ function globalRoots() {
43
43
  return roots;
44
44
  }
45
45
  /**
46
- * The line of a refusal an operator reads: the core throws, and node prints the stack under
47
- * an `Error: <message>` line. Anything else is shown as it came.
46
+ * The refusal an operator reads: the core throws, and node prints the stack under an
47
+ * `Error: <message>` header, every line of the message (a git failure's cause comes after its
48
+ * first line). Anything else is shown as it came.
48
49
  */
49
50
  export function briefError(err) {
50
- return err.match(/^\w*Error: (.+)$/m)?.[1].trim() ?? err.trim();
51
+ const m = err.match(/^\w*Error: ([\s\S]*?)\r?\n {4}at /m) ?? err.match(/^\w*Error: (.+)$/m);
52
+ return (m?.[1] ?? err).replace(/\r/g, '').trim();
51
53
  }
52
54
  /** Run `ai-factory <args>` in a project; a move can wait up to a minute on gcf's main lock. */
53
55
  export function runCli(entry, cwd, args, timeoutMs = 120_000) {
@@ -71,6 +73,8 @@ export function runCli(entry, cwd, args, timeoutMs = 120_000) {
71
73
  spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' });
72
74
  else
73
75
  child.kill();
76
+ // A grandchild holding the pipes keeps close from firing: the answer does not wait for it.
77
+ setTimeout(() => resolve({ ok: false, code: null, out, err }), 5_000).unref();
74
78
  }, timeoutMs);
75
79
  child.on('error', (e) => {
76
80
  clearTimeout(timer);
@@ -127,8 +127,9 @@ function schedule(run, at) {
127
127
  catch (e) {
128
128
  // The board's own run of an item takes it out of the schedule first, so this is rare;
129
129
  // the queue still goes on.
130
+ const why = e instanceof Error ? e.message : String(e);
130
131
  if (s)
131
- trace(project, 'resume-failed', `${s.run.ids.join(', ')}: ${e.message}`);
132
+ trace(project, 'resume-failed', `${s.run.ids.join(', ')}: ${why}`);
132
133
  advance(project);
133
134
  }
134
135
  broadcast({ type: 'board', project });
@@ -142,9 +143,7 @@ function schedule(run, at) {
142
143
  * report.
143
144
  */
144
145
  function limited(run, result) {
145
- const items = result
146
- ?.items;
147
- const hit = (items ?? []).filter((i) => i.limit_resets !== undefined);
146
+ const hit = (result?.items ?? []).filter((i) => i.limit_resets !== undefined);
148
147
  if (!hit.length)
149
148
  return '';
150
149
  const project = run.p.name;
@@ -204,6 +203,34 @@ export function stalled(ok, result) {
204
203
  const items = result?.items ?? [];
205
204
  return (items.length > 0 && items.every((i) => /another run works in the main/.test(i.error ?? '')));
206
205
  }
206
+ /**
207
+ * The harness report a run printed, or null for none. A report of another shape counts as none:
208
+ * the close listener that reads it would throw, and take the server down with it.
209
+ */
210
+ export function harnessReport(out) {
211
+ let r;
212
+ try {
213
+ r = JSON.parse(out);
214
+ }
215
+ catch {
216
+ // No report: the error says why.
217
+ return null;
218
+ }
219
+ if (typeof r !== 'object' || r === null || Array.isArray(r))
220
+ return null;
221
+ const { gate, items, orphans } = r;
222
+ const list = (v) => v === undefined || (Array.isArray(v) && v.every(isItem));
223
+ return (gate === undefined || typeof gate === 'string') && list(items) && list(orphans)
224
+ ? r
225
+ : null;
226
+ }
227
+ // The fields read as more than text: the id, and the reset a resume is scheduled at.
228
+ function isItem(i) {
229
+ if (typeof i !== 'object' || i === null)
230
+ return false;
231
+ const { id, limit_resets: resets } = i;
232
+ return typeof id === 'string' && (resets == null || typeof resets === 'string');
233
+ }
207
234
  function spawnRun(q, mode) {
208
235
  const { entry, p, ids } = q;
209
236
  const run = { project: p.name, ids, started: new Date().toISOString(), mode };
@@ -224,13 +251,7 @@ function spawnRun(q, mode) {
224
251
  // A spawn that fails fires both error and close: one end only.
225
252
  if (!active.delete(run))
226
253
  return;
227
- let result = null;
228
- try {
229
- result = JSON.parse(out);
230
- }
231
- catch {
232
- // No report: the error says why.
233
- }
254
+ const result = harnessReport(out);
234
255
  // A run of a project removed meanwhile only reports: it pauses and schedules nothing.
235
256
  const kept = !forgotten.has(run);
236
257
  const stop = kept && mode === 'main' && queues.get(p.name)?.length && stalled(ok, result);
@@ -244,8 +265,7 @@ function spawnRun(q, mode) {
244
265
  (stop ? 'the queue is paused: fix the cause, then resume it on the board' : '');
245
266
  if (stop)
246
267
  trace(p.name, 'queue-paused', `after ${ids.join(', ')}: ${note}`);
247
- const handed = result?.items;
248
- for (const i of handed ?? [])
268
+ for (const i of result?.items ?? [])
249
269
  if (kept && i.handoffs)
250
270
  trace(p.name, 'handoff', `${i.id}: ${i.handoffs} context handoff(s) to a fresh session`);
251
271
  const brief = err && briefError(err);