Make workspace/project file sync propagate deletions instead of resurrecting them

The old sync compared only the two live sides (project rows vs workspace
files), so a file present in the project but missing on disk was
indistinguishable from "never materialized here yet" - it always got
re-exported, which is why deleting a file inside a container made it come
back. The mirror direction had the same bug: a file deleted from the
project's file editor was silently re-imported from the container's stale
copy on the next tick.

Fixes it with a persisted per-file sync baseline (new project_file_sync_state
table: db_epoch/fs_epoch as they stood right after the previous sync), the
same role a rsync/Unison state file plays in any real bidirectional sync.
Deleting on either side now propagates to the other, unless the deleted
side's counterpart was edited after the last sync, in which case the edit
wins and the file is restored. A read-only project always exports (never
imports, including on tie) and always removes a workspace's stale local
copy, so it stays a faithful mirror. Sync of an unchanged file is now a true
no-op (zero writes) instead of rewriting it every ~60s tick forever.

sync_dir_bidirectional's return dict gains deleted_in_project/
deleted_in_workspace alongside exported/imported; both API call sites
already pass the whole dict through untouched. The instance sync toast now
summarizes all four counts instead of just imports.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TjdKTWgWpW2SMNW8SFqxz5
This commit is contained in:
2026-09-03 08:47:57 +02:00
co-authored by Claude Sonnet 5
parent be77672c3e
commit 70ceb3cf81
6 changed files with 399 additions and 38 deletions
+16
View File
@@ -246,6 +246,22 @@ def init_db():
_index(
db, "project_files", "idx_project_files_parent", ["project_uid", "parent_path"]
)
project_file_sync_state = get_table("project_file_sync_state")
for column, example in (
("project_uid", ""),
("path", ""),
("db_epoch", 0.0),
("fs_epoch", 0.0),
):
if not project_file_sync_state.has_column(column):
project_file_sync_state.create_column_by_example(column, example)
_index(
db,
"project_file_sync_state",
"idx_project_file_sync_state_path",
["project_uid", "path"],
unique=True,
)
_index(db, "badges", "idx_badges_user", ["user_uid"])
_index(db, "badges", "idx_badges_user_name", ["user_uid", "badge_name"])
_drop_index(db, "idx_follows_follower")
+151 -32
View File
@@ -477,6 +477,7 @@ def delete_node(project_uid: str, raw_path: str, deleted_by: str = "system") ->
def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
if "project_files" not in db.tables:
return
clear_sync_state(project_uid)
stamp = _now()
for row in _table().find(project_uid=project_uid, deleted_at=None):
_table().update(
@@ -486,6 +487,7 @@ def soft_delete_all_project_files(project_uid: str, deleted_by: str) -> None:
_SYNC_CLOCK_SKEW_SECONDS = 1.0
SYNC_STATE_TABLE = "project_file_sync_state"
def _epoch_of(iso_timestamp) -> float:
@@ -497,6 +499,48 @@ def _epoch_of(iso_timestamp) -> float:
return 0.0
def _sync_state_table():
return get_table(SYNC_STATE_TABLE)
def _load_sync_manifest(project_uid: str) -> dict:
if SYNC_STATE_TABLE not in db.tables:
return {}
return {
row["path"]: {"db_epoch": row["db_epoch"], "fs_epoch": row["fs_epoch"]}
for row in _sync_state_table().find(project_uid=project_uid)
}
def _save_sync_manifest(project_uid: str, old: dict, new: dict) -> None:
table = _sync_state_table()
for path in old:
if path not in new:
table.delete(project_uid=project_uid, path=path)
for path, entry in new.items():
if old.get(path) == entry:
continue
table.upsert(
{"project_uid": project_uid, "path": path, **entry},
["project_uid", "path"],
)
def clear_sync_state(project_uid: str) -> None:
if SYNC_STATE_TABLE not in db.tables:
return
_sync_state_table().delete(project_uid=project_uid)
def _delete_db_row_for_sync(row: dict, deleted_by: str) -> None:
_table().update(
{"uid": row["uid"], "deleted_at": _now(), "deleted_by": deleted_by},
["uid"],
)
if row.get("is_binary"):
_unlink_blob(row)
def _file_records(project_uid: str) -> dict:
records: dict = {}
for row in _table().find(project_uid=project_uid, deleted_at=None):
@@ -536,48 +580,121 @@ def _workspace_records(workspace) -> dict:
def sync_dir_bidirectional(project_uid: str, workspace, user: dict) -> dict:
empty = {
"exported": 0,
"imported": 0,
"deleted_in_project": 0,
"deleted_in_workspace": 0,
}
if "project_files" not in db.tables:
return {"exported": 0, "imported": 0}
return empty
readonly = is_readonly(project_uid)
dest = Path(workspace).resolve()
dest.mkdir(parents=True, exist_ok=True)
db_files = _file_records(project_uid)
fs_files = _workspace_records(dest)
exported = 0
imported = 0
manifest = _load_sync_manifest(project_uid)
new_manifest: dict = {}
counts = dict(empty)
for path, row in db_files.items():
for path in set(manifest) | set(db_files) | set(fs_files):
row = db_files.get(path)
fs_full = fs_files.get(path)
if fs_full is None:
_export_node(row, dest)
exported += 1
continue
try:
fs_mtime = fs_full.stat().st_mtime
except OSError:
continue
db_mtime = _epoch_of(row.get("updated_at"))
if db_mtime >= fs_mtime - _SYNC_CLOCK_SKEW_SECONDS:
_export_node(row, dest)
exported += 1
elif not readonly:
if _import_file(project_uid, user, path, fs_full):
imported += 1
entry = manifest.get(path)
if not readonly:
for path, fs_full in fs_files.items():
if path in db_files:
if row is not None and fs_full is not None:
try:
fs_epoch = fs_full.stat().st_mtime
except OSError:
continue
if _import_file(project_uid, user, path, fs_full):
imported += 1
db_epoch = _epoch_of(row.get("updated_at"))
if (
entry is not None
and abs(db_epoch - entry["db_epoch"]) <= _SYNC_CLOCK_SKEW_SECONDS
and abs(fs_epoch - entry["fs_epoch"]) <= _SYNC_CLOCK_SKEW_SECONDS
):
new_manifest[path] = entry
continue
if db_epoch >= fs_epoch - _SYNC_CLOCK_SKEW_SECONDS or readonly:
recorded = _record_export(row, dest)
if recorded:
counts["exported"] += 1
new_manifest[path] = recorded
else:
recorded = _record_import(project_uid, user, path, fs_full, fs_epoch)
if recorded:
counts["imported"] += 1
new_manifest[path] = recorded
continue
return {"exported": exported, "imported": imported}
if row is not None and fs_full is None:
db_epoch = _epoch_of(row.get("updated_at"))
if (
entry is None
or readonly
or db_epoch > entry["db_epoch"] + _SYNC_CLOCK_SKEW_SECONDS
):
recorded = _record_export(row, dest)
if recorded:
counts["exported"] += 1
new_manifest[path] = recorded
else:
_delete_db_row_for_sync(row, user["uid"])
counts["deleted_in_project"] += 1
continue
if row is None and fs_full is not None:
try:
fs_epoch = fs_full.stat().st_mtime
except OSError:
continue
if entry is None:
if not readonly:
recorded = _record_import(project_uid, user, path, fs_full, fs_epoch)
if recorded:
counts["imported"] += 1
new_manifest[path] = recorded
continue
if not readonly and fs_epoch > entry["fs_epoch"] + _SYNC_CLOCK_SKEW_SECONDS:
recorded = _record_import(project_uid, user, path, fs_full, fs_epoch)
if recorded:
counts["imported"] += 1
new_manifest[path] = recorded
continue
try:
fs_full.unlink()
counts["deleted_in_workspace"] += 1
except OSError:
pass
continue
_save_sync_manifest(project_uid, manifest, new_manifest)
return counts
def _export_node(row: dict, dest: Path) -> None:
def _record_export(row: dict, dest: Path):
target = _export_node(row, dest)
if target is None:
return None
db_epoch = _epoch_of(row.get("updated_at"))
try:
fs_epoch = target.stat().st_mtime
except OSError:
fs_epoch = db_epoch
return {"db_epoch": db_epoch, "fs_epoch": fs_epoch}
def _record_import(project_uid: str, user: dict, path: str, fs_full: Path, fs_epoch: float):
imported = _import_file(project_uid, user, path, fs_full)
if imported is None:
return None
return {"db_epoch": _epoch_of(imported.get("updated_at")), "fs_epoch": fs_epoch}
def _export_node(row: dict, dest: Path):
target = (dest / row["path"]).resolve()
if target != dest and not target.is_relative_to(dest):
return
return None
target.parent.mkdir(parents=True, exist_ok=True)
if target.is_symlink():
target.unlink()
@@ -587,20 +704,21 @@ def _export_node(row: dict, dest: Path) -> None:
shutil.copyfile(src, target)
except (FileNotFoundError, OSError):
logger.warning("Blob file missing during export: %s", src)
return None
else:
target.write_text(row.get("content") or "", encoding="utf-8")
return target
def _import_file(project_uid: str, user: dict, path: str, fs_full: Path) -> bool:
def _import_file(project_uid: str, user: dict, path: str, fs_full: Path):
try:
data = fs_full.read_bytes()
except OSError:
return False
return None
try:
store_upload(project_uid, user, _parent_of(path), _name_of(path), data)
return True
return store_upload(project_uid, user, _parent_of(path), _name_of(path), data)
except ProjectFileError:
return False
return None
def node_to_dict(row: dict) -> dict:
@@ -875,6 +993,7 @@ def append_lines(project_uid: str, raw_path: str, content: str) -> dict:
def delete_all_project_files(project_uid: str) -> None:
if "project_files" not in db.tables:
return
clear_sync_state(project_uid)
for row in _table().find(project_uid=project_uid):
if row.get("is_binary"):
_unlink_blob(row)
+54 -3
View File
@@ -292,11 +292,62 @@ The container manager drives the host docker daemon, which needs heavy wiring -
**The DooD bind-mount gotcha:** `docker run -v <path>:/app` resolves `<path>` on the HOST, so `DEVPLACE_DATA_DIR` must be mounted at an identical host+container path (the make targets use `$(CURDIR)/data` on both sides; manual `docker compose` users get a `/srv/devplace-data` default). Build contexts ship via the docker API tarball, so the container temp dir is fine. Set `DEVPLACE_CONTAINER_PROXY_HOST=host.docker.internal` only when a containerized app cannot route to the recorded gateway. See README "Container Manager wiring".
## Bidirectional newer-wins sync (load-bearing direction rule)
## Bidirectional sync with deletion propagation (load-bearing direction rule)
Sync is NOT one-directional import. `project_files.sync_dir_bidirectional(project_uid, workspace, user) -> {"exported", "imported"}` is the one helper that reconciles a project's virtual FS against an instance's `workspace_dir`: per file, the side with the newer timestamp wins (project `updated_at` epoch, via `datetime.fromisoformat(...).timestamp()`, vs filesystem `st_mtime`, with a 1s skew tolerance favouring export on ties), and a file present on only one side propagates to the other. It **NEVER deletes a file** - only creates/overwrites the older side. A **read-only** project (`is_readonly`) exports only, never imports (the read-only guard direction).
Sync is NOT one-directional import, and it is not merely "newer wins" either - it propagates
deletions on BOTH sides, which requires more state than comparing two live snapshots can
ever provide. `project_files.sync_dir_bidirectional(project_uid, workspace, user) ->
{"exported", "imported", "deleted_in_project", "deleted_in_workspace"}` reconciles a
project's virtual FS against an instance's `workspace_dir` against a **third,
persisted state: the manifest of what was true after the previous sync**
(`project_file_sync_state`, one row per `(project_uid, path)` holding `db_epoch` +
`fs_epoch` as they stood right after that prior sync wrote them - never a guess, always
re-read from the actual post-write `stat()`/row so a later comparison is exact). Comparing
only the two live sides can never tell "never synced here yet, please export it" apart from
"was here, got deleted, please propagate that" - both look identical (present in the
project, absent on disk) with no baseline. The manifest is exactly that baseline, the same
role a `.git` index or an rsync/Unison state file plays in any real bidirectional sync.
Both `api.sync_workspace` (HTTP/Devii sync action, returns `{exported, imported}`) and `api.sync_bidirectional_sync` (reconciler, non-blocking `record_event` system actor, logs only when non-zero) call the same helper. The reconciler runs it before every `_launch` AND on a ~60s wall-clock cadence over running instances (`SYNC_EVERY_SECONDS`, gated on `time.monotonic()` independent of the 5s reconcile tick). The per-instance boot-helper files (`.devplace_boot.py`/`.devplace_boot.sh`) are in `SYNC_SKIP_NAMES` so they never round-trip into the project.
Per path, three states are possible (`db_files`/`fs_files`/`manifest`, keyed by path):
- **Present both sides.** Newer wins as before (`db_epoch >= fs_epoch - 1s` skew exports,
otherwise imports) - a read-only project always exports here too (see below).
- **Present in the project, absent on disk.** Not in the manifest (or read-only) -> never
materialized here, export it. In the manifest -> the workspace deleted it since the last
sync -> **propagate: soft-delete the project row** (`_delete_db_row_for_sync`, stamps
`deleted_at`/`deleted_by=user["uid"]`, unlinks the blob) - UNLESS the project's own copy
was edited after that last sync (`db_epoch` newer than the manifest's), in which case the
edit wins and the file is restored instead of deleted.
- **Present on disk, absent from the project.** Not in the manifest -> a brand new file
created in the workspace -> import it. In the manifest -> the project deleted it since
the last sync -> **propagate: delete the local file** - UNLESS it was edited locally after
that last sync, in which case the edit wins and it is re-imported (resurrected). A failed
edit-wins import is left untouched rather than deleted, so a transient I/O error can never
destroy the only remaining copy.
**A read-only project always wins, in both directions**: present-both-sides always exports
(a locally-newer edit in a read-only workspace is discarded, never imported - it could never
round-trip anyway); a local deletion is always restored (never propagated - read-only means
the workspace mirrors the project verbatim, it does not get to delete from it); and when the
*project* deletes a file, its now-stale local copy in a read-only workspace is still removed
(that is export-direction cleanup, not an import, so it does not violate "read-only never
imports").
The manifest is written with a diff, not a full rewrite: `_save_sync_manifest` deletes only
the rows for paths that left the merged state and upserts only the rows whose entry actually
changed, so an idle project with nothing to sync costs zero writes on the next tick despite
having thousands of files. It is cleared entirely (`clear_sync_state`) whenever a project's
files are wiped (`soft_delete_all_project_files`, `delete_all_project_files` / fork
rollback) - a later restore starts the baseline fresh, which just means the first sync after
restore treats everything as newly seen (safe, not destructive).
Both `api.sync_workspace` (HTTP/Devii sync action) and `api.sync_bidirectional_sync`
(reconciler, non-blocking `record_event` system actor, logs only when any count is nonzero)
call the same helper and return/record all four counts. The reconciler runs it before every
`_launch` AND on a ~60s wall-clock cadence over running instances (`SYNC_EVERY_SECONDS`,
gated on `time.monotonic()` independent of the 5s reconcile tick). The per-instance
boot-helper files (`.devplace_boot.py`/`.devplace_boot.sh`) are in `SYNC_SKIP_NAMES` so they
never round-trip into the project, and therefore never enter the manifest either.
## Run-as user = identity + API key ONLY (load-bearing constraint)
+11 -1
View File
@@ -100,11 +100,21 @@ export class ContainerInstance {
: `${this.base}/instances/${this.uid}/${action}`;
const res = await Http.send(url, {});
if (action === "delete") { window.location.href = "/admin/containers"; return; }
if (action === "sync") this.toast(`Synced ${res.data && res.data.imported} files`, "success");
if (action === "sync") this.toast(this.syncSummary(res.data), "success");
else this.toast(`${action} requested`, "success");
} catch (err) { this.toast(err.message, "error"); }
}
syncSummary(data) {
const counts = data || {};
const parts = [];
if (counts.exported) parts.push(`${counts.exported} exported`);
if (counts.imported) parts.push(`${counts.imported} imported`);
if (counts.deleted_in_project) parts.push(`${counts.deleted_in_project} deleted in project`);
if (counts.deleted_in_workspace) parts.push(`${counts.deleted_in_workspace} deleted in workspace`);
return parts.length ? `Synced: ${parts.join(", ")}` : "Synced: already up to date";
}
startDetailPoll() {
this.detailPoll = new Poller(async () => {
const detail = await Http.getJson(`${this.base}/instances/${this.uid}`);