Skip to content

Go Engine Implementation Plan

Status: Draft Created: 2026-08-04 Replaces: index.mjs (JS entrypoint, ~1300 lines) + shunt-engine.mjs (JS engine, ~800 lines)

This document describes the plan to replace the JavaScript Cloudflare Worker (index.mjs + shunt-engine.mjs) with a native Go Workers compilation targeting WASM. The Go engine uses the shunt merge queue engine directly via mq.New(cfg, forge, stager).Reconcile(ctx).


┌─────────────────────────────────────────────────────────────────────┐
│ Go Cloudflare Worker │
│ │
│ main.go (HTTP handler) ─────────────────────────────────┐ │
│ │ │
│ Routes: │ │
│ POST /api/v1/webhooks/forgejo → webhook handler │ │
│ GET/POST /api/v1/tenants/* → API handlers (auth) │ │
│ GET/POST /auth/forgejo → OAuth redirect │ │
│ GET/GET /auth/callback → OAuth callback │ │
│ POST /api/v1/stripe/webhook → Stripe webhook │ │
│ ─────────────────────────────────────────────────── │ │
│ Durable Object: │ │
│ LeaseDO (unchanged — still serves per-tenant lease) │ │
│ ─────────────────────────────────────────────────── │ │
│ Cron: cron.go — calls engine.ReconcileAll(ctx) │ │
│ │ │
│ Engine (internal/engine/engine.go): │ │
│ mq.New(&mq.Config{...}, forgeClient, stager) │ │
│ mq.Engine.Reconcile(ctx) │ │
│ │ │
│ Support packages: │ │
│ internal/forge/ — mq.ForgeClient implementation │ │
│ internal/gitops/ — mq.Stager implementation │ │
│ internal/db/ — Supabase REST client │ │
│ internal/lease/ — QueueLease wrapper (DO or D1) │ │
│ internal/checkpoint/ — D1-based CheckpointStore │ │
└─────────────────────────────────────────────────────────────────────┘
  1. Single entrypoint: main.go serves all HTTP requests via Go’s net/http.ServeMux. No more JS route dispatcher.
  2. Engine per-tick: Each cron tick (or webhook-triggered reconciliation) creates a mq.Engine via mq.New() and calls Reconcile(ctx). The engine is short-lived — no persistent state in the Worker.
  3. CheckpointStore = D1: Queue state persists in a D1 database table (not LeaseDO storage), so the engine survives Worker cold starts.
  4. QueueLease = LeaseDO: The LeaseDO’s existing acquire/release HTTP interface is called from Go via fetch() inside the Workers runtime.
  5. LeaseDO unchanged: The LeaseDO still handles webhook events (process_event, /lease, /reconcile). The Go Worker sends events to it via HTTP.
  6. No net/http on Workers: For Forge API calls, use Workers’ fetch() through the Go Workers polyfill. The existing internal/forge/forge.go uses http.Client — this works because Cloudflare Workers Go runtime polyfills net/http to use fetch().

1.3 What the Go Engine Handles vs What JS Still Handles

Section titled “1.3 What the Go Engine Handles vs What JS Still Handles”
Concern Go JS (remaining)
HTTP routing main.go — all routes N/A
Tenant CRUD API handlers N/A
Auth middleware internal/api/auth.go N/A
Shunt engine mq.New() + Reconcile() N/A
Checkpoint store D1 table N/A
Lease management LeaseDO via fetch() N/A
Cron reconciliation internal/cron/cron.go N/A
Webhook HMAC verification internal/api/webhook.go N/A
LeaseDO (DO class) lease-do.mjs (unchanged)
KV rate limiter KV binding in main.go

main.go — HTTP router, auth, webhook, cron entrypoint, worker fetch handler
internal/worker/
worker.go — Workers-specific adapter: HTTP handler → worker fetch wrapper
d1store.go — D1-based CheckpointStore implementation
leaseclient.go — LeaseDO client: acquire/release via fetch() to LeaseDO
rate.go — KV-based rate limiter (simple in-memory with KV fallback)
internal/api/
handler.go — Tenant CRUD handlers (existing, minimal updates)
api.go — Route registration (existing)
auth.go — API key auth middleware
webhook.go — Webhook HMAC verification + event routing
internal/engine/
engine.go — mq.New() wrapper (existing, needs mq.Config fix)
tenant.go — TenantConfig from DB
internal/forge/
forge.go — mq.ForgeClient implementation (existing, unchanged)
internal/gitops/
stager.go — mq.Stager implementation (existing, unchanged)
internal/db/
db.go — Supabase REST client (existing, unchanged)
internal/lease/
lease.go — QueueLease interface + DO wrapper (existing, extends for D1)
internal/checkpoint/
checkpoint.go — D1-based CheckpointStore (new)
internal/cron/
cron.go — Cron reconciliation entrypoint (existing or new)
internal/tenant/
tenant.go — Tenant model + DB query helpers
shunt-engine.mjs — DELETED (replaced by shunt/mq engine)
index.mjs — DELETED (replaced by main.go)
lease-do.mjs — KEPT (LeaseDO class, unchanged)
wrangler.toml — Updated: main = "src/main.go" or build command
go.mod — Added: cfworker build tag, D1/LeaseDO dependencies

2.1 What to Keep, What to Change, What to Delete

Section titled “2.1 What to Keep, What to Change, What to Delete”
File Action Notes
index.mjs Delete Replaced by main.go
shunt-engine.mjs Delete Replaced by shunt/mq engine
lease-do.mjs Keep LeaseDO class unchanged
wrangler.toml Update Change main = "index.mjs" → Go build config
internal/engine/engine.go Update Fix to use mq.Config (public), not engine.Config (internal)
internal/forge/forge.go Keep Already implements mq.ForgeClient
internal/gitops/stager.go Keep Already implements mq.Stager
internal/db/db.go Keep Supabase client, unchanged
internal/lease/lease.go Update Add D1-backed QueueLease impl
internal/checkpoint/checkpoint.go New D1-backed CheckpointStore
internal/api/handler.go Keep Tenant CRUD handlers
internal/api/api.go Keep Route registration
main.go New HTTP router, fetch handler, cron

All JS routes mapped to Go handlers. The Go Worker uses Go 1.22+ net/http.ServeMux with pattern-based routing.

3.1 API Routes (authenticated via X-API-Key)

Section titled “3.1 API Routes (authenticated via X-API-Key)”
Route JS Handler Go Handler Notes
GET /api/v1/tenants supabase → tenants Handler.ListTenants() Admin only
POST /api/v1/tenants supabase → tenants Handler.CreateTenant() No auth (first tenant)
GET /api/v1/tenants/:id supabase → tenants Handler.GetTenant()
PUT /api/v1/tenants/:id supabase → tenants Handler.UpdateTenant()
DELETE /api/v1/tenants/:id supabase → tenants Handler.DeleteTenant()
GET /api/v1/tenants/:id/connections supabase → forge_connections Handler.ListConnections()
POST /api/v1/tenants/:id/connections supabase → forge_connections Handler.CreateConnection() Encrypt token
DELETE /api/v1/tenants/:id/connections/:connId supabase → forge_connections Handler.DeleteConnection()
GET /api/v1/tenants/:id/connections/:connId/repos supabase → managed_repos Handler.ListRepos()
POST /api/v1/tenants/:id/connections/:connId/repos supabase → managed_repos Handler.CreateRepo()
DELETE /api/v1/tenants/:id/connections/:connId/repos/:repoId supabase → managed_repos Handler.DeleteRepo()
GET /api/v1/tenants/:id/queue stub response Handler.GetQueueState() Query LeaseDO for state
GET /api/v1/tenants/:id/audit supabase → audit_log Handler.GetAuditLog() Paginated
Route JS Handler Go Handler Notes
GET /auth/forgejo OAuth redirect Handler.AuthRedirect() Stub — redirect to forge
GET /auth/callback JWT session Handler.AuthCallback() Stub — exchange code → session
GET /api/v1/me JWT verify Handler.Me() Session cookie → user info
POST /api/v1/logout Cookie clear Handler.Logout() Clear session cookie

3.3 Webhook Routes (no API key, HMAC verified)

Section titled “3.3 Webhook Routes (no API key, HMAC verified)”
Route JS Handler Go Handler Notes
POST /api/v1/webhooks/forgejo HMAC verify → LeaseDO Handler.WebhookForgejo() Lookup tenant, verify HMAC, call LeaseDO
Route JS Handler Go Handler Notes
POST /api/v1/billing/checkout stub Handler.Checkout() Stub → real Stripe
POST /api/v1/billing/portal stub Handler.BillingPortal() Stub → real Stripe
POST /api/v1/stripe/webhook stub Handler.StripeWebhook() Stub → real Stripe

3.5 LeaseDO Routes (no auth — DO handles its own security)

Section titled “3.5 LeaseDO Routes (no auth — DO handles its own security)”
Route JS Handler Go Handler Notes
GET /api/v1/tenants/:id/repos/:name/lease fetch → LeaseDO Handler.LeaseCheck() Forward to LeaseDO
POST /api/v1/tenants/:id/repos/:name/lease fetch → LeaseDO Handler.LeaseAcquire() Forward to LeaseDO
DELETE /api/v1/tenants/:id/repos/:name/lease fetch → LeaseDO Handler.LeaseRelease() Forward to LeaseDO

// internal/engine/engine.go — updated
package engine
import (
"context"
"log/slog"
"github.com/rbtr/shunt/mq"
"git.rbtr.dev/laputacloudco/gondolier/internal/forge"
"git.rbtr.dev/laputacloudco/gondolier/internal/gitops"
"git.rbtr.dev/laputacloudco/gondolier/internal/lease"
)
// Engine wraps shunt's mq.Engine with a per-tenant config layer.
type Engine struct {
cfg Config
forge forge.ForgeClient
stager gitops.Stager
engine *mq.Engine
logger *slog.Logger
}
// Config holds the configuration for a single tenant's engine tick.
type Config struct {
TenantID string
RepoOwner string
RepoName string
BaseBranch string
QueueName string
Token string
InstanceURL string
StatusCtx string // default: "merge-queue"
MergeStyle string // default: "squash"
MaxBatch int // default: 5
LeaseTTL int // seconds, default: 45
BotUser string // default: "mq-bot"
}
// New creates a new Engine for the given config.
func New(cfg Config) *Engine {
return &Engine{
cfg: cfg,
logger: slog.Default().With("tenant", cfg.TenantID, "repo", cfg.RepoName),
}
}
// Reconcile runs one cycle of the merge queue.
func (e *Engine) Reconcile(ctx context.Context) (*ReconcileResult, error) {
result := &ReconcileResult{QueueName: e.cfg.QueueName}
// Build the shunt engine
shuntCfg := &mq.Config{
Owner: e.cfg.RepoOwner,
Repo: e.cfg.RepoName,
Base: e.cfg.BaseBranch,
StatusCtx: e.cfg.StatusCtx,
MergeStyle: e.cfg.MergeStyle,
StagingBranch: "mq/" + e.cfg.BaseBranch + "/staging",
InstanceURL: e.cfg.InstanceURL,
PublicURL: e.cfg.InstanceURL,
MaxBatch: e.cfg.MaxBatch,
BotUser: e.cfg.BotUser,
LeaseTTL: time.Duration(e.cfg.LeaseTTL) * time.Second,
}
e.engine = mq.New(shuntCfg, e.forge, e.stager)
err := e.engine.Reconcile(ctx)
if err != nil {
result.Errors = append(result.Errors, err.Error())
return result, err
}
return result, nil
}

internal/forge/forge.go already implements mq.ForgeClient. It needs no changes.

Interface verification:

// internal/forge/forge.go implements:
// - ListOpenPRs(ctx, owner, repo, base) ([]PullRequest, error)
// - GetPR(ctx, owner, repo, index) (PullRequest, error)
// - AutomergeState(ctx, owner, repo, index) (AutomergeState, error)
// - LatestCommitStatus(ctx, owner, repo, sha, ctx) (CommitStatus, bool, error)
// - RunStatus(ctx, owner, repo, sha, branch) (string, error)
// - RunTargetURL(ctx, owner, repo, sha, branch) (string, error)
// - SetCommitStatus(ctx, owner, repo, sha, ctx, state, desc, targetURL) error
// - ScheduleAutomerge(ctx, owner, repo, index, style, headSHA) (ScheduleAutomergeResult, error)
// - CancelAutomerge(ctx, owner, repo, index) (bool, error)
// - DeleteBranch(ctx, owner, repo, branch) error
// - UpsertComment(ctx, owner, repo, index, marker, botUser, body) error ← EXISTS

internal/gitops/stager.go already implements mq.Stager:

// internal/gitops/stager.go implements:
// - BuildStaging(ctx, base, stagingBranch, refs []mq.MergedRef) (sha string, conflictPR int, err error)

The shunt engine needs a CheckpointStore for queue persistence. In Workers, bbolt (used by shunt’s default checkpoint) is unavailable. Use D1:

// internal/checkpoint/d1store.go — NEW FILE
package checkpoint
import (
"context"
"database/sql"
"encoding/json"
"github.com/rbtr/shunt/internal/checkpoint"
)
// D1Store implements checkpoint.CheckpointStore using D1.
type D1Store struct {
db *sql.DB // D1 bound as sql database
}
func NewD1Store(db *sql.DB) *D1Store {
return &D1Store{db: db}
}
func (s *D1Store) LoadQueue(ctx context.Context, key checkpoint.QueueKey) (checkpoint.QueueSnapshot, bool, error) {
var data string
err := s.db.QueryRowContext(ctx,
`SELECT data FROM queue_checkpoint WHERE owner = ? AND repo = ? AND base = ?`,
key.Owner, key.Repo, key.Base,
).Scan(&data)
if err == sql.ErrNoRows {
return checkpoint.QueueSnapshot{}, false, nil
}
if err != nil {
return checkpoint.QueueSnapshot{}, false, err
}
var snap checkpoint.QueueSnapshot
if err := json.Unmarshal([]byte(data), &snap); err != nil {
return checkpoint.QueueSnapshot{}, false, err
}
return snap, true, nil
}
func (s *D1Store) SaveQueue(ctx context.Context, snap checkpoint.QueueSnapshot) error {
data, err := json.Marshal(snap)
if err != nil {
return err
}
_, err = s.db.ExecContext(ctx,
`INSERT OR REPLACE INTO queue_checkpoint (owner, repo, base, data)
VALUES (?, ?, ?, ?)`,
snap.Key.Owner, snap.Key.Repo, snap.Key.Base, data,
)
return err
}
func (s *D1Store) DeleteQueue(ctx context.Context, key checkpoint.QueueKey) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM queue_checkpoint WHERE owner = ? AND repo = ? AND base = ?`,
key.Owner, key.Repo, key.Base,
)
return err
}

Migration SQL (supabase/migrations/004_queue_checkpoint.sql):

CREATE TABLE IF NOT EXISTS queue_checkpoint (
owner TEXT NOT NULL,
repo TEXT NOT NULL,
base TEXT NOT NULL,
data TEXT NOT NULL,
updated INTEGER NOT NULL DEFAULT (unixepoch()),
PRIMARY KEY (owner, repo, base)
);

Wrap LeaseDO’s HTTP interface as QueueLease:

// internal/lease/dolease.go — EXTEND existing lease.go
package lease
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
"github.com/rbtr/shunt/internal/checkpoint"
)
// DOLease implements QueueLease by calling LeaseDO via fetch().
type DOLease struct {
doURL string // e.g., "https://<do-name>.<tenant>.gondolier.workers.dev"
}
func NewDOLease(doURL string) *DOLease {
return &DOLease{doURL: doURL}
}
type leaseResponse struct {
Acquired bool `json:"acquired,omitempty"`
Released bool `json:"released,omitempty"`
Error string `json:"error,omitempty"`
}
func (l *DOLease) AcquireLease(ctx context.Context, key checkpoint.QueueKey, holderID string, ttl time.Duration) (bool, error) {
body, _ := json.Marshal(map[string]string{
"holder_id": holderID,
"key": key.String(),
})
req, _ := http.NewRequestWithContext(ctx, "POST", l.doURL+"/lease", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return false, err
}
defer resp.Body.Close()
var result leaseResponse
json.NewDecoder(resp.Body).Decode(&result)
if resp.StatusCode == 409 || result.Error != "" {
return false, fmt.Errorf("lease locked: %s", result.Error)
}
return result.Acquired, nil
}
// main.go — engine init
func newEngine(ctx context.Context, tenantID, connID, repoID string) (*engine.Engine, error) {
// 1. Load repo config from DB
repo, conn, tenant := db.GetRepoConfig(ctx, repoID)
if repo == nil {
return nil, fmt.Errorf("repo not found")
}
// 2. Build engine config
cfg := engine.Config{
TenantID: tenant.ID,
RepoOwner: repo.Owner,
RepoName: repo.Slug,
BaseBranch: repo.BaseBranch,
QueueName: "default",
Token: decryptToken(conn.TokenEncrypted, conn.TokenNonce),
InstanceURL: conn.InstanceURL,
StatusCtx: repo.StatusContext,
MergeStyle: repo.MergeStyle,
MaxBatch: repo.MaxBatch,
LeaseTTL: 45,
BotUser: conn.BotLogin,
}
// 3. Create engine
e := engine.New(cfg)
// 4. Attach checkpoint store (D1)
// e.engine.SetCheckpoint(checkpoint.NewD1Store(d1DB))
// 5. Attach lease (LeaseDO)
// e.engine.SetLease(lease.NewDOLease(doURL))
return e, nil
}

package main
import (
"context"
"database/sql"
"fmt"
"log/slog"
"net/http"
"os"
"cloud.google.com/go/cloudsqlconn" // or pgx pool for D1
"git.rbtr.dev/laputacloudco/gondolier/internal/api"
"git.rbtr.dev/laputacloudco/gondolier/internal/cron"
"git.rbtr.dev/laputacloudco/gondolier/internal/db"
"git.rbtr.dev/laputacloudco/gondolier/internal/engine"
"git.rbtr.dev/laputacloudco/gondolier/internal/forge"
"git.rbtr.dev/laputacloudco/gondolier/internal/gitops"
"git.rbtr.dev/laputacloudco/gondolier/internal/tenant"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stdout, nil))
// DB connections (via Cloudflare Secrets or D1 bindings)
supabaseURL := os.Getenv("SUPABASE_URL")
supabaseKey := os.Getenv("SUPABASE_SERVICE_ROLE_KEY")
adminKey := os.Getenv("ADMIN_API_KEY")
dbClient := db.New(supabaseURL, supabaseKey)
// API handler
apiHandler := api.NewHandler(dbClient, log, adminKey)
// HTTP router
mux := http.NewServeMux()
// API routes (with auth middleware)
apiHandler.RegisterRoutes(mux)
// Webhook route (no auth, HMAC verified)
mux.HandleFunc("POST /api/v1/webhooks/forgejo", func(w http.ResponseWriter, r *http.Request) {
handleWebhook(w, r, dbClient, log)
})
// Auth routes
mux.HandleFunc("GET /auth/forgejo", handleAuthRedirect)
mux.HandleFunc("GET /auth/callback", handleAuthCallback)
mux.HandleFunc("GET /api/v1/me", handleMe)
mux.HandleFunc("POST /api/v1/logout", handleLogout)
// Billing stubs
mux.HandleFunc("POST /api/v1/billing/checkout", handleBillingCheckout)
mux.HandleFunc("POST /api/v1/billing/portal", handleBillingPortal)
mux.HandleFunc("POST /api/v1/stripe/webhook", handleStripeWebhook)
// Lease forwarding
mux.HandleFunc("GET /api/v1/tenants/*/repos/*/lease", handleLease)
mux.HandleFunc("POST /api/v1/tenants/*/repos/*/lease", handleLease)
mux.HandleFunc("DELETE /api/v1/tenants/*/repos/*/lease", handleLease)
log.Info("gondolier starting")
http.ListenAndServe(":8787", mux) // local dev; Workers uses fetch()
}
// fetch is the Workers entrypoint — called by Cloudflare Workers runtime
func fetch(request *http.Request, env map[string]any, ctx context.Context) (*http.Response, error) {
// Same mux as main(), reused for both local and Workers
return nil, http.DefaultServeMux.ServeHTTP
}

Cloudflare Workers Go uses go build -target=cfworker. The compiled WASM is served via wrangler:

wrangler.toml
name = "gondolier"
compatibility_date = "2026-08-03"
compatibility_flags = ["go_worker"]
build = { command = "go build -target=cfworker -o ../dist/_worker.js" }
[durable_objects]
bindings = [{ name = "LEASE_DO", class_name = "LeaseDO" }]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["LeaseDO"]
[triggers]
crons = ["*/5 * * * *"]

