@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.
- package/CHANGELOG.md +55 -0
- package/dist/calendar/cli/index.js +4 -11
- package/dist/cli/context.d.ts +12 -0
- package/dist/cli/context.js +17 -3
- package/dist/cli/contract-templates.js +1 -0
- package/dist/cli/early-refusal.js +7 -5
- package/dist/cli/index.d.ts +3 -1
- package/dist/cli/index.js +3 -1
- package/dist/cli/messenger/doctor-command.d.ts +3 -0
- package/dist/cli/messenger/doctor-command.js +17 -3
- package/dist/cli/messenger/store-maintenance-command.d.ts +28 -3
- package/dist/cli/messenger/store-maintenance-command.js +25 -3
- package/dist/cli/program.js +3 -0
- package/dist/cli/store-info.d.ts +4 -1
- package/dist/cli/store-info.js +7 -3
- package/dist/cli/store-recover.d.ts +56 -0
- package/dist/cli/store-recover.js +153 -0
- package/dist/crm/cli/index.js +8 -6
- package/dist/errors/reasons.js +8 -0
- package/dist/kit/index.d.ts +1 -0
- package/dist/kit/index.js +1 -0
- package/dist/kit/run-scope.d.ts +34 -0
- package/dist/kit/run-scope.js +112 -0
- package/dist/mcp/app-server.js +7 -2
- package/dist/meetings/cli/index.d.ts +5 -0
- package/dist/meetings/cli/index.js +4 -3
- package/dist/store/damage.d.ts +19 -0
- package/dist/store/damage.js +58 -0
- package/dist/store/health.d.ts +50 -0
- package/dist/store/health.js +170 -0
- package/dist/store/index.d.ts +2 -0
- package/dist/store/index.js +2 -0
- package/dist/store/recover.d.ts +103 -0
- package/dist/store/recover.js +404 -0
- package/dist/store/sqlite/open-store.js +9 -2
- package/package.json +1 -1
- package/parity.json +6 -0
|
@@ -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
|
+
};
|
package/dist/crm/cli/index.js
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
464
|
+
page,
|
|
463
465
|
has_more: found.length > end,
|
|
464
466
|
...(words.length ? { args: { query: words } } : {}),
|
|
465
467
|
});
|
package/dist/errors/reasons.js
CHANGED
|
@@ -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",
|
package/dist/kit/index.d.ts
CHANGED
|
@@ -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
|
+
};
|
package/dist/mcp/app-server.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
124
|
+
const source = deps.source;
|
|
125
|
+
if (!source)
|
|
125
126
|
throw new MeetingError("configuration_error", "meeting source is unavailable");
|
|
126
|
-
print(await pull(
|
|
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>;
|