Serialise startup compatibility dialogues

This commit is contained in:
vorotamoroz
2026-07-21 03:39:12 +00:00
parent 462b769e34
commit f195b9be0c
7 changed files with 64 additions and 1 deletions
@@ -69,6 +69,7 @@ An existing settings document without an explicit `handleFilenameCaseSensitive`
- Evaluate and persist the compatibility gate after settings load, before Obsidian layout-ready recovery begins. This blocks ordinary and one-shot replication even while the review dialogue has not yet opened.
- Preserve the existing ordered flag-file recovery handlers: SCRAM at priority 5, fetch-all at priority 10, and rebuild-all at priority 20. These files express an explicit recovery instruction and may invoke their focused storage or rebuild service while ordinary replication remains gated.
- Present the compatibility review at priority 30, after any selected recovery operation. A recovery handler which cancels start-up, keeps SCRAM active, or schedules a restart returns `false`, so the current process does not open a competing compatibility dialogue. If recovery completes and start-up continues, the dialogue opens before normal synchronisation is allowed to resume.
- Keep database preparation independent of an unanswered compatibility dialogue, because the compatibility gate already blocks replication. Before Config Doctor begins its interactive checks, await the active initial review so that the two update dialogues cannot overlap.
- Never mark compatibility as acknowledged merely because fetch, rebuild, or local database reset completed. The person must still use the explicit resume action. This keeps destructive recovery intent separate from protocol and settings compatibility acknowledgement.
## Consequences
+3 -1
View File
@@ -143,6 +143,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
setNoticeClass(Notice);
const serviceHub = new ObsidianServiceHub(this);
let waitForCompatibilityReview = (): Promise<void> => Promise.resolve();
this.core = new LiveSyncBaseCore(
serviceHub,
@@ -161,7 +162,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
new ModuleObsidianGlobalHistory(this, core),
// new ModuleDev(this, core),
new SetupManager(core), // this should be moved to core?
new ModuleMigration(core),
new ModuleMigration(core, () => waitForCompatibilityReview()),
];
return extraModules;
},
@@ -199,6 +200,7 @@ export default class ObsidianLiveSyncPlugin extends Plugin {
core,
createObsidianCompatibilityReviewUi(core.confirm)
);
waitForCompatibilityReview = () => compatibilityReview.openReview();
useReviewHarness(core, this, replicator, compatibilityReview);
// p2pReplicatorResult = useP2PReplicator(core, [
// VIEW_TYPE_P2P,
+8
View File
@@ -44,6 +44,13 @@ type ErrorInfo = {
const INCOMPLETE_DOCUMENT_NOTICE_GROUP = "startup-integrity-check";
export class ModuleMigration extends AbstractModule<LiveSyncCore> {
constructor(
core: LiveSyncCore,
private readonly waitForCompatibilityReview: () => Promise<void> = () => Promise.resolve()
) {
super(core);
}
async migrateUsingDoctor(skipRebuild: boolean = false, activateReason = "updated", forceRescan = false) {
const { shouldRebuild, shouldRebuildLocal, isModified, settings } = await performDoctorConsultation(
{
@@ -354,6 +361,7 @@ export class ModuleMigration extends AbstractModule<LiveSyncCore> {
reportDatabaseNotReady: () => this._log($msg("moduleMigration.logLocalDatabaseNotReady"), LOG_LEVEL_NOTICE),
hasCompromisedChunks: () => this.hasCompromisedChunks(),
hasIncompleteDocuments: () => this.hasIncompleteDocs(),
waitForCompatibilityReview: () => this.waitForCompatibilityReview(),
runDoctor: () => this.migrateUsingDoctor(false),
migrateBulkSend: () => this.migrateDisableBulkSend(),
});
@@ -3,6 +3,7 @@ export interface ConfiguredStartupLifecycleRuntime {
reportDatabaseNotReady(): void;
hasCompromisedChunks(): Promise<boolean>;
hasIncompleteDocuments(): Promise<boolean>;
waitForCompatibilityReview(): Promise<void>;
runDoctor(): Promise<boolean>;
migrateBulkSend(): Promise<void>;
}
@@ -33,6 +34,7 @@ export async function runConfiguredStartupLifecycle(runtime: ConfiguredStartupLi
}
if (!(await runtime.hasCompromisedChunks())) return false;
if (!(await runtime.hasIncompleteDocuments())) return false;
await runtime.waitForCompatibilityReview();
if (!(await runtime.runDoctor())) return false;
await runtime.migrateBulkSend();
return true;
@@ -19,6 +19,7 @@ function createRuntime(): ConfiguredStartupLifecycleRuntime & { events: string[]
events.push("incomplete-documents");
return true;
}),
waitForCompatibilityReview: vi.fn(async () => {}),
runDoctor: vi.fn(async () => {
events.push("doctor");
return true;
@@ -38,6 +39,25 @@ describe("runConfiguredStartupLifecycle", () => {
expect(runtime.events).toEqual(["compromised-chunks", "incomplete-documents", "doctor", "bulk-send"]);
});
it("keeps Config Doctor behind the initial compatibility review", async () => {
const runtime = createRuntime();
Object.assign(runtime, {
waitForCompatibilityReview: vi.fn(async () => {
runtime.events.push("compatibility-review");
}),
});
await expect(runConfiguredStartupLifecycle(runtime)).resolves.toBe(true);
expect(runtime.events).toEqual([
"compromised-chunks",
"incomplete-documents",
"compatibility-review",
"doctor",
"bulk-send",
]);
});
it("stops before onboarding or checks when the database is unavailable", async () => {
const runtime = createRuntime();
runtime.databaseReady = false;
+29
View File
@@ -82,6 +82,12 @@ async function verifyCompatibilityReview(): Promise<void> {
});
await summary.waitFor({ state: "visible", timeout: uiTimeoutMs });
await assertMobileDialogueLayout(page, summary, "compatibility review summary");
const doctor = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
});
if (await doctor.isVisible()) {
throw new Error("Config Doctor must wait until the initial compatibility review has closed.");
}
}
);
@@ -166,6 +172,28 @@ async function verifyCompatibilityReview(): Promise<void> {
);
}
async function verifyConfigDoctorFollowsCompatibilityReview(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
const doctor = page.locator(".modal-container").filter({
has: page.locator(".modal-title").filter({ hasText: "Self-hosted LiveSync Config Doctor" }),
});
await doctor.waitFor({ state: "visible", timeout: uiTimeoutMs });
await doctor.getByText("Per-file-saved customization sync", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
await doctor.getByText("Enhance chunk size", { exact: true }).waitFor({
state: "visible",
timeout: uiTimeoutMs,
});
if ((await doctor.getByText("Data Compression", { exact: true }).count()) !== 0) {
throw new Error("Config Doctor still treats supported Data Compression as a problem.");
}
await doctor.getByRole("button", { name: /No, and do not ask again/u }).click();
await doctor.waitFor({ state: "hidden", timeout: uiTimeoutMs });
});
}
async function verifyEffectiveSettings(): Promise<void> {
await withObsidianPage(obsidianRemoteDebuggingPort(), async (page) => {
await page.evaluate(() => {
@@ -237,6 +265,7 @@ async function main(): Promise<void> {
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await verifyCompatibilityReview();
await verifyConfigDoctorFollowsCompatibilityReview();
await verifyEffectiveSettings();
console.log("Compatibility review and settings expose only effective user controls.");
} finally {
+1
View File
@@ -17,6 +17,7 @@ Earlier releases remain available in the [0.25 release history](docs/releases/0.
- Clarified the 1.0 maturity of optional features. P2P and Hidden File Sync are supported opt-in features; JWT, ignore files, automatic newer-file conflict resolution, and Garbage Collection V3 remain previews; and legacy database-format settings remain compatibility paths rather than recommendations.
- Re-evaluated Data Compression. It provides a modest but measurable reduction in stored and transferred chunk data, with workload-dependent benefits and costs. See the [Data Compression specification](docs/specs_data_compression.md) for its behaviour, measurements, compatibility, and 1.0 default-setting decision.
- Classified Customisation Sync as a supported advanced workflow, backed by its maintained two-Vault real-Obsidian regression, without changing its synchronisation behaviour.
- Serialised the initial compatibility review and Config Doctor so that their update dialogues no longer overlap. Database readiness remains available while Config Doctor waits for the compatibility decision.
- Wizard-driven new-device and existing-device setup now reserves Rebuild or Fetch before enabling imported settings, preventing ordinary start-up work from running ahead of the selected initialisation.
- Manual onboarding now creates and selects CouchDB, Object Storage, and P2P remote profiles directly while preserving existing profiles. Current Setup URIs retain profile names and selections, while older flat settings remain supported through compatibility migration.
- P2P panes and explicit rebuild actions now use the current transport after settings or database replacement. Disconnecting leaves the LiveSync room and closes signalling relay sockets without destroying Trystero-owned shared peers.