Merge branch 'main' into claude/m3-implementation-57f9d7
ci / build and test (push) Failing after 2s

Three files conflicted, and two of the resolutions are more than a choice of
side.

QuickConnectTests had both branches fixing the same build break — main's M2
merge left the shell's constructor with an ISftpSessionFactory nobody passed.
Main's version wins because it carries a comment saying why the palette never
needs a session.

VaultSession's conflict is adjacent edits: main added the remembered sign-in
members and this branch changed SyncAsync's summary from "the active vault" to
"one vault". Both kept.

VaultViewModel is the one that matters. Main taught the background pass to
report a sync that had to start over, on the grounds that a machine which
silently re-read a whole vault has had something happen to it; this branch
turned a pass into one report per readable vault. Taking either side alone
would have lost the other, so ResyncedFromStart is now one of the conditions
IsWorthReporting checks, per vault.

Merging also broke something neither branch could have caught alone, and the
build would not have said a word. SyncOnceAsync cleared LastSyncFailed
unconditionally, which was right while a pass was one vault and a failure was
an exception that never reached that line. A failure is now a report — one
unreachable team vault must not stop the others syncing — so the flag was being
cleared over a vault that had just failed, lighting the titlebar SYNCED. It is
computed from the report instead, in the one place both callers go through, so
the manual command gets it as well as the loop. The background pass still
swallows the message and keeps the fact, which is what
AnAutomaticPassThatFails_LeavesTheStatusAlone is there to hold it to.

