chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

main
307 lines · 10.8 KB Raw History
// Runs the scenario tracks under harness/scenarios/tracks: each is a day of
// ordinary git work split into steps, and each step is a fresh Claude
// session that can use only the knotless MCP server, logged in as that
// step's account. The steps of a track run in order, each told what the
// earlier ones handed off; tracks run side by side, each with its own
// accounts, against a fresh reference PDS and appview.
//
//   node harness/scenarios.mjs [track ...]
//
// Tracks marked `<!-- needs: spaces -->` run on the spaces alpha build named
// by KNOTLESS_SPACES_DEV_ENV, the rest on the npm reference PDS. Each run
// writes transcripts, report.json and report.md under
// target/scenarios/<time>/. SCENARIO_MODEL picks the model, and
// SCENARIO_TIMEOUT_SECS bounds each step (default 1200).

import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import { mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createInterface } from 'node:readline'

const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const here = join(root, 'harness', 'scenarios')
const bin = join(root, 'target', 'debug')
const timeout = Number(process.env.SCENARIO_TIMEOUT_SECS ?? 1200) * 1000
const adminPassword = 'scenario-admin'

/** A track: its steps, each a role and what it is asked to do. */
function track(name) {
  const text = readFileSync(join(here, 'tracks', `${name}.md`), 'utf8')
  const [intro, ...parts] = text.split(/^## /m)
  const steps = parts.map((part) => {
    const [role, ...rest] = part.split('\n')
    return { role: role.trim(), task: rest.join('\n').trim() }
  })
  return {
    name,
    spaces: intro.includes('<!-- needs: spaces -->'),
    about: intro.replace(/<!--.*?-->/g, '').trim(),
    steps,
    roles: [...new Set(steps.map((s) => s.role))],
  }
}

const handle = (t, role) => `${role}-${t.name}.test`

/** Fills `{role}` with that role's handle in this track. */
const fill = (t, text) =>
  text.replace(/\{([a-z]+)\}/g, (m, role) =>
    t.roles.includes(role) ? handle(t, role) : m,
  )

const freePort = () =>
  new Promise((ok) => {
    const s = createServer()
    s.listen(0, '127.0.0.1', () => {
      const { port } = s.address()
      s.close(() => ok(port))
    })
  })

/** A reference PDS with every track's accounts, and an appview on it. */
async function world(tracks, spaces, dir) {
  const names = tracks.flatMap((t) =>
    t.roles.map((r) => handle(t, r).replace(/\.test$/, '')),
  )
  const env = { ...process.env }
  delete env.KNOTLESS_DEV_ENV
  if (spaces) {
    env.KNOTLESS_DEV_ENV =
      process.env.KNOTLESS_SPACES_DEV_ENV ?? '../atproto-space-ref/packages/dev-env'
  }
  const pds = spawn('node', [join(root, 'harness', 'pds.mjs'), '--accounts', names.join(',')], {
    cwd: root,
    env,
    stdio: ['pipe', 'pipe', openSync(join(dir, 'pds.log'), 'w')],
  })
  const line = await new Promise((ok, fail) => {
    createInterface({ input: pds.stdout }).once('line', ok)
    pds.once('exit', (code) => fail(new Error(`pds.mjs exited ${code}`)))
  })
  const net = JSON.parse(line)
  if (spaces && !net.spaces) throw new Error('the spaces alpha is not running')
  const port = await freePort()
  const appview = `http://localhost:${port}`
  const serve = spawn(
    join(bin, 'knotless-serve'),
    [
      '--listen', `127.0.0.1:${port}`,
      '--index', join(dir, 'index.db'),
      '--firehose', net.pds.replace('http://', 'ws://'),
      '--public-url', appview,
    ],
    {
      env: {
        ...process.env,
        ...common(net),
        KNOTLESS_CACHE_DIR: join(dir, 'appview-cache'),
        KNOTLESS_CONFIG_DIR: join(dir, 'appview-config'),
        KNOTLESS_ADMIN_PASSWORD: adminPassword,
      },
      stdio: ['ignore', 'ignore', openSync(join(dir, 'appview.log'), 'w')],
    },
  )
  for (let i = 0; ; i++) {
    const up = await fetch(`${appview}/xrpc/dev.knotless.listRepos`).then(
      (r) => r.ok,
      () => false,
    )
    if (up) break
    if (i > 100) throw new Error('the appview did not start')
    await new Promise((ok) => setTimeout(ok, 100))
  }
  return {
    net,
    appview,
    close: () => {
      serve.kill()
      pds.stdin.end()
    },
  }
}

const common = (net) => ({
  KNOTLESS_HANDLE_RESOLVER: net.pds,
  KNOTLESS_PLC_URL: net.plc,
  KNOTLESS_DID_WEB_SCHEME: 'http',
  KNOTLESS_COMPACT: 'off',
})

/** Runs one step as a fresh Claude session; its transcript goes to `out`. */
async function step(t, i, w, dir, handoffs) {
  const { role, task } = t.steps[i]
  const me = handle(t, role)
  const account = w.net.accounts.find((a) => a.handle === me)
  const home = join(dir, 'people', role)
  const config = join(dir, `${i + 1}-${role}.mcp.json`)
  writeFileSync(
    config,
    JSON.stringify({
      mcpServers: {
        knotless: {
          command: join(bin, 'knotless-mcp'),
          env: {
            ...common(w.net),
            PATH: `${bin}:${process.env.PATH}`,
            KNOTLESS_IDENTIFIER: me,
            KNOTLESS_PASSWORD: account.password,
            KNOTLESS_APPVIEW: w.appview,
            KNOTLESS_CONFIG_DIR: join(home, 'config'),
            KNOTLESS_CACHE_DIR: join(home, 'cache'),
            GIT_AUTHOR_NAME: role,
            GIT_AUTHOR_EMAIL: `${role}@example.com`,
            GIT_COMMITTER_NAME: role,
            GIT_COMMITTER_EMAIL: `${role}@example.com`,
            GIT_CONFIG_GLOBAL: '/dev/null',
          },
        },
      },
    }),
  )
  const others = t.roles.filter((r) => r !== role).map((r) => handle(t, r))
  let prompt = readFileSync(join(here, 'brief.md'), 'utf8')
    .replaceAll('{me}', me)
    .replaceAll('{others}', others.length ? others.join(', ') : 'nobody else today')
  prompt += `\n## Your part today\n\n${fill(t, task)}\n`
  if (handoffs.length) {
    prompt += '\n## What the others handed on\n\n'
    prompt += handoffs.map((h) => `- ${h.who}: ${h.handoff}`).join('\n') + '\n'
  }
  const args = [
    '-p',
    '--output-format', 'stream-json',
    '--verbose',
    '--no-session-persistence',
    '--strict-mcp-config',
    '--mcp-config', config,
    '--tools', '',
    '--allowedTools', 'mcp__knotless',
  ]
  if (process.env.SCENARIO_MODEL) args.push('--model', process.env.SCENARIO_MODEL)
  // Outside the repository, so no project instructions reach the session.
  const cwd = mkdtempSync(join(tmpdir(), 'knotless-scenario-'))
  const claude = spawn('claude', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] })
  claude.stdin.end(prompt)
  const lines = []
  createInterface({ input: claude.stdout }).on('line', (l) => lines.push(l))
  let stderr = ''
  claude.stderr.on('data', (d) => (stderr += d))
  const timer = setTimeout(() => claude.kill(), timeout)
  const code = await new Promise((ok) => claude.on('close', ok))
  clearTimeout(timer)
  writeFileSync(join(dir, `${i + 1}-${role}.jsonl`), lines.join('\n') + '\n')
  writeFileSync(join(dir, `${i + 1}-${role}.prompt.md`), prompt)
  return { role, me, code, stderr, ...read(lines) }
}

