@crouton-kit/humanloop 0.3.33 → 0.3.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.d.ts +4 -8
- package/dist/api.js +6 -29
- package/dist/browser/server.d.ts +3 -3
- package/dist/browser/server.js +22 -25
- package/dist/cli.js +131 -1114
- package/dist/editor/feedback.d.ts +9 -0
- package/dist/editor/feedback.js +23 -0
- package/dist/editor/review.d.ts +12 -30
- package/dist/editor/review.js +109 -119
- package/dist/inbox/claim.d.ts +23 -0
- package/dist/inbox/claim.js +67 -0
- package/dist/inbox/completion.d.ts +4 -0
- package/dist/inbox/completion.js +107 -0
- package/dist/inbox/controller.d.ts +74 -0
- package/dist/inbox/controller.js +457 -0
- package/dist/inbox/convention.d.ts +15 -10
- package/dist/inbox/convention.js +149 -79
- package/dist/inbox/deck-adapter.d.ts +27 -0
- package/dist/inbox/deck-adapter.js +57 -0
- package/dist/inbox/deck-schema.d.ts +18 -14
- package/dist/inbox/deck-schema.js +59 -117
- package/dist/inbox/layout.d.ts +8 -0
- package/dist/inbox/layout.js +9 -0
- package/dist/inbox/registry.d.ts +29 -0
- package/dist/inbox/registry.js +97 -0
- package/dist/inbox/review-adapter.d.ts +24 -0
- package/dist/inbox/review-adapter.js +57 -0
- package/dist/inbox/scan.d.ts +3 -2
- package/dist/inbox/scan.js +71 -43
- package/dist/inbox/tickets.d.ts +63 -0
- package/dist/inbox/tickets.js +247 -0
- package/dist/inbox/tui.d.ts +3 -6
- package/dist/inbox/tui.js +24 -112
- package/dist/index.d.ts +12 -3
- package/dist/index.js +8 -2
- package/dist/render/termrender.d.ts +5 -0
- package/dist/render/termrender.js +63 -4
- package/dist/summary.d.ts +2 -3
- package/dist/summary.js +2 -3
- package/dist/surfaces/inbox-popup.d.ts +2 -0
- package/dist/surfaces/inbox-popup.js +32 -0
- package/dist/tui/app.js +13 -0
- package/dist/tui/tmux.d.ts +30 -7
- package/dist/tui/tmux.js +190 -66
- package/dist/types.d.ts +68 -7
- package/package.json +2 -2
|
@@ -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,24 @@
|
|
|
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
|
+
/** Human pressed Option/Alt+I inside the editor: close the whole inbox. */
|
|
11
|
+
onClose?: () => Promise<void> | void;
|
|
12
|
+
}
|
|
13
|
+
/** Controller-owned native review child. It persists drafts but delegates the sole final write to tickets.ts. */
|
|
14
|
+
export declare class ReviewAdapter {
|
|
15
|
+
private readonly opts;
|
|
16
|
+
private readonly abortController;
|
|
17
|
+
private readonly heartbeat;
|
|
18
|
+
private running;
|
|
19
|
+
private stopped;
|
|
20
|
+
constructor(opts: ReviewAdapterOptions);
|
|
21
|
+
start(): Promise<TicketResult | null>;
|
|
22
|
+
/** Toggle, cancellation, and controller teardown all take this same path. */
|
|
23
|
+
stop(): Promise<void>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
onClose: this.opts.onClose,
|
|
29
|
+
onPropose: async (proposal) => {
|
|
30
|
+
if (this.stopped || this.abortController.signal.aborted)
|
|
31
|
+
return;
|
|
32
|
+
const completed = await completeReview(this.opts.dir, proposal, this.opts.claim.token);
|
|
33
|
+
canonical = completed.result;
|
|
34
|
+
await this.opts.onSubmitted?.(canonical);
|
|
35
|
+
},
|
|
36
|
+
}).then(() => canonical).finally(() => {
|
|
37
|
+
clearInterval(this.heartbeat);
|
|
38
|
+
releaseClaim(this.opts.dir, this.opts.claim.token);
|
|
39
|
+
this.stopped = true;
|
|
40
|
+
});
|
|
41
|
+
return this.running;
|
|
42
|
+
}
|
|
43
|
+
/** Toggle, cancellation, and controller teardown all take this same path. */
|
|
44
|
+
async stop() {
|
|
45
|
+
if (this.stopped)
|
|
46
|
+
return;
|
|
47
|
+
this.stopped = true;
|
|
48
|
+
this.abortController.abort();
|
|
49
|
+
try {
|
|
50
|
+
await this.running;
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
clearInterval(this.heartbeat);
|
|
54
|
+
releaseClaim(this.opts.dir, this.opts.claim.token);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
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[];
|
package/dist/inbox/scan.js
CHANGED
|
@@ -1,62 +1,90 @@
|
|
|
1
|
-
import { readdirSync, statSync } from 'fs';
|
|
2
|
-
import {
|
|
3
|
-
import { deckPath, isResolved,
|
|
4
|
-
|
|
1
|
+
import { readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, resolve } from 'node:path';
|
|
3
|
+
import { claimPath, deckPath, isResolved, readJson, reviewPath } from './convention.js';
|
|
4
|
+
import { validateDeck, validateReviewDescriptor } from './deck-schema.js';
|
|
5
|
+
import { listInboxRoots } from './registry.js';
|
|
6
|
+
function claimSummary(dir) {
|
|
7
|
+
const claim = readJson(claimPath(dir));
|
|
8
|
+
if (claim === null || typeof claim.host !== 'string' || typeof claim.claimedAt !== 'string' || typeof claim.heartbeatAt !== 'string')
|
|
9
|
+
return undefined;
|
|
10
|
+
return { owner: claim.host, claimedAt: claim.claimedAt, heartbeatAt: claim.heartbeatAt };
|
|
11
|
+
}
|
|
12
|
+
function deckSummary(dir, id) {
|
|
13
|
+
const raw = readJson(deckPath(dir));
|
|
14
|
+
if (raw === null)
|
|
15
|
+
return null;
|
|
16
|
+
let deck;
|
|
17
|
+
try {
|
|
18
|
+
deck = validateDeck(raw);
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
const first = deck.interactions[0];
|
|
24
|
+
if (first === undefined)
|
|
25
|
+
return null;
|
|
26
|
+
let blockedSince = deck.source?.blockedSince;
|
|
27
|
+
if (blockedSince === undefined) {
|
|
28
|
+
try {
|
|
29
|
+
blockedSince = statSync(deckPath(dir)).mtime.toISOString();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return { dir, id, kind: 'deck', title: deck.title ?? first.title, subtitle: first.subtitle, interactionKind: first.kind, source: deck.source ?? {}, blockedSince, claim: claimSummary(dir) };
|
|
36
|
+
}
|
|
37
|
+
function reviewSummary(dir, id) {
|
|
38
|
+
const raw = readJson(reviewPath(dir));
|
|
39
|
+
if (raw === null)
|
|
40
|
+
return null;
|
|
41
|
+
let review;
|
|
42
|
+
try {
|
|
43
|
+
review = validateReviewDescriptor(raw);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
return { dir, id, kind: 'review', title: review.title, file: review.file, output: review.output, source: review.source, blockedSince: review.blockedSince, claim: claimSummary(dir) };
|
|
49
|
+
}
|
|
50
|
+
/** Read all unresolved deck/review tickets, newest first. Progress never affects visibility. */
|
|
5
51
|
export function scanInbox(roots) {
|
|
52
|
+
const selectedRoots = roots ?? listInboxRoots().filter((root) => root.available).map((root) => root.root);
|
|
53
|
+
const seen = new Set();
|
|
6
54
|
const items = [];
|
|
7
|
-
for (const root of
|
|
55
|
+
for (const root of selectedRoots) {
|
|
56
|
+
let canonicalRoot;
|
|
57
|
+
try {
|
|
58
|
+
canonicalRoot = realpathSync(root);
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
8
63
|
let entries;
|
|
9
64
|
try {
|
|
10
|
-
entries = readdirSync(
|
|
65
|
+
entries = readdirSync(canonicalRoot);
|
|
11
66
|
}
|
|
12
67
|
catch {
|
|
13
|
-
// root doesn't exist or isn't readable — skip silently
|
|
14
68
|
continue;
|
|
15
69
|
}
|
|
16
70
|
for (const entry of entries) {
|
|
17
|
-
const dir = resolve(
|
|
71
|
+
const dir = resolve(canonicalRoot, entry);
|
|
72
|
+
let canonicalDir;
|
|
18
73
|
try {
|
|
19
|
-
|
|
20
|
-
if (!stat.isDirectory())
|
|
74
|
+
if (!statSync(dir).isDirectory())
|
|
21
75
|
continue;
|
|
76
|
+
canonicalDir = realpathSync(dir);
|
|
22
77
|
}
|
|
23
78
|
catch {
|
|
24
79
|
continue;
|
|
25
80
|
}
|
|
26
|
-
|
|
27
|
-
if (isResolved(dir) || isClaimed(dir))
|
|
28
|
-
continue;
|
|
29
|
-
const dp = deckPath(dir);
|
|
30
|
-
const deck = readJson(dp);
|
|
31
|
-
if (deck === null)
|
|
81
|
+
if (resolve(canonicalDir, '..') !== canonicalRoot || seen.has(canonicalDir) || isResolved(canonicalDir))
|
|
32
82
|
continue;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if (
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
else {
|
|
39
|
-
try {
|
|
40
|
-
blockedSince = new Date(statSync(dp).mtime).toISOString();
|
|
41
|
-
}
|
|
42
|
-
catch {
|
|
43
|
-
blockedSince = new Date().toISOString();
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
const firstInteraction = deck.interactions[0];
|
|
47
|
-
const item = {
|
|
48
|
-
dir,
|
|
49
|
-
id: firstInteraction?.id ?? basename(dir),
|
|
50
|
-
title: deck.title ?? firstInteraction?.title,
|
|
51
|
-
subtitle: firstInteraction?.subtitle,
|
|
52
|
-
kind: firstInteraction?.kind,
|
|
53
|
-
source: deck.source,
|
|
54
|
-
blockedSince,
|
|
55
|
-
};
|
|
56
|
-
items.push(item);
|
|
83
|
+
seen.add(canonicalDir);
|
|
84
|
+
const item = deckSummary(canonicalDir, basename(canonicalDir)) ?? reviewSummary(canonicalDir, basename(canonicalDir));
|
|
85
|
+
if (item !== null)
|
|
86
|
+
items.push(item);
|
|
57
87
|
}
|
|
58
88
|
}
|
|
59
|
-
|
|
60
|
-
items.sort((a, b) => (a.blockedSince < b.blockedSince ? -1 : a.blockedSince > b.blockedSince ? 1 : 0));
|
|
61
|
-
return items;
|
|
89
|
+
return items.sort((a, b) => b.blockedSince.localeCompare(a.blockedSince) || a.id.localeCompare(b.id));
|
|
62
90
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { Deck, DeckTicketResult, FeedbackResult, ReviewDescriptor, TicketResult } from '../types.js';
|
|
2
|
+
/** Strict decoder for the only canonical final marker. */
|
|
3
|
+
export declare function readTicketResult(dirOrResponsePath: string): TicketResult | null;
|
|
4
|
+
export interface SubmitDeckOptions {
|
|
5
|
+
root: string;
|
|
6
|
+
id: string;
|
|
7
|
+
deck: Deck;
|
|
8
|
+
}
|
|
9
|
+
export declare function submitDeck(opts: SubmitDeckOptions): {
|
|
10
|
+
id: string;
|
|
11
|
+
dir: string;
|
|
12
|
+
kind: 'deck';
|
|
13
|
+
};
|
|
14
|
+
export interface SubmitReviewOptions {
|
|
15
|
+
root: string;
|
|
16
|
+
id: string;
|
|
17
|
+
review: Omit<ReviewDescriptor, 'schema' | 'file' | 'output' | 'blockedSince'> & {
|
|
18
|
+
file: string;
|
|
19
|
+
output?: string;
|
|
20
|
+
blockedSince?: string;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
export declare function submitReview(opts: SubmitReviewOptions): {
|
|
24
|
+
id: string;
|
|
25
|
+
dir: string;
|
|
26
|
+
kind: 'review';
|
|
27
|
+
};
|
|
28
|
+
export declare function finalizeDeck(dir: string, responses: DeckTicketResult['responses'], claimToken: string, completedAt?: string): {
|
|
29
|
+
won: boolean;
|
|
30
|
+
result: TicketResult;
|
|
31
|
+
};
|
|
32
|
+
export declare function finalizeReview(dir: string, feedback: FeedbackResult, claimToken: string, completedAt?: string): {
|
|
33
|
+
won: boolean;
|
|
34
|
+
result: TicketResult;
|
|
35
|
+
descriptor: ReviewDescriptor;
|
|
36
|
+
};
|
|
37
|
+
export declare function cancelTicketResult(dir: string, opts?: {
|
|
38
|
+
reason?: string;
|
|
39
|
+
actor?: string;
|
|
40
|
+
}): {
|
|
41
|
+
status: 'canceled' | 'already_resolved';
|
|
42
|
+
result: TicketResult;
|
|
43
|
+
};
|
|
44
|
+
export declare function ticketRoot(dir: string): string | null;
|
|
45
|
+
/** Finalize and immediately cross the trusted root completion boundary. */
|
|
46
|
+
export declare function completeDeck(dir: string, responses: DeckTicketResult['responses'], claimToken: string): Promise<{
|
|
47
|
+
won: boolean;
|
|
48
|
+
result: TicketResult;
|
|
49
|
+
}>;
|
|
50
|
+
/** Finalize a review, then let completion own its projection and notification boundary. */
|
|
51
|
+
export declare function completeReview(dir: string, feedback: FeedbackResult, claimToken: string): Promise<{
|
|
52
|
+
won: boolean;
|
|
53
|
+
result: TicketResult;
|
|
54
|
+
descriptor: ReviewDescriptor;
|
|
55
|
+
}>;
|
|
56
|
+
/** Cancellation races finalization but never requires or removes another claim. */
|
|
57
|
+
export declare function cancelTicket(dir: string, opts?: {
|
|
58
|
+
reason?: string;
|
|
59
|
+
actor?: string;
|
|
60
|
+
}): Promise<{
|
|
61
|
+
status: 'canceled' | 'already_resolved';
|
|
62
|
+
result: TicketResult;
|
|
63
|
+
}>;
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, realpathSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { buildSummary } from '../summary.js';
|
|
5
|
+
import { clearProgress, claimPath, deckPath, deliveryErrorPath, deliveryPath, progressPath, responsePath, reviewPath } from './convention.js';
|
|
6
|
+
import { validateDeck, validateReviewDescriptor, validateReviewProjection, resolveDeckBodyPaths } from './deck-schema.js';
|
|
7
|
+
import { registeredInboxRoot } from './registry.js';
|
|
8
|
+
import { readTicketClaim, releaseClaimLocked, withTicketLock } from './claim.js';
|
|
9
|
+
import { dispatchCompletion } from './completion.js';
|
|
10
|
+
const ticketId = z.string().regex(/^[A-Za-z0-9_-]+$/).min(1).max(128);
|
|
11
|
+
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
|
+
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();
|
|
13
|
+
const iso = z.string().datetime({ offset: true });
|
|
14
|
+
const feedbackSchema = z.object({ file: z.string().min(1).refine(isAbsolute, 'file must be absolute').refine((file) => resolve(file) === file, 'file must be canonical'), submitted: z.literal(true), approved: z.boolean(), comments: z.array(feedbackCommentSchema), submittedAt: iso, savedAt: iso }).strict().superRefine((result, ctx) => {
|
|
15
|
+
if (result.approved !== (result.comments.length === 0))
|
|
16
|
+
ctx.addIssue({ code: 'custom', message: 'approved must match empty comments' });
|
|
17
|
+
result.comments.forEach((comment, index) => {
|
|
18
|
+
if (comment.endLine < comment.line)
|
|
19
|
+
ctx.addIssue({ code: 'custom', message: 'endLine must be >= line', path: ['comments', index, 'endLine'] });
|
|
20
|
+
if ((comment.colStart === undefined) !== (comment.colEnd === undefined))
|
|
21
|
+
ctx.addIssue({ code: 'custom', message: 'columns must be supplied together', path: ['comments', index] });
|
|
22
|
+
if (comment.line === comment.endLine && comment.colStart !== undefined && comment.colEnd !== undefined && comment.colEnd <= comment.colStart)
|
|
23
|
+
ctx.addIssue({ code: 'custom', message: 'colEnd must exceed colStart', path: ['comments', index, 'colEnd'] });
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
const deckResultSchema = z.object({ schema: z.literal('humanloop.response/v2'), kind: z.literal('deck'), responses: z.array(responseSchema), summary: z.string(), completedAt: iso }).strict();
|
|
27
|
+
const reviewResultSchema = z.object({ schema: z.literal('humanloop.review-response/v1'), kind: z.literal('review'), result: feedbackSchema, completedAt: iso }).strict();
|
|
28
|
+
const canceledResultSchema = z.object({ schema: z.literal('humanloop.cancel/v1'), kind: z.literal('canceled'), canceledAt: iso, reason: z.string().optional(), actor: z.string().optional() }).strict();
|
|
29
|
+
/** Strict decoder for the only canonical final marker. */
|
|
30
|
+
export function readTicketResult(dirOrResponsePath) {
|
|
31
|
+
const path = basename(dirOrResponsePath) === 'response.json' ? dirOrResponsePath : responsePath(dirOrResponsePath);
|
|
32
|
+
let raw;
|
|
33
|
+
try {
|
|
34
|
+
raw = JSON.parse(readFileSync(path, 'utf8'));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
const parsed = deckResultSchema.safeParse(raw);
|
|
40
|
+
if (parsed.success)
|
|
41
|
+
return parsed.data;
|
|
42
|
+
const review = reviewResultSchema.safeParse(raw);
|
|
43
|
+
if (review.success)
|
|
44
|
+
return review.data;
|
|
45
|
+
const canceled = canceledResultSchema.safeParse(raw);
|
|
46
|
+
return canceled.success ? canceled.data : null;
|
|
47
|
+
}
|
|
48
|
+
function rootAndDir(root, id) {
|
|
49
|
+
const registration = registeredInboxRoot(root);
|
|
50
|
+
if (registration === null)
|
|
51
|
+
throw new Error('interaction root is not registered');
|
|
52
|
+
const parsedId = ticketId.parse(id);
|
|
53
|
+
const dir = resolve(registration.root, parsedId);
|
|
54
|
+
if (dirname(dir) !== registration.root)
|
|
55
|
+
throw new Error('ticket directory must be a direct child of its registered root');
|
|
56
|
+
return { root: registration.root, dir };
|
|
57
|
+
}
|
|
58
|
+
function ticketDir(root, id) {
|
|
59
|
+
const candidate = rootAndDir(root, id);
|
|
60
|
+
let created = false;
|
|
61
|
+
if (!existsSync(candidate.dir)) {
|
|
62
|
+
mkdirSync(candidate.dir, { mode: 0o700 });
|
|
63
|
+
created = true;
|
|
64
|
+
}
|
|
65
|
+
const dir = realpathSync(candidate.dir);
|
|
66
|
+
if (dirname(dir) !== candidate.root)
|
|
67
|
+
throw new Error('ticket directory must canonically be a direct child of its registered root');
|
|
68
|
+
return { dir, created };
|
|
69
|
+
}
|
|
70
|
+
function discardCreatedTicket(dir, created) { if (created)
|
|
71
|
+
rmSync(dir, { recursive: true, force: true }); }
|
|
72
|
+
function hasTicketProtocolState(dir) {
|
|
73
|
+
return [deckPath(dir), reviewPath(dir), responsePath(dir), progressPath(dir), claimPath(dir), deliveryPath(dir), deliveryErrorPath(dir)].some(existsSync);
|
|
74
|
+
}
|
|
75
|
+
function requireRegisteredTicket(dir) {
|
|
76
|
+
const canonical = realpathSync(dir);
|
|
77
|
+
const root = dirname(canonical);
|
|
78
|
+
const registration = registeredInboxRoot(root);
|
|
79
|
+
if (registration === null || registration.root !== root || basename(canonical) === '')
|
|
80
|
+
throw new Error('ticket must be a canonical direct child of a registered root');
|
|
81
|
+
if (!existsSync(deckPath(canonical)) && !existsSync(reviewPath(canonical)))
|
|
82
|
+
throw new Error('ticket has no request descriptor');
|
|
83
|
+
return { root, dir: canonical };
|
|
84
|
+
}
|
|
85
|
+
function publishRequest(path, value) {
|
|
86
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
87
|
+
const temp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
|
|
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
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function submitDeck(opts) {
|
|
102
|
+
// Validate shape before mutating caller-owned interaction directories.
|
|
103
|
+
validateDeck(opts.deck);
|
|
104
|
+
const { dir, created } = ticketDir(opts.root, opts.id);
|
|
105
|
+
try {
|
|
106
|
+
if (hasTicketProtocolState(dir))
|
|
107
|
+
throw new Error(`ticket protocol state already exists: ${dir}`);
|
|
108
|
+
const deck = validateDeck(resolveDeckBodyPaths(opts.deck, dir));
|
|
109
|
+
const stamped = { ...deck, source: { ...(deck.source ?? {}), blockedSince: deck.source?.blockedSince ?? new Date().toISOString() } };
|
|
110
|
+
publishRequest(deckPath(dir), stamped);
|
|
111
|
+
return { id: opts.id, dir, kind: 'deck' };
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
discardCreatedTicket(dir, created);
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
export function submitReview(opts) {
|
|
119
|
+
if (!isAbsolute(opts.review.file) || !existsSync(opts.review.file))
|
|
120
|
+
throw new Error('review file must be an existing absolute markdown file');
|
|
121
|
+
if (!/\.md(?:own)?$/i.test(opts.review.file))
|
|
122
|
+
throw new Error('review file must be markdown');
|
|
123
|
+
const source = realpathSync(opts.review.file);
|
|
124
|
+
const { dir, created } = ticketDir(opts.root, opts.id);
|
|
125
|
+
try {
|
|
126
|
+
if (hasTicketProtocolState(dir))
|
|
127
|
+
throw new Error(`ticket protocol state already exists: ${dir}`);
|
|
128
|
+
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
|
+
publishRequest(reviewPath(dir), descriptor);
|
|
130
|
+
return { id: opts.id, dir, kind: 'review' };
|
|
131
|
+
}
|
|
132
|
+
catch (error) {
|
|
133
|
+
discardCreatedTicket(dir, created);
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function exclusiveResult(dir, result) {
|
|
138
|
+
const path = responsePath(dir);
|
|
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
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function requireDeck(dir) { return validateDeck(JSON.parse(readFileSync(deckPath(dir), 'utf8'))); }
|
|
155
|
+
function requireReview(dir) { return validateReviewDescriptor(JSON.parse(readFileSync(reviewPath(dir), 'utf8'))); }
|
|
156
|
+
function validateDeckResponses(deck, raw) {
|
|
157
|
+
const responses = z.array(responseSchema).parse(raw);
|
|
158
|
+
const interactions = new Map(deck.interactions.map((interaction) => [interaction.id, interaction]));
|
|
159
|
+
const seen = new Set();
|
|
160
|
+
for (const response of responses) {
|
|
161
|
+
const interaction = interactions.get(response.id);
|
|
162
|
+
if (interaction === undefined || seen.has(response.id))
|
|
163
|
+
throw new Error(`response does not match a unique deck interaction: ${response.id}`);
|
|
164
|
+
seen.add(response.id);
|
|
165
|
+
const optionIds = new Set(interaction.options.map((option) => option.id));
|
|
166
|
+
if (response.selectedOptionId !== undefined && (!optionIds.has(response.selectedOptionId) || interaction.multiSelect === true))
|
|
167
|
+
throw new Error(`invalid single-select response for ${response.id}`);
|
|
168
|
+
if (response.selectedOptionIds !== undefined && (interaction.multiSelect !== true || response.selectedOptionIds.some((id) => !optionIds.has(id)) || new Set(response.selectedOptionIds).size !== response.selectedOptionIds.length))
|
|
169
|
+
throw new Error(`invalid multi-select response for ${response.id}`);
|
|
170
|
+
if (response.freetext !== undefined && interaction.allowFreetext !== true)
|
|
171
|
+
throw new Error(`freetext is not allowed for ${response.id}`);
|
|
172
|
+
if (response.optionComments !== undefined && (interaction.multiSelect !== true || Object.keys(response.optionComments).some((id) => !optionIds.has(id))))
|
|
173
|
+
throw new Error(`invalid option comments for ${response.id}`);
|
|
174
|
+
}
|
|
175
|
+
return responses;
|
|
176
|
+
}
|
|
177
|
+
function requireClaimOwnership(dir, token) {
|
|
178
|
+
if (readTicketClaim(dir)?.token !== token)
|
|
179
|
+
throw new Error('only the current claim holder may submit a result');
|
|
180
|
+
}
|
|
181
|
+
function clearOwnedWork(dir, claimToken) { clearProgress(dir); releaseClaimLocked(dir, claimToken); }
|
|
182
|
+
export function finalizeDeck(dir, responses, claimToken, completedAt = new Date().toISOString()) {
|
|
183
|
+
const ticket = requireRegisteredTicket(dir);
|
|
184
|
+
return withTicketLock(ticket.dir, () => {
|
|
185
|
+
requireClaimOwnership(ticket.dir, claimToken);
|
|
186
|
+
const deck = requireDeck(ticket.dir);
|
|
187
|
+
const parsedResponses = validateDeckResponses(deck, responses);
|
|
188
|
+
const result = { schema: 'humanloop.response/v2', kind: 'deck', responses: parsedResponses, summary: buildSummary(deck, parsedResponses), completedAt };
|
|
189
|
+
const won = exclusiveResult(ticket.dir, result);
|
|
190
|
+
clearOwnedWork(ticket.dir, claimToken);
|
|
191
|
+
return { won, result: won ? result : readTicketResult(ticket.dir) ?? result };
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
export function finalizeReview(dir, feedback, claimToken, completedAt = new Date().toISOString()) {
|
|
195
|
+
const ticket = requireRegisteredTicket(dir);
|
|
196
|
+
return withTicketLock(ticket.dir, () => {
|
|
197
|
+
requireClaimOwnership(ticket.dir, claimToken);
|
|
198
|
+
const descriptor = requireReview(ticket.dir);
|
|
199
|
+
const parsed = feedbackSchema.parse(feedback);
|
|
200
|
+
if (realpathSync(parsed.file) !== descriptor.file)
|
|
201
|
+
throw new Error('review result file does not match descriptor');
|
|
202
|
+
const result = { schema: 'humanloop.review-response/v1', kind: 'review', result: parsed, completedAt };
|
|
203
|
+
const won = exclusiveResult(ticket.dir, result);
|
|
204
|
+
clearOwnedWork(ticket.dir, claimToken);
|
|
205
|
+
return { won, result: won ? result : readTicketResult(ticket.dir) ?? result, descriptor };
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
export function cancelTicketResult(dir, opts = {}) {
|
|
209
|
+
const ticket = requireRegisteredTicket(dir);
|
|
210
|
+
return withTicketLock(ticket.dir, () => {
|
|
211
|
+
const result = { schema: 'humanloop.cancel/v1', kind: 'canceled', canceledAt: new Date().toISOString(), ...(opts.reason === undefined ? {} : { reason: opts.reason }), ...(opts.actor === undefined ? {} : { actor: opts.actor }) };
|
|
212
|
+
const won = exclusiveResult(ticket.dir, result);
|
|
213
|
+
return { status: won ? 'canceled' : 'already_resolved', result: won ? result : readTicketResult(ticket.dir) ?? result };
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
export function ticketRoot(dir) {
|
|
217
|
+
try {
|
|
218
|
+
return requireRegisteredTicket(dir).root;
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/** Finalize and immediately cross the trusted root completion boundary. */
|
|
225
|
+
export async function completeDeck(dir, responses, claimToken) {
|
|
226
|
+
const completed = finalizeDeck(dir, responses, claimToken);
|
|
227
|
+
const root = ticketRoot(dir);
|
|
228
|
+
if (completed.won && root !== null)
|
|
229
|
+
await dispatchCompletion(root, dir);
|
|
230
|
+
return completed;
|
|
231
|
+
}
|
|
232
|
+
/** Finalize a review, then let completion own its projection and notification boundary. */
|
|
233
|
+
export async function completeReview(dir, feedback, claimToken) {
|
|
234
|
+
const completed = finalizeReview(dir, feedback, claimToken);
|
|
235
|
+
const root = ticketRoot(dir);
|
|
236
|
+
if (completed.won && root !== null)
|
|
237
|
+
await dispatchCompletion(root, dir);
|
|
238
|
+
return completed;
|
|
239
|
+
}
|
|
240
|
+
/** Cancellation races finalization but never requires or removes another claim. */
|
|
241
|
+
export async function cancelTicket(dir, opts = {}) {
|
|
242
|
+
const canceled = cancelTicketResult(dir, opts);
|
|
243
|
+
const root = ticketRoot(dir);
|
|
244
|
+
if (canceled.status === 'canceled' && root !== null)
|
|
245
|
+
await dispatchCompletion(root, dir);
|
|
246
|
+
return canceled;
|
|
247
|
+
}
|
package/dist/inbox/tui.d.ts
CHANGED
|
@@ -1,9 +1,6 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { TicketSummary } from '../types.js';
|
|
2
2
|
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
|
-
|
|
6
|
-
export declare function
|
|
7
|
-
cols: number;
|
|
8
|
-
rows: number;
|
|
9
|
-
}): Promise<InboxItem | null>;
|
|
5
|
+
/** Pure inbox rows; selection and terminal ownership belong to InboxController. */
|
|
6
|
+
export declare function buildInboxLines(items: TicketSummary[], width: number, selectedIndex: number): string[];
|