@crouton-kit/humanloop 0.3.38 → 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/browser/server.d.ts +15 -5
- package/dist/browser/server.js +110 -14
- package/dist/cli.js +0 -0
- package/dist/editor/roundtrip.d.ts +5 -0
- package/dist/editor/roundtrip.js +36 -0
- package/dist/inbox/completion.js +1 -27
- package/dist/inbox/controller.d.ts +32 -3
- package/dist/inbox/controller.js +427 -35
- package/dist/inbox/convention.d.ts +9 -2
- package/dist/inbox/convention.js +51 -3
- package/dist/inbox/deck-adapter.d.ts +10 -2
- package/dist/inbox/deck-adapter.js +23 -12
- package/dist/inbox/deck-schema.d.ts +2 -0
- package/dist/inbox/deck-schema.js +4 -4
- package/dist/inbox/followup.d.ts +57 -0
- package/dist/inbox/followup.js +117 -0
- package/dist/inbox/registry.d.ts +4 -0
- package/dist/inbox/registry.js +9 -4
- package/dist/inbox/tickets.d.ts +5 -0
- package/dist/inbox/tickets.js +25 -32
- package/dist/inbox/tui.d.ts +1 -1
- package/dist/inbox/tui.js +58 -25
- package/dist/inbox/visual.d.ts +130 -0
- package/dist/inbox/visual.js +747 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +2 -1
- package/dist/tui/app.d.ts +3 -6
- package/dist/tui/app.js +130 -97
- package/dist/tui/input.d.ts +5 -1
- package/dist/tui/input.js +41 -15
- package/dist/tui/log.d.ts +2 -0
- package/dist/tui/log.js +53 -0
- package/dist/tui/render.js +44 -27
- package/dist/tui/terminal.d.ts +1 -0
- package/dist/tui/terminal.js +5 -0
- package/dist/tui/tmux.js +44 -22
- package/dist/types.d.ts +71 -7
- package/dist/web/assets/{index-DhbBiRqS.js → index-YFwgZZMg.js} +2 -2
- package/dist/web/index.html +1 -1
- package/package.json +6 -3
- package/dist/conversation/reader.d.ts +0 -6
- package/dist/conversation/reader.js +0 -58
- package/dist/visuals/generate.d.ts +0 -9
- package/dist/visuals/generate.js +0 -79
|
@@ -6,15 +6,22 @@ export declare function progressPath(dir: string): string;
|
|
|
6
6
|
export declare function claimPath(dir: string): string;
|
|
7
7
|
export declare function deliveryPath(dir: string): string;
|
|
8
8
|
export declare function deliveryErrorPath(dir: string): string;
|
|
9
|
+
export declare function followupRequestPath(dir: string): string;
|
|
10
|
+
export declare function followupResultPath(dir: string): string;
|
|
9
11
|
export declare function visualsDir(dir: string): string;
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
/** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
|
|
13
|
+
* acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
|
|
14
|
+
* Error carrying the handler's captured stderr. Shared by completion delivery
|
|
15
|
+
* and follow-up dispatch — both invoke a registered handler the same way. */
|
|
16
|
+
export declare function runHandler(command: string, args: string[], event: unknown): Promise<void>;
|
|
12
17
|
export type InteractionState = 'pending' | 'claimed' | 'resolved' | 'missing';
|
|
13
18
|
export declare function interactionState(dir: string): InteractionState;
|
|
14
19
|
export declare function isResolved(dir: string): boolean;
|
|
15
20
|
export declare function isClaimed(dir: string): boolean;
|
|
16
21
|
export declare function stampCanvasNode(deck: Deck): void;
|
|
17
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;
|
|
18
25
|
export declare function readJson<T>(path: string): T | null;
|
|
19
26
|
/** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
|
|
20
27
|
export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: {
|
package/dist/inbox/convention.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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
|
+
import { spawn } from 'node:child_process';
|
|
2
3
|
import { dirname } from 'node:path';
|
|
3
4
|
import { randomUUID } from 'node:crypto';
|
|
4
5
|
import { buildSummary } from '../summary.js';
|
|
@@ -9,9 +10,38 @@ export function progressPath(dir) { return `${dir}/progress.json`; }
|
|
|
9
10
|
export function claimPath(dir) { return `${dir}/claim.json`; }
|
|
10
11
|
export function deliveryPath(dir) { return `${dir}/delivery.json`; }
|
|
11
12
|
export function deliveryErrorPath(dir) { return `${dir}/delivery-error.json`; }
|
|
13
|
+
export function followupRequestPath(dir) { return `${dir}/followup-request.json`; }
|
|
14
|
+
export function followupResultPath(dir) { return `${dir}/followup-result.json`; }
|
|
12
15
|
export function visualsDir(dir) { return `${dir}/visuals`; }
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
/** Spawns a handler `{command,args}` with `event` as JSON on stdin. Exit 0
|
|
17
|
+
* acknowledges; a nonzero exit, spawn error, or 30s timeout rejects with an
|
|
18
|
+
* Error carrying the handler's captured stderr. Shared by completion delivery
|
|
19
|
+
* and follow-up dispatch — both invoke a registered handler the same way. */
|
|
20
|
+
export async function runHandler(command, args, event) {
|
|
21
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
22
|
+
// stdout stays ignored (a handler acknowledges by exit code, never stdout),
|
|
23
|
+
// but stderr is captured so a nonzero exit surfaces the handler's own
|
|
24
|
+
// diagnostics in the delivery-error record instead of a bare exit code.
|
|
25
|
+
const child = spawn(command, args, { stdio: ['pipe', 'ignore', 'pipe'] });
|
|
26
|
+
let stderr = '';
|
|
27
|
+
child.stderr?.on('data', (chunk) => { stderr += chunk; if (stderr.length > 8192)
|
|
28
|
+
stderr = stderr.slice(-8192); });
|
|
29
|
+
let timedOut = false;
|
|
30
|
+
const timeout = setTimeout(() => { timedOut = true; child.kill('SIGKILL'); }, 30_000);
|
|
31
|
+
child.once('error', (error) => { clearTimeout(timeout); rejectPromise(error); });
|
|
32
|
+
child.once('exit', (code, signal) => {
|
|
33
|
+
clearTimeout(timeout);
|
|
34
|
+
const detail = stderr.trim() === '' ? '' : `: ${stderr.trim()}`;
|
|
35
|
+
if (timedOut)
|
|
36
|
+
rejectPromise(new Error(`handler timed out after 30 seconds${detail}`));
|
|
37
|
+
else if (code === 0)
|
|
38
|
+
resolvePromise();
|
|
39
|
+
else
|
|
40
|
+
rejectPromise(new Error(`handler failed (${signal ?? code ?? 'unknown'})${detail}`));
|
|
41
|
+
});
|
|
42
|
+
child.stdin.end(`${JSON.stringify(event)}\n`);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
15
45
|
export function interactionState(dir) {
|
|
16
46
|
if (!existsSync(deckPath(dir)) && !existsSync(reviewPath(dir)))
|
|
17
47
|
return 'missing';
|
|
@@ -33,6 +63,24 @@ export function atomicWriteJson(path, value) {
|
|
|
33
63
|
writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
|
34
64
|
renameSync(tmp, path);
|
|
35
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
|
+
}
|
|
36
84
|
export function readJson(path) {
|
|
37
85
|
try {
|
|
38
86
|
return JSON.parse(readFileSync(path, 'utf8'));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Deck, InteractionResponse } from '../types.js';
|
|
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,6 +9,11 @@ export interface DeckAdapterOptions {
|
|
|
9
9
|
onComplete: (responses: InteractionResponse[]) => void;
|
|
10
10
|
onBack: () => void;
|
|
11
11
|
onDirty: () => void;
|
|
12
|
+
visualProvider?: VisualProvider;
|
|
13
|
+
onEditorRequest?: () => void;
|
|
14
|
+
followUpAvailable?: boolean;
|
|
15
|
+
onFollowUpRequest?: (question: string) => void;
|
|
16
|
+
onFollowUpCancel?: () => void;
|
|
12
17
|
}
|
|
13
18
|
/** Embeds the single deck renderer in a controller-owned rectangle. */
|
|
14
19
|
export declare class DeckAdapter {
|
|
@@ -19,9 +24,12 @@ export declare class DeckAdapter {
|
|
|
19
24
|
render(): string[];
|
|
20
25
|
resize(cols: number, rows: number): string[];
|
|
21
26
|
inputBuffer(): string | undefined;
|
|
27
|
+
setInputBuffer(text: string): void;
|
|
28
|
+
setFollowUpHandlers(available: boolean, onRequest?: (question: string) => void, onCancel?: () => void): void;
|
|
29
|
+
setFollowUpState(state: FollowUpState): void;
|
|
22
30
|
canAcceptHostKeys(): boolean;
|
|
23
31
|
handleKey(input: string, key: Key): void;
|
|
24
32
|
/** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
|
|
25
|
-
reload(): void;
|
|
33
|
+
reload(deck: Deck, visualProvider: VisualProvider | undefined): void;
|
|
26
34
|
close(): void;
|
|
27
35
|
}
|
|
@@ -1,6 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { deckPath, progressPath, readJson } from './convention.js';
|
|
3
|
-
import { validateDeck } from './deck-schema.js';
|
|
1
|
+
import { progressPath, readJson } from './convention.js';
|
|
4
2
|
import { mountPanel } from '../tui/app.js';
|
|
5
3
|
/** Embeds the single deck renderer in a controller-owned rectangle. */
|
|
6
4
|
export class DeckAdapter {
|
|
@@ -17,13 +15,24 @@ export class DeckAdapter {
|
|
|
17
15
|
rows: opts.rows,
|
|
18
16
|
onProgress: (responses) => { this.responses = responses; opts.onProgress?.(responses); },
|
|
19
17
|
onComplete: opts.onComplete,
|
|
20
|
-
|
|
18
|
+
// A partial-deck exit is a valid completion for ordinary asks, but a
|
|
19
|
+
// notification is resolved only by its explicit acknowledgement.
|
|
20
|
+
onExit: () => { if (notificationsAcknowledged(opts.deck, this.responses))
|
|
21
|
+
opts.onComplete(this.responses); },
|
|
21
22
|
onDirty: opts.onDirty,
|
|
23
|
+
visualProvider: opts.visualProvider,
|
|
24
|
+
onEditorRequest: opts.onEditorRequest,
|
|
25
|
+
followUpAvailable: opts.followUpAvailable,
|
|
26
|
+
onFollowUpRequest: opts.onFollowUpRequest,
|
|
27
|
+
onFollowUpCancel: opts.onFollowUpCancel,
|
|
22
28
|
});
|
|
23
29
|
}
|
|
24
30
|
render() { return this.panel.render(); }
|
|
25
31
|
resize(cols, rows) { return this.panel.handleResize(cols, rows); }
|
|
26
32
|
inputBuffer() { return this.panel.getInputBuffer(); }
|
|
33
|
+
setInputBuffer(text) { this.panel.setInputBuffer(text); }
|
|
34
|
+
setFollowUpHandlers(available, onRequest, onCancel) { this.panel.setFollowUpHandlers(available, onRequest, onCancel); }
|
|
35
|
+
setFollowUpState(state) { this.panel.setFollowUpState(state); }
|
|
27
36
|
canAcceptHostKeys() { return this.panel.canAcceptHostKeys(); }
|
|
28
37
|
handleKey(input, key) {
|
|
29
38
|
if (key.escape && this.panel.atDeckTop()) {
|
|
@@ -33,17 +42,19 @@ export class DeckAdapter {
|
|
|
33
42
|
this.panel.handleKey(input, key);
|
|
34
43
|
}
|
|
35
44
|
/** Fresh descriptor reads preserve mounted answers for interaction ids still present. */
|
|
36
|
-
reload() {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
this.opts.onDirty();
|
|
40
|
-
}
|
|
41
|
-
catch {
|
|
42
|
-
// An incomplete external rewrite is not a new deck; retain the current editor.
|
|
43
|
-
}
|
|
45
|
+
reload(deck, visualProvider) {
|
|
46
|
+
this.panel.loadDeck(deck, { progressPath: progressPath(this.opts.dir), visualProvider });
|
|
47
|
+
this.opts.onDirty();
|
|
44
48
|
}
|
|
45
49
|
close() { this.panel.unmount(); }
|
|
46
50
|
}
|
|
51
|
+
function notificationsAcknowledged(deck, responses) {
|
|
52
|
+
const byId = new Map(responses.map((response) => [response.id, response]));
|
|
53
|
+
return deck.interactions.filter((interaction) => interaction.kind === 'notify').every((interaction) => {
|
|
54
|
+
const response = byId.get(interaction.id);
|
|
55
|
+
return response !== undefined && (response.selectedOptionId !== undefined || (response.selectedOptionIds?.length ?? 0) > 0 || (response.freetext?.trim() ?? '') !== '');
|
|
56
|
+
});
|
|
57
|
+
}
|
|
47
58
|
function initialResponses(deck, dir) {
|
|
48
59
|
const saved = readJson(progressPath(dir))?.responses;
|
|
49
60
|
if (Array.isArray(saved))
|
|
@@ -18,6 +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
|
+
visual: z.ZodOptional<z.ZodLiteral<"humanloop.visual/v1">>;
|
|
21
22
|
}, z.core.$strip>>;
|
|
22
23
|
interactions: z.ZodArray<z.ZodObject<{
|
|
23
24
|
id: z.ZodString;
|
|
@@ -58,6 +59,7 @@ export declare const reviewDescriptorSchema: z.ZodObject<{
|
|
|
58
59
|
askedBy: z.ZodOptional<z.ZodString>;
|
|
59
60
|
blockedSince: z.ZodOptional<z.ZodString>;
|
|
60
61
|
nodeId: z.ZodOptional<z.ZodString>;
|
|
62
|
+
visual: z.ZodOptional<z.ZodLiteral<"humanloop.visual/v1">>;
|
|
61
63
|
}, z.core.$strip>;
|
|
62
64
|
blockedSince: z.ZodString;
|
|
63
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, 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']);
|
|
81
|
-
const ownProtocolPaths = new Set([deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(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 };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export interface FollowUpRequestEvent {
|
|
2
|
+
schema: 'humanloop.followup-request/v1';
|
|
3
|
+
action: 'start' | 'cancel';
|
|
4
|
+
capability: 'humanloop.follow-up/v1';
|
|
5
|
+
owner: string;
|
|
6
|
+
root: string;
|
|
7
|
+
dir: string;
|
|
8
|
+
ticketId: string;
|
|
9
|
+
requestId: string;
|
|
10
|
+
question: string;
|
|
11
|
+
}
|
|
12
|
+
/** followup-request.json — humanloop-owned. A state machine, not delete-on-cancel;
|
|
13
|
+
* the sole writer is humanloop, always under `.followup-lock`. */
|
|
14
|
+
export interface FollowUpRequest {
|
|
15
|
+
schema: 'humanloop.followup-request/v1';
|
|
16
|
+
requestId: string;
|
|
17
|
+
question: string;
|
|
18
|
+
state: 'running' | 'canceled' | 'superseded' | 'terminal';
|
|
19
|
+
askedAt: string;
|
|
20
|
+
settledAt?: string;
|
|
21
|
+
}
|
|
22
|
+
/** followup-result.json — the provider's writer, via `submitFollowUpResult`'s
|
|
23
|
+
* compare-and-publish gate. */
|
|
24
|
+
export interface FollowUpResult {
|
|
25
|
+
schema: 'humanloop.followup-result/v1';
|
|
26
|
+
requestId: string;
|
|
27
|
+
status: 'ready' | 'error';
|
|
28
|
+
markdown?: string;
|
|
29
|
+
error?: string;
|
|
30
|
+
completedAt: string;
|
|
31
|
+
}
|
|
32
|
+
/** Supersede any running request, write a fresh `running` request, and fire a
|
|
33
|
+
* best-effort `start` kickoff to the registered handler. */
|
|
34
|
+
export declare function requestFollowUp(root: string, dir: string, opts: {
|
|
35
|
+
question: string;
|
|
36
|
+
}): FollowUpRequest;
|
|
37
|
+
/** Mark the current running request `canceled` (never deleted) and best-effort
|
|
38
|
+
* notify the handler. A no-op when nothing is running. */
|
|
39
|
+
export declare function cancelFollowUp(root: string, dir: string): void;
|
|
40
|
+
/** The provider's writer. Canonicalizes a registered direct-child ticket, then
|
|
41
|
+
* publishes ONLY if `result.requestId` matches the current `running` request
|
|
42
|
+
* under `.followup-lock` \u2014 the authoritative guard that a superseded/canceled
|
|
43
|
+
* writer's late answer can never clobber a newer result. A stale writer is a
|
|
44
|
+
* silent no-op. A `ready` whose markdown fails `checkMarkdown` is downgraded
|
|
45
|
+
* to an `error` result, never thrown. */
|
|
46
|
+
export declare function submitFollowUpResult(root: string, dir: string, result: {
|
|
47
|
+
requestId: string;
|
|
48
|
+
status: 'ready' | 'error';
|
|
49
|
+
markdown?: string;
|
|
50
|
+
error?: string;
|
|
51
|
+
}): {
|
|
52
|
+
published: boolean;
|
|
53
|
+
};
|
|
54
|
+
export declare function readFollowUp(dir: string): {
|
|
55
|
+
request: FollowUpRequest | null;
|
|
56
|
+
result: FollowUpResult | null;
|
|
57
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { atomicWriteJson, followupRequestPath, followupResultPath, readJson, runHandler, withExclusiveDirectoryLock } from './convention.js';
|
|
5
|
+
import { registeredInboxRoot } from './registry.js';
|
|
6
|
+
import { ticketRoot } from './tickets.js';
|
|
7
|
+
import { checkMarkdown } from '../render/termrender.js';
|
|
8
|
+
const CAPABILITY = 'humanloop.follow-up/v1';
|
|
9
|
+
function lockPath(dir) { return `${dir}/.followup-lock`; }
|
|
10
|
+
function requireCanonicalTicket(root, dir) {
|
|
11
|
+
const registration = registeredInboxRoot(root);
|
|
12
|
+
let canonicalDir;
|
|
13
|
+
try {
|
|
14
|
+
canonicalDir = realpathSync(dir);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
throw new Error('ticket is not a canonical direct child of a registered root');
|
|
18
|
+
}
|
|
19
|
+
if (registration === null || ticketRoot(canonicalDir) !== registration.root)
|
|
20
|
+
throw new Error('ticket is not a canonical direct child of a registered root');
|
|
21
|
+
return { root: registration.root, dir: canonicalDir, owner: registration.owner, followUpHandler: registration.followUpHandler };
|
|
22
|
+
}
|
|
23
|
+
function requireFollowUpHandler(root, dir) {
|
|
24
|
+
const ticket = requireCanonicalTicket(root, dir);
|
|
25
|
+
if (ticket.followUpHandler === undefined)
|
|
26
|
+
throw new Error('root has no registered follow-up handler');
|
|
27
|
+
return { ...ticket, followUpHandler: ticket.followUpHandler };
|
|
28
|
+
}
|
|
29
|
+
function normalizeResult(result) {
|
|
30
|
+
const completedAt = new Date().toISOString();
|
|
31
|
+
if (result.status === 'ready') {
|
|
32
|
+
if (typeof result.markdown !== 'string' || result.markdown.trim().length === 0) {
|
|
33
|
+
return { schema: 'humanloop.followup-result/v1', requestId: result.requestId, status: 'error', error: 'ready result requires non-empty markdown', completedAt };
|
|
34
|
+
}
|
|
35
|
+
const check = checkMarkdown(result.markdown);
|
|
36
|
+
if (!check.ok)
|
|
37
|
+
return { schema: 'humanloop.followup-result/v1', requestId: result.requestId, status: 'error', error: check.error, completedAt };
|
|
38
|
+
return { schema: 'humanloop.followup-result/v1', requestId: result.requestId, status: 'ready', markdown: result.markdown, completedAt };
|
|
39
|
+
}
|
|
40
|
+
return { schema: 'humanloop.followup-result/v1', requestId: result.requestId, status: 'error', error: result.error ?? 'unknown error', completedAt };
|
|
41
|
+
}
|
|
42
|
+
/** Supersede any running request, write a fresh `running` request, and fire a
|
|
43
|
+
* best-effort `start` kickoff to the registered handler. */
|
|
44
|
+
export function requestFollowUp(root, dir, opts) {
|
|
45
|
+
const { root: canonicalRoot, dir: canonicalDir, owner, followUpHandler } = requireFollowUpHandler(root, dir);
|
|
46
|
+
const request = {
|
|
47
|
+
schema: 'humanloop.followup-request/v1',
|
|
48
|
+
requestId: randomUUID(),
|
|
49
|
+
question: opts.question,
|
|
50
|
+
state: 'running',
|
|
51
|
+
askedAt: new Date().toISOString(),
|
|
52
|
+
};
|
|
53
|
+
withExclusiveDirectoryLock(lockPath(canonicalDir), () => {
|
|
54
|
+
const current = readJson(followupRequestPath(canonicalDir));
|
|
55
|
+
if (current !== null && current.state === 'running') {
|
|
56
|
+
atomicWriteJson(followupRequestPath(canonicalDir), { ...current, state: 'superseded', settledAt: new Date().toISOString() });
|
|
57
|
+
}
|
|
58
|
+
atomicWriteJson(followupRequestPath(canonicalDir), request);
|
|
59
|
+
});
|
|
60
|
+
const event = {
|
|
61
|
+
schema: 'humanloop.followup-request/v1', action: 'start', capability: CAPABILITY,
|
|
62
|
+
owner, root: canonicalRoot, dir: canonicalDir, ticketId: basename(canonicalDir),
|
|
63
|
+
requestId: request.requestId, question: opts.question,
|
|
64
|
+
};
|
|
65
|
+
void runHandler(followUpHandler.command, followUpHandler.args, event).catch((error) => {
|
|
66
|
+
submitFollowUpResult(canonicalRoot, canonicalDir, { requestId: request.requestId, status: 'error', error: error instanceof Error ? error.message : String(error) });
|
|
67
|
+
});
|
|
68
|
+
return request;
|
|
69
|
+
}
|
|
70
|
+
/** Mark the current running request `canceled` (never deleted) and best-effort
|
|
71
|
+
* notify the handler. A no-op when nothing is running. */
|
|
72
|
+
export function cancelFollowUp(root, dir) {
|
|
73
|
+
const { owner, root: canonicalRoot, dir: canonicalDir, followUpHandler } = requireFollowUpHandler(root, dir);
|
|
74
|
+
const canceled = withExclusiveDirectoryLock(lockPath(canonicalDir), () => {
|
|
75
|
+
const current = readJson(followupRequestPath(canonicalDir));
|
|
76
|
+
if (current === null || current.state !== 'running')
|
|
77
|
+
return null;
|
|
78
|
+
atomicWriteJson(followupRequestPath(canonicalDir), { ...current, state: 'canceled', settledAt: new Date().toISOString() });
|
|
79
|
+
return current;
|
|
80
|
+
});
|
|
81
|
+
if (canceled === null)
|
|
82
|
+
return;
|
|
83
|
+
const event = {
|
|
84
|
+
schema: 'humanloop.followup-request/v1', action: 'cancel', capability: CAPABILITY,
|
|
85
|
+
owner, root: canonicalRoot, dir: canonicalDir, ticketId: basename(canonicalDir),
|
|
86
|
+
requestId: canceled.requestId, question: '',
|
|
87
|
+
};
|
|
88
|
+
void runHandler(followUpHandler.command, followUpHandler.args, event).catch(() => { });
|
|
89
|
+
}
|
|
90
|
+
/** The provider's writer. Canonicalizes a registered direct-child ticket, then
|
|
91
|
+
* publishes ONLY if `result.requestId` matches the current `running` request
|
|
92
|
+
* under `.followup-lock` \u2014 the authoritative guard that a superseded/canceled
|
|
93
|
+
* writer's late answer can never clobber a newer result. A stale writer is a
|
|
94
|
+
* silent no-op. A `ready` whose markdown fails `checkMarkdown` is downgraded
|
|
95
|
+
* to an `error` result, never thrown. */
|
|
96
|
+
export function submitFollowUpResult(root, dir, result) {
|
|
97
|
+
const { dir: canonicalDir } = requireCanonicalTicket(root, dir);
|
|
98
|
+
const normalized = normalizeResult(result);
|
|
99
|
+
return withExclusiveDirectoryLock(lockPath(canonicalDir), () => {
|
|
100
|
+
const current = readJson(followupRequestPath(canonicalDir));
|
|
101
|
+
if (current === null || current.requestId !== result.requestId || current.state !== 'running')
|
|
102
|
+
return { published: false };
|
|
103
|
+
atomicWriteJson(followupResultPath(canonicalDir), normalized);
|
|
104
|
+
atomicWriteJson(followupRequestPath(canonicalDir), { ...current, state: 'terminal', settledAt: normalized.completedAt });
|
|
105
|
+
return { published: true };
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
export function readFollowUp(dir) {
|
|
109
|
+
let canonicalDir;
|
|
110
|
+
try {
|
|
111
|
+
canonicalDir = realpathSync(dir);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return { request: null, result: null };
|
|
115
|
+
}
|
|
116
|
+
return { request: readJson(followupRequestPath(canonicalDir)), result: readJson(followupResultPath(canonicalDir)) };
|
|
117
|
+
}
|
package/dist/inbox/registry.d.ts
CHANGED
|
@@ -8,6 +8,8 @@ export interface InboxRootRegistration {
|
|
|
8
8
|
root: string;
|
|
9
9
|
owner: string;
|
|
10
10
|
handler?: CompletionHandler;
|
|
11
|
+
followUpHandler?: CompletionHandler;
|
|
12
|
+
visualHandler?: CompletionHandler;
|
|
11
13
|
}
|
|
12
14
|
export interface InboxRootStatus extends InboxRootRegistration {
|
|
13
15
|
available: boolean;
|
|
@@ -16,6 +18,8 @@ export interface RegisterInboxRootOptions {
|
|
|
16
18
|
root: string;
|
|
17
19
|
owner: string;
|
|
18
20
|
handler?: CompletionHandler;
|
|
21
|
+
followUpHandler?: CompletionHandler;
|
|
22
|
+
visualHandler?: CompletionHandler;
|
|
19
23
|
}
|
|
20
24
|
export declare function inboxRootsDirectory(): string;
|
|
21
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) {
|
|
@@ -26,7 +26,12 @@ function validateRegistration(raw) {
|
|
|
26
26
|
if (value.schema !== 'humanloop.inbox-root/v1' || typeof value.root !== 'string' || !value.root || typeof value.owner !== 'string' || !value.owner.trim())
|
|
27
27
|
return null;
|
|
28
28
|
try {
|
|
29
|
-
return {
|
|
29
|
+
return {
|
|
30
|
+
schema: 'humanloop.inbox-root/v1', root: value.root, owner: value.owner,
|
|
31
|
+
handler: value.handler === undefined ? undefined : validateHandler(value.handler),
|
|
32
|
+
followUpHandler: value.followUpHandler === undefined ? undefined : validateHandler(value.followUpHandler),
|
|
33
|
+
visualHandler: value.visualHandler === undefined ? undefined : validateHandler(value.visualHandler),
|
|
34
|
+
};
|
|
30
35
|
}
|
|
31
36
|
catch {
|
|
32
37
|
return null;
|
|
@@ -46,7 +51,7 @@ export function registerInboxRoot(opts) {
|
|
|
46
51
|
throw new Error(`inbox root is already owned by ${existing.owner}`);
|
|
47
52
|
if (existing !== null && existing.root !== root)
|
|
48
53
|
throw new Error('inbox root registry hash collision');
|
|
49
|
-
const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler) };
|
|
54
|
+
const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler), visualHandler: validateHandler(opts.visualHandler) };
|
|
50
55
|
atomicWriteJson(path, registration);
|
|
51
56
|
chmodSync(path, 0o600);
|
|
52
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, 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)].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'))); }
|
|
@@ -172,6 +155,12 @@ function validateDeckResponses(deck, raw) {
|
|
|
172
155
|
if (response.optionComments !== undefined && (interaction.multiSelect !== true || Object.keys(response.optionComments).some((id) => !optionIds.has(id))))
|
|
173
156
|
throw new Error(`invalid option comments for ${response.id}`);
|
|
174
157
|
}
|
|
158
|
+
for (const interaction of deck.interactions.filter((candidate) => candidate.kind === 'notify')) {
|
|
159
|
+
const response = responses.find((candidate) => candidate.id === interaction.id);
|
|
160
|
+
if (response === undefined || (response.selectedOptionId === undefined && (response.selectedOptionIds?.length ?? 0) === 0 && (response.freetext?.trim() ?? '') === '')) {
|
|
161
|
+
throw new Error(`notification requires an explicit acknowledgement: ${interaction.id}`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
175
164
|
return responses;
|
|
176
165
|
}
|
|
177
166
|
function requireClaimOwnership(dir, token) {
|
|
@@ -185,6 +174,10 @@ export function finalizeDeck(dir, responses, claimToken, completedAt = new Date(
|
|
|
185
174
|
requireClaimOwnership(ticket.dir, claimToken);
|
|
186
175
|
const deck = requireDeck(ticket.dir);
|
|
187
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);
|
|
188
181
|
const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: parsedResponses, summary: buildSummary(deck, parsedResponses), completedAt };
|
|
189
182
|
const won = exclusiveResult(ticket.dir, result);
|
|
190
183
|
clearOwnedWork(ticket.dir, claimToken);
|
package/dist/inbox/tui.d.ts
CHANGED
|
@@ -3,4 +3,4 @@ export declare const KIND_ICON: Record<string, string>;
|
|
|
3
3
|
export declare const KIND_COLOR: Record<string, string>;
|
|
4
4
|
export declare function formatTimeAgo(iso: string): string;
|
|
5
5
|
/** Pure inbox rows; selection and terminal ownership belong to InboxController. */
|
|
6
|
-
export declare function buildInboxLines(items: TicketSummary[], width: number, selectedIndex: number): string[];
|
|
6
|
+
export declare function buildInboxLines(items: TicketSummary[], width: number, selectedIndex: number, height?: number): string[];
|