@manyoars/oars 0.5.2 → 0.5.4

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.
@@ -11,7 +11,7 @@ import { makeCoverage, readCoverageSetting, writeCoverageSetting } from './cover
11
11
  import { checkSession, exclusionPaths, lastWritten, loadTerms, transcriptFilesOf } from './exclusion.js';
12
12
  import { localNames, makeRedactor, REDACTION_VERSION, redactTranscript } from './redact.js';
13
13
  import { recordSend, recordWire, newTally, tallyBatch } from '../telemetry/wire.js';
14
- import { discardStale, FIELD_ROWS, fieldsOf, heldAcross, lockedAcross, rescanPulled, machineKeys, markShown, noteMachineKey, outboxFolder, partsOf, pulledAt, removeBatchesExcept, removeBatchOf, staleBatches, unchangedBytes, writeBatch } from './pool-outbox.js';
14
+ import { discardStale, FIELD_ROWS, fieldsOf, heldAcross, lockedAcross, rescanPulled, machineKeys, markShown, noteMachineKey, outboxFolder, partsOf, pulledAt, readPulled, readRemoved, removeBatchesExcept, removeBatchOf, staleBatches, unchangedBytes, writeBatch, writePulled, writeRemoved } from './pool-outbox.js';
15
15
  import { deriveConsequences } from './recall-consequence.js';
16
16
  import { readRecalls } from './recall-object.js';
17
17
  import { HARNESSES, hasEnded, ORG_SIZES, REPO_VISIBILITIES, SESSION_IDLE_HOURS, isBundleVersion, isHarness, parseSkeleton, recommendationsOf, sessionsOf, skeletonOf, unpinnedKid, VOCAB_VERSION, verifyBundle } from './pool-wire.js';
@@ -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';
@@ -37,6 +37,7 @@ const LOGIN = 'pool-login';
37
37
  const LOGOUT = 'pool-logout';
38
38
  export const DEPOSIT = 'pool-deposit';
39
39
  const OUTBOX = 'pool-outbox';
40
+ const REMOVE = 'pool-remove';
40
41
  const POOL_DEPOSITS = '/v1/deposits';
41
42
  const FETCH = 'pool-fetch';
42
43
  const EXPORT = 'pool-export';
@@ -74,9 +75,14 @@ export const runtimeOf = (deps) => {
74
75
  throw new CliError('The session store is not configured in this run.', ExitCode.UNEXPECTED, 'unexpected');
75
76
  return deps.sessions;
76
77
  };
