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
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
agent-journey.ts16.6 KB · 325 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
// Agent journey — simulate an EXTERNAL AI platform driving a full PR
// lifecycle against a live Gluecron instance, using exactly the surface
// such a platform would use: a PAT bearer on MCP tools + git-over-HTTPS
// with token basic-auth. This is the go/no-go certification for "other
// platforms can open and merge PRs here".
//
//   1. identity        GET /api/v2/user
//   1b. sweep           delete any journey-* repos >1h old left behind by a
//                       prior run that never reached its own step 11 (e.g.
//                       the process was killed mid-run) — WARN-only
//   2. create repo     POST /api/v2/repos          (no MCP create-repo tool exists — platform gap, noted)
//   3. git push        smart-HTTP receive-pack with PAT basic auth
//   4. branch + edit   gluecron_create_branch + gluecron_write_file
//   4c. webhook         register a probe webhook (pr events) via the same
//                       PAT bearer, so step 10 can prove fireWebhooks() is
//                       wired into the real create/merge call sites
//   5. open PR         gluecron_create_pr
//   6. comment         gluecron_comment_pr
//   7. AI review       poll PR comments for the ai-review marker (WARN if absent)
//   8. merge           gluecron_merge_pr → gluecron_get_pr state=merged
//   9. events          GET /api/v2/repos/:o/:r/activity
//  10. webhook delivery poll the probe webhook's settings page for a
//                       recorded delivery attempt
//  11. cleanup         gluecron_delete_repo (always, unless KEEP_REPO=1)
//
// Run:  bun run scripts/agent-journey.ts
// Env:  GLUECRON_PAT   REQUIRED — admin-scope PAT (merge + delete need it)
//       GLUECRON_HOST  target (default https://gluecron.com)
//       KEEP_REPO=1    skip cleanup for debugging
// Exit: 0 iff all steps pass (step 7 is WARN-only — AI review may be
//       legitimately disabled). Never touches any existing repo.

import { AI_REVIEW_MARKER } from "../src/lib/ai-review";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

const HOST = (process.env.GLUECRON_HOST || "https://gluecron.com").replace(/\/+$/, "");
const PAT = process.env.GLUECRON_PAT?.trim() || "";
const KEEP = process.env.KEEP_REPO === "1";

if (!PAT) {
  console.error("GLUECRON_PAT is required (admin scope). Mint one at /settings/tokens or via scripts/emergency-pat.ts.");
  process.exit(2);
}

// Hard safety guard: the scratch repo name is generated here and must never
// collide with the self-host repo (its post-receive hook triggers deploys).
const REPO = `journey-${new Date().toISOString().slice(0, 19).replace(/[:T-]/g, "")}-${Math.random().toString(36).slice(2, 6)}`;
const FORBIDDEN = /gluecron\.com/i;
if (FORBIDDEN.test(REPO)) throw new Error("unreachable: scratch repo name collided with self-host repo");

// ─── plumbing ────────────────────────────────────────────────────────

async function api(method: string, path: string, body?: unknown) {
  const res = await fetch(HOST + path, {
    method,
    headers: {
      authorization: `Bearer ${PAT}`,
      ...(body !== undefined ? { "content-type": "application/json" } : {}),
    },
    ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
  });
  const text = await res.text();
  let json: any = null;
  try { json = JSON.parse(text); } catch { /* non-JSON */ }
  return { status: res.status, json, text };
}

let rpcId = 0;
async function mcp(tool: string, args: Record<string, unknown>) {
  const res = await fetch(HOST + "/mcp", {
    method: "POST",
    headers: { authorization: `Bearer ${PAT}`, "content-type": "application/json" },
    body: JSON.stringify({ jsonrpc: "2.0", id: ++rpcId, method: "tools/call", params: { name: tool, arguments: args } }),
  });
  const json: any = await res.json();
  if (res.status !== 200 || json.error) {
    throw new Error(`${tool} → HTTP ${res.status} ${JSON.stringify(json.error ?? json).slice(0, 300)}`);
  }
  if (json.result?.isError) {
    throw new Error(`${tool} → tool error: ${JSON.stringify(json.result.content).slice(0, 300)}`);
  }
  // MCP text content is JSON-stringified tool output; parse when possible.
  const textPart = (json.result?.content ?? []).find((p: any) => p.type === "text")?.text;
  try { return JSON.parse(textPart); } catch { return textPart; }
}