The Workers Go runtime provides these polyfills:

  • net/http → Workers fetch()
  • os.Getenv → Cloudflare Secrets / environment variables
  • time.Sleep → Workers-compatible sleep
  • database/sql → NOT polyfilled (use D1 binding directly)
// In main.go, use the D1 binding:
import "database/sql"
var d1 *sql.DB // bound by wrangler
// Use with context:
stmt, err := d1.PrepareContext(ctx, "SELECT ...")

Terminal window
# Local dev
go build -o bin/gondolier ./cmd/gondolier
# Workers WASM build
go build -target=cfworker -o dist/_worker.js ./cmd/gondolier
# Verify
go vet ./...
go test ./...
# Before:
main = "index.mjs"
# After:
name = "gondolier"
compatibility_date = "2026-08-03"
compatibility_flags = ["go_worker"]
build = { command = "go build -target=cfworker -o dist/_worker.js", watch_dir = "." }
[durable_objects]
bindings = [{ name = "LEASE_DO", class_name = "LeaseDO" }]
[[migrations]]
tag = "v1"
new_sqlite_classes = ["LeaseDO"]
[triggers]
crons = ["*/5 * * * *"]
[observability]
enabled = true
head_sampling_rate = 1
Terminal window
wrangler deploy

6.4 CI/CD Update (.forgejo/workflows/ci.yaml)

