Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/ade-cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ ade usage budget cumulative --scope global --text
ade storage snapshot --text # categorized ADE disk usage + free space (mirrors the desktop storage dashboard)
ade storage snapshot --refresh --text # force a fresh scan instead of the cached snapshot
ade storage compress --text # losslessly compress old chat/terminal history
ade --role cto storage maintenance --text # run the policy-driven ledger maintenance sweep now (CTO)
ade storage actions --text # raw storage service actions (cleanupPreview/cleanup live here)
ade actions list --domain chat --text
ade actions run git.stageFile --arg laneId=lane-id --arg path=src/index.ts
Expand Down
22 changes: 19 additions & 3 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,9 +504,15 @@ export async function createAdeRuntime(args: {
const diskPressureMonitor = createDiskPressureMonitor({
roots: [projectRoot, resolveMachineAdeLayout().adeDir],
});
// A sync-enabled runtime must prove zero connected peers before CRR
// compaction. Runtimes without sync have no peer transport and can safely
// report zero immediately.
let liveSyncPeerCount: number | null = resolvedArgs.syncRuntime?.enabled ? null : 0;
let db: AdeDb;
try {
db = await openKvDb(paths.dbPath, logger);
db = await openKvDb(paths.dbPath, logger, {
hasSyncPeers: () => liveSyncPeerCount !== 0,
});
} catch (error) {
const code = mapKvDbOpenErrorCode(classifySqliteOpenError(error));
const detail = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -1503,7 +1509,14 @@ export async function createAdeRuntime(args: {
diskPressure: diskPressureMonitor,
isPathActive: (filePath) =>
Boolean(agentChatService?.isTranscriptPathActive(filePath))
|| ptyService.isTranscriptPathActive(filePath),
|| ptyService.isTranscriptPathActive(filePath)
|| Boolean(iosSimulatorService?.isBuildPathActive(filePath)),
projectId,
// One bounded `ade_feature_used` per completed maintenance run at the daemon
// boundary (deduped to 20 h by the service).
captureAnalytics: (input) => {
productAnalyticsService.capture(input);
},
});
const budgetCapService = createBudgetCapService({
db,
Expand Down Expand Up @@ -1632,7 +1645,10 @@ export async function createAdeRuntime(args: {
getModelPickerStore: () => getSharedModelPickerStore(db),
cloudRelayStore,
syncTunnelClientService,
onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }),
onStatusChanged: (snapshot) => {
liveSyncPeerCount = snapshot.connectedPeers.length;
pushEvent("runtime", { type: "sync-status", snapshot });
},
});
syncServiceForPtyEvents = syncService;
}
Expand Down
40 changes: 40 additions & 0 deletions apps/ade-cli/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1590,6 +1590,15 @@ describe("ADE CLI", () => {
arguments: { domain: "storage", action: "compressNow", args: {} },
});

const maintenancePlan = expectExecutePlan(
buildCliPlan(["storage", "maintenance"]),
);
expect(inferFormatter(maintenancePlan)).toBe("storage-maintenance");
expect(maintenancePlan.steps[0]?.params).toEqual({
name: "run_ade_action",
arguments: { domain: "storage", action: "runMaintenanceNow", args: {} },
});

expect(
expectExecutePlan(buildCliPlan(["storage", "actions"])).steps[0]?.params,
).toEqual({ name: "list_ade_actions", arguments: { domain: "storage" } });
Expand Down Expand Up @@ -1639,6 +1648,37 @@ describe("ADE CLI", () => {
expect(snapshotText).toContain("ADE storage");
expect(snapshotText).toContain("GB free of");
expect(snapshotText).toContain("chats_history");

const maintenanceText = formatOutput(
{
startedAt: "2026-07-12T00:00:00.000Z",
finishedAt: "2026-07-12T00:00:01.000Z",
trigger: "manual",
reclaimedBytes: 3 * 1024 ** 2,
dbSizeBytes: 45 * 1024 ** 2,
actions: [
{
ledgerId: "automation_ingress_events",
kind: "prune",
itemsAffected: 120,
bytesReclaimed: 2 * 1024 ** 2,
},
{
ledgerId: "pull_request_snapshots",
kind: "vacuum",
itemsAffected: 0,
bytesReclaimed: 0,
skippedReason: "not due",
},
],
},
{ text: true } as never,
inferFormatter(maintenancePlan),
);
expect(maintenanceText).toContain("ADE storage maintenance");
expect(maintenanceText).toContain("manual");
expect(maintenanceText).toContain("automation_ingress_events");
expect(maintenanceText).toContain("skipped: not due");
});

it("formats external session action results as text", () => {
Expand Down
52 changes: 50 additions & 2 deletions apps/ade-cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ type FormatterId =
| "external-sessions"
| "storage-snapshot"
| "storage-compress"
| "storage-maintenance"
| "sync-status"
| "sync-web";

Expand Down Expand Up @@ -2087,12 +2088,14 @@ const HELP_BY_COMMAND: Record<string, string> = {
Reports what ADE is holding on disk (chats/terminal history, lane worktrees,
build output, caches, proof attachments, recovery backups, database) and the
volume's free space, mirroring the desktop Settings storage dashboard. The
snapshot is read-only; compression is lossless and safe. Target-scoped cleanup
snapshot is read-only; compression is lossless and safe. Maintenance runs the
policy-driven ledger sweep (prune/compress/vacuum). Target-scoped cleanup
(which deletes files) is intentionally left to the action bridge.

$ ade storage snapshot --text Categorized ADE disk usage + free-space summary
$ ade storage snapshot --refresh --text Force a fresh scan (skip the cached snapshot)
$ ade storage compress --text Losslessly compress old chat/terminal history
$ ade --role cto storage maintenance --text Run the policy-driven maintenance sweep now (CTO)
$ ade storage actions --text List raw storage service actions
$ ade storage action cleanupPreview --input-json '{"targets":[...]}' Preview a target-scoped cleanup
$ ade --role cto storage action cleanup --input-json '{"targets":[...],"preview":{...}}' Delete previewed targets (CTO)
Expand Down Expand Up @@ -10221,8 +10224,19 @@ function buildStoragePlan(args: string[]): CliPlan {
steps: [actionStep("result", "storage", "compressNow", {})],
};
}
if (sub === "maintenance" || sub === "maintain" || sub === "run-maintenance") {
// Runs the same policy-driven maintenance sweep as the desktop Settings
// "Run maintenance now" button (prune/compress/vacuum per the storage
// ledger). CTO-only at the action bridge, so agents must pass --role cto.
return {
kind: "execute",
label: "storage maintenance",
formatter: "storage-maintenance",
steps: [actionStep("result", "storage", "runMaintenanceNow", {})],
};
}
throw new CliUsageError(
"storage supports snapshot, compress, actions, or action <name>. Use 'ade actions run storage.cleanupPreview' / 'storage.cleanup' for target-scoped cleanup.",
"storage supports snapshot, compress, maintenance, actions, or action <name>. Use 'ade actions run storage.cleanupPreview' / 'storage.cleanup' for target-scoped cleanup.",
);
}

Expand Down Expand Up @@ -16327,6 +16341,38 @@ function formatStorageCompression(value: unknown): string {
]);
}

function formatStorageMaintenance(value: unknown): string {
if (!isRecord(value)) return JSON.stringify(value, null, 2);
const header = renderKeyValues("ADE storage maintenance", [
["trigger", value.trigger],
["reclaimed", formatBytes(value.reclaimedBytes)],
["db size", typeof value.dbSizeBytes === "number" ? formatBytes(value.dbSizeBytes) : undefined],
["started", value.startedAt],
["finished", value.finishedAt],
]);
const actions = Array.isArray(value.actions) ? value.actions.filter(isRecord) : [];
const rows = actions.map((action) => {
const note = typeof action.error === "string" && action.error
? `error: ${action.error}`
: typeof action.skippedReason === "string" && action.skippedReason
? `skipped: ${action.skippedReason}`
: "";
return [
cell(action.ledgerId, 32),
cell(action.kind, 12),
typeof action.itemsAffected === "number" ? String(action.itemsAffected) : "-",
formatBytes(action.bytesReclaimed),
note,
];
});
const table = renderTable(
["LEDGER", "KIND", "ITEMS", "RECLAIMED", "NOTE"],
rows,
"No maintenance actions were applied.",
);
return `${header}\n\n${table}`;
}

function formatLastFailureLine(report: AdeLastFailureReport): string {
const repeat = report.count > 1 ? ` x${report.count}` : "";
const scope = report.projectRoot ? ` [${report.projectRoot}]` : "";
Expand Down Expand Up @@ -17884,6 +17930,8 @@ function formatTextOutput(
return formatStorageSnapshot(value);
case "storage-compress":
return formatStorageCompression(value);
case "storage-maintenance":
return formatStorageMaintenance(value);
case "action-result":
default:
if (isRecord(value))
Expand Down
30 changes: 25 additions & 5 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2171,8 +2171,14 @@ app.whenReady().then(async () => {
}
};

// Fail closed until sync publishes its first status, then keep the DB
// compaction gate synchronized with the live connected-peer count.
let liveSyncPeerCount: number | null = null;
let syncServiceRef: ReturnType<typeof createSyncService> | null = null;
const db = await measureProjectInitStep("db_open", () =>
openKvDb(adePaths.dbPath, logger),
openKvDb(adePaths.dbPath, logger, {
hasSyncPeers: () => liveSyncPeerCount !== 0,
}),
Comment thread
arul28 marked this conversation as resolved.
);
const keybindingsService = createKeybindingsService({ db });
const agentToolsService = createAgentToolsService({ logger });
Expand Down Expand Up @@ -2906,7 +2912,6 @@ app.whenReady().then(async () => {
});
};

let syncServiceRef: ReturnType<typeof createSyncService> | null = null;
const ptyBackend = process.env.ADE_DISABLE_SUPERVISED_PTY_HOST === "1"
? null
: createSupervisedPtyLoader({ logger });
Expand Down Expand Up @@ -3586,7 +3591,12 @@ app.whenReady().then(async () => {
diskPressure: diskPressureMonitor,
isPathActive: (filePath) =>
agentChatService.isTranscriptPathActive(filePath)
|| ptyService.isTranscriptPathActive(filePath),
|| ptyService.isTranscriptPathActive(filePath)
|| iosSimulatorService.isBuildPathActive(filePath),
projectId,
captureAnalytics: (input) => {
productAnalyticsService.capture(input);
},
});

// Phone sync is owned by the per-machine ADE service. The desktop
Expand Down Expand Up @@ -3651,6 +3661,7 @@ app.whenReady().then(async () => {
projectScaffoldService.listMyGitHubRepos(input),
},
onStatusChanged: (snapshot) => {
liveSyncPeerCount = snapshot.connectedPeers.length;
Comment thread
arul28 marked this conversation as resolved.
Outdated
const normalizedProjectRoot = normalizeProjectRoot(projectRoot);
if (mobileSyncSelectedRoot == null && snapshot.connectedPeers.length > 0) {
mobileSyncSelectedRoot = normalizedProjectRoot;
Expand Down Expand Up @@ -4314,7 +4325,11 @@ app.whenReady().then(async () => {
const logger = createFileLogger(path.join(adePaths.logsDir, "main.jsonl"));
const project = toProjectInfo(projectRoot, baseRef);
const runtimeProject = await localRuntimePool.ensureProject(projectRoot);
const db = await openKvDb(adePaths.dbPath, logger);
const db = await openKvDb(adePaths.dbPath, logger, {
// The machine runtime owns sync and the real storage doctor in this mode;
// this dormant fallback has no authoritative local peer signal.
hasSyncPeers: () => true,
});
const shellContext = createDormantProjectContext(projectRoot, { enableUsageTracking: false });
const storageInsightsService = createStorageInsightsService({
projectRoot,
Expand All @@ -4323,8 +4338,13 @@ app.whenReady().then(async () => {
logger,
// Daemon-backed mode: the brain owns activity tracking and runs the real compression sweep
// (see apps/ade-cli/src/bootstrap.ts); this fallback instance deliberately refuses to compress
// because activity cannot be known here.
// because activity cannot be known here. It also never arms the maintenance timers (no
// diskPressure supplied), so it only runs maintenance if runMaintenanceNow is called directly.
isPathActive: () => true,
projectId: runtimeProject.projectId,
captureAnalytics: (input) => {
productAnalyticsService.capture(input);
},
});
const diskPressureMonitor = createDiskPressureMonitor({
roots: [projectRoot, machineAdeLayout.adeDir],
Expand Down
5 changes: 3 additions & 2 deletions apps/desktop/src/main/services/adeActions/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export const ADE_ACTION_CTO_ONLY: Partial<Record<AdeActionDomain, readonly strin
feedback: ["submitPreparedDraft"],
usage: ["forceRefresh", "refreshHistory", "poll", "start", "stop"],
analytics: ["setEnabled", "flush"],
storage: ["cleanup"],
storage: ["cleanup", "runMaintenanceNow"],
search: ["rebuildIndex"],
project_secret: ["exportEnv"],
};
Expand Down Expand Up @@ -659,7 +659,7 @@ export const ADE_ACTION_ALLOWLIST: Partial<Record<AdeActionDomain, readonly stri
"stop",
],
analytics: ["capture", "getStatus", "setEnabled", "flush"],
storage: ["cleanup", "cleanupPreview", "compressNow", "getSnapshot"],
storage: ["cleanup", "cleanupPreview", "compressNow", "getSnapshot", "runMaintenanceNow"],
budget: ["checkBudget", "getConfig", "getCumulativeUsage", "recordUsage", "updateConfig"],
update: ["checkForUpdates", "dismissInstalledNotice", "getSnapshot", "quitAndInstall"],
file: [
Expand Down Expand Up @@ -3203,6 +3203,7 @@ function buildStorageDomainService(runtime: AdeRuntime): OpaqueService | null {
return {
getSnapshot: (args?: { forceRefresh?: boolean }) => storageInsightsService.getSnapshot(args),
compressNow: () => storageInsightsService.compressNow(),
runMaintenanceNow: () => storageInsightsService.runMaintenanceNow(),
cleanupPreview: (args?: { targets?: Parameters<typeof storageInsightsService.cleanupPreview>[0] }) =>
storageInsightsService.cleanupPreview(args?.targets ?? []),
cleanup: (args?: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,15 @@ const NUMBER_PROPERTIES = new Set([
"terminal_session_count", "active_lane_count", "lanes_created", "lanes_archived", "commits_created",
"push_operations", "pr_landings", "files_changed", "artifacts_captured", "automation_runs", "worker_runs",
"active_days", "current_streak_days", "token_count", "input_token_count", "output_token_count", "call_count",
"duration_ms", "provider_count", "model_count", "error_count",
"duration_ms", "provider_count", "model_count", "error_count", "bytes_freed", "files_compressed",
]);
const BOOLEAN_PROPERTIES = new Set(["recoverable", "paired", "cached_data", "is_packaged"]);

// Actions emitted only by daemon services (not user-mutation ledger rows) that
// are still meaningful product facts. Kept here rather than in the usage-stats
// MEANINGFUL_ACTIONS set because they never correspond to a persisted mutation.
const ANALYTICS_ONLY_ACTIONS = new Set(["maintenance_run"]);

const EVENT_PROPERTY_KEYS: Record<ProductAnalyticsEventName, ReadonlySet<string>> = {
ade_app_opened: new Set([
"entry_point", "source", "release_channel", "mode", "connection_state", "paired", "cached_data", "is_packaged",
Expand All @@ -62,6 +67,7 @@ const EVENT_PROPERTY_KEYS: Record<ProductAnalyticsEventName, ReadonlySet<string>
ade_project_opened: new Set(["route_kind", "source", "mode", "connection_state"]),
ade_feature_used: new Set([
"feature", "action", "outcome", "source", "mode", "provider", "model_family", "duration_bucket", "connection_state",
"bytes_freed", "files_compressed",
]),
ade_work_session_started: new Set(["feature", "action", "outcome", "source", "mode", "provider"]),
ade_work_session_completed: new Set([
Expand Down Expand Up @@ -89,10 +95,11 @@ const SAFE_STRING_VALUES: Partial<Record<string, ReadonlySet<string>>> = {
]),
feature: new Set([
"chat", "cli", "work", "lanes", "files", "git", "processes", "orchestration", "prs",
"automations", "command_palette",
"automations", "command_palette", "storage_doctor",
]),
outcome: new Set([
"success", "started", "completed", "failure", "timeout", "opened", "cancelled", "approved", "denied",
"partial", "failed",
]),
provider: new Set(["codex", "openai", "claude", "cursor", "droid", "opencode", "gemini", "local", "other"]),
model_family: new Set([
Expand Down Expand Up @@ -141,7 +148,7 @@ function safeStringProperty(key: string, value: ProductAnalyticsPropertyValue):
if (key === "action") {
if (typeof value !== "string" || value.length > 256) return null;
const raw = value.trim();
return raw === "open" || isMeaningfulUsageAction(raw) ? raw : null;
return raw === "open" || isMeaningfulUsageAction(raw) || ANALYTICS_ONLY_ACTIONS.has(raw) ? raw : null;
}
if (key === "error_kind") return coarseErrorKind(value);
const safe = safeProductAnalyticsString(value);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,36 @@ describe("productAnalyticsService", () => {
fs.rmSync(harness.root, { recursive: true, force: true });
});

it("accepts the storage-doctor maintenance event with numeric aggregates and coarse outcome", () => {
const harness = makeHarness();
const result = harness.service.capture({
event: "ade_feature_used",
surface: "desktop",
properties: {
feature: "storage_doctor",
action: "maintenance_run",
outcome: "partial",
bytes_freed: 481_000_000,
files_compressed: 62,
secret_path: "/Users/alice/secret-project/.ade",
},
});

expect(result).toEqual({ accepted: true, reason: "accepted" });
const message = harness.messages[0] as { properties: Record<string, unknown> };
expect(message.properties).toMatchObject({
feature: "storage_doctor",
action: "maintenance_run",
outcome: "partial",
bytes_freed: 481_000_000,
files_compressed: 62,
});
// Non-allowlisted keys never cross the sanitizer.
expect(message.properties).not.toHaveProperty("secret_path");
expect(JSON.stringify(message)).not.toContain("secret-project");
fs.rmSync(harness.root, { recursive: true, force: true });
});

it("does not forward arbitrary build-controlled version text", () => {
const harness = makeHarness({ appVersion: "../../private/project\nsecret" });
expect(harness.service.capture({
Expand Down
Loading