Code

/**
 * mcp/skills/checkTimemachineStatus.ts — check_timemachine_status skill
 *
 * macOS-only.  Reports Time Machine backup state via:
 *   - `tmutil latestbackup`     → path of the most recent backup snapshot
 *   - `tmutil status`           → current state (Running / Idle / FailedBackup)
 *   - `tmutil destinationinfo`  → configured destination(s)
 *
 * Telemetry contract (Track B Phase 4)
 * ------------------------------------
 * `meta.outputKeys` includes: platform, lastBackupMs, destination, status,
 * stale.  These are at the top level of the result so the proactive
 * evaluator's restricted DSL can reference them.  Trigger 5 fires when
 * `stale == true` — i.e. the most recent backup is older than the
 * configured threshold (default 72 h).
 *
 * Read-only.
 *
 * Windows behaviour
 * -----------------
 * Time Machine is a macOS-only feature.  On win32 the tool returns:
 *   { platform: "win32", status: "not-supported", stale: false, ... }
 * Trigger 5 in Wave 2 declares no Windows variant; this is intentional —
 * Windows backup landscape is fragmented (File History, Windows Backup,
 * Microsoft Backup app, third-party tools).  A future tool can probe
 * those individually.
 */
 
import * as os from "os";
import { z }   from "zod";
 
import {
  execAsync,
  isDarwin,
  isWin32,
}                from "./_shared/platform";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "check_timemachine_status",
  description:
    "Reports the current Time Machine backup state on macOS — last-backup " +
    "timestamp, configured destination, current status, and a `stale` flag " +
    "set when the most recent backup is older than the configured " +
    "threshold (default 72h). Used both by the user-facing Cloud Sync & " +
    "Backup repair skill and as the telemetry source for the proactive " +
    "timemachine-stale trigger. Returns status: 'not-supported' on Windows.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  // See docs/proactivesupport/PROACTIVE-ARCHITECTURE.md §6.
  outputKeys: [
    "platform",
    "lastBackupMs",
    "destination",
    "status",
    "stale",
  ],
  schema: {
    staleThresholdHours: z
      .number()
      .int()
      .min(1)
      .max(720)
      .optional()
      .describe("Hours since last successful backup that count as stale. Default 72 (3 days)."),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
export type TimemachineStatus =
  | "running"
  | "idle"
  | "failed"
  | "no-destination"
  | "stale"
  | "not-supported"
  | "unknown";
 
export interface CheckTimemachineStatusResult {
  platform:      NodeJS.Platform;
  lastBackupMs:  number | null;
  /** Most recent backup snapshot path. */
  lastBackupPath: string | null;
  /** Configured destination volume name (or path).  null when none configured. */
  destination:   string | null;
  status:        TimemachineStatus;
  stale:         boolean;
  /** Diagnostic message for the user — short. */
  message:       string;
}
 
const DEFAULT_STALE_HOURS = 72;
 
// -- darwin parsing helpers ---------------------------------------------------
 
/**
 * `tmutil latestbackup` outputs lines like:
 *   /Volumes/Backup/Backups.backupdb/Mac/2026-04-23-101500
 * The trailing component is the snapshot folder named YYYY-MM-DD-HHMMSS.
 */
function parseLatestBackupPath(stdout: string): { path: string | null; mtimeMs: number | null } {
  const trimmed = stdout.trim();
  if (!trimmed) return { path: null, mtimeMs: null };
 
  // Take the last line in case multiple are returned.
  const line   = trimmed.split("\n").pop()!;
  const match  = line.match(/(\d{4}-\d{2}-\d{2}-\d{6})/);
  if (!match) return { path: line, mtimeMs: null };
 
  // YYYY-MM-DD-HHMMSS in local time.  The user-visible mtime of the
  // snapshot directory is more accurate than parsing the name (snapshots
  // can be renamed by the user), but parsing the name avoids needing a
  // separate `stat` call when tmutil is responsive.
  const ts = match[1];
  const yyyy = parseInt(ts.slice(0, 4),  10);
  const mm   = parseInt(ts.slice(5, 7),  10);
  const dd   = parseInt(ts.slice(8, 10), 10);
  const hh   = parseInt(ts.slice(11,13), 10);
  const mi   = parseInt(ts.slice(13,15), 10);
  const ss   = parseInt(ts.slice(15,17), 10);
  const date = new Date(yyyy, mm - 1, dd, hh, mi, ss);
  const mtimeMs = date.getTime();
  return { path: line, mtimeMs: Number.isNaN(mtimeMs) ? null : mtimeMs };
}
 
/**
 * `tmutil status` output is plist-formatted, with key fields:
 *   Running          1 | 0
 *   ClientID         "com.apple.backupd"
 *   BackupPhase      "Copying" | "FindingChanges" | "ThinningPostBackup" | …
 *   Percent          0.0 - 1.0
 *   Stopping         0
 * We only need the Running flag and the BackupPhase for a coarse
 * classification.
 */
function parseTmutilStatus(stdout: string): { running: boolean; phase: string | null; failed: boolean } {
  const lines      = stdout.split("\n").map((l) => l.trim());
  const runningRe  = /Running\s*=\s*(\d)/;
  const phaseRe    = /BackupPhase\s*=\s*"?([^"]+?)"?\s*;?$/;
  const failedHint = /(failedBackup|FailedSetup|InvalidDest|Error)/i;
 
  let running = false;
  let phase: string | null = null;
  let failed = false;
 
  for (const line of lines) {
    const r = line.match(runningRe);
    if (r) running = r[1] === "1";
    const p = line.match(phaseRe);
    if (p) phase = p[1];
    if (failedHint.test(line)) failed = true;
  }
  return { running, phase, failed };
}
 
