From 01b06556c3d0461d55f1936679c061f143ac89e3 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Sun, 9 Aug 2026 16:24:25 +0200 Subject: [PATCH 1/2] feat: drop dead couch channel, show account vaults as unsynced CouchDB stopped being a sync channel on 2026-07-31 when the per-memory JWT route was deleted server-side; git smart-HTTP is the only device channel. The CLI's copy of the couch channel has been dead since, while `status` kept reporting account vaults as healthy. Remove src/sync/couch/ and every caller, and make the resulting gap visible rather than silent: an account vault now renders as `account not synced - account vaults have no sync channel` in `status` (channel `account`, state `unsynced` in --json), and `vault sync` names it with the same line instead of skipping it or printing a success line. Account vaults still register, provision, and serve locally over MCP - only the machine<->account sync is gone, and the CLI now says so. Also removed as dead once the channel went: `currentBearer` (api), `onMutation` (daemon server), `SyncRunResult` (daemon client), and the CHANNEL_DISABLED / CHANNEL_CONFLICT provisioning branches - the backend create-memory route emits neither code. --- CLAUDE.md | 10 +- e2e/account-vault.test.ts | 38 +- e2e/couch-cloud.test.ts | 325 ------------- e2e/couch-sync.test.ts | 626 ------------------------- e2e/helpers.ts | 1 + src/commands/daemon/daemon-cmd.ts | 17 - src/commands/status/status.test.ts | 20 +- src/commands/vault/vault-sync.test.ts | 64 ++- src/commands/vault/vault-sync.ts | 84 ++-- src/commands/vault/vault.test.ts | 8 +- src/commands/vault/vault.ts | 5 +- src/daemon-entry.ts | 28 +- src/daemon/server.ts | 10 +- src/lib/auth/api.test.ts | 75 +-- src/lib/auth/api.ts | 14 - src/lib/auth/provision.test.ts | 45 +- src/lib/auth/provision.ts | 46 +- src/lib/daemon/daemon-client.ts | 11 +- src/lib/net/origins.ts | 2 +- src/lib/status/status-info.test.ts | 8 +- src/lib/status/status-info.ts | 23 +- src/lib/status/vaults-format.ts | 9 +- src/lib/status/vaults-status.test.ts | 53 ++- src/lib/status/vaults-status.ts | 26 +- src/lib/vault/vault-registry.ts | 7 +- src/sync/couch/cycle.ts | 60 --- src/sync/couch/discovery.test.ts | 90 ---- src/sync/couch/discovery.ts | 58 --- src/sync/couch/file-store.test.ts | 48 -- src/sync/couch/file-store.ts | 46 -- src/sync/couch/local-commit.ts | 22 - src/sync/couch/manager.fixtures.ts | 63 --- src/sync/couch/manager.test.ts | 189 -------- src/sync/couch/manager.ts | 145 ------ src/sync/couch/manager.types.ts | 104 ---- src/sync/couch/mutation-target.test.ts | 51 -- src/sync/couch/mutation-target.ts | 31 -- src/sync/couch/push-on-write.test.ts | 113 ----- src/sync/couch/push-on-write.ts | 36 -- src/sync/couch/state-store.test.ts | 39 -- src/sync/couch/state-store.ts | 30 -- src/sync/couch/targets.test.ts | 51 -- src/sync/couch/targets.ts | 32 -- src/sync/couch/wire.ts | 41 -- src/sync/discover/watcher.ts | 2 +- src/sync/git/manager.ts | 3 - src/sync/git/planner.ts | 6 +- 47 files changed, 237 insertions(+), 2578 deletions(-) delete mode 100644 e2e/couch-cloud.test.ts delete mode 100644 e2e/couch-sync.test.ts delete mode 100644 src/sync/couch/cycle.ts delete mode 100644 src/sync/couch/discovery.test.ts delete mode 100644 src/sync/couch/discovery.ts delete mode 100644 src/sync/couch/file-store.test.ts delete mode 100644 src/sync/couch/file-store.ts delete mode 100644 src/sync/couch/local-commit.ts delete mode 100644 src/sync/couch/manager.fixtures.ts delete mode 100644 src/sync/couch/manager.test.ts delete mode 100644 src/sync/couch/manager.ts delete mode 100644 src/sync/couch/manager.types.ts delete mode 100644 src/sync/couch/mutation-target.test.ts delete mode 100644 src/sync/couch/mutation-target.ts delete mode 100644 src/sync/couch/push-on-write.test.ts delete mode 100644 src/sync/couch/push-on-write.ts delete mode 100644 src/sync/couch/state-store.test.ts delete mode 100644 src/sync/couch/state-store.ts delete mode 100644 src/sync/couch/targets.test.ts delete mode 100644 src/sync/couch/targets.ts delete mode 100644 src/sync/couch/wire.ts diff --git a/CLAUDE.md b/CLAUDE.md index f29c2de..e843976 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,11 @@ its agent-runtime patterns (the local memory daemon was deliberately ported from `AGENTAGE_NO_DAEMON=1`, or fork blocked) - `src/daemon/` + `src/daemon-entry.ts` - the local daemon (node:http, 127.0.0.1 only): one in-process engine serialises vault mutations; `agentage daemon start|stop|status` -- `src/sync/` - channel-based sync; one subfolder per channel, no shared root logic: +- `src/sync/` - channel-based sync; one subfolder per channel, no shared root logic. **Git is the + only sync channel.** Account (`agentage`-origin) vaults have NO sync channel: they are registered, + provisioned in the account, and served locally over MCP, but nothing moves files between the two. + `status` renders them `account ✗ not synced` and `vault sync` says so instead of claiming success - + keep both honest if this ever changes: - `src/sync/git/` - git channel (M4): `manager` (scheduler + status), `cycle` (one commit+push / pull-rebase round), `planner` (targets + interval math), `git-exec` (spawn git, error classify), `remote-url` (allowlist + redact), `conflict` (`.conflict.md` naming). The daemon acts on @@ -33,10 +37,6 @@ its agent-runtime patterns (the local memory daemon was deliberately ported from conflicts keep both sides (zero lost writes); `ignore` rides on `.git/info/exclude` (defaults `.obsidian/` + `data.json`, a set value REPLACES them, `[]` = all). `spawn git` directly (memory-core's createGit is private); no new deps - - `src/sync/couch/` - couch channel (account vaults): `manager` (thin composition), `manager.types` - (shared shapes), `cycle` / `wire` / `push-on-write` (the sync-on-save + scheduling seams), - `mutation-target` (verb -> vault+path), `local-commit`, `discovery`, `file-store`, `state-store`, - `targets` - `src/sync/discover/` - `watcher` (auto-discovers new vaults, provisions account vaults) - `vault sync [name]` forces a git cycle via the daemon (`/api/sync/run`) or in-process when down - `src/package-guard.test.ts` - CI guard: no agent-runtime remnants (express/ws/sqlite/ diff --git a/e2e/account-vault.test.ts b/e2e/account-vault.test.ts index 4849273..4fabc18 100644 --- a/e2e/account-vault.test.ts +++ b/e2e/account-vault.test.ts @@ -1,10 +1,11 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { expect, test } from '@playwright/test'; -import { assertCliBuilt, createCliMachine } from './helpers.js'; +import { assertCliBuilt, createCliMachine, statusJson } from './helpers.js'; -// Account vaults (agentage channel) are offline-first: a no-flag `vault add` writes the local -// entry and mirror dir with zero network, provisioning the cloud channel only when signed in. +// Account vaults are offline-first: a no-flag `vault add` writes the local entry and folder with +// zero network, creating the account-side memory only when signed in. They have no sync channel, +// which `status` and `vault sync` must state outright rather than imply health or stay silent. // Egress is blackholed via unroutable proxies to prove the offline path never reaches out. @p0 const BLACKHOLE = 'http://127.0.0.1:1'; const OFFLINE = { @@ -95,17 +96,40 @@ test.describe('account vault (offline) @p0 @offline', () => { } }); - test('vault sync on an offline, signed-out account vault pauses instead of erroring', async () => { + test('vault sync names an account vault as unsynced instead of claiming success', async () => { const m = createCliMachine(OFFLINE); try { const vaultDir = join(m.configDir, 'acct'); expect((await m.exec(['vault', 'add', 'acct', '--path', vaultDir])).code).toBe(0); - // Not signed in: the couch cycle pauses with zero network, never a crash (exit 0). + // An account vault has no sync channel: say so, never a crash (exit 0), never a success line. const sync = await m.exec(['vault', 'sync', 'acct']); expect(sync.code, sync.stderr).toBe(0); - expect(sync.stdout).toContain('acct (account)'); - expect(sync.stdout).toContain('paused (signed out)'); + expect(sync.stdout).toContain('acct (account): not synced'); + expect(sync.stdout).toContain('account vaults have no sync channel'); + expect(sync.stdout).not.toContain('up to date'); + expect(sync.stdout).not.toContain('Syncing'); + } finally { + m.cleanup(); + } + }); + + test('status lists an account vault as not synced rather than healthy or hidden', async () => { + const m = createCliMachine(OFFLINE); + try { + const vaultDir = join(m.configDir, 'acct'); + expect((await m.exec(['vault', 'add', 'acct', '--path', vaultDir])).code).toBe(0); + + const report = await statusJson(m); + const acct = report.vaults.find((v) => v.name === 'acct'); + expect(acct, 'account vault missing from status').toBeDefined(); + expect(acct!.channel).toBe('account'); + expect(acct!.status).toBe('unsynced'); + + const human = await m.exec(['status']); + expect(human.code, human.stderr).toBe(0); + expect(human.stdout).toContain('not synced - account vaults have no sync channel'); + expect(human.stdout).not.toContain('connected'); } finally { m.cleanup(); } diff --git a/e2e/couch-cloud.test.ts b/e2e/couch-cloud.test.ts deleted file mode 100644 index 5aebb4c..0000000 --- a/e2e/couch-cloud.test.ts +++ /dev/null @@ -1,325 +0,0 @@ -/** - * @full - the M5 cloud roundtrip against the LIVE dev stack: a local CLI write - * replicates over the couch channel and becomes readable through the cloud MCP - * endpoint, and a cloud MCP write flows back to disk. Never runs against prod - * (hard-skips when the target FQDN is agentage.io). - * - * Env: AGENTAGE_SITE_FQDN (default dev). Self-contained like setup-oauth: it - * signs up a FRESH throwaway account per run (dev throwaways are accepted in - * this suite - see couch-sync.test.ts and the e2e provision-on-signin tier). - * - * Deployment gates (skip cleanly, never fail): - * - target is production; - * - the account OAuth bearer cannot be obtained from the live stack; - * - the couch channel is off (403 CHANNEL_DISABLED) or discovery exposes no - * couch fields for the vault; - * - the cloud MCP does not surface the couch write (the couch push lands and - * the bridge materializes it to git; the known gate is cloud MCP vault - * routing - wildcard tokens are default-only, no @-addressing, web#411). - * Once routing lands the roundtrip legs run and assert. - * - * ADR-013: a wildcard OAuth token routes BARE cloud-MCP paths to the account's - * DEFAULT memory only. On dev the default memory is git-channel (auto-seeded on - * sign-up), so provisioning "default" on couch 409s; instead this uses a - * non-default couch memory and addresses it over the cloud MCP with the - * @/ prefix (which the wildcard token honors). - */ -import { execFile } from 'node:child_process'; -import { randomBytes } from 'node:crypto'; -import { existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import { Client } from '@modelcontextprotocol/sdk/client/index.js'; -import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; -import { expect, test } from '@playwright/test'; -import { - assertCliBuilt, - createCliMachine, - ensureSession, - freePort, - newBrowserContext, - TARGET_FQDN, - waitForAuthorizeUrl, - type CliMachine, -} from './helpers.js'; - -const run = promisify(execFile); -const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); - -const IS_PROD = TARGET_FQDN === 'agentage.io'; -const SYNC_URL = `https://sync.${TARGET_FQDN}`; -const MCP_URL = `https://memory.${TARGET_FQDN}/mcp`; -const VAULT = 'cloude2e'; -const LEG_TIMEOUT_MS = 120_000; -const FWD_PATH = 'notes/roundtrip.md'; -const REV_PATH = 'notes/from-cloud.md'; - -const log = (msg: string): void => console.log(`[couch-cloud] ${msg}`); - -// A fresh throwaway account every run, independent of E2E_AUTH_* (the couch memory cap is per -// account, so a reused account would exhaust it and pile stale couch state). -const freshAccount = (): { email: string; password: string } => ({ - email: `cli-couch-e2e-${randomBytes(6).toString('hex')}@agentage.test`, - password: `cli-couch-e2e-${randomBytes(9).toString('base64url')}`, -}); - -const readBearer = (m: CliMachine): string | null => { - const p = join(m.configDir, 'auth.json'); - if (!existsSync(p)) return null; - const auth = JSON.parse(readFileSync(p, 'utf-8')) as { tokens?: { accessToken?: string } }; - return auth.tokens?.accessToken ?? null; -}; - -interface SyncDiscovery { - couch_endpoint?: string; - couch_token_url?: string; - couch_vaults?: Array<{ vault: string; db: string }>; -} - -const discovery = async (bearer: string): Promise => { - const res = await fetch(`${SYNC_URL}/.well-known/agentage-sync`, { - headers: { authorization: `Bearer ${bearer}`, accept: 'application/json' }, - }); - return (await res.json()) as SyncDiscovery; -}; - -// Direct couch check: proves a forward miss is the bridge (the doc IS in couch) and not a broken -// CLI push (the doc is absent). The couch JWT is minted the same way the daemon mints it. -const couchHasDoc = async (bearer: string, disc: SyncDiscovery, path: string): Promise => { - if (!disc.couch_endpoint || !disc.couch_token_url) return false; - const db = disc.couch_vaults?.find((v) => v.vault === VAULT)?.db; - if (!db) return false; - const mint = await fetch(disc.couch_token_url, { - method: 'POST', - headers: { authorization: `Bearer ${bearer}`, 'content-type': 'application/json' }, - body: JSON.stringify({ memory: VAULT }), - }); - if (!mint.ok) return false; - const jwt = ((await mint.json()) as { data?: { jwt?: string } }).data?.jwt; - if (!jwt) return false; - const doc = await fetch(`${disc.couch_endpoint}/${db}/${encodeURIComponent(`f:${path}`)}`, { - headers: { authorization: `Bearer ${jwt}` }, - }); - return doc.status === 200; -}; - -// Minimal shape of a cloud MCP tool result (SDK CallToolResult) without pulling its types in. -interface CloudResult { - content?: unknown[]; - structuredContent?: Record; - isError?: boolean; -} -const asResult = (r: unknown): CloudResult => r as CloudResult; - -const connectCloudMcp = async (bearer: string): Promise => { - const client = new Client({ name: 'agentage-cli-e2e', version: '0' }); - const transport = new StreamableHTTPClientTransport(new URL(MCP_URL), { - requestInit: { headers: { Authorization: `Bearer ${bearer}` } }, - }); - await client.connect(transport); - return client; -}; - -const cloudRead = async (mcp: Client, path: string): Promise => - asResult(await mcp.callTool({ name: 'memory__read', arguments: { path } })); - -test.describe('couch <-> cloud MCP roundtrip vs live dev @full', () => { - test.skip(IS_PROD, 'never run the cloud roundtrip against production'); - test.describe.configure({ timeout: 480_000 }); - - test('a CLI couch write reads via the cloud MCP, and a cloud write flows back', async () => { - assertCliBuilt(); - const account = freshAccount(); - const daemonPort = await freePort(); - const machine = createCliMachine({ - AGENTAGE_NO_DAEMON: '', - AGENTAGE_DAEMON_PORT: String(daemonPort), - }); - const browser = await newBrowserContext(); - const vaultDir = join(machine.configDir, VAULT); - let mcp: Client | undefined; - - // Live-stack auth ability: obtain the bearer via the CLI's own headless OAuth flow (the - // setup-oauth pattern - the test plays the browser). Any failure here is a live-stack gate. - let bearer: string | null = null; - let authError = ''; - try { - await ensureSession(browser, account); - const setup = machine.startSetup(); - const authorizeUrl = await waitForAuthorizeUrl(setup); - const authorize = await browser.get(authorizeUrl, { maxRedirects: 0 }); - expect(authorize.status(), 'authorize should 302 for a signed-in session').toBe(302); - const callback = await browser.get(authorize.headers()['location'] ?? ''); - expect(callback.ok(), `callback failed: ${callback.status()}`).toBe(true); - expect(await setup.waitExit(), setup.output()).toBe(0); - bearer = readBearer(machine); - } catch (err) { - authError = err instanceof Error ? err.message : String(err); - } - - try { - test.skip( - !bearer, - `no live-stack OAuth bearer (${authError || 'setup did not store a token'})` - ); - const token = bearer as string; - - // Provision a NON-default couch memory (default is git-reserved -> 409 on couch). - const add = await machine.exec(['vault', 'add', VAULT, '--path', vaultDir]); - expect(add.code, add.stderr).toBe(0); - test.skip( - /not enabled on this server/i.test(add.stdout), - `couch channel disabled on ${TARGET_FQDN}: ${add.stdout.trim()}` - ); - test.skip( - /already exists on another channel/i.test(add.stdout), - `couch channel conflict on ${TARGET_FQDN}: ${add.stdout.trim()}` - ); - // A transient provision failure at add-time is fine: the sync cycle re-provisions idempotently. - - const start = await machine.exec(['daemon', 'start']); - expect(start.code, start.stderr).toBe(0); - - // --- FORWARD leg: CLI write -> couch -> [bridge] -> cloud MCP ------------------------------- - const fwdMark = `cli-fwd-${randomBytes(6).toString('hex')}`; - const cloudFwd = `@${VAULT}/${FWD_PATH}`; - const w = await machine.exec([ - 'memory', - 'write', - FWD_PATH, - '--vault', - VAULT, - '--body', - fwdMark, - ]); - expect(w.code, w.stderr).toBe(0); - // Sync via the daemon (/api/sync/run). Retry while the cycle is still provisioning. - let sync = await machine.exec(['vault', 'sync', VAULT]); - expect(sync.code, sync.stderr).toBe(0); - for (let i = 0; i < 3 && /paused \(provisioning/.test(sync.stdout); i++) { - await sleep(3000); - sync = await machine.exec(['vault', 'sync', VAULT]); - } - test.skip( - /paused \((account sync is not enabled|name conflicts)/.test(sync.stdout), - `couch channel unavailable on ${TARGET_FQDN}: ${sync.stdout.trim()}` - ); - test.skip( - /paused/.test(sync.stdout), - `couch sync stayed paused on ${TARGET_FQDN}: ${sync.stdout.trim()}` - ); - - // Discovery must expose the couch channel + our vault, else a deployment gap. - const disc = await discovery(token); - const hasCouch = - !!disc.couch_endpoint && - !!disc.couch_token_url && - !!disc.couch_vaults?.some((v) => v.vault === VAULT); - test.skip( - !hasCouch, - `discovery lacks couch fields for '${VAULT}' on ${TARGET_FQDN} (deployment gap)` - ); - - mcp = await connectCloudMcp(token); - - const fwdStart = Date.now(); - let fwd: CloudResult | undefined; - while (Date.now() - fwdStart < LEG_TIMEOUT_MS) { - const res = await cloudRead(mcp, cloudFwd); - if (res.isError !== true && JSON.stringify(res.structuredContent ?? {}).includes(fwdMark)) { - fwd = res; - break; - } - // Re-sync each pass: drains any push the first cycle queued (idempotent, cheap). - await machine.exec(['vault', 'sync', VAULT]).catch(() => {}); - await sleep(4000); - } - - if (!fwd) { - // The couch write itself must have landed; if not, that is a real CLI regression (fail). - const landed = await couchHasDoc(token, disc, FWD_PATH); - expect( - landed, - `forward leg: CLI push did not land ${FWD_PATH} in couch (sync: ${sync.stdout.trim()})` - ).toBe(true); - test.skip( - true, - `cloud MCP did not surface ${FWD_PATH} on ${TARGET_FQDN} within ${LEG_TIMEOUT_MS / 1000}s (couch push landed; known gate: cloud MCP vault routing is default-only - web#411; roundtrip assertions skipped)` - ); - } - const fwdSecs = ((Date.now() - fwdStart) / 1000).toFixed(1); - log(`forward leg materialized via cloud MCP in ${fwdSecs}s`); - // Dual-channel shape basics: structured content + a non-empty text channel. - expect(fwd!.structuredContent, 'cloud read structuredContent').toBeTruthy(); - expect( - Array.isArray(fwd!.content) && fwd!.content.length > 0, - 'cloud read text channel' - ).toBe(true); - expect(JSON.stringify(fwd!.structuredContent)).toContain(FWD_PATH); - // Bare cloud search scopes to the default vault by design - use the @-scoped convention. - const search = asResult( - await mcp.callTool({ - name: 'memory__search', - arguments: { query: fwdMark, folder: `@${VAULT}` }, - }) - ); - expect( - JSON.stringify(search.structuredContent ?? {}), - 'cloud search finds the marker' - ).toContain(fwdMark); - - // --- REVERSE leg: cloud MCP write -> couch -> CLI disk ------------------------------------- - const revMark = `cloud-rev-${randomBytes(6).toString('hex')}`; - const cloudRev = `@${VAULT}/${REV_PATH}`; - const cw = asResult( - await mcp.callTool({ name: 'memory__write', arguments: { path: cloudRev, body: revMark } }) - ); - expect(cw.isError, JSON.stringify(cw)).not.toBe(true); - const diskFile = join(vaultDir, REV_PATH); - const revStart = Date.now(); - let reversed = false; - while (Date.now() - revStart < LEG_TIMEOUT_MS) { - await machine.exec(['vault', 'sync', VAULT]); - const onDisk = existsSync(diskFile) && readFileSync(diskFile, 'utf-8').includes(revMark); - if (onDisk) { - const gitLog = await run('git', ['-C', vaultDir, 'log', '--oneline']); - const read = await machine.exec(['memory', 'read', REV_PATH, '--vault', VAULT]); - if (gitLog.stdout.includes('sync: couch') && read.stdout.includes(revMark)) { - reversed = true; - break; - } - } - await sleep(4000); - } - expect( - reversed, - `reverse leg: cloud write did not converge to disk (committed as "sync: couch" + readable) within ${LEG_TIMEOUT_MS / 1000}s` - ).toBe(true); - log(`reverse leg converged to disk in ${((Date.now() - revStart) / 1000).toFixed(1)}s`); - - // --- DELETE sanity: CLI delete -> couch tombstone -> [bridge] -> cloud not-found ---------- - expect((await machine.exec(['memory', 'delete', FWD_PATH, '--vault', VAULT])).code).toBe(0); - expect((await machine.exec(['vault', 'sync', VAULT])).code).toBe(0); - const delStart = Date.now(); - let deleted = false; - while (Date.now() - delStart < LEG_TIMEOUT_MS) { - const res = await cloudRead(mcp, cloudFwd); - if (res.isError === true || JSON.stringify(res).includes('No memory at path')) { - deleted = true; - break; - } - await sleep(4000); - } - expect( - deleted, - `delete leg: tombstone did not reach the cloud MCP within ${LEG_TIMEOUT_MS / 1000}s` - ).toBe(true); - log(`delete leg tombstoned in cloud MCP in ${((Date.now() - delStart) / 1000).toFixed(1)}s`); - } finally { - if (mcp) await mcp.close().catch(() => {}); - await machine.exec(['daemon', 'stop']).catch(() => {}); - await browser.dispose(); - machine.cleanup(); - } - }); -}); diff --git a/e2e/couch-sync.test.ts b/e2e/couch-sync.test.ts deleted file mode 100644 index 9dcc2d1..0000000 --- a/e2e/couch-sync.test.ts +++ /dev/null @@ -1,626 +0,0 @@ -/** - * Hermetic couch account-sync tier: drives the REAL built CLI + daemon end to end against a REAL - * couchdb:3.4 container and a node:http stub for discovery/token/provision. Nothing here touches a - * deployed stack. Gated on docker: without it the whole tier skips (with a reason). @couch - * - * The couch JWT recipe (proven in a prior spike): the jwt handler must be in the config AT STARTUP - * (a runtime PUT does not engage it); the hmac key can then be set at runtime. The stub mints a - * REAL HS256 token couch must accept - that proves the production auth mode, not a bypass. - */ -import { execFile, execFileSync } from 'node:child_process'; -import { createHash, createHmac, randomUUID } from 'node:crypto'; -import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; -import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'; -import { createServer as netServer, type AddressInfo } from 'node:net'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { promisify } from 'node:util'; -import { CouchSync, createCouchState, encodeFile, type FetchLike } from '@agentage/memory-core'; -import { expect, test } from '@playwright/test'; -import { createCliMachine, freePort, type CliMachine } from './helpers.js'; - -const run = promisify(execFile); -const sleep = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)); - -// The couch user id: the JWT `sub`, and the only member of every per-test db. -const SUB = 'cli-couch-e2e'; -const VAULT = 'acct'; - -const dockerAvailable = (): boolean => { - try { - execFileSync('docker', ['info'], { stdio: 'ignore' }); - return true; - } catch { - return false; - } -}; -const DOCKER = dockerAvailable(); - -const freeTcpPort = (): Promise => - new Promise((resolve, reject) => { - const srv = netServer(); - srv.once('error', reject); - srv.listen(0, '127.0.0.1', () => { - const port = (srv.address() as AddressInfo).port; - srv.close((err) => (err ? reject(err) : resolve(port))); - }); - }); - -// --- couchdb:3.4 harness (adapted from web/packages/couch-bridge/test/couch-harness.ts) -------- -const IMAGE = 'couchdb:3.4'; -const ADMIN_USER = 'admin'; -const ADMIN_PASS = 'm5-admin-pw'; -const LOCAL_INI = [ - '[chttpd]', - 'authentication_handlers = {chttpd_auth, jwt_authentication_handler}, ' + - '{chttpd_auth, cookie_authentication_handler}, {chttpd_auth, default_authentication_handler}', - '', - '[jwt_auth]', - 'required_claims = exp', - '', -].join('\n'); - -interface Couch { - url: string; - adminAuth: string; - jwtSecret: string; - stop(): Promise; -} - -const waitUp = async (url: string): Promise => { - const deadline = Date.now() + 60_000; - for (;;) { - try { - if ((await fetch(`${url}/_up`)).ok) return; - } catch { - // not listening yet - } - if (Date.now() > deadline) throw new Error(`couch not up at ${url}`); - await sleep(250); - } -}; - -const startCouch = async (): Promise => { - const port = await freeTcpPort(); - const name = `cli-couch-it-${randomUUID().slice(0, 8)}`; - const dir = await mkdtemp(join(tmpdir(), 'cli-couch-it-')); - const ini = join(dir, 'zz-agentage.ini'); - await writeFile(ini, LOCAL_INI, 'utf8'); - const stop = async (): Promise => { - await run('docker', ['rm', '-f', name]).catch(() => {}); - await rm(dir, { recursive: true, force: true }); - }; - try { - // create + cp + start: a ro bind mount trips the image entrypoint's chown -R. - await run('docker', [ - 'create', - '--name', - name, - '-p', - `127.0.0.1:${port}:5984`, - '-e', - `COUCHDB_USER=${ADMIN_USER}`, - '-e', - `COUCHDB_PASSWORD=${ADMIN_PASS}`, - IMAGE, - ]); - await run('docker', ['cp', ini, `${name}:/opt/couchdb/etc/local.d/zz-agentage.ini`]); - await run('docker', ['start', name]); - - const url = `http://127.0.0.1:${port}`; - const adminAuth = 'Basic ' + Buffer.from(`${ADMIN_USER}:${ADMIN_PASS}`).toString('base64'); - await waitUp(url); - for (const db of ['_users', '_replicator']) { - // /_up can 200 before the env-provided admin is active - retry the cold-start 401 window - let res = await fetch(`${url}/${db}`, { - method: 'PUT', - headers: { Authorization: adminAuth }, - }); - for (let attempt = 0; res.status === 401 && attempt < 40; attempt++) { - await new Promise((r) => setTimeout(r, 250)); - res = await fetch(`${url}/${db}`, { method: 'PUT', headers: { Authorization: adminAuth } }); - } - if (!res.ok && res.status !== 412) throw new Error(`system db ${db} failed (${res.status})`); - } - const jwtSecret = `m5-secret-${randomUUID()}`; - const res = await fetch(`${url}/_node/_local/_config/jwt_keys/hmac%3A_default`, { - method: 'PUT', - headers: { Authorization: adminAuth, 'Content-Type': 'application/json' }, - body: JSON.stringify(Buffer.from(jwtSecret).toString('base64')), - }); - if (!res.ok) throw new Error(`jwt key set failed (${res.status})`); - return { url, adminAuth, jwtSecret, stop }; - } catch (e) { - await stop(); - throw e; - } -}; - -// A fresh per-test db whose only member is the JWT subject - proves the real member-scoped auth. -const createDb = async (couch: Couch): Promise => { - const db = `mem_${randomUUID().replace(/-/g, '').slice(0, 16)}`; - const create = await fetch(`${couch.url}/${db}`, { - method: 'PUT', - headers: { Authorization: couch.adminAuth }, - }); - if (!create.ok) throw new Error(`create db failed (${create.status})`); - const sec = await fetch(`${couch.url}/${db}/_security`, { - method: 'PUT', - headers: { Authorization: couch.adminAuth, 'Content-Type': 'application/json' }, - body: JSON.stringify({ - admins: { names: [], roles: [] }, - members: { names: [SUB], roles: [] }, - }), - }); - if (!sec.ok) throw new Error(`_security failed (${sec.status})`); - return db; -}; - -const b64url = (s: string): string => Buffer.from(s).toString('base64url'); - -// A real HS256 JWT (kid _default) couch validates against hmac:_default = base64(secret). -const mintJwt = (secret: string, ttlSec = 3600): string => { - const header = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT', kid: '_default' })); - const payload = b64url(JSON.stringify({ sub: SUB, exp: Math.floor(Date.now() / 1000) + ttlSec })); - const sig = createHmac('sha256', secret).update(`${header}.${payload}`).digest('base64url'); - return `${header}.${payload}.${sig}`; -}; - -const docUrl = (couch: Couch, db: string, id: string): string => - `${couch.url}/${db}/${encodeURIComponent(id)}`; - -const getDoc = async ( - couch: Couch, - db: string, - id: string -): Promise<{ status: number; json: Record }> => { - const res = await fetch(docUrl(couch, db, id), { headers: { Authorization: couch.adminAuth } }); - return { - status: res.status, - json: (await res.json().catch(() => ({}))) as Record, - }; -}; - -// Insert a proper file+leaf doc pair with the content-addressed shape, replicated-style. -const putFile = async (couch: Couch, db: string, path: string, content: string): Promise => { - const { leaves, fileDoc } = await encodeFile(path, content); - const rev = `1-${createHash('md5').update(JSON.stringify(fileDoc.leaves)).digest('hex')}`; - const res = await fetch(`${couch.url}/${db}/_bulk_docs`, { - method: 'POST', - headers: { Authorization: couch.adminAuth, 'Content-Type': 'application/json' }, - body: JSON.stringify({ new_edits: false, docs: [...leaves, { ...fileDoc, _rev: rev }] }), - }); - if (!res.ok) throw new Error(`_bulk_docs failed (${res.status})`); -}; - -// --- discovery / token / provision stub (a single server, all origins collapse onto it) -------- -interface Stub { - port: number; - requests(): number; - setDb(db: string): void; - setTokenFail(fail: boolean): void; - // Map an extra vault name onto the current db (the discover tier registers 'teamnotes' at runtime). - addVault(name: string): void; - // The vault names the daemon POSTed to /api/memories (proves the discover watcher provisioned). - provisioned(): string[]; - stop(): Promise; -} - -const readJson = (req: IncomingMessage): Promise> => - new Promise((resolve) => { - const chunks: Buffer[] = []; - req.on('data', (c: Buffer) => chunks.push(c)); - req.on('end', () => { - try { - resolve( - JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}') as Record - ); - } catch { - resolve({}); - } - }); - req.on('error', () => resolve({})); - }); - -const startStub = async (couch: Couch): Promise => { - const port = await freeTcpPort(); - let db = ''; - let count = 0; - let tokenFail = false; - const vaultNames = new Set([VAULT]); - const provisioned: string[] = []; - const handle = (req: IncomingMessage, res: ServerResponse): void => { - count += 1; - const url = (req.url ?? '').split('?')[0] ?? ''; - const send = (code: number, body: unknown): void => { - res.writeHead(code, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify(body)); - }; - if (req.method === 'GET' && url === '/.well-known/agentage-sync') - return send(200, { - git_endpoint: `http://127.0.0.1:${port}/git`, - vaults: [], - couch_endpoint: couch.url, - couch_token_url: `http://127.0.0.1:${port}/account/couch-token`, - couch_vaults: [...vaultNames].map((vault) => ({ vault, db })), - ttl: 60, - }); - if (req.method === 'POST' && url === '/account/couch-token') { - if (tokenFail) return send(401, { error: 'unauthorized' }); - // expSec 61 = the client cache lapses after ~1s (60s skew), so a test can force a re-mint; - // the JWT itself stays valid for an hour. - return send(200, { - success: true, - data: { jwt: mintJwt(couch.jwtSecret), db, sub: SUB, expSec: 61 }, - }); - } - if (req.method === 'POST' && url === '/api/memories') { - void readJson(req).then((body) => { - if (typeof body['name'] === 'string') provisioned.push(body['name']); - send(200, { success: true }); - }); - return; - } - send(404, { error: 'not found' }); - }; - const server = createServer(handle); - await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); - return { - port, - requests: () => count, - setDb: (d) => { - db = d; - }, - setTokenFail: (f) => { - tokenFail = f; - }, - addVault: (name) => void vaultNames.add(name), - provisioned: () => [...provisioned], - stop: () => new Promise((resolve) => server.close(() => resolve())), - }; -}; - -// --- machine helpers ---------------------------------------------------------------------------- -const fakeAuth = (m: CliMachine, stubPort: number): void => - writeFileSync( - join(m.configDir, 'auth.json'), - JSON.stringify({ - siteFqdn: `127.0.0.1:${stubPort}`, - clientId: 'stub-client', - tokens: { accessToken: 'stub-oauth-bearer', expiresAt: Date.now() + 3_600_000 }, - }) - ); - -// Pin the account origin to interval 0 (manual-only) so no background timer races assertions. -const setManual = (m: CliMachine): void => { - const p = join(m.configDir, 'vaults.json'); - const cfg = JSON.parse(readFileSync(p, 'utf8')) as { - vaults: Record; - }; - cfg.vaults[VAULT]!.origin[0]!.interval = 0; - writeFileSync(p, JSON.stringify(cfg, null, 2)); -}; - -interface Machine { - m: CliMachine; - vaultDir: string; - cleanup(): Promise; -} - -// A CLI machine wired to the stub, an account vault added + the daemon started. -const bootMachine = async (stub: Stub, opts: { signedIn: boolean }): Promise => { - const daemonPort = await freePort(); - const m = createCliMachine({ - AGENTAGE_SITE_FQDN: `127.0.0.1:${stub.port}`, - AGENTAGE_NO_DAEMON: '', - AGENTAGE_DAEMON_PORT: String(daemonPort), - }); - if (opts.signedIn) fakeAuth(m, stub.port); - const vaultDir = join(m.configDir, VAULT); - const add = await m.exec(['vault', 'add', VAULT, '--path', vaultDir]); - expect(add.code, add.stderr).toBe(0); - setManual(m); - const start = await m.exec(['daemon', 'start']); - expect(start.code, start.stderr).toBe(0); - return { - m, - vaultDir, - cleanup: async () => { - await m.exec(['daemon', 'stop']).catch(() => {}); - m.cleanup(); - }, - }; -}; - -const waitFor = async (predicate: () => Promise, ms = 15_000): Promise => { - const deadline = Date.now() + ms; - while (Date.now() < deadline) { - if (await predicate()) return; - await sleep(200); - } - throw new Error('waitFor: condition not met in time'); -}; - -test.describe('couch account sync (hermetic) @couch', () => { - test.skip(!DOCKER, 'docker unavailable - couch account-sync tier skipped'); - test.describe.configure({ timeout: 90_000 }); - - let couch: Couch; - let stub: Stub; - - test.beforeAll(async () => { - if (!DOCKER) return; - couch = await startCouch(); - stub = await startStub(couch); - }); - test.afterAll(async () => { - await stub?.stop(); - await couch?.stop(); - }); - - test('sync-on-save pushes a write to couch; status shows the couch vault', async () => { - const db = await createDb(couch); - stub.setDb(db); - const { m, vaultDir, cleanup } = await bootMachine(stub, { signedIn: true }); - try { - const body = 'account note pushed over the couch channel'; - expect((await m.exec(['memory', 'write', 'notes/x.md', '--body', body])).code).toBe(0); - - // The engine stored the serialized doc on disk; the couch leaf is content-addressed off it. - const onDisk = readFileSync(join(vaultDir, 'notes/x.md'), 'utf8'); - const { fileDoc, leaves } = await encodeFile('notes/x.md', onDisk); - const leaf = leaves[0]!; - await waitFor(async () => (await getDoc(couch, db, fileDoc._id)).status === 200); - const stored = await getDoc(couch, db, fileDoc._id); - expect(stored.json['path']).toBe('notes/x.md'); - expect(stored.json['leaves']).toEqual(fileDoc.leaves); - const storedLeaf = await getDoc(couch, db, leaf._id); - expect(storedLeaf.status).toBe(200); - expect(storedLeaf.json['data']).toBe(leaf.data); - - const status = await m.exec(['daemon', 'status']); - expect(status.stdout).toContain('couch sync'); - expect(status.stdout).toContain(VAULT); - } finally { - await cleanup(); - } - }); - - test('a file inserted into couch pulls to disk, commits, and reads back', async () => { - const db = await createDb(couch); - stub.setDb(db); - const { m, vaultDir, cleanup } = await bootMachine(stub, { signedIn: true }); - try { - await putFile(couch, db, 'notes/y.md', 'pulled from couch into the mirror\n'); - - const sync = await m.exec(['vault', 'sync', VAULT]); - expect(sync.code, sync.stderr).toBe(0); - - const disk = join(vaultDir, 'notes/y.md'); - expect(existsSync(disk), sync.stdout).toBe(true); - expect(readFileSync(disk, 'utf8')).toContain('pulled from couch'); - const gitLog = await run('git', ['-C', vaultDir, 'log', '--oneline']); - expect(gitLog.stdout).toContain('sync: couch'); - const read = await m.exec(['memory', 'read', 'notes/y.md']); - expect(read.stdout).toContain('pulled from couch'); - } finally { - await cleanup(); - } - }); - - test('deletes propagate both directions', async () => { - const db = await createDb(couch); - stub.setDb(db); - const { m, vaultDir, cleanup } = await bootMachine(stub, { signedIn: true }); - try { - // CLI delete -> couch tombstone. - expect((await m.exec(['memory', 'write', 'notes/z.md', '--body', 'delete me'])).code).toBe(0); - expect((await m.exec(['vault', 'sync', VAULT])).code).toBe(0); - await waitFor(async () => (await getDoc(couch, db, 'f:notes/z.md')).status === 200); - expect((await m.exec(['memory', 'delete', 'notes/z.md'])).code).toBe(0); - await waitFor(async () => (await getDoc(couch, db, 'f:notes/z.md')).status === 404); - - // Couch tombstone -> local file removed. - await putFile(couch, db, 'notes/w.md', 'temporary, soon removed by couch\n'); - expect((await m.exec(['vault', 'sync', VAULT])).code).toBe(0); - expect(existsSync(join(vaultDir, 'notes/w.md'))).toBe(true); - const cur = await getDoc(couch, db, 'f:notes/w.md'); - await fetch(`${docUrl(couch, db, 'f:notes/w.md')}?rev=${cur.json['_rev'] as string}`, { - method: 'DELETE', - headers: { Authorization: couch.adminAuth }, - }); - expect((await m.exec(['vault', 'sync', VAULT])).code).toBe(0); - expect(existsSync(join(vaultDir, 'notes/w.md'))).toBe(false); - } finally { - await cleanup(); - } - }); - - test('signed out: sync pauses, CRUD still works, zero couch requests', async () => { - const db = await createDb(couch); - stub.setDb(db); - const { m, cleanup } = await bootMachine(stub, { signedIn: false }); - try { - const before = stub.requests(); - expect( - (await m.exec(['memory', 'write', 'notes/s.md', '--body', 'works offline'])).code - ).toBe(0); - expect((await m.exec(['memory', 'read', 'notes/s.md'])).stdout).toContain('works offline'); - - const sync = await m.exec(['vault', 'sync', VAULT]); - expect(sync.code, sync.stderr).toBe(0); - expect(sync.stdout).toContain('paused (signed out)'); - - await sleep(400); // let any fire-and-forget push settle - expect(stub.requests(), 'signed-out sync must make zero network calls').toBe(before); - } finally { - await cleanup(); - } - }); - - test('a signed-out delete tombstones after sign-in; a fresh replica never resurrects it', async () => { - const db = await createDb(couch); - stub.setDb(db); - const { m, cleanup } = await bootMachine(stub, { signedIn: true }); - try { - // Two synced docs: keep.md stays, del.md is deleted while signed out. - expect((await m.exec(['memory', 'write', 'notes/keep.md', '--body', 'stays'])).code).toBe(0); - expect((await m.exec(['memory', 'write', 'notes/del.md', '--body', 'doomed'])).code).toBe(0); - expect((await m.exec(['vault', 'sync', VAULT])).code).toBe(0); - await waitFor(async () => (await getDoc(couch, db, 'f:notes/del.md')).status === 200); - - // Signed out: the delete succeeds locally and queues a durable deletion, no tombstone yet. - rmSync(join(m.configDir, 'auth.json')); - const del = await m.exec(['memory', 'delete', 'notes/del.md']); - expect(del.code, del.stderr).toBe(0); - await sleep(500); // let the fire-and-forget enqueue persist - expect((await getDoc(couch, db, 'f:notes/del.md')).status).toBe(200); - - // Sign back in and sync: the queued deletion becomes the couch tombstone. - fakeAuth(m, stub.port); - expect((await m.exec(['vault', 'sync', VAULT])).code).toBe(0); - await waitFor(async () => (await getDoc(couch, db, 'f:notes/del.md')).status === 404); - - // A fresh second replica replaying the feed from cursor 0 pulls keep.md but never del.md. - const files = new Map(); - const store = { - listMarkdown: async () => [...files.keys()], - read: async (p: string) => files.get(p) ?? null, - write: async (p: string, b: string) => void files.set(p, b), - remove: async (p: string) => void files.delete(p), - }; - const state = await createCouchState({ load: async () => null, save: async () => {} }); - const fetchLike: FetchLike = (url, init) => fetch(url, init as RequestInit); - const replica = new CouchSync( - store, - { endpoint: couch.url, db }, - fetchLike, - async () => mintJwt(couch.jwtSecret), - () => {}, - state - ); - await replica.pullOnce(); - expect(files.has('notes/keep.md'), 'replica pulled live content').toBe(true); - expect(files.has('notes/del.md'), 'deleted doc must not resurrect').toBe(false); - } finally { - await cleanup(); - } - }); - - test('a delete during a token-endpoint outage converges once tokens recover', async () => { - const db = await createDb(couch); - stub.setDb(db); - const { m, cleanup } = await bootMachine(stub, { signedIn: true }); - try { - expect((await m.exec(['memory', 'write', 'notes/q.md', '--body', 'target'])).code).toBe(0); - expect((await m.exec(['vault', 'sync', VAULT])).code).toBe(0); - await waitFor(async () => (await getDoc(couch, db, 'f:notes/q.md')).status === 200); - - // Let the daemon's short-lived token cache lapse, then 401 every mint during the delete. - await sleep(1200); - stub.setTokenFail(true); - const del = await m.exec(['memory', 'delete', 'notes/q.md']); - expect(del.code, del.stderr).toBe(0); - await sleep(500); // the live tombstone fails against the 401 and self-queues - expect((await getDoc(couch, db, 'f:notes/q.md')).status).toBe(200); - - // Token endpoint recovers: the next cycle drains the queued deletion. - stub.setTokenFail(false); - expect((await m.exec(['vault', 'sync', VAULT])).code).toBe(0); - await waitFor(async () => (await getDoc(couch, db, 'f:notes/q.md')).status === 404); - } finally { - await cleanup(); - } - }); - - test('live-discovers a folder dropped into a discover root, then honors remove+ignore', async () => { - const db = await createDb(couch); - stub.setDb(db); - stub.addVault('teamnotes'); // the discovered account vault resolves to this test's db - const daemonPort = await freePort(); - const m = createCliMachine({ - AGENTAGE_SITE_FQDN: `127.0.0.1:${stub.port}`, - AGENTAGE_NO_DAEMON: '', - AGENTAGE_DAEMON_PORT: String(daemonPort), - // Short knobs (floored at 1000/50ms) keep the tier deterministic even where fs.watch is flaky. - AGENTAGE_DISCOVER_POLL_MS: '1000', - AGENTAGE_DISCOVER_DEBOUNCE_MS: '150', - }); - fakeAuth(m, stub.port); - const rootDir = join(m.configDir, 'discover-root'); - mkdirSync(rootDir, { recursive: true }); - const vaultsPath = join(m.configDir, 'vaults.json'); - // autosync:false -> discovered entries are interval 0 (manual-only): no background timer race. - writeFileSync( - vaultsPath, - JSON.stringify( - { version: 1, discover: [{ path: rootDir, autosync: false }], vaults: {} }, - null, - 2 - ) - ); - interface Cfg { - vaults?: Record; - discover?: { path: string; ignore?: string[] }[]; - } - const readCfg = (): Cfg => JSON.parse(readFileSync(vaultsPath, 'utf8')) as Cfg; - - const start = await m.exec(['daemon', 'start']); - expect(start.code, start.stderr).toBe(0); - try { - // Drop a new folder (with a note) plus an invalid-named folder into the watched root. - mkdirSync(join(rootDir, 'teamnotes')); - writeFileSync( - join(rootDir, 'teamnotes', 'note.md'), - '# team note\nfrom a discovered folder\n' - ); - mkdirSync(join(rootDir, 'bad name!')); - - // WITHOUT a daemon restart the watcher registers teamnotes as an account vault. - await waitFor(async () => Boolean(readCfg().vaults?.teamnotes)); - const entry = readCfg().vaults!.teamnotes!; - expect(entry.origin?.[0]).toEqual({ remote: 'agentage', interval: 0 }); // account shape - expect(entry.mcp).toEqual(['local']); - expect(readCfg().vaults?.['bad name!'], 'invalid names never register').toBeUndefined(); - - // The watcher provisioned the discovered vault's cloud channel. - await waitFor(async () => stub.provisioned().includes('teamnotes')); - - // /api/sync/status lists both the discover root and the new couch target. /api/* now needs - // the daemon's per-boot token (0600 in the config dir). - const daemonToken = readFileSync(join(m.configDir, 'daemon.token'), 'utf-8').trim(); - await waitFor(async () => { - const s = (await ( - await fetch(`http://127.0.0.1:${daemonPort}/api/sync/status`, { - headers: { 'X-Agentage-Token': daemonToken }, - }) - ).json()) as { couch?: { vault: string }[]; discover?: { roots: string[] } }; - return ( - (s.discover?.roots ?? []).includes(rootDir) && - (s.couch ?? []).some((c) => c.vault === 'teamnotes') - ); - }); - - // A manual sync pushes the dropped note to couch as f:note.md. - const sync = await m.exec(['vault', 'sync', 'teamnotes']); - expect(sync.code, sync.stderr).toBe(0); - await waitFor(async () => (await getDoc(couch, db, 'f:note.md')).status === 200); - - // Remove appends the name to the root's ignore in the same save (V8). - const removed = await m.exec(['vault', 'remove', 'teamnotes']); - expect(removed.code, removed.stderr).toBe(0); - expect(removed.stdout).toContain('ignore'); - expect(readCfg().discover?.[0]?.ignore).toContain('teamnotes'); - expect(readCfg().vaults?.teamnotes).toBeUndefined(); - - // Touch the folder again: an ignored name is never re-discovered. - writeFileSync(join(rootDir, 'teamnotes', 'note2.md'), 'second note\n'); - await sleep(2500); // > debounce + several poll cycles - expect(readCfg().vaults?.teamnotes, 'ignored folder must not be re-added').toBeUndefined(); - } finally { - await m.exec(['daemon', 'stop']).catch(() => {}); - m.cleanup(); - } - }); -}); diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 90627c4..591f62f 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -124,6 +124,7 @@ export interface CliStatusReport { env: string; auth: { signedIn: boolean; tokenExpiresAt?: string; note?: string }; endpoint: { url: string; reachable: boolean }; + vaults: { name: string; channel: string; status: string }[]; } export const statusJson = async (machine: CliMachine): Promise => { diff --git a/src/commands/daemon/daemon-cmd.ts b/src/commands/daemon/daemon-cmd.ts index 4d489ee..aa877d7 100644 --- a/src/commands/daemon/daemon-cmd.ts +++ b/src/commands/daemon/daemon-cmd.ts @@ -98,23 +98,6 @@ const statusAction = async (): Promise => { console.log(` ${v.vault.padEnd(16)} ${cadence.padEnd(12)} ${state}`); } } - if (sync?.couch && sync.couch.length > 0) { - console.log('couch sync'); - for (const v of sync.couch) { - const cadence = v.intervalSeconds > 0 ? `every ${v.intervalSeconds}s` : 'manual'; - const state = v.paused - ? `paused: ${v.paused}` - : v.running - ? 'running' - : v.lastError - ? `error: ${v.lastError}` - : v.lastSync - ? `ok ${v.lastSync}` - : 'scheduled'; - const pending = v.pendingCount > 0 ? ` ${v.pendingCount} pending` : ''; - console.log(` ${v.vault.padEnd(16)} ${cadence.padEnd(12)} ${state}${pending}`); - } - } if (sync?.discover && sync.discover.roots.length > 0) { console.log(`discover roots (${sync.discover.roots.length})`); for (const root of sync.discover.roots) console.log(` ${root}`); diff --git a/src/commands/status/status.test.ts b/src/commands/status/status.test.ts index 8587bec..a890258 100644 --- a/src/commands/status/status.test.ts +++ b/src/commands/status/status.test.ts @@ -154,15 +154,27 @@ describe('printStatus', () => { ...baseReport, daemon: { running: true, port: 4243, mcp: true }, vaults: [ - { name: 'notes', channel: 'cloud', status: 'ok', lastRun: '2026-07-08T18:40:00Z' }, + { name: 'notes', channel: 'git', status: 'ok', lastRun: '2026-07-08T18:40:00Z' }, { name: 'work', channel: 'git', status: 'error', lastError: 'auth failed\ndetail' }, ], }); expect(out).toMatch(/vaults\s+2 connected/); - expect(out).toMatch(/notes\s+cloud\s+\S+ last ok/); + expect(out).toMatch(/notes\s+git\s+\S+ last ok/); expect(out).toMatch(/work\s+git\s+\S+ error \(auth failed\)/); }); + it('renders an account vault as not synced, and never counts it as connected', () => { + const out = captureLines({ + ...baseReport, + daemon: { running: true, port: 4243, mcp: true }, + vaults: [{ name: 'notes', channel: 'account', status: 'unsynced' }], + }); + expect(out).toMatch(/notes\s+account\s+\S+ not synced - account vaults have no sync channel/); + expect(out).not.toContain('connected'); + const vaultRow = out.split('\n').find((l) => l.includes('notes'))!; + expect(vaultRow).not.toMatch(/up to date|last ok|syncing/); + }); + it('renders a syncing vault while a cycle is in flight', () => { const out = captureLines({ ...baseReport, @@ -197,9 +209,9 @@ describe('printStatus', () => { const out = captureLines({ ...baseReport, daemon: { running: false, port: 4243 }, - vaults: [{ name: 'notes', channel: 'cloud', status: 'unknown' }], + vaults: [{ name: 'notes', channel: 'git', status: 'unknown' }], }); - expect(out).toMatch(/notes\s+cloud\s+.*unknown \(daemon stopped\)/); + expect(out).toMatch(/notes\s+git\s+.*unknown \(daemon stopped\)/); }); it('prints an actionable hint when there are zero vaults', () => { diff --git a/src/commands/vault/vault-sync.test.ts b/src/commands/vault/vault-sync.test.ts index ab9e117..c402c2a 100644 --- a/src/commands/vault/vault-sync.test.ts +++ b/src/commands/vault/vault-sync.test.ts @@ -1,9 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; import { type VaultsConfig } from '@agentage/memory-core'; import { type SyncResult } from '../../sync/git/cycle.js'; -import { type CouchSyncResult } from '../../sync/couch/manager.js'; import { type SyncTarget } from '../../sync/git/planner.js'; -import { runVaultSync, type VaultSyncDeps } from './vault-sync.js'; +import { ACCOUNT_NO_CHANNEL, runVaultSync, type VaultSyncDeps } from './vault-sync.js'; const result = (over: Partial = {}): SyncResult => ({ vault: 'v', @@ -15,16 +14,6 @@ const result = (over: Partial = {}): SyncResult => ({ ...over, }); -const couchResult = (over: Partial = {}): CouchSyncResult => ({ - vault: 'acct', - channel: 'couch', - ok: true, - committed: false, - pulled: false, - pendingCount: 0, - ...over, -}); - const gitConfig = (): VaultsConfig => ({ version: 1, vaults: { v: { path: '/tmp/v', origin: [{ remote: 'git@h:v.git', interval: 0 }] } }, @@ -42,7 +31,6 @@ const makeDeps = (over: Partial = {}): { deps: VaultSyncDeps; log daemonPort: async () => null, runViaDaemon: async () => result(), runGitInProcess: async () => result(), - runCouchInProcess: async () => couchResult(), log: (m) => logs.push(m), ...over, }; @@ -76,45 +64,49 @@ describe('runVaultSync', () => { expect(logs.join()).toContain('No syncable vaults'); }); - it('runs an account vault in-process over the couch channel', async () => { - const runCouchInProcess = vi.fn(async () => couchResult({ committed: true, pulled: true })); + it('names an account vault as unsynced instead of reporting success', async () => { const runGitInProcess = vi.fn(async (_t: SyncTarget) => result()); + const runViaDaemon = vi.fn(async () => result()); const { deps, logs } = makeDeps({ loadConfig: acctConfig, daemonPort: async () => null, - runCouchInProcess, runGitInProcess, + runViaDaemon, }); await runVaultSync('acct', deps); - expect(runCouchInProcess).toHaveBeenCalledWith('acct'); expect(runGitInProcess).not.toHaveBeenCalled(); - expect(logs.join()).toContain('acct (account): '); - expect(logs.join()).toContain('committed'); - expect(logs.join()).toContain('pulled'); + expect(runViaDaemon).not.toHaveBeenCalled(); + expect(logs.join('\n')).toContain(`acct (account): ${ACCOUNT_NO_CHANNEL}`); + expect(logs.join('\n')).not.toContain('up to date'); + expect(logs.join('\n')).not.toContain('Syncing'); }); - it('renders a paused account vault clearly', async () => { + it('reports account vaults even when a reachable daemon could be asked', async () => { + const runViaDaemon = vi.fn(async () => result()); const { deps, logs } = makeDeps({ loadConfig: acctConfig, - daemonPort: async () => null, - runCouchInProcess: async () => couchResult({ paused: 'signed out' }), + daemonPort: async () => 4243, + runViaDaemon, }); - await runVaultSync('acct', deps); - expect(logs.join()).toContain('paused (signed out)'); + await runVaultSync(undefined, deps); + expect(runViaDaemon).not.toHaveBeenCalled(); + expect(logs.join('\n')).toContain(ACCOUNT_NO_CHANNEL); }); - it('delegates an account vault to the daemon when one is reachable', async () => { - const runViaDaemon = vi.fn(async () => couchResult()); - const runCouchInProcess = vi.fn(async () => couchResult()); - const { deps } = makeDeps({ - loadConfig: acctConfig, - daemonPort: async () => 4243, - runViaDaemon, - runCouchInProcess, + it('syncs git vaults and still names the account vaults alongside them', async () => { + const mixed = (): VaultsConfig => ({ + version: 1, + vaults: { + ...gitConfig().vaults, + ...acctConfig().vaults, + }, }); - await runVaultSync('acct', deps); - expect(runViaDaemon).toHaveBeenCalledWith(4243, 'acct'); - expect(runCouchInProcess).not.toHaveBeenCalled(); + const { deps, logs } = makeDeps({ loadConfig: mixed, daemonPort: async () => null }); + await runVaultSync(undefined, deps); + const out = logs.join('\n'); + expect(out).toContain('Syncing 1 vault(s)...'); + expect(out).toContain('pushed'); + expect(out).toContain(`acct (account): ${ACCOUNT_NO_CHANNEL}`); }); it('runs a git vault in-process when the daemon is down', async () => { diff --git a/src/commands/vault/vault-sync.ts b/src/commands/vault/vault-sync.ts index 0dca28f..e56c44a 100644 --- a/src/commands/vault/vault-sync.ts +++ b/src/commands/vault/vault-sync.ts @@ -1,12 +1,10 @@ import chalk from 'chalk'; -import { type VaultsConfig } from '@agentage/memory-core'; -import { health, syncRun, type SyncRunResult } from '../../lib/daemon/daemon-client.js'; +import { isAccountVault, type VaultsConfig } from '@agentage/memory-core'; +import { health, syncRun } from '../../lib/daemon/daemon-client.js'; import { daemonDisabled } from '../../lib/daemon/daemon-pref.js'; import { loadVaultsConfig } from '../../lib/vault/vaults.js'; import { resolvePort } from '../../daemon/lifecycle.js'; import { runSyncCycle, type SyncResult } from '../../sync/git/cycle.js'; -import { createCouchSyncManager, type CouchSyncResult } from '../../sync/couch/manager.js'; -import { couchTargets } from '../../sync/couch/targets.js'; import { syncTargets, type SyncTarget } from '../../sync/git/planner.js'; import { redactRemoteUrl } from '../../sync/git/remote-url.js'; @@ -14,13 +12,12 @@ export interface VaultSyncDeps { loadConfig: () => VaultsConfig; // The port of a reachable daemon, or null to run in-process (daemon down or --no-daemon). daemonPort: () => Promise; - runViaDaemon: (port: number, vault: string) => Promise; + runViaDaemon: (port: number, vault: string) => Promise; runGitInProcess: (target: SyncTarget) => Promise; - runCouchInProcess: (vault: string) => Promise; log: (msg: string) => void; } -const isCouch = (r: SyncRunResult): r is CouchSyncResult => 'channel' in r && r.channel === 'couch'; +export const ACCOUNT_NO_CHANNEL = 'not synced - account vaults have no sync channel'; const describeGit = (r: SyncResult): string => { if (!r.ok) return chalk.red(`failed (${r.reason ?? 'error'})${r.error ? `: ${r.error}` : ''}`); @@ -32,29 +29,28 @@ const describeGit = (r: SyncResult): string => { return chalk.green(bits.length ? bits.join(', ') : 'up to date'); }; -const describeCouch = (r: CouchSyncResult): string => { - if (r.paused) return chalk.yellow(`paused (${r.paused})`); - if (!r.ok) return chalk.red(`failed${r.error ? `: ${r.error}` : ''}`); - const bits: string[] = []; - if (r.committed) bits.push('committed'); - if (r.pulled) bits.push('pulled'); - if (r.pendingCount) bits.push(`${r.pendingCount} pending`); - return chalk.green(bits.length ? bits.join(', ') : 'up to date'); -}; - -const report = (log: (msg: string) => void, r: SyncRunResult): void => { - if (isCouch(r)) { - log(`${r.vault} (account): ${describeCouch(r)}`); - return; - } +const report = (log: (msg: string) => void, r: SyncResult): void => { log(`${r.vault} -> ${redactRemoteUrl(r.remote)}: ${describeGit(r)}`); for (const c of r.conflicts) log(` kept remote copy: ${c}`); }; -// `agentage vault sync [name]`: sync one vault (or every syncable vault). Git-origin vaults -// commit + push + pull-rebase; account (agentage) vaults sync the couch channel. Prefers a running -// daemon (single writer), else runs the cycle in-process. Works for interval-0 (manual-only) vaults -// and with the daemon down. Failures are surfaced, not thrown (V6: never a crash). +// Account vaults whose only origin is the reserved `agentage` remote: nothing syncs them. They are +// named, never silently skipped, so `vault sync` cannot read as "everything is up to date". +const unsyncableAccountVaults = (config: VaultsConfig, gitTargets: SyncTarget[]): string[] => { + const git = new Set(gitTargets.map((t) => t.vault)); + return Object.entries(config.vaults ?? {}) + .filter(([vault, entry]) => isAccountVault(entry) && !git.has(vault)) + .map(([vault]) => vault); +}; + +const reportAccounts = (log: (msg: string) => void, vaults: string[]): void => { + for (const vault of vaults) log(`${vault} (account): ${chalk.yellow(ACCOUNT_NO_CHANNEL)}`); +}; + +// `agentage vault sync [name]`: sync one vault (or every syncable vault). Git-origin vaults commit +// + push + pull-rebase; account (agentage) vaults have no sync channel and are reported as such. +// Prefers a running daemon (single writer), else runs the cycle in-process. Works for interval-0 +// (manual-only) vaults and with the daemon down. Failures are surfaced, not thrown (V6: never a crash). export const runVaultSync = async ( name: string | undefined, deps: VaultSyncDeps @@ -63,18 +59,18 @@ export const runVaultSync = async ( // A named vault that is not registered is an error (mirrors `vault remove`), not a silent no-op. if (name && !(config.vaults ?? {})[name]) throw new Error(`vault '${name}' not found`); const gitTargets = syncTargets(config).filter((t) => !name || t.vault === name); - const couchVaults = couchTargets(config) - .filter((t) => !name || t.vault === name) - .map((t) => t.vault); - if (gitTargets.length === 0 && couchVaults.length === 0) { - deps.log( - name - ? `No syncable origin configured for vault '${name}'.` - : 'No syncable vaults. Add one with `agentage vault add `.' - ); + const accounts = unsyncableAccountVaults(config, gitTargets).filter((v) => !name || v === name); + if (gitTargets.length === 0) { + reportAccounts(deps.log, accounts); + if (accounts.length === 0) + deps.log( + name + ? `No syncable origin configured for vault '${name}'.` + : 'No syncable vaults. Add one with `agentage vault add `.' + ); return; } - const vaults = [...new Set([...gitTargets.map((t) => t.vault), ...couchVaults])]; + const vaults = [...new Set(gitTargets.map((t) => t.vault))]; deps.log(`Syncing ${vaults.length} vault(s)...`); const port = await deps.daemonPort(); if (port !== null) { @@ -82,16 +78,13 @@ export const runVaultSync = async ( deps.log(`${vault}...`); report(deps.log, await deps.runViaDaemon(port, vault)); } - return; - } - for (const target of gitTargets) { - deps.log(`${target.vault}...`); - report(deps.log, await deps.runGitInProcess(target)); - } - for (const vault of couchVaults) { - deps.log(`${vault}...`); - report(deps.log, await deps.runCouchInProcess(vault)); + } else { + for (const target of gitTargets) { + deps.log(`${target.vault}...`); + report(deps.log, await deps.runGitInProcess(target)); + } } + reportAccounts(deps.log, accounts); }; const resolveDaemonPort = async (): Promise => { @@ -105,6 +98,5 @@ export const defaultVaultSyncDeps = (): VaultSyncDeps => ({ daemonPort: resolveDaemonPort, runViaDaemon: syncRun, runGitInProcess: runSyncCycle, - runCouchInProcess: (vault) => createCouchSyncManager().runNow(vault), log: (msg) => console.log(msg), }); diff --git a/src/commands/vault/vault.test.ts b/src/commands/vault/vault.test.ts index 2d9d875..9437cd3 100644 --- a/src/commands/vault/vault.test.ts +++ b/src/commands/vault/vault.test.ts @@ -102,18 +102,18 @@ describe('vault add', () => { expect(h.ensured).toEqual(['/data/acct']); }); - it('surfaces the provisioning message even when the channel is disabled', async () => { + it('surfaces the provisioning message even when provisioning did not succeed', async () => { const h = makeDeps( { version: 1, vaults: {} }, { - status: 'disabled', - message: "Vault 'acct' registered locally. Account sync is not enabled.", + status: 'offline', + message: "Vault 'acct' registered locally - will provision when online.", } ); await runVaultAdd('acct', {}, h.deps); // Provisioning is never fatal: the entry stands and the calm one-liner is printed. expect(h.get().vaults?.acct).toBeDefined(); - expect(h.logs.join()).toContain('Account sync is not enabled'); + expect(h.logs.join()).toContain('will provision when online'); }); it('rejects --path combined with --local/--git', async () => { diff --git a/src/commands/vault/vault.ts b/src/commands/vault/vault.ts index 90f753d..404e9ee 100644 --- a/src/commands/vault/vault.ts +++ b/src/commands/vault/vault.ts @@ -59,7 +59,8 @@ const buildEntry = (name: string, opts: VaultAddOptions): VaultEntry => { const path = typeof opts.local === 'string' ? opts.local : `~/vaults/${name}`; return { path, mcp: ['local'] }; } - // No --local/--git: an account vault - a local mirror synced to the account (agentage) channel. + // No --local/--git: an account vault - a local folder plus a memory in your account; this CLI + // has no channel that syncs the two. return { path: opts.path ?? `~/vaults/${name}`, origin: [{ remote: 'agentage' }] }; }; @@ -167,7 +168,7 @@ export const registerVault = (program: Command): void => { vault .command('sync [name]') - .description('Sync vaults now (git commit/push/pull, or the account channel)') + .description('Sync vaults now (git commit/push/pull)') .action((name: string | undefined) => runVaultSync(name, defaultVaultSyncDeps()).catch((err: unknown) => { console.error(chalk.red(err instanceof Error ? err.message : String(err))); diff --git a/src/daemon-entry.ts b/src/daemon-entry.ts index 46a4649..01ab042 100644 --- a/src/daemon-entry.ts +++ b/src/daemon-entry.ts @@ -1,6 +1,5 @@ import { unwatchFile, watchFile } from 'node:fs'; import { fileURLToPath } from 'node:url'; -import { isAccountVault } from '@agentage/memory-core'; import { createClientProvider } from './daemon/client-provider.js'; import { EADDRINUSE_EXIT_CODE, @@ -15,8 +14,7 @@ import { } from './daemon/lifecycle.js'; import { createDaemonServer } from './daemon/server.js'; import { loadLocalMemoryServer } from './mcp/local-server.js'; -import { loadVaultsConfig, vaultsJsonPath } from './lib/vault/vaults.js'; -import { createCouchSyncManager } from './sync/couch/manager.js'; +import { vaultsJsonPath } from './lib/vault/vaults.js'; import { createDiscoverWatcher } from './sync/discover/watcher.js'; import { createSyncManager } from './sync/git/manager.js'; import { VERSION } from './utils/version.js'; @@ -41,7 +39,7 @@ export const createStateCleanup = ( }; // Run each reschedule independently: a transiently-invalid config edit must not crash the daemon or -// stop the other channels rescheduling; the throwing one keeps its last-good schedule. +// stop the others rescheduling; the throwing one keeps its last-good schedule. export const safeReschedule = (steps: Array<() => void>, onError: (msg: string) => void): void => { for (const step of steps) { try { @@ -73,34 +71,24 @@ const state = createStateCleanup(() => { // The detached, long-lived engine host: one loopback HTTP server that owns a single in-process // engine and serialises every vault mutation, avoiding concurrent git index.lock collisions. It -// runs both sync loops (git origins + the account/couch channel) and reschedules on config change. +// runs the git sync loop and reschedules on config change. const main = async (): Promise => { const port = resolvePort(); const authToken = generateDaemonToken(); const git = createSyncManager(); - const couch = createCouchSyncManager(); const discover = createDiscoverWatcher({ log: (msg) => console.log(`[discover] ${msg}`), debounceMs: envInt('AGENTAGE_DISCOVER_DEBOUNCE_MS'), pollMs: envInt('AGENTAGE_DISCOVER_POLL_MS'), }); - // A vault is on exactly one channel: an account (agentage) vault syncs over couch, else git. - const runNow = ( - vault: string - ): ReturnType | ReturnType => { - const entry = loadVaultsConfig().config.vaults?.[vault]; - return entry && isAccountVault(entry) ? couch.runNow(vault) : git.runNow(vault); - }; - const server = createDaemonServer({ getClient: createClientProvider(), buildMcpServer: mcpEnabled() ? loadLocalMemoryServer : undefined, sync: { - status: () => ({ ...git.status(), couch: couch.status(), discover: discover.status() }), - runNow, + status: () => ({ ...git.status(), discover: discover.status() }), + runNow: (vault) => git.runNow(vault), }, - onMutation: (verb, body) => couch.onWrite(verb, body), authToken, version: VERSION, }); @@ -111,9 +99,8 @@ const main = async (): Promise => { state.markOwned(); const reschedule = (): void => - safeReschedule( - [() => git.reschedule(), () => couch.reschedule(), () => discover.reschedule()], - (msg) => console.error(`[daemon] reschedule failed: ${msg}`) + safeReschedule([() => git.reschedule(), () => discover.reschedule()], (msg) => + console.error(`[daemon] reschedule failed: ${msg}`) ); reschedule(); @@ -123,7 +110,6 @@ const main = async (): Promise => { const shutdown = (): void => { unwatchFile(configPath); git.stop(); - couch.stop(); discover.stop(); server.stop().finally(() => { state.cleanup(); diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 0037b10..549c436 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -2,9 +2,8 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { type MemoryClient } from '../lib/memory/memory-client.js'; import { type SyncResult } from '../sync/git/cycle.js'; -import { type CouchSyncResult } from '../sync/couch/manager.js'; import { type SyncStatus } from '../sync/git/manager.js'; -import { dispatchMemory, isMemoryVerb, type MemoryVerb } from './actions.js'; +import { dispatchMemory, isMemoryVerb } from './actions.js'; import { isAllowedHost, isAllowedOrigin, loopbackHosts } from './guards.js'; import { handleMcp } from './mcp-http.js'; @@ -13,8 +12,7 @@ const AUTH_HEADER = 'x-agentage-token'; export interface DaemonSyncApi { status: () => SyncStatus; - // A git vault yields a SyncResult, an account vault a CouchSyncResult - the caller branches. - runNow: (vault: string) => Promise; + runNow: (vault: string) => Promise; } export interface DaemonServerOptions { @@ -23,9 +21,6 @@ export interface DaemonServerOptions { buildMcpServer?: () => Promise; // The git-sync surface: GET /api/sync/status + POST /api/sync/run; omit to leave both unmounted. sync?: DaemonSyncApi; - // Fired after a successful write/edit/delete so the account channel can push-on-save. Never - // awaited: a couch failure must not affect the memory API response. - onMutation?: (verb: MemoryVerb, body: unknown) => void; // Per-daemon secret required on X-Agentage-Token for every /api/* call except /api/health. authToken: string; version: string; @@ -127,7 +122,6 @@ export const createDaemonServer = (opts: DaemonServerOptions): DaemonServer => { const body = await readBody(req); const result = await dispatchMemory(await opts.getClient(), verb, body); served += 1; - opts.onMutation?.(verb, body); // fire-and-forget account push-on-save return send(res, 200, result); } catch (err) { return send(res, 400, { error: err instanceof Error ? err.message : String(err) }); diff --git a/src/lib/auth/api.test.ts b/src/lib/auth/api.test.ts index b0bfa50..5e0cbe8 100644 --- a/src/lib/auth/api.test.ts +++ b/src/lib/auth/api.test.ts @@ -6,7 +6,6 @@ import { authedGet, authedPost, AuthRequiredError, - currentBearer, introspectToken, refreshOrThrow, TransientAuthError, @@ -140,7 +139,6 @@ describe('authedPost', () => { vi.stubGlobal('fetch', fetchMock); const res = await authedPost(makeAuth(), target, 'https://x.example/api/memories', { name: 'acct', - channel: 'couch', }); expect(res.status).toBe(201); expect(fetchMock).toHaveBeenCalledWith('https://x.example/api/memories', { @@ -150,7 +148,7 @@ describe('authedPost', () => { 'content-type': 'application/json', ...versionHeaders, }, - body: JSON.stringify({ name: 'acct', channel: 'couch' }), + body: JSON.stringify({ name: 'acct' }), redirect: 'manual', }); }); @@ -179,77 +177,6 @@ describe('authedPost', () => { }); }); -describe('currentBearer', () => { - let dir: string; - - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'agentage-api-')); - process.env['AGENTAGE_CONFIG_DIR'] = dir; - }); - - afterEach(() => { - delete process.env['AGENTAGE_CONFIG_DIR']; - rmSync(dir, { recursive: true, force: true }); - vi.unstubAllGlobals(); - }); - - it('returns a valid unexpired token as-is, with zero network', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - const auth = makeAuth({ accessToken: 'live-token', expiresAt: Date.now() + 60_000 }); - const bearer = await currentBearer(() => auth, target); - expect(bearer).toBe('live-token'); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('refreshes exactly once on an expired token, returns and persists the new bearer', async () => { - const fetchMock = vi.fn((url: string) => - Promise.resolve( - String(url).includes('/token') - ? jsonResponse(200, { - access_token: 'fresh-token', - refresh_token: 'rt2', - expires_in: 3600, - }) - : jsonResponse(500, {}) - ) - ); - vi.stubGlobal('fetch', fetchMock); - const auth = makeAuth({ accessToken: 'stale-token', expiresAt: Date.now() - 1000 }); - const bearer = await currentBearer(() => auth, target); - expect(bearer).toBe('fresh-token'); - const tokenCalls = fetchMock.mock.calls.filter((c) => String(c[0]).includes('/token')); - expect(tokenCalls).toHaveLength(1); - expect(readAuth()?.tokens.accessToken).toBe('fresh-token'); - }); - - it('returns null (never throws) when the refresh fails', async () => { - const fetchMock = vi.fn((url: string) => - Promise.resolve( - String(url).includes('/token') ? jsonResponse(400, {}) : jsonResponse(200, {}) - ) - ); - vi.stubGlobal('fetch', fetchMock); - const auth = makeAuth({ accessToken: 'stale-token', expiresAt: Date.now() - 1000 }); - await expect(currentBearer(() => auth, target)).resolves.toBeNull(); - }); - - it('returns null with no refresh attempt when an expired token has no refresh token', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - const auth = makeAuth({ expiresAt: Date.now() - 1000, refreshToken: undefined }); - await expect(currentBearer(() => auth, target)).resolves.toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - it('returns null when signed out (no stored access token)', async () => { - const fetchMock = vi.fn(); - vi.stubGlobal('fetch', fetchMock); - await expect(currentBearer(() => null, target)).resolves.toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); - }); -}); - describe('introspectToken', () => { let dir: string; diff --git a/src/lib/auth/api.ts b/src/lib/auth/api.ts index e1de92b..34d1c20 100644 --- a/src/lib/auth/api.ts +++ b/src/lib/auth/api.ts @@ -52,20 +52,6 @@ const tryRefresh = async (auth: AuthState, links: Links): Promise => { } }; -// The current OAuth bearer for background (couch) sync. Reads auth.json fresh on every call - the -// user may sign in or out between ticks - and refreshes once when the stored token is past its -// stated expiry. Returns null when signed out so a caller pauses with zero network, never throws. -export const currentBearer = async ( - readAuth: () => AuthState | null, - links: Links -): Promise => { - const auth = readAuth(); - if (!auth?.tokens.accessToken) return null; - const expired = auth.tokens.expiresAt !== undefined && auth.tokens.expiresAt <= Date.now(); - if (expired && !(await tryRefresh(auth, links))) return null; - return auth.tokens.accessToken; -}; - // redirect: 'manual' so the bearer is never replayed to a redirect target; any 3xx is an error. const isRedirect = (res: Response): boolean => res.type === 'opaqueredirect' || (res.status >= 300 && res.status < 400); diff --git a/src/lib/auth/provision.test.ts b/src/lib/auth/provision.test.ts index 6025dc4..990b9a0 100644 --- a/src/lib/auth/provision.test.ts +++ b/src/lib/auth/provision.test.ts @@ -28,14 +28,11 @@ const makeDeps = ( }; describe('provisionAccountVault', () => { - it('POSTs {name, channel:"couch"} to /memories when signed in', async () => { + it('POSTs {name} to /memories when signed in', async () => { const { deps, post } = makeDeps(); await provisionAccountVault('acct', deps); expect(post).toHaveBeenCalledTimes(1); - expect(post).toHaveBeenCalledWith(auth, target, `${target.api}/memories`, { - name: 'acct', - channel: 'couch', - }); + expect(post).toHaveBeenCalledWith(auth, target, `${target.api}/memories`, { name: 'acct' }); }); it('201 -> provisioned', async () => { @@ -45,37 +42,13 @@ describe('provisionAccountVault', () => { expect(res.message).toContain("Provisioned account vault 'acct'"); }); - it('200 -> exists (idempotent, already on the channel)', async () => { + it('200 -> exists (idempotent, the memory is already in the account)', async () => { const { deps } = makeDeps({ post: vi.fn(async () => jsonResponse(200)) }); const res = await provisionAccountVault('acct', deps); expect(res.status).toBe('exists'); expect(res.message).toContain('already provisioned'); }); - it('403 CHANNEL_DISABLED -> disabled, kept locally', async () => { - const post = vi.fn(async () => jsonResponse(403, { error: { code: 'CHANNEL_DISABLED' } })); - const { deps } = makeDeps({ post }); - const res = await provisionAccountVault('acct', deps); - expect(res.status).toBe('disabled'); - expect(res.message).toContain('registered locally'); - expect(res.message).toContain('not enabled'); - }); - - it('409 CHANNEL_CONFLICT -> conflict, kept locally, no retry on another channel', async () => { - const post = vi.fn(async () => jsonResponse(409, { error: { code: 'CHANNEL_CONFLICT' } })); - const { deps } = makeDeps({ post }); - const res = await provisionAccountVault('acct', deps); - expect(res.status).toBe('conflict'); - expect(res.message).toContain('another channel'); - expect(post).toHaveBeenCalledTimes(1); - }); - - it('accepts a top-level error code envelope too', async () => { - const post = vi.fn(async () => jsonResponse(403, { code: 'CHANNEL_DISABLED' })); - const { deps } = makeDeps({ post }); - expect((await provisionAccountVault('acct', deps)).status).toBe('disabled'); - }); - it('401 -> unauthenticated, kept locally with a setup hint', async () => { const { deps } = makeDeps({ post: vi.fn(async () => jsonResponse(401)) }); const res = await provisionAccountVault('acct', deps); @@ -112,8 +85,14 @@ describe('provisionAccountVault', () => { expect(res.message).toContain('when online'); }); - it('an unexpected status stays non-fatal (offline)', async () => { - const { deps } = makeDeps({ post: vi.fn(async () => jsonResponse(500)) }); - expect((await provisionAccountVault('acct', deps)).status).toBe('offline'); + it('any other status stays non-fatal (offline), keeping the local entry', async () => { + for (const status of [403, 409, 500]) { + const post = vi.fn(async () => jsonResponse(status)); + const { deps } = makeDeps({ post }); + const res = await provisionAccountVault('acct', deps); + expect(res.status).toBe('offline'); + expect(res.message).toContain('registered locally'); + expect(post).toHaveBeenCalledTimes(1); + } }); }); diff --git a/src/lib/auth/provision.ts b/src/lib/auth/provision.ts index 1847af7..7e234c6 100644 --- a/src/lib/auth/provision.ts +++ b/src/lib/auth/provision.ts @@ -2,12 +2,12 @@ import { authedPost } from './api.js'; import { readAuth, type AuthState } from '../fs/config.js'; import { links as buildLinks, siteFqdn, type Links } from '../net/origins.js'; -// Provision an account vault's cloud channel. Offline-first: this is NEVER fatal to the local -// registration - the caller keeps the local entry whatever happens here, and the daemon sync -// loop re-provisions idempotently later. +// Create the account-side memory for an account vault. Offline-first: this is NEVER fatal to the +// local registration - the caller keeps the local entry whatever happens here, and the discover +// watcher re-provisions idempotently later. It creates the memory in the account only: nothing +// syncs it to this machine. -export type ProvisionStatus = - 'provisioned' | 'exists' | 'disabled' | 'conflict' | 'unauthenticated' | 'offline'; +export type ProvisionStatus = 'provisioned' | 'exists' | 'unauthenticated' | 'offline'; export interface ProvisionResult { status: ProvisionStatus; @@ -26,17 +26,6 @@ export const defaultProvisionDeps = (): ProvisionDeps => ({ post: authedPost, }); -// The API error envelope carries the code as `error.code` (or a top-level `code`); read it best -// effort - a non-JSON body just yields undefined and the caller falls back to non-fatal. -const errorCode = async (res: Response): Promise => { - try { - const body = (await res.json()) as { error?: { code?: string }; code?: string }; - return body.error?.code ?? body.code; - } catch { - return undefined; - } -}; - const registeredLocally = (name: string, tail: string): string => `Vault '${name}' registered locally${tail}`; @@ -48,17 +37,17 @@ export const provisionAccountVault = async ( if (!auth) { return { status: 'unauthenticated', - message: registeredLocally(name, ' - run `agentage setup` to sync.'), + message: registeredLocally(name, ' - run `agentage setup` to create it in your account.'), }; } // A PAT is an MCP-surface credential; the backend REST provisioning endpoint rejects plain - // bearers (only session cookies), so it cannot provision an account channel. Fail clearly. + // bearers (only session cookies), so it cannot create the account memory. Fail clearly. if (auth.kind === 'pat') { return { status: 'unauthenticated', message: registeredLocally( name, - ' - account-channel provisioning needs an interactive session (run `agentage setup`); ' + + ' - account provisioning needs an interactive session (run `agentage setup`); ' + 'a personal access token only authorizes memory (MCP) calls.' ), }; @@ -67,7 +56,7 @@ export const provisionAccountVault = async ( const links = deps.links(); let res: Response; try { - res = await deps.post(auth, links, `${links.api}/memories`, { name, channel: 'couch' }); + res = await deps.post(auth, links, `${links.api}/memories`, { name }); } catch { return { status: 'offline', @@ -82,22 +71,7 @@ export const provisionAccountVault = async ( if (res.status === 401) return { status: 'unauthenticated', - message: registeredLocally(name, ' - run `agentage setup` to sync.'), - }; - - const code = await errorCode(res); - if (res.status === 403 && code === 'CHANNEL_DISABLED') - return { - status: 'disabled', - message: registeredLocally(name, '. Account sync is not enabled on this server.'), - }; - if (res.status === 409 && code === 'CHANNEL_CONFLICT') - return { - status: 'conflict', - message: registeredLocally( - name, - `. A memory named '${name}' already exists on another channel - not syncing.` - ), + message: registeredLocally(name, ' - run `agentage setup` to create it in your account.'), }; // Any other status stays non-fatal: keep the local entry, let the daemon retry later. diff --git a/src/lib/daemon/daemon-client.ts b/src/lib/daemon/daemon-client.ts index bcb3e39..c146d83 100644 --- a/src/lib/daemon/daemon-client.ts +++ b/src/lib/daemon/daemon-client.ts @@ -10,12 +10,8 @@ import { } from '@agentage/memory-core'; import { EADDRINUSE_EXIT_CODE, readDaemonToken, resolvePort } from '../../daemon/lifecycle.js'; import { type SyncResult } from '../../sync/git/cycle.js'; -import { type CouchSyncResult } from '../../sync/couch/manager.js'; import { type SyncStatus } from '../../sync/git/manager.js'; import { VERSION } from '../../utils/version.js'; - -// One vault syncs on exactly one channel; /api/sync/run yields whichever result fits the vault. -export type SyncRunResult = SyncResult | CouchSyncResult; import { type DeleteResult, type ListOptions, @@ -81,9 +77,8 @@ export const syncStatus = async (port: number, timeoutMs = 1000): Promise => { +// Ask the daemon to sync one vault now; the daemon runs the git cycle in its own process. +export const syncRun = async (port: number, vault: string): Promise => { const res = await fetch(`${base(port)}/api/sync/run`, { method: 'POST', headers: apiHeaders(), @@ -93,7 +88,7 @@ export const syncRun = async (port: number, vault: string): Promise ({}))) as { error?: string }; throw new Error(data.error || `sync request failed: ${res.status}`); } - return res.json() as Promise; + return res.json() as Promise; }; export const waitForHealth = async ( diff --git a/src/lib/net/origins.ts b/src/lib/net/origins.ts index f3ee9fe..feed17c 100644 --- a/src/lib/net/origins.ts +++ b/src/lib/net/origins.ts @@ -7,7 +7,7 @@ export interface Links { api: string; auth: string; mcp: string; - // Sync bootstrap host for GET /.well-known/agentage-sync (git + couch endpoints). + // Git smart-HTTP sync host; mirrors @agentage/shared links(). sync: string; } diff --git a/src/lib/status/status-info.test.ts b/src/lib/status/status-info.test.ts index 315f867..73f9605 100644 --- a/src/lib/status/status-info.test.ts +++ b/src/lib/status/status-info.test.ts @@ -261,13 +261,15 @@ describe('gatherStatus daemon probe', () => { }); }); - it('marks mcp off and folds an error across git + couch vaults', async () => { + it('marks mcp off and folds an error across the git vaults', async () => { bootPidFiles(); stubDaemon( { ok: true, version: '9.9.9', pid: process.pid, uptime: 5, served: 0, mcp: false }, { - vaults: [{ vault: 'a', running: true, lastRun: '2026-07-08T09:00:00Z' }], - couch: [{ vault: 'b', running: false, lastError: 'push rejected', pendingCount: 0 }], + vaults: [ + { vault: 'a', running: true, lastRun: '2026-07-08T09:00:00Z' }, + { vault: 'b', running: false, lastError: 'push rejected' }, + ], } ); const report = await gatherStatus(null, 'dev.agentage.io'); diff --git a/src/lib/status/status-info.ts b/src/lib/status/status-info.ts index c3c2a30..a320d76 100644 --- a/src/lib/status/status-info.ts +++ b/src/lib/status/status-info.ts @@ -66,21 +66,20 @@ const checkSite = async (siteUrl: string, headers: Record): Prom return res !== null; }; -// Fold the git + couch per-vault states into one summary: any error wins, then any in-flight -// run, else ok. lastRun is the freshest reported; lastError the first seen. +// Fold the per-vault git states into one summary: any error wins, then any in-flight run, else ok. +// lastRun is the freshest reported; lastError the first seen. Account vaults are absent by design - +// they have no sync channel, so they are never counted as synced here (the vaults block names them). const summarizeSync = (sync: SyncStatus): DaemonSyncSummary => { const git = Array.isArray(sync.vaults) ? sync.vaults : []; - const couch = Array.isArray(sync.couch) ? sync.couch : []; - const vaults = git.length + couch.length; - const error = - git.find((v) => v.lastError)?.lastError ?? couch.find((v) => v.lastError)?.lastError; - const running = git.some((v) => v.running) || couch.some((v) => v.running); + const error = git.find((v) => v.lastError)?.lastError; + const running = git.some((v) => v.running); const state: DaemonSyncSummary['state'] = error ? 'error' : running ? 'syncing' : 'ok'; - const runs = [...git.map((v) => v.lastRun), ...couch.map((v) => v.lastSync)].filter( - (r): r is string => Boolean(r) - ); - const lastRun = runs.sort().at(-1); - return { vaults, state, lastRun, lastError: error }; + const lastRun = git + .map((v) => v.lastRun) + .filter((r): r is string => Boolean(r)) + .sort() + .at(-1); + return { vaults: git.length, state, lastRun, lastError: error }; }; // One detection routine shared with `daemon start`: a health 200 (any shape, even a legacy 0.0.3 diff --git a/src/lib/status/vaults-format.ts b/src/lib/status/vaults-format.ts index 4a11996..7d94291 100644 --- a/src/lib/status/vaults-format.ts +++ b/src/lib/status/vaults-format.ts @@ -26,13 +26,17 @@ const statusCell = (v: VaultStatus): string => { return `${mark(false)} error (${shortError(v.lastError)})`; case 'unknown': return chalk.dim('- unknown (daemon stopped)'); + case 'unsynced': + return `${mark(false)} not synced - account vaults have no sync channel`; case 'idle': return v.channel === 'local' ? chalk.dim('- local only') : chalk.dim('- idle'); } }; +// Only git vaults are "connected": an account vault has no sync channel, so counting it would +// re-tell the lie the per-vault row exists to correct. const countLabel = (vaults: VaultStatus[]): string => { - const connected = vaults.filter((v) => v.channel !== 'local').length; + const connected = vaults.filter((v) => v.channel === 'git').length; if (connected > 0) return `${connected} connected`; const n = vaults.length; return `${n} ${n === 1 ? 'vault' : 'vaults'}`; @@ -44,9 +48,10 @@ export const vaultLines = (vaults: VaultStatus[]): string[] => { if (vaults.length === 0) return [`${'vaults'.padEnd(10)} none - run: agentage vault add --local`]; const nameW = Math.max(...vaults.map((v) => v.name.length)); + const chanW = Math.max(...vaults.map((v) => v.channel.length)); const header = `${'vaults'.padEnd(10)} ${countLabel(vaults)}`; const rows = vaults.map( - (v) => ` ${v.name.padEnd(nameW)} ${v.channel.padEnd(6)} ${statusCell(v)}` + (v) => ` ${v.name.padEnd(nameW)} ${v.channel.padEnd(chanW)} ${statusCell(v)}` ); return [header, ...rows]; }; diff --git a/src/lib/status/vaults-status.test.ts b/src/lib/status/vaults-status.test.ts index db458df..eeb569f 100644 --- a/src/lib/status/vaults-status.test.ts +++ b/src/lib/status/vaults-status.test.ts @@ -8,13 +8,24 @@ const config = (vaults: VaultsConfig['vaults']): VaultsConfig => ({ version: 1, const emptySync: SyncStatus = { vaults: [] }; describe('buildVaultStatuses channel classification', () => { - it('classifies an agentage-origin vault as cloud', () => { + it('classifies an agentage-origin vault as account', () => { const [v] = buildVaultStatuses( emptySync, true, config({ notes: { path: '~/notes', origin: [{ remote: 'agentage' }] } }) ); - expect(v?.channel).toBe('cloud'); + expect(v?.channel).toBe('account'); + }); + + it('classifies an entry carrying both an agentage and an external origin as git', () => { + const [v] = buildVaultStatuses( + emptySync, + true, + config({ + mixed: { path: '~/m', origin: [{ remote: 'agentage' }, { remote: 'https://g/x.git' }] }, + }) + ); + expect(v?.channel).toBe('git'); }); it('classifies an external-remote vault as git', () => { @@ -70,28 +81,28 @@ describe('buildVaultStatuses status states', () => { expect(buildVaultStatuses(null, false, gitVault)[0]?.status).toBe('unknown'); }); - it('folds couch-channel state (lastSync) into the cloud vault', () => { + const acctVault = config({ notes: { path: '~/n', origin: [{ remote: 'agentage' }] } }); + + it('reports an account vault as unsynced with the daemon up', () => { + const [v] = buildVaultStatuses(emptySync, true, acctVault); + expect(v?.channel).toBe('account'); + expect(v?.status).toBe('unsynced'); + expect(v?.lastRun).toBeUndefined(); + }); + + it('never reports an account vault as ok, even if the daemon reports a run for it', () => { const sync: SyncStatus = { - vaults: [], - couch: [ - { - vault: 'notes', - channel: 'couch', - intervalSeconds: 60, - running: false, - lastSync: '2026-07-08T18:40:00Z', - pendingCount: 0, - }, + vaults: [ + { vault: 'notes', remote: 'r', intervalSeconds: 60, running: false, lastRun: 'then' }, ], }; - const [v] = buildVaultStatuses( - sync, - true, - config({ notes: { path: '~/n', origin: [{ remote: 'agentage' }] } }) - ); - expect(v?.channel).toBe('cloud'); - expect(v?.status).toBe('ok'); - expect(v?.lastRun).toBe('2026-07-08T18:40:00Z'); + expect(buildVaultStatuses(sync, true, acctVault)[0]?.status).toBe('unsynced'); + }); + + it('lists an account vault rather than omitting it when the daemon is down', () => { + const [v] = buildVaultStatuses(null, false, acctVault); + expect(v?.name).toBe('notes'); + expect(v?.status).toBe('unsynced'); }); it('returns an empty array when no vaults are configured', () => { diff --git a/src/lib/status/vaults-status.ts b/src/lib/status/vaults-status.ts index 7104d93..7373f07 100644 --- a/src/lib/status/vaults-status.ts +++ b/src/lib/status/vaults-status.ts @@ -2,8 +2,8 @@ import { isAccountVault, type VaultEntry, type VaultsConfig } from '@agentage/me import { loadVaultsConfig } from '../vault/vaults.js'; import { type SyncStatus } from '../../sync/git/manager.js'; -export type VaultChannel = 'local' | 'git' | 'cloud'; -export type VaultSyncState = 'ok' | 'syncing' | 'error' | 'idle' | 'unknown'; +export type VaultChannel = 'local' | 'git' | 'account'; +export type VaultSyncState = 'ok' | 'syncing' | 'error' | 'idle' | 'unknown' | 'unsynced'; export interface VaultStatus { name: string; @@ -13,23 +13,25 @@ export interface VaultStatus { lastError?: string; } -// Config alone decides the channel: an `agentage` origin is the cloud (couch) channel, any other -// origin is an external git remote, and no origin at all is a local-only vault (nothing to sync). +// Config alone decides the channel: an external remote means git (it really syncs), else an +// `agentage` origin means account, else local-only. External wins so a hand-edited entry carrying +// both is reported by the channel that actually moves bytes. const channelOf = (entry: VaultEntry): VaultChannel => { - if (isAccountVault(entry)) return 'cloud'; - return entry.origin?.some((o) => o.remote.trim() && o.remote.trim() !== 'agentage') - ? 'git' - : 'local'; + const external = entry.origin?.some((o) => o.remote.trim() && o.remote.trim() !== 'agentage'); + if (external) return 'git'; + return isAccountVault(entry) ? 'account' : 'local'; }; -// Live state from the daemon wins; a local-only vault is `idle` (nothing to sync), and any synced -// vault with no daemon report is `unknown` (daemon down or the vault not yet scheduled). +// Live state from the daemon wins; a local-only vault is `idle` (nothing to sync), an account vault +// is always `unsynced` (it has no sync channel, so no daemon report can make it healthy), and any +// git vault with no daemon report is `unknown` (daemon down or the vault not yet scheduled). const stateFrom = ( channel: VaultChannel, live: { running?: boolean; lastError?: string; lastRun?: string } | undefined, daemonUp: boolean ): VaultSyncState => { if (channel === 'local') return 'idle'; + if (channel === 'account') return 'unsynced'; if (!daemonUp) return 'unknown'; if (!live) return 'unknown'; if (live.lastError) return 'error'; @@ -37,15 +39,13 @@ const stateFrom = ( return live.lastRun ? 'ok' : 'idle'; }; -// Index the daemon's per-vault reports by name across both channels into one lookup. +// Index the daemon's per-vault git reports by name. const indexLive = ( sync: SyncStatus | null ): Map => { const map = new Map(); for (const v of sync?.vaults ?? []) map.set(v.vault, { running: v.running, lastError: v.lastError, lastRun: v.lastRun }); - for (const c of sync?.couch ?? []) - map.set(c.vault, { running: c.running, lastError: c.lastError, lastRun: c.lastSync }); return map; }; diff --git a/src/lib/vault/vault-registry.ts b/src/lib/vault/vault-registry.ts index b4c5a32..5fba5af 100644 --- a/src/lib/vault/vault-registry.ts +++ b/src/lib/vault/vault-registry.ts @@ -11,13 +11,12 @@ import { redactRemoteUrl } from '../../sync/git/remote-url.js'; import { isValidVaultName } from './vaults.schema.js'; // Offline registry operations over the unified vaults.json (object map keyed by name). No -// network here: account (agentage channel), local (--local) and git-origin (--git) entries. +// network here: account (agentage origin), local (--local) and git-origin (--git) entries. export type VaultType = 'account' | 'git' | 'local' | 'remote'; -// The human-facing type of an entry: an agentage origin is an account vault (local mirror + -// cloud channel); otherwise a path with an external origin is git, a bare path is local, and an -// origin without a path is remote. +// The human-facing type of an entry: an agentage origin is an account vault; otherwise a path with +// an external origin is git, a bare path is local, and an origin without a path is remote. export const vaultType = (entry: VaultEntry): VaultType => { if (isAccountVault(entry)) return 'account'; if (entry.path) return entry.origin?.length ? 'git' : 'local'; diff --git a/src/sync/couch/cycle.ts b/src/sync/couch/cycle.ts deleted file mode 100644 index 5438e0e..0000000 --- a/src/sync/couch/cycle.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { type CouchRuntime, type CouchSyncResult, type TargetState } from './manager.types.js'; -import { ensureWire, pendingCount } from './wire.js'; - -// One couch cycle: commit dirty local truth first, drain queued pushes/deletions, then push+pull. -// Every failure is caught and recorded (lastError / paused); it never throws to the caller. -export const runCouchCycle = async ( - rt: CouchRuntime, - st: TargetState -): Promise => { - const vault = st.target.vault; - const build = (extra: Partial): CouchSyncResult => ({ - vault, - channel: 'couch', - ok: true, - committed: false, - pulled: false, - pendingCount: pendingCount(st), - ...extra, - }); - if (st.running) return build({}); - st.running = true; - try { - const bearer = await rt.getBearer(); - if (!bearer) { - st.paused = 'signed out'; - st.lastError = undefined; - return build({ paused: 'signed out' }); - } - const decision = await rt.discovery.channelFor(vault, bearer); - if (decision.kind === 'paused') { - st.paused = decision.reason; - st.lastError = undefined; - return build({ paused: decision.reason }); - } - st.paused = undefined; - const couch = await ensureWire(rt, st, decision); - const pre = await rt.commitDirty(st.target.path, `sync: ${rt.nowIso()}`); - await couch.flushPending(); // drain queued pushes AND queued deletions first - const res = await couch.syncNow(); // pushAll + reconcile deletions, then pullOnce - const post = await rt.commitDirty(st.target.path, `sync: couch ${rt.nowIso()}`); - if (res.error) { - st.lastError = res.error; - return build({ - ok: false, - committed: pre.committed, - pulled: post.committed, - error: res.error, - }); - } - st.lastSync = rt.nowIso(); - st.lastError = undefined; - return build({ committed: pre.committed, pulled: post.committed }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - st.lastError = msg; - return build({ ok: false, error: msg }); - } finally { - st.running = false; - } -}; diff --git a/src/sync/couch/discovery.test.ts b/src/sync/couch/discovery.test.ts deleted file mode 100644 index 32c6518..0000000 --- a/src/sync/couch/discovery.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { type FetchJson } from '@agentage/memory-core'; -import { type ProvisionResult, type ProvisionStatus } from '../../lib/auth/provision.js'; -import { createDiscovery } from './discovery.js'; - -// A well-known payload advertising the couch channel for the given vault names. -const payload = (vaults: string[]): { status: number; json: unknown } => ({ - status: 200, - json: { - git_endpoint: 'https://sync.test', - ttl: 3600, - couch_endpoint: 'https://couch.test', - couch_token_url: 'https://auth.test/couch-token', - couch_vaults: vaults.map((v) => ({ vault: v, db: `mem_${v}` })), - }, -}); - -const prov = (status: ProvisionStatus): ProvisionResult => ({ status, message: '' }); - -const make = ( - fetchJson: FetchJson, - provision: () => Promise = async () => prov('provisioned') -) => - createDiscovery({ - bootstrapHost: 'https://sync.test', - fetchJson, - provision: vi.fn(provision), - now: () => 0, - }); - -describe('createDiscovery', () => { - it('resolves the couch channel and caches within the payload ttl', async () => { - const fetchJson = vi.fn(async () => payload(['acct'])); - const d = make(fetchJson); - expect(await d.channelFor('acct', 'tok')).toEqual({ - kind: 'couch', - endpoint: 'https://couch.test', - db: 'mem_acct', - tokenUrl: 'https://auth.test/couch-token', - }); - await d.channelFor('acct', 'tok'); - expect(fetchJson).toHaveBeenCalledTimes(1); // second call served from the ttl cache - }); - - it('provisions once and refreshes discovery once when the vault is missing', async () => { - let present = false; - const fetchJson = vi.fn(async () => payload(present ? ['acct'] : [])); - const provision = vi.fn(async () => { - present = true; - return prov('provisioned'); - }); - const d = createDiscovery({ - bootstrapHost: 'https://sync.test', - fetchJson, - provision, - now: () => 0, - }); - expect(await d.channelFor('acct', 'tok')).toMatchObject({ kind: 'couch', db: 'mem_acct' }); - expect(provision).toHaveBeenCalledTimes(1); - expect(fetchJson).toHaveBeenCalledTimes(2); // initial + one refresh after provisioning - }); - - it('pauses with a CHANNEL_DISABLED reason when provisioning cannot enable the channel', async () => { - const fetchJson = vi.fn(async () => payload([])); // never advertises acct - const d = make(fetchJson, async () => prov('disabled')); - expect(await d.channelFor('acct', 'tok')).toEqual({ - kind: 'paused', - reason: 'account sync is not enabled on this server', - }); - }); - - it('maps a conflict and an unauthenticated provision to distinct paused reasons', async () => { - const conflict = make( - vi.fn(async () => payload([])), - async () => prov('conflict') - ); - expect(await conflict.channelFor('acct', 'tok')).toEqual({ - kind: 'paused', - reason: 'name conflicts with a memory on another channel', - }); - const signedOut = make( - vi.fn(async () => payload([])), - async () => prov('unauthenticated') - ); - expect(await signedOut.channelFor('acct', 'tok')).toEqual({ - kind: 'paused', - reason: 'signed out', - }); - }); -}); diff --git a/src/sync/couch/discovery.ts b/src/sync/couch/discovery.ts deleted file mode 100644 index 309754a..0000000 --- a/src/sync/couch/discovery.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { channelForVault, HostResolver, type FetchJson } from '@agentage/memory-core'; -import { type ProvisionResult } from '../../lib/auth/provision.js'; - -// Resolves which channel each account vault syncs on, from GET /.well-known/agentage-sync -// (cached for the payload ttl in memory only). A vault absent from couch_vaults is provisioned -// once (idempotent) then discovery is refreshed once; if it is still absent the target pauses with -// a reason and retries next tick. A signed-out caller never reaches here (zero network). - -export type ChannelDecision = - | { kind: 'couch'; endpoint: string; db: string; tokenUrl: string } - | { kind: 'paused'; reason: string }; - -export interface DiscoveryDeps { - bootstrapHost: string; // the sync origin, e.g. https://sync. - fetchJson: FetchJson; - provision: (vault: string) => Promise; - now?: () => number; -} - -export interface Discovery { - channelFor(vault: string, token: string): Promise; - reset(): void; -} - -const pausedReason = (prov: ProvisionResult): string => { - switch (prov.status) { - case 'disabled': - return 'account sync is not enabled on this server'; - case 'conflict': - return 'name conflicts with a memory on another channel'; - case 'unauthenticated': - return 'signed out'; - default: - return 'provisioning - will retry'; - } -}; - -export const createDiscovery = (deps: DiscoveryDeps): Discovery => { - const resolver = new HostResolver(deps.bootstrapHost, deps.fetchJson, deps.now ?? Date.now); - const toCouch = (ch: ReturnType): ChannelDecision | null => - ch.channel === 'couch' - ? { kind: 'couch', endpoint: ch.endpoint, db: ch.db, tokenUrl: ch.tokenUrl } - : null; - return { - async channelFor(vault, token) { - const first = toCouch(channelForVault(await resolver.resolve(token), vault)); - if (first) return first; - // Missing from couch_vaults: provision once, refresh discovery once, re-check. - const prov = await deps.provision(vault); - resolver.invalidate(); - const second = toCouch(channelForVault(await resolver.resolve(token), vault)); - return second ?? { kind: 'paused', reason: pausedReason(prov) }; - }, - reset() { - resolver.invalidate(); - }, - }; -}; diff --git a/src/sync/couch/file-store.test.ts b/src/sync/couch/file-store.test.ts deleted file mode 100644 index 5e574a5..0000000 --- a/src/sync/couch/file-store.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { mkdtempSync, rmSync } from 'node:fs'; -import { mkdir, readFile, writeFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { createFileStore } from './file-store.js'; - -describe('createFileStore', () => { - let root: string; - beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'fstore-')); - }); - afterEach(() => rmSync(root, { recursive: true, force: true })); - - it('lists nested *.md as sorted POSIX relative paths, excluding dotfiles and non-md', async () => { - await mkdir(join(root, 'notes', 'sub'), { recursive: true }); - await mkdir(join(root, '.git', 'objects'), { recursive: true }); - await mkdir(join(root, '.obsidian'), { recursive: true }); - await writeFile(join(root, 'top.md'), 'x'); - await writeFile(join(root, 'notes', 'sub', 'deep.md'), 'x'); - await writeFile(join(root, 'notes', 'not-markdown.txt'), 'x'); - await writeFile(join(root, '.git', 'objects', 'ignored.md'), 'x'); - await writeFile(join(root, '.obsidian', 'workspace.md'), 'x'); - - const store = createFileStore(root); - expect(await store.listMarkdown()).toEqual(['notes/sub/deep.md', 'top.md']); - }); - - it('returns [] for a missing root', async () => { - expect(await createFileStore(join(root, 'nope')).listMarkdown()).toEqual([]); - }); - - it('write creates parent dirs; read returns content, null when absent', async () => { - const store = createFileStore(root); - expect(await store.read('a/b/c.md')).toBeNull(); - await store.write('a/b/c.md', 'hello'); - expect(await store.read('a/b/c.md')).toBe('hello'); - expect(await readFile(join(root, 'a', 'b', 'c.md'), 'utf8')).toBe('hello'); - }); - - it('remove deletes the file and is a no-op when absent', async () => { - const store = createFileStore(root); - await store.write('gone.md', 'bye'); - await store.remove('gone.md'); - expect(await store.read('gone.md')).toBeNull(); - await expect(store.remove('never.md')).resolves.toBeUndefined(); - }); -}); diff --git a/src/sync/couch/file-store.ts b/src/sync/couch/file-store.ts deleted file mode 100644 index 9cb6fe5..0000000 --- a/src/sync/couch/file-store.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { existsSync } from 'node:fs'; -import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises'; -import { dirname, join, relative, sep } from 'node:path'; -import { type FileStore } from '@agentage/memory-core'; - -// The couch channel speaks vault-relative POSIX paths; on Windows the fs layer still uses `\`. -const toPosix = (p: string): string => (sep === '/' ? p : p.split(sep).join('/')); -const fromPosix = (p: string): string => (sep === '/' ? p : p.split('/').join(sep)); - -// Recursively collect *.md under root as vault-relative POSIX paths. Dot-directories are skipped - -// `.git` (the engine's own repo) must never enter the content-addressed model, and editor state -// like `.obsidian/` holds no synced notes; this mirrors memory-core's own local listing. -const walk = async (root: string, dir: string, acc: string[]): Promise => { - const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); - for (const e of entries) { - if (e.name.startsWith('.')) continue; - const abs = join(dir, e.name); - if (e.isDirectory()) await walk(root, abs, acc); - else if (e.isFile() && e.name.endsWith('.md')) acc.push(toPosix(relative(root, abs))); - } -}; - -// A FileStore rooted at one account vault's mirror dir: the seam CouchSync reads/writes through. -export const createFileStore = (root: string): FileStore => ({ - async listMarkdown() { - if (!existsSync(root)) return []; - const acc: string[] = []; - await walk(root, root, acc); - return acc.sort(); - }, - async read(path) { - try { - return await readFile(join(root, fromPosix(path)), 'utf8'); - } catch { - return null; // gone from the file set - } - }, - async write(path, body) { - const abs = join(root, fromPosix(path)); - await mkdir(dirname(abs), { recursive: true }); - await writeFile(abs, body, 'utf8'); - }, - async remove(path) { - await rm(join(root, fromPosix(path)), { force: true }); - }, -}); diff --git a/src/sync/couch/local-commit.ts b/src/sync/couch/local-commit.ts deleted file mode 100644 index a8d5e9b..0000000 --- a/src/sync/couch/local-commit.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { createSyncGit, GitError } from '../git/git-exec.js'; -import { type CommitOutcome } from './manager.types.js'; - -// The default local-git commit: stage everything and make one commit when the tree is dirty. An -// index.lock collision (the engine mid-mutation) is a clean skip - the change stays for next cycle. -export const gitCommitDirty = async (path: string, message: string): Promise => { - if (!existsSync(path)) return { committed: false, skipped: false }; - const git = createSyncGit(path); - try { - if (!existsSync(join(path, '.git'))) await git.run(['init', '-b', 'main']); - await git.run(['add', '-A']); - if ((await git.exec(['diff', '--cached', '--quiet'])).code === 0) - return { committed: false, skipped: false }; - await git.run(['commit', '-m', message]); - return { committed: true, skipped: false }; - } catch (err) { - if (err instanceof GitError && err.kind === 'lock') return { committed: false, skipped: true }; - throw err; - } -}; diff --git a/src/sync/couch/manager.fixtures.ts b/src/sync/couch/manager.fixtures.ts deleted file mode 100644 index 6d9a668..0000000 --- a/src/sync/couch/manager.fixtures.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { vi } from 'vitest'; -import { type FileStore, type VaultsConfig } from '@agentage/memory-core'; -import { type ChannelDecision, type Discovery } from './discovery.js'; -import { createCouchSyncManager, type CouchSyncManagerDeps } from './manager.js'; - -export const config: VaultsConfig = { - version: 1, - default: 'acct', - vaults: { - acct: { path: '/tmp/acct', origin: [{ remote: 'agentage', interval: 0 }] }, - git: { path: '/tmp/git', origin: [{ remote: 'git@h:g.git' }] }, - local: { path: '/tmp/local' }, - }, -}; - -export const autoConfig: VaultsConfig = { - version: 1, - default: 'acct', - vaults: { - acct: { path: '/tmp/acct', origin: [{ remote: 'agentage', interval: 300 }] }, - two: { path: '/tmp/two', origin: [{ remote: 'agentage', interval: 300 }] }, - }, -}; - -export const noopStore = (): FileStore => ({ - listMarkdown: async () => [], - read: async () => null, - write: async () => {}, - remove: async () => {}, -}); - -export const couchDecision: ChannelDecision = { - kind: 'couch', - endpoint: 'https://couch.test', - db: 'mem_acct', - tokenUrl: 'https://auth.test/couch-token', -}; - -export const makeManager = (over: Partial = {}) => { - const couch = { - pushFileLive: vi.fn(async () => {}), - removeFile: vi.fn(async () => {}), - flushPending: vi.fn(async () => {}), - syncNow: vi.fn(async () => ({ pushed: true, pulled: true })), - }; - const discovery: Discovery = { - channelFor: vi.fn(async () => couchDecision), - reset: vi.fn(), - }; - const mgr = createCouchSyncManager({ - getConfig: () => config, - configDir: () => '/tmp/cfg', - getBearer: async () => 'tok', - discovery, - makeCouchSync: () => couch, - makeFileStore: noopStore, - makeStatePersistence: () => ({ load: async () => null, save: async () => {} }), - commitDirty: async () => ({ committed: false, skipped: false }), - now: () => '2026-01-01T00:00:00Z', - ...over, - }); - return { mgr, couch, discovery }; -}; diff --git a/src/sync/couch/manager.test.ts b/src/sync/couch/manager.test.ts deleted file mode 100644 index b3c982b..0000000 --- a/src/sync/couch/manager.test.ts +++ /dev/null @@ -1,189 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { type VaultsConfig } from '@agentage/memory-core'; -import { type ChannelDecision } from './discovery.js'; -import { createCouchSyncManager } from './manager.js'; -import { autoConfig, config, couchDecision, makeManager, noopStore } from './manager.fixtures.js'; - -describe('createCouchSyncManager.status and runNow', () => { - it('reports one couch target with its cadence and zero pending', () => { - const { mgr } = makeManager(); - expect(mgr.status()).toEqual([ - { - vault: 'acct', - channel: 'couch', - intervalSeconds: 0, - lastSync: undefined, - lastError: undefined, - pendingCount: 0, - paused: undefined, - running: false, - }, - ]); - }); - - it('runNow pauses a signed-out target with zero network', async () => { - const { mgr, couch, discovery } = makeManager({ getBearer: async () => null }); - const result = await mgr.runNow('acct'); - expect(result).toMatchObject({ - vault: 'acct', - channel: 'couch', - ok: true, - paused: 'signed out', - }); - expect(discovery.channelFor).not.toHaveBeenCalled(); - expect(couch.syncNow).not.toHaveBeenCalled(); - expect(mgr.status()[0]!.paused).toBe('signed out'); - }); - - it('runNow throws for a vault that is not an account vault', async () => { - const { mgr } = makeManager(); - await expect(mgr.runNow('git')).rejects.toThrow('not an account vault'); - }); - - it('runNow completes a full cycle and records lastSync', async () => { - const { mgr, couch } = makeManager(); - const result = await mgr.runNow('acct'); - expect(couch.syncNow).toHaveBeenCalledTimes(1); - expect(result).toMatchObject({ vault: 'acct', ok: true }); - expect(result.paused).toBeUndefined(); - expect(mgr.status()[0]!.lastSync).toBe('2026-01-01T00:00:00Z'); - }); - - it('commits dirty local changes BEFORE syncNow, even when couch is unreachable', async () => { - const order: string[] = []; - const couch = { - pushFileLive: vi.fn(async () => {}), - removeFile: vi.fn(async () => {}), - flushPending: vi.fn(async () => { - order.push('flush'); - }), - syncNow: vi.fn(async () => { - order.push('sync'); - return { pushed: false, pulled: false, error: 'couch unreachable' }; - }), - }; - const { mgr } = makeManager({ - makeCouchSync: () => couch, - commitDirty: vi.fn(async (_path: string, message: string) => { - order.push(message.startsWith('sync: couch') ? 'commit-post' : 'commit-pre'); - return { committed: message.startsWith('sync: couch') === false, skipped: false }; - }), - }); - const result = await mgr.runNow('acct'); - // The dirty working tree is committed first, so a couch outage never loses the local truth. - expect(order).toEqual(['commit-pre', 'flush', 'sync', 'commit-post']); - expect(result).toMatchObject({ ok: false, committed: true, error: 'couch unreachable' }); - expect(mgr.status()[0]!.lastError).toBe('couch unreachable'); - expect(mgr.status()[0]!.lastSync).toBeUndefined(); - }); - - it('flushes queued deletions on a manual run before the push/pull round', async () => { - const { mgr, couch } = makeManager(); - await mgr.runNow('acct'); - expect(couch.flushPending).toHaveBeenCalledTimes(1); - expect(couch.flushPending.mock.invocationCallOrder[0]!).toBeLessThan( - couch.syncNow.mock.invocationCallOrder[0]! - ); - }); - - it('records a token-acquisition failure as an error, never throwing', async () => { - const { mgr, couch } = makeManager({ - getBearer: async () => { - throw new Error('token endpoint down'); - }, - }); - const result = await mgr.runNow('acct'); - expect(result).toMatchObject({ ok: false, error: 'token endpoint down' }); - expect(couch.syncNow).not.toHaveBeenCalled(); - expect(mgr.status()[0]!.lastError).toBe('token endpoint down'); - }); - - it('pauses (no sync) when discovery reports the channel is not ready', async () => { - const { mgr, couch } = makeManager({ - discovery: { - channelFor: vi.fn(async (): Promise => ({ - kind: 'paused', - reason: 'not provisioned', - })), - reset: vi.fn(), - }, - }); - const result = await mgr.runNow('acct'); - expect(result).toMatchObject({ ok: true, paused: 'not provisioned' }); - expect(couch.syncNow).not.toHaveBeenCalled(); - expect(mgr.status()[0]!.paused).toBe('not provisioned'); - }); - - it('a re-entrant cycle on an already-running target is a no-op', async () => { - let release: () => void = () => {}; - const gate = new Promise((r) => (release = r)); - const couch = { - pushFileLive: vi.fn(async () => {}), - removeFile: vi.fn(async () => {}), - flushPending: vi.fn(async () => {}), - syncNow: vi.fn(async () => { - await gate; - return { pushed: true, pulled: true }; - }), - }; - const { mgr } = makeManager({ makeCouchSync: () => couch }); - const first = mgr.runNow('acct'); - const second = await mgr.runNow('acct'); // guard hit while first still holds the target - expect(second.pendingCount).toBe(0); - release(); - await first; - expect(couch.syncNow).toHaveBeenCalledTimes(1); // only the first cycle ran the round - }); - - it('reuses the cached couch wire and target state across repeated cycles', async () => { - const couch = { - pushFileLive: vi.fn(async () => {}), - removeFile: vi.fn(async () => {}), - flushPending: vi.fn(async () => {}), - syncNow: vi.fn(async () => ({ pushed: true, pulled: true })), - }; - const makeCouchSync = vi.fn(() => couch); - const mgr = createCouchSyncManager({ - getConfig: () => config, - configDir: () => '/tmp/cfg', - getBearer: async () => 'tok', - discovery: { channelFor: vi.fn(async () => couchDecision), reset: vi.fn() }, - makeCouchSync, - makeFileStore: noopStore, - makeStatePersistence: () => ({ load: async () => null, save: async () => {} }), - commitDirty: async () => ({ committed: false, skipped: false }), - now: () => '2026-01-01T00:00:00Z', - }); - await mgr.runNow('acct'); - await mgr.runNow('acct'); - expect(makeCouchSync).toHaveBeenCalledTimes(1); - expect(couch.syncNow).toHaveBeenCalledTimes(2); - }); -}); - -describe('createCouchSyncManager.reschedule and stop', () => { - it('registers a state per couch target and arms auto-loop timers', () => { - const { mgr } = makeManager({ getConfig: () => autoConfig }); - mgr.reschedule(); - expect(mgr.status().map((s) => s.vault)).toEqual(['acct', 'two']); - mgr.stop(); - }); - - it('prunes the state of a vault dropped from the config on the next reschedule', () => { - let cfg: VaultsConfig = autoConfig; - const { mgr } = makeManager({ getConfig: () => cfg }); - mgr.reschedule(); - expect(mgr.status()).toHaveLength(2); - cfg = config; // only 'acct' remains a couch target - mgr.reschedule(); - expect(mgr.status().map((s) => s.vault)).toEqual(['acct']); - mgr.stop(); - }); - - it('does not arm a timer for a manual-only (interval 0) target', () => { - const { mgr } = makeManager(); - mgr.reschedule(); - expect(mgr.status().map((s) => s.vault)).toEqual(['acct']); - mgr.stop(); - }); -}); diff --git a/src/sync/couch/manager.ts b/src/sync/couch/manager.ts deleted file mode 100644 index fe80d9b..0000000 --- a/src/sync/couch/manager.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { CouchSync, type FetchLike, type FetchJson } from '@agentage/memory-core'; -import { currentBearer } from '../../lib/auth/api.js'; -import { getConfigDir, readAuth } from '../../lib/fs/config.js'; -import { links, siteFqdn } from '../../lib/net/origins.js'; -import { requestHeaders } from '../../lib/net/user-agent.js'; -import { defaultProvisionDeps, provisionAccountVault } from '../../lib/auth/provision.js'; -import { loadVaultsConfig } from '../../lib/vault/vaults.js'; -import { intervalMs } from '../git/planner.js'; -import { runCouchCycle } from './cycle.js'; -import { createDiscovery } from './discovery.js'; -import { createFileStore } from './file-store.js'; -import { gitCommitDirty } from './local-commit.js'; -import { - type CouchRuntime, - type CouchSyncManager, - type CouchSyncManagerDeps, - type CouchTargetStatus, - type MakeCouchSync, - type TargetState, -} from './manager.types.js'; -import { resolveMutationTarget } from './mutation-target.js'; -import { pushOnWrite } from './push-on-write.js'; -import { createStatePersistence } from './state-store.js'; -import { autoCouchTargets, couchTargets, type CouchTarget } from './targets.js'; -import { getState, pendingCount } from './wire.js'; - -export { - type CouchSyncManager, - type CouchSyncManagerDeps, - type CouchSyncResult, - type CouchTargetStatus, -} from './manager.types.js'; -export { resolveMutationTarget } from './mutation-target.js'; - -// Couch sync runs inside the daemon; identify the caller as the daemon on every outbound request. -const daemonHeaders = (): Record => requestHeaders({ component: 'daemon' }); - -const defaultFetch: FetchLike = (url, init) => { - const base = init as RequestInit | undefined; - const merged: RequestInit = { ...base, headers: { ...daemonHeaders(), ...base?.headers } }; - return globalThis.fetch(url, merged); -}; - -const defaultFetchJson: FetchJson = async (url, token) => { - const res = await globalThis.fetch(url, { - headers: { authorization: `Bearer ${token}`, ...daemonHeaders() }, - }); - const json = await res.json().catch(() => null); - return { status: res.status, json }; -}; - -// The daemon-side couch scheduler: per-account-vault timers + a persistent CouchSync per target for -// sync-on-save. Every couch failure is caught and recorded (lastError / paused); it never crashes -// the daemon and never blocks a memory API response. -export const createCouchSyncManager = (deps: CouchSyncManagerDeps = {}): CouchSyncManager => { - const getConfig = deps.getConfig ?? (() => loadVaultsConfig().config); - const getBearer = deps.getBearer ?? (() => currentBearer(readAuth, links(siteFqdn()))); - const makeFileStore = deps.makeFileStore ?? createFileStore; - const makeCouchSync: MakeCouchSync = - deps.makeCouchSync ?? - ((files, cfg, f, authorize, onUnauthorized, state, log) => - new CouchSync(files, cfg, f, authorize, onUnauthorized, state, log)); - const rt: CouchRuntime = { - configDir: deps.configDir ?? getConfigDir, - getBearer, - fetch: deps.fetch ?? defaultFetch, - makeCouchSync, - makeStatePersistence: deps.makeStatePersistence ?? createStatePersistence, - commitDirty: deps.commitDirty ?? gitCommitDirty, - discovery: - deps.discovery ?? - createDiscovery({ - bootstrapHost: links(siteFqdn()).sync, - fetchJson: defaultFetchJson, - provision: (vault) => provisionAccountVault(vault, defaultProvisionDeps()), - }), - nowIso: deps.now ?? (() => new Date().toISOString()), - log: deps.log ?? (() => {}), - }; - - const states = new Map(); - const timers = new Map(); - - const ensureTargetState = (target: CouchTarget): TargetState => { - const existing = states.get(target.vault); - if (existing) { - existing.target = target; - return existing; - } - const fresh: TargetState = { target, files: makeFileStore(target.path), running: false }; - states.set(target.vault, fresh); - return fresh; - }; - - return { - reschedule() { - for (const timer of timers.values()) clearInterval(timer); - timers.clear(); - const targets = couchTargets(getConfig()); - const live = new Set(targets.map((t) => t.vault)); - for (const vault of [...states.keys()]) if (!live.has(vault)) states.delete(vault); - for (const t of targets) void getState(rt, ensureTargetState(t)).catch(() => {}); - for (const t of autoCouchTargets(getConfig())) { - const timer = setInterval( - () => void runCouchCycle(rt, ensureTargetState(t)), - intervalMs(t.intervalSeconds) - ); - timer.unref?.(); - timers.set(t.vault, timer); - } - }, - async runNow(vault) { - const t = couchTargets(getConfig()).find((x) => x.vault === vault); - if (!t) throw new Error(`'${vault}' is not an account vault`); - return runCouchCycle(rt, ensureTargetState(t)); - }, - onWrite(verb, body) { - if (verb !== 'write' && verb !== 'edit' && verb !== 'delete') return; - const target = resolveMutationTarget(getConfig(), body); - if (!target) return; - const t = couchTargets(getConfig()).find((x) => x.vault === target.vault); - if (!t) return; - void pushOnWrite(rt, ensureTargetState(t), verb, target.path); - }, - status() { - return couchTargets(getConfig()).map((t): CouchTargetStatus => { - const st = states.get(t.vault); - return { - vault: t.vault, - channel: 'couch', - intervalSeconds: t.intervalSeconds, - lastSync: st?.lastSync, - lastError: st?.lastError, - pendingCount: pendingCount(st), - paused: st?.paused, - running: st?.running ?? false, - }; - }); - }, - stop() { - for (const timer of timers.values()) clearInterval(timer); - timers.clear(); - }, - }; -}; diff --git a/src/sync/couch/manager.types.ts b/src/sync/couch/manager.types.ts deleted file mode 100644 index 271d189..0000000 --- a/src/sync/couch/manager.types.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - type CouchState, - type CouchStatePersistence, - type FetchLike, - type FileStore, - type SyncResult as CouchChannelResult, - type VaultsConfig, -} from '@agentage/memory-core'; -import { type MemoryVerb } from '../../daemon/actions.js'; -import { type Discovery } from './discovery.js'; -import { type CouchTarget } from './targets.js'; - -export interface CouchSyncResult { - vault: string; - channel: 'couch'; - ok: boolean; - committed: boolean; // committed local dirty changes before the push - pulled: boolean; // a pull applied changes and they were committed - pendingCount: number; - paused?: string; // set when the target is paused (signed out / not provisioned) - error?: string; -} - -export interface CouchTargetStatus { - vault: string; - channel: 'couch'; - intervalSeconds: number; - lastSync?: string; - lastError?: string; - pendingCount: number; - paused?: string; - running: boolean; -} - -// What the manager uses from a CouchSync - the real class satisfies it; tests inject a mock. -export interface CouchLike { - pushFileLive(path: string): Promise; - removeFile(path: string): Promise; - flushPending(): Promise; - syncNow(): Promise; -} - -export type MakeCouchSync = ( - files: FileStore, - cfg: { endpoint: string; db: string }, - fetch: FetchLike, - authorize: () => Promise, - onUnauthorized: () => void, - state: CouchState, - log?: (msg: string) => void -) => CouchLike; - -export interface CommitOutcome { - committed: boolean; - skipped: boolean; // an index.lock collision - retried next cycle -} - -export interface CouchSyncManagerDeps { - getConfig?: () => VaultsConfig; - configDir?: () => string; - getBearer?: () => Promise; - discovery?: Discovery; - fetch?: FetchLike; - makeFileStore?: (path: string) => FileStore; - makeStatePersistence?: (configDir: string, vault: string) => CouchStatePersistence; - makeCouchSync?: MakeCouchSync; - commitDirty?: (path: string, message: string) => Promise; - now?: () => string; // ISO timestamp - log?: (msg: string) => void; -} - -export interface CouchSyncManager { - reschedule(): void; - runNow(vault: string): Promise; - onWrite(verb: MemoryVerb, body: unknown): void; - status(): CouchTargetStatus[]; - stop(): void; -} - -export interface TargetState { - target: CouchTarget; - files: FileStore; - state?: CouchState; - statePromise?: Promise; - couch?: CouchLike; - wireKey?: string; - running: boolean; - lastSync?: string; - lastError?: string; - paused?: string; -} - -// Resolved deps shared by the extracted wire/cycle/push-on-write seams; built once by the manager. -export interface CouchRuntime { - configDir: () => string; - getBearer: () => Promise; - fetch: FetchLike; - makeCouchSync: MakeCouchSync; - makeStatePersistence: (configDir: string, vault: string) => CouchStatePersistence; - commitDirty: (path: string, message: string) => Promise; - discovery: Discovery; - nowIso: () => string; - log: (msg: string) => void; -} diff --git a/src/sync/couch/mutation-target.test.ts b/src/sync/couch/mutation-target.test.ts deleted file mode 100644 index 9af0db7..0000000 --- a/src/sync/couch/mutation-target.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { type VaultsConfig } from '@agentage/memory-core'; -import { resolveMutationTarget } from './mutation-target.js'; - -const config: VaultsConfig = { - version: 1, - default: 'acct', - vaults: { - acct: { path: '/tmp/acct', origin: [{ remote: 'agentage', interval: 0 }] }, - git: { path: '/tmp/git', origin: [{ remote: 'git@h:g.git' }] }, - local: { path: '/tmp/local' }, - }, -}; - -describe('resolveMutationTarget', () => { - it('maps a bare ref to the default vault when it is an account vault', () => { - expect(resolveMutationTarget(config, { ref: 'notes/x.md' })).toEqual({ - vault: 'acct', - path: 'notes/x.md', - }); - }); - - it('honours an explicit @vault/ prefix', () => { - expect(resolveMutationTarget(config, { ref: '@acct/a/b.md' })).toEqual({ - vault: 'acct', - path: 'a/b.md', - }); - }); - - it('honours opts.vault over the default', () => { - expect(resolveMutationTarget(config, { ref: 'z.md', opts: { vault: 'acct' } })).toEqual({ - vault: 'acct', - path: 'z.md', - }); - }); - - it('returns null for git/local vaults and for non-file refs', () => { - expect(resolveMutationTarget(config, { ref: '@git/z.md' })).toBeNull(); - expect(resolveMutationTarget(config, { ref: 'z.md', opts: { vault: 'local' } })).toBeNull(); - expect(resolveMutationTarget(config, { ref: '@acct' })).toBeNull(); - expect(resolveMutationTarget(config, {})).toBeNull(); - }); - - it('resolves a single-vault config with no default', () => { - const single: VaultsConfig = { - version: 1, - vaults: { only: { path: '/tmp/only', origin: [{ remote: 'agentage' }] } }, - }; - expect(resolveMutationTarget(single, { ref: 'n.md' })).toEqual({ vault: 'only', path: 'n.md' }); - }); -}); diff --git a/src/sync/couch/mutation-target.ts b/src/sync/couch/mutation-target.ts deleted file mode 100644 index c31eec4..0000000 --- a/src/sync/couch/mutation-target.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { isAccountVault, type VaultsConfig } from '@agentage/memory-core'; - -// Map one memory-verb wire payload to the account vault + vault-relative POSIX path it mutated, or -// null when the target is not an account vault (git/local mutations never touch the couch channel). -export const resolveMutationTarget = ( - config: VaultsConfig, - body: unknown -): { vault: string; path: string } | null => { - const p = (body ?? {}) as { ref?: unknown; opts?: { vault?: unknown } }; - const ref = typeof p.ref === 'string' ? p.ref : ''; - if (!ref) return null; - let vault: string | undefined; - let path: string; - if (ref.startsWith('@')) { - const m = ref.match(/^@([^/]+)\/(.+)$/); - if (!m) return null; // a bare '@vault' is not a file mutation - vault = m[1]; - path = m[2] as string; - } else { - vault = (typeof p.opts?.vault === 'string' ? p.opts.vault : undefined) ?? config.default; - if (!vault) { - const names = Object.keys(config.vaults ?? {}); - if (names.length === 1) vault = names[0]; - } - path = ref; - } - if (!vault) return null; - const entry = config.vaults?.[vault]; - if (!entry || !isAccountVault(entry)) return null; - return { vault, path: path.replace(/^\.?\//, '') }; -}; diff --git a/src/sync/couch/push-on-write.test.ts b/src/sync/couch/push-on-write.test.ts deleted file mode 100644 index ad399bc..0000000 --- a/src/sync/couch/push-on-write.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { describe, expect, it, vi } from 'vitest'; -import { type ChannelDecision } from './discovery.js'; -import { makeManager } from './manager.fixtures.js'; - -describe('createCouchSyncManager.onWrite', () => { - it('pushes a write on an account vault via the couch channel', async () => { - const { mgr, couch } = makeManager(); - mgr.onWrite('write', { ref: 'notes/x.md' }); - await vi.waitFor(() => expect(couch.pushFileLive).toHaveBeenCalledWith('notes/x.md')); - expect(couch.removeFile).not.toHaveBeenCalled(); - }); - - it('tombstones a delete on an account vault', async () => { - const { mgr, couch } = makeManager(); - mgr.onWrite('delete', { ref: 'notes/x.md' }); - await vi.waitFor(() => expect(couch.removeFile).toHaveBeenCalledWith('notes/x.md')); - expect(couch.pushFileLive).not.toHaveBeenCalled(); - }); - - it('never touches couch for a git or local vault mutation', async () => { - const { mgr, couch, discovery } = makeManager(); - mgr.onWrite('write', { ref: '@git/z.md' }); - mgr.onWrite('edit', { ref: 'z.md', opts: { vault: 'local' } }); - await new Promise((r) => setTimeout(r, 20)); - expect(couch.pushFileLive).not.toHaveBeenCalled(); - expect(discovery.channelFor).not.toHaveBeenCalled(); - }); - - it('does not fire for read/search/list verbs', async () => { - const { mgr, couch } = makeManager(); - mgr.onWrite('read', { ref: 'notes/x.md' }); - mgr.onWrite('list', {}); - await new Promise((r) => setTimeout(r, 20)); - expect(couch.pushFileLive).not.toHaveBeenCalled(); - }); - - it('enqueues (no network) when signed out', async () => { - const enqueued: string[] = []; - const { mgr, couch, discovery } = makeManager({ - getBearer: async () => null, - makeStatePersistence: () => ({ - load: async () => null, - save: async (s) => { - enqueued.push(...s.pending); - }, - }), - }); - mgr.onWrite('write', { ref: 'notes/x.md' }); - await vi.waitFor(() => expect(enqueued).toContain('notes/x.md')); - expect(discovery.channelFor).not.toHaveBeenCalled(); - expect(couch.pushFileLive).not.toHaveBeenCalled(); - }); - - it('a signed-out delete enqueues a durable deletion, zero network', async () => { - const deletions: string[] = []; - const { mgr, couch, discovery } = makeManager({ - getBearer: async () => null, - makeStatePersistence: () => ({ - load: async () => null, - save: async (s) => { - deletions.push(...(s.deletions ?? [])); - }, - }), - }); - mgr.onWrite('delete', { ref: 'notes/x.md' }); - await vi.waitFor(() => expect(deletions).toContain('notes/x.md')); - expect(discovery.channelFor).not.toHaveBeenCalled(); - expect(couch.removeFile).not.toHaveBeenCalled(); - }); - - it('defers a write to the pending queue when signed in but the channel is not couch-ready', async () => { - const pending: string[] = []; - const { mgr, couch } = makeManager({ - discovery: { - channelFor: vi.fn(async (): Promise => ({ - kind: 'paused', - reason: 'not provisioned', - })), - reset: vi.fn(), - }, - makeStatePersistence: () => ({ - load: async () => null, - save: async (s) => { - pending.push(...s.pending); - }, - }), - }); - mgr.onWrite('write', { ref: 'notes/y.md' }); - await vi.waitFor(() => expect(pending).toContain('notes/y.md')); - expect(couch.pushFileLive).not.toHaveBeenCalled(); - }); - - it('a delete surviving a discovery failure is enqueued, never dropped', async () => { - const deletions: string[] = []; - const { mgr, couch } = makeManager({ - discovery: { - channelFor: vi.fn(async () => { - throw new Error('well-known unreachable'); - }), - reset: vi.fn(), - }, - makeStatePersistence: () => ({ - load: async () => null, - save: async (s) => { - deletions.push(...(s.deletions ?? [])); - }, - }), - }); - mgr.onWrite('delete', { ref: 'notes/x.md' }); - await vi.waitFor(() => expect(deletions).toContain('notes/x.md')); - expect(couch.removeFile).not.toHaveBeenCalled(); - }); -}); diff --git a/src/sync/couch/push-on-write.ts b/src/sync/couch/push-on-write.ts deleted file mode 100644 index 820dd29..0000000 --- a/src/sync/couch/push-on-write.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { type MemoryVerb } from '../../daemon/actions.js'; -import { type CouchRuntime, type TargetState } from './manager.types.js'; -import { ensureWire, getState } from './wire.js'; - -// Sync-on-save: push (or tombstone) one path right after the engine committed it. Failures queue -// the path in the module's persisted pending/deletion sets (retried by the next cycle) and never -// surface to the API. A delete is durable regardless of auth/network state at delete time: with -// no wire it enqueues the deletion, and removeFile itself self-enqueues on transport failure. -export const pushOnWrite = async ( - rt: CouchRuntime, - st: TargetState, - verb: MemoryVerb, - path: string -): Promise => { - const defer = async (): Promise => { - const state = await getState(rt, st); - if (verb === 'delete') await state.enqueueDeletion(path); - else await state.enqueue(path); - }; - try { - const bearer = await rt.getBearer(); - if (bearer) { - const decision = await rt.discovery.channelFor(st.target.vault, bearer); - if (decision.kind === 'couch') { - const couch = await ensureWire(rt, st, decision); - if (verb === 'delete') await couch.removeFile(path); - else await couch.pushFileLive(path); - return; - } - } - await defer(); // no wire yet (signed out / paused) - queued until one exists - } catch (err) { - rt.log(`couch push-on-write ${path}: ${err instanceof Error ? err.message : String(err)}`); - await defer().catch(() => {}); - } -}; diff --git a/src/sync/couch/state-store.test.ts b/src/sync/couch/state-store.test.ts deleted file mode 100644 index 21f654e..0000000 --- a/src/sync/couch/state-store.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { readFile } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { type CouchSyncState } from '@agentage/memory-core'; -import { couchStateDir, createStatePersistence } from './state-store.js'; - -const sample: CouchSyncState = { cursor: '42', revs: { 'a.md': 'h:1,h:2' }, pending: ['b.md'] }; - -describe('createStatePersistence', () => { - let dir: string; - beforeEach(() => { - dir = mkdtempSync(join(tmpdir(), 'cstate-')); - }); - afterEach(() => rmSync(dir, { recursive: true, force: true })); - - it('round-trips a saved state and writes under couch-state/.json', async () => { - const p = createStatePersistence(dir, 'acct'); - expect(await p.load()).toBeNull(); - await p.save(sample); - expect(await p.load()).toEqual(sample); - const raw = await readFile(join(couchStateDir(dir), 'acct.json'), 'utf8'); - expect(JSON.parse(raw)).toEqual(sample); - }); - - it('degrades a corrupt state file to a fresh (null) state instead of throwing', async () => { - const p = createStatePersistence(dir, 'acct'); - await p.save(sample); - writeFileSync(join(couchStateDir(dir), 'acct.json'), '{ not json'); - expect(await p.load()).toBeNull(); - }); - - it('leaves no .tmp behind after an atomic save', async () => { - const p = createStatePersistence(dir, 'acct'); - await p.save(sample); - await expect(readFile(join(couchStateDir(dir), 'acct.json.tmp'), 'utf8')).rejects.toThrow(); - }); -}); diff --git a/src/sync/couch/state-store.ts b/src/sync/couch/state-store.ts deleted file mode 100644 index a620dce..0000000 --- a/src/sync/couch/state-store.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { mkdirSync } from 'node:fs'; -import { readFile, rename, writeFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { type CouchStatePersistence, type CouchSyncState } from '@agentage/memory-core'; - -// The couch sync cursor + rev-cache + pending queue for one vault, at -// /couch-state/.json. Load returns null on a missing OR unparseable file so a -// corrupt state degrades to a fresh from-scratch sync rather than crashing the daemon; save is -// atomic (temp + rename) so a crash mid-write never truncates it. -export const couchStateDir = (configDir: string): string => join(configDir, 'couch-state'); - -export const createStatePersistence = (configDir: string, vault: string): CouchStatePersistence => { - const dir = couchStateDir(configDir); - const path = join(dir, `${encodeURIComponent(vault)}.json`); - return { - async load(): Promise { - try { - return JSON.parse(await readFile(path, 'utf8')) as CouchSyncState; - } catch { - return null; - } - }, - async save(state: CouchSyncState): Promise { - mkdirSync(dir, { recursive: true }); - const tmp = `${path}.tmp`; - await writeFile(tmp, JSON.stringify(state), 'utf8'); - await rename(tmp, path); - }, - }; -}; diff --git a/src/sync/couch/targets.test.ts b/src/sync/couch/targets.test.ts deleted file mode 100644 index 71a820c..0000000 --- a/src/sync/couch/targets.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { type VaultsConfig } from '@agentage/memory-core'; -import { autoCouchTargets, couchTargets } from './targets.js'; - -const config = (vaults: VaultsConfig['vaults']): VaultsConfig => ({ version: 1, vaults }); - -describe('couchTargets', () => { - it('selects only account (agentage) vaults with a path', () => { - const targets = couchTargets( - config({ - acct: { path: '/tmp/acct', origin: [{ remote: 'agentage' }] }, - gitv: { path: '/tmp/gitv', origin: [{ remote: 'git@h:g.git' }] }, - local: { path: '/tmp/local' }, - }) - ); - expect(targets.map((t) => t.vault)).toEqual(['acct']); - expect(targets[0]!.path).toBe('/tmp/acct'); - }); - - it('defaults the interval to 300s and honours an explicit one', () => { - const targets = couchTargets( - config({ - a: { path: '/tmp/a', origin: [{ remote: 'agentage' }] }, - b: { path: '/tmp/b', origin: [{ remote: 'agentage', interval: 60 }] }, - c: { path: '/tmp/c', origin: [{ remote: 'agentage', interval: 0 }] }, - }) - ); - expect(Object.fromEntries(targets.map((t) => [t.vault, t.intervalSeconds]))).toEqual({ - a: 300, - b: 60, - c: 0, - }); - }); - - it('autoCouchTargets excludes interval-0 (manual-only) vaults', () => { - const auto = autoCouchTargets( - config({ - a: { path: '/tmp/a', origin: [{ remote: 'agentage', interval: 60 }] }, - c: { path: '/tmp/c', origin: [{ remote: 'agentage', interval: 0 }] }, - }) - ); - expect(auto.map((t) => t.vault)).toEqual(['a']); - }); - - it('is empty for a config with no account vaults', () => { - expect( - couchTargets(config({ g: { path: '/tmp/g', origin: [{ remote: 'x:y.git' }] } })) - ).toEqual([]); - expect(couchTargets({ version: 1 })).toEqual([]); - }); -}); diff --git a/src/sync/couch/targets.ts b/src/sync/couch/targets.ts deleted file mode 100644 index 1cc1cf3..0000000 --- a/src/sync/couch/targets.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { expandPath, isAccountVault, type VaultsConfig } from '@agentage/memory-core'; -import { DEFAULT_INTERVAL_SECONDS } from '../git/planner.js'; - -// The account (agentage) channel a couch target syncs to. Unlike a git target it has no external -// remote URL: the daemon resolves the per-memory CouchDB + JWT endpoints from discovery at runtime. -export const ACCOUNT_REMOTE = 'agentage'; - -export interface CouchTarget { - vault: string; - path: string; // absolute local working-copy path (the account mirror) - intervalSeconds: number; -} - -// Every account vault (agentage origin) with a local mirror path is a couch target. Interval rides -// on the agentage origin and matches git semantics: absent = 300s, 0 = manual-only. -export const couchTargets = (config: VaultsConfig): CouchTarget[] => { - const out: CouchTarget[] = []; - for (const [vault, entry] of Object.entries(config.vaults ?? {})) { - if (!isAccountVault(entry) || !entry.path) continue; - const origin = entry.origin?.find((o) => o.remote === ACCOUNT_REMOTE); - out.push({ - vault, - path: expandPath(entry.path), - intervalSeconds: origin?.interval ?? DEFAULT_INTERVAL_SECONDS, - }); - } - return out; -}; - -// The couch targets the daemon auto-loop schedules: interval 0 is manual-only and excluded. -export const autoCouchTargets = (config: VaultsConfig): CouchTarget[] => - couchTargets(config).filter((t) => t.intervalSeconds > 0); diff --git a/src/sync/couch/wire.ts b/src/sync/couch/wire.ts deleted file mode 100644 index 926fd49..0000000 --- a/src/sync/couch/wire.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { createCouchState, CouchTokenClient, type CouchState } from '@agentage/memory-core'; -import { type ChannelDecision } from './discovery.js'; -import { type CouchLike, type CouchRuntime, type TargetState } from './manager.types.js'; - -// Everything queued for retry: failed/deferred pushes plus not-yet-tombstoned deletions. -export const pendingCount = (st: TargetState | undefined): number => - st?.state ? st.state.pendingPaths().length + st.state.deletionPaths().length : 0; - -export const getState = (rt: CouchRuntime, st: TargetState): Promise => - (st.statePromise ??= createCouchState( - rt.makeStatePersistence(rt.configDir(), st.target.vault) - ).then((s) => (st.state = s))); - -export const ensureWire = async ( - rt: CouchRuntime, - st: TargetState, - d: ChannelDecision -): Promise => { - if (d.kind !== 'couch') throw new Error('ensureWire: not a couch channel'); - const key = `${d.endpoint}|${d.db}|${d.tokenUrl}`; - if (st.couch && st.wireKey === key) return st.couch; - const state = await getState(rt, st); - const tokens = new CouchTokenClient( - d.tokenUrl, - st.target.vault, - rt.fetch, - rt.getBearer, - Date.now - ); - st.couch = rt.makeCouchSync( - st.files, - { endpoint: d.endpoint, db: d.db }, - rt.fetch, - () => tokens.token(), - () => tokens.invalidate(), - state, - rt.log - ); - st.wireKey = key; - return st.couch; -}; diff --git a/src/sync/discover/watcher.ts b/src/sync/discover/watcher.ts index b8c4783..8dd4204 100644 --- a/src/sync/discover/watcher.ts +++ b/src/sync/discover/watcher.ts @@ -120,7 +120,7 @@ export const createDiscoverWatcher = (deps: DiscoverWatcherDeps = {}): DiscoverW if (added.length === 0) return []; for (const c of added) { log(`discovered account vault '${c.name}' -> ${c.entry.path}`); - void provision(c.name).catch(() => {}); // never fatal: the couch loop re-provisions + void provision(c.name).catch(() => {}); // never fatal: the next scan re-provisions } return added; }; diff --git a/src/sync/git/manager.ts b/src/sync/git/manager.ts index eded366..718df78 100644 --- a/src/sync/git/manager.ts +++ b/src/sync/git/manager.ts @@ -1,6 +1,5 @@ import { type VaultsConfig } from '@agentage/memory-core'; import { loadVaultsConfig } from '../../lib/vault/vaults.js'; -import { type CouchTargetStatus } from '../couch/manager.js'; import { type DiscoverStatus } from '../discover/watcher.js'; import { runSyncCycle, type SyncResult } from './cycle.js'; import { autoSyncTargets, intervalMs, syncTargets, type SyncTarget } from './planner.js'; @@ -17,8 +16,6 @@ export interface VaultSyncState { export interface SyncStatus { vaults: VaultSyncState[]; - // The account (couch) targets, composed in by the daemon; absent on an older daemon. - couch?: CouchTargetStatus[]; // The active discover roots, composed in by the daemon; absent on an older daemon. discover?: DiscoverStatus; } diff --git a/src/sync/git/planner.ts b/src/sync/git/planner.ts index 3c06552..92bb87a 100644 --- a/src/sync/git/planner.ts +++ b/src/sync/git/planner.ts @@ -10,7 +10,7 @@ export const DEFAULT_INTERVAL_SECONDS = 300; // the defaults, and an empty array syncs everything. export const DEFAULT_IGNORE: readonly string[] = ['.obsidian/', 'data.json']; -// The reserved cloud channel is never synced over external git (that path is out of scope here). +// The reserved account remote is a sentinel, not a URL: never synced over external git. const RESERVED_REMOTE = 'agentage'; export interface SyncTarget { @@ -31,8 +31,8 @@ export const intervalMs = (seconds: number): number => Math.max(0, Math.floor(se const remoteNameFor = (index: number): string => (index === 0 ? 'sync' : `sync-${index}`); // Flatten (vault, origin) pairs into sync targets. A target needs a local `path` (the working -// copy to commit/push from) AND an external origin; origin-only entries (cloud remote backends) -// and the reserved cloud channel are skipped. +// copy to commit/push from) AND an external origin; origin-only entries (remote backends) and the +// reserved account remote are skipped. export const syncTargets = (config: VaultsConfig): SyncTarget[] => { const out: SyncTarget[] = []; for (const [vault, entry] of Object.entries(config.vaults ?? {})) { From 334930254fbbdd107a58634eada7b45e01353fbf Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Sun, 9 Aug 2026 16:30:57 +0200 Subject: [PATCH 2/2] test(e2e): compare CLI output against plain text, not styled --- e2e/account-vault.test.ts | 14 +++++++------- e2e/helpers.ts | 5 +++++ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/e2e/account-vault.test.ts b/e2e/account-vault.test.ts index 4fabc18..caa1d0b 100644 --- a/e2e/account-vault.test.ts +++ b/e2e/account-vault.test.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { expect, test } from '@playwright/test'; -import { assertCliBuilt, createCliMachine, statusJson } from './helpers.js'; +import { assertCliBuilt, createCliMachine, plain, statusJson } from './helpers.js'; // Account vaults are offline-first: a no-flag `vault add` writes the local entry and folder with // zero network, creating the account-side memory only when signed in. They have no sync channel, @@ -105,10 +105,10 @@ test.describe('account vault (offline) @p0 @offline', () => { // An account vault has no sync channel: say so, never a crash (exit 0), never a success line. const sync = await m.exec(['vault', 'sync', 'acct']); expect(sync.code, sync.stderr).toBe(0); - expect(sync.stdout).toContain('acct (account): not synced'); - expect(sync.stdout).toContain('account vaults have no sync channel'); - expect(sync.stdout).not.toContain('up to date'); - expect(sync.stdout).not.toContain('Syncing'); + const out = plain(sync.stdout); + expect(out).toContain('acct (account): not synced - account vaults have no sync channel'); + expect(out).not.toContain('up to date'); + expect(out).not.toContain('Syncing'); } finally { m.cleanup(); } @@ -128,8 +128,8 @@ test.describe('account vault (offline) @p0 @offline', () => { const human = await m.exec(['status']); expect(human.code, human.stderr).toBe(0); - expect(human.stdout).toContain('not synced - account vaults have no sync channel'); - expect(human.stdout).not.toContain('connected'); + expect(plain(human.stdout)).toContain('not synced - account vaults have no sync channel'); + expect(plain(human.stdout)).not.toContain('connected'); } finally { m.cleanup(); } diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 591f62f..7045820 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -176,3 +176,8 @@ export const ensureSession = async ( } expect(false, `ensureSession failed after retries (last status ${last})`).toBe(true); }; + +// Chalk colours whenever it detects a colour-capable environment (CI included), so any +// assertion that straddles a style boundary must compare against the plain text. +const ANSI = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'); +export const plain = (s: string): string => s.replace(ANSI, '');