@evident-ai/cli 3.2.1-dev.7244588 → 3.2.1-dev.73d539a

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -113,8 +113,8 @@ Options:
113
113
  and ignored, which can leave cleanup off if it was the only rule set. Env:
114
114
  `EVIDENT_SESSION_CLEANUP_MAX_AGE`. With cleanup off, a runner whose local
115
115
  session store has grown large warns once at startup, on the runner's page,
116
- naming this flag — pruning stops the store growing, but it does not shrink
117
- what has already grown.
116
+ naming this flag — turning cleanup on both stops the store growing further
117
+ and reclaims disk space already used by deleted sessions.
118
118
  - `--session-cleanup-max-count <n>` — Keep only the newest N OpenCode sessions
119
119
  by last activity, deleting the rest. Also enables cleanup; combines with
120
120
  `--session-cleanup-max-age` as OR. A session with a turn actively in progress
@@ -135,7 +135,9 @@ Options:
135
135
  `EVIDENT_MAX_ACTIVE_SESSIONS`.
136
136
  - `--session-cleanup-interval <duration>` — How often the cleanup sweep runs
137
137
  (default: `1h`). An invalid value falls back to the default rather than
138
- disabling cleanup. Env: `EVIDENT_SESSION_CLEANUP_INTERVAL`.
138
+ disabling cleanup. Each sweep also reclaims disk space freed by the sessions
139
+ it deleted, so the store shrinks rather than merely stopping its growth. Env:
140
+ `EVIDENT_SESSION_CLEANUP_INTERVAL`.
139
141
  - `--enable-file-sync-to <dir>` — Let the runner write files Evident has queued
140
142
  for it into this directory — it collects them as part of the polling it
141
143
  already does, so they land a couple of seconds after you hand them over.
package/dist/index.js CHANGED
@@ -1052,6 +1052,7 @@ var MAX_FILE_SYNC_DIRECTORIES = 16;
1052
1052
 
1053
1053
  // ../../packages/types/src/logging/index.ts
1054
1054
  var CORRELATION_ID_HEADER = "x-evident-correlation-id";
