@manyoars/oars 0.3.0 → 0.4.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 +44 -29
- package/dist/cli.d.ts +4 -1
- package/dist/cli.js +102 -32
- package/dist/commands/login.d.ts +1 -1
- package/dist/commands/login.js +1 -1
- package/dist/gate/hook-main.js +13 -1
- package/dist/gate/hook-process.d.ts +11 -1
- package/dist/gate/hook-process.js +50 -2
- package/dist/gate/hook-record.d.ts +3 -0
- package/dist/gate/hook-record.js +8 -0
- package/dist/gate/hook.d.ts +2 -0
- package/dist/gate/hook.js +3 -0
- package/dist/gate/replay.js +2 -5
- package/dist/gate/report.js +1 -1
- package/dist/gate/transcripts.js +1 -1
- package/dist/hook-bin.js +27 -2
- package/dist/issuer.d.ts +7 -9
- package/dist/issuer.js +10 -32
- package/dist/main.js +3 -0
- package/dist/sessions/actions.js +78 -0
- package/dist/sessions/allow.d.ts +10 -1
- package/dist/sessions/allow.js +29 -9
- package/dist/sessions/baseline.d.ts +1 -1
- package/dist/sessions/baseline.js +6 -4
- package/dist/sessions/call-shape.d.ts +19 -0
- package/dist/sessions/call-shape.js +85 -0
- package/dist/sessions/cell-increments.d.ts +5 -1
- package/dist/sessions/cell-increments.js +84 -50
- package/dist/sessions/cells.d.ts +33 -3
- package/dist/sessions/cells.js +89 -9
- package/dist/sessions/codex.js +93 -27
- package/dist/sessions/command.d.ts +11 -1
- package/dist/sessions/command.js +64 -18
- package/dist/sessions/coverage.d.ts +19 -0
- package/dist/sessions/coverage.js +56 -0
- package/dist/sessions/cursor.js +2 -2
- package/dist/sessions/derive.js +72 -6
- package/dist/sessions/exclusion.d.ts +6 -0
- package/dist/sessions/exclusion.js +8 -0
- package/dist/sessions/extract.d.ts +4 -2
- package/dist/sessions/extract.js +58 -26
- package/dist/sessions/hook-calls.js +1 -1
- package/dist/sessions/hook-index.d.ts +14 -1
- package/dist/sessions/hook-index.js +10 -3
- package/dist/sessions/hooks.d.ts +5 -1
- package/dist/sessions/hooks.js +133 -22
- package/dist/sessions/identity.d.ts +8 -3
- package/dist/sessions/identity.js +45 -15
- package/dist/sessions/install.d.ts +18 -2
- package/dist/sessions/install.js +53 -8
- package/dist/sessions/mcp.d.ts +3 -2
- package/dist/sessions/mcp.js +8 -4
- package/dist/sessions/motif.d.ts +2 -1
- package/dist/sessions/motif.js +2 -2
- package/dist/sessions/opencode.d.ts +33 -0
- package/dist/sessions/opencode.js +319 -0
- package/dist/sessions/otel.d.ts +60 -0
- package/dist/sessions/otel.js +494 -0
- package/dist/sessions/package-books.d.ts +48 -0
- package/dist/sessions/package-books.js +120 -0
- package/dist/sessions/package-resolve.d.ts +2 -0
- package/dist/sessions/package-resolve.js +3 -1
- package/dist/sessions/pi.d.ts +36 -0
- package/dist/sessions/pi.js +353 -0
- package/dist/sessions/pool-books.d.ts +21 -0
- package/dist/sessions/pool-books.js +75 -0
- package/dist/sessions/pool-choices.d.ts +5 -0
- package/dist/sessions/pool-choices.js +11 -0
- package/dist/sessions/pool-command.d.ts +76 -5
- package/dist/sessions/pool-command.js +263 -136
- package/dist/sessions/pool-nightly-deps.d.ts +3 -0
- package/dist/sessions/pool-nightly-deps.js +11 -0
- package/dist/sessions/pool-nightly.d.ts +10 -0
- package/dist/sessions/pool-nightly.js +85 -0
- package/dist/sessions/pool-outbox.d.ts +15 -2
- package/dist/sessions/pool-outbox.js +79 -26
- package/dist/sessions/pool-print.d.ts +1 -1
- package/dist/sessions/pool-raw.d.ts +27 -0
- package/dist/sessions/pool-raw.js +68 -0
- package/dist/sessions/pool-replica.d.ts +35 -0
- package/dist/sessions/pool-replica.js +91 -0
- package/dist/sessions/pool-send-lock.d.ts +20 -0
- package/dist/sessions/pool-send-lock.js +87 -0
- package/dist/sessions/pool-sent.d.ts +4 -0
- package/dist/sessions/pool-sent.js +26 -0
- package/dist/sessions/pool-totals.d.ts +10 -0
- package/dist/sessions/pool-totals.js +21 -0
- package/dist/sessions/pool-wire.d.ts +240 -30
- package/dist/sessions/pool-wire.js +695 -215
- package/dist/sessions/public-vocabulary.d.ts +2 -3
- package/dist/sessions/public-vocabulary.js +2 -9
- package/dist/sessions/query.d.ts +16 -4
- package/dist/sessions/query.js +32 -10
- package/dist/sessions/read-cost.js +14 -3
- package/dist/sessions/read-recovery-edge.d.ts +2 -0
- package/dist/sessions/read-recovery-edge.js +26 -0
- package/dist/sessions/read.d.ts +8 -1
- package/dist/sessions/read.js +6 -3
- package/dist/sessions/recall-bundle.d.ts +12 -0
- package/dist/sessions/recall-bundle.js +33 -0
- package/dist/sessions/recall-frames.d.ts +18 -0
- package/dist/sessions/recall-frames.js +44 -0
- package/dist/sessions/recall-next.d.ts +145 -0
- package/dist/sessions/recall-next.js +241 -0
- package/dist/sessions/recall-object.d.ts +2 -0
- package/dist/sessions/recall-object.js +1 -0
- package/dist/sessions/recall-push.d.ts +2 -2
- package/dist/sessions/recall-push.js +3 -51
- package/dist/sessions/recall-reads.d.ts +7 -0
- package/dist/sessions/recall-reads.js +7 -0
- package/dist/sessions/recovery-calls.d.ts +21 -0
- package/dist/sessions/recovery-calls.js +25 -0
- package/dist/sessions/redact.d.ts +11 -0
- package/dist/sessions/redact.js +59 -0
- package/dist/sessions/schema.d.ts +59 -9
- package/dist/sessions/schema.js +22 -3
- package/dist/sessions/session-shape.d.ts +6 -3
- package/dist/sessions/session-shape.js +19 -6
- package/dist/sessions/sources.js +75 -1
- package/dist/sessions/store.d.ts +13 -1
- package/dist/sessions/store.js +36 -16
- package/dist/sessions/survival.d.ts +2 -0
- package/dist/sessions/survival.js +2 -2
- package/dist/sessions/transcript-tail.d.ts +14 -0
- package/dist/sessions/transcript-tail.js +64 -0
- package/dist/sessions/vscode-copilot.js +24 -7
- package/dist/telemetry/command.d.ts +8 -0
- package/dist/telemetry/command.js +85 -0
- package/dist/telemetry/consent.d.ts +17 -0
- package/dist/telemetry/consent.js +63 -0
- package/dist/telemetry/crash.d.ts +8 -0
- package/dist/telemetry/crash.js +52 -0
- package/dist/telemetry/emit.d.ts +31 -0
- package/dist/telemetry/emit.js +42 -0
- package/dist/telemetry/fields.d.ts +16 -0
- package/dist/telemetry/fields.js +26 -0
- package/dist/telemetry/health.d.ts +21 -0
- package/dist/telemetry/health.js +108 -0
- package/dist/telemetry/hook-emit.d.ts +4 -0
- package/dist/telemetry/hook-emit.js +18 -0
- package/dist/telemetry/measures.d.ts +32 -0
- package/dist/telemetry/measures.js +128 -0
- package/dist/telemetry/observe.d.ts +13 -0
- package/dist/telemetry/observe.js +51 -0
- package/dist/telemetry/read-events.d.ts +9 -0
- package/dist/telemetry/read-events.js +17 -0
- package/dist/telemetry/record.d.ts +17 -0
- package/dist/telemetry/record.js +26 -0
- package/dist/telemetry/report.d.ts +77 -0
- package/dist/telemetry/report.js +188 -0
- package/dist/telemetry/schema.d.ts +40 -0
- package/dist/telemetry/schema.js +187 -0
- package/dist/telemetry/send-main.d.ts +1 -0
- package/dist/telemetry/send-main.js +11 -0
- package/dist/telemetry/send.d.ts +36 -0
- package/dist/telemetry/send.js +172 -0
- package/dist/telemetry/spool.d.ts +17 -0
- package/dist/telemetry/spool.js +70 -0
- package/dist/telemetry/wire.d.ts +25 -0
- package/dist/telemetry/wire.js +76 -0
- package/package.json +2 -2
|
@@ -1,22 +1,32 @@
|
|
|
1
|
-
// `oars pool`: the only part of the sessions code that talks to a remote. The server is
|
|
1
|
+
// `oars pool`: the only part of the sessions code that talks to a remote. The server is the one named by flag or environment, else the Many Oars pool; nothing leaves until a command that sends is run or, after covenant acceptance, the nightly task sends. Sign-in runs the provider's device flow here and reads no harness token; a session leaves only as a skeleton of a closed list of fields, each value redacted first, and only after the exclusion check found it clean. Local capture, recall,
|
|
2
2
|
// and reads never come through here.
|
|
3
3
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
4
|
-
import { dirname
|
|
4
|
+
import { dirname } from 'node:path';
|
|
5
|
+
import { emitOf } from '../telemetry/emit.js';
|
|
5
6
|
import { CliError, exitCodeForProblem, ExitCode, unreachableError } from '../errors.js';
|
|
6
7
|
import { compileAllow } from './allow.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
8
|
+
import { makeCoverage, readCoverageSetting, writeCoverageSetting } from './coverage.js';
|
|
9
|
+
import { checkSession, exclusionPaths, lastWritten, loadTerms, transcriptFilesOf } from './exclusion.js';
|
|
10
|
+
import { localNames, makeRedactor, REDACTION_VERSION, redactTranscript } from './redact.js';
|
|
11
|
+
import { recordSend, recordWire, newTally, tallyBatch } from '../telemetry/wire.js';
|
|
12
|
+
import { discardStale, FIELD_ROWS, fieldsOf, heldBatches, lockedBatch, markShown, outboxFolder, partsOf, removeBatchesExcept, removeBatchOf, staleBatches, unchangedBytes, writeBatch } from './pool-outbox.js';
|
|
13
|
+
import { HARNESSES, hasEnded, ORG_SIZES, REPO_VISIBILITIES, SESSION_IDLE_HOURS, isBundleVersion, isHarness, parseSkeleton, sessionsOf, skeletonOf, VOCAB_VERSION, verifyBundle } from './pool-wire.js';
|
|
10
14
|
import { carriedDeltas, markDeltasSent, placedOf, sentDeltas } from './pool-delta.js';
|
|
11
15
|
import { Masker, printed, TRANSPORT_HEADERS, waitWords } from './pool-print.js';
|
|
16
|
+
import { rawAllowed, sendsOf } from './pool-choices.js';
|
|
17
|
+
import { heldRaw, rawParts, removeRawExcept, writeRaw } from './pool-raw.js';
|
|
18
|
+
import { SendLockBusy, withSendLock } from './pool-send-lock.js';
|
|
19
|
+
import { markSent, sentLog } from './pool-sent.js';
|
|
12
20
|
import { shellReader } from './projection.js';
|
|
13
21
|
import { isVocabularyExpired, REGISTRY_MAX_AGE_DAYS, VOCAB_SNAPSHOT_DATE } from './public-vocabulary.js';
|
|
14
|
-
import { contentHash } from './read.js';
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
22
|
+
import { contentHash, FIRST_ORDER_FAMILIES } from './read.js';
|
|
23
|
+
import { isObject, keptReplica, loadSignIns, numberIn, replicaFile, signInOf, signInsFile, textIn } from './pool-replica.js';
|
|
24
|
+
import { CAPTURE_VERSION, harnessOf as sessionHarness } from './schema.js';
|
|
25
|
+
import { loadStore, readRedactDomains, readSkeletons, readState, updatePoolChoices } from './store.js';
|
|
26
|
+
import { runGit } from './survival.js';
|
|
17
27
|
import { vocabulary } from './vocab-load.js';
|
|
18
28
|
const LOGIN = 'pool-login';
|
|
19
|
-
const DEPOSIT = 'pool-deposit';
|
|
29
|
+
export const DEPOSIT = 'pool-deposit';
|
|
20
30
|
const OUTBOX = 'pool-outbox';
|
|
21
31
|
const POOL_DEPOSITS = '/v1/deposits';
|
|
22
32
|
const FETCH = 'pool-fetch';
|
|
@@ -26,12 +36,13 @@ const DEFAULT_INTERVAL_SEC = 5;
|
|
|
26
36
|
const DEFAULT_EXPIRY_SEC = 900;
|
|
27
37
|
/** RFC 8628: a `slow_down` adds five seconds to every poll after it. */
|
|
28
38
|
const SLOW_DOWN_MS = 5000;
|
|
39
|
+
export { isObject, loadSignIns, textIn };
|
|
29
40
|
const usage = (message, step) => new CliError(message, ExitCode.USAGE, step);
|
|
30
|
-
/**
|
|
41
|
+
/** The Many Oars pool, used when neither `--server` nor `OARS_POOL_URL` names another. */
|
|
42
|
+
export const DEFAULT_POOL_URL = 'https://pool.manyoars.com';
|
|
43
|
+
/** `--server`, else `OARS_POOL_URL`, else the Many Oars pool. A token and a session's shape cross this connection, so plain http goes only to this machine. */
|
|
31
44
|
export const poolServer = (env, step, named) => {
|
|
32
|
-
const given = named ?? env.OARS_POOL_URL;
|
|
33
|
-
if (given === undefined || given === '')
|
|
34
|
-
throw usage('Name the pool server with --server or OARS_POOL_URL; there is no default.', step);
|
|
45
|
+
const given = named ?? (env.OARS_POOL_URL === undefined || env.OARS_POOL_URL === '' ? DEFAULT_POOL_URL : env.OARS_POOL_URL);
|
|
35
46
|
let url;
|
|
36
47
|
try {
|
|
37
48
|
url = new URL(given);
|
|
@@ -45,65 +56,12 @@ export const poolServer = (env, step, named) => {
|
|
|
45
56
|
throw usage(`The pool server ${given} is an origin only: no credentials, path, or query.`, step);
|
|
46
57
|
return url.origin;
|
|
47
58
|
};
|
|
48
|
-
const runtimeOf = (deps) => {
|
|
59
|
+
export const runtimeOf = (deps) => {
|
|
49
60
|
if (deps.sessions === undefined)
|
|
50
61
|
throw new CliError('The session store is not configured in this run.', ExitCode.UNEXPECTED, 'unexpected');
|
|
51
62
|
return deps.sessions;
|
|
52
63
|
};
|
|
53
|
-
const
|
|
54
|
-
const found = Reflect.get(value, key);
|
|
55
|
-
return typeof found === 'string' && found !== '' ? found : undefined;
|
|
56
|
-
};
|
|
57
|
-
const numberIn = (value, key) => {
|
|
58
|
-
const found = Reflect.get(value, key);
|
|
59
|
-
return typeof found === 'number' && Number.isFinite(found) ? found : undefined;
|
|
60
|
-
};
|
|
61
|
-
const isObject = (value) => typeof value === 'object' && value !== null;
|
|
62
|
-
const keyOf = (value) => {
|
|
63
|
-
if (!isObject(value))
|
|
64
|
-
return undefined;
|
|
65
|
-
const [kty, crv, x] = [textIn(value, 'kty'), textIn(value, 'crv'), textIn(value, 'x')];
|
|
66
|
-
return kty === 'OKP' && crv === 'Ed25519' && x !== undefined ? { kty, crv, x } : undefined;
|
|
67
|
-
};
|
|
68
|
-
const signInOf = (value) => {
|
|
69
|
-
if (!isObject(value))
|
|
70
|
-
return undefined;
|
|
71
|
-
const [server, account, token, expiresAt] = [textIn(value, 'server'), textIn(value, 'account'), textIn(value, 'token'), numberIn(value, 'expiresAt')];
|
|
72
|
-
const signingKey = keyOf(Reflect.get(value, 'signingKey'));
|
|
73
|
-
return server === undefined || account === undefined || token === undefined || expiresAt === undefined || signingKey === undefined ? undefined : { server, account, token, expiresAt, signingKey };
|
|
74
|
-
};
|
|
75
|
-
const signInsFile = (storeDir) => join(storeDir, 'pool-accounts.json');
|
|
76
|
-
/** 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. */
|
|
77
|
-
const loadSignIns = async (storeDir, step) => {
|
|
78
|
-
const file = signInsFile(storeDir);
|
|
79
|
-
let text;
|
|
80
|
-
try {
|
|
81
|
-
text = await readFile(file, 'utf8');
|
|
82
|
-
}
|
|
83
|
-
catch (error) {
|
|
84
|
-
if (isMissing(error))
|
|
85
|
-
return [];
|
|
86
|
-
throw error;
|
|
87
|
-
}
|
|
88
|
-
const damaged = new CliError(`${file} cannot be read. Remove it, then run oars pool login again.`, ExitCode.INVALID, step);
|
|
89
|
-
let parsed;
|
|
90
|
-
try {
|
|
91
|
-
parsed = JSON.parse(text);
|
|
92
|
-
}
|
|
93
|
-
catch {
|
|
94
|
-
throw damaged;
|
|
95
|
-
}
|
|
96
|
-
if (!Array.isArray(parsed))
|
|
97
|
-
throw damaged;
|
|
98
|
-
const entries = parsed;
|
|
99
|
-
return entries.map((entry) => {
|
|
100
|
-
const signIn = signInOf(entry);
|
|
101
|
-
if (signIn === undefined)
|
|
102
|
-
throw damaged;
|
|
103
|
-
return signIn;
|
|
104
|
-
});
|
|
105
|
-
};
|
|
106
|
-
const signedIn = async (deps, storeDir, server, step) => {
|
|
64
|
+
export const signedIn = async (deps, storeDir, server, step) => {
|
|
107
65
|
const found = (await loadSignIns(storeDir, step)).find((entry) => entry.server === server);
|
|
108
66
|
if (found === undefined || found.expiresAt <= deps.now())
|
|
109
67
|
throw new CliError(`Not signed in to ${server}. Run oars pool login first.`, ExitCode.AUTH, step);
|
|
@@ -115,16 +73,19 @@ const outgoing = (deps, server, path, init) => {
|
|
|
115
73
|
if (init.token !== undefined)
|
|
116
74
|
headers.push(['authorization', `Bearer ${init.token}`]);
|
|
117
75
|
// Node's `fetch` writes `cache-control` and `pragma` itself whenever `if-none-match` is set, so they are named here to be printed.
|
|
76
|
+
if (init.part !== undefined)
|
|
77
|
+
headers.push(['oars-part', `${String(init.part.index)}/${String(init.part.count)}`], ['oars-batch', init.part.batch]);
|
|
118
78
|
if (init.ifNoneMatch !== undefined)
|
|
119
79
|
headers.push(['if-none-match', init.ifNoneMatch], ['cache-control', 'no-cache'], ['pragma', 'no-cache']);
|
|
80
|
+
headers.push(...(init.extra ?? []));
|
|
120
81
|
const body = init.bytes ?? (init.body === undefined ? undefined : JSON.stringify(init.body));
|
|
121
82
|
if (body !== undefined)
|
|
122
|
-
headers.push(['content-type', 'application/json']);
|
|
83
|
+
headers.push(['content-type', init.type ?? 'application/json']);
|
|
123
84
|
headers.push(...TRANSPORT_HEADERS);
|
|
124
|
-
return { method: body === undefined ? 'GET' : 'POST', url: new URL(path, server), headers, ...(body === undefined ? {} : { body }) };
|
|
85
|
+
return { method: init.method ?? (body === undefined ? 'GET' : 'POST'), url: new URL(path, server), headers, ...(body === undefined ? {} : { body }) };
|
|
125
86
|
};
|
|
126
87
|
/** One call to the pool. A redirect is an error: it could carry a batch or a token somewhere the checks on the named server never saw. */
|
|
127
|
-
const ask = (deps, step, server, path, init, via) => {
|
|
88
|
+
export const ask = (deps, step, server, path, init, via) => {
|
|
128
89
|
const request = outgoing(deps, server, path, init);
|
|
129
90
|
return via === undefined ? send(deps, step, request, init.show) : via(request);
|
|
130
91
|
};
|
|
@@ -149,7 +110,7 @@ const send = async (deps, step, request, show) => {
|
|
|
149
110
|
}
|
|
150
111
|
return { status: response.status, body, headers: response.headers };
|
|
151
112
|
};
|
|
152
|
-
/** The sender
|
|
113
|
+
/** The sender `deposit --send` uses when none is given: the request goes out over the network as built. The loopback test passes it to read what reaches the socket. */
|
|
153
114
|
export const networkSender = (deps, step) => (request) => send(deps, step, request, undefined);
|
|
154
115
|
/** The server's named refusal, as a failure: its `error` is the problem's code in `--json`. */
|
|
155
116
|
const refusal = (step, { status, body }) => {
|
|
@@ -282,7 +243,7 @@ const excludedKey = (reason) => (reason === 'no-raw' ? 'noRaw' : reason === 'no-
|
|
|
282
243
|
/** A deposit answered with this code is sent again: another deposit of the same session landed between the pool's read and its write, and the next one nets against the winner. */
|
|
283
244
|
const DEPOSIT_CONFLICT = 'deposit_conflict';
|
|
284
245
|
const SEND_ATTEMPTS = 3;
|
|
285
|
-
const vocabularyExpired = (deps) => {
|
|
246
|
+
export const vocabularyExpired = (deps) => {
|
|
286
247
|
if (!isVocabularyExpired(new Date(deps.now())))
|
|
287
248
|
return undefined;
|
|
288
249
|
const title = `This oars is out of date: its vocabulary ${VOCAB_VERSION} was taken on ${VOCAB_SNAPSHOT_DATE}, over ${String(REGISTRY_MAX_AGE_DAYS)} days ago. Upgrade with npm install -g @manyoars/oars, then run oars pool deposit again. Your sessions stay on this machine.`;
|
|
@@ -293,33 +254,80 @@ const lockMessages = {
|
|
|
293
254
|
batch_not_shown: 'That batch was never printed. Run oars pool outbox, read it, then send it.',
|
|
294
255
|
batch_changed: 'That batch changed on disk after it was printed, or since it was written, so it was not sent. Run oars pool deposit to build it again, then oars pool outbox to print it.',
|
|
295
256
|
};
|
|
296
|
-
/**
|
|
257
|
+
/** The calls one batch takes, in order: one, or one per part when it is past a request's bytes. `outbox` prints these and `deposit --send` sends these, so what is printed is what is sent. */
|
|
258
|
+
const callsOf = (token, bytes, hash) => {
|
|
259
|
+
const parts = partsOf(bytes);
|
|
260
|
+
return parts.map((piece, at) => ({ token, bytes: piece, ...(parts.length === 1 ? {} : { part: { index: at + 1, count: parts.length, batch: hash } }) }));
|
|
261
|
+
};
|
|
262
|
+
/** One batch to the pool: a batch past one request's bytes goes in parts, in order, each answered 202 until the last completes the deposit with 201. A part sent again is harmless, so the retry covers every part alike. */
|
|
263
|
+
export const postBatch = async (deps, options) => {
|
|
264
|
+
const { server, token, bytes, hash, via } = options;
|
|
265
|
+
const calls = callsOf(token, bytes, hash);
|
|
266
|
+
for (const [at, call] of calls.entries()) {
|
|
267
|
+
let reply = await ask(deps, DEPOSIT, server, POOL_DEPOSITS, call, via);
|
|
268
|
+
for (let attempt = 1; attempt < SEND_ATTEMPTS && reply.status === 409 && isObject(reply.body) && textIn(reply.body, 'error') === DEPOSIT_CONFLICT; attempt += 1) {
|
|
269
|
+
reply = await ask(deps, DEPOSIT, server, POOL_DEPOSITS, call, via);
|
|
270
|
+
}
|
|
271
|
+
// 201 on any part means the deposit already finished (this part is a retry), so the rest need not be sent.
|
|
272
|
+
const ended = reply.status !== 202 || at === calls.length - 1;
|
|
273
|
+
// One send event per batch, on the reply that ends it, whatever number of parts it took.
|
|
274
|
+
if (ended)
|
|
275
|
+
recordSend(deps, { status: reply.status, code: isObject(reply.body) ? textIn(reply.body, 'error') : undefined, bytes: Buffer.byteLength(bytes) });
|
|
276
|
+
if (reply.status === 201)
|
|
277
|
+
break;
|
|
278
|
+
if (ended)
|
|
279
|
+
throw refusal(DEPOSIT, reply);
|
|
280
|
+
}
|
|
281
|
+
emitOf(deps)('deposit', { step: 'send', sent: 1, bytes: Buffer.byteLength(bytes) });
|
|
282
|
+
};
|
|
283
|
+
/**
|
|
284
|
+
* One redacted raw copy to the pool: its parts by PUT, then the completion, which the pool checks against the SHA-256 in the path. Every request states the redaction and capture versions and that the person said yes to the raw prompt;
|
|
285
|
+
* the caller sends only for a person who did. A part sent again replaces itself, and one for an object that stands writes nothing, so a copy that failed halfway is sent again whole.
|
|
286
|
+
*/
|
|
287
|
+
export const postRaw = async (deps, options) => {
|
|
288
|
+
const { server, token, hash, body, bytes, via } = options;
|
|
289
|
+
const extra = [['oars-redaction-version', REDACTION_VERSION], ['oars-capture-version', CAPTURE_VERSION], ['oars-raw-consent', 'granted']];
|
|
290
|
+
const parts = rawParts(body);
|
|
291
|
+
for (const [at, piece] of parts.entries()) {
|
|
292
|
+
const reply = await ask(deps, DEPOSIT, server, `/v1/raw/${hash}/parts/${String(at + 1)}`, { token, bytes: piece, method: 'PUT', type: 'application/x-ndjson', extra }, via);
|
|
293
|
+
if (reply.status !== 200)
|
|
294
|
+
throw refusal(DEPOSIT, reply);
|
|
295
|
+
}
|
|
296
|
+
const done = await ask(deps, DEPOSIT, server, `/v1/raw/${hash}/complete`, { token, body: { parts: parts.length, bytes }, extra }, via);
|
|
297
|
+
if (done.status !== 200 && done.status !== 201)
|
|
298
|
+
throw refusal(DEPOSIT, done);
|
|
299
|
+
};
|
|
300
|
+
const rawNote = (consent) => consent === 'granted' ? '' : consent === 'declined' ? 'You answered no to the raw prompt; oars pool raw --consent yes changes it.' : 'None is queued until you answer the raw prompt with oars pool raw --consent yes.';
|
|
301
|
+
/** `deposit --send <hash>`: the send lock the nightly task takes too, then a skip for a hash already in `sent.log`, then the batch lock, then the covenant (accepted here or before, or nothing is sent), then the request. The body is read and hashed again just before the request. */
|
|
297
302
|
const sendBatch = async (deps, options) => {
|
|
298
|
-
const { outbox, server, token, hash
|
|
303
|
+
const { outbox, server, token, hash } = options;
|
|
304
|
+
const via = options.via ?? networkSender(deps, DEPOSIT);
|
|
299
305
|
if (!/^[0-9a-f]{64}$/.test(hash))
|
|
300
306
|
throw usage('--send takes the SHA-256 that oars pool outbox printed: 64 hexadecimal characters.', DEPOSIT);
|
|
301
|
-
const
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
307
|
+
const refuse = (code, title) => new CliError(title, ExitCode.CONFLICT, DEPOSIT, { code, title, status: 409 });
|
|
308
|
+
try {
|
|
309
|
+
return await withSendLock(outbox, async () => {
|
|
310
|
+
// The rule the nightly follows: a hash `sent.log` holds is not posted again. Read under the lock, so two runs cannot both see it unsent.
|
|
311
|
+
const sentAt = (await sentLog(outbox)).get(hash);
|
|
312
|
+
if (sentAt !== undefined)
|
|
313
|
+
return { document: { ok: true, server, hash, sent: false, sentAt }, text: `already sent ${hash} at ${sentAt}` };
|
|
314
|
+
const locked = await lockedBatch(outbox, hash);
|
|
315
|
+
if (!locked.ok)
|
|
316
|
+
throw refuse(locked.code, lockMessages[locked.code]);
|
|
317
|
+
await openCovenant(deps, server, token, options.acceptCovenant, via);
|
|
318
|
+
const bytes = await unchangedBytes(locked.file, hash);
|
|
319
|
+
if (bytes === undefined)
|
|
320
|
+
throw refuse('batch_changed', lockMessages.batch_changed);
|
|
321
|
+
await postBatch(deps, { server, token, bytes, hash, via });
|
|
322
|
+
await markSent(outbox, hash, deps.now());
|
|
323
|
+
return { document: { ok: true, server, hash, sent: true }, text: `Sent batch ${hash} to ${server}.` };
|
|
324
|
+
});
|
|
315
325
|
}
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
326
|
+
catch (error) {
|
|
327
|
+
if (error instanceof SendLockBusy)
|
|
328
|
+
throw refuse('send_locked', 'Another oars run is sending from this outbox. Try again in a minute.');
|
|
329
|
+
throw error;
|
|
319
330
|
}
|
|
320
|
-
if (reply.status !== 201)
|
|
321
|
-
throw refusal(DEPOSIT, reply);
|
|
322
|
-
return { document: { ok: true, server, hash, sent: true }, text: `Sent batch ${hash} to ${server}.` };
|
|
323
331
|
};
|
|
324
332
|
/**
|
|
325
333
|
* `oars pool deposit`: every session in the store that passes both session gates is built into a canonical batch in the outbox, and the command stops: nothing is sent. The gates run before any projection does: the folder
|
|
@@ -334,17 +342,25 @@ export const poolDeposit = async (deps, options) => {
|
|
|
334
342
|
const expired = vocabularyExpired(deps);
|
|
335
343
|
if (expired !== undefined)
|
|
336
344
|
throw expired;
|
|
337
|
-
// Accepting the covenant is a step of sending
|
|
345
|
+
// Accepting the covenant is a step of sending a batch: alone it would exit 0 as though something had been sent under it.
|
|
338
346
|
if (options.send === undefined && options.acceptCovenant !== undefined)
|
|
339
|
-
throw usage('--accept-covenant belongs to --send
|
|
347
|
+
throw usage('--accept-covenant belongs to --send: it is accepted as a step of sending a batch. Nothing was accepted.', DEPOSIT);
|
|
340
348
|
const outbox = outboxFolder(runtime.storeDir, server);
|
|
341
349
|
if (options.send !== undefined)
|
|
342
350
|
return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, via: options.via });
|
|
343
351
|
const { terms } = exclusionPaths(runtime.projectsRoot, deps.env);
|
|
344
|
-
const
|
|
352
|
+
const saved = await readState(runtime.storeDir);
|
|
353
|
+
const names = localNames();
|
|
354
|
+
const allow = compileAllow(saved.allow);
|
|
345
355
|
const discarded = await discardStale(outbox);
|
|
346
|
-
const counts = { sessions: 0, batched: 0, excluded: { folder: 0, term: 0, noRaw: 0, noTerms: 0, unreadable: 0 }, uncaptured: 0, notEnded: 0, refused: 0, refusedFields: [], discarded, withdrawn: 0 };
|
|
356
|
+
const counts = { sessions: 0, batched: 0, excluded: { folder: 0, term: 0, noRaw: 0, noTerms: 0, unreadable: 0 }, off: 0, raw: { copies: 0, bytes: 0, masked: 0 }, uncaptured: 0, notEnded: 0, refused: 0, refusedFields: [], discarded, withdrawn: 0 };
|
|
357
|
+
// The one gate on a name: the operator's redactor and exclusion terms (t881). Everything else the pool classes itself.
|
|
358
|
+
const gate = { redact: makeRedactor(await readRedactDomains(runtime.storeDir)), terms: await loadTerms(terms) };
|
|
347
359
|
const built = new Set();
|
|
360
|
+
const rawKept = new Set();
|
|
361
|
+
// The bytes of the batches written in this pass: telemetry counts them, and the command's own document does not carry them.
|
|
362
|
+
let builtBytes = 0;
|
|
363
|
+
const wireTally = newTally();
|
|
348
364
|
const refusedFields = new Set();
|
|
349
365
|
const roots = new Map((await readSkeletons(runtime.storeDir, undefined, true)).skeletons.map(({ session, merkleRoot, leaves }) => [session, { merkleRoot, leaves }]));
|
|
350
366
|
// The sessions that pass both gates and have ended, in the order the store holds them: the windows of a delta are counted over these alone, and the batch of each is built after, once its deltas are known.
|
|
@@ -352,6 +368,7 @@ export const poolDeposit = async (deps, options) => {
|
|
|
352
368
|
// A session that fails a gate loses its earlier batch at once, so a later throw in this pass cannot leave one in the outbox.
|
|
353
369
|
const withdraw = async (key) => {
|
|
354
370
|
counts.withdrawn += await removeBatchOf(outbox, key);
|
|
371
|
+
await removeRawExcept(outbox, new Set(), key);
|
|
355
372
|
};
|
|
356
373
|
for (const records of sessionsOf(await loadStore(runtime.storeDir), roots)) {
|
|
357
374
|
counts.sessions += 1;
|
|
@@ -362,6 +379,12 @@ export const poolDeposit = async (deps, options) => {
|
|
|
362
379
|
await withdraw(key);
|
|
363
380
|
continue;
|
|
364
381
|
}
|
|
382
|
+
// A folder switched off sends nothing from now on; what it sent before stays where it went.
|
|
383
|
+
if (sendsOf(saved.pool, records.main.project) === 'off') {
|
|
384
|
+
counts.off += 1;
|
|
385
|
+
await withdraw(key);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
365
388
|
const verdict = await checkSession(runtime, terms, records.main.project, records.main.session);
|
|
366
389
|
if (verdict.reason !== 'clean') {
|
|
367
390
|
counts.excluded[excludedKey(verdict.reason)] += 1;
|
|
@@ -379,36 +402,59 @@ export const poolDeposit = async (deps, options) => {
|
|
|
379
402
|
// A delta is built from the sessions that passed, carried by the batch of the session that filled its after window, and marked sent only once that batch is written: a deposit that stops first builds it again.
|
|
380
403
|
const placed = ready.flatMap(({ records }) => placedOf(records) ?? []);
|
|
381
404
|
const carried = carriedDeltas(account, placed, await sentDeltas(outbox));
|
|
405
|
+
// The coverage bands (t871): the operator's two settings, read here, and the team band counted from the local git log. Nothing is looked up, and anything unread goes as unknown.
|
|
406
|
+
const coverageOf = makeCoverage(runtime, await readCoverageSetting(runtime.storeDir), options.git ?? runGit);
|
|
382
407
|
for (const { records, key } of ready) {
|
|
383
408
|
const delta = carried.get(records.main.session);
|
|
384
|
-
const skeleton = skeletonOf(records, key, shellReader(vocabulary()), contentHash, delta?.entries);
|
|
409
|
+
const skeleton = skeletonOf(records, key, shellReader(vocabulary()), contentHash, delta?.entries, gate, await coverageOf(records.main));
|
|
385
410
|
if (skeleton === null) {
|
|
386
411
|
counts.uncaptured += 1;
|
|
387
412
|
await withdraw(key);
|
|
388
413
|
continue;
|
|
389
414
|
}
|
|
390
415
|
const parsed = parseSkeleton(skeleton);
|
|
391
|
-
|
|
416
|
+
// The server reads a value outside its lists as `other` and keeps a field it does not know, so the parser no longer refuses either: the emitter is the privacy control, and a batch this client built holds neither.
|
|
417
|
+
const refusedAt = parsed.ok ? parsed.extensions[0]?.at : parsed.field;
|
|
418
|
+
if (!parsed.ok || refusedAt !== undefined) {
|
|
392
419
|
counts.refused += 1;
|
|
393
|
-
refusedFields.add(
|
|
420
|
+
refusedFields.add(refusedAt ?? '');
|
|
394
421
|
await withdraw(key);
|
|
395
422
|
continue;
|
|
396
423
|
}
|
|
397
|
-
await writeBatch(outbox, key, parsed.skeleton);
|
|
424
|
+
builtBytes += (await writeBatch(outbox, key, parsed.skeleton)).bytes;
|
|
425
|
+
// The raw copy comes only after both gates and only with a yes to the raw prompt and a folder not at skeleton only: each transcript of the session, redacted here, before it is queued.
|
|
426
|
+
if (rawAllowed(saved.pool, records.main.project)) {
|
|
427
|
+
let place = 0;
|
|
428
|
+
for (const file of await transcriptFilesOf(runtime, records.main.project, records.main.session)) {
|
|
429
|
+
const redacted = redactTranscript(await readFile(file.path, 'utf8'), saved.redactDomains, names);
|
|
430
|
+
counts.raw.bytes += (await writeRaw(outbox, key, place, redacted.text)).bytes;
|
|
431
|
+
counts.raw.copies += 1;
|
|
432
|
+
counts.raw.masked += redacted.masked;
|
|
433
|
+
place += 1;
|
|
434
|
+
}
|
|
435
|
+
rawKept.add(key);
|
|
436
|
+
}
|
|
437
|
+
tallyBatch(wireTally, parsed.skeleton);
|
|
398
438
|
await markDeltasSent(outbox, delta?.marks ?? []);
|
|
399
439
|
built.add(key);
|
|
400
440
|
counts.batched += 1;
|
|
401
441
|
}
|
|
402
442
|
// The batch of a session or account this store no longer holds goes too.
|
|
403
443
|
counts.withdrawn += await removeBatchesExcept(outbox, built);
|
|
444
|
+
await removeRawExcept(outbox, rawKept);
|
|
404
445
|
counts.refusedFields = [...refusedFields].sort();
|
|
446
|
+
recordWire(deps, wireTally);
|
|
405
447
|
const { folder, term, noRaw, noTerms, unreadable } = counts.excluded;
|
|
448
|
+
// Counts only: how many sessions each gate kept out, never a folder, a term, or a name.
|
|
449
|
+
emitOf(deps)('deposit', { step: 'build', sessions: counts.sessions, batched: counts.batched, bytes: builtBytes, excludedFolder: folder, excludedTerm: term, excludedNoRaw: noRaw, excludedNoTerms: noTerms, excludedUnreadable: unreadable, notEnded: counts.notEnded, uncaptured: counts.uncaptured, refused: counts.refused, withdrawn: counts.withdrawn, discarded });
|
|
406
450
|
const lines = [
|
|
407
451
|
`Wrote ${String(counts.batched)} of ${String(counts.sessions)} sessions to the outbox for ${server}. Nothing was sent.`,
|
|
408
452
|
`Kept out by the session gates, never built: ${String(folder)} in a folder not on the allowlist, ${String(term)} for a term, ${String(noRaw)} with no raw to scan, ${String(noTerms)} with no term file, ${String(unreadable)} unreadable.`,
|
|
409
453
|
`Not built: ${String(counts.uncaptured)} captured before steps and frames or with no root, ${String(counts.notEnded)} not ended (a transcript gained a line within ${String(SESSION_IDLE_HOURS)} hours), ${String(counts.refused)} refused by the vocabulary${counts.refusedFields.length === 0 ? '' : ` (${counts.refusedFields.join(', ')})`}.`,
|
|
410
454
|
...(counts.withdrawn === 0 ? [] : [`Removed ${String(counts.withdrawn)} ${counts.withdrawn === 1 ? 'batch' : 'batches'} of sessions that no longer pass the gates, have not ended, or are no longer in the store.`]),
|
|
411
455
|
...(discarded === 0 ? [] : [`Discarded ${String(discarded)} ${discarded === 1 ? 'batch' : 'batches'} built under an older vocabulary, field list, or skeleton format.`]),
|
|
456
|
+
`Raw copies queued: ${String(counts.raw.copies)} (${String(counts.raw.bytes)} bytes, ${String(counts.raw.masked)} values masked). ${rawNote(saved.pool.rawConsent)}`.trim(),
|
|
457
|
+
...(counts.off === 0 ? [] : [`${String(counts.off)} ${counts.off === 1 ? 'session' : 'sessions'} in a folder switched off: nothing of them is queued.`]),
|
|
412
458
|
'Run oars pool outbox to print exactly what would be sent.',
|
|
413
459
|
];
|
|
414
460
|
return { document: { ok: true, server, ...counts }, text: lines.join('\n') };
|
|
@@ -426,7 +472,7 @@ const explained = (bytes) => {
|
|
|
426
472
|
}
|
|
427
473
|
return rows;
|
|
428
474
|
};
|
|
429
|
-
/** `oars pool outbox`: prints each batch as it would be sent, the request line and every header included, with the bearer credential as its size and nothing else masked, then its SHA-256, and records that it was shown. `--explain` adds each field's rule. */
|
|
475
|
+
/** `oars pool outbox`: prints each batch as it would be sent (a batch past one request prints each part's request, in order), the request line and every header included, with the bearer credential as its size and nothing else masked, then its SHA-256, and records that it was shown. `--explain` adds each field's rule. */
|
|
430
476
|
export const poolOutbox = async (deps, options) => {
|
|
431
477
|
const runtime = runtimeOf(deps);
|
|
432
478
|
const server = poolServer(deps.env, OUTBOX, options.server);
|
|
@@ -435,35 +481,38 @@ export const poolOutbox = async (deps, options) => {
|
|
|
435
481
|
const { batches, edited } = await heldBatches(outbox);
|
|
436
482
|
const masker = new Masker();
|
|
437
483
|
masker.add(token);
|
|
484
|
+
const sent = await sentLog(outbox);
|
|
438
485
|
const shown = [];
|
|
439
486
|
for (const batch of batches) {
|
|
440
487
|
const fields = options.explain === true ? explained(batch.bytes) : undefined;
|
|
441
|
-
|
|
488
|
+
// A batch past one request prints every part's request, in the order they are sent.
|
|
489
|
+
const request = callsOf(token, batch.bytes, batch.hash).map((call) => printed(outgoing(deps, server, POOL_DEPOSITS, call), masker)).join('\n');
|
|
490
|
+
shown.push({ hash: batch.hash, request, ...(sent.has(batch.hash) ? { sent: sent.get(batch.hash) ?? '' } : {}), ...(fields === undefined ? {} : { fields }) });
|
|
442
491
|
}
|
|
492
|
+
const { copies } = await heldRaw(outbox);
|
|
493
|
+
const rawBlocks = copies.flatMap((copy) => rawParts(copy.body).map((part, at, all) => [`raw part ${String(at + 1)}/${String(all.length)} sha256: ${copy.hash} bytes: ${String(Buffer.byteLength(part))}${sent.has(copy.hash) ? ` sent: ${sent.get(copy.hash) ?? ''}` : ''}`, ...(options.explain === true ? [part] : [])].join('\n')));
|
|
443
494
|
for (const { hash } of shown)
|
|
444
495
|
await markShown(outbox, hash, deps.now());
|
|
445
496
|
const stale = await staleBatches(outbox);
|
|
446
|
-
const blocks = shown.map(({ hash, request, fields }) => [request, `sha256: ${hash}`, ...Object.entries(fields ?? {}).map(([field, row]) => ` ${field}: ${row}`)].join('\n'));
|
|
497
|
+
const blocks = shown.map(({ hash, request, sent: at, fields }) => [request, `sha256: ${hash}`, ...(at === undefined ? [] : [`sent: ${at}`]), ...Object.entries(fields ?? {}).map(([field, row]) => ` ${field}: ${row}`)].join('\n'));
|
|
447
498
|
const notes = [
|
|
448
499
|
...(edited === 0 ? [] : [`${String(edited)} ${edited === 1 ? 'batch was' : 'batches were'} edited on disk since ${edited === 1 ? 'it was' : 'they were'} written and ${edited === 1 ? 'is' : 'are'} not printed or sent. Run oars pool deposit to build ${edited === 1 ? 'it' : 'them'} again.`]),
|
|
449
500
|
...(stale === 0 ? [] : [`${String(stale)} ${stale === 1 ? 'batch was' : 'batches were'} built under an older vocabulary, field list, or skeleton format and will never be sent.`]),
|
|
450
501
|
];
|
|
451
|
-
const text = shown.length === 0 ? ['The outbox is empty. Run oars pool deposit first.', ...notes].join('\n') : [...blocks, ...notes].join('\n\n');
|
|
452
|
-
return { document: { ok: true, server, batches: shown, edited, stale }, text };
|
|
502
|
+
const text = shown.length === 0 && rawBlocks.length === 0 ? ['The outbox is empty. Run oars pool deposit first.', ...notes].join('\n') : [...blocks, ...rawBlocks, ...notes].join('\n\n');
|
|
503
|
+
return { document: { ok: true, server, batches: shown, raw: copies.map(({ hash, bytes }) => ({ hash, bytes, ...(sent.has(hash) ? { sent: sent.get(hash) ?? '' } : {}) })), edited, stale }, text };
|
|
453
504
|
};
|
|
454
|
-
/**
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
}
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
}
|
|
465
|
-
const verified = await verifyBundle(parsed, key, harness, harnessVersion);
|
|
466
|
-
return verified.ok ? { etag: isObject(parsed) ? textIn(parsed, 'etag') : undefined, bundle: verified.bundle } : undefined;
|
|
505
|
+
/**
|
|
506
|
+
* What the pool says it served a bundle at, printed as the bundle states it: the client holds no floor of its own and applies none (a floor is the pool's deploy setting), so a bundle that states none prints nothing.
|
|
507
|
+
* A family on a floor of its own is named with it, but only a family this client knows: a key the pool adds later parses and travels in `--json`, and no pool-supplied string reaches the terminal.
|
|
508
|
+
*/
|
|
509
|
+
const floorSentence = (floors) => {
|
|
510
|
+
if (floors === undefined)
|
|
511
|
+
return '';
|
|
512
|
+
const own = Object.entries(floors.families ?? {})
|
|
513
|
+
.filter(([family]) => FIRST_ORDER_FAMILIES.some((known) => known === family))
|
|
514
|
+
.map(([family, floor]) => `${family} at ${String(floor)}`);
|
|
515
|
+
return ` The pool served it at a public floor of ${String(floors.public)} clusters and a disclosure floor of ${String(floors.disclosure)}${own.length === 0 ? '' : `, with ${own.join(' and ')}`}.`;
|
|
467
516
|
};
|
|
468
517
|
/** The weekly cap, as a failure of its own code: the bundle on disk is untouched, and the words say when the pool will answer again. */
|
|
469
518
|
const cappedError = (deps, reply, kept) => {
|
|
@@ -475,25 +524,34 @@ const cappedError = (deps, reply, kept) => {
|
|
|
475
524
|
};
|
|
476
525
|
/** What a fetch asks for when no `--harness` is given, and what it falls back to when no one harness leads the local store. */
|
|
477
526
|
const DEFAULT_HARNESS = 'claude-code';
|
|
527
|
+
/** The harnesses a bundle is served for. `otel` reads the framework into the version slot, so no public bundle is keyed by it (lead 219): it is in `HARNESSES` for the wire, not for a fetch. */
|
|
528
|
+
const FETCHABLE = HARNESSES.filter((name) => name !== 'otel');
|
|
478
529
|
/**
|
|
479
530
|
* The harness a fetch asks for. `--harness` names it, and nothing is printed. Without the flag it is the harness the local store holds the most sessions of, and the choice and the way to
|
|
480
|
-
* change it go to stderr before the request: a fetch never picks silently. An empty store or a tie asks for Claude Code's and says so. It reads counts only, and none of them leaves the machine.
|
|
531
|
+
* change it go to stderr before the request: a fetch never picks silently. An empty store or a tie asks for Claude Code's and says so. Sessions of a harness with no bundle (`otel`) are not counted. It reads counts only, and none of them leaves the machine.
|
|
481
532
|
*/
|
|
482
533
|
const fetchHarness = async (deps, storeDir, named) => {
|
|
483
534
|
if (named !== undefined) {
|
|
535
|
+
if (named === 'otel')
|
|
536
|
+
throw usage(`otel has no pool bundle. Choose one of ${FETCHABLE.join(', ')}.`, FETCH);
|
|
484
537
|
if (!isHarness(named))
|
|
485
|
-
throw usage(`${named} is not a harness the pool serves. Choose one of ${
|
|
538
|
+
throw usage(`${named} is not a harness the pool serves. Choose one of ${FETCHABLE.join(', ')}.`, FETCH);
|
|
486
539
|
return named;
|
|
487
540
|
}
|
|
488
541
|
const counts = new Map();
|
|
489
|
-
|
|
490
|
-
|
|
542
|
+
const sessions = (await loadStore(storeDir)).sessions;
|
|
543
|
+
for (const session of sessions) {
|
|
544
|
+
const harness = sessionHarness(session);
|
|
545
|
+
if (FETCHABLE.includes(harness))
|
|
546
|
+
counts.set(harness, (counts.get(harness) ?? 0) + 1);
|
|
547
|
+
}
|
|
491
548
|
const [top, runnerUp] = [...counts.entries()].sort((a, b) => b[1] - a[1]);
|
|
492
549
|
const lead = top !== undefined && (runnerUp === undefined || top[1] > runnerUp[1]) ? top : undefined;
|
|
493
550
|
const harness = lead === undefined ? DEFAULT_HARNESS : lead[0];
|
|
494
551
|
const total = [...counts.values()].reduce((sum, count) => sum + count, 0);
|
|
495
|
-
const
|
|
496
|
-
|
|
552
|
+
const none = sessions.length === 0 ? 'your local store is empty' : 'your local store holds no session of a harness the pool serves';
|
|
553
|
+
const why = lead === undefined ? `${total === 0 ? none : 'no one harness leads your local store'}, so Claude Code is the default` : `the harness your local store holds the most sessions of (${String(lead[1])} of ${String(total)})`;
|
|
554
|
+
deps.io.stderr(`No --harness given: fetching ${harness}, ${why}. Pass --harness <name> to fetch another (${FETCHABLE.join(', ')}).
|
|
497
555
|
`);
|
|
498
556
|
return harness;
|
|
499
557
|
};
|
|
@@ -511,32 +569,101 @@ export const poolFetch = async (deps, options) => {
|
|
|
511
569
|
const credential = new Masker();
|
|
512
570
|
credential.add(token);
|
|
513
571
|
const reply = await ask(deps, FETCH, server, `/bundle/${harness}/${encodeURIComponent(harnessVersion)}/${encodeURIComponent(VOCAB_VERSION)}`, { token, show: credential, ...(kept?.etag === undefined ? {} : { ifNoneMatch: kept.etag }) });
|
|
514
|
-
|
|
572
|
+
const emit = emitOf(deps);
|
|
573
|
+
if (reply.status === 429) {
|
|
574
|
+
emit('fetch', { harness, fetches: 1, capHits: 1 });
|
|
515
575
|
throw cappedError(deps, reply, kept !== undefined);
|
|
576
|
+
}
|
|
516
577
|
if (reply.status === 304) {
|
|
517
578
|
// A 304 answers an If-None-Match, so one to a fetch that sent none is a pool that has lost track of what it was asked.
|
|
518
|
-
if (kept?.etag === undefined)
|
|
579
|
+
if (kept?.etag === undefined) {
|
|
580
|
+
emit('fetch', { harness, fetches: 1 });
|
|
519
581
|
throw new CliError('The pool answered 304 to a fetch that named no kept bundle.', ExitCode.UNEXPECTED, FETCH);
|
|
520
|
-
|
|
582
|
+
}
|
|
583
|
+
const { cells, motifs, reads, asOf, floors } = kept.bundle;
|
|
584
|
+
emit('fetch', { harness, fetches: 1, etagHits: 1, reads: reads.length });
|
|
521
585
|
return {
|
|
522
|
-
document: { ok: true, server, harness, harnessVersion, file, unchanged: true, cells: cells.length, motifs: motifs.length, reads: reads.length, asOf },
|
|
523
|
-
text: `${harness} ${harnessVersion} is unchanged: the bundle at ${file} is still current (${String(reads.length)} reads, ${String(cells.length)} cells, and ${String(motifs.length)} motifs)
|
|
586
|
+
document: { ok: true, server, harness, harnessVersion, file, unchanged: true, cells: cells.length, motifs: motifs.length, reads: reads.length, asOf, ...(floors === undefined ? {} : { floors }) },
|
|
587
|
+
text: `${harness} ${harnessVersion} is unchanged: the bundle at ${file} is still current (${String(reads.length)} reads, ${String(cells.length)} cells, and ${String(motifs.length)} motifs).${floorSentence(floors)}`,
|
|
524
588
|
};
|
|
525
589
|
}
|
|
526
|
-
|
|
590
|
+
// A refused fetch and a bundle that fails verification both reached the pool, so each is a fetch; only the success adds reads.
|
|
591
|
+
if (reply.status !== 200) {
|
|
592
|
+
emit('fetch', { harness, fetches: 1 });
|
|
527
593
|
throw refusal(FETCH, reply);
|
|
594
|
+
}
|
|
528
595
|
const verified = await verifyBundle(reply.body, signingKey, harness, harnessVersion);
|
|
529
596
|
if (!verified.ok) {
|
|
530
597
|
const title = `Refused the bundle: ${verified.reason}.`;
|
|
598
|
+
emit('fetch', { harness, fetches: 1 });
|
|
531
599
|
throw new CliError(title, ExitCode.INVALID, FETCH, { code: 'bundle_unverified', title });
|
|
532
600
|
}
|
|
533
601
|
const etag = reply.headers.get('etag');
|
|
534
602
|
await mkdir(dirname(file), { recursive: true });
|
|
535
603
|
await writeFile(file, `${JSON.stringify({ ...(isObject(reply.body) ? reply.body : {}), ...(etag === null ? {} : { etag }) })}
|
|
536
604
|
`);
|
|
537
|
-
const { cells, motifs, reads, asOf } = verified.bundle;
|
|
605
|
+
const { cells, motifs, reads, asOf, floors } = verified.bundle;
|
|
606
|
+
emit('fetch', { harness, fetches: 1, reads: reads.length });
|
|
538
607
|
return {
|
|
539
|
-
document: { ok: true, server, harness, harnessVersion, file, unchanged: false, cells: cells.length, motifs: motifs.length, reads: reads.length, asOf },
|
|
540
|
-
text: `Fetched ${harness} ${harnessVersion}: ${String(reads.length)} reads, ${String(cells.length)} cells, and ${String(motifs.length)} motifs, verified against the pinned key and kept at ${file}
|
|
608
|
+
document: { ok: true, server, harness, harnessVersion, file, unchanged: false, cells: cells.length, motifs: motifs.length, reads: reads.length, asOf, ...(floors === undefined ? {} : { floors }) },
|
|
609
|
+
text: `Fetched ${harness} ${harnessVersion}: ${String(reads.length)} reads, ${String(cells.length)} cells, and ${String(motifs.length)} motifs, verified against the pinned key and kept at ${file}.${floorSentence(floors)}`,
|
|
610
|
+
};
|
|
611
|
+
};
|
|
612
|
+
const RAW = 'pool-raw';
|
|
613
|
+
/** The raw prompt, in plain words: shown by `oars pool raw` until it is answered, and what the nightly send relies on. Redaction is best effort and the text says so. */
|
|
614
|
+
export const RAW_PROMPT = [
|
|
615
|
+
'Raw transcripts are separate from the covenant. If you say yes, then once the pool says your account accepted its covenant, the nightly task uploads a redacted copy of each full transcript of every folder you allowed, beside its skeleton, except a folder you set to skeleton only or off.',
|
|
616
|
+
'Redaction runs on this machine, before anything is queued, and it is best effort: it masks secret shapes it knows, emails, your username, home paths, environment values, and hosts, and it can miss a secret that has no recognizable shape or a name written in prose.',
|
|
617
|
+
'It also does not catch a host with no dot, a public host name outside a link, quoted file contents and command output, a secret split across lines, images and attachments, a JSON env value, your username written another way, another person\'s hyphenated name in a project folder path, or an IPv6 address with a zone or inside a longer word, and a common-word account name is masked everywhere it appears.',
|
|
618
|
+
'Say no, or say nothing, and only skeletons go to the pool. Change your mind any time: oars pool raw --consent yes|no. A no stops later sends only; it cannot recall what was already sent.',
|
|
619
|
+
].join('\n');
|
|
620
|
+
/** `oars pool raw`: shows the raw prompt and the answer so far; `--consent yes|no` records the answer; `--folder <entry> --mode all|skeleton|off` sets what one folder may send. */
|
|
621
|
+
export const poolRaw = async (deps, options) => {
|
|
622
|
+
const runtime = runtimeOf(deps);
|
|
623
|
+
if (options.consent !== undefined && options.consent !== 'yes' && options.consent !== 'no')
|
|
624
|
+
throw usage('--consent takes yes or no.', RAW);
|
|
625
|
+
if ((options.folder === undefined) !== (options.mode === undefined))
|
|
626
|
+
throw usage('--folder and --mode go together: --folder <folder or glob> --mode all|skeleton|off.', RAW);
|
|
627
|
+
if (options.mode !== undefined && options.mode !== 'all' && options.mode !== 'skeleton' && options.mode !== 'off')
|
|
628
|
+
throw usage('--mode takes all, skeleton, or off.', RAW);
|
|
629
|
+
const { folder, mode } = options;
|
|
630
|
+
const choices = await updatePoolChoices(runtime.storeDir, (current) => {
|
|
631
|
+
if (options.consent !== undefined)
|
|
632
|
+
current.rawConsent = options.consent === 'yes' ? 'granted' : 'declined';
|
|
633
|
+
if (folder !== undefined) {
|
|
634
|
+
if (mode === 'all')
|
|
635
|
+
Reflect.deleteProperty(current.folders, folder);
|
|
636
|
+
else if (mode === 'skeleton' || mode === 'off')
|
|
637
|
+
current.folders[folder] = mode;
|
|
638
|
+
}
|
|
639
|
+
});
|
|
640
|
+
const answer = choices.rawConsent === null ? 'not answered yet: only skeletons go to the pool' : choices.rawConsent === 'granted' ? 'yes: redacted raw copies leave' : 'no: only skeletons go to the pool';
|
|
641
|
+
const switched = Object.entries(choices.folders).map(([entry, set]) => ` ${entry}: ${set === 'off' ? 'nothing sent to the pool' : 'skeletons only'}`);
|
|
642
|
+
const lines = [...(choices.rawConsent === null ? [RAW_PROMPT, ''] : []), `Raw copies: ${answer}.`, ...(switched.length === 0 ? [] : ['Folders switched down:', ...switched])];
|
|
643
|
+
return { document: { ok: true, rawConsent: choices.rawConsent, folders: choices.folders }, text: lines.join('\n') };
|
|
644
|
+
};
|
|
645
|
+
const COVERAGE = 'pool-coverage';
|
|
646
|
+
/** A named value from a closed list, or a usage error that lists the members. */
|
|
647
|
+
const oneOfList = (list, flag, value) => {
|
|
648
|
+
const found = list.find((entry) => entry === value);
|
|
649
|
+
if (found === undefined)
|
|
650
|
+
throw usage(`${flag} takes one of ${list.join(', ')}; got ${value}. Nothing was saved.`, COVERAGE);
|
|
651
|
+
return found;
|
|
652
|
+
};
|
|
653
|
+
/**
|
|
654
|
+
* `oars pool coverage`: the two bands only the operator can state, kept on this machine and read at deposit. They are what the pool can say about whom a deposit covers, not a filter; `unknown` states nothing.
|
|
655
|
+
* With no flag it prints what is set.
|
|
656
|
+
*/
|
|
657
|
+
export const poolCoverage = async (deps, options) => {
|
|
658
|
+
const { storeDir } = runtimeOf(deps);
|
|
659
|
+
const before = await readCoverageSetting(storeDir);
|
|
660
|
+
const setting = {
|
|
661
|
+
orgSize: options.orgSize === undefined ? before.orgSize : oneOfList(ORG_SIZES, '--org-size', options.orgSize),
|
|
662
|
+
repoVisibility: options.repoVisibility === undefined ? before.repoVisibility : oneOfList(REPO_VISIBILITIES, '--repo-visibility', options.repoVisibility),
|
|
541
663
|
};
|
|
664
|
+
const changed = options.orgSize !== undefined || options.repoVisibility !== undefined;
|
|
665
|
+
if (changed)
|
|
666
|
+
await writeCoverageSetting(storeDir, setting);
|
|
667
|
+
const lines = [`Organization size: ${setting.orgSize}`, `Repository visibility: ${setting.repoVisibility}`, 'Both stay on this machine until a deposit states them as bands; nothing is looked up. The team band is counted from each repository\'s local git log at deposit.'];
|
|
668
|
+
return { document: { ok: true, saved: changed, ...setting }, text: lines.join('\n') };
|
|
542
669
|
};
|