Two comments the auto-merge left describing a world with one vault in it: the
SCOPES rail's, which said team vaults are refused by the access service, and
the host sidebar's "One heading, for one vault".
This commit is contained in:
2026-07-31 12:26:59 +02:00
42 changed files with 3708 additions and 155 deletions
+85 -4
View File
@@ -129,12 +129,30 @@ public sealed class SyncEngine
{
var state = await syncState.ReadAsync(vaultId, cancellationToken).ConfigureAwait(false);
// At most one restart per pull. A server that rejects the cursor it has just issued is not
// telling this client anything it can act on, and replaying the whole log against it would turn
// one broken deployment into an unbounded amount of work.
var alreadyStartedOver = false;
for (var page = 0; page < options.MaxPullPages; page++)
{
var response = await api.SyncPullAsync(
vaultId,
new SyncPullRequest(state.Cursor, options.PullPageSize, ItemKinds.SyncedTypes),
cancellationToken).ConfigureAwait(false);
SyncPullResponse response;
try
{
response = await api.SyncPullAsync(
vaultId,
new SyncPullRequest(state.Cursor, options.PullPageSize, ItemKinds.SyncedTypes),
cancellationToken).ConfigureAwait(false);
}
catch (DodoSshApiException exception)
when (!alreadyStartedOver && WasTheCursorRefused(exception, state.Cursor))
{
alreadyStartedOver = true;
state = await StartOverAsync(state, report, cancellationToken).ConfigureAwait(false);
continue;
}
foreach (var change in response.Changes)
{
@@ -161,6 +179,69 @@ public sealed class SyncEngine
}
}
/// <summary>
/// Whether the server refused the cursor this client sent, rather than failing for some other reason.
/// </summary>
/// <remarks>
/// Read from the problem code and not from the prose, which is free to change, and never claimed for a
/// request that carried no cursor: "from the beginning" is the one position a client is allowed to ask
/// for, so a rejection of <em>that</em> is a server this code cannot reason about and has to surface.
/// It is also what keeps the retry from looping — the restarted request sends no cursor.
/// </remarks>
private static bool WasTheCursorRefused(DodoSshApiException exception, string? cursor) =>
!string.IsNullOrEmpty(cursor)
&& string.Equals(exception.Code, ProblemCodes.InvalidCursor, StringComparison.Ordinal);
/// <summary>
/// Forgets this vault's position and reads the log again from the beginning.
/// </summary>
/// <remarks>
/// <para>
/// A cursor the server will not accept is not a transient failure. Every later pass reads the same
/// stored cursor and is refused the same way, so a vault that met one stayed there for good — pulling
/// nothing, and pushing nothing either, because the pass threw before it reached the outbox. The user
/// saw a 400 saying to resync from the beginning and had no way to do it. This is that way.
/// </para>
/// <para>
/// The causes are all on the far side: a rotated cursor signing key, a vault served from a restored
/// database whose sequences no longer reach that far, a cache copied between machines. None of them is
/// something the person at the keyboard did, and none of them is something they could act on if asked.
/// </para>
/// <para>
/// <b>The mirror is deliberately kept.</b> Replaying from the beginning rewrites every row the server
/// still has — applying a change is a blind overwrite — so the re-pull repairs the mirror as it goes.
/// Clearing it first would claim more than the evidence supports: the position was refused, not the
/// contents, and a machine that loses its connection halfway through the replay would be left with
/// less than it started with. <c>SyncStateStore.ResetAsync</c> is the heavier remedy, for when the
/// cache itself is the thing in doubt.
/// </para>
/// <para>
/// That leaves one gap, and it is worth naming rather than leaving to be discovered: once the server
/// starts collecting tombstones — <c>DodoOptions.TombstoneRetentionDays</c>, not implemented yet — a
/// replay no longer carries a deletion older than the retention window. A machine that missed such a
/// delete and then had its cursor refused would keep the row. Nothing here can tell that from a row
/// the server still has, so the answer when it matters will be to clear the mirror as well, not to
/// guess.
/// </para>
/// <para>
/// Written down before the replay begins, so a process that dies mid-replay starts the next one from
/// the beginning as well, rather than meeting the same refusal again.
/// </para>
/// </remarks>
private async Task<StoredSyncState> StartOverAsync(
StoredSyncState state,
SyncReportBuilder report,
CancellationToken cancellationToken)
{
var restarted = state with { Cursor = null };
await syncState.SaveAsync(restarted, cancellationToken).ConfigureAwait(false);
report.ResyncedFromStart = true;
return restarted;
}
private StoredSyncState Record(
Guid vaultId,
StoredSyncState state,
+15 -2
View File
@@ -75,6 +75,15 @@ public sealed record SyncOptions
/// True when the push loop hit <see cref="SyncOptions.MaxPushRounds"/> with work still outstanding. Not
/// a failure: the next pass continues from here.
/// </param>
/// <param name="ResyncedFromStart">
/// True when the server refused this machine's stored position and the whole log was read again from the
/// beginning.
/// <para>
/// Deliberately absent from <see cref="NeedsAttention"/>. Nothing is outstanding and nothing was lost —
/// the pass recovered on its own — but it explains a sync that pulled the entire vault on a day nobody
/// changed anything, which is otherwise the sort of thing that looks like a fault.
/// </para>
/// </param>
public sealed record SyncReport(
Guid VaultId,
int Pulled,
@@ -87,7 +96,8 @@ public sealed record SyncReport(
uint ServerKeyGeneration,
bool RekeyRequired,
long ServerTimeSkewMs,
bool RoundsExhausted)
bool RoundsExhausted,
bool ResyncedFromStart)
{
/// <summary>Whether anything happened that a user should be told about.</summary>
public bool NeedsAttention =>
@@ -119,6 +129,8 @@ internal sealed class SyncReportBuilder(Guid vaultId)
internal bool RoundsExhausted { get; set; }
internal bool ResyncedFromStart { get; set; }
internal SyncReport Build() =>
new(
vaultId,
@@ -132,7 +144,8 @@ internal sealed class SyncReportBuilder(Guid vaultId)
ServerKeyGeneration,
RekeyRequired,
ServerTimeSkewMs,
RoundsExhausted);
RoundsExhausted,
ResyncedFromStart);
}
/// <summary>The record written to the conflict log when a merge had to override something.</summary>