mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(docsync): read the retry limit back out, debounce the webhook, and cover the UI
The attempt counter and the backoff were written on every failure and never consulted when planning the next run: rows were found in a live database at 46 attempts against a limit of 6, so a document a provider refuses — one it reads as corrupt, a type it will not take, a full quota — was re-uploaded on every cron tick forever. planReconcile now skips a row that is shelved or still inside its window, and a manual run clears the counters, which is what "waits for a person" has to mean. The webhook had the same shape of bug: WEBHOOK_NUDGE_DEBOUNCE_SECONDS existed, with a comment explaining it, and nothing read it. Providers fire once per document, so twenty files dropped into a watched folder were twenty runs — the in-flight guard answered `busy` to nineteen of them, throwing away nineteen announcements, and the one run that did start had begun before most of the changes landed. A burst now folds into one trailing run, and the binding is re-read when the timer fires so a sync switched off in between does not get a last run out of a stale row. Four defects the new suites turned up, all fixed: the sync button lost its accessible name below sm, where its label is hidden; the stored address was never shown in the connect dialog although the form kept submitting it; the allow-self-signed switch only read the connection at mount, so a stored "on" showed as off and the next save wrote that back; and a refused folder create escaped as an unhandled rejection with nothing said to anybody. Also here: the phone shell gets the same feature (one sheet, four views, the same hook as the desktop), the marks come from the product's own icon files rather than redrawn approximations, and the 22 non-English locales carry the new keys. ADMIN-SVC-069 asserted that `documents` has no MCP surface. It does now, so the test takes its example from MCP_GATED_ADDON_IDS instead of naming one.
This commit is contained in:
@@ -162,6 +162,7 @@ export class DocSyncService {
|
||||
direction: link.direction as 'both' | 'pull' | 'push',
|
||||
remoteTruncated: listing.data.truncated,
|
||||
stableRemoteIds: provider.capabilities(ref).stableId,
|
||||
maxAttempts: ITEM_MAX_ATTEMPTS,
|
||||
});
|
||||
|
||||
if (plan.massDeleteGuardTripped) {
|
||||
@@ -440,13 +441,32 @@ export class DocSyncService {
|
||||
return 'pushed';
|
||||
}
|
||||
|
||||
/**
|
||||
* Put the shelved rows of one binding back in the queue.
|
||||
*
|
||||
* A row that has used up its attempts stays put until a person says otherwise,
|
||||
* and pressing "Sync now" is that person saying otherwise: they have taken the
|
||||
* bad document out of the folder, cleared the quota, or fixed the permission,
|
||||
* and the only way to find out is to try again. The scheduler never calls
|
||||
* this — automatic retries are exactly what the limit exists to stop.
|
||||
*/
|
||||
retryShelvedItems(linkId: number): void {
|
||||
this.db.connection
|
||||
.prepare(
|
||||
`UPDATE document_sync_items
|
||||
SET attempts = 0, next_attempt_at = NULL
|
||||
WHERE link_id = ? AND state = 'error'`,
|
||||
)
|
||||
.run(linkId);
|
||||
}
|
||||
|
||||
// ── State loading and writing ──────────────────────────────────────────────
|
||||
|
||||
private loadItems(linkId: number): SyncItemState[] {
|
||||
const rows = this.db.connection
|
||||
.prepare(
|
||||
`SELECT id, file_id, trek_doc_uid, remote_id, remote_version, remote_name, content_sha256,
|
||||
pushed_sha256, state, attempts, remote_missing_at
|
||||
pushed_sha256, state, attempts, next_attempt_at, remote_missing_at
|
||||
FROM document_sync_items WHERE link_id = ?`,
|
||||
)
|
||||
.all(linkId) as Array<Record<string, unknown>>;
|
||||
@@ -461,6 +481,7 @@ export class DocSyncService {
|
||||
pushedSha256: r.pushed_sha256 === null ? null : String(r.pushed_sha256),
|
||||
state: String(r.state),
|
||||
attempts: Number(r.attempts ?? 0),
|
||||
nextAttemptAt: r.next_attempt_at === null || r.next_attempt_at === undefined ? null : String(r.next_attempt_at),
|
||||
remoteMissingAt: r.remote_missing_at === null ? null : String(r.remote_missing_at),
|
||||
}));
|
||||
}
|
||||
@@ -694,8 +715,48 @@ export class DocSyncService {
|
||||
const counts = this.db.connection
|
||||
.prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state')
|
||||
.all(tripId) as Array<{ state: string; n: number }>;
|
||||
|
||||
/**
|
||||
* What each side is actually holding, per binding.
|
||||
*
|
||||
* The UI showed the last run's transfer counts, which are zero on a binding
|
||||
* that is already in step — so the interesting screen said nothing. These
|
||||
* are the standing numbers instead: how many documents this trip has, how
|
||||
* many of them the store has, and how many are only on one side.
|
||||
*/
|
||||
const totalHere = this.db.connection
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS n FROM trip_files
|
||||
WHERE trip_id = ? AND deleted_at IS NULL AND message_id IS NULL AND note_id IS NULL`,
|
||||
)
|
||||
.get(tripId) as { n: number };
|
||||
|
||||
const perLink = this.db.connection
|
||||
.prepare(
|
||||
`SELECT link_id,
|
||||
SUM(CASE WHEN file_id IS NOT NULL AND remote_id IS NOT NULL AND state = 'synced' THEN 1 ELSE 0 END) AS paired,
|
||||
SUM(CASE WHEN remote_id IS NOT NULL AND state != 'remote_missing' THEN 1 ELSE 0 END) AS atProvider,
|
||||
SUM(CASE WHEN state = 'remote_missing' THEN 1 ELSE 0 END) AS missing
|
||||
FROM document_sync_items
|
||||
WHERE trip_id = ?
|
||||
GROUP BY link_id`,
|
||||
)
|
||||
.all(tripId) as Array<{ link_id: number; paired: number; atProvider: number; missing: number }>;
|
||||
|
||||
const byLink = new Map(perLink.map((r) => [r.link_id, r]));
|
||||
return {
|
||||
links: links.map((l) => this.config.publicLink(l, null)),
|
||||
links: links.map((l) => {
|
||||
const row = byLink.get(l.id);
|
||||
return {
|
||||
...this.config.publicLink(l, null),
|
||||
holdings: {
|
||||
inTrek: totalHere.n,
|
||||
atProvider: Number(row?.atProvider ?? 0),
|
||||
paired: Number(row?.paired ?? 0),
|
||||
missing: Number(row?.missing ?? 0),
|
||||
},
|
||||
};
|
||||
}),
|
||||
items: Object.fromEntries(counts.map((c) => [c.state, c.n])),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user