This commit is contained in:
team3
2026-10-05 22:09:06 +02:00
parent 16630f0368
commit f3ef3861a9
5 changed files with 18 additions and 23 deletions

View File

@@ -62,7 +62,7 @@ reload:
echo "Extension neu gestartet." echo "Extension neu gestartet."
fi fi
# Dateiabgleich (Claude-Sitzungen und Memory) sofort und ohne Ruhezeit, wartet bis er durch ist # Dateiabgleich (Claude-Sitzungen und Memory) sofort anstoßen, wartet bis er durch ist
sync-files: sync-files:
@gdbus call --session --dest org.gnome.Shell --object-path /de/marha/desktop --method de.marha.Desktop.SyncFiles --timeout 3600 \ @gdbus call --session --dest org.gnome.Shell --object-path /de/marha/desktop --method de.marha.Desktop.SyncFiles --timeout 3600 \
| sed -e "s/^('//" -e "s/',)$$//" | sed -e "s/^('//" -e "s/',)$$//"

View File

@@ -64,8 +64,8 @@ Wiederverbinden wird nachgeliefert. Eine gerade offene Notiz wird nicht von auß
### Claude-Code-Sitzungen (nur PCs) ### Claude-Code-Sitzungen (nur PCs)
Die Extension gleicht auch `~/.claude/projects/` ab, also Sitzungen und Memory von Claude Code, Die Extension gleicht auch `~/.claude/projects/` ab, also Sitzungen und Memory von Claude Code,
im selben Takt wie die Einträge. Hoch geht eine Datei, wenn sie sich geändert hat und seit einer im selben Takt wie die Einträge. Jede Änderung geht beim nächsten Lauf hoch, also nach 1 bis 10
Minute nicht angefasst wurde. Sitzungen wachsen nur am Ende, deshalb geht meist nur der neue Rest: Sekunden. Sitzungen wachsen nur am Ende, deshalb geht meist nur der neue Rest:
der Client nennt Größe und Hash der letzten 64 KB des Standes, den der Server haben müsste, und der der Client nennt Größe und Hash der letzten 64 KB des Standes, den der Server haben müsste, und der
Server hängt an. Passt das nicht, geht die ganze Datei. Vom Server kommt eine Datei, wenn sie dort Server hängt an. Passt das nicht, geht die ganze Datei. Vom Server kommt eine Datei, wenn sie dort
neuer ist, ebenfalls nur ab dem eigenen Stand, mit ihrer Änderungszeit. Gelöschte Dateien neuer ist, ebenfalls nur ab dem eigenen Stand, mit ihrer Änderungszeit. Gelöschte Dateien
@@ -74,7 +74,7 @@ Auf dem Server liegen die Inhalte unter `data/files/`, in SQLite nur Pfad, Zeitp
Eine Sitzung darf nur auf einem PC gleichzeitig laufen, sonst gewinnt die neuere Datei als Ganzes. Eine Sitzung darf nur auf einem PC gleichzeitig laufen, sonst gewinnt die neuere Datei als Ganzes.
Bedingung: gleicher Pfad auf beiden PCs, der Ordnername enthält ihn. Bedingung: gleicher Pfad auf beiden PCs, der Ordnername enthält ihn.
`"files": false` in `sync.json` schaltet es ab, ein Pfad statt `false` ersetzt den Ordner. `"files": false` in `sync.json` schaltet es ab, ein Pfad statt `false` ersetzt den Ordner.
`make sync-files` stößt den Abgleich sofort an, auch für Dateien, die noch keine Minute ruhen, und wartet, bis er durch ist. `make sync-files` stößt den Abgleich sofort an und wartet, bis er durch ist.
Zugang auf jedem Gerät in `~/.local/share/desktop-app/sync.json`: Zugang auf jedem Gerät in `~/.local/share/desktop-app/sync.json`:

View File

