@app-vitals/agent-runtime 0.2.1 → 0.4.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";
@@ -56,6 +58,7 @@ import { createContextStampReader } from "./context-stamp.ts";
56
58
  import { reportCronFailure } from "./cron-failure-reporter.ts";
57
59
  import type { CronHandlerDeps } from "./cron-handler.ts";
58
60
  import { handleCronRequest } from "./cron-handler.ts";
61
+ import { createRunNowHonourer } from "./cron-run-now.ts";
59
62
  import {
60
63
  HttpCronRunReporter,
61
64
  NoopCronRunReporter,
@@ -102,6 +105,7 @@ import {
102
105
  buildProductionDeps as buildSyncConfigCloneDeps,
103
106
  syncClonedRepos,
104
107
  } from "./sync-config-clone.ts";
108
+ import { createTranscriptUploader } from "./transcript-uploader.ts";
105
109
  import { resolveDisplayName, resolveUserEmail } from "./users.ts";
106
110
  import { synthesizeSpeech, transcribeAudio } from "./voice.ts";
107
111
  import {
@@ -279,6 +283,25 @@ export async function startAgent(
279
283
  // GOR-1.3: cron spawns carry the default installation's org as the gh hint.
280
284
  getDefaultOwner: () =>
281
285
  defaultOwnerOf(githubInstallationsManagerRef.get()?.getState?.()),
286
+ // COS-43.1: redacted transcript upload after completeRun; gated by the
287
+ // synced features.transcriptUpload flag, failures swallowed.
288
+ ...(config.shipwright.apiUrl && config.shipwright.agentId
289
+ ? {
290
+ transcriptUploader: createTranscriptUploader({
291
+ fs: {
292
+ readFile: (path) => readFile(path, "utf8"),
293
+ readdir: (path) => readdir(path),
294
+ },
295
+ fetch,
296
+ clock: SystemClock(),
297
+ env: process.env,
298
+ log: (line) => console.warn(line),
299
+ apiUrl: config.shipwright.apiUrl,
300
+ agentId: config.shipwright.agentId,
301
+ getFeatures: () => agentFeaturesRef.get(),
302
+ }),
303
+ }
304
+ : {}),
282
305
  };
283
306
 
284
307
  // ARD-3.1: the specialty extension seam (agent-extension.ts). The consumer
@@ -527,6 +550,9 @@ export async function startAgent(
527
550
  // Sync the agent's cleanup-after-days live ref (APM-1.6) — read by the
528
551
  // runtime's worktree reaper.
529
552
  cleanupAfterDaysRef.set(bundle.cleanupAfterDays);
553
+
554
+ // Sync the feature flags (COS-43.1) — read by the transcript uploader.
555
+ agentFeaturesRef.set(bundle.features);
530
556
  } catch (err) {
531
557
  if (
532
558
  (err as { statusCode?: number }).statusCode === 404 &&
@@ -659,6 +685,54 @@ export async function startAgent(
659
685
  const cronTasks = new Map<string, ReturnType<typeof nodeCron.schedule>>();
660
686
 
661
687
  if (runtimeClient && agentId) {
688
+ // Run-now requests (COS-42.1): honoured at most once each, in memory.
689
+ // Dispatch kind per job from the latest sync, so run-now takes the same
690
+ // path a scheduled tick would (loop-classified crons go through dispatchLoop).
691
+ let dispatchKinds = new Map<string, "loop" | "generic">();
692
+ const honourRunNow = createRunNowHonourer({
693
+ clock: SystemClock(),
694
+ dispatch: async (req) => {
695
+ if (dispatchKinds.get(req.jobId) === "loop") {
696
+ if (!extension.dispatchLoop) {
697
+ throw new Error(
698
+ `cron job ${req.jobId} classified as loop but the agent extension has no dispatchLoop`,
699
+ );
700
+ }
701
+ // dispatchLoop creates no run-now run of its own, so record one here:
702
+ // creating it clears the stamp and flips the audit row to honoured.
703
+ const reporter = cronRunReporter ?? new NoopCronRunReporter();
704
+ const startedAt = SystemClock().now();
705
+ const runId = await reporter.createRun(
706
+ req.jobId,
707
+ startedAt,
708
+ undefined,
709
+ undefined,
710
+ undefined,
711
+ req.commandVariant,
712
+ );
713
+ let outcome: "completed" | "failed" = "completed";
714
+ try {
715
+ await extension.dispatchLoop(req.jobId);
716
+ } catch (err) {
717
+ outcome = "failed";
718
+ throw err;
719
+ } finally {
720
+ await reporter
721
+ .completeRun(req.jobId, runId, SystemClock().now(), outcome)
722
+ .catch(() => {});
723
+ }
724
+ return;
725
+ }
726
+ await handleCronRequest(req, cronDeps);
727
+ },
728
+ onError: (jobId, err) =>
729
+ reportCronFailure(jobId, err, {
730
+ cronRunReporter: cronRunReporter ?? new NoopCronRunReporter(),
731
+ sentryClient: process.env.SENTRY_DSN ? Sentry : undefined,
732
+ clock: SystemClock(),
733
+ }),
734
+ });
735
+
662
736
  async function syncCrons() {
663
737
  if (!runtimeClient || !agentId) return;
664
738
  let jobs: Awaited<ReturnType<typeof runtimeClient.listAgentCronJobs>>;
@@ -680,6 +754,13 @@ export async function startAgent(
680
754
  // the coding extension's loop-cron-classifier.ts for the full decision.
681
755
  const scheduled = planCronSchedule(extension, jobs);
682
756
  const desired = new Map(scheduled.map((entry) => [entry.job.id, entry]));
757
+ dispatchKinds = new Map(
758
+ scheduled.map((entry) => [entry.job.id, entry.dispatch]),
759
+ );
760
+
761
+ // Fire-and-forget: a run can take minutes and must not hold up the sync.
762
+ // Only scheduled (enabled) jobs: a manually disabled cron is not run.
763
+ void honourRunNow(jobs.filter((job) => desired.has(job.id)));
683
764
 
684
765
  // Cancel removed/disabled jobs
685
766
  for (const [id, task] of cronTasks) {
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
  /**
@@ -161,6 +162,8 @@ interface CronRequest {
161
162
  user?: string;
162
163
  silent?: boolean;
163
164
  preCheck?: string;
165
+ /** Run-attribution tag, e.g. "run-now" (COS-42.1); passed to createRun. */
166
+ commandVariant?: string;
164
167
  }
165
168
 
166
169
  export interface CronHandlerDeps {
@@ -214,6 +217,11 @@ export interface CronHandlerDeps {
214
217
  * spawning ~15 real bun child processes per test run (CPS-1.1).
215
218
  */
216
219
  spawner?: typeof Bun.spawn;
220
+ /**
221
+ * Uploads the run's redacted session transcript once completeRun has been
222
+ * recorded (COS-43.1). Feature-gated inside the uploader; absent → no-op.
223
+ */
224
+ transcriptUploader?: TranscriptUploader;
217
225
  }
218
226
 
219
227
  export async function handleCronRequest(
@@ -244,6 +252,7 @@ export async function handleCronRequest(
244
252
  contextStamp,
245
253
  getDefaultOwner,
246
254
  spawner = Bun.spawn,
255
+ transcriptUploader,
247
256
  } = deps;
248
257
 
249
258
  const startedAt = clock.now();
@@ -259,9 +268,34 @@ export async function handleCronRequest(
259
268
 
260
269
  // CREATE run at start (before preCheck)
261
270
  const runId = cronRunReporter
262
- ? await cronRunReporter.createRun(jobId, startedAt)
271
+ ? await cronRunReporter.createRun(
272
+ jobId,
273
+ startedAt,
274
+ undefined,
275
+ undefined,
276
+ undefined,
277
+ req.commandVariant,
278
+ )
263
279
  : null;
264
280
 
281
+ // Best-effort redacted transcript upload (COS-43.1). Runs only after the
282
+ // run is recorded and can never fail or delay-fail the dispatch.
283
+ const uploadTranscript = async (sid: string | undefined): Promise<void> => {
284
+ if (!transcriptUploader || !runId) return;
285
+ try {
286
+ await transcriptUploader.upload({
287
+ cronId: jobId,
288
+ runId,
289
+ sessionId: sid,
290
+ cwd: workspace ?? process.cwd(),
291
+ });
292
+ } catch (err) {
293
+ console.warn(
294
+ `[agent:cron] transcript upload failed for run ${runId}: ${String(err)} — swallowing`,
295
+ );
296
+ }
297
+ };
298
+
265
299
  if (req.preCheck) {
266
300
  const isRelative =
267
301
  req.preCheck.startsWith("./") || req.preCheck.startsWith("../");
@@ -584,6 +618,7 @@ export async function handleCronRequest(
584
618
  ...tokenPayload,
585
619
  sessionId: failureSessionId,
586
620
  });
621
+ await uploadTranscript(failureSessionId);
587
622
  markCronRunFailureReported(err);
588
623
  throw err;
589
624
  }
@@ -602,6 +637,7 @@ export async function handleCronRequest(
602
637
  ...buildTokenPayload(usage, modelUsage, extras(telemetry)),
603
638
  sessionId,
604
639
  });
640
+ await uploadTranscript(sessionId);
605
641
  return;
606
642
  }
607
643
  const isDmOnly = !!user && !channel;
@@ -613,6 +649,7 @@ export async function handleCronRequest(
613
649
  ...buildTokenPayload(usage, modelUsage, extras(telemetry)),
614
650
  sessionId,
615
651
  });
652
+ await uploadTranscript(sessionId);
616
653
  return;
617
654
  }
618
655
 
@@ -627,6 +664,7 @@ export async function handleCronRequest(
627
664
  ...buildTokenPayload(usage, modelUsage, extras(telemetry)),
628
665
  sessionId,
629
666
  });
667
+ await uploadTranscript(sessionId);
630
668
  return;
631
669
  }
632
670
 
@@ -654,6 +692,7 @@ export async function handleCronRequest(
654
692
  ...buildTokenPayload(usage, modelUsage, extras(telemetry)),
655
693
  sessionId,
656
694
  });
695
+ await uploadTranscript(sessionId);
657
696
 
658
697
  // The run is already recorded as "completed" above — any error from here
659
698
  // on (postMessage, dispatchMarkers) must be tagged before rethrowing so
@@ -695,6 +734,7 @@ export async function handleCronRequest(
695
734
  ...buildTokenPayload(usage, modelUsage, extras(telemetry)),
696
735
  sessionId,
697
736
  });
737
+ await uploadTranscript(sessionId);
698
738
 
699
739
  // The run is already recorded as "completed" above — any error from here
700
740
  // on (conversations.open, postMessage, dispatchMarkers) must be tagged
@@ -1175,3 +1175,89 @@ describe("handleCronRequest — telemetry forwarding", () => {
1175
1175
  expect(completes[0]?.opts?.contextFingerprint).toBeUndefined();
1176
1176
  });
1177
1177
  });
