gcf-board 0.2.4 → 0.2.6
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/dist/server/app.js +14 -7
- package/dist/server/auth.js +6 -4
- package/dist/server/desktop.js +6 -2
- package/dist/server/events.js +85 -17
- package/dist/server/files.js +9 -7
- package/dist/server/runs.js +12 -2
- package/dist/web/assets/index-CF-CscK4.js +31 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-CCqgZN0Y.js +0 -31
package/dist/server/app.js
CHANGED
|
@@ -7,10 +7,10 @@ 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';
|
|
@@ -126,8 +126,10 @@ export function createApp(o) {
|
|
|
126
126
|
/^\/api\/p\/([^/]+)\/board$/,
|
|
127
127
|
async (_req, res, m) => {
|
|
128
128
|
const p = await projectOf(m[1]);
|
|
129
|
-
// The board's own runs ride along: what it started and what waits in its queue
|
|
130
|
-
|
|
129
|
+
// The board's own runs ride along: what it started and what waits in its queue; and the
|
|
130
|
+
// count of plan writes, which tells the page an open plan's text changed.
|
|
131
|
+
const own = { runs: runsOf(p.name), plans_rev: planRev(p.name) };
|
|
132
|
+
send(res, 200, { ...(await boardOf(p)), ...own });
|
|
131
133
|
},
|
|
132
134
|
],
|
|
133
135
|
[
|
|
@@ -135,7 +137,12 @@ export function createApp(o) {
|
|
|
135
137
|
/^\/api\/p\/([^/]+)\/file$/,
|
|
136
138
|
async (_req, res, m, url) => {
|
|
137
139
|
const p = await projectOf(m[1]);
|
|
138
|
-
|
|
140
|
+
const rel = url.searchParams.get('path') ?? '';
|
|
141
|
+
// A plan an item links (`plan:`) may lie anywhere in the project: the core names it.
|
|
142
|
+
const linked = !shownShape(rel) &&
|
|
143
|
+
// A board the core cannot read links nothing: the path is refused, not a 500.
|
|
144
|
+
(await boardOf(p).catch(() => ({ items: [] }))).items.some((i) => i.plan_file === rel);
|
|
145
|
+
send(res, 200, { text: await readShown(p.path, rel, linked) });
|
|
139
146
|
},
|
|
140
147
|
],
|
|
141
148
|
[
|
|
@@ -234,7 +241,7 @@ export function createApp(o) {
|
|
|
234
241
|
const visit = req.method === 'GET' && !url.pathname.startsWith('/api/');
|
|
235
242
|
if (visit && hostAllowed(req, o.port) && tokenInUrl(url, o.token)) {
|
|
236
243
|
res.writeHead(302, {
|
|
237
|
-
'set-cookie': `${
|
|
244
|
+
'set-cookie': `${cookieName(o.port)}=${session}; HttpOnly; SameSite=Strict; Path=/`,
|
|
238
245
|
// Always the root: a path like `//elsewhere` would send the browser off the board.
|
|
239
246
|
location: '/',
|
|
240
247
|
});
|
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,59 @@ 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 alive = (pid) => {
|
|
33
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
34
|
+
return false;
|
|
35
|
+
try {
|
|
36
|
+
process.kill(pid, 0);
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
catch (e) {
|
|
40
|
+
return e.code === 'EPERM';
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* What makes items `running` in a runs directory: its entries, whether each lock's process
|
|
45
|
+
* lives, whether each session mark is fresh. A run that died or a session that expired changes
|
|
46
|
+
* it with no file written.
|
|
47
|
+
*/
|
|
48
|
+
export function runsState(dir, now = Date.now()) {
|
|
49
|
+
let names;
|
|
50
|
+
try {
|
|
51
|
+
names = readdirSync(dir).sort();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
return names
|
|
57
|
+
.map((n) => {
|
|
58
|
+
const file = path.join(dir, n);
|
|
59
|
+
try {
|
|
60
|
+
if (n.endsWith('.lock'))
|
|
61
|
+
return `${n} ${alive(Number(readFileSync(file, 'utf8').split('\n')[0]))}`;
|
|
62
|
+
if (n.endsWith('.session'))
|
|
63
|
+
return `${n} ${now - statSync(file).mtimeMs < SESSION_TTL_MS}`;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
// Gone meanwhile: the next poll sees it.
|
|
67
|
+
}
|
|
68
|
+
return n;
|
|
69
|
+
})
|
|
70
|
+
.join('\n');
|
|
71
|
+
}
|
|
72
|
+
// Each project's plan writes, counted: a plan's text can change while its checkbox progress stays,
|
|
73
|
+
// so the page reads an open plan again when the count moves.
|
|
74
|
+
const planRevs = new Map();
|
|
75
|
+
export const planRev = (project) => planRevs.get(project) ?? 0;
|
|
28
76
|
/**
|
|
29
|
-
* Watch a project: `gcf/` (every move writes there)
|
|
30
|
-
*
|
|
31
|
-
*
|
|
77
|
+
* Watch a project: `gcf/` (every move writes there), `.ai-factory/plans` (a plan is written and
|
|
78
|
+
* edited) and `.gcf-session/runs` (a run's lock or session mark comes, goes or goes stale). The
|
|
79
|
+
* runs directory may not exist yet, so it is polled, not watched. Bursts of writes make one
|
|
80
|
+
* event. Returns the stop.
|
|
32
81
|
*/
|
|
33
82
|
export function watchProject(p, debounceMs = 300) {
|
|
34
83
|
let timer = null;
|
|
@@ -37,19 +86,38 @@ export function watchProject(p, debounceMs = 300) {
|
|
|
37
86
|
clearTimeout(timer);
|
|
38
87
|
timer = setTimeout(() => broadcast({ type: 'board', project: p.name }), debounceMs);
|
|
39
88
|
};
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
89
|
+
const watchers = [];
|
|
90
|
+
const watchDir = (dir, onChange) => {
|
|
91
|
+
try {
|
|
92
|
+
const w = watch(dir, { recursive: true }, (_e, file) => onChange(file));
|
|
93
|
+
w.on('error', () => w.close());
|
|
94
|
+
watchers.push(w);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Not there: the board read shows what is missing.
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
watchDir(path.join(p.path, 'gcf'), fire);
|
|
101
|
+
// shortcut: a plan an item links outside .ai-factory/plans shows its new text only when its
|
|
102
|
+
// checkbox count moves; watch those files too if operators edit such plans by hand.
|
|
103
|
+
watchDir(path.join(p.path, '.ai-factory'), (file) => {
|
|
104
|
+
if (!/^plans[\\/]/.test(file ?? ''))
|
|
105
|
+
return;
|
|
106
|
+
planRevs.set(p.name, planRev(p.name) + 1);
|
|
107
|
+
fire();
|
|
108
|
+
});
|
|
48
109
|
const runs = path.join(p.path, '.gcf-session', 'runs');
|
|
49
|
-
|
|
110
|
+
let last = runsState(runs);
|
|
111
|
+
const poll = setInterval(() => {
|
|
112
|
+
const now = runsState(runs);
|
|
113
|
+
if (now !== last)
|
|
114
|
+
fire();
|
|
115
|
+
last = now;
|
|
116
|
+
}, 1500);
|
|
50
117
|
return () => {
|
|
51
|
-
|
|
52
|
-
|
|
118
|
+
for (const w of watchers)
|
|
119
|
+
w.close();
|
|
120
|
+
clearInterval(poll);
|
|
53
121
|
if (timer)
|
|
54
122
|
clearTimeout(timer);
|
|
55
123
|
};
|
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
|
@@ -4,6 +4,7 @@
|
|
|
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.
|
|
@@ -80,7 +81,15 @@ function spawnRun({ entry, p, ids }, mode) {
|
|
|
80
81
|
if (stop)
|
|
81
82
|
paused.add(p.name);
|
|
82
83
|
const why = stop ? '\nthe queue is paused: fix the cause, then resume it on the board' : '';
|
|
83
|
-
|
|
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
|
+
});
|
|
84
93
|
if (mode === 'main')
|
|
85
94
|
advance(p.name);
|
|
86
95
|
broadcast({ type: 'board', project: p.name });
|
|
@@ -99,7 +108,8 @@ export function startRun(entry, p, ids, mode = 'worktree') {
|
|
|
99
108
|
const busy = ids.filter((id) => queued.includes(id) || going.some((r) => r.ids.includes(id)));
|
|
100
109
|
if (busy.length)
|
|
101
110
|
throw new HttpError(409, `already running from this board: ${busy.join(', ')}`);
|
|
102
|
-
|
|
111
|
+
// A paused queue holds a new main run too: it would repeat the failure, and jump the queue.
|
|
112
|
+
if (mode === 'main' && (paused.has(p.name) || going.some((r) => r.mode === 'main'))) {
|
|
103
113
|
queues.set(p.name, [...(queues.get(p.name) ?? []), { entry, p, ids, mode }]);
|
|
104
114
|
broadcast({ type: 'board', project: p.name });
|
|
105
115
|
return { queued: ids };
|