77
- export const signedIn = async (deps, storeDir, server, step) => {
78
+ /** The sign-in held for this server that has not expired, or undefined. */
79
+ const heldSignIn = async (deps, storeDir, server, step) => {
78
80
  const found = (await loadSignIns(storeDir, step)).find((entry) => entry.server === server);
79
- if (found === undefined || found.expiresAt <= deps.now())
81
+ return found === undefined || found.expiresAt <= deps.now() ? undefined : found;
82
+ };
83
+ export const signedIn = async (deps, storeDir, server, step) => {
84
+ const found = await heldSignIn(deps, storeDir, server, step);
85
+ if (found === undefined)
80
86
  throw new CliError(`Not signed in to ${server}. Run oars pool login first.`, ExitCode.AUTH, step);
81
87
  return found;
82
88
  };
@@ -102,29 +108,95 @@ export const ask = (deps, step, server, path, init, via) => {
102
108
  const request = outgoing(deps, server, path, init);
103
109
  return via === undefined ? send(deps, step, request, init.show, init.binary === true) : via(request);
104
110
  };
105
- /** Prints the request when asked to, then sends exactly those headers and that body. */
106
- const send = async (deps, step, request, show, binary = false) => {
111
+ /** The pool took the request and then said nothing for `Guard.stallMs`. */
112
+ class PoolStalled extends Error {
113
+ }
114
+ /** A newcomer's words for the network failures a stalled or restarted pool produces; the raw reason follows the sentence in parentheses. The flag marks a failure that comes after the request began to go out, where the pool may hold the batch: a refused, unresolved, or connect-timed-out connection fails before any of it was sent, while ETIMEDOUT can also come from a retransmit timeout on an open socket after the body was written. */
115
+ const PLAIN_REASONS = [
116
+ [/ECONNRESET|socket hang up|UND_ERR_SOCKET/, 'the connection was dropped before the pool answered', true],
117
+ [/ECONNREFUSED/, 'nothing is listening there', false],
118
+ [/ENOTFOUND|EAI_AGAIN/, 'that name did not resolve', false],
119
+ [/UND_ERR_CONNECT_TIMEOUT/, 'the connection timed out', false],
120
+ [/ETIMEDOUT/, 'the connection timed out', true],
121
+ [/EPIPE/, 'the connection closed while the batch was going out', true],
122
+ ];
123
+ const NOT_MARKED = 'Nothing was marked sent.';
124
+ const TRY_AGAIN = 'Run the same command to try again.';
125
+ const RETRY = `${NOT_MARKED} ${TRY_AGAIN}`;
126
+ /** 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. */
127
+ const MAY_HAVE_RECEIVED = 'The pool may already have received it.';
128
+ /** 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. */
129
+ const SENT_LATER = 'Nothing was sent yet. Once the covenant is accepted, oars sends what is queued automatically, in the background.';
130
+ /**
131
+ * 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
132
+ * 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.
133
+ * 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.
134
+ */
135
+ export const stoppedText = (received) => [
136
+ 'Stopped. This batch stays queued, and once the covenant is accepted oars sends it automatically in the background.',
137
+ ...(received ? [MAY_HAVE_RECEIVED] : []),
138
+ 'To keep a folder out, run oars pool raw --folder <folder> --mode off.',
139
+ 'To stop this machine sending, run oars pool logout.',
140
+ ].join('\n');
141
+ /** The failure for a send the person or the launcher ended: Ctrl-C (SIGINT) exits 130, the shell convention, and any other way of stopping keeps the unexpected code. The text is the same for all of them. */
142
+ const stopped = (step, cause, received) => new CliError(stoppedText(received), cause.signal === 'SIGINT' ? ExitCode.INTERRUPTED : ExitCode.UNEXPECTED, step);
143
+ /** 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. */
144
+ const guardedFailure = (step, cause, origin, guard, received) => {
145
+ if (cause instanceof PoolStalled)
146
+ 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);
147
+ if (cause instanceof SendInterrupted)
148
+ return stopped(step, cause, received);
149
+ const unreachable = unreachableError(step, cause, origin);
150
+ const raw = unreachable.message.slice(`Cannot reach the server at ${origin}: `.length);
151
+ const plain = PLAIN_REASONS.find(([pattern]) => pattern.test(raw));
152
+ return plain === undefined ? unreachable : new CliError(`Cannot reach the pool at ${origin}: ${plain[1]} (${raw}). ${received && plain[2] ? `${NOT_MARKED} ${MAY_HAVE_RECEIVED} ${TRY_AGAIN}` : RETRY}`, ExitCode.UNREACHABLE, step);
153
+ };
154
+ /** 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. */
155
+ const send = async (deps, step, request, show, binary = false, guard) => {
107
156
  if (show !== undefined)
108
157
  deps.io.stderr(`${printed(request, show)}
109
158
  `);
110
- let response;
111
- try {
112
- response = await fetch(request.url, { method: request.method, headers: request.headers, redirect: 'error', ...(request.body === undefined ? {} : { body: request.body }) });
113
- }
114
- catch (error) {
115
- throw unreachableError(step, error, request.url.origin);
116
- }
117
- let body;
159
+ const stop = new AbortController();
160
+ const relay = () => {
161
+ stop.abort(guard?.signal?.reason);
162
+ };
163
+ guard?.signal?.addEventListener('abort', relay, { once: true });
164
+ if (guard?.signal?.aborted === true)
165
+ relay();
166
+ const timer = guard === undefined ? undefined : setTimeout(() => {
167
+ stop.abort(new PoolStalled());
168
+ }, guard.stallMs);
169
+ // 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.
170
+ let started = false;
171
+ 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));
118
172
  try {
119
- body = binary && response.status === 200 ? new Uint8Array(await response.arrayBuffer()) : await response.json();
173
+ let response;
174
+ try {
175
+ started = !stop.signal.aborted;
176
+ response = await fetch(request.url, { method: request.method, headers: request.headers, redirect: 'error', ...(guard === undefined ? {} : { signal: stop.signal }), ...(request.body === undefined ? {} : { body: request.body }) });
177
+ }
178
+ catch (error) {
179
+ throw failure(error);
180
+ }
181
+ let body;
182
+ try {
183
+ body = binary && response.status === 200 ? new Uint8Array(await response.arrayBuffer()) : await response.json();
184
+ }
185
+ catch (error) {
186
+ // An answer that began and then stalled is no answer; one that ended badly is read as no body, as before.
187
+ if (stop.signal.aborted)
188
+ throw failure(error);
189
+ body = undefined;
190
+ }
191
+ return { status: response.status, body, headers: response.headers };
120
192
  }
121
- catch {
122
- body = undefined;
193
+ finally {
194
+ clearTimeout(timer);
195
+ guard?.signal?.removeEventListener('abort', relay);
123
196
  }
124
- return { status: response.status, body, headers: response.headers };
125
197
  };
126
- /** 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. */
127
- export const networkSender = (deps, step) => (request) => send(deps, step, request, undefined);
198
+ /** 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`. */
199
+ export const networkSender = (deps, step, guard) => (request) => send(deps, step, request, undefined, false, guard);
128
200
  /** The server's named refusal, as a failure: its `error` is the problem's code in `--json`. */
