// What a row says about a unit. // // The fusion itself — which records make one piece of work, what state its tip is in, whether it is // judged — is `timeline()` in core, and none of it is recomputed here (phase6-ui-plan §3.7). What // this file holds is the presentation layer over that: the one line a row shows, the badges in its // tail, whose disc sits at the end, and the two cross-target relations the comp draws that no // single target's fold can see (a goal artifact distilled into a system record, and back). import type { ArtifactRequestRecord, CheckrunRecord, ClaimRecord, GoalView, IndexedRecord, MaterializedIndex, MessageRecord, OpenRequestState, ProjectView, StrongRef, UnitState, UnitVersion, UnitView, } from '@radial/core' import { activeGoals, activeProjects, artifactTypes, claimDeadline, staleness, timeline } from '@radial/core' import type { Directory } from './directory.js' import { artifactLabel, dayKey, dayLabel, relativeTime } from './format.js' export type BadgeKind = 'ok' | 'bad' | 'warn' | 'flat' | 'accent' export interface RowBadge { kind: BadgeKind glyph: string text: string /** * What the badge collapses to on a narrow viewport, when it must not disappear entirely. The * empty string asks for the glyph alone, and is only for a badge whose glyph and colour already * say the whole thing — never for one whose word is what tells it apart from its neighbours. * Either way the full text stays in the accessible name; this is what the eye gets. */ narrow?: string title?: string } /** A unit plus the target it hangs off — what a cross-goal row needs to link anywhere. */ export interface UnitContext { unit: UnitView target: GoalView | ProjectView /** * The exact version this row stands for, when it stands for one rather than for the unit. A * review owed is owed on one uri#cid (§3.7), so the queue's rows carry it and open the drawer * there; every other list carries nothing and follows the tip. */ version?: UnitVersion } /** * An **ask** — a built-in request, `review` or `answer` — seen from outside the thing it hangs off. * * Neither is work in the sense a row stands for: a review produces a judgement and an answer * produces conversation, so `timeline()` skips both and neither adds a row to a goal or a fraction * to its pie. But an agent really does run turns for both, and a turn of either kind can stop on a * question exactly like any other. So an ask is a row shape of its own rather than a unit, and the * lists that say "with an agent" and "stopped on you" draw it beside the units they fold. * * `reviewAsks` in `verdicts.ts` and `answerAsks` in `replies.ts` produce these; `asks.ts` is where * the two become one list. */ export interface AskContext { kind: 'review' | 'answer' request: IndexedRecord /** A review ask may be PROJECT-scoped (a system artifact); an answer ask is always in a goal. */ target: GoalView | ProjectView state: OpenRequestState /** Whose disc ends the row: the assignee, or whoever's claim won. */ actor?: string /** The live lease, when that is what says an agent has it. */ claim?: IndexedRecord /** `answer`: the exact message version it pins, when the view still holds it. */ subject?: IndexedRecord /** `review`: the unit and exact version it pins, via `findVersion`. */ pinned?: FoundVersion } /** What a smart list can hold: a unit, or an ask that never became one. */ export type SmartRow = UnitContext | AskContext export const isAskRow = (row: SmartRow): row is AskContext => 'request' in row /** * The DIDs this space treats as agents: active members whose grant says `agent`. * * Read off `index.members` rather than `index.agents`, because membership is what makes a DID an * agent here — an agent that has never published a self-description is still one, and a request * assigned to it is still with an agent rather than with a person. */ export const agentDids = (index: MaterializedIndex): Set => new Set( index.members.filter((member) => member.active && member.kind === 'agent').map((member) => member.did), ) /** * With an agent, one way or the other — the ask half of `isMoving`. * * One predicate, read by "With an agent" and — through `askWithPerson` below — by the review queue * too, so the two cannot both claim a row: the rail's badge is the sum of the lists it links to, and * a row counted twice overstates it. An ask assigned to a *human*, or to an agent whose membership * has been removed, is not this — the first is a person's to answer and the second is nobody's to * run, and it stays visible in the queue where a human can retract it. */ export const askWithAgent = (ask: AskContext, agents: Set): boolean => ask.state === 'claimed' || (ask.state === 'assigned' && agents.has(ask.actor ?? '')) /** * Owed by a person, and nothing has started: the third of the three places an ask can be. * * The partition is total and disjoint — with an agent, blocked on a person, or waiting for one — and * that is the property the rail depends on. "For me" draws a parked ask under "Waiting on you", so a * queue that also listed it under "Asked of someone" would put one row twice on one page and count * it twice in the badge above it. */ export const askWithPerson = (ask: AskContext, agents: Set): boolean => ask.state !== 'awaiting' && !askWithAgent(ask, agents) /** One block of a smart list: a heading, why it exists, and the rows under it. */ export interface SmartGroup { title: string hint?: string rows: SmartRow[] } export const isGoalView = (view: GoalView | ProjectView): view is GoalView => view.target.value.$type === 'com.disnetdev.radial.goal' /** * A registry name as it is written in prose. Registry names are lowercase identifiers and are shown * as-is; `adr` is the one that is an acronym when spoken, so it is capitalised. This is typography, * not semantics — nothing anywhere decides what a type *means* from its name (§3.3). */ export const typeLabel = (name: string): string => (name === 'adr' ? 'ADR' : name) /** * Where a row hangs off, as the row says it: the goal's title, or `System · `. * * One function rather than the same conditional in each row component, because quick find searches * what a row *shows* — a label composed separately in two components is a label one of them will * eventually spell differently, and the difference would be a word a reader can see and not find. */ export const targetLabel = (target: GoalView | ProjectView): string => isGoalView(target) ? target.target.value.title : `System · ${target.name}` /** "plan v2", or just "plan" when there has only ever been one — what a provenance line reads. */ export const versionLabel = (unit: UnitView, version: UnitVersion): string => unit.versions.length > 1 ? `${typeLabel(unit.type)} v${version.version}` : typeLabel(unit.type) /** * What a review calls the thing it is judging: the type and the version, always — `implementation * v1`, not `implementation`. * * `versionLabel` above drops the number where there has only ever been one version, because "plan * v1" is noise in a provenance line. Here it is the whole point: a review pins one uri#cid, and the * card — or the row standing for the review owed — has to say which one even when saying it is * redundant today and will not be tomorrow. */ export const pinnedLabel = (unit: UnitView, version: UnitVersion): string => `${typeLabel(unit.type)} v${version.version}` /** * Every unit in the space, each still holding the view it came from — including the ones under a * goal somebody has shelved. This is the lookup fold: a reference is resolvable wherever it points, * and archiving a goal must not turn a `basedOn` chip elsewhere back into an rkey. */ export function unitsWithContext(index: MaterializedIndex): UnitContext[] { return [...index.goals, ...index.projects].flatMap((target) => timeline(index, target.target.uri).map((unit) => ({ unit, target })), ) } /** * The units a smart list folds: everything except what is under something somebody has shelved. * * An ending says agents should skip a goal, and a cross-goal list is the same claim seen from further * away, so it honours the same rule: a review owed on an artifact under a goal somebody has ended is * not work stopped on anybody, and a list that keeps showing it is a list nobody can empty. * * Archiving a PROJECT shelves both kinds at once — its own system units, and every goal under it. * * **This is one rule, not two.** It used to be two: an archived goal's units left the lists and a * closed goal's stayed, on the grounds that a closed goal can still owe somebody a review. Once a * goal has one ending that reading had to go — the alternative is a reader and the daemon disagreeing * about which goals are live, which is the confusion the two controls caused one layer down. So * `liveTargets` delegates to `activeGoals()` in core and there is one definition of shelved. What an * ended goal still owes is not lost: the Logbook is where ended goals live, `GoalRow` carries its "N * to review" badge there, and `unitsWithContext()` above still resolves every reference into it. */ export function liveUnits(index: MaterializedIndex): UnitContext[] { const live = new Set(liveTargets(index)) return unitsWithContext(index).filter(({ target }) => live.has(target)) } /** The targets `liveUnits` folds, on their own — what a cross-goal list of anything that is not a * unit (an answer request, say) has to be built from if it is to honour the same shelving rule. */ export function liveTargets(index: MaterializedIndex): Array { return [...activeGoals(index), ...activeProjects(index)] } /** Landed, and no review pins the version being read. The queue's whole reason to exist. */ export const needsVerdict = (unit: UnitView): boolean => unit.needsVerdict && unit.versions.length > 0 // ── the tip, and what is being asked for next ─────────────────────────────────────────────────── // // A unit carries TWO states at once and they are not the same fact (`timeline()`, phase6-ui-plan // §3.1). `UnitView.state` is what became of the newest thing that LANDED — `landed`, `judged`, // `merged` — and `openRequest` is what has been asked for since. An approved v1 with a v2 under way // is `judged` and `claimed` simultaneously, and the two are both true. // // Every list here that means "what is happening to this request" therefore asks `requestState()` // rather than reading `state`. Reading `state` directly is how a v2 an agent was genuinely running // stayed out of "With an agent": the fold had the claim the whole time, and the page was looking at // the tip. Anything that really does mean the tip — the verdict, the checks, the PR, `merged` — goes // on reading `state`, deliberately. /** * Where this unit's OPEN request stands, or nothing when it is not asking for anything. * * The ladder is `openRequestState()`'s in core, read off the unit the fold already resolved rather * than recomputed from the view: `awaiting` first, because a question blocks the request whoever * holds it; then the live winning claim, which is the one signal the index KNOWS a turn is running; * then the named assignee, which says only who it is with; and `open` for a successor nobody has * taken. It answers identically for a lone request that has never landed anything and for a * successor hanging off a version chain — which is the whole point of it existing. * * Undefined means no request is open: a landed unit at rest, or a lone request a tombstone withdrew. */ export function requestState(unit: UnitView): OpenRequestState | undefined { if (!unit.openRequest) return undefined // `timeline()` lifts a landed unit's blocked successor into the tip's own state, and a lone // request carries it directly; either way this is the one rung that outranks a live claim. if (unit.state === 'awaiting') return 'awaiting' if (unit.claim) return 'claimed' return unit.assignee ? 'assigned' : 'open' } /** Stopped on a person: the turn asked a question and nobody has answered it. */ export const isAwaiting = (unit: UnitView): boolean => requestState(unit) === 'awaiting' /** Genuinely under way: an agent holds a live lease. `assigned` is not this (§3.1). */ export const isClaimed = (unit: UnitView): boolean => requestState(unit) === 'claimed' /** Named to one agent, and not claimed yet. */ export const isAssigned = (unit: UnitView): boolean => requestState(unit) === 'assigned' /** Asked for, and nobody has taken it: any member agent that produces the type may. */ export const isOpen = (unit: UnitView): boolean => requestState(unit) === 'open' /** With an agent, one way or the other — what a goal row's "in flight" count means. */ export const isMoving = (unit: UnitView): boolean => isClaimed(unit) || isAssigned(unit) /** * Whoever the open request is with: whoever's claim won, or the agent it was named to. Claim first, * matching the ladder — a claim is only ever taken on an unassigned request, so the two are never * both set, and where they somehow were the lease is the stronger fact. */ export const requestActorDid = (unit: UnitView): string | undefined => unit.claim?.did ?? unit.assignee /** * Whose disc ends the row: whoever the work is with. * * The open request comes first, because that is what is happening now — a merged v1 whose v2 an * agent is running is with that agent, not with whoever wrote v1 last month. With nothing open, it * is the agent that wrote the newest version. */ export function unitActorDid(unit: UnitView): string | undefined { return requestActorDid(unit) ?? unit.current?.artifact.did } /** Every DID that has done something on this unit, oldest first — the goal row's little crowd. */ export function unitParticipants(unit: UnitView): string[] { const dids = unit.versions.map((version) => version.artifact.did) const actor = unitActorDid(unit) if (actor && !dids.includes(actor)) dids.push(actor) return [...new Set(dids)] } /** * How long this tab will keep honouring the claim — the EARLIER of the two deadlines, so the row * never promises more time than the fold grants. * * The fold measures a modern lease as a duration from when this observer first saw the version * (`claimDeadline`), which for a writer whose clock runs ahead is well before the `expiresAt` * printed on the record. Showing the record's instant would tell an operator the work is held for * another hour while `materialize()` has already let it go. */ export function claimExpiry(unit: UnitView, now: string): string | undefined { return leaseExpiry(unit.claim, now) } /** The same sentence, for the rows that hold a claim without holding a unit (`AskContext`). */ export function leaseExpiry(claim: IndexedRecord | undefined, now: string): string | undefined { const expires = claim?.value.expiresAt if (!claim || !expires) return undefined const observed = claimDeadline(claim.value, claim.firstSeenAt) const deadline = observed && Date.parse(observed) < Date.parse(expires) ? observed : expires const left = Date.parse(deadline) - Date.parse(now) if (Number.isNaN(left)) return undefined return left <= 0 ? 'lease expired' : `lease expires in ${Math.max(1, Math.round(left / 60_000))} min` } /** * The one line under a row's title: what is true about this unit right now. * * The open request wins over the tip when there is one, because it is the news — a document that * landed in June whose v2 is being written right now reads as the v2 being written, and the tip's * own title is still one click away in the drawer. What has landed is the line only once nothing is * being asked for. */ export function unitSummary(unit: UnitView, directory: Directory, now: string): string { if (unit.retracted) return 'retracted' const actor = requestActorDid(unit) const name = actor ? directory.get(actor).name : 'an agent' const brief = unit.openRequest?.value.brief?.split('\n')[0]?.trim() switch (requestState(unit)) { case 'open': return brief || 'open to any agent that produces this type' case 'claimed': return `${name} is working — ${claimExpiry(unit, now) ?? 'claimed'}` case 'assigned': return brief || `with ${name}` case 'awaiting': return 'the turn asked a question and stopped' default: return unit.current ? artifactLabel(unit.current.artifact.value) : '' } } /** * The title of one exact VERSION, and only if that version has one. * * No fallback here, deliberately: this is what the drawer prints above the body it is already * showing, and deriving a line from that same body would be printing the body's own first sentence * twice. A version chain can change title between versions — a living document renamed at v3 — so it * is asked per version rather than per unit. */ export const versionTitle = (version: UnitVersion | undefined): string => version?.artifact.value.title?.trim() ?? '' /** * What a System row is searched by: the type, the current version's explicit title, and its body. * * All three, not the title alone — a short title is what makes a record findable by name, and it must * not cost the full-text search that was there before it existed. */ export const unitSearchText = (unit: UnitView): string => [unit.type, versionTitle(unit.current), unit.current?.artifact.value.body ?? ''].join(' ') // ── what quick find searches ──────────────────────────────────────────────────────────────────── // // Quick find narrows the view it is typed in, so what it searches is what that view SHOWS. Each row // shape therefore has one projection, next to the model the row is drawn from, rather than a // per-page guess at what the row put on screen — the failure mode otherwise is a word visibly on a // row that typing it makes disappear. // // A projection is deliberately wider than the row's one visible line in one direction only: the // artifact BODY. A short title is how a record is found by name, and it must not cost the full-text // search that was the only way to find anything before titles existed (`unitSearchText` above). /** A person as their row can be searched for: what the disc stands for, however it is spelled. */ const actorSearchText = (did: string | undefined, directory: Directory): string => { if (!did) return '' const actor = directory.get(did) return [actor.name, actor.handle ?? '', actor.did].join(' ') } /** * A unit row: the type, its current version's title and body, where it hangs off, the brief of the * request that is still open on it, and whoever it is with. * * The brief is what an unanswered request's row actually reads (`unitSummary`), so leaving it out * would make every open request on a page findable only by its type. */ export const unitRowSearchText = ( unit: UnitView, target: GoalView | ProjectView, directory: Directory, ): string => [ unitSearchText(unit), targetLabel(target), unit.openRequest?.value.brief ?? '', actorSearchText(unitActorDid(unit), directory), ].join(' ') /** * An ask row: the two words the product calls these turns, where it hangs off, its brief, and what * it pins — the artifact version for a review, the message for a reply. * * `review` and `reply` are searched as the words `AskRow` prints, never as `answer`: the lexicon's * type name is not on screen, and a reader searching for a word they cannot see is not something to * design for. */ export const askRowSearchText = (ask: AskContext, directory: Directory): string => [ ask.kind === 'review' ? 'review' : 'reply', targetLabel(ask.target), ask.request.value.brief ?? '', ask.pinned ? [ ask.pinned.unit.type, versionTitle(ask.pinned.version), ask.pinned.version.artifact.value.body, ].join(' ') : '', ask.subject?.value.body ?? '', actorSearchText(ask.actor, directory), ].join(' ') /** Either row shape, for the lists that hold both. */ export const smartRowSearchText = (row: SmartRow, directory: Directory): string => isAskRow(row) ? askRowSearchText(row, directory) : unitRowSearchText(row.unit, row.target, directory) /** * The groups a smart list draws: the same groups in the same order, each holding only the rows that * survive, and none of the ones left empty. * * New arrays throughout. The caller's groups are `$derived` from the fold and shared with whatever * else reads it, so filtering in place would be one page quietly editing another's data. */ export function filterSmartGroups( groups: SmartGroup[], keep: (row: SmartRow) => boolean, ): SmartGroup[] { const out: SmartGroup[] = [] for (const group of groups) { const rows = group.rows.filter(keep) if (rows.length > 0) out.push({ ...group, rows }) } return out } /** One day's worth of a goal's units — the comp's "Today / Yesterday / Jul 21" headings. */ export interface UnitDay { key: string label: string units: UnitView[] } /** * Units grouped into the days they were asked for, in the order they arrive. * * Built from whatever list it is handed, which is what makes a filtered goal page drop a heading * whose whole day was filtered out rather than draw an empty one. */ export function unitDays(units: UnitView[], now: string): UnitDay[] { const days: UnitDay[] = [] for (const unit of units) { const key = dayKey(unit.createdAt) const last = days.at(-1) if (last && last.key === key) last.units.push(unit) else days.push({ key, label: dayLabel(unit.createdAt, now), units: [unit] }) } return days } export function checkTally(version: UnitVersion | undefined): { pass: number; total: number } | undefined { if (!version || version.checkruns.length === 0) return undefined const results = version.checkruns.flatMap((run) => run.value.results) if (results.length === 0) return undefined return { pass: results.filter((result) => result.pass).length, total: results.length } } export const checkResults = (checkruns: Array>) => checkruns.flatMap((run) => run.value.results) /** An `at:///sh.tangled.repo.pull/` — a pull request that IS a record. */ const RECORD_PULL = /^at:\/\/did:[^/]+\/sh\.tangled\.repo\.pull\/[^/#?]+$/ export interface PullLink { /** Somewhere a browser can actually go, when the forge has such a page. */ href?: string /** The pull record's at-uri, when the forge addresses pulls as records rather than as pages. */ uri?: string /** The number a human calls it by, when the forge issues one. */ number: string } /** * The pull request a version links: where a browser can open it, what to call it, and — where those * are not the same thing — the record it really is. * * `links.pr` is not uniformly a URL. New tangled artifacts normally carry the web page Bobbin * derived from creation order, but an artifact written while Bobbin was unavailable carries the * `sh.tangled.repo.pull` record's at-uri. Its last segment is a tid that would read as a pull number * if it happened to be digits — so a number is only taken from a path that actually ends in one. */ export function pullRequest( version: UnitVersion | undefined, /** The project's remote, when the caller has it: what a record-addressed pull's repository page * is built from, since the record itself names no host. */ gitUrl?: string, ): PullLink | undefined { const value = version?.artifact.value.links?.pr ?? version?.merges[0]?.value.pr if (!value) return undefined if (value.startsWith('at://')) { const list = RECORD_PULL.test(value) ? pullListPage(gitUrl) : undefined return { ...(list ? { href: list } : {}), uri: value, number: '' } } const number = value.slice(value.lastIndexOf('/') + 1) return { href: value, number: /^\d+$/.test(number) ? number : '' } } /** * The repository's pull LIST, the safe fallback for a tangled artifact that carries only an at-uri. * * The daemon now derives a direct page from Bobbin when it writes the artifact. Older records and * writes made while Bobbin is disabled or warming retain the stable record URI, so the UI links * those to the list rather than guessing a number. Built from the project's remote and its host, * so a self-hosted knot links to itself rather than to tangled.org. */ function pullListPage(gitUrl: string | undefined): string | undefined { if (!gitUrl) return undefined let parsed: URL try { parsed = new URL(gitUrl) } catch { return undefined } if (parsed.protocol !== 'https:') return undefined const [owner, repo] = parsed.pathname.replace(/^\/+/, '').split('/') if (!owner || !repo) return undefined return `https://${parsed.host}/${owner}/${repo.replace(/\.git$/, '')}/pulls` } const verdictBadge = (verdict: 'approve' | 'request_changes'): RowBadge => verdict === 'approve' ? { kind: 'ok', glyph: '✓', text: 'approved' } : { kind: 'bad', glyph: '✕', text: 'changes' } /** * How a goal ended, as one badge — the same one on the goal's own page, in the Logbook and on a * project's list, so the three cannot say different things about the same record. * * `completed` is the only ending that gets the sage ✓: DESIGN.md reserves the coloured washes for * verdicts, checks and "for you", and gives `flat` to everything factual, which is what "we decided * not to do it" is. Painting `dropped` green would read as a success on the page of a goal that was * abandoned. A disposition this build has never heard of is rendered as the word it carries, flat — * the fold ends the goal either way, so the badge says so either way. */ export function endingBadge(goal: GoalView): RowBadge | undefined { if (!goal.ended) return undefined const title = 'agents skip an ended goal; nothing was deleted, and it can be reopened' // A green tick is completion in every list this product draws, so below 560px it can carry the // badge alone. The other dispositions cannot: `parked`, `dropped` and `superseded` share one // glyph and one colour, and the word is the only thing telling them apart. return goal.disposition === 'completed' ? { kind: 'ok', glyph: '✓', text: 'completed', narrow: '', title } : { kind: 'flat', glyph: '⌁', text: goal.disposition ?? 'ended', title } } /** * The tail of a unit row: what is being asked for first, then the review, then what the machines * observed. * * The first badge is the OPEN REQUEST's state and the rest are the landed tip's, and a row carrying * both is a row telling the truth twice over: an approved, merged v1 whose v2 is claimed is * `claimed · approved · 3/3 checks · PR #241 · merged`. Reviews annotate rather than gate (design * §7), so a successor under way takes nothing away from what the version below it earned. */ export function unitBadges(unit: UnitView, options: { now: string } = { now: '' }): RowBadge[] { const badges: RowBadge[] = [] const request = requestState(unit) if (request === 'open') badges.push({ kind: 'flat', glyph: '○', text: 'open' }) if (request === 'assigned') badges.push({ kind: 'accent', glyph: '◍', text: 'assigned', title: 'named to an agent, and not claimed yet' }) if (request === 'claimed') badges.push({ kind: 'accent', glyph: '◐', text: 'claimed', title: claimExpiry(unit, options.now) ?? 'an agent holds a live lease' }) if (request === 'awaiting') badges.push({ kind: 'warn', glyph: '◉', text: 'awaiting you' }) if (unit.retracted) badges.push({ kind: 'flat', glyph: '⊘', text: 'retracted' }) if (unit.verdict) badges.push(verdictBadge(unit.verdict)) else if (unit.versions.length > 0 && !unit.retracted) badges.push({ kind: 'flat', glyph: '◇', text: 'not reviewed' }) const checks = checkTally(unit.current) if (checks) badges.push({ kind: checks.pass === checks.total ? 'ok' : 'bad', glyph: checks.pass === checks.total ? '✓' : '✕', text: `${checks.pass}/${checks.total} checks`, }) const pr = pullRequest(unit.current) if (pr?.number) badges.push({ kind: 'flat', glyph: '◍', text: `PR #${pr.number}` }) if (unit.state === 'merged') badges.push({ kind: 'ok', glyph: '✓', text: 'merged' }) return badges } /** * The tail of an ask row, mirroring `unitBadges`: state first, then what it pins. * * Shorter than a unit's tail because an ask has less to say — nothing lands, so there is no verdict, * no checks and no PR. What it does have that a unit row does not is the version it is a review OF, * which is the only thing distinguishing two reviews owed on two versions of one document. */ export function askBadges(ask: AskContext, options: { now: string } = { now: '' }): RowBadge[] { const badges: RowBadge[] = [] if (ask.state === 'open') badges.push({ kind: 'flat', glyph: '○', text: 'open' }) if (ask.state === 'assigned') badges.push({ kind: 'accent', glyph: '◍', text: 'assigned', title: 'named to an agent, and not claimed yet' }) if (ask.state === 'claimed') badges.push({ kind: 'accent', glyph: '◐', text: 'claimed', title: leaseExpiry(ask.claim, options.now) ?? 'an agent holds a live lease' }) if (ask.state === 'awaiting') badges.push({ kind: 'warn', glyph: '◉', text: 'awaiting you' }) if (ask.pinned) badges.push({ kind: 'flat', glyph: '◇', text: pinnedLabel(ask.pinned.unit, ask.pinned.version) }) return badges } /** * The state circle's shape — the TIP's state, deliberately, not the open request's (`requestState` * above). `landed` is what the tip is when nothing has reviewed it yet, and a unit whose successor is * under way still draws what has landed; the `claimed` badge beside it is what says the rest. */ export const dotState = (unit: UnitView): UnitState => unit.state export const stateLabel: Record = { open: 'open', assigned: 'assigned to an agent', claimed: 'claimed — a turn is under way', awaiting: 'awaiting your input', landed: 'landed — awaiting your review', judged: 'reviewed', merged: 'merged', retracted: 'retracted', } /** Age of the newest thing that happened on this unit — what its tail shows. */ export function unitAge(unit: UnitView, now: string): string { const latest = unit.current?.artifact.value.createdAt ?? unit.openRequest?.value.createdAt ?? unit.createdAt return relativeTime(latest, now) } // ── capture, both directions ──────────────────────────────────────────────────────────────────── // A capture is an ordinary project-scoped request whose `basedOn` names a goal artifact (design §8): // the turn distils, it never copies. Neither target's fold can see the pairing — the request lives // in the project view and the artifact it names lives in a goal view — so it is resolved here, by // strongref, across the whole index. export interface Capture { /** The system unit the goal artifact was distilled into, or the goal unit it came from. */ unit: UnitView target: GoalView | ProjectView request: IndexedRecord } const versionRefKeys = (unit: UnitView): Set => new Set(unit.versions.map((version) => `${version.artifact.uri}#${version.artifact.cid}`)) /** A version, the unit it belongs to, and the target that unit hangs off. */ export interface FoundVersion { unit: UnitView version: UnitVersion target: GoalView | ProjectView } /** * The version a strongref names, wherever in the space it lives — so a `basedOn` chip can read * "plan v2" rather than an rkey. Cached per index: the fold is pure, so the answer cannot change * without a new index. * * It carries the target as well as the unit, because whether a reference crosses from a goal into a * project is what makes a request a capture rather than ordinary provenance (§8, and `capturedFrom` * below). */ const versionsByRef = new WeakMap>() export function findVersion(index: MaterializedIndex, reference: StrongRef): FoundVersion | undefined { let map = versionsByRef.get(index) if (!map) { map = new Map() for (const { unit, target } of unitsWithContext(index)) { for (const version of unit.versions) { map.set(`${version.artifact.uri}#${version.artifact.cid}`, { unit, version, target }) } } versionsByRef.set(index, map) } return map.get(`${reference.uri}#${reference.cid}`) } /** * A living document's drift, but only where drift means anything. * * `staleness()` answers "how much has merged since this landed" for any unit; that is only a fact * worth showing about a PROJECT-scoped living document, because those are the records that ride in * every turn's bundle and go quietly out of date. A plan inside a goal is not stale when the goal's * own implementation merges — that is the plan working. */ export function driftOf(index: MaterializedIndex, projectUri: string, unit: UnitView): number { const type = artifactTypes(index).get(unit.type) if ((type?.scope ?? 'goal') !== 'project') return 0 return staleness(index, projectUri, unit) } /** * The system record a goal artifact was distilled into, if some project request named it. * * Only a GOAL artifact is distilled — the mirror of the rule `capturedFrom` applies from the other * side. Without it a living document reported itself: architecture v3's request names v2 and v2's * names v1, so the chain that makes it one row read as three distillations of itself. */ export function distilledInto(index: MaterializedIndex, unit: UnitView): Capture[] { const first = unit.versions[0] const source = first ? findVersion(index, first.artifact) : undefined if (!source || !isGoalView(source.target)) return [] const mine = versionRefKeys(unit) const out: Capture[] = [] for (const project of index.projects) { for (const candidate of timeline(index, project.target.uri)) { for (const request of candidate.requests) { if (request.value.basedOn.some((reference) => mine.has(`${reference.uri}#${reference.cid}`))) { out.push({ unit: candidate, target: project, request }) } } } } return out } /** * The goal artifact a system record was distilled from. * * Only a PROJECT-anchored request can be a capture. An implementation request naming the plan it * follows also carries a `basedOn`, and that is ordinary provenance inside one goal — calling it a * distillation would put "distilled from plan" on every implementation in the space. */ export function capturedFrom(index: MaterializedIndex, unit: UnitView): Capture | undefined { const refs = unit.requests .filter((request) => request.value.project !== undefined) .flatMap((request) => request.value.basedOn.map((reference) => ({ reference, request }))) if (refs.length === 0) return undefined const mine = versionRefKeys(unit) for (const goal of index.goals) { for (const candidate of timeline(index, goal.target.uri)) { const keys = versionRefKeys(candidate) for (const { reference, request } of refs) { const key = `${reference.uri}#${reference.cid}` if (!mine.has(key) && keys.has(key)) return { unit: candidate, target: goal, request } } } } return undefined }