Add explicit stopped-Journal recovery to Obsidian and CLI

Offer a confirmation in Maintenance and the journal-skip command to skip
only the current stopped Journal and resume receipt. Keep Commonlib notices
visible on stderr during ordinary CLI use.

Document the missing-content risk and replay procedure, and cover
cancellation, stale failures, subsequent failures, and receipt with unit
and plain and encrypted CLI and Obsidian end-to-end tests.

Requires the Commonlib stopped-Journal recovery API. Adopt its exact
published package version before integrating this consumer change.
This commit is contained in:
vorotamoroz committed 2026-11-04 15:25:30 +00:00
1 parent bf2241e5ee
commit a94c3fef90
18 files changed
+1321 -234

No files matched your search

+60 -4
View File
@@ -28,6 +28,7 @@ import {
USER_INITIATED_REPLICATION_AUTHORITY,
} from "@vrtmrz/livesync-commonlib/replication";
import { withOwnedRemoteResource } from "@/common/ownedRemoteResource";
import { LiveSyncJournalReplicator } from "@vrtmrz/livesync-commonlib/compat/replication/journal/LiveSyncJournalReplicator";
import {
isCentralRemoteAdministrationCommand,
runCentralRemoteAdministrationCommand,
@@ -45,10 +46,7 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
writeStderrLine(standardIo, `[Error] ${forceRequirementError}`);
return false;
}
if (
options.command === "fetch" &&
isRemediationModeActive(core.services.setting.currentSettings())
) {
if (options.command === "fetch" && isRemediationModeActive(core.services.setting.currentSettings())) {
writeStderrLine(
standardIo,
`[Error] The fetch command cannot run while remediation mode is active. Clear the modification-time limit before fetching.`
@@ -202,6 +200,64 @@ export async function runCommand(options: CLIOptions, context: CLICommandContext
return isReplicationCompleted(result);
}
if (options.command === "journal-skip") {
const [journalKey] = options.commandArgs;
if (options.commandArgs.length !== 1 || journalKey === undefined || journalKey.length === 0) {
writeStderrLine(standardIo, `[Error] journal-skip requires one argument: <journal-key>`);
return false;
}
writeStdoutLine(standardIo, `[Command] journal-skip ${journalKey}`);
const replicatorBeforeReplication = core.services.replicator.getActiveReplicator();
const result = await core.services.replication.replicateUserInitiated({
trigger: "manual",
progressPresentation: REPLICATION_PROGRESS_PRESENTATIONS.NOTICE,
interaction: USER_INITIATED_REPLICATION_AUTHORITY,
});
const activeReplicator = core.services.replicator.getActiveReplicator();
if (activeReplicator !== replicatorBeforeReplication) {
writeStderrLine(
standardIo,
`[Error] The active replicator changed during replication; no Journal was skipped.`
);
return false;
}
if (isReplicationCompleted(result)) {
writeStderrLine(
standardIo,
`[Error] Replication completed without a stopped Journal; no Journal was skipped.`
);
return false;
}
if (!(activeReplicator instanceof LiveSyncJournalReplicator)) {
writeStderrLine(
standardIo,
`[Error] The active replicator is not a Journal replicator; no Journal was skipped.`
);
return false;
}
const stoppedJournal = activeReplicator.getStoppedJournal();
if (!stoppedJournal) {
writeStderrLine(standardIo, `[Error] Replication did not stop at a Journal; no Journal was skipped.`);
return false;
}
if (stoppedJournal.key !== journalKey) {
writeStderrLine(
standardIo,
`[Error] Replication stopped at Journal '${stoppedJournal.key}' (${stoppedJournal.stage}), not '${journalKey}'; no Journal was skipped.`
);
return false;
}
return await activeReplicator.skipStoppedJournalAndResume(
core.services.setting.currentSettings(),
stoppedJournal,
true
);
}
if (options.command === "p2p-peers") {
if (options.commandArgs.length < 1) {
throw new Error("p2p-peers requires one argument: <timeout>");