Add optimistic revisions, keep dirty state on failed saves, quarantine corrupt data
Template tests / tests (pull_request) Failing after 33s
Template tests / tests (pull_request) Failing after 33s
Phase 1 of the improvement plan (PR 4 of the sequence): stop concurrent
whole-object saves from losing edits, stop failed saves from reporting clean,
and stop corrupt user data from silently vanishing.
Revisions (core/schema.js, core/store.js):
- Every guide and step carries a monotonic `revision`, bumped on each store
write. Legacy v1 data without the field reads as revision 0 and upgrades on
its next save — no migration pass, no data rewrite.
- saveGuide/saveStep accept { expectedRevision } for compare-and-swap saves;
a mismatch throws RevisionConflictError instead of clobbering. Direct user
edits pass no expectation (the user is the authority); background writers
must pass one.
Stale AI responses (app/text-intel.js):
- generateStepPatch snapshots the step revision before the (slow) model call,
re-reads the step after it, and saves with the original expectedRevision. A
user edit made during generation now surfaces as "the step changed while AI
was generating; nothing was overwritten" — previously the AI response
silently overwrote the newer edit.
Autosave truthfulness (app/renderer/editor.js):
- flushStep/flushGuide cleared the dirty flag BEFORE awaiting the IPC save,
so a rejected save (invoked via a debounce that never handled rejections)
lost the visible dirty state. The flag is now cleared only after a durable
save; failures keep it dirty, surface a persistent saveError in editor
meta, toast the user, and retry on the next edit or explicit save.
- Navigating away from the editor flushes pending debounced saves so the
last edit can never be dropped by a view switch.
Corruption quarantine (core/store.js):
- listGuides/listSteps used to silently skip unreadable entries — a corrupt
guide just vanished from the library. Corrupt guide/step directories are
now moved to library/quarantine (original bytes preserved) and recorded in
a recovery report (store.getRecoveryReport()) for the UI. Empty in-progress
directories are still skipped quietly — absence of guide.json is not
corruption.
Tests: revision increments, stale-CAS rejection with user edit surviving,
CAS success path, guide CAS, v1 no-revision upgrade, guide/step quarantine
with preserved bytes + recovery report, empty-dir non-quarantine, AI
stale-write rejection end-to-end (user edit mid-generation survives) and the
clean-apply path. 240 unit tests pass; startup smoke and sample-artifact
E2E pass.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
+34
-4
@@ -124,6 +124,7 @@ class GuideEditor {
|
||||
this.currentZoom = 'fit';
|
||||
this.pendingSave = false;
|
||||
this.pendingGuideSave = false;
|
||||
this.saveError = null;
|
||||
this.canvasHistory = [];
|
||||
this.canvasFuture = [];
|
||||
this.beforeCanvasSnapshot = null;
|
||||
@@ -151,9 +152,14 @@ class GuideEditor {
|
||||
|
||||
setActive(active) {
|
||||
this.active = Boolean(active);
|
||||
// Leaving the editor cancels any in-flight AI request for this guide so a
|
||||
// slow response can't resolve against a guide the user has closed.
|
||||
if (!this.active && this.guideId) {
|
||||
// Leaving the editor: flush pending debounced saves so navigation can
|
||||
// never drop the last edit (failures keep the dirty state and retry),
|
||||
// and cancel any in-flight AI request for this guide so a slow response
|
||||
// can't resolve against a guide the user has closed.
|
||||
if (this.pendingSave || this.pendingGuideSave) {
|
||||
this.saveAll().catch(() => {});
|
||||
}
|
||||
api.ai.cancel({ guideId: this.guideId }).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -319,6 +325,7 @@ class GuideEditor {
|
||||
selectedAnnotationId: this.selectedAnnotationId,
|
||||
linked: Boolean(this.guide && this.guide.linkedSource),
|
||||
dirty: this.pendingSave || this.pendingGuideSave || this.descriptionDirty || this.titleDirty,
|
||||
saveError: this.saveError || null,
|
||||
view: 'editor',
|
||||
};
|
||||
}
|
||||
@@ -1424,8 +1431,22 @@ class GuideEditor {
|
||||
|
||||
async flushStep(step = this.currentStep) {
|
||||
if (!step) return;
|
||||
// Clear the dirty flag only AFTER a durable save. Clearing it first meant
|
||||
// a rejected IPC save silently lost the unsaved state (and this runs from
|
||||
// a debounce that does not handle rejections). Keep it dirty on failure,
|
||||
// surface it, and retry on the next edit or explicit save.
|
||||
let saved;
|
||||
try {
|
||||
saved = await api.step.save({ guideId: this.guideId, step });
|
||||
} catch (err) {
|
||||
this.pendingSave = true;
|
||||
this.saveError = (err && err.message) || 'Save failed';
|
||||
this.emitMeta();
|
||||
this.onToast('Could not save this step — your changes are kept. Retrying…', { error: true });
|
||||
return null;
|
||||
}
|
||||
this.pendingSave = false;
|
||||
const saved = await api.step.save({ guideId: this.guideId, step });
|
||||
this.saveError = null;
|
||||
const committed = this.commitSavedStep(saved);
|
||||
if (this.selectedStepId === committed.stepId) {
|
||||
this.renderStepList();
|
||||
@@ -1470,8 +1491,17 @@ class GuideEditor {
|
||||
|
||||
async flushGuide() {
|
||||
if (!this.guide) return;
|
||||
try {
|
||||
await api.guide.save({ guide: this.guide });
|
||||
} catch (err) {
|
||||
this.pendingGuideSave = true;
|
||||
this.saveError = (err && err.message) || 'Save failed';
|
||||
this.emitMeta();
|
||||
this.onToast('Could not save guide details — your changes are kept. Retrying…', { error: true });
|
||||
return;
|
||||
}
|
||||
this.pendingGuideSave = false;
|
||||
await api.guide.save({ guide: this.guide });
|
||||
this.saveError = null;
|
||||
this.emitMeta();
|
||||
}
|
||||
|
||||
|
||||
+24
-2
@@ -624,6 +624,10 @@ public static class Win32 {
|
||||
if (!guide || !step) {
|
||||
return { ok: false, reason: 'Guide or step not found.' };
|
||||
}
|
||||
// Snapshot the revision now: AI generation is slow, and the user may
|
||||
// edit the step meanwhile. We save with this expectedRevision so a
|
||||
// response built from stale data cannot overwrite a newer user edit.
|
||||
const baseRevision = Number.isInteger(step.revision) ? step.revision : 0;
|
||||
|
||||
const currentBlock = blockId
|
||||
? [...(step.textBlocks || []), ...(step.codeBlocks || []), ...(step.tableBlocks || [])].find((b) => b.id === blockId) || null
|
||||
@@ -716,8 +720,26 @@ public static class Win32 {
|
||||
guideId,
|
||||
});
|
||||
const patch = normalizeAiPatch(raw);
|
||||
const updated = applyAiPatchToStep(step, patch, { target, blockId });
|
||||
const saved = this.store.saveStep(guideId, updated);
|
||||
// Re-read the step: while generation ran, a capture auto-doc or another
|
||||
// background write may have advanced it. Apply the patch to the current
|
||||
// step and save with the original expected revision so a user edit made
|
||||
// during generation causes a conflict instead of a silent overwrite.
|
||||
let currentStep = step;
|
||||
try {
|
||||
currentStep = this.store.getStep(guideId, stepId) || step;
|
||||
} catch {
|
||||
currentStep = step;
|
||||
}
|
||||
const updated = applyAiPatchToStep(currentStep, patch, { target, blockId });
|
||||
let saved;
|
||||
try {
|
||||
saved = this.store.saveStep(guideId, updated, { expectedRevision: baseRevision });
|
||||
} catch (err) {
|
||||
if (err && err.code === 'STEPFORGE_REVISION_CONFLICT') {
|
||||
return { ok: false, reason: 'The step changed while AI was generating; nothing was overwritten.' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return { ok: true, step: saved, patch };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: err && err.message ? err.message : 'AI generation failed.' };
|
||||
|
||||
Reference in New Issue
Block a user