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:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
Reference in New Issue
Block a user