@manyoars/oars 0.4.1 → 0.5.1
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/README.md +79 -29
- package/dist/bin.js +3 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +206 -20
- package/dist/club.d.ts +19 -0
- package/dist/club.js +132 -0
- package/dist/gate/hook-fill-main.js +1 -1
- package/dist/gate/hook-fill.d.ts +1 -1
- package/dist/gate/hook-fill.js +1 -1
- package/dist/gate/hook-main.js +7 -5
- package/dist/gate/hook.d.ts +12 -1
- package/dist/gate/hook.js +38 -7
- package/dist/hook-bin.js +23 -4
- package/dist/main.js +5 -0
- package/dist/node-floor.d.ts +10 -0
- package/dist/node-floor.js +23 -0
- package/dist/oars-hook.d.ts +2 -0
- package/dist/oars-hook.js +7 -0
- package/dist/sessions/allow.d.ts +1 -0
- package/dist/sessions/allow.js +1 -1
- package/dist/sessions/call-shape.d.ts +111 -0
- package/dist/sessions/call-shape.js +464 -0
- package/dist/sessions/candidates.d.ts +65 -0
- package/dist/sessions/candidates.js +236 -0
- package/dist/sessions/card-command.d.ts +24 -0
- package/dist/sessions/card-command.js +71 -0
- package/dist/sessions/card-font.d.ts +6 -0
- package/dist/sessions/card-font.js +36 -0
- package/dist/sessions/card-line.d.ts +30 -0
- package/dist/sessions/card-line.js +105 -0
- package/dist/sessions/card-render.d.ts +15 -0
- package/dist/sessions/card-render.js +146 -0
- package/dist/sessions/card.d.ts +59 -0
- package/dist/sessions/card.js +107 -0
- package/dist/sessions/cell-increments.d.ts +14 -3
- package/dist/sessions/cell-increments.js +79 -12
- package/dist/sessions/cells.d.ts +42 -4
- package/dist/sessions/cells.js +72 -18
- package/dist/sessions/codex-hook-bin.d.ts +1 -0
- package/dist/sessions/codex-hook-bin.js +24 -0
- package/dist/sessions/codex-hook-install.d.ts +21 -0
- package/dist/sessions/codex-hook-install.js +47 -0
- package/dist/sessions/codex-hook-view.d.ts +20 -0
- package/dist/sessions/codex-hook-view.js +20 -0
- package/dist/sessions/codex-hook.d.ts +77 -0
- package/dist/sessions/codex-hook.js +255 -0
- package/dist/sessions/codex.d.ts +9 -0
- package/dist/sessions/codex.js +104 -18
- package/dist/sessions/command.d.ts +17 -2
- package/dist/sessions/command.js +145 -33
- package/dist/sessions/copilot.d.ts +3 -0
- package/dist/sessions/copilot.js +12 -4
- package/dist/sessions/derive.js +2 -1
- package/dist/sessions/empty-state.d.ts +17 -0
- package/dist/sessions/empty-state.js +38 -0
- package/dist/sessions/exclusion.js +48 -3
- package/dist/sessions/extract.d.ts +16 -3
- package/dist/sessions/extract.js +281 -24
- package/dist/sessions/gemini.d.ts +2 -0
- package/dist/sessions/gemini.js +29 -2
- package/dist/sessions/harness-files.d.ts +27 -0
- package/dist/sessions/harness-files.js +109 -0
- package/dist/sessions/head-only.d.ts +7 -0
- package/dist/sessions/head-only.js +21 -0
- package/dist/sessions/hook-calls.js +4 -3
- package/dist/sessions/hooks.d.ts +1 -1
- package/dist/sessions/hooks.js +6 -2
- package/dist/sessions/identity.d.ts +6 -0
- package/dist/sessions/injected.d.ts +13 -0
- package/dist/sessions/injected.js +212 -0
- package/dist/sessions/install.d.ts +31 -20
- package/dist/sessions/install.js +80 -82
- package/dist/sessions/load-status.d.ts +41 -0
- package/dist/sessions/load-status.js +156 -0
- package/dist/sessions/machine-child-deps.d.ts +5 -0
- package/dist/sessions/machine-child-deps.js +83 -0
- package/dist/sessions/machine-child.d.ts +43 -0
- package/dist/sessions/machine-child.js +120 -0
- package/dist/sessions/machine-credential.d.ts +10 -0
- package/dist/sessions/machine-credential.js +20 -0
- package/dist/sessions/machine-enroll.d.ts +47 -0
- package/dist/sessions/machine-enroll.js +230 -0
- package/dist/sessions/machine-hold.d.ts +49 -0
- package/dist/sessions/machine-hold.js +88 -0
- package/dist/sessions/machine-ids.d.ts +6 -0
- package/dist/sessions/machine-ids.js +9 -0
- package/dist/sessions/machine-notice.d.ts +21 -0
- package/dist/sessions/machine-notice.js +89 -0
- package/dist/sessions/machine-pull.d.ts +40 -0
- package/dist/sessions/machine-pull.js +220 -0
- package/dist/sessions/machine-repo.d.ts +7 -0
- package/dist/sessions/machine-repo.js +30 -0
- package/dist/sessions/machine-scan-cache.d.ts +12 -0
- package/dist/sessions/machine-scan-cache.js +47 -0
- package/dist/sessions/machine-send.d.ts +12 -0
- package/dist/sessions/machine-send.js +156 -0
- package/dist/sessions/machine-stop.d.ts +22 -0
- package/dist/sessions/machine-stop.js +53 -0
- package/dist/sessions/machine.d.ts +41 -0
- package/dist/sessions/machine.js +89 -0
- package/dist/sessions/opencode.d.ts +2 -0
- package/dist/sessions/opencode.js +6 -0
- package/dist/sessions/otel.d.ts +21 -0
- package/dist/sessions/otel.js +109 -38
- package/dist/sessions/owner-only.d.ts +7 -0
- package/dist/sessions/owner-only.js +34 -0
- package/dist/sessions/pi.d.ts +13 -1
- package/dist/sessions/pi.js +27 -13
- package/dist/sessions/pool-books.d.ts +1 -1
- package/dist/sessions/pool-books.js +1 -1
- package/dist/sessions/pool-choices.d.ts +4 -2
- package/dist/sessions/pool-choices.js +4 -2
- package/dist/sessions/pool-command.d.ts +46 -6
- package/dist/sessions/pool-command.js +577 -133
- package/dist/sessions/pool-delta.d.ts +7 -0
- package/dist/sessions/pool-delta.js +28 -5
- package/dist/sessions/pool-due.d.ts +43 -0
- package/dist/sessions/pool-due.js +113 -0
- package/dist/sessions/pool-nightly.d.ts +9 -1
- package/dist/sessions/pool-nightly.js +65 -21
- package/dist/sessions/pool-outbox.d.ts +51 -5
- package/dist/sessions/pool-outbox.js +217 -24
- package/dist/sessions/pool-print.d.ts +1 -1
- package/dist/sessions/pool-raw.js +4 -3
- package/dist/sessions/pool-replica.d.ts +4 -4
- package/dist/sessions/pool-replica.js +26 -7
- package/dist/sessions/pool-send-lock.js +1 -1
- package/dist/sessions/pool-sent.d.ts +2 -0
- package/dist/sessions/pool-sent.js +21 -4
- package/dist/sessions/pool-wire.d.ts +172 -12
- package/dist/sessions/pool-wire.js +284 -30
- package/dist/sessions/pool.js +4 -2
- package/dist/sessions/public-vocabulary.d.ts +5 -0
- package/dist/sessions/public-vocabulary.js +16 -0
- package/dist/sessions/read-call-shape.js +12 -6
- package/dist/sessions/read-fit.d.ts +5 -0
- package/dist/sessions/read-fit.js +23 -2
- package/dist/sessions/read.d.ts +1 -1
- package/dist/sessions/recall-consequence.d.ts +2 -2
- package/dist/sessions/recall-consequence.js +14 -9
- package/dist/sessions/recall-object.d.ts +9 -2
- package/dist/sessions/receipt.d.ts +8 -0
- package/dist/sessions/receipt.js +78 -0
- package/dist/sessions/recovery-calls.d.ts +3 -2
- package/dist/sessions/recovery-calls.js +9 -3
- package/dist/sessions/redact.d.ts +2 -2
- package/dist/sessions/redact.js +116 -14
- package/dist/sessions/reversion.d.ts +8 -0
- package/dist/sessions/reversion.js +114 -0
- package/dist/sessions/schema.d.ts +78 -4
- package/dist/sessions/schema.js +31 -1
- package/dist/sessions/session-shape.d.ts +2 -1
- package/dist/sessions/session-shape.js +15 -0
- package/dist/sessions/sources.d.ts +9 -0
- package/dist/sessions/sources.js +16 -5
- package/dist/sessions/steps.js +3 -2
- package/dist/sessions/store.d.ts +41 -2
- package/dist/sessions/store.js +68 -16
- package/dist/sessions/try-demo.d.ts +8 -0
- package/dist/sessions/try-demo.js +90 -0
- package/dist/sessions/try.d.ts +13 -0
- package/dist/sessions/try.js +50 -0
- package/dist/sessions/vscode-copilot.d.ts +4 -0
- package/dist/sessions/vscode-copilot.js +25 -18
- package/dist/telemetry/command.js +12 -6
- package/dist/telemetry/consent.d.ts +9 -3
- package/dist/telemetry/consent.js +25 -9
- package/dist/telemetry/fields.d.ts +11 -0
- package/dist/telemetry/fields.js +56 -0
- package/dist/telemetry/measures.d.ts +13 -0
- package/dist/telemetry/measures.js +30 -0
- package/dist/telemetry/observe.d.ts +15 -2
- package/dist/telemetry/observe.js +34 -7
- package/dist/telemetry/region.d.ts +6 -0
- package/dist/telemetry/region.js +28 -0
- package/dist/telemetry/report.js +1 -1
- package/dist/telemetry/schema.d.ts +3 -0
- package/dist/telemetry/schema.js +11 -1
- package/dist/telemetry/send.d.ts +5 -2
- package/dist/telemetry/send.js +15 -7
- package/dist/telemetry/words.d.ts +6 -0
- package/dist/telemetry/words.js +6 -0
- package/package.json +23 -4
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// The outbox (`oars pool deposit` and `oars pool outbox`): every batch that may leave this machine is first written here, as canonical JSON lines, and `deposit` stops. Nothing in this file
|
|
2
2
|
// looks inside a batch except to name its fields, at every depth, for `--explain`; what a batch holds is the projection's business.
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import {
|
|
5
|
-
import { join } from 'node:path';
|
|
4
|
+
import { readdir, readFile, rename, rm } from 'node:fs/promises';
|
|
5
|
+
import { basename, join } from 'node:path';
|
|
6
|
+
import { CliError, ExitCode } from '../errors.js';
|
|
7
|
+
import { appendOwnerOnly, mkdirOwnerOnly, writeOwnerOnly } from './owner-only.js';
|
|
6
8
|
import { SKELETON_FORMAT, VOCAB_VERSION } from './pool-wire.js';
|
|
7
9
|
import { contentHash } from './read.js';
|
|
8
10
|
/** What `JSON.stringify` writes for a value, or undefined for one JSON cannot hold (the lib types call this a string always). */
|
|
@@ -56,19 +58,28 @@ export const FIELD_ROWS = {
|
|
|
56
58
|
agentType: 'The type of the subagent whose run holds a step: a built-in type of the harness, else the word custom; null on a step of the main run.',
|
|
57
59
|
allocated: 'Whether the step\'s token figure was divided among the calls of one model response (true) rather than measured for the call alone; absent when measured. A flag only, never a count.',
|
|
58
60
|
argClass: 'The kinds of value a call\'s arguments held, a sorted set of a closed list: path, url, pattern, command, number, flag, text, and none; never a value.',
|
|
61
|
+
argFeatures: 'Each sent key\'s value features, sorted by key: the value\'s size bucket and, for a path, its depth, extension class, and folder kind, for a pattern what it uses; each from a closed list, never the value.',
|
|
62
|
+
argValues: 'Each sent key\'s closed value: a boolean as true or false, or a member of the tool\'s closed list for the key (an output mode, a built-in subagent type); a value off the list is not sent.',
|
|
63
|
+
arm: 'Whether a recommendation reached the asker or was withheld by the holdback, one of a closed list: shown or held. The local row\'s arm, never what was shown.',
|
|
59
64
|
cacheRead: 'A bucket of the tokens read from the cache in a prompt, a number, or null when the source never recorded it.',
|
|
60
65
|
cacheWrite: 'A bucket of the tokens written to the cache in a prompt, a number, or null when the source never recorded it.',
|
|
61
66
|
change: 'How far a delta moved, a signed bucket of its metric (a lower bound) in a number from -7 to 7 for calls per session and from -11 to 11 for thinking tokens; never the exact count.',
|
|
67
|
+
changeKind: 'What changed from the previous call of the same tool in the same prompt, a sorted set from a closed list: same, key-added, key-removed, feature-changed, value-changed, target-changed; never a value.',
|
|
68
|
+
claimed: 'The host class and launcher a trusted machine states for a batch it admitted from a held branch, from its own enrollment record; absent on a batch built and sent from the machine itself. Two closed lists, never a name.',
|
|
62
69
|
cls: 'The class of a prompt, one of a closed list of eight, computed from the kinds of tools it used; never its words.',
|
|
63
70
|
day: 'The UTC calendar day of the session as YYYY-MM-DD, or null; never a time of day.',
|
|
64
71
|
deltaKind: 'How a delta changed what the client ran: added, removed, or changed, one of a closed list the field allows; never a value.',
|
|
65
72
|
deltas: 'The changes the client found between its own before and after windows, each sent once with the session that filled the after window: a kind, a field, a name, a registry, a metric, and a bucketed change; never a session id, a root, a date, or an exact value.',
|
|
73
|
+
digests: 'The digest of the old and new text of an Edit (one pair), a MultiEdit (one pair per edit, in order), or a Write (a new digest only): the first 32 hex digits of the SHA-256 of the exact text, so a line ending is a difference and the same text gives the same digest in any session. Never the text.',
|
|
74
|
+
dirKind: 'The kind of the folders a path argument names, one of a closed list: root, source, test, doc, generated, hidden, other; never a folder name.',
|
|
66
75
|
durationMs: 'The time of one tool call in milliseconds, exact, a number or null.',
|
|
67
76
|
effort: 'The effort level the model ran at, only when it is a public one; else the literal placeholder.',
|
|
77
|
+
enumValue: 'A closed value of one argument: true, false, or a member of its tool\'s closed list for the key.',
|
|
68
78
|
errorCode: 'Why a tool call failed, one class of a closed list: an exit class (0, 1, 2, 126, 127, signal), an HTTP status class, a harness class (user-rejected, interrupted, permission-denied, input-validation, tool-use-error, timeout), or other; null on a call that did not fail or whose harness records no class. Never result text.',
|
|
69
79
|
editFollowed: 'Whether one of the calls that followed a failed call wrote a file, true or false: what followed, never that the failure was fixed.',
|
|
70
80
|
errors: 'A bucket of how many tool calls failed in a prompt, a number.',
|
|
71
81
|
explore: 'A bucket of how many steps of a prompt only looked (read a file, searched files, searched the web, or fetched a page), a number or null; never the exact count.',
|
|
82
|
+
extension: 'The class of a path argument\'s extension, one of a closed list: source, doc, config, data, image, none, other; never the extension or the name.',
|
|
72
83
|
field: 'What a delta is about, one of plugin, package, permissionMode, and effort: the field alone, never its value.',
|
|
73
84
|
flags: 'The flags of a shell command, each a flag name, never its value: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters. Capture still drops a subcommand or flag off the pinned vocabulary of its program, so a real session sends no off-list subcommand (null) and no off-list flag; a subcommand or flag the vocabulary lists goes as its name. A multi-letter single-dash flag no public list holds (`-race`) is dropped.',
|
|
74
85
|
fieldsVersion: 'The version of the field list this client builds a batch from: a constant of this client, never a value from a session.',
|
|
@@ -78,14 +89,17 @@ export const FIELD_ROWS = {
|
|
|
78
89
|
edges: 'The edges arriving at a step, each a kind and the position of the step it names, in the same transcript or, for a spawned edge, in the transcript that spawned this one; absent when none arrive.',
|
|
79
90
|
frames: 'The distinct frames a transcript\'s steps ran in, each as a frame above with the reach of the session; a step names one by position.',
|
|
80
91
|
frameworks: 'The frameworks of the project, each from a closed public list; never a path or a name of yours.',
|
|
92
|
+
from: 'The first call of a recommendation\'s window, a position in the main run\'s calls that are not sidechain, a number; with `to`, the calls a recommendation covers. No text and no time.',
|
|
81
93
|
harness: 'Which harness wrote the session, one of a closed list.',
|
|
82
94
|
harnessVersion: 'The harness version: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters. A transcript or prompt frame carries the value capture closed it to (the public name, else `<literal>`), sent as it is; the version of a call, which capture keeps as its line names it, goes as the string after the gate.',
|
|
83
95
|
host: 'The host of a web fetch or search, the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters; null when the step names no host. Capture keeps the host as the address names it, without credentials, path, or query. The pool replaces a host with a keyed hash on arrival and serves it only once enough accounts report it.',
|
|
96
|
+
hostClass: 'Where a session on a machine ran, one of a closed list: local, vps, cloud, ci, or hosted; stated by the trusted machine from its own enrollment record, never by the machine that built the batch.',
|
|
84
97
|
hour: 'The UTC hour of day, 0 to 23, beside the day; never a minute, a second, or an offset.',
|
|
85
|
-
id: "A hash the client computes per account from the account and the session, so a redeposit replaces and two accounts' sessions cannot be joined. Never the session id.",
|
|
98
|
+
id: "A hash the client computes per account from the account and the session (for a machine, from the account's session salt and the session's name), so a redeposit replaces and two accounts' sessions cannot be joined. Never the session id.",
|
|
86
99
|
identity: 'What a step called: a shell program with its subcommand and flags, an MCP server and tool, a skill, or a web host, each a name after redaction or `private`; null for an action with none.',
|
|
87
100
|
input: 'A bucket of the input tokens in a prompt, a number, or null when the source never recorded it.',
|
|
88
|
-
|
|
101
|
+
key: 'The key name an argument feature or closed value belongs to: one of the call\'s sent keys.',
|
|
102
|
+
kind: 'How an edge came about, one of a closed list: next, spawned, retried, or after-error; in a name token, what the name is, one of a closed list: file, dir, env, flag, or agent.',
|
|
89
103
|
keys: 'The input key names of a call, sorted: a public tool\'s from its published list and any other tool\'s only when each passes the name gate; none for a tool that went as private. Names only, never a value.',
|
|
90
104
|
keysDropped: 'A bucket of the key names of a call that were not sent, off a public tool\'s list or kept private by the gate, a number; never the names.',
|
|
91
105
|
intervention: 'Whether a human stepped in after a prompt\'s work and how, one of a closed list: none, after-error (the prompt\'s last call failed), redo (the next prompt edited the same file), revert (the next prompt undid the edit), user-stop (the human rejected a call or interrupted the turn), or unknown; absent when the prompt predates the signal. An outcome row, not a grade; never the next prompt\'s words.',
|
|
@@ -93,21 +107,33 @@ export const FIELD_ROWS = {
|
|
|
93
107
|
languageShare: 'How much of the files a session touched are in one language, one of four buckets: none, under 10 percent, 10 up to 50, or 50 and over; never a count of files.',
|
|
94
108
|
languageShares: 'The share bucket of each language of the project whose share is known; a language with no share is in the languages list alone.',
|
|
95
109
|
languages: 'The programming languages of the project, each from a closed public list.',
|
|
110
|
+
launcher: 'What started a session on a machine whose batch a trusted machine admitted, one of a closed list: interactive, orchestrated, scheduled, ci, hosted-run, or unknown; stated by the trusted machine from its own enrollment record, never by the machine that built the batch.',
|
|
96
111
|
leaves: 'How many lines of a transcript its root covers, a count.',
|
|
97
112
|
merkleRoot: 'The Merkle root of a transcript: a SHA-256 over the hashes of its lines, one root for each transcript, the main one first; never a line.',
|
|
98
113
|
metric: 'What a delta measures, calls per session or thinking tokens, one of a closed list the field fixes.',
|
|
99
114
|
model: 'The model name: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters. A transcript or prompt frame carries the value capture closed it to (the public name, else `<literal>`), sent as it is; the model of a call, which capture keeps as its line names it, goes as the string after the gate.',
|
|
100
115
|
ms: 'A bucket of the milliseconds a step took (a lower bound), a number or null; never the exact time.',
|
|
101
|
-
name: 'The name of a package a shell command pulled or a delta removed, as capture kept it: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters. A plugin in a delta is always private, and null when a delta names its field alone.',
|
|
116
|
+
name: 'The name of a package a shell command pulled or a delta removed, or a name token of a call (a file, folder, or variable name), as capture kept it: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters (a name token that fails is dropped and counted, never sent as `private`). A plugin in a delta is always private, and null when a delta names its field alone.',
|
|
102
117
|
native: 'The name the harness gave the tool: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters.',
|
|
118
|
+
names: 'The name tokens of a call\'s values, sorted by kind and name: file names, the names of the folders of a path below the session root, environment variable names, flag values that are names, and a custom agent\'s name, each with its kind. A name is sent as written, so it can identify a project; one the redactor changes, an exclusion term matches, or that is not a name is dropped; never a value, a path, or a command.',
|
|
119
|
+
namesDropped: 'A bucket of the name tokens of a call that were not sent, because the redactor changes them, an exclusion term matches them, or they are not names, a number; never the names.',
|
|
120
|
+
new: 'The digest of the text an edit wrote, 32 hex digits, in a digest pair; never the text.',
|
|
121
|
+
templates: 'One template per shell command of a call, in the order the commands run: its program\'s base name in lower case, without a Windows launcher suffix, its subcommand where the program\'s own public list has one, and its flags that are on the public list or one letter, as written, and every other word as a slot (`<text>`, `<path>`, `<ident>`, `<num>`, `<url>`, `<var>`, `<flag>`), so `git commit -m <text>` and `pytest <path> -k <ident>`; a program given as a path is `<path>`, and the word after a credential, text, or one-letter flag (but `-k` on pytest) is `<text>`. A kept word is sent as written, so it can identify a project; a template with a word the redactor changes, an exclusion term matches, or that is not a name is dropped whole; never an argument, a path, or a value.',
|
|
122
|
+
diffShape: 'What an edit did, a sorted set from a closed list: import-added, import-removed, identifier-renamed, lines-added, lines-removed, lines-changed, whitespace-only, file-written. The shape of the change, read on the machine from the old and new text; never the text, a line, or a name.',
|
|
123
|
+
templatesDropped: 'A bucket of the templates of a call that were not sent, because a program, subcommand, or flag in them is changed by the redactor, matched by an exclusion term, or not a name, a number; never the templates.',
|
|
103
124
|
next: 'The actions of up to the next 5 calls of the same prompt after a failed call, each one of the closed list of canonical actions: what followed, never a fix; no command, path, or text.',
|
|
125
|
+
old: 'The digest of the text an edit replaced, 32 hex digits, in a digest pair; absent for a Write, whose call does not hold the file\'s earlier text, and never the digest of an empty string.',
|
|
104
126
|
output: 'A bucket of the output tokens in a prompt, a number, or null when the source never recorded it.',
|
|
105
127
|
parent: 'The position of the call that spawned this step, in the tools of the transcript that spawnedIn names, a number, or null.',
|
|
128
|
+
pathDepth: 'How many folders a path argument sits below the repository or the working folder, a bucket: 0, 1, 2, 3-4, 5-8, over-8, or outside for an absolute path under neither; never the path.',
|
|
129
|
+
patternFeatures: 'What a pattern argument uses, a sorted set from a closed list: alternation, anchor, class, escape, group, literal, quantifier, recursive, wildcard; never the pattern.',
|
|
106
130
|
platform: 'The operating system family, one of a closed list.',
|
|
131
|
+
poolVersion: 'The deposit time of the pool snapshot a recommendation answered from, as the local row holds it.',
|
|
107
132
|
program: 'The program a shell command ran, its basename only: the name as it is after redaction; `private` when redaction changes it, an exclusion term matches it, it is not a name, or it is over 128 characters.',
|
|
108
133
|
prompt: 'Which prompt of the batch a tool call follows, a position in this batch, or null.',
|
|
109
134
|
prompts: 'The prompts of a transcript: each a class, bucketed counts and tokens, and a frame; never their words.',
|
|
110
135
|
pulls: 'The packages a shell command pulled from a registry: the registry, the public package name, and its version.',
|
|
136
|
+
recommendations: 'The recommendations the main session was shown or held back from, each an id, an arm, the pool version it answered from, and a window of calls; never the query, the text shown, or the evidence. Absent when the session had none, which the pool reads as unknown exposure, never as unexposed.',
|
|
111
137
|
recoveryCalls: 'For a failed call, what followed it: the actions of up to its next 5 calls and whether one wrote a file. Absent on a call that did not fail; never a signature or text.',
|
|
112
138
|
registry: 'Which registry a pull or a removed package came from, one of a closed list; null for a delta whose name is private or absent.',
|
|
113
139
|
remainingTokens: 'A bucket of the tokens the run spent after a step (a lower bound), a number, or null when steps follow and none reported tokens: the bucket of the exact sum, with the subagent runs a main-run step spawned later in the session.',
|
|
@@ -133,12 +159,14 @@ export const FIELD_ROWS = {
|
|
|
133
159
|
target: 'The position of the step an edge names, a number counted over the steps of one transcript in call order.',
|
|
134
160
|
targetClass: 'The class of the path a call touched, one of a closed list: source, test, config, doc, lockfile, generated, data, none, other; never the path or its name.',
|
|
135
161
|
timingPrint: 'A hash of the call durations of the main transcript in the order they ran, or null under 50 timed calls; the calls of a subagent are not in it.',
|
|
162
|
+
to: 'The end of a recommendation\'s window, one past its last call, a position in the main run\'s calls that are not sidechain, a number; with `from`, the calls a recommendation covers.',
|
|
136
163
|
tokens: 'A bucket of tokens (a lower bound of the count), a number or null; a step holds one, never the exact count.',
|
|
137
164
|
tool: 'The tool of an MCP call: the name as it is after redaction, beside a server that is not private and in a harness whose spelling delimits the server; else the literal placeholder.',
|
|
138
165
|
tools: 'The tool calls of a transcript, each a status and duration and its steps, in the order they ran; never their targets or arguments.',
|
|
139
166
|
transcripts: 'The main transcript, then each subagent: its frame, prompts, and tool calls, every value checked against its field\'s list or the name shape before the batch is built.',
|
|
140
167
|
usage: 'The tokens of a prompt as buckets (lower bounds): input, cache write, cache read, output, and thinking.',
|
|
141
168
|
usageResponses: 'A bucket of the responses of a prompt that reported usage, what a partial usage sum covers; left out only for a record captured before the coverage existed (capture version 20).',
|
|
169
|
+
valueSize: 'The size of an argument\'s value as written, a bucket of its length: 0, 1-8, 9-32, 33-128, 129-512, 513-2k, 2k-8k, over-8k; never the value.',
|
|
142
170
|
version: 'The version of a pulled package, only when it is a registry version; else the literal placeholder or null.',
|
|
143
171
|
vocabVersion: 'The version of the closed vocabulary this client built the batch under: a constant of this client, never a value from a session.',
|
|
144
172
|
wallSec: 'A bucket of the seconds a prompt took, or null.',
|
|
@@ -159,13 +187,13 @@ export const writeBatch = async (outbox, key, batch, stamp = currentStamp()) =>
|
|
|
159
187
|
const bytes = canonicalBytes(batch);
|
|
160
188
|
const hash = batchHash(bytes);
|
|
161
189
|
const folder = join(outbox, stamp);
|
|
162
|
-
await
|
|
190
|
+
await mkdirOwnerOnly(folder);
|
|
163
191
|
for (const name of await readdir(folder)) {
|
|
164
192
|
const found = BATCH_FILE.exec(name);
|
|
165
193
|
if (found?.[1] === key && found[2] !== hash)
|
|
166
194
|
await rm(join(folder, name), { force: true });
|
|
167
195
|
}
|
|
168
|
-
await
|
|
196
|
+
await writeOwnerOnly(join(folder, `${key}.${hash}.jsonl`), bytes);
|
|
169
197
|
return { hash, folder, bytes: Buffer.byteLength(bytes) };
|
|
170
198
|
};
|
|
171
199
|
export const isMissing = (error) => error instanceof Error && 'code' in error && error.code === 'ENOENT';
|
|
@@ -186,9 +214,12 @@ const namesIn = async (folder) => {
|
|
|
186
214
|
export const removeBatchesExcept = async (outbox, kept, stamp = currentStamp(), only) => {
|
|
187
215
|
const folder = join(outbox, stamp);
|
|
188
216
|
let removed = 0;
|
|
189
|
-
|
|
217
|
+
const names = await namesIn(folder);
|
|
218
|
+
// A reader is asked once the names are in hand: a key it reads then covers every batch listed above.
|
|
219
|
+
const keep = typeof kept === 'function' ? await kept() : kept;
|
|
220
|
+
for (const name of names) {
|
|
190
221
|
const found = BATCH_FILE.exec(name);
|
|
191
|
-
if (found !== null && !
|
|
222
|
+
if (found !== null && !keep.has(found[1] ?? '') && (only === undefined || found[1] === only)) {
|
|
192
223
|
await rm(join(folder, name), { force: true });
|
|
193
224
|
removed += 1;
|
|
194
225
|
}
|
|
@@ -210,9 +241,14 @@ export const unchangedBytes = async (file, hash) => {
|
|
|
210
241
|
}
|
|
211
242
|
return batchHash(bytes) === hash ? bytes : undefined;
|
|
212
243
|
};
|
|
213
|
-
/**
|
|
244
|
+
/**
|
|
245
|
+
* The batches filed under this stamp, and how many of their files no longer hash to their names. An edited file is not printed and cannot be sent: run `deposit` again to rebuild it. Under the current stamp that is every batch this client built;
|
|
246
|
+
* under any other stamp it is only a batch `pulled.json` lists there, since anything else in that folder is stale and is never printed or sent.
|
|
247
|
+
*/
|
|
214
248
|
export const heldBatches = async (outbox, stamp = currentStamp()) => {
|
|
215
249
|
const folder = join(outbox, stamp);
|
|
250
|
+
const listed = await pulledAt(outbox, stamp);
|
|
251
|
+
const here = stamp === currentStamp();
|
|
216
252
|
const batches = [];
|
|
217
253
|
let edited = 0;
|
|
218
254
|
for (const name of await namesIn(folder)) {
|
|
@@ -220,20 +256,72 @@ export const heldBatches = async (outbox, stamp = currentStamp()) => {
|
|
|
220
256
|
if (found === null)
|
|
221
257
|
continue;
|
|
222
258
|
const [, key = '', hash = ''] = found;
|
|
259
|
+
if (!here && !listed.has(key))
|
|
260
|
+
continue;
|
|
223
261
|
const file = join(folder, name);
|
|
224
262
|
const bytes = await unchangedBytes(file, hash);
|
|
225
263
|
if (bytes === undefined)
|
|
226
264
|
edited += 1;
|
|
227
265
|
else
|
|
228
|
-
batches.push({ key, hash, file, bytes });
|
|
266
|
+
batches.push({ key, stamp, pulled: listed.has(key), hash, file, bytes });
|
|
229
267
|
}
|
|
230
268
|
return { batches, edited };
|
|
231
269
|
};
|
|
270
|
+
/** The stamps a send reads: the current one, then every other stamp `pulled.json` lists. A listed name that is not a stamp folder is not a path this reads. */
|
|
271
|
+
export const sendStamps = async (outbox) => {
|
|
272
|
+
const others = new Set(Object.values(await readPulled(outbox, POOL_STEP)).filter((stamp) => stamp !== currentStamp() && STAMP_FOLDER.test(stamp)));
|
|
273
|
+
return [currentStamp(), ...[...others].sort()];
|
|
274
|
+
};
|
|
275
|
+
/** `heldBatches` over every stamp a send reads, the current one first. */
|
|
276
|
+
export const heldAcross = async (outbox) => {
|
|
277
|
+
const all = [];
|
|
278
|
+
let edited = 0;
|
|
279
|
+
for (const stamp of await sendStamps(outbox)) {
|
|
280
|
+
const held = await heldBatches(outbox, stamp);
|
|
281
|
+
all.push(...held.batches);
|
|
282
|
+
edited += held.edited;
|
|
283
|
+
}
|
|
284
|
+
return { batches: all, edited };
|
|
285
|
+
};
|
|
286
|
+
/** The held batch of one session under this stamp, read whole only when its file still hashes to its name; undefined when the session has none, or its file was edited. */
|
|
287
|
+
export const heldBatchOf = async (outbox, key, stamp = currentStamp()) => {
|
|
288
|
+
const folder = join(outbox, stamp);
|
|
289
|
+
for (const name of await namesIn(folder)) {
|
|
290
|
+
const found = BATCH_FILE.exec(name);
|
|
291
|
+
if (found?.[1] !== key)
|
|
292
|
+
continue;
|
|
293
|
+
const hash = found[2] ?? '';
|
|
294
|
+
const file = join(folder, name);
|
|
295
|
+
const bytes = await unchangedBytes(file, hash);
|
|
296
|
+
if (bytes !== undefined)
|
|
297
|
+
return { key, hash, file, bytes };
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
};
|
|
301
|
+
const machineKeysFile = (outbox) => join(outbox, 'machine-keys.log');
|
|
302
|
+
/** The batch keys a machine's Stop built, one per line. A person's `oars pool deposit` shares the outbox and cannot recompute them (the machine's name is in the key), so its sweep keeps these instead of treating them as batches of sessions the store lost. */
|
|
303
|
+
export const machineKeys = async (outbox) => {
|
|
304
|
+
try {
|
|
305
|
+
return new Set((await readFile(machineKeysFile(outbox), 'utf8')).split('\n').filter((line) => line !== ''));
|
|
306
|
+
}
|
|
307
|
+
catch (error) {
|
|
308
|
+
if (isAbsent(error))
|
|
309
|
+
return new Set();
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
};
|
|
313
|
+
/** Notes that a machine built this key; a key already noted adds no line. */
|
|
314
|
+
export const noteMachineKey = async (outbox, key) => {
|
|
315
|
+
if ((await machineKeys(outbox)).has(key))
|
|
316
|
+
return;
|
|
317
|
+
await mkdirOwnerOnly(outbox);
|
|
318
|
+
await appendOwnerOnly(machineKeysFile(outbox), `${key}\n`);
|
|
319
|
+
};
|
|
232
320
|
const shownFile = (outbox) => join(outbox, 'shown.log');
|
|
233
321
|
/** Records that this hash was printed, one line for each print: the lock below sends only a hash that appears here. */
|
|
234
322
|
export const markShown = async (outbox, hash, at) => {
|
|
235
|
-
await
|
|
236
|
-
await
|
|
323
|
+
await mkdirOwnerOnly(outbox);
|
|
324
|
+
await appendOwnerOnly(shownFile(outbox), `${hash} ${new Date(at).toISOString()}\n`);
|
|
237
325
|
};
|
|
238
326
|
const wasShown = async (outbox, hash) => {
|
|
239
327
|
try {
|
|
@@ -246,12 +334,18 @@ const wasShown = async (outbox, hash) => {
|
|
|
246
334
|
}
|
|
247
335
|
};
|
|
248
336
|
/**
|
|
249
|
-
* The lock on a send: a batch goes only if it is in the outbox, its hash was printed, and its file still hashes to that hash. The first check that fails names why. The nightly
|
|
337
|
+
* The lock on a send: a batch goes only if it is in the outbox, its hash was printed, and its file still hashes to that hash. The first check that fails names why. The detached sender (hook-bin.js nightly) sends with `requireShown: false`,
|
|
250
338
|
* because covenant acceptance is its consent and it prints nothing; the other two checks still hold.
|
|
251
339
|
*/
|
|
252
340
|
export const lockedBatch = async (outbox, hash, options = {}) => {
|
|
253
|
-
const
|
|
254
|
-
const
|
|
341
|
+
const stamp = options.stamp ?? currentStamp();
|
|
342
|
+
const folder = join(outbox, stamp);
|
|
343
|
+
// Under any stamp but the current one, only a pulled batch is a batch: the rest of that folder is stale.
|
|
344
|
+
const listed = stamp === currentStamp() ? undefined : await pulledAt(outbox, stamp);
|
|
345
|
+
const name = (await namesIn(folder)).find((entry) => {
|
|
346
|
+
const found = BATCH_FILE.exec(entry);
|
|
347
|
+
return found?.[2] === hash && (listed === undefined || listed.has(found[1] ?? ''));
|
|
348
|
+
});
|
|
255
349
|
if (name === undefined)
|
|
256
350
|
return { ok: false, code: 'batch_unknown' };
|
|
257
351
|
if (options.requireShown !== false && !(await wasShown(outbox, hash)))
|
|
@@ -260,6 +354,83 @@ export const lockedBatch = async (outbox, hash, options = {}) => {
|
|
|
260
354
|
const bytes = await unchangedBytes(file, hash);
|
|
261
355
|
return bytes === undefined ? { ok: false, code: 'batch_changed' } : { ok: true, file, bytes };
|
|
262
356
|
};
|
|
357
|
+
const PULLED = 'pulled.json';
|
|
358
|
+
/** `pulled.json` as it stands; none when the file is missing. A file that is not a list of ids and stamps fails the command that reads it, so nothing is pruned or sent on a guess. */
|
|
359
|
+
export const readPulled = async (outbox, step) => {
|
|
360
|
+
let text;
|
|
361
|
+
try {
|
|
362
|
+
text = await readFile(join(outbox, PULLED), 'utf8');
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
if (isMissing(error))
|
|
366
|
+
return {};
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
let value;
|
|
370
|
+
try {
|
|
371
|
+
value = JSON.parse(text);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
value = undefined;
|
|
375
|
+
}
|
|
376
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value) || !Object.values(value).every((stamp) => typeof stamp === 'string')) {
|
|
377
|
+
throw new CliError(`${join(outbox, PULLED)} is not a list of ids and stamps. Move it aside and run the command again; nothing was changed.`, ExitCode.INVALID, step);
|
|
378
|
+
}
|
|
379
|
+
return Object.fromEntries(Object.entries(value).map(([id, stamp]) => [id, String(stamp)]));
|
|
380
|
+
};
|
|
381
|
+
/** Staged and renamed, so a crash leaves the whole old file or the whole new one. */
|
|
382
|
+
export const writePulled = async (outbox, pulled) => {
|
|
383
|
+
await mkdirOwnerOnly(outbox);
|
|
384
|
+
const staged = join(outbox, `${PULLED}.tmp`);
|
|
385
|
+
await writeOwnerOnly(staged, `${JSON.stringify(Object.fromEntries(Object.entries(pulled).sort(([a], [b]) => (a < b ? -1 : 1))), null, 2)}\n`);
|
|
386
|
+
await rename(staged, join(outbox, PULLED));
|
|
387
|
+
};
|
|
388
|
+
const POOL_STEP = 'pool-outbox';
|
|
389
|
+
/** Whether any string or key in `value` holds one of `terms`, compared lowercased the way the local gate compares a transcript. */
|
|
390
|
+
export const holdsTerm = (value, terms) => {
|
|
391
|
+
if (typeof value === 'string') {
|
|
392
|
+
const lower = value.toLowerCase();
|
|
393
|
+
return terms.some((term) => lower.includes(term));
|
|
394
|
+
}
|
|
395
|
+
if (Array.isArray(value))
|
|
396
|
+
return value.some((entry) => holdsTerm(entry, terms));
|
|
397
|
+
if (typeof value === 'object' && value !== null)
|
|
398
|
+
return Object.entries(value).some(([key, entry]) => holdsTerm(key, terms) || holdsTerm(entry, terms));
|
|
399
|
+
return false;
|
|
400
|
+
};
|
|
401
|
+
/**
|
|
402
|
+
* The exclusion rescan of a send pass: every pulled batch is read against the trusted machine's current term file, and one that now holds a term is removed, with its `pulled.json` entry, and its hash returned. A batch can wait as long as the covenant
|
|
403
|
+
* stays closed, so the terms it was admitted under may not be the terms now. With no terms (a missing or empty file) nothing can be checked: `open` is false, nothing is removed, and the caller sends no pulled batch.
|
|
404
|
+
*/
|
|
405
|
+
export const rescanPulled = async (outbox, terms) => {
|
|
406
|
+
if (terms.length === 0)
|
|
407
|
+
return { open: false, removed: [] };
|
|
408
|
+
const pulled = await readPulled(outbox, POOL_STEP);
|
|
409
|
+
const removed = [];
|
|
410
|
+
for (const [id, stamp] of Object.entries(pulled)) {
|
|
411
|
+
const held = STAMP_FOLDER.test(stamp) ? await heldBatchOf(outbox, id, stamp) : undefined;
|
|
412
|
+
// The file is canonical JSON this client wrote, and its bytes just hashed to its name.
|
|
413
|
+
if (held === undefined || !holdsTerm(JSON.parse(held.bytes), terms))
|
|
414
|
+
continue;
|
|
415
|
+
await rm(held.file, { force: true });
|
|
416
|
+
Reflect.deleteProperty(pulled, id);
|
|
417
|
+
removed.push(held.hash);
|
|
418
|
+
}
|
|
419
|
+
if (removed.length > 0)
|
|
420
|
+
await writePulled(outbox, pulled);
|
|
421
|
+
return { open: true, removed };
|
|
422
|
+
};
|
|
423
|
+
/** The keys `pulled.json` lists at this stamp: the batches of that folder that no local build made and no stale pass may touch. */
|
|
424
|
+
export const pulledAt = async (outbox, stamp = currentStamp()) => new Set(Object.entries(await readPulled(outbox, POOL_STEP)).filter(([, at]) => at === stamp).map(([id]) => id));
|
|
425
|
+
/** `lockedBatch` over every stamp a send reads: the first stamp that holds the hash answers, whether it locks or refuses. */
|
|
426
|
+
export const lockedAcross = async (outbox, hash, options = {}) => {
|
|
427
|
+
for (const stamp of await sendStamps(outbox)) {
|
|
428
|
+
const locked = await lockedBatch(outbox, hash, { ...options, stamp });
|
|
429
|
+
if (locked.ok || locked.code !== 'batch_unknown')
|
|
430
|
+
return locked;
|
|
431
|
+
}
|
|
432
|
+
return { ok: false, code: 'batch_unknown' };
|
|
433
|
+
};
|
|
263
434
|
/** Folders of an outbox other than the current stamp's: batches built under another vocabulary, field list, or skeleton format. */
|
|
264
435
|
const staleFolders = async (outbox, stamp) => {
|
|
265
436
|
const names = [];
|
|
@@ -269,13 +440,21 @@ const staleFolders = async (outbox, stamp) => {
|
|
|
269
440
|
}
|
|
270
441
|
return names;
|
|
271
442
|
};
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
/**
|
|
443
|
+
/** The name of a stamp folder: a vocabulary version, then eight hex digits of the field list and format. No dot or slash, so a name that matches is one path segment. */
|
|
444
|
+
export const STAMP_FOLDER = /^v[^./]+-[0-9a-f]{8}$/;
|
|
445
|
+
/** The keys of the batch files in one folder. */
|
|
446
|
+
const keysIn = async (folder) => (await namesIn(folder)).flatMap((name) => BATCH_FILE.exec(name)?.[1] ?? []);
|
|
447
|
+
/** The batch files of one stale folder, and which of them a stale pass may touch: a key `pulled.json` lists at this folder's stamp is spared, by key and not by folder, so a local batch sharing the folder is still stale. */
|
|
448
|
+
const staleIn = async (folder, pulled) => {
|
|
449
|
+
const stamp = basename(folder);
|
|
450
|
+
return (await keysIn(folder)).filter((key) => pulled[key] !== stamp);
|
|
451
|
+
};
|
|
452
|
+
/** Batches in one server's outbox that were built under another vocabulary, field list, or skeleton format: never printed, never sent. A pulled batch is not one of them, at the stamp it is listed under. */
|
|
275
453
|
export const staleBatches = async (outbox, stamp = currentStamp()) => {
|
|
454
|
+
const pulled = await readPulled(outbox, POOL_STEP);
|
|
276
455
|
let count = 0;
|
|
277
456
|
for (const folder of await staleFolders(outbox, stamp))
|
|
278
|
-
count += await
|
|
457
|
+
count += (await staleIn(folder, pulled)).length;
|
|
279
458
|
return count;
|
|
280
459
|
};
|
|
281
460
|
/** Every server's stale batches, for `verify`. */
|
|
@@ -286,11 +465,25 @@ export const staleBatchesAnywhere = async (storeDir, stamp = currentStamp()) =>
|
|
|
286
465
|
count += await staleBatches(join(root, name), stamp);
|
|
287
466
|
return count;
|
|
288
467
|
};
|
|
289
|
-
/** Removes one server's stale batches, and returns how many there were. */
|
|
468
|
+
/** Removes one server's stale batches, and returns how many there were. A folder that holds a pulled batch loses only the files of the others; one that holds none goes whole. */
|
|
290
469
|
export const discardStale = async (outbox, stamp = currentStamp()) => {
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
470
|
+
const pulled = await readPulled(outbox, POOL_STEP);
|
|
471
|
+
let count = 0;
|
|
472
|
+
for (const folder of await staleFolders(outbox, stamp)) {
|
|
473
|
+
const listed = Object.values(pulled).includes(basename(folder));
|
|
474
|
+
if (!listed) {
|
|
475
|
+
count += (await keysIn(folder)).length;
|
|
476
|
+
await rm(folder, { recursive: true, force: true });
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const stale = await staleIn(folder, pulled);
|
|
480
|
+
for (const name of await namesIn(folder)) {
|
|
481
|
+
const found = BATCH_FILE.exec(name);
|
|
482
|
+
if (found === null || stale.includes(found[1] ?? ''))
|
|
483
|
+
await rm(join(folder, name), { recursive: true, force: true });
|
|
484
|
+
}
|
|
485
|
+
count += stale.length;
|
|
486
|
+
}
|
|
294
487
|
return count;
|
|
295
488
|
};
|
|
296
489
|
const collect = (value, into) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** One request as it will leave this machine. */
|
|
2
2
|
export interface Outgoing {
|
|
3
|
-
method: 'GET' | 'POST' | 'PUT';
|
|
3
|
+
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
4
4
|
url: URL;
|
|
5
5
|
/** Every header this code sets. The HTTP layer adds `host`, `connection`, and `content-length` itself, and `printed` lists those too. */
|
|
6
6
|
headers: Array<[string, string]>;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// The raw part of the outbox (t884): one redacted copy of each transcript of a session that passed the gates, in the same outbox as the batches and printed by the same command. A copy is named for its
|
|
2
2
|
// session key, its place among the session's transcripts (0 is the main one), and the SHA-256 of its redacted bytes, which is the hash the pool's `/v1/raw/:sha256` routes take.
|
|
3
3
|
import { createHash } from 'node:crypto';
|
|
4
|
-
import {
|
|
4
|
+
import { readdir, readFile, rm } from 'node:fs/promises';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
+
import { mkdirOwnerOnly, writeOwnerOnly } from './owner-only.js';
|
|
6
7
|
import { partsOf } from './pool-outbox.js';
|
|
7
8
|
import { REDACTION_VERSION } from './redact.js';
|
|
8
9
|
import { CAPTURE_VERSION } from './schema.js';
|
|
@@ -24,13 +25,13 @@ const namesIn = async (folder) => {
|
|
|
24
25
|
export const writeRaw = async (outbox, key, place, bytes) => {
|
|
25
26
|
const folder = join(outbox, rawStamp());
|
|
26
27
|
const hash = rawHash(bytes);
|
|
27
|
-
await
|
|
28
|
+
await mkdirOwnerOnly(folder);
|
|
28
29
|
for (const name of await namesIn(folder)) {
|
|
29
30
|
const found = RAW_FILE.exec(name);
|
|
30
31
|
if (found?.[1] === key && Number(found[2]) === place && found[3] !== hash)
|
|
31
32
|
await rm(join(folder, name), { force: true });
|
|
32
33
|
}
|
|
33
|
-
await
|
|
34
|
+
await writeOwnerOnly(join(folder, `${key}.${String(place)}.${hash}.redacted`), bytes);
|
|
34
35
|
return { hash, bytes: Buffer.byteLength(bytes) };
|
|
35
36
|
};
|
|
36
37
|
/** The raw copies under the current stamp that still hash to their names; `edited` counts those that no longer do, which are neither printed nor sent. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Bundle, type
|
|
1
|
+
import { type Bundle, type PinnedKeys } from './pool-wire.js';
|
|
2
2
|
import type { Harness } from './schema.js';
|
|
3
3
|
export declare const textIn: (value: object, key: string) => string | undefined;
|
|
4
4
|
export declare const numberIn: (value: object, key: string) => number | undefined;
|
|
@@ -9,8 +9,8 @@ export interface SignIn {
|
|
|
9
9
|
account: string;
|
|
10
10
|
token: string;
|
|
11
11
|
expiresAt: number;
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/** The keys pinned for this server, by key id: each is trusted at the first login that names its id and never replaced, and every bundle is checked against the one its `kid` names. */
|
|
13
|
+
signingKeys: PinnedKeys;
|
|
14
14
|
}
|
|
15
15
|
export declare const signInOf: (value: unknown) => SignIn | undefined;
|
|
16
16
|
export declare const signInsFile: (storeDir: string) => string;
|
|
@@ -19,7 +19,7 @@ export declare const loadSignIns: (storeDir: string, step: string) => Promise<Si
|
|
|
19
19
|
/** One folder per harness under the server's, so a fetch for one harness never overwrites another's bundle of the same version. */
|
|
20
20
|
export declare const replicaFile: (storeDir: string, server: string, harness: Harness, harnessVersion: string) => string;
|
|
21
21
|
/** What an earlier fetch kept: the bundle, checked again against the pinned key, and the ETag the pool sent with it. Nothing readable and verified is nothing kept, and the next fetch is then an ordinary one. */
|
|
22
|
-
export declare const keptReplica: (file: string,
|
|
22
|
+
export declare const keptReplica: (file: string, keys: PinnedKeys, harness: Harness, harnessVersion: string) => Promise<{
|
|
23
23
|
etag: string | undefined;
|
|
24
24
|
bundle: Bundle;
|
|
25
25
|
} | undefined>;
|
|
@@ -5,7 +5,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { CliError, ExitCode } from '../errors.js';
|
|
7
7
|
import { isMissing } from './pool-outbox.js';
|
|
8
|
-
import { isBundleVersion, verifyBundle } from './pool-wire.js';
|
|
8
|
+
import { isBundleVersion, isKeyId, LEGACY_KEY_ID, verifyBundle } from './pool-wire.js';
|
|
9
9
|
import { contentHash } from './read.js';
|
|
10
10
|
export const textIn = (value, key) => {
|
|
11
11
|
const found = Reflect.get(value, key);
|
|
@@ -22,12 +22,31 @@ const keyOf = (value) => {
|
|
|
22
22
|
const [kty, crv, x] = [textIn(value, 'kty'), textIn(value, 'crv'), textIn(value, 'x')];
|
|
23
23
|
return kty === 'OKP' && crv === 'Ed25519' && x !== undefined ? { kty, crv, x } : undefined;
|
|
24
24
|
};
|
|
25
|
+
/** Keys as a sign-in file or the pool's login answer states them: a `signingKeys` object by key id, or the one `signingKey` the pool names with `kid` (a `kid` that is no key id, as the pool sends them, is refused; a file from before key ids has no `kid`, and pins under `LEGACY_KEY_ID`). */
|
|
26
|
+
const keysOf = (value) => {
|
|
27
|
+
const set = Reflect.get(value, 'signingKeys');
|
|
28
|
+
if (set === undefined) {
|
|
29
|
+
const single = keyOf(Reflect.get(value, 'signingKey'));
|
|
30
|
+
const kid = textIn(value, 'kid') ?? LEGACY_KEY_ID;
|
|
31
|
+
return single === undefined || !isKeyId(kid) ? undefined : { [kid]: single };
|
|
32
|
+
}
|
|
33
|
+
if (!isObject(set))
|
|
34
|
+
return undefined;
|
|
35
|
+
const keys = {};
|
|
36
|
+
for (const [kid, entry] of Object.entries(set)) {
|
|
37
|
+
const key = keyOf(entry);
|
|
38
|
+
if (key === undefined || !isKeyId(kid))
|
|
39
|
+
return undefined;
|
|
40
|
+
keys[kid] = key;
|
|
41
|
+
}
|
|
42
|
+
return Object.keys(keys).length === 0 ? undefined : keys;
|
|
43
|
+
};
|
|
25
44
|
export const signInOf = (value) => {
|
|
26
45
|
if (!isObject(value))
|
|
27
46
|
return undefined;
|
|
28
47
|
const [server, account, token, expiresAt] = [textIn(value, 'server'), textIn(value, 'account'), textIn(value, 'token'), numberIn(value, 'expiresAt')];
|
|
29
|
-
const
|
|
30
|
-
return server === undefined || account === undefined || token === undefined || expiresAt === undefined ||
|
|
48
|
+
const signingKeys = keysOf(value);
|
|
49
|
+
return server === undefined || account === undefined || token === undefined || expiresAt === undefined || signingKeys === undefined ? undefined : { server, account, token, expiresAt, signingKeys };
|
|
31
50
|
};
|
|
32
51
|
export const signInsFile = (storeDir) => join(storeDir, 'pool-accounts.json');
|
|
33
52
|
/** Every saved sign-in. A file that cannot be read stops the command: treating it as empty would forget a pin and trust whatever key the server names next. */
|
|
@@ -63,7 +82,7 @@ export const loadSignIns = async (storeDir, step) => {
|
|
|
63
82
|
/** One folder per harness under the server's, so a fetch for one harness never overwrites another's bundle of the same version. */
|
|
64
83
|
export const replicaFile = (storeDir, server, harness, harnessVersion) => join(storeDir, 'pool-replica', contentHash(server), harness, `${encodeURIComponent(harnessVersion)}.json`);
|
|
65
84
|
/** What an earlier fetch kept: the bundle, checked again against the pinned key, and the ETag the pool sent with it. Nothing readable and verified is nothing kept, and the next fetch is then an ordinary one. */
|
|
66
|
-
export const keptReplica = async (file,
|
|
85
|
+
export const keptReplica = async (file, keys, harness, harnessVersion) => {
|
|
67
86
|
let parsed;
|
|
68
87
|
try {
|
|
69
88
|
parsed = JSON.parse(await readFile(file, 'utf8'));
|
|
@@ -71,7 +90,7 @@ export const keptReplica = async (file, key, harness, harnessVersion) => {
|
|
|
71
90
|
catch {
|
|
72
91
|
return undefined;
|
|
73
92
|
}
|
|
74
|
-
const verified = await verifyBundle(parsed,
|
|
93
|
+
const verified = await verifyBundle(parsed, keys, harness, harnessVersion);
|
|
75
94
|
return verified.ok ? { etag: isObject(parsed) ? textIn(parsed, 'etag') : undefined, bundle: verified.bundle } : undefined;
|
|
76
95
|
};
|
|
77
96
|
/**
|
|
@@ -82,8 +101,8 @@ export const sessionBundle = async (storeDir, setup) => {
|
|
|
82
101
|
if (setup === undefined || !isBundleVersion(setup.harnessVersion))
|
|
83
102
|
return undefined;
|
|
84
103
|
const signIns = await loadSignIns(storeDir, 'recall').catch(() => []);
|
|
85
|
-
for (const { server,
|
|
86
|
-
const kept = await keptReplica(replicaFile(storeDir, server, setup.harness, setup.harnessVersion),
|
|
104
|
+
for (const { server, signingKeys } of signIns) {
|
|
105
|
+
const kept = await keptReplica(replicaFile(storeDir, server, setup.harness, setup.harnessVersion), signingKeys, setup.harness, setup.harnessVersion);
|
|
87
106
|
if (kept !== undefined)
|
|
88
107
|
return kept.bundle;
|
|
89
108
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// One sender at a time per outbox
|
|
1
|
+
// One sender at a time per outbox: the detached sender (hook-bin.js nightly) and `deposit --send` both take this lock, so a batch is never posted twice by two runs that overlap. The lock is a file created
|
|
2
2
|
// with `wx` beside `sent.log`, so it holds across processes as well as inside one. A run that finds it held waits; a lock older than `staleMs` belongs to a run that died, and is taken over by one waiter only, under a guard file. The holder touches the file while it runs, so a long send (a first night of raw parts) is never mistaken for a dead run, and it removes the file only if the file still names its own run.
|
|
3
3
|
import { randomUUID } from 'node:crypto';
|
|
4
4
|
import { mkdir, open, readFile, rm, stat, utimes } from 'node:fs/promises';
|
|
@@ -2,3 +2,5 @@
|
|
|
2
2
|
export declare const sentLog: (outbox: string) => Promise<Map<string, string>>;
|
|
3
3
|
export declare const sentHashes: (outbox: string) => Promise<Set<string>>;
|
|
4
4
|
export declare const markSent: (outbox: string, hash: string, at: number) => Promise<void>;
|
|
5
|
+
/** Drops every `pulled.json` entry whose batch hash is in `sent.log`, and returns how many went. The sent batch file stays until the next prune; `sent.log` is what stops a second post. */
|
|
6
|
+
export declare const retirePulled: (outbox: string, step: string) => Promise<number>;
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// What the nightly send has already sent, so a night after a failure sends only what was not taken: one line for each hash, in the outbox beside `shown.log`. A batch that is rebuilt hashes differently and so is sent again, which is what a deposit that changed should do.
|
|
2
|
-
import {
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { isMissing } from './pool-outbox.js';
|
|
4
|
+
import { heldBatchOf, isMissing, readPulled, STAMP_FOLDER, writePulled } from './pool-outbox.js';
|
|
5
|
+
import { appendOwnerOnly, mkdirOwnerOnly } from './owner-only.js';
|
|
5
6
|
const sentFile = (outbox) => join(outbox, 'sent.log');
|
|
6
7
|
/** Each hash that was sent, with the time it was sent. A hash sent twice keeps its first time. */
|
|
7
8
|
export const sentLog = async (outbox) => {
|
|
@@ -21,6 +22,22 @@ export const sentLog = async (outbox) => {
|
|
|
21
22
|
};
|
|
22
23
|
export const sentHashes = async (outbox) => new Set((await sentLog(outbox)).keys());
|
|
23
24
|
export const markSent = async (outbox, hash, at) => {
|
|
24
|
-
await
|
|
25
|
-
await
|
|
25
|
+
await mkdirOwnerOnly(outbox);
|
|
26
|
+
await appendOwnerOnly(sentFile(outbox), `${hash} ${new Date(at).toISOString()}\n`);
|
|
27
|
+
};
|
|
28
|
+
/** Drops every `pulled.json` entry whose batch hash is in `sent.log`, and returns how many went. The sent batch file stays until the next prune; `sent.log` is what stops a second post. */
|
|
29
|
+
export const retirePulled = async (outbox, step) => {
|
|
30
|
+
const pulled = await readPulled(outbox, step);
|
|
31
|
+
const sent = await sentHashes(outbox);
|
|
32
|
+
let retired = 0;
|
|
33
|
+
for (const [id, stamp] of Object.entries(pulled)) {
|
|
34
|
+
const held = STAMP_FOLDER.test(stamp) ? await heldBatchOf(outbox, id, stamp) : undefined;
|
|
35
|
+
if (held === undefined || !sent.has(held.hash))
|
|
36
|
+
continue;
|
|
37
|
+
Reflect.deleteProperty(pulled, id);
|
|
38
|
+
retired += 1;
|
|
39
|
+
}
|
|
40
|
+
if (retired > 0)
|
|
41
|
+
await writePulled(outbox, pulled);
|
|
42
|
+
return retired;
|
|
26
43
|
};
|