feat: add server time to obsidian connect responses and enhance error handling

- Included server_time_utc in the connect response schema for better synchronization.
- Updated obsidian_connect function to set server_time_utc during connection handling.
- Enhanced integration tests to verify the presence of server_time_utc in responses.
- Improved connectivity status recovery in the sync engine for better error management.
This commit is contained in:
Anish Sarkar 2026-04-25 03:57:07 +05:30
parent 937965b335
commit 02795e08e3
6 changed files with 101 additions and 25 deletions

View file

@ -348,6 +348,7 @@ async def obsidian_connect(
connector_id=collision.id,
vault_id=collision_cfg["vault_id"],
search_space_id=collision.search_space_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
await session.commit()
@ -361,6 +362,7 @@ async def obsidian_connect(
connector_id=existing_by_vid.id,
vault_id=payload.vault_id,
search_space_id=existing_by_vid.search_space_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
await session.commit()
@ -379,6 +381,7 @@ async def obsidian_connect(
connector_id=existing_by_fp.id,
vault_id=survivor_cfg["vault_id"],
search_space_id=existing_by_fp.search_space_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
await session.commit()
@ -410,6 +413,7 @@ async def obsidian_connect(
connector_id=inserted.id,
vault_id=payload.vault_id,
search_space_id=inserted.search_space_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
await session.commit()
@ -431,6 +435,7 @@ async def obsidian_connect(
connector_id=winner.id,
vault_id=(winner.config or {})["vault_id"],
search_space_id=winner.search_space_id,
server_time_utc=datetime.now(UTC),
**_build_handshake(),
)
await session.commit()

View file

@ -169,6 +169,7 @@ class ConnectResponse(_PluginBase):
vault_id: str
search_space_id: int
capabilities: list[str]
server_time_utc: datetime
class HealthResponse(_PluginBase):

View file

@ -343,6 +343,7 @@ class TestWireContractSmoke:
assert connect_resp.connector_id > 0
assert connect_resp.vault_id == vault_id
assert "sync" in connect_resp.capabilities
assert connect_resp.server_time_utc is not None
# 2. /sync — stub the indexer so the call doesn't drag the LLM /
# embedding pipeline in. We're testing the wire contract, not the

View file

@ -149,6 +149,7 @@ export default class SurfSensePlugin extends Plugin {
});
const onNetChange = () => {
void this.engine.recoverConnectivityStatus();
if (this.shouldAutoSync()) void this.engine.flushQueue();
};
this.registerDomEvent(window, "online", onNetChange);

View file

@ -1,5 +1,6 @@
import {
type App,
type ButtonComponent,
Notice,
Platform,
PluginSettingTab,
@ -58,6 +59,11 @@ export class SurfSenseSettingTab extends PluginSettingTab {
}),
);
let verifyButton: ButtonComponent | null = null;
const updateVerifyDisabled = (): void => {
verifyButton?.setDisabled(this.plugin.settings.apiToken.trim().length === 0);
};
new Setting(containerEl)
.setName("API token")
.setDesc(
@ -78,29 +84,33 @@ export class SurfSenseSettingTab extends PluginSettingTab {
this.plugin.settings.connectorId = null;
}
this.plugin.settings.apiToken = next;
updateVerifyDisabled();
await this.plugin.saveSettings();
this.plugin.api.resetAuthBlock();
});
})
.addButton((btn) =>
btn
.setButtonText("Verify")
.setCta()
.onClick(async () => {
btn.setDisabled(true);
try {
await this.plugin.api.verifyToken();
new Notice("Surfsense: token verified.");
this.plugin.engine.refreshStatus({ force: true });
await this.refreshSearchSpaces();
this.display();
} catch (err) {
this.handleApiError(err);
} finally {
btn.setDisabled(false);
}
}),
);
.addButton((btn) => {
verifyButton = btn;
updateVerifyDisabled();
btn.setButtonText("Verify").setCta().onClick(async () => {
if (this.plugin.settings.apiToken.trim().length === 0) {
new Notice("Surfsense: paste an API token before verifying.");
return;
}
btn.setDisabled(true);
try {
await this.plugin.api.verifyToken();
new Notice("Surfsense: token verified.");
this.plugin.engine.refreshStatus({ force: true });
await this.refreshSearchSpaces();
this.display();
} catch (err) {
this.handleApiError(err);
} finally {
updateVerifyDisabled();
}
});
});
new Setting(containerEl)
.setName("Search space")
@ -233,12 +243,10 @@ export class SurfSenseSettingTab extends PluginSettingTab {
}),
);
if (Platform.isMobileApp) {
if (Platform.isAndroidApp) {
new Setting(containerEl)
.setName("Sync only on WiFi")
.setDesc(
"Pause automatic syncing on cellular. Note: only Android can detect network type, on iOS this toggle has no effect.",
)
.setDesc("Pause automatic syncing on cellular.")
.addToggle((toggle) =>
toggle
.setValue(settings.wifiOnly)
@ -367,7 +375,13 @@ export class SurfSenseSettingTab extends PluginSettingTab {
}
private handleApiError(err: unknown): void {
if (err instanceof AuthError) return;
if (err instanceof AuthError) {
if (err.message.startsWith("Missing API token")) {
new Notice("Surfsense: paste an API token before verifying.");
}
return;
}
this.plugin.engine.reportError(err);
new Notice(
`SurfSense: request failed — ${(err as Error).message ?? "unknown error"}`,
);

View file

@ -239,7 +239,10 @@ export class SyncEngine {
// ---- queue draining ---------------------------------------------------
async flushQueue(): Promise<void> {
if (this.deps.queue.size === 0) return;
if (this.deps.queue.size === 0) {
await this.recoverStatusIfNeeded();
return;
}
// Shared gate for every flush trigger so the first /sync can't race /connect.
if (!this.deps.getSettings().connectorId) {
const connected = await this.ensureConnected();
@ -259,6 +262,31 @@ export class SyncEngine {
this.setStatus(this.queueStatusKind(), this.statusDetail());
}
/**
* Lightweight status recovery path used after network-change signals.
* Clears stale offline/auth/error only when connectivity/auth is explicitly re-validated.
*/
async recoverConnectivityStatus(): Promise<void> {
const settings = this.deps.getSettings();
if (!settings.apiToken) {
this.refreshStatus({ force: true });
return;
}
if (!settings.searchSpaceId) {
try {
const health = await this.deps.apiClient.health();
this.applyHealth(health);
this.refreshStatus({ force: true });
} catch (err) {
this.handleStartupError(err);
}
return;
}
const connected = await this.ensureConnected();
if (!connected) return;
this.refreshStatus({ force: true });
}
private async processBatch(batch: QueueItem[]): Promise<BatchResult> {
const settings = this.deps.getSettings();
const upserts = batch.filter((b): b is QueueItem & { op: "upsert" } => b.op === "upsert");
@ -510,6 +538,7 @@ export class SyncEngine {
refreshStatus(opts: { force?: boolean } = {}): void {
if (!opts.force) {
const last = this.lastAppliedKind;
if (last === "syncing") return;
const isError =
last === "auth-error" || last === "offline" || last === "error";
const s = this.deps.getSettings();
@ -523,6 +552,18 @@ export class SyncEngine {
this.setStatus("auth-error", message ?? "API token expired or invalid");
}
reportError(err: unknown): void {
if (err instanceof AuthError) {
this.reportAuthError(err.message);
return;
}
if (err instanceof TransientError) {
this.setStatus("offline", err.message);
return;
}
this.setStatus("error", (err as Error).message ?? "Unknown error");
}
private setStatus(kind: StatusKind, detail?: string): void {
const s = this.deps.getSettings();
if (!s.apiToken) {
@ -601,6 +642,19 @@ export class SyncEngine {
this.setStatus(this.queueStatusKind(), `${prefix}: ${(err as Error).message}`);
}
private async recoverStatusIfNeeded(): Promise<void> {
if (!this.isRecoverableErrorState()) return;
await this.recoverConnectivityStatus();
}
private isRecoverableErrorState(): boolean {
return (
this.lastAppliedKind === "offline" ||
this.lastAppliedKind === "auth-error" ||
this.lastAppliedKind === "error"
);
}
// ---- predicates -------------------------------------------------------
private shouldTrack(file: TAbstractFile): boolean {