@crouton-kit/humanloop 0.3.33 → 0.3.35
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 +131 -1114
- package/dist/editor/feedback.d.ts +9 -0
- package/dist/editor/feedback.js +23 -0
- package/dist/editor/review.d.ts +12 -30
- package/dist/editor/review.js +109 -119
- 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 +107 -0
- package/dist/inbox/controller.d.ts +74 -0
- package/dist/inbox/controller.js +457 -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 +24 -0
- package/dist/inbox/review-adapter.js +57 -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/render/termrender.d.ts +5 -0
- package/dist/render/termrender.js +63 -4
- 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 +190 -66
- package/dist/types.d.ts +68 -7
- package/package.json +2 -2
package/dist/inbox/convention.js
CHANGED
|
@@ -1,76 +1,36 @@
|
|
|
1
|
-
import { existsSync,
|
|
2
|
-
import { dirname } from 'path';
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { buildSummary } from '../summary.js';
|
|
4
|
-
|
|
5
|
-
export function
|
|
6
|
-
|
|
7
|
-
}
|
|
8
|
-
export function
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
export function
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
export function visualsDir(dir) {
|
|
15
|
-
return `${dir}/visuals`;
|
|
16
|
-
}
|
|
17
|
-
export function visualMdPath(dir, id) {
|
|
18
|
-
return `${dir}/visuals/${id}.md`;
|
|
19
|
-
}
|
|
20
|
-
export function visualAnsiPath(dir, id) {
|
|
21
|
-
return `${dir}/visuals/${id}.ansi`;
|
|
22
|
-
}
|
|
5
|
+
export function deckPath(dir) { return `${dir}/deck.json`; }
|
|
6
|
+
export function reviewPath(dir) { return `${dir}/review.json`; }
|
|
7
|
+
export function responsePath(dir) { return `${dir}/response.json`; }
|
|
8
|
+
export function progressPath(dir) { return `${dir}/progress.json`; }
|
|
9
|
+
export function claimPath(dir) { return `${dir}/claim.json`; }
|
|
10
|
+
export function deliveryPath(dir) { return `${dir}/delivery.json`; }
|
|
11
|
+
export function deliveryErrorPath(dir) { return `${dir}/delivery-error.json`; }
|
|
12
|
+
export function visualsDir(dir) { return `${dir}/visuals`; }
|
|
13
|
+
export function visualMdPath(dir, id) { return `${dir}/visuals/${id}.md`; }
|
|
14
|
+
export function visualAnsiPath(dir, id) { return `${dir}/visuals/${id}.ansi`; }
|
|
23
15
|
export function interactionState(dir) {
|
|
24
|
-
|
|
25
|
-
const hasResponse = existsSync(responsePath(dir));
|
|
26
|
-
const hasProgress = existsSync(progressPath(dir));
|
|
27
|
-
if (!hasDeck)
|
|
16
|
+
if (!existsSync(deckPath(dir)) && !existsSync(reviewPath(dir)))
|
|
28
17
|
return 'missing';
|
|
29
|
-
if (
|
|
18
|
+
if (existsSync(responsePath(dir)))
|
|
30
19
|
return 'resolved';
|
|
31
|
-
|
|
32
|
-
return 'in-progress';
|
|
33
|
-
return 'pending';
|
|
34
|
-
}
|
|
35
|
-
export function isResolved(dir) {
|
|
36
|
-
return existsSync(responsePath(dir));
|
|
37
|
-
}
|
|
38
|
-
/** Returns true if a live resolver owns this dir (progress.json mtime < 300s). */
|
|
39
|
-
export function isClaimed(dir) {
|
|
40
|
-
const p = progressPath(dir);
|
|
41
|
-
if (!existsSync(p))
|
|
42
|
-
return false;
|
|
43
|
-
try {
|
|
44
|
-
const { mtimeMs } = statSync(p);
|
|
45
|
-
return Date.now() - mtimeMs < 300_000;
|
|
46
|
-
}
|
|
47
|
-
catch {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
20
|
+
return existsSync(claimPath(dir)) ? 'claimed' : 'pending';
|
|
50
21
|
}
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
* Stamp the originating canvas node id onto a deck's `source` so per-node
|
|
54
|
-
* attention scoping (crouter's nav chrome) can attribute the ask to the node
|
|
55
|
-
* that raised it rather than every sibling node sharing the same cwd.
|
|
56
|
-
*
|
|
57
|
-
* No-op when not inside a canvas node (CRTR_NODE_ID unset) or when the deck
|
|
58
|
-
* already carries a nodeId. Mutates `deck` in place.
|
|
59
|
-
*/
|
|
22
|
+
export function isResolved(dir) { return existsSync(responsePath(dir)); }
|
|
23
|
+
export function isClaimed(dir) { return existsSync(claimPath(dir)); }
|
|
60
24
|
export function stampCanvasNode(deck) {
|
|
61
25
|
const id = process.env['CRTR_NODE_ID'];
|
|
62
|
-
if (id === undefined || id.trim() === '')
|
|
63
|
-
return;
|
|
64
|
-
if (deck.source?.nodeId != null && deck.source.nodeId !== '')
|
|
26
|
+
if (id === undefined || id.trim() === '' || deck.source?.nodeId)
|
|
65
27
|
return;
|
|
66
28
|
deck.source = { ...(deck.source ?? {}), nodeId: id };
|
|
67
29
|
}
|
|
68
|
-
// ── Atomic I/O ────────────────────────────────────────────────────────────────
|
|
69
30
|
export function atomicWriteJson(path, value) {
|
|
70
|
-
const
|
|
71
|
-
const tmp = `${path}.tmp`;
|
|
31
|
+
const tmp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
72
32
|
mkdirSync(dirname(path), { recursive: true });
|
|
73
|
-
writeFileSync(tmp,
|
|
33
|
+
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
74
34
|
renameSync(tmp, path);
|
|
75
35
|
}
|
|
76
36
|
export function readJson(path) {
|
|
@@ -81,31 +41,141 @@ export function readJson(path) {
|
|
|
81
41
|
return null;
|
|
82
42
|
}
|
|
83
43
|
}
|
|
84
|
-
|
|
44
|
+
function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
|
|
45
|
+
// The holder's identity lives in the NAME of its single marker file. The marker's
|
|
46
|
+
// mtime is the heartbeat and its presence is ownership, so the observed token, the
|
|
47
|
+
// staleness clock, and the reclaim gate are one atomic fact.
|
|
48
|
+
const MARKER_PREFIX = 'owner.';
|
|
49
|
+
function markerPath(path, token) { return `${path}/${MARKER_PREFIX}${token}`; }
|
|
50
|
+
function observedMarkerToken(path) {
|
|
51
|
+
try {
|
|
52
|
+
return readdirSync(path).find((entry) => entry.startsWith(MARKER_PREFIX))?.slice(MARKER_PREFIX.length) ?? null;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function ownsLock(lock) { return existsSync(markerPath(lock.path, lock.token)); }
|
|
59
|
+
function releaseDirectoryLock(lock) { if (ownsLock(lock))
|
|
60
|
+
rmSync(lock.path, { recursive: true, force: true }); }
|
|
61
|
+
function lockAge(path, token) {
|
|
62
|
+
const target = token !== null ? markerPath(path, token) : path;
|
|
63
|
+
try {
|
|
64
|
+
return Date.now() - statSync(existsSync(target) ? target : path).mtimeMs;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
// Reclamation is bound to the exact instance it observed: it unlinks ONLY that
|
|
71
|
+
// token's marker, then removes the now-empty directory with an empty-guarded
|
|
72
|
+
// rmdir. A successor lock holds a different random token (a different marker
|
|
73
|
+
// name), so a lagging reclaimer that saw the old stale lock can never strip a
|
|
74
|
+
// live successor — its unlink targets a name that no longer exists.
|
|
75
|
+
function reclaimIfStale(path, staleMs) {
|
|
76
|
+
const token = observedMarkerToken(path);
|
|
77
|
+
if (lockAge(path, token) <= staleMs)
|
|
78
|
+
return;
|
|
79
|
+
if (token !== null) {
|
|
80
|
+
try {
|
|
81
|
+
unlinkSync(markerPath(path, token));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code === 'ENOENT')
|
|
85
|
+
return;
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
rmdirSync(path);
|
|
91
|
+
}
|
|
92
|
+
catch (error) {
|
|
93
|
+
const code = error.code;
|
|
94
|
+
if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST')
|
|
95
|
+
throw error;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function tryAcquireDirectoryLock(path, staleMs) {
|
|
99
|
+
const token = randomUUID();
|
|
100
|
+
try {
|
|
101
|
+
mkdirSync(path, { mode: 0o700 });
|
|
102
|
+
writeFileSync(markerPath(path, token), '', { flag: 'wx', mode: 0o600 });
|
|
103
|
+
return { path, token };
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
if (error.code !== 'EEXIST')
|
|
107
|
+
throw error;
|
|
108
|
+
reclaimIfStale(path, staleMs);
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
function acquireDirectoryLock(path, staleMs, timeoutMs) {
|
|
113
|
+
const startedAt = Date.now();
|
|
114
|
+
while (true) {
|
|
115
|
+
const lock = tryAcquireDirectoryLock(path, staleMs);
|
|
116
|
+
if (lock !== null)
|
|
117
|
+
return lock;
|
|
118
|
+
if (Date.now() - startedAt >= timeoutMs)
|
|
119
|
+
throw new Error('exclusive operation lock acquisition timed out');
|
|
120
|
+
pause(5);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
async function acquireDirectoryLockAsync(path, staleMs, timeoutMs) {
|
|
124
|
+
const startedAt = Date.now();
|
|
125
|
+
while (true) {
|
|
126
|
+
const lock = tryAcquireDirectoryLock(path, staleMs);
|
|
127
|
+
if (lock !== null)
|
|
128
|
+
return lock;
|
|
129
|
+
if (Date.now() - startedAt >= timeoutMs)
|
|
130
|
+
throw new Error('exclusive operation lock acquisition timed out');
|
|
131
|
+
await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
|
|
135
|
+
export function withExclusiveDirectoryLock(path, operation, options = {}) {
|
|
136
|
+
const lock = acquireDirectoryLock(path, options.staleMs ?? 30_000, options.timeoutMs ?? 5_000);
|
|
137
|
+
try {
|
|
138
|
+
return operation();
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
releaseDirectoryLock(lock);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** Async counterpart heartbeats while its operation runs, so a valid long handler is never stolen. */
|
|
145
|
+
export async function withExclusiveDirectoryLockAsync(path, operation, options = {}) {
|
|
146
|
+
const staleMs = options.staleMs ?? 35_000;
|
|
147
|
+
const lock = await acquireDirectoryLockAsync(path, staleMs, options.timeoutMs ?? staleMs + 5_000);
|
|
148
|
+
const heartbeat = setInterval(() => {
|
|
149
|
+
if (ownsLock(lock)) {
|
|
150
|
+
try {
|
|
151
|
+
utimesSync(markerPath(lock.path, lock.token), new Date(), new Date());
|
|
152
|
+
}
|
|
153
|
+
catch { /* a reclaimed lock is no longer ours */ }
|
|
154
|
+
}
|
|
155
|
+
}, 1_000);
|
|
156
|
+
try {
|
|
157
|
+
return await operation();
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
clearInterval(heartbeat);
|
|
161
|
+
releaseDirectoryLock(lock);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// Kept for the existing panel until H2 routes all finalization through tickets.ts.
|
|
85
165
|
export function writeResponse(dir, responses, completedAt, deck) {
|
|
86
|
-
const
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// silently emitting ''). When the deck is known at write time we compute it
|
|
90
|
-
// here; the deck is deterministic input so this never diverges from ask()'s
|
|
91
|
-
// envelope summary.
|
|
92
|
-
const summary = deck !== undefined ? buildSummary(deck, responses) : '';
|
|
93
|
-
atomicWriteJson(p, { responses, completedAt, summary });
|
|
94
|
-
return p;
|
|
166
|
+
const summary = deck === undefined ? '' : buildSummary(deck, responses);
|
|
167
|
+
atomicWriteJson(responsePath(dir), { schema: 'humanloop.response/v2', kind: 'deck', responses, summary, completedAt });
|
|
168
|
+
return responsePath(dir);
|
|
95
169
|
}
|
|
96
170
|
export function writeProgress(dir, responses) {
|
|
97
|
-
atomicWriteJson(progressPath(dir), {
|
|
98
|
-
partial: true,
|
|
99
|
-
responses,
|
|
100
|
-
savedAt: new Date().toISOString(),
|
|
101
|
-
});
|
|
171
|
+
atomicWriteJson(progressPath(dir), { kind: 'deck', responses, savedAt: new Date().toISOString() });
|
|
102
172
|
}
|
|
103
173
|
export function clearProgress(dir) {
|
|
104
174
|
try {
|
|
105
175
|
unlinkSync(progressPath(dir));
|
|
106
176
|
}
|
|
107
|
-
catch (
|
|
108
|
-
if (
|
|
109
|
-
throw
|
|
177
|
+
catch (error) {
|
|
178
|
+
if (error.code !== 'ENOENT')
|
|
179
|
+
throw error;
|
|
110
180
|
}
|
|
111
181
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Deck, InteractionResponse } from '../types.js';
|
|
2
|
+
import type { Key } from '../tui/terminal.js';
|
|
3
|
+
export interface DeckAdapterOptions {
|
|
4
|
+
dir: string;
|
|
5
|
+
deck: Deck;
|
|
6
|
+
cols: number;
|
|
7
|
+
rows: number;
|
|
8
|
+
onProgress?: (responses: InteractionResponse[]) => void;
|
|
9
|
+
onComplete: (responses: InteractionResponse[]) => void;
|
|
10
|
+
onBack: () => void;
|
|
11
|
+
onDirty: () => void;
|
|
12
|
+
}
|
|
13
|
+
/** Embeds the single deck renderer in a controller-owned rectangle. */
|
|
14
|
+
export declare class DeckAdapter {
|
|
15
|
+
private readonly opts;
|
|
16
|
+
private panel;
|
|
17
|
+
private responses;
|
|
18
|
+
constructor(opts: DeckAdapterOptions);
|
|
19
|
+
render(): string[];
|
|
20
|
+
resize(cols: number, rows: number): string[];
|
|
21
|
+
inputBuffer(): string | undefined;
|
|
22
|
+
canAcceptHostKeys(): boolean;
|
|
23
|
+
handleKey(input: string, key: Key): void;
|
|
24
|
+
/** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
|
|
25
|
+
reload(): void;
|
|
26
|
+
close(): void;
|
|
27
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { deckPath, progressPath, readJson } from './convention.js';
|
|
3
|
+
import { validateDeck } from './deck-schema.js';
|
|
4
|
+
import { mountPanel } from '../tui/app.js';
|
|
5
|
+
/** Embeds the single deck renderer in a controller-owned rectangle. */
|
|
6
|
+
export class DeckAdapter {
|
|
7
|
+
opts;
|
|
8
|
+
panel;
|
|
9
|
+
responses = [];
|
|
10
|
+
constructor(opts) {
|
|
11
|
+
this.opts = opts;
|
|
12
|
+
this.responses = initialResponses(opts.deck, opts.dir);
|
|
13
|
+
this.panel = mountPanel({
|
|
14
|
+
deck: opts.deck,
|
|
15
|
+
progressPath: progressPath(opts.dir),
|
|
16
|
+
cols: opts.cols,
|
|
17
|
+
rows: opts.rows,
|
|
18
|
+
onProgress: (responses) => { this.responses = responses; opts.onProgress?.(responses); },
|
|
19
|
+
onComplete: opts.onComplete,
|
|
20
|
+
onExit: () => opts.onComplete(this.responses),
|
|
21
|
+
onDirty: opts.onDirty,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
render() { return this.panel.render(); }
|
|
25
|
+
resize(cols, rows) { return this.panel.handleResize(cols, rows); }
|
|
26
|
+
inputBuffer() { return this.panel.getInputBuffer(); }
|
|
27
|
+
canAcceptHostKeys() { return this.panel.canAcceptHostKeys(); }
|
|
28
|
+
handleKey(input, key) {
|
|
29
|
+
if (key.escape && this.panel.atDeckTop()) {
|
|
30
|
+
this.opts.onBack();
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
this.panel.handleKey(input, key);
|
|
34
|
+
}
|
|
35
|
+
/** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
|
|
36
|
+
reload() {
|
|
37
|
+
try {
|
|
38
|
+
this.panel.loadDeck(validateDeck(JSON.parse(readFileSync(deckPath(this.opts.dir), 'utf8'))), { progressPath: progressPath(this.opts.dir) });
|
|
39
|
+
this.opts.onDirty();
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// An incomplete external rewrite is not a new deck; retain the current editor.
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
close() { this.panel.unmount(); }
|
|
46
|
+
}
|
|
47
|
+
function initialResponses(deck, dir) {
|
|
48
|
+
const saved = readJson(progressPath(dir))?.responses;
|
|
49
|
+
if (Array.isArray(saved))
|
|
50
|
+
return saved;
|
|
51
|
+
return deck.interactions.flatMap((interaction) => {
|
|
52
|
+
const answer = interaction.preAnswered;
|
|
53
|
+
if (answer === undefined)
|
|
54
|
+
return [];
|
|
55
|
+
return [{ id: interaction.id, ...(answer.selectedOptionId === undefined ? {} : { selectedOptionId: answer.selectedOptionId }), ...(answer.selectedOptionIds === undefined ? {} : { selectedOptionIds: [...answer.selectedOptionIds] }), ...(answer.freetext === undefined ? {} : { freetext: answer.freetext }) }];
|
|
56
|
+
});
|
|
57
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import type { Deck } from '../types.js';
|
|
2
|
+
import type { Deck, ReviewDescriptor } from '../types.js';
|
|
3
3
|
export declare const interactionOptionSchema: z.ZodObject<{
|
|
4
4
|
id: z.ZodString;
|
|
5
5
|
label: z.ZodString;
|
|
@@ -48,18 +48,22 @@ export declare const deckSchema: z.ZodObject<{
|
|
|
48
48
|
}, z.core.$strip>>;
|
|
49
49
|
}, z.core.$strip>>;
|
|
50
50
|
}, z.core.$strip>;
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
51
|
+
export declare const reviewDescriptorSchema: z.ZodObject<{
|
|
52
|
+
schema: z.ZodLiteral<"humanloop.review/v1">;
|
|
53
|
+
file: z.ZodString;
|
|
54
|
+
output: z.ZodString;
|
|
55
|
+
title: z.ZodString;
|
|
56
|
+
source: z.ZodObject<{
|
|
57
|
+
sessionName: z.ZodOptional<z.ZodString>;
|
|
58
|
+
askedBy: z.ZodOptional<z.ZodString>;
|
|
59
|
+
blockedSince: z.ZodOptional<z.ZodString>;
|
|
60
|
+
nodeId: z.ZodOptional<z.ZodString>;
|
|
61
|
+
}, z.core.$strip>;
|
|
62
|
+
blockedSince: z.ZodString;
|
|
63
|
+
}, z.core.$strict>;
|
|
63
64
|
export declare function resolveDeckBodyPaths(deck: Deck, dir: string): Deck;
|
|
64
|
-
export declare function parseDeck(
|
|
65
|
+
export declare function parseDeck(path: string): Deck;
|
|
65
66
|
export declare function validateDeck(parsed: unknown): Deck;
|
|
67
|
+
export declare function validateReviewDescriptor(parsed: unknown): ReviewDescriptor;
|
|
68
|
+
/** Canonicalize and authorize the one review projection boundary before every write. */
|
|
69
|
+
export declare function validateReviewProjection(dir: string, parsed: unknown): ReviewDescriptor;
|
|
@@ -1,143 +1,85 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync, realpathSync } from 'node:fs';
|
|
2
|
-
import { dirname, resolve, sep } from 'node:path';
|
|
2
|
+
import { basename, dirname, isAbsolute, resolve, sep } from 'node:path';
|
|
3
|
+
import { claimPath, deckPath, deliveryErrorPath, deliveryPath, progressPath, responsePath, reviewPath } from './convention.js';
|
|
3
4
|
import { z } from 'zod';
|
|
4
5
|
import { INTERACTION_KINDS } from '../types.js';
|
|
5
6
|
import { checkMarkdown } from '../render/termrender.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
export const interactionOptionSchema = z.object({
|
|
9
|
-
id: z.string().min(1),
|
|
10
|
-
label: z.string().min(1),
|
|
11
|
-
description: z.string().optional(),
|
|
12
|
-
// No author-settable `shortcut`: option shortcuts are auto-assigned by the
|
|
13
|
-
// TUI (assignShortcuts). zod strips unknown keys, so any author-supplied
|
|
14
|
-
// `shortcut` is silently dropped here — this is the enforcement boundary that
|
|
15
|
-
// stops a deck from shadowing a reserved key (e.g. `c` = comment).
|
|
16
|
-
});
|
|
17
|
-
export const preAnswerSchema = z.object({
|
|
18
|
-
selectedOptionId: z.string().optional(),
|
|
19
|
-
selectedOptionIds: z.array(z.string()).optional(),
|
|
20
|
-
freetext: z.string().optional(),
|
|
21
|
-
label: z.string().optional(),
|
|
22
|
-
});
|
|
7
|
+
export const interactionOptionSchema = z.object({ id: z.string().min(1), label: z.string().min(1), description: z.string().optional() });
|
|
8
|
+
export const preAnswerSchema = z.object({ selectedOptionId: z.string().optional(), selectedOptionIds: z.array(z.string()).optional(), freetext: z.string().optional(), label: z.string().optional() });
|
|
23
9
|
const interactionSchema = z.object({
|
|
24
10
|
id: z.string().regex(/^[A-Za-z0-9_-]+$/, { error: 'interaction id must match /^[A-Za-z0-9_-]+$/' }).min(1).max(64),
|
|
25
|
-
title: z.string().min(1,
|
|
26
|
-
|
|
27
|
-
body: z.string().optional(),
|
|
28
|
-
bodyPath: z.string().optional(),
|
|
29
|
-
options: z.array(interactionOptionSchema),
|
|
30
|
-
multiSelect: z.boolean().optional(),
|
|
31
|
-
allowFreetext: z.boolean().optional(),
|
|
32
|
-
freetextLabel: z.string().optional(),
|
|
33
|
-
kind: z.enum(INTERACTION_KINDS).optional(),
|
|
34
|
-
preAnswered: preAnswerSchema.optional(),
|
|
35
|
-
});
|
|
36
|
-
const deckSourceSchema = z.object({
|
|
37
|
-
sessionName: z.string().optional(),
|
|
38
|
-
askedBy: z.string().optional(),
|
|
39
|
-
blockedSince: z.string().optional(),
|
|
40
|
-
nodeId: z.string().optional(),
|
|
11
|
+
title: z.string().min(1), subtitle: z.string().min(1).optional(), body: z.string().optional(), bodyPath: z.string().optional(),
|
|
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(),
|
|
41
13
|
});
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
interactions
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
code: 'custom',
|
|
53
|
-
message: 'body and bodyPath are mutually exclusive',
|
|
54
|
-
path: ['interactions', i],
|
|
55
|
-
});
|
|
56
|
-
}
|
|
57
|
-
const prev = seen.get(interaction.id);
|
|
58
|
-
if (prev !== undefined) {
|
|
59
|
-
ctx.addIssue({
|
|
60
|
-
code: 'custom',
|
|
61
|
-
message: `duplicate interaction id "${interaction.id}" at indices ${prev} and ${i}`,
|
|
62
|
-
path: ['interactions', i, 'id'],
|
|
63
|
-
});
|
|
64
|
-
}
|
|
65
|
-
seen.set(interaction.id, i);
|
|
66
|
-
}
|
|
14
|
+
const deckSourceSchema = z.object({ sessionName: z.string().optional(), askedBy: z.string().optional(), blockedSince: z.string().optional(), nodeId: z.string().optional() });
|
|
15
|
+
export const deckSchema = z.object({ title: z.string().optional(), source: deckSourceSchema.optional(), interactions: z.array(interactionSchema).min(1) }).superRefine((input, ctx) => {
|
|
16
|
+
const seen = new Set();
|
|
17
|
+
input.interactions.forEach((interaction, index) => {
|
|
18
|
+
if (interaction.body !== undefined && interaction.bodyPath !== undefined)
|
|
19
|
+
ctx.addIssue({ code: 'custom', message: 'body and bodyPath are mutually exclusive', path: ['interactions', index] });
|
|
20
|
+
if (seen.has(interaction.id))
|
|
21
|
+
ctx.addIssue({ code: 'custom', message: `duplicate interaction id "${interaction.id}"`, path: ['interactions', index, 'id'] });
|
|
22
|
+
seen.add(interaction.id);
|
|
23
|
+
});
|
|
67
24
|
});
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
25
|
+
const reviewSourceSchema = deckSourceSchema;
|
|
26
|
+
export const reviewDescriptorSchema = z.object({
|
|
27
|
+
schema: z.literal('humanloop.review/v1'),
|
|
28
|
+
file: z.string().min(1).refine(isAbsolute, 'file must be absolute'),
|
|
29
|
+
output: z.string().min(1).refine(isAbsolute, 'output must be absolute'),
|
|
30
|
+
title: z.string().min(1),
|
|
31
|
+
source: reviewSourceSchema,
|
|
32
|
+
blockedSince: z.string().datetime({ offset: true }),
|
|
33
|
+
}).strict();
|
|
74
34
|
function readBodyPathFile(dir, bodyPath) {
|
|
75
35
|
const joined = resolve(dir, bodyPath);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const stat = lstatSync(joined);
|
|
81
|
-
if (!stat.isFile()) {
|
|
82
|
-
// Catches symlinks, directories, FIFOs — lstat does not follow symlinks.
|
|
83
|
-
throw new Error(`bodyPath must be a regular file (not a symlink, directory, or special file): ${bodyPath}`);
|
|
84
|
-
}
|
|
85
|
-
// STEP 2: realpath both sides, prefix-check (defense-in-depth for .. traversal).
|
|
86
|
-
// realpathSync is safe here: lstat already confirmed the path exists.
|
|
36
|
+
if (!existsSync(joined))
|
|
37
|
+
throw new Error(`bodyPath does not exist: '${bodyPath}'`);
|
|
38
|
+
if (!lstatSync(joined).isFile())
|
|
39
|
+
throw new Error(`bodyPath must be a regular file: ${bodyPath}`);
|
|
87
40
|
const realResolved = realpathSync(joined);
|
|
88
41
|
const realDeckDir = realpathSync(dir);
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
// STEP 3: read. lstat confirmed regular file; realpath confirmed in-tree.
|
|
94
|
-
return readFileSync(joined, 'utf-8');
|
|
42
|
+
if (!realResolved.startsWith(realDeckDir + sep))
|
|
43
|
+
throw new Error(`bodyPath '${bodyPath}' escapes the deck directory`);
|
|
44
|
+
return readFileSync(joined, 'utf8');
|
|
95
45
|
}
|
|
96
|
-
/**
|
|
97
|
-
* The ONE canonical `bodyPath` → `body` normalization boundary. Resolves every
|
|
98
|
-
* interaction's `bodyPath` (relative to `dir`, the interaction directory) into
|
|
99
|
-
* `body` and strips `bodyPath` from the result.
|
|
100
|
-
*
|
|
101
|
-
* Call this once, right before a deck is (re)written to `<dir>/deck.json` —
|
|
102
|
-
* `hl deck ask`, `hl deck update`, and the public `ask()` API all do — so
|
|
103
|
-
* every reader downstream (the terminal TUI's render + its live-reload
|
|
104
|
-
* poller, the browser server's `/api/interaction`) only ever sees a plain
|
|
105
|
-
* `body` and never has to special-case `bodyPath` itself. `parseDeck` (below)
|
|
106
|
-
* reuses this same function when reading a deck straight off disk.
|
|
107
|
-
*/
|
|
108
46
|
export function resolveDeckBodyPaths(deck, dir) {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return { ...rest, body };
|
|
116
|
-
});
|
|
117
|
-
return { ...deck, interactions };
|
|
47
|
+
return { ...deck, interactions: deck.interactions.map((interaction) => {
|
|
48
|
+
if (interaction.bodyPath === undefined)
|
|
49
|
+
return interaction;
|
|
50
|
+
const { bodyPath: _bodyPath, ...rest } = interaction;
|
|
51
|
+
return { ...rest, body: readBodyPathFile(dir, interaction.bodyPath) };
|
|
52
|
+
}) };
|
|
118
53
|
}
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const raw = readFileSync(deckPath, 'utf-8');
|
|
122
|
-
let json;
|
|
54
|
+
export function parseDeck(path) {
|
|
55
|
+
let raw;
|
|
123
56
|
try {
|
|
124
|
-
|
|
57
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
125
58
|
}
|
|
126
59
|
catch {
|
|
127
60
|
throw new Error('deck is not valid JSON');
|
|
128
61
|
}
|
|
129
|
-
const
|
|
130
|
-
const
|
|
131
|
-
for (const interaction of resolved.interactions) {
|
|
62
|
+
const deck = resolveDeckBodyPaths(deckSchema.parse(raw), dirname(path));
|
|
63
|
+
for (const interaction of deck.interactions)
|
|
132
64
|
if (interaction.body !== undefined) {
|
|
133
65
|
const check = checkMarkdown(interaction.body);
|
|
134
|
-
if (!check.ok)
|
|
66
|
+
if (!check.ok)
|
|
135
67
|
throw new Error(check.error);
|
|
136
|
-
}
|
|
137
68
|
}
|
|
138
|
-
|
|
139
|
-
return resolved;
|
|
69
|
+
return deck;
|
|
140
70
|
}
|
|
141
|
-
export function validateDeck(parsed) {
|
|
142
|
-
|
|
71
|
+
export function validateDeck(parsed) { return deckSchema.parse(parsed); }
|
|
72
|
+
export function validateReviewDescriptor(parsed) { return reviewDescriptorSchema.parse(parsed); }
|
|
73
|
+
/** Canonicalize and authorize the one review projection boundary before every write. */
|
|
74
|
+
export function validateReviewProjection(dir, parsed) {
|
|
75
|
+
const descriptor = validateReviewDescriptor(parsed);
|
|
76
|
+
if (!/\.md(?:own)?$/i.test(descriptor.file) || !existsSync(descriptor.file))
|
|
77
|
+
throw new Error('review file must be an existing absolute markdown file');
|
|
78
|
+
const file = realpathSync(descriptor.file);
|
|
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']);
|
|
81
|
+
const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir)]);
|
|
82
|
+
if (output === file || reserved.has(basename(output)) || ownProtocolPaths.has(output))
|
|
83
|
+
throw new Error('review output must not alias the source or ticket protocol files');
|
|
84
|
+
return { ...descriptor, file, output };
|
|
143
85
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface InboxLayout {
|
|
2
|
+
mode: 'two-column' | 'list' | 'detail' | 'minimum';
|
|
3
|
+
listWidth: number;
|
|
4
|
+
detailWidth: number;
|
|
5
|
+
height: number;
|
|
6
|
+
}
|
|
7
|
+
/** Pure geometry for the centralized inbox surface. */
|
|
8
|
+
export declare function inboxLayout(cols: number, rows: number, screen?: 'list' | 'detail'): InboxLayout;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Pure geometry for the centralized inbox surface. */
|
|
2
|
+
export function inboxLayout(cols, rows, screen = 'list') {
|
|
3
|
+
if (cols < 60 || rows < 18)
|
|
4
|
+
return { mode: 'minimum', listWidth: cols, detailWidth: 0, height: rows };
|
|
5
|
+
if (cols < 96)
|
|
6
|
+
return { mode: screen, listWidth: cols, detailWidth: cols, height: rows };
|
|
7
|
+
const listWidth = Math.max(30, Math.min(44, Math.floor(cols / 3)));
|
|
8
|
+
return { mode: 'two-column', listWidth, detailWidth: cols - listWidth - 1, height: rows };
|
|
9
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { CompletionEvent } from '../types.js';
|
|
2
|
+
export interface CompletionHandler {
|
|
3
|
+
command: string;
|
|
4
|
+
args: string[];
|
|
5
|
+
}
|
|
6
|
+
export interface InboxRootRegistration {
|
|
7
|
+
schema: 'humanloop.inbox-root/v1';
|
|
8
|
+
root: string;
|
|
9
|
+
owner: string;
|
|
10
|
+
handler?: CompletionHandler;
|
|
11
|
+
}
|
|
12
|
+
export interface InboxRootStatus extends InboxRootRegistration {
|
|
13
|
+
available: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface RegisterInboxRootOptions {
|
|
16
|
+
root: string;
|
|
17
|
+
owner: string;
|
|
18
|
+
handler?: CompletionHandler;
|
|
19
|
+
}
|
|
20
|
+
export declare function inboxRootsDirectory(): string;
|
|
21
|
+
/** Create/canonicalize a root and claim its user-scoped registration. */
|
|
22
|
+
export declare function registerInboxRoot(opts: RegisterInboxRootOptions): InboxRootRegistration;
|
|
23
|
+
/** Removes a matching available root through its real path, or an unavailable record by its stored canonical path. */
|
|
24
|
+
export declare function unregisterInboxRoot(root: string, owner: string): boolean;
|
|
25
|
+
export declare function listInboxRoots(): InboxRootStatus[];
|
|
26
|
+
export declare function registeredInboxRoot(root: string): InboxRootRegistration | null;
|
|
27
|
+
/** The managed SDK root is durable, user-scoped, and owned by humanloop. */
|
|
28
|
+
export declare function managedInboxRoot(): InboxRootRegistration;
|
|
29
|
+
export type CompletionHandlerEvent = CompletionEvent;
|