@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.
@@ -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.2.1",
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.2.1",
44
- "@app-vitals/agent-lib": "0.2.1",
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
+ });