@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.
- package/bin/seam.js +118 -17
- package/lib/quilts/agent-tokens.js +148 -0
- package/lib/quilts/archiver.js +241 -0
- package/lib/quilts/attest.js +212 -0
- package/lib/quilts/bisect-run.js +3 -2
- package/lib/quilts/block.js +497 -55
- package/lib/quilts/cas.js +78 -0
- package/lib/quilts/cbor.js +2 -0
- package/lib/quilts/claims.js +409 -0
- package/lib/quilts/cli.js +612 -69
- package/lib/quilts/client.js +306 -19
- package/lib/quilts/conflict-format.js +266 -0
- package/lib/quilts/daemon.js +174 -0
- package/lib/quilts/describe.js +296 -38
- package/lib/quilts/dict.js +34 -0
- package/lib/quilts/exit-archive.js +184 -0
- package/lib/quilts/export-format.js +52 -0
- package/lib/quilts/export-git.js +27 -3
- package/lib/quilts/export-lfs.js +52 -0
- package/lib/quilts/export-sign.js +49 -0
- package/lib/quilts/fastcdc.js +186 -0
- package/lib/quilts/hash.js +17 -2
- package/lib/quilts/ignored.js +46 -0
- package/lib/quilts/import-git.js +54 -2
- package/lib/quilts/journal.js +105 -0
- package/lib/quilts/limits.js +197 -0
- package/lib/quilts/linepolicy.js +109 -0
- package/lib/quilts/pack.js +146 -18
- package/lib/quilts/persona.js +70 -0
- package/lib/quilts/records.js +7 -3
- package/lib/quilts/recover.js +37 -0
- package/lib/quilts/runner.js +79 -12
- package/lib/quilts/sealed.js +231 -0
- package/lib/quilts/secrets.js +86 -0
- package/lib/quilts/signing.js +7 -3
- package/lib/quilts/tarball.js +107 -0
- package/lib/quilts/textdiff.js +7 -5
- package/lib/quilts/tools.js +404 -83
- package/lib/quilts/tree.js +351 -0
- package/lib/quilts/want.js +51 -0
- package/lib/quilts/webhook.js +116 -0
- 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
|
|
35
|
-
seam
|
|
36
|
-
seam
|
|
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
|
|
44
|
-
seam
|
|
45
|
-
seam
|
|
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
|
|
49
|
-
seam
|
|
50
|
-
seam
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|