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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The agentage CLI. Versioning RESTARTED at 0.0.x (old npm versions were
unpublished; earliest burned slot is 0.1.19 - stay below it until the line naturally
passes). Commands: `setup` (OAuth sign-in), `status`, `vault`, `memory`, `daemon`. The old
passes). Commands: `setup` (OAuth sign-in), `status`, `vault` (incl. `vault sync`), `memory`, `daemon`. The old
agent-runtime CLI (run/agents/machines/...) lives in git history only - do not resurrect
its agent-runtime patterns (the local memory daemon was deliberately ported from it).

Expand All @@ -19,6 +19,12 @@ 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/` - git sync (M4): the daemon acts on per-vault `origin[]` (external remotes only),
debounced commit+push / pull-rebase per `interval` (SECONDS; 0 = manual-only). Conflicts keep
both sides (`<file>.conflict.md` = remote copy, 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. `vault sync [name]`
forces a cycle via the daemon (`/api/sync/run`) or in-process when it is down
- `src/package-guard.test.ts` - CI guard: no agent-runtime remnants (express/ws/sqlite/
core/platform/supabase), runtime deps stay exactly `@agentage/memory-core + chalk +
commander + open`
Expand Down
170 changes: 170 additions & 0 deletions e2e/sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { execFileSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, test } from '@playwright/test';
import { assertCliBuilt, createCliMachine, freePort, type CliMachine } from './helpers.js';

// M4 git sync @p0: a vault with an external git origin syncs to a temp bare remote on disk. Fully
// self-contained - a local `git init --bare`, an isolated config dir, and an ephemeral daemon
// port; never the real ~/.agentage, never :4243, no deployed stack. Proves: the daemon auto-loop
// pushes a write within one interval; divergence keeps both sides (.conflict.md, zero loss); an
// unreachable remote never blocks a write.

const GIT_ENV = {
GIT_AUTHOR_NAME: 'e2e',
GIT_AUTHOR_EMAIL: 'e2e@example.com',
GIT_COMMITTER_NAME: 'e2e',
GIT_COMMITTER_EMAIL: 'e2e@example.com',
GIT_TERMINAL_PROMPT: '0',
};

const git = (cwd: string, args: string[]): string =>
execFileSync('git', args, {
cwd,
encoding: 'utf8',
// Capture stderr instead of inheriting it, so a probe miss (e.g. cat-file before the first
// push) does not spam the test log.
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, ...GIT_ENV },
});

// Write vaults.json directly into the isolated config dir for full control over path + origin +
// interval (the CLI `vault add --git` would default the path into the real home).
const writeVaultsConfig = (m: CliMachine, vault: string, path: string, origin: object): void => {
const config = {
version: 1,
default: vault,
vaults: { [vault]: { path, mcp: ['local'], origin: [origin] } },
};
writeFileSync(join(m.configDir, 'vaults.json'), JSON.stringify(config, null, 2), 'utf-8');
};

const bareHas = (bare: string, ref: string): boolean => {
try {
git(bare, ['cat-file', '-e', ref]);
return true;
} catch {
return false;
}
};

const alive = (pid: number): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};

test.describe('git sync @p0', () => {
test.beforeAll(() => assertCliBuilt());

test('the daemon auto-loop pushes a write to the bare remote within one interval', async () => {
const port = await freePort();
const m = createCliMachine({ AGENTAGE_NO_DAEMON: '', AGENTAGE_DAEMON_PORT: String(port) });
const scratch = mkdtempSync(join(tmpdir(), 'agentage-sync-a-'));
const bare = join(scratch, 'remote.git');
const vaultDir = join(scratch, 'vault');
let daemonPid: number | null = null;
try {
git(scratch, ['init', '--bare', '-b', 'main', bare]);
writeVaultsConfig(m, 'main', vaultDir, { remote: bare, interval: 1 });

const start = await m.exec(['daemon', 'start']);
expect(start.code, start.stderr).toBe(0);
const pidFile = join(m.configDir, 'daemon.pid');
daemonPid = existsSync(pidFile)
? Number.parseInt(readFileSync(pidFile, 'utf-8').trim(), 10)
: null;

const write = await m.exec(['memory', 'write', 'notes/hi.md', '--body', 'sync me quokka']);
expect(write.code, write.stderr).toBe(0);

// The 1s auto-loop should push well within this window.
const deadline = Date.now() + 30_000;
while (!bareHas(bare, 'main') && Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 500));
}
expect(bareHas(bare, 'main'), 'bare remote received the commit').toBe(true);
expect(git(bare, ['show', 'main:notes/hi.md'])).toContain('sync me quokka');

const status = await m.exec(['daemon', 'status']);
expect(status.stdout).toContain('sync');

await m.exec(['daemon', 'stop']);
} finally {
if (daemonPid !== null && alive(daemonPid)) {
try {
process.kill(daemonPid, 'SIGKILL');
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ESRCH') throw err;
}
}
m.cleanup();
rmSync(scratch, { recursive: true, force: true });
}
});

test('divergence keeps both sides: <file>.conflict.md holds the remote copy, zero lost writes', async () => {
// interval 0 (manual-only) + helpers default the daemon off -> a fully in-process forced sync.
const m = createCliMachine();
const scratch = mkdtempSync(join(tmpdir(), 'agentage-sync-b-'));
const bare = join(scratch, 'remote.git');
const vaultDir = join(scratch, 'vault');
try {
git(scratch, ['init', '--bare', '-b', 'main', bare]);
writeVaultsConfig(m, 'main', vaultDir, { remote: bare, interval: 0 });

// Seed a base note and push it to the bare remote.
expect((await m.exec(['memory', 'write', 'note.md', '--body', 'base'])).code).toBe(0);
expect((await m.exec(['vault', 'sync', 'main'])).code).toBe(0);

// A second clone advances the remote with a conflicting change.
const clone = join(scratch, 'clone');
git(scratch, ['clone', bare, clone]);
writeFileSync(join(clone, 'note.md'), 'REMOTE-CHANGE\n', 'utf-8');
git(clone, ['commit', '-am', 'remote change']);
git(clone, ['push', 'origin', 'HEAD:main']);

// Local makes its own conflicting change, then forces a sync.
expect((await m.exec(['memory', 'write', 'note.md', '--body', 'LOCAL-CHANGE'])).code).toBe(0);
const sync = await m.exec(['vault', 'sync', 'main']);
expect(sync.code, sync.stderr).toBe(0);
expect(sync.stdout).toContain('conflict copy');

expect(readFileSync(join(vaultDir, 'note.md'), 'utf-8')).toContain('LOCAL-CHANGE');
expect(readFileSync(join(vaultDir, 'note.conflict.md'), 'utf-8')).toContain('REMOTE-CHANGE');
// The push landed - both sides live on the remote too.
expect(git(bare, ['show', 'main:note.md'])).toContain('LOCAL-CHANGE');
expect(git(bare, ['show', 'main:note.conflict.md'])).toContain('REMOTE-CHANGE');
} finally {
m.cleanup();
rmSync(scratch, { recursive: true, force: true });
}
});

test('an unreachable remote never blocks a write; the sync error is recorded, not a crash', async () => {
const m = createCliMachine();
const scratch = mkdtempSync(join(tmpdir(), 'agentage-sync-c-'));
const vaultDir = join(scratch, 'vault');
try {
writeVaultsConfig(m, 'main', vaultDir, { remote: join(scratch, 'nope.git'), interval: 0 });

// The write succeeds even though the remote is unreachable.
const write = await m.exec(['memory', 'write', 'note.md', '--body', 'offline write']);
expect(write.code, write.stderr).toBe(0);

const sync = await m.exec(['vault', 'sync', 'main']);
expect(sync.code, sync.stderr).toBe(0); // recorded, not a crash
expect(sync.stdout).toContain('unreachable');
// The local commit persisted - CRUD never blocks on sync.
const log = git(vaultDir, ['log', '--oneline']);
expect(log.trim().length).toBeGreaterThan(0);
} finally {
m.cleanup();
rmSync(scratch, { recursive: true, force: true });
}
});
});
12 changes: 6 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
"prepublishOnly": "npm run verify"
},
"dependencies": {
"@agentage/memory-core": "^0.1.1",
"@agentage/memory-core": "^0.1.2",
"@agentage/server-memory": "^0.0.2",
"@modelcontextprotocol/sdk": "^1.29.0",
"chalk": "latest",
Expand Down
18 changes: 17 additions & 1 deletion src/commands/daemon-cmd.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import chalk from 'chalk';
import { type Command } from 'commander';
import { isDaemonRunning, resolvePort, stopDaemon } from '../daemon/lifecycle.js';
import { health, mismatchNotice, spawnDaemon } from '../lib/daemon-client.js';
import { health, mismatchNotice, spawnDaemon, syncStatus } from '../lib/daemon-client.js';

const startAction = async (): Promise<void> => {
const port = resolvePort();
Expand Down Expand Up @@ -46,6 +46,22 @@ const statusAction = async (): Promise<void> => {
console.log(`version ${h.version}`);
const notice = mismatchNotice(h.version);
if (notice) console.log(chalk.yellow(notice));

const sync = await syncStatus(port);
if (sync && sync.vaults.length > 0) {
console.log('sync');
for (const v of sync.vaults) {
const cadence = v.intervalSeconds > 0 ? `every ${v.intervalSeconds}s` : 'manual';
const state = v.running
? 'running'
: v.lastError
? `error: ${v.lastError}`
: v.lastRun
? `ok ${v.lastRun}`
: 'scheduled';
console.log(` ${v.vault.padEnd(16)} ${cadence.padEnd(12)} ${state}`);
}
}
};

export const registerDaemon = (program: Command): void => {
Expand Down
78 changes: 78 additions & 0 deletions src/commands/vault-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest';
import { type VaultsConfig } from '@agentage/memory-core';
import { type SyncResult } from '../sync/cycle.js';
import { type SyncTarget } from '../sync/planner.js';
import { runVaultSync, type VaultSyncDeps } from './vault-sync.js';

const result = (over: Partial<SyncResult> = {}): SyncResult => ({
vault: 'v',
remote: 'git@h:v.git',
ok: true,
committed: false,
pushed: true,
conflicts: [],
...over,
});

const makeDeps = (over: Partial<VaultSyncDeps> = {}): { deps: VaultSyncDeps; logs: string[] } => {
const logs: string[] = [];
const deps: VaultSyncDeps = {
loadConfig: (): VaultsConfig => ({
version: 1,
vaults: { v: { path: '/tmp/v', origin: [{ remote: 'git@h:v.git', interval: 0 }] } },
}),
daemonPort: async () => null,
runViaDaemon: async () => result(),
runInProcess: async () => result(),
log: (m) => logs.push(m),
...over,
};
return { deps, logs };
};

describe('runVaultSync', () => {
it('reports "no git origin" for an unknown vault name', async () => {
const { deps, logs } = makeDeps();
await runVaultSync('missing', deps);
expect(logs.join()).toContain("No git origin configured for vault 'missing'");
});

it('hints when no git-synced vaults exist', async () => {
const { deps, logs } = makeDeps({ loadConfig: () => ({ version: 1, vaults: {} }) });
await runVaultSync(undefined, deps);
expect(logs.join()).toContain('No git-synced vaults');
});

it('runs in-process when the daemon is down', async () => {
const runInProcess = vi.fn(async (_t: SyncTarget) => result({ committed: true }));
const { deps, logs } = makeDeps({ daemonPort: async () => null, runInProcess });
await runVaultSync('v', deps);
expect(runInProcess).toHaveBeenCalledTimes(1);
expect(logs.join()).toContain('committed');
expect(logs.join()).toContain('pushed');
});

it('delegates to the daemon when one is reachable', async () => {
const runViaDaemon = vi.fn(async () => result());
const runInProcess = vi.fn(async (t: SyncTarget) => result({ vault: t.vault }));
const { deps } = makeDeps({ daemonPort: async () => 4243, runViaDaemon, runInProcess });
await runVaultSync('v', deps);
expect(runViaDaemon).toHaveBeenCalledWith(4243, 'v');
expect(runInProcess).not.toHaveBeenCalled();
});

it('surfaces a failure line and lists preserved conflict copies', async () => {
const { deps, logs } = makeDeps({
runInProcess: async () =>
result({ ok: false, pushed: false, reason: 'unreachable', error: 'nope' }),
});
await runVaultSync('v', deps);
expect(logs.join()).toContain('failed (unreachable)');

const { deps: d2, logs: l2 } = makeDeps({
runInProcess: async () => result({ conflicts: ['note.conflict.md'] }),
});
await runVaultSync('v', d2);
expect(l2.join()).toContain('kept remote copy: note.conflict.md');
});
});
Loading