@openparachute/vault 0.7.9-rc.4 → 0.7.9-rc.6
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 +4 -0
- package/core/src/compact.test.ts +248 -0
- package/core/src/delta.test.ts +49 -0
- package/core/src/delta.ts +21 -0
- package/core/src/doctor.ts +41 -1
- package/core/src/history-capture.test.ts +244 -0
- package/core/src/history-import.test.ts +82 -0
- package/core/src/history-import.ts +157 -0
- package/core/src/history.test.ts +229 -0
- package/core/src/history.ts +681 -0
- package/core/src/mcp-manifest.ts +17 -0
- package/core/src/mcp.ts +28 -3
- package/core/src/notes.ts +6 -1
- package/core/src/portable-md.ts +1 -1
- package/core/src/schema-v29-note-versions.test.ts +63 -0
- package/core/src/schema-v30-note-deltas.test.ts +51 -0
- package/core/src/schema-v31-history-import.test.ts +25 -0
- package/core/src/schema.ts +234 -1
- package/core/src/store.ts +103 -14
- package/core/src/types.ts +35 -2
- package/core/src/vendor/fossil-delta.ts +472 -0
- package/package.json +1 -1
- package/src/cli.ts +10 -1
- package/src/config.ts +42 -0
- package/src/history-import-mcp.test.ts +94 -0
- package/src/history-import-routes.test.ts +91 -0
- package/src/history-import.test.ts +318 -0
- package/src/history-import.ts +482 -0
- package/src/mcp-http.ts +13 -0
- package/src/mcp-tools.ts +18 -2
- package/src/mirror-config.ts +38 -0
- package/src/mirror-manager.ts +6 -0
- package/src/mirror-retirement.test.ts +79 -0
- package/src/mirror-routes.ts +12 -2
- package/src/routes.ts +226 -86
- package/src/routing.ts +8 -3
- package/src/server.ts +33 -14
- package/src/vault-compact-routes.test.ts +257 -0
- package/src/vault-history-routes.test.ts +562 -0
- package/src/vault-store.ts +13 -0
|
@@ -0,0 +1,482 @@
|
|
|
1
|
+
/** Offline git-history import. Git objects are data; no working tree is checked out. */
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
import { tmpdir } from "node:os";
|
|
8
|
+
import { applyImportedNote, beginImportRun, canonicalJson, importObservationDigest, importTargetDigest, readImportReceipt, type ImportedObservation, type ImportRun } from "../core/src/history-import.ts";
|
|
9
|
+
import { hashContent, resolveHistoryPolicy, VERSION_MAX_BYTES, type HistoryPolicy } from "../core/src/history.ts";
|
|
10
|
+
import { applyConnectionPragmas, initSchema } from "../core/src/schema.ts";
|
|
11
|
+
import { DEFAULT_PORT, readGlobalConfig, readVaultConfig, vaultDbPath, vaultDir } from "./config.ts";
|
|
12
|
+
import { checkHealth } from "./health.ts";
|
|
13
|
+
import { defaultMirrorConfig, historyImportRecoveryPath, historyMirrorStatePath, mirrorConfigPath, readHistoryMirrorPhase, readMirrorConfigForVault, serializeMirrorConfig } from "./mirror-config.ts";
|
|
14
|
+
|
|
15
|
+
const MAX_OUTPUT = 32 * 1024 * 1024;
|
|
16
|
+
const MAX_COMMITS = 100_000;
|
|
17
|
+
const MAX_OBJECTS = 10_000_000;
|
|
18
|
+
const MAX_BYTES = 2 * 1024 * 1024 * 1024;
|
|
19
|
+
const ID = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
20
|
+
const decoder = new TextDecoder("utf-8", { fatal: true });
|
|
21
|
+
class GitArchiveError extends Error {}
|
|
22
|
+
function git(repo: string, args: string[]): Buffer {
|
|
23
|
+
const environment = { ...process.env };
|
|
24
|
+
// A caller's GIT_DIR/object/config variables must not redirect the private bundle reader.
|
|
25
|
+
for (const key of Object.keys(environment)) if (key.startsWith("GIT_")) delete environment[key];
|
|
26
|
+
const result = spawnSync("git", ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "-c", "protocol.allow=never", "-c", "protocol.file.allow=always", "-C", repo, ...args], {
|
|
27
|
+
encoding: "buffer", maxBuffer: MAX_OUTPUT,
|
|
28
|
+
env: { ...environment, GIT_NO_REPLACE_OBJECTS: "1", GIT_NO_LAZY_FETCH: "1", GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0", GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" },
|
|
29
|
+
});
|
|
30
|
+
if (result.error || result.status !== 0) throw new GitArchiveError(`Git ${args[0]} failed; verify complete local archive (${result.error?.message ?? result.status})`);
|
|
31
|
+
return result.stdout;
|
|
32
|
+
}
|
|
33
|
+
function text(bytes: Uint8Array): string { return decoder.decode(bytes); }
|
|
34
|
+
function object(repo: string, hash: string): string {
|
|
35
|
+
const size = Number(text(git(repo, ["cat-file", "-s", hash])).trim());
|
|
36
|
+
if (!Number.isSafeInteger(size) || size > VERSION_MAX_BYTES + 256_000) throw new Error("oversized_object");
|
|
37
|
+
return text(git(repo, ["cat-file", "blob", hash]));
|
|
38
|
+
}
|
|
39
|
+
function mapping(value: unknown): Record<string, unknown> {
|
|
40
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_metadata");
|
|
41
|
+
return value as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
function yaml(raw: string): Record<string, unknown> { return mapping(Bun.YAML.parse(raw)); }
|
|
44
|
+
function inline(raw: string): { meta: Record<string, unknown>; content: string } {
|
|
45
|
+
const match = /^(?:\uFEFF)?---\r?\n([\s\S]*?)^---(?:\r?\n|$)/m.exec(raw);
|
|
46
|
+
if (!match || match.index !== 0) throw new Error("missing_frontmatter");
|
|
47
|
+
return { meta: yaml(match[1]!), content: raw.slice(match[0].length) };
|
|
48
|
+
}
|
|
49
|
+
function observation(meta: Record<string, unknown>, content: string, commit: string, blob: string, time: string): { id: string; row: ImportedObservation } {
|
|
50
|
+
if (typeof meta.id !== "string" || !ID.test(meta.id)) throw new Error("invalid_id");
|
|
51
|
+
if (Buffer.byteLength(content) > VERSION_MAX_BYTES) throw new Error("oversized_body");
|
|
52
|
+
const path = meta.path ?? null, extension = meta.extension ?? "md", created = meta.created_at ?? null;
|
|
53
|
+
if (path !== null && (typeof path !== "string" || path.includes("\0") || path.split("/").includes(".."))) throw new Error("invalid_path");
|
|
54
|
+
if (typeof extension !== "string" || !/^[a-z0-9]+$/.test(extension)) throw new Error("invalid_extension");
|
|
55
|
+
if (created !== null && (typeof created !== "string" || !Number.isFinite(Date.parse(created)))) throw new Error("invalid_created_at");
|
|
56
|
+
return { id: meta.id, row: { content, path, extension, created_at: created, metadata: meta.metadata === undefined ? {} : mapping(meta.metadata), observed_at: time, commit, blob } };
|
|
57
|
+
}
|
|
58
|
+
export interface HistoryCandidateRef { git_path: string; blob: string; sidecar?: { git_path: string; blob: string }; }
|
|
59
|
+
export interface HistorySelection {
|
|
60
|
+
note_id: string; commit: string; selected: HistoryCandidateRef;
|
|
61
|
+
rejected: (HistoryCandidateRef & { reason: string })[]; reason: string;
|
|
62
|
+
}
|
|
63
|
+
export interface HistorySelections { format: 1; tip: string; source_fingerprint: string; selections: HistorySelection[]; }
|
|
64
|
+
const OBJECT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
65
|
+
const compareText = (a: string, b: string) => a < b ? -1 : a > b ? 1 : 0;
|
|
66
|
+
function selectionObject(input: unknown, keys: string[]): Record<string, unknown> {
|
|
67
|
+
if (!input || typeof input !== "object" || Array.isArray(input) || Object.keys(input).some(k => !keys.includes(k))) throw new Error("Invalid selections object");
|
|
68
|
+
return input as Record<string, unknown>;
|
|
69
|
+
}
|
|
70
|
+
function selectionReason(input: unknown): string {
|
|
71
|
+
if (typeof input !== "string" || !input.trim()) throw new Error("Selections require audit reasons");
|
|
72
|
+
return input;
|
|
73
|
+
}
|
|
74
|
+
function candidateRef(input: unknown, rejected = false): HistoryCandidateRef {
|
|
75
|
+
const value = selectionObject(input, rejected ? ["git_path", "blob", "sidecar", "reason"] : ["git_path", "blob", "sidecar"]);
|
|
76
|
+
if (typeof value.git_path !== "string" || !value.git_path || value.git_path.startsWith("/") || value.git_path.includes("\0") || value.git_path.split("/").some(p => p === ".." || p === "." || !p) || typeof value.blob !== "string" || !OBJECT_ID.test(value.blob)) throw new Error("Invalid selection candidate reference");
|
|
77
|
+
const ref: HistoryCandidateRef = { git_path: value.git_path, blob: value.blob };
|
|
78
|
+
if (value.sidecar !== undefined) {
|
|
79
|
+
selectionObject(value.sidecar, ["git_path", "blob"]);
|
|
80
|
+
ref.sidecar = candidateRef(value.sidecar);
|
|
81
|
+
}
|
|
82
|
+
return ref;
|
|
83
|
+
}
|
|
84
|
+
/** Canonical ordering makes options independent of selector/rejected array order. */
|
|
85
|
+
export function normalizeHistorySelections(input: unknown): HistorySelections {
|
|
86
|
+
const value = selectionObject(input, ["format", "tip", "source_fingerprint", "selections"]);
|
|
87
|
+
if (value.format !== 1 || typeof value.tip !== "string" || !OBJECT_ID.test(value.tip) || typeof value.source_fingerprint !== "string" || !/^[0-9a-f]{64}$/.test(value.source_fingerprint) || !Array.isArray(value.selections) || value.selections.length > 100_000) throw new Error("Invalid selections header or entry limit (100,000)");
|
|
88
|
+
const seen = new Set<string>();
|
|
89
|
+
const selections = value.selections.map(raw => {
|
|
90
|
+
const entry = selectionObject(raw, ["note_id", "commit", "selected", "rejected", "reason"]);
|
|
91
|
+
if (typeof entry.note_id !== "string" || !ID.test(entry.note_id) || typeof entry.commit !== "string" || !OBJECT_ID.test(entry.commit) || !Array.isArray(entry.rejected) || !entry.rejected.length) throw new Error("Invalid selection entry");
|
|
92
|
+
const key = `${entry.note_id}:${entry.commit}`;
|
|
93
|
+
if (seen.has(key)) throw new Error("Duplicate selection note/commit");
|
|
94
|
+
seen.add(key);
|
|
95
|
+
const selected = candidateRef(entry.selected), paths = new Set([selected.git_path]);
|
|
96
|
+
const rejected = entry.rejected.map(raw => {
|
|
97
|
+
const ref = candidateRef(raw, true);
|
|
98
|
+
if (paths.has(ref.git_path)) throw new Error("Duplicate selection candidate path");
|
|
99
|
+
paths.add(ref.git_path);
|
|
100
|
+
return { ...ref, reason: selectionReason((raw as Record<string, unknown>).reason) };
|
|
101
|
+
}).sort((a, b) => compareText(canonicalJson(candidateRef(a, true)), canonicalJson(candidateRef(b, true))));
|
|
102
|
+
return { note_id: entry.note_id, commit: entry.commit, selected, rejected, reason: selectionReason(entry.reason) };
|
|
103
|
+
}).sort((a, b) => compareText(`${a.note_id}:${a.commit}`, `${b.note_id}:${b.commit}`));
|
|
104
|
+
const result: HistorySelections = { format: 1, tip: value.tip, source_fingerprint: value.source_fingerprint, selections };
|
|
105
|
+
if (Buffer.byteLength(canonicalJson(result)) > MAX_OUTPUT) throw new Error("Selections exceed 32 MiB");
|
|
106
|
+
return result;
|
|
107
|
+
}
|
|
108
|
+
function selectionsDigest(selections: HistorySelections | null): string | null { return selections ? hashContent(canonicalJson(selections)) : null; }
|
|
109
|
+
function optionsDigest(policy: HistoryPolicy, waivers: Record<string, string>, selections: HistorySelections | null): string {
|
|
110
|
+
return hashContent(canonicalJson({ parser: 1, policy, waivers, ...(selections ? { selections_digest: selectionsDigest(selections) } : {}) }));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface StagedArchive { source_fingerprint: string; tip: string; commits: number; objects: number; bytes: number; }
|
|
114
|
+
/** Disk-backed staging bounds memory to one git tree and one note body. */
|
|
115
|
+
export function stageArchive(repo: string, tip: string, stage: Database, inputSelections?: HistorySelections | null): StagedArchive {
|
|
116
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(tip)) throw new Error("through must be a full commit hash");
|
|
117
|
+
if (text(git(repo, ["rev-parse", "--is-shallow-repository"])).trim() !== "false") throw new Error("Shallow archives are not supported");
|
|
118
|
+
if (text(git(repo, ["rev-parse", `${tip}^{commit}`])).trim() !== tip) throw new Error("Tip is not a commit");
|
|
119
|
+
git(repo, ["rev-list", "--objects", "--missing=error", tip]);
|
|
120
|
+
const commits = text(git(repo, ["log", "--first-parent", "--reverse", "--format=%H %T %cI", tip])).trim().split("\n");
|
|
121
|
+
if (commits.length > MAX_COMMITS) throw new Error("Archive commit limit exceeded");
|
|
122
|
+
const selections = inputSelections == null ? null : normalizeHistorySelections(inputSelections);
|
|
123
|
+
if (selections && selections.tip !== tip) throw new Error("Selections tip differs from archive");
|
|
124
|
+
const commitIds = new Set(commits.map(c => c.split(" ")[0]!));
|
|
125
|
+
const byCommit = new Map<string, Map<string, HistorySelection>>();
|
|
126
|
+
for (const selection of selections?.selections ?? []) {
|
|
127
|
+
if (!commitIds.has(selection.commit)) throw new Error("Selection commit is not on the first-parent walk");
|
|
128
|
+
let entries = byCommit.get(selection.commit);
|
|
129
|
+
if (!entries) byCommit.set(selection.commit, entries = new Map());
|
|
130
|
+
entries.set(selection.note_id, selection);
|
|
131
|
+
}
|
|
132
|
+
stage.exec("CREATE TABLE selection_failures(note_id TEXT, commit_id TEXT, reason TEXT); CREATE TABLE observations(note_id TEXT NOT NULL, seq INTEGER NOT NULL, state TEXT NOT NULL, row_json TEXT NOT NULL, PRIMARY KEY(note_id,seq)); CREATE TABLE quarantine(note_id TEXT PRIMARY KEY, reason TEXT NOT NULL); CREATE TABLE issues(commit_id TEXT, path TEXT, reason TEXT); CREATE TABLE tip_ids(note_id TEXT PRIMARY KEY); CREATE TABLE object_cache(hash TEXT PRIMARY KEY, content TEXT NOT NULL); CREATE TABLE parsed_cache(hash TEXT PRIMARY KEY, note_id TEXT NOT NULL, state TEXT NOT NULL, row_json TEXT NOT NULL)");
|
|
133
|
+
const fingerprint = createHash("sha256");
|
|
134
|
+
let objects = 0, bytes = 0;
|
|
135
|
+
const lastStates = new Map<string, string>();
|
|
136
|
+
const identities = new Set<string>();
|
|
137
|
+
const parsedGet = stage.prepare("SELECT note_id,state FROM parsed_cache WHERE hash=?");
|
|
138
|
+
const parsedBody = stage.prepare("SELECT row_json FROM parsed_cache WHERE hash=?");
|
|
139
|
+
const parsedPut = stage.prepare("INSERT OR IGNORE INTO parsed_cache VALUES(?,?,?,?)");
|
|
140
|
+
const insertObservation = stage.prepare("INSERT INTO observations VALUES(?,?,?,?)");
|
|
141
|
+
const markQuarantine = stage.prepare("INSERT OR IGNORE INTO quarantine VALUES(?,?)");
|
|
142
|
+
const markTip = stage.prepare("INSERT OR IGNORE INTO tip_ids VALUES(?)");
|
|
143
|
+
for (let seq = 0; seq < commits.length; seq++) {
|
|
144
|
+
const [commit, tree, time] = commits[seq]!.split(" ");
|
|
145
|
+
if (!commit || !tree || !time || !Number.isFinite(Date.parse(time))) throw new Error("Invalid commit record");
|
|
146
|
+
fingerprint.update(`${commit} ${tree}\n`);
|
|
147
|
+
const entries = text(git(repo, ["ls-tree", "-rz", "--full-tree", commit])).split("\0").filter(Boolean).map(record => {
|
|
148
|
+
const tab = record.indexOf("\t"), [mode, type, hash] = record.slice(0, tab).split(" ");
|
|
149
|
+
if (tab < 0 || !hash) throw new Error("Invalid tree record");
|
|
150
|
+
return { mode, type, hash, path: record.slice(tab + 1) };
|
|
151
|
+
});
|
|
152
|
+
objects += entries.length;
|
|
153
|
+
if (objects > MAX_OBJECTS) throw new Error("Archive object limit exceeded");
|
|
154
|
+
const files = new Map(entries.map(e => [e.path, e]));
|
|
155
|
+
const used = new Set<string>(), sidecarOwners = new Map<string, string>();
|
|
156
|
+
const candidates = new Map<string, { ref: HistoryCandidateRef; state: string; row: () => ImportedObservation }[]>();
|
|
157
|
+
const read = (entry: typeof entries[number]) => {
|
|
158
|
+
if (entry.mode !== "100644" && entry.mode !== "100755") throw new Error("unsupported_tree_entry");
|
|
159
|
+
const cached = stage.prepare("SELECT content FROM object_cache WHERE hash=?").get(entry.hash) as { content: string } | null;
|
|
160
|
+
if (cached) return cached.content;
|
|
161
|
+
const value = object(repo, entry.hash); bytes += Buffer.byteLength(value);
|
|
162
|
+
if (bytes > MAX_BYTES) throw new Error("Archive byte limit exceeded");
|
|
163
|
+
stage.prepare("INSERT INTO object_cache VALUES(?,?)").run(entry.hash, value);
|
|
164
|
+
return value;
|
|
165
|
+
};
|
|
166
|
+
const collect = (id: string, state: string, row: () => ImportedObservation, ref: HistoryCandidateRef) => {
|
|
167
|
+
if (!identities.has(id) && identities.size >= 100_000) throw new Error("Archive note limit exceeded");
|
|
168
|
+
identities.add(id);
|
|
169
|
+
let group = candidates.get(id);
|
|
170
|
+
if (!group) candidates.set(id, group = []);
|
|
171
|
+
group.push({ ref, state, row });
|
|
172
|
+
};
|
|
173
|
+
const parseObservation = (meta: Record<string, unknown>, body: string, entry: typeof entries[number]) => {
|
|
174
|
+
const { id, row } = observation(meta, body, commit, entry.hash, time);
|
|
175
|
+
const state = hashContent(canonicalJson({ content: row.content, path: row.path, metadata: row.metadata, extension: row.extension, created_at: row.created_at }));
|
|
176
|
+
return { id, row, state };
|
|
177
|
+
};
|
|
178
|
+
const accept = (meta: Record<string, unknown>, body: string, entry: typeof entries[number], sidecar: { git_path: string; blob: string }) => {
|
|
179
|
+
const { id, row, state } = parseObservation(meta, body, entry);
|
|
180
|
+
const cacheKey = `${entry.hash}:${sidecar.blob}`;
|
|
181
|
+
parsedPut.run(cacheKey, id, state, JSON.stringify(row));
|
|
182
|
+
collect(id, state, () => JSON.parse((parsedBody.get(cacheKey) as { row_json: string }).row_json), { git_path: entry.path, blob: entry.hash, sidecar });
|
|
183
|
+
};
|
|
184
|
+
const failure = (entry: typeof entries[number], error: unknown, id?: unknown) => {
|
|
185
|
+
const message = error instanceof Error ? error.message : "invalid_observation";
|
|
186
|
+
const reason = /^[a-z_]+$/.test(message) ? message : "invalid_metadata_or_utf8";
|
|
187
|
+
if (error instanceof GitArchiveError || message === "Archive byte limit exceeded" || message === "Archive note limit exceeded") throw error;
|
|
188
|
+
if (typeof id === "string" && ID.test(id)) stage.prepare("INSERT OR REPLACE INTO quarantine VALUES(?,?)").run(id, reason);
|
|
189
|
+
else stage.prepare("INSERT INTO issues VALUES(?,?,?)").run(commit, entry.path, reason);
|
|
190
|
+
};
|
|
191
|
+
const format = files.get(".parachute/vault.yaml");
|
|
192
|
+
if (format && yaml(read(format)).export_format_version !== 1) throw new Error("Unsupported portable export format");
|
|
193
|
+
for (const entry of entries.filter(e => /^\.parachute\/notes-meta\/[^/]+\.yaml$/.test(e.path))) {
|
|
194
|
+
let meta: Record<string, unknown> | undefined;
|
|
195
|
+
try {
|
|
196
|
+
meta = yaml(read(entry));
|
|
197
|
+
const extension = meta.extension;
|
|
198
|
+
if (typeof meta.id !== "string" || !ID.test(meta.id) || typeof extension !== "string") throw new Error("invalid_sidecar");
|
|
199
|
+
const contentPath = `${meta.path ?? `_unpathed/${meta.id}`}.${extension}`;
|
|
200
|
+
const body = files.get(contentPath);
|
|
201
|
+
if (!body) throw new Error("missing_sidecar_body");
|
|
202
|
+
if (extension === "md" || extension === "mdx") throw new Error("unexpected_inline_sidecar");
|
|
203
|
+
const priorOwner = sidecarOwners.get(contentPath);
|
|
204
|
+
if (priorOwner) {
|
|
205
|
+
stage.prepare("INSERT OR REPLACE INTO quarantine VALUES(?,?)").run(priorOwner, "ambiguous_sidecar");
|
|
206
|
+
throw new Error("ambiguous_sidecar");
|
|
207
|
+
}
|
|
208
|
+
sidecarOwners.set(contentPath, meta.id);
|
|
209
|
+
used.add(contentPath);
|
|
210
|
+
accept(meta, read(body), body, { git_path: entry.path, blob: entry.hash });
|
|
211
|
+
} catch (error) { failure(entry, error, meta?.id ?? entry.path.split("/").pop()?.slice(0, -5)); }
|
|
212
|
+
}
|
|
213
|
+
for (const entry of entries) {
|
|
214
|
+
if (entry.path.startsWith(".parachute/") || entry.path.startsWith(".doctor/") || entry.path === ".gitkeep" || used.has(entry.path)) continue;
|
|
215
|
+
if (!/\.(md|mdx)$/.test(entry.path)) { stage.prepare("INSERT INTO issues VALUES(?,?,?)").run(commit, entry.path, "unclassified_file"); continue; }
|
|
216
|
+
let meta: Record<string, unknown> | undefined;
|
|
217
|
+
try {
|
|
218
|
+
const cached = parsedGet.get(entry.hash) as { note_id: string; state: string } | null;
|
|
219
|
+
if (cached) {
|
|
220
|
+
collect(cached.note_id, cached.state, () => JSON.parse((parsedBody.get(entry.hash) as { row_json: string }).row_json), { git_path: entry.path, blob: entry.hash });
|
|
221
|
+
} else {
|
|
222
|
+
const parsed = inline(read(entry)); meta = parsed.meta;
|
|
223
|
+
const { id, row, state } = parseObservation(meta, parsed.content, entry);
|
|
224
|
+
parsedPut.run(entry.hash, id, state, JSON.stringify(row));
|
|
225
|
+
collect(id, state, () => JSON.parse((parsedBody.get(entry.hash) as { row_json: string }).row_json), { git_path: entry.path, blob: entry.hash });
|
|
226
|
+
}
|
|
227
|
+
} catch (error) { failure(entry, error, meta?.id); }
|
|
228
|
+
}
|
|
229
|
+
const requested = byCommit.get(commit);
|
|
230
|
+
for (const id of [...new Set([...candidates.keys(), ...(requested?.keys() ?? [])])].sort()) {
|
|
231
|
+
const group = candidates.get(id) ?? [], selection = requested?.get(id);
|
|
232
|
+
if (group.length && seq === commits.length - 1) markTip.run(id);
|
|
233
|
+
let selected = group.length === 1 && !selection ? group[0] : undefined;
|
|
234
|
+
let refusal = selection ? "stale_selection" : "missing_selection";
|
|
235
|
+
if (group.length >= 2 && selection) {
|
|
236
|
+
const actual = group.map(c => canonicalJson(c.ref)).sort();
|
|
237
|
+
const expected = [selection.selected, ...selection.rejected.map(r => candidateRef(r, true))].map(c => canonicalJson(c)).sort();
|
|
238
|
+
if (canonicalJson(actual) === canonicalJson(expected)) selected = group.find(c => canonicalJson(c.ref) === canonicalJson(selection.selected));
|
|
239
|
+
else refusal = "candidate_set_mismatch";
|
|
240
|
+
}
|
|
241
|
+
if (!selected) {
|
|
242
|
+
markQuarantine.run(id, "duplicate_id");
|
|
243
|
+
if (selections) stage.prepare("INSERT INTO selection_failures VALUES(?,?,?)").run(id, commit, refusal);
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
if (lastStates.get(id) === selected.state) continue;
|
|
247
|
+
if (!lastStates.has(id) && lastStates.size >= 100_000) throw new Error("Archive note limit exceeded");
|
|
248
|
+
lastStates.set(id, selected.state);
|
|
249
|
+
insertObservation.run(id, seq, selected.state, JSON.stringify({ ...selected.row(), commit, observed_at: time }));
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
const sourceFingerprint = fingerprint.digest("hex");
|
|
253
|
+
if (selections && selections.source_fingerprint !== sourceFingerprint) throw new Error("Selections source fingerprint differs from archive");
|
|
254
|
+
stage.exec("DELETE FROM observations WHERE note_id IN (SELECT note_id FROM quarantine)");
|
|
255
|
+
return { source_fingerprint: sourceFingerprint, tip, commits: commits.length, objects, bytes };
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
interface PlannedNote { id: string; target_digest: string; state_digest: string; native_drops: number[]; imported: number; retained: number; pruned_imported: number; pruned_native: number; }
|
|
259
|
+
export interface ImportManifest extends StagedArchive {
|
|
260
|
+
format: 1; kind: "diagnostic" | "final"; vault: string; evaluated_at: number; policy: HistoryPolicy;
|
|
261
|
+
archive: string | null; archive_hash: string | null; backup: string | null;
|
|
262
|
+
options_digest: string; target_ids_digest: string; notes: PlannedNote[]; skipped: string[];
|
|
263
|
+
quarantined: { note_id: string; reason: string }[]; issues: { commit_id: string; path: string; reason: string }[];
|
|
264
|
+
selections?: HistorySelections | null; selections_digest?: string | null;
|
|
265
|
+
selection_failures?: { note_id: string; commit_id: string; reason: string }[];
|
|
266
|
+
missing_at_tip: string[]; waivers: Record<string, string>; run_id: string;
|
|
267
|
+
}
|
|
268
|
+
function digestManifest(manifest: Omit<ImportManifest, "run_id"> | ImportManifest): string {
|
|
269
|
+
const { run_id: _, ...body } = manifest as ImportManifest;
|
|
270
|
+
return hashContent(canonicalJson(body));
|
|
271
|
+
}
|
|
272
|
+
function atomic(path: string, body: string): void {
|
|
273
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
274
|
+
const temp = `${path}.${crypto.randomUUID()}.tmp`;
|
|
275
|
+
const fd = openSync(temp, "wx", 0o600);
|
|
276
|
+
try { writeFileSync(fd, body); fsyncSync(fd); } finally { closeSync(fd); }
|
|
277
|
+
renameSync(temp, path);
|
|
278
|
+
const directory = openSync(dirname(path), "r");
|
|
279
|
+
try { fsyncSync(directory); } finally { closeSync(directory); }
|
|
280
|
+
}
|
|
281
|
+
function readOptional(path: string): string | null {
|
|
282
|
+
try { return readFileSync(path, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return null; throw error; }
|
|
283
|
+
}
|
|
284
|
+
export function copyDatabase(source: string, target: string): void {
|
|
285
|
+
const db = new Database(source, { readonly: true });
|
|
286
|
+
try { db.prepare("VACUUM INTO ?").run(target); } finally { db.close(); }
|
|
287
|
+
chmodSync(target, 0o600);
|
|
288
|
+
}
|
|
289
|
+
function fileHash(path: string): string {
|
|
290
|
+
const fd = openSync(path, "r"), hash = createHash("sha256"), chunk = Buffer.alloc(64 * 1024);
|
|
291
|
+
try {
|
|
292
|
+
for (;;) { const count = readSync(fd, chunk, 0, chunk.length, null); if (!count) break; hash.update(chunk.subarray(0, count)); }
|
|
293
|
+
return hash.digest("hex");
|
|
294
|
+
} finally { closeSync(fd); }
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function rowsFor(stage: Database, id: string): ImportedObservation[] {
|
|
298
|
+
const size = stage.prepare("SELECT COALESCE(SUM(length(CAST(row_json AS BLOB))),0) AS bytes FROM observations WHERE note_id=?").get(id) as { bytes: number };
|
|
299
|
+
if (size.bytes > 64 * 1024 * 1024) throw new Error("Per-note staging limit exceeded (64 MiB); no manifest can be applied");
|
|
300
|
+
const rows = stage.prepare("SELECT row_json FROM observations WHERE note_id=? ORDER BY seq").all(id) as { row_json: string }[];
|
|
301
|
+
return rows.map(r => JSON.parse(r.row_json));
|
|
302
|
+
}
|
|
303
|
+
/** Staging is disposable and rebuilt from the immutable bundle after interruption. */
|
|
304
|
+
function openStage(path: string): Database {
|
|
305
|
+
const stage = new Database(path);
|
|
306
|
+
stage.exec("PRAGMA journal_mode=OFF; PRAGMA synchronous=OFF");
|
|
307
|
+
return stage;
|
|
308
|
+
}
|
|
309
|
+
function project(vault: string, repo: string, tip: string, dir: string, opts: { archive?: string; backup?: string; waivers?: Record<string, string>; selections?: HistorySelections | null }): ImportManifest {
|
|
310
|
+
const config = readVaultConfig(vault);
|
|
311
|
+
if (!config) throw new Error("Vault does not exist");
|
|
312
|
+
const stage = openStage(join(dir, "stage.db"));
|
|
313
|
+
const copy = join(dir, "projection.db");
|
|
314
|
+
copyDatabase(vaultDbPath(vault), copy);
|
|
315
|
+
const db = new Database(copy);
|
|
316
|
+
try {
|
|
317
|
+
applyConnectionPragmas(db); initSchema(db);
|
|
318
|
+
const selections = opts.selections ?? null;
|
|
319
|
+
const source = stageArchive(repo, tip, stage, selections), policy = resolveHistoryPolicy(config.history);
|
|
320
|
+
const waivers = opts.waivers ?? {};
|
|
321
|
+
for (const [id, reason] of Object.entries(waivers)) if (!ID.test(id) || typeof reason !== "string" || !reason.trim()) throw new Error("Waivers require note IDs and reasons");
|
|
322
|
+
const options_digest = optionsDigest(policy, waivers, selections);
|
|
323
|
+
const run = { run_id: "projection", source_fingerprint: source.source_fingerprint, tip, options_digest };
|
|
324
|
+
beginImportRun(db, run);
|
|
325
|
+
const evaluated_at = Date.now(), notes: PlannedNote[] = [], skipped: string[] = [];
|
|
326
|
+
const ids = stage.prepare("SELECT DISTINCT note_id FROM observations ORDER BY note_id").all() as { note_id: string }[];
|
|
327
|
+
for (const { note_id: id } of ids) {
|
|
328
|
+
if (!db.prepare("SELECT 1 FROM notes WHERE id=?").get(id)) { skipped.push(id); continue; }
|
|
329
|
+
if (waivers[id]) continue;
|
|
330
|
+
const rows = rowsFor(stage, id), target_digest = importTargetDigest(db, id);
|
|
331
|
+
const result = applyImportedNote(db, { run, noteId: id, observations: rows, policy, now: evaluated_at, targetDigest: target_digest });
|
|
332
|
+
notes.push({ id, target_digest, state_digest: importObservationDigest(rows), native_drops: result.nativeDrops, imported: rows.length, retained: result.receipt.retained_count, pruned_imported: result.receipt.pruned_imported, pruned_native: result.receipt.pruned_native });
|
|
333
|
+
}
|
|
334
|
+
const live = db.prepare("SELECT id FROM notes ORDER BY id").all() as { id: string }[];
|
|
335
|
+
const missing_at_tip = live.filter(({ id }) => !stage.prepare("SELECT 1 FROM tip_ids WHERE note_id=?").get(id)).map(r => r.id);
|
|
336
|
+
const body: Omit<ImportManifest, "run_id"> = {
|
|
337
|
+
...source, format: 1, kind: opts.archive ? "final" : "diagnostic", vault, evaluated_at, policy,
|
|
338
|
+
archive: opts.archive ?? null, archive_hash: opts.archive ? fileHash(opts.archive) : null, backup: opts.backup ?? null,
|
|
339
|
+
selections, selections_digest: selectionsDigest(selections),
|
|
340
|
+
selection_failures: stage.prepare("SELECT * FROM selection_failures ORDER BY note_id,commit_id").all() as NonNullable<ImportManifest["selection_failures"]>,
|
|
341
|
+
options_digest, target_ids_digest: hashContent(canonicalJson(live.map(r => r.id))), notes, skipped, missing_at_tip, waivers,
|
|
342
|
+
quarantined: stage.prepare("SELECT * FROM quarantine ORDER BY note_id").all() as ImportManifest["quarantined"],
|
|
343
|
+
issues: stage.prepare("SELECT * FROM issues ORDER BY commit_id,path,reason").all() as ImportManifest["issues"],
|
|
344
|
+
};
|
|
345
|
+
return { ...body, run_id: digestManifest(body) };
|
|
346
|
+
} finally { db.close(); stage.close(); }
|
|
347
|
+
}
|
|
348
|
+
interface Recovery { prior_config: string | null; prior_marker: string | null; backup: string; backup_hash: string; phase: "preparing" | "prepared"; run_id?: string; }
|
|
349
|
+
async function offline(): Promise<void> {
|
|
350
|
+
const health = await checkHealth(readGlobalConfig().port || DEFAULT_PORT);
|
|
351
|
+
if (health.status !== "not-listening") throw new Error("Stop the vault daemon before history import; keep it stopped through retire");
|
|
352
|
+
}
|
|
353
|
+
function privateBundle(archive: string, expected: string, dir: string): string {
|
|
354
|
+
if (fileHash(archive) !== expected) throw new Error("Archive hash mismatch");
|
|
355
|
+
const copy = join(dir, "archive.bundle"); copyFileSync(archive, copy); chmodSync(copy, 0o600);
|
|
356
|
+
if (fileHash(copy) !== expected) throw new Error("Archive changed during copy");
|
|
357
|
+
const repo = join(dir, "objects.git"); mkdirSync(repo);
|
|
358
|
+
git(repo, ["init", "--bare"]);
|
|
359
|
+
git(repo, ["bundle", "verify", copy]);
|
|
360
|
+
git(repo, ["fetch", "--no-tags", copy, "+refs/*:refs/*"]);
|
|
361
|
+
return repo;
|
|
362
|
+
}
|
|
363
|
+
function readManifest(vault: string, path: string): ImportManifest {
|
|
364
|
+
const m = JSON.parse(readFileSync(path, "utf8")) as ImportManifest;
|
|
365
|
+
if (m.format !== 1 || m.kind !== "final" || m.vault !== vault || m.run_id !== digestManifest(m) || !m.archive || !m.archive_hash || !m.backup) throw new Error("A matching final manifest is required");
|
|
366
|
+
if (canonicalJson(resolveHistoryPolicy(readVaultConfig(vault)?.history)) !== canonicalJson(m.policy)) throw new Error("History policy differs from final manifest");
|
|
367
|
+
const selections = m.selections == null ? null : normalizeHistorySelections(m.selections);
|
|
368
|
+
if ((m.selections_digest ?? null) !== selectionsDigest(selections) || m.options_digest !== optionsDigest(m.policy, m.waivers, selections)) throw new Error("Manifest selection/options digest mismatch");
|
|
369
|
+
return m;
|
|
370
|
+
}
|
|
371
|
+
function retirementReady(m: ImportManifest): void {
|
|
372
|
+
if (m.issues.length) throw new Error("Unidentified source issues block retirement; resolve the archive before prepare");
|
|
373
|
+
if ([...m.quarantined.map(q => q.note_id), ...m.missing_at_tip].some(id => !m.waivers[id])) throw new Error("Quarantined or uncovered IDs require explicit waivers before prepare");
|
|
374
|
+
}
|
|
375
|
+
/** Commands are exported to exercise the same offline/raw-DB path as the CLI. */
|
|
376
|
+
export async function runHistoryImport(args: string[]): Promise<unknown> {
|
|
377
|
+
const [command, ...rest] = args;
|
|
378
|
+
if (!command || !["plan", "prepare", "apply", "retire", "cancel"].includes(command)) throw new Error("history-import plan|prepare|apply|retire|cancel --vault NAME (see UPGRADING.md)");
|
|
379
|
+
const flags = new Map<string, string>();
|
|
380
|
+
for (let i = 0; i < rest.length; i += 2) {
|
|
381
|
+
const key = rest[i], value = rest[i + 1];
|
|
382
|
+
if (!key || !["--vault", "--source", "--through", "--output", "--archive", "--manifest", "--waivers", "--selections"].includes(key) || !value || flags.has(key)) throw new Error("Invalid history-import arguments");
|
|
383
|
+
flags.set(key, value);
|
|
384
|
+
}
|
|
385
|
+
if (flags.has("--selections") && command !== "plan" && command !== "prepare") throw new Error("--selections is only valid for plan/prepare; apply uses the embedded manifest");
|
|
386
|
+
const required = (key: string) => { const value = flags.get(key); if (!value) throw new Error(`Required ${key}`); return value; };
|
|
387
|
+
const vault = required("--vault");
|
|
388
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(vault) || !readVaultConfig(vault)) throw new Error("Unknown vault");
|
|
389
|
+
if (command !== "plan") await offline();
|
|
390
|
+
const dir = mkdtempSync(join(tmpdir(), "history-import-"));
|
|
391
|
+
const lock = join(vaultDir(vault), "history-import.lock");
|
|
392
|
+
let locked = false;
|
|
393
|
+
try {
|
|
394
|
+
if (command !== "plan") {
|
|
395
|
+
if (existsSync(lock)) throw new Error(`Importer lock exists at ${lock}; verify no importer is running before removing only this lock directory, then resume the same manifest`);
|
|
396
|
+
mkdirSync(lock); locked = true; atomic(join(lock, "pid"), String(process.pid));
|
|
397
|
+
}
|
|
398
|
+
if (command === "plan" || command === "prepare") {
|
|
399
|
+
const repo = realpathSync(required("--source")), tip = required("--through"), output = resolve(required("--output"));
|
|
400
|
+
if (existsSync(output)) throw new Error("Manifest output already exists");
|
|
401
|
+
const waivers = flags.has("--waivers") ? mapping(JSON.parse(readFileSync(required("--waivers"), "utf8"))) as Record<string, string> : {};
|
|
402
|
+
let selections: HistorySelections | null = null;
|
|
403
|
+
if (flags.has("--selections")) {
|
|
404
|
+
const path = required("--selections");
|
|
405
|
+
if (statSync(path).size > MAX_OUTPUT) throw new Error("Selections exceed 32 MiB");
|
|
406
|
+
selections = normalizeHistorySelections(JSON.parse(readFileSync(path, "utf8")));
|
|
407
|
+
}
|
|
408
|
+
if (command === "plan") {
|
|
409
|
+
const m = project(vault, repo, tip, dir, { waivers, selections }); atomic(output, JSON.stringify(m, null, 2)); return m;
|
|
410
|
+
}
|
|
411
|
+
if (readHistoryMirrorPhase(vault) !== "active" || existsSync(historyImportRecoveryPath(vault))) throw new Error("Existing cutover: resume or cancel using its recovery journal");
|
|
412
|
+
if (text(git(repo, ["rev-parse", "--is-bare-repository"])).trim() !== "true" && text(git(repo, ["status", "--porcelain=v1", "--untracked-files=all", "--ignore-submodules=all"])).length) throw new Error("Source working tree is dirty");
|
|
413
|
+
const archive = resolve(required("--archive"));
|
|
414
|
+
if (existsSync(archive)) throw new Error("Archive output already exists");
|
|
415
|
+
const backup = join(vaultDir(vault), `history-before-import-${crypto.randomUUID()}.db`);
|
|
416
|
+
copyDatabase(vaultDbPath(vault), backup);
|
|
417
|
+
const journal: Recovery = { prior_config: readOptional(mirrorConfigPath(vault)), prior_marker: readOptional(historyMirrorStatePath(vault)), backup, backup_hash: fileHash(backup), phase: "preparing" };
|
|
418
|
+
atomic(historyImportRecoveryPath(vault), JSON.stringify(journal));
|
|
419
|
+
atomic(historyMirrorStatePath(vault), JSON.stringify({ phase: "paused", source: repo, tip }));
|
|
420
|
+
const config = readMirrorConfigForVault(vault) ?? defaultMirrorConfig();
|
|
421
|
+
atomic(mirrorConfigPath(vault), serializeMirrorConfig({ ...config, enabled: false }).join("\n") + "\n");
|
|
422
|
+
mkdirSync(dirname(archive), { recursive: true });
|
|
423
|
+
git(repo, ["bundle", "create", archive, "--all"]); chmodSync(archive, 0o600);
|
|
424
|
+
const objects = privateBundle(archive, fileHash(archive), dir);
|
|
425
|
+
const m = project(vault, objects, tip, dir, { archive, backup, waivers, selections });
|
|
426
|
+
atomic(output, JSON.stringify(m, null, 2));
|
|
427
|
+
atomic(historyImportRecoveryPath(vault), JSON.stringify({ ...journal, phase: "prepared", run_id: m.run_id }));
|
|
428
|
+
atomic(historyMirrorStatePath(vault), JSON.stringify({ phase: "paused", run_id: m.run_id, source: repo, tip }));
|
|
429
|
+
return m;
|
|
430
|
+
}
|
|
431
|
+
const phase = readHistoryMirrorPhase(vault);
|
|
432
|
+
if (command !== "cancel" && phase !== "paused" && !(command === "retire" && phase === "retired")) throw new Error("Vault must be paused for this command");
|
|
433
|
+
const journal = JSON.parse(readFileSync(historyImportRecoveryPath(vault), "utf8")) as Recovery;
|
|
434
|
+
if (command === "cancel") {
|
|
435
|
+
const phase = readHistoryMirrorPhase(vault);
|
|
436
|
+
if (phase === "retired") throw new Error("Only an unfinished preparation can be cancelled");
|
|
437
|
+
const db = new Database(vaultDbPath(vault), { readonly: true });
|
|
438
|
+
try {
|
|
439
|
+
if (db.prepare("SELECT 1 FROM sqlite_master WHERE name='history_import_receipts'").get() && db.prepare("SELECT 1 FROM history_import_receipts LIMIT 1").get()) throw new Error("Receipts exist: resume the same run; cancellation is unavailable");
|
|
440
|
+
} finally { db.close(); }
|
|
441
|
+
if (journal.prior_config === null) {
|
|
442
|
+
if (existsSync(mirrorConfigPath(vault))) renameSync(mirrorConfigPath(vault), `${historyImportRecoveryPath(vault)}.cancelled-config-${crypto.randomUUID()}`);
|
|
443
|
+
} else atomic(mirrorConfigPath(vault), journal.prior_config);
|
|
444
|
+
atomic(historyMirrorStatePath(vault), journal.prior_marker ?? JSON.stringify({ phase: "active" }));
|
|
445
|
+
renameSync(historyImportRecoveryPath(vault), `${historyImportRecoveryPath(vault)}.cancelled-${crypto.randomUUID()}`);
|
|
446
|
+
return { cancelled: true };
|
|
447
|
+
}
|
|
448
|
+
const m = readManifest(vault, required("--manifest"));
|
|
449
|
+
const marker = JSON.parse(readFileSync(historyMirrorStatePath(vault), "utf8"));
|
|
450
|
+
if (marker.run_id !== m.run_id || journal.run_id !== m.run_id) throw new Error("Paused marker/run mismatch");
|
|
451
|
+
const repo = privateBundle(command === "apply" ? resolve(required("--archive")) : m.archive!, m.archive_hash!, dir);
|
|
452
|
+
retirementReady(m);
|
|
453
|
+
const db = new Database(vaultDbPath(vault));
|
|
454
|
+
try {
|
|
455
|
+
applyConnectionPragmas(db); initSchema(db);
|
|
456
|
+
const liveIds = (db.prepare("SELECT id FROM notes ORDER BY id").all() as { id: string }[]).map(r => r.id);
|
|
457
|
+
if (hashContent(canonicalJson(liveIds)) !== m.target_ids_digest) throw new Error(`Live note identities changed; reconcile against ${journal.backup}`);
|
|
458
|
+
const run: ImportRun = { run_id: m.run_id, tip: m.tip, options_digest: m.options_digest, source_fingerprint: m.source_fingerprint };
|
|
459
|
+
if (command === "apply") {
|
|
460
|
+
const stage = openStage(join(dir, "apply-stage.db"));
|
|
461
|
+
try {
|
|
462
|
+
const source = stageArchive(repo, m.tip, stage, m.selections);
|
|
463
|
+
if (source.source_fingerprint !== m.source_fingerprint) throw new Error("Bundle lineage differs from manifest");
|
|
464
|
+
beginImportRun(db, run);
|
|
465
|
+
for (const note of m.notes) {
|
|
466
|
+
const rows = rowsFor(stage, note.id);
|
|
467
|
+
if (importObservationDigest(rows) !== note.state_digest) throw new Error("Staged state differs from manifest");
|
|
468
|
+
try { applyImportedNote(db, { run, noteId: note.id, observations: rows, policy: m.policy, now: m.evaluated_at, targetDigest: note.target_digest, expectedNativeDrops: note.native_drops }); }
|
|
469
|
+
catch (error) { throw new Error(`${(error as Error).message}; resume the same manifest. Pre-import backup: ${journal.backup}`); }
|
|
470
|
+
}
|
|
471
|
+
if (m.notes.some(n => readImportReceipt(db, n.id)?.run_id !== m.run_id)) throw new Error("Incomplete receipt set");
|
|
472
|
+
db.prepare("UPDATE history_import_runs SET status='complete' WHERE run_id=?").run(m.run_id);
|
|
473
|
+
return { run_id: m.run_id, complete: true, notes: m.notes.length };
|
|
474
|
+
} finally { stage.close(); }
|
|
475
|
+
}
|
|
476
|
+
const status = db.prepare("SELECT status FROM history_import_runs WHERE run_id=?").get(m.run_id) as { status: string } | null;
|
|
477
|
+
if (status?.status !== "complete" || m.notes.some(n => readImportReceipt(db, n.id)?.run_id !== m.run_id)) throw new Error("Import is incomplete; resume apply");
|
|
478
|
+
atomic(historyMirrorStatePath(vault), JSON.stringify({ phase: "retired", run_id: m.run_id, archive_hash: m.archive_hash }));
|
|
479
|
+
return { retired: true, run_id: m.run_id };
|
|
480
|
+
} finally { db.close(); }
|
|
481
|
+
} finally { if (locked) rmSync(lock, { recursive: true }); rmSync(dir, { recursive: true, force: true }); }
|
|
482
|
+
}
|
package/src/mcp-http.ts
CHANGED
|
@@ -216,6 +216,8 @@ export async function handleMcp(
|
|
|
216
216
|
name?: string;
|
|
217
217
|
code?: string;
|
|
218
218
|
note_id?: string;
|
|
219
|
+
origin?: unknown;
|
|
220
|
+
import_ix?: unknown;
|
|
219
221
|
note_path?: string | null;
|
|
220
222
|
current_updated_at?: string | null;
|
|
221
223
|
expected_updated_at?: string;
|
|
@@ -398,6 +400,17 @@ export async function handleMcp(
|
|
|
398
400
|
cycle: e.cycle ?? [],
|
|
399
401
|
});
|
|
400
402
|
}
|
|
403
|
+
if (e?.code === "HISTORY_UNRECOVERABLE") {
|
|
404
|
+
const imported = e.origin === "git-import"
|
|
405
|
+
&& typeof e.import_ix === "number"
|
|
406
|
+
&& Number.isSafeInteger(e.import_ix) && e.import_ix >= 0;
|
|
407
|
+
throw mcpDomainError(ErrorCode.InvalidParams, message, {
|
|
408
|
+
error_type: "history_unrecoverable",
|
|
409
|
+
...(imported ? { origin: "git-import", import_ix: e.import_ix } : {
|
|
410
|
+
field: e.field, hint: e.hint,
|
|
411
|
+
}),
|
|
412
|
+
});
|
|
413
|
+
}
|
|
401
414
|
// Generic catch-all (vault#554): any remaining error that carries a
|
|
402
415
|
// stable `error_type` — either a domain error class that stamps it as
|
|
403
416
|
// an instance property (IndexedFieldError, and the classes handled by
|
package/src/mcp-tools.ts
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
* tools operate on that vault and vault-info picks up its config directly.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { generateMcpTools } from "../core/src/mcp.ts";
|
|
8
|
+
import { generateMcpTools, resolveNote } from "../core/src/mcp.ts";
|
|
9
9
|
import type { McpToolDef, GenerateMcpToolsOpts } from "../core/src/mcp.ts";
|
|
10
|
-
import { getNoteTags, getVaultMap } from "../core/src/notes.ts";
|
|
10
|
+
import { getNote, getNoteTags, getVaultMap } from "../core/src/notes.ts";
|
|
11
11
|
import { narrowLinkWarningsForVisibility } from "../core/src/wikilinks.ts";
|
|
12
12
|
import type { Note } from "../core/src/types.ts";
|
|
13
13
|
import {
|
|
@@ -541,6 +541,12 @@ function applyTagScopeWrappers(
|
|
|
541
541
|
|
|
542
542
|
wrapReadTool(tools, "query-notes", async (orig, params) => {
|
|
543
543
|
const allowed = await getAllowed();
|
|
544
|
+
// Check history visibility before materialisation can throw a content error.
|
|
545
|
+
const historyId = (params as any)?.versions?.note_id;
|
|
546
|
+
if (allowed && typeof historyId === "string") {
|
|
547
|
+
const note = resolveNote(store.db, historyId);
|
|
548
|
+
if (note && !noteWithinTagScope(note, allowed, rawTags)) return { error: "Note not found", error_type: "not_found", id: historyId };
|
|
549
|
+
}
|
|
544
550
|
const result = await orig(scopeQueryTagParams(params, allowed));
|
|
545
551
|
if (!allowed) return result;
|
|
546
552
|
// `aggregate` mode returns `[{group, value}]` rollup rows — no `.tags`
|
|
@@ -566,6 +572,16 @@ function applyTagScopeWrappers(
|
|
|
566
572
|
}
|
|
567
573
|
return result;
|
|
568
574
|
}
|
|
575
|
+
if (result && typeof result === "object" && (
|
|
576
|
+
("versions" in result && Array.isArray((result as any).versions) && "total" in result)
|
|
577
|
+
|| ("version_ix" in result && "content" in result) || ("origin" in result && result.origin === "git-import" && "import_ix" in result)
|
|
578
|
+
|| ("version_ix" in result && "error" in result)
|
|
579
|
+
)) {
|
|
580
|
+
const id = (params as any).versions.note_id;
|
|
581
|
+
const note = resolveNote(store.db, id);
|
|
582
|
+
return note && noteWithinTagScope(note, allowed, rawTags)
|
|
583
|
+
? result : { error: "Note not found", error_type: "not_found", id };
|
|
584
|
+
}
|
|
569
585
|
// Possible response shapes (vault#550 added the `warnings` variants):
|
|
570
586
|
// - Array (legacy list, no cursor, no warnings)
|
|
571
587
|
// - `{notes, next_cursor}` (cursor mode, vault#313)
|
package/src/mirror-config.ts
CHANGED
|
@@ -930,3 +930,41 @@ export async function validateExternalPath(
|
|
|
930
930
|
}
|
|
931
931
|
return { ok: true, resolved_path: externalPath };
|
|
932
932
|
}
|
|
933
|
+
|
|
934
|
+
export class VaultImportPausedError extends Error {
|
|
935
|
+
readonly error_type = "history_import_paused";
|
|
936
|
+
readonly status = 503;
|
|
937
|
+
constructor() { super("Vault history import is paused"); this.name = "VaultImportPausedError"; }
|
|
938
|
+
}
|
|
939
|
+
export class MirrorRetiredError extends Error {
|
|
940
|
+
readonly error_type = "mirror_retired";
|
|
941
|
+
readonly status = 409;
|
|
942
|
+
constructor() { super("This vault mirror is retired"); this.name = "MirrorRetiredError"; }
|
|
943
|
+
}
|
|
944
|
+
export function historyMirrorStatePath(vaultName: string): string {
|
|
945
|
+
return join(dirname(mirrorConfigPath(vaultName)), "history-mirror-state.json");
|
|
946
|
+
}
|
|
947
|
+
export function historyImportRecoveryPath(vaultName: string): string {
|
|
948
|
+
return join(dirname(mirrorConfigPath(vaultName)), "history-import-recovery.json");
|
|
949
|
+
}
|
|
950
|
+
export function readHistoryMirrorPhase(vaultName: string): "active" | "paused" | "retired" {
|
|
951
|
+
let raw: string;
|
|
952
|
+
try { raw = readFileSync(historyMirrorStatePath(vaultName), "utf8"); }
|
|
953
|
+
catch (err) {
|
|
954
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") return "active";
|
|
955
|
+
throw new VaultImportPausedError();
|
|
956
|
+
}
|
|
957
|
+
try {
|
|
958
|
+
const state = JSON.parse(raw);
|
|
959
|
+
if (state?.phase === "active" || state?.phase === "paused" || state?.phase === "retired") return state.phase;
|
|
960
|
+
} catch {}
|
|
961
|
+
throw new VaultImportPausedError();
|
|
962
|
+
}
|
|
963
|
+
export function assertVaultNotPaused(vaultName: string): void {
|
|
964
|
+
if (readHistoryMirrorPhase(vaultName) === "paused") throw new VaultImportPausedError();
|
|
965
|
+
}
|
|
966
|
+
export function assertMirrorActive(vaultName: string): void {
|
|
967
|
+
const phase = readHistoryMirrorPhase(vaultName);
|
|
968
|
+
if (phase === "paused") throw new VaultImportPausedError();
|
|
969
|
+
if (phase === "retired") throw new MirrorRetiredError();
|
|
970
|
+
}
|
package/src/mirror-manager.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertMirrorActive } from "./mirror-config.ts";
|
|
1
2
|
/**
|
|
2
3
|
* Mirror lifecycle manager — boot-time bootstrap + event-driven exports.
|
|
3
4
|
*
|
|
@@ -649,6 +650,7 @@ export class MirrorManager {
|
|
|
649
650
|
// a fn returning `null` to exercise the git-not-installed start path.
|
|
650
651
|
which?: (cmd: string) => string | null,
|
|
651
652
|
): Promise<MirrorStatus> {
|
|
653
|
+
assertMirrorActive(this.deps.vaultName);
|
|
652
654
|
this.startCount++;
|
|
653
655
|
await this.stop({ preserveStatus: true });
|
|
654
656
|
|
|
@@ -846,6 +848,7 @@ export class MirrorManager {
|
|
|
846
848
|
// Test seam forwarded to `start()` — see `start(which)`.
|
|
847
849
|
which?: (cmd: string) => string | null,
|
|
848
850
|
): Promise<MirrorStatus> {
|
|
851
|
+
assertMirrorActive(this.deps.vaultName);
|
|
849
852
|
this.deps.writeMirrorConfig(newConfig);
|
|
850
853
|
return this.start(which);
|
|
851
854
|
}
|
|
@@ -861,6 +864,7 @@ export class MirrorManager {
|
|
|
861
864
|
* effect anyway.
|
|
862
865
|
*/
|
|
863
866
|
async runNow(): Promise<MirrorStatus> {
|
|
867
|
+
assertMirrorActive(this.deps.vaultName);
|
|
864
868
|
if (!this.status.enabled) {
|
|
865
869
|
return this.getStatus();
|
|
866
870
|
}
|
|
@@ -1024,6 +1028,7 @@ export class MirrorManager {
|
|
|
1024
1028
|
* dispatcher (would kill the loop).
|
|
1025
1029
|
*/
|
|
1026
1030
|
private async runOneCycle(opts: { isInitial: boolean; prune: boolean }): Promise<void> {
|
|
1031
|
+
assertMirrorActive(this.deps.vaultName);
|
|
1027
1032
|
const nextCursor = new Date().toISOString();
|
|
1028
1033
|
const path = this.status.mirror_path!;
|
|
1029
1034
|
const sinceCursor = opts.isInitial ? undefined : this.cursor;
|
|
@@ -1162,6 +1167,7 @@ export class MirrorManager {
|
|
|
1162
1167
|
| { fired: false; reason: "not_enabled" | "no_mirror_path" }
|
|
1163
1168
|
| { fired: true; pushed: boolean; sha?: string; error?: string }
|
|
1164
1169
|
> {
|
|
1170
|
+
assertMirrorActive(this.deps.vaultName);
|
|
1165
1171
|
if (!this.status.enabled) return { fired: false, reason: "not_enabled" };
|
|
1166
1172
|
if (!this.status.mirror_path) return { fired: false, reason: "no_mirror_path" };
|
|
1167
1173
|
const path = this.status.mirror_path;
|