Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commita6d8fd5unknown_key

feat(BLOCK-K): K1 agent runtime + K2 identity + K9 prod signals + Gatetest/Crontech clients

feat(BLOCK-K): K1 agent runtime + K2 identity + K9 prod signals + Gatetest/Crontech clients

Wave 1 of the autonomous agent loop. Four parallel agents delivered the
foundation substrate:

- K1 agent runtime (src/lib/agent-runtime.ts, drizzle/0034_agent_runs.sql):
  Bun.spawn sandbox with SIGTERM→SIGKILL timeouts, 64 KB stdout/stderr caps,
  256 KB log cap, status lifecycle helpers, cost accounting. 25 tests.

- K2 agent identity (src/lib/agent-identity.ts): thin wrapper over Block H
  marketplace bot infra. Vocabulary of 12 permissions incl. agent:invoke.
  Zero schema changes (reuses apps/app_installations/app_bots/app_install_tokens).
  30 tests.

- K9 prod signal ingestion (src/lib/prod-signals.ts, src/routes/signals.ts,
  drizzle/0035_prod_signals.sql): POST /api/v1/signals/error accepts PAT,
  OAuth, marketplace install token, or session. Error-hash upsert with
  count+last_seen bump. 24 tests.

- Gatetest + Crontech tool clients (src/lib/gatetest-client.ts,
  src/lib/crontech-client.ts): typed primitives for runAndRepair,
  stackTraceToTest, healSuite, getDeploymentForCommit, triggerRedeploy,
  rollbackDeployment, watchDeployment. Deterministic offline fallbacks
  when API keys absent. 30 tests.

Main thread integrated Drizzle schema additions + app.tsx route mount.
All 876 tests pass (109 new).

