mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
37b4fc8621
* 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
291 lines
8.7 KiB
TypeScript
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,
|
|
};
|
|
}
|