feat(sync): add sync-hosts store, NDJSON --json mode, and --check to subminer sync

This commit is contained in:
2026-07-11 17:45:27 -07:00
parent 8797719a09
commit 0a3f76c0a8
16 changed files with 979 additions and 27 deletions
@@ -44,6 +44,9 @@ function createContext(): LauncherCommandContext {
syncRemoteCmd: '',
syncDbPath: '',
syncForce: false,
syncJson: false,
syncCheck: false,
syncUi: false,
logLevel: 'info',
logRotation: 7,
passwordStore: '',
+202
View File
@@ -36,6 +36,12 @@ function ok(stdout = ''): { status: number; stdout: string; stderr: string } {
return { status: 0, stdout, stderr: '' };
}
// Every host-sync test must stub recordHostSyncResult: the default writes the
// real sync-hosts.json in the user's config directory.
const noRecord: Pick<SyncCommandDeps, 'recordHostSyncResult'> = {
recordHostSyncResult: () => {},
};
test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', async () => {
const context = makeContext({ syncDbPath: '/tmp/local.sqlite' });
context.mpvSocketPath = '/tmp/subminer-socket';
@@ -61,6 +67,7 @@ test('ensureTrackerQuiescent ignores stale sockets but rejects live sockets', as
test('runSyncCommand dispatches snapshot, merge, host, and missing-target modes', async () => {
const calls: string[] = [];
const deps: Partial<SyncCommandDeps> = {
...noRecord,
createDbSnapshot: (dbPath: string, outPath: string) => {
calls.push(`snapshot:${dbPath}->${outPath}`);
},
@@ -120,6 +127,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
const calls: string[] = [];
let localTmpDir = '';
const deps: Partial<SyncCommandDeps> = {
...noRecord,
createDbSnapshot: (_dbPath: string, outPath: string) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
@@ -167,6 +175,7 @@ test('runHostSync keeps tracker quiescent through local and remote merge and cle
test('runHostSync includes remote snapshot stderr in failures', async () => {
const deps: Partial<SyncCommandDeps> = {
...noRecord,
createDbSnapshot: (_dbPath: string, outPath: string) => {
fs.writeFileSync(outPath, 'snapshot');
},
@@ -192,6 +201,7 @@ test('runHostSync includes remote snapshot stderr in failures', async () => {
function makeDirectionDeps(calls: string[]): Partial<SyncCommandDeps> {
return {
...noRecord,
createDbSnapshot: (_dbPath: string, outPath: string) => {
calls.push(`snapshot:${outPath}`);
fs.writeFileSync(outPath, 'snapshot');
@@ -255,3 +265,195 @@ test('runHostSync pull only snapshots remotely and merges locally', async () =>
assert.ok(!calls.some((call) => call.startsWith('snapshot:')));
assert.ok(!calls.some((call) => call.includes(' sync --merge ')));
});
test('runSyncCommand --json emits NDJSON progress events for a two-way host sync', async () => {
const lines: string[] = [];
const deps: Partial<SyncCommandDeps> = {
...makeDirectionDeps([]),
consoleLog: (line: string) => {
lines.push(line);
},
writeStdout: ((chunk: string) => {
lines.push(`stdout:${chunk}`);
return true;
}) as unknown as typeof process.stdout.write,
runSsh: (_host: string, command: string) => {
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
if (command.includes(' sync --merge ')) return ok('remote summary text\n');
return ok();
},
recordHostSyncResult: () => {},
};
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }),
deps,
);
const events = lines.map((line) => JSON.parse(line));
const types = events.map((event) => event.type);
assert.ok(types.includes('stage'));
assert.ok(
events.some((event) => event.type === 'stage' && event.stage === 'snapshot-local'),
);
assert.ok(
events.some((event) => event.type === 'merge-summary' && event.target === 'local'),
);
assert.ok(
events.some(
(event) => event.type === 'remote-output' && event.text.includes('remote summary text'),
),
);
const last = events[events.length - 1];
assert.deepEqual(last, { type: 'result', ok: true, error: null });
assert.ok(!lines.some((line) => line.startsWith('stdout:')));
});
test('runSyncCommand --json emits an error result when the sync fails', async () => {
const lines: string[] = [];
const deps: Partial<SyncCommandDeps> = {
...makeDirectionDeps([]),
consoleLog: (line: string) => {
lines.push(line);
},
writeStdout: (() => true) as unknown as typeof process.stdout.write,
runSsh: (_host: string, command: string) => {
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
if (command.includes(' sync --merge ')) {
return { status: 9, stdout: '', stderr: 'remote merge exploded' };
}
return ok();
},
recordHostSyncResult: () => {},
};
await assert.rejects(() =>
runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box', syncJson: true }),
deps,
),
);
const events = lines.map((line) => JSON.parse(line));
const last = events[events.length - 1];
assert.equal(last.type, 'result');
assert.equal(last.ok, false);
assert.match(last.error, /Remote merge failed/);
});
test('runSyncCommand records host sync results for saved-host bookkeeping', async () => {
const recorded: Array<{ host: string; status: string; detail: string | null }> = [];
const baseDeps = makeDirectionDeps([]);
const deps: Partial<SyncCommandDeps> = {
...baseDeps,
recordHostSyncResult: (host: string, status: 'success' | 'error', detail: string | null) => {
recorded.push({ host, status, detail });
},
};
await runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
deps,
);
assert.equal(recorded.length, 1);
assert.equal(recorded[0]!.host, 'media-box');
assert.equal(recorded[0]!.status, 'success');
const failingDeps: Partial<SyncCommandDeps> = {
...deps,
runSsh: (_host: string, command: string) => {
if (command.startsWith('mktemp ')) return ok('/tmp/subminer-sync.remote\n');
if (command.includes(' sync --merge ')) {
return { status: 9, stdout: '', stderr: 'boom' };
}
return ok();
},
};
await assert.rejects(() =>
runSyncCommand(
makeContext({ syncDbPath: '/tmp/local.sqlite', syncHost: 'media-box' }),
failingDeps,
),
);
assert.equal(recorded.length, 2);
assert.equal(recorded[1]!.status, 'error');
});
test('runSyncCommand --check --json reports ssh and remote launcher status', async () => {
const lines: string[] = [];
const deps: Partial<SyncCommandDeps> = {
ensureTrackerQuiescent: async () => {},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => 'subminer',
consoleLog: (line: string) => {
lines.push(line);
},
runSsh: (_host: string, command: string) => {
if (command.includes('--version')) return ok('SubMiner 0.18.0\n');
return ok('subminer-check-ok\n');
},
fail: (message: string): never => {
throw new Error(message);
},
};
assert.equal(
await runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
deps,
),
true,
);
const events = lines.map((line) => JSON.parse(line));
const check = events.find((event) => event.type === 'check-result');
assert.ok(check);
assert.equal(check.host, 'media-box');
assert.equal(check.sshOk, true);
assert.equal(check.remoteCommand, 'subminer');
assert.equal(check.remoteVersion, 'SubMiner 0.18.0');
assert.equal(check.ok, true);
assert.equal(check.error, null);
});
test('runSyncCommand --check --json reports failures without throwing early', async () => {
const lines: string[] = [];
const deps: Partial<SyncCommandDeps> = {
ensureTrackerQuiescent: async () => {},
assertSafeSshHost: () => {},
resolveRemoteSubminerCommand: () => {
throw new Error('Could not find a runnable "subminer" on media-box.');
},
consoleLog: (line: string) => {
lines.push(line);
},
runSsh: () => ok('subminer-check-ok\n'),
fail: (message: string): never => {
throw new Error(message);
},
};
await assert.rejects(() =>
runSyncCommand(
makeContext({
syncDbPath: '/tmp/local.sqlite',
syncHost: 'media-box',
syncCheck: true,
syncJson: true,
}),
deps,
),
);
const events = lines.map((line) => JSON.parse(line));
const check = events.find((event) => event.type === 'check-result');
assert.ok(check);
assert.equal(check.sshOk, true);
assert.equal(check.ok, false);
assert.match(check.error, /Could not find a runnable/);
});
+158 -11
View File
@@ -18,8 +18,11 @@ import {
} from '../sync/ssh.js';
import { resolvePathMaybe } from '../util.js';
import { canConnectUnixSocket } from '../mpv.js';
import { recordHostSyncResultToDisk } from '../sync/sync-hosts.js';
import type { LauncherCommandContext } from './context.js';
import type { RemoteRunResult } from '../sync/ssh.js';
import type { SyncMergeSummary, SyncProgressEvent } from '../../src/shared/sync/sync-events.js';
import type { SyncResultStatus } from '../../src/shared/sync/sync-hosts-store.js';
export interface SyncCommandDeps {
createDbSnapshot: typeof createDbSnapshot;
@@ -39,6 +42,8 @@ export interface SyncCommandDeps {
consoleLog: typeof console.log;
writeStdout: typeof process.stdout.write;
ensureTrackerQuiescent: (context: LauncherCommandContext, dbPath: string) => Promise<void>;
emitEvent: (event: SyncProgressEvent) => void;
recordHostSyncResult: (host: string, status: SyncResultStatus, detail: string | null) => void;
}
function resolveDbPath(context: LauncherCommandContext): string {
@@ -96,12 +101,26 @@ const defaultSyncCommandDeps: SyncCommandDeps = {
consoleLog: console.log,
writeStdout: process.stdout.write.bind(process.stdout),
ensureTrackerQuiescent: async (context, dbPath) => ensureTrackerQuiescent(context, dbPath),
emitEvent: () => {},
recordHostSyncResult: recordHostSyncResultToDisk,
};
function resolveSyncCommandDeps(inputDeps: Partial<SyncCommandDeps> = {}): SyncCommandDeps {
return { ...defaultSyncCommandDeps, ...inputDeps };
}
// In --json mode every line on stdout is an NDJSON event: human console output
// is silenced and events are written through the original console logger.
function withJsonEvents(deps: SyncCommandDeps): SyncCommandDeps {
const writeLine = deps.consoleLog;
return {
...deps,
consoleLog: () => {},
writeStdout: (() => true) as typeof process.stdout.write,
emitEvent: (event) => writeLine(JSON.stringify(event)),
};
}
export function runSnapshotMode(
context: LauncherCommandContext,
dbPath: string,
@@ -109,7 +128,13 @@ export function runSnapshotMode(
): void {
const deps = resolveSyncCommandDeps(inputDeps);
const outPath = resolvePathMaybe(context.args.syncSnapshotPath);
deps.emitEvent({
type: 'stage',
stage: 'snapshot-local',
message: `Snapshotting local database (${dbPath})`,
});
deps.createDbSnapshot(dbPath, outPath);
deps.emitEvent({ type: 'snapshot-created', path: outPath });
deps.consoleLog(outPath);
}
@@ -121,10 +146,73 @@ export async function runMergeMode(
const deps = resolveSyncCommandDeps(inputDeps);
await deps.ensureTrackerQuiescent(context, dbPath);
const snapshotPath = resolvePathMaybe(context.args.syncMergePath);
deps.emitEvent({
type: 'stage',
stage: 'merge-local',
message: `Merging ${snapshotPath} into the local database`,
});
const summary = deps.mergeSnapshotIntoDb(dbPath, snapshotPath);
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(deps.formatMergeSummary(summary));
}
function formatHostSyncDetail(
direction: 'both' | 'push' | 'pull',
pulledSummary: SyncMergeSummary | null,
): string {
if (!pulledSummary) return direction === 'push' ? 'Pushed local stats' : 'Sync complete';
const merged = `${pulledSummary.sessionsMerged} session${pulledSummary.sessionsMerged === 1 ? '' : 's'} merged`;
return direction === 'pull' ? merged : `${merged}; pushed local stats`;
}
export async function runCheckMode(
context: LauncherCommandContext,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<void> {
const deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
const host = args.syncHost;
deps.assertSafeSshHost(host);
deps.consoleLog(`Checking SSH connection to ${host}...`);
let remoteCommand: string | null = null;
let remoteVersion: string | null = null;
let error: string | null = null;
const probe = deps.runSsh(host, 'echo subminer-check-ok');
const sshOk = probe.status === 0 && probe.stdout.includes('subminer-check-ok');
if (!sshOk) {
error = formatRemoteRunError(`Could not reach ${host} over SSH.`, probe);
} else {
deps.consoleLog('SSH connection: ok');
try {
remoteCommand = deps.resolveRemoteSubminerCommand(host, args.syncRemoteCmd || null);
const version = deps.runSsh(host, `${remoteCommand} --version`);
remoteVersion = version.status === 0 ? version.stdout.trim() || null : null;
deps.consoleLog(
`Remote subminer: ${remoteCommand}${remoteVersion ? ` (${remoteVersion})` : ''}`,
);
} catch (resolveError) {
error = resolveError instanceof Error ? resolveError.message : String(resolveError);
}
}
const ok = sshOk && remoteCommand !== null;
deps.emitEvent({
type: 'check-result',
host,
sshOk,
remoteCommand,
remoteVersion,
ok,
error,
});
if (!ok) {
throw new Error(error ?? `Connection check failed for ${host}.`);
}
deps.consoleLog('Check passed.');
}
function cleanupRemote(host: string, remoteTmpDir: string, deps: SyncCommandDeps): void {
if (!remoteTmpDir.startsWith('/tmp/')) return;
deps.runSsh(host, `rm -rf ${shellQuote(remoteTmpDir)}`);
@@ -155,6 +243,7 @@ export async function runHostSync(
const localTmpDir = deps.mkdtempSync(path.join(os.tmpdir(), 'subminer-sync-'));
let remoteTmpDir = '';
let pulledSummary: SyncMergeSummary | null = null;
try {
// Signal failures by throwing (not fail(), which exits synchronously and
// would skip the finally cleanup, leaking temp dirs holding snapshot data).
@@ -170,12 +259,18 @@ export async function runHostSync(
const localSnapshot = path.join(localTmpDir, 'local.sqlite');
if (shouldPush) {
deps.consoleLog(`Snapshotting local database (${dbPath})...`);
deps.emitEvent({
type: 'stage',
stage: 'snapshot-local',
message: `Snapshotting local database (${dbPath})`,
});
deps.createDbSnapshot(dbPath, localSnapshot);
}
const remoteSnapshot = `${remoteTmpDir}/snapshot.sqlite`;
if (shouldPull) {
deps.consoleLog(`Snapshotting ${host}...`);
deps.emitEvent({ type: 'stage', stage: 'snapshot-remote', message: `Snapshotting ${host}` });
const snapshotRun = deps.runSsh(
host,
`${remoteCmd} sync --snapshot ${shellQuote(remoteSnapshot)}${forceFlag}`,
@@ -186,25 +281,50 @@ export async function runHostSync(
}
const pulledSnapshot = path.join(localTmpDir, 'remote.sqlite');
if (shouldPull) deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
if (shouldPull) {
deps.emitEvent({
type: 'stage',
stage: 'download',
message: `Copying snapshot from ${host}`,
});
deps.runScp(`${host}:${remoteSnapshot}`, pulledSnapshot);
}
const incomingSnapshot = `${remoteTmpDir}/incoming.sqlite`;
if (shouldPush) deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
if (shouldPush) {
deps.emitEvent({ type: 'stage', stage: 'upload', message: `Copying snapshot to ${host}` });
deps.runScp(localSnapshot, `${host}:${incomingSnapshot}`);
}
if (shouldPull) {
deps.consoleLog(`\nMerging ${host} -> local:`);
deps.emitEvent({
type: 'stage',
stage: 'merge-local',
message: `Merging ${host} into the local database`,
});
await deps.ensureTrackerQuiescent(context, dbPath);
const summary = deps.mergeSnapshotIntoDb(dbPath, pulledSnapshot);
pulledSummary = summary;
deps.emitEvent({ type: 'merge-summary', target: 'local', summary });
deps.consoleLog(deps.formatMergeSummary(summary));
}
if (shouldPush) {
deps.consoleLog(`\nMerging local -> ${host}:`);
deps.emitEvent({
type: 'stage',
stage: 'merge-remote',
message: `Merging the local database into ${host}`,
});
await deps.ensureTrackerQuiescent(context, dbPath);
const mergeRun = deps.runSsh(
host,
`${remoteCmd} sync --merge ${shellQuote(incomingSnapshot)}${forceFlag}`,
);
deps.writeStdout(mergeRun.stdout);
if (mergeRun.stdout.trim()) {
deps.emitEvent({ type: 'remote-output', text: mergeRun.stdout });
}
if (mergeRun.status !== 0) {
const retryCommand =
direction === 'push' ? `subminer sync ${host} --push` : `subminer sync ${host}`;
@@ -219,6 +339,18 @@ export async function runHostSync(
}
deps.consoleLog('\nSync complete.');
deps.recordHostSyncResult(host, 'success', formatHostSyncDetail(direction, pulledSummary));
} catch (error) {
try {
deps.recordHostSyncResult(
host,
'error',
error instanceof Error ? error.message : String(error),
);
} catch {
// best effort
}
throw error;
} finally {
deps.rmSync(localTmpDir, { recursive: true, force: true });
if (remoteTmpDir) {
@@ -235,19 +367,34 @@ export async function runSyncCommand(
context: LauncherCommandContext,
inputDeps: Partial<SyncCommandDeps> = {},
): Promise<boolean> {
const deps = resolveSyncCommandDeps(inputDeps);
let deps = resolveSyncCommandDeps(inputDeps);
const { args } = context;
if (!args.sync) return false;
if (args.syncJson) deps = withJsonEvents(deps);
const dbPath = resolveDbPath(context);
if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {
await runHostSync(context, dbPath, deps);
} else {
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
try {
if (args.syncCheck) {
await runCheckMode(context, deps);
} else if (args.syncSnapshotPath) {
runSnapshotMode(context, dbPath, deps);
} else if (args.syncMergePath) {
await runMergeMode(context, dbPath, deps);
} else if (args.syncHost) {
await runHostSync(context, dbPath, deps);
} else {
deps.fail('sync requires a host, --snapshot <file>, or --merge <file>.');
}
} catch (error) {
if (args.syncJson) {
deps.emitEvent({
type: 'result',
ok: false,
error: error instanceof Error ? error.message : String(error),
});
}
throw error;
}
if (args.syncJson) deps.emitEvent({ type: 'result', ok: true, error: null });
return true;
}