Files
autofirmer-expanded/lib/backup-sync.ts
T
Brandon LiandClaude Opus 5.5 c0899620d4 Old rules for older accounts, a rules date per account, and a firm cap
When the master changes an account type with "New accounts only", it now
serves the type's old rules as kept rule sets (firm.keptAccountRules), each
for accounts dated inside [openedAt, closedAt). This build applies them:

- getAccountConfig, the one rules lookup trading reads, puts a kept set's
  rule fields over the live type when the account's rules date falls in
  its window. Id, label, prefix, size and the projection fields stay the
  live type's, so every report still names the live type.
- account_assignments gains rules_date and rules_date_manual. A new row is
  dated when the machine first sees the account, on the master's clock
  when this one runs behind and never before the catalog's last change.
  Auto dates come down to the broker's evidence: the end of the first fund
  day in Chicago, or a DXtrade registration instant. Evidence is applied
  before an account's first trade. Rows from before the migration get its
  time; unpinning keeps the row and its date. Backups carry both columns.
- An account on a kept set stays on its type while its prefix and cached
  size still match, so a tie-break flip can't move it off its old rules.
- Kept entries parse through the same normalizer as live types (now
  normalizeAccountType in lib/kept-rules.ts); a malformed one rejects the
  sync like a malformed type. The cached catalog records the parser that
  stored it, and a cache from an older parser is fetched in full rather
  than trusted with ?since= (this also brings in maxConcurrentAccounts).
- Reports add keptRuleIds per firm and live counts per kept set per type.
- The dashboard row and the account page show the rules date next to the
  account name, with an "old rules" chip saying what differs. Clicking
  the date edits it inline (PUT /api/accounts/:id/rules-date), previewing
  which rules the date lands on.

Also: the master's per-firm cap on accounts traded at once
(maxConcurrentAccounts), applied on top of the machine's own max
concurrent when a batch is drawn and in copy trading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 22:21:55 -05:00

172 lines
6.4 KiB
TypeScript

