Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

agent-runtime.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

agent-runtime.test.tsBlame253 lines · 1 contributor
a6d8fd5Claude1/**
2 * Block K1 — Agent runtime unit tests.
3 *
4 * Covers the pure helpers (state machine + log truncation) plus the
5 * runSandboxed primitive via real subprocesses. DB helpers are intentionally
6 * not tested here — they hit Neon and are covered by integration flow.
7 */
8
9import { describe, it, expect } from "bun:test";
10import {
11 type AgentRunStatus,
12 canTransition,
13 isTerminalStatus,
14 runSandboxed,
15 truncateError,
16 truncateLog,
17 __internal,
18} from "../lib/agent-runtime";
19
20const ALL_STATUSES: AgentRunStatus[] = [
21 "queued",
22 "running",
23 "succeeded",
24 "failed",
25 "killed",
26 "timeout",
27];
28
29describe("agent-runtime — isTerminalStatus", () => {
30 it("treats queued + running as non-terminal", () => {
31 expect(isTerminalStatus("queued")).toBe(false);
32 expect(isTerminalStatus("running")).toBe(false);
33 });
34
35 it("treats succeeded / failed / killed / timeout as terminal", () => {
36 expect(isTerminalStatus("succeeded")).toBe(true);
37 expect(isTerminalStatus("failed")).toBe(true);
38 expect(isTerminalStatus("killed")).toBe(true);
39 expect(isTerminalStatus("timeout")).toBe(true);
40 });
41
42 it("is exhaustive over AgentRunStatus", () => {
43 // Every status value must have a defined answer (no undefined bubbling).
44 for (const s of ALL_STATUSES) {
45 expect(typeof isTerminalStatus(s)).toBe("boolean");
46 }
47 });
48});
49
50describe("agent-runtime — canTransition", () => {
51 it("allows the happy path queued → running → succeeded", () => {
52 expect(canTransition("queued", "running")).toBe(true);
53 expect(canTransition("running", "succeeded")).toBe(true);
54 });
55
56 it("allows running → failed | timeout | killed", () => {
57 expect(canTransition("running", "failed")).toBe(true);
58 expect(canTransition("running", "timeout")).toBe(true);
59 expect(canTransition("running", "killed")).toBe(true);
60 });
61
62 it("allows queued → killed (operator cancel before start)", () => {
63 expect(canTransition("queued", "killed")).toBe(true);
64 });
65
66 it("forbids going backward from any terminal state", () => {
67 for (const terminal of ["succeeded", "failed", "killed", "timeout"] as const) {
68 for (const to of ALL_STATUSES) {
69 expect(canTransition(terminal, to)).toBe(false);
70 }
71 }
72 });
73
74 it("forbids self-transitions", () => {
75 for (const s of ALL_STATUSES) {
76 expect(canTransition(s, s)).toBe(false);
77 }
78 });
79
80 it("forbids skipping running (queued → succeeded/failed/timeout)", () => {
81 expect(canTransition("queued", "succeeded")).toBe(false);
82 expect(canTransition("queued", "failed")).toBe(false);
83 expect(canTransition("queued", "timeout")).toBe(false);
84 });
85
86 it("forbids running → queued (no re-queue)", () => {
87 expect(canTransition("running", "queued")).toBe(false);
88 });
89});
90
91describe("agent-runtime — truncateLog", () => {
92 it("returns a concatenation when well under the cap", () => {
93 const out = truncateLog("abc", "def", 1024);
94 expect(out).toBe("abcdef");
95 });
96
97 it("returns exactly the combined string at the cap boundary", () => {
98 const existing = "a".repeat(500);
99 const addition = "b".repeat(500);
100 const out = truncateLog(existing, addition, 1000);
101 expect(out.length).toBe(1000);
102 expect(out).toBe(existing + addition);
103 });
104
105 it("prepends the sentinel and keeps the last maxBytes when over the cap", () => {
106 const existing = "x".repeat(2000);
107 const addition = "y".repeat(500);
108 const out = truncateLog(existing, addition, 1000);
109 expect(out.startsWith(__internal.LOG_TRUNCATED_SENTINEL)).toBe(true);
110 // Tail length == maxBytes; we keep the rightmost characters of existing+addition.
111 const tail = out.slice(__internal.LOG_TRUNCATED_SENTINEL.length);
112 expect(tail.length).toBe(1000);
113 // Since addition ('y'*500) sits at the very end, the last 500 chars must be 'y'.
114 expect(tail.endsWith("y".repeat(500))).toBe(true);
115 // And the middle should be the tail of the 'x' run.
116 expect(tail.startsWith("x".repeat(500))).toBe(true);
117 });
118
119 it("does not double the sentinel on subsequent truncations", () => {
120 // First truncation.
121 const first = truncateLog("x".repeat(2000), "", 1000);
122 expect(first.startsWith(__internal.LOG_TRUNCATED_SENTINEL)).toBe(true);
123 // Feed the already-truncated log back in with more content that forces
124 // another truncation.
125 const second = truncateLog(first, "z".repeat(1500), 1000);
126 expect(second.startsWith(__internal.LOG_TRUNCATED_SENTINEL)).toBe(true);
127 // Only one sentinel should appear in total.
128 const occurrences = second.split(__internal.LOG_TRUNCATED_SENTINEL).length - 1;
129 expect(occurrences).toBe(1);
130 // Most of the tail should be 'z'.
131 expect(second.endsWith("z".repeat(500))).toBe(true);
132 });
133
134 it("handles empty existing and empty addition safely", () => {
135 expect(truncateLog("", "", 100)).toBe("");
136 expect(truncateLog("", "hi", 100)).toBe("hi");
137 expect(truncateLog("hi", "", 100)).toBe("hi");
138 });
139});
140
141describe("agent-runtime — truncateError", () => {
142 it("leaves short strings alone", () => {
143 expect(truncateError("boom")).toBe("boom");
144 });
145
146 it("trims long strings and appends a marker", () => {
147 const long = "e".repeat(10_000);
148 const out = truncateError(long, 4096);
149 expect(out.length).toBeLessThanOrEqual(4096 + 32); // body + marker
150 expect(out.startsWith("e".repeat(4096))).toBe(true);
151 expect(out).toContain("truncated");
152 });
153
154 it("handles empty / null-ish input", () => {
155 expect(truncateError("")).toBe("");
156 // @ts-expect-error — runtime guard against null callers.
157 expect(truncateError(null)).toBe("");
158 });
159});
160
161describe("agent-runtime — runSandboxed", () => {
162 it("runs a successful command and captures stdout", async () => {
163 const res = await runSandboxed("/bin/echo", ["hello world"], {
164 cwd: "/tmp",
165 timeoutMs: 2000,
166 });
167 expect(res.exitCode).toBe(0);
168 expect(res.timedOut).toBe(false);
169 expect(res.stdout).toContain("hello world");
170 expect(res.stderr).toBe("");
171 });
172
173 it("returns a non-zero exit code for failing commands", async () => {
174 // `false` always exits 1.
175 const res = await runSandboxed("/bin/sh", ["-c", "exit 7"], {
176 cwd: "/tmp",
177 timeoutMs: 2000,
178 });
179 expect(res.exitCode).toBe(7);
180 expect(res.timedOut).toBe(false);
181 });
182
183 it("kills long-running processes on timeout", async () => {
184 const started = Date.now();
185 const res = await runSandboxed("/bin/sleep", ["10"], {
186 cwd: "/tmp",
187 timeoutMs: 200,
188 });
189 const elapsed = Date.now() - started;
190 expect(res.timedOut).toBe(true);
191 // 200ms timeout + up to 5s SIGTERM grace before SIGKILL; sleep(1) responds
192 // to SIGTERM immediately so in practice well under a second.
193 expect(elapsed).toBeLessThan(6000);
194 expect(res.stderr).toContain("timeout");
195 }, 10_000);
196
197 it("caps stdout at the configured stream cap", async () => {
198 // Write ~200 KB of 'a's to stdout via a tiny python-free shell loop.
199 // `yes` is simpler: it spams a string forever. Combined with head
200 // we get deterministic size without relying on Python/Node.
201 const res = await runSandboxed(
202 "/bin/sh",
203 ["-c", "yes aaaaaaaaaa | head -c 204800"],
204 { cwd: "/tmp", timeoutMs: 5000, stdoutCapBytes: 64 * 1024 }
205 );
206 expect(res.exitCode).toBe(0);
207 // Capped stream = 64 KB content + '\n[... truncated ...]' suffix.
208 expect(res.stdout.length).toBeGreaterThanOrEqual(64 * 1024);
209 expect(res.stdout.length).toBeLessThan(64 * 1024 + 64);
210 expect(res.stdout).toContain("truncated");
211 }, 10_000);
212
213 it("reports spawn failure without throwing", async () => {
214 const res = await runSandboxed(
215 "/nonexistent/definitely-not-a-real-binary-xyz",
216 [],
217 { cwd: "/tmp", timeoutMs: 1000 }
218 );
219 // Bun either returns exitCode=null + stderr message, or exit!=0 — both
220 // are acceptable "failure surfaced" outcomes. What must NOT happen is a
221 // throw. Assert the call resolved and returned a result object.
222 expect(typeof res.timedOut).toBe("boolean");
223 expect(res.exitCode === null || res.exitCode !== 0).toBe(true);
224 });
225
226 it("passes a minimal env by default (no process.env leakage)", async () => {
227 // Set a secret-looking var in the parent process and ensure it does NOT
228 // leak into the sandbox's environment when the caller passes no env.
229 process.env.GLUECRON_TEST_SECRET = "leak-me-please";
230 const res = await runSandboxed(
231 "/bin/sh",
232 ["-c", "echo \"S=${GLUECRON_TEST_SECRET:-unset}\""],
233 { cwd: "/tmp", timeoutMs: 2000 }
234 );
235 delete process.env.GLUECRON_TEST_SECRET;
236 expect(res.exitCode).toBe(0);
237 expect(res.stdout).toContain("S=unset");
238 });
239
240 it("honours a caller-supplied env verbatim", async () => {
241 const res = await runSandboxed(
242 "/bin/sh",
243 ["-c", "echo \"V=$FOO\""],
244 {
245 cwd: "/tmp",
246 timeoutMs: 2000,
247 env: { PATH: process.env.PATH ?? "/usr/bin:/bin", FOO: "bar" },
248 }
249 );
250 expect(res.exitCode).toBe(0);
251 expect(res.stdout).toContain("V=bar");
252 });
253});