Something went wrong. Try again.
A collaborative coding-agent orchestrator for atproto radl.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453// The tiered doc viewer: a reading mode over records the space already holds.//// Radial's System page is a work-tracking list — rows, states, verdicts, who is holding what. That// is the right screen for somebody maintaining the project's standing memory and the wrong one for// somebody trying to read it. The docs route is the other screen over the same records, in three// tiers://// 1. **Dev docs** — the system artifacts verbatim. ADRs, the architecture document, conventions:// whatever project-scoped types the registry carries, minus the two below.// 2. **User docs** — product documentation, one unit per feature page, whose driving request pins// by strongref the system artifacts it was distilled from.// 3. **Roadmap docs** — community-facing pages, one unit per epic, whose driving request pins// GOALS instead, so the road ahead and the road travelled are derived rather than authored.//// Nothing here is a new kind of record. Tiers 2 and 3 are two entries in the artifact-type registry// (design §4), which buys versioning, reviews, threads and turn-bundle inclusion for free and leaves// the additive-only lexicon invariant untouched — the doc tiers add no record type, so `core`'s// golden scenario is unmoved and `packages/core/test/source-drift.test.mjs` asserts exactly that.//// Two rules this module exists to keep in one place://// - **The type names are constants, once.** A tier is a registry name, and a name scattered// through components is a rule nobody can find. `docTier()` is the only thing that knows them.// - **An edit request is assigned to its author.** An unassigned open request is claimable by any// operator's daemon, so a human's edit-in-progress that named nobody could be raced to a// verdict by a container. `editRequestArgs` always sends `--assignee`, and it is not optional.
import { artifactTypes, durabilityOf, sourceDrift, sourceGoals, staleness, type GoalView, type MaterializedIndex, type ProjectView, type SourceDrift, type StrongRef, type UnitVersion, type UnitView,} from '@radial/core'import type { TypeDraft } from './admin.js'import { locator } from './requests.js'import { unitsOf, withSpace } from './space.js'import { typeLabel, type RowBadge } from './units.js'
/** The registry name of the user-facing documentation tier. */export const USER_DOC_TYPE = 'user-doc'/** The registry name of the community-facing roadmap tier. */export const ROADMAP_DOC_TYPE = 'roadmap-doc'
export type TierId = 'dev' | 'user' | 'roadmap'
export interface Tier { id: TierId label: string /** One line under the tier's heading: what a reader is looking at. */ blurb: string /** * The registry type this tier IS, where it is one type. `dev` has none on purpose — it is every * other project-scoped type, so registering a new one puts it in the dev tier with no code change, * which is the same promise the ⊕ menu makes. */ type?: string /** What the sources of a doc in this tier are, in the words the edit form uses. */ sources?: string}
export const TIERS: readonly Tier[] = [ { id: 'dev', label: 'Dev', blurb: 'The project’s standing memory, as the agents read it. Every version of these goes to an agent whenever it takes on work here.', }, { id: 'user', label: 'User', blurb: 'Product documentation, one page per feature, written from the pages above.', type: USER_DOC_TYPE, sources: 'the system pages this page documents', }, { id: 'roadmap', label: 'Roadmap', blurb: 'Where an epic has got to and where it is going, from the goals underneath it.', type: ROADMAP_DOC_TYPE, sources: 'the goals this epic is made of', },]
export const tierById = (id: TierId): Tier => TIERS.find((tier) => tier.id === id) as Tier
/** Which tier a unit is drawn in. Everything project-scoped that is not a doc type is a dev doc. */export const docTier = (unit: Pick<UnitView, 'type'>): TierId => unit.type === USER_DOC_TYPE ? 'user' : unit.type === ROADMAP_DOC_TYPE ? 'roadmap' : 'dev'
/** * The project's units, split into the three tiers and ordered for reading rather than for tracking. * * Alphabetical by title within a tier — a docs sidebar is scanned by name, unlike the System list, * which is scanned by what moved most recently. A retracted request is not a document and is left * out entirely; it is still a row on System, where withdrawn work belongs. */export function docsOf( index: MaterializedIndex, project: ProjectView,): Record<TierId, UnitView[]> { const tiers: Record<TierId, UnitView[]> = { dev: [], user: [], roadmap: [] } for (const unit of unitsOf(index, project)) { if (unit.retracted) continue tiers[docTier(unit)].push(unit) } for (const id of Object.keys(tiers) as TierId[]) { tiers[id].sort( (left, right) => docTitle(left).localeCompare(docTitle(right)) || left.key.localeCompare(right.key), ) } return tiers}
/** * What this document is called. The artifact's own `title` is the field the writers are obliged to * fill (`packages/lexicons/README.md`); a record from before the field existed falls back to its * type, which is what every other surface shows for one. */export const docTitle = (unit: UnitView): string => unit.current?.artifact.value.title?.trim() || unit.type
/** One kind of document in a tier: the registry type, as prose, plus the pages that are one. */export interface DocGroup { /** The registry name — the group's identity, and what a collapsed state is keyed on. */ type: string /** The name as it is written (`typeLabel`): `adr` is spoken as an acronym, `architecture` is not. */ label: string units: UnitView[]}
/** * A tier's pages, grouped by the type of document they are. * * The dev tier is every project-scoped type that is not one of the two doc types, so it is a mixed * list by construction — ADRs, an architecture document, conventions, and whatever an admin * registers tomorrow. Read as one alphabetical run those interleave, and a reader looking for "the * ADRs" has to know which titles are ADRs. Grouping is therefore the registry's own structure shown * rather than a taxonomy this file invents, which is also why nothing here is ordered by a list of * known names: groups come out alphabetically by label, and a type nobody has seen before takes its * place among them. * * The order WITHIN a group is the order it was given — `docsOf` has already sorted for reading. */export function docGroups(units: readonly UnitView[]): DocGroup[] { const groups = new Map<string, DocGroup>() for (const unit of units) { const group = groups.get(unit.type) ?? { type: unit.type, label: typeLabel(unit.type), units: [] } group.units.push(unit) groups.set(unit.type, group) } return [...groups.values()].sort( (left, right) => left.label.localeCompare(right.label) || left.type.localeCompare(right.type), )}
/** Whether a doc in this tier is written by a member in the app rather than asked of an agent. */export const isEditable = (id: TierId): boolean => id !== 'dev'
// ── drift, twice ────────────────────────────────────────────────────────────────────────────────
/** * The two drift signals a doc page shows, side by side and never summed. * * They answer different questions and a reader acts on them differently. **Merge drift** is * `staleness()`: the CODE moved — implementations have merged under this project since this version * landed, so the document may describe something that is no longer true. **Source drift** is * `sourceDrift()`: the SOURCES moved — a record this page was distilled from has been superseded, * which is a specific, citable thing to go and read. * * A dev doc gets only the first, because it has no distilled sources to drift from; it IS a source. */export interface DocDrift { /** Merges under the project since the current version landed. 0 for an append-only type. */ merges: number /** Superseded sources, in the order the request pinned them. */ sources: SourceDrift[]}
export function docDrift( index: MaterializedIndex, project: ProjectView, unit: UnitView,): DocDrift { return { merges: staleness(index, project.target.uri, unit), sources: isEditable(docTier(unit)) ? sourceDrift(index, unit) : [], }}
export const hasDrifted = (drift: DocDrift): boolean => drift.merges > 0 || drift.sources.length > 0
/** * The badges on a doc row. Distinct wording on purpose: "3 merges" and "1 source" answer different * questions, and a single "4 drift" would be the app adding two numbers that do not add. */export function driftBadges(drift: DocDrift): RowBadge[] { const badges: RowBadge[] = [] if (drift.sources.length > 0) { badges.push({ kind: 'warn', glyph: '↑', text: `${drift.sources.length} source${drift.sources.length === 1 ? '' : 's'} moved on`, narrow: `${drift.sources.length} src`, title: drift.sources .map((entry) => `${sourceLabel(entry)} — this page documents v${entry.pinned.version}`) .join('\n'), }) } if (drift.merges > 0) { badges.push({ kind: 'flat', glyph: 'Δ', text: `${drift.merges} merge${drift.merges === 1 ? '' : 's'} since`, narrow: `${drift.merges}Δ`, title: `${drift.merges} implementation(s) merged under this project since this version landed`, }) } return badges}
/** How a drifted source is named in a sentence: its title, or the type of record it is. */export const sourceLabel = (entry: SourceDrift): string => entry.current.artifact.value.title?.trim() || entry.pinned.artifact.value.title?.trim() || entry.unit.type
/** "documents v1 · now at v3" — the whole of what a source-drift row has to say. */export const sourceDriftNote = (entry: SourceDrift): string => `documents v${entry.pinned.version} · now at v${entry.current.version}`
// ── the roadmap tier ────────────────────────────────────────────────────────────────────────────
export interface RoadmapEntry { goal: GoalView /** The merged implementations under this goal, oldest first: "here's where we got web push". */ landed: Array<{ unit: UnitView; version: UnitVersion }>}
export interface RoadmapSections { /** Live goals: the road ahead. */ ahead: RoadmapEntry[] /** Ended goals with what merged under them: the road travelled. */ travelled: RoadmapEntry[]}
/** * A roadmap page's two derived sections, resolved from its request's goal refs at READ time. * * Derived rather than authored is the whole point: closing a goal moves it from one section to the * other on the next sync, with nobody editing the page. What a person writes on a roadmap doc is the * part a list of goals cannot say. * * The goals keep the order the request pinned them in — the author's ordering of their own epic — * and each carries the merged implementations under it, which is the thread's "here's where we got * mobile push" view without a second record type to hold it. */export function roadmapSections(index: MaterializedIndex, unit: UnitView): RoadmapSections { const sections: RoadmapSections = { ahead: [], travelled: [] } for (const goal of sourceGoals(index, unit)) { const landed = unitsOf(index, goal) .filter((candidate) => !candidate.retracted) .flatMap((candidate) => candidate.versions.filter((version) => version.merged).map((version) => ({ unit: candidate, version })), ) .sort((left, right) => (left.version.mergedAt ?? '').localeCompare(right.version.mergedAt ?? '') || left.unit.key.localeCompare(right.unit.key), ) sections[goal.ended ? 'travelled' : 'ahead'].push({ goal, landed }) } return sections}
// ── setting the tiers up ────────────────────────────────────────────────────────────────────────
/** * The two registry entries the editable tiers are, as `type create` drafts. * * The briefs are written for an AGENT, because a brief IS the turn prompt the day somebody asks an * agent to write or refresh one of these pages. That the same registry entry serves a person typing * in the browser and a container running a turn is the reason these are registry types at all. */export const DOC_TYPE_DRAFTS: readonly TypeDraft[] = [ { name: USER_DOC_TYPE, scope: 'project', durability: 'living', description: 'A user-facing feature page', brief: 'Write the user-facing page for one feature, in the register of product documentation: what it does for a reader, what they see, and what it costs them. The records this is based on are the ground truth — distil them, never restate them, and never mention a record type or an implementation detail a user cannot see.', }, { name: ROADMAP_DOC_TYPE, scope: 'project', durability: 'living', description: 'A community-facing roadmap page', brief: 'Write the community-facing page for one epic. Say where the work has got to and where it is going, in a reader’s words. The goals this is based on supply the road ahead and the road travelled; write the part a list of goals cannot say.', },]
/** * The doc types this space has not registered yet. * * Registering them is load-bearing rather than cosmetic, and stronger than a grouping: `materialize()` * drops a request whose `type` no registry entry names — it cannot even tell which scope it anchors * in — so before the entries exist, a doc written into this space folds into nothing at all. That is * why the empty state is a setup card and not an invitation to write the first page. Registering the * type later folds every such record back in, with nothing rewritten. */export const missingDocTypes = (index: MaterializedIndex): TypeDraft[] => { const registry = artifactTypes(index) return DOC_TYPE_DRAFTS.filter((draft) => !registry.has(draft.name))}
// ── writing a doc ───────────────────────────────────────────────────────────────────────────────
/** * The sources a new version should pin: the previous request's refs, re-pinned to the CURRENT head * of each chain. * * Re-pinning is what "I have read the sources and this page now reflects them" means, and it is the * only thing that clears source drift — a version that carried its predecessor's stale refs forward * would look updated and still be badged. Refs the fold cannot resolve (a goal, a record from a repo * this tab has not read) are carried through untouched: they are somebody's stated provenance, and * dropping one because this observer cannot see it would quietly rewrite what the doc is about. */export function repinnedSources(index: MaterializedIndex, unit: UnitView): StrongRef[] { const request = unit.current?.request if (!request) return [] const drifted = new Map(sourceDrift(index, unit).map((entry) => [entry.ref.uri, entry.current])) return request.value.basedOn // The predecessor of this very chain is not a source; the successor pins its own predecessor // below, and carrying the old one through would pin a version that is about to stop being head. .filter((reference) => !unit.versions.some((version) => version.artifact.uri === reference.uri)) .map((reference) => { const head = drifted.get(reference.uri) return head ? { uri: head.artifact.uri, cid: head.artifact.cid } : reference })}
/** * `radial request create` for a doc edit: project-scoped, assigned to its own author, pinning the * predecessor plus every source. * * The self-assignment is the load-bearing argument. `dispatch.ts` refuses a request whose assignee is * not an active agent member, so a request named to a person is one no daemon anywhere will claim or * run — which is what makes it safe for a half-written edit to sit open in somebody's repo. Leave it * off and the request is open to any capable agent, and an operator's container may answer a page * somebody was in the middle of typing. */export function editRequestArgs(input: { project: ProjectView type: string /** The author's own DID. Not optional: see above. */ did: string /** The version being revised, when this is an edit rather than a new page. */ prev?: UnitVersion sources: StrongRef[]}): string[] { const args = ['request', 'create', '--project', input.project.target.uri, '--type', input.type] if (input.prev) args.push('--based-on', locator(input.prev.artifact)) for (const reference of input.sources) args.push('--based-on', locator(reference)) args.push('--assignee', input.did) return args}
/** * `radial artifact post` — the second half, and the same command a turn's sidecar runs. A browser has * no filesystem, so the body is inline (`--body`); everything else is a reference the CLI pins. */export function docPostArgs(input: { request: Pick<StrongRef, 'uri' | 'cid'> title: string body: string prev?: UnitVersion}): string[] { const args = ['artifact', 'post', '--request', locator(input.request)] if (input.prev) args.push('--prev', locator(input.prev.artifact)) return [...args, '--title', input.title.trim(), '--body', input.body]}
/** * An edit somebody started and did not finish: an open doc request of theirs with nothing posted * against it. * * Saving is two writes — the request, then the artifact — and a failure between them leaves the first * standing. That is harmless (a request assigned to a person is dispatched by nobody) and it is * resumable, so the page offers to finish it rather than writing a second request for the same edit * and forking the unit's history over a dropped connection. */export function resumableEdit( index: MaterializedIndex, project: ProjectView, did: string,): Array<{ unit: UnitView; request: NonNullable<UnitView['openRequest']> }> { if (!did) return [] return unitsOf(index, project) .filter((unit) => isEditable(docTier(unit)) && !unit.retracted) .flatMap((unit) => { const request = unit.openRequest return request && request.value.assignee === did && request.did === did ? [{ unit, request }] : [] })}
/** * Two members editing one page in the same ingestion window both post `prev = v3`. The fold resolves * a deterministic head and loses nothing — both records are in the version rail — but one person's * edit stops being what a reader sees, and they should hear that from the page rather than discover * it. So the head is re-read at save time and compared with the one the form was opened on. * * A warning, never a refusal: this is the trade `labelGoal` already makes, and building a merge here * would be building a merge into a protocol that deliberately has none. */export const headMoved = (unit: UnitView, openedOn: string | undefined): boolean => Boolean(openedOn && unit.current && unit.current.artifact.uri !== openedOn)
// ── routes ──────────────────────────────────────────────────────────────────────────────────────
export const docsHref = (project: ProjectView, tier?: TierId, unit?: Pick<UnitView, 'key'>): string => { const base = withSpace( `/p/${encodeURIComponent(project.name)}/docs`, project.target.value.space.uri, ) const [path, query = ''] = base.split('?') const params = new URLSearchParams(query) if (tier && tier !== 'dev') params.set('tier', tier) if (unit) params.set('doc', unit.key) const encoded = params.toString() return `${path}${encoded ? `?${encoded}` : ''}`}
/** The tier a URL asks for, defaulting to the one every space has something in. */export const tierFromParam = (value: string | null): TierId => TIERS.some((tier) => tier.id === value) ? (value as TierId) : 'dev'
/** Durability, in the words a reader of a docs site would use rather than the registry's. */export const durabilityNote = (index: MaterializedIndex, unit: UnitView): string => durabilityOf(index, unit.type) === 'append-only' ? 'This page is part of a dated history.' : 'This page is kept current; earlier versions stay readable.'