Skip to content

Commit c816de6

Browse files
committed
plugin: auto-detect R2 capability changes and document snapshot restore follow-up
1 parent 8cf7244 commit c816de6

4 files changed

Lines changed: 153 additions & 3 deletions

File tree

engineering/mobile-qa-checklist.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ This runbook is split into two runs:
1717
- rapid-fire markdown append burst coalesced to a single ingest/apply event
1818
in measured trace
1919
- OOB edit path remained stable (no tug-of-war, clean integrity diffs)
20+
- snapshot attachment restore passed across two desktop vaults after R2
21+
auto-enable
2022

2123
## Final holy QA completion (March 10, 2026)
2224

@@ -29,6 +31,8 @@ Completed and validated:
2931
5. Filesystem bridge controls
3032
6. Checkpoint/journal truncation fallback
3133
7. Migration drill + mixed-version kill switch
34+
8. Snapshot creation, markdown restore, and attachment restore after R2
35+
capability activation
3236

3337
Final diagnostics outcome for latest desktop run:
3438

@@ -276,6 +280,15 @@ Pass criteria:
276280
- selected attachment refs restore and download.
277281
- no legacy path corruption after restore.
278282

283+
Completion note:
284+
285+
- validated on March 10, 2026 in two-desktop pass:
286+
- primary vault auto-detected R2 after redeploy while already open
287+
- secondary vault auto-detected R2 on startup after redeploy
288+
- manual snapshot created successfully
289+
- restore executed on secondary vault with pre-restore backups
290+
- final desktop vault trees matched after restore
291+
279292
### 10. Long-offline anti-resurrection
280293

281294
1. Keep Device A offline.
@@ -328,3 +341,9 @@ Final diagnostics export on both devices.
328341
- Fail if post-v2 snapshot restore fails.
329342
- Fail if mixed-version guard accepts incompatible client.
330343
- Fail if storage-pressure failure is silent or unclassified.
344+
345+
## Remaining optional proof after release gate
346+
347+
- cold recovery-kit validation on a brand-new third vault remains optional
348+
rather than blocking, because hydration + restore convergence already passed
349+
across paired vaults

engineering/qa-history.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ Final validated outcomes:
2020
- attachment sync under stress (including retries and queue durability)
2121
- rapid file switching/editor binding self-heal behavior
2222
- checkpoint/journal truncation fallback convergence
23-
- snapshot and anti-resurrection defenses
23+
- snapshot creation, markdown restore, and anti-resurrection defenses
2424
- Focused follow-up pass passed:
2525
- rapid-fire filesystem append burst now coalesces to a single ingest/apply
2626
event in the measured run
2727
- out-of-band (OOB) edit behavior remains stable with no tug-of-war or
2828
integrity drift
29+
- snapshot attachment restore now passes empirically across two desktop vaults
30+
with post-restore convergence and pre-restore backups
2931

3032
Final diagnostics in latest runs show:
3133

@@ -171,6 +173,42 @@ Status:
171173
`upload: "bay.jpg" too large (11118966 bytes), skipping`
172174
- default max attachment size is 10 MB (10240 KB), and `bay.jpg` exceeds it.
173175

