@crouton-kit/humanloop 0.3.33 → 0.3.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/api.d.ts +4 -8
  2. package/dist/api.js +6 -29
  3. package/dist/browser/server.d.ts +3 -3
  4. package/dist/browser/server.js +22 -25
  5. package/dist/cli.js +126 -1114
  6. package/dist/editor/feedback.d.ts +9 -0
  7. package/dist/editor/feedback.js +23 -0
  8. package/dist/editor/review.d.ts +8 -30
  9. package/dist/editor/review.js +79 -118
  10. package/dist/inbox/claim.d.ts +23 -0
  11. package/dist/inbox/claim.js +67 -0
  12. package/dist/inbox/completion.d.ts +4 -0
  13. package/dist/inbox/completion.js +100 -0
  14. package/dist/inbox/controller.d.ts +57 -0
  15. package/dist/inbox/controller.js +311 -0
  16. package/dist/inbox/convention.d.ts +15 -10
  17. package/dist/inbox/convention.js +149 -79
  18. package/dist/inbox/deck-adapter.d.ts +27 -0
  19. package/dist/inbox/deck-adapter.js +57 -0
  20. package/dist/inbox/deck-schema.d.ts +18 -14
  21. package/dist/inbox/deck-schema.js +59 -117
  22. package/dist/inbox/layout.d.ts +8 -0
  23. package/dist/inbox/layout.js +9 -0
  24. package/dist/inbox/registry.d.ts +29 -0
  25. package/dist/inbox/registry.js +97 -0
  26. package/dist/inbox/review-adapter.d.ts +22 -0
  27. package/dist/inbox/review-adapter.js +56 -0
  28. package/dist/inbox/scan.d.ts +3 -2
  29. package/dist/inbox/scan.js +71 -43
  30. package/dist/inbox/tickets.d.ts +63 -0
  31. package/dist/inbox/tickets.js +247 -0
  32. package/dist/inbox/tui.d.ts +3 -6
  33. package/dist/inbox/tui.js +24 -112
  34. package/dist/index.d.ts +12 -3
  35. package/dist/index.js +8 -2
  36. package/dist/summary.d.ts +2 -3
  37. package/dist/summary.js +2 -3
  38. package/dist/surfaces/inbox-popup.d.ts +2 -0
  39. package/dist/surfaces/inbox-popup.js +32 -0
  40. package/dist/tui/app.js +13 -0
  41. package/dist/tui/tmux.d.ts +30 -7
  42. package/dist/tui/tmux.js +179 -66
  43. package/dist/types.d.ts +68 -7
  44. package/package.json +2 -2
