Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162import type { StrongRef } from '@radial/core'
/** Every forge Radial can reason about. The kind is data, not a branch point: only `cli.ts` (which * builds adapters) and `config.ts` (which parses them) ever match on it. */export type ForgeKind = 'github' | 'tangled'
export interface GitHubRepo { owner: string repo: string}
export interface PullRequestState { state: 'open' | 'merged' | 'closed' mergedAt?: string headRef: string /** The repository the head branch lives in, in whatever identifier the adapter's own * `projectIdentity` returns (`owner/repo` for GitHub, a DID for tangled) — the reuse guard in * `dispatch.ts` compares it against the project's identity from the SAME adapter, so the two * sides are always in the same namespace. */ headRepoFullName: string baseRef: string}
/** * What the daemon knows about the project a pull request belongs to, when it happens to know it. * * A pull request URL is self-describing on GitHub and is not on tangled: a tangled pull names its * repository by the repository's own DID, and the merge that closes it is a record in the repo * OWNER's atproto repo — a DID that appears nowhere in the pull. So an adapter with no appview to * fold state for it can only find the owner by starting from the project's `gitUrl`. * * Optional on purpose. Every caller could pass it in principle, but the merge poller's contract is * "a URL is enough", and an adapter must still answer without it (GitHub ignores it entirely). */export interface PullStateContext { /** The project's remote, exactly as the project record carries it. */ gitUrl?: string}
/** What the daemon hands an adapter that opens the pull request itself (tangled). GitHub implements * no such method — its turns open their own pull request with `gh` from inside the container. */export interface OpenPullInput { /** The project's remote, exactly as the project record carries it. */ gitUrl: string /** The base branch the pull targets (the project's default branch). */ base: string /** The head branch the turn pushed. */ branch: string /** The exact head commit the turn submitted. */ commit: string /** The commit the checkout started from, when the daemon knows it (it always does for an * implementation turn — `turn.ts` resolves it at clone time). It is the graft point of a shallow * clone, and so the one revision `format-patch` can always name. */ baseCommit?: string /** The daemon-owned checkout this turn ran in. The container's commits are already in it, so the * patch is generated from here rather than fetched back down from the remote. */ checkoutPath: string title: string body: string}
export interface OpenedPull { /** What lands in `artifact.links.pr`. A `format: uri` string — an `at://` record URI is as valid * a value there as an `https://` page. Tangled normally derives the web page through Bobbin and * falls back to the record URI unless the web page itself proves its display number. */ url: string /** The pull record itself, when the adapter wrote one. Persisted on the turn ledger so a retry * after a crash between the record write and the artifact write finds it without a list. */ record?: StrongRef}
/** Per-turn git credentials an adapter needs a container to hold. GitHub returns nothing (its * `GH_TOKEN` is threaded by `turn.ts` from the operator's own possession); tangled returns the * agent's ed25519 push key and the pinned known_hosts that make using it safe. */export interface TurnForgeGrant { /** Files written into the per-turn run directory and bind-mounted read-only into the container. */ files: Array<{ name: string; contents: string; mode: number }> /** Environment the container gets on top of what `turn.ts` sets. */ env: Record<string, string> /** Values that must never appear in an operator log line. */ secrets: string[] /** The remote a push must target when it is not `origin`. Exported as `$RADIAL_PUSH_REMOTE`. */ pushUrl?: string}
/** * One forge, for one project. Phase 4.5 kept this observation-only because a GitHub turn opens its * own pull request with `gh`; tangled has no CLI to do that with, so `openPullRequest` and * `turnEnvironment` are OPTIONAL capabilities an adapter grows when the container cannot act for * itself. An adapter that implements neither behaves exactly as `GitHubForge` always has. */export interface ForgeAdapter { readonly kind: ForgeKind /** True when this adapter is the one that speaks for `gitUrl` — host-based routing, so a space * may hold a GitHub project and a tangled project at once. */ matchesGitUrl(gitUrl: string): boolean /** Can this adapter answer `getPullRequestState` right now? False for an adapter built without * the credential it needs (a GitHub adapter with no token), which is still perfectly able to * reason about URLs. */ readonly canObserve: boolean /** Does `prUrl` name a pull request of the repository at `gitUrl`? Replaces the pair of GitHub * parse calls the dispatcher and the turn socket used to make. Async because a tangled pull is a * record whose target has to be read before the question can be answered. Throws when either * side is unparseable — callers treat a throw the way they treat `false`. */ pullBelongsToProject(prUrl: string, gitUrl: string): Promise<boolean> /** Is this pull request URL one this adapter can speak about at all? Synchronous and cheap — it * is how the merge poller routes a URL to an adapter without knowing the project it came from. */ ownsPullUrl(prUrl: string): boolean /** `context` is a hint, never a requirement — see `PullStateContext`. */ getPullRequestState(prUrl: string, context?: PullStateContext): Promise<PullRequestState> compareUrl(gitUrl: string, base: string, commit: string): string /** How this adapter names the repository at `gitUrl`, in the same namespace * `PullRequestState.headRepoFullName` uses. */ projectIdentity(gitUrl: string): Promise<string> /** Present only where the daemon must open the pull request because the container cannot. When * absent, `--pr` stays REQUIRED on an implementation submit. */ openPullRequest?(input: OpenPullInput): Promise<OpenedPull> /** Present only where a turn needs credentials this adapter owns (tangled's ssh push key). */ turnEnvironment?(input: { gitUrl: string }): Promise<TurnForgeGrant> /** Why an implementation turn cannot run on this forge right now, or undefined when it can. * Evaluated per project in `dispatch.ts`, which is what lets a tangled-only operator run at all: * a missing GitHub token used to disable implementations for EVERY project. */ implementationBlockedReason?(): string | undefined}
/** * The forges this daemon speaks, selected per project by the host of its `gitUrl`. * * A registry rather than one global adapter for two reasons: a space can hold projects on different * forges, and `run.forge` was a single switch that also gated `implementationEnabled` for * everything — so one missing GitHub token stopped implementations on projects that never needed * one. First match wins; ordering is the operator's config order. */export class ForgeRegistry { readonly adapters: readonly ForgeAdapter[]
constructor(adapters: readonly ForgeAdapter[] = []) { this.adapters = adapters }
/** The adapter that speaks for this project's remote, or undefined when none does. A malformed * `gitUrl` matches nothing rather than throwing — a member-authored field is untrusted input. */ for(gitUrl: string): ForgeAdapter | undefined { for (const adapter of this.adapters) { try { if (adapter.matchesGitUrl(gitUrl)) return adapter } catch { // A parse failure is "not mine", never a crash of the whole lookup. } } return undefined }
ofKind(kind: ForgeKind): ForgeAdapter | undefined { return this.adapters.find((adapter) => adapter.kind === kind) }
get size(): number { return this.adapters.length }}