Skip to content

Latest commit

 

History

History
224 lines (172 loc) · 56.4 KB

File metadata and controls

224 lines (172 loc) · 56.4 KB

Talyn — Claude Context

Talyn is a desktop "mission control" app for GitHub PR management, powered by cloud coding agents. It tracks your open/review-requested PRs in a prioritized GitHub panel, and delegates fix/respond/review work to cloud providers that run the agent loop on their own sandbox and open a PR. Talyn Fleet (selfhosted) is the default — Firecracker microVMs on our own hardware, running the workspace's own Claude or Codex subscription — with PostHog Code as the fall-back. Codex Cloud is deferred; Claude Code was removed.

As of the cloud-only refactor (June 2026) the app no longer runs anything locally: the bundled daemon, local/remote environments, in-process Claude agents, permission gates, backlog/continuous-build, and the per-task git working tree are all gone. Every task is a cloud task. See docs/CLOUD_PROVIDERS.md.

Target user: engineers who live in GitHub PRs and want to hand routine PR work to cloud agents.

Git Workflow

Repository: git@github.com:Gilbert09/talyn.git (main branch)

After completing each task: stage relevant files, commit with a descriptive message, push to main. No branches or PRs for Talyn itself. Keep commits focused and atomic.

Commit authorship: commits should be authored by Tom directly. Do NOT append Co-Authored-By: Claude … trailers or any other AI-attribution lines to commit messages in this repo.

CI & Releases (.github/workflows/)

