Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/couch/couch-doc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,9 @@ export const encodeFile = async (

// The deterministic content rev used as the push-skip cache key (joined leaf ids).
export const contentRev = (fileDoc: Pick<FileDoc, 'leaves'>): string => fileDoc.leaves.join(',');

// The content rev for a raw body, WITHOUT building a full file doc - the exact value pushFile
// caches (contentRev(fileDoc) === leafIdsOf(body).join(',')). Lets the preview compute the
// outgoing count from vault content alone, guaranteed equal to what pushAll would send.
export const contentRevOf = async (body: string): Promise<string> =>
(await leafIdsOf(body)).join(',');
35 changes: 35 additions & 0 deletions src/couch/couch-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from 'obsidian';
import { CouchSync, type CouchSyncConfig } from './couch-sync';
import { CouchState } from './couch-state';
import { contentRevOf } from './couch-doc';
import type { CouchMemoryState } from '../settings';

// Only the requestUrl/TFile coupling is mocked; the doc model + state are the real modules.
Expand Down Expand Up @@ -517,3 +518,37 @@ describe('syncNow is resilient like tick()', () => {
expect(result.error).toBe('pull boom');
});
});

// The honest preview count: no network, no controller - just vault content vs the push-rev cache.
describe('CouchSync.countOutgoing', () => {
const freshState = (init?: CouchMemoryState): CouchState =>
new CouchState(
() => init,
async () => {}
);

it('a fresh memory (empty cache) counts EVERY local md file - the first-sync bug fix', async () => {
const vault = new FakeVault({ 'a.md': 'A', 'b.md': 'B', 'notes/c.md': 'C' });
expect(await CouchSync.countOutgoing(vault, freshState())).toBe(3);
});

it('reports 0 once every file is at its pushed content rev (post-full-sync)', async () => {
const files = { 'a.md': 'A', 'b.md': 'B' };
const vault = new FakeVault(files);
const revs: Record<string, string> = {};
for (const [p, c] of Object.entries(files)) revs[p] = await contentRevOf(c);
expect(await CouchSync.countOutgoing(vault, freshState({ revs }))).toBe(0);
});

it('counts only files whose content changed since the last push', async () => {
const vault = new FakeVault({ 'a.md': 'A-new', 'b.md': 'B' });
const revs = { 'a.md': await contentRevOf('A-old'), 'b.md': await contentRevOf('B') };
expect(await CouchSync.countOutgoing(vault, freshState({ revs }))).toBe(1); // only a.md
});

it('counts a cached path removed from the vault as an outgoing delete', async () => {
const vault = new FakeVault({ 'a.md': 'A' });
const revs = { 'a.md': await contentRevOf('A'), 'gone.md': 'stale-rev' };
expect(await CouchSync.countOutgoing(vault, freshState({ revs }))).toBe(1); // the delete
});
});
36 changes: 34 additions & 2 deletions src/couch/couch-sync.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { requestUrl, TFile, type Vault } from 'obsidian';
import {
contentRev,
contentRevOf,
encodeFile,
fileId,
leafIdsOf,
Expand Down Expand Up @@ -38,10 +39,40 @@ export interface SyncResult {
error?: string; // first error message when either side failed
}

// The minimal read surface countOutgoing needs: the vault's md files + their content, and the
// persisted push-rev cache. Lets the preview compute the outgoing count with NO live controller
// and NO network (the same seam the tests drive).
export interface OutgoingVault {
getMarkdownFiles(): { path: string }[];
read(file: { path: string }): Promise<string>;
}
export interface OutgoingState {
revFor(path: string): string | undefined;
knownPaths(): string[];
}

export class CouchSync {
private pulling = false;
private suppress = new Set<string>(); // paths being written by a pull (skip their push)

// The honest "to send" count for the sync popup, computed from vault content alone (no network,
// no controller). A file counts when its current content-rev is absent-from / differs-from the
// push-rev cache (a push pushAll would send) - on a fresh memory (empty cache) that is EVERY md
// file. A cached path no longer present in the vault counts as a delete (mirrors
// reconcileDeletions). Reuses contentRevOf (the exact rev pushFile caches) so it can never drift
// from what pushAll actually sends.
static async countOutgoing(vault: OutgoingVault, state: OutgoingState): Promise<number> {
const md = vault.getMarkdownFiles();
const present = new Set(md.map((f) => f.path));
let pushes = 0;
for (const f of md) {
const rev = await contentRevOf(await vault.read(f));
if (state.revFor(f.path) !== rev) pushes++;
}
const deletes = state.knownPaths().filter((p) => !present.has(p)).length;
return pushes + deletes;
}

constructor(
private vault: Vault,
private cfg: CouchSyncConfig,
Expand Down Expand Up @@ -106,9 +137,10 @@ export class CouchSync {
const af = this.vault.getAbstractFileByPath(path);
if (!(af instanceof TFile) || af.extension !== 'md') return;
const body = await this.vault.read(af);
const { leaves, fileDoc } = await encodeFile(path, body);
const rev = contentRev(fileDoc);
// Same rev source countOutgoing uses, so the preview count can never drift from what we push.
const rev = await contentRevOf(body);
if (this.state.revFor(path) === rev) return; // this exact content is already pushed
const { leaves, fileDoc } = await encodeFile(path, body);
const cur = await this.getDoc(fileDoc._id);
if (cur && !cur._deleted && JSON.stringify(cur.leaves) === JSON.stringify(fileDoc.leaves)) {
await this.state.setRev(path, rev); // remote already converged; cache so we skip next time
Expand Down
40 changes: 24 additions & 16 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,14 +211,7 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost
() => Date.now()
);
const authorize: CouchAuthorize = () => tokenClient.token();
const key = `${this.activeFqdn}:${memory}`;
const state = new CouchState(
() => this.settings.couchState[key],
async (s) => {
this.settings.couchState[key] = s;
await this.saveSettings();
}
);
const state = this.couchStateFor(memory);
return new CouchSync(
this.app.vault,
{ endpoint: ch.endpoint, db: ch.db },
Expand All @@ -229,15 +222,24 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost
);
}

/** The persisted couch state for `memory`, keyed by (host, memory). Loaded the same way for
* the live controller and the offline preview count, so both read the same push-rev cache. */
private couchStateFor(memory: string): CouchState {
const key = `${this.activeFqdn}:${memory}`;
return new CouchState(
() => this.settings.couchState[key],
async (s) => {
this.settings.couchState[key] = s;
await this.saveSettings();
}
);
}

// --- site host (SettingsHost) ---
/** The host every origin is derived from this session. */
activeSiteFqdn(): string {
return this.activeFqdn;
}
/** The host the CURRENT settings resolve to (differs from active until a restart). */
pendingSiteFqdn(): string {
return resolveSiteFqdn(this.settings.siteFqdn, ENV_SITE_FQDN);
}
private origin(sub: string): string {
return `https://${sub}.${this.activeFqdn}`;
}
Expand Down Expand Up @@ -648,12 +650,18 @@ export default class AgentageMemoryPlugin extends Plugin implements SettingsHost
return this.couchSyncNow(ch, chosen);
}

// Non-mutating preview of the next sync for the popup: the honest couch outgoing count (queued
// local changes). firstSync = no memory chosen / not signed in yet, so nothing to preview.
// Non-mutating preview of the next sync for the popup: the HONEST couch outgoing count = local
// md files whose content-rev differs from (or is absent from) the chosen memory's push-rev cache,
// plus cached paths deleted locally. On a fresh memory (empty cache) that is EVERY md file - so
// the popup no longer says "0 to send" before the first push. Computed with no controller + no
// network, reusing the exact rev source pushAll uses (CouchSync.countOutgoing). firstSync = no
// memory chosen / not signed in yet, so nothing to preview.
private async previewSync(): Promise<SyncPreview> {
const chosen = normalizeVaultName(this.settings.vault);
const token = await this.auth.getValidToken();
if (!token || !normalizeVaultName(this.settings.vault)) return { pending: 0, firstSync: true };
return { pending: this.couchChannel.pendingCount(), firstSync: false };
if (!token || !chosen) return { pending: 0, firstSync: true };
const pending = await CouchSync.countOutgoing(this.app.vault, this.couchStateFor(chosen));
return { pending, firstSync: false };
}

// Auto-sync once signed in WITH a memory chosen. `withModal` shows the file-count
Expand Down
61 changes: 4 additions & 57 deletions src/settings-tab.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type App, Notice, PluginSettingTab, Setting, debounce } from 'obsidian';
import { type App, PluginSettingTab, Setting, debounce } from 'obsidian';
import {
CONNECT_URL,
PROD_SITE_FQDN,
Expand All @@ -19,31 +19,19 @@
applyConfig(): Promise<ApplyResult>;
/** Open the memory chooser popup (pick an existing memory or create a new one). */
chooseMemory(): void;
/** The site host every origin derives from this session. */
/** The site host every origin derives from this session (drives the non-prod banner). */
activeSiteFqdn(): string;
/** The host the current settings resolve to (applies on the next Obsidian restart). */
pendingSiteFqdn(): string;
}

export class AgentageMemorySettingTab extends PluginSettingTab {

Check warning on line 26 in src/settings-tab.ts

View workflow job for this annotation

GitHub Actions / 🔍 Validate Pull Request

This PluginSettingTab does not implement getSettingDefinitions(); its settings will not appear in Obsidian's settings search for users on 1.13.0 or later. Consider adopting the declarative settings API
private host: SettingsHost;
private status?: HTMLElement;
private hostState?: HTMLElement;
private writeDebounced: () => void;
private notifyRestartDebounced: () => void;

constructor(app: App, host: SettingsHost) {
super(app, host as unknown as never);
this.host = host;
this.writeDebounced = debounce(() => void this.write(), 700, true);
this.notifyRestartDebounced = debounce(
() => {
if (this.host.pendingSiteFqdn() !== this.host.activeSiteFqdn())
new Notice('Agentage Sync: restart Obsidian to apply the new host.');
},
900,
true
);
}

/** On any change: persist plugin data now, write vaults.json debounced. */
Expand All @@ -64,7 +52,6 @@
const { containerEl } = this;
const s = this.host.settings;
this.status = undefined;
this.hostState = undefined;
containerEl.empty();
containerEl.addClass('ams-settings');

Expand Down Expand Up @@ -142,48 +129,8 @@
docs.appendText('.');
}

// ---- Advanced (testing options) ----
new Setting(containerEl)
.setName('Advanced')
.setDesc('Show testing options.')
.addToggle((t) =>
t.setValue(s.showAdvanced).onChange((v) => {
s.showAdvanced = v;
void this.host.saveSettings();
this.display();
})
);
if (s.showAdvanced) {
new Setting(containerEl)
.setName('Site host')
.setDesc('For testing against dev (e.g. dev.agentage.io). Empty = production.')
.addText((t) =>
t
.setPlaceholder(PROD_SITE_FQDN)
.setValue(s.siteFqdn)
.onChange((v) => {
s.siteFqdn = v;
// Plugin-local (data.json only, never vaults.json); applied on the next restart.
void this.host.saveSettings();
this.renderHostState();
this.notifyRestartDebounced();
})
);
this.hostState = containerEl.createDiv({ cls: 'ams-hint' });
this.renderHostState();
}
}

