update
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user