129
201
  export const refusal = (step, { status, body }) => {
130
202
  const code = isObject(body) ? textIn(body, 'error') : undefined;
@@ -296,8 +368,18 @@ const regionOf = (text) => {
296
368
  const answer = text?.trim().toLowerCase();
297
369
  return answer === 'yes' || answer === 'no' ? answer : undefined;
298
370
  };
371
+ /**
372
+ * 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
373
+ * 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.
374
+ */
375
+ export const OFFER_BEFORE = [
376
+ 'Depositing gives your account access to the pool.',
377
+ 'A counting deposit lifts the weekly limit on pool fetches for 90 days.',
378
+ 'The covenant is the whole deal. Its full text is linked below. Read it before you type.',
379
+ ];
380
+ export const OFFER_AFTER = ['Accepted. Sending your batch now.', 'Run oars pool fetch --help for your first pool download.'];
299
381
  /** A send stops here when the server is closed or wants a covenant accepted, before the batch is read for the last time. */
300
- const openCovenant = async (deps, server, token, accept, region, via) => {
382
+ const openCovenant = async (deps, server, token, accept, region, via, signal) => {
301
383
  const reply = await ask(deps, DEPOSIT, server, '/v1/covenant', { token }, via);
302
384
  if (reply.status !== 200 || !isObject(reply.body))
303
385
  throw refusal(DEPOSIT, reply);
@@ -321,13 +403,17 @@ const openCovenant = async (deps, server, token, accept, region, via) => {
321
403
  if (!isTerminal(deps)) {
322
404
  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.`);
323
405
  }
406
+ deps.io.stderr(`${OFFER_BEFORE.join('\n')}\n`);
324
407
  deps.io.stderr(`\n${screen.text}\n\n> `);
325
408
  emitOf(deps)('covenant-shown', { version });
326
409
  // 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.
327
- const lines = createInterface({ input: runtimeOf(deps).stdin, terminal: false });
410
+ // 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.
411
+ const lines = createInterface({ input: runtimeOf(deps).stdin, terminal: false, ...(signal === undefined ? {} : { signal }) });
328
412
  const iterator = lines[Symbol.asyncIterator]();
329
413
  const nextLine = async () => {
330
414
  const next = await iterator.next();
415
+ if (signal?.aborted === true)
416
+ throw signal.reason;
331
417
  return next.done === true ? undefined : next.value;
332
418
  };
333
419
  let answered = region;
@@ -349,6 +435,7 @@ const openCovenant = async (deps, server, token, accept, region, via) => {
349
435
  const accepted = await ask(deps, DEPOSIT, server, '/v1/covenant/accept', { token, body }, via);
350
436
  if (accepted.status !== 200)
351
437
  throw refusal(DEPOSIT, accepted);
438
+ deps.io.stderr(`${OFFER_AFTER.join('\n')}\n`);
352
439
  // 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.
353
440
  const reset = { done: false };
354
441
  await updatePoolChoices(runtimeOf(deps).storeDir, (choices) => {
@@ -449,10 +536,12 @@ const termsHint = (file) => `No exclusion terms file at ${file}: until it holds
449
536
  /** `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. */
450
537
  const sendBatch = async (deps, options) => {
451
538
  const { outbox, server, token, hash } = options;
452
- const via = options.via ?? networkSender(deps, DEPOSIT);
453
539
  if (!/^[0-9a-f]{64}$/.test(hash))
454
540
  throw usage('--send takes the SHA-256 that oars pool outbox printed: 64 hexadecimal characters.', DEPOSIT);
455
541
  const refuse = (code, title) => new CliError(title, ExitCode.CONFLICT, DEPOSIT, { code, title, status: 409 });
542
+ // 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.
543
+ const interrupt = watchInterrupt();
544
+ const via = options.via ?? networkSender(deps, DEPOSIT, { stallMs: SEND_STALL_MS, signal: interrupt.signal });
456
545
  try {
457
546
  return await withSendLock(outbox, async () => {
458
547
  // 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.
@@ -470,19 +559,119 @@ const sendBatch = async (deps, options) => {
470
559
  throw refuse(locked.code, lockMessages[locked.code]);
471
560
  if (!scan.open && (await heldAcross(outbox)).batches.some((batch) => batch.hash === hash && batch.pulled))
472
561
  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.');
473
- await openCovenant(deps, server, token, options.acceptCovenant, options.region, via);
562
+ await openCovenant(deps, server, token, options.acceptCovenant, options.region, via, interrupt.signal);
474
563
  const bytes = await unchangedBytes(locked.file, hash);
475
564
  if (bytes === undefined)
476
565
  throw refuse('batch_changed', lockMessages.batch_changed);
566
+ deps.io.stderr(`Sending batch ${hash.slice(0, 12)} to ${server}...\n`);
477
567
  await postBatch(deps, { server, token, bytes, hash, via });
478
568
  await markSent(outbox, hash, deps.now());
479
569
  await retirePulled(outbox, DEPOSIT);
480
570
  return { document: { ok: true, server, hash, sent: true }, text: `Sent batch ${hash} to ${server}.` };
571
+ }, options.lock?.times, options.lock?.fs, {
572
+ onWait: (waiting) => {
573
+ deps.io.stderr(`${waitingLine(waiting)}\n`);
574
+ },
575
+ signal: interrupt.signal,
481
576
  });
482
577
  }
483
578
  catch (error) {
484
579
  if (error instanceof SendLockBusy)
485
- throw refuse('send_locked', 'Another oars run is sending from this outbox. Try again in a minute.');
580
+ throw refuse('send_locked', busyLine(error.detail));
581
+ if (error instanceof SendInterrupted)
582
+ throw stopped(DEPOSIT, error, false);
583
+ throw error;
584
+ }
585
+ finally {
586
+ interrupt.stop();
587
+ }
588
+ };
589
+ /**
590
+ * 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,
591
+ * 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
592
+ * sentence; resetting the timer once headers arrive would not help it, because the upload is what is slow.
593
+ */
594
+ export const SEND_STALL_MS = 30_000;
595
+ /** A span in words a person can plan around: seconds under two minutes, else whole minutes. */
596
+ const span = (ms) => {
597
+ const seconds = Math.max(1, Math.round(ms / 1000));
598
+ return seconds < 120 ? `${String(seconds)} second${seconds === 1 ? '' : 's'}` : `${String(Math.round(seconds / 60))} minutes`;
599
+ };
600
+ /** 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. */
601
+ export const waitingLine = ({ holder, ageMs, waitMs }) => holder === undefined
602
+ ? `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.`
603
+ : `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.`;
604
+ /**
605
+ * 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
606
+ * 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
607
+ * retry is certain once the lock has gone untouched for staleMs.
608
+ */
609
+ export const busyLine = ({ holder, local, ageMs, untouchedMs, waitedMs, staleMs }) => {
610
+ const who = holder === undefined ? 'Another oars run' : `Another oars run (pid ${String(holder.pid)})`;
611
+ 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.`;
612
+ if (holder !== undefined && local)
613
+ return `${held} A retry succeeds as soon as pid ${String(holder.pid)} ends.`;
614
+ 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.`;
615
+ };
616
+ /** The session id behind an outbox key, found by deriving the key of each session in the store the way `poolDeposit` does: under every account signed in to this server, or under the machine's environment. `undefined` when the store no longer holds the session. */
617
+ const sessionOfKey = async (deps, storeDir, server, key) => {
618
+ const machine = machineOf(deps.env);
619
+ const accounts = machine === undefined ? (await loadSignIns(storeDir, REMOVE)).filter((entry) => entry.server === server).map((entry) => entry.account) : [];
620
+ const keysOf = (session) => (machine === undefined ? accounts.map((account) => contentHash([account, session])) : [machineKeyOf(machine, session)]);
621
+ return (await loadStore(storeDir)).sessions.map((observed) => observed.session).find((session) => keysOf(session).includes(key));
622
+ };
623
+ /**
624
+ * `oars pool remove <hash>`: takes one batch out of this machine's outbox, the batch `oars pool outbox` printed under that hash. It takes the send lock without waiting, because a send in progress may be reading the very file, and refuses while another run holds it.
625
+ * It reads nothing from the pool and sends nothing, so it needs no sign-in, and it changes only files here. The session of a local batch goes on `removed.json`, which `poolDeposit` (and so the background sender) honors: nothing of it is built, queued, or sent until `--restore <hash>` takes it off. One a cloud session held is admitted again only by `oars pool hold pull`.
626
+ */
627
+ export const poolRemove = async (deps, hash, options) => {
628
+ const runtime = runtimeOf(deps);
629
+ const server = poolServer(deps.env, REMOVE, options.server);
630
+ if (!/^[0-9a-f]{64}$/.test(hash))
631
+ throw usage('remove takes the SHA-256 that oars pool outbox printed: 64 hexadecimal characters.', REMOVE);
632
+ const outbox = outboxFolder(runtime.storeDir, server);
633
+ const refuse = (code, title) => new CliError(title, ExitCode.CONFLICT, REMOVE, { code, title, status: 409 });
634
+ try {
635
+ return await withSendLock(outbox, async () => {
636
+ if (options.restore === true) {
637
+ const removed = await readRemoved(outbox, REMOVE);
638
+ const key = Object.keys(removed).find((candidate) => removed[candidate]?.hash === hash);
639
+ if (key === undefined)
640
+ throw refuse('removal_unknown', 'No removed batch has that hash, so nothing was restored. The hash is the one oars pool remove printed when it removed the batch.');
641
+ Reflect.deleteProperty(removed, key);
642
+ await writeRemoved(outbox, removed);
643
+ return { document: { ok: true, server, hash, restored: true }, text: `Restored the session of batch ${hash} for ${server}. Nothing was sent. A later oars pool deposit builds it again while the session passes the gates.` };
644
+ }
645
+ const removed = await readRemoved(outbox, REMOVE);
646
+ const found = (await heldAcross(outbox)).batches.find((batch) => batch.hash === hash);
647
+ if (found === undefined)
648
+ throw refuse('batch_unknown', 'No batch with that hash is in the outbox, so nothing was removed. Run oars pool outbox to list them.');
649
+ // The session goes on the list first: the build pass would otherwise rebuild it on its next run, the background sender's included, and send what the person withdrew (GDPR Article 7(3)). A list that cannot be read has already failed above, with the batch untouched.
650
+ const session = found.pulled ? undefined : await sessionOfKey(deps, runtime.storeDir, server, found.key);
651
+ if (!found.pulled)
652
+ await writeRemoved(outbox, { ...removed, [found.key]: session === undefined ? { hash } : { hash, session } });
653
+ await rm(found.file, { force: true });
654
+ // The session's queued raw copies go in the same lock: the background sender posts every raw copy it holds without looking for its batch, and a batch the person withdrew must take the session's transcript with it (GDPR Article 7(3)). A pulled batch never had one here.
655
+ const rawGone = await removeRawExcept(outbox, new Set(), found.key);
656
+ if (found.pulled) {
657
+ const pulled = await readPulled(outbox, REMOVE);
658
+ Reflect.deleteProperty(pulled, found.key);
659
+ await writePulled(outbox, pulled);
660
+ }
661
+ const sentAt = (await sentLog(outbox)).get(hash);
662
+ const text = [
663
+ `Removed batch ${hash} from the outbox for ${server}. Nothing was sent, and nothing at the pool changed.`,
664
+ ...(rawGone === 0 ? [] : [`${String(rawGone)} queued raw ${rawGone === 1 ? 'copy' : 'copies'} of the same session went with it.`]),
665
+ ...(sentAt === undefined ? [] : [`It was already sent at ${sentAt}; removing it here does not take it back from the pool.`]),
666
+ ...(found.pulled ? ['It came from a cloud session: only oars pool hold pull admits it again.'] : [`Its session stays out of every later oars pool deposit, the background sender's included, until you run oars pool remove ${hash} --restore.`]),
667
+ ...(found.pulled || session !== undefined ? [] : ['No session in the store matches it, so the exclusion holds for this account and machine settings only.']),
668
+ ].join('\n');
669
+ return { document: { ok: true, server, hash, removed: true }, text };
670
+ }, { ...options.lock?.times, waitMs: 0 }, options.lock?.fs);
671
+ }
672
+ catch (error) {
673
+ if (error instanceof SendLockBusy)
674
+ throw refuse('send_locked', `Another oars run${error.detail.holder === undefined ? '' : ` (pid ${String(error.detail.holder.pid)})`} holds this outbox's send lock, so nothing was removed. Run the same command again once it ends.`);
486
675
  throw error;
487
676
  }
488
677
  };
@@ -527,7 +716,7 @@ export const poolDeposit = async (deps, options) => {
527
716
  const outbox = outboxFolder(runtime.storeDir, server);
528
717
  await mkdirOwnerOnly(outbox);
529
718
  if (options.send !== undefined)
530
- return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, region, via: options.via });
719
+ return sendBatch(deps, { outbox, server, token, hash: options.send, acceptCovenant: options.acceptCovenant, region, via: options.via, lock: options.lock });
531
720
  const { terms } = exclusionPaths(runtime.projectsRoot, deps.env);
532
721
  const saved = await readState(runtime.storeDir);
533
722
  // 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.
@@ -537,7 +726,7 @@ export const poolDeposit = async (deps, options) => {
537
726
  const injectedKey = `injected-block:${(await localSalt(runtime.storeDir)).salt}`;
538
727
  const allow = compileAllow(saved.allow);
539
728
  const discarded = await discardStale(outbox);
540
- 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 };
729
+ 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, removedByPerson: 0, notEnded: 0, refused: 0, refusedFields: [], discarded, withdrawn: 0 };
541
730
  // The one gate on a name: the operator's redactor and exclusion terms (t881). Everything else the pool classes itself.
542
731
  const termList = await loadTerms(terms);
543
732
  const gate = { redact: makeRedactor(await readRedactDomains(runtime.storeDir)), terms: termList };
@@ -560,6 +749,11 @@ export const poolDeposit = async (deps, options) => {
560
749
  // The sessions a Stop does not name that still pass the read-only gates: they only fill the windows of a delta, and are never counted, withdrawn, or built.
561
750
  const history = [];
562
751
  const localStore = await loadStore(runtime.storeDir);
752
+ // A damaged list stops the pass before anything is built: it must never read as "nothing was removed".
753
+ const removedList = await readRemoved(outbox, DEPOSIT);
754
+ const removedKeys = new Set(Object.keys(removedList));
755
+ // By session id too: the key follows the account and the machine's environment, so a login under another account would otherwise build the same session again.
756
+ const removedSessions = new Set(Object.values(removedList).flatMap((entry) => (entry.session === undefined ? [] : [entry.session])));
563
757
  for (const records of sessionsOf(localStore, roots)) {
564
758
  // The Stop of a machine builds the one session that stopped: the others in the store belong to Stops of their own, so their batches stay as they are. They run the three read-only gates below and no more.
565
759
  const named = options.session === undefined || records.main.session === options.session;
@@ -567,6 +761,12 @@ export const poolDeposit = async (deps, options) => {
567
761
  counts.sessions += 1;
568
762
  // Hashed with the account, so a redeposit replaces its own batch and two accounts' copies of one session cannot be joined.
569
763
  const key = machine === undefined ? contentHash([account, records.main.session]) : machineKeyOf(machine, records.main.session);
764
+ // Withdrawn by the person with `oars pool remove`: nothing of the session is built, queued, or read for a window, and its batch and raw copies are already gone.
765
+ if (removedKeys.has(key) || removedSessions.has(records.main.session)) {
766
+ if (named)
767
+ counts.removedByPerson += 1;
768
+ continue;
769
+ }
570
770
  if (!allow.allows(records.main.project)) {
571
771
  if (named) {
572
772
  counts.excluded.folder += 1;
@@ -668,13 +868,14 @@ export const poolDeposit = async (deps, options) => {
668
868
  // Counts only: how many sessions each gate kept out, never a folder, a term, or a name.
669
869
  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 });
670
870
  const lines = [
671
- `Wrote ${String(counts.batched)} of ${String(counts.sessions)} sessions to the outbox for ${server}. Nothing was sent.`,
871
+ `Wrote ${String(counts.batched)} of ${String(counts.sessions)} sessions to the outbox for ${server}. ${machine === undefined ? SENT_LATER : 'Nothing was sent.'}`,
672
872
  `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.`,
673
873
  `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(', ')})`}.`,
674
874
  ...(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.`]),
675
875
  ...(discarded === 0 ? [] : [`Discarded ${String(discarded)} ${discarded === 1 ? 'batch' : 'batches'} built under an older vocabulary, field list, or skeleton format.`]),
676
876
  `Raw copies queued: ${String(counts.raw.copies)} (${String(counts.raw.bytes)} bytes, ${String(counts.raw.masked)} values masked). ${rawNote(saved.pool.rawConsent)}`.trim(),
677
877
  ...(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.`] : []),
878
+ ...(counts.removedByPerson === 0 ? [] : [`${String(counts.removedByPerson)} ${counts.removedByPerson === 1 ? 'session' : 'sessions'} you removed with oars pool remove: nothing of them is built, queued, or sent. oars pool remove <hash> --restore brings one back.`]),
678
879
  ...(counts.off === 0 ? [] : [`${String(counts.off)} ${counts.off === 1 ? 'session' : 'sessions'} in a folder switched off: nothing of them is queued.`]),
679
880
  ...(termList.length === 0 ? [termsHint(terms)] : []),
680
881
  'Run oars pool outbox to print exactly what would be sent.',
@@ -694,7 +895,10 @@ const explained = (bytes) => {
694
895
  }
695
896
  return rows;
696
897
  };
697
- /** `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. */
898
+ /**
899
+ * `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.
900
+ * With `print` each batch is written as soon as it is built and marked shown right after, and the outcome carries no text: an outbox of thousands of sessions is far past V8's string length as one string. Without it (`--json`) the document carries each batch's request, and `--json` writes it element by element.
901
+ */
698
902
  export const poolOutbox = async (deps, options) => {
699
903
  const runtime = runtimeOf(deps);
700
904
  const server = poolServer(deps.env, OUTBOX, options.server);
@@ -704,25 +908,41 @@ export const poolOutbox = async (deps, options) => {
704
908
  const masker = new Masker();
705
909
  masker.add(token);
706
910
  const sent = await sentLog(outbox);
911
+ const { print } = options;
707
912
  const shown = [];
913
+ // Blocks are separated by a blank line; each is written alone, so no string holds more than one batch.
914
+ const state = { wrote: false };
915
+ const put = (block) => {
916
+ print?.(`${state.wrote ? '\n\n' : ''}${block}`);
917
+ state.wrote = true;
918
+ };
708
919
  for (const batch of batches) {
709
920
  const fields = options.explain === true ? explained(batch.bytes) : undefined;
710
921
  // A batch past one request prints every part's request, in the order they are sent.
711
922
  const request = callsOf(token, batch.bytes, batch.hash).map((call) => printed(outgoing(deps, server, POOL_DEPOSITS, call), masker)).join('\n');
712
- shown.push({ hash: batch.hash, request, ...(sent.has(batch.hash) ? { sent: sent.get(batch.hash) ?? '' } : {}), ...(fields === undefined ? {} : { fields }) });
923
+ const at = sent.get(batch.hash);
924
+ put([request, `sha256: ${batch.hash}`, ...(at === undefined ? [] : [`sent: ${at}`]), ...Object.entries(fields ?? {}).map(([field, row]) => ` ${field}: ${row}`)].join('\n'));
925
+ shown.push({ hash: batch.hash, ...(print === undefined ? { request } : {}), ...(at === undefined ? {} : { sent: at }), ...(fields === undefined ? {} : { fields }) });
926
+ await markShown(outbox, batch.hash, deps.now());
713
927
  }
714
928
  const { copies } = await heldRaw(outbox);
715
- 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')));
716
- for (const { hash } of shown)
717
- await markShown(outbox, hash, deps.now());
929
+ for (const copy of copies) {
930
+ const parts = rawParts(copy.body);
931
+ for (const [at, part] of parts.entries())
932
+ put([`raw part ${String(at + 1)}/${String(parts.length)} sha256: ${copy.hash} bytes: ${String(Buffer.byteLength(part))}${sent.has(copy.hash) ? ` sent: ${sent.get(copy.hash) ?? ''}` : ''}`, ...(options.explain === true ? [part] : [])].join('\n'));
933
+ }
718
934
  const stale = await staleBatches(outbox);
719
- 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'));
720
935
  const notes = [
721
936
  ...(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.`]),
722
937
  ...(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.`]),
723
938
  ];
724
- 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');
725
- return { document: { ok: true, server, batches: shown, raw: copies.map(({ hash, bytes }) => ({ hash, bytes, ...(sent.has(hash) ? { sent: sent.get(hash) ?? '' } : {}) })), edited, stale }, text };
939
+ if (!state.wrote)
940
+ put(['The outbox is empty. Run oars pool deposit first.', ...notes].join('\n'));
941
+ else
942
+ for (const note of notes)
943
+ put(note);
944
+ print?.('\n');
945
+ return { document: { ok: true, server, batches: shown, raw: copies.map(({ hash, bytes }) => ({ hash, bytes, ...(sent.has(hash) ? { sent: sent.get(hash) ?? '' } : {}) })), edited, stale }, text: '', output: print === undefined ? 'chunked' : 'streamed' };
726
946
  };
727
947
  /**
728
948
  * 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.
@@ -831,6 +1051,74 @@ export const poolFetch = async (deps, options) => {
831
1051
  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)}`,
832
1052
  };
833
1053
  };
1054
+ const JOIN = 'pool-join';
1055
+ const JOIN_AGAIN = 'Run oars pool join again to continue from here.';
1056
+ /**
1057
+ * `oars pool join`: the five commands of a first deposit in one run, each by the handler the command itself uses: `login`, `deposit`, `outbox --explain`, `deposit --send <hash>` for each batch not yet sent, then `fetch`. It asks and checks
1058
+ * nothing they do not, and a step that stops ends the run with that step's own error. Run again it skips what stands: a held sign-in, a prepared batch not yet sent, a batch in `sent.log`.
1059
+ * Each step's text prints as it finishes, so the explanation is on screen before the send asks anything: on stdout, or with `json` on stderr, so stdout holds the one document.
1060
+ */
1061
+ export const poolJoin = async (deps, options) => {
1062
+ const { storeDir } = runtimeOf(deps);
1063
+ const server = poolServer(deps.env, JOIN, options.server);
1064
+ const say = (line) => {
1065
+ deps.io.stderr(`${line}
1066
+ `);
1067
+ };
1068
+ const screen = options.json === true ? deps.io.stderr : deps.io.stdout;
1069
+ const show = (outcome) => {
1070
+ if (outcome.output !== 'streamed')
1071
+ screen(`${outcome.text}
1072
+ `);
1073
+ return outcome;
1074
+ };
1075
+ const outbox = outboxFolder(storeDir, server);
1076
+ const unsent = async () => {
1077
+ const sent = await sentLog(outbox);
1078
+ return (await heldAcross(outbox)).batches.flatMap(({ hash }) => (sent.has(hash) ? [] : [hash]));
1079
+ };
1080
+ try {
1081
+ const held = await heldSignIn(deps, storeDir, server, LOGIN);
1082
+ let login = { skipped: true, account: held?.account };
1083
+ if (held === undefined) {
1084
+ say(`Signing in to ${server}.`);
1085
+ login = show(await poolLogin(deps, { server, provider: options.provider, verbose: options.verbose })).document;
1086
+ }
1087
+ else {
1088
+ say(`Already signed in to ${server} as ${held.account}.`);
1089
+ }
1090
+ let prepared = { skipped: true };
1091
+ if ((await unsent()).length > 0) {
1092
+ say('Your outbox already holds a prepared batch; not preparing it again.');
1093
+ }
1094
+ else {
1095
+ say('Preparing your deposit in the outbox.');
1096
+ prepared = show(await poolDeposit(deps, { server })).document;
1097
+ }
1098
+ say('Showing what will be sent, field by field.');
1099
+ const shown = show(await poolOutbox(deps, { server, explain: true, print: screen })).document;
1100
+ const hashes = await unsent();
1101
+ const sends = [];
1102
+ if (hashes.length === 0) {
1103
+ say((await heldAcross(outbox)).batches.length === 0 ? 'No session is ready to deposit yet; sending nothing.' : 'Everything prepared is already sent; sending nothing.');
1104
+ }
1105
+ else {
1106
+ say(`Sending ${plural(hashes.length, 'batch', 'batches')} to ${server}.`);
1107
+ for (const hash of hashes)
1108
+ sends.push(show(await poolDeposit(deps, { server, send: hash, acceptCovenant: options.acceptCovenant, inEuEeaUk: options.inEuEeaUk })).document);
1109
+ }
1110
+ say(`Fetching the bundle for ${options.harness ?? 'your most used harness'} ${options.harnessVersion}.`);
1111
+ const fetched = show(await poolFetch(deps, { server, harness: options.harness, harnessVersion: options.harnessVersion }));
1112
+ return {
1113
+ document: { ok: true, server, login, deposit: prepared, outbox: shown, sends, fetch: fetched.document },
1114
+ text: `Joined ${server}: ${plural(sends.length, 'batch', 'batches')} sent this run, and the ${options.harnessVersion} bundle is kept.`,
1115
+ };
1116
+ }
1117
+ catch (error) {
1118
+ say(JOIN_AGAIN);
1119
+ throw error;
1120
+ }
1121
+ };
834
1122
  const RAW = 'pool-raw';
835
1123
  /** The raw prompt, in plain words: shown by `oars pool raw` until it is answered, and what the detached sender relies on. Redaction is best effort and the text says so. */
836
1124
  export const RAW_PROMPT = [
@@ -5,7 +5,7 @@ import { writeFile } from 'node:fs/promises';
5
5
  import { join } from 'node:path';
6
6
  import { CliError } from '../errors.js';
7
7
  import { machineOf } from './machine.js';
8
- import { ask, currentTerms, DEPOSIT, isObject, networkSender, poolDeposit, poolServer, postBatch, postRaw, runtimeOf, signedIn, textIn, vocabularyExpired } from './pool-command.js';
8
+ import { ask, currentTerms, DEPOSIT, isObject, networkSender, poolDeposit, poolServer, postBatch, postRaw, runtimeOf, SEND_STALL_MS, signedIn, textIn, vocabularyExpired } from './pool-command.js';
9
9
  import { rawStale } from './pool-choices.js';
10
10
  import { heldAcross, lockedBatch, outboxFolder, rescanPulled } from './pool-outbox.js';
11
11
  import { heldRaw } from './pool-raw.js';
@@ -119,7 +119,8 @@ export const nightlyPoolSend = async (deps, via) => {
119
119
  const tally = { outcome: 'failed', batches: 0, raw: 0 };
120
120
  let result;
121
121
  try {
122
- result = await night(deps, via ?? networkSender(deps, DEPOSIT), tally);
122
+ // A pool that goes silent ends the night after SEND_STALL_MS, so it cannot hold the send lock (and with it every `deposit --send`) for as long as the pool stalls. The next run sends what this one did not.
123
+ result = await night(deps, via ?? networkSender(deps, DEPOSIT, { stallMs: SEND_STALL_MS }), tally);
123
124
  }
124
125
  catch {
125
126
  result = { ...tally, outcome: 'failed' };
@@ -94,6 +94,19 @@ export interface Pulled {
94
94
  export declare const readPulled: (outbox: string, step: string) => Promise<Pulled>;
95
95
  /** Staged and renamed, so a crash leaves the whole old file or the whole new one. */
96
96
  export declare const writePulled: (outbox: string, pulled: Pulled) => Promise<void>;
97
+ /** One session a person took out with `oars pool remove`: the hash of the batch that was removed, which is how `--restore` names the way back, and the session id when it could be resolved. The outbox key derives from the account or the machine's environment, so only the session id keeps the session out after either changes; an entry with no id (the session was gone from the store, or the file is older) skips by key alone. */
98
+ export interface RemovedEntry {
99
+ hash: string;
100
+ session?: string;
101
+ }
102
+ /** The sessions a person took out of the outbox with `oars pool remove`, by outbox key. Local only; nothing in it goes to the pool. */
103
+ export interface Removed {
104
+ [key: string]: RemovedEntry;
105
+ }
106
+ /** `removed.json` as it stands; none when the file is missing. A file that is not a list of keys and entries fails the command that reads it, so a damaged list never becomes "send everything". A bare string is an entry with only a hash, the form before session ids were kept. */
107
+ export declare const readRemoved: (outbox: string, step: string) => Promise<Removed>;
108
+ /** Staged and renamed, like `pulled.json`: a crash leaves the whole old list or the whole new one. */
109
+ export declare const writeRemoved: (outbox: string, removed: Removed) => Promise<void>;
97
110
  /** Whether any string or key in `value` holds one of `terms`, compared lowercased the way the local gate compares a transcript. */
98
111
  export declare const holdsTerm: (value: unknown, terms: readonly string[]) => boolean;
99
112
  /**
@@ -385,6 +385,52 @@ export const writePulled = async (outbox, pulled) => {
385
385
  await writeOwnerOnly(staged, `${JSON.stringify(Object.fromEntries(Object.entries(pulled).sort(([a], [b]) => (a < b ? -1 : 1))), null, 2)}\n`);
386
386
  await rename(staged, join(outbox, PULLED));
387
387
  };
388
+ const REMOVED = 'removed.json';
389
+ const removedEntryOf = (value) => {
390
+ if (typeof value === 'string')
391
+ return { hash: value };
392
+ if (typeof value !== 'object' || value === null || Array.isArray(value) || !('hash' in value) || typeof value.hash !== 'string')
393
+ return undefined;
394
+ if (!('session' in value))
395
+ return { hash: value.hash };
396
+ return typeof value.session === 'string' ? { hash: value.hash, session: value.session } : undefined;
397
+ };
398
+ /** `removed.json` as it stands; none when the file is missing. A file that is not a list of keys and entries fails the command that reads it, so a damaged list never becomes "send everything". A bare string is an entry with only a hash, the form before session ids were kept. */
399
+ export const readRemoved = async (outbox, step) => {
400
+ let text;
401
+ try {
402
+ text = await readFile(join(outbox, REMOVED), 'utf8');
403
+ }
404
+ catch (error) {
405
+ if (isMissing(error))
406
+ return {};
407
+ throw error;
408
+ }
409
+ let value;
410
+ try {
411
+ value = JSON.parse(text);
412
+ }
413
+ catch {
414
+ value = undefined;
415
+ }
416
+ const entries = typeof value !== 'object' || value === null || Array.isArray(value) ? undefined : Object.entries(value).map(([key, entry]) => [key, removedEntryOf(entry)]);
417
+ const kept = [];
418
+ for (const [key, entry] of entries ?? [])
419
+ if (entry !== undefined)
420
+ kept.push([key, entry]);
421
+ if (entries === undefined || kept.length !== entries.length) {
422
+ throw new CliError(`${join(outbox, REMOVED)} is not a list of removed sessions. Move it aside and run the command again; nothing was changed, built, or sent.`, ExitCode.INVALID, step);
423
+ }
424
+ return Object.fromEntries(kept);
425
+ };
426
+ /** Staged and renamed, like `pulled.json`: a crash leaves the whole old list or the whole new one. */
427
+ export const writeRemoved = async (outbox, removed) => {
428
+ await mkdirOwnerOnly(outbox);
429
+ const staged = join(outbox, `${REMOVED}.tmp`);
430
+ await writeOwnerOnly(staged, `${JSON.stringify(Object.fromEntries(Object.entries(removed).sort(([a], [b]) => (a < b ? -1 : 1))), null, 2)}
431
+ `);
432
+ await rename(staged, join(outbox, REMOVED));
433
+ };
388
434
  const POOL_STEP = 'pool-outbox';
389
435
  /** Whether any string or key in `value` holds one of `terms`, compared lowercased the way the local gate compares a transcript. */
390
436
  export const holdsTerm = (value, terms) => {