@heyquilt/seam 0.27.0 → 0.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/bin/seam.js +118 -17
  2. package/lib/quilts/agent-tokens.js +148 -0
  3. package/lib/quilts/archiver.js +241 -0
  4. package/lib/quilts/attest.js +212 -0
  5. package/lib/quilts/bisect-run.js +3 -2
  6. package/lib/quilts/block.js +497 -55
  7. package/lib/quilts/cas.js +78 -0
  8. package/lib/quilts/cbor.js +2 -0
  9. package/lib/quilts/claims.js +409 -0
  10. package/lib/quilts/cli.js +612 -69
  11. package/lib/quilts/client.js +306 -19
  12. package/lib/quilts/conflict-format.js +266 -0
  13. package/lib/quilts/daemon.js +174 -0
  14. package/lib/quilts/describe.js +296 -38
  15. package/lib/quilts/dict.js +34 -0
  16. package/lib/quilts/exit-archive.js +184 -0
  17. package/lib/quilts/export-format.js +52 -0
  18. package/lib/quilts/export-git.js +27 -3
  19. package/lib/quilts/export-lfs.js +52 -0
  20. package/lib/quilts/export-sign.js +49 -0
  21. package/lib/quilts/fastcdc.js +186 -0
  22. package/lib/quilts/hash.js +17 -2
  23. package/lib/quilts/ignored.js +46 -0
  24. package/lib/quilts/import-git.js +54 -2
  25. package/lib/quilts/journal.js +105 -0
  26. package/lib/quilts/limits.js +197 -0
  27. package/lib/quilts/linepolicy.js +109 -0
  28. package/lib/quilts/pack.js +146 -18
  29. package/lib/quilts/persona.js +70 -0
  30. package/lib/quilts/records.js +7 -3
  31. package/lib/quilts/recover.js +37 -0
  32. package/lib/quilts/runner.js +79 -12
  33. package/lib/quilts/sealed.js +231 -0
  34. package/lib/quilts/secrets.js +86 -0
  35. package/lib/quilts/signing.js +7 -3
  36. package/lib/quilts/tarball.js +107 -0
  37. package/lib/quilts/textdiff.js +7 -5
  38. package/lib/quilts/tools.js +404 -83
  39. package/lib/quilts/tree.js +351 -0
  40. package/lib/quilts/want.js +51 -0
  41. package/lib/quilts/webhook.js +116 -0
  42. package/package.json +3 -2
package/bin/seam.js CHANGED
@@ -5,7 +5,7 @@ import fs from 'node:fs'
5
5
  import path from 'node:path'
6
6
  import { spawn } from 'node:child_process'
7
7
  import { fileURLToPath } from 'node:url'
8
- import { readAccount, login, logout, resume, call } from '../lib/account.js'
8
+ import { readAccount, login, logout, resume, call, seamHome } from '../lib/account.js'
9
9
 
10
10
  const here = path.dirname(fileURLToPath(import.meta.url))
11
11
  // In the repo the client is read straight from the server's src/quilts (never a stale build);
@@ -19,42 +19,92 @@ Docs: https://seam.heyquilt.com/docs
19
19
 
20
20
  seam login [--no-browser] Sign in with your Seam account (seam.heyquilt.com)
21
21
  seam logout | seam whoami
22
+ seam computers [list | signout <id or name>] computers signed in to your account (seam login)
23
+ seam account delete --confirm <your email> delete your Seam account (your patches stay)
22
24
  seam agent new <name> | list | key <name> | revoke <name> | relink [agent:<id>] your agents and their keys
23
25
  seam oauth client new <name> --redirect <uri> | list | revoke <client_id> apps that ask people for access (OAuth 2, PKCE)
24
26
  seam quilts [new <slug> | show <q>] Your quilts (repositories)
25
- seam import git <path> [--into <slug>]
27
+ seam import git <path> [--into <slug>] [--authors <file>]
26
28
  seam github [import <owner/name> --installation <id> [--into <slug>]] from GitHub, followed
27
- seam import git <remote-url> --into <slug> [--follow] [--interval <s>] [--credential <name>]
29
+ seam import git <remote-url> --into <slug> [--authors <file>] [--follow] [--interval <s>] [--credential <name>]
28
30
  seam import status <id> | seam import poke <id> | seam import cutover <id> an import that follows git
