@manyoars/oars 0.4.1 → 0.5.2

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.
Files changed (188) hide show
  1. package/README.md +79 -29
  2. package/dist/bin.js +3 -1
  3. package/dist/cli.d.ts +2 -0
  4. package/dist/cli.js +207 -21
  5. package/dist/club.d.ts +19 -0
  6. package/dist/club.js +132 -0
  7. package/dist/gate/hook-fill-main.js +1 -1
  8. package/dist/gate/hook-fill.d.ts +1 -1
  9. package/dist/gate/hook-fill.js +1 -1
  10. package/dist/gate/hook-main.js +7 -5
  11. package/dist/gate/hook.d.ts +12 -1
  12. package/dist/gate/hook.js +38 -7
  13. package/dist/hook-bin.js +23 -4
  14. package/dist/main.js +5 -0
  15. package/dist/node-floor.d.ts +10 -0
  16. package/dist/node-floor.js +23 -0
  17. package/dist/oars-hook.d.ts +2 -0
  18. package/dist/oars-hook.js +7 -0
  19. package/dist/sessions/allow.d.ts +1 -0
  20. package/dist/sessions/allow.js +1 -1
  21. package/dist/sessions/call-shape.d.ts +111 -0
  22. package/dist/sessions/call-shape.js +464 -0
  23. package/dist/sessions/candidates.d.ts +65 -0
  24. package/dist/sessions/candidates.js +236 -0
  25. package/dist/sessions/card-command.d.ts +24 -0
  26. package/dist/sessions/card-command.js +71 -0
  27. package/dist/sessions/card-font.d.ts +6 -0
  28. package/dist/sessions/card-font.js +36 -0
  29. package/dist/sessions/card-line.d.ts +30 -0
  30. package/dist/sessions/card-line.js +105 -0
  31. package/dist/sessions/card-render.d.ts +15 -0
  32. package/dist/sessions/card-render.js +146 -0
  33. package/dist/sessions/card.d.ts +59 -0
  34. package/dist/sessions/card.js +107 -0
  35. package/dist/sessions/cell-increments.d.ts +14 -3
  36. package/dist/sessions/cell-increments.js +79 -12
  37. package/dist/sessions/cells.d.ts +42 -4
  38. package/dist/sessions/cells.js +72 -18
  39. package/dist/sessions/codex-hook-bin.d.ts +1 -0
  40. package/dist/sessions/codex-hook-bin.js +24 -0
  41. package/dist/sessions/codex-hook-install.d.ts +21 -0
  42. package/dist/sessions/codex-hook-install.js +47 -0
  43. package/dist/sessions/codex-hook-view.d.ts +20 -0
  44. package/dist/sessions/codex-hook-view.js +20 -0
  45. package/dist/sessions/codex-hook.d.ts +77 -0
  46. package/dist/sessions/codex-hook.js +255 -0
  47. package/dist/sessions/codex.d.ts +9 -0
  48. package/dist/sessions/codex.js +104 -18
  49. package/dist/sessions/command.d.ts +17 -2
  50. package/dist/sessions/command.js +145 -33
  51. package/dist/sessions/copilot.d.ts +3 -0
  52. package/dist/sessions/copilot.js +12 -4
  53. package/dist/sessions/derive.js +2 -1
  54. package/dist/sessions/empty-state.d.ts +17 -0
  55. package/dist/sessions/empty-state.js +38 -0
  56. package/dist/sessions/exclusion.js +48 -3
  57. package/dist/sessions/extract.d.ts +16 -3
  58. package/dist/sessions/extract.js +281 -24
  59. package/dist/sessions/gemini.d.ts +2 -0
  60. package/dist/sessions/gemini.js +29 -2
  61. package/dist/sessions/harness-files.d.ts +27 -0
  62. package/dist/sessions/harness-files.js +109 -0
  63. package/dist/sessions/head-only.d.ts +7 -0
  64. package/dist/sessions/head-only.js +21 -0
  65. package/dist/sessions/hook-calls.js +4 -3
  66. package/dist/sessions/hooks.d.ts +1 -1
  67. package/dist/sessions/hooks.js +6 -2
  68. package/dist/sessions/identity.d.ts +6 -0
  69. package/dist/sessions/injected.d.ts +13 -0
  70. package/dist/sessions/injected.js +260 -0
  71. package/dist/sessions/install.d.ts +31 -20
  72. package/dist/sessions/install.js +80 -82
  73. package/dist/sessions/load-status.d.ts +41 -0
  74. package/dist/sessions/load-status.js +156 -0
  75. package/dist/sessions/machine-child-deps.d.ts +5 -0
  76. package/dist/sessions/machine-child-deps.js +83 -0
  77. package/dist/sessions/machine-child.d.ts +43 -0
  78. package/dist/sessions/machine-child.js +120 -0
  79. package/dist/sessions/machine-credential.d.ts +10 -0
  80. package/dist/sessions/machine-credential.js +20 -0
  81. package/dist/sessions/machine-enroll.d.ts +47 -0
  82. package/dist/sessions/machine-enroll.js +230 -0
  83. package/dist/sessions/machine-hold.d.ts +49 -0
  84. package/dist/sessions/machine-hold.js +88 -0
  85. package/dist/sessions/machine-ids.d.ts +6 -0
  86. package/dist/sessions/machine-ids.js +9 -0
  87. package/dist/sessions/machine-notice.d.ts +21 -0
  88. package/dist/sessions/machine-notice.js +89 -0
  89. package/dist/sessions/machine-pull.d.ts +40 -0
  90. package/dist/sessions/machine-pull.js +220 -0
  91. package/dist/sessions/machine-repo.d.ts +7 -0
  92. package/dist/sessions/machine-repo.js +30 -0
  93. package/dist/sessions/machine-scan-cache.d.ts +12 -0
  94. package/dist/sessions/machine-scan-cache.js +47 -0
  95. package/dist/sessions/machine-send.d.ts +12 -0
  96. package/dist/sessions/machine-send.js +156 -0
  97. package/dist/sessions/machine-stop.d.ts +22 -0
  98. package/dist/sessions/machine-stop.js +53 -0
  99. package/dist/sessions/machine.d.ts +41 -0
  100. package/dist/sessions/machine.js +89 -0
  101. package/dist/sessions/opencode.d.ts +2 -0
  102. package/dist/sessions/opencode.js +6 -0
  103. package/dist/sessions/otel.d.ts +21 -0
  104. package/dist/sessions/otel.js +109 -38
  105. package/dist/sessions/owner-only.d.ts +7 -0
  106. package/dist/sessions/owner-only.js +34 -0
  107. package/dist/sessions/pi.d.ts +13 -1
  108. package/dist/sessions/pi.js +27 -13
  109. package/dist/sessions/pool-books.d.ts +1 -1
  110. package/dist/sessions/pool-books.js +1 -1
  111. package/dist/sessions/pool-choices.d.ts +4 -2
  112. package/dist/sessions/pool-choices.js +4 -2
  113. package/dist/sessions/pool-command.d.ts +46 -6
  114. package/dist/sessions/pool-command.js +613 -136
  115. package/dist/sessions/pool-delta.d.ts +7 -0
  116. package/dist/sessions/pool-delta.js +28 -5
  117. package/dist/sessions/pool-due.d.ts +43 -0
  118. package/dist/sessions/pool-due.js +113 -0
  119. package/dist/sessions/pool-nightly.d.ts +9 -1
  120. package/dist/sessions/pool-nightly.js +65 -21
  121. package/dist/sessions/pool-outbox.d.ts +51 -5
  122. package/dist/sessions/pool-outbox.js +217 -24
  123. package/dist/sessions/pool-print.d.ts +1 -1
  124. package/dist/sessions/pool-raw.js +4 -3
  125. package/dist/sessions/pool-replica.d.ts +4 -4
  126. package/dist/sessions/pool-replica.js +26 -7
  127. package/dist/sessions/pool-send-lock.js +1 -1
  128. package/dist/sessions/pool-sent.d.ts +2 -0
  129. package/dist/sessions/pool-sent.js +21 -4
  130. package/dist/sessions/pool-wire.d.ts +176 -15
  131. package/dist/sessions/pool-wire.js +297 -33
  132. package/dist/sessions/pool.js +4 -2
  133. package/dist/sessions/public-vocabulary.d.ts +5 -0
  134. package/dist/sessions/public-vocabulary.js +16 -0
  135. package/dist/sessions/read-call-shape.js +12 -6
  136. package/dist/sessions/read-fit.d.ts +5 -0
  137. package/dist/sessions/read-fit.js +23 -2
  138. package/dist/sessions/read-interaction.js +1 -1
  139. package/dist/sessions/read-lead-lag.js +1 -1
  140. package/dist/sessions/read-readable.js +1 -1
  141. package/dist/sessions/read.d.ts +1 -1
  142. package/dist/sessions/recall-consequence.d.ts +2 -2
  143. package/dist/sessions/recall-consequence.js +14 -9
  144. package/dist/sessions/recall-object.d.ts +9 -2
  145. package/dist/sessions/receipt.d.ts +8 -0
  146. package/dist/sessions/receipt.js +78 -0
  147. package/dist/sessions/recovery-calls.d.ts +3 -2
  148. package/dist/sessions/recovery-calls.js +9 -3
  149. package/dist/sessions/redact.d.ts +11 -2
  150. package/dist/sessions/redact.js +201 -23
  151. package/dist/sessions/repo-root.d.ts +7 -0
  152. package/dist/sessions/repo-root.js +17 -13
  153. package/dist/sessions/reversion.d.ts +8 -0
  154. package/dist/sessions/reversion.js +114 -0
  155. package/dist/sessions/schema.d.ts +78 -4
  156. package/dist/sessions/schema.js +31 -1
  157. package/dist/sessions/session-shape.d.ts +2 -1
  158. package/dist/sessions/session-shape.js +15 -0
  159. package/dist/sessions/sources.d.ts +9 -0
  160. package/dist/sessions/sources.js +16 -5
  161. package/dist/sessions/steps.js +3 -2
  162. package/dist/sessions/store.d.ts +41 -2
  163. package/dist/sessions/store.js +68 -16
  164. package/dist/sessions/try-demo.d.ts +8 -0
  165. package/dist/sessions/try-demo.js +90 -0
  166. package/dist/sessions/try.d.ts +13 -0
  167. package/dist/sessions/try.js +50 -0
  168. package/dist/sessions/vscode-copilot.d.ts +4 -0
  169. package/dist/sessions/vscode-copilot.js +25 -18
  170. package/dist/telemetry/command.js +12 -6
  171. package/dist/telemetry/consent.d.ts +9 -3
  172. package/dist/telemetry/consent.js +25 -9
  173. package/dist/telemetry/fields.d.ts +11 -0
  174. package/dist/telemetry/fields.js +56 -0
  175. package/dist/telemetry/measures.d.ts +13 -0
  176. package/dist/telemetry/measures.js +30 -0
  177. package/dist/telemetry/observe.d.ts +15 -2
  178. package/dist/telemetry/observe.js +34 -7
  179. package/dist/telemetry/region.d.ts +6 -0
  180. package/dist/telemetry/region.js +28 -0
  181. package/dist/telemetry/report.js +1 -1
  182. package/dist/telemetry/schema.d.ts +3 -0
  183. package/dist/telemetry/schema.js +11 -1
  184. package/dist/telemetry/send.d.ts +5 -2
  185. package/dist/telemetry/send.js +15 -7
  186. package/dist/telemetry/words.d.ts +6 -0
  187. package/dist/telemetry/words.js +6 -0
  188. package/package.json +24 -5