/**
 * `tmutil destinationinfo` lists configured destinations.  We only need
 * the first one's "Name" field (or "URL" / "Mount Point" as a fallback).
 */
function parseDestinationInfo(stdout: string): string | null {
  const lines = stdout.split("\n").map((l) => l.trim());
  for (const line of lines) {
    const nameMatch = line.match(/^Name\s*:\s*(.+)$/);
    if (nameMatch) return nameMatch[1].trim();
  }
  for (const line of lines) {
    const mountMatch = line.match(/^Mount Point\s*:\s*(.+)$/);
    if (mountMatch) return mountMatch[1].trim();
  }
  return null;
}
 
// -- Status computation -------------------------------------------------------
 
interface StatusInputs {
  running:      boolean;
  phase:        string | null;
  failed:       boolean;
  destination:  string | null;
  lastBackupMs: number | null;
  staleMs:      number;
}
 
function computeStatus(inputs: StatusInputs): { status: TimemachineStatus; stale: boolean; message: string } {
  if (!inputs.destination) {
    return { status: "no-destination", stale: false, message: "No Time Machine destination is configured." };
  }
  if (inputs.failed) {
    return { status: "failed", stale: true, message: "Time Machine reported a failed backup." };
  }
  if (inputs.running) {
    return {
      status: "running",
      stale:  false,
      message: inputs.phase ? `Backup running — phase: ${inputs.phase}` : "Backup currently running.",
    };
  }
  if (inputs.lastBackupMs === null) {
    return {
      status: "unknown",
      stale:  false,
      message: "Unable to determine last backup time. tmutil returned no usable output.",
    };
  }
  const ageMs = Date.now() - inputs.lastBackupMs;
  if (ageMs >= inputs.staleMs) {
    const ageHours = Math.round(ageMs / (60 * 60 * 1000));
    return {
      status: "stale",
      stale:  true,
      message: `Last backup is ${ageHours}h old, beyond the ${Math.round(inputs.staleMs / (60 * 60 * 1000))}h threshold.`,
    };
  }
  return { status: "idle", stale: false, message: "Time Machine is up to date." };
}
 
// -- darwin implementation ----------------------------------------------------
 
async function checkTimemachineDarwin(staleMs: number): Promise<CheckTimemachineStatusResult> {
  // Run all three tmutil probes in parallel.  Each is independently
  // non-fatal — if one fails we degrade the corresponding field rather
  // than aborting.
  const [latestRes, statusRes, destRes] = await Promise.allSettled([
    execAsync("tmutil latestbackup 2>/dev/null", { timeout: 10_000 }),
    execAsync("tmutil status 2>/dev/null",       { timeout: 10_000 }),
    execAsync("tmutil destinationinfo 2>/dev/null", { timeout: 10_000 }),
  ]);
 
  const latestStdout = latestRes.status === "fulfilled" ? latestRes.value.stdout : "";
  const statusStdout = statusRes.status === "fulfilled" ? statusRes.value.stdout : "";
  const destStdout   = destRes.status   === "fulfilled" ? destRes.value.stdout   : "";
 
  const { path: lastBackupPath, mtimeMs: lastBackupMs } = parseLatestBackupPath(latestStdout);
  const { running, phase, failed } = parseTmutilStatus(statusStdout);
  const destination = parseDestinationInfo(destStdout);
 
  const { status, stale, message } = computeStatus({
    running, phase, failed, destination, lastBackupMs, staleMs,
  });
 
  return {
    platform:       "darwin",
    lastBackupMs,
    lastBackupPath,
    destination,
    status,
    stale,
    message,
  };
}
 
// -- win32 implementation -----------------------------------------------------
 
function checkTimemachineWin32(): CheckTimemachineStatusResult {
  return {
    platform:       "win32",
    lastBackupMs:   null,
    lastBackupPath: null,
    destination:    null,
    status:         "not-supported",
    stale:          false,
    message:        "Time Machine is a macOS-only feature. Use Windows Backup or File History on this platform.",
  };
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  staleThresholdHours = DEFAULT_STALE_HOURS,
}: {
  staleThresholdHours?: number;
} = {}): Promise<CheckTimemachineStatusResult> {
  const platform = os.platform();
  if (isDarwin()) {
    const staleMs = staleThresholdHours * 60 * 60 * 1000;
    return checkTimemachineDarwin(staleMs);
  }
  if (isWin32()) {
    return checkTimemachineWin32();
  }
  throw new Error(`check_timemachine_status: unsupported platform "${platform}"`);
}
 
// -- Test helpers -------------------------------------------------------------
 
/** Exported for unit tests only — do not use from production code. */
export const __testing = {
  parseLatestBackupPath,
  parseTmutilStatus,
  parseDestinationInfo,
  computeStatus,
  checkTimemachineDarwin,
  checkTimemachineWin32,
};