31
+ seam export tarball [rev] [--paths a,b] [--out file] one commit as a .tar.gz (git archive)
32
+ seam export archive [--out file] the whole quilt as one .tar (admin): segments, packs, members
33
+ seam export check <file> checks an archive (sizes, BLAKE3s, the log chain) offline
34
+ seam export extract <file> [rev] --to <dir> one commit's files out of an archive, offline
35
+ seam export git <dir> --from <archive> [--line main] a line's git history rebuilt from an archive, offline
29
36
  seam export remote <url> --quilt <q> [--line main,release/*] [--releases] [--credential <name>] | seam export status
37
+ seam clone <q> [folder] [--line <line>] main into a folder, ready to work: the first commit opens a square
38
+ seam pull bring the line's new patches in (or rebase this folder's square onto them)
30
39
  seam square open "<title>" --quilt <q> [--dir <folder>]
31
40
  seam checkout <sq:id | revision> --quilt <q> [--dir <folder>] [-- paths]
32
41
  seam status | diff | commit -m "<message>" in a block folder
33
42
  seam restore <paths> | reset [rev] | clean [-f] throw away uncommitted edits
34
- seam live [sq] | live share | live clear uncommitted work others read as sq:<id>:live
35
- seam merge [sq:id] [--into main] seam rebase | revert <patch:n> | cherry-pick <rev>
36
- seam resolve <path>... --ours | --theirs | --base | --side n | --delete
43
+ seam daemon [--debounce ms] | daemon status | daemon flush | daemon rebuild | daemon stop watch this block folder, share each change as live work
44
+ seam live [sq] | live share [paths] | live clear | live join [sq] [--member m] uncommitted work others read as sq:<id>:live
45
+ seam merge [sq:id] [--into main] [--no-commit-live] seam rebase | revert <patch:n> | cherry-pick <rev>
46
+ seam resolve <path>... --ours | --theirs | --base | --side n | --delete | --keep-both | --rule <step>
37
47
  seam log [rev] [-n 20] [--oneline] seam show <rev> seam blame <path>[@rev] seam cat | ls
38
48
  seam key [add [--name n] | remove <key>] seam commit --sign | sign [rev] seam log --show-signature
39
49
  seam line [new <name> [--from rev] | freeze | unfreeze] Lines: main, release/<name>
40
50
  seam tag [<name> [rev]] seam release [<name> [rev] -m <notes>] Read as tag:<name>
41
51
  seam commit --amend [-m "<message>"] change your square's last commit
42
52
  seam square open "<title>" --on sq:<id> a square stacked on another
43
- seam claim <path|glob> [--note why] | unclaim <id> files your square is working on
44
- seam review approve|request-changes|comment|dismiss <sq> seam line require <line> [--approvals n] [--checks a,b] [--runners ci=<member>]
45
- seam check <name> green|red [--square sq | --patch n] [--run-key k] report a check (a red one on a patch reverts it)
53
+ seam square open "<title>" --stack-keys db_branch=supabase:<ref> stack keys its first commit carries
54
+ seam claim <path|glob> [--note why] [--expires secs] | unclaim <id> files your square is working on
55
+ seam claim request <claim> --title what | withdraw <id> | handoff <claim> [--note ...] wait for a claim, hand one over
56
+ seam claim finish [--note ...] let go of your square's auto-claims now (queued ones go to the first in line)
57
+ seam watch [<glob>... [--classes logic] [--min-lines 50]] | watch remove <id> hear of patches that change them
58
+ seam review approve|request-changes|comment|dismiss <sq> [--scope <glob>,<glob>] seam review comment [sq] --path <f> --line <n> [--to-line <m>] --suggest <text> seam review apply <review id> [sq] seam review request [sq] [--to <member>[,<member>]] [-m <why>] seam review interdiff [sq] [--since <rev>] seam line require <line> [--approvals n] [--checks a,b] [--runners ci=<member>] [--auto-combine lines|additive|whitespace] [--approval-survives none|whitespace|comment-only|docs|default] [--needs-stack db_branch|none] [--monotonic-stack db_migration] [--migrations-in-order on] [--stack-defaults db_branch=supabase:<ref>|none]
59
+ seam check <name> green|red [--square sq | --patch n] [--run-key k] report a check (a red one after a merge is bisected; on a patch it reverts it)
60
+ seam check pipeline <name> [--line main] the bisect of a red check, and what the last one found
61
+ seam check pipeline <name> --stop stop that bisect (admin): its waiting runs are cancelled
46
62
  seam checks [--state queued] [--name ci] [--tier pre_merge|post_merge] check runs Seam asked runners for
47
63
  seam runner --check ci --run "npm test" [--quilt <q>] [--once] run the checks Seam asks you for, on this machine
48
- seam resolutions [forget <key>] conflicts settled before, settled the same way again
49
- seam bisect <bad> <good> --run "<check>" seam grep <pattern> [rev] [--all] seam describe [rev] seam merge-base <a> <b>
50
- seam log --follow <path> | log -S <text>
64
+ seam runner --config runner.json [--listen <port>] [--idle-exit <s>] a command per check; woken by Seam's webhook
65
+ seam resolutions [--hunks] [forget <key>] conflicts settled before, settled the same way again
66
+ seam bisect <bad> <good> --run "<check>" [-- paths] seam grep <pattern> [rev] [--all] seam describe [rev] seam merge-base <a> <b>
67
+ seam stack [rev] the full-stack pointers at a revision (db_migration, db_branch, …) and where each was set seam diff <a> <b> --stack which of them changed
68
+ seam quilts infra-paths <glob,glob>|default|none --quilt <q> what seam stack derives infra from
69
+ seam quilts owner-idle-hours <1..24>|default --quilt <q> how long an owner claim holds after the last write
70
+ seam quilts public-reads --quilt <q> what readers with no sign-in fetched (90 days, address hashed)
71
+ seam quilts migrations-dir <folder>|none|default --quilt <q> where seam stack derives db_migration from
72
+ seam contains <rev> lines that have it, and where it was cherry-picked or reverted seam merge-base --is-ancestor <a> <b>
73
+ seam log --follow <path> | log -S <text> | -G <regex> | --grep <text> [-E] [-i] | --author <who> --task <id> --since 3d --until <t>
74
+ seam log --group-by author|on_behalf_of|task|tool seam log --graph | --include-hidden | --cursor <c>
75
+ seam diff <a> <b> [--stat | --name-status] [--no-renames | --renames exact] [-w | --ignore-space-at-eol] [-U <n>]
76
+ seam search <query> [--at <rev>] [--all] words, "phrases", /regex/, path:, lang:, symbol:, class:, -term, OR
77
+ seam blame <path>[@rev] [-L a,b] [--ignore-rev <rev,…>] [-M] [--reverse <rev>]
78
+ seam block [rebuild | open <square> [--paths a,b] [--mode m] [--read-only] | touch <id> | close <id>] blocks on the server
51
79
  seam token [new <name> [--level read|write] | revoke <name>] a key for one quilt, for CI
52
- seam events [--webhook <url>] What happened to your squares
80
+ seam token agent [new [--agent agent:<id>] [--level write] [--paths src] [--hours 8] | revoke <id>] the key an agent writes with
81
+ seam protect [add <path> <who> <level> [--exclude] | remove <n> | set <file> | reset | check <path>] who may do what under which path
82
+ seam team [set <name> <member>... | remove <name>] teams the protections name
83
+ seam verify [--after <seq>] [--keys <file>] check the quilt's log against the server's signatures
84
+ seam metrics the quilt's numbers the monitors watch (archiver lag, open scrub findings, …) and which are past their threshold
85
+ seam redact <blob | path[@rev]> --reason "<why>" --yes | redact list remove a secret's bytes for good, history kept
86
+ seam quilts secret-scanning [refuse|warn|off] seam commit --allow sf_… commits that look like they hold secrets
87
+ seam policy [sq] seam line require <line> --rules rules.json change classes and rules by path and class
88
+ seam policy versions <line> | approve|reject|confirm <line> <version> a line's policy versions and their approval
89
+ seam org [new <slug> | show <org> | add <org> <member> [--role r] | remove <org> <member>] orgs that own quilts
90
+ seam org log <org> [-n <count>] [--before <id>] what happened in an org: members, roles, teams, quilts (owners and admins)
91
+ seam org team <org> <name> [<member>...] an org's team, named org_team:<name> in its quilts' protections
92
+ seam quilts new <slug> --org <org> | quilts move --quilt <q> --org <org> a quilt that belongs to an org
93
+ seam quilts agents-make-lines on|off whether agents may make lines, tags and releases
94
+ seam quilts public on|off anyone may read the quilt, signed in or not
95
+ seam quilts add <email|member> [--level read|write|admin] add a member; an email with no account yet is invited
96
+ seam quilts new <slug> --sealed a quilt the server never reads: its key lives on members' computers
97
+ seam quilts sealed on|off --quilt <q> switch a quilt that holds no files yet to sealed or back
98
+ seam seal [status] | key | grant <member> | rotate [--without <member>] a sealed quilt's key
99
+ seam events [--follow|--webhook <url>] What happened to your squares
53
100
  seam export git <dir> Back to git
54
101
  seam mcp The same commands as MCP tools, for your AI tool
102
+ seam mcp --as-agent [--merge] The same, with the AI tool as an agent of yours, on a token of its own
55
103
  seam serve Run a Seam server (SEAM_AUTH_URL: its own accounts; SEAM_IDENTITY_URL: a sign-in service)
104
+ seam worker Run Seam's derived jobs and events off the API machines (they set SEAM_DERIVE_WORKERS=queue)
56
105
 
57
- Agents: set SEAM_TOKEN to the agent's key instead of signing in.`
106
+ Agents: set SEAM_TOKEN to the agent's key instead of signing in.
107
+ SEAM_CONFLICT_STYLE=git writes conflicts with git's <<<<<<< markers.`
58
108
 
59
109
  const cmd = process.argv[2]
60
110
  const argv = process.argv.slice(3)
@@ -73,6 +123,14 @@ async function main () {
73
123
  if (cmd === 'logout') { const a = await logout(); return console.log(a ? `Signed out of ${a.account.email}.` : 'Not signed in.') }
74
124
  if (cmd === 'whoami') { const a = readAccount(); if (!a) { process.exitCode = 1; return console.log('Not signed in.') } return console.log(`${a.account.name} (${a.account.email})`) }
75
125
  if (cmd === 'agent') return agentCommand(argv[0], argv[1])
126
+ if (cmd === 'computers') return computersCommand(argv[0], argv[1])
127
+ if (cmd === 'account') {
128
+ const i = argv.indexOf('--confirm')
129
+ if (argv[0] !== 'delete' || i < 0 || !argv[i + 1]) fail('seam account delete --confirm <your email>')
130
+ const r = await signedIn()('DELETE', `/v1/me?confirm=${encodeURIComponent(argv[i + 1])}`)
131
+ await logout().catch(() => {})
132
+ return console.log(`Deleted your Seam account. Your patches stay as ${r.member_id}.`)
133
+ }
76
134
  if (cmd === 'oauth') return oauthCommand(argv)
77
135
  if (cmd === 'mcp') return mcp()
78
136
  // The server is not in the package (it ships the client): it runs from Seam's source or its image.
@@ -80,6 +138,10 @@ async function main () {
80
138
  if (QUILTS === '../lib/quilts') fail('seam serve runs from Seam\'s source: node src/quilts/server.js in the repository, or the Dockerfile.seam image (docs/seam-service.md).')
81
139
  return (await quilts('server.js')).serveFromEnv(process.env)
82
140
  }
141
+ if (cmd === 'worker') {
142
+ if (QUILTS === '../lib/quilts') fail('seam worker runs from Seam\'s source: node src/quilts/server.js worker in the repository, or the Dockerfile.seam image (docs/seam-service.md).')
143
+ return (await quilts('server.js')).workerFromEnv(process.env)
144
+ }
83
145
  const { SEAM_VERBS, runSeamCommand } = await quilts('cli.js')
84
146
  if (!SEAM_VERBS.has(cmd)) fail(`seam: '${cmd}' is not a seam command. See seam help.`)
85
147
  try {
@@ -127,10 +189,11 @@ async function oauthCommand (argv) {
127
189
  }
128
190
 
129
191
  // `seam agent …`: an account's agents, each with its own key (SEAM_TOKEN for that agent).
130
- async function agentCommand (sub, name) {
192
+ // A call as the signed-in computer, resuming once when its key has expired.
193
+ function signedIn () {
131
194
  const a = readAccount()
132
195
  if (!a) fail('Not signed in: run seam login.')
133
- const go = async (method, route, body) => {
196
+ return async (method, route, body) => {
134
197
  try { return await call(method, route, body, a.token) } catch (err) {
135
198
  if (err.status !== 401) throw err
136
199
  const back = await resume().catch(() => null)
@@ -138,6 +201,29 @@ async function agentCommand (sub, name) {
138
201
  return call(method, route, body, back.token)
139
202
  }
140
203
  }
204
+ }
205
+
206
+ // `seam computers`: the computers signed in to your account with seam login; `signout <id or name>` ends one.
207
+ async function computersCommand (sub, which) {
208
+ const go = signedIn()
209
+ const { keys } = await go('GET', '/v1/me/keys')
210
+ if (!sub || sub === 'list') {
211
+ for (const k of keys) console.log(`${k.name} ${k.id} since ${new Date(k.created_at).toISOString().slice(0, 10)}${k.current ? ' (this computer)' : ''}`)
212
+ return
213
+ }
214
+ if (sub === 'signout') {
215
+ if (!which) fail('seam computers signout <id or name>')
216
+ const found = keys.filter((k) => k.id === which || k.name === which)
217
+ if (!found.length) fail(`No signed-in computer ${which}: seam computers lists them.`)
218
+ if (found.length > 1) fail(`${found.length} computers are named ${which}; use its id.`)
219
+ const r = await go('DELETE', `/v1/me/keys/${encodeURIComponent(found[0].id)}`)
220
+ return console.log(`Signed out ${r.name}${found[0].current ? ' (this computer: run seam login to sign in again)' : ''}.`)
221
+ }
222
+ fail(`seam computers: '${sub}' is not a computers command (list, signout).`)
223
+ }
224
+
225
+ async function agentCommand (sub, name) {
226
+ const go = signedIn()
141
227
  const showKey = (r) => console.log(`${r.name} (${r.member_id})\n\n SEAM_TOKEN=${r.key}\n\nThis key is shown once. Give it to the agent as SEAM_TOKEN; seam agent key ${r.name} makes a new one and ends this one.`)
142
228
  if (sub === 'new') { if (!name) fail('seam agent new <name>'); return showKey(await go('POST', '/v1/agents', { name })) }
143
229
  if (sub === 'key') { if (!name) fail('seam agent key <name>'); return showKey(await go('POST', `/v1/agents/${encodeURIComponent(name)}/key`)) }
@@ -168,7 +254,22 @@ async function mcp () {
168
254
  const { connect } = await quilts('cli.js')
169
255
  const { findBlock } = await quilts('block.js')
170
256
  const server = new McpServer({ name: 'seam', version }, { instructions: SEAM_GUIDE })
171
- registerSeamTools(server, { client: () => connect(findBlock()?.block, readAccount()?.token).client, found: () => findBlock() })
257
+ // --as-agent (SEAM_MCP_AGENT=1): the AI tool is an agent of yours, with a token of its own (11.5).
258
+ const asAgent = process.argv.includes('--as-agent') || process.env.SEAM_MCP_AGENT === '1'
259
+ let tokens = null
260
+ const client = async (args = {}) => {
261
+ const block = findBlock()?.block
262
+ const person = connect(block, readAccount()?.token)
263
+ const quilt = args.quilt || block?.quilt
264
+ if (!asAgent || !quilt) return person.client
265
+ const { personaTokens } = await quilts('persona.js')
266
+ const { quiltClient } = await quilts('client.js')
267
+ tokens ||= personaTokens({ client: person.client, home: seamHome(), merge: process.argv.includes('--merge') })
268
+ const square = !args.quilt || args.quilt === block?.quilt ? block?.square : null
269
+ const t = await tokens({ tool: server.server.getClientVersion()?.name, quilt, square })
270
+ return quiltClient({ api: person.api, token: t.token })
271
+ }
272
+ registerSeamTools(server, { client, found: () => findBlock() })
172
273
  await server.connect(new StdioServerTransport())
173
274
  }
174
275
 
@@ -0,0 +1,148 @@
1
+ // Agent quilt tokens (plans/quilt-source-control/11.5, 04 "Credentials"; TODO 3.1): an agent works
2
+ // in a quilt with a token scoped to that quilt, to squares (`own`: the ones it holds or writes;
3
+ // or a list), to paths, and to a level (read, write or merge; never admin), that ends after hours
4
+ // (8 by default, 7 days at most). A person mints one for an agent working for them, or the agent
5
+ // trades its own key for one; either way it never does more than its person can. On a server
6
+ // started with SEAM_AGENT_TOKENS=1, an agent's own key reads but no longer writes: it writes with
7
+ // a token. Quilt tokens for CI (sqt_, tokens.js) are a different kind and work as before.
8
+ import crypto from 'node:crypto'
9
+ import { QuiltError, squareShort } from './errors.js'
10
+ import { uuidv7 } from './ids.js'
11
+ import { cleanPrefix } from './records.js'
12
+ import { RATE_NAMES, rateDefault } from './limits.js'
13
+
14
+ export const AGENT_TOKEN_PREFIX = 'sat_'
15
+ export const MAX_LIVE = 20 // per agent per quilt
16
+ export const DEFAULT_TTL_MS = 8 * 60 * 60 * 1000
17
+ export const MAX_TTL_MS = 7 * 24 * 60 * 60 * 1000
18
+ const SECRET = /^sat_[A-Za-z0-9_-]{43}$/
19
+ const LEVELS = ['read', 'write', 'merge']
20
+ const RANK = { read: 1, write: 2, merge: 3, admin: 4 }
21
+
22
+ const sha = (s) => crypto.createHash('sha256').update(String(s)).digest('hex')
23
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
24
+ export const isAgentToken = (s) => SECRET.test(String(s || ''))
25
+
26
+ /** Whether an agent token lets its agent write to square `sq` (11.5: `own` is checked every time). */
27
+ export function tokenReachesSquare (token, sq, agent) {
28
+ if (!token || token.kind !== 'agent') return true
29
+ if (token.squares === 'own') return sq.holder === agent || (sq.writers || []).includes(agent)
30
+ return token.squares.includes(sq.id)
31
+ }
32
+
33
+ /** 11.5 `checks`: a token with `checks: false` reports no check verdicts (13). Tokens made before the field (null) still do. */
34
+ export function refuseChecks (me) {
35
+ if (me && me.token && me.token.kind === 'agent' && me.token.checks === false) throw new QuiltError(403, 'not_in_token', 'this token does not report check verdicts: mint one with checks: true (seam token agent new --checks)', { need: 'checks' })
36
+ }
37
+
38
+ /** 11.9 QuiltToken.rate: lower numbers for 04's per-token rows, never higher. */
39
+ function cleanRate (rate) {
40
+ if (rate == null) return null
41
+ if (typeof rate !== 'object' || Array.isArray(rate)) throw new QuiltError(400, 'bad_request', `rate is an object of ${RATE_NAMES.join(', ')}`)
42
+ const out = {}
43
+ for (const [name, v] of Object.entries(rate)) {
44
+ if (!RATE_NAMES.includes(name)) throw new QuiltError(400, 'bad_request', `rate: ${name} is not a per-token limit (${RATE_NAMES.join(', ')})`)
45
+ if (!Number.isInteger(v) || v < 1) throw new QuiltError(400, 'bad_request', `rate.${name} is a whole number of 1 or more`)
46
+ if (v > rateDefault(name)) throw new QuiltError(400, 'bad_request', `rate.${name}: ${v} is above Seam's ${rateDefault(name)}; a token's rate lowers limits, it never raises them`)
47
+ out[name] = v
48
+ }
49
+ return Object.keys(out).length ? out : null
50
+ }
51
+
52
+ export function agentTokenOps (svc, { required = false } = {}) {
53
+ const { store, now, quiltFor, fromStore } = svc.internals
54
+ // A Seam agent's row (keys.js), when the member id names one; agents from another sign-in have none.
55
+ const seamAgent = async (member) => store.getAgent && UUID.test(member.slice(6)) ? store.getAgent(member.slice(6)) : null
56
+
57
+ const view = (r) => ({ id: r.id, agent: r.agent, on_behalf_of: r.on_behalf_of, squares: r.squares, paths: r.paths, max_level: r.max_level, checks: r.checks ?? null, rate: r.rate ?? null, expires_at: r.expires_at, issued_by: r.issued_by, created_at: r.created_at, ...(r.revoked_at ? { revoked_at: r.revoked_at } : {}) })
58
+
59
+ /**
60
+ * POST /agent-tokens { agent?, squares?, paths?, max_level?, ttl_s? }: a token for an agent in
61
+ * this quilt. A person names their agent (`agent: agent:<id>`); an agent asks for itself.
62
+ */
63
+ async function createAgentToken (me, ref, body = {}) {
64
+ if (me.token) throw new QuiltError(403, 'forbidden', 'a token cannot mint a token')
65
+ // An agent asking for itself reaches the quilt through the person it works for.
66
+ const q = await quiltFor(me.kind === 'person' || !me.on_behalf_of ? me : { member_id: me.on_behalf_of, kind: 'person', name: me.name }, ref, 'read')
67
+ let agent, person
68
+ if (me.kind === 'person') {
69
+ agent = String(body.agent || '')
70
+ if (!/^agent:\S{1,190}$/.test(agent)) throw new QuiltError(400, 'bad_request', 'agent: the agent this token is for (agent:<id>)')
71
+ // A Seam agent belongs to an account: only that account's person mints for it.
72
+ const a = await seamAgent(agent)
73
+ if (a && store.getAccount) { const acc = await store.getAccount(a.account_id); if (acc && acc.member_id !== me.member_id) throw new QuiltError(403, 'forbidden', `${agent} is not one of your agents`) }
74
+ person = me.member_id
75
+ } else {
76
+ if (body.agent && body.agent !== me.member_id) throw new QuiltError(403, 'forbidden', 'an agent mints a token for itself only')
77
+ if (!me.on_behalf_of) throw new QuiltError(403, 'forbidden', 'an agent working for nobody gets no token; a person mints one for it')
78
+ agent = me.member_id
79
+ person = me.on_behalf_of
80
+ }
81
+ const level = body.max_level || 'write'
82
+ if (!LEVELS.includes(level)) throw new QuiltError(400, 'bad_request', 'max_level is read, write or merge (a token never carries admin)')
83
+ // Never more than the person holds (11.5: minted by a person who holds at least the level asked).
84
+ const personRole = await svc.internals.levelIn(q, person)
85
+ const personAccess = personRole ? await svc.internals.accessOfMember(q, person) : null
86
+ if (!personAccess || RANK[personAccess.max] < RANK[level]) throw new QuiltError(403, 'forbidden', `${person} does not hold ${level} on this quilt, so no token for it can`)
87
+ const squares = body.squares == null || body.squares === 'own' ? 'own' : body.squares
88
+ if (squares !== 'own') {
89
+ if (!Array.isArray(squares) || squares.length > 100) throw new QuiltError(400, 'bad_request', 'squares is own, or a list of up to 100 squares')
90
+ for (let i = 0; i < squares.length; i++) squares[i] = (await svc.internals.squareByRef(q.id, squares[i])).id
91
+ }
92
+ const paths = (body.paths || []).map(cleanPrefix)
93
+ if (paths.length > 50) throw new QuiltError(400, 'bad_request', 'paths is up to 50 prefixes')
94
+ const ttl = body.ttl_s == null ? DEFAULT_TTL_MS : Number(body.ttl_s) * 1000
95
+ if (!Number.isFinite(ttl) || ttl < 60 * 1000 || ttl > MAX_TTL_MS) throw new QuiltError(400, 'bad_request', 'ttl_s is from 60 seconds to 7 days')
96
+ if (body.checks != null && typeof body.checks !== 'boolean') throw new QuiltError(400, 'bad_request', 'checks is true or false: whether the token reports check verdicts')
97
+ // Default (11.5): false. Without SEAM_AGENT_TOKENS a token says nothing (null), so nothing that reports verdicts today breaks.
98
+ const checks = body.checks ?? (required ? false : null)
99
+ const rate = cleanRate(body.rate)
100
+ const live = (await store.agentTokens(q.id)).filter((t) => t.agent === agent && !t.revoked_at && t.expires_at > now())
101
+ if (live.length >= MAX_LIVE) throw new QuiltError(409, 'too_many_tokens', `an agent holds at most ${MAX_LIVE} live tokens per quilt; revoke one first`)
102
+ const secret = AGENT_TOKEN_PREFIX + crypto.randomBytes(32).toString('base64url')
103
+ const at = now()
104
+ try {
105
+ const row = await store.createAgentToken({ id: uuidv7(at), quilt_id: q.id, agent, on_behalf_of: person, squares, paths, max_level: level, checks, rate, hash: sha(secret), issued_by: me.member_id, created_at: at, expires_at: at + ttl })
106
+ return { ...view(row), quilt: q.slug, token: secret }
107
+ } catch (err) { throw fromStore(err) }
108
+ }
109
+
110
+ /** GET /agent-tokens: the quilt's agent tokens (a person sees theirs, an admin every one), never secrets. */
111
+ async function agentTokens (me, ref) {
112
+ const q = await quiltFor(me, ref, 'read')
113
+ const all = await store.agentTokens(q.id)
114
+ const admin = q.access.can('', 'admin')
115
+ return { quilt: q.slug, tokens: all.filter((t) => admin || t.on_behalf_of === me.member_id || t.agent === me.member_id).map(view) }
116
+ }
117
+
118
+ /** DELETE /agent-tokens/:id: ends a token at once (its person, its agent, or an admin). */
119
+ async function revokeAgentToken (me, ref, id) {
120
+ const q = await quiltFor(me, ref, 'read')
121
+ const t = (await store.agentTokens(q.id)).find((x) => x.id === String(id))
122
+ if (!t) throw new QuiltError(404, 'not_found', `no agent token ${id}`)
123
+ if (!(q.access.can('', 'admin') || t.on_behalf_of === me.member_id || t.agent === me.member_id)) throw new QuiltError(403, 'forbidden', 'only its person, its agent or an admin revokes a token')
124
+ if (t.revoked_at) return view(t)
125
+ try { return view(await store.revokeAgentToken({ quilt_id: q.id, id: t.id, actor: me.member_id, at: now() })) } catch (err) { throw fromStore(err) }
126
+ }
127
+
128
+ /** The caller an agent token stands for, or null when it is unknown, revoked, expired, or its agent was revoked. */
129
+ async function agentTokenCaller (secret) {
130
+ if (!isAgentToken(secret)) return null
131
+ const t = await store.agentTokenByHash(sha(secret))
132
+ if (!t || t.revoked_at || t.expires_at <= now()) return null
133
+ const a = await seamAgent(t.agent)
134
+ if (a && a.revoked_at) return null
135
+ return { member_id: t.agent, kind: 'agent', name: a?.name || t.agent, tool: 'seam', on_behalf_of: t.on_behalf_of, token: { id: t.id, kind: 'agent', quilt_id: t.quilt_id, level: t.max_level, squares: t.squares, paths: t.paths, checks: t.checks ?? null, rate: t.rate ?? null, expires_at: t.expires_at } }
136
+ }
137
+
138
+ /** With SEAM_AGENT_TOKENS=1, an agent's own key reads only: a write needs a token (403 token_required). */
139
+ function guardKey (me, need) {
140
+ if (!required || me.kind === 'person' || me.public || me.token || need === 'read') return
141
+ throw new QuiltError(403, 'token_required', 'an agent writes with a quilt token, not its own key: mint one with seam token agent new (POST /v1/quilts/<q>/agent-tokens), then use it as SEAM_TOKEN')
142
+ }
143
+
144
+ return { createAgentToken, agentTokens, revokeAgentToken, agentTokenCaller, internals: { guardKey } }
145
+ }
146
+
147
+ export const describeAgentToken = (t) => `Agent token ${t.id} for ${t.agent} (working for ${t.on_behalf_of}) on ${t.quilt}: ${t.max_level}, ${t.squares === 'own' ? 'its own squares' : `squares ${t.squares.map(squareShort).join(', ')}`}${t.paths.length ? `, paths ${t.paths.join(', ')}` : ''}${t.checks ? ', reports checks' : ''}${t.rate ? `, rate ${Object.entries(t.rate).map(([k, v]) => `${k} ${v}`).join(', ')}` : ''}, until ${new Date(t.expires_at).toISOString()}. Its secret, shown only this once:\n\n ${t.token}\n\nSet it as SEAM_TOKEN for the agent.`
148
+ export const describeAgentTokens = (r) => r.tokens.length ? r.tokens.map((t) => `${t.id} ${t.agent} ${t.max_level} ${t.squares === 'own' ? 'own' : `${t.squares.length} squares`}${t.paths.length ? ` ${t.paths.join(',')}` : ''} ${t.revoked_at ? 'revoked' : `until ${new Date(t.expires_at).toISOString()}`}`).join('\n') : `${r.quilt} has no agent tokens.`
@@ -0,0 +1,241 @@
1
+ // The log archiver (plans/quilt-source-control/03, "The log order and the archiver"; 15
2
+ // invariant 5): copies a quilt's log rows, with the commit rows they write, into immutable WAL
3
+ // segments in object storage, so the database is rebuildable by replay. A segment is written when
4
+ // 1,000 rows are waiting or the oldest waiting row is 5 minutes old, holds at most 1,000 rows, and
5
+ // names the segment before it by hash, so a quilt's segments are one chain. archive_seq then moves
6
+ // by compare-and-swap (setArchive), so two archivers never both advance one quilt; a loser's
7
+ // segment has the same name and the same bytes as the winner's (rows are immutable and the
8
+ // encoding is deterministic), and object storage keeps the first.
9
+ //
10
+ // Segment bytes: 'QWS1', a flag byte (0 deflate-raw, 1 zstd), the compressed body (one canonical
11
+ // JSON record a line: { t: 'log', row } or { t: 'commit', row }), the trailer JSON
12
+ // { quilt_id, from_seq, to_seq, row_count, prev, blake3 } and its length as a u32. `blake3` is
13
+ // the hash of everything before the trailer; `prev` the previous segment's (64 zeros first).
14
+ import zlib from 'node:zlib'
15
+ import { blake3 } from '@noble/hashes/blake3.js'
16
+ import { readJournal } from './journal.js'
17
+ import { recoverGap } from './recover.js'
18
+
19
+ export const SEGMENT_ROWS = 1000
20
+ export const SEGMENT_AGE_MS = 5 * 60 * 1000
21
+ const MAGIC = Buffer.from('QWS1')
22
+ const LEASE = ' archive' // a line lease name no real line can have (names never start with a space)
23
+ const LEASE_MS = 5 * 60 * 1000
24
+ export const LAG_ROWS = 10000
25
+ export const LAG_MS = 30 * 60 * 1000
26
+ const ZERO = '0'.repeat(64)
27
+ const HAS_ZSTD = typeof zlib.zstdCompressSync === 'function'
28
+
29
+ const pad = (n) => String(n).padStart(12, '0')
30
+ /** 03's key w/<from_seq>-<to_seq>.qws, zero-padded so names sort in seq order. */
31
+ export const segmentName = (from, to) => `${pad(from)}-${pad(to)}.qws`
32
+ const hashOf = (bytes) => Buffer.from(blake3(bytes)).toString('hex')
33
+
34
+ /** Canonical JSON: keys sorted at every level, so the same rows always give the same bytes. */
35
+ function canonical (v) {
36
+ if (Array.isArray(v)) return '[' + v.map(canonical).join(',') + ']'
37
+ if (v && typeof v === 'object') return '{' + Object.keys(v).sort().filter((key) => v[key] !== undefined).map((key) => JSON.stringify(key) + ':' + canonical(v[key])).join(',') + '}'
38
+ return JSON.stringify(v ?? null)
39
+ }
40
+
41
+ // Commit-row columns that change after the row is written (03's replay table derives them):
42
+ // hidden_seq from successors' `predecessor`, patch from merge rows, snapshot_root from the merge
43
+ // row or the deriver. A segment keeps only what never changes.
44
+ const LATER = ['hidden_seq', 'patch', 'snapshot_root']
45
+ export const frozenCommit = (c) => { const out = { ...c }; for (const key of LATER) delete out[key]; return out }
46
+
47
+ export function encodeSegment ({ quilt_id: q, from_seq: from, to_seq: to, prev, records }) {
48
+ const body = Buffer.from(records.map(canonical).join('\n'), 'utf8')
49
+ const head = Buffer.concat([MAGIC, Buffer.from([HAS_ZSTD ? 1 : 0]), HAS_ZSTD ? zlib.zstdCompressSync(body) : zlib.deflateRawSync(body, { level: 6 })])
50
+ const trailer = { quilt_id: q, from_seq: from, to_seq: to, row_count: records.filter((r) => r.t === 'log').length, prev, blake3: hashOf(head) }
51
+ const tb = Buffer.from(canonical(trailer), 'utf8')
52
+ const len = Buffer.alloc(4); len.writeUInt32BE(tb.length)
53
+ return { bytes: Buffer.concat([head, tb, len]), trailer }
54
+ }
55
+
56
+ export function decodeSegment (bytes) {
57
+ if (bytes.length < 9 || !bytes.subarray(0, 4).equals(MAGIC)) throw new Error('not a WAL segment')
58
+ const tl = bytes.readUInt32BE(bytes.length - 4)
59
+ const head = bytes.subarray(0, bytes.length - 4 - tl)
60
+ const trailer = JSON.parse(bytes.subarray(head.length, bytes.length - 4).toString('utf8'))
61
+ if (hashOf(head) !== trailer.blake3) throw new Error(`segment ${trailer.from_seq}-${trailer.to_seq}: its bytes do not match its hash`)
62
+ const flag = head[4]
63
+ if (flag === 1 && !HAS_ZSTD) throw new Error('this segment is zstd and this Node cannot read zstd (needs Node 22.15 or later)')
64
+ // Bounded as pack entries are (pack.js): a segment of 1,000 log rows is far under 64 MiB.
65
+ const body = (flag === 1 ? zlib.zstdDecompressSync : zlib.inflateRawSync)(head.subarray(5), { maxOutputLength: 64 * 1024 * 1024 }).toString('utf8')
66
+ return { trailer, records: body ? body.split('\n').map((l) => JSON.parse(l)) : [] }
67
+ }
68
+
69
+ /**
70
+ * The archiver over a store and an object store. `archive(q, { force })` writes every segment
71
+ * that is due (with `force`, the rest too, in segments of at most 1,000 rows) and returns
72
+ * { segments, archive_seq }. `maybe(q)` runs it in the background, once at a time per quilt.
73
+ */
74
+ export function createArchiver ({ store, objects, now = Date.now, rows = SEGMENT_ROWS, ageMs = SEGMENT_AGE_MS, log = () => {}, holder = `archiver:${process.pid}:${Math.random().toString(36).slice(2, 10)}`, leaseMs = LEASE_MS }) {
75
+ const running = new Map()
76
+ const alerting = new Set()
77
+
78
+ async function writeOne (q, quilt, to) {
79
+ const from = (quilt.archive_seq || 0) + 1
80
+ const records = []
81
+ for (let after = from - 1; after < to;) {
82
+ const page = await store.logRows(q, { after, limit: Math.min(1000, to - after) })
83
+ if (!page.length) throw new Error(`log rows ${after + 1}..${to} are missing: the archiver never writes past a gap`)
84
+ for (const row of page) {
85
+ if (row.seq !== after + 1) throw new Error(`log row ${after + 1} is missing (found ${row.seq}): the archiver never writes past a gap`)
86
+ records.push({ t: 'log', row })
87
+ after = row.seq
88
+ // The commit row this log row wrote (a commit, or the rebased commit of a merge).
89
+ const id = row.kind === 'commit' || row.kind === 'merge' ? row.commit || row.commit_id : null
90
+ if (id) {
91
+ const c = await store.getCommit(q, id)
92
+ if (c && c.seq === row.seq) records.push({ t: 'commit', row: frozenCommit(c) })
93
+ }
94
+ }
95
+ }
96
+ // Every check run not yet in a segment (03); a run that changes later goes in again, and
97
+ // replay keeps the last copy of each run_key.
98
+ const runs = store.unarchivedCheckRuns ? await store.unarchivedCheckRuns(q) : []
99
+ for (const run of runs) records.push({ t: 'check_run', row: run })
100
+ const prev = quilt.last_segment || ZERO
101
+ const { bytes, trailer } = encodeSegment({ quilt_id: q, from_seq: from, to_seq: to, prev, records })
102
+ await objects.putFile(q, 'w', segmentName(from, to), bytes)
103
+ const r = await store.setArchive({ quilt_id: q, expect_seq: from - 1, to_seq: to, segment: trailer.blake3 })
104
+ if (runs.length) await store.markCheckRunsArchived({ quilt_id: q, runs: runs.map(({ run_key, state, verdict }) => ({ run_key, state, verdict: verdict ?? null })), at: now() })
105
+ return { ...trailer, archive_seq: r.archive_seq }
106
+ }
107
+
108
+ // One archiver per quilt at a time, across processes (03: "per quilt, under a lease"). A second
109
+ // one finds the lease held and writes nothing; CAS on archive_seq still guards a lease that lapsed.
110
+ async function archive (q, opts = {}) {
111
+ const lease = await store.takeLease({ kind: 'line', quilt_id: q, line: LEASE, holder, ttl_ms: leaseMs, at: now() })
112
+ if (!lease.held) {
113
+ const quilt = await store.getQuilt(q)
114
+ return { segments: [], archive_seq: quilt?.archive_seq || 0, leased_by: lease.holder }
115
+ }
116
+ try { return await archiveHeld(q, opts) } finally {
117
+ await store.giveLease({ kind: 'line', quilt_id: q, line: LEASE, holder }).catch(() => {})
118
+ }
119
+ }
120
+
121
+ /** Archiver lag (15): rows since the last segment and the age of the oldest of them; `alert` past 10,000 rows or 30 minutes. */
122
+ async function lag (q) {
123
+ const quilt = await store.getQuilt(q)
124
+ if (!quilt) throw new Error('no such quilt')
125
+ const done = quilt.archive_seq || 0
126
+ const waiting = Math.max(0, quilt.head_seq - done)
127
+ let ageMsOut = 0
128
+ if (waiting) { const [oldest] = await store.logRows(q, { after: done, limit: 1 }); if (oldest) ageMsOut = Math.max(0, now() - Number(oldest.at)) }
129
+ return { archive_seq: done, head_seq: quilt.head_seq, rows: waiting, age_ms: ageMsOut, alert: waiting > LAG_ROWS || ageMsOut > LAG_MS }
130
+ }
131
+
132
+ async function archiveHeld (q, { force = false } = {}) {
133
+ const segments = []
134
+ for (;;) {
135
+ const quilt = await store.getQuilt(q)
136
+ if (!quilt) throw new Error('no such quilt')
137
+ const done = quilt.archive_seq || 0
138
+ const waiting = quilt.head_seq - done
139
+ if (waiting <= 0) return { segments, archive_seq: done }
140
+ let due = force || waiting >= rows
141
+ if (!due) {
142
+ const [oldest] = await store.logRows(q, { after: done, limit: 1 })
143
+ due = !!oldest && now() - Number(oldest.at) >= ageMs
144
+ }
145
+ if (!due) return { segments, archive_seq: done }
146
+ try {
147
+ segments.push(await writeOne(q, quilt, done + Math.min(rows, waiting)))
148
+ } catch (err) {
149
+ if (err.code === 'archive_moved') continue // another archiver advanced it: read again
150
+ throw err
151
+ }
152
+ }
153
+ }
154
+
155
+ function maybe (q) {
156
+ if (running.has(q)) return running.get(q)
157
+ const p = archive(q).catch(() => null).then(() => lag(q)).then((l) => {
158
+ // The lag alert is logged once when it starts and once when it clears.
159
+ if (l.alert && !alerting.has(q)) { alerting.add(q); log(`seam: archiver lag alert on quilt ${q}: ${l.rows} rows, oldest ${Math.round(l.age_ms / 1000)} s`) }
160
+ if (!l.alert && alerting.delete(q)) log(`seam: archiver lag cleared on quilt ${q}`)
161
+ return l
162
+ }).catch(() => null).finally(() => running.delete(q))
163
+ running.set(q, p)
164
+ return p
165
+ }
166
+
167
+ return { archive, maybe, lag }
168
+ }
169
+
170
+ /**
171
+ * Replay (03's table, for the rows segments carry): reads a quilt's segments from object storage
172
+ * alone, checks the chain end to end (each segment's hash, `prev`, and seq continuity from 1), and
173
+ * rebuilds the log, the commit rows (with hidden_seq and patch derived again) and every line's tip.
174
+ */
175
+ export async function replaySegments (objects, q, { journal = false, recover = false } = {}) {
176
+ const names = await objects.listFiles(q, 'w')
177
+ const log = []
178
+ const commits = new Map()
179
+ const checkRuns = new Map()
180
+ const lines = new Map()
181
+ let prev = ZERO
182
+ let at = 0
183
+ for (const name of names) {
184
+ const { trailer, records } = decodeSegment(await objects.readFile(q, 'w', name))
185
+ if (trailer.quilt_id !== q) throw new Error(`segment ${name} belongs to quilt ${trailer.quilt_id}`)
186
+ if (trailer.prev !== prev) throw new Error(`segment ${name} does not follow the one before it (chain broken)`)
187
+ if (trailer.from_seq !== at + 1) throw new Error(`segment ${name} starts at ${trailer.from_seq}, expected ${at + 1} (a gap)`)
188
+ if (name !== segmentName(trailer.from_seq, trailer.to_seq)) throw new Error(`segment ${name} says it holds ${trailer.from_seq}-${trailer.to_seq}`)
189
+ for (const r of records) {
190
+ if (r.t === 'commit') { commits.set(r.row.id, { ...r.row, hidden_seq: null }); continue }
191
+ if (r.t === 'check_run') { checkRuns.set(r.row.run_key, r.row); continue }
192
+ const row = r.row
193
+ if (row.seq !== at + 1) throw new Error(`segment ${name}: row ${row.seq} out of order`)
194
+ at = row.seq
195
+ log.push(row)
196
+ }
197
+ if (at !== trailer.to_seq || log.length < trailer.row_count) throw new Error(`segment ${name} ends at ${at}, its trailer says ${trailer.to_seq}`)
198
+ prev = trailer.blake3
199
+ }
200
+ // Then the tip journal (journal.js): merge, move, square and line rows past the last segment.
201
+ // The log it leaves has gaps where `commit` rows were (15's recover-gap fills those).
202
+ let journalSeq = at
203
+ if (journal) {
204
+ for (const row of await readJournal(objects, q, at)) { log.push(row); journalSeq = row.seq }
205
+ }
206
+ for (const c of commits.values()) {
207
+ const p = c.predecessor && commits.get(c.predecessor)
208
+ if (p && p.hidden_seq == null) p.hidden_seq = c.seq
209
+ }
210
+ const squares = new Map()
211
+ for (const row of log) {
212
+ // The last `square` row per id names its state (03's replay table; `head` comes from its commits).
213
+ if (row.kind === 'square' && row.square) {
214
+ const sq = squares.get(row.square) || { id: row.square }
215
+ if (row.line) sq.line = row.line
216
+ if (row.payload?.title) sq.title = row.payload.title
217
+ if (row.payload?.op) sq.last_op = row.payload.op
218
+ if (row.payload?.op === 'open') { sq.opened_by = row.actor; sq.base = row.payload.base || null }
219
+ if (row.payload?.state) sq.state = row.payload.state
220
+ else if (row.payload?.op === 'merged') sq.state = 'merged'
221
+ sq.seq = row.seq
222
+ squares.set(row.square, sq)
223
+ continue
224
+ }
225
+ // A line made from a commit, and a line frozen or unfrozen (policy rows naming the line).
226
+ if (row.kind === 'policy' && row.line && row.payload) {
227
+ const op = row.payload.op
228
+ if (op === 'line') lines.set(row.line, { name: row.line, tip: row.payload.tip || null, tip_seq: row.seq, patch_count: 0, frozen: false })
229
+ else if ((op === 'freeze' || op === 'unfreeze') && lines.has(row.line)) lines.get(row.line).frozen = op === 'freeze'
230
+ continue
231
+ }
232
+ if (row.kind !== 'merge') continue
233
+ const id = row.commit || row.to || row.commit_id
234
+ lines.set(row.line, { ...lines.get(row.line), name: row.line, tip: id, tip_seq: row.seq, patch_count: row.patch_no })
235
+ const c = commits.get(id)
236
+ if (c) { c.patch = { line: row.line, patch_no: row.patch_no }; if (row.snapshot_root) c.snapshot_root = row.snapshot_root }
237
+ }
238
+ // Then recover-gap (recover.js): commits past the last segment, from the packs alone.
239
+ const recovered = recover ? await recoverGap(objects, q, commits) : []
240
+ return { archive_seq: at, journal_seq: journalSeq, last_segment: prev === ZERO ? null : prev, log, commits, lines, squares, recovered, checkRuns }
241
+ }