function git(cwd: string, ...args: string[]): string {
  const proc = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
  const out = new TextDecoder().decode(proc.stdout);
  const err = new TextDecoder().decode(proc.stderr);
  if (proc.exitCode !== 0) throw new Error(`git ${args[0]} failed (${proc.exitCode}): ${(err || out).slice(0, 300)}`);
  return out.trim();
}

interface StepResult { name: string; ok: boolean; warn?: boolean; ms: number; detail?: string }
const results: StepResult[] = [];

async function step<T>(name: string, fn: () => Promise<T>, opts: { warnOnly?: boolean } = {}): Promise<T | null> {
  const t0 = Date.now();
  try {
    const v = await fn();
    results.push({ name, ok: true, ms: Date.now() - t0 });
    console.log(`PASS  ${name}  (${Date.now() - t0}ms)`);
    return v;
  } catch (err) {
    const detail = (err as Error).message;
    results.push({ name, ok: !!opts.warnOnly, warn: opts.warnOnly, ms: Date.now() - t0, detail });
    console.log(`${opts.warnOnly ? "WARN" : "FAIL"}  ${name}${detail}`);
    if (!opts.warnOnly) throw err;
    return null;
  }
}

// ─── the journey ─────────────────────────────────────────────────────

async function main() {
  console.log(`# agent-journey — ${HOST} — scratch repo "${REPO}" — ${new Date().toISOString()}\n`);
  let owner = "";
  let repoCreated = false;

  try {
    // 1. identity
    const user = await step("1. PAT identity (GET /api/v2/user)", async () => {
      const r = await api("GET", "/api/v2/user");
      if (r.status !== 200 || !r.json?.username) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
      return r.json;
    });
    owner = user!.username;
    if (`${owner}/${REPO}`.toLowerCase() === "ccantynz/gluecron.com") throw new Error("safety: refusing to touch the self-host repo");

    // 1b. sweep leftover probes from earlier runs — WARN-only, best-effort.
    // The step-10 cleanup below only runs inside this process's try/finally,
    // which can't protect against the process itself dying mid-run (killed
    // terminal, crashed script, host restart) — a hard kill skips `finally`
    // entirely, same as any JS runtime. Rather than chase every possible
    // cause of a mid-run death, sweep for orphaned `journey-*` repos here so
    // cleanup is self-healing across runs instead of depending on any single
    // run completing cleanly. 1-hour grace period avoids racing a
    // concurrently-running journey.
    await step("1b. sweep leftover journey-* probes", async () => {
      const r = await api("GET", `/api/v2/users/${encodeURIComponent(owner)}/repos`);
      if (r.status !== 200 || !Array.isArray(r.json)) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
      const cutoff = Date.now() - 60 * 60 * 1000;
      const orphans = r.json.filter((repo: any) =>
        /^journey-\d{14}-[a-z0-9]{4}$/.test(repo.name) &&
        repo.name !== REPO &&
        new Date(repo.createdAt).getTime() < cutoff
      );
      if (orphans.length === 0) return;
      let swept = 0;
      const failures: string[] = [];
      for (const repo of orphans) {
        try {
          await mcp("gluecron_delete_repo", { owner, repo: repo.name });
          swept++;
        } catch (err) {
          failures.push(`${repo.name}: ${(err as Error).message}`);
        }
      }
      console.log(`  swept ${swept}/${orphans.length} orphaned probe repo(s)`);
      if (failures.length > 0) throw new Error(`${failures.length} orphan(s) failed to delete: ${failures.join("; ").slice(0, 300)}`);
    }, { warnOnly: true });

    // 2. create scratch repo
    await step("2. create scratch repo (POST /api/v2/repos)", async () => {
      const r = await api("POST", "/api/v2/repos", { name: REPO, description: "agent-journey probe — auto-deleted", private: false });
      if (r.status !== 200 && r.status !== 201) throw new Error(`HTTP ${r.status}: ${r.text.slice(0, 200)}`);
      repoCreated = true;
    });

    // 3. git push over smart HTTP with PAT basic auth
    const pushUrl = `${HOST.replace("://", `://${encodeURIComponent(owner)}:${PAT}@`)}/${owner}/${REPO}.git`;
    const mainSha = await step("3. git push main over HTTPS (PAT basic auth)", async () => {
      const dir = mkdtempSync(join(tmpdir(), "gluecron-journey-"));
      try {
        git(dir, "init", "-b", "main");
        git(dir, "config", "user.email", "journey@gluecron.com");
        git(dir, "config", "user.name", "agent-journey");
        writeFileSync(join(dir, "README.md"), `# ${REPO}\n\nExternal-platform journey probe.\n`);
        git(dir, "add", ".");
        git(dir, "commit", "-m", "chore: journey seed commit");
        const sha = git(dir, "rev-parse", "HEAD");
        git(dir, "push", pushUrl, "main");
        return sha;
      } finally {
        rmSync(dir, { recursive: true, force: true });
      }
    });

    // 4. branch + file via MCP write tools
    await step("4a. gluecron_create_branch", () => mcp("gluecron_create_branch", { owner, repo: REPO, branch: "feature/journey", sha: mainSha }));
    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` }));

    // 4c. register a probe webhook (pr events) so step 11 below can prove
    // fireWebhooks() is actually wired into the real PR create/merge call
    // sites, not just present in the codebase. Points at this instance's
    // own /healthz — fast, always answers with a real HTTP status, no
    // external network dependency. requireAuth on this form route accepts
    // the same PAT bearer as everything else (softAuth: "Bearer token takes
    // precedence over cookie for API calls").
    await step("4c. register probe webhook", async () => {
      const res = await fetch(`${HOST}/${owner}/${REPO}/settings/webhooks`, {
        method: "POST",
        headers: {
          authorization: `Bearer ${PAT}`,
          "content-type": "application/x-www-form-urlencoded",
        },
        body: new URLSearchParams({ url: `${HOST}/healthz`, events: "pr" }),
        redirect: "manual",
      });
      // The handler always redirects (3xx) on both success and validation
      // error; anything else means something is actually broken.
      if (res.status < 300 || res.status >= 400) {
        throw new Error(`webhook registration: HTTP ${res.status}`);
      }
    });

    // 5. open PR
    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" }));
    const prNumber: number = pr!.number;

    // 6. comment
    await step("6. gluecron_comment_pr", () => mcp("gluecron_comment_pr", { owner, repo: REPO, number: prNumber, body: `Journey probe comment ${new Date().toISOString()}` }));

    // 7. AI review (WARN-only: may be disabled / unfunded)
    await step("7. AI review fired (poll for marker, ≤90s)", async () => {
      const deadline = Date.now() + 90_000;
      while (Date.now() < deadline) {
        const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/pulls/${prNumber}`);
        const comments: Array<{ body?: string; isAiReview?: boolean }> = r.json?.comments ?? [];
        if (comments.some((cm) => cm.isAiReview || cm.body?.includes(AI_REVIEW_MARKER))) return true;
        await new Promise((res) => setTimeout(res, 5000));
      }
      throw new Error("no AI-review comment within 90s (review disabled, or ANTHROPIC_API_KEY unset?)");
    }, { warnOnly: true });

    // 8. merge + verify
    await step("8a. gluecron_merge_pr", async () => {
      const m = await mcp("gluecron_merge_pr", { owner, repo: REPO, number: prNumber });
      if (!m?.merged) throw new Error(`not merged: ${JSON.stringify(m).slice(0, 200)}`);
      return m;
    });
    await step("8b. PR state is merged + main advanced", async () => {
      const detail = await mcp("gluecron_get_pr", { owner, repo: REPO, number: prNumber });
      const state = detail?.state ?? detail?.pr?.state;
      if (state !== "merged") throw new Error(`PR state: ${state}`);
      const dir = mkdtempSync(join(tmpdir(), "gluecron-lsr-"));
      try {
        git(dir, "init");
        const head = git(dir, "ls-remote", pushUrl, "refs/heads/main").split(/\s/)[0];
        if (head === mainSha) throw new Error("main did not advance past the seed commit");
      } finally {
        rmSync(dir, { recursive: true, force: true });
      }
    });

    // 9. events — activityFeed wiring gap (found 2026-07-15) was fixed same
    // day (commit b7b5f75): logActivity() is now called from git push, PR
    // create/comment/merge, and the MCP write tools. Promoted from
    // warnOnly to a real assertion now that the underlying gap is closed —
    // this is exactly the check that caught the gap in the first place, so
    // it stays a hard requirement going forward rather than reverting to
    // silently-optional once fixed.
    await step("9. activity feed recorded push + PR events", async () => {
      const r = await api("GET", `/api/v2/repos/${owner}/${REPO}/activity`);
      if (r.status !== 200) throw new Error(`HTTP ${r.status}`);
      // GET /api/v2/repos/:owner/:repo/activity returns a bare array.
      const events: Array<{ type?: string }> = Array.isArray(r.json)
        ? r.json
        : r.json?.events ?? r.json?.activity ?? [];
      if (!Array.isArray(events) || events.length === 0) throw new Error("no activity events recorded — activity_feed wiring regressed");
    });

    // 10. webhook delivery recorded — proves fireWebhooks() is actually
    // called from the real PR create/merge handlers (found 2026-07-16:
    // fireWebhooks() had zero callers anywhere despite a full config UI and
    // "last delivery" status pill; fixed by wiring it into post-receive.ts,
    // issues.tsx, pulls.tsx, pr-merge.ts, and web.tsx). The probe webhook
    // registered in step 4c points at this instance's own /healthz — any
    // delivery attempt, success or failure, sets webhooks.lastStatus to a
    // non-null value (see src/lib/webhook-delivery.ts's `lastStatus:
    // statusCode ?? 0` on every branch), which flips the settings page's
    // "no deliveries yet" text. Polls because fireWebhooks() enqueues
    // fire-and-forget — the delivery worker runs asynchronously after the
    // PR-open/merge response already returned.
    await step("10. webhook delivery recorded (poll for lastStatus, ≤30s)", async () => {
      const deadline = Date.now() + 30_000;
      while (Date.now() < deadline) {
        const res = await fetch(`${HOST}/${owner}/${REPO}/settings/webhooks`, {
          headers: { authorization: `Bearer ${PAT}` },
        });
        const html = await res.text();
        if (res.status === 200 && !html.includes("no deliveries yet")) return true;
        await new Promise((r) => setTimeout(r, 3000));
      }
      throw new Error("no webhook delivery recorded within 30s — fireWebhooks() call sites may have regressed");
    });
  } finally {
    // 11. cleanup — always attempt when the repo was created
    if (repoCreated && !KEEP) {
      try {
        await mcp("gluecron_delete_repo", { owner, repo: REPO });
        console.log(`PASS  11. cleanup — deleted ${owner}/${REPO}`);
        results.push({ name: "11. cleanup (gluecron_delete_repo)", ok: true, ms: 0 });
      } catch (err) {
        console.error(`FAIL  11. cleanup — LEFTOVER REPO ${owner}/${REPO}: ${(err as Error).message}`);
        results.push({ name: "11. cleanup (gluecron_delete_repo)", ok: false, ms: 0, detail: `LEFTOVER ${owner}/${REPO}` });
      }
    } else if (repoCreated) {
      console.log(`KEEP  11. cleanup skipped (KEEP_REPO=1) — ${owner}/${REPO}`);
    }
  }

  const failed = results.filter((r) => !r.ok).length;
  const warned = results.filter((r) => r.warn).length;
  console.log(`\n# agent-journey: ${failed === 0 ? "ALL GREEN" : `${failed} FAILURE(S)`}${warned ? ` — ${warned} warning(s)` : ""}`);
  process.exit(failed === 0 ? 0 : 1);
}

main().catch((err) => {
  console.error(`\n# agent-journey: FAILED — ${(err as Error).message}`);
  process.exit(1);
});