@bermudi/pi-delegate 0.1.16 → 0.1.17
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/agents.ts +1 -1
- package/extension.ts +7 -2
- package/lifecycle.ts +17 -6
- package/package.json +1 -1
- package/schema.ts +25 -8
- package/telemetry.ts +86 -11
- package/test-preload.ts +26 -0
- package/workspace.ts +92 -50
package/agents.ts
CHANGED
|
@@ -151,7 +151,7 @@ export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
|
|
|
151
151
|
systemPrompt:
|
|
152
152
|
"Review the current snapshot for correctness, regressions, security problems, and missing tests. Do not modify the source project. Run focused checks when useful. Report actionable findings ordered by severity, with concrete paths and locations. If there are no material findings, say so plainly; do not invent issues or merely summarize the implementation.",
|
|
153
153
|
builtin: true,
|
|
154
|
-
workspace: "
|
|
154
|
+
workspace: "shared",
|
|
155
155
|
},
|
|
156
156
|
};
|
|
157
157
|
|
package/extension.ts
CHANGED
|
@@ -59,10 +59,14 @@ type ShutdownDrainResult =
|
|
|
59
59
|
/** Bridge the promoted top-level session RPC (`sessionAction` + `sessionId`)
|
|
60
60
|
* to the internal single-entry batch the runner executes. Internal only — the
|
|
61
61
|
* public task schema has no `sessionAction`; validation guarantees close
|
|
62
|
-
* carries a sessionId by this point.
|
|
62
|
+
* carries a sessionId by this point. `list` never carries a sessionId: it does
|
|
63
|
+
* not target a specific session, and attaching one would make validateTasks
|
|
64
|
+
* run busy/quarantine checks that can fail the list call. */
|
|
63
65
|
function bridgeSessionControlTask(params: DelegateArguments): DispatchableTask {
|
|
64
66
|
return {
|
|
65
|
-
...(params.
|
|
67
|
+
...(params.sessionAction === "close" && params.sessionId
|
|
68
|
+
? { sessionId: params.sessionId }
|
|
69
|
+
: {}),
|
|
66
70
|
sessionAction: params.sessionAction,
|
|
67
71
|
};
|
|
68
72
|
}
|
|
@@ -193,6 +197,7 @@ export default function delegateExtension(pi: ExtensionAPI): void {
|
|
|
193
197
|
mode,
|
|
194
198
|
taskCount,
|
|
195
199
|
parentSessionFile,
|
|
200
|
+
parentCwd: ctx.cwd,
|
|
196
201
|
});
|
|
197
202
|
|
|
198
203
|
function failCall(): void {
|
package/lifecycle.ts
CHANGED
|
@@ -142,9 +142,18 @@ function scratchSetupFailureResult(
|
|
|
142
142
|
if (signalAborted) {
|
|
143
143
|
message = "Aborted";
|
|
144
144
|
failureKind = "cancelled";
|
|
145
|
-
} else
|
|
146
|
-
|
|
147
|
-
|
|
145
|
+
} else {
|
|
146
|
+
if (error instanceof ScratchDeadlineError) {
|
|
147
|
+
message = formatDeadlineExceededError(task.deadlineMs ?? 0);
|
|
148
|
+
failureKind = "deadline_exceeded";
|
|
149
|
+
}
|
|
150
|
+
// Every non-aborted setup failure names its remedy in one place, so the
|
|
151
|
+
// paths (platform guard, pre-check, reflink, worktree, deadline) cannot
|
|
152
|
+
// drift. The symlink message already carries remedy text naming
|
|
153
|
+
// workspace "shared" — skip the append there to avoid saying it twice.
|
|
154
|
+
if (!message.includes('workspace "shared"')) {
|
|
155
|
+
message = `${message} — to retry without scratch containment, use workspace: "shared".`;
|
|
156
|
+
}
|
|
148
157
|
}
|
|
149
158
|
return {
|
|
150
159
|
...failTask(task, message, undefined, failureKind),
|
|
@@ -433,9 +442,10 @@ function recordTaskOutcome(
|
|
|
433
442
|
* that model, so same-model retry is pointless. Distinguished from a bare
|
|
434
443
|
* transient 429 (per-minute rate limit) by the *account-level* wording:
|
|
435
444
|
* "usage limit", "quota", "upgrade for higher limits", "exceeded your
|
|
436
|
-
* … quota",
|
|
437
|
-
* port like 4019 doesn't false-positive)
|
|
438
|
-
*
|
|
445
|
+
* … quota", an auth/credential failure (401/403 with word boundaries so a
|
|
446
|
+
* port like 4019 doesn't false-positive), or an invalidated OAuth token
|
|
447
|
+
* ("invalid"/"invalidated" anywhere alongside "oauth token"). The parent
|
|
448
|
+
* should resume with a different `model` (see `resumeFrom` + `model`). */
|
|
439
449
|
export function isModelAttributableError(error: string | undefined): boolean {
|
|
440
450
|
if (!error) return false;
|
|
441
451
|
const e = error.toLowerCase();
|
|
@@ -454,6 +464,7 @@ export function isModelAttributableError(error: string | undefined): boolean {
|
|
|
454
464
|
e.includes("authentication") ||
|
|
455
465
|
e.includes("invalid api key") ||
|
|
456
466
|
(e.includes("api key") && e.includes("invalid")) ||
|
|
467
|
+
(e.includes("oauth token") && e.includes("invalid")) ||
|
|
457
468
|
/\b401\b/.test(e) ||
|
|
458
469
|
/\b403\b/.test(e)
|
|
459
470
|
);
|
package/package.json
CHANGED
package/schema.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
import { Type, type SchemaOptions } from "@sinclair/typebox";
|
|
2
|
-
import {
|
|
3
|
-
VALID_THINKING_LEVELS,
|
|
4
|
-
isSessionControlAction,
|
|
5
|
-
} from "./constants.ts";
|
|
2
|
+
import { VALID_THINKING_LEVELS, isSessionControlAction } from "./constants.ts";
|
|
6
3
|
import type { DelegateArguments } from "./types.ts";
|
|
7
4
|
|
|
8
5
|
// JSON Schema string enum that keeps the literal union in `Static<>`.
|
|
@@ -247,13 +244,33 @@ function validateSessionMode(params: DelegateArguments): string | undefined {
|
|
|
247
244
|
if (sessionAction === "close" && !params.sessionId) {
|
|
248
245
|
return "sessionAction 'close' requires sessionId.";
|
|
249
246
|
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
247
|
+
// Skip only fields carrying their documented default/no-op value: a caller
|
|
248
|
+
// (or a schema validator that materialises defaults) may spell out
|
|
249
|
+
// `async: false`, `force: false`, or `tasks: []` explicitly. Other false or
|
|
250
|
+
// empty-array values remain foreign so future fields cannot bypass this mode
|
|
251
|
+
// boundary merely by sharing the same value shape.
|
|
252
|
+
const foreign = Object.keys(rawParams).filter((key) => {
|
|
253
|
+
if (SESSION_MODE_FIELDS.has(key) || rawParams[key] === undefined) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
if ((key === "async" || key === "force") && rawParams[key] === false) {
|
|
257
|
+
return false;
|
|
258
|
+
}
|
|
259
|
+
if (
|
|
260
|
+
key === "tasks" &&
|
|
261
|
+
Array.isArray(rawParams[key]) &&
|
|
262
|
+
rawParams[key].length === 0
|
|
263
|
+
) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
return true;
|
|
267
|
+
});
|
|
253
268
|
if (foreign.length) {
|
|
254
269
|
return `sessionAction '${sessionAction}' cannot be combined with ${foreign
|
|
255
270
|
.map((field) => `'${field}'`)
|
|
256
|
-
.join(
|
|
271
|
+
.join(
|
|
272
|
+
", ",
|
|
273
|
+
)}; run it alone — a session action takes only 'sessionAction' (plus 'sessionId' for 'close').`;
|
|
257
274
|
}
|
|
258
275
|
return undefined;
|
|
259
276
|
}
|
package/telemetry.ts
CHANGED
|
@@ -7,7 +7,12 @@ import * as crypto from "node:crypto";
|
|
|
7
7
|
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import type { DatabaseSync, StatementSync } from "node:sqlite";
|
|
9
9
|
import { getTelemetryConfig } from "./config.ts";
|
|
10
|
-
import type {
|
|
10
|
+
import type {
|
|
11
|
+
ResolvedTask,
|
|
12
|
+
TaskProgress,
|
|
13
|
+
TaskResult,
|
|
14
|
+
WorkspaceMode,
|
|
15
|
+
} from "./types.ts";
|
|
11
16
|
|
|
12
17
|
export interface CallRecord {
|
|
13
18
|
id: string;
|
|
@@ -22,6 +27,7 @@ export interface CallRecord {
|
|
|
22
27
|
total_tokens: number;
|
|
23
28
|
total_cost: number;
|
|
24
29
|
parent_session_file: string | undefined;
|
|
30
|
+
parent_cwd: string | undefined;
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
export interface TaskRecord {
|
|
@@ -35,6 +41,7 @@ export interface TaskRecord {
|
|
|
35
41
|
model: string | undefined;
|
|
36
42
|
thinking: string | undefined;
|
|
37
43
|
tools: string;
|
|
44
|
+
workspace: WorkspaceMode | undefined;
|
|
38
45
|
outcome: string;
|
|
39
46
|
failure_kind: string | undefined;
|
|
40
47
|
duration_ms: number;
|
|
@@ -46,6 +53,7 @@ export interface TaskRecord {
|
|
|
46
53
|
output_chars: number;
|
|
47
54
|
session_file: string | undefined;
|
|
48
55
|
async: number;
|
|
56
|
+
error_snippet: string | undefined;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
export interface TelemetryRecorder {
|
|
@@ -87,13 +95,32 @@ let telemetryGeneration = 0;
|
|
|
87
95
|
let telemetryClosed = false;
|
|
88
96
|
let testingRecorder: TelemetryRecorder | undefined;
|
|
89
97
|
|
|
90
|
-
const TELEMETRY_SCHEMA_VERSION =
|
|
98
|
+
const TELEMETRY_SCHEMA_VERSION = 3;
|
|
91
99
|
const SQLITE_BUSY_TIMEOUT_MS = 5_000;
|
|
92
100
|
|
|
93
101
|
function defaultDbPath(): string {
|
|
94
102
|
return path.join(os.homedir(), ".pi", "agent", "delegate-usage.db");
|
|
95
103
|
}
|
|
96
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Telemetry database path resolution: explicit config wins, then the
|
|
107
|
+
* DELEGATE_TELEMETRY_DB environment variable, then the default user path.
|
|
108
|
+
* The env var exists so test runs can redirect the default destination away
|
|
109
|
+
* from the production database without touching user config — the pi test
|
|
110
|
+
* harness builds real sessions in-process, so process.env is the extension's
|
|
111
|
+
* environment (see test-preload.ts). Config beats env on purpose: telemetry
|
|
112
|
+
* tests drive backends through explicit config dbPath values, including
|
|
113
|
+
* spawned Node children that inherit this variable.
|
|
114
|
+
*/
|
|
115
|
+
function resolveTelemetryDbPath(
|
|
116
|
+
config: import("./config.ts").TelemetryConfig,
|
|
117
|
+
): string {
|
|
118
|
+
if (config.dbPath) return config.dbPath;
|
|
119
|
+
const fromEnv = process.env.DELEGATE_TELEMETRY_DB;
|
|
120
|
+
if (fromEnv) return fromEnv;
|
|
121
|
+
return defaultDbPath();
|
|
122
|
+
}
|
|
123
|
+
|
|
97
124
|
function findPackageJson(startFile: string): string | undefined {
|
|
98
125
|
const candidates = [
|
|
99
126
|
path.join(path.dirname(startFile), "package.json"),
|
|
@@ -175,6 +202,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
175
202
|
["total_tokens", "INTEGER"],
|
|
176
203
|
["total_cost", "REAL"],
|
|
177
204
|
["parent_session_file", "TEXT"],
|
|
205
|
+
["parent_cwd", "TEXT"],
|
|
178
206
|
],
|
|
179
207
|
createSql: `
|
|
180
208
|
CREATE TABLE IF NOT EXISTS calls(
|
|
@@ -189,7 +217,8 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
189
217
|
status TEXT,
|
|
190
218
|
total_tokens INTEGER,
|
|
191
219
|
total_cost REAL,
|
|
192
|
-
parent_session_file TEXT
|
|
220
|
+
parent_session_file TEXT,
|
|
221
|
+
parent_cwd TEXT
|
|
193
222
|
);
|
|
194
223
|
`,
|
|
195
224
|
},
|
|
@@ -206,6 +235,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
206
235
|
["model", "TEXT"],
|
|
207
236
|
["thinking", "TEXT"],
|
|
208
237
|
["tools", "TEXT"],
|
|
238
|
+
["workspace", "TEXT"],
|
|
209
239
|
["outcome", "TEXT"],
|
|
210
240
|
["failure_kind", "TEXT"],
|
|
211
241
|
["duration_ms", "INTEGER"],
|
|
@@ -217,6 +247,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
217
247
|
["output_chars", "INTEGER"],
|
|
218
248
|
["session_file", "TEXT"],
|
|
219
249
|
["async", "INTEGER"],
|
|
250
|
+
["error_snippet", "TEXT"],
|
|
220
251
|
],
|
|
221
252
|
createSql: `
|
|
222
253
|
CREATE TABLE IF NOT EXISTS tasks(
|
|
@@ -230,6 +261,7 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
230
261
|
model TEXT,
|
|
231
262
|
thinking TEXT,
|
|
232
263
|
tools TEXT,
|
|
264
|
+
workspace TEXT,
|
|
233
265
|
outcome TEXT,
|
|
234
266
|
failure_kind TEXT,
|
|
235
267
|
duration_ms INTEGER,
|
|
@@ -240,7 +272,8 @@ const TELEMETRY_TABLES: readonly TelemetryTable[] = [
|
|
|
240
272
|
prompt_chars INTEGER,
|
|
241
273
|
output_chars INTEGER,
|
|
242
274
|
session_file TEXT,
|
|
243
|
-
async INTEGER
|
|
275
|
+
async INTEGER,
|
|
276
|
+
error_snippet TEXT
|
|
244
277
|
);
|
|
245
278
|
`,
|
|
246
279
|
},
|
|
@@ -312,6 +345,26 @@ function initSchema(db: DatabaseSync): void {
|
|
|
312
345
|
}
|
|
313
346
|
}
|
|
314
347
|
|
|
348
|
+
// Backfill legacy rows that predate the workspace column. New rows store
|
|
349
|
+
// 'shared' explicitly, but ALTER TABLE leaves existing rows as NULL. Without
|
|
350
|
+
// this, GROUP BY workspace splits NULL vs 'shared' for the same semantics.
|
|
351
|
+
// Reviewer defaulted to scratch when these rows were recorded, so preserve
|
|
352
|
+
// that heuristic for historical rows; everything else was shared by
|
|
353
|
+
// default. This is idempotent and runs inside the same transaction as the schema changes so a crash before COMMIT
|
|
354
|
+
// retries cleanly.
|
|
355
|
+
try {
|
|
356
|
+
db.exec(
|
|
357
|
+
"UPDATE tasks SET workspace='scratch' WHERE workspace IS NULL AND agent='reviewer'",
|
|
358
|
+
);
|
|
359
|
+
db.exec("UPDATE tasks SET workspace='shared' WHERE workspace IS NULL");
|
|
360
|
+
} catch {
|
|
361
|
+
// tasks may not exist on first run (fresh DB) or workspace column may
|
|
362
|
+
// have just been created via CREATE TABLE — UPDATE affecting 0 rows is fine.
|
|
363
|
+
// Any real error will surface on the next write and disable telemetry
|
|
364
|
+
// via the existing fail-open path, so swallowing here preserves the
|
|
365
|
+
// repair-loop's best-effort nature.
|
|
366
|
+
}
|
|
367
|
+
|
|
315
368
|
// Set the marker only after every table/column operation succeeded.
|
|
316
369
|
db.exec(`PRAGMA user_version = ${TELEMETRY_SCHEMA_VERSION}`);
|
|
317
370
|
db.exec("COMMIT");
|
|
@@ -346,15 +399,16 @@ class SqliteTelemetryBackend implements TelemetryBackend {
|
|
|
346
399
|
this.insertCall = db.prepare(
|
|
347
400
|
`INSERT OR REPLACE INTO calls(
|
|
348
401
|
id, ts, version, pi_version, mode, parent_model, task_count,
|
|
349
|
-
wall_ms, status, total_tokens, total_cost, parent_session_file
|
|
350
|
-
|
|
402
|
+
wall_ms, status, total_tokens, total_cost, parent_session_file,
|
|
403
|
+
parent_cwd
|
|
404
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
351
405
|
);
|
|
352
406
|
this.insertTask = db.prepare(
|
|
353
407
|
`INSERT OR REPLACE INTO tasks(
|
|
354
408
|
id, call_id, ts, version, pi_version, idx, agent, model, thinking,
|
|
355
|
-
tools, outcome, failure_kind, duration_ms, tokens, cost, tool_uses,
|
|
356
|
-
retries, prompt_chars, output_chars, session_file, async
|
|
357
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
409
|
+
tools, workspace, outcome, failure_kind, duration_ms, tokens, cost, tool_uses,
|
|
410
|
+
retries, prompt_chars, output_chars, session_file, async, error_snippet
|
|
411
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
358
412
|
);
|
|
359
413
|
}
|
|
360
414
|
|
|
@@ -373,6 +427,7 @@ class SqliteTelemetryBackend implements TelemetryBackend {
|
|
|
373
427
|
record.total_tokens,
|
|
374
428
|
record.total_cost,
|
|
375
429
|
record.parent_session_file ?? null,
|
|
430
|
+
record.parent_cwd ?? null,
|
|
376
431
|
);
|
|
377
432
|
} catch (error) {
|
|
378
433
|
this.onFailure("recordCall", error);
|
|
@@ -392,6 +447,7 @@ class SqliteTelemetryBackend implements TelemetryBackend {
|
|
|
392
447
|
record.model ?? null,
|
|
393
448
|
record.thinking ?? null,
|
|
394
449
|
record.tools,
|
|
450
|
+
record.workspace ?? null,
|
|
395
451
|
record.outcome,
|
|
396
452
|
record.failure_kind ?? null,
|
|
397
453
|
record.duration_ms,
|
|
@@ -403,6 +459,7 @@ class SqliteTelemetryBackend implements TelemetryBackend {
|
|
|
403
459
|
record.output_chars,
|
|
404
460
|
record.session_file ?? null,
|
|
405
461
|
record.async,
|
|
462
|
+
record.error_snippet ?? null,
|
|
406
463
|
);
|
|
407
464
|
} catch (error) {
|
|
408
465
|
this.onFailure("recordTask", error);
|
|
@@ -453,7 +510,7 @@ function backendIdentity(
|
|
|
453
510
|
config: import("./config.ts").TelemetryConfig,
|
|
454
511
|
): string | undefined {
|
|
455
512
|
if (config.enabled === false) return undefined;
|
|
456
|
-
return config
|
|
513
|
+
return resolveTelemetryDbPath(config);
|
|
457
514
|
}
|
|
458
515
|
|
|
459
516
|
function disableActiveBackend(
|
|
@@ -495,7 +552,7 @@ function openSqliteBackend(
|
|
|
495
552
|
if (config.enabled === false || telemetryClosed) return undefined;
|
|
496
553
|
if (!DatabaseSyncCtor) return undefined;
|
|
497
554
|
|
|
498
|
-
const dbPath = config
|
|
555
|
+
const dbPath = resolveTelemetryDbPath(config);
|
|
499
556
|
let db: DatabaseSync | undefined;
|
|
500
557
|
try {
|
|
501
558
|
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
@@ -587,6 +644,8 @@ export interface CallSpanInput {
|
|
|
587
644
|
mode: string;
|
|
588
645
|
taskCount: number;
|
|
589
646
|
parentSessionFile?: string;
|
|
647
|
+
/** Parent working directory at dispatch — one dispatch, one cwd. */
|
|
648
|
+
parentCwd?: string;
|
|
590
649
|
}
|
|
591
650
|
|
|
592
651
|
export interface CallSpanFinish {
|
|
@@ -637,6 +696,7 @@ class CallSpanImpl implements CallSpan {
|
|
|
637
696
|
total_tokens: 0,
|
|
638
697
|
total_cost: 0,
|
|
639
698
|
parent_session_file: this.input.parentSessionFile,
|
|
699
|
+
parent_cwd: this.input.parentCwd,
|
|
640
700
|
};
|
|
641
701
|
}
|
|
642
702
|
|
|
@@ -700,6 +760,19 @@ function outcomeFromResult(result: TaskResult): string {
|
|
|
700
760
|
return "success";
|
|
701
761
|
}
|
|
702
762
|
|
|
763
|
+
/** Error text only — never prompt or output content — whitespace-collapsed
|
|
764
|
+
* and capped, so failure classification becomes a query instead of
|
|
765
|
+
* duration-based guessing. */
|
|
766
|
+
const ERROR_SNIPPET_MAX_CHARS = 200;
|
|
767
|
+
|
|
768
|
+
function errorSnippetOf(result: TaskResult): string | undefined {
|
|
769
|
+
if (!result.error) return undefined;
|
|
770
|
+
const collapsed = result.error.replace(/\s+/g, " ").trim();
|
|
771
|
+
if (collapsed.length === 0) return undefined;
|
|
772
|
+
if (collapsed.length <= ERROR_SNIPPET_MAX_CHARS) return collapsed;
|
|
773
|
+
return `${collapsed.slice(0, ERROR_SNIPPET_MAX_CHARS - 1)}…`;
|
|
774
|
+
}
|
|
775
|
+
|
|
703
776
|
export function recordTask(input: TaskSpanInput): string | undefined {
|
|
704
777
|
const b = input.telemetryConfig
|
|
705
778
|
? getBackendForConfig(
|
|
@@ -723,6 +796,7 @@ export function recordTask(input: TaskSpanInput): string | undefined {
|
|
|
723
796
|
model: task.model?.id,
|
|
724
797
|
thinking: task.thinking,
|
|
725
798
|
tools: JSON.stringify(task.tools),
|
|
799
|
+
workspace: task.workspace ?? "shared",
|
|
726
800
|
outcome: outcomeFromResult(result),
|
|
727
801
|
failure_kind: result.failureKind,
|
|
728
802
|
duration_ms: result.durationMs,
|
|
@@ -734,6 +808,7 @@ export function recordTask(input: TaskSpanInput): string | undefined {
|
|
|
734
808
|
output_chars: result.output?.length ?? 0,
|
|
735
809
|
session_file: result.sessionFile,
|
|
736
810
|
async: async ? 1 : 0,
|
|
811
|
+
error_snippet: errorSnippetOf(result),
|
|
737
812
|
};
|
|
738
813
|
b.recordTask(record);
|
|
739
814
|
return record.id;
|
package/test-preload.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-run telemetry isolation.
|
|
3
|
+
*
|
|
4
|
+
* The pi test harness builds real sessions in-process, so this process's
|
|
5
|
+
* environment IS the extension's environment. telemetry.ts resolves the
|
|
6
|
+
* database path as config.dbPath > DELEGATE_TELEMETRY_DB > default; setting
|
|
7
|
+
* the env var here redirects every test that runs on the default path to a
|
|
8
|
+
* throwaway directory instead of the production ~/.pi/agent/delegate-usage.db.
|
|
9
|
+
* Tests that set an explicit config dbPath are unaffected (config wins).
|
|
10
|
+
*
|
|
11
|
+
* Loaded once per `bun test` invocation via bunfig.toml [test] preload.
|
|
12
|
+
*/
|
|
13
|
+
import * as fs from "node:fs";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import * as path from "node:path";
|
|
16
|
+
|
|
17
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "delegate-telemetry-test-"));
|
|
18
|
+
process.env.DELEGATE_TELEMETRY_DB = path.join(dir, "usage.db");
|
|
19
|
+
|
|
20
|
+
process.on("exit", () => {
|
|
21
|
+
try {
|
|
22
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
23
|
+
} catch {
|
|
24
|
+
// Best-effort cleanup; a leftover temp dir is harmless.
|
|
25
|
+
}
|
|
26
|
+
});
|
package/workspace.ts
CHANGED
|
@@ -57,6 +57,7 @@ class CommandError extends Error {
|
|
|
57
57
|
constructor(
|
|
58
58
|
message: string,
|
|
59
59
|
readonly stderr: string,
|
|
60
|
+
readonly file: string,
|
|
60
61
|
options: ErrorOptions,
|
|
61
62
|
) {
|
|
62
63
|
super(message, options);
|
|
@@ -85,6 +86,7 @@ function runFile(
|
|
|
85
86
|
new CommandError(
|
|
86
87
|
detail ? `${file}: ${detail}` : `${file}: ${error.message}`,
|
|
87
88
|
detail,
|
|
89
|
+
file,
|
|
88
90
|
{ cause: error },
|
|
89
91
|
),
|
|
90
92
|
);
|
|
@@ -193,10 +195,16 @@ async function replaceSymlink(linkPath: string, target: string): Promise<void> {
|
|
|
193
195
|
}
|
|
194
196
|
}
|
|
195
197
|
|
|
196
|
-
/** Validate
|
|
197
|
-
|
|
198
|
+
/** Validate a scratch candidate tree — the source before copying (read-only
|
|
199
|
+
* fast-fail) or the completed copy before any subagent receives its path
|
|
200
|
+
* (authority, with the symlink-retarget side effect). One rule, two timings:
|
|
201
|
+
* the blockers are identical, so the pre-check cannot drift from the copy
|
|
202
|
+
* check, and the copy check remains authoritative for trees that change
|
|
203
|
+
* mid-copy. */
|
|
204
|
+
async function validateScratchTree(
|
|
198
205
|
root: string,
|
|
199
206
|
sourceRoot: string,
|
|
207
|
+
retargetLinks: boolean,
|
|
200
208
|
signal: AbortSignal,
|
|
201
209
|
parentSignal: AbortSignal | undefined,
|
|
202
210
|
): Promise<void> {
|
|
@@ -234,7 +242,8 @@ async function validateCopiedTree(
|
|
|
234
242
|
// `cp --archive` copies link text verbatim, so an absolute in-project
|
|
235
243
|
// link (bun/pnpm-style local package installs) still resolves to the
|
|
236
244
|
// real tree from inside the copy. Retarget it at its copied counterpart
|
|
237
|
-
// instead of failing: the link keeps working and stays disposable.
|
|
245
|
+
// instead of failing: the link keeps working and stays disposable. The
|
|
246
|
+
// pre-check runs the same rule read-only against the source.
|
|
238
247
|
const relinked = relinkTargetIntoCopy(
|
|
239
248
|
candidate,
|
|
240
249
|
target,
|
|
@@ -242,6 +251,7 @@ async function validateCopiedTree(
|
|
|
242
251
|
sourceRoot,
|
|
243
252
|
);
|
|
244
253
|
if (relinked !== undefined) {
|
|
254
|
+
if (!retargetLinks) continue;
|
|
245
255
|
try {
|
|
246
256
|
await replaceSymlink(candidate, relinked);
|
|
247
257
|
} catch (error) {
|
|
@@ -262,6 +272,58 @@ async function validateCopiedTree(
|
|
|
262
272
|
}
|
|
263
273
|
}
|
|
264
274
|
|
|
275
|
+
/** Reject Git metadata that redirects the worktree or git dirs outside `root`
|
|
276
|
+
* (core.worktree, commondir pointers, gitdir redirects). Runs against the
|
|
277
|
+
* source before copying and against the copy afterwards — a source that is
|
|
278
|
+
* self-consistent can still copy into a copy that points back at the source. */
|
|
279
|
+
async function assertGitMetadataContained(
|
|
280
|
+
root: string,
|
|
281
|
+
signal: AbortSignal,
|
|
282
|
+
): Promise<void> {
|
|
283
|
+
const hasGitDir = await fs.promises
|
|
284
|
+
.stat(path.join(root, ".git"))
|
|
285
|
+
.then((stat) => stat.isDirectory(), () => false);
|
|
286
|
+
if (!hasGitDir) return;
|
|
287
|
+
const effectiveWorktree = path.resolve(
|
|
288
|
+
(
|
|
289
|
+
await runFile("git", ["rev-parse", "--show-toplevel"], {
|
|
290
|
+
cwd: root,
|
|
291
|
+
signal,
|
|
292
|
+
timeout: 5000,
|
|
293
|
+
})
|
|
294
|
+
).trim(),
|
|
295
|
+
);
|
|
296
|
+
const effectiveGitDir = path.resolve(
|
|
297
|
+
root,
|
|
298
|
+
(
|
|
299
|
+
await runFile("git", ["rev-parse", "--absolute-git-dir"], {
|
|
300
|
+
cwd: root,
|
|
301
|
+
signal,
|
|
302
|
+
timeout: 5000,
|
|
303
|
+
})
|
|
304
|
+
).trim(),
|
|
305
|
+
);
|
|
306
|
+
const effectiveCommonDir = path.resolve(
|
|
307
|
+
effectiveGitDir,
|
|
308
|
+
(
|
|
309
|
+
await runFile("git", ["rev-parse", "--git-common-dir"], {
|
|
310
|
+
cwd: root,
|
|
311
|
+
signal,
|
|
312
|
+
timeout: 5000,
|
|
313
|
+
})
|
|
314
|
+
).trim(),
|
|
315
|
+
);
|
|
316
|
+
if (
|
|
317
|
+
effectiveWorktree !== root ||
|
|
318
|
+
!isWithin(root, effectiveGitDir) ||
|
|
319
|
+
!isWithin(root, effectiveCommonDir)
|
|
320
|
+
) {
|
|
321
|
+
throw new ScratchSetupError(
|
|
322
|
+
"Scratch workspace Git configuration redirects its worktree or metadata outside the copied project.",
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
265
327
|
function isWithin(root: string, candidate: string): boolean {
|
|
266
328
|
const relative = path.relative(root, candidate);
|
|
267
329
|
return (
|
|
@@ -739,6 +801,20 @@ export async function createScratchWorkspace(
|
|
|
739
801
|
);
|
|
740
802
|
}
|
|
741
803
|
|
|
804
|
+
// Fast-fail on blockers that are knowable before anything is created:
|
|
805
|
+
// the same rule the copy validation enforces, run read-only against the
|
|
806
|
+
// source. A rejection here costs milliseconds instead of a doomed
|
|
807
|
+
// reflink copy, and no lease/container is left behind. The post-copy
|
|
808
|
+
// validation below stays the authority — the tree can change mid-copy.
|
|
809
|
+
await validateScratchTree(
|
|
810
|
+
sourceRoot,
|
|
811
|
+
sourceRoot,
|
|
812
|
+
false,
|
|
813
|
+
controller.signal,
|
|
814
|
+
signal,
|
|
815
|
+
);
|
|
816
|
+
await assertGitMetadataContained(sourceRoot, controller.signal);
|
|
817
|
+
|
|
742
818
|
containerDir = path.join(path.dirname(sourceRoot), SCRATCH_CONTAINER_NAME);
|
|
743
819
|
const uid = process.getuid?.();
|
|
744
820
|
await ensureScratchContainer(containerDir, uid);
|
|
@@ -777,57 +853,14 @@ export async function createScratchWorkspace(
|
|
|
777
853
|
// GNU cp --archive applies the source root's mode to the destination.
|
|
778
854
|
// Restore the private boundary after it has finished copying metadata.
|
|
779
855
|
await fs.promises.chmod(scratchRoot, 0o700);
|
|
780
|
-
await
|
|
856
|
+
await validateScratchTree(
|
|
781
857
|
scratchRoot,
|
|
782
858
|
sourceRoot,
|
|
859
|
+
true,
|
|
783
860
|
controller.signal,
|
|
784
861
|
signal,
|
|
785
862
|
);
|
|
786
|
-
|
|
787
|
-
await fs.promises.stat(path.join(scratchRoot, ".git")).then(
|
|
788
|
-
(stat) => stat.isDirectory(),
|
|
789
|
-
() => false,
|
|
790
|
-
)
|
|
791
|
-
) {
|
|
792
|
-
const effectiveWorktree = path.resolve(
|
|
793
|
-
(
|
|
794
|
-
await runFile("git", ["rev-parse", "--show-toplevel"], {
|
|
795
|
-
cwd: scratchRoot,
|
|
796
|
-
signal: controller.signal,
|
|
797
|
-
timeout: 5000,
|
|
798
|
-
})
|
|
799
|
-
).trim(),
|
|
800
|
-
);
|
|
801
|
-
const effectiveGitDir = path.resolve(
|
|
802
|
-
scratchRoot,
|
|
803
|
-
(
|
|
804
|
-
await runFile("git", ["rev-parse", "--absolute-git-dir"], {
|
|
805
|
-
cwd: scratchRoot,
|
|
806
|
-
signal: controller.signal,
|
|
807
|
-
timeout: 5000,
|
|
808
|
-
})
|
|
809
|
-
).trim(),
|
|
810
|
-
);
|
|
811
|
-
const effectiveCommonDir = path.resolve(
|
|
812
|
-
effectiveGitDir,
|
|
813
|
-
(
|
|
814
|
-
await runFile("git", ["rev-parse", "--git-common-dir"], {
|
|
815
|
-
cwd: scratchRoot,
|
|
816
|
-
signal: controller.signal,
|
|
817
|
-
timeout: 5000,
|
|
818
|
-
})
|
|
819
|
-
).trim(),
|
|
820
|
-
);
|
|
821
|
-
if (
|
|
822
|
-
effectiveWorktree !== scratchRoot ||
|
|
823
|
-
!isWithin(scratchRoot, effectiveGitDir) ||
|
|
824
|
-
!isWithin(scratchRoot, effectiveCommonDir)
|
|
825
|
-
) {
|
|
826
|
-
throw new ScratchSetupError(
|
|
827
|
-
"Scratch workspace Git configuration redirects its worktree or metadata outside the copied project.",
|
|
828
|
-
);
|
|
829
|
-
}
|
|
830
|
-
}
|
|
863
|
+
await assertGitMetadataContained(scratchRoot, controller.signal);
|
|
831
864
|
throwIfSetupCancelled(controller.signal, signal);
|
|
832
865
|
// Keep the copied project writable, but make its private parent immutable
|
|
833
866
|
// to ordinary task commands. `mv "$PWD" …` then cannot unlink the project
|
|
@@ -865,8 +898,17 @@ export async function createScratchWorkspace(
|
|
|
865
898
|
);
|
|
866
899
|
}
|
|
867
900
|
if (error instanceof ScratchSetupError) throw error;
|
|
901
|
+
// Surface the failed command's own stderr: the generic message's
|
|
902
|
+
// reflink guidance is a guess, and the real cp failure (or a btrfs
|
|
903
|
+
// project failing for an unrelated reason) is only visible in stderr.
|
|
904
|
+
let commandDetail = "";
|
|
905
|
+
if (error instanceof CommandError) {
|
|
906
|
+
const line = error.stderr.split("\n", 1)[0]?.trim();
|
|
907
|
+
if (line) commandDetail = ` ${error.file} failed: ${line}`;
|
|
908
|
+
}
|
|
868
909
|
throw new Error(
|
|
869
|
-
"Could not create a CoW scratch workspace. The project and scratch directory must be on a reflink-capable filesystem (for example Btrfs)."
|
|
910
|
+
"Could not create a CoW scratch workspace. The project and scratch directory must be on a reflink-capable filesystem (for example Btrfs)." +
|
|
911
|
+
commandDetail,
|
|
870
912
|
{ cause: error },
|
|
871
913
|
);
|
|
872
914
|
} finally {
|