1055
+ var FORWARD_FAILURE_REASON_HEADER = "X-Evident-Failure-Reason";
1055
1056
  function log(level, event, fields) {
1056
1057
  const method = level === "debug" ? "log" : level;
1057
1058
  try {
@@ -2356,10 +2357,128 @@ function statSessionDbBytes(homeDir) {
2356
2357
  }
2357
2358
  }
2358
2359
  function buildSessionStoreSizeWarning(input) {
2359
- const { dbBytes, cleanupEnabled } = input;
2360
- if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES || cleanupEnabled) return null;
2360
+ const { dbBytes, cleanupEnabled, reclaimSkipReason } = input;
2361
+ if (dbBytes === null || dbBytes <= LARGE_DB_THRESHOLD_BYTES) return null;
2361
2362
  const mib = Math.round(dbBytes / 1024 / 1024);
2362
- return `Session store is large: opencode.db is ${mib} MiB and automatic session cleanup is off. Enable it with --session-cleanup-max-age 24h (env EVIDENT_SESSION_CLEANUP_MAX_AGE) to stop it growing \u2014 a store much larger than this can exceed a hosted runner's session-history restore budget on the next start, losing this runner's session history.`;
2363
+ if (!cleanupEnabled) {
2364
+ return `Session store is large: opencode.db is ${mib} MiB and automatic session cleanup is off. Enable it with --session-cleanup-max-age 24h (env EVIDENT_SESSION_CLEANUP_MAX_AGE) to stop it growing \u2014 a store much larger than this can exceed a hosted runner's session-history restore budget on the next start, losing this runner's session history.`;
2365
+ }
2366
+ if (reclaimSkipReason === "sqlite-unavailable" || reclaimSkipReason === "insufficient-disk-space") {
2367
+ const reasonText = reclaimSkipReason === "sqlite-unavailable" ? "this Node runtime lacks node:sqlite (needs Node >=22.5)" : "there is not enough free disk space to compact it";
2368
+ return `Session store is large: opencode.db is ${mib} MiB. Automatic session cleanup is on, but space cannot currently be reclaimed because ${reasonText} \u2014 a store this large can exceed a hosted runner's session-history restore budget on the next start, losing this runner's session history.`;
2369
+ }
2370
+ return null;
2371
+ }
2372
+
2373
+ // src/lib/opencode/session-db-reclaim.ts
2374
+ import { statSync as statSync3, statfsSync } from "fs";
2375
+ import { dirname as dirname2 } from "path";
2376
+ function insufficientSpaceReason(dbPath, requiredBytes) {
2377
+ try {
2378
+ const fsStats = statfsSync(dirname2(dbPath));
2379
+ const availableBytes = fsStats.bavail * fsStats.bsize;
2380
+ if (availableBytes < requiredBytes) {
2381
+ return `only ${availableBytes} bytes free, need ${requiredBytes} for a second copy`;
2382
+ }
2383
+ return null;
2384
+ } catch (err) {
2385
+ return `could not check free space (${err instanceof Error ? err.message : String(err)}); refusing to guess`;
2386
+ }
2387
+ }
2388
+ function readLogicalBytes(db) {
2389
+ const pageCount = db.prepare("PRAGMA page_count").get().page_count;
2390
+ const pageSize = db.prepare("PRAGMA page_size").get().page_size;
2391
+ return pageCount * pageSize;
2392
+ }
2393
+ function readCheckpointResult(db) {
2394
+ const row = db.prepare("PRAGMA wal_checkpoint(TRUNCATE)").get();
2395
+ return { busy: row.busy !== 0, log: row.log, checkpointed: row.checkpointed };
2396
+ }
2397
+ async function probeReclaimAvailability(input) {
2398
+ const { dbPath, requiredBytes } = input;
2399
+ let sqlite;
2400
+ try {
2401
+ sqlite = await import("sqlite");
2402
+ } catch (err) {
2403
+ console.warn(
2404
+ `[probeReclaimAvailability] node:sqlite unavailable for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2405
+ );
2406
+ return "sqlite-unavailable";
2407
+ }
2408
+ let autoVacuum = null;
2409
+ try {
2410
+ const db = new sqlite.DatabaseSync(dbPath, { readOnly: true });
2411
+ try {
2412
+ autoVacuum = db.prepare("PRAGMA auto_vacuum").get().auto_vacuum;
2413
+ } finally {
2414
+ db.close();
2415
+ }
2416
+ } catch (err) {
2417
+ console.warn(
2418
+ `[probeReclaimAvailability] could not read auto_vacuum mode for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2419
+ );
2420
+ }
2421
+ if (autoVacuum !== 0) return null;
2422
+ return insufficientSpaceReason(dbPath, requiredBytes) !== null ? "insufficient-disk-space" : null;
2423
+ }
2424
+ async function reclaimSessionDbSpace(input) {
2425
+ const { dbPath, maxPages, allowFullVacuum = true } = input;
2426
+ let sqlite;
2427
+ try {
2428
+ sqlite = await import("sqlite");
2429
+ } catch (err) {
2430
+ console.warn(
2431
+ `[reclaimSessionDbSpace] node:sqlite unavailable (need Node >=22.5, >=22.13 unflagged) for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2432
+ );
2433
+ return { ok: false, skipped: "sqlite-unavailable" };
2434
+ }
2435
+ const { DatabaseSync } = sqlite;
2436
+ let db;
2437
+ try {
2438
+ db = new DatabaseSync(dbPath);
2439
+ const autoVacuum = db.prepare("PRAGMA auto_vacuum").get().auto_vacuum;
2440
+ if (autoVacuum === 0) {
2441
+ if (!allowFullVacuum) {
2442
+ console.warn(
2443
+ `[reclaimSessionDbSpace] skipping VACUUM conversion of ${dbPath}: a session turn is live`
2444
+ );
2445
+ return { ok: false, skipped: "full-vacuum-blocked" };
2446
+ }
2447
+ const fileBytesForGuard = statSync3(dbPath).size;
2448
+ const skipReason = insufficientSpaceReason(dbPath, fileBytesForGuard);
2449
+ if (skipReason !== null) {
2450
+ console.warn(
2451
+ `[reclaimSessionDbSpace] skipping VACUUM conversion of ${dbPath}: ${skipReason}`
2452
+ );
2453
+ return { ok: false, skipped: "insufficient-disk-space" };
2454
+ }
2455
+ const beforeBytes = readLogicalBytes(db);
2456
+ db.exec("PRAGMA auto_vacuum=INCREMENTAL");
2457
+ db.exec("VACUUM");
2458
+ const afterBytes = readLogicalBytes(db);
2459
+ const checkpoint = readCheckpointResult(db);
2460
+ return { ok: true, mode: "convert", beforeBytes, afterBytes, checkpoint };
2461
+ }
2462
+ if (autoVacuum === 2) {
2463
+ const beforeBytes = readLogicalBytes(db);
2464
+ const bound = Math.max(0, Math.trunc(maxPages));
2465
+ db.exec(`PRAGMA incremental_vacuum(${bound})`);
2466
+ const afterBytes = readLogicalBytes(db);
2467
+ const checkpoint = readCheckpointResult(db);
2468
+ return { ok: true, mode: "incremental", beforeBytes, afterBytes, checkpoint };
2469
+ }
2470
+ console.warn(
2471
+ `[reclaimSessionDbSpace] ${dbPath} has auto_vacuum=${autoVacuum} (neither NONE nor INCREMENTAL); nothing to reclaim`
2472
+ );
2473
+ return { ok: false, skipped: "auto-vacuum-not-applicable" };
2474
+ } catch (err) {
2475
+ console.error(
2476
+ `[reclaimSessionDbSpace] reclaim failed for ${dbPath}: ${err instanceof Error ? err.message : String(err)}`
2477
+ );
2478
+ return { ok: false, skipped: "reclaim-error" };
2479
+ } finally {
2480
+ db?.close();
2481
+ }
2363
2482
  }
2364
2483
 
2365
2484
  // src/lib/tunnel/connection.ts
@@ -2527,6 +2646,17 @@ var StreamForwarder = class {
2527
2646
  };
2528
2647
 
2529
2648
  // src/lib/tunnel/connection.ts
2649
+ var FAILURE_REASON_HEADER_LC = FORWARD_FAILURE_REASON_HEADER.toLowerCase();
2650
+ var TunnelUpgradeRejectedError = class extends Error {
2651
+ constructor(message, reason) {
2652
+ super(message);
2653
+ this.reason = reason;
2654
+ }
2655
+ };
2656
+ function classifyUpgradeRejection(headers) {
2657
+ const value = headers[FAILURE_REASON_HEADER_LC];
2658
+ return value === "do_code_updated" ? "do_code_updated" : "unknown";
2659
+ }
2530
2660
  var MAX_RECONNECT_DELAY = 3e4;
2531
2661
  var BASE_RECONNECT_DELAY = 500;
2532
2662
  function getReconnectDelay(attempt) {
@@ -2571,6 +2701,7 @@ function connectTunnel(options) {
2571
2701
  onError,
2572
2702
  onResponse,
2573
2703
  onInfo,
2704
+ onWarning,
2574
2705
  onDrainPing
2575
2706
  } = options;
2576
2707
  const tunnelUrl = getTunnelUrlConfig();
@@ -2590,8 +2721,11 @@ function connectTunnel(options) {
2590
2721
  reject(new Error("Connection timeout"));
2591
2722
  }, 3e4);
2592
2723
  let upgradeRejection = null;
2724
+ let upgradeRejectionReason = null;
2593
2725
  ws.on("unexpected-response", (_req, res) => {
2594
2726
  clearTimeout(connectionTimeout);
2727
+ const reason = classifyUpgradeRejection(res.headers);
2728
+ upgradeRejectionReason = reason;
2595
2729
  const chunks = [];
2596
2730
  res.on("data", (chunk) => chunks.push(chunk));
2597
2731
  res.on("end", () => {
@@ -2605,8 +2739,14 @@ function connectTunnel(options) {
2605
2739
  }
2606
2740
  const statusLine = `HTTP ${res.statusCode}${res.statusMessage ? ` ${res.statusMessage}` : ""}`;
2607
2741
  upgradeRejection = detail ? `${statusLine}: ${detail}` : statusLine;
2608
- onError?.(`Tunnel refused by relay (${upgradeRejection})`);
2609
- reject(new Error(`Tunnel handshake rejected: ${upgradeRejection}`));
2742
+ if (reason === "do_code_updated") {
2743
+ onWarning?.("Relay redeployed \u2014 reconnecting");
2744
+ } else {
2745
+ onError?.(`Tunnel refused by relay (${upgradeRejection})`);
2746
+ }
2747
+ reject(
2748
+ new TunnelUpgradeRejectedError(`Tunnel handshake rejected: ${upgradeRejection}`, reason)
2749
+ );
2610
2750
  });
2611
2751
  });
2612
2752
  ws.on("open", () => {
@@ -2652,8 +2792,14 @@ function connectTunnel(options) {
2652
2792
  ws.on("error", (error2) => {
2653
2793
  clearTimeout(connectionTimeout);
2654
2794
  const detail = upgradeRejection ?? describeSocketError(error2, url);
2655
- onError?.(`Connection error: ${detail}`);
2656
- reject(upgradeRejection ? new Error(upgradeRejection) : new Error(detail));
2795
+ if (upgradeRejectionReason === "do_code_updated") {
2796
+ onWarning?.("Relay redeployed \u2014 reconnecting");
2797
+ } else {
2798
+ onError?.(`Connection error: ${detail}`);
2799
+ }
2800
+ reject(
2801
+ upgradeRejectionReason !== null ? new TunnelUpgradeRejectedError(detail, upgradeRejectionReason) : new Error(detail)
2802
+ );
2657
2803
  });
2658
2804
  ws.on("close", (code, reason) => {
2659
2805
  const reasonStr = reason.toString() || upgradeRejection || (code === 1006 ? "abnormal closure" : "No reason provided");
@@ -2729,7 +2875,8 @@ var RunnerConnection = class {
2729
2875
  onError: (error2) => events.onError?.(error2),
2730
2876
  onResponse: () => events.onResponse?.(),
2731
2877
  onDrainPing: () => events.onDrainPing?.(),
2732
- onInfo: (message) => events.onInfo?.(message)
2878
+ onInfo: (message) => events.onInfo?.(message),
2879
+ onWarning: (message) => events.onWarning?.(message)
2733
2880
  });
2734
2881
  return;
2735
2882
  } catch (error2) {
@@ -2740,7 +2887,12 @@ var RunnerConnection = class {
2740
2887
  }
2741
2888
  const delay = getReconnectDelay(this.reconnectAttempt);
2742
2889
  events.onReconnecting?.(this.reconnectAttempt);
2743
- events.onError?.(`Connection failed, retrying in ${Math.round(delay / 1e3)}s...`);
2890
+ const retryMessage = `Connection failed, retrying in ${Math.round(delay / 1e3)}s...`;
2891
+ if (error2 instanceof TunnelUpgradeRejectedError && error2.reason === "do_code_updated") {
2892
+ events.onWarning?.(retryMessage);
2893
+ } else {
2894
+ events.onError?.(retryMessage);
2895
+ }
2744
2896
  await this.sleep(delay);
2745
2897
  }
2746
2898
  }
@@ -2797,7 +2949,7 @@ import { homedir as homedir2 } from "os";
2797
2949
  // src/lib/file-push.ts
2798
2950
  import { randomUUID } from "crypto";
2799
2951
  import { chmod, mkdir, open as open2, realpath, rename, unlink } from "fs/promises";
2800
- import { basename, dirname as dirname2, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
2952
+ import { basename, dirname as dirname3, isAbsolute, join as join3, relative, resolve as resolve2, sep } from "path";
2801
2953
  var FILE_MODE = 384;
2802
2954
  var DIRECTORY_MODE = 448;
2803
2955
  async function writePushedFile(request) {
@@ -2828,7 +2980,7 @@ async function writePushedFile(request) {
2828
2980
  }
2829
2981
  try {
2830
2982
  const { existingAncestor, missingSegments } = await resolveNearestExistingAncestor(
2831
- dirname2(candidate)
2983
+ dirname3(candidate)
2832
2984
  );
2833
2985
  const realTarget = join3(existingAncestor, ...missingSegments, basename(candidate));
2834
2986
  const allowedDirectory = await findContainingAllowedDirectory(allowedDirectories, realTarget);
@@ -2840,8 +2992,8 @@ async function writePushedFile(request) {
2840
2992
  }
2841
2993
  if (missingSegments.length > 0) {
2842
2994
  await createMissingDirectories(existingAncestor, missingSegments);
2843
- const realParent = await realpath(dirname2(realTarget));
2844
- if (realParent !== dirname2(realTarget) || !contains(allowedDirectory, realTarget)) {
2995
+ const realParent = await realpath(dirname3(realTarget));
2996
+ if (realParent !== dirname3(realTarget) || !contains(allowedDirectory, realTarget)) {
2845
2997
  return refuse("path_not_allowed", "The runner does not allow writing to that location.", {
2846
2998
  path: realTarget,
2847
2999
  bytes,
@@ -2884,7 +3036,7 @@ async function resolveNearestExistingAncestor(directory) {
2884
3036
  try {
2885
3037
  return { existingAncestor: await realpath(current), missingSegments };
2886
3038
  } catch (err) {
2887
- const parent = dirname2(current);
3039
+ const parent = dirname3(current);
2888
3040
  if (err.code !== "ENOENT" || parent === current) {
2889
3041
  throw err;
2890
3042
  }
@@ -2945,7 +3097,7 @@ async function createMissingDirectories(existingAncestor, missingSegments) {
2945
3097
  }
2946
3098
  }
2947
3099
  async function writeAtomically(realTarget, content) {
2948
- const temporaryPath = join3(dirname2(realTarget), `.evident-push-${randomUUID()}.tmp`);
3100
+ const temporaryPath = join3(dirname3(realTarget), `.evident-push-${randomUUID()}.tmp`);
2949
3101
  let handle;
2950
3102
  try {
2951
3103
  handle = await open2(temporaryPath, "wx", FILE_MODE);
@@ -7089,6 +7241,10 @@ async function driveChannels(state, driver) {
7089
7241
  }
7090
7242
  }
7091
7243
  var SESSION_CLEANUP_FIRST_SWEEP_MS = 1e4;
7244
+ var SESSION_DB_RECLAIM_MAX_PAGES = 2e3;
7245
+ function sessionDbPath() {
7246
+ return join4(homedir3(), ".local", "share", "opencode", "opencode.db");
7247
+ }
7092
7248
  async function runSweep(state, driver, config) {
7093
7249
  const mode = `age=${config.maxAgeMs ?? "\u2014"} count=${config.maxCount ?? "\u2014"}`;
7094
7250
  try {
@@ -7131,6 +7287,25 @@ async function runSweep(state, driver, config) {
7131
7287
  type: "info",
7132
7288
  message: `Session cleanup: inspected ${sessions.length}, deleted ${deleted}${failedNote}${skippedNote} (${mode})`
7133
7289
  });
7290
+ const reclaimResult = await reclaimSessionDbSpace({
7291
+ dbPath: sessionDbPath(),
7292
+ maxPages: SESSION_DB_RECLAIM_MAX_PAGES,
7293
+ allowFullVacuum: protectedNow.size === 0
7294
+ });
7295
+ if (reclaimResult.ok) {
7296
+ const beforeMib = (reclaimResult.beforeBytes / 1024 / 1024).toFixed(1);
7297
+ const afterMib = (reclaimResult.afterBytes / 1024 / 1024).toFixed(1);
7298
+ const checkpointNote = reclaimResult.checkpoint.busy ? ` (on-disk file truncation deferred: checkpoint busy, ${reclaimResult.checkpoint.log} WAL frames pending)` : "";
7299
+ logActivity(state, {
7300
+ type: "info",
7301
+ message: `Session cleanup: reclaimed session-db space (${reclaimResult.mode}): ${beforeMib} MiB -> ${afterMib} MiB${checkpointNote}`
7302
+ });
7303
+ } else {
7304
+ logActivity(state, {
7305
+ type: "info",
7306
+ message: `Session cleanup: session-db space reclaim skipped (${reclaimResult.skipped})`
7307
+ });
7308
+ }
7134
7309
  } catch (error2) {
7135
7310
  const message = error2 instanceof Error ? error2.message : String(error2);
7136
7311
  logActivity(state, {
@@ -7151,13 +7326,22 @@ function scheduleSessionCleanup(state, driver, options) {
7151
7326
  for (const warning2 of config.warnings) {
7152
7327
  logActivity(state, { type: "info", level: "warn", message: `Session cleanup: ${warning2}` });
7153
7328
  }
7154
- const sizeWarning = buildSessionStoreSizeWarning({
7155
- dbBytes: statSessionDbBytes(homedir3()),
7156
- cleanupEnabled: config.enabled
7329
+ const dbBytes = statSessionDbBytes(homedir3());
7330
+ void (async () => {
7331
+ const reclaimSkipReason = dbBytes !== null && config.enabled ? await probeReclaimAvailability({ dbPath: sessionDbPath(), requiredBytes: dbBytes }) : null;
7332
+ const sizeWarning = buildSessionStoreSizeWarning({
7333
+ dbBytes,
7334
+ cleanupEnabled: config.enabled,
7335
+ reclaimSkipReason
7336
+ });
7337
+ if (sizeWarning !== null) {
7338
+ logActivity(state, { type: "info", level: "warn", message: sizeWarning });
7339
+ }
7340
+ })().catch((err) => {
7341
+ console.error(
7342
+ `[scheduleSessionCleanup] size-warning preflight failed: ${err instanceof Error ? err.message : String(err)}`
7343
+ );
7157
7344
  });
7158
- if (sizeWarning !== null) {
7159
- logActivity(state, { type: "info", level: "warn", message: sizeWarning });
7160
- }
7161
7345
  if (!config.enabled) return;
7162
7346
  logActivity(state, {
7163
7347
  type: "info",
@@ -7739,6 +7923,13 @@ async function run(options) {
7739
7923
  logActivity(state, { type: "error", error: error2 });
7740
7924
  if (state.interactive) displayStatus(state);
7741
7925
  },
7926
+ // `warn`, not `info`: `forwardRunnerActivity`'s FORWARDED_LEVELS floor is
7927
+ // {'warn','error'}, so an `info` entry would never leave the machine and
7928
+ // an operator couldn't correlate a reconnect storm with a relay deploy.
7929
+ onWarning: (message) => {
7930
+ logActivity(state, { type: "info", level: "warn", message });
7931
+ if (state.interactive) displayStatus(state);
7932
+ },
7742
7933
  // Web traffic is proxied transparently; note opencode is live and stamp
7743
7934
  // proxied activity so the idle loop treats interactive proxy use as work.
7744
7935
  // Fires per forwarded response head (incl. every SSE open) and excludes