176+
## Snapshot and recovery follow-up pass (March 10, 2026)
177+
178+
Two-vault desktop pass using `attachment-test` and `attachment-test copy`
179+
validated the remaining snapshot/recovery surface:
180+
181+
- R2 capability auto-enable passed in both lifecycle cases:
182+
- primary vault was already open when the worker gained `YAOS_BUCKET`
183+
- secondary vault was opened only after redeploy
184+
- attachment engine auto-started without manual refresh or manual toggle
185+
- initial attachment upload/download converged to `blobPathCount=4` on both
186+
devices with empty queues
187+
- daily snapshot path fired automatically once R2 became available
188+
- manual snapshot creation succeeded
189+
- snapshot restore succeeded on the secondary vault with pre-restore backups
190+
- attachment restore from snapshot was manually verified and final vault trees
191+
converged byte-for-byte across both desktops
192+
193+
Evidence from exported diagnostics/traces:
194+
195+
- primary vault:
196+
- `/home/kavin/attachment-test/.obsidian/plugins/yaos/diagnostics/sync-diagnostics-2026-03-10T14-23-06-853Z-device-mmkp74h1.json`
197+
- `/home/kavin/attachment-test/.obsidian/plugins/yaos/diagnostics/sync-diagnostics-2026-03-10T14-29-38-322Z-device-mmkp74h1.json`
198+
- `/home/kavin/attachment-test/.obsidian/plugins/yaos/diagnostics/sync-diagnostics-2026-03-10T14-32-32-221Z-device-mmkp74h1.json`
199+
- secondary vault:
200+
- `/home/kavin/attachment-test copy/.obsidian/plugins/yaos/diagnostics/sync-diagnostics-2026-03-10T14-30-29-710Z-device-second.json`
201+
- `/home/kavin/attachment-test copy/.obsidian/plugins/yaos/diagnostics/sync-diagnostics-2026-03-10T14-32-38-698Z-device-second.json`
202+
- `/home/kavin/attachment-test copy/.obsidian/plugins/yaos/logs/current-state.json`
203+
- `/home/kavin/attachment-test copy/.obsidian/plugins/yaos/restore-backups/2026-03-10T14-32-33-577Z/Welcome.md`
204+
- `/home/kavin/attachment-test copy/.obsidian/plugins/yaos/restore-backups/2026-03-10T14-32-33-577Z/wallpapers/quota.md`
205+
206+
Observed residual noise:
207+
208+
- one transient `missing-sync-facet` editor health flap appears in historical
209+
trace on the secondary vault and self-heals immediately via repair; it did
210+
not cause data drift, queue stalls, or restore failure.
211+
174212
## Run A completion (March 8, 2026)
175213

176214
- Migration/divergence mini-run is completed and passes.
@@ -220,6 +258,7 @@ are optional hardening/soak tests:
220258
1. Extended long-duration mobile churn soak (30-60+ minutes)
221259
2. Additional low-storage device matrix coverage beyond simulated quota
222260
3. Additional UI polish checks for oversize-attachment messaging copy
261+
4. Optional cold recovery-kit proof on a brand-new third vault
223262

224263
## Operational QA guidance (development mode)
225264

src/main.ts

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ const FAST_RECONNECT_MIN_INTERVAL_MS = 2_000;
6161
const MARKDOWN_DIRTY_SETTLE_MS = 350;
6262
const OPEN_FILE_EXTERNAL_EDIT_IDLE_GRACE_MS = 1200;
6363
const BOUND_RECOVERY_LOCK_MS = 1500;
64+
const CAPABILITY_REFRESH_INTERVAL_MS = 30_000;
6465

