From 61275574d4320aeb56b76b881b6a5beaa8cd9204 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Sun, 31 May 2026 15:24:02 -0400 Subject: [PATCH] feat(ext/sw): get_vault_status handler + cached sync state (Plan C Phase 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Returns cached ahead/behind/lastSyncAt from the GitHost plus a live count of active (non-trashed) manifest items. No network call — sync is user-initiated; the sync handler records lastSyncAt (unix seconds). ahead/behind stay 0 in the extension (writes go straight to the host, no local commit graph) and exist for parity with relicario status. Co-Authored-By: Claude Opus 4.8 --- .../__tests__/vault-status.test.ts | 53 +++++++++++++++++++ extension/src/service-worker/git-host.ts | 9 ++++ extension/src/service-worker/gitea.ts | 3 ++ extension/src/service-worker/github.ts | 3 ++ .../src/service-worker/router/popup-only.ts | 8 +-- extension/src/service-worker/vault.ts | 33 +++++++++++- 6 files changed, 105 insertions(+), 4 deletions(-) create mode 100644 extension/src/service-worker/__tests__/vault-status.test.ts diff --git a/extension/src/service-worker/__tests__/vault-status.test.ts b/extension/src/service-worker/__tests__/vault-status.test.ts new file mode 100644 index 0000000..c9497d8 --- /dev/null +++ b/extension/src/service-worker/__tests__/vault-status.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { handleGetVaultStatus } from '../vault'; +import type { Manifest, ManifestEntry } from '../../shared/types'; + +// The handler only reads gitHost's three cache fields, so the test feeds a +// minimal object — the handler's Pick-typed param makes full GitHost mocking +// unnecessary. +const cache = (lastSyncAt: number | null, ahead = 0, behind = 0) => + ({ lastSyncAt, ahead, behind }); + +function manifestWith(activeCount: number, trashedCount = 0): Manifest { + const items: Record = {}; + for (let i = 0; i < activeCount; i++) { + items[`a${i}`] = { trashed_at: undefined } as ManifestEntry; + } + for (let i = 0; i < trashedCount; i++) { + items[`t${i}`] = { trashed_at: 1000 } as ManifestEntry; + } + return { items } as Manifest; +} + +describe('handleGetVaultStatus', () => { + it('returns zeros when never synced and no manifest', () => { + const resp = handleGetVaultStatus({ gitHost: cache(null), manifest: null }); + expect(resp).toEqual({ + ok: true, + data: { ahead: 0, behind: 0, lastSyncAt: null, pendingItems: 0 }, + }); + }); + + it('reflects cached sync state + active (non-trashed) item count', () => { + const resp = handleGetVaultStatus({ + gitHost: cache(1234567890, 3, 1), + manifest: manifestWith(5, 2), + }); + expect(resp.ok).toBe(true); + if (resp.ok) { + expect(resp.data).toEqual({ + ahead: 3, behind: 1, lastSyncAt: 1234567890, pendingItems: 5, + }); + } + }); + + it('returns vault_locked error when gitHost is null', () => { + expect(handleGetVaultStatus({ gitHost: null, manifest: null })) + .toEqual({ ok: false, error: 'vault_locked' }); + }); + + it('is synchronous — no network round-trip', () => { + const resp = handleGetVaultStatus({ gitHost: cache(0), manifest: null }); + expect(resp).not.toBeInstanceOf(Promise); + }); +}); diff --git a/extension/src/service-worker/git-host.ts b/extension/src/service-worker/git-host.ts index d817e81..45c6fa9 100644 --- a/extension/src/service-worker/git-host.ts +++ b/extension/src/service-worker/git-host.ts @@ -41,6 +41,15 @@ export interface GitHost { /// Delete a blob from the repo. Currently identical to deleteFile; /// kept distinct for symmetry with putBlob. deleteBlob(path: string, message: string): Promise; + + /// Cached sync metadata, populated by the `sync` handler — get_vault_status + /// reads these without any network call. lastSyncAt is unix SECONDS (or null + /// until the first sync). ahead/behind exist for parity with `relicario + /// status`; the extension writes straight to the host (no local commit + /// graph), so in practice they stay 0. + lastSyncAt: number | null; + ahead: number; + behind: number; } /// Pre-base64 byte size at which putBlob switches from Contents API to diff --git a/extension/src/service-worker/gitea.ts b/extension/src/service-worker/gitea.ts index e888bdd..d51a931 100644 --- a/extension/src/service-worker/gitea.ts +++ b/extension/src/service-worker/gitea.ts @@ -20,6 +20,9 @@ export class GiteaHost implements GitHost { private keysUrl: string; private branch: string = 'main'; private headers: Record; + lastSyncAt: number | null = null; + ahead = 0; + behind = 0; constructor(hostUrl: string, repoPath: string, apiToken: string) { // Remove trailing slash from hostUrl diff --git a/extension/src/service-worker/github.ts b/extension/src/service-worker/github.ts index e1a3d56..85bf198 100644 --- a/extension/src/service-worker/github.ts +++ b/extension/src/service-worker/github.ts @@ -17,6 +17,9 @@ export class GitHubHost implements GitHost { private commitsUrl: string; private branch: string = 'main'; private headers: Record; + lastSyncAt: number | null = null; + ahead = 0; + behind = 0; constructor(repoPath: string, apiToken: string) { this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`; diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 8df97e9..a069d01 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -130,6 +130,8 @@ export async function handle( const handle = session.getCurrent(); if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' }; state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle); + // Record sync time (unix SECONDS) for the get_vault_status indicator. + state.gitHost.lastSyncAt = Math.floor(Date.now() / 1000); return { ok: true }; } @@ -634,9 +636,9 @@ export async function handle( case 'attach_vault': return vault.handleAttachVault(msg, state); - // get_vault_status lands in Phase 6 (Dev-C). - // Until each case lands, an unhandled popup message returns an explicit - // error rather than falling through with no return. + case 'get_vault_status': + return vault.handleGetVaultStatus(state); + default: return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` }; } diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index d12cb89..b189e18 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -7,7 +7,7 @@ import { createGitHost, uint8ArrayToBase64 } from './git-host'; import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types'; import * as session from './session'; import * as devices from './devices'; -import type { AttachVaultResponse, CreateVaultResponse } from '../shared/messages'; +import type { AttachVaultResponse, CreateVaultResponse, GetVaultStatusResponse } from '../shared/messages'; import type { PopupState } from './router/popup-only'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -548,3 +548,34 @@ export function itemToManifestEntry(item: Item): ManifestEntry { function safeHostname(url: string): string | undefined { try { return new URL(url).hostname; } catch { return undefined; } } + +// --- Vault status (Plan C Phase 6) --- + +/** + * Return the cached vault status for the sidebar indicator. Reads cached sync + * metadata off the GitHost (populated by the `sync` handler) plus a live count + * of active (non-trashed) items from the in-memory manifest. Does NOT touch + * the network — sync is user-initiated (spec 2026-05-04, Phase 6). The + * Pick-typed gitHost param both avoids a circular import of the router's + * PopupState and structurally forbids a network call from here. + */ +export function handleGetVaultStatus( + state: { + gitHost: Pick | null; + manifest: Manifest | null; + }, +): GetVaultStatusResponse | { ok: false; error: string } { + if (!state.gitHost) return { ok: false, error: 'vault_locked' }; + const pendingItems = state.manifest + ? Object.values(state.manifest.items).filter((e) => e.trashed_at === undefined).length + : 0; + return { + ok: true, + data: { + ahead: state.gitHost.ahead, + behind: state.gitHost.behind, + lastSyncAt: state.gitHost.lastSyncAt, + pendingItems, + }, + }; +}