// The tangled adapter (https://tangled.org). Written against tangled's `sh.tangled.*` lexicons as // they stood in July 2026 (core @ v1.16-alpha): // // sh.tangled.repo.pull key: tid { title, body?, target:{repo,branch}, source?:{branch,repo?}, // rounds:[{patchBlob, createdAt}], createdAt, ... } // sh.tangled.repo.pull.status key: tid { pull: at-uri, status: sh.tangled.repo.pull.status.{open,closed,merged}, // createdAt } // sh.tangled.repo key: any { knot, name?, repoDid?, description?, ... } // // THE IDENTITY RULE, because getting it wrong is silent and total (see `TangledRepoIdentity`): a // repository on tangled has its OWN did, distinct from the did of the person who owns it. A pull's // `target.repo` names the REPO did; the `sh.tangled.repo` record lives in the OWNER's atproto repo // and names the repo did through `repoDid`; and a merge — `sh.tangled.repo.pull.status` — is written // by whoever ruled, into THEIR repo, which for a repository's own project is its owner. Never the // repo did's: that did resolves to a knot, which serves no `com.atproto.repo.*` at all. So every // comparison here is repo-did against repo-did, and every status scan looks in a person's repo. // // Tangled is pre-1.0 and its lexicons move (a `pull.status` gained `createdAt`; v1.14/v1.15-alpha // changed the knot's canonical repo handle and moved collaborators from records to XRPC). So every // record read here is treated as UNTRUSTED INPUT: parsed defensively, unknown fields ignored, no // exhaustiveness asserted over `knownValues`. Any lookup failure THROWS rather than guessing — // `MergePoller` treats a throw as "back off and retry", never as "not merged", and `dispatch.ts` // treats it as "open a fresh branch". // // Reads prefer Bobbin, tangled's public read-only appview (`bobbin.ts`), which folds a pull's state // for us; the direct PDS reads below remain as the fallback and as the only path for the agent's // own records. Writes are always direct-to-PDS — Bobbin serves no write endpoint, by design. // // Two Radial instincts this file deliberately bends, both flagged where they happen: // - It MUTATES a record in place (`putForeign` on the pull, appending a round). That is legal // because the record is tangled's, not Radial's — no materializer ever reads it (boundary 3 // governs `com.disnetdev.radial.*`). Every append pins `swapRecord` so a lost update fails loudly. // - It writes a record of another protocol's lexicon at all. `createForeign` refuses Radial NSIDs // precisely so that stays a one-way door. import { gzipSync } from 'node:zlib' import type { ForeignRecordReader } from '@radial/atproto' import type { StrongRef } from '@radial/core' import type { BobbinPull, BobbinReader } from './bobbin.js' import { defaultGitExec, type GitExec } from './bundle-writer.js' import { numberForPull, pullAtNumber, type PageFetch } from './tangled-pages.js' import type { ForgeAdapter, OpenPullInput, OpenedPull, PullRequestState, PullStateContext, TurnForgeGrant, } from './forge.js' export const TANGLED_PULL = 'sh.tangled.repo.pull' export const TANGLED_PULL_STATUS = 'sh.tangled.repo.pull.status' export const TANGLED_REPO = 'sh.tangled.repo' export const TANGLED_PUBLIC_KEY = 'sh.tangled.publicKey' /** The appview tangled itself runs. Overridable so a self-hosted appview/knot works the same way. */ export const DEFAULT_TANGLED_HOSTS = ['tangled.org'] as const /** Where a pull's `patchBlob` says its bytes are. */ const PATCH_CONTENT_TYPE = 'application/gzip' /** How many pages a scan will walk per subject before giving up — of `listRecords` on a PDS, or of * the appview's own newest-first pull index. A pull's status records are few and the pull Radial is * asking about is recent; this only bounds a repo with an enormous unrelated history. */ const STATUS_PAGE_LIMIT = 5 const PAGE_SIZE = 100 const object = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value) const string = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined /** A repository on a tangled host: the appview/knot host, the owner as the URL spells it (a handle, * an `@handle`, or a DID), and the repo name. */ export interface TangledLocation { host: string owner: string repo: string } /** * The two DIDs a tangled repository has, which are NOT the same DID — the fact this adapter is built * around and got wrong once. * * `https://tangled.org/@tangled.org/core` is owned by `did:plc:wshs7t2adsemcrrd4snkeqli` * (`alsoKnownAs: at://tangled.org`, on an ordinary PDS), while the repository itself is * `did:plc:j5hmlfdrwkvtxm7cjmu7j2is` (empty `alsoKnownAs`, `#atproto_pds` = `knot1.tangled.sh`). * Pulls target the second; the repo record — and the `pull.status` record an owner writes when they * merge — live in the first. Comparing one against the other is always false, and silently so. */ export interface TangledRepoIdentity { /** The person or org that holds the `sh.tangled.repo` record, on a real PDS. Where a merge's * `sh.tangled.repo.pull.status` record lands when the owner is the one who merged. */ owner: string /** The repository's own DID — what a pull's `target.repo` names, and what `projectIdentity` * returns. Knot-hosted, so it answers no `com.atproto.repo.*` call. */ repo: string /** The `sh.tangled.repo` record the pair was read out of. */ uri: string } const SEGMENT = /^@?[A-Za-z0-9](?:[A-Za-z0-9._:-]*[A-Za-z0-9])?$/ /** * `https://tangled.org//`, `git@tangled.org:/` and * `ssh://git@tangled.org//` all name the same repository. Owner may be a handle, * an `@handle`, or a DID. Throws for anything else — including a host this adapter was not * configured for, which is what keeps `matchesGitUrl` host-routed. */ export function parseTangledRepoUrl( gitUrl: string, hosts: readonly string[] = DEFAULT_TANGLED_HOSTS, ): TangledLocation { const fail = (): never => { throw new Error(`not a tangled repository URL: ${gitUrl}`) } let host: string let path: string if (/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(gitUrl)) { let url: URL try { url = new URL(gitUrl) } catch { return fail() } if (url.password) return fail() host = url.hostname.toLowerCase() path = url.pathname } else { const scp = /^(?:[^@/:]+@)?([^@/:]+):(.+)$/.exec(gitUrl) if (!scp) return fail() host = (scp[1] as string).toLowerCase() path = scp[2] as string } if (!hosts.some((candidate) => candidate.toLowerCase() === host)) return fail() const segments = path.replace(/^\/+/, '').replace(/\.git$/, '').split('/') const [owner, repo] = segments if (segments.length !== 2 || !owner || !repo || !SEGMENT.test(owner) || !SEGMENT.test(repo)) return fail() return { host, owner, repo } } /** An `at:///sh.tangled.repo.pull/` record URI, or undefined. */ export function parsePullAtUri(value: string): { did: string; rkey: string } | undefined { const match = /^at:\/\/(did:[^/]+)\/sh\.tangled\.repo\.pull\/([^/#?]+)$/.exec(value) return match ? { did: match[1] as string, rkey: match[2] as string } : undefined } /** `https://///pulls/` — what a human pastes, and what the appview shows. * Accepted so a pull opened by hand in the browser is still one Radial can observe. */ export function parseTangledPullUrl( prUrl: string, hosts: readonly string[] = DEFAULT_TANGLED_HOSTS, ): { location: TangledLocation; number: number } | undefined { let url: URL try { url = new URL(prUrl) } catch { return undefined } if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash) { return undefined } const host = url.hostname.toLowerCase() if (!hosts.some((candidate) => candidate.toLowerCase() === host)) return undefined const match = /^\/([^/]+)\/([^/]+)\/pulls\/([1-9][0-9]*)$/.exec(url.pathname) if (!match) return undefined const owner = match[1] as string const repo = match[2] as string if (!SEGMENT.test(owner) || !SEGMENT.test(repo)) return undefined return { location: { host, owner, repo }, number: Number(match[3]) } } /** The ssh remote a push must target. Tangled knots accept git over ssh only; the https URL a * project records is for cloning and browsing. Built from validated segments, never interpolated * from raw input. */ export function tangledPushUrl(location: TangledLocation): string { return `git@${location.host}:${location.owner.replace(/^@/, '')}/${location.repo}` } /** The shape of a `sh.tangled.repo.pull` record, as much of it as Radial reads. */ export interface TangledPull { uri: string cid: string author: string title?: string body?: string target: { repo: string; branch: string } source?: { branch: string; repo?: string } rounds: unknown[] createdAt?: string } /** A DID out of whatever `target.repo`/`source.repo` carries. The lexicon says `format: did`, but * tangled has moved repo identity before, so an `at:///sh.tangled.repo/` value is read * for its authority rather than rejected. */ export function repoDid(value: unknown): string | undefined { const text = string(value) if (!text) return undefined if (text.startsWith('did:')) return text const match = /^at:\/\/(did:[^/]+)\//.exec(text) return match ? (match[1] as string) : undefined } function parsePull(record: { uri: string; cid: string; value: unknown }): TangledPull { const value = record.value if (!object(value)) throw new Error(`${record.uri} is not a record object`) const target = value.target if (!object(target)) throw new Error(`${record.uri} has no target`) // Live records carry the repo did twice — `repo` (what the lexicon declares) and `repoDid` (what // tangled's own client also writes). Either one answers the question; reading both means a record // written by a client that only sets one of them still folds. const targetRepo = repoDid(target.repo) ?? repoDid(target.repoDid) const targetBranch = string(target.branch) if (!targetRepo || !targetBranch) throw new Error(`${record.uri} has an unreadable target`) const author = /^at:\/\/(did:[^/]+)\//.exec(record.uri)?.[1] if (!author) throw new Error(`${record.uri} is not a record URI`) const source = object(value.source) ? value.source : undefined const sourceBranch = source ? string(source.branch) : undefined const sourceRepo = source ? (repoDid(source.repo) ?? repoDid(source.repoDid)) : undefined return { uri: record.uri, cid: record.cid, author, ...(string(value.title) ? { title: string(value.title) as string } : {}), ...(string(value.body) ? { body: string(value.body) as string } : {}), target: { repo: targetRepo, branch: targetBranch }, ...(sourceBranch ? { source: { branch: sourceBranch, ...(sourceRepo ? { repo: sourceRepo } : {}) } } : {}), rounds: Array.isArray(value.rounds) ? value.rounds : [], ...(string(value.createdAt) ? { createdAt: string(value.createdAt) as string } : {}), } } /** `sh.tangled.repo.pull.status.merged` → `merged`, and so does Bobbin's bare `merged`: the leaf of * the value either way. An unknown value reads as `open`, because the lexicon gives `knownValues`, * not an enum, and inventing a fourth state for a value tangled adds later would be worse than * treating it as "still live work". */ export function statusState(value: unknown): 'open' | 'merged' | 'closed' { const text = string(value) ?? '' const leaf = text.slice(text.lastIndexOf('.') + 1) if (leaf === 'merged') return 'merged' if (leaf === 'closed') return 'closed' return 'open' } /** A pull's state as somebody observed it, and when that observation was written. `at` is the * `mergedAt` of a merged pull; it is absent for a pull nothing has ruled on yet. */ interface ObservedState { state: 'open' | 'merged' | 'closed' at?: string } export interface TangledActor { did: string createForeign(collection: string, value: unknown): Promise putForeign( collection: string, uri: string, value: unknown, options?: { swapRecord?: string }, ): Promise uploadBlob(bytes: Uint8Array, contentType: string): Promise<{ $type: 'blob'; ref: { $link: string }; mimeType: string; size: number }> } export interface TangledForgeOptions { /** Unvalidated record reads — `FetchRepoTransport` satisfies this. Used for the agent's OWN pull * records (which no appview can answer more authoritatively than the agent's PDS) and as the * fallback fold when `bobbin` is absent or unreachable. */ reader: ForeignRecordReader /** Tangled's read-only appview, where a pull's folded state comes from. Absent → the adapter reads * `sh.tangled.repo.pull.status` records off PDSes itself, which can only scan the DIDs it is able * to guess, and cannot scan a DID whose `#atproto_pds` is a knot (see `getPullRequestState`). */ bobbin?: BobbinReader /** Fetches the web appview for exact page ↔ pull-record mapping. False disables page links. */ pageFetch?: PageFetch | false /** Hosts this adapter speaks for. Default `tangled.org`; a self-hosted knot adds its own. */ hosts?: readonly string[] /** handle → DID. Needed because a tangled URL names an owner by handle while a pull record names * it by DID. Omitted, only DID-shaped owners can be compared. */ resolveHandle?: (handle: string) => Promise /** The agent whose repo a pull record is written into — the same DID that signs the artifact. * Absent → `openPullRequest` is still present but refuses, so a misconfigured daemon fails loudly * at submit time rather than silently opening nothing. */ actor?: () => TangledActor | undefined /** Push material for an implementation turn (see `forge-auth.ts`). Absent → implementation turns * on this forge are blocked with a reason the operator can act on. */ push?: () => Promise<{ privateKey: string; knownHosts: string } | undefined> now?: () => string git?: GitExec log?: (message: string) => void } /** * Tangled, as one of Radial's forges. * * Observation (`getPullRequestState`) needs no new credential: reads go to Bobbin, tangled's public * read-only XRPC appview (`bobbin.ts`), which has already folded the pull's state from the firehose. * With no Bobbin configured — or with one that is unreachable — the adapter falls back to reading * `sh.tangled.repo.pull.status` records straight off PDSes, which still works but can only scan the * repos it can guess. Creation (`openPullRequest`) is daemon-side because tangled ships no * user-facing CLI — there is nothing a container could run to open a pull request — and because the * daemon is where record construction belongs anyway (boundary 6). Writes never touch Bobbin: it is * read-only by design, and tangled's own answer for writes is "direct to the PDS". */ export class TangledForge implements ForgeAdapter { readonly kind = 'tangled' as const readonly canObserve = true readonly hosts: readonly string[] readonly #options: TangledForgeOptions readonly #didCache = new Map() readonly #repoCache = new Map() readonly #pageByPull = new Map() readonly #pullByPage = new Map() constructor(options: TangledForgeOptions) { this.#options = options this.hosts = options.hosts ?? DEFAULT_TANGLED_HOSTS } matchesGitUrl(gitUrl: string): boolean { try { parseTangledRepoUrl(gitUrl, this.hosts) return true } catch { return false } } /** The repository's own DID — the identifier a pull record's `target.repo` uses, so * `PullRequestState.headRepoFullName` and this are in the same namespace. NOT the owner's DID: * see the identity rule at the top of this file. */ async projectIdentity(gitUrl: string): Promise { return (await this.#repoIdentity(parseTangledRepoUrl(gitUrl, this.hosts))).repo } /** * Both DIDs behind a tangled repository URL, read from the `sh.tangled.repo` record the URL names. * * The URL gives a handle and a repo name; everything downstream needs DIDs. The record is in the * OWNER's atproto repo — a real PDS, because an owner is a person or an org — and its `repoDid` * field is the repository's own DID. Two rkey conventions live side by side on tangled (older * records are keyed by the repo name, `…/sh.tangled.repo/core`; newer ones by a tid with the name * in `value.name`), so the owner's records are scanned and matched on either. * * Cached: a repo's DID does not change, and this sits under `pullBelongsToProject`, which the * dispatcher calls on every v2 turn. */ async #repoIdentity(location: TangledLocation): Promise { const key = `${location.host}/${location.owner.replace(/^@/, '')}/${location.repo}` const cached = this.#repoCache.get(key) if (cached) return cached const owner = await this.#ownerDid(location) const record = await this.#findRepoRecord(owner, location.repo) if (!record) { throw new Error( `no ${TANGLED_REPO} record named "${location.repo}" under ${owner}: ` + `${location.host}/${location.owner}/${location.repo} is not a repository this owner holds`, ) } const value = object(record.value) ? record.value : {} // `repoDid` post-dates the first `sh.tangled.repo` records. A record without it is read as // owner-identified, which is the only other thing its pulls could have named. const identity: TangledRepoIdentity = { owner, repo: repoDid(value.repoDid) ?? owner, uri: record.uri, } this.#repoCache.set(key, identity) return identity } async #findRepoRecord( owner: string, name: string, ): Promise<{ uri: string; cid: string; value: unknown } | undefined> { let cursor: string | undefined for (let page = 0; page < STATUS_PAGE_LIMIT; page += 1) { const result = await this.#options.reader.listForeignRecords({ did: owner, collection: TANGLED_REPO, limit: PAGE_SIZE, ...(cursor ? { cursor } : {}), }) for (const record of result.records) { const value = object(record.value) ? record.value : {} const named = string(value.name) ?? record.uri.slice(record.uri.lastIndexOf('/') + 1) if (named === name) return record } cursor = result.cursor if (!cursor || result.records.length === 0) break } return undefined } async #ownerDid(location: TangledLocation): Promise { const owner = location.owner.replace(/^@/, '') if (owner.startsWith('did:')) return owner const cached = this.#didCache.get(owner) if (cached) return cached const resolve = this.#options.resolveHandle if (!resolve) { throw new Error(`cannot resolve tangled owner "${owner}" to a DID: no handle resolver configured`) } const did = await resolve(owner) if (!did.startsWith('did:')) throw new Error(`handle ${owner} resolved to a malformed DID: ${did}`) this.#didCache.set(owner, did) return did } /** The record behind either spelling `links.pr` may carry. An appview number exists only in the * page database, so a page URL is accepted only when that page proves the record URI it names. */ async #pullAtUri(prUrl: string): Promise<{ did: string; rkey: string; uri: string }> { const atUri = parsePullAtUri(prUrl) if (atUri) return { ...atUri, uri: prUrl } const page = parseTangledPullUrl(prUrl, this.hosts) if (page) { const cached = this.#pullByPage.get(prUrl) const fetchPage = this.#options.pageFetch const uri = cached ?? (fetchPage ? await pullAtNumber(fetchPage, page.location, page.number) : undefined) if (uri) { const parsed = parsePullAtUri(uri) if (parsed) { this.#pullByPage.set(prUrl, uri) this.#pageByPull.set(uri, prUrl) return { ...parsed, uri } } } throw new Error( `cannot resolve ${prUrl} to a sh.tangled.repo.pull record: an appview page URL carries an ` + 'appview-assigned number and the page did not prove which pull record it names', ) } throw new Error(`not a tangled pull request URL: ${prUrl}`) } /** The pull record alone, for a question that does not need its state. Bobbin's `getPull` is a * live fetch rather than an index read, so it is exact even for a record written seconds ago; the * author's PDS answers the same question if the appview is unreachable. `appview: false` skips it * for a caller that has already watched the appview fail this poll — one log line per outage, not * two. */ async #loadRecord(prUrl: string, { appview = true } = {}): Promise { const { uri } = await this.#pullAtUri(prUrl) const bobbin = appview ? this.#options.bobbin : undefined if (bobbin) { try { return parsePull(await bobbin.getPull(uri)) } catch (error) { this.#appviewFailed(uri, error) } } return parsePull(await this.#options.reader.getForeignRecord(uri)) } /** Both spellings `links.pr` may carry on this forge: the pull record's own at-uri, and the * appview page a human would paste. */ ownsPullUrl(prUrl: string): boolean { return Boolean(parsePullAtUri(prUrl) ?? parseTangledPullUrl(prUrl, this.hosts)) } async pullBelongsToProject(prUrl: string, gitUrl: string): Promise { const project = await this.projectIdentity(gitUrl) const pull = await this.#loadRecord(prUrl) return pull.target.repo === project } /** * The pull's record, plus the newest state anybody has recorded for it. * * Bobbin is asked first, and its ruling is taken when it has one: it folds * `sh.tangled.repo.pull.status` records across the WHOLE network, which is the part Radial cannot * do alone. A status lives in the repo of whoever RULED on the pull — verified live: a maintainer * who merges writes it into their own repo, not the project owner's — so a third-party * collaborator's merge lands somewhere Radial would never think to look. State comes from * `listPullsBy` and not `getPull`, because `getPull` returns only the record. * * `open`, though, is not a ruling. Bobbin's index is backfilled from a firehose stream and reports * `open` for anything outside the window it has covered, which is a perfectly successful HTTP call * that says "no ruling in my index" — not "nobody ruled". Verified live in July 2026: pulls whose * merge/close status records are readable on a PDS come back `open` from `listPullsBy`. So an * `open` is corroborated against the direct fold, and a status record beats it. A merge or a close * is taken as-is: those are rulings, and where both sources have one they agree. * * The direct fold — also the whole answer when Bobbin is unreachable, not configured, or has not * indexed a pull this new — scans the two repos a ruling plausibly lands in: the pull's author, * and the repository OWNER (from `context.gitUrl`, the only place the owner can come from, since a * pull record names the repo's own DID and that DID is a knot with no records in it). Newest * `createdAt` wins, and no status at all means `open` (the lexicon's own default). Where the fold * is the only source a read failure throws, so the poller backs off and never mistakes a broken * lookup for "not merged"; where it is only corroborating, a failure leaves Bobbin's answer alone. */ async getPullRequestState(prUrl: string, context: PullStateContext = {}): Promise { const { did, uri } = await this.#pullAtUri(prUrl) const served = await this.#appviewPull(uri, did) const pull = typeof served === 'object' ? parsePull(served) : await this.#loadRecord(uri, { appview: served === 'unindexed' }) const newest = typeof served === 'object' ? await this.#corroborate( pull, { state: statusState(served.state), ...(served.stateUpdatedAt ? { at: served.stateUpdatedAt } : {}) }, context, ) : await this.#foldStatuses(pull, await this.#ownerOf(context.gitUrl)) return { state: newest.state, headRef: pull.source?.branch ?? '', headRepoFullName: pull.source?.repo ?? pull.target.repo, baseRef: pull.target.branch, ...(newest.state === 'merged' && newest.at ? { mergedAt: newest.at } : {}), } } /** An appview `open` checked against the records themselves; anything else is a ruling and stands. * Skipped when there is no owner to scan — the ruling Radial waits for is a merge, and only a * repo the fold can name could hold one, so without an owner the request could find nothing. A * failure here is not fatal: the appview already answered. */ async #corroborate( pull: TangledPull, served: ObservedState, context: PullStateContext, ): Promise { if (served.state !== 'open') return served const owner = await this.#ownerOf(context.gitUrl) if (!owner) return served let folded: ObservedState try { folded = await this.#foldStatuses(pull, owner) } catch (error) { this.#options.log?.( `could not corroborate the tangled appview's "open" for ${pull.uri} against PDS records ` + `(${error instanceof Error ? error.message : String(error)}); taking the appview's answer`, ) return served } if (folded.state === 'open') return served this.#options.log?.( `the tangled appview reports ${pull.uri} open, but a ${folded.state} status record says otherwise; ` + 'the record wins', ) return folded } /** * This pull as the appview's index carries it, or why it does not. The scan is keyed by the pull's * AUTHOR — which its at-uri names — and the index is newest-first, so the pull Radial is polling * is on the first page in every ordinary case. * * `'unindexed'` and `'unavailable'` are kept apart deliberately: a miss in a healthy index is not * a reason to stop asking the appview for the record itself, an outage is. */ async #appviewPull(prUrl: string, author: string): Promise { const bobbin = this.#options.bobbin if (!bobbin) return 'unavailable' try { let cursor: string | undefined for (let page = 0; page < STATUS_PAGE_LIMIT; page += 1) { const result = await bobbin.listPullsBy({ did: author, limit: PAGE_SIZE, ...(cursor ? { cursor } : {}) }) const found = result.items.find((item) => item.uri === prUrl) if (found) return found cursor = result.cursor if (!cursor || result.items.length === 0) break } this.#options.log?.( `the tangled appview's index does not carry ${prUrl} (yet); folding its status from PDSes instead`, ) return 'unindexed' } catch (error) { this.#appviewFailed(prUrl, error) return 'unavailable' } } #appviewFailed(prUrl: string, error: unknown): void { this.#options.log?.( `tangled appview lookup for ${prUrl} failed (${error instanceof Error ? error.message : String(error)}); ` + 'reading from PDSes instead', ) } async #pullPageUrl(location: TangledLocation, uri: string): Promise { const cached = this.#pageByPull.get(uri) if (cached) return cached const fetchPage = this.#options.pageFetch if (!fetchPage) return undefined try { const page = await numberForPull(fetchPage, location, uri, { ...(this.#options.log ? { log: this.#options.log } : {}), }) if (page) { this.#pageByPull.set(uri, page) this.#pullByPage.set(page, uri) } return page } catch (error) { this.#options.log?.( `cannot verify a tangled web-appview page for ${uri}: ${error instanceof Error ? error.message : String(error)}`, ) return undefined } } /** The repository owner behind a project's remote, for the status scan — or undefined when there * is no project context, or its URL is not one this adapter speaks for. Never fatal: the fold * still has the pull's author to scan, and reporting "cannot say" here would turn a missing * optional hint into a poll that never answers. */ async #ownerOf(gitUrl: string | undefined): Promise { if (!gitUrl) return undefined try { return await this.#ownerDid(parseTangledRepoUrl(gitUrl, this.hosts)) } catch (error) { this.#options.log?.( `cannot resolve the owner of ${gitUrl} to scan it for pull statuses ` + `(${error instanceof Error ? error.message : String(error)}); scanning the pull's author only`, ) return undefined } } /** * The pre-Bobbin fold, still the fallback: the newest status naming this pull, across the repos one * plausibly lands in — the pull's author (who can close their own pull) and the repository's * OWNER (who merges it). Both are people, so both have real PDSes. * * The repo's own DID is deliberately NOT scanned. It is what `target.repo` names, so it is the * obvious-looking candidate, and it is the wrong one twice over: a repo DID resolves to a knot, * which answers `404` to every `com.atproto.repo.*` call, and no `pull.status` record is ever * written into one. * * A candidate that cannot be read is SKIPPED, not fatal — an unreachable PDS for one of two repos * must not discard a status the other one already yielded. Only a fold that could read nothing at * all throws, which keeps the poller's "back off, never report not-merged" contract intact. */ async #foldStatuses(pull: TangledPull, owner?: string): Promise { const candidates = [...new Set([pull.author, ...(owner ? [owner] : [])])] let newest: { state: 'open' | 'merged' | 'closed'; createdAt: string } | undefined let read = 0 const failures: string[] = [] for (const did of candidates) { let records: Array<{ uri: string; cid: string; value: unknown }> try { records = await this.#listStatuses(did) } catch (error) { failures.push(`${did}: ${error instanceof Error ? error.message : String(error)}`) continue } read += 1 for (const record of records) { const value = record.value if (!object(value) || string(value.pull) !== pull.uri) continue const createdAt = string(value.createdAt) ?? '' if (newest && createdAt <= newest.createdAt) continue newest = { state: statusState(value.status), createdAt } } } if (read === 0) { throw new Error(`could not read pull statuses for ${pull.uri} from any repo (${failures.join('; ')})`) } if (failures.length > 0) { this.#options.log?.(`some repos could not be scanned for statuses of ${pull.uri}: ${failures.join('; ')}`) } if (!newest) return { state: 'open' } return { state: newest.state, ...(newest.createdAt ? { at: newest.createdAt } : {}) } } async #listStatuses(did: string): Promise> { const all: Array<{ uri: string; cid: string; value: unknown }> = [] let cursor: string | undefined for (let page = 0; page < STATUS_PAGE_LIMIT; page += 1) { const result = await this.#options.reader.listForeignRecords({ did, collection: TANGLED_PULL_STATUS, limit: PAGE_SIZE, ...(cursor ? { cursor } : {}), }) all.push(...result.records) cursor = result.cursor if (!cursor || result.records.length === 0) break } return all } /** Best effort: tangled has no compare route this adapter can rely on across versions, so this * points at the commit itself, which every appview serves. Used only for operator-facing links. */ compareUrl(gitUrl: string, _base: string, commit: string): string { const location = parseTangledRepoUrl(gitUrl, this.hosts) return `https://${location.host}/${location.owner}/${location.repo}/commit/${commit}` } implementationBlockedReason(): string | undefined { if (!this.#options.actor?.()) return 'no agent identity is loaded to author the pull record' if (!this.#options.push) return 'no tangled push key is configured (run "radiald init")' return undefined } /** Per-turn push credentials: the agent's ed25519 key, a PINNED known_hosts, and the ssh remote. * `StrictHostKeyChecking=yes` with a pinned file is the whole point — `no` would make every push * MITM-able and quietly weaken possession-based containment. */ async turnEnvironment(input: { gitUrl: string }): Promise { const location = parseTangledRepoUrl(input.gitUrl, this.hosts) const material = await this.#options.push?.() if (!material) { throw new Error( 'no tangled push key available: run "radiald init" to generate one and publish it as an ' + 'sh.tangled.publicKey record, then add the agent as a collaborator on the repository', ) } return { files: [ { name: 'id_ed25519', contents: material.privateKey, mode: 0o400 }, { name: 'known_hosts', contents: material.knownHosts, mode: 0o444 }, ], env: { GIT_SSH_COMMAND: 'ssh -i /run/radial-forge/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes ' + '-o UserKnownHostsFile=/run/radial-forge/known_hosts', RADIAL_PUSH_REMOTE: tangledPushUrl(location), }, secrets: [material.privateKey], pushUrl: tangledPushUrl(location), } } /** * Open the pull request the container could not, or append a round to the one this chain already * has. Called from `#submitArtifact` BEFORE the artifact record is written, so `links.pr` is * complete in the one write and the RecordAlreadyExists-adoption comparison stays exact. * * Create vs append: `sh.tangled.repo.pull` is keyed by tid, so Radial's usual deterministic-rkey * idempotency is unavailable. Instead the acting DID's own pull records are listed and matched on * (target repo, source branch) — the same pair the one-PR-per-chain rule is defined by. A match is * a `putForeign` with the new round appended and `swapRecord` pinned to the CID just read, so a * concurrent write fails loudly rather than dropping a round. */ async openPullRequest(input: OpenPullInput): Promise { const actor = this.#options.actor?.() if (!actor) throw new Error('no agent identity is loaded to author a tangled pull record') const location = parseTangledRepoUrl(input.gitUrl, this.hosts) // The repository's own DID, not its owner's — a pull whose `target.repo` named the owner would // name a repository the knot and the appview have never heard of, so it would attach to nothing // and no human could merge it. const targetRepo = (await this.#repoIdentity(location)).repo const now = this.#options.now?.() ?? new Date().toISOString() const patch = await this.#formatPatch(input) const blob = await actor.uploadBlob(gzipSync(new TextEncoder().encode(patch)), PATCH_CONTENT_TYPE) const round = { patchBlob: blob, createdAt: now } const existing = await this.#findOwnPull(actor.did, targetRepo, input.branch) if (existing) { const value = existing.value as Record const rounds = Array.isArray(value.rounds) ? [...value.rounds] : [] rounds.push(round) const ref = await actor.putForeign( TANGLED_PULL, existing.uri, { ...value, rounds }, { swapRecord: existing.cid }, ) this.#options.log?.(`appended round ${rounds.length} to tangled pull ${existing.uri}`) return { url: (await this.#pullPageUrl(location, existing.uri)) ?? existing.uri, record: ref, } } // Shaped exactly like the branch pulls tangled's own client writes: `target` carries the repo DID // under both spellings (`repo` is what the lexicon declares, `repoDid` is what live records also // carry), and `source` is a BRANCH — of 268 live pulls sampled in July 2026 none set // `source.repo` to the target repo; it appears only on fork pulls, which Radial does not open. const record = { $type: TANGLED_PULL, title: input.title, ...(input.body ? { body: input.body } : {}), target: { repo: targetRepo, repoDid: targetRepo, branch: input.base }, source: { branch: input.branch }, rounds: [round], createdAt: now, } const ref = await actor.createForeign(TANGLED_PULL, record) this.#options.log?.(`opened tangled pull ${ref.uri} against ${targetRepo}#${input.base}`) return { url: (await this.#pullPageUrl(location, ref.uri)) ?? ref.uri, record: ref, } } /** * This actor's own open-or-not pull for (target repo, source branch), newest first. * * Read from the agent's OWN PDS, not from Bobbin, and deliberately so. Bobbin's list endpoints are * served from an edge index backfilled off a firehose, so a pull written minutes ago may not be in * them yet — and a miss here does not degrade, it opens a SECOND pull request for a chain that * already has one. The agent's PDS is the repo the record was written to; it cannot lag behind * itself. Bobbin is for what Radial cannot know alone; this is not that. */ async #findOwnPull( did: string, targetRepo: string, branch: string, ): Promise<{ uri: string; cid: string; value: unknown } | undefined> { let cursor: string | undefined for (let page = 0; page < STATUS_PAGE_LIMIT; page += 1) { const result = await this.#options.reader.listForeignRecords({ did, collection: TANGLED_PULL, limit: PAGE_SIZE, ...(cursor ? { cursor } : {}), }) for (const record of result.records) { let pull: TangledPull try { pull = parsePull(record) } catch { continue // A record this adapter cannot read is not one it will append to. } if (pull.target.repo === targetRepo && pull.source?.branch === branch) return record } cursor = result.cursor if (!cursor || result.records.length === 0) break } return undefined } /** * `git format-patch --stdout ..` from the turn's own checkout — the daemon owns * that directory and the container's commits are already in it, so nothing is fetched back down. * * The range is `origin/..` first, because a ROUND must carry the whole pull's diff * and not merely what this turn added: a v2 continues its predecessor's branch, so the checkout's * own starting commit is the previous round's head. `origin/` is present either way — a * fresh branch is cloned `--single-branch --branch `, and a continued one is cloned * `--depth 50` with the base fetched alongside it. The recorded base commit is the fallback for a * checkout whose remote-tracking ref is missing, and one `--unshallow` retry covers a fork point * deeper than the clone, mirroring what the implementation prompt tells the agent to do. */ async #formatPatch(input: OpenPullInput): Promise { const exec = this.#options.git ?? defaultGitExec() const env: Record = { PATH: process.env.PATH ?? '/usr/bin:/bin', GIT_TERMINAL_PROMPT: '0', GIT_CONFIG_SYSTEM: '/dev/null', GIT_CONFIG_GLOBAL: '/dev/null', } const bases = [`origin/${input.base}`, ...(input.baseCommit ? [input.baseCommit] : [])] const attempt = async (): Promise<{ base: string; stdout: string } | { error: string }> => { let lastError = 'no base to generate a patch against' for (const base of bases) { const result = await exec( // `--binary` is not optional: without it a file git considers binary — which includes any // TEXT file that has picked up a NUL byte — reaches the round as a bodiless // `Binary files a/x and b/x differ`, and `git apply` refuses it for want of an index line. ['git', '-C', input.checkoutPath, 'format-patch', '--stdout', '--binary', `${base}..${input.commit}`], { env }, ) if (result.code === 0) return { base, stdout: result.stdout } lastError = `${base}..${input.commit} failed (exit ${result.code}): ${result.stderr}` } return { error: lastError } } let result = await attempt() if ('error' in result) { const deepened = await exec(['git', '-C', input.checkoutPath, 'fetch', '--unshallow', 'origin'], { env }) if (deepened.code === 0) result = await attempt() } if ('error' in result) throw new Error(`git format-patch ${result.error}`) if (result.stdout.trim().length === 0) { throw new Error(`git format-patch ${result.base}..${input.commit} produced no patch`) } await this.#assertRoundIsWhole(exec, env, input, result.base, result.stdout) return result.stdout } /** * A round is the whole pull's diff or it is nothing, and `git format-patch` will not say which it * gave you: handed a range containing a merge it omits that commit and still exits 0 with an empty * stderr. The round then reaches the appview as a patch series that does not apply, which a human * reads as "this pull request has conflicts" against a branch that may merge as a fast-forward. * * So count what the range holds and refuse rather than publish a round that cannot land. Merges are * named separately from the arithmetic because that failure has a specific remedy — rebase the * branch onto the base instead of merging the base into it — and because it is the one this * daemon's own implementation prompt used to steer agents into. */ async #assertRoundIsWhole( exec: GitExec, env: Record, input: OpenPullInput, base: string, patch: string, ): Promise { const range = `${base}..${input.commit}` const count = async (extra: string[]): Promise => { const result = await exec( ['git', '-C', input.checkoutPath, 'rev-list', '--count', ...extra, range], { env }, ) const parsed = Number.parseInt(result.stdout.trim(), 10) if (result.code !== 0 || !Number.isInteger(parsed)) { throw new Error( `cannot count ${range} to verify the round (exit ${result.code}): ${result.stderr.trim()}`, ) } return parsed } const merges = await count(['--merges']) if (merges > 0) { throw new Error( `refusing to open a tangled pull for ${range}: the range holds ${merges} merge commit(s) and ` + `git format-patch omits them silently, so the round would not apply to ${input.base}. ` + `Rebase the branch onto ${input.base} rather than merging ${input.base} into the branch.`, ) } const total = await count([]) const emitted = (patch.match(/^From [0-9a-f]{40} /gm) ?? []).length if (emitted !== total) { throw new Error( `refusing to open a tangled pull for ${range}: format-patch emitted ${emitted} patch(es) for ` + `${total} commit(s), so the round would be incomplete.`, ) } } }