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.
- package/LICENSE +21 -0
- package/README.md +53 -0
- package/client/agent-tree.ts +308 -0
- package/client/answer-state.ts +62 -0
- package/client/bead-chips.tsx +147 -0
- package/client/beads-header-button.ts +108 -0
- package/client/beads-model.ts +581 -0
- package/client/beads-screen.tsx +516 -0
- package/client/beads-tab.tsx +58 -0
- package/client/chat-card.tsx +636 -0
- package/client/chat-cards.ts +1038 -0
- package/client/dashboard-actions.tsx +255 -0
- package/client/dashboard-model.ts +947 -0
- package/client/dashboard-view.ts +215 -0
- package/client/dashboard.tsx +318 -0
- package/client/launch-manager.ts +323 -0
- package/client/launcher.tsx +516 -0
- package/client/markdown-view.tsx +112 -0
- package/client/markdown.ts +145 -0
- package/client/settings.tsx +104 -0
- package/client/setup-model.ts +552 -0
- package/client/setup-screen.tsx +913 -0
- package/client/slot.ts +47 -0
- package/client/tree.tsx +204 -0
- package/client/ui.tsx +262 -0
- package/client/waiting-pills-model.ts +156 -0
- package/client/waiting-pills.tsx +201 -0
- package/index.client.tsx +232 -0
- package/index.server.ts +168 -0
- package/package.json +35 -0
- package/paseo-plugin.json +6 -0
- package/roles/manager.md +181 -0
- package/roles/reviewer.md +160 -0
- package/roles/worker.md +407 -0
- package/server/agent-labels.ts +194 -0
- package/server/agent-role.ts +102 -0
- package/server/answer-marks.ts +120 -0
- package/server/bead-actions.ts +88 -0
- package/server/bead-work.ts +80 -0
- package/server/beads-store.ts +342 -0
- package/server/bm-report.ts +433 -0
- package/server/chat-peers.ts +65 -0
- package/server/chat-rpc.ts +122 -0
- package/server/chat-waiting.ts +182 -0
- package/server/collector.ts +629 -0
- package/server/config-writer.ts +222 -0
- package/server/cost.ts +88 -0
- package/server/dashboard-rpc.ts +662 -0
- package/server/fallback-detect.ts +183 -0
- package/server/fallback-handover.ts +365 -0
- package/server/fallback-manager.ts +170 -0
- package/server/fallback-reviewer.ts +198 -0
- package/server/fallback-rpc.ts +306 -0
- package/server/fallback-settings.ts +322 -0
- package/server/fallback-state.ts +518 -0
- package/server/fallback-switch.ts +191 -0
- package/server/fallback-wait.ts +188 -0
- package/server/format-check.ts +352 -0
- package/server/install-home.ts +187 -0
- package/server/live-timeline.ts +129 -0
- package/server/manager-instructions.ts +9 -0
- package/server/manager.ts +647 -0
- package/server/model-costs.ts +238 -0
- package/server/notice-queue.ts +315 -0
- package/server/notices.ts +81 -0
- package/server/paseo-cli.ts +115 -0
- package/server/provider-id.ts +12 -0
- package/server/review-budget.ts +208 -0
- package/server/reviewer-instructions.ts +9 -0
- package/server/role-choices.ts +161 -0
- package/server/role-extras.ts +270 -0
- package/server/role-hook.ts +347 -0
- package/server/role-mode.ts +397 -0
- package/server/role-settings-rpc.ts +325 -0
- package/server/roles.ts +96 -0
- package/server/settings-notices.ts +112 -0
- package/server/setup-rpc.ts +70 -0
- package/server/setup-skills.ts +121 -0
- package/server/setup-tools.ts +162 -0
- package/server/shell.ts +68 -0
- package/server/stop-propagation.ts +365 -0
- package/server/tools-check.ts +118 -0
- package/server/trace-store.ts +1137 -0
- package/server/traces.ts +1356 -0
- package/server/worker-instructions.ts +9 -0
- package/server/workflow-steps.ts +422 -0
- package/shared/bead-ids.ts +25 -0
- package/shared/bm-fallback.ts +91 -0
- package/shared/bm-format.ts +424 -0
- package/shared/bm-questions.ts +213 -0
- package/shared/bm-report.ts +433 -0
- package/shared/contracts.ts +1371 -0
- package/shared/fallback-patterns.ts +201 -0
- package/shared/fallback.ts +46 -0
- package/shared/new-request.ts +20 -0
- package/shared/order.ts +22 -0
- package/shared/prices.ts +65 -0
- package/shared/settings.ts +57 -0
- package/shared/sole-worker.ts +20 -0
- package/shared/version.ts +6 -0
- 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
|
+
}
|