diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..81c4e0b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 10.28.2 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm codegen + - run: git diff --exit-code + - run: pnpm typecheck + - run: pnpm lint + - run: pnpm test + - run: pnpm build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c7e935a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +coverage/ +.pnpm-store/ +*.db +*.db-shm +*.db-wal +.DS_Store diff --git a/design.md b/docs/design.md similarity index 98% rename from design.md rename to docs/design.md index 2206443..38a8930 100644 --- a/design.md +++ b/docs/design.md @@ -86,7 +86,7 @@ dev.radial.agent {handleName, harness, models: [{name, costHint}], Notes: - **Primary record + typed referencing records**, the same shape atproto itself uses (post + replies + likes). The goal author edits their own goal record (atproto records are owner-mutable, so goal closure is just an owner edit); everyone else contributes requests, artifacts, reviews, messages that reference it. Materialization is a deterministic fold over typed records, not op replay. -- **Records are immutable by convention**; the goal record's owner edit (closure) is the one sanctioned exception. atproto owners *can* rewrite records in place, so materializers keep the first version they ingest and flag any later CID change on the same record as an edit annotation — a review's pinned strongref stays meaningful even if the PDS now serves different bytes. +- **Records are immutable by convention**; goal closure and claim lease renewal are the sanctioned exceptions. atproto owners *can* rewrite records in place, so materializers keep the earliest repo revision they observe, independent of arrival order, and flag other CID changes on the same record as edit annotations — a review's pinned strongref stays meaningful even if the PDS now serves different bytes. - **Large bodies overflow to blobs.** atproto records have size ceilings; an artifact `body` that exceeds them is stored as a blob, with the record carrying a summary and the blob ref. - **Artifact types are data, not lexicon.** `plan` and `implementation` ship as built-ins; a space admin adds `security-review` or `architecture-diagram` by writing a registry record with a brief template and expected output shape. New capabilities are configuration, and the UI grows a button, not a schema. - **Scope is a property of the type.** Registry types declare where their deliverables live: `goal` (the default — plans, implementations) or `project` — **system artifacts** (architecture docs, ADRs) that outlive any one goal and collect in the UI's System section (§8). A request's goal-or-project reference matches its type's scope; `basedOn` carries cross-links between scopes. @@ -237,7 +237,7 @@ Corollary: conversation threads (`message`) are for humans and ambient discussio - System artifacts are on-protocol; an in-repo `docs/` mirror is a possible later addition, not v1. (§8) - Living-document staleness is a UI annotation, never a trigger. (§8) - Membership is an append-only event log (`addMember`/`removeMember`); removal never rewrites materialized history, and its `atRev` repo-rev cutoff makes the trust boundary identical across materializers regardless of ingestion timing. (§3) -- Records are immutable by convention; materializers keep the first-ingested version and flag in-place edits (goal closure is the sanctioned exception). Oversized bodies go to blobs. (§4) +- Records are immutable by convention; materializers keep the earliest observed repo revision and flag in-place edits (goal closure and claim lease renewal are sanctioned exceptions). Oversized bodies go to blobs. (§4) - Validation and signing are daemon-side; the in-container sidecar is a thin client and credentials never enter containers. (§2, §9, §13) - A reviewer needs no distinct DID — the same agent may review its own artifact; review value comes from the fresh per-turn context and operator-routed model diversity. (§7, §11) - Messages anchor to a goal or an artifact, and reference the request they respond to (`re`, with `declines` for turn-downs); reviews reference the request they fulfill. (§4, §6, §9) diff --git a/plan.md b/docs/plan.md similarity index 95% rename from plan.md rename to docs/plan.md index 3e5a689..6fb88e9 100644 --- a/plan.md +++ b/docs/plan.md @@ -33,10 +33,10 @@ The shared view logic (design §6), built as a pure library before any network e - Record store abstraction: `(did, collection, rkey) → record`, in-memory + SQLite. - Trust computation: space root → admins → the `addMember`/`removeMember` event log → which authors' records count (design §3), including removal semantics: the removal's `atRev` repo-rev cutoff draws the trust line — records at or before it are retained and marked, records after it are ignored — identically on every materializer regardless of ingestion timing. -- Immutability handling: keep the first-ingested version of every record; a later CID change on the same record is flagged as an edit annotation, never silently adopted (goal closure is the sanctioned owner-edit exception) (design §4). +- Immutability handling: keep the earliest repo revision observed for every record, independent of arrival order; a CID change on the same record is flagged as an edit annotation and never silently adopted (goal closure and claim lease renewal are the sanctioned owner-edit exceptions) (design §4). - The index itself: group artifacts under goals — project-scoped requests and artifacts group under the project instead (§8) — attach reviews/checkruns to subjects by strongref, match fulfillment by the `request` backref on artifacts and reviews, chain versions by `prev`, compute the **open-request set** (no fulfilling artifact/review, no declining message — `re` + `declines` — from the assignee) and the **current system-artifact set** (newest version of each project-scoped chain). - Claim tie-break for open requests (earliest `createdAt`, then lowest record URI — stable across lease renewals) — implemented and tested here, unused until Phase 7. -- **Test strategy is the deliverable:** golden-scenario tests (artifact v1→v2 with a review pinned to v1; an artifact edited in place after a review landed — first version retained, edit flagged; member removed mid-goal — records up to the removal's `atRev` retained and marked, records past it ignored; competing claims; a system-artifact chain whose versions were captured from two different goals) plus a property test — *any permutation of record arrival order yields the same index*. Order-insensitivity is the design's central convergence promise; encode it as a test that runs forever after. +- **Test strategy is the deliverable:** golden-scenario tests (artifact v1→v2 with a review pinned to v1; an artifact edited in place after a review landed — earliest repo version retained, edit flagged; member removed mid-goal — records up to the removal's `atRev` retained and marked, records past it ignored; competing claims; a system-artifact chain whose versions were captured from two different goals) plus a property test — *any permutation of record arrival order yields the same index*. Order-insensitivity is the design's central convergence promise; encode it as a test that runs forever after. **Exit:** property test passes across shuffled ingestion orders for every golden scenario; a `radial-debug index ` CLI prints the materialized view (artifact timeline, version chains, open requests) from a directory of record fixtures. diff --git a/package.json b/package.json new file mode 100644 index 0000000..6bfd342 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "radial", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "pnpm -r build", + "codegen": "pnpm --filter @radial/lexicons codegen", + "lint": "pnpm -r lint", + "test": "pnpm --filter @radial/core test", + "test:watch": "pnpm --filter @radial/core test:watch", + "typecheck": "pnpm -r typecheck" + }, + "devDependencies": { + "typescript": "5.9.3" + } +} diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..571eb90 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,23 @@ +{ + "name": "@radial/core", + "version": "0.0.0", + "private": true, + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "import": "./dist/index.js" + } + }, + "bin": { + "radial-debug": "./dist/cli.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json && node scripts/build.mjs", + "debug": "pnpm build && node dist/cli.js", + "lint": "pnpm typecheck && node scripts/lint.mjs", + "test": "pnpm build && node --test test/*.test.mjs", + "test:watch": "pnpm build && node --test --watch test/*.test.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + } +} diff --git a/packages/core/scripts/build.mjs b/packages/core/scripts/build.mjs new file mode 100644 index 0000000..89d8c54 --- /dev/null +++ b/packages/core/scripts/build.mjs @@ -0,0 +1,25 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { stripTypeScriptTypes } from 'node:module' +import { dirname, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const outputRoot = resolve(here, '../dist') + +async function compile(input, output, rewriteTestImports = false) { + await mkdir(dirname(output), { recursive: true }) + const source = await readFile(input, 'utf8') + let javascript = stripTypeScriptTypes(source, { + mode: 'transform', + sourceMap: true, + sourceUrl: pathToFileURL(input).href, + }) + if (rewriteTestImports) javascript = javascript.replaceAll("'../src/", "'../") + await writeFile(output, javascript) +} + +await compile( + resolve(here, '../test/scenario.ts'), + resolve(outputRoot, 'test/scenario.js'), + true, +) diff --git a/packages/core/scripts/lint.mjs b/packages/core/scripts/lint.mjs new file mode 100644 index 0000000..0eda706 --- /dev/null +++ b/packages/core/scripts/lint.mjs @@ -0,0 +1,22 @@ +import { readdir, readFile } from 'node:fs/promises' +import { stripTypeScriptTypes } from 'node:module' +import { dirname, extname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const sourceRoot = resolve(here, '../src') + +async function filesUnder(directory) { + const entries = await readdir(directory, { withFileTypes: true }) + const files = [] + for (const entry of entries) { + const path = join(directory, entry.name) + if (entry.isDirectory()) files.push(...(await filesUnder(path))) + else if (extname(path) === '.ts') files.push(path) + } + return files +} + +for (const file of await filesUnder(sourceRoot)) { + stripTypeScriptTypes(await readFile(file, 'utf8'), { mode: 'transform' }) +} diff --git a/packages/core/src/cli.ts b/packages/core/src/cli.ts new file mode 100644 index 0000000..810b245 --- /dev/null +++ b/packages/core/src/cli.ts @@ -0,0 +1,83 @@ +#!/usr/bin/env node + +import { readdir, readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { COLLECTIONS, type RadialRecord } from './generated/records.js' +import { materialize, type GoalView } from './materializer.js' +import { MemoryRecordStore, type StoredRecord } from './store.js' + +function usage(): never { + console.error('Usage: radial-debug index --dir [--as-of ]') + process.exit(2) +} + +async function loadDirectory(directory: string): Promise { + const files = (await readdir(directory, { withFileTypes: true })) + .filter((entry) => entry.isFile() && entry.name.endsWith('.json')) + .map((entry) => entry.name) + .sort() + const records: StoredRecord[] = [] + for (const file of files) { + const value = JSON.parse(await readFile(resolve(directory, file), 'utf8')) as unknown + const entries = Array.isArray(value) ? value : [value] + for (const entry of entries) records.push(entry as StoredRecord) + } + return records +} + +function printGoal(view: GoalView): void { + console.log(`${view.target.value.title} (${view.target.uri})`) + console.log(`Open requests: ${view.openRequests.length}`) + for (const request of view.openRequests) { + console.log(` ${request.value.type} ${request.uri}`) + } + console.log('Artifact timeline:') + if (view.artifacts.length === 0) console.log(' (empty)') + for (const artifact of view.artifacts) { + const verdicts = view.reviews.filter( + (review) => + review.value.subject.uri === artifact.uri && review.value.subject.cid === artifact.cid, + ) + const suffix = verdicts.length + ? ` [${verdicts.map((review) => review.value.verdict).join(', ')}]` + : '' + console.log(` ${artifact.value.createdAt} ${artifact.value.type} ${artifact.uri}${suffix}`) + } + console.log('Version chains:') + if (view.artifactChains.length === 0) console.log(' (empty)') + for (const chain of view.artifactChains) { + console.log(` ${chain.versions.join(' -> ')} (current: ${chain.current})`) + } +} + +export async function main(argv = process.argv.slice(2)): Promise { + if (argv[0] !== 'index' || !argv[1]) usage() + const directoryIndex = argv.indexOf('--dir') + if (directoryIndex === -1 || !argv[directoryIndex + 1]) usage() + const asOfIndex = argv.indexOf('--as-of') + const goalUri = argv[1] + const records = await loadDirectory(resolve(argv[directoryIndex + 1] as string)) + const goal = records.find( + (record) => record.collection === COLLECTIONS.goal && record.uri === goalUri, + ) + if (!goal) throw new Error(`Goal record not found: ${goalUri}`) + const spaceUri = (goal.value as { space: { uri: string } }).space.uri + const store = new MemoryRecordStore() + records.forEach((record) => store.put(record)) + const index = materialize(store, { + spaceUri, + ...(asOfIndex === -1 ? {} : { asOf: argv[asOfIndex + 1] as string }), + }) + const view = index.goals.find((candidate) => candidate.target.uri === goalUri) + if (!view) throw new Error(`Goal is not trusted or does not resolve: ${goalUri}`) + printGoal(view) +} + +const entry = process.argv[1] +if (entry && import.meta.url === pathToFileURL(entry).href) { + main().catch((error: unknown) => { + console.error(error instanceof Error ? error.message : error) + process.exitCode = 1 + }) +} diff --git a/packages/core/src/generated/records.ts b/packages/core/src/generated/records.ts new file mode 100644 index 0000000..e06f74c --- /dev/null +++ b/packages/core/src/generated/records.ts @@ -0,0 +1,947 @@ +// Generated by packages/lexicons/scripts/generate.mjs. Do not edit. + +export interface StrongRef { + uri: string + cid: string +} + +export interface BlobRef { + $type: 'blob' + ref: { $link: string } + mimeType: string + size: number +} + +export const COLLECTIONS = { + addMember: "com.disnetdev.radial.addMember", + agent: "com.disnetdev.radial.agent", + artifact: "com.disnetdev.radial.artifact", + artifactRequest: "com.disnetdev.radial.artifactRequest", + artifactType: "com.disnetdev.radial.artifactType", + checkrun: "com.disnetdev.radial.checkrun", + claim: "com.disnetdev.radial.claim", + goal: "com.disnetdev.radial.goal", + message: "com.disnetdev.radial.message", + project: "com.disnetdev.radial.project", + removeMember: "com.disnetdev.radial.removeMember", + review: "com.disnetdev.radial.review", + space: "com.disnetdev.radial.space", +} as const + +export type Collection = (typeof COLLECTIONS)[keyof typeof COLLECTIONS] + +export interface AddMemberRecord { + $type: "com.disnetdev.radial.addMember" + space: StrongRef + did: string + kind: "human" | "agent" + role: "admin" | "member" | "agent" + createdAt: string +} + +export type AgentModel = { name: string; costHint: string } + +export interface AgentRecord { + $type: "com.disnetdev.radial.agent" + handleName: string + harness: string + models: Array + artifactTypes: Array + createdAt: string +} + +export type ArtifactLinks = { branch?: string; commit?: string; pr?: string } + +export interface ArtifactRecord { + $type: "com.disnetdev.radial.artifact" + request: StrongRef + goal?: StrongRef + project?: StrongRef + type: string + prev?: StrongRef + body: string + bodyBlob?: BlobRef + links: ArtifactLinks + criteria?: Array + createdAt: string +} + +export interface ArtifactRequestRecord { + $type: "com.disnetdev.radial.artifactRequest" + goal?: StrongRef + project?: StrongRef + type: string + subject?: StrongRef + basedOn: Array + assignee?: string + brief?: string + autoReview?: boolean + createdAt: string +} + +export type ArtifactTypeOutputSpec = { format: "markdown" | "json"; description: string; schema?: unknown } + +export interface ArtifactTypeRecord { + $type: "com.disnetdev.radial.artifactType" + space: StrongRef + name: string + brief: string + outputSpec: ArtifactTypeOutputSpec + scope: "goal" | "project" + createdAt: string +} + +export type CheckrunResult = { name: string; pass: boolean; log: string } + +export interface CheckrunRecord { + $type: "com.disnetdev.radial.checkrun" + artifact: StrongRef + commit: string + results: Array + createdAt: string +} + +export interface ClaimRecord { + $type: "com.disnetdev.radial.claim" + request: StrongRef + expiresAt: string + createdAt: string +} + +export interface GoalRecord { + $type: "com.disnetdev.radial.goal" + space: StrongRef + project: StrongRef + title: string + body: string + closed?: boolean + createdAt: string +} + +export interface MessageRecord { + $type: "com.disnetdev.radial.message" + goal?: StrongRef + artifact?: StrongRef + root?: StrongRef + parent?: StrongRef + body: string + mentions: Array + re?: StrongRef + declines?: boolean + createdAt: string +} + +export type ProjectCheck = { name: string; command: string } + +export interface ProjectRecord { + $type: "com.disnetdev.radial.project" + space: StrongRef + name: string + gitUrl: string + defaultBranch: string + checks: Array + autoReview: unknown + createdAt: string +} + +export interface RemoveMemberRecord { + $type: "com.disnetdev.radial.removeMember" + space: StrongRef + did: string + atRev: string + createdAt: string +} + +export type ReviewFinding = { path?: string; line?: number; severity: "info" | "warning" | "error"; body: string } + +export interface ReviewRecord { + $type: "com.disnetdev.radial.review" + subject: StrongRef + request?: StrongRef + verdict: "approve" | "request_changes" + findings: Array + createdAt: string +} + +export interface SpaceRecord { + $type: "com.disnetdev.radial.space" + name: string + description: string + createdAt: string +} + +export interface RecordByCollection { + [COLLECTIONS.addMember]: AddMemberRecord + [COLLECTIONS.agent]: AgentRecord + [COLLECTIONS.artifact]: ArtifactRecord + [COLLECTIONS.artifactRequest]: ArtifactRequestRecord + [COLLECTIONS.artifactType]: ArtifactTypeRecord + [COLLECTIONS.checkrun]: CheckrunRecord + [COLLECTIONS.claim]: ClaimRecord + [COLLECTIONS.goal]: GoalRecord + [COLLECTIONS.message]: MessageRecord + [COLLECTIONS.project]: ProjectRecord + [COLLECTIONS.removeMember]: RemoveMemberRecord + [COLLECTIONS.review]: ReviewRecord + [COLLECTIONS.space]: SpaceRecord +} + +export type RadialRecord = RecordByCollection[keyof RecordByCollection] + +export const lexiconSchemas = [ + { + "lexicon": 1, + "id": "com.disnetdev.radial.addMember", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "space", + "did", + "kind", + "role", + "createdAt" + ], + "properties": { + "space": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "did": { + "type": "string", + "format": "did" + }, + "kind": { + "type": "string", + "enum": [ + "human", + "agent" + ] + }, + "role": { + "type": "string", + "enum": [ + "admin", + "member", + "agent" + ] + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.agent", + "defs": { + "model": { + "type": "object", + "required": [ + "name", + "costHint" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 200 + }, + "costHint": { + "type": "string", + "maxLength": 100 + } + } + }, + "main": { + "type": "record", + "key": "any", + "record": { + "type": "object", + "required": [ + "handleName", + "harness", + "models", + "artifactTypes", + "createdAt" + ], + "properties": { + "handleName": { + "type": "string", + "maxLength": 200 + }, + "harness": { + "type": "string", + "maxLength": 200 + }, + "models": { + "type": "array", + "maxLength": 50, + "items": { + "type": "ref", + "ref": "#model" + } + }, + "artifactTypes": { + "type": "array", + "maxLength": 100, + "items": { + "type": "string", + "maxLength": 100 + } + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.artifact", + "defs": { + "links": { + "type": "object", + "properties": { + "branch": { + "type": "string", + "maxLength": 500 + }, + "commit": { + "type": "string", + "maxLength": 128 + }, + "pr": { + "type": "string", + "format": "uri", + "maxLength": 2000 + } + } + }, + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "request", + "type", + "body", + "links", + "createdAt" + ], + "properties": { + "request": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "goal": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "project": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "type": { + "type": "string", + "maxLength": 100 + }, + "prev": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "body": { + "type": "string", + "maxLength": 100000 + }, + "bodyBlob": { + "type": "blob", + "accept": [ + "text/markdown", + "text/plain", + "application/json" + ], + "maxSize": 10000000 + }, + "links": { + "type": "ref", + "ref": "#links" + }, + "criteria": { + "type": "array", + "maxLength": 100, + "items": { + "type": "string", + "maxLength": 2000 + } + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.artifactRequest", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "type", + "basedOn", + "createdAt" + ], + "properties": { + "goal": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "project": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "type": { + "type": "string", + "maxLength": 100 + }, + "subject": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "basedOn": { + "type": "array", + "maxLength": 100, + "items": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + } + }, + "assignee": { + "type": "string", + "format": "did" + }, + "brief": { + "type": "string", + "maxLength": 30000 + }, + "autoReview": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.artifactType", + "defs": { + "outputSpec": { + "type": "object", + "required": [ + "format", + "description" + ], + "properties": { + "format": { + "type": "string", + "enum": [ + "markdown", + "json" + ] + }, + "description": { + "type": "string", + "maxLength": 10000 + }, + "schema": { + "type": "unknown" + } + } + }, + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "space", + "name", + "brief", + "outputSpec", + "scope", + "createdAt" + ], + "properties": { + "space": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "brief": { + "type": "string", + "maxLength": 30000 + }, + "outputSpec": { + "type": "ref", + "ref": "#outputSpec" + }, + "scope": { + "type": "string", + "enum": [ + "goal", + "project" + ] + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.checkrun", + "defs": { + "result": { + "type": "object", + "required": [ + "name", + "pass", + "log" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 100 + }, + "pass": { + "type": "boolean" + }, + "log": { + "type": "string", + "maxLength": 100000 + } + } + }, + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "artifact", + "commit", + "results", + "createdAt" + ], + "properties": { + "artifact": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "commit": { + "type": "string", + "maxLength": 128 + }, + "results": { + "type": "array", + "maxLength": 100, + "items": { + "type": "ref", + "ref": "#result" + } + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.claim", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "request", + "expiresAt", + "createdAt" + ], + "properties": { + "request": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "expiresAt": { + "type": "string", + "format": "datetime" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.goal", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "space", + "project", + "title", + "body", + "createdAt" + ], + "properties": { + "space": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "project": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "title": { + "type": "string", + "maxLength": 300 + }, + "body": { + "type": "string", + "maxLength": 100000 + }, + "closed": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.message", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "body", + "mentions", + "createdAt" + ], + "properties": { + "goal": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "artifact": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "root": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "parent": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "body": { + "type": "string", + "maxLength": 30000 + }, + "mentions": { + "type": "array", + "maxLength": 50, + "items": { + "type": "string", + "format": "did" + } + }, + "re": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "declines": { + "type": "boolean" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.project", + "defs": { + "check": { + "type": "object", + "required": [ + "name", + "command" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 100 + }, + "command": { + "type": "string", + "maxLength": 1000 + } + } + }, + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "space", + "name", + "gitUrl", + "defaultBranch", + "checks", + "autoReview", + "createdAt" + ], + "properties": { + "space": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "name": { + "type": "string", + "maxLength": 100 + }, + "gitUrl": { + "type": "string", + "format": "uri", + "maxLength": 2000 + }, + "defaultBranch": { + "type": "string", + "maxLength": 255 + }, + "checks": { + "type": "array", + "maxLength": 50, + "items": { + "type": "ref", + "ref": "#check" + } + }, + "autoReview": { + "type": "unknown" + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.removeMember", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "space", + "did", + "atRev", + "createdAt" + ], + "properties": { + "space": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "did": { + "type": "string", + "format": "did" + }, + "atRev": { + "type": "string", + "minLength": 1, + "maxLength": 32 + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.review", + "defs": { + "finding": { + "type": "object", + "required": [ + "severity", + "body" + ], + "properties": { + "path": { + "type": "string", + "maxLength": 2000 + }, + "line": { + "type": "integer", + "minimum": 1 + }, + "severity": { + "type": "string", + "enum": [ + "info", + "warning", + "error" + ] + }, + "body": { + "type": "string", + "maxLength": 10000 + } + } + }, + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "subject", + "verdict", + "findings", + "createdAt" + ], + "properties": { + "subject": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "request": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + }, + "verdict": { + "type": "string", + "enum": [ + "approve", + "request_changes" + ] + }, + "findings": { + "type": "array", + "maxLength": 100, + "items": { + "type": "ref", + "ref": "#finding" + } + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + }, + { + "lexicon": 1, + "id": "com.disnetdev.radial.space", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": [ + "name", + "description", + "createdAt" + ], + "properties": { + "name": { + "type": "string", + "maxLength": 100 + }, + "description": { + "type": "string", + "maxLength": 3000 + }, + "createdAt": { + "type": "string", + "format": "datetime" + } + } + } + } + } + } +] as const diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..bc05d81 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,4 @@ +export * from './materializer.js' +export * from './records.js' +export * from './store.js' +export * from './validation.js' diff --git a/packages/core/src/materializer.ts b/packages/core/src/materializer.ts new file mode 100644 index 0000000..abc8128 --- /dev/null +++ b/packages/core/src/materializer.ts @@ -0,0 +1,545 @@ +import { + COLLECTIONS, + type AddMemberRecord, + type ArtifactRecord, + type ArtifactRequestRecord, + type ArtifactTypeRecord, + type CheckrunRecord, + type ClaimRecord, + type GoalRecord, + type MessageRecord, + type ProjectRecord, + type RadialRecord, + type RemoveMemberRecord, + type ReviewRecord, + type SpaceRecord, + type StrongRef, +} from './generated/records.js' +import type { EditAnnotation, RecordStore, StoredRecord } from './store.js' +import { validateRecord } from './validation.js' + +export type TrustStatus = 'active' | 'removed' + +export interface IndexedRecord extends StoredRecord { + trust: TrustStatus +} + +export interface MemberView { + did: string + kind: 'human' | 'agent' + role: 'admin' | 'member' | 'agent' + active: boolean + cutoff?: string +} + +export interface IgnoredRecord { + uri: string + reason: string +} + +export interface ArtifactChain { + root: string + versions: string[] + current: string +} + +export interface TargetView { + target: IndexedRecord + requests: Array> + openRequests: Array> + artifacts: Array> + artifactChains: ArtifactChain[] + reviews: Array> + checkruns: Array> + messages: Array> + winningClaims: Record> +} + +export interface GoalView extends TargetView {} + +export interface ProjectView extends TargetView { + currentSystemArtifacts: Array> +} + +export interface MaterializedIndex { + space: IndexedRecord + members: MemberView[] + artifactTypes: Array> + goals: GoalView[] + projects: ProjectView[] + edits: EditAnnotation[] + ignored: IgnoredRecord[] +} + +export interface MaterializeOptions { + spaceUri: string + asOf?: string +} + +type AnyIndexed = IndexedRecord + +const compareRecord = (left: StoredRecord, right: StoredRecord): number => { + const leftCreated = (left.value as { createdAt?: string }).createdAt ?? '' + const rightCreated = (right.value as { createdAt?: string }).createdAt ?? '' + return leftCreated.localeCompare(rightCreated) || left.uri.localeCompare(right.uri) +} + +const exactRef = (reference: StrongRef | undefined, record: StoredRecord | undefined): boolean => + Boolean( + reference && + record && + reference.uri === record.uri && + (record.collection === COLLECTIONS.goal || reference.cid === record.cid), + ) + +const refKey = (reference: StrongRef): string => `${reference.uri}#${reference.cid}` +const recordRefKey = (record: StoredRecord): string => `${record.uri}#${record.cid}` + +function asIndexed(record: StoredRecord, trust: TrustStatus): IndexedRecord { + return { ...record, value: record.value as T, trust } +} + +function trustRecords( + records: StoredRecord[], + spaceUri: string, +): { + space: IndexedRecord + records: AnyIndexed[] + members: MemberView[] + ignored: IgnoredRecord[] +} { + const ignored: IgnoredRecord[] = [] + const valid = records.filter((record) => { + const result = validateRecord(record.collection, record.value) + if (!result.success) { + ignored.push({ + uri: record.uri, + reason: `invalid record: ${result.issues.map((issue) => `${issue.path} ${issue.message}`).join(', ')}`, + }) + return false + } + return true + }) + const spaceRecord = valid.find( + (record) => record.collection === COLLECTIONS.space && record.uri === spaceUri, + ) + if (!spaceRecord) throw new Error(`Space record not found: ${spaceUri}`) + const space = asIndexed(spaceRecord, 'active') + const rootDid = space.did + + const memberState = new Map() + const events = valid + .filter( + (record) => + record.collection === COLLECTIONS.addMember || + record.collection === COLLECTIONS.removeMember, + ) + .filter((record) => exactRef((record.value as AddMemberRecord).space, spaceRecord)) + .sort(compareRecord) + + const excludedEvents = new Set() + let authorizedEvents = new Set() + let membershipIgnored: IgnoredRecord[] = [] + let changed = true + while (changed) { + changed = false + memberState.clear() + memberState.set(rootDid, { + did: rootDid, + kind: 'human', + role: 'admin', + active: true, + }) + authorizedEvents = new Set() + membershipIgnored = [] + + for (const event of events) { + if (excludedEvents.has(event.uri)) { + membershipIgnored.push({ + uri: event.uri, + reason: 'membership event is after author removal cutoff', + }) + continue + } + const author = memberState.get(event.did) + if (event.did !== rootDid && (!author?.active || author.role !== 'admin')) { + membershipIgnored.push({ + uri: event.uri, + reason: 'membership event author is not an active admin', + }) + continue + } + authorizedEvents.add(event.uri) + if (event.collection === COLLECTIONS.addMember) { + const value = event.value as AddMemberRecord + if (value.did === rootDid) continue + memberState.set(value.did, { + did: value.did, + kind: value.kind, + role: value.role, + active: true, + }) + } else { + const value = event.value as RemoveMemberRecord + if (value.did === rootDid) continue + const current = memberState.get(value.did) + if (current) { + memberState.set(value.did, { ...current, active: false, cutoff: value.atRev }) + } + } + } + + for (const event of events) { + if (!authorizedEvents.has(event.uri)) continue + const author = memberState.get(event.did) + if (!author?.active && author?.cutoff && event.rev > author.cutoff) { + excludedEvents.add(event.uri) + changed = true + } + } + } + ignored.push(...membershipIgnored) + + const trusted: AnyIndexed[] = [] + for (const record of valid) { + if ( + (record.collection === COLLECTIONS.addMember || + record.collection === COLLECTIONS.removeMember) && + !authorizedEvents.has(record.uri) + ) { + continue + } + const member = memberState.get(record.did) + if (!member) { + ignored.push({ uri: record.uri, reason: 'author is not a space member' }) + continue + } + if (!member.active && (!member.cutoff || record.rev > member.cutoff)) { + ignored.push({ uri: record.uri, reason: `record is after removal cutoff ${member.cutoff ?? ''}` }) + continue + } + trusted.push(asIndexed(record, member.active ? 'active' : 'removed')) + } + + return { + space, + records: trusted.sort((a, b) => a.uri.localeCompare(b.uri)), + members: [...memberState.values()].sort((a, b) => a.did.localeCompare(b.did)), + ignored: ignored.sort((a, b) => a.uri.localeCompare(b.uri) || a.reason.localeCompare(b.reason)), + } +} + +function buildChains(artifacts: Array>): ArtifactChain[] { + const byRef = new Map(artifacts.map((artifact) => [recordRefKey(artifact), artifact])) + const rootFor = (artifact: IndexedRecord): IndexedRecord => { + const seen = new Set([recordRefKey(artifact)]) + let cursor = artifact + while (cursor.value.prev) { + const previous = byRef.get(refKey(cursor.value.prev)) + if ( + !previous || + previous.value.type !== artifact.value.type || + seen.has(recordRefKey(previous)) + ) { + break + } + seen.add(recordRefKey(previous)) + cursor = previous + } + return cursor + } + const groups = new Map>>() + for (const artifact of artifacts) { + const root = rootFor(artifact) + const group = groups.get(recordRefKey(root)) ?? [] + group.push(artifact) + groups.set(recordRefKey(root), group) + } + return [...groups.entries()] + .map(([root, versions]) => { + const ordered = versions.sort(compareRecord) + const referenced = new Set( + ordered.flatMap((version) => (version.value.prev ? [refKey(version.value.prev)] : [])), + ) + const leaves = ordered.filter((version) => !referenced.has(recordRefKey(version))) + const current = [...leaves].sort(compareRecord).at(-1) as IndexedRecord + return { + root: root.slice(0, root.lastIndexOf('#')), + versions: ordered.map((version) => version.uri), + current: current.uri, + } + }) + .sort((a, b) => a.root.localeCompare(b.root)) +} + +interface ViewInput { + target: IndexedRecord + requests: Array> + artifacts: Array> + reviews: Array> + checkruns: Array> + messages: Array> + claims: Array> + asOf: string +} + +function buildView(input: ViewInput): TargetView { + const requestByRef = new Map(input.requests.map((request) => [recordRefKey(request), request])) + const artifactByRef = new Map(input.artifacts.map((artifact) => [recordRefKey(artifact), artifact])) + const fulfilled = new Set() + + for (const artifact of input.artifacts) { + if (requestByRef.has(refKey(artifact.value.request))) fulfilled.add(refKey(artifact.value.request)) + } + for (const review of input.reviews) { + if (!review.value.request) continue + const request = requestByRef.get(refKey(review.value.request)) + if ( + request?.value.type === 'review' && + request.value.subject && + refKey(request.value.subject) === refKey(review.value.subject) + ) { + fulfilled.add(refKey(review.value.request)) + } + } + for (const message of input.messages) { + if (!message.value.declines || !message.value.re) continue + const request = requestByRef.get(refKey(message.value.re)) + if (request?.value.assignee === message.did) fulfilled.add(refKey(message.value.re)) + } + + const openRequests = input.requests.filter((request) => !fulfilled.has(recordRefKey(request))) + const openRefs = new Set(openRequests.map(recordRefKey)) + const winningClaims: Record> = {} + const claimsByRequest = new Map>>() + for (const claim of input.claims) { + const requestKey = refKey(claim.value.request) + const request = requestByRef.get(requestKey) + if ( + !request || + request.value.assignee || + !openRefs.has(requestKey) || + claim.value.expiresAt < input.asOf + ) { + continue + } + const claims = claimsByRequest.get(requestKey) ?? [] + claims.push(claim) + claimsByRequest.set(requestKey, claims) + } + for (const [requestKey, claims] of claimsByRequest) { + const winner = claims.sort(compareRecord)[0] + if (winner) winningClaims[requestKey.slice(0, requestKey.lastIndexOf('#'))] = winner + } + + return { + target: input.target, + requests: input.requests.sort(compareRecord), + openRequests: openRequests.sort(compareRecord), + artifacts: input.artifacts.sort(compareRecord), + artifactChains: buildChains(input.artifacts), + reviews: input.reviews + .filter((review) => artifactByRef.has(refKey(review.value.subject))) + .sort(compareRecord), + checkruns: input.checkruns + .filter((checkrun) => artifactByRef.has(refKey(checkrun.value.artifact))) + .sort(compareRecord), + messages: input.messages.sort(compareRecord), + winningClaims, + } +} + +export function materialize(store: RecordStore, options: MaterializeOptions): MaterializedIndex { + const trust = trustRecords(store.records(), options.spaceUri) + const asOf = options.asOf ?? new Date().toISOString() + const rootDid = trust.space.did + const roleByDid = new Map(trust.members.map((member) => [member.did, member.role])) + + const artifactTypes = trust.records + .filter( + (record): record is IndexedRecord => + record.collection === COLLECTIONS.artifactType && + exactRef((record.value as ArtifactTypeRecord).space, trust.space) && + (record.did === rootDid || roleByDid.get(record.did) === 'admin'), + ) + .sort(compareRecord) + const typeByName = new Map>() + for (const type of artifactTypes) { + if (!typeByName.has(type.value.name)) typeByName.set(type.value.name, type) + } + + const projects = trust.records.filter( + (record): record is IndexedRecord => + record.collection === COLLECTIONS.project && + exactRef((record.value as ProjectRecord).space, trust.space), + ) + const projectByRef = new Map(projects.map((project) => [recordRefKey(project), project])) + const goals = trust.records.filter( + (record): record is IndexedRecord => + record.collection === COLLECTIONS.goal && + exactRef((record.value as GoalRecord).space, trust.space) && + projectByRef.has(refKey((record.value as GoalRecord).project)), + ) + const goalByUri = new Map(goals.map((goal) => [goal.uri, goal])) + + const regularRequests = trust.records.filter( + (record): record is IndexedRecord => { + if (record.collection !== COLLECTIONS.artifactRequest) return false + const request = record.value as ArtifactRequestRecord + if (request.type === 'review') return false + const expectedScope = typeByName.get(request.type)?.value.scope + if (!expectedScope) return false + return expectedScope === 'goal' + ? Boolean(request.goal && goalByUri.has(request.goal.uri)) + : Boolean(request.project && projectByRef.has(refKey(request.project))) + }, + ) + const regularRequestByRef = new Map( + regularRequests.map((request) => [recordRefKey(request), request]), + ) + + const artifacts = trust.records.filter( + (record): record is IndexedRecord => { + if (record.collection !== COLLECTIONS.artifact) return false + const artifact = record.value as ArtifactRecord + const type = typeByName.get(artifact.type) + const request = regularRequestByRef.get(refKey(artifact.request)) + if (!type || !request || request.value.type !== artifact.type) return false + return type.value.scope === 'goal' + ? Boolean( + artifact.goal && + goalByUri.has(artifact.goal.uri) && + request.value.goal?.uri === artifact.goal.uri, + ) + : Boolean( + artifact.project && + projectByRef.has(refKey(artifact.project)) && + request.value.project?.uri === artifact.project.uri, + ) + }, + ) + const artifactByRef = new Map(artifacts.map((artifact) => [recordRefKey(artifact), artifact])) + const reviewRequests = trust.records.filter( + (record): record is IndexedRecord => { + if (record.collection !== COLLECTIONS.artifactRequest) return false + const request = record.value as ArtifactRequestRecord + if (request.type !== 'review' || !request.subject) return false + const subject = artifactByRef.get(refKey(request.subject)) + if (!subject) return false + return subject.value.goal + ? request.goal?.uri === subject.value.goal.uri + : Boolean( + request.project && + subject.value.project && + refKey(request.project) === refKey(subject.value.project), + ) + }, + ) + const requests = [...regularRequests, ...reviewRequests].sort(compareRecord) + const requestByRef = new Map(requests.map((request) => [recordRefKey(request), request])) + + const reviews = trust.records.filter( + (record): record is IndexedRecord => + record.collection === COLLECTIONS.review && + artifactByRef.has(refKey((record.value as ReviewRecord).subject)), + ) + const checkruns = trust.records.filter( + (record): record is IndexedRecord => + record.collection === COLLECTIONS.checkrun && + artifactByRef.has(refKey((record.value as CheckrunRecord).artifact)), + ) + const claims = trust.records.filter( + (record): record is IndexedRecord => + record.collection === COLLECTIONS.claim && + requestByRef.has(refKey((record.value as ClaimRecord).request)), + ) + const messages = trust.records.filter( + (record): record is IndexedRecord => { + if (record.collection !== COLLECTIONS.message) return false + const message = record.value as MessageRecord + return Boolean( + (message.goal && goalByUri.has(message.goal.uri)) || + (message.artifact && artifactByRef.has(refKey(message.artifact))), + ) + }, + ) + + const targetUriForRequest = (request: IndexedRecord): string => + (request.value.goal ?? request.value.project)!.uri + const targetUriForArtifact = (artifact: IndexedRecord): string => + (artifact.value.goal ?? artifact.value.project)!.uri + const artifactTargetUri = (reference: StrongRef): string | undefined => { + const artifact = artifactByRef.get(refKey(reference)) + return artifact ? targetUriForArtifact(artifact) : undefined + } + const targetUriForMessage = (message: IndexedRecord): string | undefined => + message.value.goal?.uri ?? + (message.value.artifact ? artifactTargetUri(message.value.artifact) : undefined) + const targetUriForReview = (review: IndexedRecord): string | undefined => + artifactTargetUri(review.value.subject) + const targetUriForCheckrun = (checkrun: IndexedRecord): string | undefined => + artifactTargetUri(checkrun.value.artifact) + const targetUriForClaim = (claim: IndexedRecord): string | undefined => { + const request = requestByRef.get(refKey(claim.value.request)) + return request ? targetUriForRequest(request) : undefined + } + + const makeInput = ( + target: IndexedRecord, + ): ViewInput => ({ + target, + requests: requests.filter((request) => targetUriForRequest(request) === target.uri), + artifacts: artifacts.filter((artifact) => targetUriForArtifact(artifact) === target.uri), + reviews: reviews.filter((review) => targetUriForReview(review) === target.uri), + checkruns: checkruns.filter((checkrun) => targetUriForCheckrun(checkrun) === target.uri), + messages: messages.filter((message) => targetUriForMessage(message) === target.uri), + claims: claims.filter((claim) => targetUriForClaim(claim) === target.uri), + asOf, + }) + + const goalViews = goals.map((goal) => buildView(makeInput(goal))).sort((a, b) => a.target.uri.localeCompare(b.target.uri)) + const projectViews: ProjectView[] = projects + .map((project) => { + const view = buildView(makeInput(project)) + const currentUris = new Set(view.artifactChains.map((chain) => chain.current)) + return { + ...view, + currentSystemArtifacts: view.artifacts.filter((artifact) => currentUris.has(artifact.uri)), + } + }) + .sort((a, b) => a.target.uri.localeCompare(b.target.uri)) + + const associatedUris = new Set([ + trust.space.uri, + ...artifactTypes.map((record) => record.uri), + ...projects.map((record) => record.uri), + ...goals.map((record) => record.uri), + ...requests.map((record) => record.uri), + ...artifacts.map((record) => record.uri), + ...reviews.map((record) => record.uri), + ...checkruns.map((record) => record.uri), + ...claims.map((record) => record.uri), + ...messages.map((record) => record.uri), + ]) + const membershipCollections: string[] = [COLLECTIONS.addMember, COLLECTIONS.removeMember] + const unassociated = trust.records + .filter( + (record) => + !associatedUris.has(record.uri) && + !membershipCollections.includes(record.collection) && + record.collection !== COLLECTIONS.agent, + ) + .map((record) => ({ uri: record.uri, reason: 'record does not resolve into this space index' })) + + return { + space: trust.space, + members: trust.members, + artifactTypes, + goals: goalViews, + projects: projectViews, + edits: store.edits(), + ignored: [...trust.ignored, ...unassociated].sort( + (a, b) => a.uri.localeCompare(b.uri) || a.reason.localeCompare(b.reason), + ), + } +} diff --git a/packages/core/src/node-shims.d.ts b/packages/core/src/node-shims.d.ts new file mode 100644 index 0000000..3d2fc99 --- /dev/null +++ b/packages/core/src/node-shims.d.ts @@ -0,0 +1,40 @@ +declare const process: { + argv: string[] + exit(code?: number): never + exitCode: number | undefined +} + +declare module 'node:fs/promises' { + export interface Dirent { + name: string + isFile(): boolean + } + + export function readdir( + path: string, + options: { withFileTypes: true }, + ): Promise + export function readFile(path: string, encoding: 'utf8'): Promise +} + +declare module 'node:path' { + export function resolve(...paths: string[]): string +} + +declare module 'node:url' { + export function pathToFileURL(path: string): URL +} + +declare module 'node:sqlite' { + export interface StatementSync { + all(...anonymousParameters: unknown[]): unknown[] + run(...anonymousParameters: unknown[]): unknown + } + + export class DatabaseSync { + constructor(path: string) + exec(sql: string): void + prepare(sql: string): StatementSync + close(): void + } +} diff --git a/packages/core/src/records.ts b/packages/core/src/records.ts new file mode 100644 index 0000000..d0b91a7 --- /dev/null +++ b/packages/core/src/records.ts @@ -0,0 +1,16 @@ +import type { + ArtifactRecord, + ArtifactRequestRecord, + MessageRecord, + StrongRef, +} from './generated/records.js' + +type ExactlyOne = + | (Record & Partial>) + | (Record & Partial>) + +export type ScopedArtifactRequest = ArtifactRequestRecord & ExactlyOne<'goal', 'project'> +export type ScopedArtifact = ArtifactRecord & ExactlyOne<'goal', 'project'> +export type AnchoredMessage = MessageRecord & ExactlyOne<'goal', 'artifact'> + +export * from './generated/records.js' diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts new file mode 100644 index 0000000..a8e7ee2 --- /dev/null +++ b/packages/core/src/store.ts @@ -0,0 +1,210 @@ +import { DatabaseSync } from 'node:sqlite' +import { + COLLECTIONS, + type ClaimRecord, + type GoalRecord, + type RadialRecord, +} from './generated/records.js' + +export interface StoredRecord { + did: string + collection: string + rkey: string + uri: string + cid: string + rev: string + value: T +} + +export interface EditAnnotation { + uri: string + retainedCid: string + rejectedCids: string[] +} + +export interface RecordStore { + put(record: StoredRecord): void + get(did: string, collection: string, rkey: string): StoredRecord | undefined + records(): StoredRecord[] + edits(): EditAnnotation[] + close(): void +} + +const keyOf = (record: Pick): string => + `${record.did}\u0000${record.collection}\u0000${record.rkey}` + +const compareRevision = (left: StoredRecord, right: StoredRecord): number => + left.rev.localeCompare(right.rev) || left.cid.localeCompare(right.cid) + +function stable(value: unknown): string { + if (Array.isArray(value)) return `[${value.map(stable).join(',')}]` + if (value && typeof value === 'object') { + return `{${Object.entries(value) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, child]) => `${JSON.stringify(key)}:${stable(child)}`) + .join(',')}}` + } + return JSON.stringify(value) +} + +function stableWithout(value: RadialRecord, key: string): string { + return stable(Object.fromEntries(Object.entries(value).filter(([name]) => name !== key))) +} + +function select(versions: StoredRecord[]): { record: StoredRecord; edit?: EditAnnotation } { + const sorted = [...versions].sort(compareRevision) + const original = sorted[0] as StoredRecord + let record = original + const rejectedCids: string[] = [] + + if (original.collection === COLLECTIONS.goal) { + const baseline = stableWithout(original.value, 'closed') + for (const version of sorted.slice(1)) { + const goal = version.value as GoalRecord + if (stableWithout(version.value, 'closed') === baseline && goal.closed === true) { + record = version + } else { + rejectedCids.push(version.cid) + } + } + } else if (original.collection === COLLECTIONS.claim) { + const baseline = stableWithout(original.value, 'expiresAt') + for (const version of sorted.slice(1)) { + const claim = version.value as ClaimRecord + const selectedClaim = record.value as ClaimRecord + if ( + stableWithout(version.value, 'expiresAt') === baseline && + Date.parse(claim.expiresAt) >= Date.parse(selectedClaim.expiresAt) + ) { + record = version + } else { + rejectedCids.push(version.cid) + } + } + } else { + rejectedCids.push(...sorted.slice(1).map((version) => version.cid)) + } + + return { + record, + ...(rejectedCids.length > 0 + ? { edit: { uri: record.uri, retainedCid: record.cid, rejectedCids } } + : {}), + } +} + +function collapse(versions: StoredRecord[]): { records: StoredRecord[]; edits: EditAnnotation[] } { + const groups = new Map() + for (const version of versions) { + const key = keyOf(version) + const group = groups.get(key) ?? [] + const existing = group.find((candidate) => candidate.cid === version.cid) + if (!existing) group.push(version) + else if (version.rev < existing.rev) existing.rev = version.rev + groups.set(key, group) + } + const selected = [...groups.values()].map(select) + return { + records: selected.map((entry) => entry.record).sort((a, b) => a.uri.localeCompare(b.uri)), + edits: selected + .flatMap((entry) => (entry.edit ? [entry.edit] : [])) + .sort((a, b) => a.uri.localeCompare(b.uri)), + } +} + +export class MemoryRecordStore implements RecordStore { + readonly #versions: StoredRecord[] = [] + + put(record: StoredRecord): void { + this.#versions.push(structuredClone(record)) + } + + records(): StoredRecord[] { + return collapse(structuredClone(this.#versions)).records + } + + get(did: string, collection: string, rkey: string): StoredRecord | undefined { + return this.records().find( + (record) => record.did === did && record.collection === collection && record.rkey === rkey, + ) + } + + edits(): EditAnnotation[] { + return collapse(structuredClone(this.#versions)).edits + } + + close(): void {} +} + +export class SqliteRecordStore implements RecordStore { + readonly #database: DatabaseSync + + constructor(path = ':memory:') { + this.#database = new DatabaseSync(path) + this.#database.exec(` + CREATE TABLE IF NOT EXISTS record_versions ( + did TEXT NOT NULL, + collection TEXT NOT NULL, + rkey TEXT NOT NULL, + uri TEXT NOT NULL, + cid TEXT NOT NULL, + rev TEXT NOT NULL, + value_json TEXT NOT NULL, + PRIMARY KEY (did, collection, rkey, cid) + ) STRICT + `) + } + + put(record: StoredRecord): void { + this.#database + .prepare(` + INSERT INTO record_versions (did, collection, rkey, uri, cid, rev, value_json) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (did, collection, rkey, cid) DO UPDATE SET + rev = min(record_versions.rev, excluded.rev) + `) + .run( + record.did, + record.collection, + record.rkey, + record.uri, + record.cid, + record.rev, + JSON.stringify(record.value), + ) + } + + #versions(): StoredRecord[] { + const rows = this.#database + .prepare('SELECT did, collection, rkey, uri, cid, rev, value_json FROM record_versions') + .all() as Array> + return rows.map((row) => ({ + did: row.did as string, + collection: row.collection as string, + rkey: row.rkey as string, + uri: row.uri as string, + cid: row.cid as string, + rev: row.rev as string, + value: JSON.parse(row.value_json as string) as RadialRecord, + })) + } + + records(): StoredRecord[] { + return collapse(this.#versions()).records + } + + get(did: string, collection: string, rkey: string): StoredRecord | undefined { + const versions = this.#versions().filter( + (record) => record.did === did && record.collection === collection && record.rkey === rkey, + ) + return versions.length > 0 ? select(versions).record : undefined + } + + edits(): EditAnnotation[] { + return collapse(this.#versions()).edits + } + + close(): void { + this.#database.close() + } +} diff --git a/packages/core/src/validation.ts b/packages/core/src/validation.ts new file mode 100644 index 0000000..7e7193d --- /dev/null +++ b/packages/core/src/validation.ts @@ -0,0 +1,287 @@ +import { + COLLECTIONS, + lexiconSchemas, + type Collection, + type RadialRecord, +} from './generated/records.js' + +export interface ValidationIssue { + path: string + message: string +} + +export type ValidationResult = + | { success: true; value: RadialRecord } + | { success: false; issues: ValidationIssue[] } + +type Schema = { + type: string + required?: readonly string[] + properties?: Readonly> + items?: Schema + ref?: string + enum?: readonly unknown[] + minLength?: number + maxLength?: number + minimum?: number + maximum?: number + minSize?: number + maxSize?: number + format?: string + accept?: readonly string[] +} + +type Lexicon = { + id: string + defs: Readonly> +} + +const lexicons = new Map( + (lexiconSchemas as unknown as readonly Lexicon[]).map((lexicon) => [lexicon.id, lexicon]), +) + +const isObject = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value) + +function validateSchema( + value: unknown, + schema: Schema, + lexicon: Lexicon, + path: string, + issues: ValidationIssue[], +): void { + if (schema.type === 'unknown') return + + if (schema.type === 'ref') { + if (schema.ref === 'com.atproto.repo.strongRef') { + if ( + !isObject(value) || + typeof value.uri !== 'string' || + !value.uri.startsWith('at://') || + typeof value.cid !== 'string' || + value.cid.length === 0 + ) { + issues.push({ path, message: 'must be an atproto strong reference' }) + } + return + } + const definition = schema.ref?.startsWith('#') + ? lexicon.defs[schema.ref.slice(1)] + : undefined + if (!definition) { + issues.push({ path, message: `references unknown schema ${schema.ref ?? ''}` }) + return + } + validateSchema(value, definition, lexicon, path, issues) + return + } + + if (schema.type === 'object') { + if (!isObject(value)) { + issues.push({ path, message: 'must be an object' }) + return + } + const properties = schema.properties ?? {} + for (const required of schema.required ?? []) { + if (!(required in value)) { + issues.push({ path: `${path}.${required}`, message: 'is required' }) + } + } + for (const [key, child] of Object.entries(value)) { + if (key === '$type' && path === '$') continue + const property = properties[key] + if (!property) { + issues.push({ path: `${path}.${key}`, message: 'is not allowed' }) + } else { + validateSchema(child, property, lexicon, `${path}.${key}`, issues) + } + } + return + } + + if (schema.type === 'array') { + if (!Array.isArray(value)) { + issues.push({ path, message: 'must be an array' }) + return + } + if (schema.minLength !== undefined && value.length < schema.minLength) { + issues.push({ path, message: `must contain at least ${schema.minLength} items` }) + } + if (schema.maxLength !== undefined && value.length > schema.maxLength) { + issues.push({ path, message: `must contain at most ${schema.maxLength} items` }) + } + value.forEach((item, index) => + validateSchema(item, schema.items as Schema, lexicon, `${path}[${index}]`, issues), + ) + return + } + + if (schema.type === 'blob') { + if (!isObject(value)) { + issues.push({ path, message: 'must be a blob reference' }) + return + } + const ref = value.ref + const size = value.size + const mimeType = value.mimeType + if ( + value.$type !== 'blob' || + !isObject(ref) || + typeof ref.$link !== 'string' || + typeof size !== 'number' || + !Number.isInteger(size) || + typeof mimeType !== 'string' + ) { + issues.push({ path, message: 'must be a valid blob reference' }) + return + } + if (schema.maxSize !== undefined && size > schema.maxSize) { + issues.push({ path, message: `blob exceeds ${schema.maxSize} bytes` }) + } + if (schema.accept && !schema.accept.includes(mimeType)) { + issues.push({ path, message: `blob MIME type ${mimeType} is not accepted` }) + } + return + } + + if (schema.type === 'string') { + if (typeof value !== 'string') { + issues.push({ path, message: 'must be a string' }) + return + } + if (schema.minLength !== undefined && value.length < schema.minLength) { + issues.push({ path, message: `must be at least ${schema.minLength} characters` }) + } + if (schema.maxLength !== undefined && value.length > schema.maxLength) { + issues.push({ path, message: `must be at most ${schema.maxLength} characters` }) + } + if (schema.enum && !schema.enum.includes(value)) { + issues.push({ path, message: `must be one of ${schema.enum.join(', ')}` }) + } + if (schema.format === 'did' && !/^did:[a-z0-9]+:[A-Za-z0-9._:%-]+$/.test(value)) { + issues.push({ path, message: 'must be a DID' }) + } + if (schema.format === 'datetime' && !isDatetime(value)) { + issues.push({ path, message: 'must be an RFC 3339 datetime' }) + } + if (schema.format === 'uri' && !isUri(value)) { + issues.push({ path, message: 'must be an absolute URI' }) + } + return + } + + if (schema.type === 'integer') { + if (typeof value !== 'number' || !Number.isInteger(value)) { + issues.push({ path, message: 'must be an integer' }) + return + } + if (schema.minimum !== undefined && value < schema.minimum) { + issues.push({ path, message: `must be at least ${schema.minimum}` }) + } + if (schema.maximum !== undefined && value > schema.maximum) { + issues.push({ path, message: `must be at most ${schema.maximum}` }) + } + return + } + + if (schema.type === 'boolean' && typeof value !== 'boolean') { + issues.push({ path, message: 'must be a boolean' }) + } +} + +const isDatetime = (value: string): boolean => { + const match = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/.exec(value) + if (!match) return false + const [year, month, day, hour, minute, second] = match + .slice(1) + .map((part) => Number(part)) as [number, number, number, number, number, number] + const instant = new Date(0) + instant.setUTCFullYear(year, month - 1, day) + instant.setUTCHours(hour, minute, second, 0) + return ( + instant.getUTCFullYear() === year && + instant.getUTCMonth() === month - 1 && + instant.getUTCDate() === day && + instant.getUTCHours() === hour && + instant.getUTCMinutes() === minute && + instant.getUTCSeconds() === second + ) +} + +const isUri = (value: string): boolean => { + try { + return Boolean(new URL(value).protocol) + } catch { + return false + } +} + +function validateInvariants(collection: Collection, value: Record): ValidationIssue[] { + const issues: ValidationIssue[] = [] + const exactOne = (left: string, right: string): void => { + if ((left in value) === (right in value)) { + issues.push({ path: '$', message: `must contain exactly one of ${left} or ${right}` }) + } + } + + if (collection === COLLECTIONS.artifactRequest || collection === COLLECTIONS.artifact) { + exactOne('goal', 'project') + } + if (collection === COLLECTIONS.message) { + exactOne('goal', 'artifact') + if (value.declines === true && !('re' in value)) { + issues.push({ path: '$.re', message: 'is required when declines is true' }) + } + } + if (collection === COLLECTIONS.artifactRequest) { + if (value.type === 'review' && !('subject' in value)) { + issues.push({ path: '$.subject', message: 'is required for review requests' }) + } + if (value.type !== 'review' && 'subject' in value) { + issues.push({ path: '$.subject', message: 'is only allowed for review requests' }) + } + } + if (collection === COLLECTIONS.project) { + if (!isObject(value.autoReview)) { + issues.push({ path: '$.autoReview', message: 'must be an object of boolean values' }) + } else if (Object.values(value.autoReview).some((entry) => typeof entry !== 'boolean')) { + issues.push({ path: '$.autoReview', message: 'values must be booleans' }) + } + } + return issues +} + +export function validateRecord(collection: string, value: unknown): ValidationResult { + const lexicon = lexicons.get(collection) + if (!lexicon) { + return { success: false, issues: [{ path: '$type', message: `unknown collection ${collection}` }] } + } + const issues: ValidationIssue[] = [] + if (!isObject(value)) { + return { success: false, issues: [{ path: '$', message: 'must be an object' }] } + } + if (value.$type !== collection) { + issues.push({ path: '$.$type', message: `must equal ${collection}` }) + } + const main = lexicon.defs.main + if (!main?.record) { + return { + success: false, + issues: [{ path: '$type', message: `collection ${collection} has no record schema` }], + } + } + validateSchema(value, main.record, lexicon, '$', issues) + issues.push(...validateInvariants(collection as Collection, value)) + return issues.length > 0 + ? { success: false, issues } + : { success: true, value: value as unknown as RadialRecord } +} + +export function assertValidRecord(collection: string, value: unknown): asserts value is RadialRecord { + const result = validateRecord(collection, value) + if (!result.success) { + const details = result.issues.map((issue) => `${issue.path}: ${issue.message}`).join('; ') + throw new TypeError(`Invalid ${collection} record: ${details}`) + } +} diff --git a/packages/core/test/cli.test.mjs b/packages/core/test/cli.test.mjs new file mode 100644 index 0000000..8a4c21e --- /dev/null +++ b/packages/core/test/cli.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { spawnSync } from 'node:child_process' +import { it } from 'node:test' +import { goldenScenario } from '../dist/test/scenario.js' + +it('prints a goal index from a directory of record fixtures', async () => { + const directory = await mkdtemp(join(tmpdir(), 'radial-debug-')) + try { + const scenario = goldenScenario() + await writeFile(join(directory, 'records.json'), JSON.stringify(scenario.records)) + const result = spawnSync( + process.execPath, + [ + new URL('../dist/cli.js', import.meta.url).pathname, + 'index', + scenario.goalUri, + '--dir', + directory, + '--as-of', + '2026-02-01T00:00:00Z', + ], + { encoding: 'utf8' }, + ) + assert.equal(result.status, 0, result.stderr) + assert.match(result.stdout, /Build the index/) + assert.match(result.stdout, /Open requests: 1/) + assert.match(result.stdout, /Version chains:/) + assert.match(result.stdout, /plan-v1.*plan-v2/) + } finally { + await rm(directory, { recursive: true, force: true }) + } +}) diff --git a/packages/core/test/fixtures/invalid-records.json b/packages/core/test/fixtures/invalid-records.json new file mode 100644 index 0000000..28c9873 --- /dev/null +++ b/packages/core/test/fixtures/invalid-records.json @@ -0,0 +1,15 @@ +{ + "com.disnetdev.radial.space": {"$type":"com.disnetdev.radial.space","name":"x","description":"x","createdAt":"not-a-date"}, + "com.disnetdev.radial.addMember": {"$type":"com.disnetdev.radial.addMember","space":{"uri":"at://x","cid":"x"},"did":"nope","kind":"robot","role":"owner","createdAt":"bad"}, + "com.disnetdev.radial.removeMember": {"$type":"com.disnetdev.radial.removeMember","space":{"uri":"at://x","cid":"x"},"did":"nope","atRev":"","createdAt":"bad"}, + "com.disnetdev.radial.project": {"$type":"com.disnetdev.radial.project","space":{"uri":"at://x","cid":"x"},"name":"x","gitUrl":"nope","defaultBranch":"main","checks":[],"autoReview":{"plan":"yes"},"createdAt":"bad"}, + "com.disnetdev.radial.goal": {"$type":"com.disnetdev.radial.goal","space":{"uri":"at://x","cid":"x"},"project":{"uri":"at://x","cid":"x"},"title":"x","body":"x","createdAt":"bad"}, + "com.disnetdev.radial.artifactType": {"$type":"com.disnetdev.radial.artifactType","space":{"uri":"at://x","cid":"x"},"name":"x","brief":"x","outputSpec":{"format":"xml","description":"x"},"scope":"team","createdAt":"bad"}, + "com.disnetdev.radial.artifactRequest": {"$type":"com.disnetdev.radial.artifactRequest","type":"review","basedOn":[],"createdAt":"bad"}, + "com.disnetdev.radial.claim": {"$type":"com.disnetdev.radial.claim","request":{"uri":"at://x","cid":"x"},"expiresAt":"bad","createdAt":"bad"}, + "com.disnetdev.radial.artifact": {"$type":"com.disnetdev.radial.artifact","request":{"uri":"at://x","cid":"x"},"type":"plan","body":"x","links":{},"createdAt":"bad"}, + "com.disnetdev.radial.review": {"$type":"com.disnetdev.radial.review","subject":{"uri":"at://x","cid":"x"},"verdict":"maybe","findings":[],"createdAt":"bad"}, + "com.disnetdev.radial.checkrun": {"$type":"com.disnetdev.radial.checkrun","artifact":{"uri":"at://x","cid":"x"},"commit":"x","results":[],"createdAt":"bad"}, + "com.disnetdev.radial.message": {"$type":"com.disnetdev.radial.message","body":"x","mentions":[],"declines":true,"createdAt":"bad"}, + "com.disnetdev.radial.agent": {"$type":"com.disnetdev.radial.agent","handleName":"x","harness":"x","models":[],"artifactTypes":[],"createdAt":"bad"} +} diff --git a/packages/core/test/fixtures/valid-records.json b/packages/core/test/fixtures/valid-records.json new file mode 100644 index 0000000..ba55e27 --- /dev/null +++ b/packages/core/test/fixtures/valid-records.json @@ -0,0 +1,105 @@ +{ + "com.disnetdev.radial.space": { + "$type": "com.disnetdev.radial.space", + "name": "Radial", + "description": "A fixture space", + "createdAt": "2026-01-01T00:00:00Z" + }, + "com.disnetdev.radial.addMember": { + "$type": "com.disnetdev.radial.addMember", + "space": {"uri": "at://did:plc:root/com.disnetdev.radial.space/space", "cid": "cid-space"}, + "did": "did:plc:human", + "kind": "human", + "role": "member", + "createdAt": "2026-01-01T00:00:01Z" + }, + "com.disnetdev.radial.removeMember": { + "$type": "com.disnetdev.radial.removeMember", + "space": {"uri": "at://did:plc:root/com.disnetdev.radial.space/space", "cid": "cid-space"}, + "did": "did:plc:human", + "atRev": "0000000000009", + "createdAt": "2026-01-02T00:00:00Z" + }, + "com.disnetdev.radial.project": { + "$type": "com.disnetdev.radial.project", + "space": {"uri": "at://did:plc:root/com.disnetdev.radial.space/space", "cid": "cid-space"}, + "name": "radial", + "gitUrl": "https://github.com/disnet/radial-ng.git", + "defaultBranch": "main", + "checks": [{"name": "test", "command": "pnpm test"}], + "autoReview": {"plan": true}, + "createdAt": "2026-01-01T00:00:03Z" + }, + "com.disnetdev.radial.goal": { + "$type": "com.disnetdev.radial.goal", + "space": {"uri": "at://did:plc:root/com.disnetdev.radial.space/space", "cid": "cid-space"}, + "project": {"uri": "at://did:plc:human/com.disnetdev.radial.project/project", "cid": "cid-project"}, + "title": "Build phase one", + "body": "Materialize trusted records.", + "createdAt": "2026-01-01T00:00:04Z" + }, + "com.disnetdev.radial.artifactType": { + "$type": "com.disnetdev.radial.artifactType", + "space": {"uri": "at://did:plc:root/com.disnetdev.radial.space/space", "cid": "cid-space"}, + "name": "plan", + "brief": "Write a plan.", + "outputSpec": {"format": "markdown", "description": "Plan markdown"}, + "scope": "goal", + "createdAt": "2026-01-01T00:00:02Z" + }, + "com.disnetdev.radial.artifactRequest": { + "$type": "com.disnetdev.radial.artifactRequest", + "goal": {"uri": "at://did:plc:human/com.disnetdev.radial.goal/goal", "cid": "cid-goal"}, + "type": "plan", + "basedOn": [], + "assignee": "did:plc:agent", + "createdAt": "2026-01-01T00:00:05Z" + }, + "com.disnetdev.radial.claim": { + "$type": "com.disnetdev.radial.claim", + "request": {"uri": "at://did:plc:human/com.disnetdev.radial.artifactRequest/request", "cid": "cid-request"}, + "expiresAt": "2026-01-02T00:00:00Z", + "createdAt": "2026-01-01T00:00:06Z" + }, + "com.disnetdev.radial.artifact": { + "$type": "com.disnetdev.radial.artifact", + "request": {"uri": "at://did:plc:human/com.disnetdev.radial.artifactRequest/request", "cid": "cid-request"}, + "goal": {"uri": "at://did:plc:human/com.disnetdev.radial.goal/goal", "cid": "cid-goal"}, + "type": "plan", + "body": "Summary of the full plan stored in a blob.", + "bodyBlob": {"$type": "blob", "ref": {"$link": "bafk-plan"}, "mimeType": "text/markdown", "size": 4096}, + "links": {}, + "criteria": ["Tests pass"], + "createdAt": "2026-01-01T00:00:07Z" + }, + "com.disnetdev.radial.review": { + "$type": "com.disnetdev.radial.review", + "subject": {"uri": "at://did:plc:agent/com.disnetdev.radial.artifact/plan", "cid": "cid-plan"}, + "verdict": "approve", + "findings": [], + "createdAt": "2026-01-01T00:00:08Z" + }, + "com.disnetdev.radial.checkrun": { + "$type": "com.disnetdev.radial.checkrun", + "artifact": {"uri": "at://did:plc:agent/com.disnetdev.radial.artifact/implementation", "cid": "cid-implementation"}, + "commit": "0123456789abcdef", + "results": [{"name": "test", "pass": true, "log": "ok"}], + "createdAt": "2026-01-01T00:00:09Z" + }, + "com.disnetdev.radial.message": { + "$type": "com.disnetdev.radial.message", + "goal": {"uri": "at://did:plc:human/com.disnetdev.radial.goal/goal", "cid": "cid-goal"}, + "body": "Can you clarify the acceptance criteria?", + "mentions": ["did:plc:human"], + "re": {"uri": "at://did:plc:human/com.disnetdev.radial.artifactRequest/request", "cid": "cid-request"}, + "createdAt": "2026-01-01T00:00:10Z" + }, + "com.disnetdev.radial.agent": { + "$type": "com.disnetdev.radial.agent", + "handleName": "planner", + "harness": "codex", + "models": [{"name": "gpt-5", "costHint": "high"}], + "artifactTypes": ["plan", "implementation", "review"], + "createdAt": "2026-01-01T00:00:11Z" + } +} diff --git a/packages/core/test/materializer.test.mjs b/packages/core/test/materializer.test.mjs new file mode 100644 index 0000000..e4834f4 --- /dev/null +++ b/packages/core/test/materializer.test.mjs @@ -0,0 +1,185 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { materialize } from '../dist/materializer.js' +import { MemoryRecordStore } from '../dist/store.js' +import { goldenScenario } from '../dist/test/scenario.js' +import { COLLECTIONS } from '../dist/generated/records.js' + +const AS_OF = '2026-02-01T00:00:00Z' + +function build(records) { + const scenario = goldenScenario() + const store = new MemoryRecordStore() + records.forEach((record) => store.put(record)) + return materialize(store, { spaceUri: scenario.spaceUri, asOf: AS_OF }) +} + +function shuffled(records, seed) { + const result = [...records] + let state = seed >>> 0 + const random = () => { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + return state >>> 0 + } + for (let index = result.length - 1; index > 0; index -= 1) { + const target = random() % (index + 1) + ;[result[index], result[target]] = [result[target], result[index]] + } + return result +} + +describe('materializer golden scenario', () => { + it('pins reviews to versions, retains edits, and chains v1 to v2', () => { + const scenario = goldenScenario() + const index = build(scenario.records) + const goal = index.goals.find((view) => view.target.uri === scenario.goalUri) + assert.equal(goal?.target.value.closed, true) + assert.deepEqual(goal?.artifacts.map((artifact) => artifact.value.body), [ + 'Plan version one', + 'Plan version two', + ]) + assert.equal(goal?.artifactChains.length, 1) + assert.equal(goal?.artifactChains[0].versions[0], scenario.editedArtifactUri) + assert.match(goal?.artifactChains[0].versions[1], /\/plan-v2$/) + assert.equal(goal?.reviews.length, 2) + assert.equal( + goal?.reviews.some((review) => review.value.subject.cid === 'cid-plan-v1'), + true, + ) + assert.equal(goal?.checkruns.length, 1) + assert.equal(index.edits.some((edit) => edit.uri === scenario.editedArtifactUri), true) + }) + + it('retains and marks pre-cutoff records while ignoring later records', () => { + const scenario = goldenScenario() + const index = build(scenario.records) + const goal = index.goals.find((view) => view.target.uri === scenario.goalUri) + assert.equal(goal?.artifacts.length, 2) + assert.equal(goal?.artifacts.every((artifact) => artifact.trust === 'removed'), true) + assert.deepEqual( + index.ignored.find((ignored) => ignored.uri === scenario.ignoredAfterRemovalUri), + { + uri: scenario.ignoredAfterRemovalUri, + reason: 'record is after removal cutoff 0000000000010', + }, + ) + }) + + it('computes open requests, declines, and stable claim winners', () => { + const scenario = goldenScenario() + const index = build(scenario.records) + const goal = index.goals.find((view) => view.target.uri === scenario.goalUri) + assert.deepEqual(goal?.openRequests.map((request) => request.value.type), ['plan']) + assert.equal(goal?.openRequests[0].value.assignee, undefined) + const requestUri = goal?.openRequests[0].uri + assert.equal(goal?.winningClaims[requestUri].uri, scenario.claimWinnerUri) + }) + + it('uses the renewed expiry while preserving a claim tie-break', () => { + const scenario = goldenScenario() + const records = structuredClone(scenario.records) + const claim = records.find((record) => record.uri === scenario.claimWinnerUri) + claim.value.expiresAt = '2026-01-15T00:00:00Z' + records.push({ + ...structuredClone(claim), + cid: 'cid-claim-renewed', + rev: '0000000000003', + value: { ...structuredClone(claim.value), expiresAt: '2027-01-01T00:00:00Z' }, + }) + + const index = build(records) + const goal = index.goals.find((view) => view.target.uri === scenario.goalUri) + const requestUri = goal?.openRequests[0].uri + assert.equal(goal?.winningClaims[requestUri].uri, scenario.claimWinnerUri) + assert.equal(goal?.winningClaims[requestUri].cid, 'cid-claim-renewed') + }) + + it('does not apply membership events authored after the admin removal cutoff', () => { + const scenario = goldenScenario() + const records = structuredClone(scenario.records) + const removedAdmin = 'did:plc:agenta' + const membership = records.find( + (record) => + record.collection === COLLECTIONS.addMember && record.value.did === removedAdmin, + ) + membership.value.kind = 'human' + membership.value.role = 'admin' + const space = records.find((record) => record.uri === scenario.spaceUri) + const spaceRef = { uri: space.uri, cid: space.cid } + const ghostDid = 'did:plc:ghost' + const backdatedUri = `at://${removedAdmin}/${COLLECTIONS.addMember}/backdated-add` + const ghostMessageUri = `at://${ghostDid}/${COLLECTIONS.message}/ghost-message` + records.push({ + did: removedAdmin, + collection: COLLECTIONS.addMember, + rkey: 'backdated-add', + uri: backdatedUri, + cid: 'cid-backdated-add', + rev: '0000000000011', + value: { + $type: COLLECTIONS.addMember, + space: spaceRef, + did: ghostDid, + kind: 'human', + role: 'member', + createdAt: '2026-01-01T00:11:00Z', + }, + }) + records.push({ + did: ghostDid, + collection: COLLECTIONS.message, + rkey: 'ghost-message', + uri: ghostMessageUri, + cid: 'cid-ghost-message', + rev: '0000000000001', + value: { + $type: COLLECTIONS.message, + goal: { uri: scenario.goalUri, cid: 'cid-goal-open' }, + body: 'must not be trusted', + mentions: [], + createdAt: '2026-01-01T00:14:00Z', + }, + }) + + const index = build(records) + assert.equal(index.members.some((member) => member.did === ghostDid), false) + assert.equal( + index.ignored.some( + (entry) => + entry.uri === backdatedUri && + entry.reason === 'membership event is after author removal cutoff', + ), + true, + ) + assert.equal( + index.ignored.some( + (entry) => entry.uri === ghostMessageUri && entry.reason === 'author is not a space member', + ), + true, + ) + }) + + it('selects the current project artifact across goal capture contexts', () => { + const scenario = goldenScenario() + const index = build(scenario.records) + const project = index.projects.find((view) => view.target.uri === scenario.projectUri) + assert.equal(project?.artifacts.length, 2) + assert.equal(project?.artifactChains.length, 1) + assert.equal(project?.currentSystemArtifacts.length, 1) + assert.match(project?.currentSystemArtifacts[0].value.body, /later goal context/) + assert.deepEqual( + project?.requests.map((request) => request.value.basedOn[0].uri.split('/').at(-1)), + ['plan-v1', 'goal-two-plan'], + ) + }) + + it('is invariant under 200 deterministic random arrival permutations', () => { + const scenario = goldenScenario() + const expected = build(scenario.records) + for (let seed = 1; seed <= 200; seed += 1) { + assert.deepEqual(build(shuffled(scenario.records, seed)), expected, `seed ${seed}`) + } + }) +}) diff --git a/packages/core/test/scenario.ts b/packages/core/test/scenario.ts new file mode 100644 index 0000000..f90bf0e --- /dev/null +++ b/packages/core/test/scenario.ts @@ -0,0 +1,370 @@ +import { + COLLECTIONS, + type RadialRecord, + type StrongRef, +} from '../src/generated/records.js' +import type { StoredRecord } from '../src/store.js' + +const ROOT = 'did:plc:root' +const HUMAN = 'did:plc:human' +const AGENT_A = 'did:plc:agenta' +const AGENT_B = 'did:plc:agentb' +const AGENT_C = 'did:plc:agentc' + +const rev = (value: number): string => value.toString().padStart(13, '0') + +function stored( + did: string, + rkey: string, + value: RadialRecord, + revision: number, + cid = `cid-${rkey}`, +): StoredRecord { + return { + did, + collection: value.$type, + rkey, + uri: `at://${did}/${value.$type}/${rkey}`, + cid, + rev: rev(revision), + value, + } +} + +const ref = (record: StoredRecord): StrongRef => ({ uri: record.uri, cid: record.cid }) + +export interface GoldenScenario { + records: StoredRecord[] + spaceUri: string + goalUri: string + projectUri: string + editedArtifactUri: string + ignoredAfterRemovalUri: string + claimWinnerUri: string + originalGoalCid: string +} + +export function goldenScenario(): GoldenScenario { + const records: StoredRecord[] = [] + const add = (record: T): T => { + records.push(record) + return record + } + + const space = add( + stored(ROOT, 'space', { + $type: COLLECTIONS.space, + name: 'Radial', + description: 'Golden scenario', + createdAt: '2026-01-01T00:00:00Z', + }, 1, 'cid-space'), + ) + for (const [index, did] of [HUMAN, AGENT_A, AGENT_B, AGENT_C].entries()) { + add( + stored(ROOT, `member-${index}`, { + $type: COLLECTIONS.addMember, + space: ref(space), + did, + kind: did === HUMAN ? 'human' : 'agent', + role: did === HUMAN ? 'member' : 'agent', + createdAt: `2026-01-01T00:00:0${index + 1}Z`, + }, index + 2), + ) + } + const planType = add( + stored(ROOT, 'type-plan', { + $type: COLLECTIONS.artifactType, + space: ref(space), + name: 'plan', + brief: 'Write a concrete plan.', + outputSpec: { format: 'markdown', description: 'A plan' }, + scope: 'goal', + createdAt: '2026-01-01T00:00:10Z', + }, 10), + ) + void planType + add( + stored(ROOT, 'type-adr', { + $type: COLLECTIONS.artifactType, + space: ref(space), + name: 'adr', + brief: 'Distill an architectural decision.', + outputSpec: { format: 'markdown', description: 'An ADR' }, + scope: 'project', + createdAt: '2026-01-01T00:00:11Z', + }, 11), + ) + const project = add( + stored(HUMAN, 'project', { + $type: COLLECTIONS.project, + space: ref(space), + name: 'radial', + gitUrl: 'https://github.com/disnet/radial-ng.git', + defaultBranch: 'main', + checks: [{ name: 'test', command: 'pnpm test' }], + autoReview: {}, + createdAt: '2026-01-01T00:01:00Z', + }, 1, 'cid-project'), + ) + const goal = add( + stored(HUMAN, 'goal', { + $type: COLLECTIONS.goal, + space: ref(space), + project: ref(project), + title: 'Build the index', + body: 'Implement phases zero and one.', + createdAt: '2026-01-01T00:02:00Z', + }, 2, 'cid-goal-open'), + ) + const secondGoal = add( + stored(HUMAN, 'goal-two', { + $type: COLLECTIONS.goal, + space: ref(space), + project: ref(project), + title: 'Follow-up work', + body: 'Exercise cross-goal system history.', + createdAt: '2026-01-01T00:02:01Z', + }, 3), + ) + const requestV1 = add( + stored(HUMAN, 'request-v1', { + $type: COLLECTIONS.artifactRequest, + goal: ref(goal), + type: 'plan', + basedOn: [], + assignee: AGENT_A, + createdAt: '2026-01-01T00:03:00Z', + }, 4), + ) + const artifactV1 = add( + stored(AGENT_A, 'plan-v1', { + $type: COLLECTIONS.artifact, + request: ref(requestV1), + goal: ref(goal), + type: 'plan', + body: 'Plan version one', + links: {}, + criteria: ['Index converges'], + createdAt: '2026-01-01T00:04:00Z', + }, 5, 'cid-plan-v1'), + ) + add( + stored(HUMAN, 'review-v1', { + $type: COLLECTIONS.review, + subject: ref(artifactV1), + verdict: 'request_changes', + findings: [{ severity: 'warning', body: 'Cover removals.' }], + createdAt: '2026-01-01T00:05:00Z', + }, 5), + ) + const requestV2 = add( + stored(HUMAN, 'request-v2', { + $type: COLLECTIONS.artifactRequest, + goal: ref(goal), + type: 'plan', + basedOn: [ref(artifactV1)], + assignee: AGENT_A, + createdAt: '2026-01-01T00:06:00Z', + }, 6), + ) + const artifactV2 = add( + stored(AGENT_A, 'plan-v2', { + $type: COLLECTIONS.artifact, + request: ref(requestV2), + goal: ref(goal), + type: 'plan', + prev: ref(artifactV1), + body: 'Plan version two', + links: {}, + criteria: ['Index converges', 'Removal cutoffs hold'], + createdAt: '2026-01-01T00:07:00Z', + }, 8), + ) + add( + stored(AGENT_A, 'plan-v1', { + ...artifactV1.value, + body: 'Silently rewritten plan version one', + }, 11, 'cid-plan-v1-edited'), + ) + const reviewRequest = add( + stored(HUMAN, 'request-review-v2', { + $type: COLLECTIONS.artifactRequest, + goal: ref(goal), + type: 'review', + subject: ref(artifactV2), + basedOn: [ref(artifactV2)], + assignee: AGENT_B, + createdAt: '2026-01-01T00:07:01Z', + }, 7), + ) + add( + stored(AGENT_B, 'review-v2', { + $type: COLLECTIONS.review, + subject: ref(artifactV2), + request: ref(reviewRequest), + verdict: 'approve', + findings: [], + createdAt: '2026-01-01T00:07:02Z', + }, 1), + ) + add( + stored(AGENT_B, 'check-v2', { + $type: COLLECTIONS.checkrun, + artifact: ref(artifactV2), + commit: '0123456789abcdef', + results: [{ name: 'test', pass: true, log: 'ok' }], + createdAt: '2026-01-01T00:07:03Z', + }, 2), + ) + + const openRequest = add( + stored(HUMAN, 'request-open', { + $type: COLLECTIONS.artifactRequest, + goal: ref(goal), + type: 'plan', + basedOn: [ref(artifactV2)], + createdAt: '2026-01-01T00:08:00Z', + }, 7), + ) + const losingClaim = add( + stored(AGENT_C, 'claim-z', { + $type: COLLECTIONS.claim, + request: ref(openRequest), + expiresAt: '2027-01-01T00:00:00Z', + createdAt: '2026-01-01T00:08:01Z', + }, 2), + ) + void losingClaim + const winningClaim = add( + stored(AGENT_B, 'claim-a', { + $type: COLLECTIONS.claim, + request: ref(openRequest), + expiresAt: '2027-01-01T00:00:00Z', + createdAt: '2026-01-01T00:08:01Z', + }, 2), + ) + + const declinedRequest = add( + stored(HUMAN, 'request-declined', { + $type: COLLECTIONS.artifactRequest, + goal: ref(goal), + type: 'plan', + basedOn: [], + assignee: AGENT_B, + createdAt: '2026-01-01T00:09:00Z', + }, 8), + ) + add( + stored(AGENT_B, 'decline', { + $type: COLLECTIONS.message, + goal: ref(goal), + body: 'I cannot take this request.', + mentions: [HUMAN], + re: ref(declinedRequest), + declines: true, + createdAt: '2026-01-01T00:09:01Z', + }, 3), + ) + + const systemRequestV1 = add( + stored(HUMAN, 'request-adr-v1', { + $type: COLLECTIONS.artifactRequest, + project: ref(project), + type: 'adr', + basedOn: [ref(artifactV1)], + assignee: AGENT_B, + createdAt: '2026-01-01T00:10:00Z', + }, 9), + ) + const systemV1 = add( + stored(AGENT_B, 'adr-v1', { + $type: COLLECTIONS.artifact, + request: ref(systemRequestV1), + project: ref(project), + type: 'adr', + body: 'Decision from the first goal context', + links: {}, + createdAt: '2026-01-01T00:10:01Z', + }, 4), + ) + const secondGoalRequest = add( + stored(HUMAN, 'request-goal-two', { + $type: COLLECTIONS.artifactRequest, + goal: ref(secondGoal), + type: 'plan', + basedOn: [], + assignee: AGENT_B, + createdAt: '2026-01-01T00:10:30Z', + }, 10), + ) + const secondGoalArtifact = add( + stored(AGENT_B, 'goal-two-plan', { + $type: COLLECTIONS.artifact, + request: ref(secondGoalRequest), + goal: ref(secondGoal), + type: 'plan', + body: 'A plan from the second goal', + links: {}, + createdAt: '2026-01-01T00:10:31Z', + }, 5), + ) + const systemRequestV2 = add( + stored(HUMAN, 'request-adr-v2', { + $type: COLLECTIONS.artifactRequest, + project: ref(project), + type: 'adr', + basedOn: [ref(secondGoalArtifact)], + assignee: AGENT_B, + createdAt: '2026-01-01T00:11:00Z', + }, 11), + ) + add( + stored(AGENT_B, 'adr-v2', { + $type: COLLECTIONS.artifact, + request: ref(systemRequestV2), + project: ref(project), + type: 'adr', + prev: ref(systemV1), + body: 'Decision revised from a later goal context', + links: {}, + createdAt: '2026-01-01T00:11:01Z', + }, 6), + ) + + add( + stored(ROOT, 'remove-agenta', { + $type: COLLECTIONS.removeMember, + space: ref(space), + did: AGENT_A, + atRev: rev(10), + createdAt: '2026-01-01T00:12:00Z', + }, 20), + ) + const ignoredAfterRemoval = add( + stored(AGENT_A, 'too-late', { + $type: COLLECTIONS.message, + goal: ref(goal), + body: 'This record is beyond the cutoff.', + mentions: [], + createdAt: '2026-01-01T00:13:00Z', + }, 12), + ) + + add( + stored(HUMAN, 'goal', { + ...goal.value, + closed: true, + }, 12, 'cid-goal-closed'), + ) + + return { + records, + spaceUri: space.uri, + goalUri: goal.uri, + projectUri: project.uri, + editedArtifactUri: artifactV1.uri, + ignoredAfterRemovalUri: ignoredAfterRemoval.uri, + claimWinnerUri: winningClaim.uri, + originalGoalCid: goal.cid, + } +} diff --git a/packages/core/test/store.test.mjs b/packages/core/test/store.test.mjs new file mode 100644 index 0000000..9f2bd4a --- /dev/null +++ b/packages/core/test/store.test.mjs @@ -0,0 +1,122 @@ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' +import { MemoryRecordStore, SqliteRecordStore } from '../dist/store.js' +import { goldenScenario } from '../dist/test/scenario.js' + +const implementations = [ + ['memory', () => new MemoryRecordStore()], + ['sqlite', () => new SqliteRecordStore()], +] + +for (const [name, createStore] of implementations) { + describe(`${name} record store`, () => { + it('keeps the earliest repo version and annotates in-place edits', () => { + const scenario = goldenScenario() + const store = createStore() + try { + for (const record of [...scenario.records].reverse()) store.put(record) + const retained = store.records().find((record) => record.uri === scenario.editedArtifactUri) + assert.equal(retained?.value.body, 'Plan version one') + assert.equal( + store.get(retained.did, retained.collection, retained.rkey).cid, + 'cid-plan-v1', + ) + assert.deepEqual( + store.edits().find((edit) => edit.uri === scenario.editedArtifactUri), + { + uri: scenario.editedArtifactUri, + retainedCid: 'cid-plan-v1', + rejectedCids: ['cid-plan-v1-edited'], + }, + ) + } finally { + store.close() + } + }) + + it('adopts sanctioned goal closure without flagging it', () => { + const scenario = goldenScenario() + const store = createStore() + try { + scenario.records.forEach((record) => store.put(record)) + const goal = store.records().find((record) => record.uri === scenario.goalUri) + assert.equal(goal?.value.closed, true) + assert.equal(goal?.cid, 'cid-goal-closed') + assert.equal(store.edits().some((edit) => edit.uri === scenario.goalUri), false) + } finally { + store.close() + } + }) + + it('keeps a sanctioned goal closure when a later edit changes immutable fields', () => { + const scenario = goldenScenario() + const store = createStore() + try { + scenario.records.forEach((record) => store.put(record)) + const closed = scenario.records.find( + (record) => record.uri === scenario.goalUri && record.cid === 'cid-goal-closed', + ) + store.put({ + ...structuredClone(closed), + cid: 'cid-goal-body-edit', + rev: '0000000000013', + value: { ...structuredClone(closed.value), body: 'silently rewritten goal' }, + }) + + const goal = store.records().find((record) => record.uri === scenario.goalUri) + assert.equal(goal?.value.closed, true) + assert.equal(goal?.cid, 'cid-goal-closed') + assert.deepEqual(store.edits().find((edit) => edit.uri === scenario.goalUri), { + uri: scenario.goalUri, + retainedCid: 'cid-goal-closed', + rejectedCids: ['cid-goal-body-edit'], + }) + } finally { + store.close() + } + }) + + it('adopts claim lease renewals without treating them as edits', () => { + const scenario = goldenScenario() + const store = createStore() + try { + const claim = scenario.records.find((record) => record.uri === scenario.claimWinnerUri) + const initial = { + ...structuredClone(claim), + value: { ...structuredClone(claim.value), expiresAt: '2026-01-15T00:00:00Z' }, + } + store.put(initial) + store.put({ + ...structuredClone(initial), + cid: 'cid-claim-renewed', + rev: '0000000000003', + value: { ...structuredClone(initial.value), expiresAt: '2027-01-01T00:00:00Z' }, + }) + + const renewed = store.get(claim.did, claim.collection, claim.rkey) + assert.equal(renewed?.cid, 'cid-claim-renewed') + assert.equal(renewed?.value.expiresAt, '2027-01-01T00:00:00Z') + assert.equal(store.edits().some((edit) => edit.uri === claim.uri), false) + } finally { + store.close() + } + }) + }) +} + +it('memory and SQLite stores expose identical snapshots', () => { + const scenario = goldenScenario() + const memory = new MemoryRecordStore() + const sqlite = new SqliteRecordStore() + try { + scenario.records.forEach((record) => { + memory.put(record) + sqlite.put(record) + }) + assert.deepEqual(sqlite.records(), memory.records()) + assert.deepEqual(sqlite.edits(), memory.edits()) + } finally { + memory.close() + sqlite.close() + } +}) diff --git a/packages/core/test/validation.test.mjs b/packages/core/test/validation.test.mjs new file mode 100644 index 0000000..aabd7fc --- /dev/null +++ b/packages/core/test/validation.test.mjs @@ -0,0 +1,57 @@ +import assert from 'node:assert/strict' +import { readFile } from 'node:fs/promises' +import { describe, it } from 'node:test' +import { resolve } from 'node:path' +import { COLLECTIONS } from '../dist/generated/records.js' +import { validateRecord } from '../dist/validation.js' + +const fixtures = resolve(import.meta.dirname, 'fixtures') + +describe('record validation', () => { + it('accepts a valid fixture for every collection', async () => { + const records = JSON.parse(await readFile(resolve(fixtures, 'valid-records.json'), 'utf8')) + assert.deepEqual(Object.keys(records).sort(), Object.values(COLLECTIONS).sort()) + for (const [collection, record] of Object.entries(records)) { + assert.equal(validateRecord(collection, record).success, true, collection) + } + }) + + it('rejects an invalid fixture for every collection', async () => { + const records = JSON.parse(await readFile(resolve(fixtures, 'invalid-records.json'), 'utf8')) + assert.deepEqual(Object.keys(records).sort(), Object.values(COLLECTIONS).sort()) + for (const [collection, record] of Object.entries(records)) { + assert.equal(validateRecord(collection, record).success, false, collection) + } + }) + + it('ships valid plan and implementation registry seed records', async () => { + const builtins = JSON.parse( + await readFile(resolve(import.meta.dirname, '../../lexicons/fixtures/builtins.json'), 'utf8'), + ) + assert.equal(builtins.length, 2) + for (const record of builtins) { + assert.equal(validateRecord(COLLECTIONS.artifactType, record).success, true) + } + }) + + it('enforces exact-one targets and anchors', async () => { + const records = JSON.parse(await readFile(resolve(fixtures, 'valid-records.json'), 'utf8')) + const request = records[COLLECTIONS.artifactRequest] + assert.equal( + validateRecord(COLLECTIONS.artifactRequest, { ...request, project: request.goal }).success, + false, + ) + const message = { ...records[COLLECTIONS.message] } + delete message.goal + assert.equal(validateRecord(COLLECTIONS.message, message).success, false) + }) + + it('rejects impossible RFC 3339 calendar dates', async () => { + const records = JSON.parse(await readFile(resolve(fixtures, 'valid-records.json'), 'utf8')) + const space = { + ...records[COLLECTIONS.space], + createdAt: '2026-02-31T00:00:00Z', + } + assert.equal(validateRecord(COLLECTIONS.space, space).success, false) + }) +}) diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..4d9c612 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src", + "types": [] + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "test"] +} diff --git a/packages/daemon/package.json b/packages/daemon/package.json new file mode 100644 index 0000000..2516bd1 --- /dev/null +++ b/packages/daemon/package.json @@ -0,0 +1 @@ +{"name":"@radial/daemon","version":"0.0.0","private":true,"type":"module","scripts":{"build":"true","lint":"true"}} diff --git a/packages/ingest/package.json b/packages/ingest/package.json new file mode 100644 index 0000000..eb9d165 --- /dev/null +++ b/packages/ingest/package.json @@ -0,0 +1 @@ +{"name":"@radial/ingest","version":"0.0.0","private":true,"type":"module","scripts":{"build":"true","lint":"true"}} diff --git a/packages/lexicons/README.md b/packages/lexicons/README.md new file mode 100644 index 0000000..fd1db60 --- /dev/null +++ b/packages/lexicons/README.md @@ -0,0 +1,9 @@ +# Radial lexicons + +Radial records use the permanent `com.disnetdev.radial.*` namespace. The JSON +files in `lexicons/` are the source of truth. `pnpm codegen` generates typed +record definitions and embedded runtime schemas in `packages/core`. + +Artifact records always carry a short `body`. When the full content would +overflow an atproto record, `body` is the summary and `bodyBlob` points to the +complete body. diff --git a/packages/lexicons/fixtures/builtins.json b/packages/lexicons/fixtures/builtins.json new file mode 100644 index 0000000..97003cb --- /dev/null +++ b/packages/lexicons/fixtures/builtins.json @@ -0,0 +1,26 @@ +[ + { + "$type": "com.disnetdev.radial.artifactType", + "space": {"uri": "at://did:plc:root/com.disnetdev.radial.space/space", "cid": "cid-space"}, + "name": "plan", + "brief": "Produce a concrete implementation plan grounded in the goal, repository, and supplied provenance. Call out risks, ordered work, and verification.", + "outputSpec": { + "format": "markdown", + "description": "A structured plan with context, ordered implementation steps, risks, and verification criteria." + }, + "scope": "goal", + "createdAt": "2026-01-01T00:00:01Z" + }, + { + "$type": "com.disnetdev.radial.artifactType", + "space": {"uri": "at://did:plc:root/com.disnetdev.radial.space/space", "cid": "cid-space"}, + "name": "implementation", + "brief": "Implement the requested change in the repository, run the project checks, and return exact git and pull-request provenance.", + "outputSpec": { + "format": "markdown", + "description": "A concise implementation summary with branch, commit, pull request, checks, and any remaining risks." + }, + "scope": "goal", + "createdAt": "2026-01-01T00:00:02Z" + } +] diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.addMember.json b/packages/lexicons/lexicons/com.disnetdev.radial.addMember.json new file mode 100644 index 0000000..17bd9ec --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.addMember.json @@ -0,0 +1,21 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.addMember", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["space", "did", "kind", "role", "createdAt"], + "properties": { + "space": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "did": {"type": "string", "format": "did"}, + "kind": {"type": "string", "enum": ["human", "agent"]}, + "role": {"type": "string", "enum": ["admin", "member", "agent"]}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.agent.json b/packages/lexicons/lexicons/com.disnetdev.radial.agent.json new file mode 100644 index 0000000..4a78de3 --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.agent.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.agent", + "defs": { + "model": { + "type": "object", + "required": ["name", "costHint"], + "properties": { + "name": {"type": "string", "maxLength": 200}, + "costHint": {"type": "string", "maxLength": 100} + } + }, + "main": { + "type": "record", + "key": "any", + "record": { + "type": "object", + "required": ["handleName", "harness", "models", "artifactTypes", "createdAt"], + "properties": { + "handleName": {"type": "string", "maxLength": 200}, + "harness": {"type": "string", "maxLength": 200}, + "models": {"type": "array", "maxLength": 50, "items": {"type": "ref", "ref": "#model"}}, + "artifactTypes": {"type": "array", "maxLength": 100, "items": {"type": "string", "maxLength": 100}}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.artifact.json b/packages/lexicons/lexicons/com.disnetdev.radial.artifact.json new file mode 100644 index 0000000..93ce784 --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.artifact.json @@ -0,0 +1,34 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.artifact", + "defs": { + "links": { + "type": "object", + "properties": { + "branch": {"type": "string", "maxLength": 500}, + "commit": {"type": "string", "maxLength": 128}, + "pr": {"type": "string", "format": "uri", "maxLength": 2000} + } + }, + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["request", "type", "body", "links", "createdAt"], + "properties": { + "request": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "goal": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "project": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "type": {"type": "string", "maxLength": 100}, + "prev": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "body": {"type": "string", "maxLength": 100000}, + "bodyBlob": {"type": "blob", "accept": ["text/markdown", "text/plain", "application/json"], "maxSize": 10000000}, + "links": {"type": "ref", "ref": "#links"}, + "criteria": {"type": "array", "maxLength": 100, "items": {"type": "string", "maxLength": 2000}}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.artifactRequest.json b/packages/lexicons/lexicons/com.disnetdev.radial.artifactRequest.json new file mode 100644 index 0000000..b0844ff --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.artifactRequest.json @@ -0,0 +1,25 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.artifactRequest", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["type", "basedOn", "createdAt"], + "properties": { + "goal": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "project": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "type": {"type": "string", "maxLength": 100}, + "subject": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "basedOn": {"type": "array", "maxLength": 100, "items": {"type": "ref", "ref": "com.atproto.repo.strongRef"}}, + "assignee": {"type": "string", "format": "did"}, + "brief": {"type": "string", "maxLength": 30000}, + "autoReview": {"type": "boolean"}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.artifactType.json b/packages/lexicons/lexicons/com.disnetdev.radial.artifactType.json new file mode 100644 index 0000000..5e0abbf --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.artifactType.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.artifactType", + "defs": { + "outputSpec": { + "type": "object", + "required": ["format", "description"], + "properties": { + "format": {"type": "string", "enum": ["markdown", "json"]}, + "description": {"type": "string", "maxLength": 10000}, + "schema": {"type": "unknown"} + } + }, + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["space", "name", "brief", "outputSpec", "scope", "createdAt"], + "properties": { + "space": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "name": {"type": "string", "maxLength": 100}, + "brief": {"type": "string", "maxLength": 30000}, + "outputSpec": {"type": "ref", "ref": "#outputSpec"}, + "scope": {"type": "string", "enum": ["goal", "project"]}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.checkrun.json b/packages/lexicons/lexicons/com.disnetdev.radial.checkrun.json new file mode 100644 index 0000000..ddf4f2b --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.checkrun.json @@ -0,0 +1,29 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.checkrun", + "defs": { + "result": { + "type": "object", + "required": ["name", "pass", "log"], + "properties": { + "name": {"type": "string", "maxLength": 100}, + "pass": {"type": "boolean"}, + "log": {"type": "string", "maxLength": 100000} + } + }, + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["artifact", "commit", "results", "createdAt"], + "properties": { + "artifact": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "commit": {"type": "string", "maxLength": 128}, + "results": {"type": "array", "maxLength": 100, "items": {"type": "ref", "ref": "#result"}}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.claim.json b/packages/lexicons/lexicons/com.disnetdev.radial.claim.json new file mode 100644 index 0000000..1711a8a --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.claim.json @@ -0,0 +1,19 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.claim", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["request", "expiresAt", "createdAt"], + "properties": { + "request": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "expiresAt": {"type": "string", "format": "datetime"}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.goal.json b/packages/lexicons/lexicons/com.disnetdev.radial.goal.json new file mode 100644 index 0000000..5278090 --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.goal.json @@ -0,0 +1,22 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.goal", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["space", "project", "title", "body", "createdAt"], + "properties": { + "space": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "project": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "title": {"type": "string", "maxLength": 300}, + "body": {"type": "string", "maxLength": 100000}, + "closed": {"type": "boolean"}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.message.json b/packages/lexicons/lexicons/com.disnetdev.radial.message.json new file mode 100644 index 0000000..d20c411 --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.message.json @@ -0,0 +1,25 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.message", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["body", "mentions", "createdAt"], + "properties": { + "goal": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "artifact": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "root": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "parent": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "body": {"type": "string", "maxLength": 30000}, + "mentions": {"type": "array", "maxLength": 50, "items": {"type": "string", "format": "did"}}, + "re": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "declines": {"type": "boolean"}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.project.json b/packages/lexicons/lexicons/com.disnetdev.radial.project.json new file mode 100644 index 0000000..5e9d668 --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.project.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.project", + "defs": { + "check": { + "type": "object", + "required": ["name", "command"], + "properties": { + "name": {"type": "string", "maxLength": 100}, + "command": {"type": "string", "maxLength": 1000} + } + }, + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["space", "name", "gitUrl", "defaultBranch", "checks", "autoReview", "createdAt"], + "properties": { + "space": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "name": {"type": "string", "maxLength": 100}, + "gitUrl": {"type": "string", "format": "uri", "maxLength": 2000}, + "defaultBranch": {"type": "string", "maxLength": 255}, + "checks": {"type": "array", "maxLength": 50, "items": {"type": "ref", "ref": "#check"}}, + "autoReview": {"type": "unknown"}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.removeMember.json b/packages/lexicons/lexicons/com.disnetdev.radial.removeMember.json new file mode 100644 index 0000000..a36e047 --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.removeMember.json @@ -0,0 +1,20 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.removeMember", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["space", "did", "atRev", "createdAt"], + "properties": { + "space": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "did": {"type": "string", "format": "did"}, + "atRev": {"type": "string", "minLength": 1, "maxLength": 32}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.review.json b/packages/lexicons/lexicons/com.disnetdev.radial.review.json new file mode 100644 index 0000000..d18914d --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.review.json @@ -0,0 +1,31 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.review", + "defs": { + "finding": { + "type": "object", + "required": ["severity", "body"], + "properties": { + "path": {"type": "string", "maxLength": 2000}, + "line": {"type": "integer", "minimum": 1}, + "severity": {"type": "string", "enum": ["info", "warning", "error"]}, + "body": {"type": "string", "maxLength": 10000} + } + }, + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["subject", "verdict", "findings", "createdAt"], + "properties": { + "subject": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "request": {"type": "ref", "ref": "com.atproto.repo.strongRef"}, + "verdict": {"type": "string", "enum": ["approve", "request_changes"]}, + "findings": {"type": "array", "maxLength": 100, "items": {"type": "ref", "ref": "#finding"}}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/lexicons/com.disnetdev.radial.space.json b/packages/lexicons/lexicons/com.disnetdev.radial.space.json new file mode 100644 index 0000000..3abb078 --- /dev/null +++ b/packages/lexicons/lexicons/com.disnetdev.radial.space.json @@ -0,0 +1,19 @@ +{ + "lexicon": 1, + "id": "com.disnetdev.radial.space", + "defs": { + "main": { + "type": "record", + "key": "tid", + "record": { + "type": "object", + "required": ["name", "description", "createdAt"], + "properties": { + "name": {"type": "string", "maxLength": 100}, + "description": {"type": "string", "maxLength": 3000}, + "createdAt": {"type": "string", "format": "datetime"} + } + } + } + } +} diff --git a/packages/lexicons/package.json b/packages/lexicons/package.json new file mode 100644 index 0000000..7032c46 --- /dev/null +++ b/packages/lexicons/package.json @@ -0,0 +1,12 @@ +{ + "name": "@radial/lexicons", + "version": "0.0.0", + "private": true, + "type": "module", + "files": ["lexicons", "scripts"], + "scripts": { + "build": "node scripts/generate.mjs --check", + "codegen": "node scripts/generate.mjs", + "lint": "node scripts/generate.mjs --check" + } +} diff --git a/packages/lexicons/scripts/generate.mjs b/packages/lexicons/scripts/generate.mjs new file mode 100644 index 0000000..c3c8f08 --- /dev/null +++ b/packages/lexicons/scripts/generate.mjs @@ -0,0 +1,90 @@ +#!/usr/bin/env node + +import { readFile, readdir, writeFile } from 'node:fs/promises' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const lexiconDir = resolve(here, '../lexicons') +const output = resolve(here, '../../core/src/generated/records.ts') +const check = process.argv.includes('--check') + +const files = (await readdir(lexiconDir)) + .filter((file) => file.endsWith('.json')) + .sort() +const schemas = await Promise.all( + files.map(async (file) => JSON.parse(await readFile(join(lexiconDir, file), 'utf8'))), +) + +const pascal = (value) => + value + .replace(/(^|[^a-zA-Z0-9]+)([a-zA-Z0-9])/g, (_, _prefix, char) => char.toUpperCase()) + .replace(/^[a-z]/, (char) => char.toUpperCase()) + +const recordName = (id) => pascal(id.split('.').at(-1)) + +function typeFor(schema, prefix) { + switch (schema.type) { + case 'string': + return schema.enum ? schema.enum.map((value) => JSON.stringify(value)).join(' | ') : 'string' + case 'integer': + return 'number' + case 'boolean': + return 'boolean' + case 'unknown': + return 'unknown' + case 'blob': + return 'BlobRef' + case 'array': + return `Array<${typeFor(schema.items, prefix)}>` + case 'ref': + return schema.ref === 'com.atproto.repo.strongRef' + ? 'StrongRef' + : `${prefix}${pascal(schema.ref.slice(1))}` + case 'object': + return `{ ${Object.entries(schema.properties ?? {}) + .map(([key, value]) => `${key}${(schema.required ?? []).includes(key) ? '' : '?'}: ${typeFor(value, prefix)}`) + .join('; ')} }` + default: + throw new Error(`Unsupported schema type: ${schema.type}`) + } +} + +const declarations = [] +for (const lexicon of schemas) { + const prefix = recordName(lexicon.id) + for (const [name, definition] of Object.entries(lexicon.defs)) { + if (name === 'main') continue + declarations.push(`export type ${prefix}${pascal(name)} = ${typeFor(definition, prefix)}`) + } + const record = lexicon.defs.main.record + const required = new Set(record.required ?? []) + const properties = Object.entries(record.properties ?? {}) + .map( + ([name, schema]) => + ` ${name}${required.has(name) ? '' : '?'}: ${typeFor(schema, prefix)}`, + ) + .join('\n') + declarations.push( + `export interface ${prefix}Record {\n $type: ${JSON.stringify(lexicon.id)}\n${properties}\n}`, + ) +} + +const collectionEntries = schemas + .map((schema) => ` ${recordName(schema.id)[0].toLowerCase()}${recordName(schema.id).slice(1)}: ${JSON.stringify(schema.id)},`) + .join('\n') +const recordMap = schemas + .map((schema) => ` [COLLECTIONS.${recordName(schema.id)[0].toLowerCase()}${recordName(schema.id).slice(1)}]: ${recordName(schema.id)}Record`) + .join('\n') + +const source = `// Generated by packages/lexicons/scripts/generate.mjs. Do not edit.\n\nexport interface StrongRef {\n uri: string\n cid: string\n}\n\nexport interface BlobRef {\n $type: 'blob'\n ref: { $link: string }\n mimeType: string\n size: number\n}\n\nexport const COLLECTIONS = {\n${collectionEntries}\n} as const\n\nexport type Collection = (typeof COLLECTIONS)[keyof typeof COLLECTIONS]\n\n${declarations.join('\n\n')}\n\nexport interface RecordByCollection {\n${recordMap}\n}\n\nexport type RadialRecord = RecordByCollection[keyof RecordByCollection]\n\nexport const lexiconSchemas = ${JSON.stringify(schemas, null, 2)} as const\n` + +if (check) { + const current = await readFile(output, 'utf8').catch(() => '') + if (current !== source) { + console.error('Generated record types are stale. Run pnpm codegen.') + process.exitCode = 1 + } +} else { + await writeFile(output, source) +} diff --git a/packages/sidecar/package.json b/packages/sidecar/package.json new file mode 100644 index 0000000..38ca45d --- /dev/null +++ b/packages/sidecar/package.json @@ -0,0 +1 @@ +{"name":"@radial/sidecar","version":"0.0.0","private":true,"type":"module","scripts":{"build":"true","lint":"true"}} diff --git a/packages/ui/package.json b/packages/ui/package.json new file mode 100644 index 0000000..9773efe --- /dev/null +++ b/packages/ui/package.json @@ -0,0 +1 @@ +{"name":"@radial/ui","version":"0.0.0","private":true,"type":"module","scripts":{"build":"true","lint":"true"}} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..8cc6062 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,36 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + devDependencies: + typescript: + specifier: 5.9.3 + version: 5.9.3 + + packages/core: {} + + packages/daemon: {} + + packages/ingest: {} + + packages/lexicons: {} + + packages/sidecar: {} + + packages/ui: {} + +packages: + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + +snapshots: + + typescript@5.9.3: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..6acd4a6 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +packages: + - packages/* + +onlyBuiltDependencies: [] diff --git a/readme.md b/readme.md index 841bd14..b22eb1f 100644 --- a/readme.md +++ b/readme.md @@ -1,27 +1,43 @@ # Radial -Radial is a coding agent orchestration system built on [atproto](https://atproto.com/). - -It has two main components: - -- a web ui where humans enter goals and shape work -- a command line daemon that watches for updates and launches agents in containers to do work - -All identities (both human and agents) are atproto identities (dids, etc.). - -The coordination layer is atproto records stored in each actor's repo. - -The core unit of work coordination is the `goal`: -- a goal has a title -- a goal has an accociated project (name and git url) -- a goal has a plan (a detailed description of how to achieve the goal) -- a goal has acceptance criteria (a detailed list of verifiable outcomes the demonstrate the goal was achieved) -- a goal has (zero or many) conversation threads discussing the goal (participants in a conversation thread are all atproto identities and can be human or agents) - -Since actors in atproto can only write records to their own repo, goals and other logical records in the system are materialized from "ops" in each participant's repo. - -Goals/projects/etc. are linked to a "radial space" which includes a member list (which distinguishes human and agent dids) to bound trust for the materialization. - -When there are multiple agents in a space, work contention is handled by a claim system. - -Agent operators can define which providers/harnesses/models and costs their agents provide. +Radial is a coding-agent orchestration system built on +[atproto](https://atproto.com/). Humans commission typed artifacts, agents +produce them, and reviews annotate exact artifact versions. Coordination lives +in each actor's atproto repo; code stays in git. + +Phases 0 and 1 are implemented: + +- pnpm TypeScript monorepo and offline CI/test harness +- all 13 `com.disnetdev.radial.*` lexicons and generated record interfaces +- strict runtime validation plus `plan` and `implementation` seed records +- in-memory and SQLite record stores with immutable-version annotations +- deterministic trust and membership-cutoff computation +- goal/project materialization, version chains, reviews, checkruns, request + fulfillment, declines, claims, and current system artifacts +- golden scenarios and an arrival-permutation convergence test + +## Development + +Node 24 and pnpm 10 are required. The phase 0–1 workspace has no third-party +runtime or test dependencies, so setup and verification work offline. + +```sh +pnpm install --offline +pnpm codegen +pnpm lint +pnpm test +pnpm build +``` + +The debug CLI reads one JSON record envelope (or an array of envelopes) per +file in a directory: + +```sh +pnpm --filter @radial/core debug -- \ + index at://did:plc:example/com.disnetdev.radial.goal/example \ + --dir ./fixtures \ + --as-of 2026-07-18T00:00:00Z +``` + +See [design.md](design.md) for the protocol design and [plan.md](plan.md) for +the remaining phases. diff --git a/tsconfig.base.json b/tsconfig.base.json new file mode 100644 index 0000000..709e7ce --- /dev/null +++ b/tsconfig.base.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2023", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "sourceMap": true + } +}