Section titled “6.4 CI/CD Update (.forgejo/workflows/ci.yaml)”
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
vet:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
- run: go vet ./...
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
- run: go test ./...
build:
runs-on: ubuntu-latest
needs: [vet, test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
- run: go build -target=cfworker -o dist/_worker.js ./cmd/gondolier
deploy-preview:
runs-on: ubuntu-latest
needs: [build]
if: github.event_name == 'pull_request'
steps:
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: deploy --env preview
deploy-main:
runs-on: ubuntu-latest
needs: [build]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
command: deploy

Goal: Go engine compiles and runs locally, calls shunt engine, reads from DB.

  1. Update internal/engine/engine.go to use mq.Config (public) instead of engine.Config (internal)
  2. Create internal/checkpoint/d1store.go (D1 checkpoint store)
  3. Create internal/lease/dolease.go (LeaseDO QueueLease)
  4. Write main.go stub — HTTP server with no-op handlers
  5. Update wrangler.toml for Go build
  6. Build with go build -target=cfworker
  7. Run go vet ./... && go test ./...

Keep JS running: JS index.mjs remains deployed. No changes to existing routes.

Phase 2: Replace JS API Routes (Blue-Green)

Section titled “Phase 2: Replace JS API Routes (Blue-Green)”

Goal: Go handles API routes; JS still handles webhooks + LeaseDO.

  1. Implement all API route handlers in Go (internal/api/handler.go)
  2. Wire main.go to handle /api/v1/* routes
  3. Deploy Go Worker alongside JS Worker (different subdomain or path prefix)
  4. Use Cloudflare Workers routing to send /api/v1/* → Go, everything else → JS
  5. Test all CRUD endpoints via curl
  6. Monitor error rates

Routing config (Cloudflare dashboard or wrangler):

# Two workers, routed by path:
# Worker 1 (go): handles /api/v1/*, /auth/*, /webhooks/*
# Worker 2 (js): handles everything else (LeaseDO still JS)

Goal: Go handles webhooks and cron; JS deleted.

  1. Implement webhook HMAC verification in Go (internal/api/webhook.go)
  2. Implement cron handler in Go (internal/cron/cron.go)
  3. Go Worker sends webhook events to LeaseDO via fetch()
  4. Go Worker’s cron calls engine.ReconcileAll()
  5. Delete index.mjs and shunt-engine.mjs
  6. Keep lease-do.mjs (LeaseDO must be JS — Durable Objects can’t be Go)

Goal: Remove JS dependencies, finalize.

  1. Remove shunt-engine.mjs from repo
  2. Remove index.mjs from repo
  3. Remove node_modules/ if no longer needed for tests
  4. Update go.mod — run go mod tidy
  5. Update CI/CD pipeline
  6. Update wrangler.toml — remove JS-specific config
  7. Final wrangler deploy
  8. Update docs/ROADMAP.md
  9. Run go vet ./... && go test ./... && go build ./cmd/gondolier

Risk Severity Mitigation
net/http not polyfilled on Workers Go High Test with a simple http.Get() call first. Cloudflare Workers Go has supported net/http polyfill since v1.0. If it fails, replace with raw fetch() via syscall/js or js/wasm bindings.
No database/sql on Workers High D1 Workers Go SDK uses a custom connection. Use github.com/cloudflare/cloudflare-go or the D1 binding directly. If unavailable, use fetch() to call D1 REST API.
WASM import latency Medium Go WASM startup is ~100-300ms. Keep handler logic minimal — DB queries and forge calls dominate. Engine initialization happens per-tick, not per-request.
No background goroutines Medium All reconciliation must complete within the request timeout (~5s for HTTP, ~30s for cron). Use context.WithTimeout. The engine’s Reconcile() should be called once per tick, not in a loop.
CheckpointStore D1 performance Medium D1 reads/writes are ~10-50ms. Checkpoint is read once per reconcile and written once per reconcile — acceptable. If slow, fall back to in-memory checkpoint (state re-derived from forge each tick).
LeaseDO fetch() from Go Low LeaseDO is accessed via its HTTP interface. The Go Worker calls it via fetch() polyfill. This is the same pattern used in JS. No special handling needed.
Supabase REST API from Go Worker Low The existing internal/db/db.go uses http.Client → Workers fetch(). Should work transparently. Test connection from Workers Go first.
Token encryption missing Medium internal/api/handler.go has a TODO for envelope encryption. Implement before Phase 2. Use Cloudflare Secrets for the master key.
Cron payload size Low Cron iterates all repos and calls LeaseDO for each. At 50 repos max, this is 50 sequential fetches. Consider batching or limiting to active repos only.
Rollback complexity Medium Keep JS worker deployed during migration. Use Cloudflare Workers routing to switch traffic back to JS if Go fails.

require (
github.com/rbtr/shunt v0.0.0-20260804010323-cd6a1373842b // already present
golang.org/x/crypto v0.31.0 // SHA-256 for API key hashing
github.com/stripe/stripe-go/v81 v81.4.0 // already present
)
// No new deps needed — all existing deps polyfill on Workers Go:
// net/http → fetch()
// time → Workers timer
// encoding/json → standard
Variable Used By Source
SUPABASE_URL internal/db Cloudflare Secrets
SUPABASE_SERVICE_ROLE_KEY internal/db Cloudflare Secrets
ADMIN_API_KEY internal/api Cloudflare Secrets
KMS_MASTER_KEY Envelope encryption (Phase 2) Cloudflare Secrets

Test How Scope
Unit: mq.New() Go test, mock forge + stager Engine initialization
Unit: D1Store In-memory SQLite via github.com/mattn/go-sqlite3 Checkpoint load/save
Unit: DOLease Mock HTTP server Lease acquire/release
Unit: API handlers Go net/http/httptest CRUD, auth
Unit: Webhook HMAC Go test Signature verification
Integration: full reconcile Workers local (wrangler dev) Engine → forge → stager
Integration: cron tick Workers local (wrangler dev --cors) Cron → engine
Rubber-duck: diff vs JS Manual Route parity, behavior match

Before declaring done:

  • go build -target=cfworker -o dist/_worker.js succeeds
  • go vet ./... && go test ./... clean
  • All API routes respond identically to JS versions
  • Webhook HMAC verification works
  • Cron reconciliation calls engine.ReconcileAll()
  • LeaseDO acquire/release works from Go
  • Checkpoint persists across reconcile calls
  • No tokens/credentials in logs
  • wrangler deploy succeeds
  • Rollback plan documented (switch Workers routing back to JS)