// Reviewing one exact version. // // A review is the human half of the loop: an agent lands an artifact, and nothing is done until a // person says what they think of it. Everything in this file follows from one rule the fold already // enforces (§3.7) — **a review pins a uri#cid, not a unit**. So the subject of a review is the // version being *read*, never the unit's tip; browsing back to v1 and pressing Approve approves v1, // and a v3 landing mid-form cannot silently become what was signed. // // The second rule is that a review request is not a unit either. It is a review owed on one exact // version, so it hangs off that version and the queue is a fold over versions rather than over // rows. That is why "For me" has two flavours: an artifact that landed with nothing pinned to it // (nobody asked; landing is not done), and a request that names a version and a person. // // ── One word for one thing ──────────────────────────────────────────────────────────────────── // // This file is named for the *field*, not for the act. The rule the UI keeps: // // A **review** is the thing — the act, the record, the request for one, and the queue of ones // owed. All copy a person reads says review. // // A **verdict** is only the review record's `verdict` field, whose value is `approve` or // `request_changes`. It stays in the lexicon, the `--verdict` flag, the types and the design // system's colour vocabulary, and it never appears as a bare word in copy: the UI renders that // field as the badges `approved` and `changes`. // // So `verdictArgs`, `Verdict`, `unit.verdict` and `needsVerdict` are right as they are, and a // string in a template that says "verdict" is not. import type { ArtifactRequestRecord, IndexedRecord, MaterializedIndex, ReviewFinding, UnitVersion, UnitView, Verdict, } from '@radial/core' import { openRequestState, REVIEW_TYPE_NAME } from '@radial/core' import type { Actor, Directory } from './directory.js' import { locator } from './requests.js' import { agentDids, askWithPerson, findVersion, liveTargets, liveUnits, type AskContext, type UnitContext, } from './units.js' // `pinnedLabel` — what a review calls the version it judges — lives in `units.ts` beside // `versionLabel`, the same label for a provenance line. Import it from there. /** The lexicon's three, in the order a human reads them: least consequential first. */ export const SEVERITIES = ['info', 'warning', 'error'] as const export type Severity = (typeof SEVERITIES)[number] /** * A finding as it is being written. Everything is a string because that is what an input holds; * `line` becomes an integer, or nothing at all, on the way to the record. */ export interface FindingDraft { severity: Severity path: string line: string body: string } /** * Turn a passage selected in rendered artifact prose into an ordinary review finding draft. * * The quote lives in the finding body rather than in observer-local UI state, so it survives the * write, reaches revision-turn bundles, and remains pinned by the review's subject CID. Prefix every * line separately: a multi-paragraph selection is one markdown blockquote, not a first quoted line * followed by reviewer prose that merely looks adjacent. */ export function inlineFinding( quote: string, suggestion: string, severity: Severity = 'warning', ): FindingDraft { const quoted = quote .trim() .split('\n') .map((line) => `> ${line}`) .join('\n') return { severity, path: '', line: '', body: `${quoted}\n\n${suggestion.trim()}`, } } /** * One ROW of the findings editor: a draft, plus the two things that are true of the row rather than * of the finding. * * `key` is identity. The rows are a list a reader adds to and removes from, and a body is a mounted * editor with an undo history, a caret and possibly an upload holding a position in it — so a row * has to be the same row across an add or a remove above it, which an array index is not. * * `uploading` is a picture still on the wire. It is the row's, not the card's, because the card * needs both halves of it: any row uploading holds the two verdict buttons, and the uploading row * itself cannot be removed out from under the insertion that is coming. * * Neither is ever written. `findingsOf` takes `FindingDraft`, so the serialization boundary cannot * see them even by accident. */ export interface FindingRow extends FindingDraft { key: number uploading: boolean } /** Row identity, and nothing more: never rendered, never written, never compared across cards. */ let rowsMade = 0 /** `warning` by default: the middle of the three is the one that claims the least. */ export const blankFinding = (): FindingRow => ({ key: (rowsMade += 1), severity: 'warning', path: '', line: '', body: '', uploading: false, }) /** * The editor's rows as the record's findings. * * A row with no body is not a finding — it is an empty row somebody added and did not fill in, and * writing it would put a severity with nothing behind it into a permanent record. `path` and `line` * are optional in the lexicon and stay optional here: a finding about the shape of a whole document * has no line to point at, and inventing one would be worse than omitting it. * * Typed on `FindingDraft` rather than on the row: this is the serialization boundary, and the row's * identity and upload flag are the editor's business on the other side of it. */ export function findingsOf(rows: FindingDraft[]): ReviewFinding[] { const out: ReviewFinding[] = [] for (const row of rows) { const body = row.body.trim() if (!body) continue const path = row.path.trim() const line = Number.parseInt(row.line, 10) out.push({ severity: row.severity, ...(path ? { path } : {}), // The lexicon's minimum is 1, and a line number is only meaningful next to a path. ...(path && Number.isInteger(line) && line >= 1 ? { line } : {}), body, }) } return out } /** * Whether this version can be judged at all. * * Nothing about *who* is asking: anyone reading the row sees the buttons, and the card says why it * cannot be filled in — the same shape the compose card takes (6.4). What decides this is only * whether there is something to judge: a request nobody has answered has no version to pin a review * to, and a retracted unit was withdrawn. */ export const judgeable = (unit: UnitView, version: UnitVersion | undefined): boolean => version !== undefined && !unit.retracted /** * Whether a review this DID writes would actually count. * * The fold trusts records from active members and drops the rest, so a signed-in visitor who is not * a member of this space would be writing a record into their own repo that no materializer here * will ever fold. Better to say so than to let a button appear to work. */ export const mayJudge = (directory: Directory, did: string): boolean => did !== '' && directory.get(did).active /** * The open review request this DID would be answering, if there is one. * * Preference order matters: one assigned to me, then an unassigned one, and never one assigned to * somebody else. The materializer marks a review request fulfilled when *any* trusted review names * it and pins the same subject — it does not check who wrote the review — so naming another agent's * request would quietly cancel work that agent is doing. An unrequested review is always available * and cancels nothing. */ export function owedBy( version: UnitVersion | undefined, did: string, ): IndexedRecord | undefined { if (!version || !did) return undefined return ( version.reviewRequests.find((request) => request.value.assignee === did) ?? version.reviewRequests.find((request) => request.value.assignee === undefined) ) } /** * `radial review post`. The subject is the version that was read, pinned by CID, and `--request` is * present only when this review is answering one — that ref is what closes the request in the fold. */ export function verdictArgs( version: UnitVersion, input: { verdict: Verdict findings: ReviewFinding[] request?: IndexedRecord }, ): string[] { const args = [ 'review', 'post', '--subject', locator({ uri: version.artifact.uri, cid: version.artifact.cid }), '--verdict', input.verdict, ] if (input.request) args.push('--request', locator(input.request)) // A browser has no filesystem, so the inline spelling is the only one that exists here. The // per-finding shape is the review lexicon's job, checked when the record is validated. if (input.findings.length > 0) args.push('--findings-json', JSON.stringify(input.findings)) return args } /** What the toast says once the review is public. */ export const wroteVerdict = (verdict: Verdict, version: UnitVersion, fulfilled: boolean): string => { const what = verdict === 'approve' ? 'Approved' : 'Changes requested on' return `${what} v${version.version}${fulfilled ? ' — the review request is answered' : ''}` } // ── asking somebody else for one ──────────────────────────────────────────────────────────────── /** * Who can be asked for a review: every active member but the asker. * * Not the registry — `review` is deliberately absent from it, because it is a built-in of the turn * layer rather than a type an admin registers. So this list is *membership*, not capability: a human * judges because they are a person in this space, and an agent judges because its own * self-description says it produces reviews. Asking yourself for a review is a no-op with extra * steps, so the asker is not on it. */ export function possibleReviewers(directory: Directory, did: string): Actor[] { return [...directory.humans(), ...directory.agents()] .filter((actor) => actor.active && actor.did !== did) .filter((actor) => actor.kind === 'human' || actor.artifactTypes.includes('review')) .sort((left, right) => left.kind === right.kind ? left.name.localeCompare(right.name) : left.kind === 'human' ? -1 : 1, ) } /** * `radial request create --type review --subject …`. * * A review request carries a `subject` where every other request carries `basedOn`: it names the one * version a review is owed on, rather than the provenance a new artifact is built from. That is * also why it never becomes a unit — there is no artifact coming, only a judgement. */ export function askReviewArgs(input: { target: string scope: 'goal' | 'project' version: UnitVersion assignee: string brief: string }): string[] { const args = [ 'request', 'create', input.scope === 'goal' ? '--goal' : '--project', input.target, '--type', 'review', '--subject', locator({ uri: input.version.artifact.uri, cid: input.version.artifact.cid }), ] if (input.assignee) args.push('--assignee', input.assignee) const brief = input.brief.trim() if (brief) args.push('--brief', brief) return args } export const askedForReview = (assignee: Actor | undefined, version: UnitVersion): string => assignee ? `Review of v${version.version} requested from ${assignee.handle ?? assignee.name}` : `Review of v${version.version} requested — open to any member` // ── the queue ─────────────────────────────────────────────────────────────────────────────────── /** * Every OPEN review request in the space, resolved back to the version it pins and to whoever it is * with. * * A fold over open requests rather than over units, because that is where a review ask lives: a * review request is a judgement owed on one exact version and never becomes a unit of its own. Two * asks on one version are two rows — each is a separate review owed. Shelving is honoured exactly as * `liveUnits` honours it, and a project-scoped ask (a review of a system document) is folded beside * the goal-scoped ones. */ export function reviewAsks(index: MaterializedIndex): AskContext[] { const rows: AskContext[] = [] for (const target of liveTargets(index)) { for (const request of target.openRequests) { const subject = request.value.subject if (request.value.type !== REVIEW_TYPE_NAME || !subject) continue // Can't-happen: the materializer only files a review request under the target its subject // resolves in. An ask whose version cannot be found has nothing a row could name, so it is // dropped rather than drawn as a review of nothing. const pinned = findVersion(index, subject) if (!pinned) continue const claim = target.winningClaims[request.uri] const actor = request.value.assignee ?? claim?.did rows.push({ kind: 'review', request, target, state: openRequestState(target, request), ...(actor ? { actor } : {}), ...(claim ? { claim } : {}), pinned, }) } } return rows } /** * Reviews a PERSON was explicitly asked for and has not started: the asks above, minus the ones an * agent is working on and the ones already stopped on a question, resolved back to the unit and the * exact version each one pins. * * With a DID it is that person's queue. Without one — nobody signed in — it is every review owed by * anyone, because a tab with no session still knows what this space is waiting on a human for, and * saying nothing would be less true than saying "these, by someone". * * `askWithPerson` either way, and that is the whole of the no-double-counting rule: an ask with an * agent is drawn by "With an agent", a parked one by "Waiting on you" — which is a group on this * same page — and only what is left is a queue. Three lists, three states, no row in two of them. */ export function reviewQueue(index: MaterializedIndex, did?: string): UnitContext[] { const agents = agentDids(index) return reviewAsks(index) .filter( (ask) => askWithPerson(ask, agents) && (did === undefined || ask.request.value.assignee === did), ) .flatMap((ask) => ask.pinned ? [{ unit: ask.pinned.unit, target: ask.target, version: ask.pinned.version }] : [], ) .sort( (left, right) => left.unit.createdAt.localeCompare(right.unit.createdAt) || left.unit.key.localeCompare(right.unit.key), ) } /** * Reviews nobody asked for: landed artifacts with nothing pinned to their current version and no * open review request on it either. This is the larger half of the queue in practice — an agent * delivers, and the work is with a person whether or not anyone wrote that down. */ export function unjudged(index: MaterializedIndex): UnitContext[] { return liveUnits(index).filter( ({ unit }) => unit.needsVerdict && unit.versions.length > 0 && !unit.retracted && (unit.current?.reviewRequests.length ?? 0) === 0, ) }