1178
+
1179
+ describe("handleCronRequest — commandVariant (COS-42.1)", () => {
1180
+ test("a run-now request creates its run with commandVariant run-now", async () => {
1181
+ const created: unknown[][] = [];
1182
+ const reporter: CronRunReporter = {
1183
+ async createRun(...args: unknown[]) {
1184
+ created.push(args);
1185
+ return "run-now-1";
1186
+ },
1187
+ async completeRun() {},
1188
+ async skipRun() {},
1189
+ async recordProgress() {},
1190
+ async recordSessionId() {},
1191
+ };
1192
+ await handleCronRequest(
1193
+ {
1194
+ jobId: "j-now",
1195
+ prompt: "hello",
1196
+ silent: true,
1197
+ commandVariant: "run-now",
1198
+ },
1199
+ { ...deps, cronRunReporter: reporter },
1200
+ );
1201
+ expect(created).toHaveLength(1);
1202
+ expect(created[0]?.[0]).toBe("j-now");
1203
+ expect(created[0]?.[5]).toBe("run-now");
1204
+ });
1205
+ });
1206
+
1207
+ // ─── transcript upload hook (COS-43.1) ───────────────────────────────────────
1208
+
1209
+ describe("handleCronRequest — transcript upload (COS-43.1)", () => {
1210
+ function recordingReporter(order: string[]): CronRunReporter {
1211
+ return {
1212
+ async createRun() {
1213
+ return "run-tx-1";
1214
+ },
1215
+ async completeRun() {
1216
+ order.push("completeRun");
1217
+ },
1218
+ async skipRun() {},
1219
+ async recordProgress() {},
1220
+ async recordSessionId() {},
1221
+ };
1222
+ }
1223
+
1224
+ test("uploads after completeRun with the run's cron, run, session and cwd", async () => {
1225
+ const order: string[] = [];
1226
+ const calls: unknown[] = [];
1227
+ await handleCronRequest(
1228
+ { jobId: "j-tx", prompt: "hello", silent: true },
1229
+ {
1230
+ ...deps,
1231
+ workspace: "/ws",
1232
+ cronRunReporter: recordingReporter(order),
1233
+ transcriptUploader: {
1234
+ async upload(args) {
1235
+ order.push("upload");
1236
+ calls.push(args);
1237
+ },
1238
+ },
1239
+ },
1240
+ );
1241
+ expect(order).toEqual(["completeRun", "upload"]);
1242
+ expect(calls).toEqual([
1243
+ { cronId: "j-tx", runId: "run-tx-1", sessionId: "sess-1", cwd: "/ws" },
1244
+ ]);
1245
+ });
1246
+
1247
+ test("an uploader rejection never fails the run", async () => {
1248
+ await expect(
1249
+ handleCronRequest(
1250
+ { jobId: "j-tx2", prompt: "hello", silent: true },
1251
+ {
1252
+ ...deps,
1253
+ cronRunReporter: recordingReporter([]),
1254
+ transcriptUploader: {
1255
+ async upload() {
1256
+ throw new Error("boom");
1257
+ },
1258
+ },
1259
+ },
1260
+ ),
1261
+ ).resolves.toBeUndefined();
1262
+ });
1263
+ });
@@ -0,0 +1,116 @@
1
+ /**
2
+ * runtime/cron-run-now.ts
3
+ *
4
+ * Honours cron run-now requests (COS-42.1). The admin stamps
5
+ * `runRequestedAt` on a stored cron job; on each cron sync tick the runtime
6
+ * runs the job once when the stamp is strictly newer than the last one it
7
+ * honoured (held in memory) and at most 15 minutes old. An older stamp is
8
+ * logged as expired and never run. The request is dispatched with the job's
9
+ * own stored fields, never a prompt from anywhere else, and tagged
10
+ * commandVariant "run-now" so run creation clears the stamp admin-side.
11
+ */
12
+
13
+ import type { Clock } from "./clock.ts";
14
+
15
+ /** A request older than this is expired and never run. */
16
+ export const RUN_NOW_FRESH_MS = 15 * 60_000;
17
+
18
+ /** The stored cron job fields the honourer reads. */
19
+ export interface RunNowJob {
20
+ id: string;
21
+ prompt: string;
22
+ channel?: string | null;
23
+ user?: string | null;
24
+ silent?: boolean;
25
+ preCheck?: string | null;
26
+ /** A manually disabled job is never run, even with a fresh stamp. */
27
+ enabled?: boolean;
28
+ /** ISO string on the wire (typed Date by the generated client). */
29
+ runRequestedAt?: string | Date | null;
30
+ }
31
+
32
+ export interface RunNowRequest {
33
+ jobId: string;
34
+ prompt: string;
35
+ channel?: string;
36
+ user?: string;
37
+ silent?: boolean;
38
+ preCheck?: string;
39
+ commandVariant: "run-now";
40
+ }
41
+
42
+ interface RunNowHonourerDeps {
43
+ clock: Clock;
44
+ /** Runs the job (handleCronRequest in production). */
45
+ dispatch: (req: RunNowRequest) => Promise<void>;
46
+ /** Called when a dispatch throws; the request is not retried. */
47
+ onError?: (jobId: string, err: unknown) => void | Promise<void>;
48
+ log?: (line: string) => void;
49
+ }
50
+
51
+ /**
52
+ * Returns a function to call with the fresh job list on every sync tick. It
53
+ * resolves once every dispatch it started has settled; callers that must not
54
+ * wait on long runs simply do not await it.
55
+ */
56
+ export function createRunNowHonourer(
57
+ deps: RunNowHonourerDeps,
58
+ ): (jobs: readonly RunNowJob[]) => Promise<void> {
59
+ const { clock, dispatch, onError, log = console.log } = deps;
60
+ // jobId -> last honoured / last expired-logged runRequestedAt (epoch ms).
61
+ const lastHonoured = new Map<string, number>();
62
+ const expiredLogged = new Map<string, number>();
63
+
64
+ return async (jobs) => {
65
+ const running: Promise<void>[] = [];
66
+ for (const job of jobs) {
67
+ if (!job.runRequestedAt || job.enabled === false) continue;
68
+ const requestedMs = new Date(job.runRequestedAt).getTime();
69
+ if (Number.isNaN(requestedMs)) continue;
70
+ const requested = new Date(requestedMs).toISOString();
71
+
72
+ const seen = lastHonoured.get(job.id);
73
+ if (seen !== undefined && requestedMs <= seen) continue;
74
+
75
+ if (clock.now().getTime() - requestedMs > RUN_NOW_FRESH_MS) {
76
+ if (expiredLogged.get(job.id) !== requestedMs) {
77
+ expiredLogged.set(job.id, requestedMs);
78
+ log(
79
+ JSON.stringify({
80
+ event: "cron-run-now",
81
+ status: "expired",
82
+ jobId: job.id,
83
+ requestedAt: requested,
84
+ }),
85
+ );
86
+ }
87
+ continue;
88
+ }
89
+
90
+ // Recorded before dispatch so a slow or failing run is never repeated.
91
+ lastHonoured.set(job.id, requestedMs);
92
+ log(
93
+ JSON.stringify({
94
+ event: "cron-run-now",
95
+ status: "honouring",
96
+ jobId: job.id,
97
+ requestedAt: requested,
98
+ }),
99
+ );
100
+ running.push(
101
+ dispatch({
102
+ jobId: job.id,
103
+ prompt: job.prompt,
104
+ channel: job.channel ?? undefined,
105
+ user: job.user ?? undefined,
106
+ silent: job.silent,
107
+ preCheck: job.preCheck ?? undefined,
108
+ commandVariant: "run-now",
109
+ }).catch(async (err) => {
110
+ await onError?.(job.id, err);
111
+ }),
112
+ );
113
+ }
114
+ await Promise.all(running);
115
+ };
116
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * runtime/cron-run-now.unit.test.ts
3
+ * Honour / expire / once logic for cron run-now requests (COS-42.1), with an
4
+ * injected clock and dispatch.
5
+ */
6
+
7
+ import { describe, expect, it } from "bun:test";
8
+ import {
9
+ createRunNowHonourer,
10
+ RUN_NOW_FRESH_MS,
11
+ type RunNowJob,
12
+ } from "./cron-run-now.ts";
13
+
14
+ const NOW = new Date("2026-10-26T12:00:00.000Z");
15
+
16
+ function job(over: Partial<RunNowJob> = {}): RunNowJob {
17
+ return {
18
+ id: "job-1",
19
+ prompt: "stored prompt",
20
+ channel: "C1",
21
+ user: null,
22
+ silent: false,
23
+ preCheck: null,
24
+ runRequestedAt: null,
25
+ ...over,
26
+ };
27
+ }
28
+
29
+ function setup() {
30
+ let nowMs = NOW.getTime();
31
+ const dispatched: Array<Record<string, unknown>> = [];
32
+ const logs: string[] = [];
33
+ const honour = createRunNowHonourer({
34
+ clock: { now: () => new Date(nowMs) },
35
+ dispatch: async (req) => {
36
+ dispatched.push(req as unknown as Record<string, unknown>);
37
+ },
38
+ log: (line) => logs.push(line),
39
+ });
40
+ return {
41
+ honour,
42
+ dispatched,
43
+ logs,
44
+ advance: (ms: number) => {
45
+ nowMs += ms;
46
+ },
47
+ };
48
+ }
49
+
50
+ const ago = (ms: number) => new Date(NOW.getTime() - ms).toISOString();
51
+
52
+ describe("createRunNowHonourer", () => {
53
+ it("never runs a manually disabled job", async () => {
54
+ const t = setup();
55
+ await t.honour([job({ enabled: false, runRequestedAt: ago(1000) })]);
56
+ expect(t.dispatched).toHaveLength(0);
57
+ });
58
+
59
+ it("runs a fresh request once with the stored job fields and run-now variant", async () => {
60
+ const { honour, dispatched } = setup();
61
+ await honour([job({ runRequestedAt: ago(60_000) })]);
62
+ expect(dispatched).toEqual([
63
+ {
64
+ jobId: "job-1",
65
+ prompt: "stored prompt",
66
+ channel: "C1",
67
+ user: undefined,
68
+ silent: false,
69
+ preCheck: undefined,
70
+ commandVariant: "run-now",
71
+ },
72
+ ]);
73
+ });
74
+
75
+ it("does not run the same request twice across ticks", async () => {
76
+ const { honour, dispatched, advance } = setup();
77
+ const j = job({ runRequestedAt: ago(60_000) });
78
+ await honour([j]);
79
+ advance(60_000);
80
+ await honour([j]);
81
+ await honour([j]);
82
+ expect(dispatched).toHaveLength(1);
83
+ });
84
+
85
+ it("runs again for a strictly newer request", async () => {
86
+ const { honour, dispatched, advance } = setup();
87
+ await honour([job({ runRequestedAt: ago(60_000) })]);
88
+ advance(120_000);
89
+ await honour([job({ runRequestedAt: ago(10_000) })]);
90
+ expect(dispatched).toHaveLength(2);
91
+ });
92
+
93
+ it("ignores an older-or-equal stamp than the last honoured", async () => {
94
+ const { honour, dispatched } = setup();
95
+ await honour([job({ runRequestedAt: ago(10_000) })]);
96
+ await honour([job({ runRequestedAt: ago(30_000) })]);
97
+ expect(dispatched).toHaveLength(1);
98
+ });
99
+
100
+ it("ignores a request older than 15 minutes and logs expired once", async () => {
101
+ const { honour, dispatched, logs } = setup();
102
+ const j = job({ runRequestedAt: ago(RUN_NOW_FRESH_MS + 1) });
103
+ await honour([j]);
104
+ await honour([j]);
105
+ expect(dispatched).toEqual([]);
106
+ const expired = logs.filter((l) => l.includes("expired"));
107
+ expect(expired).toHaveLength(1);
108
+ expect(logs.join("\n")).not.toContain("stored prompt");
109
+ });
110
+
111
+ it("a request exactly 15 minutes old is still fresh", async () => {
112
+ const { honour, dispatched } = setup();
113
+ await honour([job({ runRequestedAt: ago(RUN_NOW_FRESH_MS) })]);
114
+ expect(dispatched).toHaveLength(1);
115
+ });
116
+
117
+ it("a request that ages out between ticks without being seen expires", async () => {
118
+ const { honour, dispatched, logs, advance } = setup();
119
+ const j = job({ runRequestedAt: ago(0) });
120
+ advance(RUN_NOW_FRESH_MS + 60_000);
121
+ await honour([j]);
122
+ expect(dispatched).toEqual([]);
123
+ expect(logs.some((l) => l.includes("expired"))).toBe(true);
124
+ });
125
+
126
+ it("skips jobs with no request", async () => {
127
+ const { honour, dispatched, logs } = setup();
128
+ await honour([job(), job({ id: "job-2" })]);
129
+ expect(dispatched).toEqual([]);
130
+ expect(logs).toEqual([]);
131
+ });
132
+
133
+ it("a failing dispatch does not stop the other jobs and is not retried", async () => {
134
+ const dispatched: string[] = [];
135
+ const failures: string[] = [];
136
+ const honour = createRunNowHonourer({
137
+ clock: { now: () => NOW },
138
+ dispatch: async (req) => {
139
+ dispatched.push(req.jobId);
140
+ if (req.jobId === "bad") throw new Error("boom");
141
+ },
142
+ onError: (jobId) => {
143
+ failures.push(jobId);
144
+ },
145
+ log: () => {},
146
+ });
147
+ const jobs = [
148
+ job({ id: "bad", runRequestedAt: ago(1000) }),
149
+ job({ id: "good", runRequestedAt: ago(1000) }),
150
+ ];
151
+ await honour(jobs);
152
+ await honour(jobs);
153
+ expect(dispatched).toEqual(["bad", "good"]);
154
+ expect(failures).toEqual(["bad"]);
155
+ });
156
+ });
@@ -35,6 +35,8 @@ function makeCron(id: string): AgentCronJob {
35
35
  system: false,
36
36
  parentCronId: null,
37
37
  lockdownDisabledAt: null,
38
+ runRequestedAt: null,
39
+ runRequestedBy: null,
38
40
  };
39
41
  }
40
42
 
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.4.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.4.0",
44
+ "@app-vitals/agent-lib": "0.4.0",
45
45
  "@octokit/auth-app": "^8.2.0",
46
46
  "@sentry/bun": "^10.63.0",
47
47
  "@slack/bolt": "^5.0.0",
@@ -53,6 +53,8 @@ const SAMPLE_CRONS: AgentCronJob[] = [
53
53
  system: false,
54
54
  parentCronId: null,
55
55
  lockdownDisabledAt: null,
56
+ runRequestedAt: null,
57
+ runRequestedBy: null,
56
58
  createdAt: new Date("2025-01-01T00:00:00Z"),
57
59
  updatedAt: new Date("2025-01-01T00:00:00Z"),
58
60
  },
@@ -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
+ });