6566
export default class VaultCrdtSyncPlugin extends Plugin {
6667
settings: VaultSyncSettings = DEFAULT_SETTINGS;
@@ -161,6 +162,8 @@ export default class VaultCrdtSyncPlugin extends Plugin {
161162
private traceServerInFlight = false;
162163
private recentServerTrace: unknown[] = [];
163164
private serverCapabilities: ServerCapabilities | null = null;
165+
private capabilityRefreshPromise: Promise<void> | null = null;
166+
private lastCapabilityRefreshAt = 0;
164167
private commandsRegistered = false;
165168
private idbDegradedHandled = false;
166169

@@ -312,6 +315,13 @@ export default class VaultCrdtSyncPlugin extends Plugin {
312315
void this.clearSavedBlobQueue();
313316
}
314317
}
318+
const capabilityState = this.serverCapabilities;
319+
const waitingForR2 =
320+
!!this.settings.host &&
321+
(!capabilityState || !capabilityState.attachments || !capabilityState.snapshots);
322+
if (waitingForR2 && Date.now() - this.lastCapabilityRefreshAt >= CAPABILITY_REFRESH_INTERVAL_MS) {
323+
void this.refreshServerCapabilities("background-poll");
324+
}
315325
}, 3000);
316326
this.register(() => {
317327
if (this.statusInterval) clearInterval(this.statusInterval);
@@ -501,6 +511,7 @@ export default class VaultCrdtSyncPlugin extends Plugin {
501511
if (!this.vaultSync) return;
502512

503513
this.log(`Running reconnect reconciliation (gen ${generation})`);
514+
await this.refreshServerCapabilities("provider-sync");
504515
this.validateAllOpenBindings(`reconnect-pre:${generation}`);
505516

506517
// Also import any untracked files from a previous conservative run
@@ -542,6 +553,7 @@ export default class VaultCrdtSyncPlugin extends Plugin {
542553
if (!this.vaultSync) return;
543554
if (this.vaultSync.fatalAuthError) return;
544555

556+
void this.refreshServerCapabilities("app-foregrounded");
545557
this.requestFastReconnect("app-foregrounded");
546558
};
547559

@@ -564,6 +576,7 @@ export default class VaultCrdtSyncPlugin extends Plugin {
564576
this.onlineHandler = () => {
565577
this.log("Network online event — requesting fast reconnect");
566578
this.scheduleTraceStateSnapshot("network-online");
579+
void this.refreshServerCapabilities("network-online");
567580
this.requestFastReconnect("network-online");
568581
};
569582

@@ -2303,6 +2316,14 @@ export default class VaultCrdtSyncPlugin extends Plugin {
23032316
DEFAULT_SETTINGS,
23042317
data as Partial<VaultSyncSettings>,
23052318
);
2319+
let migratedSettings = false;
2320+
if (typeof data?.attachmentSyncExplicitlyConfigured !== "boolean") {
2321+
this.settings.attachmentSyncExplicitlyConfigured = data?.enableAttachmentSync === true;
2322+
if (data?.enableAttachmentSync !== true) {
2323+
this.settings.enableAttachmentSync = true;
2324+
}
2325+
migratedSettings = true;
2326+
}
23062327
// Load disk index from plugin data (stored under _diskIndex key)
23072328
if (data && typeof data._diskIndex === "object" && data._diskIndex !== null) {
23082329
this.diskIndex = data._diskIndex as DiskIndex;
@@ -2316,6 +2337,9 @@ export default class VaultCrdtSyncPlugin extends Plugin {
23162337
this.savedBlobQueue = data._blobQueue as BlobQueueSnapshot;
23172338
}
23182339
this.refreshPersistedState();
2340+
if (migratedSettings) {
2341+
await this.persistPluginState();
2342+
}
23192343
}
23202344

23212345
async saveSettings() {
@@ -2451,9 +2475,25 @@ export default class VaultCrdtSyncPlugin extends Plugin {
24512475
this.refreshStatusBar();
24522476
}
24532477

2454-
async refreshServerCapabilities(): Promise<void> {
2478+
async refreshServerCapabilities(reason = "manual"): Promise<void> {
2479+
if (this.capabilityRefreshPromise) {
2480+
return await this.capabilityRefreshPromise;
2481+
}
2482+
2483+
this.capabilityRefreshPromise = this.refreshServerCapabilitiesInner(reason)
2484+
.finally(() => {
2485+
this.capabilityRefreshPromise = null;
2486+
});
2487+
return await this.capabilityRefreshPromise;
2488+
}
2489+
2490+
private async refreshServerCapabilitiesInner(reason: string): Promise<void> {
2491+
this.lastCapabilityRefreshAt = Date.now();
2492+
const previous = this.serverCapabilities;
2493+
24552494
if (!this.settings.host) {
24562495
this.serverCapabilities = null;
2496+
await this.handleCapabilityChange(previous, null, reason);
24572497
return;
24582498
}
24592499

@@ -2463,6 +2503,55 @@ export default class VaultCrdtSyncPlugin extends Plugin {
24632503
this.serverCapabilities = null;
24642504
this.log(`Server capability probe failed: ${err}`);
24652505
}
2506+
2507+
await this.handleCapabilityChange(previous, this.serverCapabilities, reason);
2508+
}
2509+
2510+
private async handleCapabilityChange(
2511+
previous: ServerCapabilities | null,
2512+
next: ServerCapabilities | null,
2513+
reason: string,
2514+
): Promise<void> {
2515+
const prevAttachments = previous?.attachments ?? null;
2516+
const prevSnapshots = previous?.snapshots ?? null;
2517+
const nextAttachments = next?.attachments ?? null;
2518+
const nextSnapshots = next?.snapshots ?? null;
2519+
const changed =
2520+
prevAttachments !== nextAttachments ||
2521+
prevSnapshots !== nextSnapshots ||
2522+
previous?.authMode !== next?.authMode ||
2523+
previous?.claimed !== next?.claimed;
2524+
if (!changed) return;
2525+
2526+
this.log(
2527+
`Server capabilities updated (${reason}): ` +
2528+
`claimed=${next?.claimed ?? "unknown"} auth=${next?.authMode ?? "unknown"} ` +
2529+
`attachments=${nextAttachments ?? "unknown"} snapshots=${nextSnapshots ?? "unknown"}`,
2530+
);
2531+
this.scheduleTraceStateSnapshot(`capabilities:${reason}`);
2532+
2533+
if (this.vaultSync) {
2534+
await this.refreshAttachmentSyncRuntime(`capability-change:${reason}`);
2535+
}
2536+
2537+
const gainedR2 = prevAttachments === false && nextAttachments === true;
2538+
const lostR2 = prevAttachments === true && nextAttachments === false;
2539+
if (gainedR2) {
2540+
new Notice(
2541+
this.settings.enableAttachmentSync
2542+
? "YAOS: R2 backend detected. Attachments and snapshots are now available."
2543+
: "YAOS: R2 backend detected. Attachments and snapshots are available if you enable them in settings.",
2544+
7000,
2545+
);
2546+
if (this.vaultSync?.connected && this.vaultSync.providerSynced && this.serverSupportsSnapshots) {
2547+
void this.triggerDailySnapshot();
2548+
}
2549+
} else if (lostR2) {
2550+
new Notice(
2551+
"YAOS: R2 backend is unavailable. Attachment transfers are paused and snapshots are unavailable.",
2552+
7000,
2553+
);
2554+
}
24662555
}
24672556

24682557
private async handleSetupLink(params: Record<string, string>): Promise<void> {

src/settings.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ export interface VaultSyncSettings {
3434

3535
/** Enable attachment (non-markdown) sync via R2 blob store. */
3636
enableAttachmentSync: boolean;
37+
/** True once the user has explicitly changed the attachment sync toggle. */
38+
attachmentSyncExplicitlyConfigured: boolean;
3739
/** Maximum attachment size in KB. Files larger are skipped. Default 10240 (10 MB). */
3840
maxAttachmentSizeKB: number;
3941
/** Number of parallel upload/download slots. */
@@ -56,7 +58,8 @@ export const DEFAULT_SETTINGS: VaultSyncSettings = {
5658
excludePatterns: "",
5759
maxFileSizeKB: 2048,
5860
externalEditPolicy: "always",
59-
enableAttachmentSync: false,
61+
enableAttachmentSync: true,
62+
attachmentSyncExplicitlyConfigured: false,
6063
maxAttachmentSizeKB: 10240,
6164
// requestUrl cannot be hard-aborted; default to 1 to avoid stacked zombie transfers.
6265
attachmentConcurrency: 1,

0 commit comments

Comments
 (0)