Every push to main deploys — treat a push as a production release. All deploy/publish workflows are fork-guarded with if: github.repository == 'Gilbert09/talyn' (these guards compare against the CURRENT repo name; update them if the repo is ever renamed, in the same push, or deploys silently skip).

  • test.yml — every push + PR. 3-OS matrix (macOS/Windows/Ubuntu): full builds → typecheck → lint → npm test. The only gate — nothing blocks a deploy on it, so don't push red.

  • deploy-backend.yml — push to main touching packages/backend|shared, Dockerfile, railway.toml (+ workflow_dispatch). Deploys to Railway via CLI token; cutover is health-gated (/health does a real DB check and 503s while draining), so a boot-refusing build keeps the old one serving. NOTE: every deploy briefly overlaps old+new instances — the pg advisory locks (services/advisoryLock.ts) exist for exactly that window.

  • deploy-marketing.yml — push to main touching apps/marketing/**. Lint + typecheck gate, then Vercel prebuilt deploy to www.talyn.dev.

  • publish.yml — the stable release, cut every six hours and on demand. The scheduled run is skipped when main has not moved since the latest stable release (/releases/latest + the compare API, so a run the cron misses is caught up by the next one). On demand: Actions → Publish → "Run workflow" (version input optional; empty auto-picks the next patch, and electron-builder creates the release AND the tag, so no local git needed) or push a vX.Y.Z tag. Builds macOS arm64+x64 (signed + notarized), Windows NSIS, and Linux AppImage, publishing a full release, which is what the updater and the talyn.dev download button follow. The tag/input is the single source of truth for the app version (baked into release/app/package.json at build time, never committed). A concurrency group queues a manual dispatch behind a scheduled run: the version job reads the release list at run time, so two runs in flight would stamp the same version. (nightly.yml, which shipped arm64-only pre-releases on the same cron to nightly-channel users only, was folded into this in Session 96.)

    Job shape: a version job resolves the version ONCE and fans it out (three legs each computing "next patch above the latest release" would race), then the macOS leg runs alone — it's what creates the GitHub Release (and the tag, on the schedule and dispatch paths) — and only then does a windows-latest/ubuntu-latest matrix upload into it. Keep that ordering: parallelising all three races three electron-builder processes to create the same release, and chaining means a Windows/Linux failure can't take down a macOS release that already published. Windows ships unsigned until an EV cert is bought (SmartScreen warns on first install); setting CSC_LINK/CSC_KEY_PASSWORD fixes it with no workflow change.

Update channels: the desktop picker (Settings → About; persisted in userData via src/main/updateChannel.ts, default stable) maps to electron-updater's allowPrerelease. Since Session 96 nothing publishes a pre-release, so both channels receive the same stable build. The picker is kept so a pre-release track can come back without a client change; removing it (and the "every build as it lands" copy) is an open follow-up.

Testing — run the relevant tests; CI runs the rest

Do NOT run a whole package suite locally. npm test in packages/backend is ~7 minutes — many suites spin up a real pglite Postgres per file — and running it after every edit is most of the loop spent waiting. Run what covers the change:

  • Backend (packages/backend, Vitest): npx vitest run <path/to/file.test.ts> for the specific file(s); add paths or a glob (npx vitest run src/__tests__/prMonitor*) when a change spans related suites; -t "<name>" for a single describe/it.
  • Desktop (apps/desktop, Jest): npx jest <pattern>.
  • Web (apps/web, Vitest): npx vitest run <pattern>.

Pick by what the change can plausibly break, not by habit — and this holds for a cross-cutting edit too. Touching packages/shared, a widely-imported helper or a migration is a reason to run more of the relevant suites, not to run everything: a schema change means the suites that read those tables, not prMonitor's.

The full suite is CI's job. test.yml runs typecheck → lint → npm test across macOS, Windows and Ubuntu on every push and PR — broader than a local run and three platforms wider. Push and let it do that.

It is a BACKSTOP, not a fast feedback loop: that matrix takes ~30 minutes, so it tells you about a break long after you have moved on, and (see below) after the backend has already deployed. Local typecheck + lint is what actually protects the change; CI is what catches what they cannot.

What to always run locally, because it is seconds and catches most breakage before it leaves the machine:

tsc --noEmit        # in the package(s) you touched
eslint <changed files>

The consequence to respect: nothing blocks a deploy on test.yml — a push to main deploys the backend whether or not the suite has finished, let alone passed. Combined with the ~30-minute matrix, that means a red test is discovered roughly half an hour after the code it breaks is already serving traffic. So delegating the suite to CI is not the same as not caring about it: watch the run you triggered, and if it goes red, fixing it is immediate work rather than something to pick up later. And when a change is risky in a way typecheck and lint cannot see — a migration, an auth path, anything in the webhook worker — run the suites around it locally BEFORE pushing. Half an hour of a broken backend is worse than two minutes of waiting.

See docs/TESTING.md for the broader strategy.

Where Things Live

  • docs/ARCHITECTURE.md — system diagram, tech stack, core concept details, key decisions, resolved questions
  • docs/ROADMAP.md — full phased TODO (Phase 1–20), backlog, known gaps, full priority queue
  • docs/SESSIONS.md — chronological session notes
  • docs/CLOUD_PROVIDERS.md — the cloud task provider abstraction (registry, per-provider modules, roadmap)
  • docs/QUALITY_PARITY.md — desktop polish/parity assessment vs Conductor; what's done + prioritized backlog (feed perf, PR diffs/merge, composer, tests)
  • docs/INCREMENTAL_CHECK_COUNTS.md — webhook-driven incremental check counting design
  • docs/REVIEW_RANKING.md — what the offline experiment found about the Reviews tab's ordering: the shipped model is worth ~+23 points of top-3 once fitted, but a plain newest-first sort is within 1.5 points of the best model found, and recency is provably near its ceiling
  • docs/MCP_SERVER.md — the @talyn/mcp-server package
  • docs/SETUP.md — env vars / account setup
  • docs/TESTING.md — testing strategy + coverage

When a session lands non-trivial work, append a note to docs/SESSIONS.md. When a phase item changes status, update docs/ROADMAP.md. When a decision is revisited, update docs/ARCHITECTURE.md.

Keep the session log out of this file. A note in docs/SESSIONS.md is the record; this file carries only what is still true and still load-bearing — Core Concepts, the conventions above. It used to hold a summary of every recent session as well, which grew to 57% of the file: a strictly worse copy of the same notes, always behind, and loaded into every session's context whether or not it was relevant.

Core Concepts (at a glance)

  • Workspace — groups related repos + integrations (e.g., "PostHog" = posthog/posthog + posthog/posthog.com + posthog/charts). Every owner always has at least one: services/workspaceBootstrap.ts mints a DEFAULT_WORKSPACE_NAME one on GET /workspaces (the call every client makes on boot), advisory-locked per owner so concurrent first-loads cannot double-insert. Onboarding therefore opens on Connect GitHub, not on naming a workspace. Consequence to respect: a workspace existing no longer means the user is set up — both front ends read Workspace.integrations.github to decide "already onboarded", because keying off workspaces.length > 0 would skip the wizard (and its required GitHub step) for every new user. See Session 106 in docs/SESSIONS.md.
  • Cloud provider — a vendor that runs the whole agent loop on its own sandbox and opens a PR. Pluggable behind CloudTaskProvider (packages/backend/src/services/cloudProviders/): a registry + per-provider dispatch/reconcile/credentials. Talyn Fleet (selfhosted) heads CLOUD_PROVIDER_ORDER, PostHog Code is the fall-back, Codex Cloud is deferred (no server-to-server API), and Claude Code was removed (metered API credits only — the fleet runs Claude on the user's own subscription instead). See docs/CLOUD_PROVIDERS.md.
    • The fleet is one provider with TWO agents, and the model carries the vendor: fleetProviderForModel reads the model id, and the fleet builds the microVM's egress route table from it, so a Codex run has no route to api.anthropic.com at all. Picking an agent per task is picking a model — a second field would be a second source of truth that can disagree.
    • A task links the PR its run OPENED, and that is a structured field, never a search. PostHog Code answers it with output.pr_url; the fleet with sandbox.prUrl. The poller used to JSON.stringify the run and the remote task record and take the first PR URL in it — but both carry text ABOUT other people's PRs (the user's prompt, the agent's closing prose), so a "Daily PR" loop filed its runs against a PR the user had opened the day before and already merged. run.branch is not a substitute: a run that pushed nothing reports main. No scrape fallback — a missing link is visibly nothing, a wrong one reads as fact. See Session 126 in docs/SESSIONS.md.
    • A dispatch always sends the workspace's own key for the vendor it runs, and suppresses the other with policy.credentials. The gateway fills an absent or blank key from its tenant's sealed custody, so ?? '' was a silent route to spending someone else's subscription; no credential is a refusal. cloudTask.extra.llm records the vendor, and poller.recredential + resolveRunCredentials must both read it — they used to send the Claude key unconditionally.
    • The Claude sign-in authorizes at claude.com/cai/oauth/authorize, NOT platform.claude.com. Anthropic runs two authorize endpoints behind one client id: the platform one connects an Anthropic organization (metered credits, org:create_api_key) and has no subscription to give away; the cai one connects a Claude Pro/Max subscription, which is the point of the fleet. Only the authorize host differs — the token URL and the manual redirect are shared. Claude Code chooses on one question, scopes.includes('user:inference'), so the scope list is an input to flow selection and not merely a permission request: send its claude.ai set verbatim and do not trim it on a theory about what a sandbox needs. The failure mode is patient and expensive — the exchange succeeds, Settings says "connected", and every run 403s hours later — so exchangeCode and performRefresh both read the granted scope and refuse a grant that cannot run a model, which is what turns it into a connect-time error a person can act on. An ABSENT scope passes (RFC 6749 §5.1 omits it when the grant matches the request). The authorize leg stores its PKCE verifier through patchSelfHostedConfig, which UPSERTS. It used to return silently when the workspace had no selfhosted integration row — the normal state for a new user, and for anyone who has disconnected, since disconnect DELETES the row — so the sign-in answered 200 with a good authorize URL, wrote nothing, and then reported a completed sign-in as one it had never started. The lesson generalises past this flow: if (!row) return on a WRITE path reads as caution and is an assertion that no caller ever needs it to create the row. That was true when it was written and false the moment the OAuth flow was added, with nothing anywhere to notice. A revoked grant is RECORDED, not just reported: both refresh paths write reauthRequiredAt on an explicit vendor rejection (never on a transient failure), which is what stops every later dispatch re-attempting a dead refresh token and what makes fleetAgentStatus report "Reconnect needed". It was read in four places and written in none for months — both agents had tests, and they called the writer DIRECTLY, so they proved the seam existed and nothing about whether anything called it. Relatedly hasCredentials answers from STORED CONFIG and never by asking a vendor: it is what GET /cloud-providers calls per provider, so a throw there 500s the listing that draws the Settings cards, the default-agent menu and the agent picker — a dead grant used to blank the one screen with the fix on it. See Sessions 135, 138 and 139 in docs/SESSIONS.md.
    • Two separate gates, and only one of them is a flag. FLEET_ENABLED stays an env var read once at boot — it says whether this DEPLOYMENT has fleet hardware to talk to, and no flag can conjure a machine. The AUDIENCE is the talyn-fleet PostHog flag, evaluated against the workspace owner, fallback OFF — released to every workspace on 2026-09-16, so the flag is now a kill switch and a lever for taking the hardware away from one account rather than an allow-list. A workspace outside the audience has the fleet dropped from its chain and lands on PostHog Code, exactly as before.
  • Environment — now just a secret-free marker, one auto-provisioned row per connected cloud provider. Its type (a CloudProviderType) is how a task resolves its provider; per-workspace credentials live on the integrations row. No daemon, no pairing. PostHog Code's row carries EITHER an encrypted personal API key or an encrypted OAuth token pair, decided by config.authMethod (absent = key), and everything downstream reads one getToken() from posthogCode/credentials.ts rather than branching — see Session 81.
  • Task — the unit of work, always delegated to a cloud provider. Types: code_writing (freeform prompt on a repo), pr_response, pr_review. Lifecycle: queued → in_progress → completed/failed/needs_human. The cloud poller (cloudProviders/poller.ts) drives status + ingests the transcript; review happens on the provider's PR (no local awaiting_review gate).
    • needs_human is a REFUSAL, not a failure, and the difference is expensive. A run that stops because only a person can proceed (a merge gate repo policy says a human must approve, a credential the sandbox lacks, a product decision) lands here with the agent's own words in TaskResult.needsHuman.reason. It records as failed if you forget, which is indistinguishable from a crash, which is why the auto-keep watcher spent four ~14-minute runs on one Visual-Review-gated PR. The reason deliberately does not go in result.error — that is the field the admin console renders in its red failure banner.
    • Neither provider reports it, so the AGENT emits it. TALYN_NEEDS_HUMAN: (prMergeable.ts) on the last line of its final message, read back by parseNeedsHumanSentinel. Exact prefix, last non-empty line only — an agent discussing the sentinel must not trip it, the same restraint that stops findPullRequestUrl regexing prose. Absence means UNKNOWN, never needs_human and never success: the prompt template is workspace-overridable, so a fork that drops the instruction must degrade to the old behaviour. Parsed on every terminal path — error_message (the path the real incident took), output.final_message, the idle-finalize log tail (chunks rejoined in order, because a streamed message splits the sentinel line), and the fleet's error string + transcript tail.
    • Adding a TaskStatus member starts at TASK_STATUS_TERMINAL, a Record<TaskStatus, boolean> in packages/shared. Every active/terminal list derives from it, so the compiler routes you to the consumers. Before it existed there were eight hand-written copies, all bare string[]; the nastiest is the front-end stores, where a status in neither set is fetched by neither query and is simply invisible. tasks.status is plain text with no enum or CHECK, so a new value needs no migration.
    • A terminal task is REUSABLE (taskCreate.ts REUSABLE_STATUSES ≡ TERMINAL_TASK_STATUSES) — needs_human included. The next dispatch at that PR rewrites the row in place; excluding it would insert a duplicate and strand a stale row on the PR forever. The durable record of a stand-down is on the PR, the queue's event log and the notification — never the task row.
    • Both retry loops stand down on it, and neither spends an attempt. The auto-keep watcher (prAutoMergeWatcher.ts) now reads the task row in its accounting — it used to infer the outcome purely from prNeedsFollowup(lastSummary) and never looked at the task at all, which is the actual root cause of the four runs. It re-arms when mergeableBlockerSignature changes (the merge queue's "progress, not retries" test, lifted to shared byte-identically so stored seenSignatures stay comparable), and fires a one-shot auto_keep:needs_human — deliberately not merge_queue:blocked, because nothing was given up on. The queue parks on blockedCode: 'agent_needs_human', distinct from awaiting_human_check (ours, from a live Visual Review reading) because this one is the agent's verdict on a gate we cannot see. The queue's park must END the decision walk: the signature it records is in the transition's set, not yet on d.entry, so the blocked gate would release the entry on the very pass that parked it. See Session 132 in docs/SESSIONS.md.
  • Workflow — user-defined PR automation, released to every workspace. Its audience is the workflows PostHog flag (fallback ON); WORKFLOWS_ENABLED=false is the break-glass override that wins over it. Anything but an explicit false/0/off/no reads as on, so a typo turns it on rather than silently off. WORKFLOWS_ALLOWED_EMAILS is gone. A named, workspace-scoped rule: on these PR lifecycle events, matching these conditions, do these actions (labels, reviewers, assignees, a comment, a skill or prompt run, add to My PRs, add to the merge queue). Vocabulary + matcher + validator in packages/shared/src/workflows.ts (the prFilters.ts argument, higher stakes — a workflow comments and merges); engine in packages/backend/src/services/workflows/; tables workflows + workflow_runs (migration 0052). Four things to respect:
    • It reads the webhook PAYLOAD, never a pull_requests row, which is what lets it fire on every PR in a watched repo including untracked ones and other people's. Hooked into processWebhookDelivery ABOVE the isRefreshEvent gate (that predicate is about refreshes, a narrower question). Where a payload is short of a fact — an issue_comment describes an issue, so no base branch / head branch / draft; a check_suite's PRs are {number, base, head} — facts carry unknownFields, a condition on one fails rather than passing, and the engine enriches from the row first (SQL accessors into last_summary, never the blob).
    • UNIQUE (workflow_id, delivery_id) is the concurrency design. The run row is inserted BEFORE any action runs; a conflict means a redelivery or another replica already owns it. No advisory lock. Settling is TWO writes — outcome first, then the task/PR links best-effort — because one combined UPDATE fails the FK when a linked row has gone and strands the run at running.
    • A rate-limited action is PARKED, not lost. pending_retry + retry_after (migration 0053) hold the instant the gate clears, and services/workflows/retrySweep.ts re-runs only the actions that have not already succeeded — a run whose comment posted and whose label was gated must not comment twice. NOT an inline wait: these run in the webhook worker's six-wide slow lane and the gate is per ACCOUNT, so a burst from one org would block most of the lane on one wait. rate_gated is the ONLY retryable code (it is the one failure that is transient AND says when it clears); the run's facts are stored because a comment action interpolates branches the webhook payload no longer has.
    • Two loop guards, not a quota. Self-echo suppression (skip a delivery whose actor is Talyn's own App, on the events our actions produce — App only, never the connected user; and pr_merged deliberately excluded) plus max_runs_per_pr_per_hour (default 5) counted from workflow_runs, with skipped rows excluded from the count and the refusal announced once per window.
    • The merge action is the QUEUE, not mergePullRequest. A direct merge 405s on a gated base. This is why the enqueue path moved out of routes/pullRequests.ts into services/mergeQueue/membership.ts (per-PR applyQueueMembership vs per-call setQueueMembership). local: skills are refused at save time — the backend cannot read ~/.claude/skills. GET /features → { workflows } decides only what to DRAW; every route, the engine and the task actions gate independently. See Session 117 in docs/SESSIONS.md.
  • Feature flag — PostHog owns every audience. The register is packages/shared/src/featureFlags.ts (key, PostHog key, break-glass env var, per-flag fallback); the backend reads it through services/featureFlags.ts. Never gate on process.env at a call site — that reimplements the precedence per gate. Five things to respect:
    • Precedence is env override → PostHog → the flag's own fallback. The override short-circuits rather than outvotes, because break glass has to work when PostHog is the broken thing.
    • fallback is PER FLAG and the two live ones are OPPOSITE. workflows fails OPEN (a released feature must survive an outage), fleet fails CLOSED (an outage must not open hardware we own to everybody). A shared default silently flips whichever it does not describe. PostHog answering undefined — a flag nobody created, or one somebody deleted — is the fallback too, NOT false.
    • The distinct id is the Supabase user id, matching analytics.ts, so a person targeted in PostHog is the same person in their own funnels. Email is passed as a person property, which is what lets a release condition do the literal job FLEET_ALLOWED_EMAILS used to.
    • Which subject depends on the flag. Routes ask about the caller; the engine, the poller and dispatch ask about the workspace owner — a webhook has no caller, and a gate that passes when it cannot identify one is not a gate.
    • availability decides what the release notes may say, and it is a SEPARATE field from fallback on purpose — one answers "who has this feature", the other "what do we say when PostHog is down". A flag marked 'gated' withholds every "What's new" highlight tagged with it from EVERYBODY, including the accounts its PostHog audience has switched it on for; flipping it to 'general' (or deleting the flag) is what announces the feature, and the withheld backlog replays to everyone who missed it. Its releaseScopes are the commit scopes the generator tags mechanically. This replaced a hand-written list of scopes in releaseNotes.ts that said ['fleet'] on the day Loops shipped — which is why Loops was announced to every user who could not open it. See Session 124 in docs/SESSIONS.md.
    • Set TALYN_POSTHOG_PERSONAL_API_KEY in production. With it, flags evaluate in-process and a gate costs no network; without it every gate is an HTTP round trip (cached 30s here). The workflows gate runs once per delivery per watching workspace. GET /features decides only what to DRAW — every route, the engine and the task actions gate independently. See docs/SETUP.md § Feature flags.
  • Loop — user-defined recurring work, gated by the loops PostHog flag (fallback OFF — the opposite of workflows, which sits next to it in the nav; LOOPS_ENABLED is the break glass both ways, and LOOPS_ENABLED=true is how you run them locally with no PostHog project). A named, workspace-scoped rule: this prompt, on this repository, with this agent and model, on this cron schedule. Every firing creates an ordinary code_writing cloud task through createCloudTask, so the poller, the transcript, the PR and the billing gate work unchanged — what Loops adds is the clock. Free plan keeps 3 (see Billing). Vocabulary + schedule arithmetic + validator in packages/shared/src/loops.ts (built on croner, this package's first runtime dependency, because DST is the one part nobody hand-rolls correctly); engine in packages/backend/src/services/loops/; tables loops + loop_runs (migration 0054). Five things to respect:
    • The schedule is a COLUMN, not a timer. loops.next_run_at holds the next occurrence and a 30s sweep reads the due rows through a partial index (WHERE enabled). A setTimeout per loop would be lost on every Railway deploy — which is every push to main — and double-fire during the old/new overlap.
    • UNIQUE (loop_id, scheduled_for) is the concurrency design, the (workflow_id, delivery_id) trick again and stronger: a webhook delivery id is a token each replica must have received, while a scheduled instant is derived from the loop's own stored next_run_at, so two actors that both think a firing is owed cannot compute different keys. The consequence: next_run_at must only ever hold an exact occurrence instant — never rounded, never "now".
    • The order is claim → dispatch → advance, and it is load-bearing. Advancing first turns any crash into a silently dropped firing (next_run_at in the future, nothing re-selects the loop, the claimed run sits queued forever). Dispatch-first turns a crash into an ordinary retry through the due-scan, made idempotent by the unique claim. The residual window costs one duplicate task, not a lost one.
    • Catch-up fires ONCE, then advances from now. Replaying six missed occurrences is six near-identical tasks and, on a free plan, one run plus five refusals; skipping entirely lets a 30-second deploy eat the 09:00 daily run. Re-enabling a loop is resume, not backfill. Overlap is a per-loop setting (skip default, recorded in the history), which is what makes a tight cron self-limiting — hence no minimum-interval cap.
    • Internet access is a per-loop switch, off by default, and fleet-only. A loop's sandbox normally reaches its repository and its agent API through the credential proxy and has NO route anywhere else — which is what stops a prompt that has just read untrusted PR text from posting it out. internetAccess (migration 0055) rides on the TASK (metadata.internetAccess), not the loop, so a revived run gets the posture it had rather than whatever the loop says today; the fleet executor turns it into policy.egress.mode: 'open'. The fleet deleted the old rule that coupled routed egress to dropping credentials — the proxy attaches them in every mode — and the one refusal that survived (a routed run whose GitHub token names no repository) cannot bite us, because every Talyn dispatch names a repo. No egress vocabulary reaches the user: the editor offers "Repository only" / "Allow the internet" and Talyn does the translation. PostHog Code has no equivalent, so the switch is hidden there AND refused by the route — the editor is a courtesy, the route is the gate.
    • A pinned provider is never failed over for a BROKEN credential — but it is for a SPENT one. resolveCloudEnvChain's fall-back is right for "fix this PR somehow" and wrong for access that has been REVOKED: somebody who chose Talyn Fleet chose their own subscription and credential custody, so a fleet loop whose grant was withdrawn fails visibly rather than quietly spending metered PostHog credits on a problem only the user can fix. An exhausted quota is a different fact and Tom's call (2026-09-20) is to move it: the setup is correct, the month simply ran out, and the work is still wanted. services/cloudProviders/quotaFailover.ts re-dispatches such a run — the fleet's OTHER agent first (a subscription already paid for), then the rest of the chain to PostHog Code, for unattended work too. It cannot just call resolveCloudEnvChain: that resolver thinks in PROVIDERS and the fleet is one provider with two agents, so deferring to it steps over a working Codex subscription on the way to a bill. Detection is selfHosted/exhaustedQuota.ts, pinned to the vendors' own "a human must top this up" sentences — a rate limit is deliberately NOT this, because it clears by waiting and moving off it spends money to avoid a short pause. The exhaustion is REMEMBERED, on the fleet integration row (quotaExhausted) and not in memory, which would die at the next deploy: dispatch reads it and either swaps agent or refuses as capacity, so the next task never boots a microVM to learn the same thing. The reset is read, never invented — resetInstantFrom parses the vendor's own instant and answers null when there is none, because a guessed reset is stored, believed, and silently keeps work off a subscription that came back hours ago; absent one, a five-hour re-probe (the vendors' published consumer window) lets the next ordinary dispatch find out. The hold is cleared by PROOF as well as time — a completed run on that agent, or a fresh sign-in — or it outlives its truth. Each hop is recorded on the task and never retried, and the note goes on metadata.quotaFailover, NOT tasks.result — the detail panel paints any result.success === false red whatever the status says, and a re-queued task has not failed. Likewise a task-limit hit is a visible waiting_slot run retried until the next occurrence supersedes it — not a silent deferral, which is why the paywall reads as never firing elsewhere. Fleet agents reach the editor through GET /cloud-providers (already filtered by workspaceMayUseFleet), so the fleet parts sit behind the fleet flag for free, and the backend re-checks at save time and fire time.
  • MCP server — a vendor's MCP server a workspace connects (Linear, Sentry, Supabase, its own), passed to every Talyn Fleet run. Gated by the mcp-servers PostHog flag (fallback OFF, availability: 'gated' — it is fleet-only, and it is the surface where somebody pastes a Stripe key, so failing open during a PostHog outage would offer credential storage to accounts nobody chose). It is the one flag with NO env override — Tom's call, its audience is PostHog's alone. So it cannot be run against a deployment with no PostHog project (what LOOPS_ENABLED=true is for), and there is no switch to reach for if PostHog is the broken thing; the false fallback is what answers then, which turns it off rather than on. readFlagOverride returns undefined for it always, and envOverride is optional on FeatureFlagDefinition to say so in the type. Uncapped on every plan — unlike tasks, queued PRs, workflows and loops, a connected MCP server spends nothing until a run uses it, and what the run costs is bounded by the task cap already, so charging for the connection was charging twice. Vocabulary + validator + catalog in packages/shared/src/mcpServers.ts; services in packages/backend/src/services/mcpServers/; table mcp_servers (migration 0059). Five things to respect:
    • TALYN holds the credential, not the fleet, and that is the whole design's consequence. Servers go inline on POST /v1/sandboxes, and the fleet seals an inline secret on arrival and persists it NOWHERE — so its own adoption path (which reads that tenant's stored servers) has nothing to serve for one. Without us re-supplying, a fleetd restart leaves a running box with its MCP routes and no credentials on them, and every tool call 401s for the rest of the run. Three paths must agree, through one shared mcpIntegrationSecrets: the create body, poller.recredential's push, and runCredentials' answer to a host pull. The LLM key already had this exact bug once.
    • The guest never sees a credential. It is configured with a plain http://<name>.<integration-domain><path> carrying no token; the host's proxy attaches the secret per request. An agent that reads a hostile repo and decides to exfiltrate the Linear key has nothing to find. mcpServersForDispatch is the ONLY function that decrypts — keep it that way.
    • tools and loops.mcp_server_ids are TRI-STATE and nothing may collapse them: null = all / inherit, [] = none, a list = exactly those. Reading [] as "all" is the worst available guess about somebody who ticked nothing, and it would make "run this with no tools" the one thing nobody could ask for. The fleet's /run/fleet/mcp.json contract is at v2 for this reason — a v1 reader ignoring tools would run every tool on a restricted server, so an old contract is an error rather than a loose read.
    • There is NO cap on servers per run or tools per server. YAS's were removed rather than worked around (8/16/32/32/16 — all round numbers). The per-server tool allow-list is the lever instead: same saving, made as a choice. The 64-character tool name STAYS — it is a hard provider limit, and an over-long one 400s the whole request rather than failing one call.
    • Remote streamable HTTP only, and the URL rules are the fleet's own. Path required (but an explicit root is fine — Stripe really does serve at https://mcp.stripe.com/), no userinfo, no query, no fragment, no private or loopback host, and the name github is refused because that is the sandbox's own REST API. The validator in shared MIRRORS every one so a user hears it while typing rather than as a dispatch that failed an hour later. OAuth servers connect through services/mcpServers/oauth.ts — CIMD first, DCR second (deprecated 2026-07-28), PKCE S256 mandatory, RFC 8707 resource always sent, refresh advisory-locked and re-read inside the lock because rotation makes a refresh token single-use. See Session 137 in docs/SESSIONS.md.
  • Operator console — apps/admin at admin.talyn.dev. Cross-tenant by definition; gated by users.is_admin + requireAdmin, which is the whole permission model. Its API is /api/v1/admin (pre-ownerScope). Every mutation requires a reason and writes to admin_audit_log.
  • Billing — free plan = 3 active tasks (pending|queued|in_progress), 3 merge-queue PRs, 3 workflows and 3 loops per owner (MCP servers are deliberately NOT on this list — see the MCP server concept above) (MCP servers are deliberately NOT on this list — see the MCP server concept above) across all their workspaces; Unlimited = $15/mo (or $150/yr) via Polar (merchant of record). The provider-agnostic entitlement seam is services/billing/entitlements.ts (task gate in createCloudTask + the retry/start/PATCH re-activation paths, TaskLimitError → 402 code:'task_limit_reached'; merge-queue gate in POST /pull-requests/:id/merge-queue, MergeQueueLimitError → 402 code:'merge_queue_limit_reached'; workflow gate in POST /workflows, WorkflowLimitError → 402 code:'workflow_limit_reached'; loop gate in POST /loops, LoopLimitError → 402 code:'loop_limit_reached' — the last two are creation ONLY, because a PATCH replaces a rule rather than adding one, and gating it would strand a free user at the cap with a rule they cannot correct (for a loop that includes switching off the one misbehaving). The loop cap counts SCHEDULES, not runs: a firing is a cloud task like any other and is bounded by the task gate above, so the two caps compose rather than overlap. All four open the desktop UpgradeModal via maybeHandleBillingLimit); Polar specifics live only in services/billing/{polar,webhook}.ts (webhook: /api/v1/webhooks/polar, raw-body, idempotent + order-safe). Enforcement runs ONLY when the all-or-nothing POLAR_* env group is set (absent = limits off — the dev default and the prod kill switch). The gates all run unconditionally — there is no per-caller exemption. services/billing/clientGate.ts used to wave through any client identifying as a build older than the release that shipped each paywall UI; it was deleted in Session 104 once the only thing still claiming it was an unstamped LOCAL build reporting the 0.1.0 placeholder from release/app/package.json (see apps/desktop/.erb/configs/appVersion.ts, which now reports dev instead). X-Talyn-Client-Version is still sent, but nothing in billing reads it. The gate you cannot see is the watchers: a merge-queue or auto-keep fix run that hits the task cap is deferred server-side (deferred_task_limit), not 402'd — there is no request to answer, so no UpgradeModal and no paywall_shown. For a merge-queue-heavy user that is the dominant path, and it is why the paywall reads as never firing. The workflow cap counts DEFINITIONS, not enabled ones — countOwnerWorkflows joins workflows to the owner's workspaces, so disabling a rule does not free a slot (counting only the enabled ones would make the cap a toggle: keep twelve, run three, swap whenever). Deleting one does. Both Workflows pages read workflowLimit/workflows off the billing snapshot and refuse BEFORE opening the editor rather than after the form is filled in; the 402 is still what enforces it. A fourth gate is a FEATURE gate, not a cap: turning ON the workspace default "keep new PRs green" (settings.defaultAutoKeepMergeable) needs Unlimited — AutoKeepDefaultPlanError → 402 code:'auto_keep_default_requires_unlimited', asserted in the workspaces PATCH on the OFF→ON transition only, which is what grandfathers a free workspace that already has it on (turning it off gives that up). The modal's pitch is derived from usage, so the billing store carries an upgradeReason and the feature branch is checked before the usage ones. Comp an account with UPDATE users SET plan_override='unlimited' WHERE email='…' — webhooks never touch that column. See Sessions 68 + 70 + 104 + 105 + 121 in docs/SESSIONS.md.
  • GitHub/PR core — services/{github,githubGraphql,prMonitor,prCache,prFocus}.ts + routes/{github,pullRequests,repositories}.ts + the desktop GitHub panel / PR pills / detail sheet. This is the heart of the app. (The standalone Inbox — a prioritized queue of PR items needing attention — was removed; PRs needing attention surface directly in the GitHub panel's "Needs attention" / Mine / Review buckets.)

See docs/ARCHITECTURE.md for the full treatment.

Debug Tooling — keep it current

The Debug panel now lives ONLY on the operator console (admin.talyn.dev → Ops → Debug). It was removed from apps/web and apps/desktop in Session 80: it streams backend internals across every account, so it belongs on an admin-gated surface rather than in the product. It surfaces app internals live: outbound HTTP, poll-loop ticks, WebSocket traffic, and domain events. It's powered by an in-process debugBus (packages/backend/src/services/debugBus.ts, ring buffer + counters + poller registry) that records metadata only (URLs are query-stripped; no headers/bodies/tokens) and streams over the existing WS as debug:event. UI lives in apps/admin/src/components/panels/DebugPanel.tsx (one copy — the desktop/web duplicates are gone).

When you add or change a subsystem, wire it into the bus so the panel stays honest:

  • New outbound HTTP (a new external API/integration) → time the call and debugBus.recordHttp({ service, method, url, status, durationMs, ok, error? }) at the central request funnel (see github.ts apiRequest/executeGraphql, posthogCode/client.ts request). Add a one-liner to SERVICE_INFO in DebugPanel.tsx.
  • New poll loop → debugBus.registerPoller(name, intervalMs, description) in init() (the description arg is required — that's the tooltip) and debugBus.pollerTick(name, { durationMs, ok, error? }) in the tick's finally.
  • New WebSocket message/broadcast or domain event → debugBus.recordWs(...) / debugBus.recordEvent(...). If it's a new outbound broadcast type, keep the event.type !== 'debug:event' loop-guard in websocket.ts intact.
  • New DebugCategory → extend the shared type, CATEGORY_INFO, CATEGORY_LABEL, categoryClasses, the filter chips in DebugPanel.tsx, and the CATEGORIES allowlist in routes/debug.ts — an unlisted value there is not rejected, it silently falls through to "no filter", so the chip appears to do nothing rather than to fail. (db and webhook were missing for exactly that reason until Session 80.)

GraphQL budget cards ("GraphQL budget" row in the panel) show GitHub's per-account GraphQL points budget (inst:<id> for an App installation, else login), fed by services/graphqlBudget.ts. The budget is read off the free rateLimit { limit cost remaining resetAt } field spliced into every batched query (githubGraphql.ts RATE_LIMIT_FIELD); github.ts executeGraphql captures it via graphqlBudget.record(accountKey, …). The tracker is pure / debug-bus-independent on purpose — it also drives a proactive deferral: the reconcile sweep (prReconcileSweep.ts) calls graphqlBudget.shouldDefer(accountKey) and skips an account whose remaining points are in the reserve (RESERVE_POINTS), so webhooks / merge queue / manual refresh keep flowing until the window resets. debugBus.snapshot() just reads graphqlBudget.snapshot() for display. Tests: graphqlBudget.test.ts.

Give the bus the ERROR, not its message. recordDbQuery / recordHttp / pollerTick / recordWebhook all take error?: unknown and run it through describeError, which walks the cause chain. Passing err.message throws away the only useful half of a whole class of failure: drizzle reports Failed query: select … and puts the reason in cause, fetch reports fetch failed and does the same. A red poller card that names the query and not the cause is what turned a one-line fix into an hour — see Session 127. (This is for the bus only; routes that stringify err.message into an API response should keep doing that, since a cause chain is for an operator, not a caller.)

Tests live in packages/backend/src/__tests__/debugBus.test.ts — extend them alongside changes.

Database Egress — keep queries lean

The backend runs against Supabase Postgres and we pay for DB egress (result-row bytes shipped DB→backend). A bare Drizzle .select() is SELECT * — it ships every column, including large jsonb blobs the caller usually doesn't touch. The three expensive columns are tasks.transcript (the cloud-run conversation log, often MBs), pull_requests.lastSummary (~2KB, but multiplied across every tracked PR on the poll loops) and pull_requests.body (the PR description, cached so the detail panel paints without waiting on GitHub — read by GET /:id/description and by NOTHING else, which is why it is a column rather than a key in lastSummary: every tick of the auto-keep watcher, the merge-queue broadcast/executor and the monitor reads that blob and none of them want a description). The DB-egress tile in the Debug panel (fed by instrumentEgress in db/client.ts, which records per-query bytes/rows/table) is how you spot regressions — watch it after touching any read.

Rules of thumb when writing or reviewing a query:

  • Never .select() (= SELECT *) unless the caller genuinely uses every column. Default to an explicit column list. This is most critical on anything that (a) runs in a poll loop or per-request hot path, or (b) reads a table with a large jsonb column (tasks, pull_requests, workspaces.logo, integrations.config).
  • Reuse the established projection helpers — don't invent new shapes:
    • services/taskSerialize.ts → taskColumnsNoTranscript (every tasks column except transcript) + rowToTask(row, { includeTranscript? }). Any task read that doesn't render the transcript should use this. Only GET /tasks/:id and POST /tasks/:id/message select the full row.
    • For poll-loop / hot-path reads on pull_requests, define an as const projection object next to the consumer and type the row as Pick<typeof table.$inferSelect, keyof typeof PROJECTION>. Existing examples: QUEUE_COLUMNS (mergeQueueProcessor.ts), WATCH_COLUMNS (prAutoMergeWatcher.ts), PR_CACHE_COLUMNS (prCache.ts), BROADCAST_COLUMNS (mergeQueueBroadcast.ts), PR_LOOKUP_COLUMNS/PR_FLAG_COLUMNS (routes/pullRequests.ts), CLOUD_ENV_COLUMNS (taskQueue.ts). The Pick type is the regression guard — tsc fails if a consumer later reads a column the projection drops, so it can never silently re-bloat.
  • Compare a date with lt/gt/lte, NEVER with a raw sql fragment. sql\${col} < ${someDate}`andlt(col, someDate)look interchangeable and are not: the interpolated value carries no column type, so drizzle passes it to the driver unencoded and postgres-js throws *"The \"string\" argument must be of type string … Received an instance of Date"* before Postgres is asked. **The suite cannot catch this** — pglite's drizzle session encodes the Date itself, so it passes locally and fails on every request in production. It killed the entire loop scheduler sweep (Session 127); the guard isloopQueryParams.test.ts, which asserts no Date` reaches the driver.
  • If you only need a scalar/boolean derived from a big jsonb, compute it in SQL — don't fetch the blob. Use a sql<...> expression so the column never ships. Precedents: cloudProviders/poller.ts derives transcriptEmpty with a CASE … jsonb_array_length(transcript) …; prMonitor.fastPollWorkspace derives the in-flight check count with COALESCE((last_summary -> 'checks' ->> 'inProgress')::int, 0) instead of selecting lastSummary. When you do this, pin the SQL to the JS semantics it replaces with a pglite test (see cloudPollerEgress.test.ts, prMonitorFastPollEgress.test.ts) — keep the JS helper exported as the canonical definition the SQL must match.
  • Don't fetch a column to read it once for a rare branch. If a loop reads N rows but only needs an expensive column for the few that hit a condition (e.g. reconcileRelationshipFlags only needs lastSummary for rows whose flags changed), drop it from the bulk select and re-fetch it per-row inside the branch — N blob fetches/tick become K (usually 0).
  • The same discipline applies to what leaves the backend. WS broadcasts and REST responses should serialize a crafted shape, never a raw full row (see emitPullRequestUpdated / rowToPublicShape). Don't echo transcript or unread jsonb to the desktop.

When in doubt, add a .toSQL() assertion (expect(query.toSQL().sql).not.toContain('transcript')) — it proves the projection excludes the blob without a live DB (see projectionEgress.test.ts).

Active Priorities

Full list in docs/ROADMAP.md. The active direction is the cloud-provider abstraction in docs/CLOUD_PROVIDERS.md. (The daemon-everywhere / continuous-build / local-execution era docs were deleted in July 2026 — see docs/SESSIONS.md history if you need them.)

  1. Cloud provider abstraction — the seam is done and has two live providers: Talyn Fleet (services/selfHosted/* + cloudProviders/selfhosted/provider.ts, the default) and PostHog Code. Codex Cloud is deferred — OpenAI exposes no server-to-server cloud-task API (only the codex cloud CLI or @codex GitHub mentions); note this is a different thing from running Codex on the fleet, which works. Each provider is a self-contained client + credentials + executor + poller + provider module — no core changes. Selection is generic: defaultCloudProvider (selfhosted | posthog_code | ask) drives the backend resolver (resolveCloudEnvChain) and both front ends, with an "Ask every time" per-task agent menu on each PR row (the fleet contributes one entry per connected subscription, each carrying its model). Follow-ups: the deferred TranscriptSource/TranscriptConverter generalisation; measuring the real ChatGPT access-token lifetime, which decides whether server-side Codex refresh is worth its ToS exposure.
  2. Desktop polish — the composer still has no freeform task entry; every task starts from a PR row or the skill picker. (The dead local-task UI — TaskFilesPanel/TaskGitPanel/awaiting_review flow — was removed in Session 52.)
  3. Phase 18.2 polish — proper talyn login PKCE flow, CLI refresh-token rotation, invite flow.

Recent work: see docs/SESSIONS.md, newest first — one note per session, with the reasoning and the things that turned out not to work.

File Structure

fastowl/
├── apps/
│   ├── desktop/                  # Electron desktop app
│   │   └── src/
│   │       ├── main/             # main + preload
│   │       └── renderer/         # React frontend (components, hooks, stores, lib)
│   ├── web/                      # @talyn/web — browser app (app.talyn.dev), Vite + React 19
│   └── admin/                    # @talyn/admin — operator console (admin.talyn.dev), Vite + React 19
├── packages/
│   ├── backend/                  # Express + WS server, DB, services
│   ├── cli/                      # @talyn/cli — `fastowl` binary
│   ├── client/                   # @talyn/client — REST + WS transport, shared by every front end
│   ├── mcp-server/               # @talyn/mcp-server — stdio MCP for child Claudes
│   └── shared/                   # Shared TS types
│   # (packages/daemon removed in the cloud-only refactor)
├── docs/                         # ARCHITECTURE, ROADMAP, SESSIONS, CLOUD_PROVIDERS, SETUP, etc.
├── supabase/                     # Local dev Supabase stack: `npm run dev:db` (config.toml +
│                                 # gitignored .env). Local dev must NEVER point at the prod
│                                 # DB / GitHub OAuth app — see docs/SETUP.md §0 for the why.
├── CLAUDE.md                     # This file
└── package.json                  # npm workspace root

Inside packages/backend/src/: db/ (migrations + Drizzle schema/client), routes/ (REST), services/ (taskQueue, cloudProviders/ (registry + poller + posthog/claude providers), posthogCode/ (client/executor/streamer/converter), claudeCode/ (client/credentials/executor/poller/converter — Anthropic Managed Agents, poll-based transcript), github, prMonitor, prCache, taskPullRequest, events, websocket), __tests__/ (Vitest).

Inside apps/desktop/src/renderer/components/: layout/, modals/, panels/, terminal/, widgets/, ui/ (shadcn).

apps/web is a deliberate FORK of the desktop renderer, not a shared build of it. Tom's call: every UI feature gets built twice from here on, in exchange for the two clients being able to diverge freely. What is NOT forked is the backend contract — both import @talyn/client — because two copies of that drift into runtime bugs rather than type errors. Three things the fork must keep straight, all verified with a spike before the app existed:

  • Env is import.meta.env.VITE_*, never process.env.*. Vite's define entries are "defined as globals during dev and statically replaced during build", so mirroring webpack's EnvironmentPlugin with a define of process.env.TALYN_API_URL serves the dev browser an unsubstituted expression that throws on the missing process global. vite.config.ts fails a production build outright when a required key is empty (a white screen on a public URL is much worse than the desktop's runtime throw) and refuses any value containing service_role.
  • OAuth is a full-page redirect — signInWithOAuth with no skipBrowserRedirect, plus detectSessionInUrl: true. The desktop's openExternal(data.url) fires after two awaits, so its window.open fallback has lost user activation and Safari/Firefox block it, silently, on the sign-in screen.
  • Never carry migrateLegacyAuthFromLocalStorage across. On web the "bridge" IS localStorage, so its setItem-then-removeItem on the same key wipes the session every page load.

packages/client ships dual-format (dist/cjs + dist/esm, picked by the exports map) because Rollup cannot statically see the re-exports tsc's CommonJS output emits as Object.defineProperty(exports, …) getters — Vite fails with "not exported by" — while the desktop's jest suite still needs CJS. Don't collapse it to one format without checking both.

Browser-origin surface (all inert until app.talyn.dev exists): services/originPolicy.ts is the one answer to "may this origin talk to us", shared by the REST CORS gate and the WS upgrade — exact string match, never a pattern (ALLOWED_ORIGINS), because a prefix/suffix rule is how https://app.talyn.dev.evil.com gets in. A rejected origin now denies by omitting the header (cb(null, false)) instead of throwing a 500, CORS is credentials: false (the API is Bearer-only, so CSRF-immunity is structural) with maxAge: 86400 (the non-safelisted client-version header preflights every request). The null-origin concession for the packaged renderer's file:// WS handshake is forgeable by any page and sits behind TALYN_ALLOW_NULL_ORIGIN_WS — flip it to 0 the day anything moves to cookie auth, or it becomes a live cross-site WebSocket hijack. services/webApp.ts owns WEB_APP_URL: read only from env, validated at boot, and webAppUrl() refuses any path that isn't single-slash-relative — it's the GitHub App callback's redirect target, and an open redirect there turns a login flow into a phishing hop. The callback ends per-client (browser → 302 home, desktop → close-this-tab page), decided by the Origin recorded server-side when the state was minted.

packages/client is the single definition of the backend contract — every route signature, every WS event type, the 401-refresh-and-replay, the reconnect backoff. Anything that talks to the backend imports it, so a route change can't be applied to one front end and forgotten in the other. It knows nothing about how a host stores a session or where its build-time env came from: hosts call configureApiClient({ baseUrl, clientVersion, getAccessToken, recoverSession }) once at module scope. The desktop's binding is apps/desktop/src/renderer/lib/api.ts — ~50 lines of Supabase/process.env glue plus export * from '@talyn/client', so the ~40 files importing '../lib/api' never had to move. Add new endpoints here, not in a host app, and remember it compiles to dist (lib: ["ES2022", "DOM"]), so it must be built before the desktop build, the typecheck, or jest.