/** What a transcript says: the calls made, those that failed, and the
 * report at the end of the reply. */
function read(lines) {
  const events = lines.flatMap((l) => {
    try {
      return [JSON.parse(l)]
    } catch {
      return []
    }
  })
  const calls = []
  const names = new Map()
  for (const e of events) {
    for (const c of e.message?.content ?? []) {
      if (c.type === 'tool_use') names.set(c.id, c.name.replace(/^mcp__knotless__/, ''))
      if (c.type === 'tool_result') {
        const text = [c.content].flat().map((x) => x?.text ?? x).join('')
        calls.push({ tool: names.get(c.tool_use_id), error: !!c.is_error, text })
      }
    }
  }
  const result = events.findLast((e) => e.type === 'result') ?? {}
  const blocks = [...(result.result ?? '').matchAll(/```json\s*([\s\S]*?)```/g)]
  let report = { done: false, summary: 'no report', handoff: '', tripped: [] }
  try {
    report = { ...report, ...JSON.parse(blocks.at(-1)[1]) }
  } catch {}
  return {
    calls,
    errors: calls.filter((c) => c.error),
    turns: result.num_turns,
    cost: result.total_cost_usd ?? 0,
    report,
  }
}

async function run(t, w, dir) {
  const out = join(dir, t.name)
  mkdirSync(out, { recursive: true })
  const steps = []
  for (let i = 0; i < t.steps.length; i++) {
    const handoffs = steps.map((s) => ({ who: s.me, handoff: s.report.handoff }))
    const s = await step(t, i, w, out, handoffs)
    console.log(
      `${t.name} ${i + 1}/${t.steps.length} ${s.role}: ${s.report.done ? 'done' : 'NOT DONE'}, ` +
        `${s.calls.length} calls, ${s.errors.length} errors, ${s.report.tripped.length} tripped`,
    )
    steps.push(s)
  }
  return { track: t.name, about: t.about, steps }
}

