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.tsBlame270 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
9// prior run that never reached its own step 10 (e.g.
10// 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
14// 5. open PR gluecron_create_pr
15// 6. comment gluecron_comment_pr
16// 7. AI review poll PR comments for the ai-review marker (WARN if absent)
17// 8. merge gluecron_merge_pr → gluecron_get_pr state=merged
18// 9. events GET /api/v2/repos/:o/:r/activity
19// 10. cleanup gluecron_delete_repo (always, unless KEEP_REPO=1)
20//
21// Run: bun run scripts/agent-journey.ts
22// Env: GLUECRON_PAT REQUIRED — admin-scope PAT (merge + delete need it)
23// GLUECRON_HOST target (default https://gluecron.com)
24// KEEP_REPO=1 skip cleanup for debugging
25// Exit: 0 iff all steps pass (step 7 is WARN-only — AI review may be
26// legitimately disabled). Never touches any existing repo.
27
28import { AI_REVIEW_MARKER } from "../src/lib/ai-review";
29import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
30import { tmpdir } from "node:os";
31import { join } from "node:path";
32
33const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
34const PAT = process.env.GLUECRON_PAT?.trim() || "";
35const KEEP = process.env.KEEP_REPO === "1";
36
37if (!PAT) {
38 console.error("GLUECRON_PAT is required (admin scope). Mint one at /settings/tokens or via scripts/emergency-pat.ts.");
39 process.exit(2);
40}
41
42// Hard safety guard: the scratch repo name is generated here and must never
43// collide with the self-host repo (its post-receive hook triggers deploys).
44const REPO = `journey-${new Date().toISOString().slice(0, 19).replace(/[:T-]/g, "")}-${Math.random().toString(36).slice(2, 6)}`;
45const FORBIDDEN = /gluecron\.com/i;
46if (FORBIDDEN.test(REPO)) throw new Error("unreachable: scratch repo name collided with self-host repo");
47
48// ─── plumbing ────────────────────────────────────────────────────────
49
50async function api(method: string, path: string, body?: unknown) {
51 const res = await fetch(HOST + path, {
52 method,
53 headers: {
54 authorization: `Bearer ${PAT}`,
55 ...(body !== undefined ? { "content-type": "application/json" } : {}),
56 },
57 ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
58 });
59 const text = await res.text();
60 let json: any = null;
61 try { json = JSON.parse(text); } catch { /* non-JSON */ }
62 return { status: res.status, json, text };
63}
64
65let rpcId = 0;
66async function mcp(tool: string, args: Record<string, unknown>) {
67 const res = await fetch(HOST + "/mcp", {
68 method: "POST",
69 headers: { authorization: `Bearer ${PAT}`, "content-type": "application/json" },
70 body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: tool, arguments: args } }),
71 });
72 const json: any = await res.json();
73 if (res.status !== 200 || json.error) {
74 throw new Error(`${tool} → HTTP ${res.status} ${JSON.stringify(json.error ?? json).slice(0, 300)}`);
75 }
76 if (json.result?.isError) {
77 throw new Error(`${tool} → tool error: ${JSON.stringify(json.result.content).slice(0, 300)}`);
78 }
79 // MCP text content is JSON-stringified tool output; parse when possible.
80 const textPart = (json.result?.content ?? []).find((p: any) => p.type === "text")?.text;
81 try { return JSON.parse(textPart); } catch { return textPart; }
82}
83
84function git(cwd: string, ...args: string[]): string {
85 const proc = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
86 const out = new TextDecoder().decode(proc.stdout);
87 const err = new TextDecoder().decode(proc.stderr);
88 if (proc.exitCode !== 0) throw new Error(`git ${args[0]} failed (${proc.exitCode}): ${(err || out).slice(0, 300)}`);
89 return out.trim();
90}
91
92interface StepResult { name: string; ok: boolean; warn?: boolean; ms: number; detail?: string }
93const results: StepResult[] = [];
94
95async function step<T>(name: string, fn: () => Promise<T>, opts: { warnOnly?: boolean } = {}): Promise<T | null> {
96 const t0 = Date.now();
97 try {
98 const v = await fn();
99 results.push({ name, ok: true, ms: Date.now() - t0 });
100 console.log(`PASS ${name} (${Date.now() - t0}ms)`);
101 return v;
102 } catch (err) {
103 const detail = (err as Error).message;
104 results.push({ name, ok: !!opts.warnOnly, warn: opts.warnOnly, ms: Date.now() - t0, detail });
105 console.log(`${opts.warnOnly ? "WARN" : "FAIL"} ${name} — ${detail}`);
106 if (!opts.warnOnly) throw err;
107 return null;
108 }
109}
110
111// ─── the journey ─────────────────────────────────────────────────────
112
113async function main() {
114 console.log(`# agent-journey — ${HOST} — scratch repo "${REPO}" — ${new Date().toISOString()}\n`);
115 let owner = "";
116 let repoCreated = false;
117
118 try {
119 // 1. identity
120 const user = await step("1. PAT identity (GET /api/v2/user)", async () => {
121 const r = await api("GET", "/api/v2/user");
122 if (r.status !== 200 || !r.json?.username) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
123 return r.json;
124 });
125 owner = user!.username;
126 if (`${owner}/${REPO}`.toLowerCase() === "ccantynz/gluecron.com") throw new Error("safety: refusing to touch the self-host repo");
127
f8f6bd0ccanty labs128 // 1b. sweep leftover probes from earlier runs — WARN-only, best-effort.
129 // The step-10 cleanup below only runs inside this process's try/finally,
130 // which can't protect against the process itself dying mid-run (killed
131 // terminal, crashed script, host restart) — a hard kill skips `finally`
132 // entirely, same as any JS runtime. Rather than chase every possible
133 // cause of a mid-run death, sweep for orphaned `journey-*` repos here so
134 // cleanup is self-healing across runs instead of depending on any single
135 // run completing cleanly. 1-hour grace period avoids racing a
136 // concurrently-running journey.
137 await step("1b. sweep leftover journey-* probes", async () => {
138 const r = await api("GET", `/api/v2/users/${encodeURIComponent(owner)}/repos`);
139 if (r.status !== 200 || !Array.isArray(r.json)) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
140 const cutoff = Date.now() - 60 * 60 * 1000;
141 const orphans = r.json.filter((repo: any) =>
142 /^journey-\d{14}-[a-z0-9]{4}$/.test(repo.name) &&
143 repo.name !== REPO &&
144 new Date(repo.createdAt).getTime() < cutoff
145 );
146 if (orphans.length === 0) return;
147 let swept = 0;
148 const failures: string[] = [];
149 for (const repo of orphans) {
150 try {
151 await mcp("gluecron_delete_repo", { owner, repo: repo.name });
152 swept++;
153 } catch (err) {
154 failures.push(`${repo.name}: ${(err as Error).message}`);
155 }
156 }
157 console.log(` swept ${swept}/${orphans.length} orphaned probe repo(s)`);
158 if (failures.length > 0) throw new Error(`${failures.length} orphan(s) failed to delete: ${failures.join("; ").slice(0, 300)}`);
159 }, { warnOnly: true });
160
2cecc08ccanty labs161 // 2. create scratch repo
162 await step("2. create scratch repo (POST /api/v2/repos)", async () => {
163 const r = await api("POST", "/api/v2/repos", { name: REPO, description: "agent-journey probe — auto-deleted", private: false });
164 if (r.status !== 200 && r.status !== 201) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
165 repoCreated = true;
166 });
167
168 // 3. git push over smart HTTP with PAT basic auth
169 const pushUrl = `${HOST.replace("://", `://${encodeURIComponent(owner)}:${PAT}@`)}/${owner}/${REPO}.git`;
170 const mainSha = await step("3. git push main over HTTPS (PAT basic auth)", async () => {
171 const dir = mkdtempSync(join(tmpdir(), "gluecron-journey-"));
172 try {
173 git(dir, "init", "-b", "main");
174 git(dir, "config", "user.email", "journey@gluecron.com");
175 git(dir, "config", "user.name", "agent-journey");
176 writeFileSync(join(dir, "README.md"), `# ${REPO}\n\nExternal-platform journey probe.\n`);
177 git(dir, "add", ".");
178 git(dir, "commit", "-m", "chore: journey seed commit");
179 const sha = git(dir, "rev-parse", "HEAD");
180 git(dir, "push", pushUrl, "main");
181 return sha;
182 } finally {
183 rmSync(dir, { recursive: true, force: true });
184 }
185 });
186
187 // 4. branch + file via MCP write tools
188 await step("4a. gluecron_create_branch", () => mcp("gluecron_create_branch", { owner, repo: REPO, branch: "feature/journey", sha: mainSha }));
189 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` }));
190
191 // 5. open PR
192 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" }));
193 const prNumber: number = pr!.number;
194
195 // 6. comment
196 await step("6. gluecron_comment_pr", () => mcp("gluecron_comment_pr", { owner, repo: REPO, number: prNumber, body: `Journey probe comment ${new Date().toISOString()}` }));
197
198 // 7. AI review (WARN-only: may be disabled / unfunded)
199 await step("7. AI review fired (poll for marker, ≤90s)", async () => {
200 const deadline = Date.now() + 90_000;
201 while (Date.now() < deadline) {
202 const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/pulls/${prNumber}`);
203 const comments: Array<{ body?: string; isAiReview?: boolean }> = r.json?.comments ?? [];
204 if (comments.some((cm) => cm.isAiReview || cm.body?.includes(AI_REVIEW_MARKER))) return true;
205 await new Promise((res) => setTimeout(res, 5000));
206 }
207 throw new Error("no AI-review comment within 90s (review disabled, or ANTHROPIC_API_KEY unset?)");
208 }, { warnOnly: true });
209
210 // 8. merge + verify
211 await step("8a. gluecron_merge_pr", async () => {
212 const m = await mcp("gluecron_merge_pr", { owner, repo: REPO, number: prNumber });
213 if (!m?.merged) throw new Error(`not merged: ${JSON.stringify(m).slice(0, 200)}`);
214 return m;
215 });
216 await step("8b. PR state is merged + main advanced", async () => {
217 const detail = await mcp("gluecron_get_pr", { owner, repo: REPO, number: prNumber });
218 const state = detail?.state ?? detail?.pr?.state;
219 if (state !== "merged") throw new Error(`PR state: ${state}`);
220 const dir = mkdtempSync(join(tmpdir(), "gluecron-lsr-"));
221 try {
222 git(dir, "init");
223 const head = git(dir, "ls-remote", pushUrl, "refs/heads/main").split(/\s/)[0];
224 if (head === mainSha) throw new Error("main did not advance past the seed commit");
225 } finally {
226 rmSync(dir, { recursive: true, force: true });
227 }
228 });
229
48c26deccanty labs230 // 9. events — WARN-only (known platform gap, 2026-07-15): activityFeed is
231 // only written by fork/use-template/claude-connect/reviewer-suggest.
232 // None of git push, MCP branch/PR/comment/merge, or POST /api/v2/repos
233 // insert an activityFeed row, so dashboards rolling it up are blind to
234 // everything this journey just did. Real gap, not a test bug — tracked
235 // separately; not a reason to fail the merge-lifecycle certification.
2cecc08ccanty labs236 await step("9. activity feed recorded push + PR events", async () => {
237 const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/activity`);
238 if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
561b2a8ccanty labs239 // GET /api/v2/repos/:owner/:repo/activity returns a bare array.
240 const events: Array<{ type?: string }> = Array.isArray(r.json)
241 ? r.json
242 : r.json?.events ?? r.json?.activity ?? [];
48c26deccanty labs243 if (!Array.isArray(events) || events.length === 0) throw new Error("no activity events (known gap — see comment above)");
244 }, { warnOnly: true });
2cecc08ccanty labs245 } finally {
246 // 10. cleanup — always attempt when the repo was created
247 if (repoCreated && !KEEP) {
248 try {
249 await mcp("gluecron_delete_repo", { owner, repo: REPO });
250 console.log(`PASS 10. cleanup — deleted ${owner}/${REPO}`);
251 results.push({ name: "10. cleanup (gluecron_delete_repo)", ok: true, ms: 0 });
252 } catch (err) {
253 console.error(`FAIL 10. cleanup — LEFTOVER REPO ${owner}/${REPO}: ${(err as Error).message}`);
254 results.push({ name: "10. cleanup (gluecron_delete_repo)", ok: false, ms: 0, detail: `LEFTOVER ${owner}/${REPO}` });
255 }
256 } else if (repoCreated) {
257 console.log(`KEEP 10. cleanup skipped (KEEP_REPO=1) — ${owner}/${REPO}`);
258 }
259 }
260
261 const failed = results.filter((r) => !r.ok).length;
262 const warned = results.filter((r) => r.warn).length;
263 console.log(`\n# agent-journey: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${warned ? ` — ${warned} warning(s)` : ""}`);
264 process.exit(failed === 0 ? 0 : 1);
265}
266
267main().catch((err) => {
268 console.error(`\n# agent-journey: FAILED — ${(err as Error).message}`);
269 process.exit(1);
270});