diff --git a/docs/adr/2026_08_replicator_capabilities_01_core_contract.md b/docs/adr/2026_08_replicator_capabilities_01_core_contract.md index 0d9662db..1c094f65 100644 --- a/docs/adr/2026_08_replicator_capabilities_01_core_contract.md +++ b/docs/adr/2026_08_replicator_capabilities_01_core_contract.md @@ -452,6 +452,12 @@ caller decides whether creation is permitted. Only explicit `not-found` may create Journal synchronisation parameters; an unavailable read cannot be converted to a missing value or a new Security Seed. +The same rule applies to central milestone mutation and verification. A +milestone mutation may merge an available document or initialise one after an +explicit `not-found` result. An unavailable read rejects before upload. A +postcondition reader reports that unavailability as a read failure with its +diagnostic detail; it does not report that the milestone is missing. + This principle does not require one generic `RemoteObservation` type or an active-read catalogue. Existing provider-specific inspection and maintenance methods may retain their current compatibility surface until their real @@ -486,10 +492,24 @@ outer Rebuilder flow which itself initiates a lifecycle transition. A switch which waited for either global count could therefore wait for the operation which is awaiting that same switch. -Production consumers cannot synchronously inspect an unreserved active -context. They must acquire it or run work inside the admitted callback boundary. -The synchronous `inspectActiveReplicatorContext()` view is protected and exists -only for lifecycle diagnostics and focused tests. +The active context atomically carries the provider, Replicator, and private +configuration identity. A settings-bearing operation captures one effective +settings snapshot, then reprojects and compares its identity inside admission +immediately before provider dispatch. A mismatch settles without combining a +new setting with an earlier Replicator. An explicit stop request acts on the +exact active owner and therefore does not require a settings comparison. + +New typed production work cannot synchronously inspect an unreserved active +context. It must acquire the context or run inside the admitted callback +boundary. The synchronous `inspectActiveReplicatorContext()` view is protected +and exists only for lifecycle diagnostics and focused tests. + +The public `getActiveReplicator()` remains temporarily for named compatibility +consumers and retains its established missing-active diagnostic. It is not a +typed ownership path. A separate side-effect-free `hasActiveReplicator()` +predicate may distinguish a compatibility Replicator whose provider was not +composed from complete absence. It returns neither the Replicator nor its +context, and cannot be used to dispatch work. The minimum consumer surface is a callback boundary, `runWithActiveReplicatorContext(callback)`, rather than an exposed lease or @@ -539,6 +559,21 @@ replication batch loop consume the same effective signal. An already-started atomic database operation may settle before cancellation completes, but no new batch is started afterwards. +A Journal connectivity preflight is not itself cancelled by this role. The +Replicator instead records a private Stop generation when that preflight begins +and checks it again before entering `sync()`, `sendLocalJournal()`, or +`receiveRemoteJournal()`. A Stop admitted while the preflight is pending can +therefore wait for the attempt to settle without allowing a new client transfer +to start afterwards. + +The bounded Continuous startup call and an explicit stop request are admitted +against their exact publication. Continuous admission ends when the provider +has registered ownership and settled startup; it is never retained for the +lifetime of the long-lived task. Directional transfer and central-remote +administration stop the admitted publication's active transfer before their +exclusive operation begins. An unavailable or failed stop prevents that +operation rather than allowing transfer and mutation to overlap. + `getNewReplicator()` is not a general temporary-instance API. CouchDB and Object Storage Setup and settings flows request narrow connection or preferred-tweak probes. A resource-returning factory returns an owned resource @@ -548,6 +583,13 @@ the stable service's connection-probe admission described in Part 2. Only its idle continuation constructs and disposes a short-lived raw signalling trial. Neither form can replace the active Replicator or the P2P service. +The CouchDB synchronisation-information resource resolves `false` only when it +observes incompatible synchronisation information. Connection, setup, and +verification failures reject so a settings caller can report operational +failure separately from incompatibility. Connection-probe result presentation +is likewise explicit: `showResult` retains the established CouchDB success or +failure Notice, while an ordinary silent probe emits neither result Notice. + Streaming Fetch receives an owned Security Seed resource bound to its settings snapshot. The current compatibility implementation may construct an unpublished Replicator internally, but the resource owns and disposes it and @@ -668,9 +710,10 @@ service make ownership explicit. ### Treat neutral compatibility results as supported operations -Dummy zero counts, empty Security Seeds, false values, and silent mutations -lose distinctions required for safety and recovery. A neutral value remains -only when every caller proves it to be the operation's identity. +Dummy zero counts, empty Security Seeds, false values which collapse an +operational failure into incompatibility or absence, and silent mutations lose +distinctions required for safety and recovery. A neutral value remains only +when every caller proves it to be the operation's identity. ## Consequences diff --git a/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md b/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md index 4f321d08..42e302b7 100644 --- a/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md +++ b/docs/adr/2026_08_replicator_capabilities_02_p2p_service_lifecycle.md @@ -149,6 +149,19 @@ Rebuilder workflow, not an AutoStart demand. After the replacement database is ready, that continuation may request a room independently of the AutoStart veto. It does not clear the veto for later automatic-start events. +Host lifecycle closure has a second, private, reversible state. The host sets +that state before it cancels delayed automation and closes the room. While it +is set, settings reconciliation and finite stable-view operations cannot add a +room demand. Only explicit connect, database-rebuild continuation, or the +AutoStart schedule established by a resumed host lifecycle clears it; merely +reconciling saved settings does not. + +This state does not replace or clear the explicit-disconnect veto. Explicit +connect clears both states, resumed AutoStart still observes the user's veto, +and Rebuild remains the separately authorised continuation described above. +The distinction is private service policy, not another public capability or +room owner. + ### Separate automation policy from room ownership P2P automation is a composed service feature. It owns `P2P_AutoStart`, @@ -194,20 +207,23 @@ releasing RPC and room resources. ### Reconcile session settings atomically -The effective P2P session binding is derived from the selected profile, all -settings which affect transport or session-bound automation, and the current -local database identity. It is not a new persisted profile identifier or a -device-local override. The service reconciles this binding independently of -the selected main provider, so an adjunct room can be replaced while CouchDB -or Object Storage remains active. +The effective P2P session binding is derived from the selected profile, the +settings which affect transport, the device identity, and the current local +database identity. It is not a new persisted profile identifier or a +device-local override. Automation and admission policy is reconciled on the +current room. The service reconciles the binding independently of the selected +main provider, so an adjunct room can be replaced while CouchDB or Object +Storage remains active. A change to any binding input retires the whole room session and opens a -replacement when policy still requires one. Replacing a policy-only setting -may cause a temporary disconnect, but it preserves one atomic listener and -policy boundary: advertisements and temporary peer decisions are reacquired, -while persisted peer decisions survive. AutoStart reconnects when it remains -enabled and no explicit-disconnect veto is active. No old listener, credential, -client, or policy demand remains reachable after replacement. +replacement when policy still requires one. A profile-selection or policy-only +change which preserves the effective binding keeps the room and reconciles its +current policy. A real replacement preserves one atomic listener and policy +boundary: advertisements and temporary peer decisions are reacquired, while +persisted peer decisions survive. AutoStart reconnects when it remains enabled, +host lifecycle closure has been resumed, and no explicit-disconnect veto is +active. No old listener, credential, client, or policy demand remains reachable +after replacement. The candidate captures its settings, device identity, and local database object when it is constructed. The owner re-reads the effective binding after the room @@ -280,15 +296,15 @@ not immediately repeat a completed baseline transfer. ### Define the P2P trigger matrix -| Trigger | Required preconditions | Effect | -| ----------------------- | --------------------------------------------------------------------- | -------------------------------------------- | -| `P2P_AutoStart` | P2P enabled, active lifecycle generation, and no user disconnect veto | Open room; do not itself transfer files | -| `P2P_AutoSyncPeers` | Open room, matching advertisement, and accepted peer policy | Run one bidirectional finite synchronisation | -| `P2P_AutoWatchPeers` | Open room, matching accepted advertisement, and remote broadcasting | Pull later announced updates | -| `P2P_AutoBroadcast` | Open room and local broadcasting enabled | Announce later local database changes | -| `P2P_SyncOnReplication` | Configured names and advertisements received within a bounded wait | Run target-aware unattended OneShot Sync | -| Explicit peer command | Supplied peer target and accepted connection | Run user-owned finite synchronisation | -| Incoming `reqSync` | Accepted peer and ordinary readiness | Pull from requesting peer | +| Trigger | Required preconditions | Effect | +| ----------------------- | ---------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| `P2P_AutoStart` | P2P enabled, resumed lifecycle, cleared host closure, and no user disconnect veto | Open room; do not itself transfer files | +| `P2P_AutoSyncPeers` | Open room, matching advertisement, and accepted peer policy | Run one bidirectional finite synchronisation | +| `P2P_AutoWatchPeers` | Open room, matching accepted advertisement, and remote broadcasting | Pull later announced updates | +| `P2P_AutoBroadcast` | Open room and local broadcasting enabled | Announce later local database changes | +| `P2P_SyncOnReplication` | Cleared host closure, no disconnect veto, configured names, and advertisements within a bounded wait | Run target-aware unattended OneShot Sync | +| Explicit peer command | Supplied peer target and accepted connection | Run user-owned finite synchronisation | +| Incoming `reqSync` | Accepted peer and ordinary readiness | Pull from requesting peer | `P2P_AutoStart` is a transport policy, not central Continuous replication and not `syncOnStart`. AutoSync, AutoWatch, and accepted incoming requests remain @@ -297,6 +313,8 @@ request waits for advertisement for a bounded period; it does not inspect a possibly stale snapshot immediately after opening. Missing, undiscovered, unaccepted, or partly successful targets are explicit operation results. An unknown peer never opens an acceptance dialogue on an unattended path. +An enabled P2P provider with an empty configured target set settles as +`blocked/no-targets`; it is not reported as an unconfigured provider. Delayed opens belong to the lifecycle generation which scheduled them. Suspension cancels them or makes them harmless, and the callback rechecks @@ -354,6 +372,8 @@ and does not publish a second P2P lifecycle owner. - Disposing an active adapter cannot close a policy-owned or adjunct room. - Explicit user disconnect has a clear veto boundary and cannot be undone by AutoStart or a finite operation. +- Host lifecycle closure cannot be undone by settings reconciliation or a + finite operation before an explicit resume boundary. - Finite operations can request a room without changing persistent room policy. - Settings and local database replacement cannot publish mixed-session state. - Reconnects do not repeat completed baseline transfers merely because the room diff --git a/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md b/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md index 1e3f6c9b..155aad6f 100644 --- a/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md +++ b/docs/adr/2026_08_replicator_capabilities_03_migration_plan.md @@ -285,8 +285,16 @@ consumer-migration proposal: runner; - every changed configuration identity replaces the active instance; there is no same-instance rebind policy; +- the atomic active context retains that private identity, and every + settings-bearing dispatch checks its captured settings against the admitted + publication immediately before calling provider code; - typed finite dispatch reserves the exact readiness-tested publication and releases it before failure recovery; +- the bounded Continuous startup call and explicit stop request re-admit their + exact publication, while a registered long-lived task remains outside a + lifetime reservation; +- directional transfer and central-remote administration stop active transfer + work inside the same admission before beginning their exclusive operation; - CouchDB and Journal carry only a rejected attempt-local compatibility decision into the failure outcome, so a transport failure cannot reuse old mutable fields; @@ -347,9 +355,10 @@ The first implementation regressions cover: close, while reversible suspension retains the publication. Keep readiness, failure presentation, dialogue, independently owned trial -resources, P2P room-session demand, and Continuous replication outside this -reservation. The callback TSDoc forbids awaiting a lifecycle transition which -would wait for the same admission to settle. +resources, P2P room-session demand, and the lifetime of Continuous replication +outside this reservation. Re-admit only its bounded startup call and the +bounded explicit stop request. The callback TSDoc forbids awaiting a lifecycle +transition which would wait for the same admission to settle. ## Stage 7: perform a bounded structural review @@ -377,13 +386,19 @@ The final structural review retains the following minimum boundaries: registration method remains as the construction-order composition boundary; - P2P registration remains with the feature which owns the stable P2P service; - `ReplicatorService` and `ReplicationService` remain cohesive services. Their - complex state and policy already reside in `ActiveReplicatorState`, - `TypedReplicationCoordinator`, the readiness evaluator, - `RemoteResourceResolver`, and `CentralRemoteAdministrationCoordinator`, so - another split would not improve the current test seams; + complex state and policy already reside in the focused + `ReplicatorService.activeReplicatorState`, + `ReplicationService.typedReplication`, `ReplicationService.readiness`, + `ReplicatorService.remoteResourceResolver`, and + `ReplicatorService.centralRemoteAdministration` collaborators, so another + split would not improve the current test seams; - every provider explicitly declares Continuous support or inapplicability; -- `IReplicatorService` exposes only acquired or admitted active-context access. - A protected synchronous inspector remains for focused lifecycle tests; +- new typed work uses only acquired or admitted active-context access. The + public legacy Replicator getter remains temporarily for named compatibility + consumers, while one side-effect-free presence predicate classifies a legacy + active instance without returning it or emitting its missing-active Notice. + A protected synchronous context inspector remains for focused lifecycle + tests; - central-compatibility statuses, recorders, and projection helpers remain package-internal. Only stable rejection reason codes and public recovery value types remain package-index exports; and @@ -438,6 +453,17 @@ to the focused collaborators listed above, so another physical service split would add indirection without removing a current responsibility or improving a current test seam. +One concrete compatibility risk remains deferred with that maintenance work. +Journal `tryResetRemoteDatabase()` and `tryCreateRemoteDatabase()` synchronously +close and replace their lazy client without first awaiting the transfer +settlement owned by `terminateSync()`. CouchDB awaits its corresponding close. +Maintained reset and rebuild workflows normally stop ordinary synchronisation +before destructive remote work, but the Journal compatibility methods neither +encode nor independently test that precondition. A later bounded change must +first reproduce the race, then choose workflow-owned suspension, admitted +maintenance, or same-instance transfer settlement. It must not add a generic +provider capability merely to retire the compatibility facade. + The review did identify five bounded behavioural corrections inside existing owners: @@ -470,6 +496,69 @@ These corrections close ownership and presentation gaps found by the review; they do not add another generic capability, remote resource, probe framework, or provider role. The target matrices in Part 1 therefore remain unchanged. +A subsequent falsification and quality pass found further bounded corrections +inside the same owners: + +- directional failures retain the immutable compatibility hint from their + exact admitted attempt, and recovery re-admits that failed context; +- settings-bearing dispatches correlate their snapshot with the active + configuration identity, while bounded Continuous startup and explicit stop + re-admit the exact publication; +- directional transfer and central-remote administration stop active transfer + work before their exclusive operation; +- readiness calls the application lifecycle method, rather than testing the + method object; +- cancelled or incomplete P2P pull and push outcomes are not reported as + success by the CLI or Obsidian UI, and ordinary UI transfer uses the stable + targeted-transfer view rather than the compatibility Replicator. The + retained compatibility entry opens that ordinary UI only; it does not + perform the transfer; +- Journal stop does not construct an unused client, and waits for the transfer + promises admitted at its stop boundary, including synchronous setup re-entry; + and +- remote-size inspection uses one settings snapshot and preserves an observed + zero rather than treating it as absence. + +These are lifecycle, settlement, and compatibility-consumer corrections. They +do not expand the capability or facility tables. + +A later contract reconciliation found four more bounded truthfulness gaps +inside the same existing owners: + +- Object Storage central milestone mutation and postcondition verification + preserve `available`, `not-found`, and `unavailable`; only explicit absence + can initialise a document, and unavailability cannot upload or become a + missing-milestone result; +- an enabled P2P provider with no configured targets preserves the Replicator's + `blocked/no-targets` result instead of reporting provider absence; +- typed active acquisition classifies expected absence through a non-owning, + side-effect-free presence predicate rather than the Notice-producing legacy + getter; and +- the CLI prints a stable explanation when central administration is rejected + because the active configuration changed before admission. + +These corrections require no new capability, resource family, state machine, +or presentation framework. + +A subsequent lifecycle and diagnostic falsification found four further regressions +at those established boundaries: + +- CouchDB synchronisation-information inspection now distinguishes an observed + incompatibility from connection, setup, or verification failure, so the + settings flow retains its separate existing messages; +- an explicitly visible CouchDB connection probe retains its established + success or failure Notice, while silent probe consumers remain silent; +- a private Journal Stop generation prevents a connectivity preflight which + crossed a later Stop boundary from starting a client transfer; and +- host P2P lifecycle closure establishes a private reversible gate which + settings reconciliation and finite views cannot clear. Explicit connect, + database-rebuild continuation, and resumed AutoStart scheduling remain the + declared reopen boundaries. + +These corrections refine existing resource, transfer-stop, and P2P lifecycle +semantics. They add no provider capability, resource kind, public state, or +presentation framework, so the Part 1 matrices remain unchanged. + ## Verification ### Commonlib unit and type-contract tests @@ -486,25 +575,39 @@ Cover: rejection of new work during retirement, acquisition waiting, and late candidate settlement; - unchanged-identity retention, changed-identity replacement, idempotent - reservation release, and close ordering; + reservation release, settings-bearing dispatch correlation, and close + ordering; - probes which cannot replace the active Replicator or P2P service, including P2P Setup observation and blocking without trial construction, and idle admission which awaits complete disposal of the caller-owned trial; -- the deliberately narrow active-transfer stop request, including work it - does not claim to cancel; +- the deliberately narrow active-transfer stop request, including exact + admission, bounded Continuous startup, exclusive-operation ordering, and work + it does not claim to cancel; - CouchDB compatibility and transfer using the same owned OneShot connection; - Journal compatibility and transfer using one settings-bound borrowed client; - attempt-local accepted, rejected, and not-assessed decisions, including retry and Continuous reassessment on newly opened CouchDB connections; - Journal synchronisation-parameter reads distinguishing explicit absence from unavailability and never writing after the latter; +- Journal central milestone mutation rejecting unavailable reads without an + upload, and Object Storage postcondition verification preserving the same + read failure and diagnostic detail; +- Journal stop avoiding lazy resource construction, settling the transfer + promises admitted at its stop boundary, and preventing a deferred + connectivity preflight from entering a client transfer after Stop; - cohesive central administration and its truthful mutation settlement; - the narrow non-owning P2P active adapter, including download without a synthetic upload or central facility; - local-database retirement sharing the active owner boundary across reset and - close paths; and + close paths; - caller-authority preservation for unattended P2P presentation and truthful - Journal and active-lifecycle closure diagnostics. + Journal and active-lifecycle closure diagnostics; +- remote-size inspection retaining one settings snapshot and reporting a zero + estimate as an observation; +- P2P configured-target execution preserving `blocked/no-targets`; and +- P2P host lifecycle closure blocking settings reconciliation and finite room + demand until explicit connect, rebuild continuation, or resumed AutoStart; +- expected typed absence producing no legacy missing-active Notice. ### Self-hosted LiveSync unit tests @@ -522,6 +625,8 @@ Cover: - periodic, database-save, editor-save, file-open, merge, and daemon triggers remaining free of dialogues; - manual P2P and configured peer-targeted flows remaining available; +- ordinary Obsidian P2P transfer using the stable targeted-transfer view, with + cancelled or incomplete pull and push outcomes remaining non-successful; - P2P AutoStart cancellation across suspension, bounded advertisement waiting, accepted peers without unattended dialogues, remote-broadcast prerequisites, session-demand reference counts, and overlapping peer policies; @@ -532,14 +637,17 @@ Cover: - counterpart RPC authorisation and broadcast progress preserving no-dialogue authority; - Setup and settings validation through owned resources or owner-arbitrated - P2P admission; + P2P admission, including distinct CouchDB incompatibility and operational + failure messages, explicit visible probe Notices, and silent ordinary + probes; +- readiness invoking the application lifecycle predicate; - exact failed-context mismatch, unlock, and cleaned-remote recovery, including rejection after active replacement; - CLI lock diagnostics from the exact finite outcome rather than mutable fields on a later active Replicator; - CLI process exit codes for successful administration, returned verification failure with and without `--compat-remote-admin-exit-zero`, and thrown - mutation failure; + mutation failure, including a diagnostic for active-configuration mismatch; - first-device and additional-device initialisation for each current provider; - P2P as the main remote and as an adjunct transport; and - CLI, WebApp, and WebPeer composition against the same contracts, including @@ -556,9 +664,45 @@ a watched peer whose remote side broadcasts, and suspension before a delayed AutoStart callback. A focused P2P Setup check must also cover an active room with the same relay set, an attempted additional relay which is blocked without closing that room, and an idle trial which releases its short-lived resources. -Object Storage validation must also confirm that a temporarily unavailable +Deterministic verification must also confirm that a temporarily unavailable synchronisation-parameter read does not upload a new Security Seed. +The verification report must identify which boundary each real-runtime +scenario deliberately exercised. A broad passing suite is not direct evidence +for Object Storage `syncOnStart`, an injected unavailable read, P2P automatic +scheduling, or active-relay Setup arbitration unless that scenario caused the +boundary. Deterministic unavailable-read fault injection may remain at the +owned contract-test seam when no reliable real-runtime injection exists; the +remaining runtime limitation must then be stated rather than represented as a +passing end-to-end scenario. + +The maintained Object Storage Setup URI workflow now exercises the migrated +start-up combination deliberately. It persists `liveSync: true` and +`syncOnStart: true` on the first device, keeps Periodic replication disabled, +stops that device before the second device writes the return note, and then +restarts it. The workflow waits for that note without requesting manual +replication. Object Storage declares Continuous not applicable, so a successful +return journey directly exercises the unattended OneShot fallback owned by +start-up scheduling. A settings-save reconciliation before the first device +stops cannot satisfy the assertion because the return note does not yet exist. + +The maintained real-Obsidian P2P Setup URI workflow directly exercises Setup +URI application, initial Fetch, peer approval and actions, explicit disconnect +and reconnect, and bidirectional note transfer. It does not deliberately +exercise accepted configured-peer advertising, watched-peer broadcast, +suspension before a delayed AutoStart callback, or active-room relay +arbitration during P2P Setup. Those exact boundaries retain deterministic unit +or contract evidence and are not represented as direct real-runtime proof. + +The maintained MinIO harness has no existing seam which can make exactly one +synchronisation-parameter or milestone read unavailable. Stopping MinIO or +using invalid credentials fails the preceding bucket-availability check, +rather than the owned control-document read. The issue 1147 unavailable-read +case therefore remains deterministic at the storage adapter, Journal core, +and Replicator contract-test seams, where the assertions preserve +`unavailable` and forbid creation or upload. It is not represented as a +successful real-Obsidian fault-injection scenario. + No temporary stage is a release candidate. Release readiness requires the target capability matrix, the contracted core and in-scope consumer migration, focused downstream checks with the exact packed Commonlib artefact, and the diff --git a/test/e2e-obsidian/README.md b/test/e2e-obsidian/README.md index 9f9ca382..1b8f74f7 100644 --- a/test/e2e-obsidian/README.md +++ b/test/e2e-obsidian/README.md @@ -158,7 +158,7 @@ LIVESYNC_CLI_COMMAND="docker run --rm --network host --user $(id -u):$(id -g) -- `test:e2e:obsidian:minio-upload` reuses the Object Storage variables from `.test.env` or the process environment. It expects a reachable S3-compatible service and starts with isolated Object Storage settings and the device-local compatibility acknowledgement already in place, keeping the scenario focused on upload rather than unconfigured start-up or setup. It confirms those settings through `obsidian-cli eval`, creates a note in real Obsidian, runs one-shot Journal Sync, and verifies through the AWS SDK that objects were written under a unique bucket prefix. Adapter tests separately observe an in-progress SDK command, while this real-runtime workflow verifies the resulting request counters advance and rebalance. -`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies A-to-B and B-to-A notes, captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. +`test:e2e:obsidian:object-storage-setup-uri-workflow` uses the public Commonlib-backed tool to generate the initial Setup URI for a unique MinIO prefix, completes visible initialisation on the first device, and then asks that working real Obsidian device to create a new Setup URI through the registered command. A second real Obsidian device imports only the device-generated URI. The workflow verifies the A-to-B note through explicit replication, then verifies that the B-to-A note arrives through `syncOnStart` after restarting the first device, without requesting manual replication. It captures the documented onboarding choices, and removes the Object Storage prefix only after both sessions have stopped. `test:e2e:obsidian:p2p-setup-uri-workflow` runs two concurrent isolated real Obsidian sessions against the local Compose Nostr relay fixture. The first device imports a generated initial Setup URI and completes its signalling test with zero peers, creates a Setup URI for the second device through the registered command, and remains online while the second device imports it. The second device must select the expected online source before Fetch can rebuild its local database. The workflow accepts each connection request visibly on the receiving device, verifies the initial A-to-B fetch, checks that the menu for the three persistent per-peer actions remains within the viewport, reconnects both P2P sessions in join order, and verifies the B-to-A return journey. Every started session remains tracked until teardown completes. diff --git a/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts b/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts index 40d1b63c..87fd70fd 100644 --- a/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts +++ b/test/e2e-obsidian/scripts/object-storage-setup-uri-workflow.ts @@ -8,6 +8,7 @@ import { discoverObsidianCli, requireObsidianBinary } from "../runner/environmen import { assertEqual, pushLocalChanges, + type ConfiguredSettings, waitForLiveSyncCoreReady, waitForLocalDatabaseEntry, } from "../runner/liveSyncWorkflow.ts"; @@ -57,6 +58,10 @@ type RunnerContext = { activeSessions: Set; }; +type StartupSchedulingState = ConfiguredSettings & { + periodicReplication: boolean; +}; + function sessionEnvironment(port: number): NodeJS.ProcessEnv { return { ...process.env, E2E_OBSIDIAN_REMOTE_DEBUGGING_PORT: String(port) }; } @@ -187,6 +192,41 @@ async function waitForObjectStorageData(config: ObjectStorageConfig, prefix: str throw new Error(`Timed out waiting for Object Storage data under ${prefix}.`); } +async function configureMigratedStartupScheduling( + cliBinary: string, + environment: NodeJS.ProcessEnv +): Promise { + return await evalObsidianJson( + cliBinary, + [ + "(async()=>{", + "const plugin=app.plugins.plugins['obsidian-livesync'];", + "const core=plugin.core;", + // Persist only the migration-shaped scheduling flags and leave the + // generated URI unchanged. Device A stops before B creates the + // return note, so save-triggered reconciliation cannot satisfy the + // later start-up assertion. + "await core.services.setting.applyExternalSettings({liveSync:true,syncOnStart:true,periodicReplication:false},true);", + "const current=core.services.setting.currentSettings();", + "return JSON.stringify({", + "isConfigured:current.isConfigured,", + "liveSync:current.liveSync,", + "syncOnStart:current.syncOnStart,", + "syncOnSave:current.syncOnSave,", + "periodicReplication:current.periodicReplication,", + "remoteType:current.remoteType,", + "couchDB_URI:current.couchDB_URI,", + "couchDB_DBNAME:current.couchDB_DBNAME,", + "endpoint:current.endpoint,", + "bucket:current.bucket,", + "bucketPrefix:current.bucketPrefix,", + "});", + "})()", + ].join(""), + environment + ); +} + async function captureNote(port: number, path: string, text: string, filename: string): Promise { await withObsidianPage(port, async (page) => { await page.evaluate((notePath) => { @@ -254,6 +294,24 @@ async function main(): Promise { throw new Error("The first device returned the bootstrap Setup URI instead of generating a new one."); } screenshots.push(...generated.screenshots); + const startupState = await configureMigratedStartupScheduling(context.cliBinary, sessionA.cliEnv); + assertEqual(startupState.liveSync, true, "The first device did not persist its Continuous setting."); + assertEqual(startupState.syncOnStart, true, "The first device did not persist syncOnStart."); + assertEqual( + startupState.periodicReplication, + false, + "Periodic replication could mask the syncOnStart return journey." + ); + assertEqual( + startupState.endpoint, + objectStorage.endpoint, + "Enabling syncOnStart changed the Object Storage endpoint." + ); + assertEqual( + startupState.bucketPrefix, + bucketPrefix, + "Enabling syncOnStart changed the Object Storage bucket prefix." + ); await stopSession(context, sessionA); const sessionB = await startSession(context, vaultB, portB); @@ -290,7 +348,9 @@ async function main(): Promise { const returningSessionA = await startSession(context, vaultA, portA); await waitForLiveSyncCoreReady(context.cliBinary, returningSessionA.cliEnv); await resumeCompatibilityReviewIfShown(portA); - await pushLocalChanges(context.cliBinary, returningSessionA.cliEnv); + // Deliberately omit manual replication here. Object Storage reports + // Continuous as not applicable, so startup scheduling must honour the + // retained syncOnStart setting by running an unattended OneShot. await waitForPathContent(vaultA, noteFromSecond, secondContent); screenshots.push( await captureNote(