Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457// Issues on the project's tangled repository, offered as goals somebody could write.//// A tangled issue is an `sh.tangled.repo.issue` record in the repo of whoever filed it — a stranger,// usually, holding no grant in this space. So the same rule that governs guest comments governs this,// and for the same reason: **nothing here enters the fold.** Ingestion polls member repos for// `com.disnetdev.radial.*` and nothing else, so an issue is not in any `RecordStore`, not in// `materialize()`, and cannot reach a turn bundle. That safety property holds with no code at all,// and this module is careful not to be the thing that breaks it.//// What crosses into the trusted world is a GOAL — a record a member wrote, having read the issue on// screen, in their own words if they changed them, signed by them. The goal carries `source`, a// pointer saying which issue it was taken up from, and that is all it carries: the pointer is not in// any bundle (`core/test/bundle.test.mjs` asserts it), so no turn can follow it to text nobody// reviewed. An agent cannot tell a question from an instruction, and an issue tracker is open to// anyone with an atproto account.//// Three rules, the same three `guests.ts` follows://// - **Discovery is view-time, and asked for.** Bobbin (tangled's public appview) has no SLA and is// somebody else's service; a tab that queried it on every project page would also be announcing// which repositories this browser is reading. So nothing here runs until a reader opens the// project's Tangled page, which is the same bargain the Userinput section makes with somebody// else's feedback board: the ask is the navigation, and the rail item that offers it costs a// stranger's service nothing.// - **The index is a hint, never an authority.** Bobbin returns locators and its own copy of each// record; every candidate is re-read from its author's own PDS and must still say it belongs to// this repository. An index that could decide which repository somebody's words were filed under// would be an index that could put text in front of a reader under false pretences.// - **An issue this space took up keeps its row, and says so.** The goal's `source.uri` is what// says so, which is why it is on-protocol rather than a note in somebody's browser: a second// member opening this list — on a cold browser, having imported nothing — sees what the first one// took up, which goal it became, and that importing it again is a decision rather than a mistake.
import { GOAL_SOURCE_TANGLED_ISSUE, TANGLED_ISSUE_COLLECTION, type GoalView } from '@radial/core'import { seedText, type GoalSeed } from './import-goal.js'
/** * Tangled's own read-only appview. A constant rather than a literal at the call site for the reason * `CONSTELLATION_ORIGIN` is one: it is somebody else's public instance, Bobbin is self-hostable, and * an operator who ends up needing to own it should have one place to point this at. * * `sh.tangled.repo.listIssues?subject=<repo did>` mirrors the `listPulls` the daemon already reads * (`daemon/src/bobbin.ts`), down to the response shape: `{ items: [{ uri, cid, value, state }] }`. * Verified live against api.tangled.org in July 2026, including its `access-control-allow-origin: *`, * which is what makes a browser a legal caller at all. */export const TANGLED_APPVIEW = 'https://api.tangled.org'
/** The hosts this recognises as tangled. Mirrors `DEFAULT_TANGLED_HOSTS` in the daemon's adapter. */export const TANGLED_HOSTS = ['tangled.org'] as const
/** The record a repository's `sh.tangled.repo` metadata lives in — where a repo's own DID is read. */export const TANGLED_REPO_COLLECTION = 'sh.tangled.repo'
/** Per appview call. Bobbin caps a page at 100, like every other atproto list. */export const ISSUE_PAGE = 100
/** How many pages one look walks. Closed issues are filtered here, so a long-lived repository needs * more than one page to fill a screen of open ones. */export const ISSUE_PAGES = 3
/** How many candidates a look will offer. A repository with hundreds of open issues is a repository * whose backlog belongs on the forge; this is a way in, not a second issue tracker. */export const CANDIDATE_LIMIT = 50
export type Fetcher = (input: string, init?: RequestInit) => Promise<Response>
const object = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null && !Array.isArray(value)
const string = (value: unknown): string | undefined => typeof value === 'string' && value.length > 0 ? value : undefined
/** The DID of an `at://` record URI — the repo it came from, which is what makes it provenance. */export const didOf = (uri: string): string => uri.startsWith('at://') ? (uri.slice('at://'.length).split('/')[0] ?? '') : ''
// ── which repository, and whose ─────────────────────────────────────────────────────────────────
/** A repository on a tangled host, as its URL spells it. */export interface TangledLocation { host: string /** A handle, an `@handle`, or a DID — whatever the project's remote was written with. */ owner: string repo: string}
/** * `https://tangled.org/<owner>/<repo>`, `git@tangled.org:<owner>/<repo>` and * `ssh://git@tangled.org/<owner>/<repo>` all name the same repository. * * Returns undefined rather than throwing, unlike the daemon's `parseTangledRepoUrl`: the daemon is * routing a forge operation it has already decided to perform, while the only question here is "does * this project have issues we could offer?", and a GitHub project's answer to that is no, not an * error. */export function tangledRepo( gitUrl: string, hosts: readonly string[] = TANGLED_HOSTS,): TangledLocation | undefined { 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 undefined } host = url.hostname.toLowerCase() path = url.pathname } else { const scp = /^(?:[^@/:]+@)?([^@/:]+):(.+)$/.exec(gitUrl) if (!scp) return undefined host = (scp[1] as string).toLowerCase() path = scp[2] as string } if (!hosts.some((candidate) => candidate.toLowerCase() === host)) return undefined const segments = path.replace(/^\/+/, '').replace(/\.git$/, '').split('/') const [owner, repo] = segments if (segments.length !== 2 || !owner || !repo) return undefined return { host, owner, repo }}
/** The repository's issue list on the forge. The only link this section can honestly offer: an * issue's own page is numbered by the appview's database, and the number is in neither the record * nor Bobbin's answer, so a per-issue URL would be a guess. */export const issuesPageUrl = (location: TangledLocation): string => `https://${location.host}/${location.owner}/${location.repo}/issues`
/** The reads resolving a repository needs. `FetchRepoTransport` satisfies all three. */export interface RepoReader { listForeignRecords(input: { did: string collection: string cursor?: string limit: number signal?: AbortSignal }): Promise<{ records: Array<{ uri: string; cid: string; value: unknown }>; cursor?: string }> getForeignRecord(uri: string, signal?: AbortSignal): Promise<{ uri: string; cid: string; value: unknown }>}
/** Handle → DID. The browser has no DNS, so the caller supplies a directory to ask — a PDS this tab * already reads, exactly as `write.ts` resolves an assignee's handle against the writer's own. */export type HandleResolver = (handle: string) => Promise<string>
/** * A tangled repository's own DID, which is NOT its owner's — the fact the daemon's adapter is built * around (`forge-tangled.ts`, THE IDENTITY RULE) and which is silently fatal to get wrong: an issue's * `repo` field names the repository DID, and comparing it against an owner DID is always false. * * Same algorithm as the daemon's, and deliberately so. The owner's handle resolves to a DID; the * `sh.tangled.repo` record lives in the OWNER's repo on a real PDS and its `repoDid` is the answer. * Two rkey conventions live side by side on tangled — older records are keyed by the repo name, newer * ones by a tid with the name in `value.name` — so the owner's records are scanned and matched on * either. A record without `repoDid` predates the field, and its issues can only have named the owner. * * Read from the owner's PDS rather than from Bobbin: this is the fact everything else is checked * against, so it comes from the repo that holds it. */export async function resolveRepoDid(input: { location: TangledLocation reader: RepoReader resolveHandle: HandleResolver pages?: number signal?: AbortSignal}): Promise<string> { const owner = input.location.owner.replace(/^@/, '') const ownerDid = owner.startsWith('did:') ? owner : await input.resolveHandle(owner) if (!ownerDid.startsWith('did:')) { throw new Error(`${owner} resolved to a malformed DID: ${ownerDid}`) } let cursor: string | undefined for (let page = 0; page < (input.pages ?? ISSUE_PAGES); page += 1) { const result = await input.reader.listForeignRecords({ did: ownerDid, collection: TANGLED_REPO_COLLECTION, limit: ISSUE_PAGE, ...(cursor ? { cursor } : {}), ...(input.signal ? { signal: input.signal } : {}), }) 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 === input.location.repo) return string(value.repoDid) ?? ownerDid } cursor = result.cursor if (!cursor || result.records.length === 0) break } throw new Error( `no ${TANGLED_REPO_COLLECTION} record named "${input.location.repo}" under ${owner}`, )}
// ── the issues themselves ───────────────────────────────────────────────────────────────────────
/** What Bobbin hands out per issue: where it is, and the state it folded from the firehose. */export interface IssueLocator { uri: string cid: string /** `open` / `closed` today. Read as a string rather than compared to an enum — tangled's lexicons * are `knownValues` and pre-1.0, and an unfamiliar word must not read as "open". */ state?: string}
/** * One page of `sh.tangled.repo.listIssues`, newest first. * * Anything unexpected in the response is dropped rather than thrown on, exactly as the guest-comment * backlink page does: an entry whose shape we do not recognise is an entry we cannot fetch, and one * malformed item must not lose the page it arrived in. A failed REQUEST does throw — "we could not * look" and "there is nothing" are different sentences, and only one of them is worth a reader's * attention. */export async function listIssues(input: { repoDid: string cursor?: string limit?: number origin?: string fetcher?: Fetcher signal?: AbortSignal}): Promise<{ items: IssueLocator[]; cursor?: string }> { const query = new URLSearchParams({ subject: input.repoDid, limit: String(input.limit ?? ISSUE_PAGE), ...(input.cursor ? { cursor: input.cursor } : {}), }) const url = `${input.origin ?? TANGLED_APPVIEW}/xrpc/sh.tangled.repo.listIssues?${query}` const response = await (input.fetcher ?? fetch)(url, input.signal ? { signal: input.signal } : undefined) if (!response.ok) throw new Error(`tangled's appview answered ${response.status}`) const payload: unknown = await response.json() const body = object(payload) ? payload : {} const raw = Array.isArray(body.items) ? body.items : [] const items: IssueLocator[] = [] for (const entry of raw) { if (!object(entry)) continue const uri = string(entry.uri) const cid = string(entry.cid) if (!uri || !cid) continue items.push({ uri, cid, ...(string(entry.state) ? { state: string(entry.state) as string } : {}) }) } return { items, ...(string(body.cursor) ? { cursor: string(body.cursor) as string } : {}) }}
/** An issue as this tab read it, from the repo of whoever filed it. */export interface IssueCandidate { uri: string /** The version that was read. The goal pins exactly this, so "the issue changed after we imported * it" is a question anyone can answer later. */ cid: string /** Whoever filed it. Not a member of this space, in general — the row says so. */ did: string title: string body: string createdAt: string}
/** * One fetched record, if it really is an issue on this repository. * * Both checks matter and neither is redundant. The shape check is what stops a record shaped like * anything at all being rendered as an issue; the `repo` check is what stops the INDEX deciding which * repository somebody's words were filed against — the same division of labour `asGuestComment` makes * between the message lexicon and the goal it names. * * There is no Radial lexicon to validate against here, so the shape is checked by hand and defensively * (`forge-tangled.ts` explains why every tangled record is treated as untrusted input: the lexicons * are pre-1.0 and they move). A body is optional — an issue can be a title and nothing else. */export function asIssue( record: { uri: string; cid: string; value: unknown }, repoDid: string,): IssueCandidate | undefined { if (!record.uri.includes(`/${TANGLED_ISSUE_COLLECTION}/`)) return undefined const value = object(record.value) ? record.value : undefined if (!value || value.$type !== TANGLED_ISSUE_COLLECTION) return undefined const title = string(value.title) const createdAt = string(value.createdAt) if (!title || !createdAt) return undefined if (string(value.repo) !== repoDid) return undefined return { uri: record.uri, cid: record.cid, did: didOf(record.uri), title, body: typeof value.body === 'string' ? value.body : '', createdAt, }}
/** * Every issue this space has already taken up, by the URI its goals pinned. * * Read off the goals rather than kept anywhere: `source` is on-protocol precisely so that the answer * is the same for every member and survives a cold browser. Ended goals count — an issue that was * imported and then dropped has been decided about, and a row that said nothing about it would have * the next reader re-litigate that decision without knowing there had been one. * * Every goal that named an issue, not the first one: the shape `goalsByOrigin` has in the fold, and * for the same reason. Two members racing on one issue is a fact the row should be able to say out * loud rather than a duplicate for it to hide. * * `kind` is what makes this one join rather than one per forge — a goal imported from GitHub is not a * tangled row's business, and matching on the kind rather than on the URI's shape means a forge this * build has never heard of is simply not ours. It defaults to the tangled kind because this module is * tangled's; `github-issues.ts` passes its own. */export function importedIssues( goals: readonly GoalView[], kind: string = GOAL_SOURCE_TANGLED_ISSUE,): Map<string, GoalView[]> { const taken = new Map<string, GoalView[]>() for (const goal of goals) { const source = goal.target.value.source if (source?.kind !== kind) continue const matches = taken.get(source.uri) ?? [] matches.push(goal) taken.set(source.uri, matches) } return taken}
/** A candidate, plus what this space has already made of it. `FeedbackRow`'s twin, and the same * split for the same reason: everything in the candidate came off the network, and `imported` is our * own fold, which a member changes by creating a goal and which is already in memory. */export interface IssueRow extends IssueCandidate { imported: GoalView[]}
/** * The candidates a look returned, joined to this space's own account of them at the point of drawing. * * By URI and not by version: a goal written from an earlier draft of an issue was still written from * THIS issue, and a row that hid the goal would invite a second member to import it again. */export const issueRows = ( taken: ReadonlyMap<string, GoalView[]>, found: readonly IssueCandidate[],): IssueRow[] => found.map((candidate) => ({ ...candidate, imported: taken.get(candidate.uri) ?? [] }))
export interface Discovery { /** Every open issue this look could read, oldest first — the order a backlog is worked through. * * Including the ones this space has already taken up. They are what `issueRows` badges, links to * their goals and offers again, and a look is the ONLY thing that can produce them: the fold knows * an issue's URI and nothing else about it, so a page that dropped them here could draw an imported * issue only in the seconds after somebody imported it, and never on a cold navigation. */ candidates: IssueCandidate[] /** Hits that could not be turned into a candidate — a PDS that was down, a record deleted or * rewritten since Bobbin indexed it, an entry that never named this repository. Surfaced rather * than swallowed, for the reason `discoverGuestComments` surfaces its own. */ failed: number /** The page budget ran out with issues still unread. */ truncated: boolean}
/** * The open issues on a repository, read from the repos of the people who filed them. * * Order of operations is the point. Bobbin says WHERE issues are and which it folded as open; only * then is each one read from its author's own repo, which is the copy the reader is shown and the * version a goal pins. * * `taken` does not decide WHETHER an issue is read, only in which order the budget is spent on them. * An earlier draft dropped an already-imported issue here, to make a mostly-imported repository cost * one appview call and no PDS reads — and that saving was paid for by the row: an issue somebody took * up last week has a URI in the fold and nothing else, no title, no body and no filer, so a page that * never fetched it could not draw it at all. The badge, the link to the goal and "import again" would * then be reachable only in the seconds after an import, in the one browser that made it. The budget * still goes to what is still waiting, because that is what a backlog is for. */export async function discoverIssues(input: { repoDid: string taken: ReadonlySet<string> reader: Pick<RepoReader, 'getForeignRecord'> origin?: string fetcher?: Fetcher pages?: number limit?: number signal?: AbortSignal}): Promise<Discovery> { const pages = input.pages ?? ISSUE_PAGES const ceiling = input.limit ?? CANDIDATE_LIMIT const seen = new Set<string>() const waiting: IssueLocator[] = [] const already: IssueLocator[] = [] let cursor: string | undefined let truncated = false for (let page = 0; page < pages; page += 1) { const result = await listIssues({ repoDid: input.repoDid, ...(cursor ? { cursor } : {}), ...(input.origin ? { origin: input.origin } : {}), ...(input.fetcher ? { fetcher: input.fetcher } : {}), ...(input.signal ? { signal: input.signal } : {}), }) for (const item of result.items) { // Only what tangled itself folded as open. A closed issue is a decision the forge already // recorded, and re-proposing it here would be Radial arguing with it. if (item.state !== 'open' || seen.has(item.uri)) continue seen.add(item.uri) ;(input.taken.has(item.uri) ? already : waiting).push(item) } cursor = result.cursor if (!cursor) break if (page === pages - 1) truncated = true }
// Both, and the ones nobody has taken up first. A repository with more open issues than this view // loads is one whose backlog belongs on the forge, and the rows worth spending that budget on are // the ones still waiting for somebody — but an imported issue is dropped only when there is no room // left, never on principle. const found = [...waiting, ...already] if (found.length > ceiling) truncated = true const hits = found.slice(0, ceiling)
const fetched = await Promise.all( hits.map(async (hit) => { try { return await input.reader.getForeignRecord(hit.uri, input.signal) } catch { return undefined } }), ) const candidates: IssueCandidate[] = [] let failed = 0 for (const record of fetched) { const candidate = record ? asIssue(record, input.repoDid) : undefined if (candidate) candidates.push(candidate) else failed += 1 } // (createdAt, uri) — the order every list in the fold uses. Oldest first here, unlike the goal // list: the question a backlog asks is what has been waiting longest. candidates.sort( (left, right) => left.createdAt.localeCompare(right.createdAt) || left.uri.localeCompare(right.uri), ) return { candidates, failed, truncated }}
// ── what importing one writes ───────────────────────────────────────────────────────────────────
/** * The seed a candidate becomes. Truncation, the warning and the seed's own shape are `import-goal.ts`'s * — shared with GitHub's importer, because the composer is one card and the ceilings are one lexicon's. * * The filer is a DID here, which is the whole of what tangled has that GitHub does not: an issue is a * record in the repo of whoever filed it, so the card can resolve them the way it resolves every other * name in the app. */export const goalSeed = (candidate: IssueCandidate): GoalSeed => ({ ...seedText(candidate.title, candidate.body), source: { kind: GOAL_SOURCE_TANGLED_ISSUE, uri: candidate.uri, cid: candidate.cid }, filer: { kind: 'did', did: candidate.did },})