@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
|
@@ -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;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { join, resolve } from 'node:path';
|
|
5
|
+
import { atomicWriteJson, readJson, withExclusiveDirectoryLock } from './convention.js';
|
|
6
|
+
function stateHome() { return process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state'); }
|
|
7
|
+
export function inboxRootsDirectory() { return join(stateHome(), 'humanloop', 'inbox-roots'); }
|
|
8
|
+
function recordPath(root) { return join(inboxRootsDirectory(), createHash('sha256').update(root).digest('hex')); }
|
|
9
|
+
function canonicalRoot(root) { try {
|
|
10
|
+
return realpathSync(root);
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return resolve(root);
|
|
14
|
+
} }
|
|
15
|
+
function validateHandler(handler) {
|
|
16
|
+
if (handler === undefined)
|
|
17
|
+
return undefined;
|
|
18
|
+
if (!handler.command || !Array.isArray(handler.args) || !handler.args.every((arg) => typeof arg === 'string'))
|
|
19
|
+
throw new Error('completion handler requires a command and string args');
|
|
20
|
+
return { command: handler.command, args: [...handler.args] };
|
|
21
|
+
}
|
|
22
|
+
function validateRegistration(raw) {
|
|
23
|
+
if (typeof raw !== 'object' || raw === null)
|
|
24
|
+
return null;
|
|
25
|
+
const value = raw;
|
|
26
|
+
if (value.schema !== 'humanloop.inbox-root/v1' || typeof value.root !== 'string' || !value.root || typeof value.owner !== 'string' || !value.owner.trim())
|
|
27
|
+
return null;
|
|
28
|
+
try {
|
|
29
|
+
return { schema: 'humanloop.inbox-root/v1', root: value.root, owner: value.owner, handler: value.handler === undefined ? undefined : validateHandler(value.handler) };
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/** Create/canonicalize a root and claim its user-scoped registration. */
|
|
36
|
+
export function registerInboxRoot(opts) {
|
|
37
|
+
if (!opts.owner.trim())
|
|
38
|
+
throw new Error('inbox root owner must be non-empty');
|
|
39
|
+
mkdirSync(resolve(opts.root), { recursive: true, mode: 0o700 });
|
|
40
|
+
const root = realpathSync(opts.root);
|
|
41
|
+
const path = recordPath(root);
|
|
42
|
+
mkdirSync(inboxRootsDirectory(), { recursive: true, mode: 0o700 });
|
|
43
|
+
return withExclusiveDirectoryLock(`${path}.lock`, () => {
|
|
44
|
+
const existing = validateRegistration(readJson(path));
|
|
45
|
+
if (existing !== null && existing.root === root && existing.owner !== opts.owner)
|
|
46
|
+
throw new Error(`inbox root is already owned by ${existing.owner}`);
|
|
47
|
+
if (existing !== null && existing.root !== root)
|
|
48
|
+
throw new Error('inbox root registry hash collision');
|
|
49
|
+
const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler) };
|
|
50
|
+
atomicWriteJson(path, registration);
|
|
51
|
+
chmodSync(path, 0o600);
|
|
52
|
+
return registration;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/** Removes a matching available root through its real path, or an unavailable record by its stored canonical path. */
|
|
56
|
+
export function unregisterInboxRoot(root, owner) {
|
|
57
|
+
const canonical = canonicalRoot(root);
|
|
58
|
+
const path = recordPath(canonical);
|
|
59
|
+
return withExclusiveDirectoryLock(`${path}.lock`, () => {
|
|
60
|
+
const existing = validateRegistration(readJson(path));
|
|
61
|
+
if (existing === null || existing.root !== canonical || existing.owner !== owner)
|
|
62
|
+
return false;
|
|
63
|
+
unlinkSync(path);
|
|
64
|
+
return true;
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
export function listInboxRoots() {
|
|
68
|
+
let files;
|
|
69
|
+
try {
|
|
70
|
+
files = readdirSync(inboxRootsDirectory());
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return [];
|
|
74
|
+
}
|
|
75
|
+
const roots = [];
|
|
76
|
+
for (const file of files) {
|
|
77
|
+
const record = validateRegistration(readJson(join(inboxRootsDirectory(), file)));
|
|
78
|
+
if (record !== null)
|
|
79
|
+
roots.push({ ...record, available: existsSync(record.root) });
|
|
80
|
+
}
|
|
81
|
+
return roots.sort((a, b) => a.root.localeCompare(b.root));
|
|
82
|
+
}
|
|
83
|
+
export function registeredInboxRoot(root) {
|
|
84
|
+
let canonical;
|
|
85
|
+
try {
|
|
86
|
+
canonical = realpathSync(root);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
const record = validateRegistration(readJson(recordPath(canonical)));
|
|
92
|
+
return record?.root === canonical ? record : null;
|
|
93
|
+
}
|
|
94
|
+
/** The managed SDK root is durable, user-scoped, and owned by humanloop. */
|
|
95
|
+
export function managedInboxRoot() {
|
|
96
|
+
return registerInboxRoot({ root: join(stateHome(), 'humanloop', 'inbox'), owner: 'humanloop' });
|
|
97
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type TicketClaim } from './claim.js';
|
|
2
|
+
import { type ReviewOptions } from '../editor/review.js';
|
|
3
|
+
import type { ReviewDescriptor, TicketResult } from '../types.js';
|
|
4
|
+
export interface ReviewAdapterOptions {
|
|
5
|
+
dir: string;
|
|
6
|
+
descriptor: ReviewDescriptor;
|
|
7
|
+
claim: TicketClaim;
|
|
8
|
+
editor?: ReviewOptions['editor'];
|
|
9
|
+
onSubmitted?: (result: TicketResult) => Promise<void> | void;
|
|
10
|
+
}
|
|
11
|
+
/** Controller-owned native review child. It persists drafts but delegates the sole final write to tickets.ts. */
|
|
12
|
+
export declare class ReviewAdapter {
|
|
13
|
+
private readonly opts;
|
|
14
|
+
private readonly abortController;
|
|
15
|
+
private readonly heartbeat;
|
|
16
|
+
private running;
|
|
17
|
+
private stopped;
|
|
18
|
+
constructor(opts: ReviewAdapterOptions);
|
|
19
|
+
start(): Promise<TicketResult | null>;
|
|
20
|
+
/** Toggle, cancellation, and controller teardown all take this same path. */
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { completeReview } from './tickets.js';
|
|
2
|
+
import { heartbeatClaim, releaseClaim } from './claim.js';
|
|
3
|
+
import { progressPath } from './convention.js';
|
|
4
|
+
import { launchReview } from '../editor/review.js';
|
|
5
|
+
/** Controller-owned native review child. It persists drafts but delegates the sole final write to tickets.ts. */
|
|
6
|
+
export class ReviewAdapter {
|
|
7
|
+
opts;
|
|
8
|
+
abortController = new AbortController();
|
|
9
|
+
heartbeat;
|
|
10
|
+
running = null;
|
|
11
|
+
stopped = false;
|
|
12
|
+
constructor(opts) {
|
|
13
|
+
this.opts = opts;
|
|
14
|
+
this.heartbeat = setInterval(() => {
|
|
15
|
+
if (!this.stopped)
|
|
16
|
+
heartbeatClaim(this.opts.dir, this.opts.claim.token);
|
|
17
|
+
}, 10_000);
|
|
18
|
+
}
|
|
19
|
+
start() {
|
|
20
|
+
if (this.running !== null)
|
|
21
|
+
return this.running;
|
|
22
|
+
let canonical = null;
|
|
23
|
+
this.running = launchReview(this.opts.descriptor.file, {
|
|
24
|
+
output: progressPath(this.opts.dir),
|
|
25
|
+
jobDir: this.opts.dir,
|
|
26
|
+
editor: this.opts.editor,
|
|
27
|
+
signal: this.abortController.signal,
|
|
28
|
+
onPropose: async (proposal) => {
|
|
29
|
+
if (this.stopped || this.abortController.signal.aborted)
|
|
30
|
+
return;
|
|
31
|
+
const completed = await completeReview(this.opts.dir, proposal, this.opts.claim.token);
|
|
32
|
+
canonical = completed.result;
|
|
33
|
+
await this.opts.onSubmitted?.(canonical);
|
|
34
|
+
},
|
|
35
|
+
}).then(() => canonical).finally(() => {
|
|
36
|
+
clearInterval(this.heartbeat);
|
|
37
|
+
releaseClaim(this.opts.dir, this.opts.claim.token);
|
|
38
|
+
this.stopped = true;
|
|
39
|
+
});
|
|
40
|
+
return this.running;
|
|
41
|
+
}
|
|
42
|
+
/** Toggle, cancellation, and controller teardown all take this same path. */
|
|
43
|
+
async stop() {
|
|
44
|
+
if (this.stopped)
|
|
45
|
+
return;
|
|
46
|
+
this.stopped = true;
|
|
47
|
+
this.abortController.abort();
|
|
48
|
+
try {
|
|
49
|
+
await this.running;
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
clearInterval(this.heartbeat);
|
|
53
|
+
releaseClaim(this.opts.dir, this.opts.claim.token);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
package/dist/inbox/scan.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
|
|
1
|
+
import type { TicketSummary } from '../types.js';
|
|
2
|
+
/** Read all unresolved deck/review tickets, newest first. Progress never affects visibility. */
|
|
3
|
+
export declare function scanInbox(roots?: string[]): TicketSummary[];
|