Commit551a300unknown_key
Merge branch 'main' into copilot/fix-and-process-workflows
8 files changed+744−221551a30056b3325b1c2a71662a54b8a9576dbc25d
8 changed files+744−221
Modified.env.example+9−3View fileUnifiedSplit
@@ -8,9 +8,15 @@ GATETEST_API_KEY=
88# Generate a secret with: openssl rand -hex 32
99GATETEST_CALLBACK_SECRET=
1010GATETEST_HMAC_SECRET=
11CRONTECH_DEPLOY_URL=https://crontech.ai/api/hooks/gluecron/push
12# Bearer token sent on the outbound Crontech deploy webhook. Obtain from
13# Crontech (one per Gluecron install). Unset → Crontech rejects with 401.
11CRONTECH_DEPLOY_URL=https://crontech.ai/api/webhooks/gluecron-push
12# BLK-016 — only fire the Crontech deploy webhook for pushes to this
13# `<owner>/<name>` (default `ccantynz-alt/crontech`). All other repo pushes
14# are ignored.
15CRONTECH_REPO=ccantynz-alt/crontech
16# Shared HMAC secret used to sign the outbound Crontech deploy webhook.
17# Sent as `X-Gluecron-Signature: sha256=<hex>` of the JSON body. Must match
18# the `GLUECRON_WEBHOOK_SECRET` configured on the Crontech deploy-agent
19# (Vultr box). Unset → header omitted and Crontech rejects with 401.
1420GLUECRON_WEBHOOK_SECRET=
1521# Inbound bearer token Crontech MUST present on deploy.succeeded /
1622# deploy.failed callbacks to POST /api/events/deploy (Signal Bus P1 — E3/E4).
ModifiedBUILD_BIBLE.md+108−3View fileUnifiedSplit
@@ -137,6 +137,12 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
137137| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
138138| Copilot code completion | ✅ | D9 — `POST /api/copilot/completions` (PAT/OAuth/session), `GET /api/copilot/ping`. `src/lib/ai-completion.ts`, `src/routes/copilot.ts`. LRU-cached, rate-limited 60/min. |
139139| Semantic code search | ✅ | D1 — see 2.2 |
140| Spec-to-PR (NL feature spec → draft PR) | ✅ | `src/routes/specs.tsx` UI, `src/lib/spec-to-pr.ts` orchestrator, `src/lib/spec-ai.ts` Claude call + parser, `src/lib/spec-context.ts` repo context reader, `src/lib/spec-git.ts` plumbing-only branch writer. Graceful fallback when `ANTHROPIC_API_KEY` is unset. |
141| Repository intelligence engine | ✅ | `src/lib/intelligence.ts` — repo-level summarisation + signals. |
142| Time-travel code explorer | ✅ | `src/lib/timetravel.ts` — historical snapshot navigation. |
143| Dependency impact analyzer | ✅ | `src/lib/depimpact.ts` — "what breaks if I bump X" cross-reference. |
144| One-click rollback | ✅ | `src/lib/rollback.ts` — fast revert helper for failed deploys. |
145| Auto-repair (intelligence path) | ✅ | `src/lib/autorepair.ts` `autoRepair(...)` — invoked from `src/hooks/post-receive.ts`. Distinct from `src/lib/auto-repair.ts` `repairSecrets`/`repairSecurityIssues` (gate-time). Both are load-bearing — do not dedupe without owner approval. |
140146
141147### 2.5 Platform
142148| Feature | Status | Notes |
@@ -169,7 +175,16 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
169175|---|---|---|
170176| Rate limiting | ✅ | `src/middleware/rate-limit.ts` |
171177| Request-ID tracing | ✅ | `src/middleware/request-context.ts` |
172| Health / readiness / metrics | ✅ | `/healthz` `/readyz` `/metrics` |
178| Health / readiness / metrics | ✅ | `/healthz` `/readyz` `/metrics`. Extended probe surface in `src/routes/health-probe.ts`; public dashboard `src/routes/status.tsx` (`GET /status` + `/status.svg` shields badge). |
179| Public platform status JSON | ✅ | `src/routes/platform-status.ts` — machine-readable status JSON. |
180| Error tracking | ✅ | `src/lib/observability.ts` — wired into `app.onError`. Sinks: `ERROR_WEBHOOK_URL` and/or `SENTRY_DSN`. Never throws. |
181| Comprehensive REST API v2 | ✅ | `src/routes/api-v2.ts` — full CRUD across resources. Authoritative integration surface alongside the v1 `/api/*` endpoints in §4.6. |
182| API documentation page | ✅ | `src/routes/api-docs.tsx` — interactive in-app docs. |
183| SSE in-process pub/sub | ✅ | `src/lib/sse.ts` + `src/lib/sse-client.ts` — topic-based broadcaster. `src/routes/live-events.ts` exposes `GET /live-events/:topic`. Used by the live UI updates layer. |
184| Inbound deploy event receiver (Signal Bus P1) | ✅ | `src/routes/events.ts` — Crontech → Gluecron event ingest. Idempotent via `drizzle/0034_processed_events.sql` (sha256 of payload deduped at write-site). |
185| Autopilot ticker | ✅ | `src/lib/autopilot.ts` — 5-min cycle: mirror sync, merge-queue peek, weekly digests, advisory rescans. `AUTOPILOT_DISABLED=1` opt-out. Admin page `/admin/autopilot`. |
186| Demo seed | ✅ | `src/lib/demo-seed.ts` — idempotent `ensureDemoContent()`. `DEMO_SEED_ON_BOOT=1` boot flag. Site-admin reseed at `/admin` (`POST /admin/demo/reseed`). `/demo` redirect to a sample repo. |
187| SEO (robots + sitemap) | ✅ | `src/routes/seo.ts` — `/robots.txt` + `/sitemap.xml`. |
173188| Audit log (table) | ✅ | `audit_log` table |
174189| Audit log UI | ✅ | `/settings/audit` (personal) + `/:owner/:repo/settings/audit` (per-repo, owner-only) |
175190| Traffic analytics per repo | ✅ | F1 — `src/lib/traffic.ts` + `src/routes/traffic.tsx`, `drizzle/0020_analytics_and_admin.sql`; owner-only 7/14/30/90d windows, ascii-bar daily chart. SHA-256-truncated IP for unique visitors. Fire-and-forget wiring in `web.tsx` + `git.ts`. |
@@ -183,6 +198,12 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
183198| Official CLI | ✅ | G3 — `cli/gluecron.ts` Bun-compilable single binary. REST + GraphQL client, `~/.gluecron/config.json` 0600. |
184199| VS Code extension | ✅ | G4 — `vscode-extension/` with commands for explain / open-on-web / semantic search / generate tests. |
185200| Native mobile apps | ❌ | |
201| Repository collaborators (per-repo roles) | ✅ | `drizzle/0035_repo_collaborators.sql` (read/write/admin, hierarchical) + `drizzle/0036_invite_token_hash.sql` (sha256 invite tokens). `src/routes/collaborators.tsx`, `src/routes/team-collaborators.tsx`, `src/routes/invites.tsx`, `src/lib/invite-tokens.ts`. Wired through `src/middleware/repo-access.ts` permission middleware. |
202| GitHub import (single repo) | ✅ | `src/routes/import.tsx` + `src/lib/import-helper.ts`. PAT-based clone-and-rehost. |
203| Bulk GitHub import (org migration) | ✅ | `src/routes/import-bulk.tsx` — paste a token, mirror an entire org in one pass. |
204| Migration history + verifier | ✅ | `src/routes/migrations.tsx` per-user history dashboard, `src/lib/import-verify.ts` post-migration smoke check (object count, branches, default branch). |
205| Onboarding flow | ✅ | `src/routes/onboarding.tsx` — guided first-five-minutes for new accounts. |
206| Public help / quickstart | ✅ | `src/routes/help.tsx` — owner-facing migration cheatsheet at `/help`. |
186207| Dark mode | ✅ | default |
187208| Light-mode toggle | ✅ | `/theme/toggle` + `theme` cookie, pre-paint script avoids FOUC, nav sun/moon icon |
188209| Keyboard shortcuts | ✅ | `/shortcuts` page |
@@ -341,6 +362,10 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
341362- `drizzle/0031_user_follows.sql` (Block J4) — migration, never edited in place. Adds `user_follows` (composite PK on `(follower_id, following_id)`, CHECK no-self-follow, reverse index on `following_id`).
342363- `drizzle/0032_repo_rulesets.sql` (Block J6) — migration, never edited in place. Adds `repo_rulesets` (unique on `(repository_id, name)`, enforcement enum) + `ruleset_rules` (JSON params).
343364- `drizzle/0033_commit_statuses.sql` (Block J8) — migration, never edited in place. Adds `commit_statuses` (unique on `(repository_id, commit_sha, context)`, state vocabulary pending/success/failure/error).
365- `drizzle/0034_processed_events.sql` (Signal Bus P1) — migration, never edited in place. Idempotency table for inbound deploy events; sha256-keyed dedupe of at-least-once delivery from Crontech.
366- `drizzle/0035_repo_collaborators.sql` — migration, never edited in place. Adds `repo_collaborators` (unique on `(repository_id, user_id)`, role enum read/write/admin, `invited_by` nullable, `accepted_at` nullable).
367- `drizzle/0036_invite_token_hash.sql` — migration, never edited in place. Adds `invite_token_hash` column (sha256 of plaintext) to `repo_collaborators` for email-flow invites; NULL on legacy auto-accepted rows.
368- `drizzle/0037_workflow_engine_v2.sql` — migration, never edited in place. Strictly additive to Block C1 (`drizzle/0008_workflows.sql` stays locked). Adds `workflow_secrets` (AES-256-GCM, `(repo, name)` unique), `workflow_dispatch_inputs` (per-workflow input schema), `workflow_run_cache` (content-addressable cache, repo/branch/tag scoped), `workflow_runner_pool` (warm-runner registry).
344369
345370### 4.2 Git layer (locked)
346371- `src/git/repository.ts` — tree / blob / commits / diff / branches / blame / search / raw / tags / commitsBetween
@@ -363,6 +388,13 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
363388- `src/lib/pages.ts` (Block C3) — `onPagesPush` (never throws), `resolvePagesPath` (probe list including pretty URLs + traversal strip), `contentTypeFor` (MIME).
364389- `src/lib/environments.ts` (Block C4) — `matchGlob`, `listEnvironments`, `getOrCreateEnvironment`, `getEnvironmentByName`, `isReviewer`, `reviewerIdsOf`, `allowedBranchesOf`, `computeApprovalState`, `reduceApprovalState`, `recordApproval`, `requiresApprovalFor`. Empty reviewers list → repo owner approves. Any rejection hard-stops.
365390
391### 4.3.1 Permissions + collaborators (locked)
392- `src/middleware/repo-access.ts` — centralised permission check applied to all repo-write routes. Resolves owner / collaborator-role / org-team-role and rejects unauthorised mutations.
393- `src/lib/invite-tokens.ts` — opaque single-use invite secret generator + sha256 hasher. Hash stored in `repo_collaborators.invite_token_hash`; plaintext is delivered once via the invite email link.
394- `src/routes/collaborators.tsx` — per-repo collaborator add/list/remove. Owner-or-admin only. Audit-logged.
395- `src/routes/team-collaborators.tsx` — bulk-grant: invite every accepted member of a given team to a repo as a collaborator in one action.
396- `src/routes/invites.tsx` — invite acceptance landing page. Verifies token hash, sets `accepted_at`, redirects to repo.
397
366398### 4.4 AI layer (locked)
367399- `src/lib/ai-client.ts` — Anthropic client + model constants
368400- `src/lib/ai-generators.ts` — commit / PR / changelog / issue-triage / **pull-request-triage (D3)**
@@ -377,6 +409,13 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
377409- `src/lib/ai-incident.ts` (Block D4) — `onDeployFailure({deploymentId, reason, logs?})` and pure helper `summariseCommitsForIncident(commits)`. Sonnet 4 structured JSON RCA → opens `issues` row, attaches `incident` label if present, sets `deployments.blockedReason`. Never throws; deterministic fallback body when no API key. Wired from `post-receive.ts triggerCrontechDeploy` + `deployments.tsx retry-incident`.
378410- `src/lib/ai-tests.ts` (Block D8) — pure helpers `detectLanguage`, `detectTestFramework`, `buildTestsPrompt`, `suggestedTestPath`, `generateTestStub`, `contentTypeFor`. Returns `{code:"", framework:"fallback"}` on no API key. Never throws.
379411- `src/lib/branch-protection.ts` (Block D5) — `matchProtection(repoId, branch)` (exact wins; deterministic glob sort), `evaluateProtection(rule, ctx)` (pure — checks `requireAiApproval | requireGreenGates | requireHumanReview | requiredApprovals`), `countHumanApprovals(prId)` (LGTM/+1/approved heuristic). Never throws. Enforcement is in `src/routes/pulls.tsx` merge handler, after existing hard-gate filter.
412- `src/lib/intelligence.ts` — repository intelligence engine (repo-level summarisation + signals).
413- `src/lib/timetravel.ts` — time-travel code explorer (historical snapshot navigation).
414- `src/lib/depimpact.ts` — dependency impact analyzer (cross-reference of who breaks if X bumps).
415- `src/lib/rollback.ts` — one-click rollback helper for failed deploys.
416- `src/lib/spec-to-pr.ts` + `src/lib/spec-ai.ts` + `src/lib/spec-context.ts` + `src/lib/spec-git.ts` — Spec-to-PR v2 pipeline (NL feature spec → Claude → branch via git plumbing → PR row). Graceful fallback when no `ANTHROPIC_API_KEY`.
417- `src/lib/autorepair.ts` — `autoRepair(...)` invoked from `src/hooks/post-receive.ts` and exercised by `src/__tests__/intelligence.test.ts`. **Distinct from `src/lib/auto-repair.ts`** (gate-time `repairSecrets`/`repairSecurityIssues` used by `src/lib/gate.ts`). Both are load-bearing — do not dedupe.
418- `src/lib/pr-triage.ts` — placeholder for a future post-create PR-triage hook (logs only, no AI call). Distinct from `triagePullRequest` in `src/lib/ai-generators.ts` which is the live D3 path.
380419
381420### 4.5 Platform (locked)
382421- `src/lib/notify.ts` — notification creation + audit log (swallow-failures pattern). Also fans out email to opted-in recipients for `mention|review_requested|assigned|gate_failed`. Exports `__internal` for tests.
@@ -387,6 +426,19 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
387426- `src/lib/gate.ts` — gate orchestration + persistence
388427- `src/lib/cache.ts` — LRU cache, git-cache invalidation
389428- `src/lib/reactions.ts` — `summariseReactions`, `toggleReaction`, `ALLOWED_EMOJIS`, `EMOJI_GLYPH`, `isAllowedEmoji`, `isAllowedTarget`
429- `src/lib/observability.ts` — minimal dependency-free observability layer. Wired into `app.onError`. Sinks: `ERROR_WEBHOOK_URL` and/or `SENTRY_DSN`. Never throws.
430- `src/lib/sse.ts` — in-process topic-based pub/sub broadcaster for Server-Sent Events. Backs the live UI updates layer.
431- `src/lib/sse-client.ts` — emits a `<script>`-droppable JS snippet for SSR'd views to subscribe to a topic. Plain JS only — no client framework.
432- `src/lib/autopilot.ts` — 5-minute self-sufficiency ticker (mirror sync, merge-queue peek, weekly digests, advisory rescans). `AUTOPILOT_DISABLED=1` opt-out. Observability via `getLastTick()` + `getTickCount()`.
433- `src/lib/demo-seed.ts` — idempotent `ensureDemoContent()` (creates `demo` user + `hello-python` / `todo-api` / `design-docs`). `DEMO_SEED_ON_BOOT=1` boot flag in `src/index.ts`.
434- `src/lib/import-helper.ts` — small helpers for the GitHub import flow (`src/routes/import.tsx`).
435- `src/lib/import-verify.ts` — post-migration smoke verifier (object count, branches, default-branch HEAD). Re-runnable from `src/routes/migrations.tsx`.
436- `src/lib/namespace.ts` — namespace resolution for Block B2 (user vs org slug → owner record).
437- `src/lib/action-registry.ts` — built-in action registry for workflow engine v2 (Sprint 1). Backs `src/lib/actions/{cache,checkout,download-artifact,gatetest,upload-artifact}-action.ts`.
438- `src/lib/workflow-conditionals.ts` — `if:` expression evaluator for workflow steps.
439- `src/lib/workflow-matrix.ts` — matrix expansion for workflow jobs.
440- `src/lib/workflow-artifacts.ts` — artifact persistence helpers backing `workflow_run_cache`-style storage.
441- `src/lib/workflow-secrets.ts` + `src/lib/workflow-secrets-crypto.ts` — AES-256-GCM secret storage. Crypto lib stays separate from DB lib so the DB layer holds opaque bytes only.
390442
391443### 4.6 Routes (locked endpoints — behaviour must be preserved)
392444- `src/routes/git.ts` — Smart HTTP (clone/push)
@@ -473,6 +525,26 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
473525- `src/lib/close-keywords.ts` (Block J7) — pure parser. Exports `extractClosingRefs(text)` + `extractClosingRefsMulti(sources[])`. Verbs: close/closes/closed/fix/fixes/fixed/resolve/resolves/resolved. Case-insensitive, word-boundary-respecting, ignores cross-repo `owner/repo#N` refs, rejects non-positive numbers, returns sorted de-duped list. Tolerates `:` / `-` / whitespace between verb and ref.
474526- `src/lib/commit-statuses.ts` (Block J8) — pure helpers: `isValidSha`, `isValidState`, `sanitiseContext`, `reduceCombined`. DB helpers: `setStatus` (delete-then-insert upsert on `(repo, sha, context)`, SHA lowercased, description/url clamped to 1000/2048 chars), `listStatuses` (newest first), `combinedStatus` (latest per context, reduces to worst state, returns counts + context pills). `STATUS_STATES` exported array. Never throws on clamping; null/empty description returns null.
475527- `src/routes/commit-statuses.ts` (Block J8) — `POST /api/v1/repos/:owner/:repo/statuses/:sha` (requireAuth — accepts session/OAuth/PAT; owner-only), `GET /api/v1/repos/:owner/:repo/commits/:sha/statuses` (softAuth, private-repo visibility enforced), `GET /api/v1/repos/:owner/:repo/commits/:sha/status` (combined rollup, same visibility rules).
528- `src/routes/api-v2.ts` — Comprehensive REST API v2 (full CRUD across resources). Authoritative integration surface alongside the v1 endpoints in this section.
529- `src/routes/api-docs.tsx` — interactive in-app API documentation page.
530- `src/routes/collaborators.tsx` — repo collaborator add / list / remove. Owner-or-admin only. Audit-logged. (See §4.3.1.)
531- `src/routes/team-collaborators.tsx` — bulk team-to-repo collaborator grant. (See §4.3.1.)
532- `src/routes/invites.tsx` — invite acceptance flow. (See §4.3.1.)
533- `src/routes/events.ts` — `POST /events/...` inbound deploy-event receiver for Crontech (Signal Bus P1). Idempotent via `processed_events`.
534- `src/routes/live-events.ts` — `GET /live-events/:topic` SSE subscription endpoint backed by `src/lib/sse.ts`.
535- `src/routes/health-probe.ts` — extended health + metrics probes for load-balancer + observability sinks.
536- `src/routes/help.tsx` — public `/help` quickstart + API cheatsheet for owners migrating from GitHub.
537- `src/routes/import.tsx` — single-repo GitHub → Gluecron import (PAT-based clone-and-rehost).
538- `src/routes/import-bulk.tsx` — `/import/bulk` paste-a-token org-wide migration.
539- `src/routes/migrations.tsx` — `/migrations` per-user import history dashboard with re-run-verifier button.
540- `src/routes/onboarding.tsx` — guided first-five-minutes flow for new accounts.
541- `src/routes/platform-status.ts` — machine-readable platform status JSON.
542- `src/routes/seo.ts` — `/robots.txt` + `/sitemap.xml`.
543- `src/routes/signing-keys.tsx` — Block J3 UI: `GET/POST /settings/signing-keys` + `POST /settings/signing-keys/:id/delete`. Audit-logged. (Library is `src/lib/signatures.ts`, see §4.6 for the underlying lib.)
544- `src/routes/specs.tsx` — Spec-to-PR UI. Paste a plain-English feature spec, get back a draft PR. Calls `src/lib/spec-to-pr.ts`.
545- `src/routes/status.tsx` — public `GET /status` HTML dashboard + `/status.svg` shields-style badge.
546- `src/routes/workflow-artifacts.ts` — REST API for workflow run artifacts (workflow engine v2 / Sprint 1).
547- `src/routes/workflow-secrets.tsx` — per-repo workflow secrets settings UI (list / create / delete encrypted secrets). Backed by `workflow_secrets` table from `drizzle/0037`.
476548
477549### 4.7 Views (locked contracts)
478550- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -507,7 +579,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
507579```bash
508580bun install
509581bun dev # hot reload
510bun test # 601 tests currently pass
582bun test # 1033 tests currently pass (8 skipped, 0 failed) as of 2026-04-29 — run `bun install` first
511583bun run db:migrate
512584```
513585
@@ -556,8 +628,41 @@ If a block is too large for a single session, split it into a sub-plan at the to
556628
557629(Intentionally empty. Add here if a block is partially complete at session end.)
558630
559**Polish batch shipped this session:**
631**2026-04-29 reconciliation pass (branch `claude/platform-launch-assessment-8dWV8`):**
632
633The Bible had drifted behind reality. This session reconciled §2 / §4 / §5 against the actual codebase. All edits below are strictly additive — no locked entry was deleted or renamed.
634
635What was added:
636- §2.4 — surfaced previously undocumented AI features now on disk: Spec-to-PR (real AI), repository intelligence engine, time-travel code explorer, dependency impact analyzer, one-click rollback. Clarified that `auto-repair.ts` (gate-time) and `autorepair.ts` (intelligence-time) are two distinct load-bearing modules — do not dedupe.
637- §2.5 — surfaced repository collaborators (per-repo roles), single-repo GitHub import, bulk org import, migration history + verifier, onboarding flow, public `/help` quickstart.
638- §2.6 — surfaced public status page (`/status` + `/status.svg`), platform-status JSON, error-tracking wiring (`src/lib/observability.ts`), comprehensive REST API v2 (`src/routes/api-v2.ts`), API docs page, SSE pub/sub (`src/lib/sse.ts` + `src/routes/live-events.ts`), inbound deploy event receiver (`src/routes/events.ts`), autopilot ticker, demo seed, SEO routes.
639- §4.1 — added migrations 0034–0037 to LOCKED.
640- §4.3.1 (new sub-section) — permissions + collaborators surface (`src/middleware/repo-access.ts`, `src/lib/invite-tokens.ts`, three new collaborator routes).
641- §4.4 — added intelligence / time-travel / depimpact / rollback / spec-to-PR libs; clarified the `auto-repair` vs `autorepair` distinction; flagged `pr-triage.ts` as a placeholder distinct from the live D3 path.
642- §4.5 — added observability, SSE, autopilot, demo-seed, import helpers, namespace, action-registry, and the workflow-engine-v2 family (conditionals / matrix / artifacts / secrets / secrets-crypto).
643- §4.6 — added 21 new routes: api-v2, api-docs, collaborators, team-collaborators, invites, events, live-events, health-probe, help, import, import-bulk, migrations, onboarding, platform-status, seo, signing-keys, specs, status, workflow-artifacts, workflow-secrets. Plus a note pointing signing-keys.tsx at the existing `src/lib/signatures.ts`.
644- §5.1 — bumped the test-count claim from `601` to the actual current number `1033 pass / 8 skip / 0 fail` (verified with deps installed on this branch).
645
646What was verified, not changed:
647- `bun install && bun test` → 1033 pass, 0 fail. Suite is genuinely green.
648- `auto-repair.ts` (472 lines, used by `src/lib/gate.ts`) and `autorepair.ts` (328 lines, used by `src/hooks/post-receive.ts`) are not duplicates — they expose disjoint APIs (`repairSecrets` / `repairSecurityIssues` vs `autoRepair`) and are both in active use.
649- Workflow engine v2 is strictly additive on top of the locked v1 (`src/lib/workflow-runner.ts`); no v1 file was replaced.
650
651**Polish batch shipped earlier:**
560652- `src/lib/autopilot.ts` + tests — 5-min ticker (mirror sync, merge-queue peek, weekly digests, advisory rescans). `AUTOPILOT_DISABLED=1` opt-out. Observability via `getLastTick()` / `getTickCount()`. Admin page at `/admin/autopilot` with "Run tick now" button.
561653- `src/views/landing.tsx` — marketing landing for logged-out `/`. Accepts optional `stats` prop; `src/routes/web.tsx` queries public repo + user counts.
562654- `src/lib/demo-seed.ts` + tests — idempotent `ensureDemoContent()` seeds `demo` user + 3 public sample repos + seeded issues/PR. Boot flag `DEMO_SEED_ON_BOOT=1`. Site-admin reseed button on `/admin` (`POST /admin/demo/reseed`). Public `/demo` convenience redirect to `/demo/hello-python`.
563655- Doc sync: `README.md`, `DEPLOY.md`, `LAUNCH_TODAY.md` aligned with current reality.
656
657**BLK-016 Crontech-Gluecron deploy wire — Gluecron sender rewritten (pending live verification):**
658- `src/hooks/post-receive.ts triggerCrontechDeploy` now matches the Crontech receiver at `apps/api/src/webhooks/gluecron-push.ts`:
659 - Endpoint default `POST https://crontech.ai/api/webhooks/gluecron-push` (was `/api/hooks/gluecron/push`).
660 - Auth model: HMAC-SHA256 of the JSON body in `X-Gluecron-Signature: sha256=<hex>`, signed with `GLUECRON_WEBHOOK_SECRET` (no longer Bearer).
661 - Payload shape is GitHub-style: `{event, repository:{full_name}, ref, after, before, pusher:{name,email}, commits:[{id,message,timestamp}]}` plus ancillary `sent_at`/`source`. Receiver dedupes on `after`.
662 - At-least-once delivery: 6 attempts (initial + 5 backoffs at 1s/4s/16s/64s/256s). Stops on first 2xx; bails immediately on unrecoverable 4xx (except 408/429); retries on 5xx + network errors.
663 - `commits[]` + pusher derived via `commitsBetween(owner, repo, before, after)` from the bare repo (capped at 50, like GitHub). Empty when the git layer can't read the repo.
664- `onPostReceive` no longer hardcodes `refs/heads/main` — gated by `${owner}/${repo} === config.crontechRepo` (env `CRONTECH_REPO`, default `ccantynz-alt/crontech`) and the repo's actual default branch via `getDefaultBranch`. Handles `Main` (capital M) without a code change.
665- Config additions: `config.crontechRepo` (env `CRONTECH_REPO`); `config.crontechDeployUrl` default flipped; both reflected in `.env.example`.
666- Tests: `src/__tests__/crontech-deploy.test.ts` rewritten — 19 tests covering endpoint, HMAC signature, payload shape, branch-case carry-through (`Main`), retry-on-5xx, give-up-after-N, no-retry-on-4xx, retry-on-408/429, retry-on-network-error, default backoff schedule. `triggerCrontechDeploy` now takes a single `args` object and an optional `opts: {fetchImpl, sleep, retryDelaysMs, now}` for injectability.
667- Removed dead `fanoutWebhooks` helper (defined-but-uncalled, per audit).
668- **Live verification (the BLK-016 done-criterion) is out of scope for this session** — it requires SSH to the Vultr box, a real test push, and confirming the deploy-agent log + GitHub Actions silence. Do not flip `BLK-016 → ✅ SHIPPED` in the Crontech BUILD_BIBLE without owner authorisation per Crontech CLAUDE.md §0.7.
Modifiedsrc/__tests__/crontech-deploy.test.ts+233−90View fileUnifiedSplit
@@ -1,89 +1,145 @@
11/**
2 * Crontech deploy-webhook sender — Finding 1.
2 * BLK-016 — Crontech deploy webhook sender.
33 *
4 * Asserts that the outbound `triggerCrontechDeploy` call sent from
5 * `src/hooks/post-receive.ts` matches the wire contract documented at the
6 * top of that helper:
4 * Asserts that `triggerCrontechDeploy` (in `src/hooks/post-receive.ts`)
5 * matches the wire contract documented at the top of that helper, which
6 * is the inbound contract for Crontech's
7 * `apps/api/src/webhooks/gluecron-push.ts` receiver:
78 *
8 * POST https://crontech.ai/api/hooks/gluecron/push
9 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
9 * POST https://crontech.ai/api/webhooks/gluecron-push
1010 * Content-Type: application/json
11 * body = { repository, sha, branch, ref, source, timestamp }
11 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, secret))>
12 *
13 * body = {
14 * event: "push",
15 * repository: { full_name },
16 * ref, after, before,
17 * pusher: { name, email },
18 * commits: [...]
19 * }
20 *
21 * Plus at-least-once delivery: 5 attempts on 5xx with exponential backoff,
22 * stop on first 2xx or unrecoverable 4xx.
1223 *
1324 * The helper swallows DB errors, so these tests work without a real DB.
1425 */
1526
1627import { afterEach, beforeEach, describe, expect, it } from "bun:test";
28import { createHmac } from "crypto";
1729import { __test } from "../hooks/post-receive";
1830
19const { triggerCrontechDeploy } = __test;
31const { triggerCrontechDeploy, signBody } = __test;
2032
2133interface CapturedCall {
2234 url: string;
2335 init: RequestInit;
2436}
2537
26const origFetch = globalThis.fetch;
2738const origSecret = process.env.GLUECRON_WEBHOOK_SECRET;
2839const origUrl = process.env.CRONTECH_DEPLOY_URL;
40const origRepo = process.env.CRONTECH_REPO;
41
42const NULL_REPO_ID = "00000000-0000-0000-0000-000000000000";
43const ZERO_SHA = "0000000000000000000000000000000000000000";
44
45function makeArgs(overrides: Partial<{
46 owner: string;
47 repo: string;
48 before: string;
49 after: string;
50 ref: string;
51 branch: string;
52 repositoryId: string;
53}> = {}) {
54 return {
55 owner: "ccantynz-alt",
56 repo: "crontech",
57 before: ZERO_SHA,
58 after: "a".repeat(40),
59 ref: "refs/heads/Main",
60 branch: "Main",
61 repositoryId: NULL_REPO_ID,
62 ...overrides,
63 };
64}
2965
30function installFetchCapture(
31 respond: () => Response = () =>
66function captureFetch(
67 responder: (callIdx: number) => Response | Promise<Response> = () =>
3268 new Response(
33 JSON.stringify({ ok: true, deploymentId: "d1", status: "queued" }),
69 JSON.stringify({ ok: true, deploymentId: "d1" }),
3470 { status: 200, headers: { "Content-Type": "application/json" } }
3571 )
36): CapturedCall[] {
72): { calls: CapturedCall[]; fn: typeof fetch } {
3773 const calls: CapturedCall[] = [];
38 // @ts-expect-error — override global fetch for test
39 globalThis.fetch = async (
74 const fn = (async (
4075 input: RequestInfo | URL,
4176 init: RequestInit = {}
4277 ): Promise<Response> => {
78 const i = calls.length;
4379 calls.push({ url: String(input), init });
44 return respond();
45 };
46 return calls;
80 return responder(i);
81 }) as unknown as typeof fetch;
82 return { calls, fn };
4783}
4884
49function restoreFetch(): void {
50 globalThis.fetch = origFetch;
51}
85const noSleep = async (_ms: number) => {};
86
87describe("hooks/post-receive — signBody", () => {
88 it("returns null when no secret", () => {
89 expect(signBody("any body", "")).toBeNull();
90 });
91
92 it("produces sha256=<hex hmac>", () => {
93 const body = '{"event":"push"}';
94 const secret = "topsecret";
95 const expected =
96 "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
97 expect(signBody(body, secret)).toBe(expected);
98 });
99
100 it("is deterministic for the same input", () => {
101 const a = signBody("body", "k");
102 const b = signBody("body", "k");
103 expect(a).toBe(b);
104 });
105
106 it("changes when the body changes", () => {
107 const a = signBody("body1", "k");
108 const b = signBody("body2", "k");
109 expect(a).not.toBe(b);
110 });
111});
52112
53describe("hooks/post-receive — triggerCrontechDeploy (Finding 1 sender)", () => {
113describe("hooks/post-receive — triggerCrontechDeploy (BLK-016 sender)", () => {
54114 beforeEach(() => {
55 // Unset overrides so each test owns its env state.
56115 delete process.env.GLUECRON_WEBHOOK_SECRET;
57116 delete process.env.CRONTECH_DEPLOY_URL;
117 delete process.env.CRONTECH_REPO;
58118 });
59119
60120 afterEach(() => {
61 restoreFetch();
62121 if (origSecret === undefined) delete process.env.GLUECRON_WEBHOOK_SECRET;
63122 else process.env.GLUECRON_WEBHOOK_SECRET = origSecret;
64123 if (origUrl === undefined) delete process.env.CRONTECH_DEPLOY_URL;
65124 else process.env.CRONTECH_DEPLOY_URL = origUrl;
125 if (origRepo === undefined) delete process.env.CRONTECH_REPO;
126 else process.env.CRONTECH_REPO = origRepo;
66127 });
67128
68129 it("is exported from __test", () => {
69130 expect(typeof triggerCrontechDeploy).toBe("function");
70131 });
71132
72 it("POSTs to the new Crontech hooks endpoint (not the old tRPC URL)", async () => {
73 const calls = installFetchCapture();
133 it("POSTs to /api/webhooks/gluecron-push (matches Crontech receiver path)", async () => {
134 const { calls, fn } = captureFetch();
74135
75 await triggerCrontechDeploy(
76 "alice",
77 "widgets",
78 "a".repeat(40),
79 "00000000-0000-0000-0000-000000000000"
80 );
136 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
81137
82138 expect(calls.length).toBe(1);
83139 expect(calls[0]!.url).toBe(
84 "https://crontech.ai/api/hooks/gluecron/push"
140 "https://crontech.ai/api/webhooks/gluecron-push"
85141 );
86 expect(calls[0]!.url).not.toContain("/api/trpc/tenant.deploy");
142 expect(calls[0]!.url).not.toContain("/api/hooks/gluecron/push");
87143 expect(calls[0]!.init.method).toBe("POST");
88144 });
89145
@@ -92,86 +148,173 @@ describe("hooks/post-receive — triggerCrontechDeploy (Finding 1 sender)", () =
92148 const calls = installFetchCapture();
93149
94150 await triggerCrontechDeploy(
95 "alice",
96 "widgets",
97 "b".repeat(40),
98 "00000000-0000-0000-0000-000000000000"
151 makeArgs({
152 owner: "acme",
153 repo: "api",
154 after,
155 before,
156 ref: "refs/heads/Main",
157 branch: "Main",
158 }),
159 { fetchImpl: fn, sleep: noSleep }
99160 );
100161
101 expect(calls.length).toBe(1);
162 const body = JSON.parse(String(calls[0]!.init.body));
163 expect(body.event).toBe("push");
164 expect(body.repository).toEqual({ full_name: "acme/api" });
165 expect(body.ref).toBe("refs/heads/Main");
166 expect(body.after).toBe(after);
167 expect(body.before).toBe(before);
168 expect(body.pusher).toBeDefined();
169 expect(typeof body.pusher.name).toBe("string");
170 expect(typeof body.pusher.email).toBe("string");
171 expect(Array.isArray(body.commits)).toBe(true);
172 expect(typeof body.sent_at).toBe("string");
173 expect(new Date(body.sent_at).toString()).not.toBe("Invalid Date");
174 expect(body.source).toBe("gluecron");
175 });
176
177 it("signs the body with HMAC-SHA256 in X-Gluecron-Signature when secret is set", async () => {
178 process.env.GLUECRON_WEBHOOK_SECRET = "shared-vultr-secret";
179 const { calls, fn } = captureFetch();
180
181 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
182
102183 const headers = calls[0]!.init.headers as Record<string, string>;
103 expect(headers["Authorization"]).toBe("Bearer s3cret-token");
184 const sentBody = String(calls[0]!.init.body);
185 const expected =
186 "sha256=" +
187 createHmac("sha256", "shared-vultr-secret")
188 .update(sentBody)
189 .digest("hex");
190 expect(headers["X-Gluecron-Signature"]).toBe(expected);
104191 expect(headers["Content-Type"]).toBe("application/json");
105192 });
106193
107 it("omits the Authorization header when no secret is configured", async () => {
108 // GLUECRON_WEBHOOK_SECRET deliberately unset by beforeEach.
109 const calls = installFetchCapture();
194 it("omits X-Gluecron-Signature when no secret is configured", async () => {
195 const { calls, fn } = captureFetch();
110196
111 await triggerCrontechDeploy(
112 "alice",
113 "widgets",
114 "c".repeat(40),
115 "00000000-0000-0000-0000-000000000000"
116 );
197 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
117198
118 expect(calls.length).toBe(1);
119199 const headers = calls[0]!.init.headers as Record<string, string>;
120 expect(headers["Authorization"]).toBeUndefined();
121 expect(headers["Content-Type"]).toBe("application/json");
200 expect(headers["X-Gluecron-Signature"]).toBeUndefined();
122201 });
123202
124 it("sends the wire-contract body shape (repository, sha, branch, ref, source, timestamp)", async () => {
125 const calls = installFetchCapture();
126 const sha = "d".repeat(40);
203 it("attaches X-Gluecron-Event=push and a non-empty X-Gluecron-Delivery id", async () => {
204 const { calls, fn } = captureFetch();
205
206 await triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep });
207
208 const headers = calls[0]!.init.headers as Record<string, string>;
209 expect(headers["X-Gluecron-Event"]).toBe("push");
210 expect(headers["X-Gluecron-Delivery"]).toBeDefined();
211 expect(headers["X-Gluecron-Delivery"]!.length).toBeGreaterThan(0);
212 });
213
214 it("ref carries the actual case of the branch (Main, not main)", async () => {
215 const { calls, fn } = captureFetch();
127216
128217 await triggerCrontechDeploy(
129 "acme",
130 "api",
131 sha,
132 "00000000-0000-0000-0000-000000000000"
218 makeArgs({ ref: "refs/heads/Main", branch: "Main" }),
219 { fetchImpl: fn, sleep: noSleep }
133220 );
134221
135 expect(calls.length).toBe(1);
136222 const body = JSON.parse(String(calls[0]!.init.body));
137 expect(body.repository).toBe("acme/api");
138 expect(body.sha).toBe(sha);
139 expect(body.branch).toBe("main");
140 expect(body.ref).toBe("refs/heads/main");
141 expect(body.source).toBe("gluecron");
142 expect(typeof body.timestamp).toBe("string");
143 // ISO-8601 sanity check
144 expect(new Date(body.timestamp).toString()).not.toBe("Invalid Date");
223 expect(body.ref).toBe("refs/heads/Main");
224 expect(body.ref).not.toBe("refs/heads/main");
145225 });
146226
147 it("respects CRONTECH_DEPLOY_URL override", async () => {
148 process.env.CRONTECH_DEPLOY_URL =
149 "https://staging.crontech.ai/api/hooks/gluecron/push";
150 const calls = installFetchCapture();
227 it("retries on 5xx with provided backoff schedule, stops on first 2xx", async () => {
228 const responses = [
229 new Response("", { status: 502 }),
230 new Response("", { status: 503 }),
231 new Response("", { status: 200 }),
232 ];
233 const { calls, fn } = captureFetch((i) => responses[i]!);
234 const sleeps: number[] = [];
151235
152 await triggerCrontechDeploy(
153 "alice",
154 "widgets",
155 "e".repeat(40),
156 "00000000-0000-0000-0000-000000000000"
157 );
236 await triggerCrontechDeploy(makeArgs(), {
237 fetchImpl: fn,
238 sleep: async (ms) => { sleeps.push(ms); },
239 retryDelaysMs: [10, 20, 30, 40, 50],
240 });
241
242 expect(calls.length).toBe(3);
243 // Two waits — between attempt 1→2 and 2→3. None after the successful 3rd.
244 expect(sleeps).toEqual([10, 20]);
245 });
246
247 it("gives up after the configured number of attempts on persistent 5xx", async () => {
248 const { calls, fn } = captureFetch(() => new Response("", { status: 500 }));
249 const sleeps: number[] = [];
250
251 await triggerCrontechDeploy(makeArgs(), {
252 fetchImpl: fn,
253 sleep: async (ms) => { sleeps.push(ms); },
254 retryDelaysMs: [1, 2, 3, 4, 5],
255 });
256
257 // 5 delays + 1 initial = 6 total attempts (consistent with at-least-once).
258 expect(calls.length).toBe(6);
259 expect(sleeps).toEqual([1, 2, 3, 4, 5]);
260 });
261
262 it("does not retry on unrecoverable 4xx (e.g. 401 invalid signature)", async () => {
263 const { calls, fn } = captureFetch(() => new Response("", { status: 401 }));
264 const sleeps: number[] = [];
265
266 await triggerCrontechDeploy(makeArgs(), {
267 fetchImpl: fn,
268 sleep: async (ms) => { sleeps.push(ms); },
269 retryDelaysMs: [1, 2, 3, 4, 5],
270 });
158271
159272 expect(calls.length).toBe(1);
160 expect(calls[0]!.url).toBe(
161 "https://staging.crontech.ai/api/hooks/gluecron/push"
162 );
273 expect(sleeps).toEqual([]);
274 });
275
276 it("does retry 408 (request timeout) and 429 (rate limit)", async () => {
277 const responses = [
278 new Response("", { status: 429 }),
279 new Response("", { status: 408 }),
280 new Response("", { status: 200 }),
281 ];
282 const { calls, fn } = captureFetch((i) => responses[i]!);
283
284 await triggerCrontechDeploy(makeArgs(), {
285 fetchImpl: fn,
286 sleep: noSleep,
287 retryDelaysMs: [1, 2, 3, 4, 5],
288 });
289
290 expect(calls.length).toBe(3);
291 });
292
293 it("retries on network errors (fetch throws)", async () => {
294 let callCount = 0;
295 const fn = (async () => {
296 callCount++;
297 if (callCount < 3) throw new Error("ECONNREFUSED");
298 return new Response("", { status: 200 });
299 }) as unknown as typeof fetch;
300
301 await triggerCrontechDeploy(makeArgs(), {
302 fetchImpl: fn,
303 sleep: noSleep,
304 retryDelaysMs: [1, 2, 3, 4, 5],
305 });
306
307 expect(callCount).toBe(3);
163308 });
164309
165 it("does not throw when Crontech responds 401 (unset secret path)", async () => {
166 const calls = installFetchCapture(() => new Response("", { status: 401 }));
310 it("does not throw when receiver responds 401 (unconfigured-secret path)", async () => {
311 const { fn } = captureFetch(() => new Response("", { status: 401 }));
167312 await expect(
168 triggerCrontechDeploy(
169 "alice",
170 "widgets",
171 "f".repeat(40),
172 "00000000-0000-0000-0000-000000000000"
173 )
313 triggerCrontechDeploy(makeArgs(), { fetchImpl: fn, sleep: noSleep })
174314 ).resolves.toBeUndefined();
175 expect(calls.length).toBe(1);
315 });
316
317 it("uses a default exponential-backoff schedule of 1s/4s/16s/64s/256s", () => {
318 expect(__test.RETRY_DELAYS_MS).toEqual([1_000, 4_000, 16_000, 64_000, 256_000]);
176319 });
177320});
Modifiedsrc/__tests__/specs.test.ts+82−0View fileUnifiedSplit
@@ -101,4 +101,86 @@ describe("routes/specs — auth guard on GET /:owner/:repo/spec", () => {
101101 });
102102 expect(res.status).toBeLessThan(500);
103103 });
104
105 it("GET /spec?fromIssue=N is not a 500 even when the issue/repo doesn't exist", async () => {
106 const loaded = await tryLoadSpecsRoute();
107 if (!loaded.ok) {
108 expect(loaded.reason).toBe("jsx-dev-runtime");
109 return;
110 }
111 const res = await loaded.mod.default.request(
112 "/alice/demo/spec?fromIssue=123",
113 { redirect: "manual" }
114 );
115 expect(res.status).toBeLessThan(500);
116 });
117
118 it("GET /spec?fromIssue=garbage is not a 500", async () => {
119 const loaded = await tryLoadSpecsRoute();
120 if (!loaded.ok) {
121 expect(loaded.reason).toBe("jsx-dev-runtime");
122 return;
123 }
124 const res = await loaded.mod.default.request(
125 "/alice/demo/spec?fromIssue=not-a-number",
126 { redirect: "manual" }
127 );
128 expect(res.status).toBeLessThan(500);
129 });
130});
131
132describe("routes/specs — buildSpecFromIssue pure helper", () => {
133 it("emits Implement: <title>, body, then Closes #N", async () => {
134 const loaded = await tryLoadSpecsRoute();
135 if (!loaded.ok) {
136 expect(loaded.reason).toBe("jsx-dev-runtime");
137 return;
138 }
139 const fn = loaded.mod.buildSpecFromIssue;
140 expect(typeof fn).toBe("function");
141 const out = fn({
142 number: 42,
143 title: "Add dark mode toggle",
144 body: "It should sit in the navbar and persist via cookie.",
145 });
146 expect(out).toContain("Implement: Add dark mode toggle");
147 expect(out).toContain("It should sit in the navbar and persist via cookie.");
148 expect(out).toContain("Closes #42");
149 // Closes line should be last so close-keywords (J7) parses cleanly.
150 expect(out.trimEnd().endsWith("Closes #42")).toBe(true);
151 });
152
153 it("handles a missing/empty body — still emits the Closes line", async () => {
154 const loaded = await tryLoadSpecsRoute();
155 if (!loaded.ok) {
156 expect(loaded.reason).toBe("jsx-dev-runtime");
157 return;
158 }
159 const fn = loaded.mod.buildSpecFromIssue;
160 const a = fn({ number: 7, title: "Bug: race in upload handler", body: null });
161 const b = fn({ number: 7, title: "Bug: race in upload handler", body: "" });
162 const c = fn({ number: 7, title: "Bug: race in upload handler", body: " " });
163 for (const out of [a, b, c]) {
164 expect(out).toContain("Implement: Bug: race in upload handler");
165 expect(out).toContain("Closes #7");
166 }
167 });
168
169 it("trims surrounding whitespace from the title and body", async () => {
170 const loaded = await tryLoadSpecsRoute();
171 if (!loaded.ok) {
172 expect(loaded.reason).toBe("jsx-dev-runtime");
173 return;
174 }
175 const fn = loaded.mod.buildSpecFromIssue;
176 const out = fn({
177 number: 1,
178 title: " leading + trailing ",
179 body: " body text ",
180 });
181 expect(out).toContain("Implement: leading + trailing");
182 expect(out).toContain("body text");
183 // No leading whitespace on the title line.
184 expect(out.startsWith("Implement: leading + trailing")).toBe(true);
185 });
104186});
Modifiedsrc/hooks/post-receive.ts+203−120View fileUnifiedSplit
@@ -10,6 +10,7 @@
1010 * 6. Webhooks — fire registered webhook URLs
1111 */
1212
13import { createHmac } from "crypto";
1314import { and, eq } from "drizzle-orm";
1415import { config } from "../lib/config";
1516import { autoRepair } from "../lib/autorepair";
@@ -17,6 +18,7 @@ import { analyzePush, computeHealthScore } from "../lib/intelligence";
1718import { db } from "../db";
1819import { deployments, repositories, users } from "../db/schema";
1920import { onDeployFailure } from "../lib/ai-incident";
21import { commitsBetween, getDefaultBranch } from "../git/repository";
2022
2123interface PushRef {
2224 oldSha: string;
@@ -78,73 +80,156 @@ export async function onPostReceive(
7880 // 4. GateTest scan — fire-and-forget via generic webhook; the standalone
7981 // triggerGateTest helper is slated for the intelligence rework.
8082
81 // 5. Crontech deploy on push to main
82 const mainPush = refs.find(
83 (r) => r.refName === "refs/heads/main" && !r.newSha.startsWith("0000")
84 );
85 if (mainPush) {
86 let repositoryId = "";
87 try {
88 const [row] = await db
89 .select({ id: repositories.id })
90 .from(repositories)
91 .innerJoin(users, eq(repositories.ownerId, users.id))
92 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
93 .limit(1);
94 repositoryId = row?.id || "";
95 } catch {
96 /* ignore */
97 }
98 if (repositoryId) {
99 triggerCrontechDeploy(owner, repo, mainPush.newSha, repositoryId).catch(
100 (err: unknown) => console.error(`[crontech] error:`, err),
101 );
83 // 5. Crontech deploy (BLK-016) — only fires for the configured Crontech repo
84 // (CRONTECH_REPO, default `ccantynz-alt/crontech`) on a push to its
85 // default branch. The branch case (`Main` vs `main`) is determined by
86 // the bare repo's HEAD, not hardcoded.
87 if (`${owner}/${repo}` === config.crontechRepo) {
88 let defaultBranch = (await getDefaultBranch(owner, repo).catch(() => null)) || "main";
89 const targetRef = `refs/heads/${defaultBranch}`;
90 const deployPush = refs.find(
91 (r) => r.refName === targetRef && !r.newSha.startsWith("0000")
92 );
93 if (deployPush) {
94 let repositoryId = "";
95 try {
96 const [row] = await db
97 .select({ id: repositories.id })
98 .from(repositories)
99 .innerJoin(users, eq(repositories.ownerId, users.id))
100 .where(and(eq(users.username, owner), eq(repositories.name, repo)))
101 .limit(1);
102 repositoryId = row?.id || "";
103 } catch {
104 /* ignore */
105 }
106 if (repositoryId) {
107 triggerCrontechDeploy({
108 owner,
109 repo,
110 before: deployPush.oldSha,
111 after: deployPush.newSha,
112 ref: targetRef,
113 branch: defaultBranch,
114 repositoryId,
115 }).catch((err: unknown) => console.error(`[crontech] error:`, err));
116 }
102117 }
103118 }
104119}
105120
106121/**
107 * Trigger Crontech auto-deploy via the outbound webhook.
122 * BLK-016 — outbound deploy webhook for Crontech's deploy-agent.
108123 *
109 * Wire contract (Gluecron's copy — do not import from Crontech):
124 * Wire contract (matches Crontech's `apps/api/src/webhooks/gluecron-push.ts`):
110125 *
111 * POST https://crontech.ai/api/hooks/gluecron/push
112 * Authorization: Bearer ${GLUECRON_WEBHOOK_SECRET}
126 * POST https://crontech.ai/api/webhooks/gluecron-push
113127 * Content-Type: application/json
128 * X-Gluecron-Signature: sha256=<hex(hmac-sha256(body, GLUECRON_WEBHOOK_SECRET))>
114129 *
115130 * {
116 * "repository": "owner/name",
117 * "sha": "<40-hex>",
118 * "branch": "main",
119 * "ref": "refs/heads/main",
120 * "source": "gluecron",
121 * "timestamp": "<ISO-8601>"
131 * "event": "push",
132 * "repository": { "full_name": "ccantynz-alt/crontech" },
133 * "ref": "refs/heads/Main",
134 * "after": "<40-hex commit SHA>",
135 * "before": "<40-hex previous SHA>",
136 * "pusher": { "name": "<author>", "email": "<email>" },
137 * "commits": [ { "id": "<sha>", "message": "<msg>", "timestamp": "<iso8601>" } ]
122138 * }
123139 *
124 * → 200 { ok: true, deploymentId, status: "queued" | "skipped" }
125 * → 401 invalid bearer token
126 * → 400 malformed payload
127 * → 404 repository not configured for auto-deploy on Crontech
140 * The `after` SHA is the dedupe key on the receiver side (idempotent).
128141 *
129 * If `GLUECRON_WEBHOOK_SECRET` is unset we silently omit the Authorization
130 * header — Crontech will then respond 401, which we treat as a failed deploy
131 * row exactly like any other non-ok HTTP response.
142 * Delivery: at-least-once via exponential-backoff retry. Up to 5 attempts at
143 * delays 1s / 4s / 16s / 64s / 256s; first 2xx wins. If `GLUECRON_WEBHOOK_SECRET`
144 * is unset the signature header is omitted and Crontech is expected to reject —
145 * we still record the deploy row as failed.
132146 */
147const RETRY_DELAYS_MS = [1_000, 4_000, 16_000, 64_000, 256_000];
148
149interface TriggerArgs {
150 owner: string;
151 repo: string;
152 before: string;
153 after: string;
154 ref: string;
155 branch: string;
156 repositoryId: string;
157}
158
159interface TriggerOptions {
160 fetchImpl?: typeof fetch;
161 sleep?: (ms: number) => Promise<void>;
162 retryDelaysMs?: number[];
163 now?: () => Date;
164}
165
166function signBody(body: string, secret: string): string | null {
167 if (!secret) return null;
168 return "sha256=" + createHmac("sha256", secret).update(body).digest("hex");
169}
170
171async function buildPayload(args: TriggerArgs, now: Date): Promise<{
172 payload: Record<string, unknown>;
173 pusherName: string;
174 pusherEmail: string;
175}> {
176 // Walk commits new since the last push. Cap at 50 like GitHub's webhook.
177 // `before` may be all-zeros for a first push to the branch — commitsBetween
178 // handles that by treating null `from` as "everything reachable from `to`".
179 const fromSha = /^0+$/.test(args.before) ? null : args.before;
180 let commits: Array<{ id: string; message: string; timestamp: string }> = [];
181 let pusherName = "gluecron";
182 let pusherEmail = "noreply@gluecron.local";
183 try {
184 const list = await commitsBetween(args.owner, args.repo, fromSha, args.after);
185 commits = list.slice(0, 50).map((c) => ({
186 id: c.sha,
187 message: c.message,
188 timestamp: c.date,
189 }));
190 if (list[0]) {
191 pusherName = list[0].author || pusherName;
192 pusherEmail = list[0].authorEmail || pusherEmail;
193 }
194 } catch {
195 /* ignore — payload still valid with empty commits[] */
196 }
197 return {
198 payload: {
199 event: "push",
200 repository: { full_name: `${args.owner}/${args.repo}` },
201 ref: args.ref,
202 after: args.after,
203 before: args.before,
204 pusher: { name: pusherName, email: pusherEmail },
205 commits,
206 // Ancillary fields — receiver may ignore but they're useful for logs:
207 sent_at: now.toISOString(),
208 source: "gluecron",
209 },
210 pusherName,
211 pusherEmail,
212 };
213}
214
133215async function triggerCrontechDeploy(
134 owner: string,
135 repo: string,
136 sha: string,
137 repositoryId: string
216 args: TriggerArgs,
217 opts: TriggerOptions = {}
138218): Promise<void> {
219 const fetchImpl = opts.fetchImpl ?? fetch;
220 const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
221 const delays = opts.retryDelaysMs ?? RETRY_DELAYS_MS;
222 const now = opts.now ?? (() => new Date());
223
139224 let deployId = "";
140225 try {
141226 const [row] = await db
142227 .insert(deployments)
143228 .values({
144 repositoryId,
229 repositoryId: args.repositoryId,
145230 environment: "production",
146 commitSha: sha,
147 ref: "refs/heads/main",
231 commitSha: args.after,
232 ref: args.ref,
148233 status: "pending",
149234 target: "crontech",
150235 })
@@ -154,93 +239,91 @@ async function triggerCrontechDeploy(
154239 /* ignore */
155240 }
156241
157 try {
158 const headers: Record<string, string> = {
159 "Content-Type": "application/json",
160 };
161 if (config.gluecronWebhookSecret) {
162 headers["Authorization"] = `Bearer ${config.gluecronWebhookSecret}`;
163 }
164 const response = await fetch(config.crontechDeployUrl, {
165 method: "POST",
166 headers,
167 body: JSON.stringify({
168 repository: `${owner}/${repo}`,
169 sha,
170 branch: "main",
171 ref: "refs/heads/main",
172 source: "gluecron",
173 timestamp: new Date().toISOString(),
174 }),
175 });
176 console.log(
177 `[crontech] deploy triggered for ${owner}/${repo}@${sha.slice(0, 7)}: ${response.status}`
178 );
179 if (deployId) {
180 await db
181 .update(deployments)
182 .set({
183 status: response.ok ? "success" : "failed",
184 completedAt: new Date(),
185 })
186 .where(eq(deployments.id, deployId));
242 const { payload } = await buildPayload(args, now());
243 const body = JSON.stringify(payload);
244 const signature = signBody(body, config.gluecronWebhookSecret);
245
246 const headers: Record<string, string> = {
247 "Content-Type": "application/json",
248 "User-Agent": "gluecron-webhook/1",
249 "X-Gluecron-Event": "push",
250 "X-Gluecron-Delivery": cryptoRandomId(),
251 };
252 if (signature) headers["X-Gluecron-Signature"] = signature;
253
254 let lastStatus = 0;
255 let lastError = "";
256 let success = false;
257
258 // Up to delays.length + 1 attempts (initial try + each delay).
259 const totalAttempts = delays.length + 1;
260 for (let attempt = 0; attempt < totalAttempts; attempt++) {
261 try {
262 const response = await fetchImpl(config.crontechDeployUrl, {
263 method: "POST",
264 headers,
265 body,
266 });
267 lastStatus = response.status;
268 console.log(
269 `[crontech] attempt ${attempt + 1}/${totalAttempts} → ${lastStatus} for ${args.owner}/${args.repo}@${args.after.slice(0, 7)}`
270 );
271 if (response.ok) {
272 success = true;
273 break;
274 }
275 // 4xx (except 408/429) is unrecoverable — stop retrying.
276 if (response.status >= 400 && response.status < 500 &&
277 response.status !== 408 && response.status !== 429) {
278 break;
279 }
280 } catch (err) {
281 lastError = err instanceof Error ? err.message : String(err);
282 console.error(
283 `[crontech] attempt ${attempt + 1}/${totalAttempts} failed: ${lastError}`
284 );
187285 }
188 // D4: when Crontech returns a non-ok HTTP status, kick off the AI
189 // incident responder AFTER the deployment row is flipped to "failed".
190 if (!response.ok && deployId) {
191 void onDeployFailure({
192 repositoryId,
193 deploymentId: deployId,
194 ref: "refs/heads/main",
195 commitSha: sha,
196 target: "crontech",
197 errorMessage: `HTTP ${response.status}`,
198 }).catch((e) => console.error("[ai-incident]", e));
286 const nextDelay = delays[attempt];
287 if (nextDelay !== undefined && attempt < totalAttempts - 1) {
288 await sleep(nextDelay);
199289 }
200 } catch (err) {
201 console.error(`[crontech] failed to trigger deploy:`, err);
202 if (deployId) {
290 }
291
292 if (deployId) {
293 try {
203294 await db
204295 .update(deployments)
205296 .set({
206 status: "failed",
207 blockedReason: (err as Error).message,
297 status: success ? "success" : "failed",
298 blockedReason: success
299 ? null
300 : (lastError ? lastError : `HTTP ${lastStatus}`),
208301 completedAt: new Date(),
209302 })
210303 .where(eq(deployments.id, deployId));
211 // D4: fire-and-forget incident analysis AFTER marking the row failed.
212 void onDeployFailure({
213 repositoryId,
214 deploymentId: deployId,
215 ref: "refs/heads/main",
216 commitSha: sha,
217 target: "crontech",
218 errorMessage: (err as Error).message,
219 }).catch((e) => console.error("[ai-incident]", e));
304 } catch {
305 /* ignore */
220306 }
221307 }
222}
223308
224async function fanoutWebhooks(
225 repositoryId: string,
226 owner: string,
227 repo: string,
228 refs: PushRef[]
229): Promise<void> {
230 try {
231 const { fireWebhooks } = await import("../routes/webhooks");
232 await fireWebhooks(repositoryId, "push", {
233 repository: `${owner}/${repo}`,
234 refs: refs.map((r) => ({
235 ref: r.refName,
236 before: r.oldSha,
237 after: r.newSha,
238 })),
239 });
240 } catch {
241 // best-effort
309 if (!success && deployId) {
310 void onDeployFailure({
311 repositoryId: args.repositoryId,
312 deploymentId: deployId,
313 ref: args.ref,
314 commitSha: args.after,
315 target: "crontech",
316 errorMessage: lastError || `HTTP ${lastStatus}`,
317 }).catch((e) => console.error("[ai-incident]", e));
242318 }
243319}
244320
321function cryptoRandomId(): string {
322 // Short opaque delivery ID for log correlation. Not security-sensitive.
323 const bytes = new Uint8Array(8);
324 crypto.getRandomValues(bytes);
325 return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
326}
327
245328/** Test-only access to internal helpers. */
246export const __test = { triggerCrontechDeploy };
329export const __test = { triggerCrontechDeploy, signBody, buildPayload, RETRY_DELAYS_MS };
Modifiedsrc/lib/config.ts+14−4View fileUnifiedSplit
@@ -19,13 +19,23 @@ export const config = {
1919 get crontechDeployUrl() {
2020 return (
2121 process.env.CRONTECH_DEPLOY_URL ||
22 "https://crontech.ai/api/hooks/gluecron/push"
22 "https://crontech.ai/api/webhooks/gluecron-push"
2323 );
2424 },
2525 /**
26 * Bearer token sent on outbound deploy webhook to Crontech's
27 * `POST /api/hooks/gluecron/push` endpoint. Default empty → header is
28 * omitted and Crontech will reject with 401 (treated as a failed deploy).
26 * BLK-016 — only fire the Crontech deploy webhook for pushes to this
27 * `<owner>/<name>`. Every other repo's push is ignored. Override per
28 * environment via `CRONTECH_REPO`.
29 */
30 get crontechRepo() {
31 return process.env.CRONTECH_REPO || "ccantynz-alt/crontech";
32 },
33 /**
34 * Shared HMAC secret for the outbound deploy webhook to Crontech's
35 * `POST /api/webhooks/gluecron-push` endpoint. Used to compute the
36 * `X-Gluecron-Signature: sha256=<hex>` header on every fire. Default
37 * empty → header is omitted and Crontech will reject with 401 (treated
38 * as a failed deploy).
2939 */
3040 get gluecronWebhookSecret() {
3141 return process.env.GLUECRON_WEBHOOK_SECRET || "";
Modifiedsrc/routes/issues.tsx+10−0View fileUnifiedSplit
@@ -359,6 +359,16 @@ issueRoutes.get("/:owner/:repo/issues/:number", softAuth, requireRepoAccess("rea
359359 </strong>{" "}
360360 opened this issue {formatRelative(issue.createdAt)}
361361 </span>
362 {issue.state === "open" && user && user.id === resolved.owner.id && (
363 <a
364 href={`/${ownerName}/${repoName}/spec?fromIssue=${issue.number}`}
365 class="btn btn-primary"
366 style="margin-left:auto;font-size:13px;padding:4px 10px"
367 title="Generate a draft pull request from this issue using Claude"
368 >
369 Build with AI
370 </a>
371 )}
362372 </Flex>
363373
364374 {issue.body && (
Modifiedsrc/routes/specs.tsx+85−1View fileUnifiedSplit
@@ -15,7 +15,7 @@
1515import { Hono } from "hono";
1616import { and, eq } from "drizzle-orm";
1717import { db } from "../db";
18import { repositories, users } from "../db/schema";
18import { repositories, users, issues } from "../db/schema";
1919import { Layout } from "../views/layout";
2020import { RepoHeader } from "../views/components";
2121import { softAuth, requireAuth } from "../middleware/auth";
@@ -108,6 +108,38 @@ function hasWriteAccess(
108108 return !!userId && resolved.ownerId === userId;
109109}
110110
111/**
112 * Build the default spec text for an issue-driven generation. Format:
113 *
114 * Implement: <title>
115 *
116 * <body>
117 *
118 * Closes #<n>
119 *
120 * The trailing `Closes #N` is picked up by `src/lib/close-keywords.ts` (J7)
121 * so the issue auto-closes when the AI-generated PR is merged. Body is
122 * trimmed and falls back to an empty string if missing. Pure helper —
123 * exported for tests.
124 */
125export function buildSpecFromIssue(input: {
126 number: number;
127 title: string;
128 body: string | null | undefined;
129}): string {
130 const title = (input.title || "").trim();
131 const body = (input.body || "").trim();
132 const lines: string[] = [];
133 if (title) lines.push(`Implement: ${title}`);
134 if (body) {
135 lines.push("");
136 lines.push(body);
137 }
138 lines.push("");
139 lines.push(`Closes #${input.number}`);
140 return lines.join("\n");
141}
142
111143function SpecForm({
112144 ownerName,
113145 repoName,
@@ -116,6 +148,8 @@ function SpecForm({
116148 spec,
117149 baseRef,
118150 error,
151 fromIssueNumber,
152 fromIssueTitle,
119153}: {
120154 ownerName: string;
121155 repoName: string;
@@ -124,6 +158,8 @@ function SpecForm({
124158 spec?: string;
125159 baseRef?: string;
126160 error?: string;
161 fromIssueNumber?: number;
162 fromIssueTitle?: string;
127163}) {
128164 const branchList = branches.length > 0 ? branches : [defaultBranch];
129165 const selectedBase = baseRef && branchList.includes(baseRef)
@@ -141,6 +177,18 @@ function SpecForm({
141177 merging.
142178 </div>
143179
180 {fromIssueNumber && (
181 <Alert variant="info">
182 Building from issue{" "}
183 <a href={`/${ownerName}/${repoName}/issues/${fromIssueNumber}`}>
184 #{fromIssueNumber}
185 {fromIssueTitle ? ` — ${fromIssueTitle}` : ""}
186 </a>
187 . The spec below has been pre-filled from the issue and will
188 auto-close it on merge.
189 </Alert>
190 )}
191
144192 <h2 style="margin-bottom:4px">Spec to PR</h2>
145193 <Text muted style="display:block;margin-bottom:16px">
146194 Describe a feature in plain English. Claude will draft the code
@@ -259,6 +307,39 @@ specs.get("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
259307 branches = [];
260308 }
261309
310 // Optional: pre-fill from an issue. Triggered from the "Build with AI"
311 // button on the issue detail page. Silently no-ops on missing/unknown
312 // issue so the form still renders.
313 let prefilledSpec: string | undefined;
314 let fromIssueNumber: number | undefined;
315 let fromIssueTitle: string | undefined;
316 const fromIssueRaw = c.req.query("fromIssue");
317 if (fromIssueRaw) {
318 const n = Number.parseInt(fromIssueRaw, 10);
319 if (Number.isInteger(n) && n > 0) {
320 try {
321 const [issueRow] = await db
322 .select()
323 .from(issues)
324 .where(
325 and(eq(issues.repositoryId, resolved.repoId), eq(issues.number, n))
326 )
327 .limit(1);
328 if (issueRow) {
329 fromIssueNumber = issueRow.number;
330 fromIssueTitle = issueRow.title;
331 prefilledSpec = buildSpecFromIssue({
332 number: issueRow.number,
333 title: issueRow.title,
334 body: issueRow.body,
335 });
336 }
337 } catch {
338 // Pre-fill is a convenience, never block form render.
339 }
340 }
341 }
342
262343 return c.html(
263344 <Layout title={`Spec to PR — ${owner}/${repo}`} user={user}>
264345 <RepoHeader owner={owner} repo={repo} />
@@ -267,6 +348,9 @@ specs.get("/:owner/:repo/spec", softAuth, requireAuth, async (c) => {
267348 repoName={repo}
268349 branches={branches}
269350 defaultBranch={resolved.defaultBranch}
351 spec={prefilledSpec}
352 fromIssueNumber={fromIssueNumber}
353 fromIssueTitle={fromIssueTitle}
270354 />
271355 </Layout>
272356 );
273357