Code

/**
 * mcp/skills/checkCloudSyncStatus.ts — check_cloud_sync_status skill
 *
 * Probes the on-disk state of cloud sync clients (OneDrive, iCloud Drive,
 * Google Drive, Dropbox) and reports per-tick a `stale` boolean +
 * supporting context that the proactive Trigger 4 (`cloud-sync-stale`)
 * evaluates on a steady schedule.
 *
 * Telemetry contract (Track B Phase 4)
 * ------------------------------------
 * `meta.outputKeys` includes: platform, client, lastSyncMs, queueDepth,
 * status, stale.  These are at the **top level** of the result so the
 * proactive evaluator's restricted DSL can reference them.  The DSL
 * never sees the per-client breakdown in `clients[]`.
 *
 * Default mode (`client: "auto"`) picks the most-stale installed
 * client, biased toward producing a `stale: true` observation when any
 * sync is overdue — that's what the rule wants to detect.  Caller can
 * pass `client: "onedrive"` etc. to inspect a specific client.
 *
 * Read-only — sync state mutation lives in `pause_resume_cloud_sync`.
 */
 
import * as os   from "os";
import * as path from "path";
import { promises as fs } from "fs";
import { z }     from "zod";
 
// -- Meta ---------------------------------------------------------------------
 
export const meta = {
  name: "check_cloud_sync_status",
  description:
    "Reports the current sync state of installed cloud sync clients " +
    "(OneDrive, iCloud Drive, Google Drive, Dropbox) — per-client install " +
    "state, last-sync timestamp, queue depth where surfaced, and a top-level " +
    "`stale` flag set when any client's lastSync is older than the threshold " +
    "(default 24h). Used both by the user-facing Cloud Sync repair skill and " +
    "as the telemetry source for the proactive cloud-sync-stale trigger.",
  riskLevel:       "low",
  destructive:     false,
  requiresConsent: false,
  supportsDryRun:  false,
  affectedScope:   ["user"],
  auditRequired:   false,
  // See docs/proactivesupport/PROACTIVE-ARCHITECTURE.md §6.
  outputKeys: [
    "platform",
    "client",
    "lastSyncMs",
    "queueDepth",
    "status",
    "stale",
  ],
  schema: {
    client: z
      .enum(["onedrive", "icloud", "google-drive", "dropbox", "auto"])
      .optional()
      .describe(
        "Which client's status to report at the top level. Default 'auto' " +
        "picks the most-stale installed client (the one that should fire " +
        "a proactive trigger if any do). Pass an explicit client to inspect " +
        "one specific sync.",
      ),
    staleThresholdHours: z
      .number()
      .int()
      .min(1)
      .max(168)
      .optional()
      .describe("Hours since lastSync that count as stale. Default 24."),
  },
} as const;
 
// -- Types --------------------------------------------------------------------
 
export type SyncClient = "onedrive" | "icloud" | "google-drive" | "dropbox";
 
export type SyncStatus =
  | "syncing"        // active sync in progress
  | "idle"           // fresh; nothing to do
  | "stale"          // hasn't synced in > threshold
  | "error"          // surfaced error from the client
  | "not-installed"
  | "unknown";
 
export interface SyncClientInfo {
  client:        SyncClient;
  installed:     boolean;
  /** Detected installation path (app bundle on darwin, directory on win32). */
  installPath:   string | null;
  /** ms since epoch — null when no probe path was readable. */
  lastSyncMs:    number | null;
  /** Number of items pending sync — null when not surfaced. */
  queueDepth:    number | null;
  status:        SyncStatus;
  stale:         boolean;
}
 
export interface CheckCloudSyncStatusResult {
  platform:    NodeJS.Platform;
  /** Top-level summary fields — duplicated from the chosen client (auto = most stale). */
  client:      SyncClient | null;
  lastSyncMs:  number | null;
  queueDepth:  number | null;
  status:      SyncStatus;
  stale:       boolean;
  /** Per-client breakdown for the skill's manual flow. */
  clients:     SyncClientInfo[];
}
 
const ALL_CLIENTS: SyncClient[] = ["onedrive", "icloud", "google-drive", "dropbox"];
const DEFAULT_STALE_HOURS = 24;
 