@@ -0,0 +1,57 @@
1
+ import type { InteractionResponse, TicketSummary } from '../types.js';
2
+ import type { Key } from '../tui/terminal.js';
3
+ export interface InboxControllerOptions {
4
+ roots?: string[];
5
+ cols?: number;
6
+ rows?: number;
7
+ scan?: (roots?: string[]) => TicketSummary[];
8
+ completeDeck?: (dir: string, responses: InteractionResponse[], token: string) => Promise<unknown>;
9
+ }
10
+ type Screen = 'list' | 'detail';
11
+ /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
12
+ export declare class InboxController {
13
+ private readonly options;
14
+ private readonly scan;
15
+ private readonly finishDeck;
16
+ private items;
17
+ private selectedDir;
18
+ private selectedIndex;
19
+ private screen;
20
+ private adapter;
21
+ private claim;
22
+ private cols;
23
+ private rows;
24
+ private frame;
25
+ private status;
26
+ private watchers;
27
+ private selectedWatcher;
28
+ private closed;
29
+ private running;
30
+ private finishRun;
31
+ constructor(options?: InboxControllerOptions);
32
+ snapshot(): {
33
+ items: TicketSummary[];
34
+ selectedDir?: string;
35
+ screen: Screen;
36
+ inputBuffer?: string;
37
+ };
38
+ rescan(): void;
39
+ invalidate(): void;
40
+ resize(cols?: number, rows?: number): void;
41
+ render(): string[];
42
+ handleKey(input: string, key: Key): void;
43
+ activate(): void;
44
+ reloadSelectedDeck(): void;
45
+ close(): void;
46
+ run(): Promise<void>;
47
+ private complete;
48
+ private leaveDetail;
49
+ private select;
50
+ private detailSize;
51
+ private detailLines;
52
+ private withStatus;
53
+ private repaint;
54
+ private watchRoots;
55
+ private watchSelected;
56
+ }
57
+ export {};
@@ -0,0 +1,311 @@
1
+ import { watch } from 'node:fs';
2
+ import { getTerminalSize, parseKeypress, restoreTerminal, setupTerminal } from '../tui/terminal.js';
3
+ import { diffFrame } from '../tui/render.js';
4
+ import { BOLD, CYAN, DIM, RESET, YELLOW } from '../tui/ansi.js';
5
+ import { buildInboxLines } from './tui.js';
6
+ import { inboxLayout } from './layout.js';
7
+ import { scanInbox } from './scan.js';
8
+ import { inboxRootsDirectory, listInboxRoots } from './registry.js';
9
+ import { claimTicket, heartbeatClaim, releaseClaim } from './claim.js';
10
+ import { completeDeck, readTicketResult } from './tickets.js';
11
+ import { clearProgress, deckPath, progressPath, readJson } from './convention.js';
12
+ import { DeckAdapter } from './deck-adapter.js';
13
+ /** One terminal owner for scanning, stable selection, claims, and frame diffs. */
14
+ export class InboxController {
15
+ options;
16
+ scan;
17
+ finishDeck;
18
+ items = [];
19
+ selectedDir;
20
+ selectedIndex = 0;
21
+ screen = 'list';
22
+ adapter;
23
+ claim;
24
+ cols;
25
+ rows;
26
+ frame = [];
27
+ status;
28
+ watchers = [];
29
+ selectedWatcher;
30
+ closed = false;
31
+ running = false;
32
+ finishRun;
33
+ constructor(options = {}) {
34
+ this.options = options;
35
+ this.scan = options.scan ?? scanInbox;
36
+ this.finishDeck = options.completeDeck ?? completeDeck;
37
+ const size = getTerminalSize();
38
+ this.cols = options.cols ?? size.cols;
39
+ this.rows = options.rows ?? size.rows;
40
+ this.rescan();
41
+ }
42
+ snapshot() {
43
+ return { items: [...this.items], selectedDir: this.selectedDir, screen: this.screen, inputBuffer: this.adapter?.inputBuffer() };
44
+ }
45
+ rescan() {
46
+ const priorDir = this.selectedDir;
47
+ const priorIndex = this.selectedIndex;
48
+ this.items = this.scan(this.options.roots);
49
+ const found = priorDir === undefined ? -1 : this.items.findIndex((item) => item.dir === priorDir);
50
+ if (found >= 0) {
51
+ this.selectedIndex = found;
52
+ this.selectedDir = priorDir;
53
+ return;
54
+ }
55
+ if (this.adapter !== undefined && priorDir !== undefined) {
56
+ const canceled = readTicketResult(priorDir)?.kind === 'canceled';
57
+ if (canceled)
58
+ clearProgress(priorDir);
59
+ this.status = canceled ? 'canceled by requester' : 'ticket resolved elsewhere';
60
+ this.leaveDetail();
61
+ }
62
+ if (this.items.length === 0) {
63
+ this.selectedIndex = 0;
64
+ this.selectedDir = undefined;
65
+ return;
66
+ }
67
+ this.selectedIndex = Math.min(priorIndex, this.items.length - 1);
68
+ this.selectedDir = this.items[this.selectedIndex].dir;
69
+ }
70
+ invalidate() { this.rescan(); this.repaint(); }
71
+ resize(cols = getTerminalSize().cols, rows = getTerminalSize().rows) {
72
+ this.cols = cols;
73
+ this.rows = rows;
74
+ this.frame = [];
75
+ this.adapter?.resize(this.detailSize().cols, this.detailSize().rows);
76
+ this.repaint(true);
77
+ }
78
+ render() {
79
+ const geometry = inboxLayout(this.cols, this.rows, this.screen);
80
+ if (geometry.mode === 'minimum')
81
+ return [`${YELLOW}Resize terminal to at least 60×18 to use inbox.${RESET}`];
82
+ const list = buildInboxLines(this.items, geometry.listWidth, this.selectedIndex);
83
+ const detail = this.detailLines(geometry.detailWidth, geometry.height);
84
+ if (geometry.mode === 'list')
85
+ return this.withStatus(list);
86
+ if (geometry.mode === 'detail')
87
+ return this.withStatus(detail);
88
+ const lines = [];
89
+ for (let i = 0; i < geometry.height; i++) {
90
+ const left = list[i] ?? '';
91
+ const right = detail[i] ?? '';
92
+ lines.push(`${left}${' '.repeat(Math.max(1, geometry.listWidth - visibleWidth(left)))} ${right}`);
93
+ }
94
+ return this.withStatus(lines);
95
+ }
96
+ handleKey(input, key) {
97
+ if (key.ctrl && input === 'c') {
98
+ this.close();
99
+ return;
100
+ }
101
+ if (this.screen === 'detail' && this.adapter !== undefined) {
102
+ this.adapter.handleKey(input, key);
103
+ this.repaint();
104
+ return;
105
+ }
106
+ if (key.escape || input === 'q') {
107
+ this.close();
108
+ return;
109
+ }
110
+ if (input === 'j' || key.downArrow)
111
+ this.select(this.selectedIndex + 1);
112
+ else if (input === 'k' || key.upArrow)
113
+ this.select(this.selectedIndex - 1);
114
+ else if (key.return || input === 'a')
115
+ this.activate();
116
+ this.repaint();
117
+ }
118
+ activate() {
119
+ const item = this.items[this.selectedIndex];
120
+ if (item === undefined || item.kind !== 'deck')
121
+ return;
122
+ const claim = claimTicket(item.dir);
123
+ if (claim === null) {
124
+ this.status = 'ticket is being edited by another inbox';
125
+ return;
126
+ }
127
+ this.claim = { dir: item.dir, token: claim.token };
128
+ const deck = readJson(deckPath(item.dir));
129
+ if (deck === null) {
130
+ releaseClaim(item.dir, claim.token);
131
+ this.claim = undefined;
132
+ this.invalidate();
133
+ return;
134
+ }
135
+ if (item.interactionKind === 'notify') {
136
+ void this.complete([{ id: deck.interactions[0]?.id ?? 'notify', selectedOptionId: deck.interactions[0]?.options[0]?.id }]);
137
+ return;
138
+ }
139
+ this.screen = 'detail';
140
+ this.adapter = new DeckAdapter({
141
+ dir: item.dir,
142
+ deck,
143
+ cols: this.detailSize().cols,
144
+ rows: this.detailSize().rows,
145
+ onDirty: () => this.repaint(),
146
+ onBack: () => { this.leaveDetail(); this.repaint(); },
147
+ onComplete: (responses) => { void this.complete(responses); },
148
+ });
149
+ this.watchSelected(item.dir);
150
+ }
151
+ reloadSelectedDeck() { this.adapter?.reload(); this.repaint(); }
152
+ close() {
153
+ if (this.closed)
154
+ return;
155
+ this.closed = true;
156
+ this.leaveDetail();
157
+ for (const watcher of this.watchers)
158
+ watcher.close();
159
+ this.watchers = [];
160
+ this.selectedWatcher?.close();
161
+ this.selectedWatcher = undefined;
162
+ this.finishRun?.();
163
+ }
164
+ async run() {
165
+ setupTerminal();
166
+ this.running = true;
167
+ this.watchRoots();
168
+ this.repaint(true);
169
+ const heartbeat = setInterval(() => {
170
+ if (this.claim !== undefined)
171
+ heartbeatClaim(this.claim.dir, this.claim.token);
172
+ }, 10_000);
173
+ await new Promise((resolve) => {
174
+ const onData = (data) => {
175
+ const { input, key } = parseKeypress(data);
176
+ this.handleKey(input, key);
177
+ if (this.closed)
178
+ finish();
179
+ };
180
+ const onResize = () => this.resize();
181
+ let done = false;
182
+ this.finishRun = () => {
183
+ if (done)
184
+ return;
185
+ done = true;
186
+ process.stdin.removeListener('data', onData);
187
+ process.stdout.removeListener('resize', onResize);
188
+ clearInterval(heartbeat);
189
+ this.running = false;
190
+ this.finishRun = undefined;
191
+ restoreTerminal();
192
+ resolve();
193
+ };
194
+ const finish = this.finishRun;
195
+ process.stdin.on('data', onData);
196
+ process.stdout.on('resize', onResize);
197
+ });
198
+ }
199
+ async complete(responses) {
200
+ const claim = this.claim;
201
+ if (claim === undefined)
202
+ return;
203
+ try {
204
+ await this.finishDeck(claim.dir, responses, claim.token);
205
+ }
206
+ catch (error) {
207
+ this.status = error instanceof Error ? error.message : String(error);
208
+ return;
209
+ }
210
+ this.leaveDetail(false);
211
+ this.rescan();
212
+ this.repaint();
213
+ }
214
+ leaveDetail(release = true) {
215
+ this.adapter?.close();
216
+ this.adapter = undefined;
217
+ this.selectedWatcher?.close();
218
+ this.selectedWatcher = undefined;
219
+ if (release && this.claim !== undefined)
220
+ releaseClaim(this.claim.dir, this.claim.token);
221
+ this.claim = undefined;
222
+ this.screen = 'list';
223
+ }
224
+ select(index) {
225
+ if (this.items.length === 0)
226
+ return;
227
+ this.selectedIndex = Math.max(0, Math.min(index, this.items.length - 1));
228
+ this.selectedDir = this.items[this.selectedIndex].dir;
229
+ }
230
+ detailSize() {
231
+ const layout = inboxLayout(this.cols, this.rows, this.screen);
232
+ return { cols: Math.max(1, layout.detailWidth - 2), rows: layout.height };
233
+ }
234
+ detailLines(width, rows) {
235
+ if (this.adapter !== undefined)
236
+ return this.adapter.render();
237
+ const selected = this.items[this.selectedIndex];
238
+ if (selected === undefined)
239
+ return [` ${DIM}Select a pending interaction.${RESET}`];
240
+ const lines = [` ${BOLD}${CYAN}${selected.title}${RESET}`, '', ` ${DIM}${selected.kind} · ${selected.source.sessionName ?? selected.source.askedBy ?? 'unknown source'}${RESET}`];
241
+ if (selected.kind === 'deck') {
242
+ const deck = readJson(deckPath(selected.dir));
243
+ if (deck !== null) {
244
+ for (const interaction of deck.interactions) {
245
+ lines.push('', ` ${BOLD}${interaction.title}${RESET}`);
246
+ for (const option of interaction.options)
247
+ lines.push(` ${DIM}• ${option.label}${RESET}`);
248
+ }
249
+ const saved = readJson(progressPath(selected.dir))?.responses;
250
+ lines.push('', ` ${DIM}${Array.isArray(saved) ? saved.length : 0} saved responses${RESET}`);
251
+ }
252
+ }
253
+ if (selected.subtitle)
254
+ lines.push('', ` ${selected.subtitle}`);
255
+ lines.push('', ` ${DIM}Enter${RESET} open ${DIM}j/k${RESET} select ${DIM}q${RESET} close`);
256
+ while (lines.length < rows)
257
+ lines.push('');
258
+ return lines.map((line) => line.slice(0, width));
259
+ }
260
+ withStatus(lines) {
261
+ if (this.status === undefined || this.rows < 1)
262
+ return lines;
263
+ const next = [...lines];
264
+ while (next.length < this.rows)
265
+ next.push('');
266
+ next[this.rows - 1] = `${YELLOW}${this.status}${RESET}`;
267
+ return next;
268
+ }
269
+ repaint(clear = false) {
270
+ if (this.closed || !this.running)
271
+ return;
272
+ if (clear)
273
+ process.stdout.write('\x1b[2J\x1b[H');
274
+ const diff = diffFrame(this.frame, this.render(), this.rows, this.cols);
275
+ process.stdout.write('\x1b[?2026h');
276
+ for (const write of diff.writes)
277
+ process.stdout.write(write);
278
+ process.stdout.write('\x1b[?2026l');
279
+ this.frame = diff.nextPrevFrame;
280
+ }
281
+ watchRoots() {
282
+ const roots = this.options.roots ?? listInboxRoots().filter((root) => root.available).map((root) => root.root);
283
+ for (const root of roots) {
284
+ try {
285
+ this.watchers.push(watch(root, () => this.invalidate()));
286
+ }
287
+ catch { /* unavailable roots remain discoverable through later rescans */ }
288
+ }
289
+ if (this.options.roots === undefined) {
290
+ try {
291
+ this.watchers.push(watch(inboxRootsDirectory(), () => this.invalidate()));
292
+ }
293
+ catch { /* registry appears after the next explicit open */ }
294
+ }
295
+ }
296
+ watchSelected(dir) {
297
+ this.selectedWatcher?.close();
298
+ try {
299
+ this.selectedWatcher = watch(dir, (_event, file) => {
300
+ if (file === 'deck.json')
301
+ this.reloadSelectedDeck();
302
+ else
303
+ this.invalidate();
304
+ });
305
+ }
306
+ catch {
307
+ this.selectedWatcher = undefined;
308
+ }
309
+ }
310
+ }
311
+ function visibleWidth(line) { return line.replace(/\x1b\[[0-9;]*m/g, '').length; }
@@ -1,26 +1,31 @@
1
1
  import type { Deck, InteractionResponse } from '../types.js';
2
2
  export declare function deckPath(dir: string): string;
3
+ export declare function reviewPath(dir: string): string;
3
4
  export declare function responsePath(dir: string): string;
4
5
  export declare function progressPath(dir: string): string;
6
+ export declare function claimPath(dir: string): string;
7
+ export declare function deliveryPath(dir: string): string;
8
+ export declare function deliveryErrorPath(dir: string): string;
5
9
  export declare function visualsDir(dir: string): string;
6
10
  export declare function visualMdPath(dir: string, id: string): string;
7
11
  export declare function visualAnsiPath(dir: string, id: string): string;
8
- export type InteractionState = 'pending' | 'in-progress' | 'resolved' | 'missing';
12
+ export type InteractionState = 'pending' | 'claimed' | 'resolved' | 'missing';
9
13
  export declare function interactionState(dir: string): InteractionState;
10
14
  export declare function isResolved(dir: string): boolean;
11
- /** Returns true if a live resolver owns this dir (progress.json mtime < 300s). */
12
15
  export declare function isClaimed(dir: string): boolean;
13
- /**
14
- * Stamp the originating canvas node id onto a deck's `source` so per-node
15
- * attention scoping (crouter's nav chrome) can attribute the ask to the node
16
- * that raised it rather than every sibling node sharing the same cwd.
17
- *
18
- * No-op when not inside a canvas node (CRTR_NODE_ID unset) or when the deck
19
- * already carries a nodeId. Mutates `deck` in place.
20
- */
21
16
  export declare function stampCanvasNode(deck: Deck): void;
22
17
  export declare function atomicWriteJson(path: string, value: unknown): void;
23
18
  export declare function readJson<T>(path: string): T | null;
19
+ /** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
20
+ export declare function withExclusiveDirectoryLock<T>(path: string, operation: () => T, options?: {
21
+ staleMs?: number;
22
+ timeoutMs?: number;
23
+ }): T;
24
+ /** Async counterpart heartbeats while its operation runs, so a valid long handler is never stolen. */
25
+ export declare function withExclusiveDirectoryLockAsync<T>(path: string, operation: () => Promise<T>, options?: {
26
+ staleMs?: number;
27
+ timeoutMs?: number;
28
+ }): Promise<T>;
24
29
  export declare function writeResponse(dir: string, responses: InteractionResponse[], completedAt: string, deck?: Deck): string;
25
30
  export declare function writeProgress(dir: string, responses: InteractionResponse[]): void;
26
31
  export declare function clearProgress(dir: string): void;
@@ -1,76 +1,36 @@
1
- import { existsSync, statSync, writeFileSync, renameSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
2
- import { dirname } from 'path';
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { buildSummary } from '../summary.js';
4
- // ── Path helpers ──────────────────────────────────────────────────────────────
5
- export function deckPath(dir) {
6
- return `${dir}/deck.json`;
7
- }
8
- export function responsePath(dir) {
9
- return `${dir}/response.json`;
10
- }
11
- export function progressPath(dir) {
12
- return `${dir}/progress.json`;
13
- }
14
- export function visualsDir(dir) {
15
- return `${dir}/visuals`;
16
- }
17
- export function visualMdPath(dir, id) {
18
- return `${dir}/visuals/${id}.md`;
19
- }
20
- export function visualAnsiPath(dir, id) {
21
- return `${dir}/visuals/${id}.ansi`;
22
- }
5
+ export function deckPath(dir) { return `${dir}/deck.json`; }
6
+ export function reviewPath(dir) { return `${dir}/review.json`; }
7
+ export function responsePath(dir) { return `${dir}/response.json`; }
8
+ export function progressPath(dir) { return `${dir}/progress.json`; }
9
+ export function claimPath(dir) { return `${dir}/claim.json`; }
10
+ export function deliveryPath(dir) { return `${dir}/delivery.json`; }
11
+ export function deliveryErrorPath(dir) { return `${dir}/delivery-error.json`; }
12
+ export function visualsDir(dir) { return `${dir}/visuals`; }
13
+ export function visualMdPath(dir, id) { return `${dir}/visuals/${id}.md`; }
14
+ export function visualAnsiPath(dir, id) { return `${dir}/visuals/${id}.ansi`; }
23
15
  export function interactionState(dir) {
24
- const hasDeck = existsSync(deckPath(dir));
25
- const hasResponse = existsSync(responsePath(dir));
26
- const hasProgress = existsSync(progressPath(dir));
27
- if (!hasDeck)
16
+ if (!existsSync(deckPath(dir)) && !existsSync(reviewPath(dir)))
28
17
  return 'missing';
29
- if (hasResponse)
18
+ if (existsSync(responsePath(dir)))
30
19
  return 'resolved';
31
- if (hasProgress)
32
- return 'in-progress';
33
- return 'pending';
34
- }
35
- export function isResolved(dir) {
36
- return existsSync(responsePath(dir));
37
- }
38
- /** Returns true if a live resolver owns this dir (progress.json mtime < 300s). */
39
- export function isClaimed(dir) {
40
- const p = progressPath(dir);
41
- if (!existsSync(p))
42
- return false;
43
- try {
44
- const { mtimeMs } = statSync(p);
45
- return Date.now() - mtimeMs < 300_000;
46
- }
47
- catch {
48
- return false;
49
- }
20
+ return existsSync(claimPath(dir)) ? 'claimed' : 'pending';
50
21
  }
51
- // ── Canvas-node attribution ─────────────────────────────────────────
52
- /**
53
- * Stamp the originating canvas node id onto a deck's `source` so per-node
54
- * attention scoping (crouter's nav chrome) can attribute the ask to the node
55
- * that raised it rather than every sibling node sharing the same cwd.
56
- *
57
- * No-op when not inside a canvas node (CRTR_NODE_ID unset) or when the deck
58
- * already carries a nodeId. Mutates `deck` in place.
59
- */
22
+ export function isResolved(dir) { return existsSync(responsePath(dir)); }
23
+ export function isClaimed(dir) { return existsSync(claimPath(dir)); }
60
24
  export function stampCanvasNode(deck) {
61
25
  const id = process.env['CRTR_NODE_ID'];
62
- if (id === undefined || id.trim() === '')
63
- return;
64
- if (deck.source?.nodeId != null && deck.source.nodeId !== '')
26
+ if (id === undefined || id.trim() === '' || deck.source?.nodeId)
65
27
  return;
66
28
  deck.source = { ...(deck.source ?? {}), nodeId: id };
67
29
  }
68
- // ── Atomic I/O ────────────────────────────────────────────────────────────────
69
30
  export function atomicWriteJson(path, value) {
70
- const payload = JSON.stringify(value, null, 2);
71
- const tmp = `${path}.tmp`;
31
+ const tmp = `${path}.${process.pid}.${Math.random().toString(16).slice(2)}.tmp`;
72
32
  mkdirSync(dirname(path), { recursive: true });
73
- writeFileSync(tmp, payload);
33
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
74
34
  renameSync(tmp, path);
75
35
  }
76
36
  export function readJson(path) {
@@ -81,31 +41,141 @@ export function readJson(path) {
81
41
  return null;
82
42
  }
83
43
  }
84
- // ── High-level write helpers ──────────────────────────────────────────────────
44
+ function pause(ms) { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); }
45
+ // The holder's identity lives in the NAME of its single marker file. The marker's
46
+ // mtime is the heartbeat and its presence is ownership, so the observed token, the
47
+ // staleness clock, and the reclaim gate are one atomic fact.
48
+ const MARKER_PREFIX = 'owner.';
49
+ function markerPath(path, token) { return `${path}/${MARKER_PREFIX}${token}`; }
50
+ function observedMarkerToken(path) {
51
+ try {
52
+ return readdirSync(path).find((entry) => entry.startsWith(MARKER_PREFIX))?.slice(MARKER_PREFIX.length) ?? null;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ function ownsLock(lock) { return existsSync(markerPath(lock.path, lock.token)); }
59
+ function releaseDirectoryLock(lock) { if (ownsLock(lock))
60
+ rmSync(lock.path, { recursive: true, force: true }); }
61
+ function lockAge(path, token) {
62
+ const target = token !== null ? markerPath(path, token) : path;
63
+ try {
64
+ return Date.now() - statSync(existsSync(target) ? target : path).mtimeMs;
65
+ }
66
+ catch {
67
+ return 0;
68
+ }
69
+ }
70
+ // Reclamation is bound to the exact instance it observed: it unlinks ONLY that
71
+ // token's marker, then removes the now-empty directory with an empty-guarded
72
+ // rmdir. A successor lock holds a different random token (a different marker
73
+ // name), so a lagging reclaimer that saw the old stale lock can never strip a
74
+ // live successor — its unlink targets a name that no longer exists.
75
+ function reclaimIfStale(path, staleMs) {
76
+ const token = observedMarkerToken(path);
77
+ if (lockAge(path, token) <= staleMs)
78
+ return;
79
+ if (token !== null) {
80
+ try {
81
+ unlinkSync(markerPath(path, token));
82
+ }
83
+ catch (error) {
84
+ if (error.code === 'ENOENT')
85
+ return;
86
+ throw error;
87
+ }
88
+ }
89
+ try {
90
+ rmdirSync(path);
91
+ }
92
+ catch (error) {
93
+ const code = error.code;
94
+ if (code !== 'ENOENT' && code !== 'ENOTEMPTY' && code !== 'EEXIST')
95
+ throw error;
96
+ }
97
+ }
98
+ function tryAcquireDirectoryLock(path, staleMs) {
99
+ const token = randomUUID();
100
+ try {
101
+ mkdirSync(path, { mode: 0o700 });
102
+ writeFileSync(markerPath(path, token), '', { flag: 'wx', mode: 0o600 });
103
+ return { path, token };
104
+ }
105
+ catch (error) {
106
+ if (error.code !== 'EEXIST')
107
+ throw error;
108
+ reclaimIfStale(path, staleMs);
109
+ return null;
110
+ }
111
+ }
112
+ function acquireDirectoryLock(path, staleMs, timeoutMs) {
113
+ const startedAt = Date.now();
114
+ while (true) {
115
+ const lock = tryAcquireDirectoryLock(path, staleMs);
116
+ if (lock !== null)
117
+ return lock;
118
+ if (Date.now() - startedAt >= timeoutMs)
119
+ throw new Error('exclusive operation lock acquisition timed out');
120
+ pause(5);
121
+ }
122
+ }
123
+ async function acquireDirectoryLockAsync(path, staleMs, timeoutMs) {
124
+ const startedAt = Date.now();
125
+ while (true) {
126
+ const lock = tryAcquireDirectoryLock(path, staleMs);
127
+ if (lock !== null)
128
+ return lock;
129
+ if (Date.now() - startedAt >= timeoutMs)
130
+ throw new Error('exclusive operation lock acquisition timed out');
131
+ await new Promise((resolvePromise) => setTimeout(resolvePromise, 10));
132
+ }
133
+ }
134
+ /** Runs a short filesystem transition under a token-checked, crash-reclaimable directory lock. */
135
+ export function withExclusiveDirectoryLock(path, operation, options = {}) {
136
+ const lock = acquireDirectoryLock(path, options.staleMs ?? 30_000, options.timeoutMs ?? 5_000);
137
+ try {
138
+ return operation();
139
+ }
140
+ finally {
141
+ releaseDirectoryLock(lock);
142
+ }
143
+ }
144
+ /** Async counterpart heartbeats while its operation runs, so a valid long handler is never stolen. */
145
+ export async function withExclusiveDirectoryLockAsync(path, operation, options = {}) {
146
+ const staleMs = options.staleMs ?? 35_000;
147
+ const lock = await acquireDirectoryLockAsync(path, staleMs, options.timeoutMs ?? staleMs + 5_000);
148
+ const heartbeat = setInterval(() => {
149
+ if (ownsLock(lock)) {
150
+ try {
151
+ utimesSync(markerPath(lock.path, lock.token), new Date(), new Date());
152
+ }
153
+ catch { /* a reclaimed lock is no longer ours */ }
154
+ }
155
+ }, 1_000);
156
+ try {
157
+ return await operation();
158
+ }
159
+ finally {
160
+ clearInterval(heartbeat);
161
+ releaseDirectoryLock(lock);
162
+ }
163
+ }
164
+ // Kept for the existing panel until H2 routes all finalization through tickets.ts.
85
165
  export function writeResponse(dir, responses, completedAt, deck) {
86
- const p = responsePath(dir);
87
- // Persist the deterministic summary alongside the raw responses so
88
- // `hl job result` can return a populated summary without re-deriving it (or
89
- // silently emitting ''). When the deck is known at write time we compute it
90
- // here; the deck is deterministic input so this never diverges from ask()'s
91
- // envelope summary.
92
- const summary = deck !== undefined ? buildSummary(deck, responses) : '';
93
- atomicWriteJson(p, { responses, completedAt, summary });
94
- return p;
166
+ const summary = deck === undefined ? '' : buildSummary(deck, responses);
167
+ atomicWriteJson(responsePath(dir), { schema: 'humanloop.response/v2', kind: 'deck', responses, summary, completedAt });
168
+ return responsePath(dir);
95
169
  }
96
170
  export function writeProgress(dir, responses) {
97
- atomicWriteJson(progressPath(dir), {
98
- partial: true,
99
- responses,
100
- savedAt: new Date().toISOString(),
101
- });
171
+ atomicWriteJson(progressPath(dir), { kind: 'deck', responses, savedAt: new Date().toISOString() });
102
172
  }
103
173
  export function clearProgress(dir) {
104
174
  try {
105
175
  unlinkSync(progressPath(dir));
106
176
  }
107
- catch (err) {
108
- if (err.code !== 'ENOENT')
109
- throw err;
177
+ catch (error) {
178
+ if (error.code !== 'ENOENT')
179
+ throw error;
110
180
  }
111
181
  }