paseo-bm-plugin 0.0.0-placeholder.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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +53 -0
  3. package/client/agent-tree.ts +308 -0
  4. package/client/answer-state.ts +62 -0
  5. package/client/bead-chips.tsx +147 -0
  6. package/client/beads-header-button.ts +108 -0
  7. package/client/beads-model.ts +581 -0
  8. package/client/beads-screen.tsx +516 -0
  9. package/client/beads-tab.tsx +58 -0
  10. package/client/chat-card.tsx +636 -0
  11. package/client/chat-cards.ts +1038 -0
  12. package/client/dashboard-actions.tsx +255 -0
  13. package/client/dashboard-model.ts +947 -0
  14. package/client/dashboard-view.ts +215 -0
  15. package/client/dashboard.tsx +318 -0
  16. package/client/launch-manager.ts +323 -0
  17. package/client/launcher.tsx +516 -0
  18. package/client/markdown-view.tsx +112 -0
  19. package/client/markdown.ts +145 -0
  20. package/client/settings.tsx +104 -0
  21. package/client/setup-model.ts +552 -0
  22. package/client/setup-screen.tsx +913 -0
  23. package/client/slot.ts +47 -0
  24. package/client/tree.tsx +204 -0
  25. package/client/ui.tsx +262 -0
  26. package/client/waiting-pills-model.ts +156 -0
  27. package/client/waiting-pills.tsx +201 -0
  28. package/index.client.tsx +232 -0
  29. package/index.server.ts +168 -0
  30. package/package.json +35 -0
  31. package/paseo-plugin.json +6 -0
  32. package/roles/manager.md +181 -0
  33. package/roles/reviewer.md +160 -0
  34. package/roles/worker.md +407 -0
  35. package/server/agent-labels.ts +194 -0
  36. package/server/agent-role.ts +102 -0
  37. package/server/answer-marks.ts +120 -0
  38. package/server/bead-actions.ts +88 -0
  39. package/server/bead-work.ts +80 -0
  40. package/server/beads-store.ts +342 -0
  41. package/server/bm-report.ts +433 -0
  42. package/server/chat-peers.ts +65 -0
  43. package/server/chat-rpc.ts +122 -0
  44. package/server/chat-waiting.ts +182 -0
  45. package/server/collector.ts +629 -0
  46. package/server/config-writer.ts +222 -0
  47. package/server/cost.ts +88 -0
  48. package/server/dashboard-rpc.ts +662 -0
  49. package/server/fallback-detect.ts +183 -0
  50. package/server/fallback-handover.ts +365 -0
  51. package/server/fallback-manager.ts +170 -0
  52. package/server/fallback-reviewer.ts +198 -0
  53. package/server/fallback-rpc.ts +306 -0
  54. package/server/fallback-settings.ts +322 -0
  55. package/server/fallback-state.ts +518 -0
  56. package/server/fallback-switch.ts +191 -0
  57. package/server/fallback-wait.ts +188 -0
  58. package/server/format-check.ts +352 -0
  59. package/server/install-home.ts +187 -0
  60. package/server/live-timeline.ts +129 -0
  61. package/server/manager-instructions.ts +9 -0
  62. package/server/manager.ts +647 -0
  63. package/server/model-costs.ts +238 -0
  64. package/server/notice-queue.ts +315 -0
  65. package/server/notices.ts +81 -0
  66. package/server/paseo-cli.ts +115 -0
  67. package/server/provider-id.ts +12 -0
  68. package/server/review-budget.ts +208 -0
  69. package/server/reviewer-instructions.ts +9 -0
  70. package/server/role-choices.ts +161 -0
  71. package/server/role-extras.ts +270 -0
  72. package/server/role-hook.ts +347 -0
  73. package/server/role-mode.ts +397 -0
  74. package/server/role-settings-rpc.ts +325 -0
  75. package/server/roles.ts +96 -0
  76. package/server/settings-notices.ts +112 -0
  77. package/server/setup-rpc.ts +70 -0
  78. package/server/setup-skills.ts +121 -0
  79. package/server/setup-tools.ts +162 -0
  80. package/server/shell.ts +68 -0
  81. package/server/stop-propagation.ts +365 -0
  82. package/server/tools-check.ts +118 -0
  83. package/server/trace-store.ts +1137 -0
  84. package/server/traces.ts +1356 -0
  85. package/server/worker-instructions.ts +9 -0
  86. package/server/workflow-steps.ts +422 -0
  87. package/shared/bead-ids.ts +25 -0
  88. package/shared/bm-fallback.ts +91 -0
  89. package/shared/bm-format.ts +424 -0
  90. package/shared/bm-questions.ts +213 -0
  91. package/shared/bm-report.ts +433 -0
  92. package/shared/contracts.ts +1371 -0
  93. package/shared/fallback-patterns.ts +201 -0
  94. package/shared/fallback.ts +46 -0
  95. package/shared/new-request.ts +20 -0
  96. package/shared/order.ts +22 -0
  97. package/shared/prices.ts +65 -0
  98. package/shared/settings.ts +57 -0
  99. package/shared/sole-worker.ts +20 -0
  100. package/shared/version.ts +6 -0
  101. package/tsconfig.json +16 -0
