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
20 changes: 20 additions & 0 deletions packages/cli/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,26 @@ export const commands: Record<string, CommandSpec> = {
positionals: [],
summary: "Export persisted state and storage metadata",
},
"storage-support-bundle": {
tool: "storage_export_support_bundle",
positionals: [],
summary: "Export storage support bundle artifact",
},
"storage-clear": {
tool: "storage_clear_collection",
positionals: ["name"],
summary: "Clear logs, audit, or artifacts (--confirm)",
},
"storage-delete-screenshots": {
tool: "storage_delete_old_screenshots",
positionals: [],
summary: "Delete screenshot artifacts older than --olderThanDays (--confirm)",
},
"storage-prune-sessions": {
tool: "storage_prune_stale_agent_sessions",
positionals: [],
summary: "Prune agent sessions older than --olderThanDays (--confirm)",
},
plugins: {
tool: "list_plugins",
positionals: [],
Expand Down
3 changes: 3 additions & 0 deletions packages/desktop/src/main/__tests__/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ describe("socket integration", () => {
expect(names).toContain("state");
expect(names).toContain("logs");
expect(names).toContain("audit");
expect(names).toContain("artifacts");
expect(names).toContain("agent_sessions");
expect(names).toContain("agent_transcripts");
});

it("denies privileged socket calls without an explicit session grant", async () => {
Expand Down
8 changes: 5 additions & 3 deletions packages/desktop/src/main/__tests__/toolFactories.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,10 @@ const factoryCases = [
"storage_list_collections",
"storage_read_collection",
"storage_export_state",
"storage_export_support_bundle",
"storage_clear_collection",
"storage_delete_old_screenshots",
"storage_prune_stale_agent_sessions",
],
},
{
Expand Down Expand Up @@ -235,9 +239,7 @@ describe("tool factory contracts", () => {
const violations: string[] = [];

for (const tool of all) {
const isMutating = MUTATING_PREFIXES.some((prefix) =>
tool.name.startsWith(prefix),
);
const isMutating = MUTATING_PREFIXES.some((prefix) => tool.name.startsWith(prefix));
if (!isMutating) continue;

const caps = tool.capabilities ?? [];
Expand Down
5 changes: 4 additions & 1 deletion packages/desktop/src/main/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import { WorkspaceFileService } from "./services/WorkspaceFileService.js";
import { ArtifactStore } from "./storage/ArtifactStore.js";
import { createAppTools } from "./tools/appTools.js";
import { createBrowserTools } from "./tools/browserTools.js";
import type { ToolDeps } from "./tools/deps.js";
import { createFileTools } from "./tools/fileTools.js";
import { createGitTools } from "./tools/gitTools.js";
import { createPluginTools } from "./tools/pluginTools.js";
Expand Down Expand Up @@ -357,7 +358,7 @@ export async function bootstrap(
managedPluginsDir: join(userDataPath, "plugins"),
});
pluginRef.current = plugins;
const deps = {
const deps: ToolDeps = {
appState,
browserTabs,
spaces,
Expand All @@ -369,6 +370,7 @@ export async function bootstrap(
storage,
plugins,
permissions,
artifacts,
};
registry.registerAll(createBrowserTools(deps));
registry.registerAll(createSpaceTools(deps));
Expand Down Expand Up @@ -413,6 +415,7 @@ export async function bootstrap(
});
agents.hydrate();
agents.startIdleGc();
deps.agents = agents;

registry.registerAll(
createAppTools(deps, {
Expand Down
172 changes: 162 additions & 10 deletions packages/desktop/src/main/services/StorageService.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { existsSync, statSync } from "node:fs";
import { existsSync, readFileSync, readdirSync, rmSync, statSync } from "node:fs";
import { basename, extname, join } from "node:path";
import { JsonlStore } from "../storage/JsonlStore.js";
import { CURRENT_STATE_VERSION } from "../storage/migrations.js";
import type { AppStateService } from "./AppStateService.js";

export type CollectionKind = "json" | "jsonl";
export type CollectionKind = "json" | "jsonl" | "directory";

export interface CollectionInfo {
name: string;
Expand All @@ -18,6 +19,30 @@ interface CollectionDef extends Omit<CollectionInfo, "exists" | "sizeBytes"> {
read: (limit?: number) => unknown;
}

export interface StorageMaintenanceResult {
collection?: string;
deletedFiles: number;
deletedBytes: number;
}

interface FileInfo {
path: string;
sizeBytes: number;
createdAt: number;
modifiedAt: number;
}

export interface SupportBundle {
schema: "meith-support-bundle/v1";
exportedAt: number;
dataDirectory: string;
stateVersion: number;
collections: CollectionInfo[];
state: unknown;
recentLogs: unknown;
recentAudit: unknown;
}

export interface StorageServiceOptions {
dataDir: string;
appState: AppStateService;
Expand Down Expand Up @@ -64,6 +89,29 @@ export class StorageService {
description: "Append-only redacted tool-call audit history.",
read: (limit) => auditStore.tail(limit ?? 200),
});
this.define({
name: "artifacts",
kind: "directory",
path: this.artifactsDirectory,
description: "Binary and JSON artifacts, including screenshots and bug reports.",
read: (limit) => this.listFiles(this.artifactsDirectory).slice(0, limit ?? 500),
});
this.define({
name: "agent_sessions",
kind: "json",
path: this.agentSessionsPath,
description:
"Agent session metadata index; transcripts are stored as per-session JSONL.",
read: () => this.readCollectionFile(this.agentSessionsPath),
});
this.define({
name: "agent_transcripts",
kind: "directory",
path: this.agentTranscriptsDirectory,
description: "Append-only JSONL agent transcripts, one file per session.",
read: (limit) =>
this.listFiles(this.agentTranscriptsDirectory).slice(0, limit ?? 500),
});
}

private define(def: CollectionDef): void {
Expand All @@ -75,17 +123,22 @@ export class StorageService {
return this.opts.dataDir;
}

get artifactsDirectory(): string {
return join(this.opts.dataDir, "artifacts");
}

get agentSessionsPath(): string {
return join(this.opts.dataDir, "agent", "sessions.json");
}

get agentTranscriptsDirectory(): string {
return join(this.opts.dataDir, "agent", "sessions");
}

listCollections(): CollectionInfo[] {
return [...this.collections.values()].map((def) => {
let sizeBytes = 0;
const exists = existsSync(def.path);
if (exists) {
try {
sizeBytes = statSync(def.path).size;
} catch {
sizeBytes = 0;
}
}
const sizeBytes = exists ? this.pathSize(def.path) : 0;
return {
name: def.name,
kind: def.kind,
Expand Down Expand Up @@ -115,4 +168,103 @@ export class StorageService {
state: this.opts.appState.getState(),
};
}

exportSupportBundle(logsLimit = 500): SupportBundle {
return {
schema: "meith-support-bundle/v1",
exportedAt: Date.now(),
dataDirectory: this.opts.dataDir,
stateVersion: CURRENT_STATE_VERSION,
collections: this.listCollections(),
state: this.opts.appState.getState(),
recentLogs: this.readCollection("logs", logsLimit),
recentAudit: this.readCollection("audit", logsLimit),
};
}

clearCollection(name: string): StorageMaintenanceResult {
const allowed = new Set(["logs", "audit", "artifacts"]);
if (!allowed.has(name)) {
throw new Error(`Collection cannot be cleared from storage management: ${name}`);
}
const def = this.collections.get(name);
if (!def) throw new Error(`Unknown collection: ${name}`);
const before = this.pathSize(def.path);
const deletedFiles = this.pathExists(def.path)
? statSync(def.path).isDirectory()
? this.listFiles(def.path).length
: 1
: 0;
rmSync(def.path, { recursive: true, force: true });
return { collection: name, deletedFiles, deletedBytes: before };
}

deleteOldScreenshots(olderThanDays: number): StorageMaintenanceResult {
const cutoff = Date.now() - olderThanDays * 24 * 60 * 60 * 1000;
let deletedFiles = 0;
let deletedBytes = 0;
for (const file of this.listFiles(this.artifactsDirectory)) {
const name = basename(file.path);
const isScreenshot =
extname(name).toLowerCase() === ".png" &&
(name.startsWith("screenshot-") || name.startsWith("app-"));
if (!isScreenshot || file.modifiedAt >= cutoff) continue;
rmSync(file.path, { force: true });
deletedFiles += 1;
deletedBytes += file.sizeBytes;
}
return { collection: "artifacts", deletedFiles, deletedBytes };
}

private readCollectionFile(path: string): unknown {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return null;
}
}

private pathExists(path: string): boolean {
try {
statSync(path);
return true;
} catch {
return false;
}
}

private pathSize(path: string): number {
try {
const st = statSync(path);
if (!st.isDirectory()) return st.size;
return this.listFiles(path).reduce((sum, file) => sum + file.sizeBytes, 0);
} catch {
return 0;
}
}

private listFiles(dir: string): FileInfo[] {
if (!existsSync(dir)) return [];
const out: FileInfo[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
try {
if (entry.isDirectory()) {
out.push(...this.listFiles(path));
continue;
}
if (!entry.isFile()) continue;
const st = statSync(path);
out.push({
path,
sizeBytes: st.size,
createdAt: st.birthtimeMs || st.mtimeMs,
modifiedAt: st.mtimeMs,
});
} catch {
// Ignore files that disappear while storage accounting is running.
}
}
return out.sort((a, b) => b.modifiedAt - a.modifiedAt);
}
}
4 changes: 4 additions & 0 deletions packages/desktop/src/main/tools/deps.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { AgentService } from "../services/AgentService.js";
import type { AppStateService } from "../services/AppStateService.js";
import type { BrowserTabService } from "../services/BrowserTabService.js";
import type { DevServerService } from "../services/DevServerService.js";
Expand All @@ -9,6 +10,7 @@ import type { SpaceService } from "../services/SpaceService.js";
import type { StorageService } from "../services/StorageService.js";
import type { TerminalService } from "../services/TerminalService.js";
import type { WorkspaceFileService } from "../services/WorkspaceFileService.js";
import type { ArtifactStore } from "../storage/ArtifactStore.js";

/**
* Dependencies injected into tool factories. Keeping this explicit (rather than
Expand All @@ -27,4 +29,6 @@ export interface ToolDeps {
storage: StorageService;
plugins: PluginHostService;
permissions: PermissionService;
agents?: AgentService;
artifacts?: ArtifactStore;
}
Loading
Loading