feat(ext/sw): get_vault_status handler + cached sync state (Plan C Phase 6)
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 <noreply@anthropic.com>
This commit is contained in:
53
extension/src/service-worker/__tests__/vault-status.test.ts
Normal file
53
extension/src/service-worker/__tests__/vault-status.test.ts
Normal file
@@ -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<string, ManifestEntry> = {};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -41,6 +41,15 @@ export interface GitHost {
|
|||||||
/// Delete a blob from the repo. Currently identical to deleteFile;
|
/// Delete a blob from the repo. Currently identical to deleteFile;
|
||||||
/// kept distinct for symmetry with putBlob.
|
/// kept distinct for symmetry with putBlob.
|
||||||
deleteBlob(path: string, message: string): Promise<void>;
|
deleteBlob(path: string, message: string): Promise<void>;
|
||||||
|
|
||||||
|
/// 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
|
/// Pre-base64 byte size at which putBlob switches from Contents API to
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export class GiteaHost implements GitHost {
|
|||||||
private keysUrl: string;
|
private keysUrl: string;
|
||||||
private branch: string = 'main';
|
private branch: string = 'main';
|
||||||
private headers: Record<string, string>;
|
private headers: Record<string, string>;
|
||||||
|
lastSyncAt: number | null = null;
|
||||||
|
ahead = 0;
|
||||||
|
behind = 0;
|
||||||
|
|
||||||
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
constructor(hostUrl: string, repoPath: string, apiToken: string) {
|
||||||
// Remove trailing slash from hostUrl
|
// Remove trailing slash from hostUrl
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ export class GitHubHost implements GitHost {
|
|||||||
private commitsUrl: string;
|
private commitsUrl: string;
|
||||||
private branch: string = 'main';
|
private branch: string = 'main';
|
||||||
private headers: Record<string, string>;
|
private headers: Record<string, string>;
|
||||||
|
lastSyncAt: number | null = null;
|
||||||
|
ahead = 0;
|
||||||
|
behind = 0;
|
||||||
|
|
||||||
constructor(repoPath: string, apiToken: string) {
|
constructor(repoPath: string, apiToken: string) {
|
||||||
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
|
this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`;
|
||||||
|
|||||||
@@ -130,6 +130,8 @@ export async function handle(
|
|||||||
const handle = session.getCurrent();
|
const handle = session.getCurrent();
|
||||||
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
if (!handle || !state.gitHost) return { ok: false, error: 'vault_locked' };
|
||||||
state.manifest = await vault.fetchAndDecryptManifest(state.gitHost, handle);
|
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 };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -634,9 +636,9 @@ export async function handle(
|
|||||||
case 'attach_vault':
|
case 'attach_vault':
|
||||||
return vault.handleAttachVault(msg, state);
|
return vault.handleAttachVault(msg, state);
|
||||||
|
|
||||||
// get_vault_status lands in Phase 6 (Dev-C).
|
case 'get_vault_status':
|
||||||
// Until each case lands, an unhandled popup message returns an explicit
|
return vault.handleGetVaultStatus(state);
|
||||||
// error rather than falling through with no return.
|
|
||||||
default:
|
default:
|
||||||
return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` };
|
return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { createGitHost, uint8ArrayToBase64 } from './git-host';
|
|||||||
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types';
|
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types';
|
||||||
import * as session from './session';
|
import * as session from './session';
|
||||||
import * as devices from './devices';
|
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';
|
import type { PopupState } from './router/popup-only';
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// 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 {
|
function safeHostname(url: string): string | undefined {
|
||||||
try { return new URL(url).hostname; } catch { return 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<GitHost, 'lastSyncAt' | 'ahead' | 'behind'> | 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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user