This commit is contained in:
team3
2026-10-05 21:15:41 +02:00
parent 24efa2f4ee
commit 16630f0368
10 changed files with 364 additions and 127 deletions

View File

@@ -1,9 +1,14 @@
// 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.
//
// Regeln: Eine Datei geht hoch, wenn sie sich geändert hat und seit einer Stunde nicht angefasst wurde.
// Vom Server kommt sie, wenn sie dort neuer ist. Bei Konflikten gewinnt die neuere Änderungszeit.
// Verschwundene Dateien werden als Tombstone gemeldet, gelöschte vom Server lokal entfernt.
// Regeln: Eine Datei geht hoch, wenn sie sich geändert hat und seit einer Minute nicht angefasst wurde.
// 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
// 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.
import GLib from 'gi://GLib';
export const TAIL = 64 * 1024;
// Zeitstempel aus der Änderungszeit der Datei, gleiche Form wie updated bei Terminen
export function fileStamp(mtimeSeconds) {
@@ -14,7 +19,19 @@ export function stampToSeconds(stamp) {
return Math.floor(Date.parse(stamp) / 1000);
}
// Was hochgeladen wird. local: Map id → mtime in Sekunden. pushed: id → updated, null = als gelöscht gemeldet.
// Hash der letzten 64 KB, gleiche Rechnung wie auf dem Server
export function tailHash(bytes) {
const tail = bytes.length > TAIL ? bytes.subarray(bytes.length - TAIL) : bytes;
return GLib.compute_checksum_for_bytes(GLib.ChecksumType.SHA256, new GLib.Bytes(tail));
}
// pushed[id] ist der Stand auf dem Server: {updated, size, hash}. Ältere Stände sind nur der
// Zeitstempel als String, null heißt als gelöscht gemeldet.
export function knownUpdated(known) {
return known && typeof known === 'object' ? known.updated : known;
}
// Was hochgeladen wird. local: Map id → mtime in Sekunden.
// upload: Einträge ohne Inhalt, remove: IDs, die es lokal nicht mehr gibt.
export function planPush(local, pushed, nowSeconds, idleSeconds) {
const upload = [];
@@ -22,13 +39,31 @@ export function planPush(local, pushed, nowSeconds, idleSeconds) {
if (nowSeconds - mtime < idleSeconds)
continue;
const updated = fileStamp(mtime);
if (pushed[id] !== updated)
if (knownUpdated(pushed[id]) !== updated)
upload.push({id, updated});
}
const remove = Object.keys(pushed).filter(id => pushed[id] !== null && !local.has(id));
return {upload, remove};
}
// Eintrag für den Server aus den Bytes der Datei: nur der Rest, wenn der bekannte Anfang noch stimmt,
// sonst die ganze Datei. null, wenn sie kein UTF-8 ist.
export function uploadItem(id, updated, bytes, known) {
const decoder = new TextDecoder('utf-8', {fatal: true});
if (known && typeof known === 'object' && known.size < bytes.length && tailHash(bytes.subarray(0, known.size)) === known.hash) {
try {
return {id, updated, base: {size: known.size, hash: known.hash}, append: decoder.decode(bytes.subarray(known.size))};
} catch {
// Schnitt mitten im Zeichen: dann eben ganz
}
}
try {
return {id, updated, content: decoder.decode(bytes)};
} catch {
return null;
}
}
// Was mit Einträgen vom Server passiert: write, delete oder skip.
// mine: der lokale Zeitstempel oder null, damit sync.js den Stand nachhalten kann.
export function planPull(remote, local) {
@@ -63,3 +98,10 @@ export function batches(items, sizeOf, maxBytes) {
out.push(current);
return out;
}
export function concat(a, b) {
const out = new Uint8Array(a.length + b.length);
out.set(a, 0);
out.set(b, a.length);
return out;
}

View File

@@ -2,7 +2,7 @@ import Gio from 'gi://Gio';
import GLib from 'gi://GLib';
import Soup from 'gi://Soup';
import {batches, fileStamp, planPull, planPush, stampToSeconds} from './files.js';
import {batches, concat, planPull, planPush, stampToSeconds, tailHash, uploadItem} from './files.js';
import {readJson, writeJson} from './store.js';
// Abgleich mit dem Server. Zugang steht in sync.json im Datenordner:
@@ -23,11 +23,11 @@ const INTERVALS = [
// Kurze Wartezeit nach einer eigenen Änderung, damit mehrere Klicks in einem Lauf landen
const SOON_MS = 500;
// Dateien (Claude-Sitzungen und Memory): alle 10 Minuten, hoch geht nur, was eine Stunde ruht.
// Dateien (Claude-Sitzungen und Memory): gleicher Takt wie die Einträge, hoch geht, was eine Minute ruht.
// Meist nur der neue Rest einer Datei, siehe files.js.
// "files" in sync.json: false schaltet ab, ein Pfad ersetzt ~/.claude/projects.
const FILES_FIRST_SECONDS = 60;
const FILES_INTERVAL_SECONDS = 10 * 60;
const FILES_IDLE_SECONDS = 60 * 60;
const FILES_FIRST_SECONDS = 10;
const FILES_IDLE_SECONDS = 60;
const FILES_BATCH_BYTES = 16 * 1024 * 1024;
// Von außen anstoßbar: make sync-files ruft diese Methode über D-Bus auf
@@ -267,16 +267,21 @@ export class Sync {
}
// ── Dateien ──────────────────────────────────────────────────────────────
// Nach jedem Lauf neu planen, im Takt der Einträge (1 s, 5 s, 10 s je nach Aktivität).
_scheduleFiles(seconds) {
if (this._stopped)
return;
this._filesTimerId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, seconds, () => {
this._filesTimerId = null;
this._filesCycle().catch(e => this._fail(e.message)).finally(() => this._scheduleFiles(FILES_INTERVAL_SECONDS));
this._filesCycle().catch(e => this._fail(e.message)).finally(() => this._scheduleFiles(this.interval));
return GLib.SOURCE_REMOVE;
});
}
_path(id) {
return GLib.build_filenamev([this._filesRoot, id]);
}
// 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.
// force: Ruhezeit ignorieren, alles Geänderte geht hoch.
@@ -291,50 +296,64 @@ export class Sync {
const now = Math.floor(GLib.get_real_time() / 1e6);
const {upload, remove} = planPush(local, state.pushed, now, force ? 0 : FILES_IDLE_SECONDS);
const items = [];
for (const entry of upload) {
const content = readText(GLib.build_filenamev([this._filesRoot, entry.id]));
if (content !== null)
items.push({...entry, content});
for (const {id, updated} of upload) {
const bytes = readBytes(this._path(id));
const item = bytes && uploadItem(id, updated, bytes, state.pushed[id]);
if (item)
items.push(Object.assign(item, {_size: bytes.length, _hash: tailHash(bytes)}));
}
const nowStamp = new Date(now * 1000).toISOString();
for (const id of remove)
items.push({id, updated: nowStamp, deleted: true});
// Hoch in Paketen, runter in Paketen: solange der Server "more" meldet, weiter fragen
const packets = batches(items, item => (item.content ?? '').length, FILES_BATCH_BYTES);
let index = 0;
let more = true;
while (index < packets.length || more) {
const files = packets[index++] ?? [];
const before = state.cursor;
// Mindestens ein Lauf, auch ohne eigene Änderungen: holt Neues vom Server
const packets = items.length ? batches(items, item => (item.content ?? item.append ?? '').length, FILES_BATCH_BYTES) : [[]];
for (const packet of packets) {
const files = packet.map(({_size, _hash, ...item}) => item);
const response = await this._post({since: state.cursor, files}, this._filesSession);
// Ein Server ohne Dateien-Sammlung würde alles verwerfen, der Stand bliebe sonst falsch
if (!Array.isArray(response.files))
throw new Error('Server kennt keine Dateien, dort erst make prod ausführen');
more = Boolean(response.more) && response.cursor !== before;
for (const item of files) {
state.pushed[item.id] = item.deleted ? null : item.updated;
for (const item of packet) {
// Kommt der Eintrag mit anderem updated zurück, hat der Server abgelehnt. Sein Stand gilt dann als bekannt,
// beim nächsten Lauf passt der Anhang oder es geht die ganze Datei.
const echo = response.files.find(f => f.id === item.id && f.updated !== item.updated);
if (echo) {
state.pushed[item.id] = echo.deleted ? null : {updated: echo.updated, size: echo.size, hash: echo.hash};
continue;
}
state.pushed[item.id] = item.deleted ? null : {updated: item.updated, size: item._size, hash: item._hash};
result[item.deleted ? 'removed' : 'uploaded']++;
}
this._applyFiles(response, local, result);
await this._applyFiles(response, local, result);
writeJson(this._statePath, this._state);
}
if (result.uploaded + result.removed + result.written + result.deleted)
this._touchActivity();
} finally {
this._filesBusy = false;
}
return result;
}
_applyFiles(response, local, result) {
async _applyFiles(response, local, result) {
const state = this._state.files;
for (const {file, action, mine} of planPull(response.files ?? [], local)) {
const path = GLib.build_filenamev([this._filesRoot, file.id]);
for (const {file, action, mine} of planPull(response.files, local)) {
const path = this._path(file.id);
if (action === 'write') {
// Nur den Rest holen, wenn der eigene Anfang noch zum Server passt
const have = readBytes(path) ?? new Uint8Array(0);
const {bytes, from} = await this._download(file.id, have.length, tailHash(have));
const appended = from > 0 && from === have.length;
const content = appended ? concat(have, bytes) : bytes;
GLib.mkdir_with_parents(GLib.path_get_dirname(path), 0o700);
GLib.file_set_contents(path, file.content);
if (appended)
appendBytes(path, bytes);
else
GLib.file_set_contents(path, content);
const seconds = stampToSeconds(file.updated);
Gio.File.new_for_path(path).set_attribute_uint64('time::modified', seconds, Gio.FileQueryInfoFlags.NONE, null);
local.set(file.id, seconds);
state.pushed[file.id] = file.updated;
state.pushed[file.id] = {updated: file.updated, size: content.length, hash: tailHash(content)};
result.written++;
} else if (action === 'delete') {
GLib.unlink(path);
@@ -343,14 +362,36 @@ export class Sync {
result.deleted++;
} else if (file.deleted && mine === null) {
state.pushed[file.id] = null;
} else if (mine === file.updated) {
state.pushed[file.id] = mine;
} else if (mine === file.updated && typeof state.pushed[file.id] !== 'object') {
// Gleicher Stand wie der Server, aber noch ohne Größe und Hash gemerkt
const bytes = readBytes(path);
if (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 Stunde ruht.
// Sonst ist die lokale Datei neuer und geht hoch, sobald sie eine Minute ruht.
}
state.cursor = response.cursor;
}
// Inhalt vom Server ab dem eigenen Stand. X-From sagt, ab wo er wirklich liefert (0 = ganze Datei).
_download(id, from, hash) {
const path = id.split('/').map(encodeURIComponent).join('/');
const message = Soup.Message.new('GET', `${this._config.url}/api/files/${path}?from=${from}&hash=${hash}`);
message.request_headers.append('Authorization', `Bearer ${this._config.token}`);
return new Promise((resolve, reject) => {
this._filesSession.send_and_read_async(message, GLib.PRIORITY_DEFAULT, null, (session, result) => {
try {
const bytes = session.send_and_read_finish(result);
if (message.get_status() !== Soup.Status.OK)
throw new Error(`HTTP ${message.get_status()} ${message.get_reason_phrase()}`);
resolve({bytes: bytes.get_data() ?? new Uint8Array(0), from: Number(message.get_response_headers().get_one('X-From'))});
} catch (e) {
reject(e);
}
});
});
}
_fail(message) {
if (this._stopped)
return;
@@ -385,12 +426,18 @@ function scanFiles(root) {
return local;
}
// Textdatei lesen. null, wenn sie fehlt oder kein UTF-8 ist.
function readText(path) {
// Datei als Bytes lesen. null, wenn sie fehlt.
function readBytes(path) {
try {
const [, bytes] = GLib.file_get_contents(path);
return new TextDecoder('utf-8', {fatal: true}).decode(bytes);
return bytes;
} catch {
return null;
}
}
function appendBytes(path, bytes) {
const stream = Gio.File.new_for_path(path).append_to(Gio.FileCreateFlags.NONE, null);
stream.write_bytes(new GLib.Bytes(bytes), null);
stream.close(null);
}