Files
TylerandClaude Fable 5 c1ccb5739b
Template tests / tests (pull_request) Failing after 33s
Add optimistic revisions, keep dirty state on failed saves, quarantine corrupt data
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]>
2026-07-03 22:57:50 -05:00

434 lines
16 KiB
JavaScript

'use strict';
const fs = require('node:fs');
const path = require('node:path');
const {
newId, nowIso, writeJsonSync, readJsonSync, readJsonIfExists,
atomicWriteFileSync, deepClone,
} = require('./util');
const {
createGuide, createStep, validateGuide, validateStep,
normalizeGuide, normalizeStep,
} = require('./schema');
const { sanitizeHtml } = require('./sanitize');
/**
* Thrown by revision-aware saves when the on-disk revision no longer matches
* the caller's expectation — i.e. someone else wrote in between. Callers that
* pass expectedRevision (background/AI/capture writes) use this to avoid
* clobbering a newer user edit.
*/
class RevisionConflictError extends Error {
constructor(kind, id, expected, actual) {
super(`${kind} ${id} changed since it was read (expected revision ${expected}, found ${actual})`);
this.name = 'RevisionConflictError';
this.code = 'STEPFORGE_REVISION_CONFLICT';
this.expected = expected;
this.actual = actual;
}
}
/**
* Folder-based guide store. One directory per guide, one directory per step,
* all JSON written atomically. This is the only module that knows the
* on-disk layout of the library.
*/
class GuideStore {
constructor(rootDir) {
if (!rootDir) throw new Error('GuideStore requires a root directory');
this.root = rootDir;
this.settingsDir = path.join(rootDir, 'settings');
this.templatesDir = path.join(this.settingsDir, 'templates');
this.libraryDir = path.join(rootDir, 'library');
this.guidesDir = path.join(this.libraryDir, 'guides');
this.indexDir = path.join(this.libraryDir, 'index');
this.trashDir = path.join(this.libraryDir, 'trash');
this.quarantineDir = path.join(this.libraryDir, 'quarantine');
this.tempDir = path.join(rootDir, 'temp');
this.sharedLinksDir = path.join(rootDir, 'shared-links');
this.foldersFile = path.join(this.libraryDir, 'folders.json');
// In-memory log of files quarantined this session (corrupt/unreadable),
// surfaced to the UI instead of silently vanishing.
this.recoveryReport = [];
this.ensureLayout();
}
ensureLayout() {
for (const dir of [
this.settingsDir, this.templatesDir, this.guidesDir, this.indexDir,
this.trashDir, this.quarantineDir, this.tempDir, this.sharedLinksDir,
]) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* Move a corrupt/unreadable file or directory into quarantine (preserving
* the original bytes) and record it, rather than silently dropping it. A
* guide/step never just disappears without an explanation.
*/
quarantine(sourcePath, kind, reason) {
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const dest = path.join(this.quarantineDir, `${kind}-${path.basename(sourcePath)}-${stamp}`);
try {
fs.mkdirSync(this.quarantineDir, { recursive: true });
fs.renameSync(sourcePath, dest);
} catch {
// If we cannot move it (e.g. cross-device or vanished), still record it.
}
const entry = { kind, source: sourcePath, quarantined: dest, reason: String(reason || 'unreadable'), at: nowIso() };
this.recoveryReport.push(entry);
return entry;
}
/** Corrupt files quarantined this session (for a recovery UI). */
getRecoveryReport() {
return [...this.recoveryReport];
}
guideDir(guideId) {
if (!/^[a-zA-Z0-9_-]+$/.test(guideId)) throw new Error(`bad guide id: ${guideId}`);
return path.join(this.guidesDir, guideId);
}
stepDir(guideId, stepId) {
if (!/^[a-zA-Z0-9_-]+$/.test(stepId)) throw new Error(`bad step id: ${stepId}`);
return path.join(this.guideDir(guideId), 'steps', stepId);
}
// ---- guides -------------------------------------------------------------
createGuide(fields = {}) {
const guide = createGuide(fields);
validateGuide(guide);
writeJsonSync(path.join(this.guideDir(guide.guideId), 'guide.json'), guide);
return guide;
}
guideExists(guideId) {
return fs.existsSync(path.join(this.guideDir(guideId), 'guide.json'));
}
getGuide(guideId) {
const raw = readJsonSync(path.join(this.guideDir(guideId), 'guide.json'));
return normalizeGuide(raw);
}
saveGuide(guide, { touch = true, expectedRevision = null } = {}) {
validateGuide(guide);
// Optimistic concurrency: a caller that read the guide can pass the
// revision it saw; if disk moved on since, refuse rather than clobber.
if (expectedRevision !== null) {
const current = this.guideExists(guide.guideId) ? this.getGuide(guide.guideId).revision : 0;
if (current !== expectedRevision) {
throw new RevisionConflictError('guide', guide.guideId, expectedRevision, current);
}
}
const stored = deepClone(guide);
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1;
if (touch) stored.updatedAt = nowIso();
writeJsonSync(path.join(this.guideDir(guide.guideId), 'guide.json'), stored);
return stored;
}
listGuides() {
const out = [];
for (const entry of fs.readdirSync(this.guidesDir, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = path.join(this.guidesDir, entry.name);
const file = path.join(dir, 'guide.json');
if (!fs.existsSync(file)) continue; // in-progress/empty dir, not corruption
try {
out.push(normalizeGuide(readJsonSync(file)));
} catch (err) {
// A corrupt guide.json used to make the guide silently vanish from the
// library. Quarantine the directory (preserving it) and record it so
// the user can be told, instead of losing it without explanation.
this.quarantine(dir, 'guide', err && err.message);
}
}
out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
return out;
}
setFavorite(guideId, favorite) {
const guide = this.getGuide(guideId);
guide.favorite = Boolean(favorite);
return this.saveGuide(guide, { touch: false });
}
/** Move a guide directory into trash (recoverable until purged). */
deleteGuide(guideId) {
const dir = this.guideDir(guideId);
if (!fs.existsSync(dir)) throw new Error(`guide not found: ${guideId}`);
const dest = path.join(this.trashDir, `${guideId}-${Date.now()}`);
fs.renameSync(dir, dest);
const folders = this.loadFolders();
delete folders.guideFolders[guideId];
this.saveFolders(folders);
return dest;
}
restoreFromTrash(trashName) {
const src = path.join(this.trashDir, path.basename(trashName));
const guide = readJsonSync(path.join(src, 'guide.json'));
const dest = this.guideDir(guide.guideId);
if (fs.existsSync(dest)) throw new Error(`guide already exists: ${guide.guideId}`);
fs.renameSync(src, dest);
return guide.guideId;
}
listTrash() {
if (!fs.existsSync(this.trashDir)) return [];
return fs.readdirSync(this.trashDir).filter((n) => {
return fs.existsSync(path.join(this.trashDir, n, 'guide.json'));
});
}
purgeTrash() {
for (const name of fs.readdirSync(this.trashDir)) {
fs.rmSync(path.join(this.trashDir, name), { recursive: true, force: true });
}
}
purgeTrashItems(names) {
for (const name of names) {
fs.rmSync(path.join(this.trashDir, path.basename(name)), { recursive: true, force: true });
}
}
duplicateGuide(guideId, { title } = {}) {
const src = this.getGuide(guideId);
const steps = this.listSteps(guideId);
const copy = createGuide({
...deepClone(src),
guideId: undefined,
title: title || `${src.title} (copy)`,
linkedSource: null,
});
const idMap = new Map();
for (const oldId of src.stepsOrder) idMap.set(oldId, newId('step'));
this.createGuide({ ...copy });
for (const oldId of src.stepsOrder) {
const oldStep = steps.get(oldId);
if (!oldStep) continue;
const newStep = deepClone(oldStep);
newStep.stepId = idMap.get(oldId);
newStep.parentStepId = oldStep.parentStepId ? idMap.get(oldStep.parentStepId) || null : null;
writeJsonSync(path.join(this.stepDir(copy.guideId, newStep.stepId), 'step.json'), newStep);
const oldDir = this.stepDir(guideId, oldId);
for (const file of fs.readdirSync(oldDir)) {
if (file === 'step.json') continue;
fs.copyFileSync(path.join(oldDir, file), path.join(this.stepDir(copy.guideId, newStep.stepId), file));
}
}
copy.stepsOrder = src.stepsOrder.map((id) => idMap.get(id)).filter(Boolean);
return this.saveGuide(copy);
}
// ---- steps --------------------------------------------------------------
/**
* Create a step and append it to the guide's order.
* `imageBuffer` (PNG bytes) is optional; when given it is stored as both
* original.png (immutable) and working.png (crop target).
*/
addStep(guideId, fields = {}, imageBuffer = null, imageSize = null, { position } = {}) {
const guide = this.getGuide(guideId);
const step = createStep(fields);
if (imageBuffer) {
const dir = this.stepDir(guideId, step.stepId);
fs.mkdirSync(dir, { recursive: true });
atomicWriteFileSync(path.join(dir, 'original.png'), imageBuffer);
atomicWriteFileSync(path.join(dir, 'working.png'), imageBuffer);
step.kind = 'image';
step.image = {
originalPath: 'original.png',
workingPath: 'working.png',
size: imageSize || { width: 0, height: 0 },
};
}
validateStep(step);
writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), step);
const at = Number.isInteger(position) ? position : guide.stepsOrder.length;
guide.stepsOrder.splice(at, 0, step.stepId);
this.saveGuide(guide);
return step;
}
getStep(guideId, stepId) {
return normalizeStep(readJsonSync(path.join(this.stepDir(guideId, stepId), 'step.json')));
}
/** Map of stepId -> step for every step directory of the guide. */
listSteps(guideId) {
const stepsRoot = path.join(this.guideDir(guideId), 'steps');
const map = new Map();
if (!fs.existsSync(stepsRoot)) return map;
for (const entry of fs.readdirSync(stepsRoot, { withFileTypes: true })) {
if (!entry.isDirectory()) continue;
const dir = path.join(stepsRoot, entry.name);
const file = path.join(dir, 'step.json');
if (!fs.existsSync(file)) continue;
try {
map.set(entry.name, normalizeStep(readJsonSync(file)));
} catch (err) {
// Quarantine a corrupt step (preserving it) and record it rather than
// silently dropping it from the guide.
this.quarantine(dir, 'step', err && err.message);
}
}
return map;
}
saveStep(guideId, step, { expectedRevision = null } = {}) {
// Optimistic concurrency for background/AI/capture writes: refuse to
// overwrite a step that changed since it was read. Direct user edits pass
// no expectedRevision (last-write-wins — the user is the authority).
if (expectedRevision !== null) {
let current = 0;
try {
current = this.getStep(guideId, step.stepId).revision;
} catch {
current = 0; // step vanished; treat as revision 0
}
if (current !== expectedRevision) {
throw new RevisionConflictError('step', step.stepId, expectedRevision, current);
}
}
const stored = normalizeStep(deepClone(step));
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1;
validateStep(stored);
writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored);
const guide = this.getGuide(guideId);
this.saveGuide(guide); // bump updatedAt
return stored;
}
deleteStep(guideId, stepId) {
const guide = this.getGuide(guideId);
// Re-parent substeps of the deleted step to the top level.
for (const [, step] of this.listSteps(guideId)) {
if (step.parentStepId === stepId) {
step.parentStepId = null;
writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), step);
}
}
fs.rmSync(this.stepDir(guideId, stepId), { recursive: true, force: true });
guide.stepsOrder = guide.stepsOrder.filter((id) => id !== stepId);
this.saveGuide(guide);
}
/**
* Recreate a previously-deleted step, preserving its stepId, and splice it
* back into the guide's order. Used to undo step deletion.
*/
restoreStep(guideId, step, images, position) {
const guide = this.getGuide(guideId);
const restored = normalizeStep(deepClone(step));
validateStep(restored);
const dir = this.stepDir(guideId, restored.stepId);
fs.mkdirSync(dir, { recursive: true });
if (restored.image && images) {
if (images.original) atomicWriteFileSync(path.join(dir, restored.image.originalPath), images.original);
if (images.working) atomicWriteFileSync(path.join(dir, restored.image.workingPath), images.working);
}
writeJsonSync(path.join(dir, 'step.json'), restored);
const at = Number.isInteger(position) ? Math.min(position, guide.stepsOrder.length) : guide.stepsOrder.length;
guide.stepsOrder.splice(at, 0, restored.stepId);
this.saveGuide(guide);
return restored;
}
reorderSteps(guideId, newOrder) {
const guide = this.getGuide(guideId);
const current = new Set(guide.stepsOrder);
if (newOrder.length !== guide.stepsOrder.length || !newOrder.every((id) => current.has(id))) {
throw new Error('reorderSteps: new order must contain exactly the existing steps');
}
guide.stepsOrder = [...newOrder];
return this.saveGuide(guide);
}
stepImagePath(guideId, stepId, which = 'working') {
const step = this.getStep(guideId, stepId);
if (!step.image) return null;
const rel = which === 'original' ? step.image.originalPath : step.image.workingPath;
return path.join(this.stepDir(guideId, stepId), rel);
}
/** Replace the working image (crop result). The original is never touched. */
setWorkingImage(guideId, stepId, pngBuffer, size, stepPatch = null) {
const step = stepPatch ? deepClone(stepPatch) : this.getStep(guideId, stepId);
if (!step.image) throw new Error('step has no image');
atomicWriteFileSync(path.join(this.stepDir(guideId, stepId), step.image.workingPath), pngBuffer);
step.image.size = size;
return this.saveStep(guideId, step);
}
/** Restore working.png from original.png (un-crop). */
resetWorkingImage(guideId, stepId, size) {
const step = this.getStep(guideId, stepId);
if (!step.image) throw new Error('step has no image');
const dir = this.stepDir(guideId, stepId);
fs.copyFileSync(path.join(dir, step.image.originalPath), path.join(dir, step.image.workingPath));
if (size) step.image.size = size;
return this.saveStep(guideId, step);
}
// ---- folders & favorites ------------------------------------------------
loadFolders() {
return readJsonIfExists(this.foldersFile, { folders: [], guideFolders: {} });
}
saveFolders(data) {
writeJsonSync(this.foldersFile, data);
return data;
}
createFolder(name, parentId = null) {
const data = this.loadFolders();
const folder = { id: newId('folder'), name, parentId };
data.folders.push(folder);
this.saveFolders(data);
return folder;
}
renameFolder(folderId, name) {
const data = this.loadFolders();
const folder = data.folders.find((f) => f.id === folderId);
if (!folder) throw new Error(`folder not found: ${folderId}`);
folder.name = name;
this.saveFolders(data);
return folder;
}
deleteFolder(folderId) {
const data = this.loadFolders();
data.folders = data.folders.filter((f) => f.id !== folderId);
for (const [gid, fid] of Object.entries(data.guideFolders)) {
if (fid === folderId) delete data.guideFolders[gid];
}
for (const f of data.folders) {
if (f.parentId === folderId) f.parentId = null;
}
this.saveFolders(data);
}
moveGuideToFolder(guideId, folderId) {
const data = this.loadFolders();
if (folderId === null) delete data.guideFolders[guideId];
else {
if (!data.folders.some((f) => f.id === folderId)) throw new Error(`folder not found: ${folderId}`);
data.guideFolders[guideId] = folderId;
}
this.saveFolders(data);
}
}
module.exports = { GuideStore, RevisionConflictError };