run402 4.61.0 → 4.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.mjs CHANGED
@@ -526,18 +526,23 @@ switch (cmd) {
526
526
  // cli-conventions gate keeps them in lockstep), so no second list.
527
527
  const { COMMAND_MANIFEST, SKIPPED_FAMILIES } = await import("./lib/command-manifest.mjs");
528
528
  const { closestWord } = await import("./lib/argparse.mjs");
529
+ const { describeRejectedValue } = await import("./core-dist/redact.js");
529
530
  const families = new Set([
530
531
  ...COMMAND_MANIFEST.map((entry) => entry.path[0]),
531
532
  ...Object.keys(SKIPPED_FAMILIES),
532
533
  ]);
533
534
  const closest = typeof cmd === "string" ? closestWord(cmd, [...families]) : null;
535
+ // `cmd` is the first bare positional — could be anything a script passes
536
+ // by mistake (kychee-com/run402-private#640 is the demonstrated risk for
537
+ // this shape of value), so it must not be echoed verbatim.
538
+ const shownCmd = describeRejectedValue(cmd);
534
539
  fail({
535
540
  code: "UNKNOWN_COMMAND",
536
541
  message: closest
537
- ? `Unknown command: ${cmd}. Did you mean ${closest}?`
538
- : `Unknown command: ${cmd}`,
542
+ ? `Unknown command: ${shownCmd}. Did you mean ${closest}?`
543
+ : `Unknown command: ${shownCmd}`,
539
544
  hint: "Run `run402 --help` for the command list.",
540
- details: { command: cmd, closest: closest ? [closest] : [] },
545
+ details: { command: shownCmd, closest: closest ? [closest] : [] },
541
546
  });
542
547
  }
543
548
  }
@@ -2,6 +2,7 @@ import { homedir } from "node:os";
2
2
  import { dirname, join } from "node:path";
