@crouton-kit/humanloop 0.3.29 → 0.3.31
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/api.js +6 -0
- package/dist/browser/open.d.ts +7 -0
- package/dist/browser/open.js +26 -0
- package/dist/browser/server.d.ts +62 -0
- package/dist/browser/server.js +440 -0
- package/dist/cli.js +43 -12
- package/dist/editor/feedback.d.ts +18 -0
- package/dist/editor/feedback.js +204 -0
- package/dist/editor/review.js +162 -93
- package/dist/inbox/deck-schema.d.ts +14 -1
- package/dist/inbox/deck-schema.js +40 -18
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/render/version.d.ts +1 -1
- package/dist/render/version.js +1 -1
- package/dist/tui/app.js +122 -3
- package/dist/tui/input.js +4 -1
- package/dist/tui/render.d.ts +7 -0
- package/dist/tui/render.js +25 -0
- package/dist/types.d.ts +2 -1
- package/dist/types.js +5 -1
- package/dist/web/assets/index-BxJZgs8l.css +2 -0
- package/dist/web/assets/index-DhbBiRqS.js +46 -0
- package/dist/web/index.html +13 -0
- package/package.json +8 -3
package/dist/api.js
CHANGED
|
@@ -5,6 +5,7 @@ import { resolveInteractionDir } from './tui/app.js';
|
|
|
5
5
|
import { scanInbox } from './inbox/scan.js';
|
|
6
6
|
import { pickFromInbox } from './inbox/tui.js';
|
|
7
7
|
import { deckPath, atomicWriteJson, readJson, stampCanvasNode } from './inbox/convention.js';
|
|
8
|
+
import { resolveDeckBodyPaths } from './inbox/deck-schema.js';
|
|
8
9
|
import { getTerminalSize } from './tui/terminal.js';
|
|
9
10
|
import { notifyDeck } from './inbox/deck-factories.js';
|
|
10
11
|
import { buildSummary } from './summary.js';
|
|
@@ -20,6 +21,11 @@ function managedDir() {
|
|
|
20
21
|
export async function ask(deck, opts = {}) {
|
|
21
22
|
const dir = opts.dir ?? managedDir();
|
|
22
23
|
mkdirSync(dir, { recursive: true });
|
|
24
|
+
// Canonical bodyPath → body normalization boundary (see deck-schema.ts) —
|
|
25
|
+
// resolved BEFORE the deck is ever written to disk, so every reader
|
|
26
|
+
// downstream (terminal render, the live-reload poller, the browser server)
|
|
27
|
+
// only ever sees a plain `body`.
|
|
28
|
+
deck = resolveDeckBodyPaths(deck, dir);
|
|
23
29
|
stampCanvasNode(deck);
|
|
24
30
|
atomicWriteJson(deckPath(dir), deck);
|
|
25
31
|
const { responses, completedAt, responsePath, deck: answeredDeck } = await resolveInteractionDir(dir, deck, {
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort open a URL in the user's default browser: `open` on macOS,
|
|
3
|
+
* `xdg-open` on Linux. Never throws and never blocks the handoff — an
|
|
4
|
+
* unsupported platform or a missing binary just prints the URL so the human
|
|
5
|
+
* can open it by hand.
|
|
6
|
+
*/
|
|
7
|
+
export declare function openBrowser(url: string): void;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Best-effort open a URL in the user's default browser: `open` on macOS,
|
|
4
|
+
* `xdg-open` on Linux. Never throws and never blocks the handoff — an
|
|
5
|
+
* unsupported platform or a missing binary just prints the URL so the human
|
|
6
|
+
* can open it by hand.
|
|
7
|
+
*/
|
|
8
|
+
export function openBrowser(url) {
|
|
9
|
+
const bin = process.platform === 'darwin' ? 'open'
|
|
10
|
+
: process.platform === 'linux' ? 'xdg-open'
|
|
11
|
+
: null;
|
|
12
|
+
if (bin === null) {
|
|
13
|
+
process.stderr.write(`humanloop: open this URL in a browser: ${url}\n`);
|
|
14
|
+
return;
|
|
15
|
+
}
|
|
16
|
+
try {
|
|
17
|
+
const child = spawn(bin, [url], { stdio: 'ignore', detached: true });
|
|
18
|
+
child.on('error', () => {
|
|
19
|
+
process.stderr.write(`humanloop: could not launch "${bin}" — open this URL manually: ${url}\n`);
|
|
20
|
+
});
|
|
21
|
+
child.unref();
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
process.stderr.write(`humanloop: open this URL in a browser: ${url}\n`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { Deck, FeedbackResult, InteractionResponse } from '../types.js';
|
|
2
|
+
export interface WebServerOpts {
|
|
3
|
+
/** Interaction dir — the deck.json / response.json / progress.json convention. */
|
|
4
|
+
dir: string;
|
|
5
|
+
/** The deck to serve. Re-read from disk on every GET in case it changed. */
|
|
6
|
+
deck: Deck;
|
|
7
|
+
/**
|
|
8
|
+
* Fired once the browser's submit has been written to response.json —
|
|
9
|
+
* exactly once per server instance, only for the first accepted submit
|
|
10
|
+
* (later submits get a 409 and never re-fire this). `responsePath`/
|
|
11
|
+
* `completedAt` are already persisted to disk — the caller converges the
|
|
12
|
+
* host surface, it must NOT write the result again. Fired only after the
|
|
13
|
+
* submit's own HTTP 200 has finished flushing to its socket, so it is
|
|
14
|
+
* safe for this callback to `stop()` the server (which force-closes all
|
|
15
|
+
* open sockets) without racing the ack the browser is waiting on.
|
|
16
|
+
*/
|
|
17
|
+
onSubmit?: (responses: InteractionResponse[], completedAt: string, responsePath: string) => void;
|
|
18
|
+
}
|
|
19
|
+
export interface ReviewWebServerOpts {
|
|
20
|
+
/** Shared review job dir — carries review.vim and feedback.json. */
|
|
21
|
+
jobDir: string;
|
|
22
|
+
/** Absolute source file under review. */
|
|
23
|
+
file: string;
|
|
24
|
+
/** Absolute feedback JSON output path. */
|
|
25
|
+
output: string;
|
|
26
|
+
/** Optional submit sentinel written on final browser submit. */
|
|
27
|
+
submitFlagPath?: string;
|
|
28
|
+
/** Fired after the HTTP submit ack has finished flushing. */
|
|
29
|
+
onSubmit?: (result: FeedbackResult, submittedAt: string, outputPath: string) => void;
|
|
30
|
+
/** Test-only override for how long `requestTakeBack()` waits for open tabs
|
|
31
|
+
* to ack a flush before giving up and broadcasting `taken-back` anyway.
|
|
32
|
+
* Defaults to 2000ms in real usage. */
|
|
33
|
+
takeBackAckTimeoutMs?: number;
|
|
34
|
+
}
|
|
35
|
+
export interface WebServerHandle {
|
|
36
|
+
/** `http://127.0.0.1:<port>/` — open this in a browser. */
|
|
37
|
+
url: string;
|
|
38
|
+
port: number;
|
|
39
|
+
/** Mark this server as the active editing authority — until called, the
|
|
40
|
+
* write endpoints (review draft/submit) 409 with `not_handed_off`. Deck's
|
|
41
|
+
* implementation is a documented no-op: a deck server is only ever created
|
|
42
|
+
* post-handoff (see `enterHandoff` in tui/app.ts), so it has no separate
|
|
43
|
+
* activation moment to gate. */
|
|
44
|
+
activate(): void;
|
|
45
|
+
/** Ask every connected browser tab to flush any pending edit and ack, then
|
|
46
|
+
* broadcast `{type:'taken-back'}`. Bounded by a timeout so a tab that never
|
|
47
|
+
* acks (closed, network hiccup) can't hang take-back forever. Safe to call
|
|
48
|
+
* with zero open sockets (e.g. the browser was never opened). */
|
|
49
|
+
requestTakeBack(): Promise<void>;
|
|
50
|
+
/** Stop listening, close all sockets (WS and HTTP), and tear down. */
|
|
51
|
+
stop(): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Start an on-demand local HTTP+WS server over one interaction dir. Binds to
|
|
55
|
+
* an ephemeral port on 127.0.0.1 only (never 0.0.0.0 — this is a same-machine
|
|
56
|
+
* handoff, not a shareable link). The caller owns the lifecycle: start it
|
|
57
|
+
* when the human hands off to the browser, `stop()` it on take-back or once
|
|
58
|
+
* `onSubmit` fires.
|
|
59
|
+
*/
|
|
60
|
+
export declare function startWebServer(opts: WebServerOpts): Promise<WebServerHandle>;
|
|
61
|
+
/** Start a review-mode browser server over one markdown review job. */
|
|
62
|
+
export declare function startReviewWebServer(opts: ReviewWebServerOpts): Promise<WebServerHandle>;
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { join, extname, dirname, basename } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { WebSocketServer } from 'ws';
|
|
7
|
+
import { deckPath, readJson, writeResponse, clearProgress } from '../inbox/convention.js';
|
|
8
|
+
import { buildDraftFeedbackResult, buildFinalFeedbackResult, parseFeedbackComments, readStoredDraftFeedbackResult, serializeFeedbackResult, writeFeedbackResult, writeSubmitFlag, } from '../editor/feedback.js';
|
|
9
|
+
// See $CRTR_CONTEXT_DIR/phase1-server-contract.md for the deck HTTP/WS API this
|
|
10
|
+
// module implements — this file is the reference implementation for the deck
|
|
11
|
+
// surface, and the review surface reuses the same static/WS skeleton.
|
|
12
|
+
const MIME = {
|
|
13
|
+
'.html': 'text/html; charset=utf-8',
|
|
14
|
+
'.js': 'text/javascript; charset=utf-8',
|
|
15
|
+
'.mjs': 'text/javascript; charset=utf-8',
|
|
16
|
+
'.css': 'text/css; charset=utf-8',
|
|
17
|
+
'.json': 'application/json; charset=utf-8',
|
|
18
|
+
'.svg': 'image/svg+xml',
|
|
19
|
+
'.png': 'image/png',
|
|
20
|
+
'.ico': 'image/x-icon',
|
|
21
|
+
'.woff': 'font/woff',
|
|
22
|
+
'.woff2': 'font/woff2',
|
|
23
|
+
'.txt': 'text/plain; charset=utf-8',
|
|
24
|
+
};
|
|
25
|
+
// Compiled server.js lives at dist/browser/server.js; the Vite SPA bundle
|
|
26
|
+
// builds into dist/web (see web/vite.config.ts) — siblings under dist/, so
|
|
27
|
+
// this never collides with tsc's own output tree.
|
|
28
|
+
const STATIC_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', 'web');
|
|
29
|
+
function send(res, status, body, contentType) {
|
|
30
|
+
res.writeHead(status, { 'Content-Type': contentType, 'Content-Length': Buffer.byteLength(body) });
|
|
31
|
+
res.end(body);
|
|
32
|
+
}
|
|
33
|
+
function sendJson(res, status, value) {
|
|
34
|
+
send(res, status, JSON.stringify(value), 'application/json; charset=utf-8');
|
|
35
|
+
}
|
|
36
|
+
async function readRequestBody(req) {
|
|
37
|
+
const chunks = [];
|
|
38
|
+
for await (const chunk of req)
|
|
39
|
+
chunks.push(chunk);
|
|
40
|
+
return Buffer.concat(chunks).toString('utf8');
|
|
41
|
+
}
|
|
42
|
+
async function serveStatic(reqPath, res) {
|
|
43
|
+
// Strip query string + guard path traversal. Unknown paths (and `/`) fall
|
|
44
|
+
// back to index.html — there is no server-side routing, the SPA owns it.
|
|
45
|
+
const safePath = reqPath.split('?')[0].replace(/\.\.(\/|\\)/g, '');
|
|
46
|
+
let filePath = join(STATIC_ROOT, safePath === '/' ? '/index.html' : safePath);
|
|
47
|
+
if (!existsSync(filePath))
|
|
48
|
+
filePath = join(STATIC_ROOT, 'index.html');
|
|
49
|
+
try {
|
|
50
|
+
const body = await readFile(filePath);
|
|
51
|
+
const type = MIME[extname(filePath)] ?? 'application/octet-stream';
|
|
52
|
+
res.writeHead(200, { 'Content-Type': type, 'Content-Length': body.length });
|
|
53
|
+
res.end(body);
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
send(res, 404, 'humanloop: dist/web is missing — run `npm run build` (which builds the web/ SPA too) before opening the browser surface.', 'text/plain; charset=utf-8');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function wrongSurface(expected) {
|
|
60
|
+
return { error: 'wrong_surface', expected };
|
|
61
|
+
}
|
|
62
|
+
function createServerScaffold() {
|
|
63
|
+
let handleRequest = async () => { };
|
|
64
|
+
const httpServer = createServer((req, res) => {
|
|
65
|
+
void handleRequest(req, res);
|
|
66
|
+
});
|
|
67
|
+
const wss = new WebSocketServer({ server: httpServer, path: '/ws' });
|
|
68
|
+
const sockets = new Set();
|
|
69
|
+
wss.on('connection', (ws) => {
|
|
70
|
+
sockets.add(ws);
|
|
71
|
+
ws.on('close', () => sockets.delete(ws));
|
|
72
|
+
});
|
|
73
|
+
let stopped = false;
|
|
74
|
+
// Resolves once every currently-open socket's `ws.send` has actually
|
|
75
|
+
// flushed its payload to the OS socket buffer — NOT once the browser has
|
|
76
|
+
// received/processed it. Callers that follow a broadcast with a teardown
|
|
77
|
+
// (stop()/closeAllConnections()) must await this first, or the terminate
|
|
78
|
+
// can race the send and the frame never leaves the process.
|
|
79
|
+
function broadcast(message) {
|
|
80
|
+
const payload = JSON.stringify(message);
|
|
81
|
+
const flushed = [];
|
|
82
|
+
for (const ws of sockets) {
|
|
83
|
+
if (ws.readyState !== ws.OPEN)
|
|
84
|
+
continue;
|
|
85
|
+
flushed.push(new Promise((resolveSend) => { ws.send(payload, () => resolveSend()); }));
|
|
86
|
+
}
|
|
87
|
+
return Promise.all(flushed).then(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
function setHandler(next) {
|
|
90
|
+
handleRequest = next;
|
|
91
|
+
}
|
|
92
|
+
function stop() {
|
|
93
|
+
if (stopped)
|
|
94
|
+
return Promise.resolve();
|
|
95
|
+
stopped = true;
|
|
96
|
+
return new Promise((resolveStop) => {
|
|
97
|
+
for (const ws of sockets)
|
|
98
|
+
ws.terminate();
|
|
99
|
+
wss.close();
|
|
100
|
+
// Ephemeral, single-purpose server: force-close any lingering keep-alive
|
|
101
|
+
// sockets rather than waiting on http.Server#close's default drain-first
|
|
102
|
+
// behavior.
|
|
103
|
+
httpServer.closeAllConnections();
|
|
104
|
+
httpServer.close(() => resolveStop());
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
return { httpServer, broadcast, setHandler, stop, sockets };
|
|
108
|
+
}
|
|
109
|
+
async function listen(server) {
|
|
110
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
111
|
+
server.once('error', rejectListen);
|
|
112
|
+
server.listen(0, '127.0.0.1', () => resolveListen());
|
|
113
|
+
});
|
|
114
|
+
const address = server.address();
|
|
115
|
+
return typeof address === 'object' && address !== null ? address.port : 0;
|
|
116
|
+
}
|
|
117
|
+
async function startDeckServer(opts) {
|
|
118
|
+
let deck = opts.deck;
|
|
119
|
+
const dir = opts.dir;
|
|
120
|
+
let submitted = null;
|
|
121
|
+
const scaffold = createServerScaffold();
|
|
122
|
+
scaffold.setHandler(async (req, res) => {
|
|
123
|
+
const url = req.url ?? '/';
|
|
124
|
+
if (url === '/api/surface' && req.method === 'GET') {
|
|
125
|
+
sendJson(res, 200, { kind: 'deck' });
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (url === '/api/interaction' && req.method === 'GET') {
|
|
129
|
+
// Re-read deck.json in case an agent rewrote it mid-handoff (mirrors
|
|
130
|
+
// the terminal deck's own live-reload poll in resolveInteractionDir).
|
|
131
|
+
// Best-effort — falls back to the in-memory deck if the read fails.
|
|
132
|
+
const onDisk = readJson(deckPath(dir));
|
|
133
|
+
if (onDisk !== null)
|
|
134
|
+
deck = onDisk;
|
|
135
|
+
sendJson(res, 200, { dir, deck });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (url === '/api/submit' && req.method === 'POST') {
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(await readRequestBody(req));
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
sendJson(res, 400, { error: 'bad_json', message: 'Request body is not valid JSON.' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (!Array.isArray(parsed.responses)) {
|
|
148
|
+
sendJson(res, 400, { error: 'bad_input', message: 'responses must be an array.' });
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// Single-assignment: the first accepted submit wins. A later submit
|
|
152
|
+
// (double-click, a second open tab, a retry) never re-writes
|
|
153
|
+
// response.json or re-fires onSubmit — it gets the same canonical
|
|
154
|
+
// result back via 409.
|
|
155
|
+
if (submitted !== null) {
|
|
156
|
+
sendJson(res, 409, { ok: false, error: 'already_submitted', ...submitted });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const responses = parsed.responses;
|
|
160
|
+
const completedAt = new Date().toISOString();
|
|
161
|
+
// Canonical write — the exact helper the terminal path uses, so nothing
|
|
162
|
+
// downstream of response.json can tell which surface produced it.
|
|
163
|
+
const responsePath = writeResponse(dir, responses, completedAt, deck);
|
|
164
|
+
submitted = { responsePath, completedAt };
|
|
165
|
+
clearProgress(dir);
|
|
166
|
+
// Await the flush BEFORE registering the finish callback that can
|
|
167
|
+
// trigger caller-side stop() — that ordering is what closes the race
|
|
168
|
+
// between a lost WS frame and the teardown it precedes.
|
|
169
|
+
await scaffold.broadcast({ type: 'converged' });
|
|
170
|
+
// Ack-ordering guarantee: the HTTP caller must always receive its 200
|
|
171
|
+
// body before any lifecycle teardown can close its socket.
|
|
172
|
+
res.once('finish', () => {
|
|
173
|
+
opts.onSubmit?.(responses, completedAt, responsePath);
|
|
174
|
+
});
|
|
175
|
+
sendJson(res, 200, { ok: true, responsePath, completedAt });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (url.startsWith('/api/review')) {
|
|
179
|
+
sendJson(res, 404, wrongSurface('deck'));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
await serveStatic(url, res);
|
|
183
|
+
});
|
|
184
|
+
const port = await listen(scaffold.httpServer);
|
|
185
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
186
|
+
return {
|
|
187
|
+
url,
|
|
188
|
+
port,
|
|
189
|
+
// No-op: a deck server is only ever started post-handoff (enterHandoff in
|
|
190
|
+
// tui/app.ts), so it has no separate activation moment to gate.
|
|
191
|
+
activate() { },
|
|
192
|
+
async requestTakeBack() {
|
|
193
|
+
await scaffold.broadcast({ type: 'taken-back' });
|
|
194
|
+
},
|
|
195
|
+
stop() {
|
|
196
|
+
return scaffold.stop();
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
async function startReviewServer(opts) {
|
|
201
|
+
const { jobDir, file, output, submitFlagPath } = opts;
|
|
202
|
+
const scaffold = createServerScaffold();
|
|
203
|
+
let activated = false;
|
|
204
|
+
let version = 0;
|
|
205
|
+
let ackWaiter = null;
|
|
206
|
+
const TAKE_BACK_ACK_TIMEOUT_MS = opts.takeBackAckTimeoutMs ?? 2000;
|
|
207
|
+
let currentResult = readStoredDraftFeedbackResult(output, file)
|
|
208
|
+
?? buildDraftFeedbackResult(file, [], new Date().toISOString());
|
|
209
|
+
function refreshInitialDraftFromDisk() {
|
|
210
|
+
if (version !== 0 || currentResult.submitted)
|
|
211
|
+
return;
|
|
212
|
+
const onDisk = readStoredDraftFeedbackResult(output, file);
|
|
213
|
+
if (onDisk === null)
|
|
214
|
+
return;
|
|
215
|
+
if (serializeFeedbackResult(onDisk) !== serializeFeedbackResult(currentResult)) {
|
|
216
|
+
currentResult = onDisk;
|
|
217
|
+
version += 1;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
function currentSnapshot() {
|
|
221
|
+
return currentResult;
|
|
222
|
+
}
|
|
223
|
+
// Bounded wait for `expected` open tabs to POST /api/review/take-back-ack
|
|
224
|
+
// after a take-back-requested broadcast. Resolves early once every tab has
|
|
225
|
+
// acked, or after `timeoutMs` regardless — a tab that never acks (closed,
|
|
226
|
+
// network hiccup) can't hang take-back forever.
|
|
227
|
+
function waitForTakeBackAcks(expected, timeoutMs) {
|
|
228
|
+
if (expected <= 0)
|
|
229
|
+
return Promise.resolve();
|
|
230
|
+
return new Promise((resolveWait) => {
|
|
231
|
+
let settled = false;
|
|
232
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
233
|
+
function finish() {
|
|
234
|
+
if (settled)
|
|
235
|
+
return;
|
|
236
|
+
settled = true;
|
|
237
|
+
clearTimeout(timer);
|
|
238
|
+
ackWaiter = null;
|
|
239
|
+
resolveWait();
|
|
240
|
+
}
|
|
241
|
+
ackWaiter = { expected, count: 0, resolve: finish };
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
scaffold.setHandler(async (req, res) => {
|
|
245
|
+
const url = req.url ?? '/';
|
|
246
|
+
if (url === '/api/surface' && req.method === 'GET') {
|
|
247
|
+
sendJson(res, 200, { kind: 'review' });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
if (url === '/api/review' && req.method === 'GET') {
|
|
251
|
+
refreshInitialDraftFromDisk();
|
|
252
|
+
let content;
|
|
253
|
+
try {
|
|
254
|
+
content = await readFile(file, 'utf8');
|
|
255
|
+
}
|
|
256
|
+
catch (err) {
|
|
257
|
+
sendJson(res, 404, {
|
|
258
|
+
error: 'source_not_found',
|
|
259
|
+
message: err instanceof Error ? err.message : String(err),
|
|
260
|
+
});
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
sendJson(res, 200, {
|
|
264
|
+
kind: 'review',
|
|
265
|
+
file,
|
|
266
|
+
output,
|
|
267
|
+
jobId: basename(jobDir),
|
|
268
|
+
content,
|
|
269
|
+
result: currentSnapshot(),
|
|
270
|
+
version,
|
|
271
|
+
activated,
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
if (url === '/api/review/take-back-ack' && req.method === 'POST') {
|
|
276
|
+
// No activation gate needed — acking a flush request is safe regardless
|
|
277
|
+
// of handoff state.
|
|
278
|
+
if (ackWaiter !== null) {
|
|
279
|
+
ackWaiter.count += 1;
|
|
280
|
+
if (ackWaiter.count >= ackWaiter.expected)
|
|
281
|
+
ackWaiter.resolve();
|
|
282
|
+
}
|
|
283
|
+
sendJson(res, 200, { ok: true });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
if (url === '/api/review/draft' && req.method === 'PUT') {
|
|
287
|
+
let parsed;
|
|
288
|
+
try {
|
|
289
|
+
parsed = JSON.parse(await readRequestBody(req));
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
sendJson(res, 400, { error: 'bad_json', message: 'Request body is not valid JSON.' });
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (!activated) {
|
|
296
|
+
sendJson(res, 409, { error: 'not_handed_off', message: 'Review authority has not been handed off to the browser yet.' });
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
refreshInitialDraftFromDisk();
|
|
300
|
+
if (currentResult.submitted) {
|
|
301
|
+
sendJson(res, 409, { error: 'already_submitted', result: currentSnapshot() });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (!Number.isInteger(parsed.baseVersion)) {
|
|
305
|
+
sendJson(res, 400, { error: 'bad_input', message: 'baseVersion must be an integer.' });
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
if (parsed.baseVersion !== version) {
|
|
309
|
+
sendJson(res, 409, { error: 'stale_draft', version, result: currentSnapshot() });
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
const parsedComments = parseFeedbackComments(parsed.comments);
|
|
313
|
+
if (!parsedComments.ok) {
|
|
314
|
+
sendJson(res, 400, { error: 'bad_input', message: parsedComments.message });
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const savedAt = new Date().toISOString();
|
|
318
|
+
const nextResult = buildDraftFeedbackResult(file, parsedComments.comments, savedAt);
|
|
319
|
+
try {
|
|
320
|
+
writeFeedbackResult(output, nextResult);
|
|
321
|
+
}
|
|
322
|
+
catch (err) {
|
|
323
|
+
sendJson(res, 500, { error: 'internal', message: err instanceof Error ? err.message : String(err) });
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
currentResult = nextResult;
|
|
327
|
+
version += 1;
|
|
328
|
+
await scaffold.broadcast({ type: 'review-draft-updated', version, savedAt });
|
|
329
|
+
sendJson(res, 200, { ok: true, result: currentSnapshot(), version });
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (url === '/api/review/submit' && req.method === 'POST') {
|
|
333
|
+
let parsed;
|
|
334
|
+
try {
|
|
335
|
+
parsed = JSON.parse(await readRequestBody(req));
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
sendJson(res, 400, { error: 'bad_json', message: 'Request body is not valid JSON.' });
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (!activated) {
|
|
342
|
+
sendJson(res, 409, { error: 'not_handed_off', message: 'Review authority has not been handed off to the browser yet.' });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
refreshInitialDraftFromDisk();
|
|
346
|
+
if (currentResult.submitted) {
|
|
347
|
+
sendJson(res, 409, {
|
|
348
|
+
ok: false,
|
|
349
|
+
error: 'already_submitted',
|
|
350
|
+
output,
|
|
351
|
+
submittedAt: currentResult.submittedAt,
|
|
352
|
+
result: currentSnapshot(),
|
|
353
|
+
});
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
if (!Number.isInteger(parsed.baseVersion)) {
|
|
357
|
+
sendJson(res, 400, { error: 'bad_input', message: 'baseVersion must be an integer.' });
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
if (parsed.baseVersion !== version) {
|
|
361
|
+
sendJson(res, 409, { error: 'stale_draft', version, result: currentSnapshot() });
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const parsedComments = parseFeedbackComments(parsed.comments);
|
|
365
|
+
if (!parsedComments.ok) {
|
|
366
|
+
sendJson(res, 400, { error: 'bad_input', message: parsedComments.message });
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
const submittedAt = new Date().toISOString();
|
|
370
|
+
const nextResult = buildFinalFeedbackResult(file, parsedComments.comments, submittedAt);
|
|
371
|
+
try {
|
|
372
|
+
writeFeedbackResult(output, nextResult);
|
|
373
|
+
currentResult = nextResult;
|
|
374
|
+
if (submitFlagPath !== undefined && submitFlagPath.length > 0) {
|
|
375
|
+
writeSubmitFlag(submitFlagPath);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
sendJson(res, 500, { error: 'internal', message: err instanceof Error ? err.message : String(err) });
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
// Await the flush BEFORE registering the finish callback that can
|
|
383
|
+
// trigger caller-side stop() — that ordering is what closes the race
|
|
384
|
+
// between a lost WS frame and the teardown it precedes.
|
|
385
|
+
await scaffold.broadcast({ type: 'converged' });
|
|
386
|
+
res.once('finish', () => {
|
|
387
|
+
opts.onSubmit?.(currentSnapshot(), submittedAt, output);
|
|
388
|
+
});
|
|
389
|
+
sendJson(res, 200, { ok: true, output, submittedAt, result: currentSnapshot() });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (url.startsWith('/api/interaction') || url === '/api/submit') {
|
|
393
|
+
sendJson(res, 404, wrongSurface('review'));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
await serveStatic(url, res);
|
|
397
|
+
});
|
|
398
|
+
const port = await listen(scaffold.httpServer);
|
|
399
|
+
const url = `http://127.0.0.1:${port}/`;
|
|
400
|
+
return {
|
|
401
|
+
url,
|
|
402
|
+
port,
|
|
403
|
+
activate() {
|
|
404
|
+
activated = true;
|
|
405
|
+
},
|
|
406
|
+
async requestTakeBack() {
|
|
407
|
+
const targets = [...scaffold.sockets].filter((ws) => ws.readyState === ws.OPEN);
|
|
408
|
+
if (targets.length > 0) {
|
|
409
|
+
// Arm the ack waiter BEFORE broadcasting take-back-requested — a very
|
|
410
|
+
// fast browser could otherwise flush its dirty draft and POST
|
|
411
|
+
// /api/review/take-back-ack while the broadcast's own ws.send flush
|
|
412
|
+
// callbacks are still resolving. Arming after the broadcast (the old
|
|
413
|
+
// order) drops that ack on the floor (ackWaiter was still null when
|
|
414
|
+
// it arrived), forcing the full timeout even though the flush
|
|
415
|
+
// already succeeded. Starting the waiter first, then awaiting both
|
|
416
|
+
// the broadcast flush and the waiter together, closes that window.
|
|
417
|
+
const acked = waitForTakeBackAcks(targets.length, TAKE_BACK_ACK_TIMEOUT_MS);
|
|
418
|
+
await Promise.all([scaffold.broadcast({ type: 'take-back-requested' }), acked]);
|
|
419
|
+
}
|
|
420
|
+
await scaffold.broadcast({ type: 'taken-back' });
|
|
421
|
+
},
|
|
422
|
+
stop() {
|
|
423
|
+
return scaffold.stop();
|
|
424
|
+
},
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Start an on-demand local HTTP+WS server over one interaction dir. Binds to
|
|
429
|
+
* an ephemeral port on 127.0.0.1 only (never 0.0.0.0 — this is a same-machine
|
|
430
|
+
* handoff, not a shareable link). The caller owns the lifecycle: start it
|
|
431
|
+
* when the human hands off to the browser, `stop()` it on take-back or once
|
|
432
|
+
* `onSubmit` fires.
|
|
433
|
+
*/
|
|
434
|
+
export async function startWebServer(opts) {
|
|
435
|
+
return startDeckServer(opts);
|
|
436
|
+
}
|
|
437
|
+
/** Start a review-mode browser server over one markdown review job. */
|
|
438
|
+
export async function startReviewWebServer(opts) {
|
|
439
|
+
return startReviewServer(opts);
|
|
440
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -6,13 +6,15 @@ import { tmpdir } from 'node:os';
|
|
|
6
6
|
import { resolve, join, basename } from 'node:path';
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { launchReview, reviewVimscript } from './editor/review.js';
|
|
9
|
-
import {
|
|
9
|
+
import { writeFeedbackResult } from './editor/feedback.js';
|
|
10
|
+
import { validateDeck, resolveDeckBodyPaths } from './inbox/deck-schema.js';
|
|
10
11
|
import { ask, inbox } from './api.js';
|
|
11
12
|
import { display } from './surfaces/display.js';
|
|
12
13
|
import { renderMarkdown, checkMarkdown } from './render/termrender.js';
|
|
13
14
|
import { scanInbox } from './inbox/scan.js';
|
|
14
15
|
import { deckPath, atomicWriteJson, readJson, responsePath, stampCanvasNode, } from './inbox/convention.js';
|
|
15
16
|
import { buildSummary } from './summary.js';
|
|
17
|
+
import { INTERACTION_KINDS } from './types.js';
|
|
16
18
|
// ── Version ───────────────────────────────────────────────────────────────────
|
|
17
19
|
const HL_VERSION = '0.2.1';
|
|
18
20
|
function emitError(err, exitCode = 1) {
|
|
@@ -218,7 +220,7 @@ const REQUEST_SCHEMA = {
|
|
|
218
220
|
},
|
|
219
221
|
allowFreetext: { type: 'boolean' },
|
|
220
222
|
freetextLabel: { type: 'string' },
|
|
221
|
-
kind: { type: 'string', enum: [
|
|
223
|
+
kind: { type: 'string', enum: [...INTERACTION_KINDS] },
|
|
222
224
|
},
|
|
223
225
|
},
|
|
224
226
|
},
|
|
@@ -372,6 +374,19 @@ deckCmd
|
|
|
372
374
|
}
|
|
373
375
|
const dir = input.dir ? resolve(input.dir) : mkdtempSync(join(tmpdir(), 'hl-ix-'));
|
|
374
376
|
mkdirSync(dir, { recursive: true });
|
|
377
|
+
// Canonical bodyPath → body normalization boundary (deck-schema.ts) —
|
|
378
|
+
// resolved before deck.json is ever written, so terminal render, the
|
|
379
|
+
// live-reload poller, and the browser server all just see `body`.
|
|
380
|
+
try {
|
|
381
|
+
deck = resolveDeckBodyPaths(deck, dir);
|
|
382
|
+
}
|
|
383
|
+
catch (bodyErr) {
|
|
384
|
+
emitError({
|
|
385
|
+
error: 'deck_invalid',
|
|
386
|
+
message: bodyErr instanceof Error ? bodyErr.message : String(bodyErr),
|
|
387
|
+
next: 'Fix bodyPath (must point at a real file inside the interaction dir) and retry.',
|
|
388
|
+
});
|
|
389
|
+
}
|
|
375
390
|
stampCanvasNode(deck);
|
|
376
391
|
atomicWriteJson(deckPath(dir), deck);
|
|
377
392
|
const jobId = basename(dir);
|
|
@@ -510,6 +525,17 @@ deckCmd
|
|
|
510
525
|
next: "The live deck is unchanged. Fix the deck, then: echo '{\"deck\":{...}}' | hl deck validate",
|
|
511
526
|
});
|
|
512
527
|
}
|
|
528
|
+
// Same canonical bodyPath → body normalization boundary as `deck ask`.
|
|
529
|
+
try {
|
|
530
|
+
deck = resolveDeckBodyPaths(deck, dir);
|
|
531
|
+
}
|
|
532
|
+
catch (bodyErr) {
|
|
533
|
+
emitError({
|
|
534
|
+
error: 'deck_invalid',
|
|
535
|
+
message: bodyErr instanceof Error ? bodyErr.message : String(bodyErr),
|
|
536
|
+
next: 'The live deck is unchanged. Fix bodyPath (must point at a real file inside the interaction dir) and retry.',
|
|
537
|
+
});
|
|
538
|
+
}
|
|
513
539
|
atomicWriteJson(deckPath(dir), deck);
|
|
514
540
|
appendJobLog(dir, {
|
|
515
541
|
level: 'info', event: 'deck_updated',
|
|
@@ -575,7 +601,9 @@ reviewCmd
|
|
|
575
601
|
' harness notifies you when it returns with the result.\n')
|
|
576
602
|
.helpOption('-h, --help', 'Show help')
|
|
577
603
|
.action(async () => {
|
|
578
|
-
const input =
|
|
604
|
+
const input = process.env.HL_REVIEW_OPEN_INPUT
|
|
605
|
+
? JSON.parse(process.env.HL_REVIEW_OPEN_INPUT)
|
|
606
|
+
: parseStdinJson();
|
|
579
607
|
if (!input.file || typeof input.file !== 'string') {
|
|
580
608
|
emitError({ error: 'bad_input', message: 'file is required', field: 'file', next: 'Provide: {"file": "/path/to/doc.md"}' });
|
|
581
609
|
}
|
|
@@ -606,7 +634,7 @@ reviewCmd
|
|
|
606
634
|
}
|
|
607
635
|
const sq = (s) => /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
608
636
|
const childInput = JSON.stringify({ file: absFile, output, editor: input.editor ?? null, tmux: false, dir: jobDir });
|
|
609
|
-
const cmd = `
|
|
637
|
+
const cmd = `HL_REVIEW_OPEN_INPUT=${sq(childInput)} ${sq(process.execPath)} ${sq(scriptPath)} review open`;
|
|
610
638
|
try {
|
|
611
639
|
execFileSync('tmux', ['split-window', '-d', '-h', cmd], { stdio: 'ignore' });
|
|
612
640
|
}
|
|
@@ -623,15 +651,18 @@ reviewCmd
|
|
|
623
651
|
await new Promise((resolvePromise) => {
|
|
624
652
|
const poll = setInterval(() => {
|
|
625
653
|
const fp = join(jobDir, 'feedback.json');
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
process.exit(0);
|
|
632
|
-
}
|
|
654
|
+
const result = existsSync(fp) ? tryParseJson(readFileSync(fp, 'utf8')) : null;
|
|
655
|
+
if (result !== null && result.submitted === true) {
|
|
656
|
+
clearInterval(poll);
|
|
657
|
+
process.stdout.write(JSON.stringify({ job_id: jobId, output, status: 'done', result }) + '\n');
|
|
658
|
+
process.exit(0);
|
|
633
659
|
}
|
|
634
660
|
const state = detectJobState(jobDir);
|
|
661
|
+
if (state === 'done' && result !== null) {
|
|
662
|
+
clearInterval(poll);
|
|
663
|
+
process.stdout.write(JSON.stringify({ job_id: jobId, output, status: 'done', result }) + '\n');
|
|
664
|
+
process.exit(0);
|
|
665
|
+
}
|
|
635
666
|
if (state === 'failed' || state === 'canceled') {
|
|
636
667
|
clearInterval(poll);
|
|
637
668
|
process.stdout.write(JSON.stringify({ job_id: jobId, output, status: state }) + '\n');
|
|
@@ -652,7 +683,7 @@ reviewCmd
|
|
|
652
683
|
jobDir,
|
|
653
684
|
});
|
|
654
685
|
appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'review finished', data: { comments: result.comments.length } });
|
|
655
|
-
|
|
686
|
+
writeFeedbackResult(join(jobDir, 'feedback.json'), result);
|
|
656
687
|
process.stdout.write(JSON.stringify({
|
|
657
688
|
job_id: jobId,
|
|
658
689
|
output,
|