/**
* Pushes this machine's full configuration — settings, broker credentials,
* instruments, manual assignments — to the master, so a dead VPS can be
* replaced by restoring the backup (lib/backup-restore.ts) and adopting this
* instance's uuid.
*
* This is the deliberate exception to "credentials never leave the machine":
* the payload goes over HTTPS to /api/instances/backup and is AES-encrypted
* at rest on the master. It never rides the control socket. Pushing needs no
* token — every instance backs up automatically once it has its uuid; only
* PULLING a backup back out (lib/backup-restore.ts) is token-gated, because
* that is the direction credentials come out in the clear.
*
* Push policy: on bootstrap, then every POLL_INTERVAL, but only when the
* content actually changed (hash comparison) — plus an immediate debounced
* push whenever settings or credentials are edited (scheduleBackupPush, called
* from the mutation routes).
*/
import { createHash } from 'node:crypto';
import {
getSetting,
setSetting,
listSettings,
listCredentials,
getInstruments,
listAllAssignments,
type FirmCredentialRow,
type BackupAssignmentRow,
} from './db';
export interface BackupPayload {
v: 1;
savedAt: string;
settings: Record<string, string>;
credentials: FirmCredentialRow[];
instruments: { symbol: string; enabled: boolean }[];
/** Whole rows (SELECT *), rules dates included — absent from a backup
* an older build pushed. */
assignments: BackupAssignmentRow[];
}
const BACKUP_PATH = '/api/instances/backup';
const POLL_INTERVAL_MS = 5 * 60_000;
const DEBOUNCE_MS = 5_000;
const FETCH_TIMEOUT_MS = 10_000;
const g = globalThis as typeof globalThis & {
__backupSyncTimer?: ReturnType<typeof setTimeout>;
__backupDebounceTimer?: ReturnType<typeof setTimeout>;
__backupLastHash?: string;
};
// Bookkeeping that describes this process run, not the configuration worth
// restoring. Excluded from the hash so a heartbeat doesn't look like a change
// worth pushing. (The restore side has its own skip-set; these still travel in
// the payload so a restore debugger can see them.)
const VOLATILE_KEYS = new Set([
'runner_heartbeat',
'catalog_last_sync_at',
'catalog_last_error',
'backup_last_push_at',
'backup_last_error',
]);
export function collectBackup(): BackupPayload {
return {
v: 1,
savedAt: new Date().toISOString(),
settings: listSettings(),
credentials: listCredentials(),
instruments: getInstruments().map((i) => ({ symbol: i.symbol, enabled: Boolean(i.enabled) })),
assignments: listAllAssignments(),
};
}
/** Stable digest of everything restorable, so unchanged config never pushes. */
function contentHash(payload: BackupPayload): string {
const settings = Object.entries(payload.settings)
.filter(([k]) => !VOLATILE_KEYS.has(k))
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
return createHash('sha256')
.update(JSON.stringify({
settings,
credentials: payload.credentials,
instruments: payload.instruments,
assignments: payload.assignments,
}))
.digest('hex');
}
function preconditions(): { masterUrl: string; id: string } | null {
const masterUrl = getSetting('master_dashboard_url');
// No uuid yet = the master has never said welcome; there is nothing stable
// to key the backup by, so wait for the first hello to complete. No token
// needed: pushes are open on the master (only PULLING a backup is
// token-gated), so every instance backs up automatically.
const id = getSetting('instance_id');
if (!masterUrl || !id) return null;
return { masterUrl, id };
}
export async function pushBackup(force = false): Promise<{ ok: boolean; skipped?: boolean; error?: string }> {
const pre = preconditions();
if (!pre) return { ok: false, skipped: true, error: 'master url or instance uuid not set' };
const payload = collectBackup();
const hash = contentHash(payload);
if (!force && hash === g.__backupLastHash) return { ok: true, skipped: true };
let url: URL;
try {
url = new URL(BACKUP_PATH, pre.masterUrl);
} catch {
return { ok: false, error: `master_dashboard_url is not a URL: ${pre.masterUrl}` };
}
try {
const res = await fetch(url, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
id: pre.id,
name: getSetting('instance_name') ?? '',
payload,
}),
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
const detail = await res.json().then((b) => b?.error).catch(() => '');
throw new Error(`master returned ${res.status}${detail ? ` — ${detail}` : ''}`);
}
g.__backupLastHash = hash;
setSetting('backup_last_push_at', new Date().toISOString());
setSetting('backup_last_error', '');
console.log('[backup] pushed to master');
return { ok: true };
} catch (err) {
const error = err instanceof Error ? err.message : String(err);
console.error('[backup] push failed:', error);
setSetting('backup_last_error', error);
return { ok: false, error };
}
}
/** Debounced push for the mutation routes: a settings PATCH or credential PUT
* lands on the master seconds later instead of at the next poll. */
export function scheduleBackupPush(): void {
if (g.__backupDebounceTimer) clearTimeout(g.__backupDebounceTimer);
g.__backupDebounceTimer = setTimeout(() => { void pushBackup(); }, DEBOUNCE_MS);
}
export function startBackupSync(): void {
stopBackupSync();
const tick = async () => {
// Forced: pushes need no token, so anyone who reaches the master and
// knows this uuid could overwrite our backup there. Re-asserting the
// real state every tick bounds that poisoning window to 5 minutes.
await pushBackup(true);
// Jitter so a fleet restarting together doesn't push in lockstep.
g.__backupSyncTimer = setTimeout(tick, POLL_INTERVAL_MS + Math.floor(Math.random() * 30_000));
};
void tick();
}
export function stopBackupSync(): void {
if (g.__backupSyncTimer) {
clearTimeout(g.__backupSyncTimer);
g.__backupSyncTimer = undefined;
}
}