@app-vitals/agent-runtime 0.2.1 → 0.3.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/agent-features-ref.ts +32 -0
- package/agent-features-ref.unit.test.ts +21 -0
- package/boot.ts +25 -0
- package/cron-handler.ts +31 -0
- package/cron-handler.unit.test.ts +58 -0
- package/package.json +3 -3
- package/transcript-uploader.ts +158 -0
- package/transcript-uploader.unit.test.ts +217 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/agent-features-ref.ts
|
|
3
|
+
*
|
|
4
|
+
* A tiny mutable box holding the `features` object from the most recently
|
|
5
|
+
* synced config bundle (COS-43.1), so consumers such as the transcript
|
|
6
|
+
* uploader read a live view without closing over one syncConfig() tick.
|
|
7
|
+
* Absent until the first successful sync (and when talking to an older admin
|
|
8
|
+
* that does not send `features`) — consumers treat that as "all off".
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface AgentFeatures {
|
|
12
|
+
transcriptUpload?: boolean;
|
|
13
|
+
repoAccessCheck?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AgentFeaturesRef {
|
|
17
|
+
get(): AgentFeatures | undefined;
|
|
18
|
+
set(features: AgentFeatures | undefined): void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createAgentFeaturesRef(): AgentFeaturesRef {
|
|
22
|
+
let current: AgentFeatures | undefined;
|
|
23
|
+
return {
|
|
24
|
+
get: () => current,
|
|
25
|
+
set(features) {
|
|
26
|
+
current = features;
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Process-wide ref written by boot's syncConfig() on every successful tick. */
|
|
32
|
+
export const agentFeaturesRef: AgentFeaturesRef = createAgentFeaturesRef();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/agent-features-ref.unit.test.ts
|
|
3
|
+
* Tests an independent instance; the process-wide singleton is not mutated.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "bun:test";
|
|
7
|
+
import { createAgentFeaturesRef } from "./agent-features-ref.ts";
|
|
8
|
+
|
|
9
|
+
describe("createAgentFeaturesRef", () => {
|
|
10
|
+
it("is undefined until set, then reflects the latest features", () => {
|
|
11
|
+
const ref = createAgentFeaturesRef();
|
|
12
|
+
expect(ref.get()).toBeUndefined();
|
|
13
|
+
ref.set({ transcriptUpload: true, repoAccessCheck: false });
|
|
14
|
+
expect(ref.get()).toEqual({
|
|
15
|
+
transcriptUpload: true,
|
|
16
|
+
repoAccessCheck: false,
|
|
17
|
+
});
|
|
18
|
+
ref.set(undefined);
|
|
19
|
+
expect(ref.get()).toBeUndefined();
|
|
20
|
+
});
|
|
21
|
+
});
|
package/boot.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
* 8. Graceful SIGTERM/SIGINT shutdown
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
20
21
|
import { join } from "node:path";
|
|
21
22
|
import { initSentry } from "@app-vitals/agent-lib/sentry";
|
|
22
23
|
import * as Sentry from "@sentry/bun";
|
|
@@ -49,6 +50,7 @@ import {
|
|
|
49
50
|
setLiveClaudeConfig,
|
|
50
51
|
} from "./claude.ts";
|
|
51
52
|
import { bunExec, detectClaudeCodeVersion } from "./claude-version.ts";
|
|
53
|
+
import { agentFeaturesRef } from "./agent-features-ref.ts";
|
|
52
54
|
import { cleanupAfterDaysRef } from "./cleanup-after-days-ref.ts";
|
|
53
55
|
import { SystemClock } from "./clock.ts";
|
|
54
56
|
import { createConfig } from "./config.ts";
|
|
@@ -102,6 +104,7 @@ import {
|
|
|
102
104
|
buildProductionDeps as buildSyncConfigCloneDeps,
|
|
103
105
|
syncClonedRepos,
|
|
104
106
|
} from "./sync-config-clone.ts";
|
|
107
|
+
import { createTranscriptUploader } from "./transcript-uploader.ts";
|
|
105
108
|
import { resolveDisplayName, resolveUserEmail } from "./users.ts";
|
|
106
109
|
import { synthesizeSpeech, transcribeAudio } from "./voice.ts";
|
|
107
110
|
import {
|
|
@@ -279,6 +282,25 @@ export async function startAgent(
|
|
|
279
282
|
// GOR-1.3: cron spawns carry the default installation's org as the gh hint.
|
|
280
283
|
getDefaultOwner: () =>
|
|
281
284
|
defaultOwnerOf(githubInstallationsManagerRef.get()?.getState?.()),
|
|
285
|
+
// COS-43.1: redacted transcript upload after completeRun; gated by the
|
|
286
|
+
// synced features.transcriptUpload flag, failures swallowed.
|
|
287
|
+
...(config.shipwright.apiUrl && config.shipwright.agentId
|
|
288
|
+
? {
|
|
289
|
+
transcriptUploader: createTranscriptUploader({
|
|
290
|
+
fs: {
|
|
291
|
+
readFile: (path) => readFile(path, "utf8"),
|
|
292
|
+
readdir: (path) => readdir(path),
|
|
293
|
+
},
|
|
294
|
+
fetch,
|
|
295
|
+
clock: SystemClock(),
|
|
296
|
+
env: process.env,
|
|
297
|
+
log: (line) => console.warn(line),
|
|
298
|
+
apiUrl: config.shipwright.apiUrl,
|
|
299
|
+
agentId: config.shipwright.agentId,
|
|
300
|
+
getFeatures: () => agentFeaturesRef.get(),
|
|
301
|
+
}),
|
|
302
|
+
}
|
|
303
|
+
: {}),
|
|
282
304
|
};
|
|
283
305
|
|
|
284
306
|
// ARD-3.1: the specialty extension seam (agent-extension.ts). The consumer
|
|
@@ -527,6 +549,9 @@ export async function startAgent(
|
|
|
527
549
|
// Sync the agent's cleanup-after-days live ref (APM-1.6) — read by the
|
|
528
550
|
// runtime's worktree reaper.
|
|
529
551
|
cleanupAfterDaysRef.set(bundle.cleanupAfterDays);
|
|
552
|
+
|
|
553
|
+
// Sync the feature flags (COS-43.1) — read by the transcript uploader.
|
|
554
|
+
agentFeaturesRef.set(bundle.features);
|
|
530
555
|
} catch (err) {
|
|
531
556
|
if (
|
|
532
557
|
(err as { statusCode?: number }).statusCode === 404 &&
|
package/cron-handler.ts
CHANGED
|
@@ -33,6 +33,7 @@ import { parseMarkers } from "./markers.ts";
|
|
|
33
33
|
import type { RunTelemetry } from "./run-telemetry.ts";
|
|
34
34
|
import { dispatchMarkers, type SynthesizeSpeechFn } from "./slack.ts";
|
|
35
35
|
import { buildSpawnEnv } from "./spawn-env.ts";
|
|
36
|
+
import type { TranscriptUploader } from "./transcript-uploader.ts";
|
|
36
37
|
import type { VoiceConfig } from "./voice.ts";
|
|
37
38
|
|
|
38
39
|
/**
|
|
@@ -214,6 +215,11 @@ export interface CronHandlerDeps {
|
|
|
214
215
|
* spawning ~15 real bun child processes per test run (CPS-1.1).
|
|
215
216
|
*/
|
|
216
217
|
spawner?: typeof Bun.spawn;
|
|
218
|
+
/**
|
|
219
|
+
* Uploads the run's redacted session transcript once completeRun has been
|
|
220
|
+
* recorded (COS-43.1). Feature-gated inside the uploader; absent → no-op.
|
|
221
|
+
*/
|
|
222
|
+
transcriptUploader?: TranscriptUploader;
|
|
217
223
|
}
|
|
218
224
|
|
|
219
225
|
export async function handleCronRequest(
|
|
@@ -244,6 +250,7 @@ export async function handleCronRequest(
|
|
|
244
250
|
contextStamp,
|
|
245
251
|
getDefaultOwner,
|
|
246
252
|
spawner = Bun.spawn,
|
|
253
|
+
transcriptUploader,
|
|
247
254
|
} = deps;
|
|
248
255
|
|
|
249
256
|
const startedAt = clock.now();
|
|
@@ -262,6 +269,24 @@ export async function handleCronRequest(
|
|
|
262
269
|
? await cronRunReporter.createRun(jobId, startedAt)
|
|
263
270
|
: null;
|
|
264
271
|
|
|
272
|
+
// Best-effort redacted transcript upload (COS-43.1). Runs only after the
|
|
273
|
+
// run is recorded and can never fail or delay-fail the dispatch.
|
|
274
|
+
const uploadTranscript = async (sid: string | undefined): Promise<void> => {
|
|
275
|
+
if (!transcriptUploader || !runId) return;
|
|
276
|
+
try {
|
|
277
|
+
await transcriptUploader.upload({
|
|
278
|
+
cronId: jobId,
|
|
279
|
+
runId,
|
|
280
|
+
sessionId: sid,
|
|
281
|
+
cwd: workspace ?? process.cwd(),
|
|
282
|
+
});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
console.warn(
|
|
285
|
+
`[agent:cron] transcript upload failed for run ${runId}: ${String(err)} — swallowing`,
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
265
290
|
if (req.preCheck) {
|
|
266
291
|
const isRelative =
|
|
267
292
|
req.preCheck.startsWith("./") || req.preCheck.startsWith("../");
|
|
@@ -584,6 +609,7 @@ export async function handleCronRequest(
|
|
|
584
609
|
...tokenPayload,
|
|
585
610
|
sessionId: failureSessionId,
|
|
586
611
|
});
|
|
612
|
+
await uploadTranscript(failureSessionId);
|
|
587
613
|
markCronRunFailureReported(err);
|
|
588
614
|
throw err;
|
|
589
615
|
}
|
|
@@ -602,6 +628,7 @@ export async function handleCronRequest(
|
|
|
602
628
|
...buildTokenPayload(usage, modelUsage, extras(telemetry)),
|
|
603
629
|
sessionId,
|
|
604
630
|
});
|
|
631
|
+
await uploadTranscript(sessionId);
|
|
605
632
|
return;
|
|
606
633
|
}
|
|
607
634
|
const isDmOnly = !!user && !channel;
|
|
@@ -613,6 +640,7 @@ export async function handleCronRequest(
|
|
|
613
640
|
...buildTokenPayload(usage, modelUsage, extras(telemetry)),
|
|
614
641
|
sessionId,
|
|
615
642
|
});
|
|
643
|
+
await uploadTranscript(sessionId);
|
|
616
644
|
return;
|
|
617
645
|
}
|
|
618
646
|
|
|
@@ -627,6 +655,7 @@ export async function handleCronRequest(
|
|
|
627
655
|
...buildTokenPayload(usage, modelUsage, extras(telemetry)),
|
|
628
656
|
sessionId,
|
|
629
657
|
});
|
|
658
|
+
await uploadTranscript(sessionId);
|
|
630
659
|
return;
|
|
631
660
|
}
|
|
632
661
|
|
|
@@ -654,6 +683,7 @@ export async function handleCronRequest(
|
|
|
654
683
|
...buildTokenPayload(usage, modelUsage, extras(telemetry)),
|
|
655
684
|
sessionId,
|
|
656
685
|
});
|
|
686
|
+
await uploadTranscript(sessionId);
|
|
657
687
|
|
|
658
688
|
// The run is already recorded as "completed" above — any error from here
|
|
659
689
|
// on (postMessage, dispatchMarkers) must be tagged before rethrowing so
|
|
@@ -695,6 +725,7 @@ export async function handleCronRequest(
|
|
|
695
725
|
...buildTokenPayload(usage, modelUsage, extras(telemetry)),
|
|
696
726
|
sessionId,
|
|
697
727
|
});
|
|
728
|
+
await uploadTranscript(sessionId);
|
|
698
729
|
|
|
699
730
|
// The run is already recorded as "completed" above — any error from here
|
|
700
731
|
// on (conversations.open, postMessage, dispatchMarkers) must be tagged
|
|
@@ -1175,3 +1175,61 @@ describe("handleCronRequest — telemetry forwarding", () => {
|
|
|
1175
1175
|
expect(completes[0]?.opts?.contextFingerprint).toBeUndefined();
|
|
1176
1176
|
});
|
|
1177
1177
|
});
|
|
1178
|
+
|
|
1179
|
+
// ─── transcript upload hook (COS-43.1) ───────────────────────────────────────
|
|
1180
|
+
|
|
1181
|
+
describe("handleCronRequest — transcript upload (COS-43.1)", () => {
|
|
1182
|
+
function recordingReporter(order: string[]): CronRunReporter {
|
|
1183
|
+
return {
|
|
1184
|
+
async createRun() {
|
|
1185
|
+
return "run-tx-1";
|
|
1186
|
+
},
|
|
1187
|
+
async completeRun() {
|
|
1188
|
+
order.push("completeRun");
|
|
1189
|
+
},
|
|
1190
|
+
async skipRun() {},
|
|
1191
|
+
async recordProgress() {},
|
|
1192
|
+
async recordSessionId() {},
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
test("uploads after completeRun with the run's cron, run, session and cwd", async () => {
|
|
1197
|
+
const order: string[] = [];
|
|
1198
|
+
const calls: unknown[] = [];
|
|
1199
|
+
await handleCronRequest(
|
|
1200
|
+
{ jobId: "j-tx", prompt: "hello", silent: true },
|
|
1201
|
+
{
|
|
1202
|
+
...deps,
|
|
1203
|
+
workspace: "/ws",
|
|
1204
|
+
cronRunReporter: recordingReporter(order),
|
|
1205
|
+
transcriptUploader: {
|
|
1206
|
+
async upload(args) {
|
|
1207
|
+
order.push("upload");
|
|
1208
|
+
calls.push(args);
|
|
1209
|
+
},
|
|
1210
|
+
},
|
|
1211
|
+
},
|
|
1212
|
+
);
|
|
1213
|
+
expect(order).toEqual(["completeRun", "upload"]);
|
|
1214
|
+
expect(calls).toEqual([
|
|
1215
|
+
{ cronId: "j-tx", runId: "run-tx-1", sessionId: "sess-1", cwd: "/ws" },
|
|
1216
|
+
]);
|
|
1217
|
+
});
|
|
1218
|
+
|
|
1219
|
+
test("an uploader rejection never fails the run", async () => {
|
|
1220
|
+
await expect(
|
|
1221
|
+
handleCronRequest(
|
|
1222
|
+
{ jobId: "j-tx2", prompt: "hello", silent: true },
|
|
1223
|
+
{
|
|
1224
|
+
...deps,
|
|
1225
|
+
cronRunReporter: recordingReporter([]),
|
|
1226
|
+
transcriptUploader: {
|
|
1227
|
+
async upload() {
|
|
1228
|
+
throw new Error("boom");
|
|
1229
|
+
},
|
|
1230
|
+
},
|
|
1231
|
+
},
|
|
1232
|
+
),
|
|
1233
|
+
).resolves.toBeUndefined();
|
|
1234
|
+
});
|
|
1235
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@app-vitals/agent-runtime",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/app-vitals/agent.git",
|
|
@@ -40,8 +40,8 @@
|
|
|
40
40
|
"./workspace-paths": "./workspace-paths.ts"
|
|
41
41
|
},
|
|
42
42
|
"dependencies": {
|
|
43
|
-
"@app-vitals/agent-admin": "0.
|
|
44
|
-
"@app-vitals/agent-lib": "0.
|
|
43
|
+
"@app-vitals/agent-admin": "0.3.0",
|
|
44
|
+
"@app-vitals/agent-lib": "0.3.0",
|
|
45
45
|
"@octokit/auth-app": "^8.2.0",
|
|
46
46
|
"@sentry/bun": "^10.63.0",
|
|
47
47
|
"@slack/bolt": "^5.0.0",
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/transcript-uploader.ts
|
|
3
|
+
* Uploads a redacted copy of a cron run's Claude session transcript to the
|
|
4
|
+
* admin API (COS-43.1) once the run is recorded.
|
|
5
|
+
*
|
|
6
|
+
* Gated by the synced config bundle's `features.transcriptUpload` (the admin
|
|
7
|
+
* flag SHIPWRIGHT_TRANSCRIPT_API_ENABLED). Redaction happens HERE, before the
|
|
8
|
+
* transcript leaves the pod: live SECRET_ENV_VARS values plus well-known token
|
|
9
|
+
* shapes (lib/redact.ts). The body is tail-truncated to 1 MiB on whole lines
|
|
10
|
+
* with a marker line first. Every failure logs one line (never the body) and
|
|
11
|
+
* is swallowed — an upload problem must never fail or alter a run.
|
|
12
|
+
*
|
|
13
|
+
* All I/O is injected ({ fs, fetch, clock, env, log }) so it is unit-testable.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import { redact } from "@app-vitals/agent-lib/redact";
|
|
18
|
+
import type { Clock } from "./clock.ts";
|
|
19
|
+
|
|
20
|
+
/** Upload cap; the admin route 413s above it. */
|
|
21
|
+
export const MAX_TRANSCRIPT_BYTES = 1024 * 1024;
|
|
22
|
+
|
|
23
|
+
/** Bytes reserved for the truncation marker line. */
|
|
24
|
+
const MARKER_RESERVE = 256;
|
|
25
|
+
|
|
26
|
+
export interface TranscriptFs {
|
|
27
|
+
readFile(path: string): Promise<string>;
|
|
28
|
+
readdir(path: string): Promise<string[]>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface TranscriptFeatures {
|
|
32
|
+
transcriptUpload?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface TranscriptUploaderDeps {
|
|
36
|
+
fs: TranscriptFs;
|
|
37
|
+
fetch: typeof fetch;
|
|
38
|
+
clock: Clock;
|
|
39
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
40
|
+
/** One-line logger; never receives transcript content. */
|
|
41
|
+
log: (line: string) => void;
|
|
42
|
+
apiUrl: string;
|
|
43
|
+
agentId: string;
|
|
44
|
+
/** The last synced `features` from the config bundle (absent before sync). */
|
|
45
|
+
getFeatures: () => TranscriptFeatures | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface TranscriptUploadArgs {
|
|
49
|
+
cronId: string;
|
|
50
|
+
runId: string;
|
|
51
|
+
sessionId: string | undefined;
|
|
52
|
+
/** The cwd the run's Claude process was spawned in. */
|
|
53
|
+
cwd: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface TranscriptUploader {
|
|
57
|
+
upload(args: TranscriptUploadArgs): Promise<void>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Claude Code's project-dir naming: every "/" and "." becomes "-". */
|
|
61
|
+
export function encodeCwd(cwd: string): string {
|
|
62
|
+
return cwd.replace(/[/.]/g, "-");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function readTranscript(
|
|
66
|
+
deps: TranscriptUploaderDeps,
|
|
67
|
+
cwd: string,
|
|
68
|
+
sessionId: string,
|
|
69
|
+
): Promise<string> {
|
|
70
|
+
const home = deps.env.AGENT_HOME ?? "";
|
|
71
|
+
const projects = join(home, "dot-claude", "projects");
|
|
72
|
+
const primary = join(projects, encodeCwd(cwd), `${sessionId}.jsonl`);
|
|
73
|
+
try {
|
|
74
|
+
return await deps.fs.readFile(primary);
|
|
75
|
+
} catch {
|
|
76
|
+
// Fall back to scanning every project dir for the session file.
|
|
77
|
+
}
|
|
78
|
+
for (const dir of await deps.fs.readdir(projects)) {
|
|
79
|
+
try {
|
|
80
|
+
return await deps.fs.readFile(join(projects, dir, `${sessionId}.jsonl`));
|
|
81
|
+
} catch {
|
|
82
|
+
// not in this project dir
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
throw new Error("transcript file not found");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Keeps whole trailing lines within the cap, prefixed by a marker line. */
|
|
89
|
+
function tailTruncate(text: string): { body: string; truncated: boolean } {
|
|
90
|
+
if (Buffer.byteLength(text) <= MAX_TRANSCRIPT_BYTES) {
|
|
91
|
+
return { body: text, truncated: false };
|
|
92
|
+
}
|
|
93
|
+
const lines = text.split("\n");
|
|
94
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
95
|
+
const budget = MAX_TRANSCRIPT_BYTES - MARKER_RESERVE;
|
|
96
|
+
let used = 0;
|
|
97
|
+
let keepFrom = lines.length;
|
|
98
|
+
while (keepFrom > 0) {
|
|
99
|
+
const size = Buffer.byteLength(lines[keepFrom - 1] as string) + 1;
|
|
100
|
+
if (used + size > budget) break;
|
|
101
|
+
used += size;
|
|
102
|
+
keepFrom--;
|
|
103
|
+
}
|
|
104
|
+
const marker = JSON.stringify({
|
|
105
|
+
type: "transcript-truncated",
|
|
106
|
+
note: "earlier lines dropped to fit the 1 MiB transcript cap",
|
|
107
|
+
droppedLines: keepFrom,
|
|
108
|
+
keptLines: lines.length - keepFrom,
|
|
109
|
+
});
|
|
110
|
+
const kept = lines.slice(keepFrom);
|
|
111
|
+
return {
|
|
112
|
+
body: `${marker}\n${kept.length ? `${kept.join("\n")}\n` : ""}`,
|
|
113
|
+
truncated: true,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function createTranscriptUploader(
|
|
118
|
+
deps: TranscriptUploaderDeps,
|
|
119
|
+
): TranscriptUploader {
|
|
120
|
+
return {
|
|
121
|
+
async upload({ cronId, runId, sessionId, cwd }) {
|
|
122
|
+
if (deps.getFeatures()?.transcriptUpload !== true) return;
|
|
123
|
+
if (!sessionId) return;
|
|
124
|
+
const apiKey = deps.env.SHIPWRIGHT_AGENT_API_KEY;
|
|
125
|
+
if (!apiKey) return;
|
|
126
|
+
const started = deps.clock.now().getTime();
|
|
127
|
+
try {
|
|
128
|
+
const raw = await readTranscript(deps, cwd, sessionId);
|
|
129
|
+
const { text, count } = redact(raw, deps.env);
|
|
130
|
+
const { body, truncated } = tailTruncate(text);
|
|
131
|
+
const url = `${deps.apiUrl}/agents/${deps.agentId}/crons/${cronId}/runs/${runId}/transcript`;
|
|
132
|
+
const res = await deps.fetch(url, {
|
|
133
|
+
method: "PUT",
|
|
134
|
+
headers: {
|
|
135
|
+
"Content-Type": "application/json",
|
|
136
|
+
Authorization: `Bearer ${apiKey}`,
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify({
|
|
139
|
+
sessionId,
|
|
140
|
+
body,
|
|
141
|
+
truncated,
|
|
142
|
+
redactions: count,
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
if (!res.ok) {
|
|
146
|
+
deps.log(
|
|
147
|
+
`[transcript-upload] run ${runId} upload returned ${res.status} — skipped`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
} catch (err) {
|
|
151
|
+
const ms = deps.clock.now().getTime() - started;
|
|
152
|
+
deps.log(
|
|
153
|
+
`[transcript-upload] run ${runId} failed after ${ms}ms: ${err instanceof Error ? err.message : String(err)} — skipped`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runtime/transcript-uploader.unit.test.ts
|
|
3
|
+
* COS-43.1: path resolution + fallback, truncation marker, feature off,
|
|
4
|
+
* redaction before upload, errors swallowed. All deps injected.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, expect, it } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
createTranscriptUploader,
|
|
10
|
+
MAX_TRANSCRIPT_BYTES,
|
|
11
|
+
type TranscriptFs,
|
|
12
|
+
} from "./transcript-uploader.ts";
|
|
13
|
+
|
|
14
|
+
const HOME = "/data/agent-home";
|
|
15
|
+
const CWD = "/data/agent-home/workspace";
|
|
16
|
+
const ENCODED = "-data-agent-home-workspace";
|
|
17
|
+
const SESSION = "sess-123";
|
|
18
|
+
const PRIMARY = `${HOME}/dot-claude/projects/${ENCODED}/${SESSION}.jsonl`;
|
|
19
|
+
|
|
20
|
+
function memFs(files: Record<string, string>): TranscriptFs {
|
|
21
|
+
return {
|
|
22
|
+
async readFile(path) {
|
|
23
|
+
const f = files[path];
|
|
24
|
+
if (f === undefined) throw new Error(`ENOENT ${path}`);
|
|
25
|
+
return f;
|
|
26
|
+
},
|
|
27
|
+
async readdir(path) {
|
|
28
|
+
const prefix = `${path}/`;
|
|
29
|
+
return [
|
|
30
|
+
...new Set(
|
|
31
|
+
Object.keys(files)
|
|
32
|
+
.filter((p) => p.startsWith(prefix))
|
|
33
|
+
.map((p) => p.slice(prefix.length).split("/")[0] as string),
|
|
34
|
+
),
|
|
35
|
+
];
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface Call {
|
|
41
|
+
url: string;
|
|
42
|
+
init: RequestInit;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function setup(
|
|
46
|
+
files: Record<string, string>,
|
|
47
|
+
opts: {
|
|
48
|
+
features?: { transcriptUpload: boolean } | undefined;
|
|
49
|
+
status?: number;
|
|
50
|
+
throwFetch?: boolean;
|
|
51
|
+
env?: Record<string, string>;
|
|
52
|
+
} = {},
|
|
53
|
+
) {
|
|
54
|
+
const calls: Call[] = [];
|
|
55
|
+
const logs: string[] = [];
|
|
56
|
+
const features =
|
|
57
|
+
"features" in opts ? opts.features : { transcriptUpload: true };
|
|
58
|
+
const uploader = createTranscriptUploader({
|
|
59
|
+
fs: memFs(files),
|
|
60
|
+
fetch: (async (url: string, init: RequestInit) => {
|
|
61
|
+
calls.push({ url, init });
|
|
62
|
+
if (opts.throwFetch) throw new Error("network down");
|
|
63
|
+
return new Response("{}", { status: opts.status ?? 200 });
|
|
64
|
+
}) as unknown as typeof fetch,
|
|
65
|
+
clock: { now: () => new Date("2026-10-26T00:00:00Z") },
|
|
66
|
+
env: {
|
|
67
|
+
AGENT_HOME: HOME,
|
|
68
|
+
SHIPWRIGHT_AGENT_API_KEY: "agent-key-xyz",
|
|
69
|
+
...opts.env,
|
|
70
|
+
},
|
|
71
|
+
log: (l) => logs.push(l),
|
|
72
|
+
apiUrl: "https://admin.example",
|
|
73
|
+
agentId: "agent-1",
|
|
74
|
+
getFeatures: () => features,
|
|
75
|
+
});
|
|
76
|
+
const run = (sessionId: string | null = SESSION) =>
|
|
77
|
+
uploader.upload({
|
|
78
|
+
cronId: "cron-1",
|
|
79
|
+
runId: "run-1",
|
|
80
|
+
sessionId: sessionId ?? undefined,
|
|
81
|
+
cwd: CWD,
|
|
82
|
+
});
|
|
83
|
+
return { calls, logs, run };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("transcript uploader", () => {
|
|
87
|
+
it("PUTs the redacted transcript to the run's transcript path", async () => {
|
|
88
|
+
const { calls, run } = setup({ [PRIMARY]: '{"a":1}\n{"b":2}\n' });
|
|
89
|
+
await run();
|
|
90
|
+
expect(calls).toHaveLength(1);
|
|
91
|
+
expect(calls[0]?.url).toBe(
|
|
92
|
+
"https://admin.example/agents/agent-1/crons/cron-1/runs/run-1/transcript",
|
|
93
|
+
);
|
|
94
|
+
const init = calls[0]?.init as RequestInit;
|
|
95
|
+
expect(init.method).toBe("PUT");
|
|
96
|
+
expect((init.headers as Record<string, string>).Authorization).toBe(
|
|
97
|
+
"Bearer agent-key-xyz",
|
|
98
|
+
);
|
|
99
|
+
expect(JSON.parse(init.body as string)).toEqual({
|
|
100
|
+
sessionId: SESSION,
|
|
101
|
+
body: '{"a":1}\n{"b":2}\n',
|
|
102
|
+
truncated: false,
|
|
103
|
+
redactions: 0,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("falls back to projects/*/<sessionId>.jsonl when the encoded cwd misses", async () => {
|
|
108
|
+
const other = `${HOME}/dot-claude/projects/-somewhere-else/${SESSION}.jsonl`;
|
|
109
|
+
const { calls, run } = setup({
|
|
110
|
+
[other]: "fallback\n",
|
|
111
|
+
[`${HOME}/dot-claude/projects/-x/other.jsonl`]: "nope\n",
|
|
112
|
+
});
|
|
113
|
+
await run();
|
|
114
|
+
expect(calls).toHaveLength(1);
|
|
115
|
+
expect(JSON.parse(calls[0]?.init.body as string).body).toBe("fallback\n");
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("encodes both / and . in the cwd", async () => {
|
|
119
|
+
const dotted = "/data/agent.home/work";
|
|
120
|
+
const p = `${HOME}/dot-claude/projects/-data-agent-home-work/${SESSION}.jsonl`;
|
|
121
|
+
const calls: string[] = [];
|
|
122
|
+
const up = createTranscriptUploader({
|
|
123
|
+
fs: memFs({ [p]: "ok\n" }),
|
|
124
|
+
fetch: (async (_u: string, init: RequestInit) => {
|
|
125
|
+
calls.push(init.body as string);
|
|
126
|
+
return new Response("{}");
|
|
127
|
+
}) as unknown as typeof fetch,
|
|
128
|
+
clock: { now: () => new Date() },
|
|
129
|
+
env: { AGENT_HOME: HOME, SHIPWRIGHT_AGENT_API_KEY: "k" },
|
|
130
|
+
log: () => {},
|
|
131
|
+
apiUrl: "https://a",
|
|
132
|
+
agentId: "a1",
|
|
133
|
+
getFeatures: () => ({ transcriptUpload: true }),
|
|
134
|
+
});
|
|
135
|
+
await up.upload({
|
|
136
|
+
cronId: "c",
|
|
137
|
+
runId: "r",
|
|
138
|
+
sessionId: SESSION,
|
|
139
|
+
cwd: dotted,
|
|
140
|
+
});
|
|
141
|
+
expect(calls).toHaveLength(1);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("redacts secrets and reports the count", async () => {
|
|
145
|
+
const tok = `ghp_${"A1b2C3d4E5".repeat(3)}`;
|
|
146
|
+
const { calls, run } = setup({ [PRIMARY]: `line ${tok}\n` });
|
|
147
|
+
await run();
|
|
148
|
+
const body = JSON.parse(calls[0]?.init.body as string);
|
|
149
|
+
expect(body.body).not.toContain(tok);
|
|
150
|
+
expect(body.redactions).toBe(1);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("tail-truncates to 1 MiB keeping whole lines with a marker first", async () => {
|
|
154
|
+
const line = `${JSON.stringify({ pad: "x".repeat(1000) })}\n`;
|
|
155
|
+
const count = Math.ceil((MAX_TRANSCRIPT_BYTES * 1.5) / line.length);
|
|
156
|
+
const lines = Array.from({ length: count }, (_, i) =>
|
|
157
|
+
JSON.stringify({ i, pad: "x".repeat(1000) }),
|
|
158
|
+
);
|
|
159
|
+
const { calls, run } = setup({ [PRIMARY]: `${lines.join("\n")}\n` });
|
|
160
|
+
await run();
|
|
161
|
+
const body = JSON.parse(calls[0]?.init.body as string);
|
|
162
|
+
expect(body.truncated).toBe(true);
|
|
163
|
+
expect(Buffer.byteLength(body.body)).toBeLessThanOrEqual(
|
|
164
|
+
MAX_TRANSCRIPT_BYTES,
|
|
165
|
+
);
|
|
166
|
+
const out = (body.body as string).split("\n").filter(Boolean);
|
|
167
|
+
expect(JSON.parse(out[0] as string).type).toBe("transcript-truncated");
|
|
168
|
+
// The kept tail is whole lines ending at the final input line.
|
|
169
|
+
expect(JSON.parse(out[out.length - 1] as string).i).toBe(count - 1);
|
|
170
|
+
for (const l of out) JSON.parse(l);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("does nothing when the feature is off, absent, or sessionId unknown", async () => {
|
|
174
|
+
for (const features of [{ transcriptUpload: false }, undefined]) {
|
|
175
|
+
const { calls, run } = setup({ [PRIMARY]: "x\n" }, { features });
|
|
176
|
+
await run();
|
|
177
|
+
expect(calls).toHaveLength(0);
|
|
178
|
+
}
|
|
179
|
+
const { calls, run } = setup({ [PRIMARY]: "x\n" });
|
|
180
|
+
await run(null);
|
|
181
|
+
expect(calls).toHaveLength(0);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
it("does nothing without an API key", async () => {
|
|
185
|
+
const { calls, run } = setup(
|
|
186
|
+
{ [PRIMARY]: "x\n" },
|
|
187
|
+
{ env: { SHIPWRIGHT_AGENT_API_KEY: "" } },
|
|
188
|
+
);
|
|
189
|
+
await run();
|
|
190
|
+
expect(calls).toHaveLength(0);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("swallows a missing file, a fetch failure and a non-2xx with one log line each", async () => {
|
|
194
|
+
const missing = setup({});
|
|
195
|
+
await missing.run();
|
|
196
|
+
expect(missing.logs).toHaveLength(1);
|
|
197
|
+
expect(missing.calls).toHaveLength(0);
|
|
198
|
+
|
|
199
|
+
const thrown = setup({ [PRIMARY]: "x\n" }, { throwFetch: true });
|
|
200
|
+
await thrown.run();
|
|
201
|
+
expect(thrown.logs).toHaveLength(1);
|
|
202
|
+
|
|
203
|
+
const bad = setup({ [PRIMARY]: "x\n" }, { status: 500 });
|
|
204
|
+
await bad.run();
|
|
205
|
+
expect(bad.logs).toHaveLength(1);
|
|
206
|
+
expect(bad.logs[0]).toContain("500");
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("never logs the transcript body", async () => {
|
|
210
|
+
const { logs, run } = setup(
|
|
211
|
+
{ [PRIMARY]: "TOP-SECRET-BODY\n" },
|
|
212
|
+
{ status: 500 },
|
|
213
|
+
);
|
|
214
|
+
await run();
|
|
215
|
+
expect(logs.join("\n")).not.toContain("TOP-SECRET-BODY");
|
|
216
|
+
});
|
|
217
|
+
});
|