// -- Per-client probe paths ---------------------------------------------------
 
interface ProbePath {
  installPaths:    string[];   // existence of any one means "installed"
  syncProbePaths:  string[];   // mtime of newest existing path → lastSyncMs
}
 
function probePathsFor(client: SyncClient, platform: NodeJS.Platform): ProbePath {
  const home    = os.homedir();
  const appData = process.env.APPDATA      ?? path.join(home, "AppData", "Roaming");
  const local   = process.env.LOCALAPPDATA ?? path.join(home, "AppData", "Local");
 
  switch (client) {
    case "onedrive":
      if (platform === "darwin") {
        return {
          installPaths:   ["/Applications/OneDrive.app"],
          syncProbePaths: [
            path.join(home, "Library", "Logs", "OneDrive"),
            path.join(home, "Library", "Application Support", "OneDrive"),
            path.join(home, "Library", "Containers", "com.microsoft.OneDrive-mac"),
          ],
        };
      }
      return {
        installPaths:   [
          path.join(local, "Microsoft", "OneDrive"),
          "C:\\Program Files\\Microsoft OneDrive",
          "C:\\Program Files (x86)\\Microsoft OneDrive",
        ],
        syncProbePaths: [
          path.join(local, "Microsoft", "OneDrive", "logs"),
          path.join(local, "Microsoft", "OneDrive", "settings"),
        ],
      };
 
    case "icloud":
      if (platform === "darwin") {
        return {
          installPaths:   [
            path.join(home, "Library", "Application Support", "CloudDocs"),
            path.join(home, "Library", "Mobile Documents"),
          ],
          syncProbePaths: [
            path.join(home, "Library", "Application Support", "CloudDocs", "session", "db"),
            path.join(home, "Library", "Mobile Documents"),
          ],
        };
      }
      // iCloud Drive on Windows is rare; the desktop client stores
      // state under %USERPROFILE%\iCloudDrive but the sync engine is
      // not deeply observable from outside the app.
      return {
        installPaths:   [
          path.join(local, "Apple Inc", "iCloud"),
          path.join(home, "iCloudDrive"),
        ],
        syncProbePaths: [
          path.join(home, "iCloudDrive"),
        ],
      };
 
    case "google-drive":
      if (platform === "darwin") {
        return {
          installPaths:   ["/Applications/Google Drive.app"],
          syncProbePaths: [
            path.join(home, "Library", "Application Support", "Google", "DriveFS", "Logs"),
            path.join(home, "Library", "Application Support", "Google", "DriveFS"),
          ],
        };
      }
      return {
        installPaths:   [
          path.join(local, "Google", "DriveFS"),
          "C:\\Program Files\\Google\\Drive File Stream",
        ],
        syncProbePaths: [
          path.join(local, "Google", "DriveFS", "Logs"),
          path.join(local, "Google", "DriveFS"),
        ],
      };
 
    case "dropbox":
      if (platform === "darwin") {
        return {
          installPaths:   [
            "/Applications/Dropbox.app",
            path.join(home, "Library", "Application Support", "Dropbox"),
          ],
          syncProbePaths: [
            path.join(home, "Library", "Application Support", "Dropbox", "instance1", "logs"),
            path.join(home, "Library", "Application Support", "Dropbox", "info.json"),
          ],
        };
      }
      return {
        installPaths:   [
          path.join(appData, "Dropbox"),
          path.join(local, "Dropbox"),
        ],
        syncProbePaths: [
          path.join(appData, "Dropbox", "info.json"),
          path.join(appData, "Dropbox", "instance1", "logs"),
        ],
      };
  }
}
 
// -- Filesystem helpers -------------------------------------------------------
 
async function pathExists(p: string): Promise<boolean> {
  try {
    await fs.access(p);
    return true;
  } catch {
    return false;
  }
}
 
async function newestMtimeMs(paths: string[]): Promise<number | null> {
  let newest: number | null = null;
  for (const p of paths) {
    try {
      const s = await fs.stat(p);
      if (newest === null || s.mtimeMs > newest) newest = s.mtimeMs;
    } catch {
      // Path missing — skip.
    }
  }
  return newest;
}
 