@@ -0,0 +1,89 @@
1
+ // The notice file of a machine: the one way the detached child tells the person anything. A child prints to nowhere (its output is redirected, and its Stop has long returned), so a condition the person has to act on is written here, and the
2
+ // next Stop reads the file before it starts its own child and prints it once, as the hook's `systemMessage`. The file is `<store>/machine-notice.json`, `{"entries":[{"code":"vocabulary_expired","at":<ms>}]}`, one entry per code
3
+ // (a repeat replaces its entry, so a condition that holds at every Stop is one line, not a pile). `vocabulary_expired` is the first code: a client past its vocabulary's age builds no batch, and its sessions wait.
4
+ // The reader runs inside the Stop hook, so it is synchronous and reads only this one small file.
5
+ import { readFileSync, renameSync, rmSync } from 'node:fs';
6
+ import { readFile } from 'node:fs/promises';
7
+ import { join } from 'node:path';
8
+ import { mkdirOwnerOnly, writeOwnerOnly } from './owner-only.js';
9
+ export const NOTICE_CODES = ['vocabulary_expired', 'hold_unconfigured', 'machine_key_expiring', 'machine_key_expired', 'machine_key_revoked', 'machine_key_retired', 'machine_key_unknown'];
10
+ const NOTICE = 'machine-notice.json';
11
+ // When each code was last written: the notice file is cleared when a Stop prints it, so the cap needs a record that outlives it.
12
+ const WRITTEN = 'machine-notified.json';
13
+ /** A code is written at most this often, so an expired client prints its message once a day, not at every Stop. */
14
+ export const NOTICE_INTERVAL_MS = 24 * 60 * 60 * 1000;
15
+ export const noticeFile = (storeDir) => join(storeDir, NOTICE);
16
+ /** What the person is told for each code: what stopped, what is kept, and the one step. */
17
+ const LIST = 'oars pool machines lists the machines of your account, their expiry included.';
18
+ const TEXT = {
19
+ hold_unconfigured: () => 'oars on this machine holds nothing: OARS_MACHINE or OARS_SESSION_SALT is missing from its enrollment, so no session reaches its hold branch and the data of its sessions is lost. Run oars pool enroll --hold-only on your own machine and set the printed block in this environment.',
20
+ vocabulary_expired: () => 'oars on this machine is out of date, so it builds no deposit batches: its closed vocabulary is past its age limit. The sessions stay on this machine. Upgrade with npm install -g @manyoars/oars.',
21
+ machine_key_expiring: (entry) => `The pool key of this machine expires ${entry.expiresAt === undefined ? 'soon' : `on ${new Date(entry.expiresAt).toISOString().slice(0, 10)}`}; after that its deposits are refused. ${LIST} Enroll the machine again with oars pool enroll before then.`,
22
+ machine_key_expired: () => `The pool key of this machine has expired, so its deposits are refused and its batches wait in its outbox. ${LIST} Enroll the machine again with oars pool enroll.`,
23
+ machine_key_revoked: () => 'The account revoked the pool key of this machine, so its deposits are refused and its batches wait in its outbox. See oars pool machines; enroll this machine again with oars pool enroll if the revocation was a mistake.',
24
+ machine_key_retired: () => `The pool key of this machine was replaced by a rotation that did not finish on this machine, so the pool refuses what is left of it and its batches wait in its outbox. ${LIST} Enroll the machine again with oars pool enroll.`,
25
+ machine_key_unknown: () => `The pool does not know the credential this machine presents, so its deposits are refused and its batches wait in its outbox. Check the network secret (cloud) or the key file (other machines), or enroll the machine again; ${LIST} If this machine is hold-only, set OARS_HOLD_ONLY=1.`,
26
+ };
27
+ const isCode = (value) => NOTICE_CODES.some((code) => code === value);
28
+ /** The entries in `text`, those with a known code and a numeric time; anything else in the file (a newer client's code, a damaged line) is dropped. */
29
+ const entriesOf = (text) => {
30
+ let parsed;
31
+ try {
32
+ parsed = JSON.parse(text);
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ const listed = typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'entries') : undefined;
38
+ if (!Array.isArray(listed))
39
+ return [];
40
+ return listed.flatMap((entry) => {
41
+ const code = typeof entry === 'object' && entry !== null ? Reflect.get(entry, 'code') : undefined;
42
+ const at = typeof entry === 'object' && entry !== null ? Reflect.get(entry, 'at') : undefined;
43
+ const expiresAt = typeof entry === 'object' && entry !== null ? Reflect.get(entry, 'expiresAt') : undefined;
44
+ return isCode(code) && typeof at === 'number' ? [{ code, at, ...(typeof expiresAt === 'number' ? { expiresAt } : {}) }] : [];
45
+ });
46
+ };
47
+ /** The child records a condition: its entry replaces an earlier one of the same code and keeps its place, so the file lists the codes in the order they were first raised. A notice file it cannot read is rewritten. */
48
+ export const recordNotice = async (storeDir, code, at, expiresAt) => {
49
+ const written = entriesOf(await readFile(join(storeDir, WRITTEN), 'utf8').catch(() => ''));
50
+ const last = written.find((entry) => entry.code === code);
51
+ // A clock that moved backward (`at < last.at`) writes again, rather than staying quiet until it catches up.
52
+ if (last !== undefined && at >= last.at && at - last.at < NOTICE_INTERVAL_MS)
53
+ return;
54
+ const kept = entriesOf(await readFile(noticeFile(storeDir), 'utf8').catch(() => ''));
55
+ // A Map keeps a key's first place when it is set again.
56
+ const byCode = new Map(kept.map((entry) => [entry.code, entry]));
57
+ // A key that is expired or revoked is no longer "expiring": one message must not say both.
58
+ if (code === 'machine_key_expired' || code === 'machine_key_revoked')
59
+ byCode.delete('machine_key_expiring');
60
+ byCode.set(code, { code, at, ...(expiresAt === undefined ? {} : { expiresAt }) });
61
+ const entries = [...byCode.values()];
62
+ await mkdirOwnerOnly(storeDir);
63
+ await writeOwnerOnly(noticeFile(storeDir), `${JSON.stringify({ entries })}\n`);
64
+ await writeOwnerOnly(join(storeDir, WRITTEN), `${JSON.stringify({ entries: [...written.filter((entry) => entry.code !== code), { code, at }] })}\n`);
65
+ };
66
+ /** The one message for a set of entries, or `undefined` for none. */
67
+ export const messageOf = (entries) => (entries.length === 0 ? undefined : entries.map((entry) => TEXT[entry.code](entry)).join(' '));
68
+ /**
69
+ * The Stop reads the notice and clears it: the file is renamed first, so two Stops never both print it, then read and removed. The text, or `undefined` when there is no notice. Never throws: a hook that cannot read its notice goes on to
70
+ * start its child, and the condition, still true, is written again by that child.
71
+ */
72
+ export const takeNotice = (storeDir) => {
73
+ const taken = `${noticeFile(storeDir)}.${String(process.pid)}`;
74
+ try {
75
+ renameSync(noticeFile(storeDir), taken);
76
+ }
77
+ catch {
78
+ return undefined;
79
+ }
80
+ try {
81
+ return messageOf(entriesOf(readFileSync(taken, 'utf8')));
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ finally {
87
+ rmSync(taken, { force: true });
88
+ }
89
+ };
@@ -0,0 +1,40 @@
1
+ import type { Outcome, RunDeps } from '../cli.js';
2
+ import { type Visibility } from './machine-enroll.js';
3
+ import { type LockTimes } from './pool-send-lock.js';
4
+ import type { Git } from './survival.js';
5
+ /** What a pull reaches outside the store: GitHub's record of a repository, local git plumbing (`runGit`), git over the network (`runNetGit`), and where a repository is fetched from. */
6
+ export interface PullPorts {
7
+ visibility: Visibility;
8
+ git: Git;
9
+ net: Git;
10
+ remoteOf: (repo: string) => string;
11
+ /** The send lock's waits, for a test that cannot sit out the two-minute default. */
12
+ lock?: Partial<LockTimes>;
13
+ /** Called as a pull comes to the send lock to write an admitted batch: a test waits on it instead of a sleep. */
14
+ onLock?: () => void;
15
+ }
16
+ /** Why a repository or a branch was set aside; the report names it, and a test holds each. */
17
+ export type PullReason = 'repo_public' | 'repo_unconfirmed' | 'repo_unreadable' | 'branch_name' | 'unknown_machine' | 'machine_repo' | 'duplicate_id' | 'unreadable_file' | 'batch_invalid' | 'id_mismatch' | 'stamp_invalid' | 'terms_stale' | 'differing_stamp' | 'field_unknown' | 'claimed_present' | 'off_list' | 'a38_term' | 'machine_record';
18
+ /** A repository (no `branch`) or a branch that was refused, and why. */
19
+ export interface PullRefusal {
20
+ repo: string;
21
+ branch?: string;
22
+ reason: PullReason;
23
+ detail: string;
24
+ }
25
+ /** A batch that reached the outbox, under the folder of its stamp. */
26
+ export interface PullAdmission {
27
+ repo: string;
28
+ branch: string;
29
+ id: string;
30
+ stamp: string;
31
+ /** Whether the branch is gone from the repository. False when it moved after it was fetched (a newer push is kept and the next pull admits it) or the remote refused the delete; the batch is admitted either way. */
32
+ branchDeleted: boolean;
33
+ }
34
+ /**
35
+ * Pulls what every enrolled machine held. Each repository on any machine's list is checked against GitHub's record again on every run, and one that is no longer private is refused and reported, its branches unread. The branches that
36
+ * remain are taken newest first, and the newest of an `id` alone speaks for it: an older branch of that `id` is reported as a duplicate whether or not the newest was admitted; each branch must name a machine this one enrolled, for that repository.
37
+ */
38
+ export declare const holdPull: (deps: RunDeps, options: {
39
+ server?: string | undefined;
40
+ }, ports: PullPorts) => Promise<Outcome>;
@@ -0,0 +1,220 @@
1
+ // `oars pool hold pull`: admission of the batches a cloud VM held on `oars-hold/<machine>/<id>` branches of its own private repository (the VM side is `machine-hold.ts`). The trusted machine does it, and treats every branch as input
2
+ // from outside: anything able to push a branch can write one, the session's own agent included. A branch that fails any gate is reported with its reason, writes nothing to the outbox, and stays where it is; the branch of an admitted batch is deleted
3
+ // from here (the VM's proxy refuses deletions), and only after the batch and its `pulled.json` entry are durable.
4
+ import { mkdtemp, readdir, rm } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { CliError, ExitCode } from '../errors.js';
7
+ import { exclusionPaths, loadTerms } from './exclusion.js';
8
+ import { termsDigest } from './machine-hold.js';
9
+ import { readMachines } from './machine-enroll.js';
10
+ import { derivedSalt } from './machine-ids.js';
11
+ import { poolServer, runtimeOf } from './pool-command.js';
12
+ import { SendLockBusy, withSendLock } from './pool-send-lock.js';
13
+ import { BATCH_FIELDS, STAMP_FOLDER, currentStamp, fieldsOf, holdsTerm, outboxFolder, readPulled, removeBatchOf, writeBatch, writePulled } from './pool-outbox.js';
14
+ import { CLAIMED_HOST_CLASSES, CLAIMED_LAUNCHERS, parseSkeleton } from './pool-wire.js';
15
+ import { localSalt } from './store.js';
16
+ const STEP = 'pool-hold-pull';
17
+ class Refused extends Error {
18
+ reason;
19
+ constructor(reason, detail) {
20
+ super(detail);
21
+ this.reason = reason;
22
+ }
23
+ }
24
+ /** The id is the outbox key, which holds only 16 hex digits (`machineIdOf`), so a branch of any other id is refused by its name, before its file is read. */
25
+ const BRANCH = /^oars-hold\/([^/]+)\/([0-9a-f]{16})$/u;
26
+ /** The branch tips of `oars-hold/*` in one repository, each commit only: depth 1, so history is never fetched, and no tag. The scratch repository is bare and lives under `parent` until the pull ends. */
27
+ const tipsOf = async (ports, parent, repo) => {
28
+ const scratch = await mkdtemp(join(parent, 'repo-'));
29
+ await ports.git(scratch, ['init', '--bare', '--quiet']);
30
+ // A depth-1 fetch of a pattern that matches nothing fails, and with branches deleted as they are admitted that is the usual state of a repository: ask first, and fetch only when a held branch is there.
31
+ if ((await ports.net(scratch, ['ls-remote', '--heads', ports.remoteOf(repo), 'refs/heads/oars-hold/*'])).trim() === '')
32
+ return [];
33
+ await ports.net(scratch, ['fetch', '--quiet', '--no-tags', '--depth=1', ports.remoteOf(repo), '+refs/heads/oars-hold/*:refs/held/*']);
34
+ const listing = await ports.git(scratch, ['for-each-ref', '--format=%(refname:strip=2)%09%(objectname)%09%(committerdate:unix)', 'refs/held/']);
35
+ return listing
36
+ .split('\n')
37
+ .filter((line) => line !== '')
38
+ .map((line) => {
39
+ const [name = '', sha = '', at = '0'] = line.split('\t');
40
+ return { repo, branch: `oars-hold/${name}`, sha, at: Number(at), scratch };
41
+ });
42
+ };
43
+ /** A file of the tip's tree, or undefined when the tree has none by that name (or it is not a file). */
44
+ const fileOf = (ports, tip, name) => ports.git(tip.scratch, ['cat-file', 'blob', `${tip.sha}:${name}`]).catch(() => undefined);
45
+ const refusalOf = (tip, error) => {
46
+ if (error instanceof Refused)
47
+ return { repo: tip.repo, branch: tip.branch, reason: error.reason, detail: error.message };
48
+ throw error;
49
+ };
50
+ /** The batch file of a tip as the skeleton the pool's own reader makes of it; throws `Refused` for anything that reader, or the branch's own name, would not take. */
51
+ const skeletonOf = async (run, tip, id) => {
52
+ const batch = await fileOf(run.ports, tip, 'batch.json');
53
+ if (batch === undefined)
54
+ throw new Refused('unreadable_file', 'the branch holds no batch.json');
55
+ let value;
56
+ try {
57
+ value = JSON.parse(batch);
58
+ }
59
+ catch {
60
+ throw new Refused('batch_invalid', 'batch.json is not JSON');
61
+ }
62
+ // None of these details repeats a value of the file: a branch is written by whoever could push it, and its text must not reach a log.
63
+ // `JSON.parse` takes a file nested far deeper than the recursive walks below can follow, and a RangeError out of one of them would end the whole pull.
64
+ try {
65
+ const names = fieldsOf(batch);
66
+ if (names.includes('claimed'))
67
+ throw new Refused('claimed_present', 'the batch holds a claimed field, which only this machine writes');
68
+ if (names.some((name) => !BATCH_FIELDS.includes(name)))
69
+ throw new Refused('field_unknown', 'the batch holds a field outside the batch field list');
70
+ const parsed = parseSkeleton(value);
71
+ if (!parsed.ok)
72
+ throw new Refused('batch_invalid', `${parsed.field}: ${parsed.reason}`);
73
+ if (parsed.extensions.length > 0)
74
+ throw new Refused('off_list', `${String(parsed.extensions.length)} value or field off the current lists, at ${parsed.extensions.slice(0, 3).map(({ at }) => at).join(', ')}`);
75
+ if (parsed.skeleton.id !== id)
76
+ throw new Refused('id_mismatch', 'the batch names another id than its branch');
77
+ if (holdsTerm(value, run.list))
78
+ throw new Refused('a38_term', 'a string or key of the batch holds a term of the exclusion list of this machine');
79
+ return parsed.skeleton;
80
+ }
81
+ catch (error) {
82
+ if (error instanceof RangeError)
83
+ throw new Refused('batch_invalid', 'batch.json is nested too deeply');
84
+ throw error;
85
+ }
86
+ };
87
+ /** A refusal of a batch built under another stamp is reported as that, with the cause: an older vocabulary or field list is the usual reason a file fails the reader or the closed lists now. */
88
+ const underStamp = async (stamp, work) => {
89
+ try {
90
+ return await work();
91
+ }
92
+ catch (error) {
93
+ if (error instanceof Refused && stamp !== currentStamp())
94
+ throw new Refused('differing_stamp', `${error.reason}: ${error.message}`);
95
+ throw error;
96
+ }
97
+ };
98
+ /**
99
+ * Deletes the branch the batch came from, but only while it still points at the tip that was read: a push that landed since is a newer state of the session and is left for the next pull. The remote's own text is never kept, since it can carry
100
+ * the remote's URL. Answers whether the branch is gone.
101
+ */
102
+ const deleteBranch = async (ports, tip) => {
103
+ try {
104
+ await ports.net(tip.scratch, ['push', '--quiet', `--force-with-lease=refs/heads/${tip.branch}:${tip.sha}`, ports.remoteOf(tip.repo), `:refs/heads/${tip.branch}`]);
105
+ return true;
106
+ }
107
+ catch {
108
+ return false;
109
+ }
110
+ };
111
+ /** One tip through its gates; answers the admission, or throws `Refused` with the first gate that failed. Nothing is written before the batch is through every gate. */
112
+ const admit = async (run, tip, id, machine) => {
113
+ const record = run.machines.find((known) => known.machine === machine);
114
+ if (record === undefined)
115
+ throw new Refused('unknown_machine', `no machine ${machine} in machines.jsonl`);
116
+ if (!record.allowRepos.includes(tip.repo))
117
+ throw new Refused('machine_repo', `machine ${machine} was not enrolled for ${tip.repo}`);
118
+ // The claim comes from this machine's own record and nothing the branch holds, so a record the closed lists do not hold admits nothing.
119
+ const hostClass = CLAIMED_HOST_CLASSES.find((name) => name === record.hostClass);
120
+ const launcher = CLAIMED_LAUNCHERS.find((name) => name === record.launcher);
121
+ if (hostClass === undefined || launcher === undefined)
122
+ throw new Refused('machine_record', `machines.jsonl holds a host class or launcher for machine ${machine} that the closed lists do not`);
123
+ // The stamp becomes a path segment, so it is checked before anything else of the branch is used.
124
+ const stamp = (await fileOf(run.ports, tip, 'stamp'))?.trim();
125
+ if (stamp === undefined || !STAMP_FOLDER.test(stamp))
126
+ throw new Refused('stamp_invalid', 'the branch holds no stamp, or it is not a stamp folder name');
127
+ // The compare is made whatever the stamp says: a list that changed since the environment was enrolled keeps every branch of that environment.
128
+ const held = (await fileOf(run.ports, tip, 'terms'))?.trim();
129
+ if (held !== run.terms)
130
+ throw new Refused('terms_stale', held === undefined ? 'the branch holds no terms digest' : 'the branch was gated under another exclusion term list than this machine\'s');
131
+ const skeleton = await underStamp(stamp, () => skeletonOf(run, tip, id));
132
+ // The stamp's version names the vocabulary the batch was built under: a file that says otherwise is not what its stamp claims.
133
+ if (stamp.slice(0, -9) !== `v${skeleton.vocabVersion}`)
134
+ throw new Refused('stamp_invalid', 'the stamp does not name the batch\'s vocabulary version');
135
+ // The send lock is held across the writes, because the nightly and `--send` change `pulled.json` and prune the current-stamp folder under it: without it a pass could prune the batch before its entry is written, or write its own read of
136
+ // `pulled.json` over the new entry. The file is read again inside the lock and only this id is merged. The order is the batch, then pulled.json, then the older copy: a crash between them leaves the next pull to replace the same id.
137
+ run.ports.onLock?.();
138
+ await withSendLock(run.outbox, async () => {
139
+ await writeBatch(run.outbox, id, { ...skeleton, claimed: { hostClass, launcher } }, stamp);
140
+ const pulled = await readPulled(run.outbox, STEP);
141
+ pulled[id] = stamp;
142
+ await writePulled(run.outbox, pulled);
143
+ for (const folder of await readdir(run.outbox)) {
144
+ if (folder !== stamp && STAMP_FOLDER.test(folder))
145
+ await removeBatchOf(run.outbox, id, folder);
146
+ }
147
+ }, run.ports.lock);
148
+ // The lock is let go first, so a waiting send pass is not held up by the network. The batch and `pulled.json` are durable, so the branch is no longer the only copy; a crash before this line leaves it for the next pull to replace the same id.
149
+ return { repo: tip.repo, branch: tip.branch, id, stamp, branchDeleted: await deleteBranch(run.ports, tip) };
150
+ };
151
+ const summary = (admitted, refused) => [
152
+ `Admitted ${String(admitted.length)} ${admitted.length === 1 ? 'batch' : 'batches'}; refused ${String(refused.length)}. A refused branch stays where it is; an admitted one is deleted.`,
153
+ ...admitted.map((entry) => ` admitted ${entry.branch} (${entry.repo}) as ${entry.id} under ${entry.stamp}${entry.branchDeleted ? '' : ' (branch kept: it moved since it was read, or the repository refused the delete)'}`),
154
+ ...refused.map((entry) => ` refused ${entry.branch ?? 'the repository'} (${entry.repo}): ${entry.reason}: ${entry.detail}`),
155
+ ].join('\n');
156
+ /**
157
+ * Pulls what every enrolled machine held. Each repository on any machine's list is checked against GitHub's record again on every run, and one that is no longer private is refused and reported, its branches unread. The branches that
158
+ * remain are taken newest first, and the newest of an `id` alone speaks for it: an older branch of that `id` is reported as a duplicate whether or not the newest was admitted; each branch must name a machine this one enrolled, for that repository.
159
+ */
160
+ export const holdPull = async (deps, options, ports) => {
161
+ const runtime = runtimeOf(deps);
162
+ const outbox = outboxFolder(runtime.storeDir, poolServer(deps.env, STEP, options.server));
163
+ const machines = await readMachines(runtime.storeDir);
164
+ const repos = [...new Set(machines.flatMap((record) => record.allowRepos))].sort();
165
+ const admitted = [];
166
+ const refused = [];
167
+ if (repos.length === 0)
168
+ return { document: { ok: true, admitted, refused }, text: 'No machine is enrolled: nothing to pull. oars pool enroll --hold-only enrolls one.' };
169
+ // An empty term file refuses everything, as the local gate does: a digest of no terms would admit a branch from an environment that held none.
170
+ const termsFile = exclusionPaths(runtime.projectsRoot, deps.env).terms;
171
+ const terms = await loadTerms(termsFile);
172
+ if (terms.length === 0)
173
+ throw new CliError(`No exclusion terms in ${termsFile}: every branch would be unverifiable. Nothing was fetched.`, ExitCode.USAGE, STEP);
174
+ const sessionSalt = derivedSalt((await (runtime.accountSalt ?? localSalt)(runtime.storeDir)).salt, 'sessionId/1');
175
+ // Read once to stop before any fetch when the file is bad; `admit` reads it again under the send lock, where it is changed.
176
+ await readPulled(outbox, STEP);
177
+ const scratch = await mkdtemp(join(runtime.storeDir, 'hold-pull-'));
178
+ try {
179
+ const tips = [];
180
+ for (const repo of repos) {
181
+ const seen = await ports.visibility(repo);
182
+ if (seen !== 'private') {
183
+ refused.push({ repo, reason: seen === 'public' ? 'repo_public' : 'repo_unconfirmed', detail: seen === 'public' ? 'the repository is public now: its branches were not read' : 'could not confirm the repository is private (a GH_TOKEN or GITHUB_TOKEN that can read it is needed): its branches were not read' });
184
+ continue;
185
+ }
186
+ try {
187
+ tips.push(...(await tipsOf(ports, scratch, repo)));
188
+ }
189
+ catch {
190
+ // The git error text carries the command line and the remote's URL, so the report gives a fixed cause instead.
191
+ refused.push({ repo, reason: 'repo_unreadable', detail: 'its branches could not be fetched (check this machine\'s git credential for the repository)' });
192
+ }
193
+ }
194
+ const run = { ports, outbox, machines, terms: termsDigest(sessionSalt, terms), list: terms };
195
+ const taken = new Set();
196
+ for (const tip of [...tips].sort((a, b) => b.at - a.at || (a.branch < b.branch ? -1 : 1))) {
197
+ try {
198
+ const named = BRANCH.exec(tip.branch);
199
+ if (named === null)
200
+ throw new Refused('branch_name', 'the branch is not oars-hold/<machine>/<id>');
201
+ const [, machine = '', id = ''] = named;
202
+ if (taken.has(id))
203
+ throw new Refused('duplicate_id', `a newer branch already supplied ${id}`);
204
+ // Taken before the gates: the newest tip of an `id` alone speaks for it, so when it is refused an older one is not admitted in its place.
205
+ taken.add(id);
206
+ admitted.push(await admit(run, tip, id, machine));
207
+ }
208
+ catch (error) {
209
+ // A night holds the lock across its whole send, so a pull that overlaps a long one can be turned away: what was admitted before stays, and the tips left are read by the next pull.
210
+ if (error instanceof SendLockBusy)
211
+ throw new CliError('Another oars run is sending from this outbox. The batches already admitted stay; the rest were not pulled. Run oars pool hold pull again in a minute.', ExitCode.CONFLICT, STEP, { title: 'Another oars run is sending from this outbox.', code: 'send_locked', status: 409 }, { admitted });
212
+ refused.push(refusalOf(tip, error));
213
+ }
214
+ }
215
+ }
216
+ finally {
217
+ await rm(scratch, { recursive: true, force: true });
218
+ }
219
+ return { document: { ok: true, admitted, refused }, text: summary(admitted, refused) };
220
+ };
@@ -0,0 +1,7 @@
1
+ import type { Git } from './survival.js';
2
+ /** `owner/name`, lowercased (GitHub names are case-insensitive), from a GitHub remote URL in any common form; `undefined` for anything else. */
3
+ export declare const repoOf: (url: string) => string | undefined;
4
+ /** The repository `origin` names in `cwd`, or `undefined` when the folder is not a repository, has no `origin`, or names one this cannot read. The URL itself is never returned or logged. */
5
+ export declare const originRepo: (git: Git, cwd: string) => Promise<string | undefined>;
6
+ /** Whether the session's folder is a repository on the allow list (`MachineConfig.allowRepos`, already lowercased). A session that forwarded no folder, or an empty list, allows nothing. */
7
+ export declare const repoAllowed: (allowRepos: readonly string[], cwd: string | undefined, git: Git) => Promise<boolean>;
@@ -0,0 +1,30 @@
1
+ // `user[:token]@` is allowed before the host and is never kept: the match below captures the path only, so a credential in a remote URL cannot reach a log or a result.
2
+ const URL_FORM = /^(?:https?|ssh|git):\/\/(?:[^@/\s]+@)?github\.com(?::\d+)?\/([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/iu;
3
+ const SCP_FORM = /^(?:[^@/\s]+@)?github\.com:([\w.-]+)\/([\w.-]+?)(?:\.git)?\/?$/iu;
4
+ /** `owner/name`, lowercased (GitHub names are case-insensitive), from a GitHub remote URL in any common form; `undefined` for anything else. */
5
+ export const repoOf = (url) => {
6
+ const found = URL_FORM.exec(url.trim()) ?? SCP_FORM.exec(url.trim());
7
+ if (found === null)
8
+ return undefined;
9
+ const owner = found[1];
10
+ const name = found[2];
11
+ if (owner === undefined || name === undefined || /^\.+$/u.test(owner) || /^\.+$/u.test(name))
12
+ return undefined;
13
+ return `${owner}/${name}`.toLowerCase();
14
+ };
15
+ /** The repository `origin` names in `cwd`, or `undefined` when the folder is not a repository, has no `origin`, or names one this cannot read. The URL itself is never returned or logged. */
16
+ export const originRepo = async (git, cwd) => {
17
+ try {
18
+ return repoOf(await git(cwd, ['remote', 'get-url', 'origin']));
19
+ }
20
+ catch {
21
+ return undefined;
22
+ }
23
+ };
24
+ /** Whether the session's folder is a repository on the allow list (`MachineConfig.allowRepos`, already lowercased). A session that forwarded no folder, or an empty list, allows nothing. */
25
+ export const repoAllowed = async (allowRepos, cwd, git) => {
26
+ if (cwd === undefined || allowRepos.length === 0)
27
+ return false;
28
+ const repo = await originRepo(git, cwd);
29
+ return repo !== undefined && allowRepos.includes(repo);
30
+ };
@@ -0,0 +1,12 @@
1
+ /** How long a clean verdict of a session this Stop does not build is reused. */
2
+ export declare const SCAN_TTL_MS: number;
3
+ export interface ScanCache {
4
+ /** Whether this session passed the gate at most `SCAN_TTL_MS` ago, under this term list. */
5
+ fresh: (project: string, session: string) => boolean;
6
+ /** Records a pass of the gate now. */
7
+ note: (project: string, session: string) => void;
8
+ /** Writes the cache when a pass was noted. */
9
+ save: () => Promise<void>;
10
+ }
11
+ /** The cache for this term list (`terms` is the list's hash, so a changed list starts empty). An unreadable or damaged file is an empty cache. */
12
+ export declare const scanCache: (storeDir: string, terms: string, now: () => number) => Promise<ScanCache>;
@@ -0,0 +1,47 @@
1
+ // What a Stop remembers about the sessions it does not build. A Stop names one session and only reads the others to fill the windows of a delta, but each read is the whole exclusion gate (every transcript, scanned for the terms): measured on a
2
+ // store of 5,000 sessions it was 17.8 s of the 18.6 s a Stop took on Windows. So a session that passed the gate is remembered for ten minutes, per term list: the same list and the same session are not scanned again at the Stop after.
3
+ // A session that gains a term inside the window is still excluded from every batch (the session a Stop builds is always scanned in full); it can only fill a window for ten minutes more. `<store>/machine-scan-cache.json`, owner-only.
4
+ import { readFile } from 'node:fs/promises';
5
+ import { join } from 'node:path';
6
+ import { mkdirOwnerOnly, writeOwnerOnly } from './owner-only.js';
7
+ /** How long a clean verdict of a session this Stop does not build is reused. */
8
+ export const SCAN_TTL_MS = 10 * 60 * 1000;
9
+ const FILE = 'machine-scan-cache.json';
10
+ const keyOf = (project, session) => JSON.stringify([project, session]);
11
+ const timesOf = (text, terms) => {
12
+ let parsed;
13
+ try {
14
+ parsed = JSON.parse(text);
15
+ }
16
+ catch {
17
+ return new Map();
18
+ }
19
+ const same = typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'terms') : undefined;
20
+ const listed = typeof parsed === 'object' && parsed !== null ? Reflect.get(parsed, 'passed') : undefined;
21
+ if (same !== terms || typeof listed !== 'object' || listed === null)
22
+ return new Map();
23
+ return new Map(Object.entries(listed).flatMap(([key, at]) => (typeof at === 'number' ? [[key, at]] : [])));
24
+ };
25
+ /** The cache for this term list (`terms` is the list's hash, so a changed list starts empty). An unreadable or damaged file is an empty cache. */
26
+ export const scanCache = async (storeDir, terms, now) => {
27
+ const passed = timesOf(await readFile(join(storeDir, FILE), 'utf8').catch(() => ''), terms);
28
+ let changed = false;
29
+ return {
30
+ fresh: (project, session) => {
31
+ const at = passed.get(keyOf(project, session));
32
+ return at !== undefined && at <= now() && now() - at < SCAN_TTL_MS;
33
+ },
34
+ note: (project, session) => {
35
+ passed.set(keyOf(project, session), now());
36
+ changed = true;
37
+ },
38
+ save: async () => {
39
+ if (!changed)
40
+ return;
41
+ // Entries past their window go: the file never holds more than the sessions of the last ten minutes' Stops.
42
+ const live = [...passed].filter(([, at]) => at <= now() && now() - at < SCAN_TTL_MS);
43
+ await mkdirOwnerOnly(storeDir);
44
+ await writeOwnerOnly(join(storeDir, FILE), `${JSON.stringify({ terms, passed: Object.fromEntries(live) })}\n`);
45
+ },
46
+ };
47
+ };
@@ -0,0 +1,12 @@
1
+ import type { RunDeps } from '../cli.js';
2
+ import { type Sender } from './pool-command.js';
3
+ /** A key this close to its expiry, or past it, is told to the person. */
4
+ export declare const EXPIRY_NOTICE_MS: number;
5
+ export type MachineSendOutcome = 'not-machine' | 'no-server' | 'no-credential' | 'refused' | 'not-accepted' | 'sent';
6
+ export interface MachineSend {
7
+ outcome: MachineSendOutcome;
8
+ /** Batches this run sent. */
9
+ batches: number;
10
+ }
11
+ /** Activates if needed, checks the covenant, and sends every unsent batch this machine built. Never throws: every failure is a line in `pool-send.log`. */
12
+ export declare const machineSend: (deps: RunDeps, via?: Sender) => Promise<MachineSend>;
@@ -0,0 +1,156 @@
1
+ // What the machine child does with the pool, after it has built and held a session's batch (`machine-child.ts` calls it under the send lock). First the key: a token is activated (`POST /v1/machines/activate`), and where the disk is durable the pool says to trade it for a fresh key
2
+ // (`/rotate`), which is written owner-only before the token is deleted, so a crash between the two leaves nothing worse than a run that finds the key and deletes the spent token. A cloud machine's token is held by its runtime's proxy and is its key for good. Then, once the covenant
3
+ // is accepted, every unsent batch the machine built goes to the pool (raw never: a machine key deposits none), and the key's expiry, from any answer that carries it or from the refusal of a dead key, is written to the notice file for the next Stop. A refusal is one masked line in
4
+ // `pool-send.log` and leaves the batch unsent; a dead key sends nothing and deletes nothing. No key or token value is ever logged or put in a notice.
5
+ import { rm, stat } from 'node:fs/promises';
6
+ import { join } from 'node:path';
7
+ import { CliError } from '../errors.js';
8
+ import { credentialOf, keyFile, tokenFile } from './machine-credential.js';
9
+ import { machineOf } from './machine.js';
10
+ import { recordNotice } from './machine-notice.js';
11
+ import { writeOwnerOnly } from './owner-only.js';
12
+ import { ask, DEPOSIT, isObject, networkSender, poolServer, postBatch, refusal, runtimeOf, textIn } from './pool-command.js';
13
+ import { logFailure } from './pool-due.js';
14
+ import { heldAcross, lockedBatch, outboxFolder } from './pool-outbox.js';
15
+ import { markSent, sentHashes } from './pool-sent.js';
16
+ /** A key this close to its expiry, or past it, is told to the person. */
17
+ export const EXPIRY_NOTICE_MS = 14 * 24 * 60 * 60 * 1000;
18
+ /** A cloud VM is new every time, so "activated" is a file in its own store; a durable machine's mark is its key file. */
19
+ const markerOf = (storeDir) => join(storeDir, 'machine-activated');
20
+ const DEAD = { machine_expired: 'machine_key_expired', machine_revoked: 'machine_key_revoked',
21
+ // A rotation that succeeded at the pool but died before the key was written leaves a retired token and no key.
22
+ token_retired: 'machine_key_retired',
23
+ unauthorized: 'machine_key_unknown',
24
+ };
25
+ /** The expiry an answer states, in epoch milliseconds: the deposit and covenant answers carry `machineExpiresAt`, activation the machine's `expiresAt`. A number or a date string. */
26
+ const expiryOf = (body) => {
27
+ if (!isObject(body))
28
+ return undefined;
29
+ const machine = Reflect.get(body, 'machine');
30
+ const value = Reflect.get(body, 'machineExpiresAt') ?? (isObject(machine) ? Reflect.get(machine, 'expiresAt') : undefined);
31
+ const at = typeof value === 'string' ? Date.parse(value) : value;
32
+ return typeof at === 'number' && Number.isFinite(at) ? at : undefined;
33
+ };
34
+ /** Writes the key's state for the next Stop: expired, or expiring within the window; a key with longer to live writes nothing. */
35
+ const noteExpiry = async (storeDir, now, expiresAt) => {
36
+ if (expiresAt <= now)
37
+ await recordNotice(storeDir, 'machine_key_expired', now);
38
+ else if (expiresAt - now <= EXPIRY_NOTICE_MS)
39
+ await recordNotice(storeDir, 'machine_key_expiring', now, expiresAt);
40
+ };
41
+ /** A sender that notes the expiry any answer states as it passes. */
42
+ const watching = (deps, storeDir, net) => async (request) => {
43
+ const reply = await net(request);
44
+ const expiresAt = reply.status < 300 ? expiryOf(reply.body) : undefined;
45
+ if (expiresAt !== undefined)
46
+ await noteExpiry(storeDir, deps.now(), expiresAt);
47
+ return reply;
48
+ };
49
+ /** Whether the pool refused the key itself: that is written for the person, and the caller sends no more. */
50
+ const deadKey = async (run, error) => {
51
+ const notice = DEAD[error instanceof CliError ? (error.problem.code ?? '') : ''];
52
+ if (notice === undefined)
53
+ return false;
54
+ await recordNotice(run.storeDir, notice, run.deps.now());
55
+ return true;
56
+ };
57
+ /** A refusal that is logged, and noticed when it names a dead key. */
58
+ const failed = async (run, error) => {
59
+ await logFailure(run.storeDir, run.deps.now(), error);
60
+ await deadKey(run, error);
61
+ return undefined;
62
+ };
63
+ /** Activates a token and, where the pool says so, trades it for a key written owner-only; the token goes after the key is on disk. Returns the credential to send with, or `undefined` when the pool refused. */
64
+ const activated = async (run, hostClass) => {
65
+ const { deps, server, storeDir, via, credential } = run;
66
+ if (credential.source === 'key') {
67
+ // A crash after the key was written and before the token was deleted leaves the token behind; it is spent, so it goes now.
68
+ await rm(tokenFile(deps.env, storeDir), { force: true });
69
+ return credential;
70
+ }
71
+ if (hostClass === 'cloud' && (await stat(markerOf(storeDir)).then(() => true, () => false)))
72
+ return credential;
73
+ const call = credential.token === undefined ? {} : { token: credential.token };
74
+ const reply = await ask(deps, DEPOSIT, server, '/v1/machines/activate', { ...call, body: {} }, via);
75
+ if (reply.status !== 200 || !isObject(reply.body)) {
76
+ return failed(run, refusal(DEPOSIT, reply));
77
+ }
78
+ if (Reflect.get(reply.body, 'rotate') !== true) {
79
+ await writeOwnerOnly(markerOf(storeDir), `${new Date(deps.now()).toISOString()}\n`);
80
+ return credential;
81
+ }
82
+ const traded = await ask(deps, DEPOSIT, server, '/v1/machines/rotate', { ...call, body: {} }, via);
83
+ const key = traded.status === 200 && isObject(traded.body) ? textIn(traded.body, 'key') : undefined;
84
+ if (key === undefined)
85
+ return failed(run, refusal(DEPOSIT, traded));
86
+ await writeOwnerOnly(keyFile(storeDir), `${key}\n`);
87
+ await rm(tokenFile(deps.env, storeDir), { force: true });
88
+ return { source: 'key', token: key };
89
+ };
90
+ /** `OARS_HOLD_ONLY` set to anything but empty, `0`, or `false`: the machine holds its sessions and deposits nothing itself. */
91
+ const isHoldOnly = (env) => !['', '0', 'false'].includes((env.OARS_HOLD_ONLY ?? '').trim().toLowerCase());
92
+ /** A refusal about this one batch (its content), after which the next may still go; anything else (a dead key, a closed pool, an unreachable one) ends the run. */
93
+ const aboutBatch = (error) => error instanceof CliError && [400, 409, 413, 422].includes(error.problem.status ?? 0);
94
+ /** Activates if needed, checks the covenant, and sends every unsent batch this machine built. Never throws: every failure is a line in `pool-send.log`. */
95
+ export const machineSend = async (deps, via) => {
96
+ const none = (outcome) => ({ outcome, batches: 0 });
97
+ const machine = machineOf(deps.env);
98
+ if (machine === undefined)
99
+ return none('not-machine');
100
+ const { storeDir } = runtimeOf(deps);
101
+ try {
102
+ let server;
103
+ try {
104
+ server = poolServer(deps.env, DEPOSIT);
105
+ }
106
+ catch {
107
+ return none('no-server');
108
+ }
109
+ // A hold-only enrollment has no pool key to present; `OARS_HOLD_ONLY` says so, and every other cloud 401 is reported.
110
+ if (isHoldOnly(deps.env))
111
+ return none('no-credential');
112
+ const first = await credentialOf(machine.hostClass, deps.env, storeDir);
113
+ if (first === undefined)
114
+ return none('no-credential');
115
+ const net = watching(deps, storeDir, via ?? networkSender(deps, DEPOSIT));
116
+ const credential = await activated({ deps, server, storeDir, via: net, credential: first }, machine.hostClass);
117
+ if (credential === undefined)
118
+ return none('refused');
119
+ const run = { deps, server, storeDir, via: net, credential };
120
+ const { token } = credential;
121
+ const covenant = await ask(deps, DEPOSIT, server, '/v1/covenant', token === undefined ? {} : { token }, net);
122
+ if (covenant.status !== 200 || !isObject(covenant.body)) {
123
+ await failed(run, refusal(DEPOSIT, covenant));
124
+ return none('refused');
125
+ }
126
+ if (Reflect.get(covenant.body, 'accepted') !== true)
127
+ return none('not-accepted');
128
+ const outbox = outboxFolder(storeDir, server);
129
+ const done = await sentHashes(outbox);
130
+ const result = none('sent');
131
+ for (const batch of (await heldAcross(outbox)).batches) {
132
+ // A batch `hold pull` admitted is the trusted machine's to rescan against its terms and send; this machine's own are sent as built.
133
+ if (done.has(batch.hash) || batch.pulled)
134
+ continue;
135
+ const locked = await lockedBatch(outbox, batch.hash, { requireShown: false, stamp: batch.stamp });
136
+ if (!locked.ok)
137
+ continue;
138
+ try {
139
+ await postBatch(deps, { server, token, bytes: locked.bytes, hash: batch.hash, via: net });
140
+ }
141
+ catch (error) {
142
+ await failed(run, error);
143
+ if (aboutBatch(error))
144
+ continue;
145
+ return { ...result, outcome: 'refused' };
146
+ }
147
+ await markSent(outbox, batch.hash, deps.now());
148
+ result.batches += 1;
149
+ }
150
+ return result;
151
+ }
152
+ catch (error) {
153
+ await logFailure(storeDir, deps.now(), error);
154
+ return none('refused');
155
+ }
156
+ };