Files
openclaw/src/commands/status.test-support.ts
T
Peter Steinberger 37b4fc8621 feat(backup): recorded runs, scheduled backups, and git-backed versioned snapshots (#122485)
* refactor(infra): extract shared git exec and verified snapshot-copy helpers

Moves the worktrees git wrapper to src/infra/git-exec.ts (with optional
maxOutputBytes for large buffered reads) and the online-backup/sanitize/
VACUUM/verify snapshot step into src/snapshot/openclaw-snapshot-copy.ts so
snapshot backends share one hardened copy path. Behavior-identical moves;
all importers updated.

* feat(snapshot): git-backed versioned SQLite snapshot engine

Deterministic per-table JSONL dumps (PK-ordered, lossless bigint/blob
encoding), verbatim DDL preservation, virtual/shadow-table skipping with
FTS rebuild on restore, secret-table redaction policy, manifest with
per-table row counts and content hashes, and restore verification by
re-serialization. Unchanged data produces no commit.

* feat(backup): recorded runs, freshness surfacing, and scheduled git backups

Every backup attempt is recorded in the previously writer-less backup_runs
table (bounded to 200 rows). openclaw status gains a Backups overview row
and JSON payload; doctor prints an informational hint when no successful
backup is recorded or the newest is stale. New commands: backup git
init/create/log/verify/restore and backup enable/disable, which provision
one idempotent gateway cron job running scheduled git backups.

* fix(state): stop bumping schema_meta.updated_at on unchanged opens

updated_at now records when schema metadata actually changed instead of
when the database was last opened; unconditional bumps dirtied the row on
every open and defeated no-change backup detection.

* docs: document versioned git backups, scheduling, and backup freshness

* fix(backup): satisfy CI ownership checks

* fix(backup): complete CI contract coverage

* fix(backup): complete credential table redaction

* fix(backup): isolate git repository ownership

* fix(backup): persist push degradation

* fix(backup): atomically converge schedules

* fix(status): isolate backup freshness environment

* fix(status): carry scan environment to freshness reads

* fix(backup): harden Git repository ownership

* docs(backup): document Git repository safety

* fix(backup): non-creating outcome log and origin preflight for pushed schedules

Recording a backup outcome never bootstraps an absent state database (a
failed backup on a fresh host would otherwise create a blank DB that a
retry treats as real input), and backup enable --push now requires the
repository to have an origin remote, pointing at backup git init --remote
instead of scheduling permanently degraded pushes.

* refactor(worktrees): use shared git exec helpers

* refactor(worktrees): remove unused git buffer wrapper

* refactor(worktrees): consume buffered git helper

* feat(backup): redact pushed schedules by default

Unattended recurring pushes retain credential-bearing tables durably in
remote Git history, so backup enable --push now defaults to
--exclude-secrets; --include-secrets is the explicit full-fidelity
override (still warned). Local non-push schedules keep full fidelity for
complete restores.

* fix(backup): redact audit HMAC and OAuth pending state; tolerate absent backup_runs

Adds audit_identity_keys (audit HMAC key) and mcp_oauth_pending_authorizations
(live OAuth callback state) to the redaction inventory, and makes read-only
backup freshness treat a same-version database without the additive
backup_runs table as no recorded backups instead of failing before a
writable open converges the schema.

* fix(backup): restrict schedules to local gateways

* fix(snapshot): harden Git restore and redaction

* fix(backup): block pushes of adopted history

* fix(backup): contain commits and pairing secrets
2026-08-12 08:11:22 -07:00

291 lines
8.7 KiB
TypeScript

// Status test support builds reusable gateway, update, heartbeat, and service fixtures for command tests.
import os from "node:os";
import path from "node:path";
import type { HeartbeatEventPayload } from "../infra/heartbeat-events.js";
import { isBetaTag } from "../infra/update-channels.js";
import type { Tone } from "../memory-host-sdk/status.js";
import type { PluginCompatibilityNotice } from "../plugins/status.js";
import type { StatusSummary } from "../status/types.js";
import { VERSION } from "../version.js";
import type { buildStatusCommandOverviewRows } from "./status-overview-rows.ts";
import type { StatusOverviewSurface } from "./status-overview-surface.ts";
import type { AgentLocalStatus } from "./status.agent-local.js";
import type { buildStatusCommandReportData } from "./status.command-report-data.ts";
import type { MemoryPluginStatus, MemoryStatusSnapshot } from "./status.scan.shared.js";
type StatusCommandOverviewRowsParams = Parameters<typeof buildStatusCommandOverviewRows>[0];
type StatusCommandReportDataParams = Parameters<typeof buildStatusCommandReportData>[0];
const STATUS_TEST_STATE_DIR = path.join(os.tmpdir(), `openclaw-status-test-${process.pid}-absent`);
export const baseStatusCfg = {
update: { channel: "stable" },
gateway: { bind: "loopback" },
} as const;
export const baseStatusUpdate = {
installKind: "git",
git: {
branch: "main",
tag: "v1.2.3",
upstream: "origin/main",
behind: 2,
ahead: 0,
dirty: false,
fetchOk: true,
},
registry: { latestVersion: "2026.4.10" },
} as never;
export const baseStatusExpectedUpdateChannelInfo = isBetaTag(VERSION)
? {
channel: "beta",
source: "installed-version",
label: "beta (installed version)",
}
: {
channel: "stable",
source: "config",
label: "stable (config)",
};
export const baseStatusExpectedUpdateChannelLabel = baseStatusExpectedUpdateChannelInfo.label;
export const baseStatusGatewaySnapshot = {
gatewayMode: "remote",
remoteUrlMissing: false,
gatewayConnection: {
url: "wss://gateway.example.com",
urlSource: "config",
message: "Gateway target: wss://gateway.example.com",
},
gatewayReachable: true,
gatewayProbe: { connectLatencyMs: 42, error: null } as never,
gatewayProbeAuth: { token: "tok" },
gatewayProbeAuthWarning: "warn-text",
gatewaySelf: { host: "gateway", version: "1.2.3" },
} as const;
export const baseStatusOverviewScanFields = {
cfg: baseStatusCfg,
update: baseStatusUpdate,
tailscaleMode: "serve",
tailscaleDns: "box.tail.ts.net",
tailscaleHttpsUrl: "https://box.tail.ts.net",
...baseStatusGatewaySnapshot,
};
const baseStatusGatewayService = {
label: "LaunchAgent",
installed: true,
managedByOpenClaw: true,
loadedText: "loaded",
runtimeShort: "running",
};
const baseStatusNodeService = {
label: "node",
installed: true,
loadedText: "loaded",
runtime: { status: "running", pid: 42 },
};
export const baseStatusServices = {
gatewayService: baseStatusGatewayService,
nodeService: baseStatusNodeService,
nodeOnlyGateway: null,
};
export const baseStatusOverviewSurface = {
...baseStatusOverviewScanFields,
...baseStatusServices,
} as unknown as StatusOverviewSurface;
const baseStatusSummary = {
tasks: { total: 3, active: 1, failures: 0, byStatus: { queued: 1, running: 1 } },
taskAudit: { errors: 1, warnings: 0 },
heartbeat: {
defaultAgentId: "main",
agents: [{ agentId: "main", enabled: true, everyMs: 60_000, every: "1m" }],
},
channelSummary: [],
queuedSystemEvents: ["one", "two"],
sessions: {
count: 2,
paths: ["store.json"],
defaults: { model: "gpt-5.5", contextTokens: 12_000 },
recent: [
{
key: "session-key",
kind: "direct",
updatedAt: 1,
age: 5_000,
model: "gpt-5.5",
configuredModel: "openai/gpt-5.5",
selectedModel: "openai/gpt-5.5",
modelSelectionReason: null,
runtime: "OpenClaw Default",
totalTokens: 12_000,
totalTokensFresh: true,
remainingTokens: 4_000,
percentUsed: 75,
contextTokens: 16_000,
flags: [],
},
],
byAgent: [],
},
} as unknown as StatusSummary;
const baseStatusAgentStatus = {
defaultId: "main",
bootstrapPendingCount: 1,
totalSessions: 2,
agents: [{ id: "main", lastActiveAgeMs: 60_000 }] as AgentLocalStatus[],
};
const baseStatusMemory = {
agentId: "main",
files: 1,
chunks: 2,
vector: {},
fts: {},
cache: {},
} as unknown as MemoryStatusSnapshot;
const baseStatusMemoryPlugin = {
enabled: true,
slot: "memory",
} as const satisfies MemoryPluginStatus;
const baseStatusPluginCompatibility = [
{ pluginId: "a", severity: "warn", message: "legacy" },
] as PluginCompatibilityNotice[];
function createStatusLastHeartbeat(): HeartbeatEventPayload {
return {
ts: Date.now() - 30_000,
status: "ok-token",
channel: "quietchat",
accountId: "acct",
};
}
function createStatusHealth() {
return {
ok: true as const,
ts: Date.now(),
durationMs: 42,
channels: {},
channelOrder: [],
channelLabels: {},
heartbeatSeconds: 60,
defaultAgentId: "main",
agents: [],
sessions: {
path: "store.json",
count: 2,
recent: [{ key: "session-key", updatedAt: 1, age: 5_000 }],
},
};
}
const statusTestDecorators = {
ok: (value: string) => `ok(${value})`,
warn: (value: string) => `warn(${value})`,
muted: (value: string) => `muted(${value})`,
accentDim: (value: string) => `accent(${value})`,
};
const statusTestFormatting = {
shortenText: (value: string) => value,
formatCliCommand: (value: string) => `cmd:${value}`,
formatTimeAgo: (value: number) => `${value}ms`,
formatKTokens: (value: number) => `${Math.round(value / 1000)}k`,
formatTokensCompact: () => "12k",
formatPromptCacheCompact: () => "cache ok",
formatHealthChannelLines: () => ["QuietChat: OK · ready"],
formatPluginCompatibilityNotice: (notice: { message?: unknown }) => String(notice.message),
formatUpdateAvailableHint: () => "update available",
};
const statusTestMemoryResolvers = {
resolveMemoryVectorState: () => ({ state: "ready", tone: "ok" as Tone }),
resolveMemoryFtsState: () => ({ state: "ready", tone: "warn" as Tone }),
resolveMemoryCacheSummary: () => ({ text: "cache warm", tone: "muted" as Tone }),
};
const statusTestTheme = {
heading: (value: string) => `# ${value}`,
muted: (value: string) => `muted(${value})`,
warn: (value: string) => `warn(${value})`,
error: (value: string) => `error(${value})`,
};
export function createStatusCommandOverviewRowsParams(
overrides: Partial<StatusCommandOverviewRowsParams> = {},
): StatusCommandOverviewRowsParams {
return {
env: { OPENCLAW_STATE_DIR: STATUS_TEST_STATE_DIR },
opts: { deep: true },
surface: baseStatusOverviewSurface,
osLabel: "macOS",
summary: baseStatusSummary,
health: createStatusHealth(),
lastHeartbeat: createStatusLastHeartbeat(),
agentStatus: baseStatusAgentStatus,
memory: baseStatusMemory,
memoryPlugin: baseStatusMemoryPlugin,
pluginCompatibility: baseStatusPluginCompatibility,
...statusTestDecorators,
...statusTestFormatting,
...statusTestMemoryResolvers,
updateValue: "available · custom update",
...overrides,
};
}
export function createStatusCommandReportDataParams(
overrides: Partial<StatusCommandReportDataParams> = {},
): StatusCommandReportDataParams {
return {
env: { OPENCLAW_STATE_DIR: STATUS_TEST_STATE_DIR },
opts: { deep: true, verbose: true },
surface: baseStatusOverviewSurface,
osSummary: { label: "macOS" } as never,
summary: baseStatusSummary,
securityAudit: {
ts: Date.now(),
summary: { critical: 0, warn: 1, info: 0 },
findings: [
{
checkId: "warn-first",
severity: "warn",
title: "Warn first",
detail: "warn detail",
},
],
},
health: createStatusHealth(),
usageLines: ["usage line"],
lastHeartbeat: createStatusLastHeartbeat(),
agentStatus: baseStatusAgentStatus,
channels: {
rows: [{ id: "quietchat", label: "QuietChat", enabled: true, state: "ok", detail: "ready" }],
},
channelIssues: [{ channel: "quietchat", message: "warn msg" }],
memory: baseStatusMemory,
memoryPlugin: baseStatusMemoryPlugin,
pluginCompatibility: baseStatusPluginCompatibility,
pairingRecovery: { requestId: "req-1", reason: null, remediationHint: null },
tableWidth: 120,
...statusTestDecorators,
...statusTestFormatting,
...statusTestMemoryResolvers,
theme: statusTestTheme,
renderTable: ({ rows }: { rows: Array<Record<string, string>> }) => `table:${rows.length}`,
updateValue: "available · custom update",
...overrides,
};
}