https://claude.ai/code/session_019KmMAXLy6fvcXjmGyepeCT
Claude committed on April 17, 2026Parent: 483c9eb
15 files changed+38190a6d8fd5b755d0ecd09d754735ae23e124d7b0efe
15 changed files+3819−0
Addeddrizzle/0034_agent_runs.sql+35−0View fileUnifiedSplit
1-- Block K1 — Autonomous agent runtime + sandbox.
2--
3-- The substrate every other Block K agent (triage, fix, review_response,
4-- deploy_watcher, heal_bot) runs on top of. A single row records one
5-- invocation: its kind + trigger (what fired it), its status lifecycle
6-- (queued → running → succeeded/failed/killed/timeout), a short summary,
7-- a size-capped append-only log (256 KB), cost accounting (input/output
8-- tokens + cents), and optional error_message on failure.
9
10CREATE TABLE IF NOT EXISTS "agent_runs" (
11 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
12 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
13 "kind" text NOT NULL, -- 'triage' | 'fix' | 'review_response' | 'deploy_watcher' | 'heal_bot' | 'custom'
14 "trigger" text NOT NULL, -- 'issue.opened' | 'pr.opened' | 'pr.review_comment' | 'deploy.failed' | 'manual' | 'scheduled'
15 "trigger_ref" text, -- e.g. issue number, PR number, commit sha
16 "status" text NOT NULL DEFAULT 'queued', -- 'queued' | 'running' | 'succeeded' | 'failed' | 'killed' | 'timeout'
17 "summary" text,
18 "log" text NOT NULL DEFAULT '',
19 "cost_input_tokens" integer NOT NULL DEFAULT 0,
20 "cost_output_tokens" integer NOT NULL DEFAULT 0,
21 "cost_cents" integer NOT NULL DEFAULT 0,
22 "started_at" timestamp,
23 "finished_at" timestamp,
24 "created_at" timestamp NOT NULL DEFAULT now(),
25 "error_message" text
26);
27
28CREATE INDEX IF NOT EXISTS "agent_runs_repo_created_idx"
29 ON "agent_runs" ("repository_id", "created_at" DESC);
30
31CREATE INDEX IF NOT EXISTS "agent_runs_status_idx"
32 ON "agent_runs" ("status");
33
34CREATE INDEX IF NOT EXISTS "agent_runs_kind_status_idx"
35 ON "agent_runs" ("kind", "status");
Addeddrizzle/0035_prod_signals.sql+37−0View fileUnifiedSplit
1-- Block K9 — Production + test signal ingestion. Crontech, Gatetest, Sentry,
2-- and manual sources feed per-commit signals back into Gluecron for
3-- attribution, PR annotation, and agent fix loops.
4
5CREATE TABLE IF NOT EXISTS "prod_signals" (
6 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
7 "repository_id" uuid NOT NULL REFERENCES "repositories"("id") ON DELETE CASCADE,
8 "commit_sha" text NOT NULL,
9 "error_hash" text NOT NULL, -- truncated sha-256 of normalised error msg + top frame
10 "source" text NOT NULL, -- 'crontech' | 'gatetest' | 'sentry' | 'manual'
11 "kind" text NOT NULL, -- 'runtime_error' | 'test_failure' | 'deploy_failure' | 'performance' | 'security'
12 "severity" text NOT NULL DEFAULT 'error', -- 'info' | 'warning' | 'error' | 'critical'
13 "status" text NOT NULL DEFAULT 'open', -- 'open' | 'dismissed' | 'resolved'
14 "message" text NOT NULL DEFAULT '',
15 "stack_trace" text,
16 "deploy_id" text,
17 "environment" text, -- 'production' | 'staging' | etc.
18 "sample_payload" text, -- JSON string, optional
19 "count" integer NOT NULL DEFAULT 1,
20 "first_seen" timestamp NOT NULL DEFAULT now(),
21 "last_seen" timestamp NOT NULL DEFAULT now(),
22 "resolved_at" timestamp,
23 "resolved_by_commit" text,
24 "created_at" timestamp NOT NULL DEFAULT now()
25);
26
27CREATE UNIQUE INDEX IF NOT EXISTS "prod_signals_repo_hash_unique"
28 ON "prod_signals" ("repository_id", "error_hash");
29
30CREATE INDEX IF NOT EXISTS "prod_signals_repo_sha_idx"
31 ON "prod_signals" ("repository_id", "commit_sha");
32
33CREATE INDEX IF NOT EXISTS "prod_signals_repo_status_seen_idx"
34 ON "prod_signals" ("repository_id", "status", "last_seen" DESC);
35
36CREATE INDEX IF NOT EXISTS "prod_signals_source_kind_idx"
37 ON "prod_signals" ("source", "kind");
Addedsrc/__tests__/agent-identity.test.ts+244−0View fileUnifiedSplit
1/**
2 * Block K2 — Agent identity wrapper tests.
3 *
4 * Pure helpers + graceful-null-return DB paths only. Style follows
5 * commit-statuses.test.ts: tight `describe` groups, single-assertion-ish
6 * `it` blocks, no live DB dependency.
7 */
8
9import { describe, it, expect } from "bun:test";
10import {
11 AGENT_PERMISSIONS,
12 AGENT_SLUG_PREFIX,
13 agentSlug,
14 ensureAgentApp,
15 installAgentForRepo,
16 isAgentBotUsername,
17 isAgentPermission,
18 issueAgentToken,
19 normaliseAgentPermissions,
20 parseAgentPermissions,
21 requireAgentPermission,
22 revokeAgentToken,
23 revokeAgentTokenByRaw,
24 uninstallAgent,
25 verifyAgentToken,
26} from "../lib/agent-identity";
27import { hasPermission } from "../lib/marketplace";
28
29describe("agent-identity — AGENT_PERMISSIONS vocabulary", () => {
30 it("includes the full marketplace read/write pairs", () => {
31 for (const family of [
32 "contents",
33 "issues",
34 "pulls",
35 "checks",
36 "deployments",
37 ]) {
38 expect(AGENT_PERMISSIONS).toContain(`${family}:read`);
39 expect(AGENT_PERMISSIONS).toContain(`${family}:write`);
40 }
41 expect(AGENT_PERMISSIONS).toContain("metadata:read");
42 });
43
44 it("adds the new agent:invoke permission", () => {
45 expect(AGENT_PERMISSIONS).toContain("agent:invoke");
46 });
47
48 it("has no duplicates", () => {
49 const set = new Set(AGENT_PERMISSIONS);
50 expect(set.size).toBe(AGENT_PERMISSIONS.length);
51 });
52});
53
54describe("agent-identity — isAgentPermission", () => {
55 it("accepts every declared permission", () => {
56 for (const p of AGENT_PERMISSIONS) {
57 expect(isAgentPermission(p)).toBe(true);
58 }
59 });
60
61 it("rejects unknown values", () => {
62 expect(isAgentPermission("bogus:thing")).toBe(false);
63 expect(isAgentPermission("")).toBe(false);
64 expect(isAgentPermission("CONTENTS:READ")).toBe(false);
65 });
66});
67
68describe("agent-identity — normaliseAgentPermissions", () => {
69 it("drops unknown values", () => {
70 const out = normaliseAgentPermissions([
71 "contents:read",
72 "bogus",
73 "agent:invoke",
74 ]);
75 expect(out).toEqual(["contents:read", "agent:invoke"]);
76 });
77
78 it("de-duplicates and preserves first-appearance order", () => {
79 const out = normaliseAgentPermissions([
80 "issues:write",
81 "contents:read",
82 "issues:write",
83 "contents:read",
84 ]);
85 expect(out).toEqual(["issues:write", "contents:read"]);
86 });
87
88 it("returns [] for empty input", () => {
89 expect(normaliseAgentPermissions([])).toEqual([]);
90 });
91});
92
93describe("agent-identity — parseAgentPermissions", () => {
94 it("reads JSON array out of DB column", () => {
95 const raw = JSON.stringify(["contents:read", "agent:invoke", "bogus"]);
96 expect(parseAgentPermissions(raw)).toEqual([
97 "contents:read",
98 "agent:invoke",
99 ]);
100 });
101
102 it("handles null / undefined / empty / invalid JSON", () => {
103 expect(parseAgentPermissions(null)).toEqual([]);
104 expect(parseAgentPermissions(undefined)).toEqual([]);
105 expect(parseAgentPermissions("")).toEqual([]);
106 expect(parseAgentPermissions("not json")).toEqual([]);
107 expect(parseAgentPermissions("{}")).toEqual([]);
108 });
109});
110
111describe("agent-identity — agentSlug", () => {
112 it("prefixes bare kinds with agent-", () => {
113 expect(agentSlug("reviewer")).toBe("agent-reviewer");
114 });
115
116 it("leaves already-prefixed slugs alone", () => {
117 expect(agentSlug("agent-reviewer")).toBe("agent-reviewer");
118 });
119
120 it("lowercases + collapses punctuation", () => {
121 expect(agentSlug("AI Reviewer!")).toBe("agent-ai-reviewer");
122 });
123
124 it("falls back to agent-unknown on empty/whitespace input", () => {
125 expect(agentSlug("")).toBe("agent-unknown");
126 expect(agentSlug(" ")).toBe("agent-unknown");
127 });
128
129 it("exports the expected prefix constant", () => {
130 expect(AGENT_SLUG_PREFIX).toBe("agent-");
131 });
132});
133
134describe("agent-identity — isAgentBotUsername", () => {
135 it("accepts agent-* bots", () => {
136 expect(isAgentBotUsername("agent-reviewer[bot]")).toBe(true);
137 expect(isAgentBotUsername("agent-merge-sentry[bot]")).toBe(true);
138 });
139
140 it("rejects non-agent bots and plain usernames", () => {
141 expect(isAgentBotUsername("dependabot[bot]")).toBe(false);
142 expect(isAgentBotUsername("agent-reviewer")).toBe(false);
143 expect(isAgentBotUsername("alice")).toBe(false);
144 expect(isAgentBotUsername("")).toBe(false);
145 expect(isAgentBotUsername(null)).toBe(false);
146 expect(isAgentBotUsername(undefined)).toBe(false);
147 });
148});
149
150describe("agent-identity — hasPermission (through this layer)", () => {
151 it("write implies read on every family", () => {
152 expect(hasPermission(["contents:write"], "contents:read")).toBe(true);
153 expect(hasPermission(["issues:write"], "issues:read")).toBe(true);
154 expect(hasPermission(["pulls:write"], "pulls:read")).toBe(true);
155 expect(hasPermission(["checks:write"], "checks:read")).toBe(true);
156 expect(hasPermission(["deployments:write"], "deployments:read")).toBe(true);
157 });
158
159 it("read does NOT imply write", () => {
160 expect(hasPermission(["contents:read"], "contents:write")).toBe(false);
161 expect(hasPermission(["pulls:read"], "pulls:write")).toBe(false);
162 });
163
164 it("exact match still wins", () => {
165 expect(hasPermission(["agent:invoke"], "agent:invoke")).toBe(true);
166 });
167
168 it("missing permission returns false", () => {
169 expect(hasPermission([], "contents:read")).toBe(false);
170 expect(hasPermission(["metadata:read"], "contents:read")).toBe(false);
171 });
172});
173
174describe("agent-identity — verifyAgentToken", () => {
175 it("rejects tokens without the ghi_ prefix", async () => {
176 expect(await verifyAgentToken("")).toBeNull();
177 expect(await verifyAgentToken("glc_not_an_install_token")).toBeNull();
178 expect(await verifyAgentToken("ghp_personal_token")).toBeNull();
179 expect(await verifyAgentToken("bearer whatever")).toBeNull();
180 });
181
182 it("rejects an obviously-bogus ghi_ token (no matching install)", async () => {
183 // No DATABASE_URL in this test env → verifyInstallToken returns null.
184 expect(await verifyAgentToken("ghi_deadbeef")).toBeNull();
185 });
186});
187
188describe("agent-identity — requireAgentPermission", () => {
189 it("throws on invalid/missing tokens", async () => {
190 await expect(
191 requireAgentPermission("", "contents:read")
192 ).rejects.toThrow(/invalid|expired/);
193 await expect(
194 requireAgentPermission("ghi_deadbeef", "contents:read")
195 ).rejects.toThrow(/invalid|expired/);
196 });
197});
198
199describe("agent-identity — DB helpers fail gracefully", () => {
200 it("ensureAgentApp returns null when DB is unreachable", async () => {
201 const out = await ensureAgentApp("reviewer", "Reviewer", [
202 "contents:read",
203 ]);
204 // Test env has no DATABASE_URL so this must degrade to null, not throw.
205 expect(out).toBeNull();
206 });
207
208 it("installAgentForRepo returns null when app is missing / DB down", async () => {
209 const out = await installAgentForRepo(
210 "agent-nope",
211 "00000000-0000-0000-0000-000000000000",
212 "00000000-0000-0000-0000-000000000000",
213 ["contents:read"]
214 );
215 expect(out).toBeNull();
216 });
217
218 it("issueAgentToken returns null when no install exists", async () => {
219 const out = await issueAgentToken(
220 "agent-nope",
221 "00000000-0000-0000-0000-000000000000"
222 );
223 expect(out).toBeNull();
224 });
225
226 it("revokeAgentToken returns false for empty hash / unknown token", async () => {
227 expect(await revokeAgentToken("")).toBe(false);
228 expect(await revokeAgentToken("not-a-real-hash")).toBe(false);
229 });
230
231 it("revokeAgentTokenByRaw returns false for empty / unknown tokens", async () => {
232 expect(await revokeAgentTokenByRaw("")).toBe(false);
233 expect(await revokeAgentTokenByRaw("ghi_never_issued")).toBe(false);
234 });
235
236 it("uninstallAgent returns false when app/install missing", async () => {
237 expect(
238 await uninstallAgent(
239 "agent-nope",
240 "00000000-0000-0000-0000-000000000000"
241 )
242 ).toBe(false);
243 });
244});
Addedsrc/__tests__/agent-runtime.test.ts+253−0View fileUnifiedSplit
1/**
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});
Addedsrc/__tests__/crontech-client.test.ts+235−0View fileUnifiedSplit
1/**
2 * Block K — Crontech client tests.
3 *
4 * Exercises config toggle, auth headers, and offline short-circuits. When a
5 * key is present, we mock globalThis.fetch to confirm graceful degradation.
6 */
7
8import { describe, it, expect, beforeEach, afterEach } from "bun:test";
9import {
10 buildAuthHeaders,
11 getDeploymentForCommit,
12 isConfigured,
13 rollbackDeployment,
14 triggerRedeploy,
15 watchDeployment,
16} from "../lib/crontech-client";
17
18const ENV_KEYS = ["CRONTECH_API_KEY", "CRONTECH_BASE_URL"] as const;
19
20let savedEnv: Record<string, string | undefined> = {};
21let savedFetch: typeof fetch;
22
23beforeEach(() => {
24 savedEnv = {};
25 for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
26 for (const k of ENV_KEYS) delete process.env[k];
27 savedFetch = globalThis.fetch;
28});
29
30afterEach(() => {
31 for (const k of ENV_KEYS) {
32 const v = savedEnv[k];
33 if (v === undefined) delete process.env[k];
34 else process.env[k] = v;
35 }
36 globalThis.fetch = savedFetch;
37});
38
39// ---------------------------------------------------------------------------
40// isConfigured + buildAuthHeaders
41// ---------------------------------------------------------------------------
42
43describe("crontech-client — config", () => {
44 it("isConfigured is false when no API key is set", () => {
45 expect(isConfigured()).toBe(false);
46 });
47
48 it("isConfigured flips true once CRONTECH_API_KEY is set", () => {
49 process.env.CRONTECH_API_KEY = "ct-test";
50 expect(isConfigured()).toBe(true);
51 });
52
53 it("buildAuthHeaders omits Authorization when no key is present", () => {
54 const h = buildAuthHeaders();
55 expect(h["Content-Type"]).toBe("application/json");
56 expect(h["Authorization"]).toBeUndefined();
57 });
58
59 it("buildAuthHeaders includes a Bearer token when the key is present", () => {
60 process.env.CRONTECH_API_KEY = "ct-live";
61 const h = buildAuthHeaders();
62 expect(h["Authorization"]).toBe("Bearer ct-live");
63 });
64});
65
66// ---------------------------------------------------------------------------
67// getDeploymentForCommit
68// ---------------------------------------------------------------------------
69
70describe("crontech-client — getDeploymentForCommit", () => {
71 it("returns null in offline mode (no key)", async () => {
72 globalThis.fetch = (() => {
73 throw new Error("fetch must not run offline");
74 }) as unknown as typeof fetch;
75 const result = await getDeploymentForCommit({
76 repo: "o/r",
77 commitSha: "deadbeef",
78 });
79 expect(result).toBeNull();
80 });
81
82 it("returns null on a 500 response", async () => {
83 process.env.CRONTECH_API_KEY = "ct";
84 globalThis.fetch = (async () =>
85 new Response("", { status: 500 })) as unknown as typeof fetch;
86 const result = await getDeploymentForCommit({
87 repo: "o/r",
88 commitSha: "deadbeef",
89 });
90 expect(result).toBeNull();
91 });
92
93 it("parses a valid deployment body on 200", async () => {
94 process.env.CRONTECH_API_KEY = "ct";
95 globalThis.fetch = (async () =>
96 new Response(
97 JSON.stringify({
98 deployId: "dep_1",
99 commitSha: "deadbeef",
100 status: "live",
101 environment: "production",
102 startedAt: "2026-01-01T00:00:00Z",
103 }),
104 { status: 200, headers: { "Content-Type": "application/json" } }
105 )) as unknown as typeof fetch;
106 const result = await getDeploymentForCommit({
107 repo: "o/r",
108 commitSha: "deadbeef",
109 });
110 expect(result).not.toBeNull();
111 expect(result?.deployId).toBe("dep_1");
112 expect(result?.status).toBe("live");
113 });
114});
115
116// ---------------------------------------------------------------------------
117// triggerRedeploy
118// ---------------------------------------------------------------------------
119
120describe("crontech-client — triggerRedeploy", () => {
121 it("returns null in offline mode (no key)", async () => {
122 const result = await triggerRedeploy({
123 repo: "o/r",
124 commitSha: "cafef00d",
125 });
126 expect(result).toBeNull();
127 });
128
129 it("returns null on a 500 response", async () => {
130 process.env.CRONTECH_API_KEY = "ct";
131 globalThis.fetch = (async () =>
132 new Response("", { status: 500 })) as unknown as typeof fetch;
133 const result = await triggerRedeploy({
134 repo: "o/r",
135 commitSha: "cafef00d",
136 environment: "staging",
137 });
138 expect(result).toBeNull();
139 });
140});
141
142// ---------------------------------------------------------------------------
143// rollbackDeployment
144// ---------------------------------------------------------------------------
145
146describe("crontech-client — rollbackDeployment", () => {
147 it("returns false in offline mode (no key)", async () => {
148 const ok = await rollbackDeployment({ repo: "o/r", deployId: "dep_1" });
149 expect(ok).toBe(false);
150 });
151
152 it("returns false on a 500 response", async () => {
153 process.env.CRONTECH_API_KEY = "ct";
154 globalThis.fetch = (async () =>
155 new Response("", { status: 500 })) as unknown as typeof fetch;
156 const ok = await rollbackDeployment({ repo: "o/r", deployId: "dep_1" });
157 expect(ok).toBe(false);
158 });
159
160 it("returns false when deployId is empty even with a key", async () => {
161 process.env.CRONTECH_API_KEY = "ct";
162 const ok = await rollbackDeployment({ repo: "o/r", deployId: "" });
163 expect(ok).toBe(false);
164 });
165});
166
167// ---------------------------------------------------------------------------
168// watchDeployment
169// ---------------------------------------------------------------------------
170
171describe("crontech-client — watchDeployment", () => {
172 it("returns offline:true immediately when no key is set", async () => {
173 globalThis.fetch = (() => {
174 throw new Error("fetch must not run offline");
175 }) as unknown as typeof fetch;
176 const start = Date.now();
177 const result = await watchDeployment({
178 repo: "o/r",
179 deployId: "dep_1",
180 maxWaitMs: 60_000,
181 });
182 const elapsed = Date.now() - start;
183 expect(result.offline).toBe(true);
184 expect(result.finalStatus).toBe("failed");
185 expect(result.errors).toEqual([]);
186 // Should short-circuit in well under a second.
187 expect(elapsed).toBeLessThan(500);
188 });
189
190 it("resolves with finalStatus=live when the poll returns terminal status", async () => {
191 process.env.CRONTECH_API_KEY = "ct";
192 let call = 0;
193 globalThis.fetch = (async (input: unknown) => {
194 call++;
195 const url = String(input);
196 if (url.endsWith("/status")) {
197 return new Response(
198 JSON.stringify({ status: "live" }),
199 { status: 200, headers: { "Content-Type": "application/json" } }
200 );
201 }
202 if (url.endsWith("/errors")) {
203 return new Response(
204 JSON.stringify({ errors: [] }),
205 { status: 200, headers: { "Content-Type": "application/json" } }
206 );
207 }
208 return new Response("", { status: 404 });
209 }) as unknown as typeof fetch;
210
211 const result = await watchDeployment({
212 repo: "o/r",
213 deployId: "dep_1",
214 maxWaitMs: 5_000,
215 pollIntervalMs: 50,
216 });
217 expect(result.offline).toBe(false);
218 expect(result.finalStatus).toBe("live");
219 expect(call).toBeGreaterThanOrEqual(2); // status + errors
220 });
221
222 it("bails to offline after repeated 500 status polls", async () => {
223 process.env.CRONTECH_API_KEY = "ct";
224 globalThis.fetch = (async () =>
225 new Response("", { status: 500 })) as unknown as typeof fetch;
226 const result = await watchDeployment({
227 repo: "o/r",
228 deployId: "dep_1",
229 maxWaitMs: 5_000,
230 pollIntervalMs: 10,
231 });
232 expect(result.offline).toBe(true);
233 expect(result.finalStatus).toBe("failed");
234 });
235});
Addedsrc/__tests__/gatetest-client.test.ts+234−0View fileUnifiedSplit
1/**
2 * Block K — Gatetest client tests.
3 *
4 * Exercises the env-driven configuration toggle, the auth header builder,
5 * and the offline short-circuits. When a key is present, we mock
6 * globalThis.fetch with a 500 to confirm graceful degradation to the
7 * offline result shape.
8 */
9
10import { describe, it, expect, beforeEach, afterEach } from "bun:test";
11import {
12 buildAuthHeaders,
13 healSuite,
14 isConfigured,
15 runAndRepair,
16 stackTraceToTest,
17} from "../lib/gatetest-client";
18
19const ENV_KEYS = ["GATETEST_API_KEY", "GATETEST_BASE_URL"] as const;
20
21let savedEnv: Record<string, string | undefined> = {};
22let savedFetch: typeof fetch;
23
24beforeEach(() => {
25 savedEnv = {};
26 for (const k of ENV_KEYS) savedEnv[k] = process.env[k];
27 // Start every test with a clean slate.
28 for (const k of ENV_KEYS) delete process.env[k];
29 savedFetch = globalThis.fetch;
30});
31
32afterEach(() => {
33 for (const k of ENV_KEYS) {
34 const v = savedEnv[k];
35 if (v === undefined) delete process.env[k];
36 else process.env[k] = v;
37 }
38 globalThis.fetch = savedFetch;
39});
40
41// ---------------------------------------------------------------------------
42// isConfigured
43// ---------------------------------------------------------------------------
44
45describe("gatetest-client — isConfigured", () => {
46 it("returns false when no API key is set", () => {
47 expect(isConfigured()).toBe(false);
48 });
49
50 it("returns true once GATETEST_API_KEY is set", () => {
51 process.env.GATETEST_API_KEY = "sk-test-abc";
52 expect(isConfigured()).toBe(true);
53 });
54
55 it("flips back to false when the key is removed mid-process", () => {
56 process.env.GATETEST_API_KEY = "sk-test-abc";
57 expect(isConfigured()).toBe(true);
58 delete process.env.GATETEST_API_KEY;
59 expect(isConfigured()).toBe(false);
60 });
61});
62
63// ---------------------------------------------------------------------------
64// buildAuthHeaders
65// ---------------------------------------------------------------------------
66
67describe("gatetest-client — buildAuthHeaders", () => {
68 it("returns only Content-Type when no key is present", () => {
69 const h = buildAuthHeaders();
70 expect(h["Content-Type"]).toBe("application/json");
71 expect(h["Authorization"]).toBeUndefined();
72 });
73
74 it("includes a Bearer token when the key is present", () => {
75 process.env.GATETEST_API_KEY = "sk-live-xyz";
76 const h = buildAuthHeaders();
77 expect(h["Content-Type"]).toBe("application/json");
78 expect(h["Authorization"]).toBe("Bearer sk-live-xyz");
79 });
80});
81
82// ---------------------------------------------------------------------------
83// runAndRepair
84// ---------------------------------------------------------------------------
85
86describe("gatetest-client — runAndRepair", () => {
87 it("returns offline result with zeros when no API key is set", async () => {
88 // No fetch mock — the method MUST short-circuit.
89 globalThis.fetch = (() => {
90 throw new Error("fetch must not be called in offline mode");
91 }) as unknown as typeof fetch;
92 const result = await runAndRepair({ repo: "o/r", ref: "main" });
93 expect(result.offline).toBe(true);
94 expect(result.passed).toBe(false);
95 expect(result.totalTests).toBe(0);
96 expect(result.failedBefore).toBe(0);
97 expect(result.failedAfter).toBe(0);
98 expect(result.repairs).toEqual([]);
99 expect(result.unfixable).toEqual([]);
100 expect(result.durationMs).toBe(0);
101 });
102
103 it("falls back to offline on a 500 response", async () => {
104 process.env.GATETEST_API_KEY = "sk-test";
105 globalThis.fetch = (async () =>
106 new Response("boom", { status: 500 })) as unknown as typeof fetch;
107 const result = await runAndRepair({ repo: "o/r", ref: "main" });
108 expect(result.offline).toBe(true);
109 expect(result.passed).toBe(false);
110 expect(result.totalTests).toBe(0);
111 });
112
113 it("parses a healthy 200 response into a structured result", async () => {
114 process.env.GATETEST_API_KEY = "sk-test";
115 const payload = {
116 passed: true,
117 totalTests: 42,
118 failedBefore: 3,
119 failedAfter: 0,
120 repairs: [
121 { file: "a.ts", before: "x", after: "y", reason: "flake" },
122 ],
123 unfixable: [],
124 durationMs: 12345,
125 };
126 globalThis.fetch = (async () =>
127 new Response(JSON.stringify(payload), {
128 status: 200,
129 headers: { "Content-Type": "application/json" },
130 })) as unknown as typeof fetch;
131 const result = await runAndRepair({
132 repo: "o/r",
133 ref: "main",
134 targetGlob: "src/**",
135 });
136 expect(result.offline).toBe(false);
137 expect(result.passed).toBe(true);
138 expect(result.totalTests).toBe(42);
139 expect(result.repairs).toHaveLength(1);
140 });
141});
142
143// ---------------------------------------------------------------------------
144// stackTraceToTest
145// ---------------------------------------------------------------------------
146
147describe("gatetest-client — stackTraceToTest", () => {
148 it("returns a deterministic offline stub when no key is set", async () => {
149 const result = await stackTraceToTest({
150 repo: "o/r",
151 stackTrace: "TypeError: cannot read 'x' of undefined\n at foo",
152 language: "typescript",
153 });
154 expect(result.offline).toBe(true);
155 expect(result.framework).toBe("fallback");
156 expect(result.testCode).toContain("TODO");
157 expect(result.suggestedPath.endsWith(".test.ts")).toBe(true);
158 });
159
160 it("picks a pytest path for python offline stubs", async () => {
161 const result = await stackTraceToTest({
162 repo: "o/r",
163 stackTrace: "AttributeError: foo",
164 language: "python",
165 });
166 expect(result.offline).toBe(true);
167 expect(result.suggestedPath.endsWith(".py")).toBe(true);
168 expect(result.testCode).toContain("def test_");
169 });
170
171 it("falls back to offline on a 500 response", async () => {
172 process.env.GATETEST_API_KEY = "sk-test";
173 globalThis.fetch = (async () =>
174 new Response("down", { status: 500 })) as unknown as typeof fetch;
175 const result = await stackTraceToTest({
176 repo: "o/r",
177 stackTrace: "boom",
178 });
179 expect(result.offline).toBe(true);
180 expect(result.framework).toBe("fallback");
181 });
182});
183
184// ---------------------------------------------------------------------------
185// healSuite
186// ---------------------------------------------------------------------------
187
188describe("gatetest-client — healSuite", () => {
189 it("returns offline zeros when no key is set", async () => {
190 const result = await healSuite({ repo: "o/r" });
191 expect(result.offline).toBe(true);
192 expect(result.flakyFound).toBe(0);
193 expect(result.deadFound).toBe(0);
194 expect(result.coverageGapsFound).toBe(0);
195 expect(result.prDraftBranch).toBeNull();
196 });
197
198 it("falls back to offline on a 500 response", async () => {
199 process.env.GATETEST_API_KEY = "sk-test";
200 globalThis.fetch = (async () =>
201 new Response("fail", { status: 500 })) as unknown as typeof fetch;
202 const result = await healSuite({ repo: "o/r" });
203 expect(result.offline).toBe(true);
204 expect(result.flakyFound).toBe(0);
205 });
206
207 it("falls back to offline when fetch itself throws (network down)", async () => {
208 process.env.GATETEST_API_KEY = "sk-test";
209 globalThis.fetch = (async () => {
210 throw new Error("ECONNREFUSED");
211 }) as unknown as typeof fetch;
212 const result = await healSuite({ repo: "o/r" });
213 expect(result.offline).toBe(true);
214 expect(result.prDraftBranch).toBeNull();
215 });
216
217 it("parses a healthy 200 response", async () => {
218 process.env.GATETEST_API_KEY = "sk-test";
219 globalThis.fetch = (async () =>
220 new Response(
221 JSON.stringify({
222 flakyFound: 2,
223 deadFound: 1,
224 coverageGapsFound: 4,
225 prDraftBranch: "gatetest/heal-42",
226 }),
227 { status: 200, headers: { "Content-Type": "application/json" } }
228 )) as unknown as typeof fetch;
229 const result = await healSuite({ repo: "o/r" });
230 expect(result.offline).toBe(false);
231 expect(result.flakyFound).toBe(2);
232 expect(result.prDraftBranch).toBe("gatetest/heal-42");
233 });
234});
Addedsrc/__tests__/prod-signals.test.ts+239−0View fileUnifiedSplit
1/**
2 * Block K9 — Production-signal ingestion tests.
3 *
4 * Pure helpers cover the security-critical bits (sha sanity, hash
5 * stability, frame extraction, source allow-listing). Route smokes
6 * only assert auth behaviour — DB-backed CRUD is an integration
7 * concern and lives in the live-DB suite.
8 */
9
10import { describe, it, expect } from "bun:test";
11import app from "../app";
12import {
13 SIGNAL_SOURCES,
14 extractTopFrame,
15 hashError,
16 isValidSha,
17 sanitiseKind,
18 sanitiseSeverity,
19 sanitiseSource,
20 __internal,
21} from "../lib/prod-signals";
22
23describe("prod-signals — isValidSha", () => {
24 it("accepts 7–64 hex chars", () => {
25 expect(isValidSha("abcdef1")).toBe(true); // 7
26 expect(isValidSha("a".repeat(40))).toBe(true); // full git sha
27 expect(isValidSha("a".repeat(64))).toBe(true); // sha-256 bound
28 expect(isValidSha("DEADBEEF1234")).toBe(true); // case insensitive
29 });
30
31 it("rejects too short / too long / bad chars / empty / null", () => {
32 expect(isValidSha("abc123")).toBe(false); // 6 < 7
33 expect(isValidSha("a".repeat(65))).toBe(false);
34 expect(isValidSha("xyz12345")).toBe(false);
35 expect(isValidSha("")).toBe(false);
36 expect(isValidSha(null)).toBe(false);
37 expect(isValidSha(undefined)).toBe(false);
38 expect(isValidSha(1234567 as any)).toBe(false);
39 });
40});
41
42describe("prod-signals — hashError", () => {
43 it("is deterministic for the same inputs", () => {
44 const a = hashError("TypeError: x is null", "at foo (bar.ts:1:1)");
45 const b = hashError("TypeError: x is null", "at foo (bar.ts:1:1)");
46 expect(a).toBe(b);
47 });
48
49 it("returns a 16-hex-char string", () => {
50 const h = hashError("boom", "at foo (bar.ts)");
51 expect(h.length).toBe(__internal.HASH_LEN);
52 expect(/^[a-f0-9]+$/.test(h)).toBe(true);
53 });
54
55 it("differs when top frame differs", () => {
56 const a = hashError("boom", "at foo (a.ts:1:1)");
57 const b = hashError("boom", "at bar (c.ts:1:1)");
58 expect(a).not.toBe(b);
59 });
60
61 it("collapses volatile details in messages (whitespace, hex pointers, line:col)", () => {
62 // Two forms of the same error that differ only by whitespace / addr / line:col
63 // should collide after normalisation.
64 const a = hashError(
65 "Cannot read property 'x' of null at 0xdeadbeef:12:34",
66 "at foo (a.ts:10:5)"
67 );
68 const b = hashError(
69 "Cannot read property 'x' of null at 0xfeedface:99:1",
70 "at foo (a.ts:10:5)"
71 );
72 expect(a).toBe(b);
73 });
74
75 it("tolerates null / undefined inputs", () => {
76 const h = hashError(null as any, undefined as any);
77 expect(h.length).toBe(__internal.HASH_LEN);
78 });
79});
80
81describe("prod-signals — extractTopFrame", () => {
82 it("returns the first non-empty, non-node_modules line", () => {
83 const st = [
84 "",
85 " at Module._compile (node:internal/modules)",
86 " at ./node_modules/foo/index.js:3:10",
87 " at ./src/app.ts:42:5",
88 ].join("\n");
89 expect(extractTopFrame(st)).toBe(
90 "at Module._compile (node:internal/modules)"
91 );
92 });
93
94 it("skips node_modules frames", () => {
95 const st = [
96 " at ./node_modules/react/cjs/react.js:10:1",
97 " at ./src/App.tsx:15:3",
98 ].join("\n");
99 expect(extractTopFrame(st)).toBe("at ./src/App.tsx:15:3");
100 });
101
102 it("caps at FRAME_MAX", () => {
103 const line = "at foo " + "x".repeat(2000);
104 const top = extractTopFrame(line);
105 expect(top.length).toBe(__internal.FRAME_MAX);
106 });
107
108 it("returns empty string on malformed / empty input", () => {
109 expect(extractTopFrame("")).toBe("");
110 expect(extractTopFrame(null)).toBe("");
111 expect(extractTopFrame(undefined)).toBe("");
112 expect(extractTopFrame(" \n\n ")).toBe("");
113 });
114});
115
116describe("prod-signals — sanitiseSource", () => {
117 it("accepts the allow-listed sources", () => {
118 for (const s of SIGNAL_SOURCES) expect(sanitiseSource(s)).toBe(s);
119 });
120
121 it("lower-cases and trims", () => {
122 expect(sanitiseSource(" CRONTECH ")).toBe("crontech");
123 expect(sanitiseSource("Gatetest")).toBe("gatetest");
124 });
125
126 it("falls back to 'manual' for unknown / missing", () => {
127 expect(sanitiseSource("datadog")).toBe("manual");
128 expect(sanitiseSource("")).toBe("manual");
129 expect(sanitiseSource(null)).toBe("manual");
130 expect(sanitiseSource(undefined)).toBe("manual");
131 expect(sanitiseSource(42 as any)).toBe("manual");
132 });
133});
134
135describe("prod-signals — sanitiseKind", () => {
136 it("accepts canonical kinds", () => {
137 expect(sanitiseKind("runtime_error")).toBe("runtime_error");
138 expect(sanitiseKind("test_failure")).toBe("test_failure");
139 expect(sanitiseKind("deploy_failure")).toBe("deploy_failure");
140 expect(sanitiseKind("performance")).toBe("performance");
141 expect(sanitiseKind("security")).toBe("security");
142 });
143
144 it("defaults unknown kinds to runtime_error", () => {
145 expect(sanitiseKind("weird")).toBe("runtime_error");
146 expect(sanitiseKind(null)).toBe("runtime_error");
147 expect(sanitiseKind(undefined)).toBe("runtime_error");
148 });
149});
150
151describe("prod-signals — sanitiseSeverity", () => {
152 it("accepts canonical severities", () => {
153 expect(sanitiseSeverity("info")).toBe("info");
154 expect(sanitiseSeverity("warning")).toBe("warning");
155 expect(sanitiseSeverity("error")).toBe("error");
156 expect(sanitiseSeverity("critical")).toBe("critical");
157 });
158
159 it("defaults to 'error'", () => {
160 expect(sanitiseSeverity("")).toBe("error");
161 expect(sanitiseSeverity("fatal")).toBe("error");
162 expect(sanitiseSeverity(null)).toBe("error");
163 expect(sanitiseSeverity(undefined)).toBe("error");
164 });
165});
166
167// ---------------------------------------------------------------------------
168// Route auth smoke tests. Without a live DB we only assert that
169// unauthenticated writes are rejected and that the shape of the error
170// is JSON (not an HTML /login redirect — API clients don't follow it).
171// These tolerate "not yet mounted" (404) so the file can land before the
172// main thread wires signals.ts into app.tsx.
173// ---------------------------------------------------------------------------
174describe("prod-signals — route auth", () => {
175 it("POST /api/v1/signals/error without auth → 401 JSON (or 404 pre-mount)", async () => {
176 const res = await app.request("/api/v1/signals/error", {
177 method: "POST",
178 headers: { "content-type": "application/json" },
179 body: JSON.stringify({
180 repo: "alice/demo",
181 commit_sha: "abc1234",
182 source: "crontech",
183 kind: "runtime_error",
184 message: "boom",
185 }),
186 });
187 expect([401, 404]).toContain(res.status);
188 });
189
190 it("POST with invalid bearer token → 401 JSON (or 404 pre-mount)", async () => {
191 const res = await app.request("/api/v1/signals/error", {
192 method: "POST",
193 headers: {
194 "content-type": "application/json",
195 authorization: "Bearer glc_not_a_real_token",
196 },
197 body: JSON.stringify({
198 repo: "alice/demo",
199 commit_sha: "abc1234",
200 source: "manual",
201 kind: "runtime_error",
202 message: "boom",
203 }),
204 });
205 expect([401, 404]).toContain(res.status);
206 });
207
208 it("POST dismiss without auth → 401 (or 404 pre-mount)", async () => {
209 const res = await app.request(
210 "/api/v1/signals/00000000-0000-0000-0000-000000000000/dismiss",
211 { method: "POST" }
212 );
213 expect([401, 404]).toContain(res.status);
214 });
215
216 it("POST resolve without auth → 401 (or 404 pre-mount)", async () => {
217 const res = await app.request(
218 "/api/v1/signals/00000000-0000-0000-0000-000000000000/resolve",
219 {
220 method: "POST",
221 headers: { "content-type": "application/json" },
222 body: "{}",
223 }
224 );
225 expect([401, 404]).toContain(res.status);
226 });
227
228 it("GET repo signals returns JSON (200 / 403 / 404 / 500 depending on env)", async () => {
229 const res = await app.request("/api/v1/repos/alice/demo/signals");
230 expect([200, 403, 404, 500]).toContain(res.status);
231 });
232
233 it("GET commit signals with invalid sha → 400 (or 404 pre-mount)", async () => {
234 const res = await app.request(
235 "/api/v1/repos/alice/demo/commits/NOT_HEX/signals"
236 );
237 expect([400, 404]).toContain(res.status);
238 });
239});
Modifiedsrc/app.tsx+4−0View fileUnifiedSplit
7575import followsRoutes from "./routes/follows";
7676import rulesetsRoutes from "./routes/rulesets";
7777import commitStatusesRoutes from "./routes/commit-statuses";
78import signalsRoutes from "./routes/signals";
7879import webRoutes from "./routes/web";
7980
8081const app = new Hono();
262263// Commit status API — /api/v1/repos/:o/:r/statuses/:sha (Block J8)
263264app.route("/", commitStatusesRoutes);
264265
266// Prod/test signal ingestion — /api/v1/signals/* (Block K9)
267app.route("/", signalsRoutes);
268
265269// Insights + milestones
266270app.route("/", insightsRoutes);
267271
Modifiedsrc/db/schema.ts+82−0View fileUnifiedSplit
23592359);
23602360
23612361export type CommitStatus = typeof commitStatuses.$inferSelect;
2362
2363// ---------- Block K1 — Autonomous agent runs ----------
2364
2365export const agentRuns = pgTable(
2366 "agent_runs",
2367 {
2368 id: uuid("id").primaryKey().defaultRandom(),
2369 repositoryId: uuid("repository_id")
2370 .notNull()
2371 .references(() => repositories.id, { onDelete: "cascade" }),
2372 kind: text("kind").notNull(),
2373 trigger: text("trigger").notNull(),
2374 triggerRef: text("trigger_ref"),
2375 status: text("status").default("queued").notNull(),
2376 summary: text("summary"),
2377 log: text("log").default("").notNull(),
2378 costInputTokens: integer("cost_input_tokens").default(0).notNull(),
2379 costOutputTokens: integer("cost_output_tokens").default(0).notNull(),
2380 costCents: integer("cost_cents").default(0).notNull(),
2381 startedAt: timestamp("started_at"),
2382 finishedAt: timestamp("finished_at"),
2383 createdAt: timestamp("created_at").defaultNow().notNull(),
2384 errorMessage: text("error_message"),
2385 },
2386 (table) => [
2387 index("agent_runs_repo_created_idx").on(
2388 table.repositoryId,
2389 table.createdAt
2390 ),
2391 index("agent_runs_status_idx").on(table.status),
2392 index("agent_runs_kind_status_idx").on(table.kind, table.status),
2393 ]
2394);
2395
2396export type AgentRun = typeof agentRuns.$inferSelect;
2397
2398// ---------- Block K9 — Production + test signal ingestion ----------
2399
2400export const prodSignals = pgTable(
2401 "prod_signals",
2402 {
2403 id: uuid("id").primaryKey().defaultRandom(),
2404 repositoryId: uuid("repository_id")
2405 .notNull()
2406 .references(() => repositories.id, { onDelete: "cascade" }),
2407 commitSha: text("commit_sha").notNull(),
2408 errorHash: text("error_hash").notNull(),
2409 source: text("source").notNull(),
2410 kind: text("kind").notNull(),
2411 severity: text("severity").default("error").notNull(),
2412 status: text("status").default("open").notNull(),
2413 message: text("message").default("").notNull(),
2414 stackTrace: text("stack_trace"),
2415 deployId: text("deploy_id"),
2416 environment: text("environment"),
2417 samplePayload: text("sample_payload"),
2418 count: integer("count").default(1).notNull(),
2419 firstSeen: timestamp("first_seen").defaultNow().notNull(),
2420 lastSeen: timestamp("last_seen").defaultNow().notNull(),
2421 resolvedAt: timestamp("resolved_at"),
2422 resolvedByCommit: text("resolved_by_commit"),
2423 createdAt: timestamp("created_at").defaultNow().notNull(),
2424 },
2425 (table) => [
2426 uniqueIndex("prod_signals_repo_hash_unique").on(
2427 table.repositoryId,
2428 table.errorHash
2429 ),
2430 index("prod_signals_repo_sha_idx").on(
2431 table.repositoryId,
2432 table.commitSha
2433 ),
2434 index("prod_signals_repo_status_seen_idx").on(
2435 table.repositoryId,
2436 table.status,
2437 table.lastSeen
2438 ),
2439 index("prod_signals_source_kind_idx").on(table.source, table.kind),
2440 ]
2441);
2442
2443export type ProdSignal = typeof prodSignals.$inferSelect;
Addedsrc/lib/agent-identity.ts+476−0View fileUnifiedSplit
1/**
2 * Block K2 — Agent identities + scoped permissions.
3 *
4 * Thin wrapper over Block H's marketplace.ts that lets the K-agent layer
5 * spin up auditable "bot" apps per agent kind. Every K-agent runs as an
6 * app with slug `agent-<kind>` and a matching `app_bots` row named
7 * `agent-<kind>[bot]`. Installations are per-repository, permissions are
8 * scoped at install time and revocable, and every token is minted through
9 * marketplace.ts's `ghi_`-prefixed install-token issuer.
10 *
11 * No new tables — K2 reuses H1/H2's `apps`, `app_installations`, `app_bots`,
12 * `app_install_tokens`, `app_events`.
13 *
14 * Design rules:
15 * - Do not duplicate primitives (hashing, token gen, bot username). Import
16 * from marketplace.ts.
17 * - Do not modify marketplace.ts. If we need extra vocabulary, layer it.
18 * - `AGENT_PERMISSIONS` is a superset of marketplace `KNOWN_PERMISSIONS`
19 * and adds `agent:invoke` so one agent can trigger another.
20 * - All DB helpers return null / false on error — never throw. This mirrors
21 * marketplace.ts's style and keeps callers out of try/catch ceremony.
22 */
23
24import { and, asc, eq, isNull } from "drizzle-orm";
25import { db } from "../db";
26import {
27 apps,
28 appBots,
29 appEvents,
30 appInstallations,
31 appInstallTokens,
32 users,
33 type App,
34 type AppInstallation,
35} from "../db/schema";
36import {
37 botUsername,
38 hasPermission,
39 hashBearer,
40 issueInstallToken,
41 KNOWN_PERMISSIONS,
42 verifyInstallToken,
43 type Permission,
44} from "./marketplace";
45
46// ---------------------------------------------------------------------------
47// Permission vocabulary
48// ---------------------------------------------------------------------------
49
50/**
51 * Vocabulary agents are allowed to request. Superset of marketplace
52 * `KNOWN_PERMISSIONS` + the new `agent:invoke` which lets one K-agent spawn
53 * another.
54 *
55 * Only the marketplace-known subset can be persisted through `createApp` /
56 * `installApp`. `agent:invoke` is stored by writing straight to `apps.permissions`
57 * / `app_installations.granted_permissions` via this module, bypassing the
58 * `normalisePermissions` filter in marketplace.ts (which would drop unknowns).
59 */
60export const AGENT_PERMISSIONS = [
61 "contents:read",
62 "contents:write",
63 "issues:read",
64 "issues:write",
65 "pulls:read",
66 "pulls:write",
67 "checks:read",
68 "checks:write",
69 "deployments:read",
70 "deployments:write",
71 "metadata:read",
72 "agent:invoke",
73] as const;
74
75export type AgentPermission = (typeof AGENT_PERMISSIONS)[number];
76
77/** Which of the agent perms are also understood by marketplace.ts. */
78const MARKETPLACE_PERMS: ReadonlySet<string> = new Set(
79 KNOWN_PERMISSIONS as readonly string[]
80);
81
82/** All agent slugs are prefixed with `agent-`. */
83export const AGENT_SLUG_PREFIX = "agent-";
84
85// ---------------------------------------------------------------------------
86// Pure helpers
87// ---------------------------------------------------------------------------
88
89/** Is this string a valid agent permission in our vocabulary? */
90export function isAgentPermission(p: string): p is AgentPermission {
91 return (AGENT_PERMISSIONS as readonly string[]).includes(p);
92}
93
94/** Drop unknowns, de-duplicate, preserve order of first appearance. */
95export function normaliseAgentPermissions(
96 input: readonly string[]
97): AgentPermission[] {
98 const seen = new Set<string>();
99 const out: AgentPermission[] = [];
100 for (const p of input) {
101 if (isAgentPermission(p) && !seen.has(p)) {
102 seen.add(p);
103 out.push(p);
104 }
105 }
106 return out;
107}
108
109/** Parse JSON permissions column, filtered to the agent vocabulary. */
110export function parseAgentPermissions(
111 raw: string | null | undefined
112): AgentPermission[] {
113 if (!raw) return [];
114 try {
115 const parsed = JSON.parse(raw);
116 if (!Array.isArray(parsed)) return [];
117 return normaliseAgentPermissions(parsed);
118 } catch {
119 return [];
120 }
121}
122
123/** Derive the canonical agent slug for a kind (e.g. "reviewer" → "agent-reviewer"). */
124export function agentSlug(kind: string): string {
125 const trimmed = (kind || "").toLowerCase().replace(/[^a-z0-9]+/g, "-");
126 const stripped = trimmed.replace(/^-+|-+$/g, "").slice(0, 32);
127 const base = stripped || "unknown";
128 return base.startsWith(AGENT_SLUG_PREFIX) ? base : AGENT_SLUG_PREFIX + base;
129}
130
131/** Guard: does this bot username belong to a K-agent? */
132export function isAgentBotUsername(name: string | null | undefined): boolean {
133 if (!name) return false;
134 return name.startsWith(AGENT_SLUG_PREFIX) && name.endsWith("[bot]");
135}
136
137// ---------------------------------------------------------------------------
138// DB helpers — defensive, null-returning on error.
139// ---------------------------------------------------------------------------
140
141/** Pick the oldest user as "system" for creator_id bootstrapping. */
142async function resolveSystemUserId(): Promise<string | null> {
143 try {
144 const [first] = await db
145 .select({ id: users.id })
146 .from(users)
147 .orderBy(asc(users.createdAt))
148 .limit(1);
149 return first?.id || null;
150 } catch {
151 return null;
152 }
153}
154
155/**
156 * Idempotent: ensure an app row with the given slug exists, plus its
157 * `app_bots` row. Returns the app, or null on failure (e.g. no users yet).
158 *
159 * `slug` is rewritten to have the `agent-` prefix if missing. Permissions
160 * are stored as the full agent vocabulary (including `agent:invoke`),
161 * bypassing marketplace's filter.
162 */
163export async function ensureAgentApp(
164 slug: string,
165 displayName: string,
166 permissions: readonly string[]
167): Promise<App | null> {
168 const finalSlug = slug.startsWith(AGENT_SLUG_PREFIX)
169 ? slug
170 : AGENT_SLUG_PREFIX + slug;
171 const perms = normaliseAgentPermissions(permissions);
172 try {
173 const [existing] = await db
174 .select()
175 .from(apps)
176 .where(eq(apps.slug, finalSlug))
177 .limit(1);
178 if (existing) return existing;
179 const creatorId = await resolveSystemUserId();
180 if (!creatorId) {
181 console.error(
182 "[agent-identity] ensureAgentApp: no users exist yet; cannot bootstrap"
183 );
184 return null;
185 }
186 const [row] = await db
187 .insert(apps)
188 .values({
189 slug: finalSlug,
190 name: displayName,
191 description: `K-agent: ${displayName}`,
192 creatorId,
193 permissions: JSON.stringify(perms),
194 defaultEvents: "[]",
195 isPublic: false,
196 })
197 .returning();
198 if (!row) return null;
199 // Matching bot account — unique on username + appId.
200 try {
201 await db.insert(appBots).values({
202 appId: row.id,
203 username: botUsername(finalSlug),
204 displayName: `${displayName} (agent bot)`,
205 });
206 } catch (err) {
207 // Duplicate from a prior partial run is fine; anything else is logged.
208 if (!String((err as Error)?.message || "").includes("duplicate")) {
209 console.error("[agent-identity] ensureAgentApp bot insert:", err);
210 }
211 }
212 return row;
213 } catch (err) {
214 console.error("[agent-identity] ensureAgentApp:", err);
215 return null;
216 }
217}
218
219/**
220 * Install an agent for a single repository. Idempotent — if a non-uninstalled
221 * installation exists, the granted permissions are overwritten to the new
222 * set. Audit trail is recorded via `app_events`.
223 *
224 * `grantedPermissions` is filtered through the agent vocabulary AND against
225 * the permissions the app originally declared — you cannot grant more than
226 * the agent asked for. Returns the installation row, or null on failure.
227 */
228export async function installAgentForRepo(
229 agentSlug: string,
230 repoId: string,
231 installerUserId: string,
232 grantedPermissions: readonly string[]
233): Promise<AppInstallation | null> {
234 try {
235 const [app] = await db
236 .select()
237 .from(apps)
238 .where(eq(apps.slug, agentSlug))
239 .limit(1);
240 if (!app) return null;
241 const appPerms = parseAgentPermissions(app.permissions);
242 const requested = normaliseAgentPermissions(grantedPermissions);
243 const filtered = requested.filter((p) => appPerms.includes(p));
244 const [existing] = await db
245 .select()
246 .from(appInstallations)
247 .where(
248 and(
249 eq(appInstallations.appId, app.id),
250 eq(appInstallations.targetType, "repository"),
251 eq(appInstallations.targetId, repoId),
252 isNull(appInstallations.uninstalledAt)
253 )
254 )
255 .limit(1);
256 if (existing) {
257 await db
258 .update(appInstallations)
259 .set({ grantedPermissions: JSON.stringify(filtered) })
260 .where(eq(appInstallations.id, existing.id));
261 try {
262 await db.insert(appEvents).values({
263 appId: app.id,
264 installationId: existing.id,
265 kind: "installed",
266 payload: JSON.stringify({ updated: true, agent: agentSlug }),
267 });
268 } catch {
269 /* audit best-effort */
270 }
271 return existing;
272 }
273 const [row] = await db
274 .insert(appInstallations)
275 .values({
276 appId: app.id,
277 installedBy: installerUserId,
278 targetType: "repository",
279 targetId: repoId,
280 grantedPermissions: JSON.stringify(filtered),
281 })
282 .returning();
283 if (row) {
284 try {
285 await db.insert(appEvents).values({
286 appId: app.id,
287 installationId: row.id,
288 kind: "installed",
289 payload: JSON.stringify({ agent: agentSlug, repoId }),
290 });
291 } catch {
292 /* audit best-effort */
293 }
294 }
295 return row || null;
296 } catch (err) {
297 console.error("[agent-identity] installAgentForRepo:", err);
298 return null;
299 }
300}
301
302/**
303 * Mint a short-lived bearer for an agent scoped to a single repo.
304 * Reuses marketplace.ts's `issueInstallToken` — we do not duplicate
305 * token generation here.
306 */
307export async function issueAgentToken(
308 agentSlug: string,
309 repoId: string,
310 ttlSeconds = 3600
311): Promise<{ token: string; expiresAt: Date } | null> {
312 try {
313 const [app] = await db
314 .select()
315 .from(apps)
316 .where(eq(apps.slug, agentSlug))
317 .limit(1);
318 if (!app) return null;
319 const [install] = await db
320 .select()
321 .from(appInstallations)
322 .where(
323 and(
324 eq(appInstallations.appId, app.id),
325 eq(appInstallations.targetType, "repository"),
326 eq(appInstallations.targetId, repoId),
327 isNull(appInstallations.uninstalledAt)
328 )
329 )
330 .limit(1);
331 if (!install) return null;
332 return await issueInstallToken(install.id, ttlSeconds);
333 } catch (err) {
334 console.error("[agent-identity] issueAgentToken:", err);
335 return null;
336 }
337}
338
339/** What `verifyAgentToken` returns when a token is valid + agent-shaped. */
340export interface AgentTokenContext {
341 agentSlug: string;
342 repoId: string;
343 botUsername: string;
344 permissions: AgentPermission[];
345 installationId: string;
346}
347
348/**
349 * Verify a bearer. Returns null when:
350 * - the token is not `ghi_`-prefixed,
351 * - the underlying install-token is unknown / expired / revoked / uninstalled,
352 * - the bot behind the token is not an `agent-*` bot.
353 */
354export async function verifyAgentToken(
355 token: string
356): Promise<AgentTokenContext | null> {
357 if (!token || !token.startsWith("ghi_")) return null;
358 const ctx = await verifyInstallToken(token);
359 if (!ctx) return null;
360 if (!isAgentBotUsername(ctx.botUsername)) return null;
361 if (ctx.installation.targetType !== "repository") return null;
362 return {
363 agentSlug: ctx.app.slug,
364 repoId: ctx.installation.targetId,
365 botUsername: ctx.botUsername,
366 permissions: parseAgentPermissions(ctx.installation.grantedPermissions),
367 installationId: ctx.installation.id,
368 };
369}
370
371/**
372 * Verify + assert a required permission. Returns the context on success,
373 * throws `Error` on failure. Handlers should wrap with try/catch and map
374 * to a 401 / 403 response.
375 */
376export async function requireAgentPermission(
377 token: string,
378 permission: AgentPermission | string
379): Promise<AgentTokenContext> {
380 const ctx = await verifyAgentToken(token);
381 if (!ctx) throw new Error("agent token invalid or expired");
382 if (!hasPermission(ctx.permissions as readonly string[], permission)) {
383 throw new Error(`agent token missing permission: ${permission}`);
384 }
385 return ctx;
386}
387
388/** Revoke a single token by its stored hash. Idempotent. */
389export async function revokeAgentToken(tokenHash: string): Promise<boolean> {
390 if (!tokenHash) return false;
391 try {
392 const [row] = await db
393 .update(appInstallTokens)
394 .set({ revokedAt: new Date() })
395 .where(
396 and(
397 eq(appInstallTokens.tokenHash, tokenHash),
398 isNull(appInstallTokens.revokedAt)
399 )
400 )
401 .returning();
402 return !!row;
403 } catch (err) {
404 console.error("[agent-identity] revokeAgentToken:", err);
405 return false;
406 }
407}
408
409/** Convenience: hash + revoke in one go (when you only have the raw token). */
410export async function revokeAgentTokenByRaw(token: string): Promise<boolean> {
411 if (!token) return false;
412 return revokeAgentToken(hashBearer(token));
413}
414
415/**
416 * Soft-uninstall an agent for a repo. Sets `uninstalledAt` and revokes any
417 * outstanding tokens for that installation. Idempotent — returns false if
418 * no matching install was found.
419 */
420export async function uninstallAgent(
421 agentSlug: string,
422 repoId: string
423): Promise<boolean> {
424 try {
425 const [app] = await db
426 .select()
427 .from(apps)
428 .where(eq(apps.slug, agentSlug))
429 .limit(1);
430 if (!app) return false;
431 const [updated] = await db
432 .update(appInstallations)
433 .set({ uninstalledAt: new Date() })
434 .where(
435 and(
436 eq(appInstallations.appId, app.id),
437 eq(appInstallations.targetType, "repository"),
438 eq(appInstallations.targetId, repoId),
439 isNull(appInstallations.uninstalledAt)
440 )
441 )
442 .returning();
443 if (!updated) return false;
444 // Revoke every live token.
445 try {
446 await db
447 .update(appInstallTokens)
448 .set({ revokedAt: new Date() })
449 .where(
450 and(
451 eq(appInstallTokens.installationId, updated.id),
452 isNull(appInstallTokens.revokedAt)
453 )
454 );
455 } catch {
456 /* best-effort */
457 }
458 try {
459 await db.insert(appEvents).values({
460 appId: app.id,
461 installationId: updated.id,
462 kind: "uninstalled",
463 payload: JSON.stringify({ agent: agentSlug, repoId }),
464 });
465 } catch {
466 /* audit best-effort */
467 }
468 return true;
469 } catch (err) {
470 console.error("[agent-identity] uninstallAgent:", err);
471 return false;
472 }
473}
474
475// Types re-exported for callers that only import from this module.
476export type { Permission };
Addedsrc/lib/agent-runtime.ts+613−0View fileUnifiedSplit
1/**
2 * Block K1 — Autonomous agent runtime + sandbox.
3 *
4 * The substrate every other Block K agent (triage, fix, review_response,
5 * deploy_watcher, heal_bot) runs on top of. A single `agent_runs` row records
6 * one invocation: its kind + trigger, lifecycle status, a size-capped
7 * append-only log, cost accounting, and an optional error message.
8 *
9 * File layout mirrors commit-statuses.ts:
10 * 1. Pure helpers (types, truncation, state machine) — fully unit-testable
11 * 2. Sandboxing primitive (runSandboxed) — Bun.spawn wrapper
12 * 3. DB helpers — wrap every call in try/catch
13 *
14 * Every DB helper returns null/false on failure and console.errors; this
15 * matches the "never crash the caller" philosophy of workflow-runner.ts and
16 * post-receive.ts.
17 */
18
19import { sql } from "drizzle-orm";
20import { db } from "../db";
21
22// ---------------------------------------------------------------------------
23// Pure helpers
24// ---------------------------------------------------------------------------
25
26export type AgentRunStatus =
27 | "queued"
28 | "running"
29 | "succeeded"
30 | "failed"
31 | "killed"
32 | "timeout";
33
34export type AgentRunTrigger =
35 | "issue.opened"
36 | "pr.opened"
37 | "pr.review_comment"
38 | "deploy.failed"
39 | "manual"
40 | "scheduled";
41
42export type AgentKind =
43 | "triage"
44 | "fix"
45 | "review_response"
46 | "deploy_watcher"
47 | "heal_bot"
48 | "custom";
49
50const TERMINAL_STATUSES: ReadonlySet<AgentRunStatus> = new Set([
51 "succeeded",
52 "failed",
53 "killed",
54 "timeout",
55]);
56
57/** True iff `s` is a final, immutable status. */
58export function isTerminalStatus(s: AgentRunStatus): boolean {
59 return TERMINAL_STATUSES.has(s);
60}
61
62/**
63 * Allowed transitions:
64 * queued → running | killed (killed lets an operator cancel before start)
65 * running → succeeded | failed | timeout | killed
66 * terminal → nothing
67 *
68 * Self-transitions are disallowed so a double-write doesn't silently "succeed".
69 */
70export function canTransition(
71 from: AgentRunStatus,
72 to: AgentRunStatus
73): boolean {
74 if (from === to) return false;
75 if (isTerminalStatus(from)) return false;
76 if (from === "queued") {
77 return to === "running" || to === "killed";
78 }
79 if (from === "running") {
80 return (
81 to === "succeeded" ||
82 to === "failed" ||
83 to === "timeout" ||
84 to === "killed"
85 );
86 }
87 return false;
88}
89
90const LOG_TRUNCATED_SENTINEL = "[log truncated]\n";
91
92/**
93 * Append `addition` to `existing` and cap the total at `maxBytes`.
94 *
95 * When the combined length exceeds the cap we keep only the last `maxBytes`
96 * characters and prepend the `[log truncated]\n` sentinel — but only once.
97 * If `existing` already starts with the sentinel we don't double it.
98 *
99 * Measurement is character-based (JS string .length) rather than UTF-8 byte
100 * length. For our log content (ASCII-dominant stdout/stderr) the two are
101 * equivalent to within a few percent; correctness of the cap in the
102 * pathological multibyte case is not worth the Buffer round-trip cost.
103 */
104export function truncateLog(
105 existing: string,
106 addition: string,
107 maxBytes: number = 256 * 1024
108): string {
109 const combined = (existing || "") + (addition || "");
110 if (combined.length <= maxBytes) return combined;
111
112 // Strip any prior sentinel so we don't double it after a re-truncation.
113 const stripped = combined.startsWith(LOG_TRUNCATED_SENTINEL)
114 ? combined.slice(LOG_TRUNCATED_SENTINEL.length)
115 : combined;
116
117 // Take the last maxBytes chars and prepend a single sentinel. Reserve space
118 // for the sentinel so the final string length is <= maxBytes + sentinel.
119 const tail = stripped.slice(-maxBytes);
120 return LOG_TRUNCATED_SENTINEL + tail;
121}
122
123/** Cap an error message / stack trace for DB storage. */
124export function truncateError(
125 msg: string,
126 maxChars: number = 4096
127): string {
128 if (!msg) return "";
129 if (msg.length <= maxChars) return msg;
130 return msg.slice(0, maxChars) + "\n[... truncated ...]";
131}
132
133// ---------------------------------------------------------------------------
134// Sandboxing
135// ---------------------------------------------------------------------------
136
137export interface SandboxOptions {
138 cwd: string;
139 env?: Record<string, string>;
140 timeoutMs?: number;
141 stdoutCapBytes?: number;
142 stderrCapBytes?: number;
143}
144
145export interface SandboxResult {
146 exitCode: number | null;
147 stdout: string;
148 stderr: string;
149 timedOut: boolean;
150}
151
152const DEFAULT_SANDBOX_TIMEOUT_MS = 10 * 60 * 1000; // 10 minutes
153const DEFAULT_SANDBOX_STREAM_CAP = 64 * 1024; // 64 KB per stream
154const SANDBOX_KILL_GRACE_MS = 5_000;
155
156function capStream(value: string, limit: number): string {
157 if (value.length <= limit) return value;
158 return value.slice(0, limit) + "\n[... truncated ...]";
159}
160
161/**
162 * Spawn `cmd args` under a minimal env with a hard timeout.
163 *
164 * Semantics:
165 * - env defaults to `{ PATH, HOME }` from the parent process if the caller
166 * passes nothing. This is deliberately narrower than `process.env` so
167 * secrets (DATABASE_URL, ANTHROPIC_API_KEY, etc) aren't accidentally
168 * leaked to agent-authored commands.
169 * - On timeout we send SIGTERM, then SIGKILL 5s later.
170 * - stdout/stderr are capped to 64 KB each by default; overflow is
171 * replaced with a `\n[... truncated ...]` sentinel.
172 * - Never throws — a spawn failure is reported as exitCode=null with the
173 * error message on stderr.
174 */
175export async function runSandboxed(
176 cmd: string,
177 args: string[],
178 opts: SandboxOptions
179): Promise<SandboxResult> {
180 const timeoutMs = opts.timeoutMs ?? DEFAULT_SANDBOX_TIMEOUT_MS;
181 const stdoutCap = opts.stdoutCapBytes ?? DEFAULT_SANDBOX_STREAM_CAP;
182 const stderrCap = opts.stderrCapBytes ?? DEFAULT_SANDBOX_STREAM_CAP;
183
184 const env = opts.env
185 ? opts.env
186 : {
187 PATH: process.env.PATH ?? "/usr/bin:/bin",
188 HOME: process.env.HOME ?? "/tmp",
189 };
190
191 let proc: ReturnType<typeof Bun.spawn> | null = null;
192 let timedOut = false;
193 let killTimer: ReturnType<typeof setTimeout> | null = null;
194 let escalateTimer: ReturnType<typeof setTimeout> | null = null;
195
196 try {
197 proc = Bun.spawn([cmd, ...args], {
198 cwd: opts.cwd,
199 env,
200 stdout: "pipe",
201 stderr: "pipe",
202 });
203
204 killTimer = setTimeout(() => {
205 timedOut = true;
206 try {
207 proc?.kill("SIGTERM");
208 } catch {
209 /* ignore */
210 }
211 escalateTimer = setTimeout(() => {
212 try {
213 proc?.kill("SIGKILL");
214 } catch {
215 /* ignore */
216 }
217 }, SANDBOX_KILL_GRACE_MS);
218 }, timeoutMs);
219
220 const stdoutPromise = proc.stdout
221 ? new Response(proc.stdout as ReadableStream).text()
222 : Promise.resolve("");
223 const stderrPromise = proc.stderr
224 ? new Response(proc.stderr as ReadableStream).text()
225 : Promise.resolve("");
226
227 const [stdoutRaw, stderrRaw] = await Promise.all([
228 stdoutPromise.catch(() => ""),
229 stderrPromise.catch(() => ""),
230 ]);
231 const exitCode = await proc.exited;
232
233 if (killTimer) clearTimeout(killTimer);
234 if (escalateTimer) clearTimeout(escalateTimer);
235
236 return {
237 exitCode,
238 stdout: capStream(stdoutRaw, stdoutCap),
239 stderr: capStream(
240 timedOut
241 ? `${stderrRaw}\n[sandbox killed after ${timeoutMs}ms timeout]`
242 : stderrRaw,
243 stderrCap
244 ),
245 timedOut,
246 };
247 } catch (err) {
248 if (killTimer) clearTimeout(killTimer);
249 if (escalateTimer) clearTimeout(escalateTimer);
250 return {
251 exitCode: null,
252 stdout: "",
253 stderr: capStream(
254 `[sandbox] spawn failed: ${(err as Error).message}`,
255 stderrCap
256 ),
257 timedOut,
258 };
259 }
260}
261
262// ---------------------------------------------------------------------------
263// DB helpers
264// ---------------------------------------------------------------------------
265
266/**
267 * Shape of a row in `agent_runs` — mirrors drizzle/0034_agent_runs.sql.
268 * Defined here (rather than imported from db/schema) because the Drizzle
269 * table is added by the main thread; this file stays self-contained so
270 * agents can build against it before the schema ships.
271 */
272export interface AgentRun {
273 id: string;
274 repositoryId: string;
275 kind: AgentKind | string;
276 trigger: AgentRunTrigger | string;
277 triggerRef: string | null;
278 status: AgentRunStatus;
279 summary: string | null;
280 log: string;
281 costInputTokens: number;
282 costOutputTokens: number;
283 costCents: number;
284 startedAt: Date | null;
285 finishedAt: Date | null;
286 createdAt: Date;
287 errorMessage: string | null;
288}
289
290/**
291 * Map a raw Postgres row (snake_case) to our TS shape. Neon returns values
292 * pre-parsed (timestamps as Date, ints as number) so we only rename.
293 */
294function rowToAgentRun(row: Record<string, unknown>): AgentRun {
295 return {
296 id: String(row.id),
297 repositoryId: String(row.repository_id),
298 kind: String(row.kind),
299 trigger: String(row.trigger),
300 triggerRef: (row.trigger_ref as string | null) ?? null,
301 status: String(row.status) as AgentRunStatus,
302 summary: (row.summary as string | null) ?? null,
303 log: String(row.log ?? ""),
304 costInputTokens: Number(row.cost_input_tokens ?? 0),
305 costOutputTokens: Number(row.cost_output_tokens ?? 0),
306 costCents: Number(row.cost_cents ?? 0),
307 startedAt: (row.started_at as Date | null) ?? null,
308 finishedAt: (row.finished_at as Date | null) ?? null,
309 createdAt: (row.created_at as Date) ?? new Date(0),
310 errorMessage: (row.error_message as string | null) ?? null,
311 };
312}
313
314export interface StartAgentRunInput {
315 repositoryId: string;
316 kind: AgentKind;
317 trigger: AgentRunTrigger;
318 triggerRef?: string | null;
319}
320
321/** Insert a queued run. Returns the row or null on DB failure. */
322export async function startAgentRun(
323 input: StartAgentRunInput
324): Promise<AgentRun | null> {
325 try {
326 const rows = (await db.execute(sql`
327 INSERT INTO agent_runs (repository_id, kind, trigger, trigger_ref, status)
328 VALUES (
329 ${input.repositoryId},
330 ${input.kind},
331 ${input.trigger},
332 ${input.triggerRef ?? null},
333 'queued'
334 )
335 RETURNING *
336 `)) as unknown as Array<Record<string, unknown>>;
337 const row = Array.isArray(rows) ? rows[0] : undefined;
338 return row ? rowToAgentRun(row) : null;
339 } catch (err) {
340 console.error("[agent-runtime] startAgentRun:", err);
341 return null;
342 }
343}
344
345export async function getAgentRun(id: string): Promise<AgentRun | null> {
346 try {
347 const rows = (await db.execute(sql`
348 SELECT * FROM agent_runs WHERE id = ${id} LIMIT 1
349 `)) as unknown as Array<Record<string, unknown>>;
350 const row = Array.isArray(rows) ? rows[0] : undefined;
351 return row ? rowToAgentRun(row) : null;
352 } catch (err) {
353 console.error("[agent-runtime] getAgentRun:", err);
354 return null;
355 }
356}
357
358export interface ListAgentRunsOptions {
359 limit?: number;
360 status?: AgentRunStatus;
361 kind?: AgentKind;
362}
363
364export async function listAgentRunsForRepo(
365 repositoryId: string,
366 opts: ListAgentRunsOptions = {}
367): Promise<AgentRun[]> {
368 const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
369 try {
370 // Filters are composed conditionally — sql`` interpolation handles
371 // parameterisation safely.
372 const statusFilter = opts.status
373 ? sql`AND status = ${opts.status}`
374 : sql``;
375 const kindFilter = opts.kind ? sql`AND kind = ${opts.kind}` : sql``;
376 const rows = (await db.execute(sql`
377 SELECT * FROM agent_runs
378 WHERE repository_id = ${repositoryId}
379 ${statusFilter}
380 ${kindFilter}
381 ORDER BY created_at DESC
382 LIMIT ${limit}
383 `)) as unknown as Array<Record<string, unknown>>;
384 if (!Array.isArray(rows)) return [];
385 return rows.map(rowToAgentRun);
386 } catch (err) {
387 console.error("[agent-runtime] listAgentRunsForRepo:", err);
388 return [];
389 }
390}
391
392async function setStatus(
393 runId: string,
394 status: AgentRunStatus,
395 extra: {
396 summary?: string | null;
397 errorMessage?: string | null;
398 setStartedAt?: boolean;
399 setFinishedAt?: boolean;
400 } = {}
401): Promise<boolean> {
402 try {
403 const summaryClause =
404 extra.summary !== undefined
405 ? sql`, summary = ${extra.summary}`
406 : sql``;
407 const errorClause =
408 extra.errorMessage !== undefined
409 ? sql`, error_message = ${extra.errorMessage}`
410 : sql``;
411 const startedClause = extra.setStartedAt ? sql`, started_at = now()` : sql``;
412 const finishedClause = extra.setFinishedAt
413 ? sql`, finished_at = now()`
414 : sql``;
415 await db.execute(sql`
416 UPDATE agent_runs
417 SET status = ${status}
418 ${summaryClause}
419 ${errorClause}
420 ${startedClause}
421 ${finishedClause}
422 WHERE id = ${runId}
423 `);
424 return true;
425 } catch (err) {
426 console.error("[agent-runtime] setStatus:", err);
427 return false;
428 }
429}
430
431export interface AgentExecutorContext {
432 appendLog: (line: string) => Promise<void>;
433 recordCost: (
434 inputTokens: number,
435 outputTokens: number,
436 cents: number
437 ) => Promise<void>;
438}
439
440export interface AgentExecutorResult {
441 ok: boolean;
442 summary: string;
443}
444
445export interface ExecuteAgentRunOptions {
446 timeoutMs?: number;
447}
448
449const DEFAULT_EXECUTOR_TIMEOUT_MS = 10 * 60 * 1000;
450
451/**
452 * Drive a queued run through its lifecycle. Never throws.
453 *
454 * 1. Transition queued → running (abort if transition fails).
455 * 2. Invoke `executor` with a context that lets it append logs + record cost.
456 * 3. If executor resolves: transition running → succeeded|failed + store summary.
457 * 4. If timeout fires first: transition running → timeout.
458 * 5. If executor throws: transition running → failed with truncated stack.
459 *
460 * Note: this wrapper does NOT hold a Bun.Subprocess handle — the executor
461 * owns any child process. On timeout we simply stop awaiting and flip DB
462 * state; the executor's runSandboxed call should be passed a timeout slightly
463 * lower than ours so its own SIGKILL happens first.
464 */
465export async function executeAgentRun<T extends AgentExecutorResult>(
466 runId: string,
467 executor: (ctx: AgentExecutorContext) => Promise<T>,
468 opts: ExecuteAgentRunOptions = {}
469): Promise<void> {
470 const timeoutMs = opts.timeoutMs ?? DEFAULT_EXECUTOR_TIMEOUT_MS;
471
472 const transitioned = await setStatus(runId, "running", {
473 setStartedAt: true,
474 });
475 if (!transitioned) {
476 console.error(
477 `[agent-runtime] executeAgentRun: could not transition ${runId} to running`
478 );
479 return;
480 }
481
482 const ctx: AgentExecutorContext = {
483 appendLog: async (line: string) => {
484 await appendAgentLog(runId, line);
485 },
486 recordCost: async (input, output, cents) => {
487 await recordAgentCost(runId, input, output, cents);
488 },
489 };
490
491 let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
492 let didTimeout = false;
493 const timeoutPromise = new Promise<"__timeout__">((resolve) => {
494 timeoutHandle = setTimeout(() => {
495 didTimeout = true;
496 resolve("__timeout__");
497 }, timeoutMs);
498 });
499
500 try {
501 const race = await Promise.race([executor(ctx), timeoutPromise]);
502 if (timeoutHandle) clearTimeout(timeoutHandle);
503
504 if (didTimeout || race === "__timeout__") {
505 await setStatus(runId, "timeout", {
506 setFinishedAt: true,
507 errorMessage: truncateError(
508 `Agent run exceeded timeout of ${timeoutMs}ms`
509 ),
510 });
511 return;
512 }
513
514 const result = race as T;
515 await setStatus(runId, result.ok ? "succeeded" : "failed", {
516 setFinishedAt: true,
517 summary: (result.summary ?? "").slice(0, 2000),
518 });
519 } catch (err) {
520 if (timeoutHandle) clearTimeout(timeoutHandle);
521 const stack =
522 err instanceof Error
523 ? err.stack || err.message
524 : String(err);
525 await setStatus(runId, "failed", {
526 setFinishedAt: true,
527 errorMessage: truncateError(stack),
528 });
529 }
530}
531
532/**
533 * Best-effort kill: flip DB status from queued/running → killed. Does NOT
534 * terminate an in-flight subprocess — that subprocess is owned by whichever
535 * executor invoked runSandboxed. Cross-process SIGKILL would require tracking
536 * a Bun.Subprocess handle in memory, which doesn't survive restarts and is
537 * out of scope for v1. Operators wanting a real kill should restart the
538 * worker; the executor's own runSandboxed timeout will then bound runtime.
539 */
540export async function killAgentRun(runId: string): Promise<boolean> {
541 try {
542 await db.execute(sql`
543 UPDATE agent_runs
544 SET status = 'killed', finished_at = now()
545 WHERE id = ${runId}
546 AND status IN ('queued', 'running')
547 `);
548 return true;
549 } catch (err) {
550 console.error("[agent-runtime] killAgentRun:", err);
551 return false;
552 }
553}
554
555/**
556 * Append `line` to the run's log, capped at 256 KB. Re-reads the current
557 * log then writes back — good enough for v1 since a run has a single
558 * executor and therefore no concurrent writers.
559 */
560export async function appendAgentLog(
561 runId: string,
562 line: string
563): Promise<boolean> {
564 try {
565 const rows = (await db.execute(sql`
566 SELECT log FROM agent_runs WHERE id = ${runId} LIMIT 1
567 `)) as unknown as Array<Record<string, unknown>>;
568 const row = Array.isArray(rows) ? rows[0] : undefined;
569 if (!row) return false;
570 const existing = String(row.log ?? "");
571 const next = truncateLog(existing, line.endsWith("\n") ? line : line + "\n");
572 await db.execute(sql`
573 UPDATE agent_runs SET log = ${next} WHERE id = ${runId}
574 `);
575 return true;
576 } catch (err) {
577 console.error("[agent-runtime] appendAgentLog:", err);
578 return false;
579 }
580}
581
582/** Atomically add to the cost counters. */
583export async function recordAgentCost(
584 runId: string,
585 inputTokens: number,
586 outputTokens: number,
587 cents: number
588): Promise<boolean> {
589 try {
590 const dIn = Math.max(0, Math.floor(inputTokens || 0));
591 const dOut = Math.max(0, Math.floor(outputTokens || 0));
592 const dCents = Math.max(0, Math.floor(cents || 0));
593 await db.execute(sql`
594 UPDATE agent_runs
595 SET cost_input_tokens = cost_input_tokens + ${dIn},
596 cost_output_tokens = cost_output_tokens + ${dOut},
597 cost_cents = cost_cents + ${dCents}
598 WHERE id = ${runId}
599 `);
600 return true;
601 } catch (err) {
602 console.error("[agent-runtime] recordAgentCost:", err);
603 return false;
604 }
605}
606
607export const __internal = {
608 LOG_TRUNCATED_SENTINEL,
609 DEFAULT_SANDBOX_TIMEOUT_MS,
610 DEFAULT_SANDBOX_STREAM_CAP,
611 SANDBOX_KILL_GRACE_MS,
612 DEFAULT_EXECUTOR_TIMEOUT_MS,
613};
Addedsrc/lib/crontech-client.ts+319−0View fileUnifiedSplit
1/**
2 * Block K — Crontech HTTP client.
3 *
4 * Typed tool primitives that K-agents call to drive Crontech (external
5 * runtime / deploy platform). Each primitive hits the documented endpoint
6 * when `CRONTECH_API_KEY` is set and falls back to a deterministic offline
7 * mode otherwise. No method throws.
8 *
9 * Env vars (read lazily via getters so tests can flip them per-case):
10 * CRONTECH_API_KEY — bearer token for the Crontech API (required)
11 * CRONTECH_BASE_URL — override base URL (default `https://crontech.ai`)
12 *
13 * Endpoint shapes assumed (documented for the Crontech team):
14 * GET {base}/api/v1/deployments?repo=<owner/name>&sha=<commitSha>
15 * 200 -> Deployment | null
16 * POST {base}/api/v1/deployments
17 * body: { repo, commitSha, environment? }
18 * 200 -> Deployment
19 * POST {base}/api/v1/deployments/:id/rollback
20 * body: { repo }
21 * 200 -> { ok: true }
22 * GET {base}/api/v1/deployments/:id/status
23 * 200 -> { status: Deployment["status"], finishedAt?, url? }
24 * GET {base}/api/v1/deployments/:id/errors
25 * 200 -> { errors: [{ message, stackTrace?, count }] }
26 */
27
28// ---------------------------------------------------------------------------
29// Env getters
30// ---------------------------------------------------------------------------
31
32export const crontechEnv = {
33 get apiKey(): string {
34 return process.env.CRONTECH_API_KEY || "";
35 },
36 get baseUrl(): string {
37 return (process.env.CRONTECH_BASE_URL || "https://crontech.ai").replace(
38 /\/+$/,
39 ""
40 );
41 },
42};
43
44export function isConfigured(): boolean {
45 return !!crontechEnv.apiKey;
46}
47
48export function buildAuthHeaders(): Record<string, string> {
49 const headers: Record<string, string> = {
50 "Content-Type": "application/json",
51 };
52 const key = crontechEnv.apiKey;
53 if (key) headers["Authorization"] = `Bearer ${key}`;
54 return headers;
55}
56
57// ---------------------------------------------------------------------------
58// Types
59// ---------------------------------------------------------------------------
60
61export type DeploymentStatus =
62 | "pending"
63 | "deploying"
64 | "live"
65 | "failed"
66 | "rolled_back";
67
68export type Deployment = {
69 deployId: string;
70 commitSha: string;
71 status: DeploymentStatus;
72 environment: string;
73 url?: string;
74 startedAt: string;
75 finishedAt?: string;
76};
77
78export type DeployError = {
79 message: string;
80 stackTrace?: string;
81 count: number;
82};
83
84export type DeployWatchResult = {
85 deployId: string;
86 finalStatus: DeploymentStatus;
87 errors: DeployError[];
88 watchedForMs: number;
89 offline: boolean;
90};
91
92const TERMINAL_STATUSES: DeploymentStatus[] = ["live", "failed", "rolled_back"];
93
94function isTerminal(s: DeploymentStatus): boolean {
95 return TERMINAL_STATUSES.includes(s);
96}
97
98function coerceStatus(raw: unknown): DeploymentStatus {
99 const s = String(raw || "").toLowerCase();
100 if (
101 s === "pending" ||
102 s === "deploying" ||
103 s === "live" ||
104 s === "failed" ||
105 s === "rolled_back"
106 ) {
107 return s;
108 }
109 return "pending";
110}
111
112function coerceDeployment(data: unknown): Deployment | null {
113 if (!data || typeof data !== "object") return null;
114 const d = data as Record<string, unknown>;
115 const deployId = typeof d.deployId === "string" ? d.deployId : "";
116 if (!deployId) return null;
117 return {
118 deployId,
119 commitSha: typeof d.commitSha === "string" ? d.commitSha : "",
120 status: coerceStatus(d.status),
121 environment:
122 typeof d.environment === "string" ? d.environment : "production",
123 url: typeof d.url === "string" ? d.url : undefined,
124 startedAt:
125 typeof d.startedAt === "string"
126 ? d.startedAt
127 : new Date().toISOString(),
128 finishedAt: typeof d.finishedAt === "string" ? d.finishedAt : undefined,
129 };
130}
131
132// ---------------------------------------------------------------------------
133// Shared fetch-with-timeout helpers. Never throw — return null on any
134// failure so callers flip to the offline branch.
135// ---------------------------------------------------------------------------
136
137async function request(
138 url: string,
139 method: "GET" | "POST",
140 body: unknown,
141 timeoutMs: number
142): Promise<unknown | null> {
143 const controller = new AbortController();
144 const timer = setTimeout(() => controller.abort(), timeoutMs);
145 try {
146 const init: RequestInit = {
147 method,
148 headers: buildAuthHeaders(),
149 signal: controller.signal,
150 };
151 if (method !== "GET") init.body = JSON.stringify(body ?? {});
152 const res = await fetch(url, init);
153 if (!res.ok) return null;
154 return await res.json().catch(() => null);
155 } catch {
156 return null;
157 } finally {
158 clearTimeout(timer);
159 }
160}
161
162// ---------------------------------------------------------------------------
163// Public primitives
164// ---------------------------------------------------------------------------
165
166export async function getDeploymentForCommit(params: {
167 repo: string;
168 commitSha: string;
169}): Promise<Deployment | null> {
170 if (!isConfigured()) return null;
171 const qs = new URLSearchParams({
172 repo: params.repo,
173 sha: params.commitSha,
174 });
175 const url = `${crontechEnv.baseUrl}/api/v1/deployments?${qs.toString()}`;
176 const data = await request(url, "GET", undefined, 30_000);
177 return coerceDeployment(data);
178}
179
180export async function triggerRedeploy(params: {
181 repo: string;
182 commitSha: string;
183 environment?: string;
184}): Promise<Deployment | null> {
185 if (!isConfigured()) return null;
186 const url = `${crontechEnv.baseUrl}/api/v1/deployments`;
187 const data = await request(url, "POST", params, 60_000);
188 return coerceDeployment(data);
189}
190
191export async function rollbackDeployment(params: {
192 repo: string;
193 deployId: string;
194}): Promise<boolean> {
195 if (!isConfigured()) return false;
196 if (!params.deployId) return false;
197 const url = `${crontechEnv.baseUrl}/api/v1/deployments/${encodeURIComponent(
198 params.deployId
199 )}/rollback`;
200 const data = await request(url, "POST", { repo: params.repo }, 60_000);
201 if (!data || typeof data !== "object") return false;
202 // Accept either `{ok: true}` or any 200 JSON body as success.
203 const ok = (data as Record<string, unknown>).ok;
204 return ok === undefined ? true : !!ok;
205}
206
207export async function watchDeployment(params: {
208 repo: string;
209 deployId: string;
210 maxWaitMs?: number;
211 pollIntervalMs?: number;
212}): Promise<DeployWatchResult> {
213 const maxWaitMs = params.maxWaitMs ?? 300_000;
214 const pollIntervalMs = params.pollIntervalMs ?? 10_000;
215
216 if (!isConfigured()) {
217 return {
218 deployId: params.deployId,
219 finalStatus: "failed",
220 errors: [],
221 watchedForMs: 0,
222 offline: true,
223 };
224 }
225 if (!params.deployId) {
226 return {
227 deployId: "",
228 finalStatus: "failed",
229 errors: [],
230 watchedForMs: 0,
231 offline: true,
232 };
233 }
234
235 const started = Date.now();
236 const statusUrl = `${crontechEnv.baseUrl}/api/v1/deployments/${encodeURIComponent(
237 params.deployId
238 )}/status`;
239 const errorsUrl = `${crontechEnv.baseUrl}/api/v1/deployments/${encodeURIComponent(
240 params.deployId
241 )}/errors`;
242
243 let currentStatus: DeploymentStatus = "pending";
244 let errors: DeployError[] = [];
245 let pollsFailed = 0;
246 const maxPollFailures = 3;
247
248 while (Date.now() - started < maxWaitMs) {
249 const statusRes = await request(statusUrl, "GET", undefined, 30_000);
250 if (!statusRes || typeof statusRes !== "object") {
251 pollsFailed++;
252 if (pollsFailed >= maxPollFailures) {
253 return {
254 deployId: params.deployId,
255 finalStatus: "failed",
256 errors,
257 watchedForMs: Date.now() - started,
258 offline: true,
259 };
260 }
261 } else {
262 pollsFailed = 0;
263 currentStatus = coerceStatus(
264 (statusRes as Record<string, unknown>).status
265 );
266 if (isTerminal(currentStatus)) {
267 // On terminal state, fetch latest errors so the caller has context.
268 const errRes = await request(errorsUrl, "GET", undefined, 30_000);
269 if (errRes && typeof errRes === "object") {
270 const list = (errRes as Record<string, unknown>).errors;
271 if (Array.isArray(list)) {
272 errors = list
273 .filter((e) => e && typeof e === "object")
274 .map((e) => {
275 const obj = e as Record<string, unknown>;
276 return {
277 message:
278 typeof obj.message === "string" ? obj.message : "",
279 stackTrace:
280 typeof obj.stackTrace === "string"
281 ? obj.stackTrace
282 : undefined,
283 count: Number(obj.count || 1),
284 };
285 });
286 }
287 }
288 return {
289 deployId: params.deployId,
290 finalStatus: currentStatus,
291 errors,
292 watchedForMs: Date.now() - started,
293 offline: false,
294 };
295 }
296 }
297
298 // Sleep until next poll (bounded by remaining budget).
299 const remaining = maxWaitMs - (Date.now() - started);
300 if (remaining <= 0) break;
301 await new Promise((r) => setTimeout(r, Math.min(pollIntervalMs, remaining)));
302 }
303
304 return {
305 deployId: params.deployId,
306 finalStatus: isTerminal(currentStatus) ? currentStatus : "failed",
307 errors,
308 watchedForMs: Date.now() - started,
309 offline: false,
310 };
311}
312
313export const __internal = {
314 isTerminal,
315 coerceStatus,
316 coerceDeployment,
317 request,
318 TERMINAL_STATUSES,
319};
Addedsrc/lib/gatetest-client.ts+288−0View fileUnifiedSplit
1/**
2 * Block K — Gatetest HTTP client.
3 *
4 * Typed tool primitives that K-agents call to drive Gatetest (external
5 * testing / test-repair platform). Each primitive hits the documented
6 * endpoint when `GATETEST_API_KEY` is set and falls back to a deterministic
7 * offline mode otherwise. No method throws — callers get a well-formed
8 * result with `offline: true` on any failure.
9 *
10 * Env vars (read lazily via getters so tests can flip them per-case):
11 * GATETEST_API_KEY — bearer token for the Gatetest API (required)
12 * GATETEST_BASE_URL — override base URL (default `https://gatetest.ai`)
13 *
14 * Endpoint shapes assumed (documented for the Gatetest team):
15 * POST {base}/api/v2/run-and-repair
16 * body: { repo, ref, targetGlob? }
17 * 200 -> {
18 * passed: boolean,
19 * totalTests: number,
20 * failedBefore: number,
21 * failedAfter: number,
22 * repairs: [{ file, before, after, reason }],
23 * unfixable: [{ file, reason }],
24 * durationMs: number
25 * }
26 * POST {base}/api/v2/stack-to-test
27 * body: { repo, stackTrace, language? }
28 * 200 -> { testCode, framework, suggestedPath }
29 * POST {base}/api/v2/heal-suite
30 * body: { repo }
31 * 200 -> {
32 * flakyFound: number,
33 * deadFound: number,
34 * coverageGapsFound: number,
35 * prDraftBranch: string | null
36 * }
37 */
38
39// ---------------------------------------------------------------------------
40// Env getters — read process.env at access time so tests can mutate freely.
41// ---------------------------------------------------------------------------
42
43export const gatetestEnv = {
44 get apiKey(): string {
45 return process.env.GATETEST_API_KEY || "";
46 },
47 get baseUrl(): string {
48 return (process.env.GATETEST_BASE_URL || "https://gatetest.ai").replace(
49 /\/+$/,
50 ""
51 );
52 },
53};
54
55export function isConfigured(): boolean {
56 return !!gatetestEnv.apiKey;
57}
58
59export function buildAuthHeaders(): Record<string, string> {
60 const headers: Record<string, string> = {
61 "Content-Type": "application/json",
62 };
63 const key = gatetestEnv.apiKey;
64 if (key) headers["Authorization"] = `Bearer ${key}`;
65 return headers;
66}
67
68// ---------------------------------------------------------------------------
69// Types
70// ---------------------------------------------------------------------------
71
72export type GatetestRepair = {
73 file: string;
74 before: string;
75 after: string;
76 reason: string;
77};
78
79export type GatetestUnfixable = {
80 file: string;
81 reason: string;
82};
83
84export type RunAndRepairResult = {
85 passed: boolean;
86 totalTests: number;
87 failedBefore: number;
88 failedAfter: number;
89 repairs: GatetestRepair[];
90 unfixable: GatetestUnfixable[];
91 durationMs: number;
92 offline: boolean;
93};
94
95export type StackTraceToTestResult = {
96 testCode: string;
97 framework: string;
98 suggestedPath: string;
99 offline: boolean;
100};
101
102export type HealSuiteResult = {
103 flakyFound: number;
104 deadFound: number;
105 coverageGapsFound: number;
106 prDraftBranch: string | null;
107 offline: boolean;
108};
109
110// ---------------------------------------------------------------------------
111// Offline defaults
112// ---------------------------------------------------------------------------
113
114function offlineRunAndRepair(): RunAndRepairResult {
115 return {
116 passed: false,
117 totalTests: 0,
118 failedBefore: 0,
119 failedAfter: 0,
120 repairs: [],
121 unfixable: [],
122 durationMs: 0,
123 offline: true,
124 };
125}
126
127function offlineHealSuite(): HealSuiteResult {
128 return {
129 flakyFound: 0,
130 deadFound: 0,
131 coverageGapsFound: 0,
132 prDraftBranch: null,
133 offline: true,
134 };
135}
136
137function offlineStackToTest(
138 stackTrace: string,
139 language?: string
140): StackTraceToTestResult {
141 // Deterministic stub test. Intentionally decoupled from ai-tests.ts —
142 // we're an offline fallback, not an AI-driven generator.
143 const firstLine = (stackTrace || "").split("\n")[0]?.trim() || "error";
144 const escaped = firstLine.replace(/[`\\]/g, "").slice(0, 200);
145 const lang = (language || "typescript").toLowerCase();
146 let suggestedPath = "tests/reproduce.test.ts";
147 let testCode = "";
148 if (lang === "python") {
149 suggestedPath = "tests/test_reproduce.py";
150 testCode =
151 `# TODO: Gatetest is offline — replace this stub with a real reproducer.\n` +
152 `# Seed stack-trace: ${escaped}\n` +
153 `def test_reproduce():\n` +
154 ` assert False, "offline stub: paste stack trace + repro here"\n`;
155 } else if (lang === "go") {
156 suggestedPath = "reproduce_test.go";
157 testCode =
158 `// TODO: Gatetest is offline — replace this stub with a real reproducer.\n` +
159 `// Seed stack-trace: ${escaped}\n` +
160 `package main\n\nimport "testing"\n\n` +
161 `func TestReproduce(t *testing.T) {\n` +
162 `\tt.Fatal("offline stub: paste stack trace + repro here")\n` +
163 `}\n`;
164 } else {
165 testCode =
166 `// TODO: Gatetest is offline — replace this stub with a real reproducer.\n` +
167 `// Seed stack-trace: ${escaped}\n` +
168 `import { test, expect } from "bun:test";\n\n` +
169 `test("reproduce", () => {\n` +
170 ` expect.unreachable("offline stub: paste stack trace + repro here");\n` +
171 `});\n`;
172 }
173 return {
174 testCode,
175 framework: "fallback",
176 suggestedPath,
177 offline: true,
178 };
179}
180
181// ---------------------------------------------------------------------------
182// Shared fetch-with-timeout helper. Never throws — returns null on any
183// failure so callers can flip to the offline branch.
184// ---------------------------------------------------------------------------
185
186async function postJson(
187 url: string,
188 body: unknown,
189 timeoutMs: number
190): Promise<unknown | null> {
191 const controller = new AbortController();
192 const timer = setTimeout(() => controller.abort(), timeoutMs);
193 try {
194 const res = await fetch(url, {
195 method: "POST",
196 headers: buildAuthHeaders(),
197 body: JSON.stringify(body ?? {}),
198 signal: controller.signal,
199 });
200 if (!res.ok) return null;
201 return await res.json().catch(() => null);
202 } catch {
203 return null;
204 } finally {
205 clearTimeout(timer);
206 }
207}
208
209// ---------------------------------------------------------------------------
210// Public primitives
211// ---------------------------------------------------------------------------
212
213export async function runAndRepair(params: {
214 repo: string;
215 ref: string;
216 targetGlob?: string;
217}): Promise<RunAndRepairResult> {
218 if (!isConfigured()) return offlineRunAndRepair();
219 const url = `${gatetestEnv.baseUrl}/api/v2/run-and-repair`;
220 const data = (await postJson(url, params, 5 * 60 * 1000)) as
221 | Partial<RunAndRepairResult>
222 | null;
223 if (!data || typeof data !== "object") return offlineRunAndRepair();
224 return {
225 passed: !!data.passed,
226 totalTests: Number(data.totalTests || 0),
227 failedBefore: Number(data.failedBefore || 0),
228 failedAfter: Number(data.failedAfter || 0),
229 repairs: Array.isArray(data.repairs) ? (data.repairs as GatetestRepair[]) : [],
230 unfixable: Array.isArray(data.unfixable)
231 ? (data.unfixable as GatetestUnfixable[])
232 : [],
233 durationMs: Number(data.durationMs || 0),
234 offline: false,
235 };
236}
237
238export async function stackTraceToTest(params: {
239 repo: string;
240 stackTrace: string;
241 language?: string;
242}): Promise<StackTraceToTestResult> {
243 if (!isConfigured()) {
244 return offlineStackToTest(params.stackTrace, params.language);
245 }
246 const url = `${gatetestEnv.baseUrl}/api/v2/stack-to-test`;
247 const data = (await postJson(url, params, 60 * 1000)) as
248 | Partial<StackTraceToTestResult>
249 | null;
250 if (!data || typeof data !== "object" || typeof data.testCode !== "string") {
251 return offlineStackToTest(params.stackTrace, params.language);
252 }
253 return {
254 testCode: data.testCode,
255 framework: typeof data.framework === "string" ? data.framework : "unknown",
256 suggestedPath:
257 typeof data.suggestedPath === "string"
258 ? data.suggestedPath
259 : "tests/reproduce.test.ts",
260 offline: false,
261 };
262}
263
264export async function healSuite(params: {
265 repo: string;
266}): Promise<HealSuiteResult> {
267 if (!isConfigured()) return offlineHealSuite();
268 const url = `${gatetestEnv.baseUrl}/api/v2/heal-suite`;
269 const data = (await postJson(url, params, 10 * 60 * 1000)) as
270 | Partial<HealSuiteResult>
271 | null;
272 if (!data || typeof data !== "object") return offlineHealSuite();
273 return {
274 flakyFound: Number(data.flakyFound || 0),
275 deadFound: Number(data.deadFound || 0),
276 coverageGapsFound: Number(data.coverageGapsFound || 0),
277 prDraftBranch:
278 typeof data.prDraftBranch === "string" ? data.prDraftBranch : null,
279 offline: false,
280 };
281}
282
283export const __internal = {
284 offlineRunAndRepair,
285 offlineHealSuite,
286 offlineStackToTest,
287 postJson,
288};
Addedsrc/lib/prod-signals.ts+412−0View fileUnifiedSplit
1/**
2 * Block K9 — Production + test signal ingestion.
3 *
4 * Crontech (prod runtime), Gatetest (test runner), Sentry (external APM),
5 * and manual callers post per-commit error signals back into Gluecron so
6 * commits / PRs can be annotated with real-world failure data and so the
7 * autonomous agent loops (fix, heal_bot) have something concrete to chase.
8 *
9 * Shape follows the commit-statuses template: pure helpers at the top,
10 * DB helpers under the divider. DB helpers are defensive — never throw,
11 * errors route to console.error so the caller's primary flow survives a
12 * bad DB hop.
13 */
14
15import { createHash } from "node:crypto";
16import { and, desc, eq, gte, sql } from "drizzle-orm";
17import { db } from "../db";
18
19// NOTE: `prodSignals` is declared in src/db/schema.ts by the main thread
20// (see the snippet delivered alongside this file). We resolve it lazily so
21// the pure helpers in this module remain importable even if a session
22// lands the code before the schema edit is merged. Once schema.ts exports
23// `prodSignals`, the runtime import resolves on first DB call.
24let _prodSignals: any;
25async function prodSignalsTable(): Promise<any> {
26 if (_prodSignals) return _prodSignals;
27 const schema = await import("../db/schema");
28 _prodSignals = (schema as any).prodSignals;
29 if (!_prodSignals) {
30 throw new Error(
31 "prodSignals table not exported from db/schema.ts (Block K9). " +
32 "See lib/prod-signals.ts header for the snippet to paste."
33 );
34 }
35 return _prodSignals;
36}
37// Re-export for convenience in route code that wants the symbol directly.
38export async function _getProdSignalsTable() {
39 return prodSignalsTable();
40}
41
42export type SignalSource = "crontech" | "gatetest" | "sentry" | "manual";
43
44export const SIGNAL_SOURCES: SignalSource[] = [
45 "crontech",
46 "gatetest",
47 "sentry",
48 "manual",
49];
50
51export type SignalKind =
52 | "runtime_error"
53 | "test_failure"
54 | "deploy_failure"
55 | "performance"
56 | "security";
57
58export type SignalSeverity = "info" | "warning" | "error" | "critical";
59export type SignalStatus = "open" | "dismissed" | "resolved";
60
61const MESSAGE_MAX = 4000;
62const STACK_MAX = 16_000;
63const FRAME_MAX = 512;
64const HASH_LEN = 16;
65
66/** Git short-sha / full-sha sanity check. Hex, 7–64. */
67export function isValidSha(sha: string | null | undefined): boolean {
68 if (!sha) return false;
69 if (typeof sha !== "string") return false;
70 if (sha.length < 7 || sha.length > 64) return false;
71 return /^[a-f0-9]+$/i.test(sha);
72}
73
74/**
75 * Normalise an error message for grouping. Collapses whitespace, strips
76 * volatile fragments (hex pointers, line/col tails, numeric-only tokens)
77 * so two occurrences of the same bug from different runs hash identically.
78 */
79function normaliseMessage(msg: string): string {
80 return msg
81 .trim()
82 .replace(/\s+/g, " ")
83 // Strip hex pointers like 0x7fffabcd
84 .replace(/0x[0-9a-f]+/gi, "0x_")
85 // Strip trailing :line:col noise
86 .replace(/:\d+:\d+\b/g, ":_:_")
87 .slice(0, MESSAGE_MAX);
88}
89
90/**
91 * Extract the top, user-meaningful stack frame. Skips blank lines and any
92 * line containing `node_modules`. Returns "" on malformed / empty input.
93 * Capped at FRAME_MAX chars.
94 */
95export function extractTopFrame(stackTrace: string | null | undefined): string {
96 if (!stackTrace || typeof stackTrace !== "string") return "";
97 const lines = stackTrace.split(/\r?\n/);
98 for (const raw of lines) {
99 const line = raw.trim();
100 if (!line) continue;
101 if (line.includes("node_modules")) continue;
102 return line.slice(0, FRAME_MAX);
103 }
104 return "";
105}
106
107/**
108 * Stable 16-hex-char fingerprint of `message + " @ " + topFrame` used as
109 * the grouping key for count-bumping. Deterministic across processes.
110 */
111export function hashError(
112 message: string | null | undefined,
113 topStackFrame: string | null | undefined
114): string {
115 const m = normaliseMessage(String(message ?? ""));
116 const f = String(topStackFrame ?? "").trim().slice(0, FRAME_MAX);
117 const h = createHash("sha256").update(`${m} @ ${f}`).digest("hex");
118 return h.slice(0, HASH_LEN);
119}
120
121/** Allow-list the source tag. Unknown / empty → "manual". */
122export function sanitiseSource(source: unknown): SignalSource {
123 if (typeof source !== "string") return "manual";
124 const s = source.trim().toLowerCase();
125 if ((SIGNAL_SOURCES as string[]).includes(s)) return s as SignalSource;
126 return "manual";
127}
128
129const VALID_KINDS: SignalKind[] = [
130 "runtime_error",
131 "test_failure",
132 "deploy_failure",
133 "performance",
134 "security",
135];
136
137export function sanitiseKind(kind: unknown): SignalKind {
138 if (typeof kind !== "string") return "runtime_error";
139 const k = kind.trim().toLowerCase();
140 if ((VALID_KINDS as string[]).includes(k)) return k as SignalKind;
141 return "runtime_error";
142}
143
144const VALID_SEVERITIES: SignalSeverity[] = [
145 "info",
146 "warning",
147 "error",
148 "critical",
149];
150
151export function sanitiseSeverity(sev: unknown): SignalSeverity {
152 if (typeof sev !== "string") return "error";
153 const s = sev.trim().toLowerCase();
154 if ((VALID_SEVERITIES as string[]).includes(s)) return s as SignalSeverity;
155 return "error";
156}
157
158// ---------- DB helpers ----------
159
160export interface RecordSignalInput {
161 repositoryId: string;
162 commitSha: string;
163 source: SignalSource | string;
164 kind: SignalKind | string;
165 message: string;
166 stackTrace?: string | null;
167 deployId?: string | null;
168 environment?: string | null;
169 severity?: SignalSeverity | string | null;
170 samplePayload?: string | null;
171}
172
173export interface RecordSignalResult {
174 id: string;
175 status: SignalStatus;
176 count: number;
177 bumped: boolean;
178}
179
180/**
181 * Insert or bump-count a signal. Keyed on (repository_id, error_hash).
182 * Repeated posts of the same bug increment `count` and push `last_seen`
183 * forward without creating new rows — cheap idempotency for noisy
184 * runtimes. Defensive: returns null on any DB failure.
185 */
186export async function recordSignal(
187 input: RecordSignalInput
188): Promise<RecordSignalResult | null> {
189 try {
190 if (!isValidSha(input.commitSha)) return null;
191 const sha = input.commitSha.toLowerCase();
192 const source = sanitiseSource(input.source);
193 const kind = sanitiseKind(input.kind);
194 const severity = sanitiseSeverity(input.severity);
195 const message = (input.message || "").slice(0, MESSAGE_MAX);
196 const stackTrace = input.stackTrace
197 ? input.stackTrace.slice(0, STACK_MAX)
198 : null;
199 const topFrame = extractTopFrame(stackTrace);
200 const errorHash = hashError(message, topFrame);
201 const prodSignals = await prodSignalsTable();
202
203 // Check for existing row with the same (repo, hash).
204 const [existing] = await db
205 .select()
206 .from(prodSignals)
207 .where(
208 and(
209 eq(prodSignals.repositoryId, input.repositoryId),
210 eq(prodSignals.errorHash, errorHash)
211 )
212 )
213 .limit(1);
214
215 if (existing) {
216 const [bumped] = await db
217 .update(prodSignals)
218 .set({
219 count: (existing.count || 0) + 1,
220 lastSeen: new Date(),
221 // Most recent commit wins — if the bug keeps happening on newer
222 // shas the view should reflect that.
223 commitSha: sha,
224 })
225 .where(eq(prodSignals.id, existing.id))
226 .returning();
227 if (!bumped) return null;
228 return {
229 id: bumped.id,
230 status: (bumped.status || "open") as SignalStatus,
231 count: bumped.count || 1,
232 bumped: true,
233 };
234 }
235
236 const [row] = await db
237 .insert(prodSignals)
238 .values({
239 repositoryId: input.repositoryId,
240 commitSha: sha,
241 errorHash,
242 source,
243 kind,
244 severity,
245 message,
246 stackTrace,
247 deployId: input.deployId || null,
248 environment: input.environment || null,
249 samplePayload: input.samplePayload || null,
250 })
251 .returning();
252 if (!row) return null;
253 return {
254 id: row.id,
255 status: (row.status || "open") as SignalStatus,
256 count: row.count || 1,
257 bumped: false,
258 };
259 } catch (err) {
260 console.error("[prod-signals] recordSignal:", err);
261 return null;
262 }
263}
264
265export async function listSignalsForCommit(
266 repositoryId: string,
267 commitSha: string,
268 limit = 50
269): Promise<any[]> {
270 try {
271 if (!isValidSha(commitSha)) return [];
272 const prodSignals = await prodSignalsTable();
273 return await db
274 .select()
275 .from(prodSignals)
276 .where(
277 and(
278 eq(prodSignals.repositoryId, repositoryId),
279 eq(prodSignals.commitSha, commitSha.toLowerCase())
280 )
281 )
282 .orderBy(desc(prodSignals.lastSeen))
283 .limit(limit);
284 } catch (err) {
285 console.error("[prod-signals] listSignalsForCommit:", err);
286 return [];
287 }
288}
289
290export async function listOpenSignalsForRepo(
291 repositoryId: string,
292 limit = 100
293): Promise<any[]> {
294 try {
295 const prodSignals = await prodSignalsTable();
296 return await db
297 .select()
298 .from(prodSignals)
299 .where(
300 and(
301 eq(prodSignals.repositoryId, repositoryId),
302 eq(prodSignals.status, "open")
303 )
304 )
305 .orderBy(desc(prodSignals.lastSeen))
306 .limit(limit);
307 } catch (err) {
308 console.error("[prod-signals] listOpenSignalsForRepo:", err);
309 return [];
310 }
311}
312
313/**
314 * PR-scoped listing. Pragmatic v1: rather than shelling out to `git log
315 * base..head`, we return signals in the repo created after the PR's
316 * creation timestamp. Noisy-but-useful — the agent fix loop filters
317 * further by commit_sha once it has the PR's commit list from git.
318 */
319export async function listSignalsForPr(
320 repositoryId: string,
321 _baseSha: string,
322 _headSha: string,
323 prCreatedAt: Date | null = null,
324 limit = 50
325): Promise<any[]> {
326 try {
327 const since = prCreatedAt || new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
328 const prodSignals = await prodSignalsTable();
329 return await db
330 .select()
331 .from(prodSignals)
332 .where(
333 and(
334 eq(prodSignals.repositoryId, repositoryId),
335 gte(prodSignals.createdAt, since)
336 )
337 )
338 .orderBy(desc(prodSignals.lastSeen))
339 .limit(limit);
340 } catch (err) {
341 console.error("[prod-signals] listSignalsForPr:", err);
342 return [];
343 }
344}
345
346export async function dismissSignal(id: string): Promise<boolean> {
347 try {
348 const prodSignals = await prodSignalsTable();
349 const [row] = await db
350 .update(prodSignals)
351 .set({ status: "dismissed" })
352 .where(eq(prodSignals.id, id))
353 .returning();
354 return !!row;
355 } catch (err) {
356 console.error("[prod-signals] dismissSignal:", err);
357 return false;
358 }
359}
360
361export async function resolveSignal(
362 id: string,
363 resolvedByCommit?: string | null
364): Promise<boolean> {
365 try {
366 const commit =
367 resolvedByCommit && isValidSha(resolvedByCommit)
368 ? resolvedByCommit.toLowerCase()
369 : null;
370 const prodSignals = await prodSignalsTable();
371 const [row] = await db
372 .update(prodSignals)
373 .set({
374 status: "resolved",
375 resolvedAt: new Date(),
376 resolvedByCommit: commit,
377 })
378 .where(eq(prodSignals.id, id))
379 .returning();
380 return !!row;
381 } catch (err) {
382 console.error("[prod-signals] resolveSignal:", err);
383 return false;
384 }
385}
386
387/** Count total signals in a repo — for badges / nav. Defensive. */
388export async function countOpenSignals(repositoryId: string): Promise<number> {
389 try {
390 const prodSignals = await prodSignalsTable();
391 const [r] = await db
392 .select({ n: sql<number>`count(*)::int` })
393 .from(prodSignals)
394 .where(
395 and(
396 eq(prodSignals.repositoryId, repositoryId),
397 eq(prodSignals.status, "open")
398 )
399 );
400 return Number(r?.n || 0);
401 } catch {
402 return 0;
403 }
404}
405
406export const __internal = {
407 MESSAGE_MAX,
408 STACK_MAX,
409 FRAME_MAX,
410 HASH_LEN,
411 normaliseMessage,
412};
Addedsrc/routes/signals.ts+348−0View fileUnifiedSplit
1/**
2 * Block K9 — Production + test signal ingestion API.
3 *
4 * External systems (Crontech runtime, Gatetest runner, Sentry bridge) POST
5 * per-commit error signals here; the Gluecron web + agent layers consume
6 * them to annotate commits and drive fix loops.
7 *
8 * POST /api/v1/signals/error
9 * body: { repo: "owner/name", commit_sha, source, kind, message,
10 * stack_trace?, deploy_id?, environment?, severity?,
11 * sample_payload? }
12 * 200: { id, status, count }
13 * 400: invalid sha / missing fields / unknown repo format
14 * 401: no valid auth
15 * 403: token lacks contents:read AND isn't the repo owner
16 * 404: repo not found
17 *
18 * GET /api/v1/repos/:owner/:repo/signals
19 * 200: { total, signals: [...] } -- open only
20 *
21 * GET /api/v1/repos/:owner/:repo/commits/:sha/signals
22 * 200: { total, signals: [...] }
23 *
24 * POST /api/v1/signals/:id/dismiss (owner only, audit-logged)
25 * POST /api/v1/signals/:id/resolve (owner only, audit-logged)
26 *
27 * Auth: accepts three bearer-token flavours in Authorization:
28 * - glc_* personal access tokens (PAT) via softAuth-resolved user
29 * - glct_* OAuth access tokens
30 * - ghi_* marketplace install tokens (verifyInstallToken)
31 * Session cookies also work. Un-authenticated POSTs return 401 JSON
32 * rather than a /login redirect — API clients don't follow HTML.
33 */
34
35import { Hono } from "hono";
36import { and, eq } from "drizzle-orm";
37import { db } from "../db";
38import { repositories, users } from "../db/schema";
39import { softAuth } from "../middleware/auth";
40import type { AuthEnv } from "../middleware/auth";
41import {
42 verifyInstallToken,
43 hasPermission,
44 type Permission,
45} from "../lib/marketplace";
46import { audit } from "../lib/notify";
47import {
48 _getProdSignalsTable,
49 dismissSignal,
50 isValidSha,
51 listOpenSignalsForRepo,
52 listSignalsForCommit,
53 recordSignal,
54 resolveSignal,
55 sanitiseKind,
56 sanitiseSeverity,
57 sanitiseSource,
58} from "../lib/prod-signals";
59
60const signals = new Hono<AuthEnv>();
61
62type AuthPrincipal =
63 | { kind: "user"; userId: string; scopes: string[] }
64 | { kind: "install"; permissions: Permission[]; botUsername: string };
65
66/**
67 * Resolve the caller. Prefers install token (ghi_*) when present since
68 * marketplace tokens are the canonical cross-org write path. Falls back
69 * to the softAuth-resolved user / PAT / OAuth token / session cookie.
70 */
71async function resolveAuth(c: any): Promise<AuthPrincipal | null> {
72 const authHeader = c.req.header("authorization") || "";
73 if (authHeader.toLowerCase().startsWith("bearer ")) {
74 const bearer = authHeader.slice(7).trim();
75 if (bearer.startsWith("ghi_")) {
76 const install = await verifyInstallToken(bearer);
77 if (!install) return null;
78 return {
79 kind: "install",
80 permissions: install.permissions,
81 botUsername: install.botUsername,
82 };
83 }
84 }
85 const user = c.get("user");
86 if (user) {
87 const scopes = (c.get("oauthScopes") as string[] | undefined) || [];
88 return { kind: "user", userId: user.id, scopes };
89 }
90 return null;
91}
92
93async function resolveRepoByName(ownerName: string, repoName: string) {
94 const [owner] = await db
95 .select()
96 .from(users)
97 .where(eq(users.username, ownerName))
98 .limit(1);
99 if (!owner) return null;
100 const [repo] = await db
101 .select()
102 .from(repositories)
103 .where(
104 and(eq(repositories.ownerId, owner.id), eq(repositories.name, repoName))
105 )
106 .limit(1);
107 if (!repo) return null;
108 return { owner, repo };
109}
110
111function parseRepoSlug(raw: unknown): { owner: string; name: string } | null {
112 if (typeof raw !== "string") return null;
113 const parts = raw.trim().split("/");
114 if (parts.length !== 2) return null;
115 const [owner, name] = parts;
116 if (!owner || !name) return null;
117 return { owner, name };
118}
119
120function canWriteRepo(
121 principal: AuthPrincipal,
122 repoOwnerId: string
123): boolean {
124 if (principal.kind === "user") return principal.userId === repoOwnerId;
125 // Install tokens need contents:read (or higher — write implies read)
126 return hasPermission(principal.permissions, "contents:read");
127}
128
129// ---------------------------------------------------------------------------
130// POST ingest
131// ---------------------------------------------------------------------------
132signals.post("/api/v1/signals/error", softAuth, async (c) => {
133 const principal = await resolveAuth(c);
134 if (!principal) {
135 return c.json({ error: "Authentication required" }, 401);
136 }
137
138 let body: any = {};
139 try {
140 body = await c.req.json();
141 } catch {
142 return c.json({ error: "Invalid JSON body" }, 400);
143 }
144
145 const slug = parseRepoSlug(body.repo ?? body.repository);
146 if (!slug) {
147 return c.json(
148 { error: "Missing or malformed 'repo' (expected 'owner/name')" },
149 400
150 );
151 }
152
153 const sha = String(body.commit_sha ?? body.commitSha ?? "").toLowerCase();
154 if (!isValidSha(sha)) {
155 return c.json({ error: "Invalid commit_sha" }, 400);
156 }
157
158 const message = String(body.message ?? "");
159 if (!message.trim()) {
160 return c.json({ error: "'message' is required" }, 400);
161 }
162
163 const resolved = await resolveRepoByName(slug.owner, slug.name);
164 if (!resolved) return c.json({ error: "Repository not found" }, 404);
165
166 if (!canWriteRepo(principal, resolved.owner.id)) {
167 return c.json({ error: "Forbidden" }, 403);
168 }
169
170 const result = await recordSignal({
171 repositoryId: resolved.repo.id,
172 commitSha: sha,
173 source: sanitiseSource(body.source),
174 kind: sanitiseKind(body.kind),
175 severity: sanitiseSeverity(body.severity),
176 message,
177 stackTrace:
178 typeof body.stack_trace === "string"
179 ? body.stack_trace
180 : typeof body.stackTrace === "string"
181 ? body.stackTrace
182 : null,
183 deployId:
184 typeof body.deploy_id === "string"
185 ? body.deploy_id
186 : typeof body.deployId === "string"
187 ? body.deployId
188 : null,
189 environment:
190 typeof body.environment === "string" ? body.environment : null,
191 samplePayload:
192 typeof body.sample_payload === "string"
193 ? body.sample_payload
194 : typeof body.samplePayload === "string"
195 ? body.samplePayload
196 : null,
197 });
198
199 if (!result) {
200 return c.json({ error: "Could not record signal" }, 500);
201 }
202
203 return c.json({
204 id: result.id,
205 status: result.status,
206 count: result.count,
207 });
208});
209
210// ---------------------------------------------------------------------------
211// GET list (open signals for a repo)
212// ---------------------------------------------------------------------------
213signals.get("/api/v1/repos/:owner/:repo/signals", softAuth, async (c) => {
214 const { owner: ownerName, repo: repoName } = c.req.param();
215 const resolved = await resolveRepoByName(ownerName, repoName);
216 if (!resolved) return c.json({ error: "Repository not found" }, 404);
217
218 const user = c.get("user");
219 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
220 return c.json({ error: "Forbidden" }, 403);
221 }
222
223 const rows = await listOpenSignalsForRepo(resolved.repo.id);
224 return c.json({ total: rows.length, signals: rows });
225});
226
227// ---------------------------------------------------------------------------
228// GET list for a specific commit
229// ---------------------------------------------------------------------------
230signals.get(
231 "/api/v1/repos/:owner/:repo/commits/:sha/signals",
232 softAuth,
233 async (c) => {
234 const { owner: ownerName, repo: repoName, sha } = c.req.param();
235 if (!isValidSha(sha)) return c.json({ error: "Invalid sha" }, 400);
236 const resolved = await resolveRepoByName(ownerName, repoName);
237 if (!resolved) return c.json({ error: "Repository not found" }, 404);
238
239 const user = c.get("user");
240 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
241 return c.json({ error: "Forbidden" }, 403);
242 }
243
244 const rows = await listSignalsForCommit(resolved.repo.id, sha);
245 return c.json({ total: rows.length, signals: rows });
246 }
247);
248
249// ---------------------------------------------------------------------------
250// POST dismiss
251// ---------------------------------------------------------------------------
252signals.post("/api/v1/signals/:id/dismiss", softAuth, async (c) => {
253 const principal = await resolveAuth(c);
254 if (!principal) return c.json({ error: "Authentication required" }, 401);
255 if (principal.kind !== "user") {
256 return c.json({ error: "Only repo owners can dismiss" }, 403);
257 }
258
259 const id = c.req.param("id");
260 const prodSignals = await _getProdSignalsTable();
261 const [row] = await db
262 .select()
263 .from(prodSignals)
264 .where(eq(prodSignals.id, id))
265 .limit(1);
266 if (!row) return c.json({ error: "Signal not found" }, 404);
267
268 const [repo] = await db
269 .select()
270 .from(repositories)
271 .where(eq(repositories.id, row.repositoryId))
272 .limit(1);
273 if (!repo) return c.json({ error: "Signal not found" }, 404);
274 if (repo.ownerId !== principal.userId) {
275 return c.json({ error: "Forbidden" }, 403);
276 }
277
278 const ok = await dismissSignal(id);
279 if (!ok) return c.json({ error: "Could not dismiss" }, 500);
280
281 await audit({
282 userId: principal.userId,
283 repositoryId: repo.id,
284 action: "signal.dismiss",
285 targetType: "prod_signal",
286 targetId: id,
287 });
288 return c.json({ ok: true });
289});
290
291// ---------------------------------------------------------------------------
292// POST resolve
293// ---------------------------------------------------------------------------
294signals.post("/api/v1/signals/:id/resolve", softAuth, async (c) => {
295 const principal = await resolveAuth(c);
296 if (!principal) return c.json({ error: "Authentication required" }, 401);
297 if (principal.kind !== "user") {
298 return c.json({ error: "Only repo owners can resolve" }, 403);
299 }
300
301 const id = c.req.param("id");
302
303 let body: any = {};
304 try {
305 body = await c.req.json();
306 } catch {
307 body = {};
308 }
309
310 const [row] = await db
311 .select()
312 .from(prodSignals)
313 .where(eq(prodSignals.id, id))
314 .limit(1);
315 if (!row) return c.json({ error: "Signal not found" }, 404);
316
317 const [repo] = await db
318 .select()
319 .from(repositories)
320 .where(eq(repositories.id, row.repositoryId))
321 .limit(1);
322 if (!repo) return c.json({ error: "Signal not found" }, 404);
323 if (repo.ownerId !== principal.userId) {
324 return c.json({ error: "Forbidden" }, 403);
325 }
326
327 const resolvedByCommit =
328 typeof body.resolved_by_commit === "string"
329 ? body.resolved_by_commit
330 : typeof body.resolvedByCommit === "string"
331 ? body.resolvedByCommit
332 : null;
333
334 const ok = await resolveSignal(id, resolvedByCommit);
335 if (!ok) return c.json({ error: "Could not resolve" }, 500);
336
337 await audit({
338 userId: principal.userId,
339 repositoryId: repo.id,
340 action: "signal.resolve",
341 targetType: "prod_signal",
342 targetId: id,
343 metadata: resolvedByCommit ? { resolvedByCommit } : undefined,
344 });
345 return c.json({ ok: true });
346});
347
348export default signals;
0349