@crouton-kit/humanloop 0.3.33 → 0.3.34
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.d.ts +4 -8
- package/dist/api.js +6 -29
- package/dist/browser/server.d.ts +3 -3
- package/dist/browser/server.js +22 -25
- package/dist/cli.js +126 -1114
- package/dist/editor/feedback.d.ts +9 -0
- package/dist/editor/feedback.js +23 -0
- package/dist/editor/review.d.ts +8 -30
- package/dist/editor/review.js +79 -118
- package/dist/inbox/claim.d.ts +23 -0
- package/dist/inbox/claim.js +67 -0
- package/dist/inbox/completion.d.ts +4 -0
- package/dist/inbox/completion.js +100 -0
- package/dist/inbox/controller.d.ts +57 -0
- package/dist/inbox/controller.js +311 -0
- package/dist/inbox/convention.d.ts +15 -10
- package/dist/inbox/convention.js +149 -79
- package/dist/inbox/deck-adapter.d.ts +27 -0
- package/dist/inbox/deck-adapter.js +57 -0
- package/dist/inbox/deck-schema.d.ts +18 -14
- package/dist/inbox/deck-schema.js +59 -117
- package/dist/inbox/layout.d.ts +8 -0
- package/dist/inbox/layout.js +9 -0
- package/dist/inbox/registry.d.ts +29 -0
- package/dist/inbox/registry.js +97 -0
- package/dist/inbox/review-adapter.d.ts +22 -0
- package/dist/inbox/review-adapter.js +56 -0
- package/dist/inbox/scan.d.ts +3 -2
- package/dist/inbox/scan.js +71 -43
- package/dist/inbox/tickets.d.ts +63 -0
- package/dist/inbox/tickets.js +247 -0
- package/dist/inbox/tui.d.ts +3 -6
- package/dist/inbox/tui.js +24 -112
- package/dist/index.d.ts +12 -3
- package/dist/index.js +8 -2
- package/dist/summary.d.ts +2 -3
- package/dist/summary.js +2 -3
- package/dist/surfaces/inbox-popup.d.ts +2 -0
- package/dist/surfaces/inbox-popup.js +32 -0
- package/dist/tui/app.js +13 -0
- package/dist/tui/tmux.d.ts +30 -7
- package/dist/tui/tmux.js +179 -66
- package/dist/types.d.ts +68 -7
- package/package.json +2 -2
|
@@ -15,4 +15,13 @@ export declare function serializeFeedbackResult(result: FeedbackResult): string;
|
|
|
15
15
|
export declare function writeFeedbackResult(path: string, result: FeedbackResult): string;
|
|
16
16
|
export declare function writeDraftFeedbackResult(path: string, file: string, comments: FeedbackComment[], savedAt?: string): FeedbackResult;
|
|
17
17
|
export declare function writeFinalFeedbackResult(path: string, file: string, comments: FeedbackComment[], timestamp?: string): FeedbackResult;
|
|
18
|
+
export interface ReviewDraft {
|
|
19
|
+
kind: 'review';
|
|
20
|
+
comments: FeedbackComment[];
|
|
21
|
+
savedAt: string;
|
|
22
|
+
version: number;
|
|
23
|
+
}
|
|
24
|
+
/** Durable, resumable review work. Canonical feedback is produced only by the ticket finalizer. */
|
|
25
|
+
export declare function readReviewDraft(path: string): ReviewDraft | null;
|
|
26
|
+
export declare function writeReviewDraft(path: string, comments: FeedbackComment[], version: number, savedAt?: string): ReviewDraft;
|
|
18
27
|
export declare function writeSubmitFlag(path: string): void;
|
package/dist/editor/feedback.js
CHANGED
|
@@ -198,6 +198,29 @@ export function writeFinalFeedbackResult(path, file, comments, timestamp = nowIs
|
|
|
198
198
|
writeFeedbackResult(path, result);
|
|
199
199
|
return result;
|
|
200
200
|
}
|
|
201
|
+
/** Durable, resumable review work. Canonical feedback is produced only by the ticket finalizer. */
|
|
202
|
+
export function readReviewDraft(path) {
|
|
203
|
+
if (!existsSync(path))
|
|
204
|
+
return null;
|
|
205
|
+
let raw;
|
|
206
|
+
try {
|
|
207
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
if (!isRecord(raw) || raw.kind !== 'review' || !Number.isInteger(raw.version) || raw.version < 0 || typeof raw.savedAt !== 'string')
|
|
213
|
+
return null;
|
|
214
|
+
return { kind: 'review', comments: sanitizeFeedbackComments(raw.comments), savedAt: raw.savedAt, version: raw.version };
|
|
215
|
+
}
|
|
216
|
+
export function writeReviewDraft(path, comments, version, savedAt = nowIso()) {
|
|
217
|
+
const draft = { kind: 'review', comments: sanitizeFeedbackComments(comments), savedAt, version };
|
|
218
|
+
const tmp = `${path}.tmp`;
|
|
219
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
220
|
+
writeFileSync(tmp, `${JSON.stringify(draft, null, 2)}\n`);
|
|
221
|
+
renameSync(tmp, path);
|
|
222
|
+
return draft;
|
|
223
|
+
}
|
|
201
224
|
export function writeSubmitFlag(path) {
|
|
202
225
|
mkdirSync(dirname(path), { recursive: true });
|
|
203
226
|
writeFileSync(path, '');
|
package/dist/editor/review.d.ts
CHANGED
|
@@ -1,35 +1,14 @@
|
|
|
1
1
|
import type { FeedbackResult } from '../types.js';
|
|
2
2
|
export interface ReviewOptions {
|
|
3
|
-
/** Where the
|
|
3
|
+
/** Where the resumable review draft is written. */
|
|
4
4
|
output: string;
|
|
5
5
|
/** Editor binary override. Default: first of nvim, vim on PATH. */
|
|
6
6
|
editor?: string;
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
* When omitted, any editor exit finalizes `submitted: true` (legacy behavior).
|
|
13
|
-
*/
|
|
14
|
-
submitFlagPath?: string;
|
|
15
|
-
/**
|
|
16
|
-
* When true and $TMUX is set and !opts.noTmux, launch via
|
|
17
|
-
* `tmux display-popup -E` instead of `tmux split-window`.
|
|
18
|
-
* display-popup -E is synchronous so no poll loop is needed.
|
|
19
|
-
*/
|
|
20
|
-
tmuxPopup?: boolean;
|
|
21
|
-
/** Override the default 90%/90% popup size. Only used when tmuxPopup is true. */
|
|
22
|
-
popupSize?: {
|
|
23
|
-
w: string;
|
|
24
|
-
h: string;
|
|
25
|
-
};
|
|
26
|
-
/**
|
|
27
|
-
* Reuse this directory for `review.vim` instead of minting a fresh temp dir.
|
|
28
|
-
* The async `review open` kickoff passes its job dir so the detached child
|
|
29
|
-
* sources the same vimscript the parent already wrote there (which also lets
|
|
30
|
-
* the job be recognized as a review while it's still live). When the file
|
|
31
|
-
* already exists it is left untouched.
|
|
32
|
-
*/
|
|
7
|
+
/** Called exactly once when the human explicitly submits a final proposal. */
|
|
8
|
+
onPropose?: (result: FeedbackResult) => Promise<void> | void;
|
|
9
|
+
/** Controller cancellation closes the editor/browser handoff without proposing a result. */
|
|
10
|
+
signal?: AbortSignal;
|
|
11
|
+
/** Reuse this controller-owned directory for `review.vim`; an existing script is left untouched. */
|
|
33
12
|
jobDir?: string;
|
|
34
13
|
}
|
|
35
14
|
export declare function reviewVimscript(): string;
|
|
@@ -43,8 +22,7 @@ export declare function formatFeedbackSummary(result: FeedbackResult, feedbackJs
|
|
|
43
22
|
/**
|
|
44
23
|
* Open a markdown file in a clean, read-only Neovim/Vim review session. The
|
|
45
24
|
* human anchors comments to source lines/selections with native vim motions
|
|
46
|
-
* and
|
|
47
|
-
*
|
|
48
|
-
* the next run resumes.
|
|
25
|
+
* and explicitly submits a proposal. Blocks until the editor exits. Autosaved
|
|
26
|
+
* drafts survive exit; canonical ticket finalization belongs to the controller.
|
|
49
27
|
*/
|
|
50
28
|
export declare function launchReview(file: string, opts: ReviewOptions): Promise<FeedbackResult>;
|
package/dist/editor/review.js
CHANGED
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
import { spawn, spawnSync
|
|
1
|
+
import { spawn, spawnSync } from 'child_process';
|
|
2
2
|
import { existsSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
3
3
|
import { tmpdir } from 'os';
|
|
4
4
|
import { resolve, join } from 'path';
|
|
5
5
|
import { openBrowser } from '../browser/open.js';
|
|
6
6
|
import { startReviewWebServer } from '../browser/server.js';
|
|
7
|
-
import {
|
|
8
|
-
function shellQuote(s) {
|
|
9
|
-
if (s.length > 0 && /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s))
|
|
10
|
-
return s;
|
|
11
|
-
return `'${s.replace(/'/g, `'\\''`)}'`;
|
|
12
|
-
}
|
|
7
|
+
import { buildFinalFeedbackResult, readReviewDraft, } from './feedback.js';
|
|
13
8
|
function resolveEditor(override) {
|
|
14
9
|
const candidates = override ? [override] : ['nvim', 'vim'];
|
|
15
10
|
for (const bin of candidates) {
|
|
@@ -41,6 +36,7 @@ export function reviewVimscript() {
|
|
|
41
36
|
`let g:hl_review_url = $HL_REVIEW_URL`,
|
|
42
37
|
`let g:hl_handoff_flag = $HL_HANDOFF_FLAG`,
|
|
43
38
|
`let s:comments = []`,
|
|
39
|
+
`let s:version = 0`,
|
|
44
40
|
`let s:idseq = 0`,
|
|
45
41
|
`let s:ns = exists('*nvim_create_namespace') ? nvim_create_namespace('hl_review') : -1`,
|
|
46
42
|
``,
|
|
@@ -50,15 +46,17 @@ export function reviewVimscript() {
|
|
|
50
46
|
` endif`,
|
|
51
47
|
` try`,
|
|
52
48
|
` let l:obj = json_decode(join(readfile(g:hl_out), "\\n"))`,
|
|
53
|
-
` if type(l:obj) == type({}) && get(l:obj,'
|
|
49
|
+
` if type(l:obj) == type({}) && get(l:obj,'kind','') ==# 'review'`,
|
|
54
50
|
` let s:comments = get(l:obj,'comments',[])`,
|
|
51
|
+
` let s:version = get(l:obj,'version',0)`,
|
|
55
52
|
` endif`,
|
|
56
53
|
` catch`,
|
|
57
54
|
` endtry`,
|
|
58
55
|
`endfunction`,
|
|
59
56
|
``,
|
|
60
57
|
`function! s:Save() abort`,
|
|
61
|
-
` let
|
|
58
|
+
` let s:version += 1`,
|
|
59
|
+
` let l:obj = {'kind': 'review', 'comments': s:comments, 'savedAt': strftime('%Y-%m-%dT%H:%M:%S'), 'version': s:version}`,
|
|
62
60
|
` let l:tmp = g:hl_out . '.tmp'`,
|
|
63
61
|
` call writefile([json_encode(l:obj)], l:tmp)`,
|
|
64
62
|
` call rename(l:tmp, g:hl_out)`,
|
|
@@ -428,7 +426,7 @@ export function reviewVimscript() {
|
|
|
428
426
|
``,
|
|
429
427
|
`" Watchmode: the source is opened read-only here but may be rewritten on`,
|
|
430
428
|
`" disk by the agent while the human reviews. autoread + a periodic checktime`,
|
|
431
|
-
`" (timers fire even when the
|
|
429
|
+
`" (timers fire even when the terminal is unfocused) reload the buffer`,
|
|
432
430
|
`" silently — it is never locally modified, so there is nothing to clobber.`,
|
|
433
431
|
`" On reload, re-run Setup (read-only guard, treesitter, maps) and redraw the`,
|
|
434
432
|
`" comment anchors, which the reload cleared. Comments live in s:comments /`,
|
|
@@ -496,78 +494,58 @@ export function formatFeedbackSummary(result, feedbackJsonPath) {
|
|
|
496
494
|
out.push(` schema: ${FEEDBACK_SCHEMA} · cols are 0-based byte offsets, colEnd exclusive`);
|
|
497
495
|
return out.join('\n');
|
|
498
496
|
}
|
|
499
|
-
|
|
500
|
-
// `paneCmd` MUST be an `exec`-prefixed command so the editor replaces the
|
|
501
|
-
// shell and becomes the pane's process. tmux's `#{pane_current_command}`
|
|
502
|
-
// then reports `nvim` — many users gate "let Ctrl-D/Ctrl-U/etc. through to
|
|
503
|
-
// the app vs. take over with tmux copy-mode" on exactly that. If the pane
|
|
504
|
-
// command were `nvim …; tmux wait-for` the pane process stays the shell,
|
|
505
|
-
// pane_current_command is `zsh`/`sh`, and those bindings hijack native vim
|
|
506
|
-
// keys. Because the editor is exec'd there is no "after" to signal from, so
|
|
507
|
-
// completion is detected purely by the pane disappearing on exit.
|
|
508
|
-
const paneId = execFileSync('tmux', ['split-window', '-h', '-d', '-P', '-F', '#{pane_id}', paneCmd], { encoding: 'utf8' }).trim();
|
|
509
|
-
// Ensure the pane closes when the editor exits (some configs set
|
|
510
|
-
// remain-on-exit globally, which would hang the poll below).
|
|
511
|
-
try {
|
|
512
|
-
execFileSync('tmux', ['set-option', '-p', '-t', paneId, 'remain-on-exit', 'off'], { stdio: 'ignore' });
|
|
513
|
-
}
|
|
514
|
-
catch {
|
|
515
|
-
// older tmux without -p pane scope — default is already 'off'
|
|
516
|
-
}
|
|
517
|
-
await new Promise((resolvePromise, rejectPromise) => {
|
|
518
|
-
let settled = false;
|
|
519
|
-
const finish = (fn) => { if (!settled) {
|
|
520
|
-
settled = true;
|
|
521
|
-
clearInterval(poll);
|
|
522
|
-
fn();
|
|
523
|
-
} };
|
|
524
|
-
// The editor IS the pane process; when it exits the pane is destroyed.
|
|
525
|
-
const poll = setInterval(() => {
|
|
526
|
-
try {
|
|
527
|
-
const panes = execFileSync('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], { encoding: 'utf8' });
|
|
528
|
-
if (!panes.split('\n').map((s) => s.trim()).includes(paneId)) {
|
|
529
|
-
finish(resolvePromise);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
catch (e) {
|
|
533
|
-
finish(() => rejectPromise(new Error(`tmux list-panes failed: ${e instanceof Error ? e.message : String(e)}`)));
|
|
534
|
-
}
|
|
535
|
-
}, 200);
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
function runInCurrentTerminal(bin, args, env) {
|
|
497
|
+
function runInCurrentTerminal(bin, args, env, signal) {
|
|
539
498
|
return new Promise((resolvePromise, rejectPromise) => {
|
|
540
499
|
const child = spawn(bin, args, { stdio: 'inherit', env });
|
|
541
|
-
|
|
542
|
-
|
|
500
|
+
let settled = false;
|
|
501
|
+
const cleanup = () => signal?.removeEventListener('abort', abort);
|
|
502
|
+
const finish = (error) => {
|
|
503
|
+
if (settled)
|
|
504
|
+
return;
|
|
505
|
+
settled = true;
|
|
506
|
+
cleanup();
|
|
507
|
+
if (error === undefined)
|
|
508
|
+
resolvePromise();
|
|
509
|
+
else
|
|
510
|
+
rejectPromise(error);
|
|
511
|
+
};
|
|
512
|
+
const abort = () => child.kill('SIGTERM');
|
|
513
|
+
child.once('error', finish);
|
|
514
|
+
child.once('exit', () => finish());
|
|
515
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
516
|
+
if (signal?.aborted)
|
|
517
|
+
abort();
|
|
543
518
|
});
|
|
544
519
|
}
|
|
545
520
|
function clearFlag(path) {
|
|
546
521
|
rmSync(path, { force: true });
|
|
547
522
|
}
|
|
548
|
-
function
|
|
549
|
-
|
|
550
|
-
return stored?.comments ?? [];
|
|
523
|
+
function proposedFeedback(path, absFile) {
|
|
524
|
+
return buildFinalFeedbackResult(absFile, readReviewDraft(path)?.comments ?? []);
|
|
551
525
|
}
|
|
552
|
-
function
|
|
553
|
-
const
|
|
554
|
-
return
|
|
555
|
-
|
|
556
|
-
:
|
|
526
|
+
function draftFeedback(path, absFile) {
|
|
527
|
+
const draft = readReviewDraft(path);
|
|
528
|
+
return {
|
|
529
|
+
file: absFile,
|
|
530
|
+
submitted: false,
|
|
531
|
+
approved: false,
|
|
532
|
+
comments: draft?.comments ?? [],
|
|
533
|
+
savedAt: draft?.savedAt ?? new Date().toISOString(),
|
|
534
|
+
};
|
|
557
535
|
}
|
|
558
|
-
async function waitForParkedReviewSubmit(submitted) {
|
|
536
|
+
async function waitForParkedReviewSubmit(submitted, signal) {
|
|
559
537
|
process.stderr.write('\nhumanloop: browser review handoff is active.\n' +
|
|
560
538
|
' The terminal editor is parked; the browser is the editing authority.\n' +
|
|
561
539
|
' Press w to take back into nvim, or Ctrl+C to exit with an unsubmitted draft.\n\n');
|
|
562
|
-
if (!process.stdin.isTTY) {
|
|
563
|
-
const result = await submitted;
|
|
564
|
-
return { type: 'submitted', result };
|
|
565
|
-
}
|
|
566
540
|
return new Promise((resolveAction) => {
|
|
567
541
|
const stdin = process.stdin;
|
|
542
|
+
const interactive = stdin.isTTY;
|
|
568
543
|
const wasRaw = stdin.isRaw;
|
|
569
544
|
let settled = false;
|
|
570
545
|
const cleanup = () => {
|
|
546
|
+
signal?.removeEventListener('abort', onAbort);
|
|
547
|
+
if (!interactive)
|
|
548
|
+
return;
|
|
571
549
|
stdin.off('data', onData);
|
|
572
550
|
if (stdin.setRawMode)
|
|
573
551
|
stdin.setRawMode(wasRaw);
|
|
@@ -580,6 +558,7 @@ async function waitForParkedReviewSubmit(submitted) {
|
|
|
580
558
|
cleanup();
|
|
581
559
|
resolveAction(action);
|
|
582
560
|
};
|
|
561
|
+
const onAbort = () => finish({ type: 'cancel' });
|
|
583
562
|
const onData = (chunk) => {
|
|
584
563
|
const text = chunk.toString('utf8');
|
|
585
564
|
if (text === 'w' || text === 'W')
|
|
@@ -587,9 +566,14 @@ async function waitForParkedReviewSubmit(submitted) {
|
|
|
587
566
|
if (text === '\u0003')
|
|
588
567
|
finish({ type: 'cancel' });
|
|
589
568
|
};
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
569
|
+
if (interactive) {
|
|
570
|
+
stdin.setRawMode(true);
|
|
571
|
+
stdin.resume();
|
|
572
|
+
stdin.on('data', onData);
|
|
573
|
+
}
|
|
574
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
575
|
+
if (signal?.aborted)
|
|
576
|
+
onAbort();
|
|
593
577
|
submitted.then((result) => finish({ type: 'submitted', result }), () => finish({ type: 'cancel' }));
|
|
594
578
|
});
|
|
595
579
|
}
|
|
@@ -600,9 +584,8 @@ async function stopReviewServer(handle) {
|
|
|
600
584
|
/**
|
|
601
585
|
* Open a markdown file in a clean, read-only Neovim/Vim review session. The
|
|
602
586
|
* human anchors comments to source lines/selections with native vim motions
|
|
603
|
-
* and
|
|
604
|
-
*
|
|
605
|
-
* the next run resumes.
|
|
587
|
+
* and explicitly submits a proposal. Blocks until the editor exits. Autosaved
|
|
588
|
+
* drafts survive exit; canonical ticket finalization belongs to the controller.
|
|
606
589
|
*/
|
|
607
590
|
export async function launchReview(file, opts) {
|
|
608
591
|
const absFile = resolve(file);
|
|
@@ -611,19 +594,18 @@ export async function launchReview(file, opts) {
|
|
|
611
594
|
}
|
|
612
595
|
const outPath = resolve(opts.output);
|
|
613
596
|
const bin = resolveEditor(opts.editor);
|
|
614
|
-
//
|
|
615
|
-
// already wrote there; otherwise mint a private temp dir as before.
|
|
597
|
+
// Reuse the controller-owned job directory when supplied; otherwise create a private directory.
|
|
616
598
|
const dir = opts.jobDir ?? mkdtempSync(join(tmpdir(), 'hl-review-'));
|
|
617
599
|
const initPath = join(dir, 'review.vim');
|
|
618
600
|
if (!existsSync(initPath))
|
|
619
601
|
writeFileSync(initPath, reviewVimscript());
|
|
620
602
|
const handoffFlagPath = join(dir, 'browser-handoff.flag');
|
|
603
|
+
const submitFlagPath = join(dir, 'review-submit.flag');
|
|
621
604
|
// `-u NONE`: do NOT load the user's init.lua / LazyVim / plugins / keymaps.
|
|
622
605
|
// Default runtimepath still includes the config dir (for the gloam
|
|
623
606
|
// colorscheme) and the site dir (for the treesitter markdown parser), so the
|
|
624
607
|
// review layer pulls in ONLY the colorscheme + treesitter styling itself.
|
|
625
608
|
const editorArgs = ['-u', 'NONE', '-n', '-i', 'NONE', absFile, '-c', `source ${initPath}`];
|
|
626
|
-
const inTmux = !!process.env.TMUX && !opts.noTmux;
|
|
627
609
|
async function runEditor(reviewUrl) {
|
|
628
610
|
const env = {
|
|
629
611
|
...process.env,
|
|
@@ -631,73 +613,47 @@ export async function launchReview(file, opts) {
|
|
|
631
613
|
HL_SOURCE: absFile,
|
|
632
614
|
HL_REVIEW_URL: reviewUrl,
|
|
633
615
|
HL_HANDOFF_FLAG: handoffFlagPath,
|
|
634
|
-
|
|
616
|
+
HL_SUBMIT_FLAG: submitFlagPath,
|
|
635
617
|
};
|
|
636
|
-
process.stderr.write(`\nhumanloop: opening "${absFile}" for review in ${bin}` +
|
|
637
|
-
|
|
638
|
-
` Answers : ${outPath}\n` +
|
|
618
|
+
process.stderr.write(`\nhumanloop: opening "${absFile}" for review in ${bin}.\n` +
|
|
619
|
+
` Draft : ${outPath}\n` +
|
|
639
620
|
` Browser : available after <Space>w hands off (or :HLBrowser)\n` +
|
|
640
621
|
` Keys : <Space>c comment · <Space>l list · <Space>u undo · <Space>s submit & quit · <Space>w browser (or :HLComment/:HLSubmit/:HLBrowser)\n` +
|
|
641
622
|
` Status : BLOCKING — waiting for you to finish the review.\n\n`);
|
|
642
|
-
|
|
643
|
-
// `exec env …` so the editor replaces the shell and becomes the pane's
|
|
644
|
-
// process (so tmux `#{pane_current_command}` is `nvim`, not the shell).
|
|
645
|
-
const envPairs = [
|
|
646
|
-
`HL_OUTPUT=${shellQuote(outPath)}`,
|
|
647
|
-
`HL_SOURCE=${shellQuote(absFile)}`,
|
|
648
|
-
`HL_REVIEW_URL=${shellQuote(reviewUrl)}`,
|
|
649
|
-
`HL_HANDOFF_FLAG=${shellQuote(handoffFlagPath)}`,
|
|
650
|
-
...(opts.submitFlagPath ? [`HL_SUBMIT_FLAG=${shellQuote(opts.submitFlagPath)}`] : []),
|
|
651
|
-
];
|
|
652
|
-
const paneCmd = [
|
|
653
|
-
'exec',
|
|
654
|
-
'env',
|
|
655
|
-
...envPairs,
|
|
656
|
-
shellQuote(bin),
|
|
657
|
-
...editorArgs.map(shellQuote),
|
|
658
|
-
].join(' ');
|
|
659
|
-
try {
|
|
660
|
-
if (opts.tmuxPopup) {
|
|
661
|
-
const w = opts.popupSize ? opts.popupSize.w : '90%';
|
|
662
|
-
const h = opts.popupSize ? opts.popupSize.h : '90%';
|
|
663
|
-
execFileSync('tmux', ['display-popup', '-E', '-w', w, '-h', h, paneCmd], { stdio: 'inherit' });
|
|
664
|
-
}
|
|
665
|
-
else {
|
|
666
|
-
await runInTmuxPane(paneCmd);
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
catch (err) {
|
|
670
|
-
process.stderr.write(`tmux dispatch failed, running in current terminal: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
671
|
-
await runInCurrentTerminal(bin, editorArgs, env);
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
else {
|
|
675
|
-
await runInCurrentTerminal(bin, editorArgs, env);
|
|
676
|
-
}
|
|
623
|
+
await runInCurrentTerminal(bin, editorArgs, env, opts.signal);
|
|
677
624
|
}
|
|
678
625
|
while (true) {
|
|
679
626
|
clearFlag(handoffFlagPath);
|
|
627
|
+
clearFlag(submitFlagPath);
|
|
680
628
|
let resolveSubmitted;
|
|
681
629
|
const submitted = new Promise((resolveSubmittedPromise) => {
|
|
682
630
|
resolveSubmitted = resolveSubmittedPromise;
|
|
683
631
|
});
|
|
632
|
+
if (opts.signal?.aborted)
|
|
633
|
+
return draftFeedback(outPath, absFile);
|
|
684
634
|
const server = await startReviewWebServer({
|
|
685
635
|
jobDir: dir,
|
|
686
636
|
file: absFile,
|
|
687
637
|
output: outPath,
|
|
688
|
-
submitFlagPath
|
|
638
|
+
submitFlagPath,
|
|
689
639
|
onSubmit: (result) => resolveSubmitted(result),
|
|
690
640
|
});
|
|
641
|
+
if (opts.signal?.aborted) {
|
|
642
|
+
await stopReviewServer(server);
|
|
643
|
+
return draftFeedback(outPath, absFile);
|
|
644
|
+
}
|
|
691
645
|
try {
|
|
692
646
|
await runEditor(server.url);
|
|
693
647
|
if (existsSync(handoffFlagPath)) {
|
|
694
648
|
server.activate();
|
|
695
649
|
process.stderr.write(`humanloop: browser review handoff active — ${server.url}\n`);
|
|
696
650
|
openBrowser(server.url);
|
|
697
|
-
const action = await waitForParkedReviewSubmit(submitted);
|
|
651
|
+
const action = await waitForParkedReviewSubmit(submitted, opts.signal);
|
|
698
652
|
if (action.type === 'submitted') {
|
|
699
653
|
await stopReviewServer(server);
|
|
700
654
|
clearFlag(handoffFlagPath);
|
|
655
|
+
if (!opts.signal?.aborted)
|
|
656
|
+
await opts.onPropose?.(action.result);
|
|
701
657
|
return action.result;
|
|
702
658
|
}
|
|
703
659
|
if (action.type === 'take-back') {
|
|
@@ -709,11 +665,16 @@ export async function launchReview(file, opts) {
|
|
|
709
665
|
}
|
|
710
666
|
await stopReviewServer(server);
|
|
711
667
|
clearFlag(handoffFlagPath);
|
|
712
|
-
return
|
|
668
|
+
return draftFeedback(outPath, absFile);
|
|
713
669
|
}
|
|
714
670
|
await stopReviewServer(server);
|
|
715
|
-
|
|
716
|
-
|
|
671
|
+
if (existsSync(submitFlagPath)) {
|
|
672
|
+
const proposal = proposedFeedback(outPath, absFile);
|
|
673
|
+
if (!opts.signal?.aborted)
|
|
674
|
+
await opts.onPropose?.(proposal);
|
|
675
|
+
return proposal;
|
|
676
|
+
}
|
|
677
|
+
return draftFeedback(outPath, absFile);
|
|
717
678
|
}
|
|
718
679
|
catch (err) {
|
|
719
680
|
await stopReviewServer(server);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface TicketClaim {
|
|
2
|
+
token: string;
|
|
3
|
+
host: string;
|
|
4
|
+
pid: number;
|
|
5
|
+
claimedAt: string;
|
|
6
|
+
heartbeatAt: string;
|
|
7
|
+
tmuxClient?: string;
|
|
8
|
+
}
|
|
9
|
+
export interface ClaimOptions {
|
|
10
|
+
host?: string;
|
|
11
|
+
pid?: number;
|
|
12
|
+
tmuxClient?: string;
|
|
13
|
+
now?: Date;
|
|
14
|
+
}
|
|
15
|
+
export declare function readTicketClaim(dir: string): TicketClaim | null;
|
|
16
|
+
export declare function isStaleClaim(claim: TicketClaim, now?: number, localHost?: string): boolean;
|
|
17
|
+
/** Acquire a visible claim only for a still-pending ticket. Malformed crash artifacts are stale claims. */
|
|
18
|
+
export declare function claimTicket(dir: string, opts?: ClaimOptions): TicketClaim | null;
|
|
19
|
+
export declare function heartbeatClaim(dir: string, token: string, now?: Date): boolean;
|
|
20
|
+
export declare function releaseClaimLocked(dir: string, token: string): boolean;
|
|
21
|
+
export declare function releaseClaim(dir: string, token: string): boolean;
|
|
22
|
+
export declare function claimExists(dir: string): boolean;
|
|
23
|
+
export declare function withTicketLock<T>(dir: string, operation: () => T): T;
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { existsSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { claimPath, deckPath, readJson, responsePath, reviewPath, withExclusiveDirectoryLock } from './convention.js';
|
|
5
|
+
const REMOTE_STALE_MS = 30_000;
|
|
6
|
+
function parseClaim(raw) {
|
|
7
|
+
if (typeof raw !== 'object' || raw === null)
|
|
8
|
+
return null;
|
|
9
|
+
const claim = raw;
|
|
10
|
+
if (typeof claim.token !== 'string' || typeof claim.host !== 'string' || !Number.isInteger(claim.pid) || typeof claim.claimedAt !== 'string' || typeof claim.heartbeatAt !== 'string')
|
|
11
|
+
return null;
|
|
12
|
+
return { token: claim.token, host: claim.host, pid: claim.pid, claimedAt: claim.claimedAt, heartbeatAt: claim.heartbeatAt, ...(typeof claim.tmuxClient === 'string' ? { tmuxClient: claim.tmuxClient } : {}) };
|
|
13
|
+
}
|
|
14
|
+
export function readTicketClaim(dir) { return parseClaim(readJson(claimPath(dir))); }
|
|
15
|
+
function isLiveLocalPid(pid) { try {
|
|
16
|
+
process.kill(pid, 0);
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
} }
|
|
22
|
+
export function isStaleClaim(claim, now = Date.now(), localHost = hostname()) {
|
|
23
|
+
if (claim.host === localHost)
|
|
24
|
+
return !isLiveLocalPid(claim.pid);
|
|
25
|
+
const heartbeat = Date.parse(claim.heartbeatAt);
|
|
26
|
+
return !Number.isFinite(heartbeat) || now - heartbeat > REMOTE_STALE_MS;
|
|
27
|
+
}
|
|
28
|
+
/** Acquire a visible claim only for a still-pending ticket. Malformed crash artifacts are stale claims. */
|
|
29
|
+
export function claimTicket(dir, opts = {}) {
|
|
30
|
+
return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, () => {
|
|
31
|
+
if (existsSync(responsePath(dir)) || (!existsSync(deckPath(dir)) && !existsSync(reviewPath(dir))))
|
|
32
|
+
return null;
|
|
33
|
+
const path = claimPath(dir);
|
|
34
|
+
const existing = readTicketClaim(dir);
|
|
35
|
+
if (existing !== null && !isStaleClaim(existing, Date.now(), opts.host ?? hostname()))
|
|
36
|
+
return null;
|
|
37
|
+
if (existsSync(path))
|
|
38
|
+
unlinkSync(path);
|
|
39
|
+
const host = opts.host ?? hostname();
|
|
40
|
+
const pid = opts.pid ?? process.pid;
|
|
41
|
+
const now = (opts.now ?? new Date()).toISOString();
|
|
42
|
+
const claim = { token: randomUUID(), host, pid, claimedAt: now, heartbeatAt: now, ...(opts.tmuxClient ? { tmuxClient: opts.tmuxClient } : {}) };
|
|
43
|
+
writeFileSync(path, `${JSON.stringify(claim)}\n`, { flag: 'wx', mode: 0o600 });
|
|
44
|
+
return claim;
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
export function heartbeatClaim(dir, token, now = new Date()) {
|
|
48
|
+
return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, () => {
|
|
49
|
+
const claim = readTicketClaim(dir);
|
|
50
|
+
if (claim === null || claim.token !== token)
|
|
51
|
+
return false;
|
|
52
|
+
const temp = `${claimPath(dir)}.${token}.heartbeat`;
|
|
53
|
+
writeFileSync(temp, `${JSON.stringify({ ...claim, heartbeatAt: now.toISOString() })}\n`, { flag: 'wx', mode: 0o600 });
|
|
54
|
+
renameSync(temp, claimPath(dir));
|
|
55
|
+
return true;
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
export function releaseClaimLocked(dir, token) {
|
|
59
|
+
const claim = readTicketClaim(dir);
|
|
60
|
+
if (claim === null || claim.token !== token)
|
|
61
|
+
return false;
|
|
62
|
+
unlinkSync(claimPath(dir));
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
export function releaseClaim(dir, token) { return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, () => releaseClaimLocked(dir, token)); }
|
|
66
|
+
export function claimExists(dir) { return existsSync(claimPath(dir)); }
|
|
67
|
+
export function withTicketLock(dir, operation) { return withExclusiveDirectoryLock(`${dir}/.ticket-lock`, operation); }
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Deliver one canonical result. It is safe to call repeatedly after crashes. */
|
|
2
|
+
export declare function dispatchCompletion(root: string, dir: string): Promise<'delivered' | 'pending' | 'none'>;
|
|
3
|
+
/** Event-driven startup/root-change reconciliation for results lacking an ack. */
|
|
4
|
+
export declare function reconcileCompletions(root: string): Promise<void>;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { readdirSync, statSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { basename, resolve } from 'node:path';
|
|
4
|
+
import { atomicWriteJson, deliveryErrorPath, deliveryPath, responsePath, readJson, reviewPath, withExclusiveDirectoryLockAsync } from './convention.js';
|
|
5
|
+
import { registeredInboxRoot } from './registry.js';
|
|
6
|
+
import { readTicketResult, ticketRoot } from './tickets.js';
|
|
7
|
+
import { validateReviewProjection } from './deck-schema.js';
|
|
8
|
+
function receiptMatches(dir) {
|
|
9
|
+
const receipt = readJson(deliveryPath(dir));
|
|
10
|
+
return receipt?.schema === 'humanloop.delivery/v1' && receipt.responsePath === responsePath(dir);
|
|
11
|
+
}
|
|
12
|
+
function eventFor(root, dir, result) {
|
|
13
|
+
return { schema: 'humanloop.completion/v1', root, dir, ticketId: basename(dir), kind: result.kind, outcome: result.kind === 'canceled' ? 'canceled' : 'resolved', responsePath: responsePath(dir) };
|
|
14
|
+
}
|
|
15
|
+
async function runHandler(command, args, event) {
|
|
16
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
17
|
+
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'ignore'] });
|
|
18
|
+
let timedOut = false;
|
|
19
|
+
const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 30_000);
|
|
20
|
+
child.once('error', (error) => { clearTimeout(timeout); rejectPromise(error); });
|
|
21
|
+
child.once('exit', (code, signal) => {
|
|
22
|
+
clearTimeout(timeout);
|
|
23
|
+
if (timedOut)
|
|
24
|
+
rejectPromise(new Error('completion handler timed out after 30 seconds'));
|
|
25
|
+
else if (code === 0)
|
|
26
|
+
resolvePromise();
|
|
27
|
+
else
|
|
28
|
+
rejectPromise(new Error(`completion handler failed (${signal ?? code ?? 'unknown'})`));
|
|
29
|
+
});
|
|
30
|
+
child.stdin.end(`${JSON.stringify(event)}\n`);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function projectReview(dir, result) {
|
|
34
|
+
if (result.kind !== 'review')
|
|
35
|
+
return;
|
|
36
|
+
const descriptor = validateReviewProjection(dir, readJson(reviewPath(dir)));
|
|
37
|
+
atomicWriteJson(descriptor.output, result.result);
|
|
38
|
+
}
|
|
39
|
+
/** Deliver one canonical result. It is safe to call repeatedly after crashes. */
|
|
40
|
+
export async function dispatchCompletion(root, dir) {
|
|
41
|
+
const registration = registeredInboxRoot(root);
|
|
42
|
+
const canonicalRoot = ticketRoot(dir);
|
|
43
|
+
if (registration === null || canonicalRoot !== registration.root)
|
|
44
|
+
throw new Error('ticket is not a canonical direct child of a registered root');
|
|
45
|
+
return withExclusiveDirectoryLockAsync(`${resolve(dir)}/.delivery-lock`, async () => {
|
|
46
|
+
const result = readTicketResult(dir);
|
|
47
|
+
if (result === null)
|
|
48
|
+
return 'none';
|
|
49
|
+
try {
|
|
50
|
+
projectReview(dir, result);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
atomicWriteJson(deliveryErrorPath(dir), { schema: 'humanloop.delivery-error/v1', responsePath: responsePath(dir), failedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) });
|
|
54
|
+
return 'pending';
|
|
55
|
+
}
|
|
56
|
+
if (registration.handler === undefined)
|
|
57
|
+
return 'none';
|
|
58
|
+
if (receiptMatches(dir))
|
|
59
|
+
return 'delivered';
|
|
60
|
+
const event = eventFor(registration.root, resolve(dir), result);
|
|
61
|
+
try {
|
|
62
|
+
await runHandler(registration.handler.command, registration.handler.args, event);
|
|
63
|
+
atomicWriteJson(deliveryPath(dir), { schema: 'humanloop.delivery/v1', responsePath: event.responsePath, deliveredAt: new Date().toISOString() });
|
|
64
|
+
try {
|
|
65
|
+
unlinkSync(deliveryErrorPath(dir));
|
|
66
|
+
}
|
|
67
|
+
catch { /* no prior error */ }
|
|
68
|
+
return 'delivered';
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
atomicWriteJson(deliveryErrorPath(dir), { schema: 'humanloop.delivery-error/v1', responsePath: event.responsePath, failedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) });
|
|
72
|
+
return 'pending';
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Event-driven startup/root-change reconciliation for results lacking an ack. */
|
|
77
|
+
export async function reconcileCompletions(root) {
|
|
78
|
+
const registration = registeredInboxRoot(root);
|
|
79
|
+
if (registration === null)
|
|
80
|
+
return;
|
|
81
|
+
let entries;
|
|
82
|
+
try {
|
|
83
|
+
entries = readdirSync(registration.root);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const dir = resolve(registration.root, entry);
|
|
90
|
+
try {
|
|
91
|
+
if (!statSync(dir).isDirectory())
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ticketRoot(dir) === registration.root && readTicketResult(dir) !== null && !receiptMatches(dir))
|
|
98
|
+
await dispatchCompletion(registration.root, dir);
|
|
99
|
+
}
|
|
100
|
+
}
|