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

agent-journey.ts

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

agent-journey.tsBlame325 lines · 1 contributor
2cecc08ccanty labs1// Agent journey — simulate an EXTERNAL AI platform driving a full PR
2// lifecycle against a live Gluecron instance, using exactly the surface
3// such a platform would use: a PAT bearer on MCP tools + git-over-HTTPS
4// with token basic-auth. This is the go/no-go certification for "other
5// platforms can open and merge PRs here".
6//
7// 1. identity GET /api/v2/user
f8f6bd0ccanty labs8// 1b. sweep delete any journey-* repos >1h old left behind by a
a74f4edccanty labs9// prior run that never reached its own step 11 (e.g.
f8f6bd0ccanty labs10// the process was killed mid-run) — WARN-only
2cecc08ccanty labs11// 2. create repo POST /api/v2/repos (no MCP create-repo tool exists — platform gap, noted)
12// 3. git push smart-HTTP receive-pack with PAT basic auth
13// 4. branch + edit gluecron_create_branch + gluecron_write_file
a74f4edccanty labs14// 4c. webhook register a probe webhook (pr events) via the same
15// PAT bearer, so step 10 can prove fireWebhooks() is
16// wired into the real create/merge call sites
2cecc08ccanty labs17// 5. open PR gluecron_create_pr
18// 6. comment gluecron_comment_pr
19// 7. AI review poll PR comments for the ai-review marker (WARN if absent)
20// 8. merge gluecron_merge_pr → gluecron_get_pr state=merged
21// 9. events GET /api/v2/repos/:o/:r/activity
a74f4edccanty labs22// 10. webhook delivery poll the probe webhook's settings page for a
23// recorded delivery attempt
24// 11. cleanup gluecron_delete_repo (always, unless KEEP_REPO=1)
2cecc08ccanty labs25//
26// Run: bun run scripts/agent-journey.ts
27// Env: GLUECRON_PAT REQUIRED — admin-scope PAT (merge + delete need it)
28// GLUECRON_HOST target (default https://gluecron.com)
29// KEEP_REPO=1 skip cleanup for debugging
30// Exit: 0 iff all steps pass (step 7 is WARN-only — AI review may be
31// legitimately disabled). Never touches any existing repo.
32
33import { AI_REVIEW_MARKER } from "../src/lib/ai-review";
34import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
35import { tmpdir } from "node:os";
36import { join } from "node:path";
37
38const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
39const PAT = process.env.GLUECRON_PAT?.trim() || "";
40const KEEP = process.env.KEEP_REPO === "1";
41
42if (!PAT) {
43 console.error("GLUECRON_PAT is required (admin scope). Mint one at /settings/tokens or via scripts/emergency-pat.ts.");
44 process.exit(2);
45}
46
47// Hard safety guard: the scratch repo name is generated here and must never
48// collide with the self-host repo (its post-receive hook triggers deploys).
49const REPO = `journey-${new Date().toISOString().slice(0, 19).replace(/[:T-]/g, "")}-${Math.random().toString(36).slice(2, 6)}`;
50const FORBIDDEN = /gluecron\.com/i;
51if (FORBIDDEN.test(REPO)) throw new Error("unreachable: scratch repo name collided with self-host repo");
52
53// ─── plumbing ────────────────────────────────────────────────────────
54
55async function api(method: string, path: string, body?: unknown) {
56 const res = await fetch(HOST + path, {
57 method,
58 headers: {
59 authorization: `Bearer ${PAT}`,
60 ...(body !== undefined ? { "content-type": "application/json" } : {}),
61 },
62 ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
63 });
64 const text = await res.text();
65 let json: any = null;
66 try { json = JSON.parse(text); } catch { /* non-JSON */ }
67 return { status: res.status, json, text };
68}
69
70let rpcId = 0;
71async function mcp(tool: string, args: Record<string, unknown>) {
72 const res = await fetch(HOST + "/mcp", {
73 method: "POST",
74 headers: { authorization: `Bearer ${PAT}`, "content-type": "application/json" },
75 body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: tool, arguments: args } }),
76 });
77 const json: any = await res.json();
78 if (res.status !== 200 || json.error) {
79 throw new Error(`${tool} → HTTP ${res.status} ${JSON.stringify(json.error ?? json).slice(0, 300)}`);
80 }
81 if (json.result?.isError) {
82 throw new Error(`${tool} → tool error: ${JSON.stringify(json.result.content).slice(0, 300)}`);
83 }
84 // MCP text content is JSON-stringified tool output; parse when possible.
85 const textPart = (json.result?.content ?? []).find((p: any) => p.type === "text")?.text;
86 try { return JSON.parse(textPart); } catch { return textPart; }
87}
88
89function git(cwd: string, ...args: string[]): string {
90 const proc = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
91 const out = new TextDecoder().decode(proc.stdout);
92 const err = new TextDecoder().decode(proc.stderr);
93 if (proc.exitCode !== 0) throw new Error(`git ${args[0]} failed (${proc.exitCode}): ${(err || out).slice(0, 300)}`);
94 return out.trim();
95}
96
97interface StepResult { name: string; ok: boolean; warn?: boolean; ms: number; detail?: string }
98const results: StepResult[] = [];
99
100async function step<T>(name: string, fn: () => Promise<T>, opts: { warnOnly?: boolean } = {}): Promise<T | null> {
101 const t0 = Date.now();
102 try {
103 const v = await fn();
104 results.push({ name, ok: true, ms: Date.now() - t0 });
105 console.log(`PASS ${name} (${Date.now() - t0}ms)`);
106 return v;
107 } catch (err) {
108 const detail = (err as Error).message;
109 results.push({ name, ok: !!opts.warnOnly, warn: opts.warnOnly, ms: Date.now() - t0, detail });
110 console.log(`${opts.warnOnly ? "WARN" : "FAIL"} ${name} — ${detail}`);
111 if (!opts.warnOnly) throw err;
112 return null;
113 }
114}
115
116// ─── the journey ─────────────────────────────────────────────────────
117
118async function main() {
119 console.log(`# agent-journey — ${HOST} — scratch repo "${REPO}" — ${new Date().toISOString()}\n`);
120 let owner = "";
121 let repoCreated = false;
122
123 try {
124 // 1. identity
125 const user = await step("1. PAT identity (GET /api/v2/user)", async () => {
126 const r = await api("GET", "/api/v2/user");
127 if (r.status !== 200 || !r.json?.username) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
128 return r.json;
129 });
130 owner = user!.username;
131 if (`${owner}/${REPO}`.toLowerCase() === "ccantynz/gluecron.com") throw new Error("safety: refusing to touch the self-host repo");
132
f8f6bd0ccanty labs133 // 1b. sweep leftover probes from earlier runs — WARN-only, best-effort.
134 // The step-10 cleanup below only runs inside this process's try/finally,
135 // which can't protect against the process itself dying mid-run (killed
136 // terminal, crashed script, host restart) — a hard kill skips `finally`
137 // entirely, same as any JS runtime. Rather than chase every possible
138 // cause of a mid-run death, sweep for orphaned `journey-*` repos here so
139 // cleanup is self-healing across runs instead of depending on any single
140 // run completing cleanly. 1-hour grace period avoids racing a
141 // concurrently-running journey.
142 await step("1b. sweep leftover journey-* probes", async () => {
143 const r = await api("GET", `/api/v2/users/${encodeURIComponent(owner)}/repos`);
144 if (r.status !== 200 || !Array.isArray(r.json)) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
145 const cutoff = Date.now() - 60 * 60 * 1000;
146 const orphans = r.json.filter((repo: any) =>
147 /^journey-\d{14}-[a-z0-9]{4}$/.test(repo.name) &&
148 repo.name !== REPO &&
149 new Date(repo.createdAt).getTime() < cutoff
150 );
151 if (orphans.length === 0) return;
152 let swept = 0;
153 const failures: string[] = [];
154 for (const repo of orphans) {
155 try {
156 await mcp("gluecron_delete_repo", { owner, repo: repo.name });
157 swept++;
158 } catch (err) {
159 failures.push(`${repo.name}: ${(err as Error).message}`);
160 }
161 }
162 console.log(` swept ${swept}/${orphans.length} orphaned probe repo(s)`);
163 if (failures.length > 0) throw new Error(`${failures.length} orphan(s) failed to delete: ${failures.join("; ").slice(0, 300)}`);
164 }, { warnOnly: true });
165
2cecc08ccanty labs166 // 2. create scratch repo
167 await step("2. create scratch repo (POST /api/v2/repos)", async () => {
168 const r = await api("POST", "/api/v2/repos", { name: REPO, description: "agent-journey probe — auto-deleted", private: false });
169 if (r.status !== 200 && r.status !== 201) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
170 repoCreated = true;
171 });
172
173 // 3. git push over smart HTTP with PAT basic auth
174 const pushUrl = `${HOST.replace("://", `://${encodeURIComponent(owner)}:${PAT}@`)}/${owner}/${REPO}.git`;
175 const mainSha = await step("3. git push main over HTTPS (PAT basic auth)", async () => {
176 const dir = mkdtempSync(join(tmpdir(), "gluecron-journey-"));
177 try {
178 git(dir, "init", "-b", "main");
179 git(dir, "config", "user.email", "journey@gluecron.com");
180 git(dir, "config", "user.name", "agent-journey");
181 writeFileSync(join(dir, "README.md"), `# ${REPO}\n\nExternal-platform journey probe.\n`);
182 git(dir, "add", ".");
183 git(dir, "commit", "-m", "chore: journey seed commit");
184 const sha = git(dir, "rev-parse", "HEAD");
185 git(dir, "push", pushUrl, "main");
186 return sha;
187 } finally {
188 rmSync(dir, { recursive: true, force: true });
189 }
190 });
191
192 // 4. branch + file via MCP write tools
193 await step("4a. gluecron_create_branch", () => mcp("gluecron_create_branch", { owner, repo: REPO, branch: "feature/journey", sha: mainSha }));
194 await step("4b. gluecron_write_file on branch", () => mcp("gluecron_write_file", { owner, repo: REPO, path: "probe.md", branch: "feature/journey", message: "feat: journey probe file", content: `probe ${new Date().toISOString()}\n` }));
195
a74f4edccanty labs196 // 4c. register a probe webhook (pr events) so step 11 below can prove
197 // fireWebhooks() is actually wired into the real PR create/merge call
198 // sites, not just present in the codebase. Points at this instance's
199 // own /healthz — fast, always answers with a real HTTP status, no
200 // external network dependency. requireAuth on this form route accepts
201 // the same PAT bearer as everything else (softAuth: "Bearer token takes
202 // precedence over cookie for API calls").
203 await step("4c. register probe webhook", async () => {
204 const res = await fetch(`${HOST}/${owner}/${REPO}/settings/webhooks`, {
205 method: "POST",
206 headers: {
207 authorization: `Bearer ${PAT}`,
208 "content-type": "application/x-www-form-urlencoded",
209 },
210 body: new URLSearchParams({ url: `${HOST}/healthz`, events: "pr" }),
211 redirect: "manual",
212 });
213 // The handler always redirects (3xx) on both success and validation
214 // error; anything else means something is actually broken.
215 if (res.status < 300 || res.status >= 400) {
216 throw new Error(`webhook registration: HTTP ${res.status}`);
217 }
218 });
219
2cecc08ccanty labs220 // 5. open PR
221 const pr = await step("5. gluecron_create_pr", () => mcp("gluecron_create_pr", { owner, repo: REPO, title: "Journey probe PR", body: "Opened by scripts/agent-journey.ts — will be merged and the repo deleted.", head_branch: "feature/journey", base_branch: "main" }));
222 const prNumber: number = pr!.number;
223
224 // 6. comment
225 await step("6. gluecron_comment_pr", () => mcp("gluecron_comment_pr", { owner, repo: REPO, number: prNumber, body: `Journey probe comment ${new Date().toISOString()}` }));
226
227 // 7. AI review (WARN-only: may be disabled / unfunded)
228 await step("7. AI review fired (poll for marker, ≤90s)", async () => {
229 const deadline = Date.now() + 90_000;
230 while (Date.now() < deadline) {
231 const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/pulls/${prNumber}`);
232 const comments: Array<{ body?: string; isAiReview?: boolean }> = r.json?.comments ?? [];
233 if (comments.some((cm) => cm.isAiReview || cm.body?.includes(AI_REVIEW_MARKER))) return true;
234 await new Promise((res) => setTimeout(res, 5000));
235 }
236 throw new Error("no AI-review comment within 90s (review disabled, or ANTHROPIC_API_KEY unset?)");
237 }, { warnOnly: true });
238
239 // 8. merge + verify
240 await step("8a. gluecron_merge_pr", async () => {
241 const m = await mcp("gluecron_merge_pr", { owner, repo: REPO, number: prNumber });
242 if (!m?.merged) throw new Error(`not merged: ${JSON.stringify(m).slice(0, 200)}`);
243 return m;
244 });
245 await step("8b. PR state is merged + main advanced", async () => {
246 const detail = await mcp("gluecron_get_pr", { owner, repo: REPO, number: prNumber });
247 const state = detail?.state ?? detail?.pr?.state;
248 if (state !== "merged") throw new Error(`PR state: ${state}`);
249 const dir = mkdtempSync(join(tmpdir(), "gluecron-lsr-"));
250 try {
251 git(dir, "init");
252 const head = git(dir, "ls-remote", pushUrl, "refs/heads/main").split(/\s/)[0];
253 if (head === mainSha) throw new Error("main did not advance past the seed commit");
254 } finally {
255 rmSync(dir, { recursive: true, force: true });
256 }
257 });
258
a74f4edccanty labs259 // 9. events — activityFeed wiring gap (found 2026-07-15) was fixed same
260 // day (commit b7b5f75): logActivity() is now called from git push, PR
261 // create/comment/merge, and the MCP write tools. Promoted from
262 // warnOnly to a real assertion now that the underlying gap is closed —
263 // this is exactly the check that caught the gap in the first place, so
264 // it stays a hard requirement going forward rather than reverting to
265 // silently-optional once fixed.
2cecc08ccanty labs266 await step("9. activity feed recorded push + PR events", async () => {
267 const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/activity`);
268 if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
561b2a8ccanty labs269 // GET /api/v2/repos/:owner/:repo/activity returns a bare array.
270 const events: Array<{ type?: string }> = Array.isArray(r.json)
271 ? r.json
272 : r.json?.events ?? r.json?.activity ?? [];
a74f4edccanty labs273 if (!Array.isArray(events) || events.length === 0) throw new Error("no activity events recorded — activity_feed wiring regressed");
274 });
275
276 // 10. webhook delivery recorded — proves fireWebhooks() is actually
277 // called from the real PR create/merge handlers (found 2026-07-16:
278 // fireWebhooks() had zero callers anywhere despite a full config UI and
279 // "last delivery" status pill; fixed by wiring it into post-receive.ts,
280 // issues.tsx, pulls.tsx, pr-merge.ts, and web.tsx). The probe webhook
281 // registered in step 4c points at this instance's own /healthz — any
282 // delivery attempt, success or failure, sets webhooks.lastStatus to a
283 // non-null value (see src/lib/webhook-delivery.ts's `lastStatus:
284 // statusCode ?? 0` on every branch), which flips the settings page's
285 // "no deliveries yet" text. Polls because fireWebhooks() enqueues
286 // fire-and-forget — the delivery worker runs asynchronously after the
287 // PR-open/merge response already returned.
288 await step("10. webhook delivery recorded (poll for lastStatus, ≤30s)", async () => {
289 const deadline = Date.now() + 30_000;
290 while (Date.now() < deadline) {
291 const res = await fetch(`${HOST}/${owner}/${REPO}/settings/webhooks`, {
292 headers: { authorization: `Bearer ${PAT}` },
293 });
294 const html = await res.text();
295 if (res.status === 200 && !html.includes("no deliveries yet")) return true;
296 await new Promise((r) => setTimeout(r, 3000));
297 }
298 throw new Error("no webhook delivery recorded within 30s — fireWebhooks() call sites may have regressed");
299 });
2cecc08ccanty labs300 } finally {
a74f4edccanty labs301 // 11. cleanup — always attempt when the repo was created
2cecc08ccanty labs302 if (repoCreated && !KEEP) {
303 try {
304 await mcp("gluecron_delete_repo", { owner, repo: REPO });
a74f4edccanty labs305 console.log(`PASS 11. cleanup — deleted ${owner}/${REPO}`);
306 results.push({ name: "11. cleanup (gluecron_delete_repo)", ok: true, ms: 0 });
2cecc08ccanty labs307 } catch (err) {
a74f4edccanty labs308 console.error(`FAIL 11. cleanup — LEFTOVER REPO ${owner}/${REPO}: ${(err as Error).message}`);
309 results.push({ name: "11. cleanup (gluecron_delete_repo)", ok: false, ms: 0, detail: `LEFTOVER ${owner}/${REPO}` });
2cecc08ccanty labs310 }
311 } else if (repoCreated) {
a74f4edccanty labs312 console.log(`KEEP 11. cleanup skipped (KEEP_REPO=1) — ${owner}/${REPO}`);
2cecc08ccanty labs313 }
314 }
315
316 const failed = results.filter((r) => !r.ok).length;
317 const warned = results.filter((r) => r.warn).length;
318 console.log(`\n# agent-journey: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${warned ? ` — ${warned} warning(s)` : ""}`);
319 process.exit(failed === 0 ? 0 : 1);
320}
321
322main().catch((err) => {
323 console.error(`\n# agent-journey: FAILED — ${(err as Error).message}`);
324 process.exit(1);
325});