Something went wrong. Try again.
An AT Protocol Personal Data Server written in JavaScript pdsjs.dev
pds atproto
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341#!/usr/bin/env node// biome-ignore-all lint/suspicious/noTemplateCurlyInString: the placeholders// belong to the demo project's own source, not to these strings./** * Local-dev seed for the collaboration prototype: one repository worked on by * four accounts, each on its own PDS. * * The shape under test is the one the space model already forces. Nobody * writes into anybody else's repo. Alice's copy of `demo-app` is canonical, * every other collaborator pushes branches into their own copy under the same * name, and the reader fans in. A pull request is the branch itself, named by * the account that holds it and the ref: a branch that is not an ancestor of * canonical main is open, and the object graph says so. * * What the seed puts in place, and what each part is there to answer: * * - Five accounts on five servers. Alice holds the canonical repo, Bob and * Carol collaborate, Nova is a review agent that never pushes code, and Dan * is a stranger nobody has named. * - A branch already merged (bob/agent/badge), so the browser can be seen * deriving "merged" from ancestry rather than from stored state. * - A branch rewritten by a rebase (bob/agent/farewell), with one check * against each version. The account and the ref are unchanged by the * rewrite, which is the case a sha-keyed artifact loses. * - A branch force-pushed three times (bob/agent/plural), failing twice and * then passing, once as a rebase across a main that moved. The second * failure is on a commit the branch still carries, so the timeline can say * which commit broke the build. Every tip carries a word, so there is an * answer beside every push and a comment on the first still reads against * the last. * - Two branches that touch the same file (bob/agent/farewell and * carol/agent/errors), so the conflict matrix has something to find. * - A branch taken before main moved (carol/agent/docs), so "behind" is * visible. * - Reviews as records in the reviewer's own repo, from a human and from an * agent, naming the account and the ref they read. * - A pull request from Dan, who is in no collaborator list, plus the * runner's index of it. That is the only path by which a repository hears * from somebody it has never named. * - A draft from Carol on her own agent/docs branch, the same record carrying * the words the graph cannot: a title and the author's own status. * * Every account must already exist. See `just dev-git-collab`, which starts * the five servers and then runs this. * * Usage: node scripts/dev-git-collab-demo.mjs [repo-name] * Env: PDS_DEV_URL alice's PDS (default http://localhost:2471) * PDS_DEV_BOB_URL (default http://localhost:2472) * PDS_DEV_CAROL_URL (default http://localhost:2473) * PDS_DEV_NOVA_URL (default http://localhost:2482) * PDS_DEV_DAN_URL (default http://localhost:2483) * PDS_DEV_PASSWORD (default test-password) * PDS_DEV_PLC_URL (default http://localhost:2582) * PDS_DEV_WORK_DIR (default .dev-pds/collab) */import { spawn, spawnSync } from 'node:child_process';import { appendFileSync, mkdirSync, openSync, readFileSync, rmSync, symlinkSync, writeFileSync,} from 'node:fs';import { dirname, resolve } from 'node:path';import { fileURLToPath } from 'node:url';
const PASSWORD = process.env.PDS_DEV_PASSWORD || 'test-password';const PLC_URL = process.env.PDS_DEV_PLC_URL || 'http://localhost:2582';const WORK = resolve(process.env.PDS_DEV_WORK_DIR || '.dev-pds/collab');const NAME = process.argv[2] || 'demo-app';const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const REPO_COLLECTION = 'dev.pdsjs.git.repo';const CONFIG_COLLECTION = 'dev.pdsjs.git.config';const CHECK_COLLECTION = 'dev.pdsjs.git.check';const REVIEW_COLLECTION = 'dev.pdsjs.git.review';const PULL_COLLECTION = 'dev.pdsjs.git.pull';const PULLS_COLLECTION = 'dev.pdsjs.git.pulls';const IDENTITY_COLLECTION = 'dev.pdsjs.git.identity';const ISSUE_COLLECTION = 'dev.pdsjs.git.issue';const ISSUES_COLLECTION = 'dev.pdsjs.git.issues';const LATEST_COLLECTION = 'dev.pdsjs.git.latestCheck';
/** The cast. Each is one server, because a PDS answers for a single DID. */const PEOPLE = { alice: { base: process.env.PDS_DEV_URL || 'http://localhost:2471', name: 'Alice', email: 'alice@localhost', }, bob: { base: process.env.PDS_DEV_BOB_URL || 'http://localhost:2472', name: 'Bob', email: 'bob@localhost', }, carol: { base: process.env.PDS_DEV_CAROL_URL || 'http://localhost:2473', name: 'Carol', email: 'carol@localhost', }, nova: { base: process.env.PDS_DEV_NOVA_URL || 'http://localhost:2482', name: 'Nova', email: 'nova@localhost', }, dan: { base: process.env.PDS_DEV_DAN_URL || 'http://localhost:2483', name: 'Dan', email: 'dan@localhost', },};
// ---- plumbing -------------------------------------------------------------
/** @param {string} command @param {string[]} args @param {object} [options] */function run(command, args, options = {}) { const result = spawnSync(command, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', ...options, env: { ...process.env, PATH: `${WORK}/bin:${process.env.PATH}`, ATPROTO_GIT_PASSWORD: PASSWORD, ATPROTO_GIT_PLC_URL: PLC_URL, ...(options.env ?? {}), }, }); if (result.status !== 0) { throw new Error( `${command} ${args.join(' ')} failed:\n${result.stderr || result.stdout}`, ); } return result.stdout?.trim() ?? '';}
/** Commit times march forward, so a log reads in the order things happened. */let clock = Date.now() - 6 * 60 * 60 * 1000;function nextTime() { clock += 11 * 60 * 1000; return new Date(clock);}
/** * One commit, authored by whoever the checkout belongs to. The branch is the * proposal; nothing is written into the message to make it one. * @param {string} who * @param {string} subject * @param {string[]} [trailers] */function commit(who, subject, trailers = []) { const person = PEOPLE[who]; const when = nextTime().toISOString(); run('git', ['add', '-A'], { cwd: `${WORK}/${who}` }); run( 'git', [ '-c', `user.email=${person.email}`, '-c', `user.name=${person.name}`, 'commit', '-q', '-m', subject, // One paragraph, so every trailer sits in the same trailer block. ...(trailers.length > 0 ? ['-m', trailers.join('\n')] : []), ], { cwd: `${WORK}/${who}`, env: { GIT_AUTHOR_DATE: when, GIT_COMMITTER_DATE: when }, }, ); return run('git', ['rev-parse', 'HEAD'], { cwd: `${WORK}/${who}` });}
/** @param {string} who @param {string} path @param {string} body */const write = (who, path, body) => writeFileSync(`${WORK}/${who}/${path}`, body);
/** @param {string} who @param {string} path @param {string} body */const append = (who, path, body) => appendFileSync(`${WORK}/${who}/${path}`, body);
/** @param {string} who @param {string} path */const read = (who, path) => readFileSync(`${WORK}/${who}/${path}`, 'utf8');
const git = (who, ...args) => run('git', args, { cwd: `${WORK}/${who}` });
/** @param {string} base @param {string} path @param {RequestInit} [init] */async function xrpc(base, path, init) { const res = await fetch(`${base}/xrpc/${path}`, init); if (!res.ok) { throw new Error( `${path.split('?')[0]} ${res.status}: ${(await res.text()).slice(0, 300)}`, ); } return res.json();}
/** @type {Record<string, {did: string, token: string}>} */const sessions = {};
/** @param {string} who @param {string} collection @param {object} value */function putRecord(who, collection, value, rkey) { const { did, token } = sessions[who]; return xrpc( PEOPLE[who].base, rkey ? 'com.atproto.repo.putRecord' : 'com.atproto.repo.createRecord', { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }, body: JSON.stringify({ repo: did, collection, ...(rkey ? { rkey } : {}), record: { $type: collection, ...value }, }), }, );}
/** The repo record's current version, which a check or a review names. */async function repoRef(who) { const params = new URLSearchParams({ repo: sessions[who].did, collection: REPO_COLLECTION, rkey: NAME, }); const body = await xrpc( PEOPLE[who].base, `com.atproto.repo.getRecord?${params}`, ); return { uri: body.uri, cid: body.cid };}
// ---- the cast signs in ----------------------------------------------------
for (const [who, person] of Object.entries(PEOPLE)) { const did = ( await (await fetch(`${person.base}/.well-known/atproto-did`)).text() ) .trim() .replace(/^"|"$/g, ''); if (!did.startsWith('did:')) { throw new Error(`${person.base} serves no account yet`); } const session = await xrpc(person.base, 'com.atproto.server.createSession', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: did, password: PASSWORD }), }); sessions[who] = { did, token: session.accessJwt };}
const url = (who) => `atproto://${sessions[who].did}/${NAME}`;
// ---- what an earlier run left behind -------------------------------------//// The checkouts below start empty, so a repository record still holding the// last run's history refuses their first push. The config goes first: a// repository with a protected branch refuses deletion.//// Only this repository's records go. These accounts hold others, seeded by// scripts beside this one, and a rerun here must leave them alone.
for (const who of Object.keys(PEOPLE)) { const { did, token } = sessions[who]; const headers = { 'Content-Type': 'application/json', Authorization: `Bearer ${token}`, }; const remove = (collection, rkey) => fetch(`${PEOPLE[who].base}/xrpc/com.atproto.repo.deleteRecord`, { method: 'POST', headers, body: JSON.stringify({ repo: did, collection, rkey }), });
await remove(CONFIG_COLLECTION, NAME); await remove(REPO_COLLECTION, NAME);
// A check, a review or a pull request names the repository it is about in // its subject, which is what tells this repository's from another's. for (const collection of [ CHECK_COLLECTION, REVIEW_COLLECTION, PULL_COLLECTION, PULLS_COLLECTION, ISSUE_COLLECTION, ISSUES_COLLECTION, ]) { const params = new URLSearchParams({ repo: did, collection, limit: '100' }); const listed = await fetch( `${PEOPLE[who].base}/xrpc/com.atproto.repo.listRecords?${params}`, ); if (!listed.ok) continue; for (const row of (await listed.json()).records ?? []) { if (!String(row.value?.subject?.uri ?? '').endsWith(`/${NAME}`)) continue; await remove(collection, row.uri.split('/').pop()); } }
// The latest-check record carries no subject; its derived key names the // repository instead, as <repo-did>:<name>:<ref>. const params = new URLSearchParams({ repo: did, collection: LATEST_COLLECTION, limit: '100', }); const listed = await fetch( `${PEOPLE[who].base}/xrpc/com.atproto.repo.listRecords?${params}`, ); if (listed.ok) { for (const row of (await listed.json()).records ?? []) { const rkey = row.uri.split('/').pop(); if (rkey.includes(`:${NAME}:`)) await remove(LATEST_COLLECTION, rkey); } }}
rmSync(WORK, { recursive: true, force: true });mkdirSync(`${WORK}/bin`, { recursive: true });symlinkSync( `${ROOT}/packages/git/src/cli.js`, `${WORK}/bin/git-remote-atproto`,);
// ---- 0. the runner, before anything moves ---------------------------------//// Nova's daemon watches the three copies, clones each push, runs the workflow// in the tree, and publishes a check record naming the ref it ran against.// The pushes below have to happen while it is connected, which is why it// starts first and the seed waits for its sockets.//// The pid file sits in a directory of its own so `just pds-stop` reaps the// daemon the same way it reaps the member servers.
const CI_DIR = resolve('.dev-pds/ci');// A rerun's fresh state file would make a surviving daemon replay every// event, so the previous daemon dies before its successor starts.try { const previous = Number(readFileSync(`${CI_DIR}/pds.pid`, 'utf8').trim()); if (previous > 0) process.kill(previous);} catch { // No pid file, or the daemon is already gone.}rmSync(CI_DIR, { recursive: true, force: true });mkdirSync(CI_DIR, { recursive: true });const ciLog = `${CI_DIR}/daemon.log`;const ciOut = openSync(ciLog, 'a');const daemon = spawn( process.execPath, [ `${ROOT}/packages/git-ci/src/cli.js`, 'watch', `${sessions.alice.did}/${NAME}`, `${sessions.bob.did}/${NAME}`, `${sessions.carol.did}/${NAME}`, ], { detached: true, stdio: ['ignore', ciOut, ciOut], env: { ...process.env, PATH: `${WORK}/bin:${process.env.PATH}`, PDSJS_CI_IDENTIFIER: sessions.nova.did, PDSJS_CI_PASSWORD: PASSWORD, PDSJS_CI_SERVICE: PEOPLE.nova.base, PDSJS_CI_STATE: `${CI_DIR}/state.json`, ATPROTO_GIT_PLC_URL: PLC_URL, }, },);daemon.unref();writeFileSync(`${CI_DIR}/pds.pid`, `${daemon.pid}\n`);
// Three repository firehoses, each logging as its socket opens.for (let i = 0; ; i++) { const opened = (readFileSync(ciLog, 'utf8').match(/ watching http/g) ?? []) .length; if (opened >= 3) break; if (i >= 60) throw new Error(`the runner did not connect; see ${ciLog}`); await new Promise((done) => setTimeout(done, 500));}console.log(`nova's runner is watching (log: ${ciLog})`);
// ---- 1. alice's repository, the canonical one -----------------------------
mkdirSync(`${WORK}/alice`, { recursive: true });git('alice', 'init', '-q', '-b', 'main');
write( 'alice', 'README.md', `# ${NAME}\n\nA small package published to its own atproto account.\n`,);write( 'alice', 'index.js', ['export function greet(who) {', ' return `hello, ${who}`;', '}', ''].join( '\n', ),);write( 'alice', 'package.json', `${JSON.stringify( { name: NAME, version: '1.0.0', main: 'index.js', type: 'module', license: 'MIT', }, null, 2, )}\n`,);// The workflow the runner finds at every commit. One step, and it really// runs: every check on the work screen is this file's exit code.mkdirSync(`${WORK}/alice/.pdsjs/workflows`, { recursive: true });write( 'alice', '.pdsjs/workflows/ci.yml', ['name: ci', 'steps:', ' - name: test', ' run: node test.js', ''].join( '\n', ),);commit('alice', 'the first commit');
write( 'alice', 'test.js', [ "import { greet } from './index.js';", '', "if (greet('world') !== 'hello, world') {", " console.error('greet is wrong');", ' process.exit(1);', '}', "console.log('greet ok');", '', ].join('\n'),);commit('alice', 'a test file to grow into');
git('alice', 'push', '-q', url('alice'), 'main');console.log(`alice pushed ${NAME} (canonical)`);
// ---- 2. bob clones it and opens two changes -------------------------------
run('git', ['clone', '-q', url('alice'), `${WORK}/bob`], { cwd: WORK });git('bob', 'remote', 'add', 'mine', url('bob'));git('bob', 'push', '-q', 'mine', 'main');
// A branch that merges during this seed, so the browser has an ancestry-only// "merged" to derive rather than a state field to read.git('bob', 'checkout', '-q', '-b', 'agent/badge');write( 'bob', 'README.md', `${read('bob', 'README.md')}\nEvery push is [checked](checks).\n`,);commit('bob', 'link the checks page from the readme');git('bob', 'push', '-q', 'mine', 'agent/badge');
// The change that gets rewritten. Two commits, so the version history has// something to show.git('bob', 'checkout', '-q', 'main');git('bob', 'checkout', '-q', '-b', 'agent/farewell');append( 'bob', 'index.js', [ '', 'export function farewell(who) {', ' return `bye, ${who}`;', '}', '', ].join('\n'),);commit('bob', 'add farewell()');// The test asserts "goodbye" while the function says "bye", so the runner's// check on this version fails for real, logs and all.append( 'bob', 'test.js', [ '', "import { farewell } from './index.js';", '', "if (farewell('world') !== 'goodbye, world') {", " console.error('farewell is wrong');", ' process.exit(1);', '}', "console.log('farewell ok');", '', ].join('\n'),);commit('bob', 'cover farewell() in the test file');git('bob', 'push', '-q', 'mine', 'agent/farewell');
const farewellV1 = git('bob', 'rev-parse', 'agent/farewell');const bobV1 = await repoRef('bob');console.log('bob pushed agent/badge and agent/farewell');
// ---- 3. alice merges the badge -------------------------------------------
git('alice', 'fetch', '-q', url('bob'), 'agent/badge');git('alice', 'merge', '-q', '--ff-only', 'FETCH_HEAD');git('alice', 'push', '-q', url('alice'), 'main');console.log('alice merged agent/badge onto main');
// ---- 4. bob rebases his branch onto the moved main ------------------------//// New shas, same account and same ref. The runner already checked v1 and// failed it, and that record names a commit no branch points at any more,// which is exactly the artifact a sha-keyed model throws away on every force// push.
git('bob', 'fetch', '-q', 'origin', 'main');git('bob', 'rebase', '-q', 'origin/main');write( 'bob', 'index.js', read('bob', 'index.js').replace('bye, ${who}', 'goodbye, ${who}'),);commit('bob', 'say goodbye rather than bye');git('bob', 'push', '-q', '--force', 'mine', 'agent/farewell');
const farewellV2 = git('bob', 'rev-parse', 'agent/farewell');const bobV2 = await repoRef('bob');console.log('bob rebased agent/farewell; the pull request is the same one');
// ---- 4b. dan files an issue -----------------------------------------------//// Before anyone fixes it: the issue's address exists first, and the fix// names it in a trailer the way a GitHub commit says Fixes #123. Dan is in// nobody's list; his record reaches the page through the runner's index.
const danIssue = await putRecord('dan', ISSUE_COLLECTION, { subject: { uri: `at://${sessions.alice.did}/${REPO_COLLECTION}/${NAME}` }, createdAt: nextTime().toISOString(), title: 'greet(null) throws an unhelpful TypeError', body: 'Calling greet(null) blows up with a bare TypeError. A message naming the argument would save the next person the stack trace.',});console.log('dan filed an issue');
// ---- 5. carol opens two changes ------------------------------------------
run('git', ['clone', '-q', url('alice'), `${WORK}/carol`], { cwd: WORK });git('carol', 'remote', 'add', 'mine', url('carol'));git('carol', 'push', '-q', 'mine', 'main');
// Touches index.js, which is what bob's change touches too.git('carol', 'checkout', '-q', '-b', 'agent/errors');write( 'carol', 'index.js', [ 'export function greet(who) {', " if (typeof who !== 'string') throw new TypeError('who must be a string');", ' return `hello, ${who}`;', '}', '', ].join('\n'),);commit('carol', 'refuse a non-string name', [`Fixes: ${danIssue.uri}`]);git('carol', 'push', '-q', 'mine', 'agent/errors');
// Branched before the badge merged, so this one is behind main.git('carol', 'checkout', '-q', '-b', 'agent/docs', 'main~1');append('carol', 'README.md', '\n## Usage\n\n```js\ngreet("world");\n```\n');commit('carol', 'document the usage');git('carol', 'push', '-q', 'mine', 'agent/docs');
const carolRef = await repoRef('carol');console.log('carol pushed agent/errors and agent/docs');
// ---- 6. alice opens one of her own ---------------------------------------//// The authority is a collaborator like any other: her branches are open pull// requests, and only her `main` is canonical.
git('alice', 'checkout', '-q', '-b', 'agent/license');write('alice', 'LICENSE', 'MIT License\n\nCopyright (c) 2026\n');commit('alice', 'add the license file');git('alice', 'push', '-q', url('alice'), 'agent/license');console.log('alice pushed agent/license');
// ---- 6b. one branch, four versions ---------------------------------------//// The case a sha-keyed model cannot hold. Bob writes a counter, gets it wrong,// rebases across a main that moved under him, gets it wrong again, and renames// it after review. Four tips under one account and one ref.//// Every one is a real push, so the runner checks each and the log nova keeps// is what says a force-push happened at all. The first two fail because the// test really fails; nothing here is seeded.
git('bob', 'checkout', '-q', 'main');git('bob', 'fetch', '-q', 'origin', 'main');git('bob', 'reset', '-q', '--hard', 'FETCH_HEAD');git('bob', 'checkout', '-q', '-b', 'agent/plural');
/** The counter, written whichever way this version got it wrong. */const counter = (body) => write( 'bob', 'count.js', ['export function items(n) {', ` ${body}`, '}', ''].join('\n'), );
// The test comes first and does not move, so each version is measured// against the same claim.write( 'bob', 'count.test.js', [ "import { items } from './count.js';", '', 'const cases = [', " [0, 'no items'],", " [1, '1 item'],", " [4, '4 items'],", '];', 'for (const [n, want] of cases) {', ' if (items(n) !== want) {', ' console.error(`items(${n}) gave ${items(n)}, wanted ${want}`);', ' process.exit(1);', ' }', '}', "console.log('items ok');", '', ].join('\n'),);append( 'bob', '.pdsjs/workflows/ci.yml', ' - name: count\n run: node count.test.js\n',);
// v1: the plural is always there.counter('return `${n} items`;');commit('bob', 'add items()');git('bob', 'push', '-q', 'mine', 'agent/plural');const pluralV1 = git('bob', 'rev-parse', 'agent/plural');// The runner stamps each push with its own wall clock, so the words that// answer a push have to be dated against the same one. The demo's clock runs// hours behind, and a review on it would sort above every push.const pushedAt = [Date.now()];
/** * A pause between the pushes of agent/plural. * * The runner stamps each push with the moment it saw it, and the words that * answer a push are dated between it and the next. Four pushes inside three * seconds leave no room between them, and the whole conversation sorts below * the whole history. Real work has hours here; the demo needs seconds. */const spaced = () => new Promise((done) => setTimeout(done, 5000));await spaced();
// Main moves under the branch, so the next push is a rebase rather than// another commit. It happens here, before the branch is finished, so the// commits that fail their checks are ones the rebase carries forward: a// reader looking for what broke the build finds it on a commit the branch// still has, which is the case a rebase at the end would hide.git('alice', 'checkout', '-q', 'main');append('alice', 'README.md', '\nBuilt in the open.\n');commit('alice', 'a line about the project');git('alice', 'push', '-q', url('alice'), 'main');
// v2: rebased onto the moved main. One is singular now, and zero still reads// as a number, so this one fails its check too.git('bob', 'fetch', '-q', 'origin', 'main');git('bob', 'rebase', '-q', 'FETCH_HEAD');counter("return n === 1 ? '1 item' : `${n} items`;");commit('bob', 'say one item, not 1 items');git('bob', 'push', '-q', '--force', 'mine', 'agent/plural');const pluralV2 = git('bob', 'rev-parse', 'agent/plural');pushedAt.push(Date.now());await spaced();
// v3: zero spelled out, which is what finally passes.counter("return n === 0 ? 'no items' : n === 1 ? '1 item' : `${n} items`;");commit('bob', 'spell zero out');git('bob', 'push', '-q', '--force', 'mine', 'agent/plural');const pluralV3 = git('bob', 'rev-parse', 'agent/plural');pushedAt.push(Date.now());await spaced();
// v4: the name the reviewer asked for. The work is the same; the tip is not.write( 'bob', 'count.js', read('bob', 'count.js').replace('items(n)', 'countOf(n)'),);write( 'bob', 'count.test.js', read('bob', 'count.test.js') .replace('import { items }', 'import { countOf }') .replaceAll('items(', 'countOf('),);commit('bob', 'call it countOf');git('bob', 'push', '-q', '--force', 'mine', 'agent/plural');const pluralV4 = git('bob', 'rev-parse', 'agent/plural');pushedAt.push(Date.now());await spaced();const bobPlural = await repoRef('bob');
console.log( `bob pushed agent/plural four times (${[ pluralV1, pluralV2, pluralV3, pluralV4, ] .map((sha) => sha.slice(0, 8)) .join(' -> ')})`,);
// ---- 7. a stranger opens a pull request ----------------------------------//// Dan is in nobody's collaborator list. He clones the public repository,// pushes to his own copy, and writes a record in his own repo saying the work// is there. Alice's repository is untouched by any of it.
run('git', ['clone', '-q', url('alice'), `${WORK}/dan`], { cwd: WORK });git('dan', 'remote', 'add', 'mine', url('dan'));git('dan', 'checkout', '-q', '-b', 'fix/contributing');write( 'dan', 'CONTRIBUTING.md', '# Contributing\n\nPush a branch to your own copy and offer it.\n',);commit('dan', 'add a contributing guide');git('dan', 'push', '-q', 'mine', 'main', 'fix/contributing');
const aliceRef = await repoRef('alice');await putRecord('dan', PULL_COLLECTION, { subject: aliceRef, repo: NAME, ref: 'refs/heads/fix/contributing', sha: git('dan', 'rev-parse', 'fix/contributing'), title: 'Add a contributing guide', note: 'Saw the repo had no contributing guide. Take it or leave it.', createdAt: nextTime().toISOString(),});
// Nothing else happens on dan's side. Nova's daemon saw that write on dan's// firehose and indexed it; the open list reads the index from nova and marks// the row a fork.console.log('dan opened a pull request from outside the collaborator list');
// A collaborator writes the same record for the words alone. The graph says// carol's branch is open; only carol can say it is a draft.await putRecord('carol', PULL_COLLECTION, { subject: aliceRef, repo: NAME, ref: 'refs/heads/agent/docs', sha: git('carol', 'rev-parse', 'agent/docs'), title: 'Document the usage', status: 'draft', note: 'Still writing the usage examples.', createdAt: nextTime().toISOString(),});console.log('carol marked agent/docs a draft');
// Bob writes a proper description of the counter, so the page has markdown to// render: headings, a list, a table, inline code and a fenced block. A// description comes out of an account this site does not control, so it goes// through the same renderer a README does, which prints raw HTML rather than// parsing it.await putRecord('bob', PULL_COLLECTION, { subject: aliceRef, repo: NAME, ref: 'refs/heads/agent/plural', sha: pluralV4, title: 'Count things without saying "1 items"', note: [ '## What this is', '', 'A counter that reads as English. `items(1)` used to give `1 items`, which', 'is the kind of thing you stop seeing after a week and every reader sees', 'forever.', '', '```js', "import { countOf } from './count.js';", '', "countOf(0); // 'no items'", "countOf(1); // '1 item'", "countOf(4); // '4 items'", '```', '', '## Why it took four goes', '', '| push | what it got wrong |', '| --- | --- |', '| first | the plural was always there |', '| second | one read right, zero did not |', '| third | nothing, but the name was a verb |', '| fourth | nothing |', '', 'The middle one is worth reading on its own: it is rebased across a `main`', "that moved under it, so the diff carries somebody else's README line as", 'well as mine.', '', '## What is left', '', '- [x] zero, one and many each said once', '- [x] a test that asserts all three', "- [ ] a word about it in the readme, which belongs with carol's docs branch", '', '> The name came from carol. `items()` reads as a verb everywhere else in', '> this repository, and a getter should not.', '', 'See [the readme](https://example.invalid/readme) for the rest.', ].join('\n'), createdAt: nextTime().toISOString(),});console.log('bob described agent/plural at length, in markdown');
// ---- 8. reviews, in the reviewer's own repo ------------------------------//// Nobody has write access to anybody's repository here. A review is a signed// statement by a DID about an immutable subject, and the reader decides whose// statements to render.
const review = (who, subject, sha, author, ref, verdict, note, anchor, at) => putRecord(who, REVIEW_COLLECTION, { subject, sha, author, ref, verdict, ...(anchor ? { anchor } : {}), note, reviewedAt: at ?? nextTime().toISOString(), });
// The farewell branch, as an argument that runs alongside its commits.//// The sections above run in order: every commit is written before any// review, so statements timed by the demo's own clock would all land after// the branch was finished. These carry times taken from the commits they// answer, so a complaint about a missing test comes before the test.//// Author dates, because the rebase stamped its own committer date on// everything it moved: by that clock the commit that caused the rebase// comes before the two it rewrote.const farewellCommits = git( 'bob', 'log', '--reverse', '--format=%H %aI', 'origin/main..agent/farewell',) .split('\n') .filter(Boolean) .map((line) => { const [sha, at] = line.split(' '); return { sha, at: Date.parse(at) }; });
/** * A clock for the conversation. It starts at the first commit, never goes * backwards, and `afterCommit` carries it past a push so the next word * answers that push rather than preceding it. */let said = farewellCommits[0].at;// Two minutes a word. The commits are eleven minutes apart on the demo's// clock, and an exchange has to fit between two of them or the next push// lands in the middle of the argument it settles.const soon = (minutes = 2) => { said += minutes * 60 * 1000; return new Date(said).toISOString();};const afterCommit = (index) => { said = Math.max(said, farewellCommits[index].at);};
/** * Where a line sits in bob's working copy. Read rather than counted: the * files above are appended to, so a hand-written number goes stale the * moment anything is added before it. * @param {string} path * @param {string} needle */const lineOf = (path, needle) => read('bob', path) .split('\n') .findIndex((line) => line.includes(needle)) + 1;
/** * The line farewell() returns from. The rebase changed the word on it and * nothing else, so both versions anchor to the same line. */const returnAt = lineOf('index.js', 'goodbye, ${who}');const returnLine = (word) => ({ path: 'index.js', line: returnAt, side: 'new', snippet: ` return \`${word}, \${who}\`;`,});
const assertLine = { path: 'test.js', line: lineOf('test.js', "!== 'goodbye, world'"), side: 'new', snippet: "if (farewell('world') !== 'goodbye, world') {",};
/** * A word about the farewell branch. Dan is not among the speakers here: the * config names him nowhere, so a reader would not fetch his repo and his * words would not appear. That is the point of him, and it is why nobody * answers him. */const say = (who, sha, note, extra = {}, minutes) => putRecord(who, REVIEW_COLLECTION, { subject: sha === farewellV1 ? bobV1 : bobV2, sha, author: sessions.bob.did, ref: 'refs/heads/agent/farewell', verdict: 'comment', note, ...extra, reviewedAt: soon(minutes), });
// ---- v1: the branch as bob first pushed it -------------------------------
afterCommit(0);await review( 'nova', bobV1, farewellV1, sessions.bob.did, 'refs/heads/agent/farewell', 'changesRequested', 'farewell() has no test and the greeting is inconsistent with greet().', undefined, soon(),);
const wording = await say( 'nova', farewellV1, 'This returns "bye" but greet() says "hello". Two greetings, two registers.', { anchor: returnLine('bye') },);const wordingReply = await say( 'bob', farewellV1, 'I went with the shorter one deliberately. Is that worth a whole revision?', { anchor: returnLine('bye'), replyTo: { uri: wording.uri, cid: wording.cid }, },);await say( 'carol', farewellV1, 'It is, yes. A library that says hello should say goodbye. Anything else reads as two authors.', { anchor: returnLine('bye'), replyTo: { uri: wordingReply.uri, cid: wordingReply.cid }, },);
afterCommit(1);const assertion = await say( 'carol', farewellV1, 'The test asserts "goodbye, world" and the function returns "bye, world". One of the two is wrong and the check will say which.', { anchor: assertLine },);await say( 'nova', farewellV1, 'The check has run and it fails here. The log is on the run for this sha.', { anchor: assertLine, replyTo: { uri: assertion.uri, cid: assertion.cid } },);await say( 'bob', farewellV1, 'Right. The test is the one that is correct. I will change the function.', { anchor: assertLine, replyTo: { uri: assertion.uri, cid: assertion.cid } },);
await say( 'carol', farewellV1, 'Reading this from outside: is farewell() meant to be part of the public API, or is it internal for now? The readme does not say.',);await say( 'alice', farewellV1, 'Public. It goes in the readme with greet() once the wording settles.',);
// ---- v2: the same change, rebased, with the wording fixed ----------------
afterCommit(2);const settled = await say( 'carol', farewellV2, 'This is the wording. Thank you for going round again on it.', { anchor: returnLine('goodbye') },);await say( 'bob', farewellV2, 'The branch is what the argument names, so the rebase kept every word above attached to it.', { anchor: returnLine('goodbye'), replyTo: { uri: settled.uri, cid: settled.cid }, },);await say( 'carol', farewellV2, 'Every forge I have used loses this on a force push. Here it is still one change.', { anchor: returnLine('goodbye'), replyTo: { uri: settled.uri, cid: settled.cid }, },);
await review( 'nova', bobV2, farewellV2, sessions.bob.did, 'refs/heads/agent/farewell', 'approve', 'Wording now matches greet(). Test covers the new export.', undefined, soon(),);await review( 'carol', bobV2, farewellV2, sessions.bob.did, 'refs/heads/agent/farewell', 'approve', 'Reads well.', undefined, soon(),);await say( 'nova', farewellV2, 'Check passes on this version. Both exports covered.',);await say( 'alice', farewellV2, 'Good to merge once somebody who is not the author approves it.',);
// ---- the other changes ---------------------------------------------------
// Anchored to the line it speaks to, the way the pull request page writes one.const anchored = await review( 'nova', carolRef, git('carol', 'rev-parse', 'agent/errors'), sessions.carol.did, 'refs/heads/agent/errors', 'comment', 'Throwing on a non-string is a breaking change for callers passing numbers.', { path: 'index.js', line: 2, side: 'new', snippet: " if (typeof who !== 'string') throw new TypeError('who must be a string');", },);
// The author answers on the same line: a reply is a review naming the review// it answers, and still naming the pull request, so a reader that does not// know replyTo shows it beside what it answers.await putRecord('carol', REVIEW_COLLECTION, { subject: carolRef, sha: git('carol', 'rev-parse', 'agent/errors'), author: sessions.carol.did, ref: 'refs/heads/agent/errors', verdict: 'comment', anchor: { path: 'index.js', line: 2, side: 'new', snippet: " if (typeof who !== 'string') throw new TypeError('who must be a string');", }, replyTo: { uri: anchored.uri, cid: anchored.cid }, note: 'Fair. I will coerce numbers and keep the throw for everything else.', reviewedAt: nextTime().toISOString(),});
console.log( `bob's farewell carries ${farewellCommits.length} commits and an argument`,);
// ---- 8c. a word against each tip of agent/plural -------------------------//// One statement against each tip, so the timeline has a word beside every// force-push. The first still reads on the fourth: it names the account and// the ref, and neither moved.
/** * A word about one version, dated just after the push it answers. Seconds * apart is enough: what matters is that each word sorts after its push and * before the next one. * @param {number} which - the push this answers, counting from 1 */const onPlural = (who, which, verdict, note, anchor, seconds = 2) => putRecord(who, REVIEW_COLLECTION, { subject: bobPlural, sha: [pluralV1, pluralV2, pluralV3, pluralV4][which - 1], author: sessions.bob.did, ref: 'refs/heads/agent/plural', verdict, ...(anchor ? { anchor } : {}), note, reviewedAt: new Date(pushedAt[which - 1] + seconds * 1000).toISOString(), });
await onPlural( 'nova', 1, 'changesRequested', 'items(1) reads "1 items". The test says so and the run failed on it.', { path: 'count.js', line: 2, side: 'new', snippet: ' return `${n} items`;', },);await onPlural( 'carol', 2, 'changesRequested', 'Rebased and one reads right now. Zero still says "0 items", which is the same bug one place along, and the run says so.', { path: 'count.js', line: 2, side: 'new', snippet: " return n === 1 ? '1 item' : `${n} items`;", },);await onPlural( 'carol', 3, 'comment', [ 'All three cases pass now. One thing left, and it is only a name:', '', '```js', 'items(4) // reads as "do the items"', 'countOf(4) // reads as "the count of"', '```', '', '`items()` is a verb wherever else we use that word in this repository.', ].join('\n'),);// Anchored to the tip, so the line reads with the sign and the weight the// diff gives it. Every other anchor here is on a commit the branch replaced,// and one of those has only the snippet it recorded.await onPlural( 'carol', 4, 'comment', 'This is the line. Zero, one and many, each said once.', { path: 'count.js', line: 2, side: 'new', snippet: " return n === 0 ? 'no items' : n === 1 ? '1 item' : `${n} items`;", },);await onPlural( 'nova', 4, 'approve', 'Renamed, and the run is green on this tip.',);await onPlural( 'alice', 4, 'approve', 'Three force-pushes and the argument is still attached to the branch. This is the case a sha would have lost twice.',);console.log('agent/plural was force-pushed three times, each answered');
// ---- 8b. issues, each in its filer's own repo -----------------------------//// Carol's is answered and resolved by alice, whose disposition record is// what the page shows as the issue's state.
const issue = (who, value) => putRecord(who, ISSUE_COLLECTION, { subject: { uri: aliceRef.uri }, createdAt: nextTime().toISOString(), ...value, });
const carolIssue = await issue('carol', { title: 'Document what greet returns', body: 'The readme shows a call but never says the return value is a string.',});await issue('alice', { body: 'Covered by the usage section once agent/docs merges.', replyTo: { uri: carolIssue.uri, cid: carolIssue.cid }, disposition: 'resolved',});console.log('carol filed an issue; alice resolved it');
// The other disposition, so both settled states are on the list. Bob answers// after alice has declined, which is the case the model is for: the thread// stays readable and repliable, and the state is one person's word rather// than a bit anybody with push rights flipped.
const bobIssue = await issue('bob', { title: 'Ship greet as a CLI', body: 'A `greet` binary on the PATH would let a shell script call this without a node import.',});await issue('alice', { body: 'Out of scope. This is a library, and a CLI is a package of its own with its own release cadence.', replyTo: { uri: bobIssue.uri, cid: bobIssue.cid }, disposition: 'declined',});await issue('bob', { body: 'Fair. I will put it in a separate repo and link it from the readme.', replyTo: { uri: bobIssue.uri, cid: bobIssue.cid },});console.log('bob filed an issue; alice declined it; bob answered anyway');
// ---- 8d. who wrote which commits -----------------------------------------//// A commit records an author as a name and an address, and neither is an// atproto identity. This record is alice's statement about which addresses// belong to whom, in her own repo under her own DID. A reader trusts it as// far as it trusts her, which is the bar they already met by opening her// repositories.//// Without it a commit shows a letter and a name from the commit itself, which// is honest and says nothing about who on the network wrote it.
await putRecord( 'alice', IDENTITY_COLLECTION, { idents: [ { email: PEOPLE.alice.email }, { email: PEOPLE.bob.email, did: sessions.bob.did }, { email: PEOPLE.carol.email, did: sessions.carol.did }, { email: PEOPLE.dan.email, did: sessions.dan.did }, { email: PEOPLE.nova.email, did: sessions.nova.did }, ], createdAt: new Date().toISOString(), }, 'self',);console.log('alice claimed the commit addresses she knows');
// ---- 9. the config record, which is the whole fan-in -------------------//// atproto has no reverse index, so nothing connects alice's repository to the// copies on bob's and carol's servers except this list.
await putRecord( 'alice', CONFIG_COLLECTION, { collaborators: [sessions.bob.did, sessions.carol.did], reviewers: [sessions.carol.did, sessions.nova.did], runner: sessions.nova.did, runners: [sessions.nova.did], protectedBranches: ['main'], updatedAt: new Date().toISOString(), }, NAME,);
// ---- 10. wait for the runner to catch up ---------------------------------//// Fifteen pushes crossed the firehoses above: four on alice's copy, eight on// bob's, three on carol's. The runner clones and runs each one, so the seed// is not done until the records exist; without this wait the browser opens// on checks still arriving, which is realistic and confusing.
const expectedRuns = 15;process.stdout.write(`waiting for nova's runner (${expectedRuns} checks)`);for (let i = 0; ; i++) { const params = new URLSearchParams({ repo: sessions.nova.did, collection: CHECK_COLLECTION, limit: '100', }); const listed = await fetch( `${PEOPLE.nova.base}/xrpc/com.atproto.repo.listRecords?${params}`, ); const rows = listed.ok ? ((await listed.json()).records ?? []) : []; const mine = rows.filter((row) => String(row.value?.subject?.uri ?? '').endsWith(`/${NAME}`), ); const done = mine.filter((row) => row.value?.status !== 'running').length; if (done >= expectedRuns) { process.stdout.write(` ${done} published\n`); break; } if (i >= 240) { process.stdout.write(`\nstill ${done}/${expectedRuns}; see ${ciLog}\n`); break; } process.stdout.write('.'); await new Promise((finish) => setTimeout(finish, 1000));}
console.log('');console.log(`Collaborators on ${NAME}:`);for (const who of ['alice', 'bob', 'carol']) { console.log(` ${who.padEnd(6)} ${sessions[who].did} ${PEOPLE[who].base}`);}console.log(` nova ${sessions.nova.did} runner and review agent`);console.log(` dan ${sessions.dan.did} opened a fork pull request`);