Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
23 changes: 20 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 its durable pairing registry is empty
// before CRR compaction. Offline paired devices still require dedupe history.
// Runtimes without sync have no peer transport and can report zero now.
let registeredSyncPeerCount: number | null = resolvedArgs.syncRuntime?.enabled ? null : 0;
let db: AdeDb;
try {
db = await openKvDb(paths.dbPath, logger);
db = await openKvDb(paths.dbPath, logger, {
hasSyncPeers: () => registeredSyncPeerCount !== 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,8 +1645,12 @@ export async function createAdeRuntime(args: {
getModelPickerStore: () => getSharedModelPickerStore(db),
cloudRelayStore,
syncTunnelClientService,
onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }),
onStatusChanged: (snapshot) => {
registeredSyncPeerCount = syncService?.getRegisteredPeerCount() ?? null;
pushEvent("runtime", { type: "sync-status", snapshot });
},
});
registeredSyncPeerCount = syncService.getRegisteredPeerCount();
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
24 changes: 24 additions & 0 deletions apps/ade-cli/src/services/sync/syncPairingStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,30 @@ describe("sync SSH pairing trust", () => {
expect(fs.readFileSync(filePath, "utf8")).not.toContain(paired.secret);
});

it("counts durable pairings independently of live connections", () => {
const { store } = createStore();
const peer = {
deviceId: "offline-phone-1",
deviceName: "Offline iPhone",
platform: "iOS",
deviceType: "phone",
siteId: "offline-ios-site-1",
dbVersion: 0,
} satisfies SyncPeerMetadata;

expect(store.countPairingRecords()).toBe(0);
store.pairPeerViaLocalTrust(peer);
expect(store.countPairingRecords()).toBe(1);
store.revoke(peer.deviceId);
expect(store.countPairingRecords()).toBe(0);
});

it("does not treat an unreadable pairing registry as empty", () => {
const { filePath, store } = createStore();
fs.writeFileSync(filePath, "not json");
expect(store.countPairingRecords()).toBeNull();
});

it("grants desktop runtime access only after the SSH trust gate", () => {
const { store } = createStore();
const peer = {
Expand Down
12 changes: 12 additions & 0 deletions apps/ade-cli/src/services/sync/syncPairingStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,18 @@ export function createSyncPairingStore(args: SyncPairingStoreArgs) {
return readRecords()[normalized] != null;
},

countPairingRecords(): number | null {
if (!fs.existsSync(args.filePath)) return 0;
try {
const parsed: unknown = JSON.parse(fs.readFileSync(args.filePath, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
return Object.keys(parsed).length;
} catch {
// A damaged registry is not evidence that there are no paired peers.
return null;
}
},

revoke(deviceId: string): void {
const normalized = deviceId.trim();
if (!normalized) return;
Expand Down
4 changes: 4 additions & 0 deletions apps/ade-cli/src/services/sync/syncService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1734,6 +1734,10 @@ export function createSyncService(args: SyncServiceArgs) {
return deviceRegistryService;
},

getRegisteredPeerCount(): number | null {
return pairingStore.countPairingRecords();
},

async dispose(): Promise<void> {
disposed = true;
syncPeerService.disconnect();
Expand Down
32 changes: 27 additions & 5 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2171,8 +2171,15 @@ app.whenReady().then(async () => {
}
};

// Fail closed until the durable pairing registry is available. Offline
// paired devices still need their CRR history, so live connections alone
// are never sufficient evidence that compaction is safe.
let registeredSyncPeerCount: 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: () => registeredSyncPeerCount !== 0,
}),
Comment thread
arul28 marked this conversation as resolved.
);
const keybindingsService = createKeybindingsService({ db });
const agentToolsService = createAgentToolsService({ logger });
Expand Down Expand Up @@ -2906,7 +2913,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 +3592,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 +3662,7 @@ app.whenReady().then(async () => {
projectScaffoldService.listMyGitHubRepos(input),
},
onStatusChanged: (snapshot) => {
registeredSyncPeerCount = syncServiceRef?.getRegisteredPeerCount() ?? null;
const normalizedProjectRoot = normalizeProjectRoot(projectRoot);
if (mobileSyncSelectedRoot == null && snapshot.connectedPeers.length > 0) {
mobileSyncSelectedRoot = normalizedProjectRoot;
Expand Down Expand Up @@ -3688,6 +3700,7 @@ app.whenReady().then(async () => {
},
});
syncServiceRef = syncService;
registeredSyncPeerCount = syncService.getRegisteredPeerCount();
scheduleBackgroundProjectTask(
"sync.initialize",
() => measureProjectInitStep("sync.initialize", () => syncService.initialize()),
Expand Down Expand Up @@ -4314,7 +4327,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 +4340,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
Loading