@crouton-kit/humanloop 0.3.39 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +3 -2
- package/dist/api.js +2 -2
- package/dist/inbox/controller.d.ts +9 -4
- package/dist/inbox/controller.js +167 -6
- package/dist/inbox/convention.d.ts +2 -2
- package/dist/inbox/convention.js +19 -3
- package/dist/inbox/deck-adapter.d.ts +3 -3
- package/dist/inbox/deck-adapter.js +3 -3
- package/dist/inbox/deck-schema.d.ts +2 -2
- package/dist/inbox/deck-schema.js +4 -4
- package/dist/inbox/registry.d.ts +2 -0
- package/dist/inbox/registry.js +4 -3
- package/dist/inbox/tickets.d.ts +5 -0
- package/dist/inbox/tickets.js +19 -32
- package/dist/inbox/visual.d.ts +130 -0
- package/dist/inbox/visual.js +747 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/tui/app.d.ts +3 -6
- package/dist/tui/app.js +59 -18
- package/dist/tui/render.js +4 -4
- package/dist/types.d.ts +38 -11
- package/package.json +1 -3
- package/dist/conversation/reader.d.ts +0 -20
- package/dist/conversation/reader.js +0 -348
- package/dist/visuals/conversation.d.ts +0 -7
- package/dist/visuals/conversation.js +0 -16
- package/dist/visuals/generate.d.ts +0 -11
- package/dist/visuals/generate.js +0 -81
package/dist/api.d.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import type { Deck, ResolutionEnvelope } from './types.js';
|
|
1
|
+
import type { Deck, ResolutionEnvelope, VisualProvider } from './types.js';
|
|
2
2
|
export interface AskOpts {
|
|
3
3
|
/** Interaction directory. Defaults to a managed temp dir under os.tmpdir(). */
|
|
4
4
|
dir?: string;
|
|
5
|
-
|
|
5
|
+
visualProvider?: VisualProvider;
|
|
6
6
|
cols?: number;
|
|
7
7
|
rows?: number;
|
|
8
8
|
}
|
|
@@ -18,6 +18,7 @@ export interface InboxOpts {
|
|
|
18
18
|
cols?: number;
|
|
19
19
|
rows?: number;
|
|
20
20
|
roots?: string[];
|
|
21
|
+
visualProvider?: VisualProvider;
|
|
21
22
|
}
|
|
22
23
|
/** Open the centralized inbox controller in the current human terminal. */
|
|
23
24
|
export declare function openInbox(opts?: InboxOpts): Promise<void>;
|
package/dist/api.js
CHANGED
|
@@ -27,7 +27,7 @@ export async function ask(deck, opts = {}) {
|
|
|
27
27
|
stampCanvasNode(deck);
|
|
28
28
|
atomicWriteJson(deckPath(dir), deck);
|
|
29
29
|
const { responses, completedAt, responsePath, deck: answeredDeck } = await resolveInteractionDir(dir, deck, {
|
|
30
|
-
|
|
30
|
+
visualProvider: opts.visualProvider,
|
|
31
31
|
cols: opts.cols,
|
|
32
32
|
rows: opts.rows,
|
|
33
33
|
});
|
|
@@ -48,6 +48,6 @@ export async function notify(title, body) {
|
|
|
48
48
|
}
|
|
49
49
|
/** Open the centralized inbox controller in the current human terminal. */
|
|
50
50
|
export async function openInbox(opts = {}) {
|
|
51
|
-
const controller = new InboxController({ roots: opts.roots, cols: opts.cols, rows: opts.rows });
|
|
51
|
+
const controller = new InboxController({ roots: opts.roots, cols: opts.cols, rows: opts.rows, visualProvider: opts.visualProvider });
|
|
52
52
|
await controller.run();
|
|
53
53
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import type { InteractionResponse, TicketSummary } from '../types.js';
|
|
1
|
+
import type { InteractionResponse, TicketSummary, VisualProvider } from '../types.js';
|
|
2
2
|
import type { Key } from '../tui/terminal.js';
|
|
3
3
|
import { startWebServer } from '../browser/server.js';
|
|
4
|
-
import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
|
|
5
4
|
export interface InboxControllerOptions {
|
|
6
5
|
roots?: string[];
|
|
7
6
|
cols?: number;
|
|
@@ -10,8 +9,7 @@ export interface InboxControllerOptions {
|
|
|
10
9
|
completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
|
|
11
10
|
startDeckBrowser?: typeof startWebServer;
|
|
12
11
|
openBrowser?: (url: string) => void;
|
|
13
|
-
|
|
14
|
-
visualGeneratorForSession?: typeof visualGeneratorForConversationSession;
|
|
12
|
+
visualProvider?: VisualProvider;
|
|
15
13
|
}
|
|
16
14
|
type Screen = 'list' | 'detail';
|
|
17
15
|
/** One terminal owner for scanning, stable selection, claims, and frame diffs. */
|
|
@@ -39,6 +37,8 @@ export declare class InboxController {
|
|
|
39
37
|
private deckBrowserGeneration;
|
|
40
38
|
private claim;
|
|
41
39
|
private reconciling;
|
|
40
|
+
private visualReconciling;
|
|
41
|
+
private readonly visualCleanupExecutor;
|
|
42
42
|
private suspended;
|
|
43
43
|
private submittingDir;
|
|
44
44
|
private stdinListener;
|
|
@@ -94,6 +94,11 @@ export declare class InboxController {
|
|
|
94
94
|
/** Owner-boundary reconciliation for resolved results still lacking an ack.
|
|
95
95
|
* Guarded so overlapping fs events cannot stack concurrent scans. */
|
|
96
96
|
private reconcileRoots;
|
|
97
|
+
/** Reconciliation owns stale-claim retirement and durable cleanup, never a Visual start. */
|
|
98
|
+
private reconcileVisualWork;
|
|
99
|
+
/** Automatic ticket capability is deliberately marker + current-root-handler only. */
|
|
100
|
+
private visualProviderFor;
|
|
101
|
+
private startTicketVisual;
|
|
97
102
|
private leaveDetail;
|
|
98
103
|
private select;
|
|
99
104
|
private scrollPreview;
|
package/dist/inbox/controller.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, watch } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
2
3
|
import { getTerminalSize, parseKeypress, restoreTerminal, setupTerminal } from '../tui/terminal.js';
|
|
3
4
|
import { diffFrame } from '../tui/render.js';
|
|
4
5
|
import { renderMarkdown } from '../render/termrender.js';
|
|
@@ -13,13 +14,68 @@ import { inboxRootsDirectory, listInboxRoots, registeredInboxRoot } from './regi
|
|
|
13
14
|
import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
|
|
14
15
|
import { completeDeck, readTicketResult, ticketRoot } from './tickets.js';
|
|
15
16
|
import { reconcileCompletions } from './completion.js';
|
|
16
|
-
import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath } from './convention.js';
|
|
17
|
+
import { clearProgress, deckPath, progressPath, readJson, responsePath, reviewPath, visualsDir } from './convention.js';
|
|
17
18
|
import { DeckAdapter } from './deck-adapter.js';
|
|
18
19
|
import { validateDeck } from './deck-schema.js';
|
|
19
20
|
import { ReviewAdapter } from './review-adapter.js';
|
|
20
|
-
import { visualGeneratorForConversationSession } from '../visuals/conversation.js';
|
|
21
21
|
import { editBufferInEditor } from '../editor/roundtrip.js';
|
|
22
22
|
import { cancelFollowUp, readFollowUp, requestFollowUp } from './followup.js';
|
|
23
|
+
import { cancelVisualRequest, dispatchVisualCleanup, listVisualCleanupObligationsForRoot, readVisualResult, reconcileStaleVisualRequestsForRoot, reconcileVisualRequestsForTicket, startVisualRequest, VISUAL_CAPABILITY, } from './visual.js';
|
|
24
|
+
/** One controller-owned retry loop for durable cancellation only. */
|
|
25
|
+
class VisualCleanupExecutor {
|
|
26
|
+
roots;
|
|
27
|
+
timer;
|
|
28
|
+
dispatching = new Set();
|
|
29
|
+
stopped = false;
|
|
30
|
+
constructor(roots) {
|
|
31
|
+
this.roots = roots;
|
|
32
|
+
}
|
|
33
|
+
reconcile() {
|
|
34
|
+
if (this.stopped)
|
|
35
|
+
return;
|
|
36
|
+
let tasks = [];
|
|
37
|
+
for (const root of this.roots()) {
|
|
38
|
+
try {
|
|
39
|
+
tasks.push(...listVisualCleanupObligationsForRoot(root));
|
|
40
|
+
}
|
|
41
|
+
catch { /* the next root reconciliation retries durable work */ }
|
|
42
|
+
}
|
|
43
|
+
tasks = tasks.filter((task) => !this.dispatching.has(this.key(task)));
|
|
44
|
+
const now = Date.now();
|
|
45
|
+
for (const task of tasks.filter((candidate) => Date.parse(candidate.nextAttemptAt) <= now))
|
|
46
|
+
this.dispatch(task);
|
|
47
|
+
this.arm(tasks.filter((task) => !this.dispatching.has(this.key(task))));
|
|
48
|
+
}
|
|
49
|
+
stop() {
|
|
50
|
+
this.stopped = true;
|
|
51
|
+
if (this.timer !== undefined)
|
|
52
|
+
clearTimeout(this.timer);
|
|
53
|
+
this.timer = undefined;
|
|
54
|
+
}
|
|
55
|
+
dispatch(task) {
|
|
56
|
+
const key = this.key(task);
|
|
57
|
+
if (this.dispatching.has(key))
|
|
58
|
+
return;
|
|
59
|
+
this.dispatching.add(key);
|
|
60
|
+
void dispatchVisualCleanup(task.root, task.dir, task.requestId).catch(() => undefined).finally(() => {
|
|
61
|
+
this.dispatching.delete(key);
|
|
62
|
+
this.reconcile();
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
arm(tasks) {
|
|
66
|
+
if (this.timer !== undefined)
|
|
67
|
+
clearTimeout(this.timer);
|
|
68
|
+
this.timer = undefined;
|
|
69
|
+
const earliest = tasks.reduce((next, task) => {
|
|
70
|
+
const due = Date.parse(task.nextAttemptAt);
|
|
71
|
+
return Number.isFinite(due) && (next === undefined || due < next) ? due : next;
|
|
72
|
+
}, undefined);
|
|
73
|
+
if (earliest === undefined)
|
|
74
|
+
return;
|
|
75
|
+
this.timer = setTimeout(() => { this.timer = undefined; this.reconcile(); }, Math.max(0, earliest - Date.now()));
|
|
76
|
+
}
|
|
77
|
+
key(task) { return `${task.root}\u0000${task.dir}\u0000${task.requestId}`; }
|
|
78
|
+
}
|
|
23
79
|
/** One terminal owner for scanning, stable selection, claims, and frame diffs. */
|
|
24
80
|
export class InboxController {
|
|
25
81
|
options;
|
|
@@ -45,6 +101,8 @@ export class InboxController {
|
|
|
45
101
|
deckBrowserGeneration = 0;
|
|
46
102
|
claim;
|
|
47
103
|
reconciling = false;
|
|
104
|
+
visualReconciling = false;
|
|
105
|
+
visualCleanupExecutor;
|
|
48
106
|
suspended = false;
|
|
49
107
|
submittingDir;
|
|
50
108
|
stdinListener;
|
|
@@ -64,7 +122,9 @@ export class InboxController {
|
|
|
64
122
|
const size = getTerminalSize();
|
|
65
123
|
this.cols = options.cols ?? size.cols;
|
|
66
124
|
this.rows = options.rows ?? size.rows;
|
|
125
|
+
this.visualCleanupExecutor = new VisualCleanupExecutor(() => this.resolvedRoots());
|
|
67
126
|
this.rescan();
|
|
127
|
+
this.reconcileVisualWork();
|
|
68
128
|
}
|
|
69
129
|
snapshot() {
|
|
70
130
|
return { items: [...this.items], selectedDir: this.selectedDir, screen: this.screen, inputBuffer: this.adapter?.inputBuffer() };
|
|
@@ -196,13 +256,20 @@ export class InboxController {
|
|
|
196
256
|
return;
|
|
197
257
|
}
|
|
198
258
|
this.claim = { dir: item.dir, token: claim.token };
|
|
199
|
-
const deck =
|
|
200
|
-
if (deck ===
|
|
259
|
+
const deck = this.readDeck(item.dir);
|
|
260
|
+
if (deck === undefined) {
|
|
201
261
|
releaseClaim(item.dir, claim.token);
|
|
202
262
|
this.claim = undefined;
|
|
203
263
|
this.invalidate();
|
|
204
264
|
return;
|
|
205
265
|
}
|
|
266
|
+
const root = ticketRoot(item.dir);
|
|
267
|
+
if (root !== null) {
|
|
268
|
+
// A newly acquired claim makes every older running generation stale
|
|
269
|
+
// before the panel can mint its own work. This only dispatches cleanup.
|
|
270
|
+
const retirement = reconcileVisualRequestsForTicket(root, item.dir, claim.token);
|
|
271
|
+
void retirement.delivery.finally(() => this.visualCleanupExecutor.reconcile());
|
|
272
|
+
}
|
|
206
273
|
// Notifications use the same canonical deck panel as every other deck:
|
|
207
274
|
// opening is not acknowledgement; panel completion is.
|
|
208
275
|
this.activeDeck = deck;
|
|
@@ -214,7 +281,7 @@ export class InboxController {
|
|
|
214
281
|
cols: this.detailSize().cols,
|
|
215
282
|
rows: this.detailSize().rows,
|
|
216
283
|
onDirty: () => this.repaint(),
|
|
217
|
-
|
|
284
|
+
visualProvider: this.visualProviderFor(item.dir, deck, claim.token),
|
|
218
285
|
onEditorRequest: () => this.editActiveDeckInput(),
|
|
219
286
|
followUpAvailable: followUp.available,
|
|
220
287
|
onFollowUpRequest: followUp.onRequest,
|
|
@@ -282,7 +349,8 @@ export class InboxController {
|
|
|
282
349
|
this.activeDeck = deck;
|
|
283
350
|
const followUp = this.followUpHandlers(this.claim.dir, deck);
|
|
284
351
|
this.adapter.setFollowUpHandlers(followUp.available, followUp.onRequest, followUp.onCancel);
|
|
285
|
-
|
|
352
|
+
// Reload replaces the capability before it mints the next panel generation.
|
|
353
|
+
this.adapter.reload(deck, this.visualProviderFor(this.claim.dir, deck, this.claim.token));
|
|
286
354
|
if (followUp.available)
|
|
287
355
|
this.adapter.setFollowUpState(this.followUpViewState(this.claim.dir));
|
|
288
356
|
this.repaint();
|
|
@@ -462,6 +530,7 @@ export class InboxController {
|
|
|
462
530
|
this.deckBrowser = undefined;
|
|
463
531
|
void browser?.stop();
|
|
464
532
|
this.leaveDetail();
|
|
533
|
+
this.visualCleanupExecutor.stop();
|
|
465
534
|
for (const watcher of this.watchers)
|
|
466
535
|
watcher.close();
|
|
467
536
|
this.watchers = [];
|
|
@@ -587,6 +656,97 @@ export class InboxController {
|
|
|
587
656
|
this.reconciling = false;
|
|
588
657
|
}
|
|
589
658
|
})();
|
|
659
|
+
this.reconcileVisualWork();
|
|
660
|
+
}
|
|
661
|
+
/** Reconciliation owns stale-claim retirement and durable cleanup, never a Visual start. */
|
|
662
|
+
reconcileVisualWork() {
|
|
663
|
+
if (this.visualReconciling)
|
|
664
|
+
return;
|
|
665
|
+
this.visualReconciling = true;
|
|
666
|
+
const roots = this.resolvedRoots();
|
|
667
|
+
void (async () => {
|
|
668
|
+
try {
|
|
669
|
+
const deliveries = roots.flatMap((root) => {
|
|
670
|
+
try {
|
|
671
|
+
return reconcileStaleVisualRequestsForRoot(root).map((entry) => entry.delivery);
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
return [];
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
await Promise.all(deliveries);
|
|
678
|
+
}
|
|
679
|
+
finally {
|
|
680
|
+
this.visualReconciling = false;
|
|
681
|
+
this.visualCleanupExecutor.reconcile();
|
|
682
|
+
}
|
|
683
|
+
})();
|
|
684
|
+
}
|
|
685
|
+
/** Automatic ticket capability is deliberately marker + current-root-handler only. */
|
|
686
|
+
visualProviderFor(dir, deck, claimToken) {
|
|
687
|
+
// An inline provider is intentional host injection, independent of ticket capability.
|
|
688
|
+
if (this.options.visualProvider !== undefined)
|
|
689
|
+
return this.options.visualProvider;
|
|
690
|
+
const root = ticketRoot(dir);
|
|
691
|
+
if (root === null || deck.source?.visual !== VISUAL_CAPABILITY || registeredInboxRoot(root)?.visualHandler === undefined)
|
|
692
|
+
return undefined;
|
|
693
|
+
return (request) => this.startTicketVisual(root, dir, claimToken, request);
|
|
694
|
+
}
|
|
695
|
+
startTicketVisual(root, dir, claimToken, request) {
|
|
696
|
+
let watcher;
|
|
697
|
+
let settled = false;
|
|
698
|
+
let settle;
|
|
699
|
+
const result = new Promise((resolve) => { settle = resolve; });
|
|
700
|
+
const finish = (outcome) => {
|
|
701
|
+
if (settled)
|
|
702
|
+
return;
|
|
703
|
+
settled = true;
|
|
704
|
+
watcher?.close();
|
|
705
|
+
watcher = undefined;
|
|
706
|
+
settle(outcome);
|
|
707
|
+
};
|
|
708
|
+
let started;
|
|
709
|
+
try {
|
|
710
|
+
started = startVisualRequest({ root, dir, claimToken, request });
|
|
711
|
+
}
|
|
712
|
+
catch (error) {
|
|
713
|
+
finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
|
|
714
|
+
return { result, cancel: () => { } };
|
|
715
|
+
}
|
|
716
|
+
const reread = () => {
|
|
717
|
+
if (settled)
|
|
718
|
+
return;
|
|
719
|
+
try {
|
|
720
|
+
const outcome = readVisualResult(root, dir, request.requestId);
|
|
721
|
+
if (outcome === null)
|
|
722
|
+
return;
|
|
723
|
+
finish(outcome.status === 'ready'
|
|
724
|
+
? { status: 'ready', markdown: outcome.markdown }
|
|
725
|
+
: { status: 'error', error: outcome.error });
|
|
726
|
+
}
|
|
727
|
+
catch (error) {
|
|
728
|
+
finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
try {
|
|
732
|
+
// Install the watch before the first durable reread so a publication in
|
|
733
|
+
// the narrow setup window is either observed or found by that reread.
|
|
734
|
+
watcher = watch(join(visualsDir(dir), request.requestId), () => reread());
|
|
735
|
+
watcher.once('error', (error) => finish({ status: 'error', error: error.message }));
|
|
736
|
+
reread();
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
finish({ status: 'error', error: error instanceof Error ? error.message : String(error) });
|
|
740
|
+
}
|
|
741
|
+
void started.delivery.then(reread, (error) => finish({ status: 'error', error: error instanceof Error ? error.message : String(error) }));
|
|
742
|
+
return {
|
|
743
|
+
result,
|
|
744
|
+
cancel: () => {
|
|
745
|
+
watcher?.close();
|
|
746
|
+
watcher = undefined;
|
|
747
|
+
void cancelVisualRequest(root, dir, request.requestId).finally(() => this.visualCleanupExecutor.reconcile());
|
|
748
|
+
},
|
|
749
|
+
};
|
|
590
750
|
}
|
|
591
751
|
leaveDetail(release = true) {
|
|
592
752
|
this.deckBrowserGeneration++;
|
|
@@ -595,6 +755,7 @@ export class InboxController {
|
|
|
595
755
|
this.deckBrowserStarting = false;
|
|
596
756
|
this.adapter?.close();
|
|
597
757
|
this.adapter = undefined;
|
|
758
|
+
this.visualCleanupExecutor.reconcile();
|
|
598
759
|
this.activeDeck = undefined;
|
|
599
760
|
this.selectedWatcher?.close();
|
|
600
761
|
this.selectedWatcher = undefined;
|
|
@@ -9,8 +9,6 @@ export declare function deliveryErrorPath(dir: string): string;
|
|
|
9
9
|
export declare function followupRequestPath(dir: string): string;
|
|
10
10
|
export declare function followupResultPath(dir: string): string;
|
|
11
11
|
export declare function visualsDir(dir: string): string;
|
|
12
|
-
export declare function visualMdPath(dir: string, id: string): string;
|
|
13
|
-
export declare function visualAnsiPath(dir: string, id: string): string;
|
|
14
12
|
/** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
|
|
15
13
|
* acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
|
|
16
14
|
* Error carrying the handler's captured stderr. Shared by completion delivery
|
|
@@ -22,6 +20,8 @@ export declare function isResolved(dir: string): boolean;
|
|
|
22
20
|
export declare function isClaimed(dir: string): boolean;
|
|
23
21
|
export declare function stampCanvasNode(deck: Deck): void;
|
|
24
22
|
export declare function atomicWriteJson(path: string, value: unknown): void;
|
|
23
|
+
/** Publish one immutable JSON record without replacing an existing winner. */
|
|
24
|
+
export declare function publishJsonExclusive(path: string, value: unknown): boolean;
|
|
25
25
|
export declare function readJson<T>(path: string): T | null;
|
|
26
26
|
/** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
|
|
27
27
|
export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: {
|
package/dist/inbox/convention.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { dirname } from 'node:path';
|
|
4
4
|
import { randomUUID } from 'node:crypto';
|
|
@@ -13,8 +13,6 @@ export function deliveryErrorPath(dir) { return `${dir}/delivery-error.json`; }
|
|
|
13
13
|
export function followupRequestPath(dir) { return `${dir}/followup-request.json`; }
|
|
14
14
|
export function followupResultPath(dir) { return `${dir}/followup-result.json`; }
|
|
15
15
|
export function visualsDir(dir) { return `${dir}/visuals`; }
|
|
16
|
-
export function visualMdPath(dir, id) { return `${dir}/visuals/${id}.md`; }
|
|
17
|
-
export function visualAnsiPath(dir, id) { return `${dir}/visuals/${id}.ansi`; }
|
|
18
16
|
/** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
|
|
19
17
|
* acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
|
|
20
18
|
* Error carrying the handler's captured stderr. Shared by completion delivery
|
|
@@ -65,6 +63,24 @@ export function atomicWriteJson(path, value) {
|
|
|
65
63
|
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
66
64
|
renameSync(tmp, path);
|
|
67
65
|
}
|
|
66
|
+
/** Publish one immutable JSON record without replacing an existing winner. */
|
|
67
|
+
export function publishJsonExclusive(path, value) {
|
|
68
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
69
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
70
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
71
|
+
try {
|
|
72
|
+
linkSync(tmp, path);
|
|
73
|
+
return true;
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error.code === 'EEXIST')
|
|
77
|
+
return false;
|
|
78
|
+
throw error;
|
|
79
|
+
}
|
|
80
|
+
finally {
|
|
81
|
+
unlinkSync(tmp);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
68
84
|
export function readJson(path) {
|
|
69
85
|
try {
|
|
70
86
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Deck, FollowUpState,
|
|
1
|
+
import type { Deck, FollowUpState, InteractionResponse, VisualProvider } from '../types.js';
|
|
2
2
|
import type { Key } from '../tui/terminal.js';
|
|
3
3
|
export interface DeckAdapterOptions {
|
|
4
4
|
dir: string;
|
|
@@ -9,7 +9,7 @@ export interface DeckAdapterOptions {
|
|
|
9
9
|
onComplete: (responses: InteractionResponse[]) => void;
|
|
10
10
|
onBack: () => void;
|
|
11
11
|
onDirty: () => void;
|
|
12
|
-
|
|
12
|
+
visualProvider?: VisualProvider;
|
|
13
13
|
onEditorRequest?: () => void;
|
|
14
14
|
followUpAvailable?: boolean;
|
|
15
15
|
onFollowUpRequest?: (question: string) => void;
|
|
@@ -30,6 +30,6 @@ export declare class DeckAdapter {
|
|
|
30
30
|
canAcceptHostKeys(): boolean;
|
|
31
31
|
handleKey(input: string, key: Key): void;
|
|
32
32
|
/** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
|
|
33
|
-
reload(deck: Deck): void;
|
|
33
|
+
reload(deck: Deck, visualProvider: VisualProvider | undefined): void;
|
|
34
34
|
close(): void;
|
|
35
35
|
}
|
|
@@ -20,7 +20,7 @@ export class DeckAdapter {
|
|
|
20
20
|
onExit: () => { if (notificationsAcknowledged(opts.deck, this.responses))
|
|
21
21
|
opts.onComplete(this.responses); },
|
|
22
22
|
onDirty: opts.onDirty,
|
|
23
|
-
|
|
23
|
+
visualProvider: opts.visualProvider,
|
|
24
24
|
onEditorRequest: opts.onEditorRequest,
|
|
25
25
|
followUpAvailable: opts.followUpAvailable,
|
|
26
26
|
onFollowUpRequest: opts.onFollowUpRequest,
|
|
@@ -42,8 +42,8 @@ export class DeckAdapter {
|
|
|
42
42
|
this.panel.handleKey(input, key);
|
|
43
43
|
}
|
|
44
44
|
/** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
|
|
45
|
-
reload(deck) {
|
|
46
|
-
this.panel.loadDeck(deck, { progressPath: progressPath(this.opts.dir) });
|
|
45
|
+
reload(deck, visualProvider) {
|
|
46
|
+
this.panel.loadDeck(deck, { progressPath: progressPath(this.opts.dir), visualProvider });
|
|
47
47
|
this.opts.onDirty();
|
|
48
48
|
}
|
|
49
49
|
close() { this.panel.unmount(); }
|
|
@@ -18,7 +18,7 @@ export declare const deckSchema: z.ZodObject<{
|
|
|
18
18
|
askedBy: z.ZodOptional<z.ZodString>;
|
|
19
19
|
blockedSince: z.ZodOptional<z.ZodString>;
|
|
20
20
|
nodeId: z.ZodOptional<z.ZodString>;
|
|
21
|
-
|
|
21
|
+
visual: z.ZodOptional<z.ZodLiteral<"humanloop.visual/v1">>;
|
|
22
22
|
}, z.core.$strip>>;
|
|
23
23
|
interactions: z.ZodArray<z.ZodObject<{
|
|
24
24
|
id: z.ZodString;
|
|
@@ -59,7 +59,7 @@ export declare const reviewDescriptorSchema: z.ZodObject<{
|
|
|
59
59
|
askedBy: z.ZodOptional<z.ZodString>;
|
|
60
60
|
blockedSince: z.ZodOptional<z.ZodString>;
|
|
61
61
|
nodeId: z.ZodOptional<z.ZodString>;
|
|
62
|
-
|
|
62
|
+
visual: z.ZodOptional<z.ZodLiteral<"humanloop.visual/v1">>;
|
|
63
63
|
}, z.core.$strip>;
|
|
64
64
|
blockedSince: z.ZodString;
|
|
65
65
|
}, z.core.$strict>;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
3
|
-
import { claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, responsePath, reviewPath } from './convention.js';
|
|
3
|
+
import { claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, responsePath, reviewPath, visualsDir } from './convention.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { INTERACTION_KINDS } from '../types.js';
|
|
6
6
|
import { checkMarkdown } from '../render/termrender.js';
|
|
@@ -11,7 +11,7 @@ const interactionSchema = z.object({
|
|
|
11
11
|
title: z.string().min(1), subtitle: z.string().min(1).optional(), body: z.string().optional(), bodyPath: z.string().optional(),
|
|
12
12
|
options: z.array(interactionOptionSchema), multiSelect: z.boolean().optional(), allowFreetext: z.boolean().optional(), freetextLabel: z.string().optional(), kind: z.enum(INTERACTION_KINDS).optional(), preAnswered: preAnswerSchema.optional(),
|
|
13
13
|
});
|
|
14
|
-
const deckSourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), blockedSince: z.string().optional(), nodeId: z.string().optional(),
|
|
14
|
+
const deckSourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), blockedSince: z.string().optional(), nodeId: z.string().optional(), visual: z.literal('humanloop.visual/v1').optional() });
|
|
15
15
|
export const deckSchema = z.object({ title: z.string().optional(), source: deckSourceSchema.optional(), interactions: z.array(interactionSchema).min(1) }).superRefine((input, ctx) => {
|
|
16
16
|
const seen = new Set();
|
|
17
17
|
input.interactions.forEach((interaction, index) => {
|
|
@@ -77,8 +77,8 @@ export function validateReviewProjection(dir, parsed) {
|
|
|
77
77
|
throw new Error('review file must be an existing absolute markdown file');
|
|
78
78
|
const file = realpathSync(descriptor.file);
|
|
79
79
|
const output = resolve(realpathSync(dirname(descriptor.output)), basename(descriptor.output));
|
|
80
|
-
const reserved = new Set(['deck.json', 'review.json', 'response.json', 'progress.json', 'claim.json', 'delivery.json', 'delivery-error.json', 'followup-request.json', 'followup-result.json']);
|
|
81
|
-
const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir)]);
|
|
80
|
+
const reserved = new Set(['deck.json', 'review.json', 'response.json', 'progress.json', 'claim.json', 'delivery.json', 'delivery-error.json', 'followup-request.json', 'followup-result.json', 'visuals']);
|
|
81
|
+
const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir), visualsDir(dir)]);
|
|
82
82
|
if (output === file || reserved.has(basename(output)) || ownProtocolPaths.has(output))
|
|
83
83
|
throw new Error('review output must not alias the source or ticket protocol files');
|
|
84
84
|
return { ...descriptor, file, output };
|
package/dist/inbox/registry.d.ts
CHANGED
|
@@ -9,6 +9,7 @@ export interface InboxRootRegistration {
|
|
|
9
9
|
owner: string;
|
|
10
10
|
handler?: CompletionHandler;
|
|
11
11
|
followUpHandler?: CompletionHandler;
|
|
12
|
+
visualHandler?: CompletionHandler;
|
|
12
13
|
}
|
|
13
14
|
export interface InboxRootStatus extends InboxRootRegistration {
|
|
14
15
|
available: boolean;
|
|
@@ -18,6 +19,7 @@ export interface RegisterInboxRootOptions {
|
|
|
18
19
|
owner: string;
|
|
19
20
|
handler?: CompletionHandler;
|
|
20
21
|
followUpHandler?: CompletionHandler;
|
|
22
|
+
visualHandler?: CompletionHandler;
|
|
21
23
|
}
|
|
22
24
|
export declare function inboxRootsDirectory(): string;
|
|
23
25
|
/** Create/canonicalize a root and claim its user-scoped registration. */
|
package/dist/inbox/registry.js
CHANGED
|
@@ -15,8 +15,8 @@ catch {
|
|
|
15
15
|
function validateHandler(handler) {
|
|
16
16
|
if (handler === undefined)
|
|
17
17
|
return undefined;
|
|
18
|
-
if (
|
|
19
|
-
throw new Error('
|
|
18
|
+
if (typeof handler.command !== 'string' || handler.command.trim() === '' || !Array.isArray(handler.args) || !handler.args.every((arg) => typeof arg === 'string'))
|
|
19
|
+
throw new Error('handler requires a non-empty command and string args');
|
|
20
20
|
return { command: handler.command, args: [...handler.args] };
|
|
21
21
|
}
|
|
22
22
|
function validateRegistration(raw) {
|
|
@@ -30,6 +30,7 @@ function validateRegistration(raw) {
|
|
|
30
30
|
schema: 'humanloop.inbox-root/v1', root: value.root, owner: value.owner,
|
|
31
31
|
handler: value.handler === undefined ? undefined : validateHandler(value.handler),
|
|
32
32
|
followUpHandler: value.followUpHandler === undefined ? undefined : validateHandler(value.followUpHandler),
|
|
33
|
+
visualHandler: value.visualHandler === undefined ? undefined : validateHandler(value.visualHandler),
|
|
33
34
|
};
|
|
34
35
|
}
|
|
35
36
|
catch {
|
|
@@ -50,7 +51,7 @@ export function registerInboxRoot(opts) {
|
|
|
50
51
|
throw new Error(`inbox root is already owned by ${existing.owner}`);
|
|
51
52
|
if (existing !== null && existing.root !== root)
|
|
52
53
|
throw new Error('inbox root registry hash collision');
|
|
53
|
-
const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler) };
|
|
54
|
+
const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler), visualHandler: validateHandler(opts.visualHandler) };
|
|
54
55
|
atomicWriteJson(path, registration);
|
|
55
56
|
chmodSync(path, 0o600);
|
|
56
57
|
return registration;
|
package/dist/inbox/tickets.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { Deck, DeckTicketResult, FeedbackResult, ReviewDescriptor, TicketResult } from '../types.js';
|
|
2
2
|
/** Strict decoder for the only canonical final marker. */
|
|
3
3
|
export declare function readTicketResult(dirOrResponsePath: string): TicketResult | null;
|
|
4
|
+
/** Canonical registered-root/direct-child containment shared by host protocols. */
|
|
5
|
+
export declare function requireCanonicalTicket(root: string, dir: string): {
|
|
6
|
+
root: string;
|
|
7
|
+
dir: string;
|
|
8
|
+
};
|
|
4
9
|
export interface SubmitDeckOptions {
|
|
5
10
|
root: string;
|
|
6
11
|
id: string;
|
package/dist/inbox/tickets.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { buildSummary } from '../summary.js';
|
|
5
|
-
import { clearProgress, claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, responsePath, reviewPath } from './convention.js';
|
|
5
|
+
import { clearProgress, claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, publishJsonExclusive, responsePath, reviewPath, visualsDir } from './convention.js';
|
|
6
6
|
import { validateDeck, validateReviewDescriptor, validateReviewProjection, resolveDeckBodyPaths } from './deck-schema.js';
|
|
7
7
|
import { registeredInboxRoot } from './registry.js';
|
|
8
8
|
import { readTicketClaim, releaseClaimLocked, withTicketLock } from './claim.js';
|
|
9
9
|
import { dispatchCompletion } from './completion.js';
|
|
10
|
+
import { cancelVisualRequestsForTicket } from './visual.js';
|
|
10
11
|
const ticketId = z.string().regex(/^[A-Za-z0-9_-]+$/).min(1).max(128);
|
|
11
12
|
const responseSchema = z.object({ id: z.string().min(1), selectedOptionId: z.string().optional(), selectedOptionIds: z.array(z.string()).optional(), freetext: z.string().optional(), optionComments: z.record(z.string(), z.string()).optional() }).strict();
|
|
12
13
|
const feedbackCommentSchema = z.object({ id: z.string().min(1), line: z.number().int().positive(), endLine: z.number().int().positive(), quote: z.string().optional(), colStart: z.number().int().nonnegative().optional(), colEnd: z.number().int().nonnegative().optional(), lineText: z.string(), comment: z.string().min(1), createdAt: z.string().min(1) }).strict();
|
|
@@ -70,7 +71,7 @@ function ticketDir(root, id) {
|
|
|
70
71
|
function discardCreatedTicket(dir, created) { if (created)
|
|
71
72
|
rmSync(dir, { recursive: true, force: true }); }
|
|
72
73
|
function hasTicketProtocolState(dir) {
|
|
73
|
-
return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir)].some(existsSync);
|
|
74
|
+
return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir), visualsDir(dir)].some(existsSync);
|
|
74
75
|
}
|
|
75
76
|
function requireRegisteredTicket(dir) {
|
|
76
77
|
const canonical = realpathSync(dir);
|
|
@@ -82,21 +83,17 @@ function requireRegisteredTicket(dir) {
|
|
|
82
83
|
throw new Error('ticket has no request descriptor');
|
|
83
84
|
return { root, dir: canonical };
|
|
84
85
|
}
|
|
86
|
+
/** Canonical registered-root/direct-child containment shared by host protocols. */
|
|
87
|
+
export function requireCanonicalTicket(root, dir) {
|
|
88
|
+
const registration = registeredInboxRoot(root);
|
|
89
|
+
const ticket = requireRegisteredTicket(dir);
|
|
90
|
+
if (registration === null || ticket.root !== registration.root)
|
|
91
|
+
throw new Error('ticket is not a canonical direct child of the registered root');
|
|
92
|
+
return ticket;
|
|
93
|
+
}
|
|
85
94
|
function publishRequest(path, value) {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
writeFileSync(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
89
|
-
try {
|
|
90
|
-
linkSync(temp, path);
|
|
91
|
-
}
|
|
92
|
-
catch (error) {
|
|
93
|
-
if (error.code === 'EEXIST')
|
|
94
|
-
throw new Error(`ticket request already exists: ${path}`);
|
|
95
|
-
throw error;
|
|
96
|
-
}
|
|
97
|
-
finally {
|
|
98
|
-
unlinkSync(temp);
|
|
99
|
-
}
|
|
95
|
+
if (!publishJsonExclusive(path, value))
|
|
96
|
+
throw new Error(`ticket request already exists: ${path}`);
|
|
100
97
|
}
|
|
101
98
|
export function submitDeck(opts) {
|
|
102
99
|
// Validate shape before mutating caller-owned interaction directories.
|
|
@@ -135,21 +132,7 @@ export function submitReview(opts) {
|
|
|
135
132
|
}
|
|
136
133
|
}
|
|
137
134
|
function exclusiveResult(dir, result) {
|
|
138
|
-
|
|
139
|
-
const temp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
140
|
-
writeFileSync(temp, `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600 });
|
|
141
|
-
try {
|
|
142
|
-
linkSync(temp, path);
|
|
143
|
-
return true;
|
|
144
|
-
}
|
|
145
|
-
catch (error) {
|
|
146
|
-
if (error.code === 'EEXIST')
|
|
147
|
-
return false;
|
|
148
|
-
throw error;
|
|
149
|
-
}
|
|
150
|
-
finally {
|
|
151
|
-
unlinkSync(temp);
|
|
152
|
-
}
|
|
135
|
+
return publishJsonExclusive(responsePath(dir), result);
|
|
153
136
|
}
|
|
154
137
|
function requireDeck(dir) { return validateDeck(JSON.parse(readFileSync(deckPath(dir), 'utf8'))); }
|
|
155
138
|
function requireReview(dir) { return validateReviewDescriptor(JSON.parse(readFileSync(reviewPath(dir), 'utf8'))); }
|
|
@@ -191,6 +174,10 @@ export function finalizeDeck(dir, responses, claimToken, completedAt = new Date(
|
|
|
191
174
|
requireClaimOwnership(ticket.dir, claimToken);
|
|
192
175
|
const deck = requireDeck(ticket.dir);
|
|
193
176
|
const parsedResponses = validateDeckResponses(deck, responses);
|
|
177
|
+
// A valid primary result ends its panel generation before the canonical
|
|
178
|
+
// response can cross the owner boundary. The protocol persists cancellation
|
|
179
|
+
// first; its independent cleanup executor owns eventual handler delivery.
|
|
180
|
+
void cancelVisualRequestsForTicket(ticket.root, ticket.dir);
|
|
194
181
|
const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: parsedResponses, summary: buildSummary(deck, parsedResponses), completedAt };
|
|
195
182
|
const won = exclusiveResult(ticket.dir, result);
|
|
196
183
|
clearOwnedWork(ticket.dir, claimToken);
|