M10.7e: desktop Sync settings screen (task 2108)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 33s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 1m32s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m4s

The surface that turns the engine into a feature (rule 27). Desktop-only —
the web build IS a server's UI, so a "connect a server" screen there would be
nonsense; the route redirects to the board and the nav entry is hidden.

UNLINKED IS THE RESTING STATE, not an incomplete setup. The empty case leads
with "Working offline on this device — everything works without a server",
because a screen that framed the default as a problem would push people into
configuring something they may never need. The app is local-first; this is
opt-in.

Probe before credentials. "Check" shows who actually answered — site name,
version, and the M10.6 verdict — before any password or token is typed. An
incompatible server is shown in red and the sign-in fields never appear, so
you cannot hand a credential to something that can't use it. `degraded` names
the missing capabilities rather than staying quiet and letting a feature
mysteriously do nothing.

Both credential paths, matching the Rust side: email+password (a fresh
install has no session to mint a token from) or a pasted device token (for
anyone who'd rather not type a password into a desktop app). Secrets are
cleared from component state the moment they're exchanged.

Disconnect states plainly that the token stays valid server-side and points
at Account -> Linked devices, rather than implying a remote revoke that
didn't happen (issue 2110). Wording avoids "revoke" for exactly that reason.

Push rejections are surfaced verbatim after a sync, never swallowed — a
duplicate label name is the realistic case and only a person can resolve it.

Adds schema v3: last_sync_at. The cursor can't answer "am I up to date?" —
it's a revision watermark, not a time, and it doesn't move at all when a sync
legitimately finds nothing new, so "synced a moment ago, nothing new" would
be indistinguishable from "never synced". Stamped only after BOTH halves of
the cycle succeed; a stamp after a partial cycle would claim currency the
data doesn't have. Cleared on unlink so a new server can't inherit it.

run_cycle now returns the post-cycle status, so the UI updates from one
round-trip instead of chasing every sync with a status call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SreJkbxB4gx8pPsu8QbLPi
This commit is contained in:
2026-07-26 00:30:37 -04:00
co-authored by Claude Opus 5
parent 75b2d096ec
commit fe683595df
8 changed files with 532 additions and 4 deletions
+31 -2
View File
@@ -18,6 +18,7 @@ pub struct SyncState {
pub server_url: Option<String>,
pub device_token: Option<String>,
pub last_cursor: i64,
pub last_sync_at: Option<String>,
}
impl SyncState {
@@ -37,6 +38,7 @@ pub struct Status {
pub linked: bool,
pub server_url: Option<String>,
pub last_cursor: i64,
pub last_sync_at: Option<String>,
}
impl From<&SyncState> for Status {
@@ -45,6 +47,7 @@ impl From<&SyncState> for Status {
linked: s.is_linked(),
server_url: s.server_url.clone(),
last_cursor: s.last_cursor,
last_sync_at: s.last_sync_at.clone(),
}
}
}
@@ -56,11 +59,12 @@ fn present(value: Option<String>) -> Option<String> {
pub fn read(conn: &Connection) -> rusqlite::Result<SyncState> {
conn.query_row(
"SELECT server_url, device_token, last_cursor FROM sync_state WHERE id = 1",
"SELECT server_url, device_token, last_cursor, last_sync_at FROM sync_state WHERE id = 1",
[],
|row| {
let cursor: Option<String> = row.get(2)?;
Ok(SyncState {
last_sync_at: present(row.get(3)?),
server_url: present(row.get(0)?),
device_token: present(row.get(1)?),
// Stored TEXT (schema) but used as an integer watermark. Absent or
@@ -101,13 +105,26 @@ pub fn set_link(conn: &Connection, server_url: &str, device_token: &str) -> rusq
pub fn clear_link(conn: &Connection) -> rusqlite::Result<()> {
conn.execute(
"UPDATE sync_state
SET server_url = NULL, device_token = NULL, last_cursor = NULL
SET server_url = NULL, device_token = NULL, last_cursor = NULL,
last_sync_at = NULL
WHERE id = 1",
[],
)?;
Ok(())
}
/// Stamp a completed sync. The cursor can't stand in for this: it's a revision
/// watermark, and it doesn't move at all when a sync correctly finds nothing new —
/// so "synced a moment ago, no changes" would be indistinguishable from "never
/// synced" without it.
pub fn mark_synced(conn: &Connection, when: &str) -> rusqlite::Result<()> {
conn.execute(
"UPDATE sync_state SET last_sync_at = ?1 WHERE id = 1",
params![when],
)?;
Ok(())
}
/// Advance the consumed-change watermark. Called by the pull loop (M10.7b) only
/// after a page has been fully applied.
pub fn set_cursor(conn: &Connection, cursor: i64) -> rusqlite::Result<()> {
@@ -196,6 +213,18 @@ mod tests {
assert!(state.device_token.is_none());
}
#[test]
fn unlink_clears_the_last_sync_stamp() {
// Otherwise a freshly-linked server would claim it synced at a time that
// belonged to a different one.
let conn = db();
set_link(&conn, "https://a.example.com", "tok-1").expect("link");
mark_synced(&conn, "2026-07-26T04:00:00.000Z").expect("stamp");
assert!(read(&conn).expect("read").last_sync_at.is_some());
clear_link(&conn).expect("unlink");
assert!(read(&conn).expect("read").last_sync_at.is_none());
}
#[test]
fn half_written_link_is_not_linked() {
let conn = db();