Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
27 changes: 22 additions & 5 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import * as nodePty from "node-pty";
import { isSourceCheckoutRuntimeModule } from "./runtimePackaging";
import { createFileLogger, type Logger } from "../../desktop/src/main/services/logging/logger";
import { classifySqliteOpenError, openKvDb, type AdeDb } from "../../desktop/src/main/services/state/kvDb";
import { createRegisteredSyncPeerGate } from "../../desktop/src/main/services/state/syncPeerCompactionGate";
import {
clearLastFailure,
recordLastFailure,
Expand Down Expand Up @@ -504,9 +505,16 @@ export async function createAdeRuntime(args: {
const diskPressureMonitor = createDiskPressureMonitor({
roots: [projectRoot, resolveMachineAdeLayout().adeDir],
});
let syncService: ReturnType<typeof createSyncService> | null = null;
const hasSyncPeers = createRegisteredSyncPeerGate({
syncEnabled: resolvedArgs.syncRuntime?.enabled === true,
getSyncService: () => syncService,
});
let db: AdeDb;
try {
db = await openKvDb(paths.dbPath, logger);
db = await openKvDb(paths.dbPath, logger, {
hasSyncPeers,
});
} catch (error) {
const code = mapKvDbOpenErrorCode(classifySqliteOpenError(error));
const detail = error instanceof Error ? error.message : String(error);
Expand Down Expand Up @@ -1503,7 +1511,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 @@ -1573,7 +1588,6 @@ export async function createAdeRuntime(args: {
}

let externalSessionsService: ReturnType<typeof createExternalSessionsService> | null = null;
let syncService: ReturnType<typeof createSyncService> | null = null;
if (resolvedArgs.syncRuntime?.enabled && agentChatService) {
const { createSyncService } = await import("./services/sync/syncService");
syncService = createSyncService({
Expand Down Expand Up @@ -1632,15 +1646,18 @@ export async function createAdeRuntime(args: {
getModelPickerStore: () => getSharedModelPickerStore(db),
cloudRelayStore,
syncTunnelClientService,
onStatusChanged: (snapshot) => pushEvent("runtime", { type: "sync-status", snapshot }),
onStatusChanged: (snapshot) => {
pushEvent("runtime", { type: "sync-status", snapshot });
},
});
syncServiceForPtyEvents = syncService;
}

if (syncService) {
const currentSyncService = syncService;
const initializeSyncService = async () => {
try {
await syncService.initialize();
await currentSyncService.initialize();
} catch (error) {
logger.warn("sync.runtime_initialize_failed", {
error: error instanceof Error ? error.message : String(error),
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
31 changes: 26 additions & 5 deletions apps/desktop/src/main/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, Menu, nativeImage, Notification, protocol, safeStorage, shell } from "electron";

Check warning on line 1 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'shell' is defined but never used. Allowed unused vars must match /^_/u

if (app.isPackaged && process.env.ADE_RUNTIME_PACKAGED === undefined) {
process.env.ADE_RUNTIME_PACKAGED = "1";
Expand Down Expand Up @@ -32,6 +32,7 @@
import { startMetricsSampler } from "./services/perf/metricsSampler";
import { registerPerfIpcHandlers } from "./services/perf/perfIpc";
import { openKvDb } from "./services/state/kvDb";
import { createRegisteredSyncPeerGate } from "./services/state/syncPeerCompactionGate";
import { ensureAdeDirs } from "./services/state/projectState";
import {
persistableRemoteProjectIconDataUrl,
Expand Down Expand Up @@ -2171,8 +2172,15 @@
}
};

let syncServiceRef: ReturnType<typeof createSyncService> | null = null;
const hasSyncPeers = createRegisteredSyncPeerGate({
syncEnabled: true,
getSyncService: () => syncServiceRef,
});
const db = await measureProjectInitStep("db_open", () =>
openKvDb(adePaths.dbPath, logger),
openKvDb(adePaths.dbPath, logger, {
hasSyncPeers,
}),
Comment thread
arul28 marked this conversation as resolved.
);
const keybindingsService = createKeybindingsService({ db });
const agentToolsService = createAgentToolsService({ logger });
Expand Down Expand Up @@ -2906,7 +2914,6 @@
});
};

let syncServiceRef: ReturnType<typeof createSyncService> | null = null;
const ptyBackend = process.env.ADE_DISABLE_SUPERVISED_PTY_HOST === "1"
? null
: createSupervisedPtyLoader({ logger });
Expand Down Expand Up @@ -3129,7 +3136,7 @@
}
},
});
agentChatServiceRef = agentChatService;

Check warning on line 3139 in apps/desktop/src/main/main.ts

View workflow job for this annotation

GitHub Actions / lint-desktop

'agentChatServiceRef' is assigned a value but never used. Allowed unused vars must match /^_/u
laneTeardownDeps.agentChatService = {
countActiveForLane: (laneId) => agentChatService.countActiveForLane(laneId),
disposeForLane: (laneId) => agentChatService.disposeForLane(laneId),
Expand Down Expand Up @@ -3586,7 +3593,12 @@
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 @@ -4314,7 +4326,11 @@
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 +4339,13 @@
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
Loading