@wirecat/cli-core 0.597.0 → 0.599.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.
@@ -0,0 +1,153 @@
1
+ import { existsSync } from "node:fs";
2
+ import { CliError } from "@wirecat/cli-base";
3
+ import { annotate } from "@wirecat/cli-base/commands";
4
+ import { Command } from "commander";
5
+ import { explainDamage } from "../store/damage.js";
6
+ import { storeHealth } from "../store/health.js";
7
+ import { storePath } from "../store/path.js";
8
+ import { activeTimers, recoverStore, sqlite3Program, storeHolders, tableRows, } from "../store/recover.js";
9
+ import { openSqlite } from "../store/store.js";
10
+ import { environmentOf, outputFor } from "./context.js";
11
+ import { LOCAL_READ, LOCAL_WRITE } from "./effects.js";
12
+ import { itemOf, renderAnswer, snakeKeys } from "./envelope.js";
13
+ /** The tables whose counts say what the store holds; every other table is in `store info`. */
14
+ const COUNTED = [
15
+ "accounts",
16
+ "chats",
17
+ "messages",
18
+ "attachments",
19
+ "emails",
20
+ "documents",
21
+ "notes",
22
+ "meetings",
23
+ "chunks",
24
+ "embeddings",
25
+ "mentions",
26
+ ];
27
+ const stopTimers = (timers) => `systemctl --user stop ${timers.join(" ")}`;
28
+ /** `store recover`: what is wrong with the store and what to run, reading every page; changes nothing. */
29
+ export const storeRecoveryCheck = async (env, command) => {
30
+ const path = storePath(env);
31
+ if (!existsSync(path))
32
+ return { path, exists: false, ok: true, next: [] };
33
+ const health = await storeHealth(path, { deep: true });
34
+ let rows = {};
35
+ try {
36
+ const { database } = await openSqlite(path, { readOnly: true });
37
+ try {
38
+ const all = tableRows(database);
39
+ rows = Object.fromEntries(COUNTED.filter((table) => table in all).map((table) => [table, all[table] ?? null]));
40
+ }
41
+ finally {
42
+ database.close();
43
+ }
44
+ }
45
+ catch { }
46
+ const explanation = explainDamage(health, command);
47
+ const inUse = storeHolders(path) ?? null;
48
+ const timers = activeTimers() ?? null;
49
+ const sqlite3 = sqlite3Program();
50
+ const next = health.ok
51
+ ? []
52
+ : [
53
+ ...(timers?.length ? [stopTimers(timers)] : []),
54
+ ...(inUse ?? []).map(({ pid, name }) => `stop ${name} (process ${pid}): it has the store open`),
55
+ ...(sqlite3.usable ? [] : ["install sqlite3 3.43 or newer (apt install sqlite3, brew install sqlite)"]),
56
+ `${command} store recover run`,
57
+ ];
58
+ return {
59
+ path,
60
+ exists: true,
61
+ ok: health.ok,
62
+ seconds: Math.round(health.ms / 100) / 10,
63
+ problems: health.problems ?? 0,
64
+ damagedTables: health.damagedTables ?? [],
65
+ damagedSearchIndexes: (health.searchIndexes ?? [])
66
+ .filter((index) => !index.ok)
67
+ .map(({ name, error }) => ({ name, ...(error ? { error } : {}) })),
68
+ rows,
69
+ explanation,
70
+ inUse,
71
+ timers,
72
+ sqlite3: { version: sqlite3.version, usable: sqlite3.usable },
73
+ next,
74
+ };
75
+ };
76
+ /** `store recover run`: refuses while a WireCat timer is scheduled, so none writes to the file being replaced. */
77
+ export const storeRecoveryRun = async (env, { command, version, note, timers: scheduled = activeTimers, copy, }) => {
78
+ const timers = scheduled() ?? [];
79
+ if (timers.length > 0)
80
+ throw new CliError("validation_error", `stop the timers first, they open the store: ${stopTimers(timers)}`, {
81
+ reason: "store.in-use",
82
+ });
83
+ const recovery = await recoverStore({
84
+ path: storePath(env),
85
+ command,
86
+ ...(version ? { version } : {}),
87
+ ...(note ? { note } : {}),
88
+ ...(copy ? { copy } : {}),
89
+ });
90
+ const mail = recovery.mailToFetch.map(({ account, emails }) => `mail <profile of ${account}> sync --rescan # ${emails} emails`);
91
+ const lostMail = recovery.lost.some(({ table }) => table === "emails") && mail.length === 0
92
+ ? ["mail <profile> sync --rescan # each mail profile"]
93
+ : [];
94
+ const { health, copyErrors, ...kept } = recovery;
95
+ return {
96
+ ...kept,
97
+ copyErrors: copyErrors.length,
98
+ checkedOk: health.ok,
99
+ next: [...mail, ...lostMail, "systemctl --user start <the timers stopped before>"],
100
+ };
101
+ };
102
+ /** The lines a person reads under the answer; the JSON carries the same in its fields. */
103
+ export const recoveryNotes = (answer) => {
104
+ if ("keptAt" in answer) {
105
+ const lost = answer.lost.map(({ table, before, read, kept }) => `${table}: ${kept} kept of ${before ?? "an uncountable number"}${read > kept ? `, ${read - kept} read but unusable` : ""}`);
106
+ return [
107
+ `recovered in ${answer.seconds.total} s; the damaged file is kept at ${answer.keptAt}`,
108
+ ...(lost.length ? lost : ["no table lost a row"]),
109
+ ...answer.dangling.map(({ table, parent, rows }) => `${rows} rows of ${table} point at ${parent} rows the store does not hold`),
110
+ ...answer.next.map((line) => `next: ${line}`),
111
+ ];
112
+ }
113
+ if (!answer.exists)
114
+ return ["no store yet"];
115
+ if (answer.ok)
116
+ return [`the store is healthy (checked every page and search index in ${answer.seconds} s)`];
117
+ const why = answer.explanation;
118
+ return [
119
+ ...(why ? [why.what, ...why.impact, why.externalUnaffected] : []),
120
+ ...answer.next.map((line) => `next: ${line}`),
121
+ ];
122
+ };
123
+ /** `store recover` and `store recover run`, for a CLI whose commands run on cli-core's context. */
124
+ export const storeRecoverCommand = ({ command, version }) => {
125
+ const recover = annotate(new Command("recover"), { effects: LOCAL_READ, examples: [`${command} store recover`] })
126
+ .summary("what is damaged in the store and how to recover it")
127
+ .description("check every page and search index of the store, say what is damaged, what it means and the commands that recover it; changes nothing")
128
+ .action(async function () {
129
+ const output = outputFor(this);
130
+ const answer = await storeRecoveryCheck(environmentOf(this).env ?? process.env, command);
131
+ renderAnswer(this, output, itemOf(snakeKeys(answer)));
132
+ if (output.format === "pretty")
133
+ for (const line of recoveryNotes(answer))
134
+ output.renderer.note(line);
135
+ });
136
+ recover.addCommand(annotate(new Command("run"), { effects: LOCAL_WRITE, examples: [`${command} store recover run`] })
137
+ .summary("rebuild a damaged store from every row it can read")
138
+ .description("copy every readable row into a new file with the sqlite3 program, rebuild the search indexes, mentions and vector index, " +
139
+ "check it, and put it in place of the store; the damaged file is kept beside it. Refuses while a WireCat timer or process has the store")
140
+ .action(async function () {
141
+ const output = outputFor(this);
142
+ const answer = await storeRecoveryRun(environmentOf(this).env ?? process.env, {
143
+ command,
144
+ ...(version ? { version } : {}),
145
+ note: (text) => output.renderer.note(text),
146
+ });
147
+ renderAnswer(this, output, itemOf(snakeKeys(answer)));
148
+ if (output.format === "pretty")
149
+ for (const line of recoveryNotes(answer))
150
+ output.renderer.note(line);
151
+ }));
152
+ return recover;
153
+ };
@@ -3,7 +3,7 @@ import { annotate, metaOf, sharedCommand } from "@wirecat/cli-base/commands";
3
3
  import { Argument, InvalidArgumentError } from "commander";