3
3
  import { existsSync, renameSync, mkdirSync, chmodSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { randomBytes } from "node:crypto";
5
+ import { describeRejectedValue } from "./redact.js";
5
6
  export const DEFAULT_API_BASE = "https://api.run402.com";
6
7
  /**
7
8
  * Validate a user-supplied API base URL. Throws a clear error message that
@@ -25,10 +26,10 @@ function validateApiBase(envVar, raw, fallback) {
25
26
  u = new URL(raw);
26
27
  }
27
28
  catch {
28
- throw new Error(`${envVar} is not a valid URL: ${JSON.stringify(raw)}. Expected an http(s) URL like https://api.run402.com.`);
29
+ throw new Error(`${envVar} is not a valid URL: ${JSON.stringify(describeRejectedValue(raw))}. Expected an http(s) URL like https://api.run402.com.`);
29
30
  }
30
31
  if (u.protocol !== "https:" && u.protocol !== "http:") {
31
- throw new Error(`${envVar} must use http(s):, got ${u.protocol} (full value: ${JSON.stringify(raw)}).`);
32
+ throw new Error(`${envVar} must use http(s):, got ${u.protocol} (full value: ${JSON.stringify(describeRejectedValue(raw))}).`);
32
33
  }
33
34
  return raw;
34
35
  }
@@ -81,7 +82,15 @@ function assertSafeProfileName(name) {
81
82
  if (name === DEFAULT_PROFILE)
82
83
  return;
83
84
  if (!isValidProfileName(name)) {
84
- throw new Error(`Invalid wallet/profile name ${JSON.stringify(name)}. ` +
85
+ // A value that failed this check is a value we know nothing about — the
86
+ // most likely mistake is a typo, but kychee-com/run402-private#640 was a
87
+ // Base-mainnet private key pasted into RUN402_WALLET (a NAME field), and
88
+ // the raw value used to be echoed straight into this message and
89
+ // wherever it propagated (terminal, logs, session transcripts).
90
+ // describeRejectedValue() shows short/plain values in full (useful for
91
+ // an actual typo) and redacts anything long or hex-shaped enough to be
92
+ // a secret instead of a name.
93
+ throw new Error(`Invalid wallet/profile name ${JSON.stringify(describeRejectedValue(name))}. ` +
85
94
  "Names must match /^[a-z0-9][a-z0-9_-]{0,63}$/ (lowercase letters, digits, '_' and '-'). " +
86
95
  "Check the RUN402_WALLET / RUN402_PROFILE env var.");
87
96
  }
@@ -18,6 +18,7 @@ import { readFileSync, writeFileSync, mkdirSync, renameSync, chmodSync, existsSy
18
18
  import { dirname, join } from "node:path";
19
19
  import { randomBytes } from "node:crypto";
20
20
  import { getConfigBaseDir, getProfilesDir, DEFAULT_PROFILE, isValidProfileName, } from "./config.js";
21
+ import { describeRejectedValue } from "./redact.js";
21
22
  function atomicWrite(p, content, mode) {
22
23
  const dir = dirname(p);
23
24
  mkdirSync(dir, { recursive: true });
@@ -153,7 +154,10 @@ export function removeProfile(name) {
153
154
  */
154
155
  export function renameProfile(oldName, newName) {
155
156
  if (!isValidProfileName(newName)) {
156
- throw new Error(`Invalid wallet name ${JSON.stringify(newName)}.`);
157
+ // See describeRejectedValue()'s doc comment (kychee-com/run402-private#640):
158
+ // a rejected value may be a secret pasted into a name field, so it is
159
+ // never safe to echo verbatim here.
160
+ throw new Error(`Invalid wallet name ${JSON.stringify(describeRejectedValue(newName))}.`);
157
161
  }
158
162
  if (newName === oldName)
159
163
  return;
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Safe-echo policy for values that failed local (client-side) validation.
3
+ *
4
+ * A value rejected by a format/existence check is a value we know nothing
5
+ * about — the CLI has only established what it is NOT. The likeliest
6
+ * failure is an ordinary typo, but the second likeliest, demonstrated in
7
+ * practice, is a credential pasted into the wrong slot:
8
+ * kychee-com/run402-private#640 — a Base-mainnet private key holding real
9
+ * funds landed in `RUN402_WALLET` (which takes a wallet NAME, not a key),
10
+ * failed the name check, and was printed verbatim to a terminal, a log, and
11
+ * a session transcript.
12
+ *
13
+ * `describeRejectedValue` is the one place that decides what a rejected
14
+ * value is safe to show, so every call site — wallet names, org ids, room
15
+ * keys, unknown commands/subcommands, "not found" lookups — gets the same
16
+ * answer instead of each reinventing (or forgetting) the judgment call.
17
+ *
18
+ * Short, low-entropy values (ordinary typos) are returned unchanged — that
19
+ * is what makes the resulting error message useful. Long or
20
+ * high-entropy-looking values are replaced with a shape-only description
21
+ * (character count only), never a substring or prefix of the original:
22
+ * even a short prefix of a private key is more than a debugging aid needs
23
+ * and more than a leak should give up.
24
+ *
25
+ * This is a heuristic, not a content-aware secret scanner: treat "returned
26
+ * unchanged" as "short and plain enough to be a typo," not as proof the
27
+ * value holds no secret. Callers with a value that is never supposed to be
28
+ * secret-shaped in the first place (a service key, an admin key) should
29
+ * still never echo it at all, redacted or not — this helper is for slots
30
+ * that normally hold a plain identifier and occasionally, by mistake,
31
+ * don't.
32
+ */
33
+ // Real hand-typed identifiers (wallet names, room keys, command names) top
34
+ // out well under this, and it comfortably clears a UUID (36 chars — the
35
+ // canonical shape of an org id, and *itself* a public, harmless-to-echo
36
+ // identifier even when it's the "wrong" one in a conflict, not a secret).
37
+ // Secrets that land in the wrong slot (private keys, API tokens, JWTs) are
38
+ // almost always well past it — the private key in #640 was 64-66 characters.
39
+ const MAX_SAFE_ECHO_LENGTH = 40;
40
+ // A contiguous hex run (with or without a `0x` prefix) reads as key
41
+ // material or a hash rather than a human-typed identifier, even when short
42
+ // enough to pass the length check above — the exact shape of #640.
43
+ const HEX_RUN_RE = /^(0x)?[0-9a-fA-F]{16,}$/;
44
+ function looksSecretShaped(str) {
45
+ return str.length > MAX_SAFE_ECHO_LENGTH || HEX_RUN_RE.test(str);
46
+ }
47
+ /**
48
+ * Return `value` unchanged when it is short and plain enough to be a
49
+ * harmless typo; otherwise return a shape-only placeholder (character count
50
+ * only — never a substring) that is still safe to embed in a message or a
51
+ * structured `details` field.
52
+ *
53
+ * Never throws. Coerces non-string input the same way template-literal
54
+ * interpolation would, so a caller can pass whatever it already has without
55
+ * a separate type check.
56
+ */
57
+ export function describeRejectedValue(value) {
58
+ const str = typeof value === "string" ? value : String(value ?? "");
59
+ if (looksSecretShaped(str)) {
60
+ return `${str.length} chars, not shown — too long or hex-shaped to be a typo (may be a credential that landed in the wrong place)`;
61
+ }
62
+ return str;
63
+ }
64
+ //# sourceMappingURL=redact.js.map
@@ -1,5 +1,5 @@
1
1
  {
2
- "surface_version": "4.61.0",
2
+ "surface_version": "4.62.0",
3
3
  "verbs": [
4
4
  "repos create",
5
5
  "repos list",
package/lib/argparse.mjs CHANGED
@@ -2,6 +2,7 @@ import { existsSync, statSync } from "node:fs";
2
2
  import { fail } from "./sdk-errors.mjs";
3
3
  import { resolveProjectId } from "./config.mjs";
4
4
  import { COMMAND_MANIFEST } from "./command-manifest.mjs";
5
+ import { describeRejectedValue } from "../core-dist/redact.js";
5
6
 
6
7
  export function normalizeArgv(argv = []) {
7
8
  const out = [];
@@ -140,12 +141,15 @@ export function validateEvmAddress(value, fieldName = "address") {
140
141
  }
141
142
  }
142
143
 
144
+ // A rejected positional could be anything the caller typed or piped in — see
145
+ // core-dist/redact.js's doc comment (kychee-com/run402-private#640) — so it
146
+ // is never safe to echo verbatim.
143
147
  export function failBadProjectId(value) {
144
148
  fail({
145
149
  code: "BAD_PROJECT_ID",
146
- message: `Argument '${value}' is not a project id. Project IDs must start with 'prj_'.`,
150
+ message: `Argument '${describeRejectedValue(value)}' is not a project id. Project IDs must start with 'prj_'.`,
147
151
  hint: "Omit the project id to use the active project, or pass the full prj_... id.",
148
- details: { value, expected_prefix: "prj_" },
152
+ details: { value: describeRejectedValue(value), expected_prefix: "prj_" },
149
153
  });
150
154
  }
151
155
 
@@ -433,16 +437,22 @@ export function knownSubcommands(family) {
433
437
  export function failUnknownSubcommand(family, sub, { hint, label, extraSubcommands = [], next_actions } = {}) {
434
438
  const displayLabel = label ?? family;
435
439
  const known = [...new Set([...knownSubcommands(family), ...extraSubcommands])].sort();
440
+ // closestWord() itself never echoes — it only ever returns one of the
441
+ // known, safe `known` candidates — but `sub` is an arbitrary positional
442
+ // (could be anything piped or mistyped in; kychee-com/run402-private#640
443
+ // is the demonstrated risk), so the ECHO of `sub` below still needs
444
+ // redaction even though the matching runs on the raw value.
436
445
  const closest = typeof sub === "string" ? closestWord(sub, known) : null;
446
+ const shownSub = describeRejectedValue(sub);
437
447
  fail({
438
448
  code: "UNKNOWN_SUBCOMMAND",
439
449
  message: closest
440
- ? `Unknown ${displayLabel} subcommand: ${sub}. Did you mean ${closest}?`
441
- : `Unknown ${displayLabel} subcommand: ${sub}`,
450
+ ? `Unknown ${displayLabel} subcommand: ${shownSub}. Did you mean ${closest}?`
451
+ : `Unknown ${displayLabel} subcommand: ${shownSub}`,
442
452
  hint: hint ?? `Run \`run402 ${displayLabel} --help\` for usage.`,
443
453
  details: {
444
454
  command: displayLabel,
445
- subcommand: sub,
455
+ subcommand: shownSub,
446
456
  closest: closest ? [closest] : [],
447
457
  known_subcommands: known,
448
458
  },
package/lib/email.mjs CHANGED
@@ -4,6 +4,7 @@ import { resolveProjectId } from "./config.mjs";
4
4
  import { getSdk } from "./sdk.mjs";
5
5
  import { reportSdkError, fail, parseFlagJson } from "./sdk-errors.mjs";
6
6
  import { assertKnownFlags, flagValue, normalizeArgv, parseIntegerFlag, positionalArgs, failUnknownSubcommand } from "./argparse.mjs";
7
+ import { describeRejectedValue } from "../core-dist/redact.js";
7
8
 
8
9
  // Extension → content-type for `--attach <path>` without an explicit `:type`.
9
10
  const ATTACH_EXT_CONTENT_TYPES = {
@@ -365,11 +366,14 @@ function mailboxIdFromSelector(envelope, selector, flag) {
365
366
  if (/^mbx_/.test(selector)) return selector;
366
367
  const hit = (envelope.mailboxes ?? []).find((m) => m.mailbox_id === selector || m.slug === selector);
367
368
  if (!hit) {
369
+ // A selector that matches nothing is a value we know nothing about — see
370
+ // core-dist/redact.js's doc comment (kychee-com/run402-private#640) —
371
+ // so it must not be echoed verbatim.
368
372
  fail({
369
373
  code: "MAILBOX_NOT_FOUND",
370
- message: `No mailbox matching ${JSON.stringify(selector)} for ${flag}.`,
374
+ message: `No mailbox matching ${JSON.stringify(describeRejectedValue(selector))} for ${flag}.`,
371
375
  details: {
372
- selector,
376
+ selector: describeRejectedValue(selector),
373
377
  flag,
374
378
  candidates: (envelope.mailboxes ?? []).map(summarizeMailboxForDefaults),
375
379
  },
@@ -89,7 +89,7 @@ export async function runDaemon() {
89
89
  socket.setNoDelay(true);
90
90
  armIdle();
91
91
  let buf = "";
92
- let session = null; // { stdin, restoreEnv, restoreCwd, restoreWrites }
92
+ let session = null; // { stdin, restoreEnv, restoreCwd, restoreWrites, backgroundWork }
93
93
 
94
94
  const send = (obj) => {
95
95
  try {
@@ -99,22 +99,47 @@ export async function runDaemon() {
99
99
  }
100
100
  };
101
101
 
102
- const teardown = () => {
102
+ /**
103
+ * gitvault-checkpoint-cadence design D2: `session.backgroundWork` — set
104
+ * by `onBackgroundWork` below when a push's auto-gc cycle was handed
105
+ * off — is a promise this daemon keeps itself alive for BEFORE
106
+ * restoring env/cwd/`busy`, even though the CLIENT already got its
107
+ * `exit` and the socket is already ending. `session` is cleared and
108
+ * write-redirection restored immediately and SYNCHRONOUSLY (so a stray
109
+ * log line from the background cycle lands on the daemon's own
110
+ * stdout/stderr, never an attempt to write into an already-closing
111
+ * socket) — only `busy`/env/cwd/idle-arming wait on the promise. This
112
+ * is what "keep the daemon alive through it" means structurally:
113
+ * `busy` stays `true` (refusing a new `hello`, and holding off
114
+ * idle-exit) for the ENTIRE compaction, not just the git protocol
115
+ * exchange that preceded it — the single-session invariant this
116
+ * module's whole design rests on would otherwise race a NEW session's
117
+ * env/cwd swap against the still-running compaction's own SDK calls.
118
+ * `backgroundWork` itself never rejects (`maybeRunAutoGc` swallows the
119
+ * cycle's own failure before handing the promise off) — the `catch`
120
+ * here is belt-and-suspenders, not a real error path.
121
+ */
122
+ const teardown = async () => {
103
123
  if (!session) return;
104
124
  const s = session;
105
125
  session = null;
106
- busy = false;
107
126
  try {
108
127
  s.stdin.end();
109
128
  } catch {
110
129
  /* already ended */
111
130
  }
112
131
  s.restoreWrites();
132
+ if (s.backgroundWork) {
133
+ await s.backgroundWork.catch(() => undefined);
134
+ }
135
+ busy = false;
113
136
  s.restoreEnv();
114
137
  s.restoreCwd();
115
138
  armIdle();
116
139
  };
117
- socket.on("close", teardown);
140
+ socket.on("close", () => {
141
+ void teardown();
142
+ });
118
143
  socket.on("error", () => socket.destroy());
119
144
 
120
145
  socket.on("data", (chunk) => {
@@ -186,18 +211,30 @@ export async function runDaemon() {
186
211
  process.stdout.write = realOut;
187
212
  process.stderr.write = realErr;
188
213
  };
189
- session = { stdin, restoreEnv, restoreCwd, restoreWrites };
214
+ session = { stdin, restoreEnv, restoreCwd, restoreWrites, backgroundWork: null };
190
215
  send({ t: "ready" });
191
- runHelperSession(Array.isArray(msg.argv) ? msg.argv : [], { stdin })
216
+ // gitvault-checkpoint-cadence design D2: a push's auto-gc cycle
217
+ // (if triggered) hands its ALREADY-STARTED promise here instead
218
+ // of being awaited inline — `runHelperSession` itself still
219
+ // resolves at push speed, so `exit`/`socket.end()` below reach
220
+ // the client immediately; `teardown()` is what actually waits on
221
+ // it (see its own doc comment) before this daemon looks idle or
222
+ // accepts a new session.
223
+ runHelperSession(Array.isArray(msg.argv) ? msg.argv : [], {
224
+ stdin,
225
+ onBackgroundWork: (promise) => {
226
+ if (session) session.backgroundWork = promise;
227
+ },
228
+ })
192
229
  .then((code) => {
193
- teardown();
194
230
  send({ t: "exit", code });
195
231
  socket.end();
232
+ void teardown();
196
233
  })
197
234
  .catch(() => {
198
- teardown();
199
235
  send({ t: "exit", code: 1 });
200
236
  socket.end();
237
+ void teardown();
201
238
  });
202
239
  } else if (msg.t === "in") {
203
240
  session?.stdin.write(Buffer.from(msg.d, "base64"));
@@ -43,6 +43,7 @@ import { flagValue } from "./argparse.mjs";
43
43
  import { findBindingKey } from "./wallet-context.mjs";
44
44
  import { fail } from "./sdk-errors.mjs";
45
45
  import { nextAction } from "./next-actions.mjs";
46
+ import { describeRejectedValue } from "../core-dist/redact.js";
46
47
  import {
47
48
  getActiveOrgId as coreGetActiveOrgId,
48
49
  setActiveOrgId as coreSetActiveOrgId,
@@ -66,14 +67,19 @@ const trimmed = (v) => (typeof v === "string" && v.trim() ? v.trim() : null);
66
67
  /**
67
68
  * Shape-validate an organization id supplied by a local source. Membership is
68
69
  * NEVER checked here — only that the value could be an org id at all.
70
+ *
71
+ * A rejected value is a value we know nothing about — see
72
+ * core-dist/redact.js's doc comment (kychee-com/run402-private#640): the
73
+ * same class of mistake that put a private key into RUN402_WALLET can put
74
+ * one into RUN402_ORG / --org, so this must never echo the raw value.
69
75
  */
70
76
  function assertOrgIdShape(orgId, origin) {
71
77
  if (ORG_ID_RE.test(orgId)) return orgId;
72
78
  fail({
73
79
  code: "BAD_ORG_ID",
74
- message: `Invalid organization id ${JSON.stringify(orgId)} (from ${origin}).`,
80
+ message: `Invalid organization id ${JSON.stringify(describeRejectedValue(orgId))} (from ${origin}).`,
75
81
  hint: "An org_id is a UUID. Run 'run402 org list' to see the organizations you belong to.",
76
- details: { org_id: orgId, origin },
82
+ details: { org_id: describeRejectedValue(orgId), origin },
77
83
  next_actions: [listOrgsAction()],
78
84
  });
79
85
  }
@@ -126,7 +126,7 @@ const nodeModP = import("#sdk/node");
126
126
  const { getSdk } = await sdkModP;
127
127
  const { resolveWalletCore, enforceWalletExistsCore, WalletSelectionError } = await walletModP;
128
128
  const { gitvaultRemoteAddressForm, gitvaultSlugReleasedInfo, parseGitvaultRemoteUrl } = await isoModP;
129
- const { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo } = await nodeModP;
129
+ const { GITVAULT_R402_REF_NAMESPACE, hardenedGit, resolveGitInvocationRepo, readPinnedGitvaultRepo, pinGitvaultRepo, readGitvaultRestoreMarker, readGitvaultAutoGcThreshold } = await nodeModP;
130
130
 
131
131
  /** The session's input stream — injected per session (daemon: the socket's forwarded stdin). */
132
132
  let sessionStdin = process.stdin;
@@ -339,7 +339,20 @@ export function chooseGitvaultHeadTargetForPush({ baseHeadTarget, baseRefs, upda
339
339
  return { head_target: { kind: "symref", ref: chosen }, note };
340
340
  }
341
341
 
342
- async function main(argv) {
342
+ /**
343
+ * gitvault-checkpoint-cadence design D1 — the pure post-push auto-gc
344
+ * threshold decision, exported standalone so the threshold matrix (below /
345
+ * at / disabled) is directly unit-testable without the session's heavier
346
+ * closures (network, git, daemon plumbing). `0` (or anything not a
347
+ * positive finite number — a corrupt local config value) means disabled;
348
+ * `generationsSinceCheckpoint >= threshold` is the trigger (matches
349
+ * `gitvaultCheckpointStaleness`'s own `since >= THRESHOLD` shape).
350
+ */
351
+ export function shouldRunAutoGc(threshold, generationsSinceCheckpoint) {
352
+ return Number.isFinite(threshold) && threshold > 0 && Number.isFinite(generationsSinceCheckpoint) && generationsSinceCheckpoint >= threshold;
353
+ }
354
+
355
+ async function main(argv, { onBackgroundWork } = {}) {
343
356
  // gitvault-connection-amortization (bench P5) note: the prewarm now fires
344
357
  // at the module TOP, before the SDK graph loads (gitvault-startup-
345
358
  // amortization D1) — connection dial and signer warmup both race module
@@ -511,13 +524,29 @@ async function main(argv) {
511
524
  // checkout) — `list` still works, and there is nothing for `push` to
512
525
  // share later in that case.
513
526
  }
527
+ // gitvault-session-state-reuse design D2: read the restore MARKER —
528
+ // NEVER the chain-trust pin (see `tryStateFastPath`'s own doc comment on
529
+ // why the pin is the wrong `since` for a standing clone that is
530
+ // generations behind) — once, here, and thread it to the materialize
531
+ // call below AND to `fetch`'s reuse below. Best-effort: a marker-read
532
+ // failure (no repository, no prior restore) just means no `since` is
533
+ // sent, exactly today's behavior.
534
+ let fetchMarker = null;
535
+ if (repoDir) {
536
+ try {
537
+ fetchMarker = await readGitvaultRestoreMarker(repoDir);
538
+ } catch {
539
+ fetchMarker = null;
540
+ }
541
+ }
542
+ const materializeOpts = fetchMarker ? { deltaSince: fetchMarker.generation } : {};
514
543
  let vault;
515
544
  let state;
516
545
  try {
517
546
  const opened = await openVault(repoDir ?? undefined);
518
547
  vault = opened.vault;
519
548
  try {
520
- state = await vault.materialize();
549
+ state = await vault.materialize(materializeOpts);
521
550
  } catch (err) {
522
551
  // An OFFLINE (id-carrying pin) resolution discovers a stale pin on
523
552
  // its FIRST repo-scoped read (client-surface spec, id-pinning
@@ -532,7 +561,7 @@ async function main(argv) {
532
561
  if (!recovered) throw err;
533
562
  note(`pinned vault ${opened.resolution.repo_id} no longer resolves — re-resolved to ${recovered.resolution.repo_id}, retrying`);
534
563
  vault = recovered.handle.vault;
535
- state = await vault.materialize();
564
+ state = await vault.materialize(materializeOpts);
536
565
  }
537
566
  } catch (err) {
538
567
  // An unallocated vault is not an error here: `list` is the read half of
@@ -549,7 +578,12 @@ async function main(argv) {
549
578
  }
550
579
  throw err;
551
580
  }
552
- if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, base: state };
581
+ // gitvault-session-state-reuse design D1: `fetchState`/`fetchMarker`
582
+ // extend the SAME session-scoped handoff `push` already reuses (`base`)
583
+ // — the `fetch` phase of THIS session reuses this response instead of
584
+ // issuing its own state read. Session-scoped only: dropped the moment a
585
+ // push admits in this same session (see `runPush`'s reset below).
586
+ if (repoDir) sharedListSession = { repoDir, walletName: resolvedWallet?.name ?? null, vault, base: state, fetchMarker, fetchState: state };
553
587
  const refs = state.refs ?? {};
554
588
  for (const ref of Object.keys(refs).sort()) out(`${refs[ref]} ${ref}`);
555
589
  // A snapshot-only vault holds protocol refs but no branch heads, so a
@@ -586,7 +620,17 @@ async function main(argv) {
586
620
  // wherever clone was run FROM, unrelated to the target repository).
587
621
  applyWalletForDir(repoDir);
588
622
  if (verbosity >= 1) note(`restoring the vault object database for ${batch.length} ref(s) into ${repoDir}`);
589
- const restored = await getSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
623
+ // gitvault-session-state-reuse design D1/D4: reuse THIS session's `list`
624
+ // phase state — same resolved repository AND wallet, exactly the same
625
+ // matching rule `push`'s own reuse uses above — instead of a second
626
+ // network state read. Any mismatch (no prior `list`, a failed list, a
627
+ // different repository/wallet, or a push that already admitted in this
628
+ // same session — see `runPush`'s reset) falls back to the vault's own
629
+ // read, unchanged.
630
+ const shared = sharedListSession && sharedListSession.repoDir === repoDir && sharedListSession.walletName === (resolvedWallet?.name ?? null) ? sharedListSession : null;
631
+ const restored = shared
632
+ ? await shared.vault.restoreObjectsInto(repoDir, { marker: shared.fetchMarker, state: shared.fetchState })
633
+ : await getSdk().gitvault.restore({ ...target, repo_dir: repoDir, target_dir: repoDir });
590
634
  if (verbosity >= 1) note(`restored generation ${restored.generation}`);
591
635
  // clone-installs-retained-refs D3: a bookkeeping failure here degrades to
592
636
  // exactly today's (pre-change) behavior — one stderr note, fetch still
@@ -600,8 +644,81 @@ async function main(argv) {
600
644
  return 0;
601
645
  }
602
646
 
647
+ /**
648
+ * gitvault-checkpoint-cadence (design D1/D2) — the post-push auto-gc
649
+ * cadence, `git gc --auto`'s shape: a cheap local threshold check after
650
+ * every successful push, maintenance only past it.
651
+ *
652
+ * MUST be called strictly AFTER the push's own `ok`/`error` lines and
653
+ * `endBlock()` have already been written — auto-gc can never fail, slow,
654
+ * or reorder the push it follows (this function itself never throws).
655
+ * `generationsSinceCheckpoint` comes from `published.checkpoint_staleness`
656
+ * (the push's own already-materialized chain) — zero extra reads to learn
657
+ * it.
658
+ *
659
+ * Threshold: `repoDir`'s local `auto_gc_generations` (default 32, `0`
660
+ * disables — `readGitvaultAutoGcThreshold`, design D1's "rides the
661
+ * existing vault policy surface" as a per-checkout `repos policy` knob).
662
+ *
663
+ * Dispatch: with a daemon-supplied `onBackgroundWork`, the compaction is
664
+ * STARTED immediately and handed off as a promise — the daemon keeps
665
+ * itself alive until it settles, but THIS call returns immediately so the
666
+ * client-visible session (and the user's prompt) is not held up. Without
667
+ * one (the in-process fallback), the SAME cycle is awaited right here,
668
+ * with one stderr advisory line naming the wait.
669
+ *
670
+ * `compact()` (namespace, `sdk/src/namespaces/gitvault.ts`) already owns
671
+ * the compaction headroom grant's open/close and single-flight refusal
672
+ * (`GITVAULT_COMPACTION_IN_PROGRESS`) — this function only decides WHETHER
673
+ * and HOW to run the cycle, never re-implements those. `--force-headroom`
674
+ * is NEVER passed on this path (design D5) — an insufficient-headroom
675
+ * refusal, a conflicting in-flight compaction, or any other failure all
676
+ * degrade identically: one advisory line naming `run402 repos gc`, never
677
+ * a thrown error, never a retry loop.
678
+ */
679
+ async function maybeRunAutoGc({ repoDir, generationsSinceCheckpoint }) {
680
+ let threshold;
681
+ try {
682
+ threshold = await readGitvaultAutoGcThreshold(repoDir);
683
+ } catch {
684
+ return; // never let a local-config read failure touch the push it follows
685
+ }
686
+ if (!shouldRunAutoGc(threshold, generationsSinceCheckpoint)) return;
687
+
688
+ const runCycle = async () => {
689
+ const sdk = getSdk();
690
+ const checkpoint = await sdk.gitvault.compact({ ...target });
691
+ const prune = await sdk.gitvault.prune(target); // PLAN only — never `submit`; see compact()'s own grant-close doc comment for why prune needs no headroom of its own
692
+ return { checkpoint, prune };
693
+ };
694
+
695
+ if (typeof onBackgroundWork === "function") {
696
+ // Daemon host: start it now, hand the PROMISE off, return immediately.
697
+ // Failures are swallowed here (best-effort, matching the fallback
698
+ // branch's degrade contract) — there is no live client to advise by
699
+ // the time this settles, and the daemon's own teardown does not
700
+ // depend on the outcome, only on the promise SETTLING.
701
+ const cyclePromise = runCycle().catch(() => undefined);
702
+ onBackgroundWork(cyclePromise);
703
+ return;
704
+ }
705
+
706
+ // In-process fallback: the extra wall time is real, so name it.
707
+ note(`gitvault: compacting (${generationsSinceCheckpoint} generations since checkpoint)…`);
708
+ try {
709
+ await runCycle();
710
+ } catch (err) {
711
+ note(`gitvault: auto-compaction stopped short — ${describeError(err)} — run \`run402 repos gc\` to finish it by hand.`);
712
+ }
713
+ }
714
+
603
715
  async function runPush(batch) {
604
716
  const specs = batch.map(parsePushSpec);
717
+ // gitvault-checkpoint-cadence: set ONLY after a successful admission
718
+ // (never in the catch block) — `null` means "auto-gc has nothing to
719
+ // do", which is also the correct value for every early-return path
720
+ // above (nothing pushed, nothing refused-only, dry-run).
721
+ let autoGcCandidate = null;
605
722
  // D4: `refs/r402/*` is client-local — refuse it per-ref, BEFORE any
606
723
  // repository/wallet/network work, while unrelated branch updates in the
607
724
  // SAME push proceed normally (client-surface spec's own scenario).
@@ -721,14 +838,26 @@ async function main(argv) {
721
838
  base,
722
839
  ...(headFix.head_target ? { head_target: headFix.head_target } : {}),
723
840
  });
841
+ // gitvault-session-state-reuse design D4: this admission just advanced
842
+ // the vault, so the `list` phase's handoff (if any) is now stale —
843
+ // drop it. A `fetch` later in this SAME session (an unusual ordering
844
+ // git's own protocol does not normally produce) reads fresh rather
845
+ // than reusing pre-admission state.
846
+ sharedListSession = null;
724
847
  if (verbosity >= 1) note(`published generation ${published.generation} (${published.form})`);
725
- // gitvault-clone-scaling (P3): advisory only — never blocks, never
726
- // auto-runs compaction, and never fires on a failed push (this line is
727
- // unreachable from the catch). Unknown coverage reads as not-advised.
848
+ // gitvault-clone-scaling (P3): advisory only — informational, always
849
+ // fires at 25 generations regardless of the SEPARATE auto-gc
850
+ // threshold below (design D1's `auto_gc_generations`, default 32)
851
+ // the two are deliberately different numbers for different purposes.
728
852
  if (published.checkpoint_staleness?.advised) {
729
853
  note(`${published.checkpoint_staleness.generations_since_checkpoint} generations since the last checkpoint — cold clones re-verify each one; run402 repos gc compacts them`);
730
854
  }
731
855
  for (const spec of allowed) out(`ok ${spec.dst}`);
856
+ // gitvault-checkpoint-cadence: captured here (never in the catch
857
+ // block below — auto-gc must never fire on a failed push) and acted
858
+ // on AFTER this function's own `endBlock()`, so the auto-gc check
859
+ // itself can never delay, alter, or reorder the push's own report.
860
+ autoGcCandidate = { repoDir, generationsSinceCheckpoint: published.checkpoint_staleness?.generations_since_checkpoint ?? 0 };
732
861
  } catch (err) {
733
862
  // The transaction is atomic, so a failure failed every ref in it. Report
734
863
  // it against each one rather than letting some look like they landed.
@@ -742,6 +871,10 @@ async function main(argv) {
742
871
  for (const spec of allowed) out(`error ${spec.dst} ${reason}`);
743
872
  }
744
873
  endBlock();
874
+ // gitvault-checkpoint-cadence design D1/D2: strictly AFTER the push's
875
+ // own report — `null` (a failed push, a refused-only batch, dry-run)
876
+ // is a silent no-op. `maybeRunAutoGc` itself never throws.
877
+ if (autoGcCandidate) await maybeRunAutoGc(autoGcCandidate);
745
878
  return 0;
746
879
  }
747
880
 
@@ -852,12 +985,21 @@ async function main(argv) {
852
985
  * serving sequential sessions never leaks one invocation's resolution into
853
986
  * the next (D2). Never throws — the error path is the same
854
987
  * note-and-exit-1 the standalone binary always had.
988
+ *
989
+ * `onBackgroundWork` (gitvault-checkpoint-cadence design D2): when supplied
990
+ * (the DAEMON host only — see `gitvault-daemon.mjs`), a successful push's
991
+ * auto-gc cycle is handed to it as an ALREADY-STARTED promise instead of
992
+ * being awaited inline, so this call resolves (and the client-visible
993
+ * session ends) at push speed. The caller is responsible for keeping
994
+ * whatever this promise needs alive (its own connections, its own process)
995
+ * until the promise settles. Absent (the in-process fallback host), the
996
+ * SAME cycle is awaited inline instead — see `maybeRunAutoGc` below.
855
997
  */
856
- export async function runHelperSession(argv, { stdin } = {}) {
998
+ export async function runHelperSession(argv, { stdin, onBackgroundWork } = {}) {
857
999
  sessionStdin = stdin ?? process.stdin;
858
1000
  resolvedWallet = null;
859
1001
  try {
860
- return await main(argv);
1002
+ return await main(argv, { onBackgroundWork });
861
1003
  } catch (err) {
862
1004
  note(describeError(err));
863
1005
  return 1;