pk-annotator 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.ko.md +92 -0
- package/README.md +92 -0
- package/dist/overlay.d.mts +172 -0
- package/dist/overlay.mjs +176 -0
- package/dist/pka-mcp-server-BPtb4Kn8.mjs +24652 -0
- package/dist/pka-mcp.d.mts +1 -0
- package/dist/pka-mcp.mjs +7 -0
- package/dist/pka-overlay-agent-art-3b6Y-lJN.mjs +1 -0
- package/dist/pka-overlay-agent-theme-By8py_yl.mjs +399 -0
- package/dist/pka-overlay-app-8vOHqTu0.mjs +246 -0
- package/dist/pka-overlay-channel-z4oIRzcB.mjs +113 -0
- package/dist/pka-overlay-composer-Cg4L5obR.mjs +244 -0
- package/dist/pka-overlay-copy-DcEIUgrX.mjs +8 -0
- package/dist/pka-overlay-gifenc-Cx3exeY6.mjs +1 -0
- package/dist/pka-overlay-perf-panel-C4rxt4tB.mjs +2 -0
- package/dist/pka-overlay-rolldown-runtime-hePW80VL.mjs +1 -0
- package/dist/pka-overlay-send-A6i2GZG0.mjs +139 -0
- package/dist/pka-overlay-snapdom-Dn2JAZkv.mjs +34 -0
- package/dist/pka-overlay-store-eSg4Q7Sz.mjs +1 -0
- package/dist/pka-overlay-thread-store-zl2wNG0R.mjs +1 -0
- package/dist/pka-overlay-untracked-fetch-Bin99U-o.mjs +1 -0
- package/dist/pka.d.mts +1 -0
- package/dist/pka.mjs +1678 -0
- package/dist/recording-CpEDddkZ.mjs +1644 -0
- package/dist/vite.d.mts +18 -0
- package/dist/vite.mjs +729 -0
- package/package.json +122 -0
|
@@ -0,0 +1,1644 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { constants, readFileSync, readlinkSync, watch } from "node:fs";
|
|
3
|
+
import { link, lstat, mkdir, open, readdir, realpath, rename, rm, stat, unlink } from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { hostname } from "node:os";
|
|
7
|
+
//#region src/shared/schema.ts
|
|
8
|
+
const ID_PATTERN = /^[a-z0-9-]{8,40}$/;
|
|
9
|
+
const Id = z.string().regex(ID_PATTERN, "id must match ^[a-z0-9-]{8,40}$");
|
|
10
|
+
const RelativePath = z.string().max(200).regex(/^(?!.*(?:^|\/)\.{1,2}(?:\/|$))[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)*$/, "path must be relative to the annotation directory, without . or .. segments");
|
|
11
|
+
const Timestamp = z.iso.datetime({ offset: true });
|
|
12
|
+
const Viewport = z.strictObject({
|
|
13
|
+
w: z.number().int().positive(),
|
|
14
|
+
h: z.number().int().positive(),
|
|
15
|
+
dpr: z.number().positive(),
|
|
16
|
+
scrollX: z.number(),
|
|
17
|
+
scrollY: z.number()
|
|
18
|
+
});
|
|
19
|
+
const Box = z.strictObject({
|
|
20
|
+
x: z.number(),
|
|
21
|
+
y: z.number(),
|
|
22
|
+
w: z.number().nonnegative(),
|
|
23
|
+
h: z.number().nonnegative()
|
|
24
|
+
});
|
|
25
|
+
const Selector = z.strictObject({
|
|
26
|
+
role: z.string().optional(),
|
|
27
|
+
name: z.string().optional(),
|
|
28
|
+
testId: z.string().optional(),
|
|
29
|
+
css: z.string()
|
|
30
|
+
});
|
|
31
|
+
const ElementRef = z.strictObject({
|
|
32
|
+
n: z.number().int().positive(),
|
|
33
|
+
source: z.string().regex(/^.+:\d+:\d+$/, "source must be file:line:col").optional(),
|
|
34
|
+
usedAt: z.string().regex(/^.+:\d+:\d+$/, "usedAt must be file:line:col").optional(),
|
|
35
|
+
owners: z.array(z.string()),
|
|
36
|
+
selector: Selector,
|
|
37
|
+
html: z.string(),
|
|
38
|
+
box: Box,
|
|
39
|
+
crop: RelativePath.optional(),
|
|
40
|
+
text: z.string().optional(),
|
|
41
|
+
nearbyText: z.string().optional()
|
|
42
|
+
});
|
|
43
|
+
const AttachmentKind = z.enum([
|
|
44
|
+
"recording",
|
|
45
|
+
"errors",
|
|
46
|
+
"network",
|
|
47
|
+
"perf",
|
|
48
|
+
"frame",
|
|
49
|
+
"video"
|
|
50
|
+
]);
|
|
51
|
+
const Attachment = z.strictObject({
|
|
52
|
+
kind: AttachmentKind,
|
|
53
|
+
path: RelativePath,
|
|
54
|
+
summary: z.string()
|
|
55
|
+
});
|
|
56
|
+
const Annotation = z.strictObject({
|
|
57
|
+
id: Id,
|
|
58
|
+
createdAt: Timestamp,
|
|
59
|
+
url: z.string(),
|
|
60
|
+
route: z.string(),
|
|
61
|
+
viewport: Viewport,
|
|
62
|
+
prompt: z.string(),
|
|
63
|
+
elements: z.array(ElementRef),
|
|
64
|
+
attachments: z.array(Attachment)
|
|
65
|
+
});
|
|
66
|
+
const AnnotationDraft = Annotation.omit({
|
|
67
|
+
id: true,
|
|
68
|
+
createdAt: true
|
|
69
|
+
});
|
|
70
|
+
const Status = z.enum([
|
|
71
|
+
"pending",
|
|
72
|
+
"acknowledged",
|
|
73
|
+
"resolved",
|
|
74
|
+
"dismissed"
|
|
75
|
+
]);
|
|
76
|
+
const StatusEvent = z.strictObject({
|
|
77
|
+
status: Status,
|
|
78
|
+
at: Timestamp,
|
|
79
|
+
by: z.string().optional(),
|
|
80
|
+
note: z.string().optional()
|
|
81
|
+
});
|
|
82
|
+
const State = z.strictObject({
|
|
83
|
+
status: Status,
|
|
84
|
+
history: z.array(StatusEvent).min(1)
|
|
85
|
+
});
|
|
86
|
+
/**
|
|
87
|
+
* The claiming process. A pid means something only inside its PID namespace, so `namespace`
|
|
88
|
+
* names that namespace on its host; `startTime` (Linux) tells a reused pid apart.
|
|
89
|
+
*/
|
|
90
|
+
const ClaimProcess = z.strictObject({
|
|
91
|
+
pid: z.number().int().positive(),
|
|
92
|
+
namespace: z.string().min(1).max(200),
|
|
93
|
+
startTime: z.string().regex(/^\d+$/).optional()
|
|
94
|
+
});
|
|
95
|
+
const Claim = z.strictObject({
|
|
96
|
+
by: z.string().min(1),
|
|
97
|
+
at: Timestamp,
|
|
98
|
+
process: ClaimProcess.optional()
|
|
99
|
+
});
|
|
100
|
+
const ThreadEntry = z.strictObject({
|
|
101
|
+
at: Timestamp,
|
|
102
|
+
from: z.enum(["agent", "human"]),
|
|
103
|
+
text: z.string().min(1)
|
|
104
|
+
});
|
|
105
|
+
const ErrorGroupStatus = z.enum([
|
|
106
|
+
"open",
|
|
107
|
+
"sent",
|
|
108
|
+
"cleared"
|
|
109
|
+
]);
|
|
110
|
+
const ErrorGroup = z.strictObject({
|
|
111
|
+
fingerprint: z.string().min(1).max(200),
|
|
112
|
+
message: z.string(),
|
|
113
|
+
type: z.string(),
|
|
114
|
+
count: z.number().int().positive(),
|
|
115
|
+
firstSeen: Timestamp,
|
|
116
|
+
lastSeen: Timestamp,
|
|
117
|
+
lastSeq: z.number().int().nonnegative(),
|
|
118
|
+
topFrame: z.string().regex(/^.+:\d+$/, "topFrame must be file:line").optional(),
|
|
119
|
+
stack: z.string(),
|
|
120
|
+
status: ErrorGroupStatus
|
|
121
|
+
});
|
|
122
|
+
const LiveErrorsSnapshot = z.strictObject({
|
|
123
|
+
updatedAt: Timestamp,
|
|
124
|
+
groups: z.array(ErrorGroup)
|
|
125
|
+
});
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/store/store.ts
|
|
128
|
+
const STORE_SUBDIR = path.join("_interim", "annotations");
|
|
129
|
+
const DEFAULT_SIZE_CAP_BYTES = 524288e3;
|
|
130
|
+
const PRUNE_AGE_MS = 6048e5;
|
|
131
|
+
const STAGING_PREFIX = ".creating-";
|
|
132
|
+
const STAGING_AGE_MS = 864e5;
|
|
133
|
+
const CREATE_EXCLUSIVE = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW;
|
|
134
|
+
/** An error whose message is meant for the person or agent that called the operation. */
|
|
135
|
+
var PkaError = class extends Error {
|
|
136
|
+
name = "PkaError";
|
|
137
|
+
};
|
|
138
|
+
/** The annotation directory does not exist, for example because prune removed it. */
|
|
139
|
+
var MissingAnnotationError = class extends PkaError {
|
|
140
|
+
name = "MissingAnnotationError";
|
|
141
|
+
};
|
|
142
|
+
function isErrno(cause, code) {
|
|
143
|
+
return cause instanceof Error && "code" in cause && cause.code === code;
|
|
144
|
+
}
|
|
145
|
+
async function storeUnder(root) {
|
|
146
|
+
const dir = path.resolve(root, STORE_SUBDIR);
|
|
147
|
+
try {
|
|
148
|
+
if (!(await stat(dir)).isDirectory()) throw new PkaError(`${dir} exists but is not a directory`);
|
|
149
|
+
} catch (thrown) {
|
|
150
|
+
if (isErrno(thrown, "ENOENT")) return void 0;
|
|
151
|
+
throw thrown;
|
|
152
|
+
}
|
|
153
|
+
return realpath(dir);
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Finds the annotation store without creating anything: the explicit root,
|
|
157
|
+
* then CLAUDE_PROJECT_DIR, then the nearest ancestor of cwd that has one.
|
|
158
|
+
*/
|
|
159
|
+
async function findStore(sources) {
|
|
160
|
+
if (sources.explicit !== void 0) {
|
|
161
|
+
const store = await storeUnder(sources.explicit);
|
|
162
|
+
if (store === void 0) throw new PkaError(`No annotation store at ${path.resolve(sources.explicit, STORE_SUBDIR)}. Start the app's Vite dev server with the pk-annotator plugin once to create it, or pass a different --root.`);
|
|
163
|
+
return store;
|
|
164
|
+
}
|
|
165
|
+
if (sources.claudeProjectDir !== void 0) {
|
|
166
|
+
const store = await storeUnder(sources.claudeProjectDir);
|
|
167
|
+
if (store !== void 0) return store;
|
|
168
|
+
}
|
|
169
|
+
for (let dir = path.resolve(sources.cwd);; dir = path.dirname(dir)) {
|
|
170
|
+
const store = await storeUnder(dir);
|
|
171
|
+
if (store !== void 0) return store;
|
|
172
|
+
if (path.dirname(dir) === dir) break;
|
|
173
|
+
}
|
|
174
|
+
const checked = [...new Set([sources.claudeProjectDir, sources.cwd].filter((dir) => dir !== void 0).map((dir) => path.resolve(dir)))];
|
|
175
|
+
throw new PkaError(`No ${STORE_SUBDIR} found in ${checked.join(" or ")} or any parent directory. Start the app's Vite dev server with the pk-annotator plugin once to create it, or pass --root <project> (or PKA_ROOT).`);
|
|
176
|
+
}
|
|
177
|
+
/** Creates the store under a project root. Only the Vite plugin calls this. */
|
|
178
|
+
async function createStore(projectRoot) {
|
|
179
|
+
const dir = path.resolve(projectRoot, STORE_SUBDIR);
|
|
180
|
+
await mkdir(path.join(dir, "live"), { recursive: true });
|
|
181
|
+
return realpath(dir);
|
|
182
|
+
}
|
|
183
|
+
/** A sortable id: base-36 milliseconds, then random hex. */
|
|
184
|
+
function newId(now) {
|
|
185
|
+
return `${now.toString(36).padStart(9, "0")}-${randomBytes(4).toString("hex")}`;
|
|
186
|
+
}
|
|
187
|
+
function checkId(id) {
|
|
188
|
+
if (!ID_PATTERN.test(id)) throw new PkaError(`Invalid annotation id ${JSON.stringify(id)}: ids match ^[a-z0-9-]{8,40}$. List annotations to get a valid id.`);
|
|
189
|
+
return id;
|
|
190
|
+
}
|
|
191
|
+
function resolveInside(base, ...segments) {
|
|
192
|
+
const target = path.resolve(base, ...segments);
|
|
193
|
+
const relative = path.relative(base, target);
|
|
194
|
+
if (relative === "" || relative.split(path.sep)[0] === ".." || path.isAbsolute(relative)) throw new PkaError(`Path ${segments.join("/")} resolves outside ${base}`);
|
|
195
|
+
return target;
|
|
196
|
+
}
|
|
197
|
+
/** Refuses a symlink anywhere between the store and the target. Missing components end the walk. */
|
|
198
|
+
async function refuseSymlinks(store, target) {
|
|
199
|
+
let current = store;
|
|
200
|
+
for (const segment of path.relative(store, target).split(path.sep)) {
|
|
201
|
+
current = path.join(current, segment);
|
|
202
|
+
try {
|
|
203
|
+
if ((await lstat(current)).isSymbolicLink()) throw new PkaError(`Refusing symlink in the annotation store: ${current}`);
|
|
204
|
+
} catch (thrown) {
|
|
205
|
+
if (isErrno(thrown, "ENOENT")) return;
|
|
206
|
+
throw thrown;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function annotationFiles(store, id) {
|
|
211
|
+
const dir = resolveInside(store, checkId(id));
|
|
212
|
+
return {
|
|
213
|
+
dir,
|
|
214
|
+
annotation: resolveInside(dir, "annotation.json"),
|
|
215
|
+
state: resolveInside(dir, "state.json"),
|
|
216
|
+
claim: resolveInside(dir, "claim.json"),
|
|
217
|
+
thread: resolveInside(dir, "thread.jsonl")
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
function liveErrorsFile(store) {
|
|
221
|
+
return resolveInside(store, "live", "errors.json");
|
|
222
|
+
}
|
|
223
|
+
function processAlive(pid) {
|
|
224
|
+
try {
|
|
225
|
+
process.kill(pid, 0);
|
|
226
|
+
return true;
|
|
227
|
+
} catch (cause) {
|
|
228
|
+
return !isErrno(cause, "ESRCH");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
/** Field 22 of /proc/<pid>/stat, clock ticks after boot. The command name before it may hold spaces and parentheses. */
|
|
232
|
+
function linuxStartTime(pid) {
|
|
233
|
+
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
234
|
+
const field = stat.slice(stat.lastIndexOf(")") + 2).split(" ")[19];
|
|
235
|
+
if (field === void 0 || !/^\d+$/.test(field)) throw new Error(`Unexpected /proc/${pid}/stat: ${stat}`);
|
|
236
|
+
return field;
|
|
237
|
+
}
|
|
238
|
+
let ownNamespace;
|
|
239
|
+
/**
|
|
240
|
+
* Names this process's PID namespace on its host. Every Linux host gives its initial
|
|
241
|
+
* namespace the same inode, so the boot id tells hosts (and boots) apart.
|
|
242
|
+
*/
|
|
243
|
+
function pidNamespace() {
|
|
244
|
+
ownNamespace ??= process.platform === "linux" ? `linux:${readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim()}:${readlinkSync("/proc/self/ns/pid")}` : `${process.platform}:${hostname()}`;
|
|
245
|
+
return ownNamespace;
|
|
246
|
+
}
|
|
247
|
+
let ownProcess;
|
|
248
|
+
/** This process, as recorded in a claim or lock so another process can tell when it has exited. */
|
|
249
|
+
function thisProcess() {
|
|
250
|
+
if (ownProcess === void 0) {
|
|
251
|
+
ownProcess = {
|
|
252
|
+
pid: process.pid,
|
|
253
|
+
namespace: pidNamespace()
|
|
254
|
+
};
|
|
255
|
+
if (process.platform === "linux") ownProcess.startTime = linuxStartTime("self");
|
|
256
|
+
}
|
|
257
|
+
return ownProcess;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* True only when this process can tell that `owner` has exited: it names a process in this
|
|
261
|
+
* PID namespace, and that pid is gone or now belongs to a process started later. A process
|
|
262
|
+
* from another namespace is never judged.
|
|
263
|
+
*/
|
|
264
|
+
function processExited(owner) {
|
|
265
|
+
if (owner.namespace !== pidNamespace()) return false;
|
|
266
|
+
if (owner.startTime !== void 0) try {
|
|
267
|
+
return linuxStartTime(owner.pid) !== owner.startTime;
|
|
268
|
+
} catch (cause) {
|
|
269
|
+
if (![
|
|
270
|
+
"ENOENT",
|
|
271
|
+
"ESRCH",
|
|
272
|
+
"EACCES",
|
|
273
|
+
"EPERM"
|
|
274
|
+
].some((code) => isErrno(cause, code))) throw cause;
|
|
275
|
+
}
|
|
276
|
+
return !processAlive(owner.pid);
|
|
277
|
+
}
|
|
278
|
+
/** Throws a PkaError naming the next step when the annotation directory is absent. */
|
|
279
|
+
async function requireAnnotation(store, id) {
|
|
280
|
+
const files = annotationFiles(store, id);
|
|
281
|
+
try {
|
|
282
|
+
const info = await lstat(files.dir);
|
|
283
|
+
if (info.isSymbolicLink()) throw new PkaError(`Refusing symlink in the annotation store: ${files.dir}`);
|
|
284
|
+
if (!info.isDirectory()) throw new PkaError(`${files.dir} is not a directory`);
|
|
285
|
+
} catch (thrown) {
|
|
286
|
+
if (isErrno(thrown, "ENOENT")) throw new MissingAnnotationError(`No annotation ${id}. List annotations to see the ids that exist.`);
|
|
287
|
+
throw thrown;
|
|
288
|
+
}
|
|
289
|
+
return files;
|
|
290
|
+
}
|
|
291
|
+
async function readText(store, file) {
|
|
292
|
+
await refuseSymlinks(store, file);
|
|
293
|
+
const handle = await open(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
294
|
+
try {
|
|
295
|
+
return await handle.readFile("utf8");
|
|
296
|
+
} finally {
|
|
297
|
+
await handle.close();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
function parseWith(schema, text, where) {
|
|
301
|
+
let data;
|
|
302
|
+
try {
|
|
303
|
+
data = JSON.parse(text);
|
|
304
|
+
} catch (thrown) {
|
|
305
|
+
throw new PkaError(`Invalid JSON in ${where}`, { cause: thrown });
|
|
306
|
+
}
|
|
307
|
+
const parsed = schema.safeParse(data);
|
|
308
|
+
if (!parsed.success) throw new PkaError(`Invalid data in ${where}:\n${z.prettifyError(parsed.error)}`);
|
|
309
|
+
return parsed.data;
|
|
310
|
+
}
|
|
311
|
+
async function readJson(store, file, schema) {
|
|
312
|
+
return parseWith(schema, await readText(store, file), file);
|
|
313
|
+
}
|
|
314
|
+
/** Reads a JSON-lines file; an absent file has no entries. */
|
|
315
|
+
async function readJsonLines(store, file, schema) {
|
|
316
|
+
let text;
|
|
317
|
+
try {
|
|
318
|
+
text = await readText(store, file);
|
|
319
|
+
} catch (thrown) {
|
|
320
|
+
if (isErrno(thrown, "ENOENT")) return [];
|
|
321
|
+
throw thrown;
|
|
322
|
+
}
|
|
323
|
+
return text.split("\n").flatMap((line, index) => line.trim() === "" ? [] : [parseWith(schema, line, `${file}:${index + 1}`)]);
|
|
324
|
+
}
|
|
325
|
+
async function writeExclusive(file, data) {
|
|
326
|
+
const handle = await open(file, CREATE_EXCLUSIVE, 420);
|
|
327
|
+
try {
|
|
328
|
+
await handle.writeFile(data);
|
|
329
|
+
} finally {
|
|
330
|
+
await handle.close();
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
function temporaryName(file) {
|
|
334
|
+
return `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
|
|
335
|
+
}
|
|
336
|
+
/** Writes through a temporary file and a rename, so readers see the old or the new content. */
|
|
337
|
+
async function writeJsonAtomic(store, file, value) {
|
|
338
|
+
await refuseSymlinks(store, path.dirname(file));
|
|
339
|
+
const temporary = temporaryName(file);
|
|
340
|
+
try {
|
|
341
|
+
await writeExclusive(temporary, `${JSON.stringify(value, null, 2)}\n`);
|
|
342
|
+
await rename(temporary, file);
|
|
343
|
+
} catch (thrown) {
|
|
344
|
+
await rm(temporary, { force: true });
|
|
345
|
+
throw thrown;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
async function appendJsonLine(store, file, value) {
|
|
349
|
+
await refuseSymlinks(store, file);
|
|
350
|
+
const handle = await open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 420);
|
|
351
|
+
try {
|
|
352
|
+
await handle.write(`${JSON.stringify(value)}\n`);
|
|
353
|
+
} finally {
|
|
354
|
+
await handle.close();
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Claims an annotation. The claim is written to a temporary file opened with
|
|
359
|
+
* O_EXCL | O_NOFOLLOW and then hard-linked into place, so exactly one caller
|
|
360
|
+
* wins and a loser never reads a half-written claim. A claim that disappears
|
|
361
|
+
* before the loser reads it was removed by replaceClaim, so the link is retried.
|
|
362
|
+
*/
|
|
363
|
+
async function createClaim(store, id, claim) {
|
|
364
|
+
const files = await requireAnnotation(store, id);
|
|
365
|
+
const temporary = temporaryName(files.claim);
|
|
366
|
+
await writeExclusive(temporary, `${JSON.stringify(claim)}\n`);
|
|
367
|
+
try {
|
|
368
|
+
for (;;) {
|
|
369
|
+
try {
|
|
370
|
+
await link(temporary, files.claim);
|
|
371
|
+
return {
|
|
372
|
+
won: true,
|
|
373
|
+
claim
|
|
374
|
+
};
|
|
375
|
+
} catch (thrown) {
|
|
376
|
+
if (!isErrno(thrown, "EEXIST")) throw thrown;
|
|
377
|
+
}
|
|
378
|
+
try {
|
|
379
|
+
return {
|
|
380
|
+
won: false,
|
|
381
|
+
claim: await readJson(store, files.claim, Claim)
|
|
382
|
+
};
|
|
383
|
+
} catch (thrown) {
|
|
384
|
+
if (!isErrno(thrown, "ENOENT")) throw thrown;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
} finally {
|
|
388
|
+
await unlink(temporary);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
/** Replaces claim.json with `claim` in one rename, so it never goes missing. Hold the annotation lock. */
|
|
392
|
+
async function writeClaim(store, id, claim) {
|
|
393
|
+
const files = await requireAnnotation(store, id);
|
|
394
|
+
await refuseSymlinks(store, files.claim);
|
|
395
|
+
const temporary = temporaryName(files.claim);
|
|
396
|
+
try {
|
|
397
|
+
await writeExclusive(temporary, `${JSON.stringify(claim)}\n`);
|
|
398
|
+
await rename(temporary, files.claim);
|
|
399
|
+
} catch (thrown) {
|
|
400
|
+
await rm(temporary, { force: true });
|
|
401
|
+
throw thrown;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
/** How long a caller waits for a lock whose owner is still running before it reports it. */
|
|
405
|
+
const LOCK_WAIT_MS = 1e4;
|
|
406
|
+
const LockOwner = z.strictObject({
|
|
407
|
+
token: z.string().regex(/^[0-9a-f]{16}$/),
|
|
408
|
+
process: ClaimProcess
|
|
409
|
+
});
|
|
410
|
+
async function readLockOwner(store, lock) {
|
|
411
|
+
try {
|
|
412
|
+
return await readJson(store, lock, LockOwner);
|
|
413
|
+
} catch (thrown) {
|
|
414
|
+
if (isErrno(thrown, "ENOENT")) return void 0;
|
|
415
|
+
throw thrown;
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function lockChange(lock, deadline) {
|
|
419
|
+
const name = path.basename(lock);
|
|
420
|
+
const watcher = watch(path.dirname(lock));
|
|
421
|
+
let failure;
|
|
422
|
+
let finish = () => {};
|
|
423
|
+
const settled = new Promise((resolve) => {
|
|
424
|
+
finish = resolve;
|
|
425
|
+
});
|
|
426
|
+
watcher.on("change", (_event, changedName) => {
|
|
427
|
+
if (changedName === null || changedName === name) finish();
|
|
428
|
+
});
|
|
429
|
+
watcher.on("error", (cause) => {
|
|
430
|
+
failure = cause;
|
|
431
|
+
finish();
|
|
432
|
+
});
|
|
433
|
+
const timer = setTimeout(finish, Math.max(0, deadline - Date.now()));
|
|
434
|
+
return {
|
|
435
|
+
async changed() {
|
|
436
|
+
await settled;
|
|
437
|
+
if (failure !== void 0) throw failure;
|
|
438
|
+
},
|
|
439
|
+
close() {
|
|
440
|
+
watcher.close();
|
|
441
|
+
clearTimeout(timer);
|
|
442
|
+
}
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
/**
|
|
446
|
+
* Takes the lock file `lock` and returns its token. The owner record is complete before the
|
|
447
|
+
* link makes it visible. A lock whose owner has exited is broken. A caller waits for a lock
|
|
448
|
+
* whose owner runs, or cannot be judged from this PID namespace, by watching for its removal
|
|
449
|
+
* until `deadline`; it then checks the owner once more and reports a lock that is still held,
|
|
450
|
+
* never removing it. A killed owner writes nothing, so a caller already waiting finds it
|
|
451
|
+
* dead only at the deadline.
|
|
452
|
+
*/
|
|
453
|
+
async function acquireLock(store, lock, deadline) {
|
|
454
|
+
const token = randomBytes(8).toString("hex");
|
|
455
|
+
const temporary = temporaryName(lock);
|
|
456
|
+
await writeExclusive(temporary, `${JSON.stringify({
|
|
457
|
+
token,
|
|
458
|
+
process: thisProcess()
|
|
459
|
+
})}\n`);
|
|
460
|
+
try {
|
|
461
|
+
for (;;) {
|
|
462
|
+
const wake = lockChange(lock, deadline);
|
|
463
|
+
try {
|
|
464
|
+
try {
|
|
465
|
+
await link(temporary, lock);
|
|
466
|
+
return token;
|
|
467
|
+
} catch (thrown) {
|
|
468
|
+
if (!isErrno(thrown, "EEXIST")) throw thrown;
|
|
469
|
+
}
|
|
470
|
+
const owner = await readLockOwner(store, lock);
|
|
471
|
+
if (owner === void 0) continue;
|
|
472
|
+
if (processExited(owner.process)) {
|
|
473
|
+
await breakLock(store, lock, owner, deadline);
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (Date.now() >= deadline) throw new PkaError(`${lock} is held by process ${owner.process.pid} (${owner.process.namespace}); retry later. A process in another PID namespace cannot be checked from here: remove the file only after that process has exited.`);
|
|
477
|
+
await wake.changed();
|
|
478
|
+
} finally {
|
|
479
|
+
wake.close();
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
} finally {
|
|
483
|
+
await unlink(temporary);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
/**
|
|
487
|
+
* Removes `lock` if it still names the exited owner `dead`. Breakers of one owner take turns
|
|
488
|
+
* through a lock of their own, so while one rereads and removes `lock`, no other caller can
|
|
489
|
+
* remove it: a breaker that read `dead` long ago finds the newer lock and leaves it.
|
|
490
|
+
*/
|
|
491
|
+
async function breakLock(store, lock, dead, deadline) {
|
|
492
|
+
await withLock(store, `${lock}.${dead.token}.break`, deadline, async () => {
|
|
493
|
+
if ((await readLockOwner(store, lock))?.token === dead.token) await unlink(lock);
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
async function withLock(store, lock, deadline, act) {
|
|
497
|
+
const token = await acquireLock(store, lock, deadline);
|
|
498
|
+
let result;
|
|
499
|
+
try {
|
|
500
|
+
result = await act();
|
|
501
|
+
} catch (thrown) {
|
|
502
|
+
await releaseLock(store, lock, token);
|
|
503
|
+
throw thrown;
|
|
504
|
+
}
|
|
505
|
+
await releaseLock(store, lock, token);
|
|
506
|
+
return result;
|
|
507
|
+
}
|
|
508
|
+
async function releaseLock(store, lock, token) {
|
|
509
|
+
const owner = await readLockOwner(store, lock);
|
|
510
|
+
if (owner === void 0) return;
|
|
511
|
+
if (owner.token !== token) throw new Error(`${lock} was replaced by process ${owner.process.pid} while held`);
|
|
512
|
+
await rm(lock, { force: true });
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Runs `act` while this caller alone holds the annotation's state.lock, so status, claim,
|
|
516
|
+
* reply, and attachment writes from separate processes never interleave. Read the state that
|
|
517
|
+
* `act` decides on inside it. A crashed holder's lock is removed only once its process is
|
|
518
|
+
* known to have exited.
|
|
519
|
+
*/
|
|
520
|
+
async function withAnnotationLock(store, id, act) {
|
|
521
|
+
const files = await requireAnnotation(store, id);
|
|
522
|
+
return withLock(store, resolveInside(files.dir, "state.lock"), Date.now() + LOCK_WAIT_MS, () => act(files));
|
|
523
|
+
}
|
|
524
|
+
async function readClaim(store, id) {
|
|
525
|
+
const files = annotationFiles(store, id);
|
|
526
|
+
try {
|
|
527
|
+
return await readJson(store, files.claim, Claim);
|
|
528
|
+
} catch (thrown) {
|
|
529
|
+
if (isErrno(thrown, "ENOENT")) return void 0;
|
|
530
|
+
throw thrown;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
/** Annotation ids in the store, oldest first. Symlinks and other names are skipped. */
|
|
534
|
+
async function listIds(store) {
|
|
535
|
+
return (await readdir(store, { withFileTypes: true })).filter((entry) => entry.isDirectory() && ID_PATTERN.test(entry.name)).map((entry) => entry.name).sort();
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Writes a complete annotation directory in a staging directory and renames it
|
|
539
|
+
* into place. With `staged`, that directory already holds the listed capture
|
|
540
|
+
* files and becomes the annotation directory; otherwise a new one is made.
|
|
541
|
+
*/
|
|
542
|
+
async function writeAnnotationDir(store, id, files, staged) {
|
|
543
|
+
const dir = annotationFiles(store, id).dir;
|
|
544
|
+
const staging = resolveInside(store, staged?.dir ?? `${STAGING_PREFIX}${id}`);
|
|
545
|
+
try {
|
|
546
|
+
if (staged === void 0) await mkdir(staging);
|
|
547
|
+
else for (const relative of staged.paths) {
|
|
548
|
+
const file = resolveInside(staging, relative);
|
|
549
|
+
await refuseSymlinks(store, file);
|
|
550
|
+
if (!(await lstat(file)).isFile()) throw new PkaError(`Staged capture file ${relative} is not a regular file`);
|
|
551
|
+
}
|
|
552
|
+
for (const file of files) {
|
|
553
|
+
const target = resolveInside(staging, file.path);
|
|
554
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
555
|
+
await writeExclusive(target, file.data);
|
|
556
|
+
}
|
|
557
|
+
await rename(staging, dir);
|
|
558
|
+
} catch (thrown) {
|
|
559
|
+
await rm(staging, {
|
|
560
|
+
recursive: true,
|
|
561
|
+
force: true
|
|
562
|
+
});
|
|
563
|
+
throw thrown;
|
|
564
|
+
}
|
|
565
|
+
return dir;
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* Adds files to an existing annotation directory without overwriting any:
|
|
569
|
+
* each is written to a temporary file and hard-linked into place. If one
|
|
570
|
+
* fails, the files already placed are removed. Returns the placed paths.
|
|
571
|
+
*/
|
|
572
|
+
async function placeNewFiles(store, dir, files) {
|
|
573
|
+
const placed = [];
|
|
574
|
+
try {
|
|
575
|
+
for (const file of files) {
|
|
576
|
+
const target = resolveInside(dir, file.path);
|
|
577
|
+
await refuseSymlinks(store, path.dirname(target));
|
|
578
|
+
await mkdir(path.dirname(target), { recursive: true });
|
|
579
|
+
const temporary = temporaryName(target);
|
|
580
|
+
try {
|
|
581
|
+
await writeExclusive(temporary, file.data);
|
|
582
|
+
await link(temporary, target);
|
|
583
|
+
} catch (thrown) {
|
|
584
|
+
if (isErrno(thrown, "EEXIST")) throw new PkaError(`${file.path} already exists in ${dir}`);
|
|
585
|
+
throw thrown;
|
|
586
|
+
} finally {
|
|
587
|
+
await rm(temporary, { force: true });
|
|
588
|
+
}
|
|
589
|
+
placed.push(target);
|
|
590
|
+
}
|
|
591
|
+
} catch (thrown) {
|
|
592
|
+
await Promise.all(placed.map((target) => rm(target, { force: true })));
|
|
593
|
+
throw thrown;
|
|
594
|
+
}
|
|
595
|
+
return placed;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Removes resolved and dismissed annotations whose last status change is
|
|
599
|
+
* older than seven days, and staging directories left by a crashed writer.
|
|
600
|
+
*/
|
|
601
|
+
async function prune$1(store, now) {
|
|
602
|
+
const removed = [];
|
|
603
|
+
for (const entry of await readdir(store, { withFileTypes: true })) {
|
|
604
|
+
if (entry.isDirectory() && entry.name.startsWith(STAGING_PREFIX)) {
|
|
605
|
+
const staging = resolveInside(store, entry.name);
|
|
606
|
+
if (now - (await lstat(staging)).mtimeMs > STAGING_AGE_MS) {
|
|
607
|
+
await rm(staging, {
|
|
608
|
+
recursive: true,
|
|
609
|
+
force: true
|
|
610
|
+
});
|
|
611
|
+
removed.push(entry.name);
|
|
612
|
+
}
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
if (!entry.isDirectory() || !ID_PATTERN.test(entry.name)) continue;
|
|
616
|
+
const files = annotationFiles(store, entry.name);
|
|
617
|
+
const state = await readJson(store, files.state, State);
|
|
618
|
+
const last = state.history.at(-1);
|
|
619
|
+
if (last === void 0) throw new PkaError(`${files.state} has an empty history`);
|
|
620
|
+
if ((state.status === "resolved" || state.status === "dismissed") && now - Date.parse(last.at) > PRUNE_AGE_MS) {
|
|
621
|
+
await rm(files.dir, {
|
|
622
|
+
recursive: true,
|
|
623
|
+
force: true
|
|
624
|
+
});
|
|
625
|
+
removed.push(entry.name);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
return removed;
|
|
629
|
+
}
|
|
630
|
+
/** Total size of regular files in the store; the plugin refuses new video when it is over the cap. */
|
|
631
|
+
async function checkStoreSize(store, capBytes = DEFAULT_SIZE_CAP_BYTES) {
|
|
632
|
+
let bytes = 0;
|
|
633
|
+
for (const entry of await readdir(store, {
|
|
634
|
+
withFileTypes: true,
|
|
635
|
+
recursive: true
|
|
636
|
+
})) if (entry.isFile()) bytes += (await lstat(path.join(entry.parentPath, entry.name))).size;
|
|
637
|
+
return {
|
|
638
|
+
bytes,
|
|
639
|
+
capBytes,
|
|
640
|
+
overCap: bytes > capBytes
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
//#endregion
|
|
644
|
+
//#region src/shared/agent.ts
|
|
645
|
+
/** Presence files of running pka-mcp sessions, relative to the store. */
|
|
646
|
+
const AGENTS_DIR = ["live", "agents"];
|
|
647
|
+
const AgentPresence = z.strictObject({
|
|
648
|
+
name: z.string().min(1).max(200),
|
|
649
|
+
version: z.string().max(200),
|
|
650
|
+
pid: z.number().int().positive(),
|
|
651
|
+
connectedAt: Timestamp
|
|
652
|
+
});
|
|
653
|
+
//#endregion
|
|
654
|
+
//#region src/ops/presence.ts
|
|
655
|
+
function agentsDir(store) {
|
|
656
|
+
return resolveInside(store, ...AGENTS_DIR);
|
|
657
|
+
}
|
|
658
|
+
/**
|
|
659
|
+
* True only when this process can tell that the claimant's process has exited; see
|
|
660
|
+
* processExited. A claim without a process (the CLI) is never judged.
|
|
661
|
+
*/
|
|
662
|
+
function claimantExited(claim) {
|
|
663
|
+
return claim.process !== void 0 && processExited(claim.process);
|
|
664
|
+
}
|
|
665
|
+
/** Reads the presence files and removes those whose process has exited. */
|
|
666
|
+
async function liveAgents(store) {
|
|
667
|
+
const dir = agentsDir(store);
|
|
668
|
+
let names;
|
|
669
|
+
try {
|
|
670
|
+
names = await readdir(dir);
|
|
671
|
+
} catch (cause) {
|
|
672
|
+
if (isErrno(cause, "ENOENT")) return {
|
|
673
|
+
agents: [],
|
|
674
|
+
invalid: []
|
|
675
|
+
};
|
|
676
|
+
throw cause;
|
|
677
|
+
}
|
|
678
|
+
const result = {
|
|
679
|
+
agents: [],
|
|
680
|
+
invalid: []
|
|
681
|
+
};
|
|
682
|
+
for (const name of names) {
|
|
683
|
+
if (!name.endsWith(".json")) continue;
|
|
684
|
+
const file = resolveInside(dir, name);
|
|
685
|
+
let presence;
|
|
686
|
+
try {
|
|
687
|
+
presence = await readJson(store, file, AgentPresence);
|
|
688
|
+
} catch (cause) {
|
|
689
|
+
if (isErrno(cause, "ENOENT")) continue;
|
|
690
|
+
if (!(cause instanceof PkaError)) throw cause;
|
|
691
|
+
result.invalid.push({
|
|
692
|
+
file,
|
|
693
|
+
reason: cause.message
|
|
694
|
+
});
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
if (processAlive(presence.pid)) result.agents.push(presence);
|
|
698
|
+
else await rm(file, { force: true });
|
|
699
|
+
}
|
|
700
|
+
result.agents.sort((a, b) => Date.parse(b.connectedAt) - Date.parse(a.connectedAt));
|
|
701
|
+
return result;
|
|
702
|
+
}
|
|
703
|
+
//#endregion
|
|
704
|
+
//#region src/ops/views.ts
|
|
705
|
+
const Detail = z.enum(["concise", "full"]);
|
|
706
|
+
const CAP = {
|
|
707
|
+
short: 300,
|
|
708
|
+
label: 500,
|
|
709
|
+
text: 1e3,
|
|
710
|
+
html: 4e3,
|
|
711
|
+
stack: 8e3,
|
|
712
|
+
owners: 12
|
|
713
|
+
};
|
|
714
|
+
/**
|
|
715
|
+
* UTF-8 bytes of a concise annotation or list page, so it stays well under Claude Code's
|
|
716
|
+
* 10k-token warning. detail full has no budget.
|
|
717
|
+
*/
|
|
718
|
+
const CONCISE_BYTES = 2e4;
|
|
719
|
+
function jsonBytes(value) {
|
|
720
|
+
return Buffer.byteLength(JSON.stringify(value));
|
|
721
|
+
}
|
|
722
|
+
const CONTROL = /(?![\t\n])\p{Cc}/gu;
|
|
723
|
+
const BIDI = /\p{Bidi_Control}/gu;
|
|
724
|
+
/** Strips control and bidirectional-override characters from page-derived text and caps its length. */
|
|
725
|
+
function pageText(value, max) {
|
|
726
|
+
return capped(value.replace(CONTROL, "").replace(BIDI, ""), max);
|
|
727
|
+
}
|
|
728
|
+
function capped(value, max) {
|
|
729
|
+
if (value.length <= max) return value;
|
|
730
|
+
return `${value.slice(0, max)}… [${value.length - max} more characters]`;
|
|
731
|
+
}
|
|
732
|
+
function optionalPageText(value, max) {
|
|
733
|
+
return value === void 0 ? void 0 : pageText(value, max);
|
|
734
|
+
}
|
|
735
|
+
const ElementView = z.strictObject({
|
|
736
|
+
n: z.number(),
|
|
737
|
+
source: z.string().optional(),
|
|
738
|
+
usedAt: z.string().optional(),
|
|
739
|
+
owners: z.array(z.string()),
|
|
740
|
+
selector: z.strictObject({
|
|
741
|
+
role: z.string().optional(),
|
|
742
|
+
name: z.string().optional(),
|
|
743
|
+
testId: z.string().optional(),
|
|
744
|
+
css: z.string()
|
|
745
|
+
}),
|
|
746
|
+
crop: z.string().optional(),
|
|
747
|
+
text: z.string().optional(),
|
|
748
|
+
nearbyText: z.string().optional(),
|
|
749
|
+
html: z.string().optional(),
|
|
750
|
+
box: Box.optional()
|
|
751
|
+
});
|
|
752
|
+
const AnnotationView = z.strictObject({
|
|
753
|
+
id: Id,
|
|
754
|
+
createdAt: Timestamp,
|
|
755
|
+
status: Status,
|
|
756
|
+
claimedBy: z.string().optional(),
|
|
757
|
+
dir: z.string().describe("Absolute annotation directory; attachment and crop paths are relative to it"),
|
|
758
|
+
url: z.string(),
|
|
759
|
+
route: z.string(),
|
|
760
|
+
viewport: Viewport.optional(),
|
|
761
|
+
prompt: z.string().describe("The human's request"),
|
|
762
|
+
elements: z.array(ElementView),
|
|
763
|
+
attachments: z.array(z.strictObject({
|
|
764
|
+
kind: AttachmentKind,
|
|
765
|
+
path: z.string(),
|
|
766
|
+
summary: z.string().optional()
|
|
767
|
+
})),
|
|
768
|
+
threadCount: z.number(),
|
|
769
|
+
omitted: z.strictObject({
|
|
770
|
+
elements: z.number(),
|
|
771
|
+
attachments: z.number(),
|
|
772
|
+
promptCharacters: z.number(),
|
|
773
|
+
note: z.string()
|
|
774
|
+
}).optional().describe("Elements and attachments left out of a concise response"),
|
|
775
|
+
history: z.array(StatusEvent).optional(),
|
|
776
|
+
thread: z.array(ThreadEntry).optional()
|
|
777
|
+
});
|
|
778
|
+
const ListItem = z.strictObject({
|
|
779
|
+
id: Id,
|
|
780
|
+
createdAt: Timestamp,
|
|
781
|
+
status: Status,
|
|
782
|
+
claimedBy: z.string().optional(),
|
|
783
|
+
route: z.string(),
|
|
784
|
+
prompt: z.string(),
|
|
785
|
+
elementCount: z.number(),
|
|
786
|
+
attachmentCount: z.number()
|
|
787
|
+
});
|
|
788
|
+
const ErrorGroupView = z.strictObject({
|
|
789
|
+
fingerprint: z.string(),
|
|
790
|
+
type: z.string(),
|
|
791
|
+
message: z.string(),
|
|
792
|
+
count: z.number(),
|
|
793
|
+
lastSeen: Timestamp,
|
|
794
|
+
topFrame: z.string().optional(),
|
|
795
|
+
status: ErrorGroupStatus,
|
|
796
|
+
firstSeen: Timestamp.optional(),
|
|
797
|
+
lastSeq: z.number().optional(),
|
|
798
|
+
stack: z.string().optional()
|
|
799
|
+
});
|
|
800
|
+
function elementView(element, detail) {
|
|
801
|
+
const full = detail === "full";
|
|
802
|
+
const view = {
|
|
803
|
+
n: element.n,
|
|
804
|
+
source: optionalPageText(element.source, CAP.label),
|
|
805
|
+
usedAt: optionalPageText(element.usedAt, CAP.label),
|
|
806
|
+
owners: element.owners.slice(0, CAP.owners).map((owner) => pageText(owner, CAP.short)),
|
|
807
|
+
selector: {
|
|
808
|
+
role: optionalPageText(element.selector.role, CAP.short),
|
|
809
|
+
name: optionalPageText(element.selector.name, CAP.short),
|
|
810
|
+
testId: optionalPageText(element.selector.testId, CAP.short),
|
|
811
|
+
css: pageText(element.selector.css, CAP.label)
|
|
812
|
+
},
|
|
813
|
+
crop: element.crop,
|
|
814
|
+
text: optionalPageText(element.text, full ? CAP.text : CAP.short)
|
|
815
|
+
};
|
|
816
|
+
if (full) {
|
|
817
|
+
view.nearbyText = optionalPageText(element.nearbyText, CAP.text);
|
|
818
|
+
view.html = pageText(element.html, CAP.html);
|
|
819
|
+
view.box = element.box;
|
|
820
|
+
}
|
|
821
|
+
return view;
|
|
822
|
+
}
|
|
823
|
+
function annotationView(record, detail) {
|
|
824
|
+
const { annotation, state } = record;
|
|
825
|
+
const full = detail === "full";
|
|
826
|
+
const view = {
|
|
827
|
+
id: annotation.id,
|
|
828
|
+
createdAt: annotation.createdAt,
|
|
829
|
+
status: state.status,
|
|
830
|
+
claimedBy: record.claim === void 0 || full ? record.claim?.by : capped(record.claim.by, CAP.short),
|
|
831
|
+
dir: record.dir,
|
|
832
|
+
url: pageText(annotation.url, CAP.label),
|
|
833
|
+
route: pageText(annotation.route, CAP.label),
|
|
834
|
+
prompt: annotation.prompt,
|
|
835
|
+
elements: annotation.elements.map((element) => elementView(element, detail)),
|
|
836
|
+
attachments: annotation.attachments.map((attachment) => ({
|
|
837
|
+
kind: attachment.kind,
|
|
838
|
+
path: attachment.path,
|
|
839
|
+
summary: pageText(attachment.summary, full ? CAP.text : CAP.short)
|
|
840
|
+
})),
|
|
841
|
+
threadCount: record.thread.length
|
|
842
|
+
};
|
|
843
|
+
if (full) {
|
|
844
|
+
view.viewport = annotation.viewport;
|
|
845
|
+
view.history = state.history;
|
|
846
|
+
view.thread = record.thread;
|
|
847
|
+
return view;
|
|
848
|
+
}
|
|
849
|
+
return withinBudget(view);
|
|
850
|
+
}
|
|
851
|
+
/** Most of CONCISE_BYTES that the human's prompt may take, so its elements still fit. */
|
|
852
|
+
const PROMPT_BYTES = 1e4;
|
|
853
|
+
/** The longest leading part of `prompt` whose JSON string fits PROMPT_BYTES. */
|
|
854
|
+
function promptPrefix(prompt) {
|
|
855
|
+
let length = Math.min(prompt.length, PROMPT_BYTES);
|
|
856
|
+
for (;;) {
|
|
857
|
+
if (/[\uD800-\uDBFF]/.test(prompt.charAt(length - 1))) length -= 1;
|
|
858
|
+
const bytes = Buffer.byteLength(JSON.stringify(prompt.slice(0, length)));
|
|
859
|
+
if (bytes <= PROMPT_BYTES) return prompt.slice(0, length);
|
|
860
|
+
length = Math.floor(length * PROMPT_BYTES / bytes);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Caps the prompt, then keeps the leading elements and attachments that fit CONCISE_BYTES, so
|
|
865
|
+
* `[element n]` and `[attachment n]` keep their numbers, and says how much was left out.
|
|
866
|
+
*/
|
|
867
|
+
function withinBudget(view) {
|
|
868
|
+
const { elements, attachments } = view;
|
|
869
|
+
const prompt = promptPrefix(view.prompt);
|
|
870
|
+
const promptCharacters = view.prompt.length - prompt.length;
|
|
871
|
+
const omitted = (keptElements, keptAttachments) => ({
|
|
872
|
+
elements: elements.length - keptElements,
|
|
873
|
+
attachments: attachments.length - keptAttachments,
|
|
874
|
+
promptCharacters,
|
|
875
|
+
note: OMITTED_NOTE
|
|
876
|
+
});
|
|
877
|
+
let used = jsonBytes({
|
|
878
|
+
...view,
|
|
879
|
+
prompt,
|
|
880
|
+
elements: [],
|
|
881
|
+
attachments: [],
|
|
882
|
+
omitted: omitted(0, 0)
|
|
883
|
+
});
|
|
884
|
+
const fit = (items) => {
|
|
885
|
+
const kept = [];
|
|
886
|
+
for (const item of items) {
|
|
887
|
+
const bytes = jsonBytes(item) + 1;
|
|
888
|
+
if (used + bytes > 2e4) break;
|
|
889
|
+
used += bytes;
|
|
890
|
+
kept.push(item);
|
|
891
|
+
}
|
|
892
|
+
return kept;
|
|
893
|
+
};
|
|
894
|
+
const keptElements = fit(elements);
|
|
895
|
+
const keptAttachments = fit(attachments);
|
|
896
|
+
const result = promptCharacters === 0 && keptElements.length === elements.length && keptAttachments.length === attachments.length ? view : {
|
|
897
|
+
...view,
|
|
898
|
+
prompt,
|
|
899
|
+
elements: keptElements,
|
|
900
|
+
attachments: keptAttachments,
|
|
901
|
+
omitted: omitted(keptElements.length, keptAttachments.length)
|
|
902
|
+
};
|
|
903
|
+
if (jsonBytes(result) > 2e4) throw new PkaError(`The concise view of annotation ${view.id} is over ${CONCISE_BYTES} bytes without its elements and attachments; request detail full.`);
|
|
904
|
+
return result;
|
|
905
|
+
}
|
|
906
|
+
const OMITTED_NOTE = "Left out to keep this response small. get_annotation (pka get) with detail full returns all of them.";
|
|
907
|
+
function listItem(record) {
|
|
908
|
+
const { annotation } = record;
|
|
909
|
+
return {
|
|
910
|
+
id: annotation.id,
|
|
911
|
+
createdAt: annotation.createdAt,
|
|
912
|
+
status: record.state.status,
|
|
913
|
+
claimedBy: record.claim === void 0 ? void 0 : capped(record.claim.by, CAP.short),
|
|
914
|
+
route: pageText(annotation.route, CAP.label),
|
|
915
|
+
prompt: annotation.prompt.length > CAP.short ? `${annotation.prompt.slice(0, CAP.short)}…` : annotation.prompt,
|
|
916
|
+
elementCount: annotation.elements.length,
|
|
917
|
+
attachmentCount: annotation.attachments.length
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
function errorGroupView(group, detail) {
|
|
921
|
+
const full = detail === "full";
|
|
922
|
+
const view = {
|
|
923
|
+
fingerprint: pageText(group.fingerprint, CAP.short),
|
|
924
|
+
type: pageText(group.type, CAP.short),
|
|
925
|
+
message: pageText(group.message, full ? CAP.text : CAP.short),
|
|
926
|
+
count: group.count,
|
|
927
|
+
lastSeen: group.lastSeen,
|
|
928
|
+
topFrame: optionalPageText(group.topFrame, CAP.label),
|
|
929
|
+
status: group.status
|
|
930
|
+
};
|
|
931
|
+
if (full) {
|
|
932
|
+
view.firstSeen = group.firstSeen;
|
|
933
|
+
view.lastSeq = group.lastSeq;
|
|
934
|
+
view.stack = pageText(group.stack, CAP.stack);
|
|
935
|
+
}
|
|
936
|
+
return view;
|
|
937
|
+
}
|
|
938
|
+
//#endregion
|
|
939
|
+
//#region src/ops/ops.ts
|
|
940
|
+
const PROGRESS_INTERVAL_MS = 15e3;
|
|
941
|
+
/** A claim this old on a still-pending annotation lost its claimant between the claim and the state write. */
|
|
942
|
+
const ORPHANED_CLAIM_MS = 6e4;
|
|
943
|
+
const MAX_ERROR_GROUPS = 200;
|
|
944
|
+
/** Annotations read at once by a scan; libuv runs four file system calls in parallel by default. */
|
|
945
|
+
const SCAN_BATCH = 16;
|
|
946
|
+
const detailField = Detail.default("concise").describe("concise (default) keeps the response small; full adds HTML, boxes, nearby text, summaries, history, and the thread");
|
|
947
|
+
const limitField = z.number().int().min(1).max(100).default(20);
|
|
948
|
+
const ListInput = z.strictObject({
|
|
949
|
+
status: z.enum([...Status.options, "all"]).default("pending").describe("Filter by status; pending by default"),
|
|
950
|
+
limit: limitField,
|
|
951
|
+
cursor: Id.optional().describe("nextCursor from the previous page"),
|
|
952
|
+
detail: detailField
|
|
953
|
+
});
|
|
954
|
+
const GetInput = z.strictObject({
|
|
955
|
+
id: Id,
|
|
956
|
+
detail: detailField
|
|
957
|
+
});
|
|
958
|
+
const SetStatusInput = z.strictObject({
|
|
959
|
+
id: Id,
|
|
960
|
+
status: z.enum([
|
|
961
|
+
"acknowledged",
|
|
962
|
+
"resolved",
|
|
963
|
+
"dismissed"
|
|
964
|
+
]),
|
|
965
|
+
note: z.string().min(1).max(2e3).optional().describe("What was done, or why it was dismissed")
|
|
966
|
+
});
|
|
967
|
+
const ReplyInput = z.strictObject({
|
|
968
|
+
id: Id,
|
|
969
|
+
text: z.string().min(1).max(1e4)
|
|
970
|
+
});
|
|
971
|
+
const ErrorsInput = z.strictObject({
|
|
972
|
+
since: Timestamp.optional().describe("Only groups seen at or after this ISO time"),
|
|
973
|
+
limit: limitField,
|
|
974
|
+
detail: detailField
|
|
975
|
+
});
|
|
976
|
+
z.strictObject({
|
|
977
|
+
items: z.array(z.union([ListItem, AnnotationView])),
|
|
978
|
+
nextCursor: Id.optional()
|
|
979
|
+
});
|
|
980
|
+
z.strictObject({ annotation: AnnotationView });
|
|
981
|
+
z.strictObject({
|
|
982
|
+
timedOut: z.boolean(),
|
|
983
|
+
annotation: AnnotationView.optional()
|
|
984
|
+
});
|
|
985
|
+
z.strictObject({
|
|
986
|
+
id: Id,
|
|
987
|
+
status: Status,
|
|
988
|
+
changed: z.boolean(),
|
|
989
|
+
claimedBy: z.string().optional()
|
|
990
|
+
});
|
|
991
|
+
z.strictObject({
|
|
992
|
+
id: Id,
|
|
993
|
+
entry: ThreadEntry
|
|
994
|
+
});
|
|
995
|
+
z.strictObject({
|
|
996
|
+
updatedAt: Timestamp.nullable().describe("null when the dev server has not recorded errors yet"),
|
|
997
|
+
total: z.number().describe("Open groups that matched before the limit"),
|
|
998
|
+
groups: z.array(ErrorGroupView)
|
|
999
|
+
});
|
|
1000
|
+
/** Reads one annotation with its state, claim, and thread. The Vite plugin uses it to push changes. */
|
|
1001
|
+
async function loadAnnotation(store, id) {
|
|
1002
|
+
const files = await requireAnnotation(store, id);
|
|
1003
|
+
const [annotation, state, claim, thread] = await Promise.all([
|
|
1004
|
+
readJson(store, files.annotation, Annotation),
|
|
1005
|
+
readJson(store, files.state, State),
|
|
1006
|
+
readClaim(store, id),
|
|
1007
|
+
readJsonLines(store, files.thread, ThreadEntry)
|
|
1008
|
+
]);
|
|
1009
|
+
if (annotation.id !== id) throw new PkaError(`${files.annotation} has id ${annotation.id}, expected ${id}`);
|
|
1010
|
+
return {
|
|
1011
|
+
dir: files.dir,
|
|
1012
|
+
annotation,
|
|
1013
|
+
state,
|
|
1014
|
+
claim,
|
|
1015
|
+
thread
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Reads the parts of an annotation that change after it is written: its state and reply
|
|
1020
|
+
* thread. The Vite plugin uses it to push changes without rereading annotation.json.
|
|
1021
|
+
*/
|
|
1022
|
+
async function loadAnnotationUpdates(store, id) {
|
|
1023
|
+
const files = await requireAnnotation(store, id);
|
|
1024
|
+
const [state, thread] = await Promise.all([readJson(store, files.state, State), readJsonLines(store, files.thread, ThreadEntry)]);
|
|
1025
|
+
return {
|
|
1026
|
+
dir: files.dir,
|
|
1027
|
+
state,
|
|
1028
|
+
thread
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
/** Like loadAnnotation, but an annotation removed between listing and reading (prune, rm) is absent. */
|
|
1032
|
+
async function loadListed(store, id) {
|
|
1033
|
+
try {
|
|
1034
|
+
return await loadAnnotation(store, id);
|
|
1035
|
+
} catch (thrown) {
|
|
1036
|
+
if (thrown instanceof MissingAnnotationError || isErrno(thrown, "ENOENT")) return void 0;
|
|
1037
|
+
throw thrown;
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
/** An annotation removed after listing (prune, rm) has no state. */
|
|
1041
|
+
async function readListedState(store, id) {
|
|
1042
|
+
try {
|
|
1043
|
+
return await readJson(store, annotationFiles(store, id).state, State);
|
|
1044
|
+
} catch (thrown) {
|
|
1045
|
+
if (isErrno(thrown, "ENOENT")) return void 0;
|
|
1046
|
+
throw thrown;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Returns up to `want` of `ids` that `matches` accepts, in order. Ids are checked in
|
|
1051
|
+
* batches, so a store of resolved annotations costs one small state read per id.
|
|
1052
|
+
*/
|
|
1053
|
+
async function firstMatching(ids, want, matches) {
|
|
1054
|
+
const found = [];
|
|
1055
|
+
for (let start = 0; start < ids.length && found.length < want; start += SCAN_BATCH) {
|
|
1056
|
+
const batch = ids.slice(start, start + SCAN_BATCH);
|
|
1057
|
+
const accepted = await Promise.all(batch.map(matches));
|
|
1058
|
+
found.push(...batch.filter((_, index) => accepted[index]));
|
|
1059
|
+
}
|
|
1060
|
+
return found.slice(0, want);
|
|
1061
|
+
}
|
|
1062
|
+
async function list(store, input) {
|
|
1063
|
+
const ids = (await listIds(store)).filter((id) => input.cursor === void 0 || id > input.cursor);
|
|
1064
|
+
const { status } = input;
|
|
1065
|
+
const matched = status === "all" ? ids.slice(0, input.limit + 1) : await firstMatching(ids, input.limit + 1, async (id) => (await readListedState(store, id))?.status === status);
|
|
1066
|
+
const page = matched.slice(0, input.limit);
|
|
1067
|
+
const items = [];
|
|
1068
|
+
let bytes = jsonBytes({
|
|
1069
|
+
items: [],
|
|
1070
|
+
nextCursor: page.at(-1)
|
|
1071
|
+
});
|
|
1072
|
+
for (let start = 0; start < page.length; start += SCAN_BATCH) {
|
|
1073
|
+
const records = await Promise.all(page.slice(start, start + SCAN_BATCH).map((id) => loadListed(store, id)));
|
|
1074
|
+
for (const record of records) {
|
|
1075
|
+
if (record === void 0 || status !== "all" && record.state.status !== status) continue;
|
|
1076
|
+
if (input.detail === "full") {
|
|
1077
|
+
items.push(annotationView(record, "full"));
|
|
1078
|
+
continue;
|
|
1079
|
+
}
|
|
1080
|
+
const item = listItem(record);
|
|
1081
|
+
bytes += jsonBytes(item) + 1;
|
|
1082
|
+
const last = items.at(-1);
|
|
1083
|
+
if (last !== void 0 && bytes > 2e4) return {
|
|
1084
|
+
items,
|
|
1085
|
+
nextCursor: last.id
|
|
1086
|
+
};
|
|
1087
|
+
items.push(item);
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
return {
|
|
1091
|
+
items,
|
|
1092
|
+
nextCursor: matched.length > input.limit ? page.at(-1) : void 0
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
async function get(store, input) {
|
|
1096
|
+
return { annotation: annotationView(await loadAnnotation(store, input.id), input.detail) };
|
|
1097
|
+
}
|
|
1098
|
+
/**
|
|
1099
|
+
* An open annotation's claim is orphaned when its claimant's process has exited, or, when
|
|
1100
|
+
* that cannot be told, when it is old and the annotation is still pending.
|
|
1101
|
+
*/
|
|
1102
|
+
function isOrphaned(state, claim, now) {
|
|
1103
|
+
if (isClosed(state.status)) return false;
|
|
1104
|
+
if (claimantExited(claim)) return true;
|
|
1105
|
+
return state.status === "pending" && now - Date.parse(claim.at) > ORPHANED_CLAIM_MS;
|
|
1106
|
+
}
|
|
1107
|
+
function isOffered(state, claim) {
|
|
1108
|
+
if (claim === void 0) return state.status === "pending";
|
|
1109
|
+
return isOrphaned(state, claim, Date.now());
|
|
1110
|
+
}
|
|
1111
|
+
async function oldestOffered(store, skip) {
|
|
1112
|
+
const ids = (await listIds(store)).filter((id) => !skip.has(id));
|
|
1113
|
+
const offered = async (id) => {
|
|
1114
|
+
const state = await readListedState(store, id);
|
|
1115
|
+
return state !== void 0 && !isClosed(state.status) && isOffered(state, await readClaim(store, id));
|
|
1116
|
+
};
|
|
1117
|
+
for (let start = 0; start < ids.length; start += SCAN_BATCH) for (const id of await firstMatching(ids.slice(start, start + SCAN_BATCH), SCAN_BATCH, offered)) {
|
|
1118
|
+
const record = await loadListed(store, id);
|
|
1119
|
+
if (record !== void 0 && isOffered(record.state, record.claim)) return record;
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
/**
|
|
1123
|
+
* Returns the oldest offered annotation (pending and unclaimed, or open with an orphaned
|
|
1124
|
+
* claim) at once if there is one.
|
|
1125
|
+
* Otherwise holds one fs.watch on the store until one appears, the timeout
|
|
1126
|
+
* elapses, or the signal aborts. The watcher and timers are released on every
|
|
1127
|
+
* outcome.
|
|
1128
|
+
*/
|
|
1129
|
+
function wait(store, options) {
|
|
1130
|
+
const { signal } = options;
|
|
1131
|
+
signal?.throwIfAborted();
|
|
1132
|
+
return new Promise((resolve, reject) => {
|
|
1133
|
+
const started = Date.now();
|
|
1134
|
+
let settled = false;
|
|
1135
|
+
let scanning = false;
|
|
1136
|
+
let rescan = false;
|
|
1137
|
+
const watcher = watch(store, { persistent: true }, (_event, name) => {
|
|
1138
|
+
if (name === null || ID_PATTERN.test(name)) scan();
|
|
1139
|
+
});
|
|
1140
|
+
const timeout = options.timeoutMs === void 0 ? void 0 : setTimeout(() => settle({ timedOut: true }, void 0), options.timeoutMs);
|
|
1141
|
+
const { onProgress } = options;
|
|
1142
|
+
const progress = onProgress === void 0 ? void 0 : setInterval(() => onProgress(Date.now() - started), PROGRESS_INTERVAL_MS);
|
|
1143
|
+
const onAbort = () => settle(void 0, signal?.reason);
|
|
1144
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
1145
|
+
watcher.on("error", (cause) => settle(void 0, cause));
|
|
1146
|
+
function settle(result, cause) {
|
|
1147
|
+
if (settled) return;
|
|
1148
|
+
settled = true;
|
|
1149
|
+
watcher.close();
|
|
1150
|
+
clearTimeout(timeout);
|
|
1151
|
+
clearInterval(progress);
|
|
1152
|
+
signal?.removeEventListener("abort", onAbort);
|
|
1153
|
+
if (result === void 0) reject(cause);
|
|
1154
|
+
else resolve(result);
|
|
1155
|
+
}
|
|
1156
|
+
async function scan() {
|
|
1157
|
+
if (scanning) {
|
|
1158
|
+
rescan = true;
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
scanning = true;
|
|
1162
|
+
try {
|
|
1163
|
+
do {
|
|
1164
|
+
rescan = false;
|
|
1165
|
+
const record = await oldestOffered(store, options.skip);
|
|
1166
|
+
if (settled) return;
|
|
1167
|
+
if (record !== void 0) {
|
|
1168
|
+
settle({
|
|
1169
|
+
timedOut: false,
|
|
1170
|
+
annotation: annotationView(record, "concise")
|
|
1171
|
+
}, void 0);
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
} while (rescan && !settled);
|
|
1175
|
+
} catch (cause) {
|
|
1176
|
+
settle(void 0, cause);
|
|
1177
|
+
} finally {
|
|
1178
|
+
scanning = false;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
scan();
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
function isClosed(status) {
|
|
1185
|
+
return status === "resolved" || status === "dismissed";
|
|
1186
|
+
}
|
|
1187
|
+
function closedError(id, status) {
|
|
1188
|
+
return new PkaError(`Annotation ${id} is ${status}; reply before set_status ${status}.`);
|
|
1189
|
+
}
|
|
1190
|
+
function claimedError(id, claim) {
|
|
1191
|
+
return new PkaError(claimantExited(claim) ? `Annotation ${id} was claimed by ${claim.by} at ${claim.at}, whose session has exited. Call set_status acknowledged to take it over, then retry.` : `Annotation ${id} was claimed by ${claim.by} at ${claim.at}. Pick another pending annotation.`);
|
|
1192
|
+
}
|
|
1193
|
+
function requireClaimant(id, claim, by) {
|
|
1194
|
+
if (claim !== void 0 && claim.by !== by) throw claimedError(id, claim);
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* `owner` identifies a long-lived claimant's process, so its claim can be taken over once it
|
|
1198
|
+
* exits. The decision and the write happen under the annotation lock, so a competing change
|
|
1199
|
+
* from another process is either seen here or sees this one.
|
|
1200
|
+
*/
|
|
1201
|
+
async function setStatus(store, input, by, owner) {
|
|
1202
|
+
return withAnnotationLock(store, input.id, async (files) => {
|
|
1203
|
+
const state = await readJson(store, files.state, State);
|
|
1204
|
+
if (isClosed(state.status)) {
|
|
1205
|
+
if (state.status === input.status) return {
|
|
1206
|
+
id: input.id,
|
|
1207
|
+
status: state.status,
|
|
1208
|
+
changed: false
|
|
1209
|
+
};
|
|
1210
|
+
throw closedError(input.id, state.status);
|
|
1211
|
+
}
|
|
1212
|
+
let claim = await readClaim(store, input.id);
|
|
1213
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
1214
|
+
if (input.status === "acknowledged") {
|
|
1215
|
+
const mine = owner === void 0 ? {
|
|
1216
|
+
by,
|
|
1217
|
+
at
|
|
1218
|
+
} : {
|
|
1219
|
+
by,
|
|
1220
|
+
at,
|
|
1221
|
+
process: owner
|
|
1222
|
+
};
|
|
1223
|
+
let tookOver = false;
|
|
1224
|
+
if (claim === void 0) {
|
|
1225
|
+
if (state.status !== "pending") throw new PkaError(`Annotation ${input.id} is ${state.status}. Pick another pending annotation.`);
|
|
1226
|
+
claim = (await createClaim(store, input.id, mine)).claim;
|
|
1227
|
+
} else if (claim.by !== by && isOrphaned(state, claim, Date.parse(at))) {
|
|
1228
|
+
await writeClaim(store, input.id, mine);
|
|
1229
|
+
claim = mine;
|
|
1230
|
+
tookOver = true;
|
|
1231
|
+
}
|
|
1232
|
+
requireClaimant(input.id, claim, by);
|
|
1233
|
+
if (state.status !== "pending" && !tookOver) return {
|
|
1234
|
+
id: input.id,
|
|
1235
|
+
status: state.status,
|
|
1236
|
+
changed: false,
|
|
1237
|
+
claimedBy: by
|
|
1238
|
+
};
|
|
1239
|
+
} else requireClaimant(input.id, claim, by);
|
|
1240
|
+
const event = {
|
|
1241
|
+
status: input.status,
|
|
1242
|
+
at,
|
|
1243
|
+
by
|
|
1244
|
+
};
|
|
1245
|
+
if (input.note !== void 0) event.note = input.note;
|
|
1246
|
+
await writeJsonAtomic(store, files.state, {
|
|
1247
|
+
status: input.status,
|
|
1248
|
+
history: [...state.history, event]
|
|
1249
|
+
});
|
|
1250
|
+
return {
|
|
1251
|
+
id: input.id,
|
|
1252
|
+
status: input.status,
|
|
1253
|
+
changed: true,
|
|
1254
|
+
claimedBy: input.status === "acknowledged" ? by : void 0
|
|
1255
|
+
};
|
|
1256
|
+
});
|
|
1257
|
+
}
|
|
1258
|
+
async function reply(store, input, by) {
|
|
1259
|
+
return withAnnotationLock(store, input.id, async (files) => {
|
|
1260
|
+
const state = await readJson(store, files.state, State);
|
|
1261
|
+
if (isClosed(state.status)) throw closedError(input.id, state.status);
|
|
1262
|
+
requireClaimant(input.id, await readClaim(store, input.id), by);
|
|
1263
|
+
const entry = {
|
|
1264
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1265
|
+
from: "agent",
|
|
1266
|
+
text: input.text
|
|
1267
|
+
};
|
|
1268
|
+
await appendJsonLine(store, files.thread, entry);
|
|
1269
|
+
return {
|
|
1270
|
+
id: input.id,
|
|
1271
|
+
entry
|
|
1272
|
+
};
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
async function readErrors(store) {
|
|
1276
|
+
try {
|
|
1277
|
+
return await readJson(store, liveErrorsFile(store), LiveErrorsSnapshot);
|
|
1278
|
+
} catch (thrown) {
|
|
1279
|
+
if (isErrno(thrown, "ENOENT")) return void 0;
|
|
1280
|
+
throw thrown;
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
async function errors(store, input) {
|
|
1284
|
+
const snapshot = await readErrors(store);
|
|
1285
|
+
if (snapshot === void 0) return {
|
|
1286
|
+
updatedAt: null,
|
|
1287
|
+
total: 0,
|
|
1288
|
+
groups: []
|
|
1289
|
+
};
|
|
1290
|
+
const since = input.since === void 0 ? void 0 : Date.parse(input.since);
|
|
1291
|
+
const open = snapshot.groups.filter((group) => group.status === "open" && (since === void 0 || Date.parse(group.lastSeen) >= since)).sort((a, b) => Date.parse(b.lastSeen) - Date.parse(a.lastSeen));
|
|
1292
|
+
return {
|
|
1293
|
+
updatedAt: snapshot.updatedAt,
|
|
1294
|
+
total: open.length,
|
|
1295
|
+
groups: open.slice(0, input.limit).map((group) => errorGroupView(group, input.detail))
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
/** Merges new or changed groups into live/errors.json by fingerprint. Used by the Vite plugin. */
|
|
1299
|
+
async function upsertErrorGroups(store, groups) {
|
|
1300
|
+
const merged = new Map(((await readErrors(store))?.groups ?? []).map((group) => [group.fingerprint, group]));
|
|
1301
|
+
for (const group of groups) merged.set(group.fingerprint, group);
|
|
1302
|
+
const snapshot = {
|
|
1303
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1304
|
+
groups: [...merged.values()].sort((a, b) => Date.parse(b.lastSeen) - Date.parse(a.lastSeen)).slice(0, MAX_ERROR_GROUPS)
|
|
1305
|
+
};
|
|
1306
|
+
await writeJsonAtomic(store, liveErrorsFile(store), snapshot);
|
|
1307
|
+
return snapshot;
|
|
1308
|
+
}
|
|
1309
|
+
/**
|
|
1310
|
+
* Checks the capture files an annotation declares: each under capture/, listed
|
|
1311
|
+
* once, and every attachment and crop path among them.
|
|
1312
|
+
*/
|
|
1313
|
+
function checkCaptureFiles(draft, paths) {
|
|
1314
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1315
|
+
for (const file of paths) {
|
|
1316
|
+
if (!file.startsWith("capture/")) throw new PkaError(`Capture file ${file} must be under capture/`);
|
|
1317
|
+
if (seen.has(file)) throw new PkaError(`Capture file ${file} is listed twice`);
|
|
1318
|
+
seen.add(file);
|
|
1319
|
+
}
|
|
1320
|
+
const missing = [...draft.attachments.map((attachment) => attachment.path), ...draft.elements.flatMap((element) => element.crop === void 0 ? [] : [element.crop])].filter((reference) => !seen.has(reference));
|
|
1321
|
+
if (missing.length > 0) throw new PkaError(`Annotation references files that were not sent: ${missing.join(", ")}`);
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Writes a new pending annotation. Capture files are staged on disk by the
|
|
1325
|
+
* Vite plugin and move into place with the annotation in one directory rename.
|
|
1326
|
+
*/
|
|
1327
|
+
async function create(store, draft, staged) {
|
|
1328
|
+
checkCaptureFiles(draft, staged?.paths ?? []);
|
|
1329
|
+
const now = /* @__PURE__ */ new Date();
|
|
1330
|
+
const id = newId(now.getTime());
|
|
1331
|
+
const annotation = Annotation.parse({
|
|
1332
|
+
id,
|
|
1333
|
+
createdAt: now.toISOString(),
|
|
1334
|
+
...draft
|
|
1335
|
+
});
|
|
1336
|
+
const state = {
|
|
1337
|
+
status: "pending",
|
|
1338
|
+
history: [{
|
|
1339
|
+
status: "pending",
|
|
1340
|
+
at: now.toISOString()
|
|
1341
|
+
}]
|
|
1342
|
+
};
|
|
1343
|
+
await writeAnnotationDir(store, id, [{
|
|
1344
|
+
path: "annotation.json",
|
|
1345
|
+
data: `${JSON.stringify(annotation, null, 2)}\n`
|
|
1346
|
+
}, {
|
|
1347
|
+
path: "state.json",
|
|
1348
|
+
data: `${JSON.stringify(state, null, 2)}\n`
|
|
1349
|
+
}], staged);
|
|
1350
|
+
return { id };
|
|
1351
|
+
}
|
|
1352
|
+
async function checkAttachTarget(store, files, id, by) {
|
|
1353
|
+
const state = await readJson(store, files.state, State);
|
|
1354
|
+
if (isClosed(state.status)) throw closedError(id, state.status);
|
|
1355
|
+
requireClaimant(id, await readClaim(store, id), by);
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Fails as attach would when the annotation is missing, closed, or claimed by another
|
|
1359
|
+
* claimant, without writing. Check before expensive work; attach checks again because the
|
|
1360
|
+
* annotation can change in between.
|
|
1361
|
+
*/
|
|
1362
|
+
async function validateAttachTarget(store, id, by) {
|
|
1363
|
+
await checkAttachTarget(store, await requireAnnotation(store, id), id, by);
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Adds files to an existing annotation's capture/ directory and lists them in
|
|
1367
|
+
* annotation.json. An agent write: resolved and dismissed annotations refuse
|
|
1368
|
+
* it. Existing files are never overwritten; on any failure no new file stays
|
|
1369
|
+
* behind and annotation.json is unchanged.
|
|
1370
|
+
*/
|
|
1371
|
+
async function attach(store, id, files, by) {
|
|
1372
|
+
const added = files.map((file) => {
|
|
1373
|
+
const parsed = Attachment.safeParse({
|
|
1374
|
+
kind: file.kind,
|
|
1375
|
+
path: file.path,
|
|
1376
|
+
summary: file.summary
|
|
1377
|
+
});
|
|
1378
|
+
if (!parsed.success) throw new PkaError(`Invalid attachment ${file.path}:\n${z.prettifyError(parsed.error)}`);
|
|
1379
|
+
return parsed.data;
|
|
1380
|
+
});
|
|
1381
|
+
return withAnnotationLock(store, id, async (paths) => {
|
|
1382
|
+
await checkAttachTarget(store, paths, id, by);
|
|
1383
|
+
const annotation = await readJson(store, paths.annotation, Annotation);
|
|
1384
|
+
checkCaptureFiles({
|
|
1385
|
+
...annotation,
|
|
1386
|
+
elements: [],
|
|
1387
|
+
attachments: added
|
|
1388
|
+
}, added.map((attachment) => attachment.path));
|
|
1389
|
+
const listed = new Set(annotation.attachments.map((attachment) => attachment.path));
|
|
1390
|
+
const relisted = added.filter((attachment) => listed.has(attachment.path));
|
|
1391
|
+
if (relisted.length > 0) throw new PkaError(`Annotation ${id} already lists ${relisted.map((a) => a.path).join(", ")}`);
|
|
1392
|
+
const placed = await placeNewFiles(store, paths.dir, files.map((file) => ({
|
|
1393
|
+
path: file.path,
|
|
1394
|
+
data: file.data
|
|
1395
|
+
})));
|
|
1396
|
+
const next = {
|
|
1397
|
+
...annotation,
|
|
1398
|
+
attachments: [...annotation.attachments, ...added]
|
|
1399
|
+
};
|
|
1400
|
+
try {
|
|
1401
|
+
await writeJsonAtomic(store, paths.annotation, next);
|
|
1402
|
+
} catch (thrown) {
|
|
1403
|
+
await Promise.all(placed.map((file) => rm(file, { force: true })));
|
|
1404
|
+
throw thrown;
|
|
1405
|
+
}
|
|
1406
|
+
return {
|
|
1407
|
+
id,
|
|
1408
|
+
attachments: next.attachments
|
|
1409
|
+
};
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
async function prune(store) {
|
|
1413
|
+
return { removed: await prune$1(store, Date.now()) };
|
|
1414
|
+
}
|
|
1415
|
+
//#endregion
|
|
1416
|
+
//#region src/shared/timeline.ts
|
|
1417
|
+
const Seq$1 = z.number().int().nonnegative();
|
|
1418
|
+
const ConsoleEntry = z.strictObject({
|
|
1419
|
+
kind: z.literal("console"),
|
|
1420
|
+
seq: Seq$1,
|
|
1421
|
+
at: Timestamp,
|
|
1422
|
+
level: z.enum([
|
|
1423
|
+
"log",
|
|
1424
|
+
"info",
|
|
1425
|
+
"warn",
|
|
1426
|
+
"error",
|
|
1427
|
+
"debug"
|
|
1428
|
+
]),
|
|
1429
|
+
args: z.array(z.json())
|
|
1430
|
+
});
|
|
1431
|
+
const ErrorSource = z.enum([
|
|
1432
|
+
"window",
|
|
1433
|
+
"resource",
|
|
1434
|
+
"rejection",
|
|
1435
|
+
"console",
|
|
1436
|
+
"react-caught",
|
|
1437
|
+
"react-uncaught",
|
|
1438
|
+
"react-recoverable"
|
|
1439
|
+
]);
|
|
1440
|
+
const ErrorEntry = z.strictObject({
|
|
1441
|
+
kind: z.literal("error"),
|
|
1442
|
+
seq: Seq$1,
|
|
1443
|
+
at: Timestamp,
|
|
1444
|
+
source: ErrorSource,
|
|
1445
|
+
fingerprint: z.string().min(1).max(200),
|
|
1446
|
+
type: z.string(),
|
|
1447
|
+
message: z.string(),
|
|
1448
|
+
stack: z.string().optional(),
|
|
1449
|
+
componentStack: z.string().optional(),
|
|
1450
|
+
ownerStack: z.string().optional(),
|
|
1451
|
+
resource: z.strictObject({
|
|
1452
|
+
tag: z.string(),
|
|
1453
|
+
url: z.string()
|
|
1454
|
+
}).optional()
|
|
1455
|
+
});
|
|
1456
|
+
const ServerTiming = z.strictObject({
|
|
1457
|
+
name: z.string(),
|
|
1458
|
+
duration: z.number(),
|
|
1459
|
+
description: z.string()
|
|
1460
|
+
});
|
|
1461
|
+
const RequestEntry = z.strictObject({
|
|
1462
|
+
kind: z.literal("request"),
|
|
1463
|
+
seq: Seq$1,
|
|
1464
|
+
at: Timestamp,
|
|
1465
|
+
performanceMs: z.number().nonnegative().optional(),
|
|
1466
|
+
initiator: z.enum(["fetch", "xhr"]),
|
|
1467
|
+
method: z.string(),
|
|
1468
|
+
url: z.string(),
|
|
1469
|
+
state: z.enum([
|
|
1470
|
+
"pending",
|
|
1471
|
+
"open",
|
|
1472
|
+
"done",
|
|
1473
|
+
"failed",
|
|
1474
|
+
"aborted"
|
|
1475
|
+
]),
|
|
1476
|
+
status: z.number().int().nonnegative().optional(),
|
|
1477
|
+
error: z.string().optional(),
|
|
1478
|
+
durationMs: z.number().nonnegative().optional(),
|
|
1479
|
+
requestSize: z.number().int().nonnegative().optional(),
|
|
1480
|
+
responseSize: z.number().int().nonnegative().optional(),
|
|
1481
|
+
transferSize: z.number().int().nonnegative().optional(),
|
|
1482
|
+
responseType: z.string().optional(),
|
|
1483
|
+
contentType: z.string().optional(),
|
|
1484
|
+
stream: z.boolean(),
|
|
1485
|
+
serverFn: z.boolean(),
|
|
1486
|
+
traceparent: z.string().optional(),
|
|
1487
|
+
requestHeaders: z.record(z.string(), z.string()),
|
|
1488
|
+
responseHeaders: z.record(z.string(), z.string()),
|
|
1489
|
+
requestBody: z.string().optional(),
|
|
1490
|
+
responseBody: z.string().optional(),
|
|
1491
|
+
serverTiming: z.array(ServerTiming).optional()
|
|
1492
|
+
});
|
|
1493
|
+
const ActionTarget = z.strictObject({
|
|
1494
|
+
tag: z.string(),
|
|
1495
|
+
id: z.string().optional(),
|
|
1496
|
+
testId: z.string().optional(),
|
|
1497
|
+
src: z.string().optional(),
|
|
1498
|
+
role: z.string().optional(),
|
|
1499
|
+
text: z.string().optional(),
|
|
1500
|
+
label: z.string().optional()
|
|
1501
|
+
});
|
|
1502
|
+
const ActionEntry = z.strictObject({
|
|
1503
|
+
kind: z.literal("action"),
|
|
1504
|
+
seq: Seq$1,
|
|
1505
|
+
at: Timestamp,
|
|
1506
|
+
performanceMs: z.number().nonnegative().optional(),
|
|
1507
|
+
durationMs: z.number().nonnegative().optional(),
|
|
1508
|
+
type: z.enum([
|
|
1509
|
+
"click",
|
|
1510
|
+
"input",
|
|
1511
|
+
"change",
|
|
1512
|
+
"submit",
|
|
1513
|
+
"keydown"
|
|
1514
|
+
]),
|
|
1515
|
+
key: z.enum(["Enter", "Escape"]).optional(),
|
|
1516
|
+
target: ActionTarget
|
|
1517
|
+
});
|
|
1518
|
+
const NavigationEntry = z.strictObject({
|
|
1519
|
+
kind: z.literal("navigation"),
|
|
1520
|
+
seq: Seq$1,
|
|
1521
|
+
at: Timestamp,
|
|
1522
|
+
type: z.enum([
|
|
1523
|
+
"load",
|
|
1524
|
+
"push",
|
|
1525
|
+
"replace",
|
|
1526
|
+
"reload",
|
|
1527
|
+
"traverse"
|
|
1528
|
+
]),
|
|
1529
|
+
from: z.string().optional(),
|
|
1530
|
+
to: z.string()
|
|
1531
|
+
});
|
|
1532
|
+
const TimelineEntry = z.discriminatedUnion("kind", [
|
|
1533
|
+
ConsoleEntry,
|
|
1534
|
+
ErrorEntry,
|
|
1535
|
+
RequestEntry,
|
|
1536
|
+
ActionEntry,
|
|
1537
|
+
NavigationEntry
|
|
1538
|
+
]);
|
|
1539
|
+
//#endregion
|
|
1540
|
+
//#region src/shared/recording.ts
|
|
1541
|
+
const Seq = z.number().int().nonnegative();
|
|
1542
|
+
const Count = z.number().int().nonnegative();
|
|
1543
|
+
const RecordingFrame = z.strictObject({
|
|
1544
|
+
path: RelativePath,
|
|
1545
|
+
seq: Seq,
|
|
1546
|
+
at: Timestamp
|
|
1547
|
+
});
|
|
1548
|
+
const RecordingVideo = z.union([z.strictObject({
|
|
1549
|
+
path: RelativePath,
|
|
1550
|
+
mimeType: z.string(),
|
|
1551
|
+
bytes: Count,
|
|
1552
|
+
startedAt: Timestamp,
|
|
1553
|
+
endedAt: Timestamp,
|
|
1554
|
+
restrictedTo: z.literal("body"),
|
|
1555
|
+
truncated: z.boolean()
|
|
1556
|
+
}), z.strictObject({
|
|
1557
|
+
path: z.null(),
|
|
1558
|
+
reason: z.string()
|
|
1559
|
+
})]);
|
|
1560
|
+
const RecordingManifestDraft = z.strictObject({
|
|
1561
|
+
url: z.string(),
|
|
1562
|
+
endUrl: z.string(),
|
|
1563
|
+
viewport: Viewport,
|
|
1564
|
+
startedAt: Timestamp,
|
|
1565
|
+
endedAt: Timestamp,
|
|
1566
|
+
seq: z.strictObject({
|
|
1567
|
+
first: Seq,
|
|
1568
|
+
last: Seq
|
|
1569
|
+
}).nullable(),
|
|
1570
|
+
entries: z.strictObject({
|
|
1571
|
+
recorded: Count,
|
|
1572
|
+
limit: Count,
|
|
1573
|
+
dropped: Count
|
|
1574
|
+
}),
|
|
1575
|
+
bodies: z.strictObject({
|
|
1576
|
+
limitBytes: Count,
|
|
1577
|
+
dropped: Count
|
|
1578
|
+
}),
|
|
1579
|
+
frames: z.strictObject({
|
|
1580
|
+
items: z.array(RecordingFrame),
|
|
1581
|
+
limit: Count,
|
|
1582
|
+
dropped: Count,
|
|
1583
|
+
failed: Count
|
|
1584
|
+
}),
|
|
1585
|
+
video: RecordingVideo,
|
|
1586
|
+
region: Box.nullable().optional(),
|
|
1587
|
+
gif: z.strictObject({
|
|
1588
|
+
path: RelativePath,
|
|
1589
|
+
width: Count,
|
|
1590
|
+
height: Count,
|
|
1591
|
+
frames: Count,
|
|
1592
|
+
durationMs: Count,
|
|
1593
|
+
truncated: z.boolean()
|
|
1594
|
+
}).optional(),
|
|
1595
|
+
redaction: z.strictObject({
|
|
1596
|
+
inputs: z.literal("values never recorded; masked in keyframes"),
|
|
1597
|
+
headers: z.literal("credential headers dropped"),
|
|
1598
|
+
urlParams: z.literal("credential-like values replaced with REDACTED"),
|
|
1599
|
+
bodies: z.array(z.string()),
|
|
1600
|
+
storage: z.literal("not read"),
|
|
1601
|
+
video: z.literal("not redacted; shows what the tab showed, typed values included")
|
|
1602
|
+
})
|
|
1603
|
+
});
|
|
1604
|
+
const RecordingManifest = RecordingManifestDraft.extend({ gitSha: z.string().regex(/^[0-9a-f]{40}(?:[0-9a-f]{24})?$/).nullable() });
|
|
1605
|
+
const RecordingErrors = z.strictObject({ groups: z.array(ErrorGroup) });
|
|
1606
|
+
const Header = z.strictObject({
|
|
1607
|
+
name: z.string(),
|
|
1608
|
+
value: z.string()
|
|
1609
|
+
});
|
|
1610
|
+
RequestEntry.pick({
|
|
1611
|
+
seq: true,
|
|
1612
|
+
state: true,
|
|
1613
|
+
initiator: true,
|
|
1614
|
+
error: true,
|
|
1615
|
+
stream: true,
|
|
1616
|
+
serverFn: true,
|
|
1617
|
+
traceparent: true,
|
|
1618
|
+
serverTiming: true
|
|
1619
|
+
}).extend({
|
|
1620
|
+
startedDateTime: Timestamp,
|
|
1621
|
+
time: z.number().nonnegative().nullable(),
|
|
1622
|
+
request: z.strictObject({
|
|
1623
|
+
method: z.string(),
|
|
1624
|
+
url: z.string(),
|
|
1625
|
+
headers: z.array(Header),
|
|
1626
|
+
bodySize: z.number().int().min(-1),
|
|
1627
|
+
postData: z.strictObject({
|
|
1628
|
+
mimeType: z.string(),
|
|
1629
|
+
text: z.string()
|
|
1630
|
+
}).optional()
|
|
1631
|
+
}),
|
|
1632
|
+
response: z.strictObject({
|
|
1633
|
+
status: Count,
|
|
1634
|
+
headers: z.array(Header),
|
|
1635
|
+
content: z.strictObject({
|
|
1636
|
+
size: z.number().int().min(-1),
|
|
1637
|
+
mimeType: z.string(),
|
|
1638
|
+
text: z.string().optional()
|
|
1639
|
+
}),
|
|
1640
|
+
transferSize: z.number().int().min(-1)
|
|
1641
|
+
})
|
|
1642
|
+
});
|
|
1643
|
+
//#endregion
|
|
1644
|
+
export { createStore as A, Id as B, agentsDir as C, MissingAnnotationError as D, DEFAULT_SIZE_CAP_BYTES as E, readJsonLines as F, State as H, resolveInside as I, AnnotationDraft as L, isErrno as M, listIds as N, PkaError as O, readJson as P, ErrorGroup as R, wait as S, AgentPresence as T, ThreadEntry as U, RelativePath as V, Timestamp as W, prune as _, ErrorsInput as a, upsertErrorGroups as b, ReplyInput as c, checkCaptureFiles as d, create as f, loadAnnotationUpdates as g, list as h, TimelineEntry as i, findStore as j, checkStoreSize as k, SetStatusInput as l, get as m, RecordingManifest as n, GetInput as o, errors as p, RecordingManifestDraft as r, ListInput as s, RecordingErrors as t, attach as u, reply as v, liveAgents as w, validateAttachTarget as x, setStatus as y, ID_PATTERN as z };
|