@manyoars/oars 0.5.1 → 0.5.3
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/LICENSE +1 -1
- package/README.md +2 -2
- package/dist/cli.js +1 -1
- package/dist/sessions/empty-state.d.ts +1 -1
- package/dist/sessions/empty-state.js +1 -1
- package/dist/sessions/injected.js +54 -6
- package/dist/sessions/pool-command.d.ts +40 -2
- package/dist/sessions/pool-command.js +181 -30
- package/dist/sessions/pool-nightly.js +3 -2
- package/dist/sessions/pool-send-lock.d.ts +72 -2
- package/dist/sessions/pool-send-lock.js +116 -18
- package/dist/sessions/pool-wire.d.ts +5 -4
- package/dist/sessions/pool-wire.js +14 -4
- package/dist/sessions/read-interaction.js +1 -1
- package/dist/sessions/read-lead-lag.js +1 -1
- package/dist/sessions/read-readable.js +1 -1
- package/dist/sessions/redact.d.ts +11 -2
- package/dist/sessions/redact.js +95 -19
- package/dist/sessions/repo-root.d.ts +7 -0
- package/dist/sessions/repo-root.js +17 -13
- package/dist/sessions/vocab-load.d.ts +7 -4
- package/dist/sessions/vocab-load.js +16 -6
- package/package.json +2 -2
package/LICENSE
CHANGED
|
@@ -187,7 +187,7 @@
|
|
|
187
187
|
same "printed page" as the copyright notice for easier
|
|
188
188
|
identification within third-party archives.
|
|
189
189
|
|
|
190
|
-
Copyright 2026
|
|
190
|
+
Copyright 2026 Many Oars Co.
|
|
191
191
|
|
|
192
192
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
193
193
|
you may not use this file except in compliance with the License.
|
package/README.md
CHANGED
|
@@ -209,9 +209,9 @@ Replay is local and read-only on your transcripts; it writes `crossings.jsonl` a
|
|
|
209
209
|
|
|
210
210
|
`oars pool` uses the Many Oars pool, `https://pool.manyoars.com`, which is live (deposits there are closed until its covenant is published), or a pool server you name with `--server <url>` or `OARS_POOL_URL`. Every other command works without it.
|
|
211
211
|
|
|
212
|
-
- `oars pool login --provider github|google` asks the pool to start the provider's device flow, prints the user code and link, and polls the pool until you approve; with `--verbose`, every request prints before it is sent. The pool holds the OAuth client and its secret, so the CLI needs no client ID or secret, and the provider's tokens never reach this machine.
|
|
212
|
+
- `oars pool login --provider github|google` asks the pool to start the provider's device flow, prints the user code and link, and polls the pool until you approve (a failed poll is retried with a longer wait until the code expires; the output names the exclusion-terms file when there is none, and the first `pool deposit` needs it); with `--verbose`, every request prints before it is sent. The pool holds the OAuth client and its secret, so the CLI needs no client ID or secret, and the provider's tokens never reach this machine.
|
|
213
213
|
- `oars pool logout` revokes this machine's credential at the pool, then removes the local sign-in. If the pool cannot be reached it still removes the local sign-in and says the credential was not revoked there.
|
|
214
|
-
- `oars pool deposit` writes a batch of session skeletons (a closed list of fields, each value redacted first; a host is hashed by the pool on arrival) to an outbox for each session that passes your folder allowlist and the exclusion check, and stops: no session data is sent. `--send <hash>` sends one batch that `outbox` printed and that is unchanged on disk, once this account has accepted the pool covenant (a person types the phrase the covenant screen states, at a terminal; `--accept-covenant <version>` only names the version expected and accepts nothing; the screen then asks "Are you in the EU, the EEA, or the UK? (yes/no)" and sends the answer, and `--in-eu-eea-uk <yes|no>` answers it without asking; while the pool is closed to those regions a yes, or a request from them, is refused with `region_closed` and nothing is accepted); before that it reads the covenant, sends no batch, and exits 3 with `covenant_not_accepted`. A hash that was never shown, or a batch that changed, is refused before any request. The detached sender is the other sender: after the pool says your account accepted its covenant, it sends new batches, and raw copies only if you said yes to the raw prompt (`oars pool raw --consent yes`, answered by typing the phrase `I consent to sending raw copies` at a terminal; a yes also tells the pool, and `--consent no` tells it to stop accepting raw copies, whatever a later request states (a machine that held a yes and is signed out exits nonzero: the pool was not told), and the pool's nightly job then erases the raw copies it already holds, starting that night; a large account can take more than one night). With no sign-in, no acceptance, or an unreachable server it sends nothing.
|
|
214
|
+
- `oars pool deposit` writes a batch of session skeletons (a closed list of fields, each value redacted first; a host is hashed by the pool on arrival) to an outbox for each session that passes your folder allowlist and the exclusion check and whose last line is over 6 hours old, and stops: no session data is sent. `--send <hash>` sends one batch that `outbox` printed and that is unchanged on disk, once this account has accepted the pool covenant (a person types the phrase the covenant screen states, at a terminal; `--accept-covenant <version>` only names the version expected and accepts nothing; the screen then asks "Are you in the EU, the EEA, or the UK? (yes/no)" and sends the answer, and `--in-eu-eea-uk <yes|no>` answers it without asking; while the pool is closed to those regions a yes, or a request from them, is refused with `region_closed` and nothing is accepted); before that it reads the covenant, sends no batch, and exits 3 with `covenant_not_accepted`. A hash that was never shown, or a batch that changed, is refused before any request. The detached sender is the other sender: after the pool says your account accepted its covenant, it sends new batches, and raw copies only if you said yes to the raw prompt (`oars pool raw --consent yes`, answered by typing the phrase `I consent to sending raw copies` at a terminal; a yes also tells the pool, and `--consent no` tells it to stop accepting raw copies, whatever a later request states (a machine that held a yes and is signed out exits nonzero: the pool was not told), and the pool's nightly job then erases the raw copies it already holds, starting that night; a large account can take more than one night). With no sign-in, no acceptance, or an unreachable server it sends nothing.
|
|
215
215
|
- `deposit`, `outbox`, `export`, and `fetch` need `oars pool login` first; without it they exit 3 and name the server.
|
|
216
216
|
- `oars pool coverage [--org-size <band>] [--repo-visibility public|private|unknown]` sets the two coverage bands only you can state, read locally and never from the network; an unset band goes on the wire as `unknown`.
|
|
217
217
|
- `oars pool export --out <folder> [--raw]` saves a copy of what the pool holds about your account (GDPR Articles 15(3) and 20), as JSON, one file a page, readable by you only. It covers your account row (with your raw consent record), when your sign-ins end (never the credential), your covenant acceptances, your machines (without their keys, with the day of each one's last deposit and its key days), your repository keys, your deposit hours, your deposits as they now read (with the machine, host class, and launcher each came from, and the host class and launcher the batch claimed), your raw ledger, your staged deposit parts, your erasure audit rows, and your telemetry days. `--raw` also saves each raw transcript you deposited that has not been erased, checked against its hash. It needs `oars pool login`, writes nothing to the pool, prints what it saved, and exits nonzero naming any page or object that failed; a rerun overwrites the same files. Totals the pool derives from many accounts are not yours to export and are not in it.
|
package/dist/cli.js
CHANGED
|
@@ -151,7 +151,7 @@ const buildProgram = (deps, observed) => {
|
|
|
151
151
|
const period = flag(options, 'year') ? 'year' : flag(options, 'month') ? 'month' : 'week';
|
|
152
152
|
emit(deps, flag(options, 'json'), await sessionsCard(sessionDeps(deps), { period, text: flag(options, 'text'), out: text(options, 'out'), noCost: options.cost === false }, deps.env));
|
|
153
153
|
});
|
|
154
|
-
const sessions = program.command('sessions').description("deposit and recall your own coding-agent session logs, locally (these commands make no model call and no network call, except `gold arms`, which asks your own Claude subscription; telemetry is separate: oars telemetry)");
|
|
154
|
+
const sessions = program.command('sessions').description("deposit and recall your own coding-agent session logs, locally; try it on made-up sessions: oars sessions try (these commands make no model call and no network call, except `gold arms`, which asks your own Claude subscription; telemetry is separate: oars telemetry)");
|
|
155
155
|
sessions
|
|
156
156
|
.command('deposit')
|
|
157
157
|
.description('read session logs into the local observation log, from every folder unless --allow names some or --deny keeps some out; idempotent. Subagent transcripts under each session (<project>/<session>/subagents/) are deposited with their parent, and each raw transcript is hard-linked or copied into the oars archive beside the store')
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { Source } from './sources.js';
|
|
2
2
|
import type { DepositResult, SessionsRuntime } from './store.js';
|
|
3
3
|
/** `recall` over a store with no sessions in it. */
|
|
4
|
-
export declare const EMPTY_RECALL = "Your store is empty, so there is nothing to recall. See it work on made-up sessions with `oars sessions try`, or fill it with `oars sessions deposit`, which reads this machine's coding-agent session logs into a local store and archives a copy of each transcript.";
|
|
4
|
+
export declare const EMPTY_RECALL = "Your store is empty, so there is nothing to recall. See it work on made-up sessions with `oars sessions try`, or fill it with `oars sessions deposit`, which reads this machine's coding-agent session logs into a local store and archives a copy of each transcript. It reads your own session history, not made-up sessions.";
|
|
5
5
|
/** `books` over a store with no sessions in it; `what` is "prompts" or "pulls". */
|
|
6
6
|
export declare const emptyBooks: (what: string) => string;
|
|
7
7
|
/**
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// What `deposit`, `recall`, and `books` say when there is nothing to show, so a first run reads as "nothing found here" and not as a broken tool.
|
|
2
2
|
import { compileAllow, isDenial } from './allow.js';
|
|
3
3
|
/** The two commands that get someone out of an empty store: watch it work on made-up sessions first with `sessions try`, or fill it from their own sessions with `sessions deposit`. */
|
|
4
|
-
const WAYS_OUT = "See it work on made-up sessions with `oars sessions try`, or fill it with `oars sessions deposit`, which reads this machine's coding-agent session logs into a local store and archives a copy of each transcript.";
|
|
4
|
+
const WAYS_OUT = "See it work on made-up sessions with `oars sessions try`, or fill it with `oars sessions deposit`, which reads this machine's coding-agent session logs into a local store and archives a copy of each transcript. It reads your own session history, not made-up sessions.";
|
|
5
5
|
/** `recall` over a store with no sessions in it. */
|
|
6
6
|
export const EMPTY_RECALL = `Your store is empty, so there is nothing to recall. ${WAYS_OUT}`;
|
|
7
7
|
/** `books` over a store with no sessions in it; `what` is "prompts" or "pulls". */
|
|
@@ -76,6 +76,31 @@ const mapContent = (content, text, result, definition) => {
|
|
|
76
76
|
});
|
|
77
77
|
return items.some((item, at) => item !== content[at]) ? items : content;
|
|
78
78
|
};
|
|
79
|
+
// Harness text a request blob of the system role carries, by what it opens with or names. The whole block is marked, under the kind (and so the hash) its
|
|
80
|
+
// attachment record gets, because it is the same text. First match wins. A system text with none of these forms is left to the other rules.
|
|
81
|
+
const SYSTEM_FORMS = [
|
|
82
|
+
[/\bSessionStart(?::[\w-]+)? hook\b|\bhook additional context\b/, 'hook-additional-context'],
|
|
83
|
+
[/following skills are available for use with the Skill tool/i, 'skill-listing'],
|
|
84
|
+
[/Available agent types/i, 'agent-listing-delta'],
|
|
85
|
+
[/^\s*#{1,3} MCP Server Instructions\b|following MCP servers have provided instructions/m, 'mcp-instructions-delta'],
|
|
86
|
+
[/^\s*#{1,3} Environment\s*$|^\s*You have been invoked in the following environment/m, 'environment'],
|
|
87
|
+
[/Contents of [^\n]*?[\\/]\.claude[\\/]rules[\\/][^\n]*\.md\b|Contents of [^\n]*?(?:CLAUDE|AGENTS|MEMORY)(?:\.local)?\.md\b/, 'instructions'],
|
|
88
|
+
];
|
|
89
|
+
// A memory file: an instruction file, the memory index, a note under a `memory` folder, or a rules file.
|
|
90
|
+
const MEMORY_PATH = /(?:^|[\\/])(?:CLAUDE|AGENTS|MEMORY)(?:\.local)?\.md$|[\\/]memory[\\/][^\\/]+\.md$|[\\/]\.claude[\\/]rules[\\/][^\\/]+\.md$/i;
|
|
91
|
+
const MEMORY_MENTION = /(?:CLAUDE|AGENTS|MEMORY)(?:\.local)?\.md\b|[\\/]memory[\\/][^\s"'`\\/]+\.md\b/i;
|
|
92
|
+
const CHANGED = /changed on disk|was modified/i;
|
|
93
|
+
/** The kind of harness text a system-role string is, or undefined: one of the forms above, or a note that a memory file changed. */
|
|
94
|
+
const systemKindOf = (text) => {
|
|
95
|
+
const form = SYSTEM_FORMS.find(([shape]) => shape.test(text));
|
|
96
|
+
if (form !== undefined)
|
|
97
|
+
return form[1];
|
|
98
|
+
return MEMORY_MENTION.test(text) && CHANGED.test(text) ? 'memory-file' : undefined;
|
|
99
|
+
};
|
|
100
|
+
const systemText = (key) => (text) => {
|
|
101
|
+
const kind = text.trim() === '' || MARKER.test(text.trim()) ? undefined : systemKindOf(text);
|
|
102
|
+
return kind === undefined ? text : markerOf(key, kind, text);
|
|
103
|
+
};
|
|
79
104
|
const messageRule = (key, position, known) => {
|
|
80
105
|
const whole = (text) => (text.trim() === '' || MARKER.test(text.trim()) ? text : markerOf(key, 'meta', text));
|
|
81
106
|
const leading = (text) => {
|
|
@@ -91,13 +116,25 @@ const messageRule = (key, position, known) => {
|
|
|
91
116
|
return content === message['content'] || content === undefined ? message : { ...message, content };
|
|
92
117
|
};
|
|
93
118
|
};
|
|
94
|
-
const withMessage = (line, field, rule) => {
|
|
119
|
+
const withMessage = (line, field, rule, role = 'user') => {
|
|
95
120
|
const message = line[field];
|
|
96
|
-
if (!isObject(message) || message['role'] !==
|
|
121
|
+
if (!isObject(message) || message['role'] !== role)
|
|
97
122
|
return line;
|
|
98
123
|
const next = rule(message);
|
|
99
124
|
return next === message ? line : { ...line, [field]: next };
|
|
100
125
|
};
|
|
126
|
+
/** The rule for a request blob of the system role: the harness's own forms first, then the text other lines repeat. */
|
|
127
|
+
const systemMessage = (key, known) => {
|
|
128
|
+
const mark = systemText(key);
|
|
129
|
+
const text = (value) => {
|
|
130
|
+
const next = mark(value);
|
|
131
|
+
return next !== value || !known.has(normalize(value)) ? next : markerOf(key, 'meta', value);
|
|
132
|
+
};
|
|
133
|
+
return (message) => {
|
|
134
|
+
const content = mapContent(message['content'], text, text, (tool) => tool);
|
|
135
|
+
return content === message['content'] || content === undefined ? message : { ...message, content };
|
|
136
|
+
};
|
|
137
|
+
};
|
|
101
138
|
const withField = (line, field, change) => {
|
|
102
139
|
const value = line[field];
|
|
103
140
|
if (value === undefined)
|
|
@@ -168,6 +205,8 @@ export const injectedRule = (key, known = new Set()) => {
|
|
|
168
205
|
const userMessage = messageRule(key, 'typed', known);
|
|
169
206
|
const metaMessage = messageRule(key, 'meta', known);
|
|
170
207
|
const blobMessage = messageRule(key, 'blob', known);
|
|
208
|
+
const systemBlob = systemMessage(key, known);
|
|
209
|
+
const memory = blocksIn(key, 'memory-file');
|
|
171
210
|
// A meta line's `origin` says where the injected text came from; its `body` and `handback` are that text again, while `kind` stays readable.
|
|
172
211
|
const originText = (origin) => {
|
|
173
212
|
if (!isObject(origin))
|
|
@@ -179,10 +218,16 @@ export const injectedRule = (key, known = new Set()) => {
|
|
|
179
218
|
case 'user':
|
|
180
219
|
return line['isMeta'] === true ? withField(withMessage(line, 'message', metaMessage), 'origin', originText) : withMessage(line, 'message', userMessage);
|
|
181
220
|
case 'api-request-blob':
|
|
182
|
-
return withMessage(line, 'message', blobMessage);
|
|
221
|
+
return withMessage(withMessage(line, 'message', blobMessage), 'message', systemBlob, 'system');
|
|
183
222
|
case 'attachment': {
|
|
184
223
|
const attachment = line['attachment'];
|
|
185
224
|
const kind = isObject(attachment) ? attachment['type'] : undefined;
|
|
225
|
+
// A memory file the model edited or that changed on disk: the snippet (and the message it was shown in) is the file's text.
|
|
226
|
+
const file = isObject(attachment) ? attachment['filename'] : undefined;
|
|
227
|
+
if (kind === 'edited_text_file' && typeof file === 'string' && MEMORY_PATH.test(file)) {
|
|
228
|
+
const snippet = (value) => (isObject(value) ? withField(value, 'snippet', (text) => mapStrings(text, 'snippet', memory)) : value);
|
|
229
|
+
return withField(withField(line, 'attachment', snippet), 'rendered', (value) => mapStrings(value, 'rendered', memory));
|
|
230
|
+
}
|
|
186
231
|
if (typeof kind !== 'string' || !INJECTED_ATTACHMENTS.has(kind))
|
|
187
232
|
return line;
|
|
188
233
|
// The record repeats its text as the message the model was shown (`rendered`), so that is the same block again.
|
|
@@ -197,9 +242,12 @@ export const injectedRule = (key, known = new Set()) => {
|
|
|
197
242
|
});
|
|
198
243
|
case 'system':
|
|
199
244
|
// The hook output a stop-hook summary carries: what a hook printed, its error text, and the command line it ran.
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
245
|
+
if (line['subtype'] === 'stop_hook_summary')
|
|
246
|
+
return HOOK_SUMMARY_FIELDS.reduce((acc, name) => withField(acc, name, (value) => mapStrings(value, name, blocksIn(key, 'hook-summary'))), line);
|
|
247
|
+
// Any other system text is the harness speaking (a changed memory file, a notice), except the echo of a command the operator ran: that is operator output, not an instruction.
|
|
248
|
+
if (line['subtype'] === 'local_command')
|
|
249
|
+
return line;
|
|
250
|
+
return withField(line, 'content', (value) => (typeof value === 'string' ? systemText(key)(value) : value));
|
|
203
251
|
case 'session_meta':
|
|
204
252
|
case 'turn_context':
|
|
205
253
|
return withField(line, 'payload', (payload) => (isObject(payload) ? codexFields(key, payload) : payload));
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Outcome, RunDeps } from '../cli.js';
|
|
2
2
|
import { CliError } from '../errors.js';
|
|
3
3
|
import { Masker, type Outgoing } from './pool-print.js';
|
|
4
|
+
import { type BusyDetail, type LockFs, type LockTimes, type Waiting } from './pool-send-lock.js';
|
|
4
5
|
import { isObject, loadSignIns, textIn, type SignIn } from './pool-replica.js';
|
|
5
6
|
import { type MachineConfig } from './machine.js';
|
|
6
7
|
import { type SessionsRuntime } from './store.js';
|
|
@@ -46,8 +47,19 @@ interface Call {
|
|
|
46
47
|
export type Sender = (request: Outgoing) => Promise<Reply>;
|
|
47
48
|
/** 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. */
|
|
48
49
|
export declare const ask: (deps: RunDeps, step: string, server: string, path: string, init: Call, via?: Sender) => Promise<Reply>;
|
|
49
|
-
/**
|
|
50
|
-
export
|
|
50
|
+
/** What a guarded send runs under: the longest the pool gets to answer one request, and (for an interactive `--send`) the signal that ends the request when the person, or the program that launched the send, ends the run. The background sender has no one to interrupt it. */
|
|
51
|
+
export interface Guard {
|
|
52
|
+
stallMs: number;
|
|
53
|
+
signal?: AbortSignal;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* What a person reads after a send they stopped (Ctrl-C, a closed terminal, a launcher that died): the batch stays queued, and the background sender (hook-bin.js nightly, started by a later oars command
|
|
57
|
+
* or the end of a session, about once an hour at most, not on a timer) sends it once the covenant is accepted, whether or not this command ever finishes. The second line is added only when a request carrying the batch had gone out.
|
|
58
|
+
* The folder switch and the sign-out are the two real ways to stop that: the sender builds the outbox again first, which withdraws a folder that is off, and it sends nothing without a sign-in.
|
|
59
|
+
*/
|
|
60
|
+
export declare const stoppedText: (received: boolean) => string;
|
|
61
|
+
/** 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. An interactive send passes a `Guard`. */
|
|
62
|
+
export declare const networkSender: (deps: RunDeps, step: string, guard?: Guard) => Sender;
|
|
51
63
|
/** The server's named refusal, as a failure: its `error` is the problem's code in `--json`. */
|
|
52
64
|
export declare const refusal: (step: string, { status, body }: Reply) => CliError;
|
|
53
65
|
/**
|
|
@@ -66,6 +78,12 @@ export declare const poolLogin: (deps: RunDeps, options: {
|
|
|
66
78
|
export declare const poolLogout: (deps: RunDeps, options: {
|
|
67
79
|
server?: string | undefined;
|
|
68
80
|
}) => Promise<Outcome>;
|
|
81
|
+
/**
|
|
82
|
+
* The CLI's own words around the covenant screen, never inside it: the screen text and the hash of it that the pool records stay exactly what the pool served. They say what a deposit gives and what to run next, and
|
|
83
|
+
* they promise only access, as covenant 2.1 does ("What you get" and Section 4.3). The third line points at the full text because Part A is a summary and Section 16.2 makes the full covenant the whole agreement.
|
|
84
|
+
*/
|
|
85
|
+
export declare const OFFER_BEFORE: readonly string[];
|
|
86
|
+
export declare const OFFER_AFTER: readonly string[];
|
|
69
87
|
/** A person is here when input and the screen's stream are both terminals: a typed phrase counts only for a text that reached a screen. */
|
|
70
88
|
export declare const isTerminal: (deps: RunDeps) => boolean;
|
|
71
89
|
/** The next line a person types, or `undefined` when the input ends first. The interface is closed after it, so the terminal is released. */
|
|
@@ -94,6 +112,25 @@ export declare const postRaw: (deps: RunDeps, options: {
|
|
|
94
112
|
}) => Promise<void>;
|
|
95
113
|
/** The exclusion terms in force now, re-read from the term file each time it is asked: empty when the file is missing or unreadable. */
|
|
96
114
|
export declare const currentTerms: (deps: RunDeps) => Promise<string[]>;
|
|
115
|
+
/** What a test replaces in the send lock: its times and its clock, pause, and process checks, so a wait runs on driven time. */
|
|
116
|
+
export interface SendLock {
|
|
117
|
+
times: Partial<LockTimes>;
|
|
118
|
+
fs: LockFs;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* How long the pool gets to answer one request of a send (an interactive `--send` and the background sender) before the send ends. It is a deadline for the whole request, the upload and the answer together,
|
|
122
|
+
* not an idle timeout: a batch is one session's skeleton, whose largest fixture is 887 KB (docs/pool.md), which a link of 240 kbit/s or better uploads inside 30 seconds. A slower link fails each try with the stall
|
|
123
|
+
* sentence; resetting the timer once headers arrive would not help it, because the upload is what is slow.
|
|
124
|
+
*/
|
|
125
|
+
export declare const SEND_STALL_MS = 30000;
|
|
126
|
+
/** What the person reads within a second of a send that has to wait: whose lock it is, how old it is, and how long this run waits. */
|
|
127
|
+
export declare const waitingLine: ({ holder, ageMs, waitMs }: Waiting) => string;
|
|
128
|
+
/**
|
|
129
|
+
* What a send that gave up says: nothing was sent, who held the lock, and when a retry can succeed. A live holder on this host touches the lock every quarter of staleMs, even while it waits on a stalled pool, so the clock
|
|
130
|
+
* never frees its lock: the true condition is its end (a dead holder is taken over at once). Only a holder the lock does not name, or one on another host, whose liveness cannot be read, is decided by the clock: a
|
|
131
|
+
* retry is certain once the lock has gone untouched for staleMs.
|
|
132
|
+
*/
|
|
133
|
+
export declare const busyLine: ({ holder, local, ageMs, untouchedMs, waitedMs, staleMs }: BusyDetail) => string;
|
|
97
134
|
/** The outbox key, and the skeleton `id`, of a machine's session: from the environment's session salt (per account, not per machine), else its own name standing in for the account, which a machine has none of. The holding route names its branch by the same key. */
|
|
98
135
|
export declare const machineKeyOf: (machine: MachineConfig, session: string) => string;
|
|
99
136
|
/**
|
|
@@ -110,6 +147,7 @@ export declare const poolDeposit: (deps: RunDeps, options: {
|
|
|
110
147
|
git?: Git | undefined;
|
|
111
148
|
session?: string | undefined;
|
|
112
149
|
covenant?: string | undefined;
|
|
150
|
+
lock?: SendLock | undefined;
|
|
113
151
|
}) => Promise<Outcome>;
|
|
114
152
|
/** `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. */
|
|
115
153
|
export declare const poolOutbox: (deps: RunDeps, options: {
|
|
@@ -19,7 +19,7 @@ import { carriedDeltas, heldDeltas, keptDeltas, markDeltasSent, placedOf, sentDe
|
|
|
19
19
|
import { Masker, printed, TRANSPORT_HEADERS, waitWords } from './pool-print.js';
|
|
20
20
|
import { rawAllowed, rawStale, sendsOf } from './pool-choices.js';
|
|
21
21
|
import { heldRaw, rawParts, removeRawExcept, writeRaw } from './pool-raw.js';
|
|
22
|
-
import { SendLockBusy, withSendLock } from './pool-send-lock.js';
|
|
22
|
+
import { SendInterrupted, SendLockBusy, watchInterrupt, withSendLock } from './pool-send-lock.js';
|
|
23
23
|
import { markSent, retirePulled, sentLog } from './pool-sent.js';
|
|
24
24
|
import { shellReader } from './projection.js';
|
|
25
25
|
import { isVocabularyExpired, REGISTRY_MAX_AGE_DAYS, VOCAB_SNAPSHOT_DATE } from './public-vocabulary.js';
|
|
@@ -47,6 +47,8 @@ const DEFAULT_INTERVAL_SEC = 5;
|
|
|
47
47
|
const DEFAULT_EXPIRY_SEC = 900;
|
|
48
48
|
/** RFC 8628: a `slow_down` adds five seconds to every poll after it. */
|
|
49
49
|
const SLOW_DOWN_MS = 5000;
|
|
50
|
+
/** The longest wait between polls after failed ones. */
|
|
51
|
+
const MAX_BACKOFF_MS = 60_000;
|
|
50
52
|
export { isObject, loadSignIns, textIn };
|
|
51
53
|
const usage = (message, step) => new CliError(message, ExitCode.USAGE, step);
|
|
52
54
|
/** The Many Oars pool, used when neither `--server` nor `OARS_POOL_URL` names another. */
|
|
@@ -100,29 +102,92 @@ export const ask = (deps, step, server, path, init, via) => {
|
|
|
100
102
|
const request = outgoing(deps, server, path, init);
|
|
101
103
|
return via === undefined ? send(deps, step, request, init.show, init.binary === true) : via(request);
|
|
102
104
|
};
|
|
103
|
-
/**
|
|
104
|
-
|
|
105
|
+
/** The pool took the request and then said nothing for `Guard.stallMs`. */
|
|
106
|
+
class PoolStalled extends Error {
|
|
107
|
+
}
|
|
108
|
+
/** A newcomer's words for the network failures a stalled or restarted pool produces; the raw reason follows the sentence in parentheses. */
|
|
109
|
+
const PLAIN_REASONS = [
|
|
110
|
+
[/ECONNRESET|socket hang up|UND_ERR_SOCKET/, 'the connection was dropped before the pool answered'],
|
|
111
|
+
[/ECONNREFUSED/, 'nothing is listening there'],
|
|
112
|
+
[/ENOTFOUND|EAI_AGAIN/, 'that name did not resolve'],
|
|
113
|
+
[/ETIMEDOUT|UND_ERR_CONNECT_TIMEOUT/, 'the connection timed out'],
|
|
114
|
+
[/EPIPE/, 'the connection closed while the batch was going out'],
|
|
115
|
+
];
|
|
116
|
+
const NOT_MARKED = 'Nothing was marked sent.';
|
|
117
|
+
const TRY_AGAIN = 'Run the same command to try again.';
|
|
118
|
+
const RETRY = `${NOT_MARKED} ${TRY_AGAIN}`;
|
|
119
|
+
/** Said after a stop or a stall once the request carrying the batch had gone out: the pool may hold it even though nothing here saw the answer. */
|
|
120
|
+
const MAY_HAVE_RECEIVED = 'The pool may already have received it.';
|
|
121
|
+
/** What `deposit` says after it writes the outbox: the covenant (the person's consent, docs/covenant 2.1) already lets the background sender send what is queued, with or without this command. A machine's batches are never sent by it. */
|
|
122
|
+
const SENT_LATER = 'Nothing was sent yet. Once the covenant is accepted, oars sends what is queued automatically, in the background.';
|
|
123
|
+
/**
|
|
124
|
+
* What a person reads after a send they stopped (Ctrl-C, a closed terminal, a launcher that died): the batch stays queued, and the background sender (hook-bin.js nightly, started by a later oars command
|
|
125
|
+
* or the end of a session, about once an hour at most, not on a timer) sends it once the covenant is accepted, whether or not this command ever finishes. The second line is added only when a request carrying the batch had gone out.
|
|
126
|
+
* The folder switch and the sign-out are the two real ways to stop that: the sender builds the outbox again first, which withdraws a folder that is off, and it sends nothing without a sign-in.
|
|
127
|
+
*/
|
|
128
|
+
export const stoppedText = (received) => [
|
|
129
|
+
'Stopped. This batch stays queued, and once the covenant is accepted oars sends it automatically in the background.',
|
|
130
|
+
...(received ? [MAY_HAVE_RECEIVED] : []),
|
|
131
|
+
'To keep a folder out, run oars pool raw --folder <folder> --mode off.',
|
|
132
|
+
'To stop this machine sending, run oars pool logout.',
|
|
133
|
+
].join('\n');
|
|
134
|
+
/** Why a guarded request ended without an answer, in plain words: a stall, an interrupt, or a network failure whose raw reason is kept after the sentence. The exit code is the unreachable one, as before. */
|
|
135
|
+
const guardedFailure = (step, cause, origin, guard, received) => {
|
|
136
|
+
if (cause instanceof PoolStalled)
|
|
137
|
+
return new CliError(`The pool at ${origin} did not answer within ${String(guard.stallMs / 1000)} seconds. ${received ? `${NOT_MARKED} ${MAY_HAVE_RECEIVED} ${TRY_AGAIN}` : RETRY}`, ExitCode.UNREACHABLE, step);
|
|
138
|
+
if (cause instanceof SendInterrupted)
|
|
139
|
+
return new CliError(stoppedText(received), ExitCode.UNEXPECTED, step);
|
|
140
|
+
const unreachable = unreachableError(step, cause, origin);
|
|
141
|
+
const raw = unreachable.message.slice(`Cannot reach the server at ${origin}: `.length);
|
|
142
|
+
const plain = PLAIN_REASONS.find(([pattern]) => pattern.test(raw));
|
|
143
|
+
return plain === undefined ? unreachable : new CliError(`Cannot reach the pool at ${origin}: ${plain[1]} (${raw}). ${RETRY}`, ExitCode.UNREACHABLE, step);
|
|
144
|
+
};
|
|
145
|
+
/** Prints the request when asked to, then sends exactly those headers and that body. A guarded request also ends when its stall time passes or its signal aborts, and fails in plain words. */
|
|
146
|
+
const send = async (deps, step, request, show, binary = false, guard) => {
|
|
105
147
|
if (show !== undefined)
|
|
106
148
|
deps.io.stderr(`${printed(request, show)}
|
|
107
149
|
`);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
150
|
+
const stop = new AbortController();
|
|
151
|
+
const relay = () => {
|
|
152
|
+
stop.abort(guard?.signal?.reason);
|
|
153
|
+
};
|
|
154
|
+
guard?.signal?.addEventListener('abort', relay, { once: true });
|
|
155
|
+
if (guard?.signal?.aborted === true)
|
|
156
|
+
relay();
|
|
157
|
+
const timer = guard === undefined ? undefined : setTimeout(() => {
|
|
158
|
+
stop.abort(new PoolStalled());
|
|
159
|
+
}, guard.stallMs);
|
|
160
|
+
// Whether the batch itself had gone out when the run ended: only the deposit request carries it, and only when it went out before the run was stopped.
|
|
161
|
+
let started = false;
|
|
162
|
+
const failure = (error) => (guard === undefined ? unreachableError(step, error, request.url.origin) : guardedFailure(step, stop.signal.aborted ? stop.signal.reason : error, request.url.origin, guard, started && request.url.pathname === POOL_DEPOSITS));
|
|
116
163
|
try {
|
|
117
|
-
|
|
164
|
+
let response;
|
|
165
|
+
try {
|
|
166
|
+
started = !stop.signal.aborted;
|
|
167
|
+
response = await fetch(request.url, { method: request.method, headers: request.headers, redirect: 'error', ...(guard === undefined ? {} : { signal: stop.signal }), ...(request.body === undefined ? {} : { body: request.body }) });
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
throw failure(error);
|
|
171
|
+
}
|
|
172
|
+
let body;
|
|
173
|
+
try {
|
|
174
|
+
body = binary && response.status === 200 ? new Uint8Array(await response.arrayBuffer()) : await response.json();
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
// An answer that began and then stalled is no answer; one that ended badly is read as no body, as before.
|
|
178
|
+
if (stop.signal.aborted)
|
|
179
|
+
throw failure(error);
|
|
180
|
+
body = undefined;
|
|
181
|
+
}
|
|
182
|
+
return { status: response.status, body, headers: response.headers };
|
|
118
183
|
}
|
|
119
|
-
|
|
120
|
-
|
|
184
|
+
finally {
|
|
185
|
+
clearTimeout(timer);
|
|
186
|
+
guard?.signal?.removeEventListener('abort', relay);
|
|
121
187
|
}
|
|
122
|
-
return { status: response.status, body, headers: response.headers };
|
|
123
188
|
};
|
|
124
|
-
/** 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. */
|
|
125
|
-
export const networkSender = (deps, step) => (request) => send(deps, step, request, undefined);
|
|
189
|
+
/** 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. An interactive send passes a `Guard`. */
|
|
190
|
+
export const networkSender = (deps, step, guard) => (request) => send(deps, step, request, undefined, false, guard);
|
|
126
191
|
/** The server's named refusal, as a failure: its `error` is the problem's code in `--json`. */
|
|
127
192
|
export const refusal = (step, { status, body }) => {
|
|
128
193
|
const code = isObject(body) ? textIn(body, 'error') : undefined;
|
|
@@ -156,9 +221,30 @@ ${proofLines(account, provedAt)}`;
|
|
|
156
221
|
/** Polls the pool until the person approves, and returns the pool's answer with the credential. Time is counted in the waits asked for, so a machine that slept through the code still gets one poll that says it expired. */
|
|
157
222
|
const awaitCredential = async (deps, server, started, show) => {
|
|
158
223
|
let { intervalMs } = started;
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
224
|
+
let failures = 0;
|
|
225
|
+
let lastFailure;
|
|
226
|
+
for (let waited = 0; waited < started.expiresMs;) {
|
|
227
|
+
// A failed poll (a network error or a 5xx) is the provider or the network having a bad minute, not the person's answer: wait longer each time, up to the cap, and ask again until the code's own expiry.
|
|
228
|
+
const wait = Math.min(intervalMs * 2 ** failures, Math.max(intervalMs, MAX_BACKOFF_MS));
|
|
229
|
+
await deps.sleep(wait);
|
|
230
|
+
waited += wait;
|
|
231
|
+
// `ask` rejects only with the unreachable failure of `send`.
|
|
232
|
+
const [settled] = await Promise.allSettled([ask(deps, LOGIN, server, '/v1/login/poll', { body: { handle: started.handle }, show })]);
|
|
233
|
+
// A rejection that is not the unreachable failure is a bug, not a bad minute: it ends the login as itself.
|
|
234
|
+
if (settled.status === 'rejected') {
|
|
235
|
+
if (!(settled.reason instanceof CliError) || settled.reason.exitCode !== ExitCode.UNREACHABLE)
|
|
236
|
+
throw settled.reason;
|
|
237
|
+
lastFailure = settled.reason;
|
|
238
|
+
}
|
|
239
|
+
const reply = settled.status === 'fulfilled' ? settled.value : undefined;
|
|
240
|
+
if (reply !== undefined && reply.status >= 500)
|
|
241
|
+
lastFailure = refusal(LOGIN, reply);
|
|
242
|
+
if (reply === undefined || reply.status >= 500) {
|
|
243
|
+
failures += 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
failures = 0;
|
|
247
|
+
lastFailure = undefined;
|
|
162
248
|
if (reply.status !== 200 || !isObject(reply.body))
|
|
163
249
|
throw closedProof(reply) ?? refusal(LOGIN, reply);
|
|
164
250
|
const status = textIn(reply.body, 'status');
|
|
@@ -175,6 +261,9 @@ const awaitCredential = async (deps, server, started, show) => {
|
|
|
175
261
|
else if (status !== 'pending')
|
|
176
262
|
throw new CliError('The pool answered a sign-in poll with a state this client does not know.', ExitCode.UNEXPECTED, LOGIN);
|
|
177
263
|
}
|
|
264
|
+
// Out of time with the last poll still failing: the failure is the truth (exit 8), not an expiry nobody saw.
|
|
265
|
+
if (lastFailure !== undefined)
|
|
266
|
+
throw lastFailure;
|
|
178
267
|
const title = 'The sign-in code expired. Run oars pool login again.';
|
|
179
268
|
throw new CliError(title, ExitCode.AUTH, LOGIN, { code: 'EXPIRED_TOKEN', title });
|
|
180
269
|
};
|
|
@@ -223,7 +312,11 @@ export const poolLogin = async (deps, options) => {
|
|
|
223
312
|
const pinned = { ...signIn, signingKeys: known === undefined ? signIn.signingKeys : held };
|
|
224
313
|
await mkdirOwnerOnly(storeDir);
|
|
225
314
|
await writeOwnerOnly(signInsFile(storeDir), `${JSON.stringify([...saved.filter((entry) => entry !== known), pinned])}\n`);
|
|
226
|
-
|
|
315
|
+
// The first deposit stops without a term file, so the login that precedes it says so.
|
|
316
|
+
const { terms: termsFile } = exclusionPaths(runtimeOf(deps).projectsRoot, deps.env);
|
|
317
|
+
const bare = (await loadTerms(termsFile)).length === 0;
|
|
318
|
+
return { document: { ok: true, server, account: signIn.account, expiresAt: signIn.expiresAt, ...(bare ? { termsFile } : {}) }, text: `Signed in to ${server} as ${signIn.account}.${bare ? `
|
|
319
|
+
${termsHint(termsFile)}` : ''}` };
|
|
227
320
|
};
|
|
228
321
|
/** The pool is not answering: it could not be reached, or a gateway in front of it says so. */
|
|
229
322
|
const poolDown = (status) => status === 502 || status === 503 || status === 504;
|
|
@@ -266,8 +359,18 @@ const regionOf = (text) => {
|
|
|
266
359
|
const answer = text?.trim().toLowerCase();
|
|
267
360
|
return answer === 'yes' || answer === 'no' ? answer : undefined;
|
|
268
361
|
};
|
|
362
|
+
/**
|
|
363
|
+
* The CLI's own words around the covenant screen, never inside it: the screen text and the hash of it that the pool records stay exactly what the pool served. They say what a deposit gives and what to run next, and
|
|
364
|
+
* they promise only access, as covenant 2.1 does ("What you get" and Section 4.3). The third line points at the full text because Part A is a summary and Section 16.2 makes the full covenant the whole agreement.
|
|
365
|
+
*/
|
|
366
|
+
export const OFFER_BEFORE = [
|
|
367
|
+
'Depositing gives your account access to the pool.',
|
|
368
|
+
'A counting deposit lifts the weekly limit on pool fetches for 90 days.',
|
|
369
|
+
'The covenant is the whole deal. Its full text is linked below. Read it before you type.',
|
|
370
|
+
];
|
|
371
|
+
export const OFFER_AFTER = ['Accepted. Sending your batch now.', 'Run oars pool fetch --help for your first pool download.'];
|
|
269
372
|
/** A send stops here when the server is closed or wants a covenant accepted, before the batch is read for the last time. */
|
|
270
|
-
const openCovenant = async (deps, server, token, accept, region, via) => {
|
|
373
|
+
const openCovenant = async (deps, server, token, accept, region, via, signal) => {
|
|
271
374
|
const reply = await ask(deps, DEPOSIT, server, '/v1/covenant', { token }, via);
|
|
272
375
|
if (reply.status !== 200 || !isObject(reply.body))
|
|
273
376
|
throw refusal(DEPOSIT, reply);
|
|
@@ -291,13 +394,17 @@ const openCovenant = async (deps, server, token, accept, region, via) => {
|
|
|
291
394
|
if (!isTerminal(deps)) {
|
|
292
395
|
throw notAccepted(`Covenant ${version} is not accepted for this account. Accepting it takes a person at a terminal who reads it and types its phrase: run this command from one. Nothing was accepted.`);
|
|
293
396
|
}
|
|
397
|
+
deps.io.stderr(`${OFFER_BEFORE.join('\n')}\n`);
|
|
294
398
|
deps.io.stderr(`\n${screen.text}\n\n> `);
|
|
295
399
|
emitOf(deps)('covenant-shown', { version });
|
|
296
400
|
// One reader for both lines: a second one opened after the first closed would lose a line the first had already taken from a pipe.
|
|
297
|
-
|
|
401
|
+
// A stop signal (Ctrl-C, a closed terminal) closes the reader, so a read that is waiting for the person ends with the signal's reason instead of holding the process open.
|
|
402
|
+
const lines = createInterface({ input: runtimeOf(deps).stdin, terminal: false, ...(signal === undefined ? {} : { signal }) });
|
|
298
403
|
const iterator = lines[Symbol.asyncIterator]();
|
|
299
404
|
const nextLine = async () => {
|
|
300
405
|
const next = await iterator.next();
|
|
406
|
+
if (signal?.aborted === true)
|
|
407
|
+
throw signal.reason;
|
|
301
408
|
return next.done === true ? undefined : next.value;
|
|
302
409
|
};
|
|
303
410
|
let answered = region;
|
|
@@ -319,6 +426,7 @@ const openCovenant = async (deps, server, token, accept, region, via) => {
|
|
|
319
426
|
const accepted = await ask(deps, DEPOSIT, server, '/v1/covenant/accept', { token, body }, via);
|
|
320
427
|
if (accepted.status !== 200)
|
|
321
428
|
throw refusal(DEPOSIT, accepted);
|
|
429
|
+
deps.io.stderr(`${OFFER_AFTER.join('\n')}\n`);
|
|
322
430
|
// A yes to the raw prompt belongs to the covenant it was given under, and a covenant can change what that prompt covers: accepting another version asks the raw question again.
|
|
323
431
|
const reset = { done: false };
|
|
324
432
|
await updatePoolChoices(runtimeOf(deps).storeDir, (choices) => {
|
|
@@ -414,13 +522,17 @@ export const postRaw = async (deps, options) => {
|
|
|
414
522
|
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.';
|
|
415
523
|
/** The exclusion terms in force now, re-read from the term file each time it is asked: empty when the file is missing or unreadable. */
|
|
416
524
|
export const currentTerms = (deps) => loadTerms(exclusionPaths(runtimeOf(deps).projectsRoot, deps.env).terms);
|
|
525
|
+
/** The one line a person with no exclusion terms needs: where the file goes and what names another. The deposit stays fail-closed without it (the A38 exclusion rule), so the first run says so before the deposit has to. */
|
|
526
|
+
const termsHint = (file) => `No exclusion terms file at ${file}: until it holds a term, one per line, every session stays out of the outbox. Create it there, or set OARS_EXCLUSION_TERMS to the file of your own.`;
|
|
417
527
|
/** `deposit --send <hash>`: the send lock the detached sender (hook-bin.js nightly) 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. */
|
|
418
528
|
const sendBatch = async (deps, options) => {
|
|
419
529
|
const { outbox, server, token, hash } = options;
|
|
420
|
-
const via = options.via ?? networkSender(deps, DEPOSIT);
|
|
421
530
|
if (!/^[0-9a-f]{64}$/.test(hash))
|
|
422
531
|
throw usage('--send takes the SHA-256 that oars pool outbox printed: 64 hexadecimal characters.', DEPOSIT);
|
|
423
532
|
const refuse = (code, title) => new CliError(title, ExitCode.CONFLICT, DEPOSIT, { code, title, status: 409 });
|
|
533
|
+
// Ctrl-C, a kill, a closed terminal, or a launcher that died ends the wait for the lock and the request in flight, so a send the person gave up on cannot finish later.
|
|
534
|
+
const interrupt = watchInterrupt();
|
|
535
|
+
const via = options.via ?? networkSender(deps, DEPOSIT, { stallMs: SEND_STALL_MS, signal: interrupt.signal });
|
|
424
536
|
try {
|
|
425
537
|
return await withSendLock(outbox, async () => {
|
|
426
538
|
// 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.
|
|
@@ -438,21 +550,59 @@ const sendBatch = async (deps, options) => {
|
|
|
438
550
|
throw refuse(locked.code, lockMessages[locked.code]);
|
|
439
551
|
if (!scan.open && (await heldAcross(outbox)).batches.some((batch) => batch.hash === hash && batch.pulled))
|
|
440
552
|
throw refuse('terms_missing', 'This batch was held by a cloud session, and your exclusion term file is missing or empty, so it cannot be checked and was not sent.');
|
|
441
|
-
await openCovenant(deps, server, token, options.acceptCovenant, options.region, via);
|
|
553
|
+
await openCovenant(deps, server, token, options.acceptCovenant, options.region, via, interrupt.signal);
|
|
442
554
|
const bytes = await unchangedBytes(locked.file, hash);
|
|
443
555
|
if (bytes === undefined)
|
|
444
556
|
throw refuse('batch_changed', lockMessages.batch_changed);
|
|
557
|
+
deps.io.stderr(`Sending batch ${hash.slice(0, 12)} to ${server}...\n`);
|
|
445
558
|
await postBatch(deps, { server, token, bytes, hash, via });
|
|
446
559
|
await markSent(outbox, hash, deps.now());
|
|
447
560
|
await retirePulled(outbox, DEPOSIT);
|
|
448
561
|
return { document: { ok: true, server, hash, sent: true }, text: `Sent batch ${hash} to ${server}.` };
|
|
562
|
+
}, options.lock?.times, options.lock?.fs, {
|
|
563
|
+
onWait: (waiting) => {
|
|
564
|
+
deps.io.stderr(`${waitingLine(waiting)}\n`);
|
|
565
|
+
},
|
|
566
|
+
signal: interrupt.signal,
|
|
449
567
|
});
|
|
450
568
|
}
|
|
451
569
|
catch (error) {
|
|
452
570
|
if (error instanceof SendLockBusy)
|
|
453
|
-
throw refuse('send_locked',
|
|
571
|
+
throw refuse('send_locked', busyLine(error.detail));
|
|
572
|
+
if (error instanceof SendInterrupted)
|
|
573
|
+
throw new CliError(stoppedText(false), ExitCode.UNEXPECTED, DEPOSIT);
|
|
454
574
|
throw error;
|
|
455
575
|
}
|
|
576
|
+
finally {
|
|
577
|
+
interrupt.stop();
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
/**
|
|
581
|
+
* How long the pool gets to answer one request of a send (an interactive `--send` and the background sender) before the send ends. It is a deadline for the whole request, the upload and the answer together,
|
|
582
|
+
* not an idle timeout: a batch is one session's skeleton, whose largest fixture is 887 KB (docs/pool.md), which a link of 240 kbit/s or better uploads inside 30 seconds. A slower link fails each try with the stall
|
|
583
|
+
* sentence; resetting the timer once headers arrive would not help it, because the upload is what is slow.
|
|
584
|
+
*/
|
|
585
|
+
export const SEND_STALL_MS = 30_000;
|
|
586
|
+
/** A span in words a person can plan around: seconds under two minutes, else whole minutes. */
|
|
587
|
+
const span = (ms) => {
|
|
588
|
+
const seconds = Math.max(1, Math.round(ms / 1000));
|
|
589
|
+
return seconds < 120 ? `${String(seconds)} second${seconds === 1 ? '' : 's'}` : `${String(Math.round(seconds / 60))} minutes`;
|
|
590
|
+
};
|
|
591
|
+
/** What the person reads within a second of a send that has to wait: whose lock it is, how old it is, and how long this run waits. */
|
|
592
|
+
export const waitingLine = ({ holder, ageMs, waitMs }) => holder === undefined
|
|
593
|
+
? `Another oars run holds this outbox's send lock (the lock does not name its process; it was last touched ${span(ageMs)} ago). Waiting up to ${span(waitMs)} for it; Ctrl-C stops this run.`
|
|
594
|
+
: `Another oars run (pid ${String(holder.pid)}) took this outbox's send lock ${span(ageMs)} ago and is still sending. Waiting up to ${span(waitMs)} for it; Ctrl-C stops this run.`;
|
|
595
|
+
/**
|
|
596
|
+
* What a send that gave up says: nothing was sent, who held the lock, and when a retry can succeed. A live holder on this host touches the lock every quarter of staleMs, even while it waits on a stalled pool, so the clock
|
|
597
|
+
* never frees its lock: the true condition is its end (a dead holder is taken over at once). Only a holder the lock does not name, or one on another host, whose liveness cannot be read, is decided by the clock: a
|
|
598
|
+
* retry is certain once the lock has gone untouched for staleMs.
|
|
599
|
+
*/
|
|
600
|
+
export const busyLine = ({ holder, local, ageMs, untouchedMs, waitedMs, staleMs }) => {
|
|
601
|
+
const who = holder === undefined ? 'Another oars run' : `Another oars run (pid ${String(holder.pid)})`;
|
|
602
|
+
const held = `${who} has held this outbox's send lock for ${span(ageMs)} and was still sending after the ${span(waitedMs)} this run waited, so nothing was sent.`;
|
|
603
|
+
if (holder !== undefined && local)
|
|
604
|
+
return `${held} A retry succeeds as soon as pid ${String(holder.pid)} ends.`;
|
|
605
|
+
return `${held} Run the same command again once it ends. If it has hung, oars takes the lock over about ${span(Math.max(1, staleMs - untouchedMs))} from now.`;
|
|
456
606
|
};
|
|
457
607
|
/** The covenant version the pool names, or `undefined` when it cannot be asked or names none. The route is public, so no credential goes with it. */
|
|
458
608
|
const covenantOrUndefined = async (deps, server) => {
|
|
@@ -495,7 +645,7 @@ export const poolDeposit = async (deps, options) => {
|
|
|
495
645
|
const outbox = outboxFolder(runtime.storeDir, server);
|
|
496
646
|
await mkdirOwnerOnly(outbox);
|
|
497
647
|
if (options.send !== undefined)
|
|
498
|
-
return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, region, via: options.via });
|
|
648
|
+
return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, region, via: options.via, lock: options.lock });
|
|
499
649
|
const { terms } = exclusionPaths(runtime.projectsRoot, deps.env);
|
|
500
650
|
const saved = await readState(runtime.storeDir);
|
|
501
651
|
// A yes to the raw prompt counts only for the covenant it was given under, so an interactive build asks the pool which is in force; the nightly already knows and passes it. A pool that cannot be asked, or names none, leaves it unknown and the build goes on as before.
|
|
@@ -568,7 +718,7 @@ export const poolDeposit = async (deps, options) => {
|
|
|
568
718
|
continue;
|
|
569
719
|
}
|
|
570
720
|
// The batch of a session is built once the session and its subagents have ended; a session still in progress waits, and an earlier batch of it is withdrawn. A machine's Stop is the end of a turn, not of a session: its VM may be reclaimed before the session ends, so it builds the open session as it stands.
|
|
571
|
-
if (machine === undefined && !hasEnded(records, deps.now(), await lastWritten(runtime, records.main.project, records.main.session))) {
|
|
721
|
+
if (machine === undefined && !hasEnded(records, deps.now(), await lastWritten(runtime, records.main.project, records.main.session), undefined, options.covenant !== undefined)) {
|
|
572
722
|
counts.notEnded += 1;
|
|
573
723
|
await withdraw(key);
|
|
574
724
|
continue;
|
|
@@ -636,7 +786,7 @@ export const poolDeposit = async (deps, options) => {
|
|
|
636
786
|
// Counts only: how many sessions each gate kept out, never a folder, a term, or a name.
|
|
637
787
|
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 });
|
|
638
788
|
const lines = [
|
|
639
|
-
`Wrote ${String(counts.batched)} of ${String(counts.sessions)} sessions to the outbox for ${server}. Nothing was sent
|
|
789
|
+
`Wrote ${String(counts.batched)} of ${String(counts.sessions)} sessions to the outbox for ${server}. ${machine === undefined ? SENT_LATER : 'Nothing was sent.'}`,
|
|
640
790
|
`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.`,
|
|
641
791
|
`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(', ')})`}.`,
|
|
642
792
|
...(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.`]),
|
|
@@ -644,9 +794,10 @@ export const poolDeposit = async (deps, options) => {
|
|
|
644
794
|
`Raw copies queued: ${String(counts.raw.copies)} (${String(counts.raw.bytes)} bytes, ${String(counts.raw.masked)} values masked). ${rawNote(saved.pool.rawConsent)}`.trim(),
|
|
645
795
|
...(rawStale(saved.pool, covenant) ? [`Your yes to the raw prompt was given under covenant ${saved.pool.rawConsentVersion ?? 'none'}, not ${covenant ?? ''}, so no raw copy is queued. Run oars pool raw --consent yes to answer for the covenant now in force.`] : []),
|
|
646
796
|
...(counts.off === 0 ? [] : [`${String(counts.off)} ${counts.off === 1 ? 'session' : 'sessions'} in a folder switched off: nothing of them is queued.`]),
|
|
797
|
+
...(termList.length === 0 ? [termsHint(terms)] : []),
|
|
647
798
|
'Run oars pool outbox to print exactly what would be sent.',
|
|
648
799
|
];
|
|
649
|
-
return { document: { ok: true, server, ...counts }, text: lines.join('\n') };
|
|
800
|
+
return { document: { ok: true, server, ...counts, ...(termList.length === 0 ? { termsFile: terms } : {}) }, text: lines.join('\n') };
|
|
650
801
|
};
|
|
651
802
|
/** Each field of a batch, at any depth, with its row. A field with no row fails the command rather than printing blank. */
|
|
652
803
|
const explained = (bytes) => {
|