chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/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`);