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
package/dist/vite.mjs
ADDED
|
@@ -0,0 +1,729 @@
|
|
|
1
|
+
import { A as createStore, B as Id, C as agentsDir, D as MissingAnnotationError, H as State, I as resolveInside, L as AnnotationDraft, M as isErrno, N as listIds, O as PkaError, R as ErrorGroup, T as AgentPresence, U as ThreadEntry, V as RelativePath, b as upsertErrorGroups, d as checkCaptureFiles, f as create, g as loadAnnotationUpdates, k as checkStoreSize, r as RecordingManifestDraft, t as RecordingErrors, w as liveAgents, z as ID_PATTERN } from "./recording-CpEDddkZ.mjs";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { randomBytes } from "node:crypto";
|
|
4
|
+
import { constants, watch } from "node:fs";
|
|
5
|
+
import { lstat, mkdir, open, readFile, readdir, realpath, rm, stat } from "node:fs/promises";
|
|
6
|
+
import path, { posix } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { promisify } from "node:util";
|
|
9
|
+
import { Visitor, normalizePath, parseSync, searchForWorkspaceRoot } from "vite";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import MagicString from "magic-string";
|
|
12
|
+
import { TraceMap, originalPositionFor } from "@jridgewell/trace-mapping";
|
|
13
|
+
//#region src/shared/channel.ts
|
|
14
|
+
const CHANNEL = {
|
|
15
|
+
create: "pka:create",
|
|
16
|
+
file: "pka:file",
|
|
17
|
+
errors: "pka:errors",
|
|
18
|
+
created: "pka:created",
|
|
19
|
+
createFailed: "pka:create-failed",
|
|
20
|
+
state: "pka:state",
|
|
21
|
+
thread: "pka:thread",
|
|
22
|
+
errorsAck: "pka:errors-ack",
|
|
23
|
+
sync: "pka:sync",
|
|
24
|
+
synced: "pka:synced",
|
|
25
|
+
symbolicate: "pka:symbolicate",
|
|
26
|
+
symbolicated: "pka:symbolicated",
|
|
27
|
+
presence: "pka:presence",
|
|
28
|
+
agent: "pka:agent",
|
|
29
|
+
setup: "pka:setup",
|
|
30
|
+
setupInfo: "pka:setup-info"
|
|
31
|
+
};
|
|
32
|
+
const MAX_CHUNK_BYTES = 524288;
|
|
33
|
+
const CreateMessage = z.strictObject({
|
|
34
|
+
requestId: Id,
|
|
35
|
+
draft: AnnotationDraft,
|
|
36
|
+
files: z.array(z.strictObject({
|
|
37
|
+
path: RelativePath,
|
|
38
|
+
bytes: z.number().int().nonnegative().max(1073741824)
|
|
39
|
+
})).max(500)
|
|
40
|
+
});
|
|
41
|
+
const FileChunkMessage = z.strictObject({
|
|
42
|
+
requestId: Id,
|
|
43
|
+
path: RelativePath,
|
|
44
|
+
offset: z.number().int().nonnegative(),
|
|
45
|
+
data: z.base64().max(Math.ceil(MAX_CHUNK_BYTES / 3) * 4)
|
|
46
|
+
});
|
|
47
|
+
const ErrorsMessage = z.strictObject({ groups: z.array(ErrorGroup).min(1).max(50) });
|
|
48
|
+
const SyncMessage = z.strictObject({ ids: z.array(Id).max(200) });
|
|
49
|
+
const SymbolicateMessage = z.strictObject({
|
|
50
|
+
requestId: Id,
|
|
51
|
+
stacks: z.array(z.string().max(16100)).max(150)
|
|
52
|
+
});
|
|
53
|
+
const PresenceMessage = z.strictObject({});
|
|
54
|
+
const SetupMessage = z.strictObject({ requestId: Id });
|
|
55
|
+
z.strictObject({
|
|
56
|
+
requestId: Id,
|
|
57
|
+
id: Id,
|
|
58
|
+
dir: z.string().min(1).max(4096)
|
|
59
|
+
});
|
|
60
|
+
z.strictObject({
|
|
61
|
+
requestId: Id,
|
|
62
|
+
message: z.string()
|
|
63
|
+
});
|
|
64
|
+
z.strictObject({
|
|
65
|
+
id: Id,
|
|
66
|
+
state: State
|
|
67
|
+
});
|
|
68
|
+
z.strictObject({
|
|
69
|
+
id: Id,
|
|
70
|
+
entry: ThreadEntry
|
|
71
|
+
});
|
|
72
|
+
z.strictObject({ groups: z.array(z.strictObject({
|
|
73
|
+
fingerprint: z.string().min(1).max(200),
|
|
74
|
+
stack: z.string(),
|
|
75
|
+
topFrame: z.string().optional()
|
|
76
|
+
})) });
|
|
77
|
+
z.strictObject({
|
|
78
|
+
requestId: Id,
|
|
79
|
+
stacks: z.array(z.string())
|
|
80
|
+
});
|
|
81
|
+
z.strictObject({ annotations: z.array(z.strictObject({
|
|
82
|
+
id: Id,
|
|
83
|
+
state: State,
|
|
84
|
+
thread: z.array(ThreadEntry)
|
|
85
|
+
})) });
|
|
86
|
+
z.strictObject({
|
|
87
|
+
requestId: Id,
|
|
88
|
+
root: z.string().min(1),
|
|
89
|
+
store: z.string().min(1),
|
|
90
|
+
command: z.array(z.string().min(1)).min(1).nullable()
|
|
91
|
+
});
|
|
92
|
+
z.strictObject({
|
|
93
|
+
agent: AgentPresence.pick({
|
|
94
|
+
name: true,
|
|
95
|
+
version: true,
|
|
96
|
+
connectedAt: true
|
|
97
|
+
}).nullable(),
|
|
98
|
+
cause: z.enum(["presence", "change"])
|
|
99
|
+
});
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/vite/source.ts
|
|
102
|
+
const ATTRIBUTE = "data-pka-src";
|
|
103
|
+
function isHostElement(name) {
|
|
104
|
+
return name.type === "JSXIdentifier" && /^[a-z]/.test(name.name);
|
|
105
|
+
}
|
|
106
|
+
function lineStarts(code) {
|
|
107
|
+
const starts = [0];
|
|
108
|
+
for (let index = 0; index < code.length; index++) if (code.charCodeAt(index) === 10) starts.push(index + 1);
|
|
109
|
+
return starts;
|
|
110
|
+
}
|
|
111
|
+
function location(starts, offset) {
|
|
112
|
+
let low = 0;
|
|
113
|
+
let high = starts.length - 1;
|
|
114
|
+
while (low < high) {
|
|
115
|
+
const middle = low + high + 1 >> 1;
|
|
116
|
+
if ((starts[middle] ?? Infinity) <= offset) low = middle;
|
|
117
|
+
else high = middle - 1;
|
|
118
|
+
}
|
|
119
|
+
return `${low + 1}:${offset - (starts[low] ?? 0) + 1}`;
|
|
120
|
+
}
|
|
121
|
+
/** Adds data-pka-src="<relativePath>:<line>:<column>" (1-based) to every host JSX element. */
|
|
122
|
+
function stampSource(code, file, relativePath) {
|
|
123
|
+
const result = parseSync(file, code, { sourceType: "module" });
|
|
124
|
+
if (result.errors.length > 0) return null;
|
|
125
|
+
const starts = lineStarts(code);
|
|
126
|
+
const output = new MagicString(code);
|
|
127
|
+
const escaped = relativePath.replaceAll("&", "&").replaceAll("\"", """);
|
|
128
|
+
new Visitor({ JSXOpeningElement(node) {
|
|
129
|
+
if (!isHostElement(node.name)) return;
|
|
130
|
+
if (node.attributes.some((attribute) => attribute.type === "JSXAttribute" && attribute.name.type === "JSXIdentifier" && attribute.name.name === ATTRIBUTE)) return;
|
|
131
|
+
output.appendLeft(node.name.end, ` ${ATTRIBUTE}="${escaped}:${location(starts, node.start)}"`);
|
|
132
|
+
} }).visit(result.program);
|
|
133
|
+
if (!output.hasChanged()) return null;
|
|
134
|
+
return {
|
|
135
|
+
code: output.toString(),
|
|
136
|
+
map: output.generateMap({ hires: "boundary" })
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Runs before every other transform (hook order "pre") in every environment,
|
|
141
|
+
* so client, route-split client chunks (?tsr-split=…), and SSR stamp the
|
|
142
|
+
* untouched source and hydrate with identical attributes.
|
|
143
|
+
*/
|
|
144
|
+
function sourcePlugin() {
|
|
145
|
+
let workspaceRoot = "";
|
|
146
|
+
return {
|
|
147
|
+
name: "pk-annotator:source",
|
|
148
|
+
apply: "serve",
|
|
149
|
+
enforce: "pre",
|
|
150
|
+
configResolved(config) {
|
|
151
|
+
workspaceRoot = normalizePath(searchForWorkspaceRoot(config.root));
|
|
152
|
+
},
|
|
153
|
+
transform: {
|
|
154
|
+
order: "pre",
|
|
155
|
+
filter: { id: {
|
|
156
|
+
include: /\.[jt]sx(?:\?|$)/,
|
|
157
|
+
exclude: /\/node_modules\//
|
|
158
|
+
} },
|
|
159
|
+
handler(code, id) {
|
|
160
|
+
const file = id.split("?", 1)[0] ?? id;
|
|
161
|
+
if (!file.startsWith(`${workspaceRoot}/`)) return null;
|
|
162
|
+
return stampSource(code, file, posix.relative(workspaceRoot, file));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
//#endregion
|
|
168
|
+
//#region src/vite/symbolicate.ts
|
|
169
|
+
const FRAME = /^(?<head>.*?)(?<url>https?:\/\/[^\s()]+?):(?<line>\d+):(?<column>\d+)(?<tail>\)?\s*)$/;
|
|
170
|
+
const VENDOR = /\/node_modules\/|\/\.vite\/deps\/|\/@vite\//;
|
|
171
|
+
const traceMaps = /* @__PURE__ */ new WeakMap();
|
|
172
|
+
async function traceMapFor(environment, url) {
|
|
173
|
+
const module = await environment.moduleGraph.getModuleByUrl(url);
|
|
174
|
+
const result = module?.transformResult;
|
|
175
|
+
if (module?.file == null || result == null || result.map == null) return void 0;
|
|
176
|
+
if (result.map.mappings === "") return void 0;
|
|
177
|
+
let map = traceMaps.get(result);
|
|
178
|
+
if (map === void 0) {
|
|
179
|
+
map = new TraceMap(JSON.stringify(result.map), module.file);
|
|
180
|
+
traceMaps.set(result, map);
|
|
181
|
+
}
|
|
182
|
+
return map;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Maps every frame of a browser stack whose URL is a module in the client
|
|
186
|
+
* module graph back to its original file, line, and column, relative to the
|
|
187
|
+
* workspace root. Dependency and Vite client frames stay as they are.
|
|
188
|
+
*/
|
|
189
|
+
async function symbolicate(environment, workspaceRoot, stack) {
|
|
190
|
+
const base = environment.config.base;
|
|
191
|
+
let topFrame;
|
|
192
|
+
const lines = [];
|
|
193
|
+
for (const line of stack.split("\n")) {
|
|
194
|
+
const frame = FRAME.exec(line)?.groups;
|
|
195
|
+
if (frame === void 0) {
|
|
196
|
+
lines.push(line);
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
const { head = "", url = "", tail = "" } = frame;
|
|
200
|
+
const parsed = URL.parse(url);
|
|
201
|
+
const requested = parsed === null ? "" : `${parsed.pathname}${parsed.search}`;
|
|
202
|
+
if (!requested.startsWith(base) || VENDOR.test(requested)) {
|
|
203
|
+
lines.push(line);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const map = await traceMapFor(environment, `/${requested.slice(base.length)}`);
|
|
207
|
+
const original = map === void 0 ? void 0 : originalPositionFor(map, {
|
|
208
|
+
line: Number(frame["line"]),
|
|
209
|
+
column: Number(frame["column"]) - 1
|
|
210
|
+
});
|
|
211
|
+
if (original?.source == null) {
|
|
212
|
+
lines.push(line);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const resolved = original.source.startsWith("file://") ? new URL(original.source).pathname : original.source;
|
|
216
|
+
const source = resolved.split("?", 1)[0] ?? resolved;
|
|
217
|
+
const relative = path.relative(workspaceRoot, source);
|
|
218
|
+
const inWorkspace = !relative.startsWith("..") && !path.isAbsolute(relative);
|
|
219
|
+
const file = inWorkspace ? relative.split(path.sep).join("/") : source;
|
|
220
|
+
lines.push(`${head}${file}:${original.line}:${original.column + 1}${tail}`);
|
|
221
|
+
if (topFrame === void 0 && inWorkspace && !VENDOR.test(`/${file}`)) topFrame = `${file}:${original.line}`;
|
|
222
|
+
}
|
|
223
|
+
return {
|
|
224
|
+
stack: lines.join("\n"),
|
|
225
|
+
topFrame
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
//#endregion
|
|
229
|
+
//#region src/vite/index.ts
|
|
230
|
+
const AnnotatorOptions = z.strictObject({
|
|
231
|
+
bodies: z.array(z.string().startsWith("/")).optional(),
|
|
232
|
+
maxStoreBytes: z.number().int().positive().optional(),
|
|
233
|
+
storeRoot: z.string().min(1).optional()
|
|
234
|
+
});
|
|
235
|
+
/**
|
|
236
|
+
* The overlay reads `bodies` from this global. Vite's client sets every
|
|
237
|
+
* `define` key on globalThis in dev, so it reaches a pre-bundled overlay too.
|
|
238
|
+
*/
|
|
239
|
+
const BODIES_GLOBAL = "__PKA_BODIES__";
|
|
240
|
+
const STAGING_DIR = ".staging";
|
|
241
|
+
const MB = 1048576;
|
|
242
|
+
const MAX_RECORDING_JSON_BYTES = 4 * MB;
|
|
243
|
+
const execFileAsync = promisify(execFile);
|
|
244
|
+
/** A staging directory named <pid>-<hex> belongs to a running process unless that pid is gone. */
|
|
245
|
+
function ownerAlive(name) {
|
|
246
|
+
const pid = Number(name.split("-", 1)[0]);
|
|
247
|
+
if (!Number.isSafeInteger(pid) || pid <= 0) return false;
|
|
248
|
+
try {
|
|
249
|
+
process.kill(pid, 0);
|
|
250
|
+
return true;
|
|
251
|
+
} catch (cause) {
|
|
252
|
+
return !isErrno(cause, "ESRCH");
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
function describeError(cause) {
|
|
256
|
+
return cause instanceof Error ? cause.message : String(cause);
|
|
257
|
+
}
|
|
258
|
+
async function writeChunk(file, offset, data) {
|
|
259
|
+
const handle = await open(file, constants.O_WRONLY | constants.O_NOFOLLOW);
|
|
260
|
+
try {
|
|
261
|
+
const { bytesWritten } = await handle.write(data, 0, data.length, offset);
|
|
262
|
+
if (bytesWritten !== data.length) throw new Error(`Short write to ${file}: ${bytesWritten} of ${data.length} bytes`);
|
|
263
|
+
} finally {
|
|
264
|
+
await handle.close();
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
async function createEmpty(file) {
|
|
268
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
269
|
+
await (await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 420)).close();
|
|
270
|
+
}
|
|
271
|
+
async function rewrite(file, text) {
|
|
272
|
+
const handle = await open(file, constants.O_WRONLY | constants.O_TRUNC | constants.O_NOFOLLOW);
|
|
273
|
+
try {
|
|
274
|
+
await handle.writeFile(text);
|
|
275
|
+
} finally {
|
|
276
|
+
await handle.close();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/** HEAD of the workspace when the annotation is stored, so the SHA matches the code it was taken against. */
|
|
280
|
+
async function headSha(cwd) {
|
|
281
|
+
try {
|
|
282
|
+
const { stdout } = await execFileAsync("git", [
|
|
283
|
+
"rev-parse",
|
|
284
|
+
"--verify",
|
|
285
|
+
"HEAD"
|
|
286
|
+
], {
|
|
287
|
+
cwd,
|
|
288
|
+
timeout: 5e3
|
|
289
|
+
});
|
|
290
|
+
return stdout.trim();
|
|
291
|
+
} catch (cause) {
|
|
292
|
+
const stderr = cause instanceof Error && "stderr" in cause ? String(cause.stderr) : "";
|
|
293
|
+
if (isErrno(cause, "ENOENT") || /not a git repository|Needed a single revision/.test(stderr)) return null;
|
|
294
|
+
throw new Error(`git rev-parse HEAD failed in ${cwd}: ${describeError(cause)}`, { cause });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* The command that launches pka-mcp: the workspace's bin, which survives version bumps, else
|
|
299
|
+
* this package's own build, beside the built plugin or, for this repository's fixture, which
|
|
300
|
+
* loads the plugin from src/vite, in dist. Null when none exists.
|
|
301
|
+
*/
|
|
302
|
+
async function mcpCommand(workspaceRoot) {
|
|
303
|
+
const bin = path.join(workspaceRoot, "node_modules/.bin/pka-mcp");
|
|
304
|
+
const builds = ["./pka-mcp.mjs", "../../dist/pka-mcp.mjs"].map((relative) => fileURLToPath(new URL(relative, import.meta.url)));
|
|
305
|
+
for (const [file, command] of [[bin, [normalizePath(bin)]], ...builds.map((build) => [build, ["node", normalizePath(build)]])]) if (await stat(file).catch((cause) => {
|
|
306
|
+
if (isErrno(cause, "ENOENT")) return void 0;
|
|
307
|
+
throw cause;
|
|
308
|
+
}) !== void 0) return [...command];
|
|
309
|
+
return null;
|
|
310
|
+
}
|
|
311
|
+
/** Opens the store, serves the overlay's channel events, and pushes store changes. Returns the closer. */
|
|
312
|
+
async function serve(server, maxStoreBytes, storeRoot) {
|
|
313
|
+
const workspaceRoot = normalizePath(searchForWorkspaceRoot(server.config.root));
|
|
314
|
+
let projectRoot = workspaceRoot;
|
|
315
|
+
if (storeRoot !== void 0) {
|
|
316
|
+
projectRoot = normalizePath(path.resolve(server.config.root, storeRoot));
|
|
317
|
+
if (!(await stat(projectRoot).catch((cause) => {
|
|
318
|
+
if (isErrno(cause, "ENOENT")) return void 0;
|
|
319
|
+
throw cause;
|
|
320
|
+
}))?.isDirectory()) throw new PkaError(`storeRoot ${projectRoot} is not an existing directory`);
|
|
321
|
+
}
|
|
322
|
+
const store = await createStore(projectRoot);
|
|
323
|
+
const fromWorkspace = path.relative(await realpath(workspaceRoot), store);
|
|
324
|
+
const storeDisplay = fromWorkspace.startsWith("..") || path.isAbsolute(fromWorkspace) ? normalizePath(store) : normalizePath(fromWorkspace);
|
|
325
|
+
const stagingRoot = resolveInside(store, STAGING_DIR);
|
|
326
|
+
const serverStaging = resolveInside(stagingRoot, `${process.pid}-${randomBytes(4).toString("hex")}`);
|
|
327
|
+
const environment = server.environments.client;
|
|
328
|
+
const { hot } = environment;
|
|
329
|
+
const { logger } = server.config;
|
|
330
|
+
const uploads = /* @__PURE__ */ new Map();
|
|
331
|
+
const seen = /* @__PURE__ */ new Map();
|
|
332
|
+
const listeners = [];
|
|
333
|
+
let errorsQueue = Promise.resolve();
|
|
334
|
+
let pushQueue = Promise.resolve();
|
|
335
|
+
const changed = /* @__PURE__ */ new Set();
|
|
336
|
+
let flushScheduled = false;
|
|
337
|
+
let closed = false;
|
|
338
|
+
/** Pending and acknowledged annotations only; resolved and dismissed ones are not watched. */
|
|
339
|
+
const annotationWatchers = /* @__PURE__ */ new Map();
|
|
340
|
+
const warn = (line) => logger.warn(`pk-annotator: ${line}`, { timestamp: true });
|
|
341
|
+
const error = (line) => logger.error(`pk-annotator: ${line}`, { timestamp: true });
|
|
342
|
+
/** Removes this server's abandoned requests and the staging directories of exited processes. */
|
|
343
|
+
async function removeStaleStaging() {
|
|
344
|
+
for (const name of await readdir(serverStaging)) if (!uploads.has(name)) await rm(resolveInside(serverStaging, name), {
|
|
345
|
+
recursive: true,
|
|
346
|
+
force: true
|
|
347
|
+
});
|
|
348
|
+
for (const name of await readdir(stagingRoot)) if (!ownerAlive(name)) await rm(resolveInside(stagingRoot, name), {
|
|
349
|
+
recursive: true,
|
|
350
|
+
force: true
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
async function abort(upload, cause) {
|
|
354
|
+
upload.done = true;
|
|
355
|
+
uploads.delete(upload.requestId);
|
|
356
|
+
await rm(upload.dir, {
|
|
357
|
+
recursive: true,
|
|
358
|
+
force: true
|
|
359
|
+
});
|
|
360
|
+
if (!(cause instanceof PkaError)) error(`storing annotation ${upload.requestId} failed: ${describeError(cause)}`);
|
|
361
|
+
upload.client.send(CHANNEL.createFailed, {
|
|
362
|
+
requestId: upload.requestId,
|
|
363
|
+
message: describeError(cause)
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
function enqueue(upload, step) {
|
|
367
|
+
upload.queue = upload.queue.then(async () => {
|
|
368
|
+
if (upload.done) return;
|
|
369
|
+
try {
|
|
370
|
+
await step();
|
|
371
|
+
} catch (thrown) {
|
|
372
|
+
await abort(upload, thrown);
|
|
373
|
+
}
|
|
374
|
+
}).catch((cause) => error(`cleaning up ${upload.requestId} failed: ${describeError(cause)}`));
|
|
375
|
+
}
|
|
376
|
+
/** Replaces the page's raw stack and top frame with source positions. */
|
|
377
|
+
async function symbolicateGroup(group) {
|
|
378
|
+
const { topFrame: _pageTopFrame, ...rest } = group;
|
|
379
|
+
const result = await symbolicate(environment, workspaceRoot, group.stack);
|
|
380
|
+
return result.topFrame === void 0 ? {
|
|
381
|
+
...rest,
|
|
382
|
+
stack: result.stack
|
|
383
|
+
} : {
|
|
384
|
+
...rest,
|
|
385
|
+
stack: result.stack,
|
|
386
|
+
topFrame: result.topFrame
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
async function readStagedJson(upload, relative, schema) {
|
|
390
|
+
const bytes = upload.files.get(relative)?.bytes ?? 0;
|
|
391
|
+
if (bytes > MAX_RECORDING_JSON_BYTES) throw new PkaError(`${relative} is ${Math.round(bytes / MB)} MB; the limit is 4 MB`);
|
|
392
|
+
const text = await readFile(resolveInside(upload.dir, relative), "utf8");
|
|
393
|
+
let value;
|
|
394
|
+
try {
|
|
395
|
+
value = JSON.parse(text);
|
|
396
|
+
} catch (cause) {
|
|
397
|
+
throw new PkaError(`${relative} is not JSON: ${describeError(cause)}`);
|
|
398
|
+
}
|
|
399
|
+
const parsed = schema.safeParse(value);
|
|
400
|
+
if (!parsed.success) {
|
|
401
|
+
const issue = parsed.error.issues[0];
|
|
402
|
+
throw new PkaError(`${relative} does not match its schema: ${issue === void 0 ? "invalid" : `${issue.path.join(".")}: ${issue.message}`}`);
|
|
403
|
+
}
|
|
404
|
+
return parsed.data;
|
|
405
|
+
}
|
|
406
|
+
/** Stamps the git SHA into a recording's manifest and symbolicates its error groups. */
|
|
407
|
+
async function completeRecording(upload, directory) {
|
|
408
|
+
const manifestPath = path.posix.join(directory, "manifest.json");
|
|
409
|
+
const errorsPath = path.posix.join(directory, "errors.json");
|
|
410
|
+
const manifest = {
|
|
411
|
+
...await readStagedJson(upload, manifestPath, RecordingManifestDraft),
|
|
412
|
+
gitSha: await headSha(workspaceRoot)
|
|
413
|
+
};
|
|
414
|
+
await rewrite(resolveInside(upload.dir, manifestPath), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
415
|
+
if (!upload.files.has(errorsPath)) return;
|
|
416
|
+
const errors = await readStagedJson(upload, errorsPath, RecordingErrors);
|
|
417
|
+
const groups = await Promise.all(errors.groups.map(symbolicateGroup));
|
|
418
|
+
await rewrite(resolveInside(upload.dir, errorsPath), `${JSON.stringify({ groups }, null, 2)}\n`);
|
|
419
|
+
}
|
|
420
|
+
async function finish(upload) {
|
|
421
|
+
const recordingDirectories = new Set(upload.draft.attachments.filter((attachment) => attachment.kind === "recording").map((attachment) => path.posix.dirname(attachment.path)));
|
|
422
|
+
for (const directory of recordingDirectories) await completeRecording(upload, directory);
|
|
423
|
+
const { id } = await create(store, upload.draft, {
|
|
424
|
+
dir: upload.dir,
|
|
425
|
+
paths: [...upload.files.keys()]
|
|
426
|
+
});
|
|
427
|
+
upload.done = true;
|
|
428
|
+
uploads.delete(upload.requestId);
|
|
429
|
+
seen.set(id, {
|
|
430
|
+
history: 1,
|
|
431
|
+
thread: 0
|
|
432
|
+
});
|
|
433
|
+
upload.client.send(CHANNEL.created, {
|
|
434
|
+
requestId: upload.requestId,
|
|
435
|
+
id,
|
|
436
|
+
dir: path.posix.join(storeDisplay, id)
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
async function prepare(upload, declaredBytes) {
|
|
440
|
+
if ([...upload.files.keys()].some((file) => file.endsWith(".webm") || file.endsWith(".gif")) || upload.draft.attachments.some((attachment) => attachment.kind === "video")) {
|
|
441
|
+
const size = await checkStoreSize(store, maxStoreBytes);
|
|
442
|
+
if (size.bytes + declaredBytes > maxStoreBytes) throw new PkaError(`The annotation store holds ${Math.round(size.bytes / MB)} MB; this ${Math.round(declaredBytes / MB)} MB recording would pass the ${Math.round(maxStoreBytes / MB)} MB cap. Run pka prune or remove old annotations, or send it without video.`);
|
|
443
|
+
}
|
|
444
|
+
await removeStaleStaging();
|
|
445
|
+
await mkdir(upload.dir);
|
|
446
|
+
for (const file of upload.files.keys()) await createEmpty(resolveInside(upload.dir, file));
|
|
447
|
+
}
|
|
448
|
+
function listen(event, schema, handle) {
|
|
449
|
+
const listener = (data, client) => {
|
|
450
|
+
const parsed = schema.safeParse(data);
|
|
451
|
+
if (!parsed.success) {
|
|
452
|
+
const issue = parsed.error.issues[0];
|
|
453
|
+
warn(`dropped ${event}: ${issue === void 0 ? "invalid" : `${issue.path.join(".") || "payload"}: ${issue.message}`}` + (parsed.error.issues.length > 1 ? ` (+${parsed.error.issues.length - 1} more)` : ""));
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
handle(parsed.data, client).catch((cause) => error(`${event} failed: ${describeError(cause)}`));
|
|
457
|
+
};
|
|
458
|
+
hot.on(event, listener);
|
|
459
|
+
listeners.push([event, listener]);
|
|
460
|
+
}
|
|
461
|
+
listen(CHANNEL.create, CreateMessage, async (message, client) => {
|
|
462
|
+
const { requestId } = message;
|
|
463
|
+
const refuse = (reason) => client.send(CHANNEL.createFailed, {
|
|
464
|
+
requestId,
|
|
465
|
+
message: reason
|
|
466
|
+
});
|
|
467
|
+
if (uploads.has(requestId)) {
|
|
468
|
+
refuse(`Request ${requestId} is already in progress`);
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
checkCaptureFiles(message.draft, message.files.map((file) => file.path));
|
|
473
|
+
} catch (thrown) {
|
|
474
|
+
if (!(thrown instanceof PkaError)) throw thrown;
|
|
475
|
+
refuse(thrown.message);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
const declaredBytes = message.files.reduce((total, file) => total + file.bytes, 0);
|
|
479
|
+
const upload = {
|
|
480
|
+
requestId,
|
|
481
|
+
client,
|
|
482
|
+
dir: resolveInside(serverStaging, requestId),
|
|
483
|
+
draft: message.draft,
|
|
484
|
+
files: new Map(message.files.map((file) => [file.path, {
|
|
485
|
+
bytes: file.bytes,
|
|
486
|
+
received: 0
|
|
487
|
+
}])),
|
|
488
|
+
remaining: declaredBytes,
|
|
489
|
+
done: false,
|
|
490
|
+
queue: Promise.resolve()
|
|
491
|
+
};
|
|
492
|
+
uploads.set(requestId, upload);
|
|
493
|
+
enqueue(upload, () => prepare(upload, declaredBytes));
|
|
494
|
+
if (declaredBytes === 0) enqueue(upload, () => finish(upload));
|
|
495
|
+
});
|
|
496
|
+
listen(CHANNEL.file, FileChunkMessage, async (message, client) => {
|
|
497
|
+
const upload = uploads.get(message.requestId);
|
|
498
|
+
if (upload === void 0 || upload.client !== client) {
|
|
499
|
+
warn(`dropped ${CHANNEL.file}: no upload ${message.requestId} in progress for this page`);
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const file = upload.files.get(message.path);
|
|
503
|
+
const data = Buffer.from(message.data, "base64");
|
|
504
|
+
if (file === void 0 || message.offset !== file.received || file.received + data.length > file.bytes) {
|
|
505
|
+
const reason = new PkaError(file === void 0 ? `Chunk for ${message.path}, which the request did not declare` : `Chunk for ${message.path} at offset ${message.offset} does not continue the ${file.received} of ${file.bytes} bytes received`);
|
|
506
|
+
enqueue(upload, () => Promise.reject(reason));
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
file.received += data.length;
|
|
510
|
+
upload.remaining -= data.length;
|
|
511
|
+
const target = resolveInside(upload.dir, message.path);
|
|
512
|
+
enqueue(upload, () => writeChunk(target, message.offset, data));
|
|
513
|
+
if (upload.remaining === 0) enqueue(upload, () => finish(upload));
|
|
514
|
+
});
|
|
515
|
+
listen(CHANNEL.errors, ErrorsMessage, async (message, client) => {
|
|
516
|
+
const groups = await Promise.all(message.groups.map(symbolicateGroup));
|
|
517
|
+
const write = errorsQueue.then(() => upsertErrorGroups(store, groups));
|
|
518
|
+
errorsQueue = write.then(() => void 0, () => void 0);
|
|
519
|
+
await write;
|
|
520
|
+
const ack = { groups: groups.map((group) => group.topFrame === void 0 ? {
|
|
521
|
+
fingerprint: group.fingerprint,
|
|
522
|
+
stack: group.stack
|
|
523
|
+
} : {
|
|
524
|
+
fingerprint: group.fingerprint,
|
|
525
|
+
stack: group.stack,
|
|
526
|
+
topFrame: group.topFrame
|
|
527
|
+
}) };
|
|
528
|
+
client.send(CHANNEL.errorsAck, ack);
|
|
529
|
+
});
|
|
530
|
+
listen(CHANNEL.symbolicate, SymbolicateMessage, async (message, client) => {
|
|
531
|
+
const stacks = await Promise.all(message.stacks.map(async (stack) => (await symbolicate(environment, workspaceRoot, stack)).stack));
|
|
532
|
+
const reply = {
|
|
533
|
+
requestId: message.requestId,
|
|
534
|
+
stacks
|
|
535
|
+
};
|
|
536
|
+
client.send(CHANNEL.symbolicated, reply);
|
|
537
|
+
});
|
|
538
|
+
listen(CHANNEL.setup, SetupMessage, async (message, client) => {
|
|
539
|
+
const reply = {
|
|
540
|
+
requestId: message.requestId,
|
|
541
|
+
root: projectRoot,
|
|
542
|
+
store: normalizePath(store),
|
|
543
|
+
command: await mcpCommand(workspaceRoot)
|
|
544
|
+
};
|
|
545
|
+
client.send(CHANNEL.setupInfo, reply);
|
|
546
|
+
});
|
|
547
|
+
listen(CHANNEL.sync, SyncMessage, async (message, client) => {
|
|
548
|
+
const synced = { annotations: [] };
|
|
549
|
+
for (const id of message.ids) try {
|
|
550
|
+
const record = await loadAnnotationUpdates(store, id);
|
|
551
|
+
synced.annotations.push({
|
|
552
|
+
id,
|
|
553
|
+
state: record.state,
|
|
554
|
+
thread: record.thread
|
|
555
|
+
});
|
|
556
|
+
} catch (thrown) {
|
|
557
|
+
if (!(thrown instanceof MissingAnnotationError)) throw thrown;
|
|
558
|
+
}
|
|
559
|
+
client.send(CHANNEL.synced, synced);
|
|
560
|
+
});
|
|
561
|
+
const onDisconnect = (_data, client) => {
|
|
562
|
+
for (const upload of uploads.values()) if (upload.client === client) enqueue(upload, () => Promise.reject(new PkaError("The page disconnected before the upload finished")));
|
|
563
|
+
};
|
|
564
|
+
hot.on("vite:client:disconnect", onDisconnect);
|
|
565
|
+
listeners.push(["vite:client:disconnect", onDisconnect]);
|
|
566
|
+
let agentPushed = "";
|
|
567
|
+
let agentQueue = Promise.resolve();
|
|
568
|
+
let agentScheduled = false;
|
|
569
|
+
async function readAgent() {
|
|
570
|
+
const { agents, invalid } = await liveAgents(store);
|
|
571
|
+
for (const { file, reason } of invalid) warn(`ignored agent presence ${file}: ${reason}`);
|
|
572
|
+
const latest = agents[0];
|
|
573
|
+
return latest === void 0 ? null : {
|
|
574
|
+
name: latest.name,
|
|
575
|
+
version: latest.version,
|
|
576
|
+
connectedAt: latest.connectedAt
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
/** Broadcasts the active agent when it differs from the last broadcast. */
|
|
580
|
+
function pushAgent(agent) {
|
|
581
|
+
const text = JSON.stringify(agent);
|
|
582
|
+
if (text === agentPushed || closed) return;
|
|
583
|
+
agentPushed = text;
|
|
584
|
+
const message = {
|
|
585
|
+
agent,
|
|
586
|
+
cause: "change"
|
|
587
|
+
};
|
|
588
|
+
hot.send(CHANNEL.agent, message);
|
|
589
|
+
}
|
|
590
|
+
function refreshAgent() {
|
|
591
|
+
agentScheduled = false;
|
|
592
|
+
agentQueue = agentQueue.then(async () => pushAgent(await readAgent())).catch((cause) => error(`reading agent presence failed: ${describeError(cause)}`));
|
|
593
|
+
}
|
|
594
|
+
listen(CHANNEL.presence, PresenceMessage, async (_message, client) => {
|
|
595
|
+
const answered = agentQueue.then(async () => {
|
|
596
|
+
const agent = await readAgent();
|
|
597
|
+
pushAgent(agent);
|
|
598
|
+
if (agent !== null) {
|
|
599
|
+
const message = {
|
|
600
|
+
agent,
|
|
601
|
+
cause: "presence"
|
|
602
|
+
};
|
|
603
|
+
client.send(CHANNEL.agent, message);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
agentQueue = answered.catch(() => void 0);
|
|
607
|
+
await answered;
|
|
608
|
+
});
|
|
609
|
+
function schedule(id) {
|
|
610
|
+
changed.add(id);
|
|
611
|
+
if (flushScheduled) return;
|
|
612
|
+
flushScheduled = true;
|
|
613
|
+
setImmediate(flush);
|
|
614
|
+
}
|
|
615
|
+
function unwatch(id) {
|
|
616
|
+
annotationWatchers.get(id)?.close();
|
|
617
|
+
annotationWatchers.delete(id);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Brings one annotation's watch and broadcasts up to date. An annotation
|
|
621
|
+
* seen for the first time sets the baseline without broadcasting.
|
|
622
|
+
*/
|
|
623
|
+
async function sync(id) {
|
|
624
|
+
let record;
|
|
625
|
+
try {
|
|
626
|
+
record = await loadAnnotationUpdates(store, id);
|
|
627
|
+
} catch (thrown) {
|
|
628
|
+
if (!(thrown instanceof MissingAnnotationError)) throw thrown;
|
|
629
|
+
unwatch(id);
|
|
630
|
+
seen.delete(id);
|
|
631
|
+
return;
|
|
632
|
+
}
|
|
633
|
+
if (closed) return;
|
|
634
|
+
const known = seen.get(id);
|
|
635
|
+
if (known !== void 0) {
|
|
636
|
+
if (record.state.history.length !== known.history) hot.send(CHANNEL.state, {
|
|
637
|
+
id,
|
|
638
|
+
state: record.state
|
|
639
|
+
});
|
|
640
|
+
for (const entry of record.thread.slice(known.thread)) hot.send(CHANNEL.thread, {
|
|
641
|
+
id,
|
|
642
|
+
entry
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
seen.set(id, {
|
|
646
|
+
history: record.state.history.length,
|
|
647
|
+
thread: record.thread.length
|
|
648
|
+
});
|
|
649
|
+
if (record.state.status === "resolved" || record.state.status === "dismissed") {
|
|
650
|
+
unwatch(id);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
if (annotationWatchers.has(id)) return;
|
|
654
|
+
const watcher = watch(record.dir, (_event, filename) => {
|
|
655
|
+
if (filename === "state.json" || filename === "thread.jsonl") schedule(id);
|
|
656
|
+
});
|
|
657
|
+
watcher.on("error", (cause) => {
|
|
658
|
+
error(`watching annotation ${id} failed: ${describeError(cause)}`);
|
|
659
|
+
unwatch(id);
|
|
660
|
+
});
|
|
661
|
+
annotationWatchers.set(id, watcher);
|
|
662
|
+
schedule(id);
|
|
663
|
+
}
|
|
664
|
+
function flush() {
|
|
665
|
+
flushScheduled = false;
|
|
666
|
+
const ids = [...changed];
|
|
667
|
+
changed.clear();
|
|
668
|
+
pushQueue = pushQueue.then(async () => {
|
|
669
|
+
for (const id of ids) await sync(id).catch((cause) => error(`reading annotation ${id} failed: ${describeError(cause)}`));
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
await mkdir(stagingRoot, { recursive: true });
|
|
673
|
+
if ((await lstat(stagingRoot)).isSymbolicLink()) throw new PkaError(`Refusing symlink in the annotation store: ${stagingRoot}`);
|
|
674
|
+
await mkdir(serverStaging);
|
|
675
|
+
await removeStaleStaging();
|
|
676
|
+
const storeWatcher = watch(store, (_event, filename) => {
|
|
677
|
+
if (filename !== null && ID_PATTERN.test(filename)) schedule(filename);
|
|
678
|
+
});
|
|
679
|
+
storeWatcher.on("error", (cause) => error(`store watcher failed: ${describeError(cause)}`));
|
|
680
|
+
for (const id of await listIds(store)) schedule(id);
|
|
681
|
+
await mkdir(agentsDir(store), { recursive: true });
|
|
682
|
+
const agentWatcher = watch(agentsDir(store), () => {
|
|
683
|
+
if (agentScheduled) return;
|
|
684
|
+
agentScheduled = true;
|
|
685
|
+
setImmediate(refreshAgent);
|
|
686
|
+
});
|
|
687
|
+
agentWatcher.on("error", (cause) => error(`agent watcher failed: ${describeError(cause)}`));
|
|
688
|
+
agentPushed = JSON.stringify(await readAgent());
|
|
689
|
+
return async () => {
|
|
690
|
+
closed = true;
|
|
691
|
+
storeWatcher.close();
|
|
692
|
+
agentWatcher.close();
|
|
693
|
+
for (const watcher of annotationWatchers.values()) watcher.close();
|
|
694
|
+
annotationWatchers.clear();
|
|
695
|
+
for (const [event, listener] of listeners) hot.off(event, listener);
|
|
696
|
+
for (const upload of uploads.values()) upload.done = true;
|
|
697
|
+
uploads.clear();
|
|
698
|
+
await rm(serverStaging, {
|
|
699
|
+
recursive: true,
|
|
700
|
+
force: true
|
|
701
|
+
});
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
function channelPlugin(maxStoreBytes, bodies, storeRoot) {
|
|
705
|
+
let close;
|
|
706
|
+
return {
|
|
707
|
+
name: "pk-annotator:channel",
|
|
708
|
+
apply: "serve",
|
|
709
|
+
config() {
|
|
710
|
+
return { define: { [BODIES_GLOBAL]: JSON.stringify(bodies) } };
|
|
711
|
+
},
|
|
712
|
+
async configureServer(server) {
|
|
713
|
+
close = await serve(server, maxStoreBytes, storeRoot);
|
|
714
|
+
},
|
|
715
|
+
async buildEnd() {
|
|
716
|
+
const closing = close;
|
|
717
|
+
close = void 0;
|
|
718
|
+
await closing?.();
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
/** Dev-server plugins: source attributes, the overlay channel, symbolication, and the store watcher. Nothing runs in builds or under Vitest. */
|
|
723
|
+
function annotator(options = {}) {
|
|
724
|
+
const parsed = AnnotatorOptions.parse(options);
|
|
725
|
+
if (process.env.VITEST) return [];
|
|
726
|
+
return [sourcePlugin(), channelPlugin(parsed.maxStoreBytes ?? 524288e3, parsed.bodies ?? [], parsed.storeRoot)];
|
|
727
|
+
}
|
|
728
|
+
//#endregion
|
|
729
|
+
export { BODIES_GLOBAL, annotator };
|