4
4
  import * as v from "valibot";
5
5
  import { answerEntity } from "../../cli/answer-entity.js";
6
- import { environmentOf, outputFor, runBudgetOf } from "../../cli/context.js";
6
+ import { environmentOf, inCommandRun, outputFor, runBudgetOf } from "../../cli/context.js";
7
7
  import { honoursDryRun } from "../../cli/dry-run-guard.js";
8
8
  import { FETCH, LOCAL_READ, NO_EFFECTS, REMOTE_WRITE } from "../../cli/effects.js";
9
9
  import { itemOf, listOf, renderAnswer, snakeKeys, snakeKeysKeeping } from "../../cli/envelope.js";
@@ -142,7 +142,7 @@ const dealKeys = snakeKeysKeeping(["providerMetadata"]);
142
142
  const marked = (items, untrusted) => items.map((item) => ({ ...dealKeys(item), untrusted }));
143
143
  const addCrmCommandsDefinition = (program, options) => {
144
144
  const now = () => options.now?.() ?? Date.now();
145
- const within = async (command, work) => {
145
+ const within = (command, work) => inCommandRun(command, async () => {
146
146
  const run = await options.open(command, { readOnly: metaOf(command).effects?.local !== "write" });
147
147
  let body;
148
148
  try {
@@ -152,7 +152,7 @@ const addCrmCommandsDefinition = (program, options) => {
152
152
  await run.close?.();
153
153
  }
154
154
  renderAnswer(command, outputFor(command), body);
155
- };
155
+ });
156
156
  const example = (command, ...lines) => annotate(command, { examples: lines.map((line) => `${options.app.command} work ${line}`) });
157
157
  example(answerEntity(annotate(program.command("sync"), { effects: FETCH, offline: false, cooperative: true, partial: true }), "crm_sync", crmSync).description("bring the CRM's copy in the local store up to date: pipelines, companies, contacts, deals and leads that " +
158
158
  "changed since the last sync, or everything with --rescan; a run stops before its deadline or --max-pages " +
@@ -444,7 +444,8 @@ const addCrmCommandsDefinition = (program, options) => {
444
444
  .description("the CRM's custom fields of one kind, in its order, with a list field's options")
445
445
  .argument("<kind>", fieldKinds.join(", "))
446
446
  .option("--limit <n>", `fields per page, 1–${MOST}`, integer(MOST), 50)
447
- .option("--page <n>", "which page, starting at 1", integer(1_000_000), 1)
447
+ // No default: the run's settings refuse --all beside a --page, given or defaulted.
448
+ .option("--page <n>", "which page, starting at 1", integer(1_000_000))
448
449
  .option("--all", "every field, no paging"), FIELDS_QUERY), "fields list deals", "fields list people --json").action(async (kind, words, opts, command) => {
449
450
  if (!fieldKinds.includes(kind))
450
451
  throw new CliError("validation_error", `fields are listed for ${fieldKinds.join(", ")}; got ${kind}`, {
@@ -454,12 +455,13 @@ const addCrmCommandsDefinition = (program, options) => {
454
455
  queryTerms(words, FIELDS_QUERY, "fields list");
455
456
  await within(command, async (run) => {
456
457
  const found = await run.store.crm.fields(run.account, FIELD_KINDS[kind]);
457
- const offset = opts.all ? 0 : (opts.page - 1) * opts.limit;
458
+ const page = opts.page ?? 1;
459
+ const offset = opts.all ? 0 : (page - 1) * opts.limit;
458
460
  const end = opts.all ? found.length : offset + opts.limit;
459
461
  return listOf(pathOf(command).join(" "), {
460
462
  items: found.slice(offset, end).map((field) => ({ ...field, untrusted: FIELD_UNTRUSTED })),
461
463
  limit: opts.all ? Math.max(1, found.length) : opts.limit,
462
- page: opts.page,
464
+ page,
463
465
  has_more: found.length > end,
464
466
  ...(words.length ? { args: { query: words } } : {}),
465
467
  });
@@ -156,6 +156,14 @@ const OTHERS = [
156
156
  ],
157
157
  validation("store.refused", "the local store refused the document text"),
158
158
  ["store.not-writable", "permission_error", "the message store cannot be written from this process"],
159
+ validation("store.in-use", "a process or a scheduled timer has the store open; stop it, then recover the store"),
160
+ [
161
+ "store.sqlite3-missing",
162
+ "configuration_error",
163
+ "recovering the store needs the sqlite3 program, 3.43 or newer, with its .recover command",
164
+ ],
165
+ validation("store.recovery-failed", "the recovered store did not pass its check, so the store was left as it was"),
166
+ validation("store.changed-during-recovery", "the store was written to during the recovery, so it was left as it was"),
159
167
  [
160
168
  "store.vector-extension-missing",
161
169
  "configuration_error",
@@ -2,5 +2,6 @@ export { ARGV_LOG, argvLogSetup } from "./argv-log.js";
2
2
  export { type ContractCase, type ContractOptions, contractCases, OPTIONAL_METHODS } from "./contract.js";
3
3
  export { type FakeAdapter, fakeAdapter } from "./fake.js";
4
4
  export { type McpStdio, type McpStdioStreams, type McpToolResult, mcpOverStdio } from "./mcp-stdio.js";
5
+ export { neverAnswers, type RunScopeCase, type RunScopeOptions, runScopeCases } from "./run-scope.js";
5
6
  export { type SearchRecipes, searchRecipes, seedSearchRecipes } from "./search-recipes.js";
6
7
  export { BUSY_PAGE, contractSeed, digitIds, type IdKind, type IdMaker, type Seed, wordIds } from "./seed.js";
package/dist/kit/index.js CHANGED
@@ -2,5 +2,6 @@ export { ARGV_LOG, argvLogSetup } from "./argv-log.js";
2
2
  export { contractCases, OPTIONAL_METHODS } from "./contract.js";
3
3
  export { fakeAdapter } from "./fake.js";
4
4
  export { mcpOverStdio } from "./mcp-stdio.js";
5
+ export { neverAnswers, runScopeCases } from "./run-scope.js";
5
6
  export { searchRecipes, seedSearchRecipes } from "./search-recipes.js";
6
7
  export { BUSY_PAGE, contractSeed, digitIds, wordIds } from "./seed.js";
@@ -0,0 +1,34 @@
1
+ import { type ProgramDefinition, type RunOptions } from "../cli/program.js";
2
+ export interface RunScopeCase {
3
+ name: string;
4
+ /** Throws an `AssertionError` when the command does not run inside cli-core's run. */
5
+ run(): Promise<void>;
6
+ }
7
+ export interface RunScopeOptions {
8
+ /** The CLI's own program definition, as its `run()` hands it to cli-core. */
9
+ definition: ProgramDefinition;
10
+ /**
11
+ * The words that run each remote command to success against the answering fake, by command path:
12
+ * `{ "comments show": ["personal", "comments", "show", "c1"] }`. A write whose level is `ask` needs a profile that
13
+ * allows it, or its refusal fails the case.
14
+ */
15
+ commands: Record<string, readonly string[]>;
16
+ /** Remote commands left out, each with its reason: one that listens until stopped, one that asks npm. */
17
+ skip?: Record<string, string>;
18
+ /** What `run()` gets beside the streams: the keyring, and the fake that answers every request. */
19
+ answering: () => RunOptions;
20
+ /** The same, with a fake that takes every request and never settles it, `neverAnswers` for an HTTP service. */
21
+ silent: () => RunOptions;
22
+ /** The `--timeout` a silent run gets; 100 ms. */
23
+ timeoutMs?: number;
24
+ }
25
+ /** A `fetch` that never settles, not even on abort: only the run's own deadline can end the command. */
26
+ export declare const neverAnswers: typeof fetch;
27
+ /**
28
+ * Cases that each remote command runs inside cli-core's run — `baseContext(command, resolveSettings).run(body)` — and
29
+ * not beside it, so `--record` keeps a run named after the command (WC-LIFE-15); and that `--timeout` ends it when
30
+ * its service never answers, a write as `outcome_unknown` (WC-LIFE-5, WC-LIFE-9). The recorded case is the one that
31
+ * catches a command beside the run: `run()`'s own deadline still ends such a command on time. The first case fails
32
+ * on a remote command with no words and no skip.
33
+ */
34
+ export declare const runScopeCases: (options: RunScopeOptions) => RunScopeCase[];
@@ -0,0 +1,112 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { metaOf } from "@wirecat/cli-base/commands";
6
+ import { createProgram, run } from "../cli/program.js";
7
+ import { findRun, runsDirFor } from "../runs/run.js";
8
+ /** A `fetch` that never settles, not even on abort: only the run's own deadline can end the command. */
9
+ export const neverAnswers = (() => new Promise(() => { }));
10
+ const GRACE_MS = 2000;
11
+ const remoteCommands = (program, path = []) => program.commands.flatMap((command) => {
12
+ const at = [...path, command.name()];
13
+ if (command.commands.length > 0)
14
+ return remoteCommands(command, at);
15
+ const meta = metaOf(command);
16
+ const remote = meta.effects?.remote;
17
+ return remote && remote !== "none" ? [{ path: at.join(" "), remote, partial: meta.partial === true }] : [];
18
+ });
19
+ const within = async (ms, what, body) => {
20
+ let timer;
21
+ const late = new Promise((_, reject) => {
22
+ timer = setTimeout(() => reject(new assert.AssertionError({ message: what })), ms);
23
+ timer.unref();
24
+ });
25
+ try {
26
+ return await Promise.race([body, late]);
27
+ }
28
+ finally {
29
+ clearTimeout(timer);
30
+ }
31
+ };
32
+ const parsed = (text) => {
33
+ try {
34
+ return JSON.parse(text.trim());
35
+ }
36
+ catch {
37
+ return undefined;
38
+ }
39
+ };
40
+ /**
41
+ * Cases that each remote command runs inside cli-core's run — `baseContext(command, resolveSettings).run(body)` — and
42
+ * not beside it, so `--record` keeps a run named after the command (WC-LIFE-15); and that `--timeout` ends it when
43
+ * its service never answers, a write as `outcome_unknown` (WC-LIFE-5, WC-LIFE-9). The recorded case is the one that
44
+ * catches a command beside the run: `run()`'s own deadline still ends such a command on time. The first case fails
45
+ * on a remote command with no words and no skip.
46
+ */
47
+ export const runScopeCases = (options) => {
48
+ const { definition, commands, skip = {}, timeoutMs = 100 } = options;
49
+ const { app } = definition;
50
+ const cli = async (argv, given) => {
51
+ const out = [];
52
+ const err = [];
53
+ const env = {
54
+ ...process.env,
55
+ ...given.env,
56
+ [`${app.envPrefix}_STATE_DIR`]: mkdtempSync(join(tmpdir(), "run-scope-")),
57
+ };
58
+ const code = await run(argv, definition, {
59
+ ...given,
60
+ env,
61
+ tty: false,
62
+ streams: { data: (text) => out.push(text), diagnostic: (text) => err.push(text) },
63
+ });
64
+ return { code, out: out.join(""), err: err.join(""), env };
65
+ };
66
+ const machine = (words) => words.some((word) => word === "--json" || word.startsWith("--json=")) ? [...words] : [...words, "--json"];
67
+ const recordedCase = (path, words) => ({
68
+ name: `${path} runs in a recorded run named after it`,
69
+ run: async () => {
70
+ const ran = await within(GRACE_MS, `${path} did not end against the answering fake`, (async () => cli(["--record", ...machine(words)], options.answering()))());
71
+ assert.equal(ran.code, 0, `${path} must succeed against the answering fake; it exited ${ran.code}: ${ran.err}`);
72
+ const runId = parsed(ran.out)?.meta?.run_id;
73
+ assert.equal(typeof runId, "string", `${path} answered without meta.run_id`);
74
+ assert.doesNotMatch(String(runId), /-process-/, `${path} answered outside a run of its own: ${runId}`);
75
+ const kept = findRun(runsDirFor(app, ran.env), String(runId));
76
+ assert.ok(kept, `--record kept no run ${runId} for ${path}`);
77
+ assert.equal(kept.metadata.command, path, `the run of ${path} is named ${kept.metadata.command}`);
78
+ },
79
+ });
80
+ const timeoutCase = ({ path, remote, partial }, words) => {
81
+ const expected = remote === "write" ? "outcome_unknown" : "timeout";
82
+ return {
83
+ name: `${path} ends in ${expected}${partial ? " or a partial answer" : ""} at --timeout when the service never answers`,
84
+ run: async () => {
85
+ const ran = await within(timeoutMs + GRACE_MS, `${path} was still running ${GRACE_MS} ms after its --timeout ${timeoutMs}ms`, cli(["--timeout", `${timeoutMs}ms`, ...machine(words)], options.silent()));
86
+ const answered = parsed(ran.out)?.meta?.complete;
87
+ if (partial && ran.code === 0 && answered === false)
88
+ return;
89
+ const code = parsed(ran.err)?.error?.code;
90
+ assert.equal(code, expected, `${path} against a silent service ended in ${String(code)}: ${ran.out}${ran.err}`);
91
+ },
92
+ };
93
+ };
94
+ const remote = remoteCommands(createProgram(definition));
95
+ const coverage = {
96
+ name: "every remote command has its words or a reason to skip it",
97
+ run: async () => {
98
+ const missing = remote.map(({ path }) => path).filter((path) => !(path in commands) && !(path in skip));
99
+ assert.deepEqual(missing, [], `remote commands with no words in \`commands\` and no reason in \`skip\``);
100
+ const known = new Set(remote.map(({ path }) => path));
101
+ const stray = [...Object.keys(commands), ...Object.keys(skip)].filter((path) => !known.has(path));
102
+ assert.deepEqual(stray, [], "paths in `commands` or `skip` that are not remote commands of this program");
103
+ },
104
+ };
105
+ return [
106
+ coverage,
107
+ ...remote.flatMap((one) => {
108
+ const words = commands[one.path];
109
+ return words ? [recordedCase(one.path, words), timeoutCase(one, words)] : [];
110
+ }),
111
+ ];
112
+ };
@@ -124,8 +124,13 @@ export const serveAppOverStdio = async (command, context, options) => {
124
124
  });
125
125
  const ended = await new Promise((resolve) => {
126
126
  wire.ended.then(() => resolve("input"));
127
- if (own)
128
- signal?.addEventListener("abort", () => resolve("stopped"), { once: true });
127
+ if (own) {
128
+ // A stop that came before this line fires no abort event.
129
+ if (signal?.aborted)
130
+ resolve("stopped");
131
+ else
132
+ signal?.addEventListener("abort", () => resolve("stopped"), { once: true });
133
+ }
129
134
  else {
130
135
  process.once("SIGINT", () => resolve("stopped"));
131
136
  process.once("SIGTERM", () => resolve("stopped"));
@@ -11,6 +11,11 @@ export interface MeetingCommandDeps {
11
11
  importHelp?: string;
12
12
  writeFile?: (path: string, content: string) => Promise<void>;
13
13
  write: (value: unknown, format: "json" | "jsonl" | "text") => void;
14
+ /**
15
+ * Runs a command that reaches the account inside cli-core's run, so it is recorded and ended by `--timeout`:
16
+ * `inCommandRun` from `@wirecat/cli-core/cli`. Required, since this module cannot reach the run itself.
17
+ */
18
+ inRun: <T>(command: Command, body: () => Promise<T>) => Promise<T>;
14
19
  now?: () => number;
15
20
  }
16
21
  export { addMeetingPassageCommand, createMeetingPassageCommand, type MeetingPassageFlags, meetingPassageRequest, } from "./passage.js";
@@ -121,14 +121,15 @@ const addMeetingCommandsDefinition = (program, deps) => {
121
121
  .option("--since-time <time>", "fetch meetings starting at or after this ISO 8601 date or time", date)
122
122
  .option("--lookback-days <days>", "revisit recent occurrences for late transcripts (0–31)", lookbackDays, 0)
123
123
  .action(async (opts, command) => {
124
- if (!deps.source)
124
+ const source = deps.source;
125
+ if (!source)
125
126
  throw new MeetingError("configuration_error", "meeting source is unavailable");
126
- print(await pull(deps.source, deps.store, {
127
+ await deps.inRun(command, async () => print(await pull(source, deps.store, {
127
128
  accountId: deps.accountId,
128
129
  since: opts.sinceTime === undefined ? undefined : new Date(opts.sinceTime).toISOString(),
129
130
  now: deps.now?.(),
130
131
  lookbackMs: opts.lookbackDays * 86400000,
131
- }), command);
132
+ }), command));
132
133
  });
133
134
  const importCommand = annotate(group.command("import"), {
134
135
  effects: LOCAL_WRITE,
@@ -0,0 +1,19 @@
1
+ import type { StoreHealth } from "./health.js";
2
+ /**
3
+ * What a damaged store means, in the owner's words: one structure the CLIs render and the desktop app can show as it
4
+ * is. Built from a health check only, so it never reads the damaged file again.
5
+ */
6
+ export interface DamageExplanation {
7
+ what: string;
8
+ causes: string[];
9
+ investigate: string[];
10
+ impact: string[];
11
+ externalUnaffected: string;
12
+ steps: string[];
13
+ }
14
+ /**
15
+ * Tables a recovery rebuilds from the others: search indexes, the vector index, what the indexing finds, and the
16
+ * vectors, which a search by meaning computes again for any text that has none.
17
+ */
18
+ export declare const isDerivedTable: (table: string, searchIndexes: readonly string[]) => boolean;
19
+ export declare const explainDamage: (health: StoreHealth, command: string) => DamageExplanation | null;
@@ -0,0 +1,58 @@
1
+ const DERIVED = new Set(["mentions", "involvements", "embeddings"]);
2
+ const named = (tables) => tables.length <= 5 ? tables.join(", ") : `${tables.slice(0, 5).join(", ")} and ${tables.length - 5} more`;
3
+ /**
4
+ * Tables a recovery rebuilds from the others: search indexes, the vector index, what the indexing finds, and the
5
+ * vectors, which a search by meaning computes again for any text that has none.
6
+ */
7
+ export const isDerivedTable = (table, searchIndexes) => DERIVED.has(table) ||
8
+ table.startsWith("vector_index_") ||
9
+ searchIndexes.some((index) => table === index || table.startsWith(`${index}_`));
10
+ const WIRECAT_UNITS = ["memo-*", "mail-*", "tg-*", "max-*", "wirecat-*"];
11
+ export const explainDamage = (health, command) => {
12
+ if (health.ok)
13
+ return null;
14
+ const indexNames = (health.searchIndexes ?? []).map((index) => index.name);
15
+ const badIndexes = (health.searchIndexes ?? []).filter((index) => !index.ok).map((index) => index.name);
16
+ const tables = (health.damagedTables ?? []).map((one) => one.table).filter((table) => table !== null);
17
+ const sources = tables.filter((table) => !isDerivedTable(table, indexNames));
18
+ const derived = [...new Set([...tables.filter((table) => isDerivedTable(table, indexNames)), ...badIndexes])];
19
+ const unopened = health.error !== undefined;
20
+ const what = unopened
21
+ ? `the store file cannot be opened: ${health.error}`
22
+ : health.checked === "quick"
23
+ ? "the store file's header or schema is damaged"
24
+ : `the store file is damaged: ${health.problems ?? 0} problems` +
25
+ (sources.length ? ` in the data of ${named(sources)}` : "") +
26
+ (derived.length ? `${sources.length ? ", and" : " in"} the search data of ${named(derived)}` : "");
27
+ const impact = [
28
+ ...(sources.length
29
+ ? [`some rows of ${named(sources)} cannot be read; a recovery keeps every row it can read and names the rest`]
30
+ : []),
31
+ ...(derived.length
32
+ ? [`searches may miss results or fail; ${named(derived)} hold nothing that is not elsewhere, and are rebuilt`]
33
+ : []),
34
+ "reads that do not touch a damaged page work; a write that touches one fails until the store is recovered",
35
+ ];
36
+ return {
37
+ what,
38
+ causes: [
39
+ "the computer lost power or crashed, or the disk filled up, while a tool was writing",
40
+ "the file was copied, restored or synced while a tool had it open, or it sits in a synced or network folder",
41
+ "a fault of the disk itself",
42
+ ],
43
+ investigate: [
44
+ `journalctl --user ${WIRECAT_UNITS.map((unit) => `-u '${unit}'`).join(" ")} --since -14d | grep -i -e malformed -e corrupt -e 'disk full'`,
45
+ "journalctl -k --since -14d | grep -i -e 'I/O error' -e nvme -e ata",
46
+ `df -h ${health.path.replace(/\/[^/]*$/, "")}`,
47
+ ...(health.checked === "quick" ? [`${command} store recover`] : []),
48
+ ],
49
+ impact,
50
+ externalUnaffected: "only this computer's copy is damaged: Telegram, MAX, the mail servers and Google Drive keep their own data, " +
51
+ "so whatever is lost here can be fetched again by a sync",
52
+ steps: [
53
+ "stop every timer and server of the WireCat tools; `store recover run` lists the ones still running",
54
+ `${command} store recover run`,
55
+ "run the fetch commands it prints, then start the timers again",
56
+ ],
57
+ };
58
+ };
@@ -0,0 +1,50 @@
1
+ import type { CacheDatabase } from "./driver.js";
2
+ /** One table's share of what `PRAGMA quick_check` found; `table` is null for pages no table claims. */
3
+ export interface DamagedTable {
4
+ table: string | null;
5
+ problems: number;
6
+ /** The first line SQLite gave for it: page and cell numbers, never row content. */
7
+ sample: string;
8
+ }
9
+ export interface SearchIndexHealth {
10
+ name: string;
11
+ ok: boolean;
12
+ error?: string;
13
+ }
14
+ export interface StoreHealth {
15
+ path: string;
16
+ /** `quick`: the file header and the schema only, in milliseconds. `deep`: every page and every search index. */
17
+ checked: "quick" | "deep";
18
+ ok: boolean;
19
+ error?: string;
20
+ file?: {
21
+ bytes: number;
22
+ pages: number;
23
+ pageSize: number;
24
+ freePages: number;
25
+ };
26
+ damagedTables?: DamagedTable[];
27
+ /** Lines `quick_check` gave in all, capped at `QUICK_CHECK_LIMIT`. */
28
+ problems?: number;
29
+ searchIndexes?: SearchIndexHealth[];
30
+ ms: number;
31
+ }
32
+ export declare const QUICK_CHECK_LIMIT = 10000;
33
+ /** What `doctor` checks by default, said where it answers. */
34
+ export declare const QUICK_HEALTH_CHECKS = "the file header, its size against its page count, and the schema's own pages";
35
+ export declare const fts5Tables: (database: CacheDatabase) => {
36
+ name: string;
37
+ sql: string;
38
+ external: boolean;
39
+ }[];
40
+ /** Groups the lines by the table whose tree they name: `Tree <root page> …` or `NULL value in <table>.<column>`. */
41
+ export declare const damagedTables: (database: CacheDatabase, lines: string[]) => DamagedTable[];
42
+ /** `rank = 0` checks the index's own structure, the only check a contentless index has; it writes nothing. */
43
+ export declare const searchIndexHealth: (database: CacheDatabase) => SearchIndexHealth[];
44
+ /**
45
+ * The store's health without changing it. `quick` takes milliseconds at any size; `deep` reads every page
46
+ * (about 30 s for 6 GB) and checks every search index, under a transaction it rolls back.
47
+ */
48
+ export declare const storeHealth: (path: string, { deep }?: {
49
+ deep?: boolean;
50
+ }) => Promise<StoreHealth>;