async function firstExistingPath(candidates: string[]): Promise<string | null> {
  for (const c of candidates) {
    if (await pathExists(c)) return c;
  }
  return null;
}
 
// -- Per-client status computation --------------------------------------------
 
function computeStatus(lastSyncMs: number | null, staleThresholdMs: number): { status: SyncStatus; stale: boolean } {
  if (lastSyncMs === null) return { status: "unknown", stale: false };
  const ageMs = Date.now() - lastSyncMs;
  if (ageMs >= staleThresholdMs) return { status: "stale", stale: true };
  // We don't have a clean "syncing-now" signal across all clients, so we
  // err on the side of "idle" when fresh.  A future revision could probe
  // an in-progress lock file per client.
  return { status: "idle", stale: false };
}
 
async function probeOne(
  client:           SyncClient,
  platform:         NodeJS.Platform,
  staleThresholdMs: number,
): Promise<SyncClientInfo> {
  const probe       = probePathsFor(client, platform);
  const installPath = await firstExistingPath(probe.installPaths);
 
  if (!installPath) {
    return {
      client,
      installed:    false,
      installPath:  null,
      lastSyncMs:   null,
      queueDepth:   null,
      status:       "not-installed",
      stale:        false,
    };
  }
 
  const lastSyncMs = await newestMtimeMs(probe.syncProbePaths);
  const { status, stale } = computeStatus(lastSyncMs, staleThresholdMs);
 
  return {
    client,
    installed:    true,
    installPath,
    lastSyncMs,
    queueDepth:   null,   // Per-client queue introspection is out of scope
                          // for the alpha — added later as each client's
                          // CLI / lock file is reverse-engineered.
    status,
    stale,
  };
}
 
// -- Top-level summary picker -------------------------------------------------
 
function pickTopLevel(
  clients: SyncClientInfo[],
  prefer:  SyncClient | "auto",
): SyncClientInfo | null {
  if (prefer !== "auto") return clients.find((c) => c.client === prefer) ?? null;
 
  // Prefer stale > installed > anything.  Among stale, the oldest sync
  // wins — that's the one most worth surfacing to the user.
  const installed = clients.filter((c) => c.installed);
  if (installed.length === 0) return null;
 
  const stale = installed.filter((c) => c.stale);
  if (stale.length > 0) {
    return stale.reduce((oldest, c) =>
      (c.lastSyncMs ?? Infinity) < (oldest.lastSyncMs ?? Infinity) ? c : oldest);
  }
 
  // No stale → return the newest sync (least likely to need attention,
  // but also the one the user is most likely to recognise as a working
  // baseline).
  return installed.reduce((newest, c) =>
    (c.lastSyncMs ?? 0) > (newest.lastSyncMs ?? 0) ? c : newest);
}
 
// -- Exported run function ----------------------------------------------------
 
export async function run({
  client = "auto",
  staleThresholdHours = DEFAULT_STALE_HOURS,
}: {
  client?:              "onedrive" | "icloud" | "google-drive" | "dropbox" | "auto";
  staleThresholdHours?: number;
} = {}): Promise<CheckCloudSyncStatusResult> {
  const platform = os.platform();
  if (platform !== "darwin" && platform !== "win32") {
    throw new Error(`check_cloud_sync_status: unsupported platform "${platform}"`);
  }
 
  const staleThresholdMs = staleThresholdHours * 60 * 60 * 1000;
  const clients = await Promise.all(ALL_CLIENTS.map((c) => probeOne(c, platform, staleThresholdMs)));
  const top     = pickTopLevel(clients, client);
 
  return {
    platform,
    client:      top?.client     ?? null,
    lastSyncMs:  top?.lastSyncMs ?? null,
    queueDepth:  top?.queueDepth ?? null,
    status:      top?.status     ?? "not-installed",
    stale:       top?.stale      ?? false,
    clients,
  };
}
 
// -- Test helpers -------------------------------------------------------------
 
/** Exported for unit tests only — do not use from production code. */
export const __testing = {
  ALL_CLIENTS,
  probePathsFor,
  pathExists,
  newestMtimeMs,
  firstExistingPath,
  computeStatus,
  probeOne,
  pickTopLevel,
};