@crouton-kit/humanloop 0.3.39 → 0.4.1
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/cli.js +6 -2
- package/dist/inbox/controller.d.ts +14 -8
- package/dist/inbox/controller.js +133 -40
- package/dist/inbox/convention.d.ts +2 -2
- package/dist/inbox/convention.js +19 -3
- package/dist/inbox/deck-adapter.d.ts +3 -3
- package/dist/inbox/deck-adapter.js +3 -3
- package/dist/inbox/deck-schema.d.ts +2 -2
- package/dist/inbox/deck-schema.js +4 -4
- package/dist/inbox/maintenance.d.ts +4 -0
- package/dist/inbox/maintenance.js +145 -0
- package/dist/inbox/registry.d.ts +8 -0
- package/dist/inbox/registry.js +17 -5
- package/dist/inbox/tickets.d.ts +5 -0
- package/dist/inbox/tickets.js +35 -36
- package/dist/inbox/visual.d.ts +130 -0
- package/dist/inbox/visual.js +747 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/surfaces/inbox-popup.d.ts +1 -1
- package/dist/surfaces/inbox-popup.js +2 -2
- package/dist/tui/app.d.ts +3 -6
- package/dist/tui/app.js +59 -18
- package/dist/tui/render.js +4 -4
- package/dist/tui/tmux.js +2 -1
- package/dist/types.d.ts +48 -11
- package/package.json +2 -3
- package/dist/conversation/reader.d.ts +0 -20
- package/dist/conversation/reader.js +0 -348
- package/dist/visuals/conversation.d.ts +0 -7
- package/dist/visuals/conversation.js +0 -16
- package/dist/visuals/generate.d.ts +0 -11
- package/dist/visuals/generate.js +0 -81
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { inboxRootsDirectory, listInboxRoots } from './registry.js';
|
|
6
|
+
import { reconcileCompletions } from './completion.js';
|
|
7
|
+
import { dispatchVisualCleanup, listVisualCleanupObligationsForRoot, reconcileStaleVisualRequestsForRoot } from './visual.js';
|
|
8
|
+
const LEASE_STALE_MS = 300_000;
|
|
9
|
+
function leasePath() { return join(dirname(inboxRootsDirectory()), 'maintenance.lock'); }
|
|
10
|
+
function roots() { return listInboxRoots().filter((root) => root.available).map((root) => root.root); }
|
|
11
|
+
function leaseOwner(path) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(readFileSync(join(path, 'owner.json'), 'utf8'));
|
|
14
|
+
return typeof parsed.pid === 'number' && Number.isInteger(parsed.pid) ? parsed.pid : undefined;
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function processIsAlive(pid) {
|
|
21
|
+
try {
|
|
22
|
+
process.kill(pid, 0);
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function staleLease(path) {
|
|
30
|
+
const owner = leaseOwner(path);
|
|
31
|
+
if (owner !== undefined)
|
|
32
|
+
return !processIsAlive(owner);
|
|
33
|
+
try {
|
|
34
|
+
return Date.now() - statSync(path).mtimeMs > LEASE_STALE_MS;
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Start one detached repair pass. The lease makes repeated UI gestures free. */
|
|
41
|
+
export function kickInboxMaintenance() {
|
|
42
|
+
const lease = leasePath();
|
|
43
|
+
try {
|
|
44
|
+
mkdirSync(dirname(lease), { recursive: true, mode: 0o700 });
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
mkdirSync(lease, { mode: 0o700 });
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
if (!staleLease(lease))
|
|
54
|
+
return;
|
|
55
|
+
try {
|
|
56
|
+
rmSync(lease, { recursive: true, force: true });
|
|
57
|
+
mkdirSync(lease, { mode: 0o700 });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const builtEntry = fileURLToPath(new URL('../cli.js', import.meta.url));
|
|
64
|
+
const sourceEntry = fileURLToPath(new URL('../cli.ts', import.meta.url));
|
|
65
|
+
const entry = existsSync(builtEntry) ? builtEntry : sourceEntry;
|
|
66
|
+
if (!existsSync(entry)) {
|
|
67
|
+
rmSync(lease, { recursive: true, force: true });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
// A built CLI needs no parent flags. Source-mode launches retain their tsx
|
|
71
|
+
// loader, but always execute Humanloop's CLI rather than the caller's
|
|
72
|
+
// entrypoint (which may be a test or a consuming application).
|
|
73
|
+
const runtimeArgs = entry.endsWith('.ts') ? process.execArgv : [];
|
|
74
|
+
const child = spawn(process.execPath, [...runtimeArgs, entry, 'inbox', '_maintain', '--lease', lease], {
|
|
75
|
+
detached: true,
|
|
76
|
+
stdio: 'ignore',
|
|
77
|
+
});
|
|
78
|
+
if (child.pid === undefined) {
|
|
79
|
+
rmSync(lease, { recursive: true, force: true });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
try {
|
|
83
|
+
writeFileSync(join(lease, 'owner.json'), JSON.stringify({ pid: child.pid }));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
child.kill();
|
|
87
|
+
rmSync(lease, { recursive: true, force: true });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
child.unref();
|
|
91
|
+
}
|
|
92
|
+
function dueTasks(allRoots) {
|
|
93
|
+
const tasks = [];
|
|
94
|
+
for (const root of allRoots) {
|
|
95
|
+
try {
|
|
96
|
+
tasks.push(...listVisualCleanupObligationsForRoot(root));
|
|
97
|
+
}
|
|
98
|
+
catch { /* malformed historical state is isolated to its root */ }
|
|
99
|
+
}
|
|
100
|
+
return tasks;
|
|
101
|
+
}
|
|
102
|
+
async function repairOnce() {
|
|
103
|
+
const allRoots = roots();
|
|
104
|
+
for (const root of allRoots) {
|
|
105
|
+
try {
|
|
106
|
+
await reconcileCompletions(root);
|
|
107
|
+
}
|
|
108
|
+
catch { /* receipt remains durable for the next pass */ }
|
|
109
|
+
}
|
|
110
|
+
const retirements = allRoots.flatMap((root) => {
|
|
111
|
+
try {
|
|
112
|
+
return reconcileStaleVisualRequestsForRoot(root).map((entry) => entry.delivery);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
return [];
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
await Promise.all(retirements);
|
|
119
|
+
const tasks = dueTasks(allRoots);
|
|
120
|
+
await Promise.all(tasks.filter((task) => Date.parse(task.nextAttemptAt) <= Date.now()).map((task) => dispatchVisualCleanup(task.root, task.dir, task.requestId)));
|
|
121
|
+
return dueTasks(roots());
|
|
122
|
+
}
|
|
123
|
+
function releaseLease(lease) {
|
|
124
|
+
if (leaseOwner(lease) !== process.pid)
|
|
125
|
+
return;
|
|
126
|
+
rmSync(lease, { recursive: true, force: true });
|
|
127
|
+
}
|
|
128
|
+
/** Run repair and stay alive only until the durable cleanup retry queue is empty. */
|
|
129
|
+
export async function runInboxMaintenance(lease) {
|
|
130
|
+
try {
|
|
131
|
+
while (true) {
|
|
132
|
+
const tasks = await repairOnce();
|
|
133
|
+
const next = tasks.reduce((earliest, task) => {
|
|
134
|
+
const due = Date.parse(task.nextAttemptAt);
|
|
135
|
+
return Number.isFinite(due) && (earliest === undefined || due < earliest) ? due : earliest;
|
|
136
|
+
}, undefined);
|
|
137
|
+
if (next === undefined)
|
|
138
|
+
return;
|
|
139
|
+
await new Promise((resolve) => setTimeout(resolve, Math.max(0, next - Date.now())));
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
releaseLease(lease);
|
|
144
|
+
}
|
|
145
|
+
}
|
package/dist/inbox/registry.d.ts
CHANGED
|
@@ -9,6 +9,8 @@ export interface InboxRootRegistration {
|
|
|
9
9
|
owner: string;
|
|
10
10
|
handler?: CompletionHandler;
|
|
11
11
|
followUpHandler?: CompletionHandler;
|
|
12
|
+
visualHandler?: CompletionHandler;
|
|
13
|
+
focusHandler?: CompletionHandler;
|
|
12
14
|
}
|
|
13
15
|
export interface InboxRootStatus extends InboxRootRegistration {
|
|
14
16
|
available: boolean;
|
|
@@ -18,8 +20,14 @@ export interface RegisterInboxRootOptions {
|
|
|
18
20
|
owner: string;
|
|
19
21
|
handler?: CompletionHandler;
|
|
20
22
|
followUpHandler?: CompletionHandler;
|
|
23
|
+
visualHandler?: CompletionHandler;
|
|
24
|
+
focusHandler?: CompletionHandler;
|
|
21
25
|
}
|
|
26
|
+
export declare function inboxStateDirectory(): string;
|
|
22
27
|
export declare function inboxRootsDirectory(): string;
|
|
28
|
+
export declare function inboxActivityPath(): string;
|
|
29
|
+
/** Signal one durable ticket mutation to every open inbox without watching every root. */
|
|
30
|
+
export declare function signalInboxActivity(): void;
|
|
23
31
|
/** Create/canonicalize a root and claim its user-scoped registration. */
|
|
24
32
|
export declare function registerInboxRoot(opts: RegisterInboxRootOptions): InboxRootRegistration;
|
|
25
33
|
/** Removes a matching available root through its real path, or an unavailable record by its stored canonical path. */
|
package/dist/inbox/registry.js
CHANGED
|
@@ -1,10 +1,20 @@
|
|
|
1
|
-
import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, unlinkSync } from 'node:fs';
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { createHash } from 'node:crypto';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join, resolve } from 'node:path';
|
|
5
5
|
import { atomicWriteJson, readJson, withExclusiveDirectoryLock } from './convention.js';
|
|
6
6
|
function stateHome() { return process.env['XDG_STATE_HOME'] || join(homedir(), '.local', 'state'); }
|
|
7
|
-
export function
|
|
7
|
+
export function inboxStateDirectory() { return join(stateHome(), 'humanloop'); }
|
|
8
|
+
export function inboxRootsDirectory() { return join(inboxStateDirectory(), 'inbox-roots'); }
|
|
9
|
+
export function inboxActivityPath() { return join(inboxStateDirectory(), 'inbox-activity'); }
|
|
10
|
+
/** Signal one durable ticket mutation to every open inbox without watching every root. */
|
|
11
|
+
export function signalInboxActivity() {
|
|
12
|
+
try {
|
|
13
|
+
mkdirSync(inboxStateDirectory(), { recursive: true, mode: 0o700 });
|
|
14
|
+
writeFileSync(inboxActivityPath(), `${Date.now()}\n`, { mode: 0o600 });
|
|
15
|
+
}
|
|
16
|
+
catch { /* a later popup scan is still authoritative */ }
|
|
17
|
+
}
|
|
8
18
|
function recordPath(root) { return join(inboxRootsDirectory(), createHash('sha256').update(root).digest('hex')); }
|
|
9
19
|
function canonicalRoot(root) { try {
|
|
10
20
|
return realpathSync(root);
|
|
@@ -15,8 +25,8 @@ catch {
|
|
|
15
25
|
function validateHandler(handler) {
|
|
16
26
|
if (handler === undefined)
|
|
17
27
|
return undefined;
|
|
18
|
-
if (
|
|
19
|
-
throw new Error('
|
|
28
|
+
if (typeof handler.command !== 'string' || handler.command.trim() === '' || !Array.isArray(handler.args) || !handler.args.every((arg) => typeof arg === 'string'))
|
|
29
|
+
throw new Error('handler requires a non-empty command and string args');
|
|
20
30
|
return { command: handler.command, args: [...handler.args] };
|
|
21
31
|
}
|
|
22
32
|
function validateRegistration(raw) {
|
|
@@ -30,6 +40,8 @@ function validateRegistration(raw) {
|
|
|
30
40
|
schema: 'humanloop.inbox-root/v1', root: value.root, owner: value.owner,
|
|
31
41
|
handler: value.handler === undefined ? undefined : validateHandler(value.handler),
|
|
32
42
|
followUpHandler: value.followUpHandler === undefined ? undefined : validateHandler(value.followUpHandler),
|
|
43
|
+
visualHandler: value.visualHandler === undefined ? undefined : validateHandler(value.visualHandler),
|
|
44
|
+
focusHandler: value.focusHandler === undefined ? undefined : validateHandler(value.focusHandler),
|
|
33
45
|
};
|
|
34
46
|
}
|
|
35
47
|
catch {
|
|
@@ -50,7 +62,7 @@ export function registerInboxRoot(opts) {
|
|
|
50
62
|
throw new Error(`inbox root is already owned by ${existing.owner}`);
|
|
51
63
|
if (existing !== null && existing.root !== root)
|
|
52
64
|
throw new Error('inbox root registry hash collision');
|
|
53
|
-
const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler) };
|
|
65
|
+
const registration = { schema: 'humanloop.inbox-root/v1', root, owner: opts.owner, handler: validateHandler(opts.handler), followUpHandler: validateHandler(opts.followUpHandler), visualHandler: validateHandler(opts.visualHandler), focusHandler: validateHandler(opts.focusHandler) };
|
|
54
66
|
atomicWriteJson(path, registration);
|
|
55
67
|
chmodSync(path, 0o600);
|
|
56
68
|
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,14 @@
|
|
|
1
|
-
import { existsSync,
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from 'node:fs';
|
|
2
2
|
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { buildSummary } from '../summary.js';
|
|
5
|
-
import { clearProgress, claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, responsePath, reviewPath } from './convention.js';
|
|
5
|
+
import { clearProgress, claimPath, deckPath, deliveryErrorPath, deliveryPath, followupRequestPath, followupResultPath, progressPath, publishJsonExclusive, responsePath, reviewPath, visualsDir } from './convention.js';
|
|
6
6
|
import { validateDeck, validateReviewDescriptor, validateReviewProjection, resolveDeckBodyPaths } from './deck-schema.js';
|
|
7
|
-
import { registeredInboxRoot } from './registry.js';
|
|
7
|
+
import { registeredInboxRoot, signalInboxActivity } 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';
|
|
11
|
+
import { kickInboxMaintenance } from './maintenance.js';
|
|
10
12
|
const ticketId = z.string().regex(/^[A-Za-z0-9_-]+$/).min(1).max(128);
|
|
11
13
|
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
14
|
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 +72,7 @@ function ticketDir(root, id) {
|
|
|
70
72
|
function discardCreatedTicket(dir, created) { if (created)
|
|
71
73
|
rmSync(dir, { recursive: true, force: true }); }
|
|
72
74
|
function hasTicketProtocolState(dir) {
|
|
73
|
-
return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir)].some(existsSync);
|
|
75
|
+
return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir), followupRequestPath(dir), followupResultPath(dir), visualsDir(dir)].some(existsSync);
|
|
74
76
|
}
|
|
75
77
|
function requireRegisteredTicket(dir) {
|
|
76
78
|
const canonical = realpathSync(dir);
|
|
@@ -82,21 +84,17 @@ function requireRegisteredTicket(dir) {
|
|
|
82
84
|
throw new Error('ticket has no request descriptor');
|
|
83
85
|
return { root, dir: canonical };
|
|
84
86
|
}
|
|
87
|
+
/** Canonical registered-root/direct-child containment shared by host protocols. */
|
|
88
|
+
export function requireCanonicalTicket(root, dir) {
|
|
89
|
+
const registration = registeredInboxRoot(root);
|
|
90
|
+
const ticket = requireRegisteredTicket(dir);
|
|
91
|
+
if (registration === null || ticket.root !== registration.root)
|
|
92
|
+
throw new Error('ticket is not a canonical direct child of the registered root');
|
|
93
|
+
return ticket;
|
|
94
|
+
}
|
|
85
95
|
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
|
-
}
|
|
96
|
+
if (!publishJsonExclusive(path, value))
|
|
97
|
+
throw new Error(`ticket request already exists: ${path}`);
|
|
100
98
|
}
|
|
101
99
|
export function submitDeck(opts) {
|
|
102
100
|
// Validate shape before mutating caller-owned interaction directories.
|
|
@@ -108,6 +106,7 @@ export function submitDeck(opts) {
|
|
|
108
106
|
const deck = validateDeck(resolveDeckBodyPaths(opts.deck, dir));
|
|
109
107
|
const stamped = { ...deck, source: { ...(deck.source ?? {}), blockedSince: deck.source?.blockedSince ?? new Date().toISOString() } };
|
|
110
108
|
publishRequest(deckPath(dir), stamped);
|
|
109
|
+
signalInboxActivity();
|
|
111
110
|
return { id: opts.id, dir, kind: 'deck' };
|
|
112
111
|
}
|
|
113
112
|
catch (error) {
|
|
@@ -127,6 +126,7 @@ export function submitReview(opts) {
|
|
|
127
126
|
throw new Error(`ticket protocol state already exists: ${dir}`);
|
|
128
127
|
const descriptor = validateReviewProjection(dir, { schema: 'humanloop.review/v1', file: source, output: resolve(opts.review.output ?? `${dir}/feedback.json`), title: opts.review.title, source: opts.review.source, blockedSince: opts.review.blockedSince ?? new Date().toISOString() });
|
|
129
128
|
publishRequest(reviewPath(dir), descriptor);
|
|
129
|
+
signalInboxActivity();
|
|
130
130
|
return { id: opts.id, dir, kind: 'review' };
|
|
131
131
|
}
|
|
132
132
|
catch (error) {
|
|
@@ -135,21 +135,7 @@ export function submitReview(opts) {
|
|
|
135
135
|
}
|
|
136
136
|
}
|
|
137
137
|
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
|
-
}
|
|
138
|
+
return publishJsonExclusive(responsePath(dir), result);
|
|
153
139
|
}
|
|
154
140
|
function requireDeck(dir) { return validateDeck(JSON.parse(readFileSync(deckPath(dir), 'utf8'))); }
|
|
155
141
|
function requireReview(dir) { return validateReviewDescriptor(JSON.parse(readFileSync(reviewPath(dir), 'utf8'))); }
|
|
@@ -187,19 +173,26 @@ function requireClaimOwnership(dir, token) {
|
|
|
187
173
|
function clearOwnedWork(dir, claimToken) { clearProgress(dir); releaseClaimLocked(dir, claimToken); }
|
|
188
174
|
export function finalizeDeck(dir, responses, claimToken, completedAt = new Date().toISOString()) {
|
|
189
175
|
const ticket = requireRegisteredTicket(dir);
|
|
190
|
-
|
|
176
|
+
const finalized = withTicketLock(ticket.dir, () => {
|
|
191
177
|
requireClaimOwnership(ticket.dir, claimToken);
|
|
192
178
|
const deck = requireDeck(ticket.dir);
|
|
193
179
|
const parsedResponses = validateDeckResponses(deck, responses);
|
|
180
|
+
// A valid primary result ends its panel generation before the canonical
|
|
181
|
+
// response can cross the owner boundary. The protocol persists cancellation
|
|
182
|
+
// first; its independent cleanup executor owns eventual handler delivery.
|
|
183
|
+
void cancelVisualRequestsForTicket(ticket.root, ticket.dir).finally(kickInboxMaintenance);
|
|
194
184
|
const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: parsedResponses, summary: buildSummary(deck, parsedResponses), completedAt };
|
|
195
185
|
const won = exclusiveResult(ticket.dir, result);
|
|
196
186
|
clearOwnedWork(ticket.dir, claimToken);
|
|
197
187
|
return { won, result: won ? result : readTicketResult(ticket.dir) ?? result };
|
|
198
188
|
});
|
|
189
|
+
if (finalized.won)
|
|
190
|
+
signalInboxActivity();
|
|
191
|
+
return finalized;
|
|
199
192
|
}
|
|
200
193
|
export function finalizeReview(dir, feedback, claimToken, completedAt = new Date().toISOString()) {
|
|
201
194
|
const ticket = requireRegisteredTicket(dir);
|
|
202
|
-
|
|
195
|
+
const finalized = withTicketLock(ticket.dir, () => {
|
|
203
196
|
requireClaimOwnership(ticket.dir, claimToken);
|
|
204
197
|
const descriptor = requireReview(ticket.dir);
|
|
205
198
|
const parsed = feedbackSchema.parse(feedback);
|
|
@@ -210,14 +203,20 @@ export function finalizeReview(dir, feedback, claimToken, completedAt = new Date
|
|
|
210
203
|
clearOwnedWork(ticket.dir, claimToken);
|
|
211
204
|
return { won, result: won ? result : readTicketResult(ticket.dir) ?? result, descriptor };
|
|
212
205
|
});
|
|
206
|
+
if (finalized.won)
|
|
207
|
+
signalInboxActivity();
|
|
208
|
+
return finalized;
|
|
213
209
|
}
|
|
214
210
|
export function cancelTicketResult(dir, opts = {}) {
|
|
215
211
|
const ticket = requireRegisteredTicket(dir);
|
|
216
|
-
|
|
212
|
+
const canceled = withTicketLock(ticket.dir, () => {
|
|
217
213
|
const result = { schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: new Date().toISOString(), ...(opts.reason === undefined ? {} : { reason: opts.reason }), ...(opts.actor === undefined ? {} : { actor: opts.actor }) };
|
|
218
214
|
const won = exclusiveResult(ticket.dir, result);
|
|
219
215
|
return { status: won ? 'canceled' : 'already_resolved', result: won ? result : readTicketResult(ticket.dir) ?? result };
|
|
220
216
|
});
|
|
217
|
+
if (canceled.status === 'canceled')
|
|
218
|
+
signalInboxActivity();
|
|
219
|
+
return canceled;
|
|
221
220
|
}
|
|
222
221
|
export function ticketRoot(dir) {
|
|
223
222
|
try {
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { CanonicalInteraction, Interaction, VisualRequest as PanelVisualRequest } from '../types.js';
|
|
2
|
+
import { type CompletionHandler } from './registry.js';
|
|
3
|
+
export declare const VISUAL_CAPABILITY: "humanloop.visual/v1";
|
|
4
|
+
export interface VisualClaimIdentity {
|
|
5
|
+
token: string;
|
|
6
|
+
host: string;
|
|
7
|
+
pid: number;
|
|
8
|
+
claimedAt: string;
|
|
9
|
+
}
|
|
10
|
+
export interface VisualCleanupObligation {
|
|
11
|
+
reason: 'canceled' | 'unreceipted_start_error';
|
|
12
|
+
attempts: number;
|
|
13
|
+
nextAttemptAt: string;
|
|
14
|
+
lastAttemptAt?: string;
|
|
15
|
+
lastError?: string;
|
|
16
|
+
}
|
|
17
|
+
interface VisualBinding {
|
|
18
|
+
capability: typeof VISUAL_CAPABILITY;
|
|
19
|
+
owner: string;
|
|
20
|
+
root: string;
|
|
21
|
+
dir: string;
|
|
22
|
+
ticketId: string;
|
|
23
|
+
requestId: string;
|
|
24
|
+
generationId: string;
|
|
25
|
+
interactionId: string;
|
|
26
|
+
interaction: CanonicalInteraction;
|
|
27
|
+
claim: VisualClaimIdentity;
|
|
28
|
+
handler: CompletionHandler;
|
|
29
|
+
}
|
|
30
|
+
export interface VisualProtocolRequest extends VisualBinding {
|
|
31
|
+
schema: 'humanloop.visual-request/v1';
|
|
32
|
+
state: 'running' | 'canceled' | 'terminal';
|
|
33
|
+
requestedAt: string;
|
|
34
|
+
settledAt?: string;
|
|
35
|
+
cleanup?: VisualCleanupObligation;
|
|
36
|
+
}
|
|
37
|
+
export interface VisualRequestEvent extends VisualBinding {
|
|
38
|
+
schema: 'humanloop.visual-request-event/v1';
|
|
39
|
+
action: 'start' | 'cancel';
|
|
40
|
+
}
|
|
41
|
+
interface VisualResultBinding extends VisualBinding {
|
|
42
|
+
schema: 'humanloop.visual-result/v1';
|
|
43
|
+
completedAt: string;
|
|
44
|
+
}
|
|
45
|
+
export type VisualProtocolResult = (VisualResultBinding & {
|
|
46
|
+
status: 'ready';
|
|
47
|
+
markdown: string;
|
|
48
|
+
}) | (VisualResultBinding & {
|
|
49
|
+
status: 'error';
|
|
50
|
+
error: string;
|
|
51
|
+
});
|
|
52
|
+
export type VisualResultSubmission = {
|
|
53
|
+
requestId: string;
|
|
54
|
+
generationId: string;
|
|
55
|
+
interactionId: string;
|
|
56
|
+
interaction: CanonicalInteraction;
|
|
57
|
+
claimToken: string;
|
|
58
|
+
status: 'ready';
|
|
59
|
+
markdown: string;
|
|
60
|
+
} | {
|
|
61
|
+
requestId: string;
|
|
62
|
+
generationId: string;
|
|
63
|
+
interactionId: string;
|
|
64
|
+
interaction: CanonicalInteraction;
|
|
65
|
+
claimToken: string;
|
|
66
|
+
status: 'error';
|
|
67
|
+
error: string;
|
|
68
|
+
};
|
|
69
|
+
export interface StartVisualRequestOptions {
|
|
70
|
+
root: string;
|
|
71
|
+
dir: string;
|
|
72
|
+
claimToken: string;
|
|
73
|
+
request: PanelVisualRequest;
|
|
74
|
+
}
|
|
75
|
+
export type VisualStartDeliveryResult = 'delivered' | 'already_attempted' | 'ineligible' | 'failed';
|
|
76
|
+
export type VisualCleanupDeliveryResult = 'delivered' | 'pending' | 'none';
|
|
77
|
+
export interface StartedVisualRequest {
|
|
78
|
+
request: VisualProtocolRequest;
|
|
79
|
+
/** Resolves after the one permitted start attempt reaches a determinate outcome. */
|
|
80
|
+
delivery: Promise<VisualStartDeliveryResult>;
|
|
81
|
+
}
|
|
82
|
+
export interface VisualCleanupTask {
|
|
83
|
+
root: string;
|
|
84
|
+
dir: string;
|
|
85
|
+
requestId: string;
|
|
86
|
+
reason: VisualCleanupObligation['reason'];
|
|
87
|
+
nextAttemptAt: string;
|
|
88
|
+
}
|
|
89
|
+
/** Normalize one Interaction into the sole persisted/event correlation shape. */
|
|
90
|
+
export declare function canonicalizeInteraction(raw: Interaction | CanonicalInteraction): CanonicalInteraction;
|
|
91
|
+
/** Stable bytes used for every interaction equality check across the protocol. */
|
|
92
|
+
export declare function canonicalInteractionJson(raw: Interaction | CanonicalInteraction): string;
|
|
93
|
+
export declare function parseVisualRequestEvent(raw: unknown): VisualRequestEvent;
|
|
94
|
+
/** Strict, repairing reader; malformed or unbound request bytes return null. */
|
|
95
|
+
export declare function readVisualRequest(root: string, dir: string, requestId: string): VisualProtocolRequest | null;
|
|
96
|
+
/** Strict, repairing reader; canceled and mismatched results are never returned. */
|
|
97
|
+
export declare function readVisualResult(root: string, dir: string, requestId: string): VisualProtocolResult | null;
|
|
98
|
+
/** Decode an event and atomically bind it to Humanloop's immutable request state. */
|
|
99
|
+
export declare function readVisualRequestForEvent(raw: unknown): VisualProtocolRequest | null;
|
|
100
|
+
/** Persist one claim-bound request and dispatch its frozen handler at most once. */
|
|
101
|
+
export declare function startVisualRequest(opts: StartVisualRequestOptions): StartedVisualRequest;
|
|
102
|
+
/** Compare-publish the first fully correlated ready/error result. Stale writers are no-ops. */
|
|
103
|
+
export declare function submitVisualResult(root: string, dir: string, raw: VisualResultSubmission): {
|
|
104
|
+
published: boolean;
|
|
105
|
+
};
|
|
106
|
+
/** Attempt one due cleanup delivery through the request's frozen handler. */
|
|
107
|
+
export declare function dispatchVisualCleanup(root: string, dir: string, requestId: string): Promise<VisualCleanupDeliveryResult>;
|
|
108
|
+
/** State-first cancellation for one handle; the returned promise is cleanup delivery only. */
|
|
109
|
+
export declare function cancelVisualRequest(root: string, dir: string, requestId: string): Promise<VisualCleanupDeliveryResult>;
|
|
110
|
+
/** State-first cancel every running request, then dispatch every owed cleanup (including start failures). */
|
|
111
|
+
export declare function cancelVisualRequestsForTicket(root: string, dir: string): Promise<{
|
|
112
|
+
canceled: number;
|
|
113
|
+
cleanupOwed: number;
|
|
114
|
+
}>;
|
|
115
|
+
/** Durable tasks the next controller-owned retry executor should schedule. */
|
|
116
|
+
export declare function listVisualCleanupObligations(root: string, dir: string): VisualCleanupTask[];
|
|
117
|
+
export interface VisualRequestReconciliation {
|
|
118
|
+
retired: number;
|
|
119
|
+
cleanupOwed: number;
|
|
120
|
+
/** Cleanup delivery only; reconciliation never redelivers a start. */
|
|
121
|
+
delivery: Promise<VisualCleanupDeliveryResult[]>;
|
|
122
|
+
}
|
|
123
|
+
/** Retire abandoned generations under the ticket claim boundary. A live claim is
|
|
124
|
+
* authoritative; a newly acquired claim retires every older generation first. */
|
|
125
|
+
export declare function reconcileVisualRequestsForTicket(root: string, dir: string, currentClaimToken?: string): VisualRequestReconciliation;
|
|
126
|
+
/** Enumerate direct ticket children only, including resolved tickets whose cleanup is still owed. */
|
|
127
|
+
export declare function listVisualCleanupObligationsForRoot(root: string): VisualCleanupTask[];
|
|
128
|
+
/** Startup reconciliation only retires stale/missing-claim work; live owners stay untouched. */
|
|
129
|
+
export declare function reconcileStaleVisualRequestsForRoot(root: string): VisualRequestReconciliation[];
|
|
130
|
+
export {};
|