@@ -0,0 +1,1137 @@
1
+ /**
2
+ * Trace store primitives: the one writable-path resolver and the one lock
3
+ * (WP-203, Dashboard Design §3.8).
4
+ *
5
+ * Everything that mutates `<install home>/traces` goes through this module.
6
+ * That is not a style preference — it is the containment boundary. A caller
7
+ * that builds a path itself bypasses the symlink check, and a caller that
8
+ * writes without the lock can interleave with a delete and lose a record.
9
+ *
10
+ * Two mechanisms, and the reason each exists:
11
+ *
12
+ * 1. **No-follow path guard.** Checking that a resolved path starts with the
13
+ * store directory is NOT enough: if `traces/<workspaceId>` is a symlink to
14
+ * somewhere else, the prefix still matches and the write lands outside the
15
+ * store. So every existing component from the install home down is `lstat`ed
16
+ * and any symlink is refused, and the final open uses `O_NOFOLLOW`.
17
+ * 2. **In-process mutex keyed by workspace id.** The collector and every RPC
18
+ * handler run in the same forked plugin-server worker, so a file lock would
19
+ * be pointless ceremony; one async mutex per workspace is exactly enough.
20
+ * Reads deliberately do not take it — the store is one record per line, so a
21
+ * reader can tolerate a half-written trailing line (it is skipped).
22
+ *
23
+ * The only other process that can touch the store is the `paseo-bm` CLI during
24
+ * uninstall, and that is handled by ordering rather than locking: uninstall
25
+ * stops the plugin (`paseo plugin remove`) before it touches `traces/`.
26
+ */
27
+ import {
28
+ closeSync,
29
+ constants as fsConstants,
30
+ fsyncSync,
31
+ lstatSync,
32
+ mkdirSync,
33
+ openSync,
34
+ readFileSync,
35
+ readdirSync,
36
+ renameSync,
37
+ rmSync,
38
+ statSync,
39
+ unlinkSync,
40
+ writeSync,
41
+ } from "node:fs";
42
+ import { createHash } from "node:crypto";
43
+ import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
44
+ import {
45
+ DashboardError,
46
+ TRACE_STORE_SCHEMA_VERSION,
47
+ traceRecordSchema,
48
+ traceStoreMetaSchema,
49
+ traceWorkspaceMetaSchema,
50
+ type StoreSize,
51
+ type TraceDeleteScope,
52
+ type TraceMessage,
53
+ type TraceRecord,
54
+ type TraceStoreMeta,
55
+ type TraceWorkspaceMeta,
56
+ type WorkspaceState,
57
+ } from "../shared/contracts";
58
+
59
+ /** Workspace ids are used as directory names, so they are restricted (design §3.5). */
60
+ export const WORKSPACE_ID_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
61
+
62
+ /** How long a mutation waits for the workspace lock before giving up (design §3.8). */
63
+ export const LOCK_WAIT_TIMEOUT_MS = 5_000;
64
+
65
+ /** Directory and file modes of the store (REQ-048c). */
66
+ export const STORE_DIR_MODE = 0o700;
67
+ export const STORE_FILE_MODE = 0o600;
68
+
69
+ function unwritable(detail: string, cause?: unknown): DashboardError {
70
+ return new DashboardError("E_TRACE_STORE_UNWRITABLE", detail, cause ? { cause } : undefined);
71
+ }
72
+
73
+ /**
74
+ * Rejects a workspace id that cannot be used as a directory name.
75
+ *
76
+ * `.` and `..` match the character class but would escape or alias the store,
77
+ * so they are excluded explicitly.
78
+ */
79
+ export function assertWorkspaceId(workspaceId: string): void {
80
+ if (typeof workspaceId !== "string" || !WORKSPACE_ID_PATTERN.test(workspaceId)) {
81
+ throw unwritable(`workspace id is not usable as a store directory: ${JSON.stringify(workspaceId)}`);
82
+ }
83
+ if (workspaceId === "." || workspaceId === "..") {
84
+ throw unwritable(`workspace id is not usable as a store directory: ${JSON.stringify(workspaceId)}`);
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Refuses any symlink between `root` and `target`, without following one.
90
+ *
91
+ * Components that do not exist yet are fine: nothing below them exists either,
92
+ * so there is nothing to escape through. A component that exists and is a
93
+ * symlink fails before the caller opens, renames or unlinks anything.
94
+ */
95
+ export function assertNoSymlinkOnPath(root: string, target: string): void {
96
+ const rootAbsolute = resolve(root);
97
+ const targetAbsolute = resolve(target);
98
+
99
+ const check = (path: string): boolean => {
100
+ try {
101
+ if (lstatSync(path).isSymbolicLink()) {
102
+ throw unwritable(`refusing to use a symlinked path inside the trace store: ${path}`);
103
+ }
104
+ return true;
105
+ } catch (error) {
106
+ if (error instanceof DashboardError) throw error;
107
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
108
+ throw unwritable(`cannot inspect ${path}`, error);
109
+ }
110
+ };
111
+
112
+ if (!check(rootAbsolute)) return;
113
+ if (targetAbsolute === rootAbsolute) return;
114
+
115
+ let current = rootAbsolute;
116
+ for (const part of relative(rootAbsolute, targetAbsolute).split(sep)) {
117
+ if (part === "" || part === ".") continue;
118
+ current = join(current, part);
119
+ if (!check(current)) return;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Turns store-relative segments into an absolute path, applying every rule of
125
+ * design §3.8 in order: id shape, prefix containment, then no-follow.
126
+ *
127
+ * `tracesDir` comes from WP-202's resolver; this module never guesses it.
128
+ */
129
+ export function storePath(tracesDir: string, workspaceId: string, ...segments: string[]): string {
130
+ if (!isAbsolute(tracesDir)) {
131
+ throw unwritable(`trace store directory must be absolute: ${tracesDir}`);
132
+ }
133
+ assertWorkspaceId(workspaceId);
134
+ for (const segment of segments) {
135
+ if (segment.includes("/") || segment.includes("\\") || segment === "" || segment === "." || segment === "..") {
136
+ throw unwritable(`unsafe trace store path segment: ${JSON.stringify(segment)}`);
137
+ }
138
+ }
139
+
140
+ const root = resolve(tracesDir);
141
+ const target = resolve(join(root, workspaceId, ...segments));
142
+ const rel = relative(root, target);
143
+ if (rel.startsWith("..") || isAbsolute(rel)) {
144
+ throw unwritable(`path escapes the trace store: ${target}`);
145
+ }
146
+ assertNoSymlinkOnPath(dirname(root), target);
147
+ return target;
148
+ }
149
+
150
+ /**
151
+ * Creates a store directory with mode 0700, checking for symlinks before and
152
+ * after. The second check closes the window where a component appears between
153
+ * the check and the `mkdir`.
154
+ */
155
+ export function ensureStoreDir(tracesDir: string, directory: string): void {
156
+ const root = resolve(tracesDir);
157
+ assertNoSymlinkOnPath(dirname(root), directory);
158
+ try {
159
+ mkdirSync(directory, { recursive: true, mode: STORE_DIR_MODE });
160
+ } catch (error) {
161
+ throw unwritable(`cannot create ${directory}`, error);
162
+ }
163
+ assertNoSymlinkOnPath(dirname(root), directory);
164
+ }
165
+
166
+ /**
167
+ * Opens a store file for appending with `O_NOFOLLOW`, so the final component
168
+ * cannot be a symlink even if it appears after the path check.
169
+ */
170
+ export function openStoreFileForAppend(path: string): number {
171
+ const flags =
172
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_APPEND | fsConstants.O_NOFOLLOW;
173
+ try {
174
+ return openSync(path, flags, STORE_FILE_MODE);
175
+ } catch (error) {
176
+ throw unwritable(`cannot append to ${path}`, error);
177
+ }
178
+ }
179
+
180
+ /**
181
+ * Creates a temporary file for a rewrite, in the destination's own directory so
182
+ * the later `rename` stays inside one filesystem. `O_EXCL` means an existing
183
+ * file — including one an attacker just created — fails instead of being reused.
184
+ */
185
+ export function createStoreTempFile(path: string): number {
186
+ const flags =
187
+ fsConstants.O_WRONLY | fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_NOFOLLOW;
188
+ try {
189
+ return openSync(path, flags, STORE_FILE_MODE);
190
+ } catch (error) {
191
+ throw unwritable(`cannot create the temporary file ${path}`, error);
192
+ }
193
+ }
194
+
195
+ /** Closes a descriptor without masking the error that made the caller close it. */
196
+ export function closeQuietly(fd: number | null): void {
197
+ if (fd === null) return;
198
+ try {
199
+ closeSync(fd);
200
+ } catch {
201
+ // Nothing useful to do: the write already reported the real failure.
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Thrown when a mutation could not get the workspace lock in time.
207
+ *
208
+ * A distinct class so the collector can tell "busy" from "broken": on this one
209
+ * it drops the turn and logs a line, because a trace is never worth stalling an
210
+ * agent for (REQ-053c).
211
+ */
212
+ export class TraceStoreLockTimeout extends DashboardError {
213
+ constructor(workspaceId: string, timeoutMs: number) {
214
+ super(
215
+ "E_TRACE_STORE_UNWRITABLE",
216
+ `timed out after ${timeoutMs} ms waiting for the trace store lock of workspace ${workspaceId}`,
217
+ );
218
+ this.name = "TraceStoreLockTimeout";
219
+ }
220
+ }
221
+
222
+ /** One promise chain per workspace id. Empty between mutations. */
223
+ const lockTails = new Map<string, Promise<unknown>>();
224
+
225
+ function settled(promise: Promise<unknown>): Promise<void> {
226
+ return promise.then(
227
+ () => undefined,
228
+ () => undefined,
229
+ );
230
+ }
231
+
232
+ /**
233
+ * Runs `fn` with the workspace's mutation lock held.
234
+ *
235
+ * Mutations of the same workspace never interleave; different workspaces are
236
+ * independent. A waiter that times out releases its slot, so the queue keeps
237
+ * moving and the lock is never left held.
238
+ */
239
+ export async function withWorkspaceLock<T>(
240
+ workspaceId: string,
241
+ fn: () => T | Promise<T>,
242
+ options: { timeoutMs?: number } = {},
243
+ ): Promise<T> {
244
+ assertWorkspaceId(workspaceId);
245
+ const timeoutMs = options.timeoutMs ?? LOCK_WAIT_TIMEOUT_MS;
246
+
247
+ const previous = lockTails.get(workspaceId) ?? Promise.resolve();
248
+ let release!: () => void;
249
+ const held = new Promise<void>((resolveHeld) => {
250
+ release = resolveHeld;
251
+ });
252
+ const tail = settled(previous).then(() => held);
253
+ lockTails.set(workspaceId, tail);
254
+
255
+ let timer: ReturnType<typeof setTimeout> | undefined;
256
+ try {
257
+ await new Promise<void>((resolveWait, rejectWait) => {
258
+ let finished = false;
259
+ timer = setTimeout(() => {
260
+ if (finished) return;
261
+ finished = true;
262
+ rejectWait(new TraceStoreLockTimeout(workspaceId, timeoutMs));
263
+ }, timeoutMs);
264
+ void settled(previous).then(() => {
265
+ if (finished) return;
266
+ finished = true;
267
+ resolveWait();
268
+ });
269
+ });
270
+ } catch (error) {
271
+ // Never acquired: hand the slot to the next waiter so the queue drains.
272
+ release();
273
+ throw error;
274
+ } finally {
275
+ if (timer !== undefined) clearTimeout(timer);
276
+ }
277
+
278
+ try {
279
+ return await fn();
280
+ } finally {
281
+ release();
282
+ // Drop the entry when nobody queued behind us, so the map cannot grow
283
+ // once for every workspace the machine has ever seen.
284
+ if (lockTails.get(workspaceId) === tail) lockTails.delete(workspaceId);
285
+ }
286
+ }
287
+
288
+ /** Number of workspaces with a live lock chain. Test-only observability. */
289
+ export function activeLockCount(): number {
290
+ return lockTails.size;
291
+ }
292
+
293
+ // ---------------------------------------------------------------------------
294
+ // Store operations (WP-203 part 2, Dashboard Design §3.2, §3.3, §3.4, §3.6).
295
+ //
296
+ // Built only on the primitives above: no other path construction, no other
297
+ // mutation path. Append is the normal write; the only rewrite is deletion,
298
+ // which WP-210 owns.
299
+ // ---------------------------------------------------------------------------
300
+
301
+ /** Longest single message text kept in a record (design §3.3). */
302
+ export const MAX_MESSAGE_CHARS = 8 * 1024;
303
+
304
+ /** Longest whole record kept; a record over this has its texts shrunk, never dropped. */
305
+ export const MAX_RECORD_CHARS = 32 * 1024;
306
+
307
+ /** Marker appended to any text the store had to cut. */
308
+ export const TRUNCATION_MARKER = "...[truncated]";
309
+
310
+ /** Name of the monthly file a timestamp belongs to (design §3.2). */
311
+ export function monthlyFileName(at: string): string {
312
+ const date = new Date(at);
313
+ const stamp = Number.isNaN(date.getTime()) ? new Date(0) : date;
314
+ const year = stamp.getUTCFullYear().toString().padStart(4, "0");
315
+ const month = (stamp.getUTCMonth() + 1).toString().padStart(2, "0");
316
+ return `events-${year}${month}.jsonl`;
317
+ }
318
+
319
+ /** Cuts a text to the cap and says so, instead of silently shortening it. */
320
+ export function capText(
321
+ text: string,
322
+ limit: number = MAX_MESSAGE_CHARS,
323
+ ): { text: string; truncated: boolean } {
324
+ if (typeof text !== "string") return { text: "", truncated: false };
325
+ if (text.length <= limit) return { text, truncated: false };
326
+ return { text: `${text.slice(0, limit)}${TRUNCATION_MARKER}`, truncated: true };
327
+ }
328
+
329
+ function capMessages(messages: readonly TraceMessage[], limit = MAX_MESSAGE_CHARS): TraceMessage[] {
330
+ return messages.map((message) => {
331
+ const capped = capText(message.text, limit);
332
+ return { ...message, text: capped.text, truncated: message.truncated || capped.truncated };
333
+ });
334
+ }
335
+
336
+ /**
337
+ * Applies the design's text caps before a record is written.
338
+ *
339
+ * Over the record cap it shrinks message bodies rather than dropping messages:
340
+ * losing a message loses a timestamp and a data point, while losing some text
341
+ * only loses detail the UI already truncates.
342
+ */
343
+ export function capRecord(record: TraceRecord): TraceRecord {
344
+ const capped: TraceRecord = {
345
+ ...record,
346
+ sent: capMessages(record.sent),
347
+ received: capMessages(record.received),
348
+ };
349
+ if (JSON.stringify(capped).length <= MAX_RECORD_CHARS) return capped;
350
+ return { ...capped, sent: capMessages(capped.sent, 512), received: capMessages(capped.received, 512) };
351
+ }
352
+
353
+ export interface TraceStoreLocation {
354
+ /** `<install home>/traces`, from WP-202's resolver. */
355
+ tracesDir: string;
356
+ }
357
+
358
+ /** What a read returned, plus what it had to skip. */
359
+ export interface ReadRecordsResult {
360
+ records: TraceRecord[];
361
+ skippedLines: number;
362
+ notices: string[];
363
+ /** True when the store holds a schema this build only partly understands. */
364
+ limitedRead: boolean;
365
+ }
366
+
367
+ interface CacheEntry {
368
+ mtimeMs: number;
369
+ size: number;
370
+ records: TraceRecord[];
371
+ skippedLines: number;
372
+ }
373
+
374
+ /** Per-file read cache keyed by absolute path, invalidated by mtime+size (design §3.4). */
375
+ const readCache = new Map<string, CacheEntry>();
376
+
377
+ /** Drops the read cache. Used by tests and after a destructive operation. */
378
+ export function clearTraceStoreCache(): void {
379
+ readCache.clear();
380
+ }
381
+
382
+ function readJsonFile(path: string): unknown | null {
383
+ try {
384
+ return JSON.parse(readFileSync(path, "utf8")) as unknown;
385
+ } catch {
386
+ return null;
387
+ }
388
+ }
389
+
390
+ /**
391
+ * Reads `<traces>/meta.json`, creating nothing.
392
+ *
393
+ * A store whose `schemaVersion` is higher than this build understands is
394
+ * readable but must never be written (design §3.2): a newer paseo-bm owns that
395
+ * directory, and rewriting its records would destroy data this build cannot
396
+ * even represent.
397
+ */
398
+ export function readStoreMeta(location: TraceStoreLocation): {
399
+ meta: TraceStoreMeta | null;
400
+ tooNew: boolean;
401
+ } {
402
+ const path = join(resolve(location.tracesDir), "meta.json");
403
+ const parsed = readJsonFile(path);
404
+ if (parsed === null) return { meta: null, tooNew: false };
405
+ const result = traceStoreMetaSchema.safeParse(parsed);
406
+ if (!result.success) return { meta: null, tooNew: false };
407
+ return { meta: result.data, tooNew: result.data.schemaVersion > TRACE_STORE_SCHEMA_VERSION };
408
+ }
409
+
410
+ /** Throws when the store belongs to a newer paseo-bm (design §3.2). */
411
+ export function assertWritableSchema(location: TraceStoreLocation): void {
412
+ const { meta, tooNew } = readStoreMeta(location);
413
+ if (tooNew && meta !== null) {
414
+ throw new DashboardError(
415
+ "E_TRACE_STORE_SCHEMA_TOO_NEW",
416
+ `the trace store at ${location.tracesDir} uses schemaVersion ${meta.schemaVersion}, newer than this plugin understands (${TRACE_STORE_SCHEMA_VERSION}); reading only`,
417
+ );
418
+ }
419
+ }
420
+
421
+ /** Temp-then-rename write, used for both `meta.json` files and later rewrites. */
422
+ export function writeStoreFileAtomically(
423
+ location: TraceStoreLocation,
424
+ path: string,
425
+ body: string,
426
+ ): void {
427
+ const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
428
+ assertNoSymlinkOnPath(dirname(resolve(location.tracesDir)), tempPath);
429
+ let fd: number | null = null;
430
+ try {
431
+ fd = createStoreTempFile(tempPath);
432
+ writeSync(fd, body);
433
+ fsyncSync(fd);
434
+ } finally {
435
+ closeQuietly(fd);
436
+ }
437
+ try {
438
+ renameSync(tempPath, path);
439
+ } catch (error) {
440
+ try {
441
+ unlinkSync(tempPath);
442
+ } catch {
443
+ // Leaving a stray temp file behind beats masking the rename failure.
444
+ }
445
+ throw unwritable(`cannot replace ${path}`, error);
446
+ }
447
+ }
448
+
449
+ /** Creates the store skeleton and stamps the schema version. Idempotent. */
450
+ export function ensureStore(location: TraceStoreLocation): void {
451
+ const root = resolve(location.tracesDir);
452
+ ensureStoreDir(location.tracesDir, root);
453
+ const { meta, tooNew } = readStoreMeta(location);
454
+ if (tooNew) return;
455
+ if (meta === null) {
456
+ const now = new Date().toISOString();
457
+ const body = JSON.stringify({
458
+ schemaVersion: TRACE_STORE_SCHEMA_VERSION,
459
+ createdAt: now,
460
+ updatedAt: now,
461
+ });
462
+ writeStoreFileAtomically(location, join(root, "meta.json"), `${body}\n`);
463
+ }
464
+ }
465
+
466
+ /**
467
+ * Records the workspace's last known name and path, so a workspace that later
468
+ * disappears from Paseo is still recognisable to a person (REQ-057a).
469
+ *
470
+ * Only writes when something changed: this runs on every collected turn.
471
+ */
472
+ export function writeWorkspaceMeta(
473
+ location: TraceStoreLocation,
474
+ workspaceId: string,
475
+ meta: TraceWorkspaceMeta,
476
+ ): void {
477
+ const path = storePath(location.tracesDir, workspaceId, "meta.json");
478
+ ensureStoreDir(location.tracesDir, dirname(path));
479
+ const existing = traceWorkspaceMetaSchema.safeParse(readJsonFile(path));
480
+ // Skip only when the file already says exactly this. The earlier guard
481
+ // compared the name and directory alone, so `lastSeenAt` froze at the first
482
+ // turn: after the WP-214 acceptance run it read 06:42 for a workspace that
483
+ // stayed busy until 07:35. A field called `lastSeenAt` has to mean it, and
484
+ // this write is one small atomic file beside an append that already happens
485
+ // on every turn.
486
+ if (
487
+ existing.success &&
488
+ existing.data.lastKnownName === meta.lastKnownName &&
489
+ existing.data.lastKnownDirectory === meta.lastKnownDirectory &&
490
+ existing.data.lastSeenAt === meta.lastSeenAt
491
+ ) {
492
+ return;
493
+ }
494
+ writeStoreFileAtomically(location, path, `${JSON.stringify(meta)}\n`);
495
+ }
496
+
497
+ /** Reads a workspace's `meta.json`, or null when it has none. */
498
+ export function readWorkspaceMeta(
499
+ location: TraceStoreLocation,
500
+ workspaceId: string,
501
+ ): TraceWorkspaceMeta | null {
502
+ const path = storePath(location.tracesDir, workspaceId, "meta.json");
503
+ const parsed = traceWorkspaceMetaSchema.safeParse(readJsonFile(path));
504
+ return parsed.success ? parsed.data : null;
505
+ }
506
+
507
+ /**
508
+ * Appends one record, under the workspace lock.
509
+ *
510
+ * One `write` of one newline-terminated line, then `fsync`: a reader that meets
511
+ * a half-written final line (process killed mid-write) skips exactly that line
512
+ * and keeps every completed line before it.
513
+ */
514
+ export async function appendRecord(
515
+ location: TraceStoreLocation,
516
+ record: TraceRecord,
517
+ options: { timeoutMs?: number } = {},
518
+ ): Promise<void> {
519
+ const line = `${JSON.stringify(capRecord(record))}\n`;
520
+ await withWorkspaceLock(
521
+ record.workspaceId,
522
+ () => {
523
+ assertWritableSchema(location);
524
+ ensureStore(location);
525
+ const path = storePath(location.tracesDir, record.workspaceId, monthlyFileName(record.at));
526
+ ensureStoreDir(location.tracesDir, dirname(path));
527
+ let fd: number | null = null;
528
+ try {
529
+ fd = openStoreFileForAppend(path);
530
+ writeSync(fd, line);
531
+ fsyncSync(fd);
532
+ } finally {
533
+ closeQuietly(fd);
534
+ }
535
+ readCache.delete(path);
536
+ },
537
+ options,
538
+ );
539
+ }
540
+
541
+ /** Monthly files of a workspace, newest first (design §3.4). */
542
+ export function monthlyFiles(location: TraceStoreLocation, workspaceId: string): string[] {
543
+ const dir = dirname(storePath(location.tracesDir, workspaceId, "meta.json"));
544
+ let names: string[];
545
+ try {
546
+ names = readdirSync(dir);
547
+ } catch {
548
+ return [];
549
+ }
550
+ return names
551
+ .filter((name) => /^events-\d{6}\.jsonl$/.test(name))
552
+ .sort()
553
+ .reverse()
554
+ .map((name) => join(dir, name));
555
+ }
556
+
557
+ function readMonthlyFile(path: string): { records: TraceRecord[]; skippedLines: number } {
558
+ let stats: { mtimeMs: number; size: number };
559
+ try {
560
+ const stat = statSync(path);
561
+ stats = { mtimeMs: stat.mtimeMs, size: stat.size };
562
+ } catch {
563
+ return { records: [], skippedLines: 0 };
564
+ }
565
+ const cached = readCache.get(path);
566
+ if (cached !== undefined && cached.mtimeMs === stats.mtimeMs && cached.size === stats.size) {
567
+ return { records: cached.records, skippedLines: cached.skippedLines };
568
+ }
569
+
570
+ let body: string;
571
+ try {
572
+ body = readFileSync(path, "utf8");
573
+ } catch {
574
+ return { records: [], skippedLines: 0 };
575
+ }
576
+
577
+ const records: TraceRecord[] = [];
578
+ let skippedLines = 0;
579
+ const lines = body.split("\n");
580
+ for (const [index, line] of lines.entries()) {
581
+ // A trailing newline leaves a final "" that is not a record. A blank line
582
+ // anywhere else, or a line that will not parse, is counted as skipped.
583
+ if (line === "") {
584
+ if (index !== lines.length - 1) skippedLines += 1;
585
+ continue;
586
+ }
587
+ let parsed: unknown;
588
+ try {
589
+ parsed = JSON.parse(line) as unknown;
590
+ } catch {
591
+ skippedLines += 1;
592
+ continue;
593
+ }
594
+ const result = traceRecordSchema.safeParse(parsed);
595
+ if (!result.success) {
596
+ skippedLines += 1;
597
+ continue;
598
+ }
599
+ records.push(result.data);
600
+ }
601
+
602
+ readCache.set(path, { ...stats, records, skippedLines });
603
+ return { records, skippedLines };
604
+ }
605
+
606
+ /**
607
+ * Fingerprint of what a turn actually said: the text of `sent` then `received`,
608
+ * in order. `null` when the record carries no message at all.
609
+ *
610
+ * Content, not time, because a rewrite must fingerprint the same. The collector
611
+ * stamps every message with the WRITE time whenever its `refetch` fails
612
+ * (`collector.ts` `buildRecord`: `endedAt = now()`), so two writes of one turn
613
+ * can carry different times while carrying the same words — delta 20260917d §4
614
+ * B, which is why the first draft of this key was rejected.
615
+ */
616
+ function turnFingerprint(record: TraceRecord): string | null {
617
+ const messages = [...record.sent, ...record.received];
618
+ if (messages.length === 0) return null;
619
+ // Length-prefixed so two messages cannot be re-split into a different pair.
620
+ const joined = messages.map((message) => `${message.text.length}:${message.text}`).join("");
621
+ return createHash("sha1").update(joined).digest("hex").slice(0, 16);
622
+ }
623
+
624
+ /**
625
+ * Keeps one record per `(agentId, turnId, turn fingerprint)`: the one with the
626
+ * latest `at`.
627
+ *
628
+ * The collector hook can run again after a plugin reload, so the same turn can
629
+ * legitimately be written twice; the store tolerates that rather than trying to
630
+ * prevent it (design §3.3). The fingerprint is there because Paseo REUSES turn
631
+ * ids inside one agent (delta 20260917d §5: `foreground-turn-1…3` twice in one
632
+ * Manager, eight minutes apart). Keyed on `(agentId, turnId)` alone, the reader
633
+ * dropped the owner's opening question and a Worker turn of 12,476 output
634
+ * tokens. Records with no turn id cannot be keyed and are all kept.
635
+ */
636
+ export function dedupeRecords(records: readonly TraceRecord[]): TraceRecord[] {
637
+ const best = new Map<string, TraceRecord>();
638
+ const keyless: TraceRecord[] = [];
639
+ for (const record of records) {
640
+ if (record.turnId === null) {
641
+ keyless.push(record);
642
+ continue;
643
+ }
644
+ const fingerprint = turnFingerprint(record);
645
+ const key = `${record.agentId}::${record.turnId}::${fingerprint ?? ""}`;
646
+ const current = best.get(key);
647
+ if (current === undefined || record.at >= current.at) best.set(key, record);
648
+ }
649
+ return [...best.values(), ...keyless].sort((a, b) => (a.at < b.at ? -1 : a.at > b.at ? 1 : 0));
650
+ }
651
+
652
+ /**
653
+ * Reads a workspace's records newest-file-first, stopping once `maxRecords` is
654
+ * reached. Takes no lock: line-oriented data plus the skip rule above make a
655
+ * concurrent append harmless.
656
+ */
657
+ export function readRecords(
658
+ location: TraceStoreLocation,
659
+ workspaceId: string,
660
+ options: { maxRecords?: number } = {},
661
+ ): ReadRecordsResult {
662
+ const { meta, tooNew } = readStoreMeta(location);
663
+ const notices: string[] = [];
664
+ if (tooNew && meta !== null) {
665
+ notices.push(
666
+ `Trace store schemaVersion ${meta.schemaVersion} is newer than this plugin understands (${TRACE_STORE_SCHEMA_VERSION}); showing what could be read and writing nothing.`,
667
+ );
668
+ }
669
+
670
+ const maxRecords = options.maxRecords ?? Number.POSITIVE_INFINITY;
671
+ const collected: TraceRecord[] = [];
672
+ let skippedLines = 0;
673
+ for (const path of monthlyFiles(location, workspaceId)) {
674
+ const page = readMonthlyFile(path);
675
+ skippedLines += page.skippedLines;
676
+ collected.push(...page.records);
677
+ if (collected.length >= maxRecords) break;
678
+ }
679
+ if (skippedLines > 0) {
680
+ notices.push(`Skipped ${skippedLines} unreadable line(s) in the trace store.`);
681
+ }
682
+ return { records: dedupeRecords(collected), skippedLines, notices, limitedRead: tooNew };
683
+ }
684
+
685
+ /**
686
+ * Byte size of the store, measured with `stat` only (design §3.6). It never
687
+ * reads content: the client shows bytes, and counts come from the delete
688
+ * preview, which counts exactly what it would delete.
689
+ */
690
+ export function measureStore(location: TraceStoreLocation, workspaceId?: string): StoreSize {
691
+ const root = resolve(location.tracesDir);
692
+ let bytes = 0;
693
+ let workspaceBytes = 0;
694
+
695
+ let workspaceDirs: string[] = [];
696
+ try {
697
+ workspaceDirs = readdirSync(root, { withFileTypes: true })
698
+ .filter((entry) => entry.isDirectory())
699
+ .map((entry) => entry.name);
700
+ } catch {
701
+ workspaceDirs = [];
702
+ }
703
+
704
+ for (const id of workspaceDirs) {
705
+ const dir = join(root, id);
706
+ let names: string[];
707
+ try {
708
+ names = readdirSync(dir);
709
+ } catch {
710
+ continue;
711
+ }
712
+ for (const name of names) {
713
+ let size = 0;
714
+ try {
715
+ size = statSync(join(dir, name)).size;
716
+ } catch {
717
+ continue;
718
+ }
719
+ bytes += size;
720
+ if (id === workspaceId) workspaceBytes += size;
721
+ }
722
+ }
723
+
724
+ try {
725
+ bytes += statSync(join(root, "meta.json")).size;
726
+ } catch {
727
+ // No store metadata yet: nothing to add.
728
+ }
729
+
730
+ return { bytes, workspaceBytes };
731
+ }
732
+
733
+ /**
734
+ * How many traces a set of records makes, for every user-facing count.
735
+ *
736
+ * Requests, not turns. Keying an unlabelled record by its turn id made one
737
+ * request look like six in the WP-214 acceptance run, and later made a
738
+ * five-request workspace read "102 traces" in the reassign confirmation. An
739
+ * agent that never reports a request id is counted once, by agent; a Manager
740
+ * turn without an id belongs to a request and adds nothing.
741
+ */
742
+ export function countTraces(records: readonly TraceRecord[]): number {
743
+ const requestIds = new Set(records.map((record) => record.requestId).filter((id): id is string => id !== null));
744
+ const agentsWithRequest = new Set(
745
+ records.filter((record) => record.requestId !== null).map((record) => record.agentId),
746
+ );
747
+ const orphanAgents = new Set(
748
+ records
749
+ .filter((record) => record.requestId === null && record.role !== "manager" && !agentsWithRequest.has(record.agentId))
750
+ .map((record) => record.agentId),
751
+ );
752
+ return requestIds.size + orphanAgents.size;
753
+ }
754
+
755
+ /** Identity of one stored record, used to delete exactly what a trace shows. */
756
+ export function recordKeyOf(record: TraceRecord): string {
757
+ return `${record.agentId}|${record.turnId ?? ""}|${record.at}`;
758
+ }
759
+
760
+ // ---------------------------------------------------------------------------
761
+ // Deletion (WP-210, Dashboard Design §3.5).
762
+ //
763
+ // The only rewrite path in the store, and the only irreversible operation in
764
+ // the feature: there is no undo and no bin. Two rules therefore hold without
765
+ // exception — every mutation goes through the guard and the mutex above, and
766
+ // **nothing in the product calls this except a user action** (REQ-054f). There
767
+ // is deliberately no timer, hook or retention policy that can reach it.
768
+ // ---------------------------------------------------------------------------
769
+
770
+ /** How many traces and bytes a delete would remove, or removed. */
771
+ export interface DeleteOutcome {
772
+ traces: number;
773
+ bytes: number;
774
+ }
775
+
776
+ /** Scope of a deletion, as `traces.delete` validated it. */
777
+ export type DeleteScope = TraceDeleteScope;
778
+
779
+ /** Trace key of a record: the request it belongs to, or a per-turn fallback. */
780
+ export function traceKeyOf(record: TraceRecord): string {
781
+ return record.requestId ?? `${record.agentId}::${record.turnId ?? record.at}`;
782
+ }
783
+
784
+ function matchesScope(record: TraceRecord, scope: DeleteScope, recordKeys?: ReadonlySet<string>): boolean {
785
+ if ("allOfWorkspace" in scope) return true;
786
+ if ("traceId" in scope) {
787
+ // The caller resolves a trace to its records (a trace spans several agents
788
+ // and unnamed Manager turns); a bare key match is only the fallback.
789
+ if (recordKeys !== undefined) return recordKeys.has(recordKeyOf(record));
790
+ return traceKeyOf(record) === scope.traceId || `req:${traceKeyOf(record)}` === scope.traceId;
791
+ }
792
+ return record.at < scope.before;
793
+ }
794
+
795
+ function bytesOf(path: string): number {
796
+ try {
797
+ return statSync(path).size;
798
+ } catch {
799
+ return 0;
800
+ }
801
+ }
802
+
803
+ /** Splits one monthly file into the lines a scope keeps and the ones it drops. */
804
+ function splitMonthlyFile(
805
+ path: string,
806
+ scope: DeleteScope,
807
+ recordKeys?: ReadonlySet<string>,
808
+ ): { keptLines: string[]; dropped: TraceRecord[]; droppedLines: number; fileBytes: number } {
809
+ let body: string;
810
+ try {
811
+ body = readFileSync(path, "utf8");
812
+ } catch {
813
+ return { keptLines: [], dropped: [], droppedLines: 0, fileBytes: 0 };
814
+ }
815
+ const keptLines: string[] = [];
816
+ const dropped: TraceRecord[] = [];
817
+ let droppedLines = 0;
818
+ for (const line of body.split("\n")) {
819
+ if (line === "") continue;
820
+ const parsed = traceRecordSchema.safeParse(safeJson(line));
821
+ // An unreadable line matches no trace, so it is kept: deletion must only
822
+ // remove what the user asked for, never data it failed to understand.
823
+ if (!parsed.success || !matchesScope(parsed.data, scope, recordKeys)) {
824
+ keptLines.push(line);
825
+ continue;
826
+ }
827
+ droppedLines += 1;
828
+ dropped.push(parsed.data);
829
+ }
830
+ return { keptLines, dropped, droppedLines, fileBytes: Buffer.byteLength(body, "utf8") };
831
+ }
832
+
833
+ /**
834
+ * Plans a deletion: what would go.
835
+ *
836
+ * `traces.delete` with `dryRun: true` returns exactly this, so the confirmation
837
+ * the user sees is computed by the same code that does the work — a preview
838
+ * that can disagree with the deletion is worse than no preview at all.
839
+ */
840
+ export function planDeletion(
841
+ location: TraceStoreLocation,
842
+ workspaceId: string,
843
+ scope: DeleteScope,
844
+ options: { recordKeys?: ReadonlySet<string> } = {},
845
+ ): DeleteOutcome {
846
+ const doomed: TraceRecord[] = [];
847
+ let bytes = 0;
848
+
849
+ for (const path of monthlyFiles(location, workspaceId)) {
850
+ const split = splitMonthlyFile(path, scope, options.recordKeys);
851
+ if (split.droppedLines === 0) continue;
852
+ doomed.push(...split.dropped);
853
+ bytes +=
854
+ split.keptLines.length === 0
855
+ ? split.fileBytes
856
+ : split.fileBytes - Buffer.byteLength(`${split.keptLines.join("\n")}\n`, "utf8");
857
+ }
858
+
859
+ if ("allOfWorkspace" in scope) {
860
+ bytes += bytesOf(storePath(location.tracesDir, workspaceId, "meta.json"));
861
+ }
862
+
863
+ return {
864
+ traces: "traceId" in scope ? (doomed.length > 0 ? 1 : 0) : countTraces(doomed),
865
+ bytes,
866
+ };
867
+ }
868
+
869
+ /**
870
+ * Deletes traces. Irreversible: no bin, no undo.
871
+ *
872
+ * Whole months and whole workspaces are removed outright; a month that keeps
873
+ * some records is rewritten through the store's temp + `fsync` + `rename` path,
874
+ * so a crash leaves either the old file or the new one, never a half file.
875
+ */
876
+ export async function deleteTraces(
877
+ location: TraceStoreLocation,
878
+ workspaceId: string,
879
+ scope: DeleteScope,
880
+ options: {
881
+ dryRun?: boolean;
882
+ /** Records a trace-scoped delete removes, from the reconstructed trace. */
883
+ recordKeys?: ReadonlySet<string>;
884
+ timeoutMs?: number;
885
+ } = {},
886
+ ): Promise<DeleteOutcome> {
887
+ assertWorkspaceId(workspaceId);
888
+ // Validate the path family before anything is touched, so an escaping id
889
+ // fails with nothing written.
890
+ storePath(location.tracesDir, workspaceId, "meta.json");
891
+
892
+ if (options.dryRun === true) {
893
+ return planDeletion(location, workspaceId, scope, options);
894
+ }
895
+
896
+ return withWorkspaceLock(
897
+ workspaceId,
898
+ () => {
899
+ assertWritableSchema(location);
900
+ const planned = planDeletion(location, workspaceId, scope, options);
901
+
902
+ if ("allOfWorkspace" in scope) {
903
+ const dir = dirname(storePath(location.tracesDir, workspaceId, "meta.json"));
904
+ assertNoSymlinkOnPath(dirname(resolve(location.tracesDir)), dir);
905
+ try {
906
+ rmSync(dir, { recursive: true, force: true });
907
+ } catch (error) {
908
+ throw unwritable(`cannot remove ${dir}`, error);
909
+ }
910
+ clearTraceStoreCache();
911
+ return planned;
912
+ }
913
+
914
+ for (const path of monthlyFiles(location, workspaceId)) {
915
+ const split = splitMonthlyFile(path, scope, options.recordKeys);
916
+ if (split.droppedLines === 0) continue;
917
+ if (split.keptLines.length === 0) {
918
+ assertNoSymlinkOnPath(dirname(resolve(location.tracesDir)), path);
919
+ try {
920
+ unlinkSync(path);
921
+ } catch (error) {
922
+ throw unwritable(`cannot remove ${path}`, error);
923
+ }
924
+ } else {
925
+ writeStoreFileAtomically(location, path, `${split.keptLines.join("\n")}\n`);
926
+ }
927
+ readCache.delete(path);
928
+ }
929
+
930
+ clearTraceStoreCache();
931
+ return planned;
932
+ },
933
+ { timeoutMs: options.timeoutMs },
934
+ );
935
+ }
936
+
937
+ function safeJson(line: string): unknown {
938
+ try {
939
+ return JSON.parse(line) as unknown;
940
+ } catch {
941
+ return null;
942
+ }
943
+ }
944
+
945
+ // ---------------------------------------------------------------------------
946
+ // Workspace classification and reassignment (WP-210, Design §3.7).
947
+ //
948
+ // A workspace the user removed from Paseo must not take its history with it,
949
+ // and it must not be guessed back onto a new workspace either: matching by
950
+ // repository path would merge two different lines of work the first time
951
+ // someone reuses a directory. So the store reports what it sees, and only a
952
+ // user action moves anything (REQ-057f).
953
+ // ---------------------------------------------------------------------------
954
+
955
+ /** Workspace ids that have a directory in the store. */
956
+ export function storedWorkspaceIds(location: TraceStoreLocation): string[] {
957
+ try {
958
+ return readdirSync(resolve(location.tracesDir), { withFileTypes: true })
959
+ .filter((entry) => entry.isDirectory())
960
+ .map((entry) => entry.name)
961
+ .filter((name) => WORKSPACE_ID_PATTERN.test(name))
962
+ .sort();
963
+ } catch {
964
+ return [];
965
+ }
966
+ }
967
+
968
+ /** What Paseo currently says about a workspace, as far as the store can tell. */
969
+ export interface WorkspaceClassification {
970
+ workspaceId: string;
971
+ state: WorkspaceState;
972
+ lastKnownName: string | null;
973
+ lastKnownDirectory: string | null;
974
+ }
975
+
976
+ /**
977
+ * Sorts stored workspaces into live / archived / orphaned.
978
+ *
979
+ * `listed === null` means the workspace list could not be read; everything is
980
+ * then `unknown`. Turning one failed call into "this workspace is gone" would
981
+ * tell the user their history is orphaned every time the daemon hiccups
982
+ * (REQ-057a).
983
+ */
984
+ export function classifyWorkspaces(
985
+ location: TraceStoreLocation,
986
+ listed: ReadonlyArray<{ id: string; archived: boolean }> | null,
987
+ ): WorkspaceClassification[] {
988
+ const byId = new Map(listed?.map((entry) => [entry.id, entry]) ?? []);
989
+ return storedWorkspaceIds(location).map((workspaceId) => {
990
+ const meta = readWorkspaceMeta(location, workspaceId);
991
+ const entry = byId.get(workspaceId);
992
+ const state: WorkspaceState =
993
+ listed === null
994
+ ? "unknown"
995
+ : entry === undefined
996
+ ? "orphaned"
997
+ : entry.archived
998
+ ? "archived"
999
+ : "live";
1000
+ return {
1001
+ workspaceId,
1002
+ state,
1003
+ lastKnownName: meta?.lastKnownName ?? null,
1004
+ lastKnownDirectory: meta?.lastKnownDirectory ?? null,
1005
+ };
1006
+ });
1007
+ }
1008
+
1009
+ /** How many traces and bytes a reassignment would move, or moved. */
1010
+ export interface ReassignOutcome {
1011
+ traces: number;
1012
+ bytes: number;
1013
+ }
1014
+
1015
+ function reassignInvalid(detail: string): DashboardError {
1016
+ return new DashboardError("E_TRACE_REASSIGN_INVALID", detail);
1017
+ }
1018
+
1019
+ /**
1020
+ * Moves every trace of `fromWorkspaceId` onto `toWorkspaceId`.
1021
+ *
1022
+ * An empty destination is a single `rename` of the directory: one syscall, so
1023
+ * an interruption cannot leave half the traces behind. A destination that
1024
+ * already has files is merged month by month with the same `dedupeRecords` key,
1025
+ * which is also what makes an interrupted merge harmless — the worst case is
1026
+ * both copies existing, and the reader keeps one (REQ-057e).
1027
+ *
1028
+ * The `workspaceId` inside each record is left alone: it is the historical
1029
+ * fact. Directory position is what decides which workspace a trace belongs to,
1030
+ * and the caller reports the difference as `reassignedFrom`.
1031
+ */
1032
+ export async function reassignWorkspace(
1033
+ location: TraceStoreLocation,
1034
+ fromWorkspaceId: string,
1035
+ toWorkspaceId: string,
1036
+ options: {
1037
+ dryRun?: boolean;
1038
+ /** Ids Paseo currently lists; the destination must be one of them. */
1039
+ destinationExists?: boolean;
1040
+ timeoutMs?: number;
1041
+ } = {},
1042
+ ): Promise<ReassignOutcome> {
1043
+ assertWorkspaceId(fromWorkspaceId);
1044
+ assertWorkspaceId(toWorkspaceId);
1045
+ if (fromWorkspaceId === toWorkspaceId) {
1046
+ throw reassignInvalid(`cannot reassign workspace ${fromWorkspaceId} onto itself`);
1047
+ }
1048
+ if (options.destinationExists === false) {
1049
+ throw reassignInvalid(`Paseo does not list a workspace ${toWorkspaceId} to reassign onto`);
1050
+ }
1051
+
1052
+ const sourceDir = dirname(storePath(location.tracesDir, fromWorkspaceId, "meta.json"));
1053
+ const destinationDir = dirname(storePath(location.tracesDir, toWorkspaceId, "meta.json"));
1054
+ const sourceFiles = monthlyFiles(location, fromWorkspaceId);
1055
+
1056
+ const moving: TraceRecord[] = [];
1057
+ let bytes = 0;
1058
+ for (const path of sourceFiles) {
1059
+ bytes += bytesOf(path);
1060
+ moving.push(...linesOf(path).records);
1061
+ }
1062
+ const planned: ReassignOutcome = { traces: countTraces(moving), bytes };
1063
+ if (options.dryRun === true) return planned;
1064
+
1065
+ // Both workspaces are mutated, so both locks are taken, always in id order
1066
+ // so two concurrent reassignments cannot deadlock against each other.
1067
+ const [firstId, secondId] = [fromWorkspaceId, toWorkspaceId].sort();
1068
+ return withWorkspaceLock(
1069
+ firstId!,
1070
+ () =>
1071
+ withWorkspaceLock(
1072
+ secondId!,
1073
+ () => {
1074
+ assertWritableSchema(location);
1075
+ const root = dirname(resolve(location.tracesDir));
1076
+ assertNoSymlinkOnPath(root, sourceDir);
1077
+ assertNoSymlinkOnPath(root, destinationDir);
1078
+
1079
+ let destinationExisting: string[];
1080
+ try {
1081
+ destinationExisting = readdirSync(destinationDir);
1082
+ } catch {
1083
+ destinationExisting = [];
1084
+ }
1085
+
1086
+ if (destinationExisting.length === 0) {
1087
+ try {
1088
+ rmSync(destinationDir, { recursive: true, force: true });
1089
+ renameSync(sourceDir, destinationDir);
1090
+ } catch (error) {
1091
+ throw unwritable(`cannot move ${sourceDir} to ${destinationDir}`, error);
1092
+ }
1093
+ clearTraceStoreCache();
1094
+ return planned;
1095
+ }
1096
+
1097
+ ensureStoreDir(location.tracesDir, destinationDir);
1098
+ for (const sourcePath of sourceFiles) {
1099
+ const name = sourcePath.slice(sourcePath.lastIndexOf("/") + 1);
1100
+ const destinationPath = storePath(location.tracesDir, toWorkspaceId, name);
1101
+ const destination = linesOf(destinationPath);
1102
+ const source = linesOf(sourcePath);
1103
+ const merged = dedupeRecords([...destination.records, ...source.records]);
1104
+ // Lines this version cannot read are carried over as they are: a
1105
+ // merge must not delete data it failed to understand.
1106
+ const body = [
1107
+ ...merged.map((record) => JSON.stringify(record)),
1108
+ ...destination.unreadable,
1109
+ ...source.unreadable,
1110
+ ].join("\n");
1111
+ writeStoreFileAtomically(location, destinationPath, `${body}\n`);
1112
+ try {
1113
+ unlinkSync(sourcePath);
1114
+ } catch (error) {
1115
+ throw unwritable(`cannot remove ${sourcePath} after merging it`, error);
1116
+ }
1117
+ }
1118
+ // The destination keeps its own metadata; the source directory goes.
1119
+ try {
1120
+ rmSync(sourceDir, { recursive: true, force: true });
1121
+ } catch (error) {
1122
+ throw unwritable(`cannot remove ${sourceDir}`, error);
1123
+ }
1124
+ clearTraceStoreCache();
1125
+ return planned;
1126
+ },
1127
+ { timeoutMs: options.timeoutMs },
1128
+ ),
1129
+ { timeoutMs: options.timeoutMs },
1130
+ );
1131
+ }
1132
+
1133
+ /** Records of one monthly file and the lines that are not records, uncached. */
1134
+ function linesOf(path: string): { records: TraceRecord[]; unreadable: string[] } {
1135
+ const split = splitMonthlyFile(path, { allOfWorkspace: true });
1136
+ return { records: split.dropped, unreadable: split.keptLines };
1137
+ }