function markdown(results, when) {
  let md = `# Scenarios, ${when}\n\n`
  md += '| track | step | done | calls | errors | tripped | cost |\n|---|---|---|---|---|---|---|\n'
  for (const r of results) {
    r.steps.forEach((s, i) => {
      md += `| ${r.track} | ${i + 1} ${s.role} | ${s.report.done ? 'yes' : '**no**'} | ${s.calls.length} | ${s.errors.length} | ${s.report.tripped.length} | $${s.cost.toFixed(2)} |\n`
    })
  }
  for (const r of results) {
    md += `\n## ${r.track}\n\n${r.about}\n`
    r.steps.forEach((s, i) => {
      md += `\n### ${i + 1}. ${s.role} (${s.me})\n\n${s.report.summary}\n`
      if (s.code !== 0) md += `\nThe session exited ${s.code}: ${s.stderr.trim().slice(0, 500)}\n`
      for (const tr of s.report.tripped) {
        md += `\n- **${tr.tool}**: ${tr.what}\n  - expected: ${tr.expected}\n  - workaround: ${tr.workaround}\n`
      }
      for (const e of s.errors) {
        md += `\n- error from \`${e.tool}\`: ${e.text.replace(/\s+/g, ' ').slice(0, 300)}\n`
      }
    })
  }
  return md
}

const all = readdirSync(join(here, 'tracks'))
  .filter((f) => f.endsWith('.md'))
  .map((f) => f.slice(0, -3))
  .sort()
const chosen = process.argv.slice(2).length ? process.argv.slice(2) : all
const tracks = chosen.map(track)
const when = new Date().toISOString().replace(/[:.]/g, '-')
const dir = join(root, 'target', 'scenarios', when)
mkdirSync(dir, { recursive: true })

const groups = [false, true]
  .map((spaces) => ({ spaces, tracks: tracks.filter((t) => t.spaces === spaces) }))
  .filter((g) => g.tracks.length)
const results = (
  await Promise.all(
    groups.map(async (g) => {
      const wdir = join(dir, g.spaces ? 'spaces' : 'stock')
      mkdirSync(wdir, { recursive: true })
      const w = await world(g.tracks, g.spaces, wdir)
      try {
        return await Promise.all(g.tracks.map((t) => run(t, w, dir)))
      } finally {
        w.close()
      }
    }),
  )
).flat()
results.sort((a, b) => a.track.localeCompare(b.track))
writeFileSync(join(dir, 'report.json'), JSON.stringify(results, null, 2))
writeFileSync(join(dir, 'report.md'), markdown(results, when))
console.log(`\n${join(dir, 'report.md')}`)
process.exit(0)