@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,747 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readdirSync, realpathSync, statSync, unlinkSync } from 'node:fs';
|
|
2
|
+
import { basename, dirname, isAbsolute, resolve } from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { INTERACTION_KINDS } from '../types.js';
|
|
5
|
+
import { checkMarkdown } from '../render/termrender.js';
|
|
6
|
+
import { atomicWriteJson, deckPath, publishJsonExclusive, readJson, runHandler, visualsDir, withExclusiveDirectoryLock, withExclusiveDirectoryLockAsync, } from './convention.js';
|
|
7
|
+
import { parseDeck } from './deck-schema.js';
|
|
8
|
+
import { isStaleClaim, readTicketClaim, withTicketLock } from './claim.js';
|
|
9
|
+
import { registeredInboxRoot } from './registry.js';
|
|
10
|
+
import { requireCanonicalTicket } from './tickets.js';
|
|
11
|
+
export const VISUAL_CAPABILITY = 'humanloop.visual/v1';
|
|
12
|
+
const id = z.string().regex(/^[A-Za-z0-9_-]+$/).min(1).max(128);
|
|
13
|
+
const uuid = z.string().uuid();
|
|
14
|
+
const iso = z.string().datetime({ offset: true });
|
|
15
|
+
const canonicalPath = z.string().min(1).refine(isAbsolute, 'path must be absolute').refine((path) => resolve(path) === path, 'path must be canonical');
|
|
16
|
+
const handlerSchema = z.object({ command: z.string().refine((value) => value.trim().length > 0, 'handler command must be non-empty'), args: z.array(z.string()) }).strict();
|
|
17
|
+
const claimSchema = z.object({ token: uuid, host: z.string().min(1), pid: z.number().int().positive(), claimedAt: iso }).strict();
|
|
18
|
+
const optionInputSchema = z.object({ id: z.string().min(1), label: z.string().min(1), description: z.string().optional(), shortcut: z.string().optional() }).strict();
|
|
19
|
+
const optionSchema = z.object({ id: z.string().min(1), label: z.string().min(1), description: z.string().optional() }).strict();
|
|
20
|
+
const preAnsweredSchema = z.object({ selectedOptionId: z.string().optional(), selectedOptionIds: z.array(z.string()).optional(), freetext: z.string().optional(), label: z.string().optional() }).strict();
|
|
21
|
+
const interactionInputSchema = z.object({
|
|
22
|
+
id: z.string().regex(/^[A-Za-z0-9_-]+$/).min(1).max(64),
|
|
23
|
+
title: z.string().min(1),
|
|
24
|
+
subtitle: z.string().min(1).optional(),
|
|
25
|
+
body: z.string().optional(),
|
|
26
|
+
bodyPath: z.string().optional(),
|
|
27
|
+
options: z.array(optionInputSchema),
|
|
28
|
+
multiSelect: z.boolean().optional(),
|
|
29
|
+
allowFreetext: z.boolean().optional(),
|
|
30
|
+
freetextLabel: z.string().optional(),
|
|
31
|
+
kind: z.enum(INTERACTION_KINDS).optional(),
|
|
32
|
+
preAnswered: preAnsweredSchema.optional(),
|
|
33
|
+
}).strict();
|
|
34
|
+
const canonicalInteractionSchema = z.object({
|
|
35
|
+
id: z.string().regex(/^[A-Za-z0-9_-]+$/).min(1).max(64),
|
|
36
|
+
title: z.string().min(1),
|
|
37
|
+
subtitle: z.string().min(1).optional(),
|
|
38
|
+
body: z.string().optional(),
|
|
39
|
+
options: z.array(optionSchema),
|
|
40
|
+
multiSelect: z.boolean().optional(),
|
|
41
|
+
allowFreetext: z.boolean().optional(),
|
|
42
|
+
freetextLabel: z.string().optional(),
|
|
43
|
+
kind: z.enum(INTERACTION_KINDS).optional(),
|
|
44
|
+
preAnswered: preAnsweredSchema.optional(),
|
|
45
|
+
}).strict();
|
|
46
|
+
const cleanupSchema = z.object({
|
|
47
|
+
reason: z.enum(['canceled', 'unreceipted_start_error']),
|
|
48
|
+
attempts: z.number().int().nonnegative(),
|
|
49
|
+
nextAttemptAt: iso,
|
|
50
|
+
lastAttemptAt: iso.optional(),
|
|
51
|
+
lastError: z.string().min(1).max(8_192).optional(),
|
|
52
|
+
}).strict();
|
|
53
|
+
const bindingShape = {
|
|
54
|
+
capability: z.literal(VISUAL_CAPABILITY),
|
|
55
|
+
owner: z.string().min(1),
|
|
56
|
+
root: canonicalPath,
|
|
57
|
+
dir: canonicalPath,
|
|
58
|
+
ticketId: id,
|
|
59
|
+
requestId: uuid,
|
|
60
|
+
generationId: uuid,
|
|
61
|
+
interactionId: z.string().regex(/^[A-Za-z0-9_-]+$/).min(1).max(64),
|
|
62
|
+
interaction: canonicalInteractionSchema,
|
|
63
|
+
claim: claimSchema,
|
|
64
|
+
handler: handlerSchema,
|
|
65
|
+
};
|
|
66
|
+
const requestSchema = z.object({
|
|
67
|
+
schema: z.literal('humanloop.visual-request/v1'),
|
|
68
|
+
...bindingShape,
|
|
69
|
+
state: z.enum(['running', 'canceled', 'terminal']),
|
|
70
|
+
requestedAt: iso,
|
|
71
|
+
settledAt: iso.optional(),
|
|
72
|
+
cleanup: cleanupSchema.optional(),
|
|
73
|
+
}).strict().superRefine((request, ctx) => {
|
|
74
|
+
if (request.interactionId !== request.interaction.id)
|
|
75
|
+
ctx.addIssue({ code: 'custom', message: 'interactionId must match interaction.id' });
|
|
76
|
+
if (request.state === 'running' && (request.settledAt !== undefined || request.cleanup !== undefined))
|
|
77
|
+
ctx.addIssue({ code: 'custom', message: 'running requests cannot be settled or cleanup-owed' });
|
|
78
|
+
if (request.state !== 'running' && request.settledAt === undefined)
|
|
79
|
+
ctx.addIssue({ code: 'custom', message: 'settled requests require settledAt' });
|
|
80
|
+
if (request.state === 'canceled' && request.cleanup?.reason !== 'canceled')
|
|
81
|
+
ctx.addIssue({ code: 'custom', message: 'canceled requests require canceled cleanup' });
|
|
82
|
+
if (request.state === 'terminal' && request.cleanup !== undefined && request.cleanup.reason !== 'unreceipted_start_error')
|
|
83
|
+
ctx.addIssue({ code: 'custom', message: 'terminal cleanup must be for an unreceipted start error' });
|
|
84
|
+
});
|
|
85
|
+
const eventSchema = z.object({ schema: z.literal('humanloop.visual-request-event/v1'), action: z.enum(['start', 'cancel']), ...bindingShape }).strict().superRefine((event, ctx) => {
|
|
86
|
+
if (event.interactionId !== event.interaction.id)
|
|
87
|
+
ctx.addIssue({ code: 'custom', message: 'interactionId must match interaction.id' });
|
|
88
|
+
});
|
|
89
|
+
const readyResultSchema = z.object({ schema: z.literal('humanloop.visual-result/v1'), ...bindingShape, status: z.literal('ready'), markdown: z.string().refine((value) => value.trim().length > 0, 'ready result requires non-empty markdown'), completedAt: iso }).strict().superRefine((result, ctx) => {
|
|
90
|
+
if (result.interactionId !== result.interaction.id)
|
|
91
|
+
ctx.addIssue({ code: 'custom', message: 'interactionId must match interaction.id' });
|
|
92
|
+
});
|
|
93
|
+
const errorResultSchema = z.object({ schema: z.literal('humanloop.visual-result/v1'), ...bindingShape, status: z.literal('error'), error: z.string().min(1).max(8_192), completedAt: iso }).strict().superRefine((result, ctx) => {
|
|
94
|
+
if (result.interactionId !== result.interaction.id)
|
|
95
|
+
ctx.addIssue({ code: 'custom', message: 'interactionId must match interaction.id' });
|
|
96
|
+
});
|
|
97
|
+
const resultSchema = z.union([readyResultSchema, errorResultSchema]);
|
|
98
|
+
const readySubmissionSchema = z.object({ requestId: uuid, generationId: uuid, interactionId: z.string().min(1).max(64), interaction: canonicalInteractionSchema, claimToken: uuid, status: z.literal('ready'), markdown: z.string() }).strict();
|
|
99
|
+
const errorSubmissionSchema = z.object({ requestId: uuid, generationId: uuid, interactionId: z.string().min(1).max(64), interaction: canonicalInteractionSchema, claimToken: uuid, status: z.literal('error'), error: z.string().min(1).max(8_192) }).strict();
|
|
100
|
+
const submissionSchema = z.union([readySubmissionSchema, errorSubmissionSchema]);
|
|
101
|
+
const deliveryAttemptSchema = z.object({ schema: z.literal('humanloop.visual-delivery-attempt/v1'), action: z.literal('start'), event: eventSchema, attemptedAt: iso }).strict().superRefine((attempt, ctx) => {
|
|
102
|
+
if (attempt.event.action !== attempt.action)
|
|
103
|
+
ctx.addIssue({ code: 'custom', message: 'attempt action must match event action' });
|
|
104
|
+
});
|
|
105
|
+
const deliveryReceiptSchema = z.object({ schema: z.literal('humanloop.visual-delivery-receipt/v1'), action: z.enum(['start', 'cancel']), event: eventSchema, deliveredAt: iso }).strict().superRefine((receipt, ctx) => {
|
|
106
|
+
if (receipt.event.action !== receipt.action)
|
|
107
|
+
ctx.addIssue({ code: 'custom', message: 'receipt action must match event action' });
|
|
108
|
+
});
|
|
109
|
+
const startDeliveryErrorSchema = z.object({
|
|
110
|
+
schema: z.literal('humanloop.visual-delivery-error/v1'),
|
|
111
|
+
action: z.literal('start'),
|
|
112
|
+
event: eventSchema,
|
|
113
|
+
failedAt: iso,
|
|
114
|
+
error: z.string().min(1).max(8_192),
|
|
115
|
+
terminalAt: iso.optional(),
|
|
116
|
+
}).strict().superRefine((failure, ctx) => {
|
|
117
|
+
if (failure.event.action !== failure.action)
|
|
118
|
+
ctx.addIssue({ code: 'custom', message: 'delivery-error action must match event action' });
|
|
119
|
+
});
|
|
120
|
+
/** Normalize one Interaction into the sole persisted/event correlation shape. */
|
|
121
|
+
export function canonicalizeInteraction(raw) {
|
|
122
|
+
const parsed = interactionInputSchema.parse(raw);
|
|
123
|
+
if (parsed.bodyPath !== undefined)
|
|
124
|
+
throw new Error('Visual interaction must contain resolved body, not bodyPath');
|
|
125
|
+
const canonical = {
|
|
126
|
+
id: parsed.id,
|
|
127
|
+
title: parsed.title,
|
|
128
|
+
...(parsed.subtitle === undefined ? {} : { subtitle: parsed.subtitle }),
|
|
129
|
+
...(parsed.body === undefined ? {} : { body: parsed.body }),
|
|
130
|
+
options: parsed.options.map((option) => ({ id: option.id, label: option.label, ...(option.description === undefined ? {} : { description: option.description }) })),
|
|
131
|
+
...(parsed.multiSelect === undefined ? {} : { multiSelect: parsed.multiSelect }),
|
|
132
|
+
...(parsed.allowFreetext === undefined ? {} : { allowFreetext: parsed.allowFreetext }),
|
|
133
|
+
...(parsed.freetextLabel === undefined ? {} : { freetextLabel: parsed.freetextLabel }),
|
|
134
|
+
...(parsed.kind === undefined ? {} : { kind: parsed.kind }),
|
|
135
|
+
...(parsed.preAnswered === undefined ? {} : { preAnswered: {
|
|
136
|
+
...(parsed.preAnswered.selectedOptionId === undefined ? {} : { selectedOptionId: parsed.preAnswered.selectedOptionId }),
|
|
137
|
+
...(parsed.preAnswered.selectedOptionIds === undefined ? {} : { selectedOptionIds: [...parsed.preAnswered.selectedOptionIds] }),
|
|
138
|
+
...(parsed.preAnswered.freetext === undefined ? {} : { freetext: parsed.preAnswered.freetext }),
|
|
139
|
+
...(parsed.preAnswered.label === undefined ? {} : { label: parsed.preAnswered.label }),
|
|
140
|
+
} }),
|
|
141
|
+
};
|
|
142
|
+
return canonicalInteractionSchema.parse(canonical);
|
|
143
|
+
}
|
|
144
|
+
/** Stable bytes used for every interaction equality check across the protocol. */
|
|
145
|
+
export function canonicalInteractionJson(raw) {
|
|
146
|
+
return JSON.stringify(canonicalizeInteraction(raw));
|
|
147
|
+
}
|
|
148
|
+
export function parseVisualRequestEvent(raw) {
|
|
149
|
+
return eventSchema.parse(raw);
|
|
150
|
+
}
|
|
151
|
+
function requestPath(requestDir) { return `${requestDir}/request.json`; }
|
|
152
|
+
function resultPath(requestDir) { return `${requestDir}/result.json`; }
|
|
153
|
+
function requestLockPath(requestDir) { return `${requestDir}/.request-lock`; }
|
|
154
|
+
function startLockPath(requestDir) { return `${requestDir}/.start-lock`; }
|
|
155
|
+
function cancelLockPath(requestDir) { return `${requestDir}/.cancel-lock`; }
|
|
156
|
+
function startAttemptPath(requestDir) { return `${requestDir}/start-attempt.json`; }
|
|
157
|
+
function startReceiptPath(requestDir) { return `${requestDir}/start-receipt.json`; }
|
|
158
|
+
function startErrorPath(requestDir) { return `${requestDir}/start-delivery-error.json`; }
|
|
159
|
+
function cancelReceiptPath(requestDir) { return `${requestDir}/cancel-receipt.json`; }
|
|
160
|
+
function cancelErrorPath(requestDir) { return `${requestDir}/cancel-delivery-error.json`; }
|
|
161
|
+
function canonicalVisualsDirectory(ticketDir, create) {
|
|
162
|
+
const path = visualsDir(ticketDir);
|
|
163
|
+
if (create)
|
|
164
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
165
|
+
if (!existsSync(path))
|
|
166
|
+
return null;
|
|
167
|
+
const canonical = realpathSync(path);
|
|
168
|
+
if (canonical !== path || dirname(canonical) !== ticketDir || basename(canonical) !== 'visuals' || !statSync(canonical).isDirectory())
|
|
169
|
+
throw new Error('visuals must be a canonical directory directly under the ticket');
|
|
170
|
+
return canonical;
|
|
171
|
+
}
|
|
172
|
+
function canonicalRequestDirectory(ticketDir, requestId, create) {
|
|
173
|
+
uuid.parse(requestId);
|
|
174
|
+
const parent = canonicalVisualsDirectory(ticketDir, create);
|
|
175
|
+
if (parent === null)
|
|
176
|
+
return null;
|
|
177
|
+
const path = resolve(parent, requestId);
|
|
178
|
+
if (dirname(path) !== parent)
|
|
179
|
+
throw new Error('Visual request must be a direct child of the ticket visuals directory');
|
|
180
|
+
if (create)
|
|
181
|
+
mkdirSync(path, { recursive: true, mode: 0o700 });
|
|
182
|
+
if (!existsSync(path))
|
|
183
|
+
return null;
|
|
184
|
+
const canonical = realpathSync(path);
|
|
185
|
+
if (canonical !== path || dirname(canonical) !== parent || basename(canonical) !== requestId || !statSync(canonical).isDirectory())
|
|
186
|
+
throw new Error('Visual request must be a canonical direct child of the ticket visuals directory');
|
|
187
|
+
return canonical;
|
|
188
|
+
}
|
|
189
|
+
function parseRequest(path) {
|
|
190
|
+
const parsed = requestSchema.safeParse(readJson(path));
|
|
191
|
+
return parsed.success ? parsed.data : null;
|
|
192
|
+
}
|
|
193
|
+
function parseResult(path) {
|
|
194
|
+
const parsed = resultSchema.safeParse(readJson(path));
|
|
195
|
+
if (!parsed.success)
|
|
196
|
+
return null;
|
|
197
|
+
if (parsed.data.status === 'ready' && !checkMarkdown(parsed.data.markdown).ok)
|
|
198
|
+
return null;
|
|
199
|
+
return parsed.data;
|
|
200
|
+
}
|
|
201
|
+
function bindingFingerprint(binding) {
|
|
202
|
+
return JSON.stringify({
|
|
203
|
+
capability: binding.capability,
|
|
204
|
+
owner: binding.owner,
|
|
205
|
+
root: binding.root,
|
|
206
|
+
dir: binding.dir,
|
|
207
|
+
ticketId: binding.ticketId,
|
|
208
|
+
requestId: binding.requestId,
|
|
209
|
+
generationId: binding.generationId,
|
|
210
|
+
interactionId: binding.interactionId,
|
|
211
|
+
interaction: canonicalizeInteraction(binding.interaction),
|
|
212
|
+
claim: binding.claim,
|
|
213
|
+
handler: binding.handler,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
function bindingMatches(left, right) {
|
|
217
|
+
return bindingFingerprint(left) === bindingFingerprint(right);
|
|
218
|
+
}
|
|
219
|
+
function requestBelongsTo(request, root, dir, requestId) {
|
|
220
|
+
return request.root === root && request.dir === dir && request.ticketId === basename(dir) && request.requestId === requestId;
|
|
221
|
+
}
|
|
222
|
+
function resultFor(request, outcome, completedAt) {
|
|
223
|
+
const binding = {
|
|
224
|
+
capability: request.capability,
|
|
225
|
+
owner: request.owner,
|
|
226
|
+
root: request.root,
|
|
227
|
+
dir: request.dir,
|
|
228
|
+
ticketId: request.ticketId,
|
|
229
|
+
requestId: request.requestId,
|
|
230
|
+
generationId: request.generationId,
|
|
231
|
+
interactionId: request.interactionId,
|
|
232
|
+
interaction: request.interaction,
|
|
233
|
+
claim: request.claim,
|
|
234
|
+
handler: request.handler,
|
|
235
|
+
};
|
|
236
|
+
return outcome.status === 'ready'
|
|
237
|
+
? { schema: 'humanloop.visual-result/v1', ...binding, status: 'ready', markdown: outcome.markdown, completedAt }
|
|
238
|
+
: { schema: 'humanloop.visual-result/v1', ...binding, status: 'error', error: outcome.error, completedAt };
|
|
239
|
+
}
|
|
240
|
+
function eventFor(request, action) {
|
|
241
|
+
return {
|
|
242
|
+
schema: 'humanloop.visual-request-event/v1',
|
|
243
|
+
action,
|
|
244
|
+
capability: request.capability,
|
|
245
|
+
owner: request.owner,
|
|
246
|
+
root: request.root,
|
|
247
|
+
dir: request.dir,
|
|
248
|
+
ticketId: request.ticketId,
|
|
249
|
+
requestId: request.requestId,
|
|
250
|
+
generationId: request.generationId,
|
|
251
|
+
interactionId: request.interactionId,
|
|
252
|
+
interaction: request.interaction,
|
|
253
|
+
claim: request.claim,
|
|
254
|
+
handler: request.handler,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
function readReceipt(path, request, action) {
|
|
258
|
+
const parsed = deliveryReceiptSchema.safeParse(readJson(path));
|
|
259
|
+
return parsed.success && parsed.data.action === action && bindingMatches(parsed.data.event, request);
|
|
260
|
+
}
|
|
261
|
+
function publishReceipt(path, receipt, request) {
|
|
262
|
+
if (publishJsonExclusive(path, receipt) || readReceipt(path, request, receipt.action))
|
|
263
|
+
return;
|
|
264
|
+
throw new Error(`Visual ${receipt.action} receipt path contains a conflicting record`);
|
|
265
|
+
}
|
|
266
|
+
function cleanupOwed(requestDir, request) {
|
|
267
|
+
return request.cleanup !== undefined && !readReceipt(cancelReceiptPath(requestDir), request, 'cancel');
|
|
268
|
+
}
|
|
269
|
+
function parseStartDeliveryError(requestDir, request) {
|
|
270
|
+
const parsed = startDeliveryErrorSchema.safeParse(readJson(startErrorPath(requestDir)));
|
|
271
|
+
if (!parsed.success || !bindingMatches(parsed.data.event, request))
|
|
272
|
+
return null;
|
|
273
|
+
return parsed.data;
|
|
274
|
+
}
|
|
275
|
+
function startFailureMessage(error) {
|
|
276
|
+
return `Visual start delivery failed: ${error}`.slice(0, 8_192);
|
|
277
|
+
}
|
|
278
|
+
function isCommittedStartFailure(result, failure) {
|
|
279
|
+
return failure?.terminalAt !== undefined
|
|
280
|
+
&& result.status === 'error'
|
|
281
|
+
&& result.completedAt === failure.terminalAt
|
|
282
|
+
&& result.error === startFailureMessage(failure.error);
|
|
283
|
+
}
|
|
284
|
+
/** Repair a committed result whose mutable request mirror was interrupted. Caller holds the request lock. */
|
|
285
|
+
function readLocked(requestDir, root, dir, requestId) {
|
|
286
|
+
let request = parseRequest(requestPath(requestDir));
|
|
287
|
+
if (request === null || !requestBelongsTo(request, root, dir, requestId))
|
|
288
|
+
return null;
|
|
289
|
+
let result = parseResult(resultPath(requestDir));
|
|
290
|
+
if (result !== null && !bindingMatches(result, request))
|
|
291
|
+
result = null;
|
|
292
|
+
// A determinate handler failure is committed before its result publication.
|
|
293
|
+
// Complete either interrupted mirror write without reopening result currency.
|
|
294
|
+
const startFailure = parseStartDeliveryError(requestDir, request);
|
|
295
|
+
if (startFailure?.terminalAt !== undefined && request.state === 'running' && result === null) {
|
|
296
|
+
const repair = resultFor(request, { status: 'error', error: startFailureMessage(startFailure.error) }, startFailure.terminalAt);
|
|
297
|
+
if (publishJsonExclusive(resultPath(requestDir), repair))
|
|
298
|
+
result = repair;
|
|
299
|
+
else {
|
|
300
|
+
const raced = parseResult(resultPath(requestDir));
|
|
301
|
+
if (raced !== null && bindingMatches(raced, request))
|
|
302
|
+
result = raced;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
if (result !== null
|
|
306
|
+
&& isCommittedStartFailure(result, startFailure)
|
|
307
|
+
&& request.state !== 'canceled'
|
|
308
|
+
&& (request.state === 'running' || request.cleanup?.reason !== 'unreceipted_start_error')) {
|
|
309
|
+
request = { ...request, state: 'terminal', settledAt: result.completedAt, cleanup: cleanup('unreceipted_start_error', result.completedAt) };
|
|
310
|
+
atomicWriteJson(requestPath(requestDir), request);
|
|
311
|
+
}
|
|
312
|
+
else if (result !== null && request.state === 'running') {
|
|
313
|
+
request = { ...request, state: 'terminal', settledAt: result.completedAt };
|
|
314
|
+
atomicWriteJson(requestPath(requestDir), request);
|
|
315
|
+
}
|
|
316
|
+
else if (result !== null && request.state === 'canceled') {
|
|
317
|
+
result = null;
|
|
318
|
+
}
|
|
319
|
+
return { request, result };
|
|
320
|
+
}
|
|
321
|
+
function locate(root, dir, requestId) {
|
|
322
|
+
const ticket = requireCanonicalTicket(root, dir);
|
|
323
|
+
return { ...ticket, requestDir: canonicalRequestDirectory(ticket.dir, requestId, false) };
|
|
324
|
+
}
|
|
325
|
+
/** Strict, repairing reader; malformed or unbound request bytes return null. */
|
|
326
|
+
export function readVisualRequest(root, dir, requestId) {
|
|
327
|
+
const located = locate(root, dir, requestId);
|
|
328
|
+
if (located.requestDir === null)
|
|
329
|
+
return null;
|
|
330
|
+
return withExclusiveDirectoryLock(requestLockPath(located.requestDir), () => readLocked(located.requestDir, located.root, located.dir, requestId)?.request ?? null);
|
|
331
|
+
}
|
|
332
|
+
/** Strict, repairing reader; canceled and mismatched results are never returned. */
|
|
333
|
+
export function readVisualResult(root, dir, requestId) {
|
|
334
|
+
const located = locate(root, dir, requestId);
|
|
335
|
+
if (located.requestDir === null)
|
|
336
|
+
return null;
|
|
337
|
+
return withExclusiveDirectoryLock(requestLockPath(located.requestDir), () => readLocked(located.requestDir, located.root, located.dir, requestId)?.result ?? null);
|
|
338
|
+
}
|
|
339
|
+
/** Decode an event and atomically bind it to Humanloop's immutable request state. */
|
|
340
|
+
export function readVisualRequestForEvent(raw) {
|
|
341
|
+
const event = parseVisualRequestEvent(raw);
|
|
342
|
+
const located = locate(event.root, event.dir, event.requestId);
|
|
343
|
+
if (located.requestDir === null)
|
|
344
|
+
return null;
|
|
345
|
+
return withExclusiveDirectoryLock(requestLockPath(located.requestDir), () => {
|
|
346
|
+
const current = readLocked(located.requestDir, located.root, located.dir, event.requestId);
|
|
347
|
+
if (current === null || !bindingMatches(event, current.request))
|
|
348
|
+
return null;
|
|
349
|
+
if (event.action === 'start') {
|
|
350
|
+
return current.request.state === 'running'
|
|
351
|
+
&& current.result === null
|
|
352
|
+
&& !readReceipt(startReceiptPath(located.requestDir), current.request, 'start')
|
|
353
|
+
? current.request
|
|
354
|
+
: null;
|
|
355
|
+
}
|
|
356
|
+
return cleanupOwed(located.requestDir, current.request)
|
|
357
|
+
&& (current.request.state === 'canceled' || (current.request.state === 'terminal' && current.request.cleanup?.reason === 'unreceipted_start_error'))
|
|
358
|
+
? current.request
|
|
359
|
+
: null;
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
function claimIdentity(claim) {
|
|
363
|
+
return { token: claim.token, host: claim.host, pid: claim.pid, claimedAt: claim.claimedAt };
|
|
364
|
+
}
|
|
365
|
+
function sameBirthRequest(existing, opts, interaction) {
|
|
366
|
+
return existing.requestId === opts.request.requestId
|
|
367
|
+
&& existing.generationId === opts.request.generationId
|
|
368
|
+
&& existing.interactionId === interaction.id
|
|
369
|
+
&& existing.claim.token === opts.claimToken
|
|
370
|
+
&& canonicalInteractionJson(existing.interaction) === canonicalInteractionJson(interaction);
|
|
371
|
+
}
|
|
372
|
+
function createOrReadRequest(opts) {
|
|
373
|
+
const ticket = requireCanonicalTicket(opts.root, opts.dir);
|
|
374
|
+
const requestedInteraction = canonicalizeInteraction(opts.request.interaction);
|
|
375
|
+
return withTicketLock(ticket.dir, () => {
|
|
376
|
+
const existingDir = canonicalRequestDirectory(ticket.dir, opts.request.requestId, false);
|
|
377
|
+
if (existingDir !== null) {
|
|
378
|
+
const existing = withExclusiveDirectoryLock(requestLockPath(existingDir), () => readLocked(existingDir, ticket.root, ticket.dir, opts.request.requestId)?.request ?? null);
|
|
379
|
+
if (existing !== null) {
|
|
380
|
+
if (!sameBirthRequest(existing, opts, requestedInteraction))
|
|
381
|
+
throw new Error('Visual request identity is already bound to different immutable coordinates');
|
|
382
|
+
return { request: existing, created: false };
|
|
383
|
+
}
|
|
384
|
+
if (existsSync(requestPath(existingDir)))
|
|
385
|
+
throw new Error('Visual request record is malformed');
|
|
386
|
+
}
|
|
387
|
+
const registration = registeredInboxRoot(ticket.root);
|
|
388
|
+
if (registration?.visualHandler === undefined)
|
|
389
|
+
throw new Error('root has no registered Visual handler');
|
|
390
|
+
const handler = registration.visualHandler;
|
|
391
|
+
const claim = readTicketClaim(ticket.dir);
|
|
392
|
+
if (claim === null || claim.token !== opts.claimToken)
|
|
393
|
+
throw new Error('only the current ticket claim may create a Visual request');
|
|
394
|
+
const deck = parseDeck(deckPath(ticket.dir));
|
|
395
|
+
if (deck.source?.visual !== VISUAL_CAPABILITY)
|
|
396
|
+
throw new Error('ticket does not carry the Visual capability marker');
|
|
397
|
+
const deckInteraction = deck.interactions.find((candidate) => candidate.id === requestedInteraction.id);
|
|
398
|
+
if (deckInteraction === undefined || canonicalInteractionJson(deckInteraction) !== canonicalInteractionJson(requestedInteraction))
|
|
399
|
+
throw new Error('Visual interaction does not match the canonical ticket interaction');
|
|
400
|
+
const requestDir = existingDir ?? canonicalRequestDirectory(ticket.dir, opts.request.requestId, true);
|
|
401
|
+
return withExclusiveDirectoryLock(requestLockPath(requestDir), () => {
|
|
402
|
+
const raced = readLocked(requestDir, ticket.root, ticket.dir, opts.request.requestId)?.request ?? null;
|
|
403
|
+
if (raced !== null) {
|
|
404
|
+
if (!sameBirthRequest(raced, opts, requestedInteraction))
|
|
405
|
+
throw new Error('Visual request identity is already bound to different immutable coordinates');
|
|
406
|
+
return { request: raced, created: false };
|
|
407
|
+
}
|
|
408
|
+
const request = {
|
|
409
|
+
schema: 'humanloop.visual-request/v1',
|
|
410
|
+
capability: VISUAL_CAPABILITY,
|
|
411
|
+
owner: registration.owner,
|
|
412
|
+
root: ticket.root,
|
|
413
|
+
dir: ticket.dir,
|
|
414
|
+
ticketId: basename(ticket.dir),
|
|
415
|
+
requestId: opts.request.requestId,
|
|
416
|
+
generationId: opts.request.generationId,
|
|
417
|
+
interactionId: requestedInteraction.id,
|
|
418
|
+
interaction: requestedInteraction,
|
|
419
|
+
claim: claimIdentity(claim),
|
|
420
|
+
handler: { command: handler.command, args: [...handler.args] },
|
|
421
|
+
state: 'running',
|
|
422
|
+
requestedAt: new Date().toISOString(),
|
|
423
|
+
};
|
|
424
|
+
requestSchema.parse(request);
|
|
425
|
+
if (!publishJsonExclusive(requestPath(requestDir), request))
|
|
426
|
+
throw new Error('Visual request publication lost an unexpected race');
|
|
427
|
+
return { request, created: true };
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
/** Persist one claim-bound request and dispatch its frozen handler at most once. */
|
|
432
|
+
export function startVisualRequest(opts) {
|
|
433
|
+
uuid.parse(opts.request.requestId);
|
|
434
|
+
uuid.parse(opts.request.generationId);
|
|
435
|
+
uuid.parse(opts.claimToken);
|
|
436
|
+
const birth = createOrReadRequest(opts);
|
|
437
|
+
return {
|
|
438
|
+
request: birth.request,
|
|
439
|
+
delivery: birth.created
|
|
440
|
+
? deliverVisualStart(birth.request.root, birth.request.dir, birth.request.requestId)
|
|
441
|
+
: Promise.resolve('already_attempted'),
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
function boundedError(error) {
|
|
445
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
446
|
+
return message.trim().slice(0, 8_192) || 'unknown handler error';
|
|
447
|
+
}
|
|
448
|
+
function removeIfPresent(path) {
|
|
449
|
+
try {
|
|
450
|
+
unlinkSync(path);
|
|
451
|
+
}
|
|
452
|
+
catch (error) {
|
|
453
|
+
if (error.code !== 'ENOENT')
|
|
454
|
+
throw error;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
function cleanup(reason, now) {
|
|
458
|
+
return { reason, attempts: 0, nextAttemptAt: now };
|
|
459
|
+
}
|
|
460
|
+
function publishStartFailure(root, dir, requestDir, requestId, event, failedAt, error) {
|
|
461
|
+
return withExclusiveDirectoryLock(requestLockPath(requestDir), () => {
|
|
462
|
+
const current = readLocked(requestDir, root, dir, requestId);
|
|
463
|
+
const eligible = current !== null
|
|
464
|
+
&& current.request.state === 'running'
|
|
465
|
+
&& current.result === null
|
|
466
|
+
&& bindingMatches(event, current.request);
|
|
467
|
+
const terminalAt = eligible ? new Date().toISOString() : undefined;
|
|
468
|
+
const failure = {
|
|
469
|
+
schema: 'humanloop.visual-delivery-error/v1',
|
|
470
|
+
action: 'start',
|
|
471
|
+
event,
|
|
472
|
+
failedAt,
|
|
473
|
+
error,
|
|
474
|
+
...(terminalAt === undefined ? {} : { terminalAt }),
|
|
475
|
+
};
|
|
476
|
+
startDeliveryErrorSchema.parse(failure);
|
|
477
|
+
atomicWriteJson(startErrorPath(requestDir), failure);
|
|
478
|
+
if (!eligible || current === null || terminalAt === undefined)
|
|
479
|
+
return false;
|
|
480
|
+
const result = resultFor(current.request, { status: 'error', error: startFailureMessage(error) }, terminalAt);
|
|
481
|
+
resultSchema.parse(result);
|
|
482
|
+
if (!publishJsonExclusive(resultPath(requestDir), result))
|
|
483
|
+
return false;
|
|
484
|
+
atomicWriteJson(requestPath(requestDir), { ...current.request, state: 'terminal', settledAt: terminalAt, cleanup: cleanup('unreceipted_start_error', terminalAt) });
|
|
485
|
+
return true;
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
/** Execute the sole birth-owned start attempt. The immutable attempt marker records its dispatch edge. */
|
|
489
|
+
async function deliverVisualStart(root, dir, requestId) {
|
|
490
|
+
const located = locate(root, dir, requestId);
|
|
491
|
+
if (located.requestDir === null)
|
|
492
|
+
return 'ineligible';
|
|
493
|
+
return withExclusiveDirectoryLockAsync(startLockPath(located.requestDir), async () => {
|
|
494
|
+
const request = withExclusiveDirectoryLock(requestLockPath(located.requestDir), () => {
|
|
495
|
+
const current = readLocked(located.requestDir, located.root, located.dir, requestId);
|
|
496
|
+
if (current === null || current.request.state !== 'running' || current.result !== null)
|
|
497
|
+
return null;
|
|
498
|
+
if (existsSync(startAttemptPath(located.requestDir)) || readReceipt(startReceiptPath(located.requestDir), current.request, 'start'))
|
|
499
|
+
return 'attempted';
|
|
500
|
+
const event = eventFor(current.request, 'start');
|
|
501
|
+
const attemptedAt = new Date().toISOString();
|
|
502
|
+
const attempt = { schema: 'humanloop.visual-delivery-attempt/v1', action: 'start', event, attemptedAt };
|
|
503
|
+
deliveryAttemptSchema.parse(attempt);
|
|
504
|
+
if (!publishJsonExclusive(startAttemptPath(located.requestDir), attempt))
|
|
505
|
+
return 'attempted';
|
|
506
|
+
return current.request;
|
|
507
|
+
});
|
|
508
|
+
if (request === null)
|
|
509
|
+
return 'ineligible';
|
|
510
|
+
if (request === 'attempted')
|
|
511
|
+
return 'already_attempted';
|
|
512
|
+
const event = eventFor(request, 'start');
|
|
513
|
+
try {
|
|
514
|
+
await runHandler(request.handler.command, request.handler.args, event);
|
|
515
|
+
const receipt = { schema: 'humanloop.visual-delivery-receipt/v1', action: 'start', event, deliveredAt: new Date().toISOString() };
|
|
516
|
+
deliveryReceiptSchema.parse(receipt);
|
|
517
|
+
publishReceipt(startReceiptPath(located.requestDir), receipt, request);
|
|
518
|
+
removeIfPresent(startErrorPath(located.requestDir));
|
|
519
|
+
return 'delivered';
|
|
520
|
+
}
|
|
521
|
+
catch (error) {
|
|
522
|
+
const message = boundedError(error);
|
|
523
|
+
const failedAt = new Date().toISOString();
|
|
524
|
+
if (publishStartFailure(located.root, located.dir, located.requestDir, requestId, event, failedAt, message))
|
|
525
|
+
await dispatchVisualCleanup(located.root, located.dir, requestId);
|
|
526
|
+
return 'failed';
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
function submissionMatches(request, submission) {
|
|
531
|
+
return request.requestId === submission.requestId
|
|
532
|
+
&& request.generationId === submission.generationId
|
|
533
|
+
&& request.interactionId === submission.interactionId
|
|
534
|
+
&& request.claim.token === submission.claimToken
|
|
535
|
+
&& canonicalInteractionJson(request.interaction) === canonicalInteractionJson(submission.interaction);
|
|
536
|
+
}
|
|
537
|
+
/** Compare-publish the first fully correlated ready/error result. Stale writers are no-ops. */
|
|
538
|
+
export function submitVisualResult(root, dir, raw) {
|
|
539
|
+
const submission = submissionSchema.parse(raw);
|
|
540
|
+
const located = locate(root, dir, submission.requestId);
|
|
541
|
+
if (located.requestDir === null)
|
|
542
|
+
return { published: false };
|
|
543
|
+
return withExclusiveDirectoryLock(requestLockPath(located.requestDir), () => {
|
|
544
|
+
const current = readLocked(located.requestDir, located.root, located.dir, submission.requestId);
|
|
545
|
+
if (current === null || current.request.state !== 'running' || current.result !== null || !submissionMatches(current.request, submission))
|
|
546
|
+
return { published: false };
|
|
547
|
+
const completedAt = new Date().toISOString();
|
|
548
|
+
let outcome;
|
|
549
|
+
if (submission.status === 'ready') {
|
|
550
|
+
const check = submission.markdown.trim().length === 0 ? { ok: false, error: 'ready result requires non-empty markdown' } : checkMarkdown(submission.markdown);
|
|
551
|
+
outcome = check.ok ? { status: 'ready', markdown: submission.markdown } : { status: 'error', error: boundedError(check.error) };
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
outcome = { status: 'error', error: submission.error };
|
|
555
|
+
}
|
|
556
|
+
const result = resultFor(current.request, outcome, completedAt);
|
|
557
|
+
resultSchema.parse(result);
|
|
558
|
+
if (!publishJsonExclusive(resultPath(located.requestDir), result))
|
|
559
|
+
return { published: false };
|
|
560
|
+
atomicWriteJson(requestPath(located.requestDir), { ...current.request, state: 'terminal', settledAt: completedAt });
|
|
561
|
+
return { published: true };
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
function stateFirstCancel(root, dir, requestId) {
|
|
565
|
+
const located = locate(root, dir, requestId);
|
|
566
|
+
if (located.requestDir === null)
|
|
567
|
+
return null;
|
|
568
|
+
return withExclusiveDirectoryLock(requestLockPath(located.requestDir), () => {
|
|
569
|
+
const current = readLocked(located.requestDir, located.root, located.dir, requestId);
|
|
570
|
+
if (current === null)
|
|
571
|
+
return null;
|
|
572
|
+
let request = current.request;
|
|
573
|
+
let changed = false;
|
|
574
|
+
if (request.state === 'running' && current.result === null) {
|
|
575
|
+
const settledAt = new Date().toISOString();
|
|
576
|
+
request = { ...request, state: 'canceled', settledAt, cleanup: cleanup('canceled', settledAt) };
|
|
577
|
+
atomicWriteJson(requestPath(located.requestDir), request);
|
|
578
|
+
changed = true;
|
|
579
|
+
}
|
|
580
|
+
return { requestDir: located.requestDir, owed: cleanupOwed(located.requestDir, request), changed };
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
function retryDelayMs(attempts) {
|
|
584
|
+
return Math.min(60_000, 1_000 * (2 ** Math.min(Math.max(0, attempts - 1), 6)));
|
|
585
|
+
}
|
|
586
|
+
/** Attempt one due cleanup delivery through the request's frozen handler. */
|
|
587
|
+
export async function dispatchVisualCleanup(root, dir, requestId) {
|
|
588
|
+
const located = locate(root, dir, requestId);
|
|
589
|
+
if (located.requestDir === null)
|
|
590
|
+
return 'none';
|
|
591
|
+
return withExclusiveDirectoryLockAsync(cancelLockPath(located.requestDir), async () => {
|
|
592
|
+
const due = withExclusiveDirectoryLock(requestLockPath(located.requestDir), () => {
|
|
593
|
+
const current = readLocked(located.requestDir, located.root, located.dir, requestId);
|
|
594
|
+
if (current === null || !cleanupOwed(located.requestDir, current.request))
|
|
595
|
+
return null;
|
|
596
|
+
const obligation = current.request.cleanup;
|
|
597
|
+
if (Date.parse(obligation.nextAttemptAt) > Date.now())
|
|
598
|
+
return 'not_due';
|
|
599
|
+
const attemptedAt = new Date().toISOString();
|
|
600
|
+
const attempts = obligation.attempts + 1;
|
|
601
|
+
const updated = {
|
|
602
|
+
...current.request,
|
|
603
|
+
cleanup: { ...obligation, attempts, lastAttemptAt: attemptedAt, nextAttemptAt: new Date(Date.now() + retryDelayMs(attempts)).toISOString() },
|
|
604
|
+
};
|
|
605
|
+
atomicWriteJson(requestPath(located.requestDir), updated);
|
|
606
|
+
return updated;
|
|
607
|
+
});
|
|
608
|
+
if (due === null)
|
|
609
|
+
return 'none';
|
|
610
|
+
if (due === 'not_due')
|
|
611
|
+
return 'pending';
|
|
612
|
+
const event = eventFor(due, 'cancel');
|
|
613
|
+
try {
|
|
614
|
+
await runHandler(due.handler.command, due.handler.args, event);
|
|
615
|
+
const receipt = { schema: 'humanloop.visual-delivery-receipt/v1', action: 'cancel', event, deliveredAt: new Date().toISOString() };
|
|
616
|
+
deliveryReceiptSchema.parse(receipt);
|
|
617
|
+
publishReceipt(cancelReceiptPath(located.requestDir), receipt, due);
|
|
618
|
+
removeIfPresent(cancelErrorPath(located.requestDir));
|
|
619
|
+
return 'delivered';
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
const message = boundedError(error);
|
|
623
|
+
atomicWriteJson(cancelErrorPath(located.requestDir), { schema: 'humanloop.visual-delivery-error/v1', action: 'cancel', event, failedAt: new Date().toISOString(), error: message });
|
|
624
|
+
withExclusiveDirectoryLock(requestLockPath(located.requestDir), () => {
|
|
625
|
+
const current = readLocked(located.requestDir, located.root, located.dir, requestId);
|
|
626
|
+
if (current?.request.cleanup === undefined || current.request.cleanup.attempts !== due.cleanup.attempts)
|
|
627
|
+
return;
|
|
628
|
+
atomicWriteJson(requestPath(located.requestDir), { ...current.request, cleanup: { ...current.request.cleanup, lastError: message } });
|
|
629
|
+
});
|
|
630
|
+
return 'pending';
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
/** State-first cancellation for one handle; the returned promise is cleanup delivery only. */
|
|
635
|
+
export function cancelVisualRequest(root, dir, requestId) {
|
|
636
|
+
const canceled = stateFirstCancel(root, dir, requestId);
|
|
637
|
+
return canceled?.owed === true ? dispatchVisualCleanup(root, dir, requestId) : Promise.resolve('none');
|
|
638
|
+
}
|
|
639
|
+
function requestIdsForTicket(root, dir) {
|
|
640
|
+
const ticket = requireCanonicalTicket(root, dir);
|
|
641
|
+
const parent = canonicalVisualsDirectory(ticket.dir, false);
|
|
642
|
+
if (parent === null)
|
|
643
|
+
return [];
|
|
644
|
+
return readdirSync(parent).filter((entry) => uuid.safeParse(entry).success).sort();
|
|
645
|
+
}
|
|
646
|
+
/** State-first cancel every running request, then dispatch every owed cleanup (including start failures). */
|
|
647
|
+
export function cancelVisualRequestsForTicket(root, dir) {
|
|
648
|
+
const requests = requestIdsForTicket(root, dir).map((requestId) => ({ requestId, canceled: stateFirstCancel(root, dir, requestId) }));
|
|
649
|
+
const owed = requests.filter((entry) => entry.canceled?.owed === true);
|
|
650
|
+
const canceled = requests.filter((entry) => entry.canceled?.changed === true).length;
|
|
651
|
+
return Promise.all(owed.map((entry) => dispatchVisualCleanup(root, dir, entry.requestId))).then(() => ({ canceled, cleanupOwed: owed.length }));
|
|
652
|
+
}
|
|
653
|
+
/** Durable tasks the next controller-owned retry executor should schedule. */
|
|
654
|
+
export function listVisualCleanupObligations(root, dir) {
|
|
655
|
+
const ticket = requireCanonicalTicket(root, dir);
|
|
656
|
+
const tasks = [];
|
|
657
|
+
for (const requestId of requestIdsForTicket(ticket.root, ticket.dir)) {
|
|
658
|
+
const requestDir = canonicalRequestDirectory(ticket.dir, requestId, false);
|
|
659
|
+
if (requestDir === null)
|
|
660
|
+
continue;
|
|
661
|
+
const request = readVisualRequest(ticket.root, ticket.dir, requestId);
|
|
662
|
+
if (request?.cleanup !== undefined && cleanupOwed(requestDir, request))
|
|
663
|
+
tasks.push({ root: ticket.root, dir: ticket.dir, requestId, reason: request.cleanup.reason, nextAttemptAt: request.cleanup.nextAttemptAt });
|
|
664
|
+
}
|
|
665
|
+
return tasks.sort((left, right) => left.nextAttemptAt.localeCompare(right.nextAttemptAt) || left.requestId.localeCompare(right.requestId));
|
|
666
|
+
}
|
|
667
|
+
/** Retire abandoned generations under the ticket claim boundary. A live claim is
|
|
668
|
+
* authoritative; a newly acquired claim retires every older generation first. */
|
|
669
|
+
export function reconcileVisualRequestsForTicket(root, dir, currentClaimToken) {
|
|
670
|
+
const ticket = requireCanonicalTicket(root, dir);
|
|
671
|
+
const reconciled = withTicketLock(ticket.dir, () => {
|
|
672
|
+
const claim = readTicketClaim(ticket.dir);
|
|
673
|
+
if (currentClaimToken !== undefined) {
|
|
674
|
+
uuid.parse(currentClaimToken);
|
|
675
|
+
if (claim?.token !== currentClaimToken)
|
|
676
|
+
return { retired: 0, owed: [] };
|
|
677
|
+
}
|
|
678
|
+
else if (claim !== null && !isStaleClaim(claim)) {
|
|
679
|
+
return { retired: 0, owed: [] };
|
|
680
|
+
}
|
|
681
|
+
const entries = requestIdsForTicket(ticket.root, ticket.dir).map((requestId) => {
|
|
682
|
+
const request = readVisualRequest(ticket.root, ticket.dir, requestId);
|
|
683
|
+
if (request === null || (currentClaimToken !== undefined && request.claim.token === currentClaimToken))
|
|
684
|
+
return { requestId, canceled: null };
|
|
685
|
+
return { requestId, canceled: stateFirstCancel(ticket.root, ticket.dir, requestId) };
|
|
686
|
+
});
|
|
687
|
+
const owed = entries.filter((entry) => entry.canceled?.owed === true).map((entry) => entry.requestId);
|
|
688
|
+
return { retired: entries.filter((entry) => entry.canceled?.changed === true).length, owed };
|
|
689
|
+
});
|
|
690
|
+
return {
|
|
691
|
+
retired: reconciled.retired,
|
|
692
|
+
cleanupOwed: reconciled.owed.length,
|
|
693
|
+
delivery: Promise.all(reconciled.owed.map((requestId) => dispatchVisualCleanup(ticket.root, ticket.dir, requestId))),
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
/** Enumerate direct ticket children only, including resolved tickets whose cleanup is still owed. */
|
|
697
|
+
export function listVisualCleanupObligationsForRoot(root) {
|
|
698
|
+
const registration = registeredInboxRoot(root);
|
|
699
|
+
if (registration === null)
|
|
700
|
+
return [];
|
|
701
|
+
const tasks = [];
|
|
702
|
+
for (const entry of readdirSync(registration.root)) {
|
|
703
|
+
const candidate = resolve(registration.root, entry);
|
|
704
|
+
let dir;
|
|
705
|
+
try {
|
|
706
|
+
if (!statSync(candidate).isDirectory())
|
|
707
|
+
continue;
|
|
708
|
+
dir = realpathSync(candidate);
|
|
709
|
+
}
|
|
710
|
+
catch {
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (dirname(dir) !== registration.root)
|
|
714
|
+
continue;
|
|
715
|
+
try {
|
|
716
|
+
tasks.push(...listVisualCleanupObligations(registration.root, dir));
|
|
717
|
+
}
|
|
718
|
+
catch { /* unrelated or malformed children are not Visual work */ }
|
|
719
|
+
}
|
|
720
|
+
return tasks.sort((left, right) => left.nextAttemptAt.localeCompare(right.nextAttemptAt) || left.dir.localeCompare(right.dir) || left.requestId.localeCompare(right.requestId));
|
|
721
|
+
}
|
|
722
|
+
/** Startup reconciliation only retires stale/missing-claim work; live owners stay untouched. */
|
|
723
|
+
export function reconcileStaleVisualRequestsForRoot(root) {
|
|
724
|
+
const registration = registeredInboxRoot(root);
|
|
725
|
+
if (registration === null)
|
|
726
|
+
return [];
|
|
727
|
+
const reconciled = [];
|
|
728
|
+
for (const entry of readdirSync(registration.root)) {
|
|
729
|
+
const candidate = resolve(registration.root, entry);
|
|
730
|
+
let dir;
|
|
731
|
+
try {
|
|
732
|
+
if (!statSync(candidate).isDirectory())
|
|
733
|
+
continue;
|
|
734
|
+
dir = realpathSync(candidate);
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (dirname(dir) !== registration.root)
|
|
740
|
+
continue;
|
|
741
|
+
try {
|
|
742
|
+
reconciled.push(reconcileVisualRequestsForTicket(registration.root, dir));
|
|
743
|
+
}
|
|
744
|
+
catch { /* unrelated or malformed children have no protocol obligation */ }
|
|
745
|
+
}
|
|
746
|
+
return reconciled;
|
|
747
|
+
}
|