@@ -1,8 +1,7 @@
// Dateiabgleich für ~/.claude/projects (Claude-Code-Sitzungen und Memory), nur zwischen PCs. // Dateiabgleich für ~/.claude/projects (Claude-Code-Sitzungen und Memory), nur zwischen PCs.
// Reine Logik ohne Dateisystem, damit sie sich testen lässt. sync.js liest und schreibt die Dateien. // Reine Logik ohne Dateisystem, damit sie sich testen lässt. sync.js liest und schreibt die Dateien.
// //
// Regeln: Eine Datei geht hoch, wenn sie sich geändert hat und seit einer Minute nicht angefasst wurde. // Regeln: Eine Datei geht hoch, sobald sie sich geändert hat. Sitzungen wachsen nur am Ende, deshalb geht meist nur der neue Rest ("append"), zusammen mit dem
// Sitzungen wachsen nur am Ende, deshalb geht meist nur der neue Rest ("append"), zusammen mit dem
// Stand, den der Server haben müsste (base: Größe und Hash der letzten 64 KB). Vom Server kommt eine // Stand, den der Server haben müsste (base: Größe und Hash der letzten 64 KB). Vom Server kommt eine
// Datei, wenn sie dort neuer ist, ebenfalls nur ab dem eigenen Stand. Bei Konflikten gewinnt die neuere // Datei, wenn sie dort neuer ist, ebenfalls nur ab dem eigenen Stand. Bei Konflikten gewinnt die neuere
// Änderungszeit. Verschwundene Dateien werden als Tombstone gemeldet, gelöschte vom Server lokal entfernt. // Änderungszeit. Verschwundene Dateien werden als Tombstone gemeldet, gelöschte vom Server lokal entfernt.

View File

@@ -23,11 +23,10 @@ const INTERVALS = [
// Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen // Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen
const SOON_MS = 500; const SOON_MS = 500;
// Dateien (Claude-Sitzungen und Memory): gleicher Takt wie die Einträge, hoch geht, was eine Minute ruht. // Dateien (Claude-Sitzungen und Memory): gleicher Takt wie die Einträge, jede Änderung geht beim
// Meist nur der neue Rest einer Datei, siehe files.js. // nächsten Lauf hoch, meist nur der neue Rest einer Datei, siehe files.js.
// "files" in sync.json: false schaltet ab, ein Pfad ersetzt ~/.claude/projects. // "files" in sync.json: false schaltet ab, ein Pfad ersetzt ~/.claude/projects.
const FILES_FIRST_SECONDS = 10; const FILES_FIRST_SECONDS = 10;
const FILES_IDLE_SECONDS = 60;
const FILES_BATCH_BYTES = 16 * 1024 * 1024; const FILES_BATCH_BYTES = 16 * 1024 * 1024;
// Von außen anstoßbar: make sync-files ruft diese Methode über D-Bus auf // Von außen anstoßbar: make sync-files ruft diese Methode über D-Bus auf
@@ -84,15 +83,14 @@ export class Sync {
this._dbus.export(Gio.DBus.session, '/de/marha/desktop'); this._dbus.export(Gio.DBus.session, '/de/marha/desktop');
} }
// Dateiabgleich sofort und erzwungen: auch Dateien, die noch keine Stunde ruhen. // Dateiabgleich sofort, die Antwort kommt erst, wenn er durch ist.
// Die Antwort kommt erst, wenn er durch ist.
SyncFilesAsync(_params, invocation) { SyncFilesAsync(_params, invocation) {
const reply = text => invocation.return_value(new GLib.Variant('(s)', [text])); const reply = text => invocation.return_value(new GLib.Variant('(s)', [text]));
if (!this._filesRoot) { if (!this._filesRoot) {
reply('Dateiabgleich ist aus ("files": false in sync.json)'); reply('Dateiabgleich ist aus ("files": false in sync.json)');
return; return;
} }
this._filesCycle(true) this._filesCycle()
.then(r => reply(r ? `hoch ${r.uploaded}, gelöscht gemeldet ${r.removed}, runter ${r.written}, lokal gelöscht ${r.deleted}` : 'läuft gerade schon')) .then(r => reply(r ? `hoch ${r.uploaded}, gelöscht gemeldet ${r.removed}, runter ${r.written}, lokal gelöscht ${r.deleted}` : 'läuft gerade schon'))
.catch(e => reply(`Fehler: ${e.message}`)); .catch(e => reply(`Fehler: ${e.message}`));
} }
@@ -284,8 +282,7 @@ export class Sync {
// Eigener Lauf mit eigenem Stand: die Antwort enthält nur Dateien, der Takt der Einträge bleibt unberührt. // Eigener Lauf mit eigenem Stand: die Antwort enthält nur Dateien, der Takt der Einträge bleibt unberührt.
// Gibt zurück, was übertragen wurde, oder null, wenn gerade ein Lauf aktiv ist. // Gibt zurück, was übertragen wurde, oder null, wenn gerade ein Lauf aktiv ist.
// force: Ruhezeit ignorieren, alles Geänderte geht hoch. async _filesCycle() {
async _filesCycle(force = false) {
if (this._filesBusy || !this._filesRoot) if (this._filesBusy || !this._filesRoot)
return null; return null;
this._filesBusy = true; this._filesBusy = true;
@@ -294,7 +291,7 @@ export class Sync {
const state = this._state.files; const state = this._state.files;
const local = scanFiles(this._filesRoot); const local = scanFiles(this._filesRoot);
const now = Math.floor(GLib.get_real_time() / 1e6); const now = Math.floor(GLib.get_real_time() / 1e6);
const {upload, remove} = planPush(local, state.pushed, now, force ? 0 : FILES_IDLE_SECONDS); const {upload, remove} = planPush(local, state.pushed, now, 0);
const items = []; const items = [];
for (const {id, updated} of upload) { for (const {id, updated} of upload) {
const bytes = readBytes(this._path(id)); const bytes = readBytes(this._path(id));
@@ -368,7 +365,7 @@ export class Sync {
if (bytes) if (bytes)
state.pushed[file.id] = {updated: mine, size: bytes.length, hash: tailHash(bytes)}; state.pushed[file.id] = {updated: mine, size: bytes.length, hash: tailHash(bytes)};
} }
// Sonst ist die lokale Datei neuer und geht hoch, sobald sie eine Minute ruht. // Sonst ist die lokale Datei neuer und geht beim nächsten Lauf hoch.
} }
state.cursor = response.cursor; state.cursor = response.cursor;
} }

