salidium 0.4.1 → 0.5.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.
@@ -13,6 +13,7 @@ import {
13
13
  existsSync as existsSync21,
14
14
  mkdirSync as mkdirSync12,
15
15
  openSync as openSync7,
16
+ readFileSync as readFileSync13,
16
17
  statfsSync as statfsSync3,
17
18
  statSync as statSync15
18
19
  } from "node:fs";
@@ -21,6 +22,7 @@ import { arch, homedir as homedir5, platform as platform2, release } from "node:
21
22
  import { join as join23, resolve as resolve6, sep as sep3 } from "node:path";
22
23
  import { createInterface } from "node:readline/promises";
23
24
  import { setTimeout as sleep } from "node:timers/promises";
25
+ import { parentPort } from "node:worker_threads";
24
26
 
25
27
  // ../core/dist/claims/markdown.js
26
28
  var FENCE = /(^|\n)\s*(```|~~~)/;
@@ -18989,6 +18991,30 @@ var OperationsStoreMeasurementSchema = external_exports.object({
18989
18991
  retention: RetentionDaysSchema.nullable(),
18990
18992
  lastIngestAt: CanonicalTimestampSchema.nullable()
18991
18993
  }).strict();
18994
+ var StorageCompositionPartSchema = external_exports.object({
18995
+ key: external_exports.enum(["sessions", "checkpoints", "provenance", "structure", "reusable"]),
18996
+ bytes: external_exports.number().int().nonnegative()
18997
+ }).strict();
18998
+ var StorageCompositionProjectSchema = external_exports.object({
18999
+ /** Repository root, else working directory, else empty when the session recorded neither. */
19000
+ path: external_exports.string(),
19001
+ sessions: external_exports.number().int().nonnegative(),
19002
+ bytes: external_exports.number().int().nonnegative()
19003
+ }).strict();
19004
+ var StorageCompositionSchema = external_exports.object({
19005
+ contractVersion: external_exports.literal(OPERATIONS_CONTRACT_VERSION),
19006
+ state: external_exports.enum(["absent", "running", "ready", "failed"]),
19007
+ computedAt: CanonicalTimestampSchema.nullable(),
19008
+ elapsedMs: external_exports.number().int().nonnegative().nullable(),
19009
+ /** The database file at the moment of measurement, from page count and page size. */
19010
+ fileBytes: external_exports.number().int().nonnegative().nullable(),
19011
+ sessions: external_exports.number().int().nonnegative().nullable(),
19012
+ parts: external_exports.array(StorageCompositionPartSchema),
19013
+ projects: external_exports.array(StorageCompositionProjectSchema),
19014
+ /** Projects measured but not listed, so a truncated list can say so rather than imply totality. */
19015
+ projectsOmitted: external_exports.number().int().nonnegative(),
19016
+ failure: external_exports.string().max(500).nullable()
19017
+ }).strict();
18992
19018
  var MaintenancePhaseSchema = external_exports.enum([
18993
19019
  "idle",
18994
19020
  "pause",
@@ -26912,6 +26938,87 @@ var SqliteStore = class {
26912
26938
  });
26913
26939
  return true;
26914
26940
  }
26941
+ /*
26942
+ * What the store is made of, measured rather than estimated.
26943
+ *
26944
+ * Every part here is one pass over a table. The events pass is the expensive one: `length(json)`
26945
+ * does not read a blob's payload, but it does read the header of every row, so on a store with
26946
+ * 1.3 million events that is a ten second scan and the reason this never runs on the health path
26947
+ * or on a timer. The daemon runs it on a worker with its own read-only connection for the same
26948
+ * reason it runs usage backfill there: a ten second synchronous scan on the main loop is ten
26949
+ * seconds of unanswered hooks.
26950
+ *
26951
+ * `structure` is deliberately a subtraction. Naming what each index costs needs `dbstat`, which
26952
+ * is a further nine to fifteen seconds because it walks every page in the file, and the answer
26953
+ * it adds is "your indexes are large" rather than anything a reader can act on. The difference
26954
+ * is honest, arrives free, and is labelled as a remainder wherever it is shown.
26955
+ */
26956
+ storageComposition(projectLimit = 12) {
26957
+ const started = Date.now();
26958
+ const scalar = (sql) => {
26959
+ const row = this.db.prepare(sql).get();
26960
+ const value2 = row ? Object.values(row)[0] : 0;
26961
+ return typeof value2 === "number" ? value2 : Number(value2 ?? 0);
26962
+ };
26963
+ const pageSize = scalar("PRAGMA page_size");
26964
+ const fileBytes = scalar("PRAGMA page_count") * pageSize;
26965
+ const reusable = scalar("PRAGMA freelist_count") * pageSize;
26966
+ const sessions = this.db.prepare(`SELECT id, COALESCE(NULLIF(repo_root, ''), NULLIF(cwd, ''), '') AS project FROM sessions`).all();
26967
+ const projectOf = new Map(sessions.map((row) => [row.id, row.project]));
26968
+ const perProject = /* @__PURE__ */ new Map();
26969
+ for (const row of sessions) {
26970
+ const entry2 = perProject.get(row.project) ?? { sessions: 0, bytes: 0 };
26971
+ entry2.sessions += 1;
26972
+ perProject.set(row.project, entry2);
26973
+ }
26974
+ let sessionBytes = 0;
26975
+ let checkpointBytes = 0;
26976
+ const accumulate = (sql, into) => {
26977
+ for (const row of this.db.prepare(sql).all()) {
26978
+ const bytes = Number(row.b ?? 0);
26979
+ into(bytes);
26980
+ const project = projectOf.get(row.id);
26981
+ if (project === void 0)
26982
+ continue;
26983
+ const entry2 = perProject.get(project);
26984
+ if (entry2)
26985
+ entry2.bytes += bytes;
26986
+ }
26987
+ };
26988
+ accumulate("SELECT session_id AS id, SUM(length(json)) AS b FROM events GROUP BY session_id", (b) => {
26989
+ sessionBytes += b;
26990
+ });
26991
+ accumulate("SELECT session_id AS id, SUM(length(json)) AS b FROM changes GROUP BY session_id", (b) => {
26992
+ sessionBytes += b;
26993
+ });
26994
+ accumulate("SELECT session_id AS id, SUM(length(state_json)) AS b FROM checkpoints GROUP BY session_id", (b) => {
26995
+ checkpointBytes += b;
26996
+ });
26997
+ const provenanceBytes = scalar(`SELECT COALESCE(SUM(length(session_id) + length(event_id) + length(path)
26998
+ + length(record_hash) + length(captured_at) + length(COALESCE(origin, ''))), 0) AS b
26999
+ FROM raw_record_fingerprints`);
27000
+ const named = sessionBytes + checkpointBytes + provenanceBytes + reusable;
27001
+ const ranked = [...perProject.entries()].map(([path, value2]) => ({ path, sessions: value2.sessions, bytes: value2.bytes })).sort((a, b) => b.bytes - a.bytes || b.sessions - a.sessions);
27002
+ return {
27003
+ contractVersion: OPERATIONS_CONTRACT_VERSION,
27004
+ state: "ready",
27005
+ computedAt: new Date(started).toISOString(),
27006
+ elapsedMs: Date.now() - started,
27007
+ fileBytes,
27008
+ sessions: sessions.length,
27009
+ parts: [
27010
+ { key: "sessions", bytes: sessionBytes },
27011
+ { key: "checkpoints", bytes: checkpointBytes },
27012
+ { key: "provenance", bytes: provenanceBytes },
27013
+ // A store measured while it is being written to can name more than the file holds.
27014
+ { key: "structure", bytes: Math.max(fileBytes - named, 0) },
27015
+ { key: "reusable", bytes: reusable }
27016
+ ],
27017
+ projects: ranked.slice(0, projectLimit),
27018
+ projectsOmitted: Math.max(ranked.length - projectLimit, 0),
27019
+ failure: null
27020
+ };
27021
+ }
26915
27022
  /** Offline space reclamation after bounded cleanup batches. */
26916
27023
  compact() {
26917
27024
  if (!this.usageBackfillProgress().complete)
@@ -28756,6 +28863,13 @@ function createHttpServer(deps) {
28756
28863
  }
28757
28864
  if (req.method === "POST" && url2.pathname === "/api/operations/maintenance/drain" && deps.operations)
28758
28865
  return json2(res, 200, deps.operations.drainQueue());
28866
+ if (url2.pathname === "/api/operations/storage" && deps.operations) {
28867
+ if (req.method === "GET")
28868
+ return json2(res, 200, deps.operations.storageComposition());
28869
+ if (req.method === "POST")
28870
+ return json2(res, 200, deps.operations.analyzeStorage());
28871
+ return json2(res, 405, { error: "method not allowed" });
28872
+ }
28759
28873
  const acknowledge = /^\/api\/operations\/alerts\/([^/]+)\/acknowledge$/.exec(url2.pathname);
28760
28874
  if (req.method === "POST" && acknowledge?.[1] && deps.operations) {
28761
28875
  try {
@@ -29495,6 +29609,67 @@ async function startDaemon(overrides = {}) {
29495
29609
  refreshHealthSampling?.();
29496
29610
  return effective2;
29497
29611
  };
29612
+ let composition = {
29613
+ contractVersion: OPERATIONS_CONTRACT_VERSION,
29614
+ state: "absent",
29615
+ computedAt: null,
29616
+ elapsedMs: null,
29617
+ fileBytes: null,
29618
+ sessions: null,
29619
+ parts: [],
29620
+ projects: [],
29621
+ projectsOmitted: 0,
29622
+ failure: null
29623
+ };
29624
+ let compositionWorker;
29625
+ const analyzeStorage = () => {
29626
+ if (compositionWorker)
29627
+ return composition;
29628
+ const sibling = new URL("./storage/compositionWorker.js", import.meta.url);
29629
+ const worker = existsSync16(fileURLToPath(sibling)) ? new Worker(sibling, { workerData: paths.db }) : (() => {
29630
+ const runtime = process.argv[1];
29631
+ if (!runtime)
29632
+ return void 0;
29633
+ return new Worker(resolve4(runtime), {
29634
+ argv: ["__storage-composition", paths.db]
29635
+ });
29636
+ })();
29637
+ if (!worker) {
29638
+ composition = {
29639
+ ...composition,
29640
+ state: "failed",
29641
+ failure: "Salidium could not locate a runtime to measure the store with."
29642
+ };
29643
+ return composition;
29644
+ }
29645
+ compositionWorker = worker;
29646
+ worker.unref();
29647
+ composition = { ...composition, state: "running", failure: null };
29648
+ worker.once("message", (message) => {
29649
+ composition = message;
29650
+ });
29651
+ worker.once("error", (error51) => {
29652
+ log.warn("storage composition worker failed", { err: String(error51) });
29653
+ composition = { ...composition, state: "failed", failure: String(error51).slice(0, 500) };
29654
+ });
29655
+ worker.once("exit", (code) => {
29656
+ if (compositionWorker === worker)
29657
+ compositionWorker = void 0;
29658
+ if (code !== 0 && composition.state === "running")
29659
+ composition = {
29660
+ ...composition,
29661
+ state: "failed",
29662
+ failure: `The measurement stopped with status ${code}.`
29663
+ };
29664
+ else if (composition.state === "running")
29665
+ composition = {
29666
+ ...composition,
29667
+ state: "failed",
29668
+ failure: "The measurement produced no result."
29669
+ };
29670
+ });
29671
+ return composition;
29672
+ };
29498
29673
  let port = config2.port;
29499
29674
  const server = createHttpServer({
29500
29675
  registry: registry2,
@@ -29530,7 +29705,9 @@ async function startDaemon(overrides = {}) {
29530
29705
  return inspectQueue(config2.home, { entryLimit: limit });
29531
29706
  },
29532
29707
  drainQueue: () => runQueueDrainMaintenance(config2.home, () => hooks.drainSpool()),
29533
- acknowledgeAlert: (id) => acknowledgeLocalAlert(config2.home, id)
29708
+ acknowledgeAlert: (id) => acknowledgeLocalAlert(config2.home, id),
29709
+ storageComposition: () => composition,
29710
+ analyzeStorage
29534
29711
  },
29535
29712
  settings: {
29536
29713
  explainer: explainerSettings,
@@ -29621,6 +29798,8 @@ async function startDaemon(overrides = {}) {
29621
29798
  clearTimeout(usageBackfillRetryTimer);
29622
29799
  if (usageBackfillWorker)
29623
29800
  void usageBackfillWorker.terminate();
29801
+ if (compositionWorker)
29802
+ void compositionWorker.terminate();
29624
29803
  if (healthTimer)
29625
29804
  clearTimeout(healthTimer);
29626
29805
  if (hookTrustTimer)
@@ -31567,6 +31746,15 @@ async function runFirstRunOnboarding(context, io, options = {}) {
31567
31746
  };
31568
31747
  }
31569
31748
 
31749
+ // src/pauseOnRun.ts
31750
+ function clearsPauseOnRun(command, argument, argv) {
31751
+ if (argv.includes("--no-resume")) return false;
31752
+ if (["pause", "resume", "stop", "service"].includes(command)) return false;
31753
+ if (command.startsWith("__")) return false;
31754
+ if (command === "storage" && argument === "optimize") return false;
31755
+ return true;
31756
+ }
31757
+
31570
31758
  // src/render.ts
31571
31759
  var H = "\u2500";
31572
31760
  var CONTROL = /[\u0000-\u001F\u007F-\u009F]/g;
@@ -31797,6 +31985,7 @@ Usage:
31797
31985
  salidium retention apply Apply one cleanup batch now (daemon must be stopped)
31798
31986
  salidium retention compact Return reusable SQLite pages to the OS (daemon must be stopped)
31799
31987
  salidium storage Show the lossless storage layout and page size
31988
+ salidium storage composition Measure what is using the space, by part and by project
31800
31989
  salidium storage optimize Coordinate, copy, verify, and install the compact layout
31801
31990
  salidium pin [session] Exempt a session from automatic retention
31802
31991
  salidium unpin [session] Remove the retention exemption
@@ -31818,7 +32007,9 @@ Environment:
31818
32007
 
31819
32008
  Native Windows imports transcript history but does not install the POSIX live-hook relay.
31820
32009
  Ordinary commands resume an expired or manual pause. pause, stop, service commands, and coordinated
31821
- storage optimize do not; resume changes collection state explicitly.
32010
+ storage optimize do not; resume changes collection state explicitly. Add --no-resume to any command
32011
+ to leave a pause in place, for a caller that has already seen the daemon answer and so cannot be
32012
+ recovering a marker its dead owner left behind.
31822
32013
  `;
31823
32014
  var require3 = createRequire2(import.meta.url);
31824
32015
  var VERSION2 = (() => {
@@ -31838,11 +32029,10 @@ async function main(argv) {
31838
32029
  const jsonOutput = argv.includes("--json");
31839
32030
  const quiet = argv.includes("--quiet");
31840
32031
  const positional = argv.filter(
31841
- (value2) => !["--yes", "-y", "--no-open", "--json", "--quiet"].includes(value2)
32032
+ (value2) => !["--yes", "-y", "--no-open", "--json", "--quiet", "--no-resume"].includes(value2)
31842
32033
  );
31843
32034
  const [cmd = "up", arg, ...args] = positional;
31844
- if (!["pause", "resume", "stop", "service", "__usage-backfill"].includes(cmd) && !(cmd === "storage" && arg === "optimize"))
31845
- await implicitlyResumeCollection();
32035
+ if (clearsPauseOnRun(cmd, arg, argv)) await implicitlyResumeCollection();
31846
32036
  switch (cmd) {
31847
32037
  case "help":
31848
32038
  case "--help":
@@ -31885,6 +32075,24 @@ async function main(argv) {
31885
32075
  store.close();
31886
32076
  }
31887
32077
  }
32078
+ /*
32079
+ * The other private worker entrypoint. Measuring what the store is made of reads the header of
32080
+ * every stored event, which is ten seconds on a three gigabyte store, and `node:sqlite` is
32081
+ * synchronous: run on the daemon's loop that is ten seconds of hooks going unanswered and
32082
+ * spooling to disk. It runs here instead, on its own event loop and its own connection, and
32083
+ * posts one message back.
32084
+ */
32085
+ case "__storage-composition": {
32086
+ if (!arg || !resolve6(arg).startsWith(`${resolve6(salidiumHome)}${sep3}`))
32087
+ throw new Error("storage composition store must be inside the Salidium state directory");
32088
+ const store = new SqliteStore(resolve6(arg), { concurrentWriter: true });
32089
+ try {
32090
+ parentPort?.postMessage(store.storageComposition());
32091
+ return 0;
32092
+ } finally {
32093
+ store.close();
32094
+ }
32095
+ }
31888
32096
  case "start": {
31889
32097
  const running = await ensureDaemon();
31890
32098
  const explanations = await currentExplanationState(running, "reachable");
@@ -32521,10 +32729,54 @@ Change with: salidium explanations off|when-done|each-reply
32521
32729
  `);
32522
32730
  return 1;
32523
32731
  }
32524
- if (arg !== void 0 && arg !== "optimize") {
32525
- process.stderr.write("storage accepts only `optimize`\n");
32732
+ if (arg !== void 0 && arg !== "optimize" && arg !== "composition") {
32733
+ process.stderr.write("storage accepts only `composition` or `optimize`\n");
32526
32734
  return 2;
32527
32735
  }
32736
+ if (arg === "composition") {
32737
+ const store = new SqliteStore(db, { concurrentWriter: true });
32738
+ try {
32739
+ const measured = store.storageComposition();
32740
+ if (jsonOutput) {
32741
+ process.stdout.write(`${JSON.stringify(measured)}
32742
+ `);
32743
+ return 0;
32744
+ }
32745
+ const label = {
32746
+ sessions: "Recorded sessions",
32747
+ checkpoints: "Replay checkpoints",
32748
+ provenance: "Provenance records",
32749
+ structure: "Indexes and internal structure",
32750
+ reusable: "Reusable space"
32751
+ };
32752
+ process.stdout.write(`On this Mac: ${formatBytes(measured.fileBytes ?? 0)}
32753
+ `);
32754
+ for (const part of measured.parts)
32755
+ process.stdout.write(
32756
+ ` ${(label[part.key] ?? part.key).padEnd(30)} ${formatBytes(part.bytes)}
32757
+ `
32758
+ );
32759
+ process.stdout.write(
32760
+ `
32761
+ ${measured.sessions ?? 0} sessions across ${measured.projects.length + measured.projectsOmitted} projects.
32762
+ `
32763
+ );
32764
+ for (const project of measured.projects)
32765
+ process.stdout.write(
32766
+ ` ${formatBytes(project.bytes).padStart(9)} ${String(project.sessions).padStart(4)} ${project.sessions === 1 ? "session " : "sessions"} ${project.path || "No project recorded"}
32767
+ `
32768
+ );
32769
+ if (measured.projectsOmitted > 0)
32770
+ process.stdout.write(` ${measured.projectsOmitted} more not listed.
32771
+ `);
32772
+ process.stdout.write(
32773
+ "\nIndexes and internal structure is the remainder after the named parts, not a separate measurement.\n"
32774
+ );
32775
+ return 0;
32776
+ } finally {
32777
+ store.close();
32778
+ }
32779
+ }
32528
32780
  if (arg === void 0) {
32529
32781
  const layout = inspectStoreLayout(db);
32530
32782
  if (quiet) return layout.optimized ? 0 : 3;
@@ -33579,11 +33831,22 @@ async function ensureDaemon() {
33579
33831
  for (let i = 0; i < 100; i++) {
33580
33832
  await sleep(100);
33581
33833
  if (childFailure)
33582
- throw new Error(`daemon ${childFailure} before it became ready; see ${paths.startupLogFile}`);
33834
+ throw new Error(
33835
+ `daemon ${childFailure} before it became ready: ${startupFailureReason(paths.startupLogFile)}`
33836
+ );
33583
33837
  const d = readDaemonJson(salidiumHome);
33584
33838
  if (d && d.pid === child.pid && await alive(d)) return d;
33585
33839
  }
33586
- throw new Error(`daemon did not start; see ${paths.startupLogFile}`);
33840
+ throw new Error(`daemon did not start: ${startupFailureReason(paths.startupLogFile)}`);
33841
+ }
33842
+ function startupFailureReason(logFile) {
33843
+ try {
33844
+ const lines = readFileSync13(logFile, "utf8").split("\n").map((line) => line.trim()).filter(Boolean);
33845
+ const last = lines.at(-1);
33846
+ if (last) return `${last.slice(0, 200)} (see ${logFile})`;
33847
+ } catch {
33848
+ }
33849
+ return `see ${logFile}`;
33587
33850
  }
33588
33851
  function compareSemver(left, right) {
33589
33852
  const parse3 = (value2) => {