/** "Active host: x" line under the Site host field, plus the restart hint when it differs. */
private renderHostState(): void {
if (!this.hostState) return;
const active = this.host.activeSiteFqdn();
const pending = this.host.pendingSiteFqdn();
this.hostState.setText(
pending === active
? `Active host: ${active}`
: `Active host: ${active}. Restart Obsidian to apply ${pending}.`
);
// The dev/testing host override (siteFqdn) is intentionally NOT editable here - set it
// directly in data.json for a dev vault; it applies on the next Obsidian restart.
}

/** Add/remove an MCP scope, then persist. */
Expand Down
6 changes: 2 additions & 4 deletions src/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,10 @@ export interface AgentageMemorySettings {
mcp: McpScope[];
/** Dir holding vaults.json (memory-core: AGENTAGE_CONFIG_DIR or ~/.agentage). */
configDir: string;
/** UI only - reveal the advanced fields (not written to vaults.json). */
showAdvanced: boolean;
/** Plugin-local - the vault name last written to vaults.json, for renames. */
writtenVaultName: string;
/** Site host override for testing against dev (e.g. dev.agentage.io). Empty = env-or-prod. */
/** Dev/testing host override, set directly in data.json (no on-page editor). Empty = env-or-prod.
* Read once at load by resolveSiteFqdn; applies on the next Obsidian restart. */
siteFqdn: string;
/** Per-(host, memory) couch sync state (pull cursor + push-rev cache + pending pushes),
* keyed "<host>:<memory>". Plugin-local; never written to vaults.json. */
Expand Down Expand Up @@ -86,7 +85,6 @@ export const DEFAULT_SETTINGS: AgentageMemorySettings = {
origin: { remote: AGENTAGE_REMOTE, interval: 5, ignore: [] },
mcp: ['remote'], // expose to AI apps over MCP by default (local MCP is out of scope here)
configDir: '~/.agentage',
showAdvanced: false,
writtenVaultName: '',
siteFqdn: '',
couchState: {},
Expand Down
9 changes: 5 additions & 4 deletions src/sync-preview-modal.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { type App, Modal, Setting } from 'obsidian';

export interface SyncPreview {
pending: number; // local changes queued to send up (couch pending push + delete queue)
firstSync: boolean; // no sync cursor yet - first sync sets things up
pending: number; // local md files the next sync would push up (content differs from the push cache)
firstSync: boolean; // no memory chosen / not signed in yet - nothing to preview
}

// The post-sign-in sync popup: on the couch channel the incoming count is only known after a
// pull, so we show the honest outgoing figure (queued local changes) then run the live sync and
// report the result. Informational + non-blocking (the sync auto-starts).
// pull, so we show the honest outgoing figure (local files whose content differs from what was
// last pushed - EVERY md file on a fresh memory) then run the live sync and report the result.
// Informational + non-blocking (the sync auto-starts).
class SyncPreviewModal extends Modal {
constructor(
app: App,
Expand Down
61 changes: 61 additions & 0 deletions test/integration/preview-count.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { obsidianMockFactory } from '../fakes/obsidian';

// The plugin import graph resolves 'obsidian' to the harness mock; everything else is injected
// via the fake app + the Router behind requestUrl. See test/integration/first-sync.test.ts.
vi.mock('obsidian', () => obsidianMockFactory());

import { bootPlugin, signIn, type Handles } from '../fakes/boot';
import type { SyncPreview } from '../../src/sync-preview-modal';

// The sync popup's count comes from the private previewSync(); expose it via a cast (same pattern
// the signIn helper uses to reach auth.handleCallback).
const preview = (h: Handles): Promise<SyncPreview> =>
(h.plugin as unknown as { previewSync(): Promise<SyncPreview> }).previewSync();

describe('sync-preview count is honest on a FRESH memory (the first-sync bug)', () => {
let h: Handles;

beforeEach(async () => {
// A fresh couch-channel memory 'work' the CLOUD side is EMPTY for, while the LOCAL vault
// already has three markdown notes - the exact repro: first sync must push all three.
h = await bootPlugin({
files: { 'a.md': 'Alpha', 'b.md': 'Bravo', 'notes/c.md': 'Charlie' },
memoryName: 'work',
couchDb: 'mem_work',
memories: [{ name: 'work', entries: 0, folderCount: 0, updated: null }],
});
});

afterEach(async () => {
await h.teardown();
});

it('reports N (not 0) before the first push, then 0 after a full sync', async () => {
await signIn(h);
await h.plugin.selectVault('work');

// BEFORE any sync: the honest outgoing count is every local md file, NOT 0 (the old bug read
// the failed-push retry queue, which is empty on a fresh pick and reported "0 to send").
const before = await preview(h);
expect(before.firstSync).toBe(false);
expect(before.pending).toBe(3);

// Run the real sync (pushes all three to the fake couch).
const result = await h.plugin.syncNow();
expect(result.ok).toBe(true);
expect(h.couch.filePaths()).toEqual(['a.md', 'b.md', 'notes/c.md']);

// AFTER a full sync: the push-rev cache is warm, so the preview honestly reports nothing to send.
const after = await preview(h);
expect(after.pending).toBe(0);
});

it('firstSync=true when no memory is chosen yet', async () => {
await signIn(h);
// Signed in but selectVault not called -> nothing to preview.
const p = await preview(h);
expect(p.firstSync).toBe(true);
expect(p.pending).toBe(0);
});
});