View File

@@ -158,25 +158,24 @@ FILES=/tmp/desktop-app-nested/claude-projects
SFILES=$SERVER_DATA/files SFILES=$SERVER_DATA/files
files() { api -X POST "$URL/api/sync" -d '{"since": 0, "files": []}' | python3 -c 'import json,sys; print(sorted((f["id"], bool(f.get("deleted"))) for f in json.load(sys.stdin)["files"]))'; } files() { api -X POST "$URL/api/sync" -d '{"since": 0, "files": []}' | python3 -c 'import json,sys; print(sorted((f["id"], bool(f.get("deleted"))) for f in json.load(sys.stdin)["files"]))'; }
same() { cmp -s "$SFILES/$1" "$FILES/$1" && echo gleich || echo verschieden; } same() { cmp -s "$SFILES/$1" "$FILES/$1" && echo gleich || echo verschieden; }
# Lauf wie vom Timer: Ruhezeit gilt # Lauf wie vom Timer
timercycle() { "$E" "$PRE ext._sync._filesCycle(); 'ok'" >/dev/null; sleep 3; } timercycle() { "$E" "$PRE ext._sync._filesCycle(); 'ok'" >/dev/null; sleep 3; }
# Wie make sync-files, nur auf dem Bus der Test-Shell: erzwungen, wartet, bis der Lauf durch ist. # Wie make sync-files, nur auf dem Bus der Test-Shell: wartet, bis der Lauf durch ist.
filescycle() { DBUS_SESSION_BUS_ADDRESS=$(cat /tmp/desktop-app-nested/bus) gdbus call --session --dest org.gnome.Shell --object-path /de/marha/desktop --method de.marha.Desktop.SyncFiles | sed -e "s/^('//" -e "s/',)$//"; } filescycle() { DBUS_SESSION_BUS_ADDRESS=$(cat /tmp/desktop-app-nested/bus) gdbus call --session --dest org.gnome.Shell --object-path /de/marha/desktop --method de.marha.Desktop.SyncFiles | sed -e "s/^('//" -e "s/',)$//"; }
mkdir -p "$FILES/-home-x/memory" mkdir -p "$FILES/-home-x/memory"
printf '{"a":1}\n' > "$FILES/-home-x/alt.jsonl"; touch -d '2 hours ago' "$FILES/-home-x/alt.jsonl" printf '{"a":1}\n' > "$FILES/-home-x/alt.jsonl"; touch -d '2 hours ago' "$FILES/-home-x/alt.jsonl"
printf '# Memo\n' > "$FILES/-home-x/memory/MEMORY.md"; touch -d '90 minutes ago' "$FILES/-home-x/memory/MEMORY.md" printf '# Memo\n' > "$FILES/-home-x/memory/MEMORY.md"; touch -d '90 minutes ago' "$FILES/-home-x/memory/MEMORY.md"
printf 'läuft noch\n' > "$FILES/-home-x/frisch.jsonl" printf 'läuft noch\n' > "$FILES/-home-x/frisch.jsonl"
timercycle timercycle
check "Timer-Lauf: ruhende Dateien auf dem Server, frische nicht" "$(files)" "[('-home-x/alt.jsonl', False), ('-home-x/memory/MEMORY.md', False)]" check "Timer-Lauf: alle Dateien sofort auf dem Server, auch die frische" "$(files)" "[('-home-x/alt.jsonl', False), ('-home-x/frisch.jsonl', False), ('-home-x/memory/MEMORY.md', False)]"
check "Inhalt liegt als Datei auf dem Server" "$(same -home-x/alt.jsonl) $(same -home-x/memory/MEMORY.md)" "gleich gleich" check "Inhalt liegt als Datei auf dem Server" "$(same -home-x/alt.jsonl) $(same -home-x/memory/MEMORY.md) $(same -home-x/frisch.jsonl)" "gleich gleich gleich"
check "Takt nach Übertragung wie beim Kalender" "$(val 'ext._sync.interval')" "1" check "Takt nach Übertragung wie beim Kalender" "$(val 'ext._sync.interval')" "1"
check "make sync-files erzwingt auch die frische Datei" "$(filescycle)" "hoch 1, gelöscht gemeldet 0, runter 0, lokal gelöscht 0" check "make sync-files hat danach nichts mehr zu tun" "$(filescycle)" "hoch 0, gelöscht gemeldet 0, runter 0, lokal gelöscht 0"
check "Frische Datei jetzt auf dem Server" "$(files)" "[('-home-x/alt.jsonl', False), ('-home-x/frisch.jsonl', False), ('-home-x/memory/MEMORY.md', False)]"
check "Normale Anfrage bekommt keine Dateien" "$(api -X POST "$URL/api/sync" -d '{"since": 0}' | python3 -c 'import json,sys; d=json.load(sys.stdin); print("files" in d, "events" in d)')" "False True" check "Normale Anfrage bekommt keine Dateien" "$(api -X POST "$URL/api/sync" -d '{"since": 0}' | python3 -c 'import json,sys; d=json.load(sys.stdin); print("files" in d, "events" in d)')" "False True"
check "Stand der Dateien mit Größe gemerkt" "$(val 'Object.keys(state().files.pushed).sort().join(",") + " " + (state().files.cursor > 0) + " " + state().files.pushed["-home-x/alt.jsonl"].size')" "-home-x/alt.jsonl,-home-x/frisch.jsonl,-home-x/memory/MEMORY.md true 8" check "Stand der Dateien mit Größe gemerkt" "$(val 'Object.keys(state().files.pushed).sort().join(",") + " " + (state().files.cursor > 0) + " " + state().files.pushed["-home-x/alt.jsonl"].size')" "-home-x/alt.jsonl,-home-x/frisch.jsonl,-home-x/memory/MEMORY.md true 8"
printf '{"a":2}\n' >> "$FILES/-home-x/alt.jsonl"; touch -d '2 minutes ago' "$FILES/-home-x/alt.jsonl" printf '{"a":2}\n' >> "$FILES/-home-x/alt.jsonl"; touch -d '2 minutes ago' "$FILES/-home-x/alt.jsonl"
timercycle timercycle
check "Angehängte Zeile nach einer Minute Ruhe auf dem Server" "$(same -home-x/alt.jsonl) $(val 'state().files.pushed["-home-x/alt.jsonl"].size')" "gleich 16" check "Angehängte Zeile auf dem Server" "$(same -home-x/alt.jsonl) $(val 'state().files.pushed["-home-x/alt.jsonl"].size')" "gleich 16"
printf 'neu\n' > "$FILES/-home-x/alt.jsonl"; touch -d '2 minutes ago' "$FILES/-home-x/alt.jsonl" printf 'neu\n' > "$FILES/-home-x/alt.jsonl"; touch -d '2 minutes ago' "$FILES/-home-x/alt.jsonl"
timercycle timercycle
check "Umgeschriebene Datei ganz ersetzt" "$(same -home-x/alt.jsonl) $(cat "$SFILES/-home-x/alt.jsonl")" "gleich neu" check "Umgeschriebene Datei ganz ersetzt" "$(same -home-x/alt.jsonl) $(cat "$SFILES/-home-x/alt.jsonl")" "gleich neu"