~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
1340 lines · 43.0 KB Raw History
#!/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`);