diff --git a/.dependency-cruiser.cjs b/.dependency-cruiser.cjs index 1fe898d..689e194 100644 --- a/.dependency-cruiser.cjs +++ b/.dependency-cruiser.cjs @@ -19,12 +19,48 @@ module.exports = { from: { path: "^apps/api-ts/src/lib/" }, to: { path: "^apps/api-ts/src/app/" }, }, + { + name: "platform-must-not-import-domains", + severity: "error", + from: { path: "^apps/api-ts/src/platform/" }, + to: { path: "^apps/api-ts/src/domains/" }, + }, + { + name: "platform-must-not-import-app", + severity: "error", + from: { path: "^apps/api-ts/src/platform/" }, + to: { path: "^apps/api-ts/src/app/" }, + }, { name: "provider-services-must-not-import-app", severity: "error", from: { path: "^apps/api-ts/src/domains/providers/services/" }, to: { path: "^apps/api-ts/src/app/" }, }, + { + name: "provider-services-must-not-import-provider-adapters-directly", + severity: "error", + from: { + path: "^apps/api-ts/src/domains/providers/services/", + pathNot: "\\.test\\.ts$", + }, + to: { path: "^apps/api-ts/src/domains/providers/adapters/" }, + }, + { + name: "provider-adapters-must-not-import-provider-services", + severity: "error", + from: { + path: "^apps/api-ts/src/domains/providers/adapters/", + pathNot: "\\.test\\.ts$", + }, + to: { path: "^apps/api-ts/src/domains/providers/services/" }, + }, + { + name: "provider-adapters-must-not-import-app", + severity: "error", + from: { path: "^apps/api-ts/src/domains/providers/adapters/" }, + to: { path: "^apps/api-ts/src/app/" }, + }, { name: "providers-must-not-import-threads-domain", severity: "error", diff --git a/apps/api-ts/src/app/create-server.ts b/apps/api-ts/src/app/create-server.ts index 5587176..3385fb7 100644 --- a/apps/api-ts/src/app/create-server.ts +++ b/apps/api-ts/src/app/create-server.ts @@ -36,9 +36,9 @@ import { invalidateProviderSearchCaches, } from "../domains/providers/search.js"; import { - registerPlatformRoutes, + registerSystemRoutes, type ProxyRequest, -} from "./routes/platform/index.js"; +} from "./routes/system/index.js"; import { registerThreadRoutes } from "./routes/threads/index.js"; import { registerProviderRoutes } from "./routes/providers/index.js"; @@ -244,7 +244,7 @@ export async function createServer(): Promise { }); app.addHook("preHandler", requireApiTokenForProtectedLocalActions); - await registerPlatformRoutes(app, { + await registerSystemRoutes(app, { getAgentRuntimeState, getCachedDataSources, }); diff --git a/apps/api-ts/src/app/routes/providers/actions.ts b/apps/api-ts/src/app/routes/providers/actions.ts new file mode 100644 index 0000000..72c7096 --- /dev/null +++ b/apps/api-ts/src/app/routes/providers/actions.ts @@ -0,0 +1,40 @@ +import type { FastifyInstance } from "fastify"; +import { runProviderSessionAction } from "../../../domains/providers/index.js"; +import { + envelope, + withSchemaVersion, +} from "../../../lib/utils.js"; +import { providerSessionActionSchema } from "./schemas.js"; + +export function registerProviderActionRoutes(app: FastifyInstance): void { + app.post<{ Body: unknown }>( + "/api/provider-session-action", + async (req, reply) => { + const parsed = providerSessionActionSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + try { + const result = await runProviderSessionAction( + parsed.data.provider, + parsed.data.action, + parsed.data.file_paths, + parsed.data.dry_run, + parsed.data.confirm_token, + { + backup_before_delete: parsed.data.backup_before_delete, + backup_root: parsed.data.backup_root, + }, + ); + const status = result.ok ? 200 : 400; + return reply.code(status).send(withSchemaVersion(result)); + } catch (error) { + return reply + .code(500) + .send( + envelope(null, `provider-session-action-error: ${String(error)}`), + ); + } + }, + ); +} diff --git a/apps/api-ts/src/app/routes/providers/index.ts b/apps/api-ts/src/app/routes/providers/index.ts index 83ac3f5..f1f5f52 100644 --- a/apps/api-ts/src/app/routes/providers/index.ts +++ b/apps/api-ts/src/app/routes/providers/index.ts @@ -1,404 +1,17 @@ -import { execFile } from "node:child_process"; -import path from "node:path"; import type { FastifyInstance } from "fastify"; -import { z } from "zod"; -import type { ProviderId } from "../../../domains/providers/types.js"; -import { - listProviderActionProviderIds, - listSessionReadableProviderIds, - parseSearchableProviderId, - parseSessionReadableProviderId, - parseTranscriptReadableProviderId, -} from "../../../domains/providers/capabilities.js"; -import { - resolveAllowedProviderFilePath, -} from "../../../domains/providers/path-safety.js"; -import { getProviderMatrixTs } from "../../../domains/providers/matrix.js"; -import { getProviderSessionTranscript } from "../../../domains/providers/session-transcript-service.js"; -import { runProviderSessionAction } from "../../../domains/providers/index.js"; -import { - getProviderParserHealthTs, - getProviderSessionsTs, - searchConversationSessionHitsTs, - searchLocalConversationsTs, -} from "../../../domains/providers/search.js"; -import { - envelope, - parseQueryNumber, - pathExists, - type QueryMap, - withSchemaVersion, -} from "../../../lib/utils.js"; +import { registerProviderActionRoutes } from "./actions.js"; +import { registerProviderMatrixRoutes } from "./matrix.js"; +import { registerProviderSearchRoutes, type ProviderSearchRouteDeps } from "./search.js"; +import { registerProviderSessionRoutes } from "./sessions.js"; +import { registerProviderTranscriptRoutes } from "./transcript.js"; export async function registerProviderRoutes( app: FastifyInstance, - deps: { - parseConversationSearchProviders: ( - raw: string | string[] | undefined, - ) => { providers?: ProviderId[]; invalid: string[] }; - }, + deps: ProviderSearchRouteDeps, ): Promise { - const providerActionIdTuple = listProviderActionProviderIds() as [ - ProviderId, - ...ProviderId[], - ]; - const sessionReadableProviderIdTuple = listSessionReadableProviderIds() as [ - ProviderId, - ...ProviderId[], - ]; - - const providerSessionActionSchema = z.object({ - provider: z.enum(providerActionIdTuple), - action: z.enum(["backup_local", "archive_local", "unarchive_local", "delete_local"]), - file_paths: z.array(z.string().min(1)).min(1).max(500), - dry_run: z.boolean().optional().default(true), - confirm_token: z.string().optional().default(""), - backup_before_delete: z.boolean().optional().default(false), - backup_root: z.string().optional().default(""), - }); - const providerOpenFolderSchema = z.object({ - provider: z.enum(sessionReadableProviderIdTuple), - file_path: z.string().min(1), - }); - - const openDirectoryInOs = async (directoryPath: string): Promise => { - const [command, args] = - process.platform === "darwin" - ? ["open", [directoryPath]] - : process.platform === "win32" - ? ["explorer", [directoryPath]] - : ["xdg-open", [directoryPath]]; - - await new Promise((resolve, reject) => { - execFile(command, args, (error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); - }; - - app.post<{ Body: unknown }>( - "/api/provider-session-action", - async (req, reply) => { - const parsed = providerSessionActionSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - try { - const result = await runProviderSessionAction( - parsed.data.provider, - parsed.data.action, - parsed.data.file_paths, - parsed.data.dry_run, - parsed.data.confirm_token, - { - backup_before_delete: parsed.data.backup_before_delete, - backup_root: parsed.data.backup_root, - }, - ); - const status = result.ok ? 200 : 400; - return reply.code(status).send(withSchemaVersion(result)); - } catch (error) { - return reply - .code(500) - .send( - envelope(null, `provider-session-action-error: ${String(error)}`), - ); - } - }, - ); - - app.post<{ Body: unknown }>( - "/api/provider-open-folder", - async (req, reply) => { - const parsed = providerOpenFolderSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - - try { - const safeFilePath = await resolveAllowedProviderFilePath( - parsed.data.provider, - parsed.data.file_path, - ); - if (!safeFilePath) { - return reply - .code(400) - .send(envelope(null, "file_path outside provider roots")); - } - - const exists = await pathExists(safeFilePath); - if (!exists) { - return reply.code(404).send(envelope(null, "session file not found")); - } - - const directoryPath = path.dirname(safeFilePath); - const directoryExists = await pathExists(directoryPath); - if (!directoryExists) { - return reply.code(404).send(envelope(null, "session folder not found")); - } - - await openDirectoryInOs(directoryPath); - return reply.code(200).send( - withSchemaVersion({ - ok: true, - directory_path: directoryPath, - }), - ); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `provider-open-folder-error: ${String(error)}`)); - } - }, - ); - - app.get<{ Querystring: QueryMap }>("/api/provider-matrix", async (req, reply) => { - try { - const refreshRaw = Array.isArray(req.query.refresh) - ? req.query.refresh[0] - : req.query.refresh; - const forceRefresh = Number(refreshRaw) > 0; - const data = await getProviderMatrixTs({ forceRefresh }); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `provider-matrix-error: ${String(error)}`)); - } - }); - - app.get<{ Querystring: QueryMap }>( - "/api/provider-sessions", - async (req, reply) => { - try { - const providerRaw = Array.isArray(req.query.provider) - ? req.query.provider[0] - : req.query.provider; - const limitRaw = Array.isArray(req.query.limit) - ? req.query.limit[0] - : req.query.limit; - const provider = parseSessionReadableProviderId(providerRaw); - if (providerRaw && !provider) { - return reply.code(400).send(envelope(null, "invalid provider")); - } - const limit = Math.max(1, Math.min(240, Number(limitRaw) || 80)); - const refreshRaw = Array.isArray(req.query.refresh) - ? req.query.refresh[0] - : req.query.refresh; - const forceRefresh = Number(refreshRaw) > 0; - const data = await getProviderSessionsTs(provider, limit, { - forceRefresh, - }); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `provider-sessions-error: ${String(error)}`)); - } - }, - ); - - app.get<{ Querystring: QueryMap }>( - "/api/provider-parser-health", - async (req, reply) => { - try { - const providerRaw = Array.isArray(req.query.provider) - ? req.query.provider[0] - : req.query.provider; - const limitRaw = Array.isArray(req.query.limit) - ? req.query.limit[0] - : req.query.limit; - const provider = parseSessionReadableProviderId(providerRaw); - if (providerRaw && !provider) { - return reply.code(400).send(envelope(null, "invalid provider")); - } - const limit = Math.max(1, Math.min(120, Number(limitRaw) || 80)); - const refreshRaw = Array.isArray(req.query.refresh) - ? req.query.refresh[0] - : req.query.refresh; - const forceRefresh = Number(refreshRaw) > 0; - const data = await getProviderParserHealthTs(provider, limit, { - forceRefresh, - }); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send( - envelope(null, `provider-parser-health-error: ${String(error)}`), - ); - } - }, - ); - - app.get<{ Querystring: QueryMap }>( - "/api/conversation-search", - async (req, reply) => { - const abortController = new AbortController(); - const abort = () => abortController.abort(); - req.raw.once("close", abort); - try { - const q = String( - Array.isArray(req.query.q) ? req.query.q[0] : req.query.q ?? "", - ).trim(); - if (!q) return reply.code(400).send(envelope(null, "q required")); - - const { providers, invalid } = deps.parseConversationSearchProviders( - req.query.provider, - ); - if (invalid.length > 0) { - return reply - .code(400) - .send(envelope(null, `invalid provider: ${invalid.join(", ")}`)); - } - - const limit = Math.max(1, Math.min(200, parseQueryNumber(req.query.limit, 40))); - const pageSize = Math.max( - 1, - Math.min(200, parseQueryNumber(req.query.page_size, limit)), - ); - const cursor = String( - Array.isArray(req.query.cursor) ? req.query.cursor[0] : req.query.cursor ?? "", - ).trim(); - const previewHitsPerSession = Math.max( - 1, - Math.min(20, parseQueryNumber(req.query.preview_hits_per_session, 3)), - ); - const refreshRaw = Array.isArray(req.query.refresh) - ? req.query.refresh[0] - : req.query.refresh; - const forceRefresh = Number(refreshRaw) > 0; - const data = await searchLocalConversationsTs(q, { - providers, - limit: pageSize, - pageSize, - ...(cursor ? { cursor } : {}), - forceRefresh, - previewHitsPerSession, - signal: abortController.signal, - }); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - if (abortController.signal.aborted) return; - return reply - .code(500) - .send(envelope(null, `conversation-search-error: ${String(error)}`)); - } finally { - req.raw.off("close", abort); - } - }, - ); - - app.get<{ Querystring: QueryMap }>( - "/api/conversation-search/session-hits", - async (req, reply) => { - const abortController = new AbortController(); - const abort = () => abortController.abort(); - req.raw.once("close", abort); - try { - const q = String( - Array.isArray(req.query.q) ? req.query.q[0] : req.query.q ?? "", - ).trim(); - if (!q) return reply.code(400).send(envelope(null, "q required")); - - const providerRaw = Array.isArray(req.query.provider) - ? req.query.provider[0] - : req.query.provider; - const provider = parseSearchableProviderId(providerRaw); - if (!provider) { - return reply.code(400).send(envelope(null, "invalid provider")); - } - const sessionId = String( - Array.isArray(req.query.session_id) - ? req.query.session_id[0] - : req.query.session_id ?? "", - ).trim(); - if (!sessionId) { - return reply.code(400).send(envelope(null, "session_id required")); - } - const pageSize = Math.max( - 1, - Math.min(200, parseQueryNumber(req.query.page_size, 40)), - ); - const filePath = String( - Array.isArray(req.query.file_path) - ? req.query.file_path[0] - : req.query.file_path ?? "", - ).trim(); - const cursor = String( - Array.isArray(req.query.cursor) ? req.query.cursor[0] : req.query.cursor ?? "", - ).trim(); - const refreshRaw = Array.isArray(req.query.refresh) - ? req.query.refresh[0] - : req.query.refresh; - const forceRefresh = Number(refreshRaw) > 0; - const data = await searchConversationSessionHitsTs(q, { - provider, - sessionId, - ...(filePath ? { filePath } : {}), - pageSize, - ...(cursor ? { cursor } : {}), - forceRefresh, - signal: abortController.signal, - }); - if (!data) { - return reply.code(404).send(envelope(null, "session not found")); - } - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - if (abortController.signal.aborted) return; - return reply - .code(500) - .send( - envelope(null, `conversation-search-session-hits-error: ${String(error)}`), - ); - } finally { - req.raw.off("close", abort); - } - }, - ); - - app.get<{ Querystring: QueryMap }>( - "/api/session-transcript", - async (req, reply) => { - try { - const providerRaw = Array.isArray(req.query.provider) - ? req.query.provider[0] - : req.query.provider; - const fileRaw = Array.isArray(req.query.file_path) - ? req.query.file_path[0] - : req.query.file_path; - const limitRaw = Array.isArray(req.query.limit) - ? req.query.limit[0] - : req.query.limit; - const provider = parseTranscriptReadableProviderId(providerRaw); - if (!provider) { - return reply.code(400).send(envelope(null, "invalid provider")); - } - const filePath = String(fileRaw ?? "").trim(); - if (!filePath) { - return reply.code(400).send(envelope(null, "file_path required")); - } - const result = await getProviderSessionTranscript( - provider, - filePath, - Number(limitRaw) || 300, - ); - if (!result.ok) { - return reply - .code(result.statusCode) - .send(envelope(null, result.message)); - } - return reply.code(200).send(withSchemaVersion(result.data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `session-transcript-error: ${String(error)}`)); - } - }, - ); + registerProviderActionRoutes(app); + registerProviderSessionRoutes(app); + registerProviderMatrixRoutes(app); + registerProviderSearchRoutes(app, deps); + registerProviderTranscriptRoutes(app); } diff --git a/apps/api-ts/src/app/routes/providers/matrix.ts b/apps/api-ts/src/app/routes/providers/matrix.ts new file mode 100644 index 0000000..8f6ee1b --- /dev/null +++ b/apps/api-ts/src/app/routes/providers/matrix.ts @@ -0,0 +1,24 @@ +import type { FastifyInstance } from "fastify"; +import { getProviderMatrixTs } from "../../../domains/providers/matrix.js"; +import { + envelope, + type QueryMap, + withSchemaVersion, +} from "../../../lib/utils.js"; + +export function registerProviderMatrixRoutes(app: FastifyInstance): void { + app.get<{ Querystring: QueryMap }>("/api/provider-matrix", async (req, reply) => { + try { + const refreshRaw = Array.isArray(req.query.refresh) + ? req.query.refresh[0] + : req.query.refresh; + const forceRefresh = Number(refreshRaw) > 0; + const data = await getProviderMatrixTs({ forceRefresh }); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `provider-matrix-error: ${String(error)}`)); + } + }); +} diff --git a/apps/api-ts/src/app/routes/providers/schemas.ts b/apps/api-ts/src/app/routes/providers/schemas.ts new file mode 100644 index 0000000..0a31d3b --- /dev/null +++ b/apps/api-ts/src/app/routes/providers/schemas.ts @@ -0,0 +1,30 @@ +import { z } from "zod"; +import type { ProviderId } from "../../../domains/providers/types.js"; +import { + listProviderActionProviderIds, + listSessionReadableProviderIds, +} from "../../../domains/providers/capabilities.js"; + +const providerActionIdTuple = listProviderActionProviderIds() as [ + ProviderId, + ...ProviderId[], +]; +const sessionReadableProviderIdTuple = listSessionReadableProviderIds() as [ + ProviderId, + ...ProviderId[], +]; + +export const providerSessionActionSchema = z.object({ + provider: z.enum(providerActionIdTuple), + action: z.enum(["backup_local", "archive_local", "unarchive_local", "delete_local"]), + file_paths: z.array(z.string().min(1)).min(1).max(500), + dry_run: z.boolean().optional().default(true), + confirm_token: z.string().optional().default(""), + backup_before_delete: z.boolean().optional().default(false), + backup_root: z.string().optional().default(""), +}); + +export const providerOpenFolderSchema = z.object({ + provider: z.enum(sessionReadableProviderIdTuple), + file_path: z.string().min(1), +}); diff --git a/apps/api-ts/src/app/routes/providers/search.ts b/apps/api-ts/src/app/routes/providers/search.ts new file mode 100644 index 0000000..8e1ee25 --- /dev/null +++ b/apps/api-ts/src/app/routes/providers/search.ts @@ -0,0 +1,151 @@ +import type { FastifyInstance } from "fastify"; +import type { ProviderId } from "../../../domains/providers/types.js"; +import { parseSearchableProviderId } from "../../../domains/providers/capabilities.js"; +import { + searchConversationSessionHitsTs, + searchLocalConversationsTs, +} from "../../../domains/providers/search.js"; +import { + envelope, + parseQueryNumber, + type QueryMap, + withSchemaVersion, +} from "../../../lib/utils.js"; + +export type ProviderSearchRouteDeps = { + parseConversationSearchProviders: ( + raw: string | string[] | undefined, + ) => { providers?: ProviderId[]; invalid: string[] }; +}; + +export function registerProviderSearchRoutes( + app: FastifyInstance, + deps: ProviderSearchRouteDeps, +): void { + app.get<{ Querystring: QueryMap }>( + "/api/conversation-search", + async (req, reply) => { + const abortController = new AbortController(); + const abort = () => abortController.abort(); + req.raw.once("close", abort); + try { + const q = String( + Array.isArray(req.query.q) ? req.query.q[0] : req.query.q ?? "", + ).trim(); + if (!q) return reply.code(400).send(envelope(null, "q required")); + + const { providers, invalid } = deps.parseConversationSearchProviders( + req.query.provider, + ); + if (invalid.length > 0) { + return reply + .code(400) + .send(envelope(null, `invalid provider: ${invalid.join(", ")}`)); + } + + const limit = Math.max(1, Math.min(200, parseQueryNumber(req.query.limit, 40))); + const pageSize = Math.max( + 1, + Math.min(200, parseQueryNumber(req.query.page_size, limit)), + ); + const cursor = String( + Array.isArray(req.query.cursor) ? req.query.cursor[0] : req.query.cursor ?? "", + ).trim(); + const previewHitsPerSession = Math.max( + 1, + Math.min(20, parseQueryNumber(req.query.preview_hits_per_session, 3)), + ); + const refreshRaw = Array.isArray(req.query.refresh) + ? req.query.refresh[0] + : req.query.refresh; + const forceRefresh = Number(refreshRaw) > 0; + const data = await searchLocalConversationsTs(q, { + providers, + limit: pageSize, + pageSize, + ...(cursor ? { cursor } : {}), + forceRefresh, + previewHitsPerSession, + signal: abortController.signal, + }); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + if (abortController.signal.aborted) return; + return reply + .code(500) + .send(envelope(null, `conversation-search-error: ${String(error)}`)); + } finally { + req.raw.off("close", abort); + } + }, + ); + + app.get<{ Querystring: QueryMap }>( + "/api/conversation-search/session-hits", + async (req, reply) => { + const abortController = new AbortController(); + const abort = () => abortController.abort(); + req.raw.once("close", abort); + try { + const q = String( + Array.isArray(req.query.q) ? req.query.q[0] : req.query.q ?? "", + ).trim(); + if (!q) return reply.code(400).send(envelope(null, "q required")); + + const providerRaw = Array.isArray(req.query.provider) + ? req.query.provider[0] + : req.query.provider; + const provider = parseSearchableProviderId(providerRaw); + if (!provider) { + return reply.code(400).send(envelope(null, "invalid provider")); + } + const sessionId = String( + Array.isArray(req.query.session_id) + ? req.query.session_id[0] + : req.query.session_id ?? "", + ).trim(); + if (!sessionId) { + return reply.code(400).send(envelope(null, "session_id required")); + } + const pageSize = Math.max( + 1, + Math.min(200, parseQueryNumber(req.query.page_size, 40)), + ); + const filePath = String( + Array.isArray(req.query.file_path) + ? req.query.file_path[0] + : req.query.file_path ?? "", + ).trim(); + const cursor = String( + Array.isArray(req.query.cursor) ? req.query.cursor[0] : req.query.cursor ?? "", + ).trim(); + const refreshRaw = Array.isArray(req.query.refresh) + ? req.query.refresh[0] + : req.query.refresh; + const forceRefresh = Number(refreshRaw) > 0; + const data = await searchConversationSessionHitsTs(q, { + provider, + sessionId, + ...(filePath ? { filePath } : {}), + pageSize, + ...(cursor ? { cursor } : {}), + forceRefresh, + signal: abortController.signal, + }); + if (!data) { + return reply.code(404).send(envelope(null, "session not found")); + } + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + if (abortController.signal.aborted) return; + return reply + .code(500) + .send( + envelope(null, `conversation-search-session-hits-error: ${String(error)}`), + ); + } finally { + req.raw.off("close", abort); + } + }, + ); +} diff --git a/apps/api-ts/src/app/routes/providers/sessions.ts b/apps/api-ts/src/app/routes/providers/sessions.ts new file mode 100644 index 0000000..8393c4b --- /dev/null +++ b/apps/api-ts/src/app/routes/providers/sessions.ts @@ -0,0 +1,146 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import type { FastifyInstance } from "fastify"; +import { parseSessionReadableProviderId } from "../../../domains/providers/capabilities.js"; +import { resolveAllowedProviderFilePath } from "../../../domains/providers/path-safety.js"; +import { + getProviderParserHealthTs, + getProviderSessionsTs, +} from "../../../domains/providers/search.js"; +import { + envelope, + pathExists, + type QueryMap, + withSchemaVersion, +} from "../../../lib/utils.js"; +import { providerOpenFolderSchema } from "./schemas.js"; + +async function openDirectoryInOs(directoryPath: string): Promise { + const [command, args] = + process.platform === "darwin" + ? ["open", [directoryPath]] + : process.platform === "win32" + ? ["explorer", [directoryPath]] + : ["xdg-open", [directoryPath]]; + + await new Promise((resolve, reject) => { + execFile(command, args, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +export function registerProviderSessionRoutes(app: FastifyInstance): void { + app.post<{ Body: unknown }>( + "/api/provider-open-folder", + async (req, reply) => { + const parsed = providerOpenFolderSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + + try { + const safeFilePath = await resolveAllowedProviderFilePath( + parsed.data.provider, + parsed.data.file_path, + ); + if (!safeFilePath) { + return reply + .code(400) + .send(envelope(null, "file_path outside provider roots")); + } + + const exists = await pathExists(safeFilePath); + if (!exists) { + return reply.code(404).send(envelope(null, "session file not found")); + } + + const directoryPath = path.dirname(safeFilePath); + const directoryExists = await pathExists(directoryPath); + if (!directoryExists) { + return reply.code(404).send(envelope(null, "session folder not found")); + } + + await openDirectoryInOs(directoryPath); + return reply.code(200).send( + withSchemaVersion({ + ok: true, + directory_path: directoryPath, + }), + ); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `provider-open-folder-error: ${String(error)}`)); + } + }, + ); + + app.get<{ Querystring: QueryMap }>( + "/api/provider-sessions", + async (req, reply) => { + try { + const providerRaw = Array.isArray(req.query.provider) + ? req.query.provider[0] + : req.query.provider; + const limitRaw = Array.isArray(req.query.limit) + ? req.query.limit[0] + : req.query.limit; + const provider = parseSessionReadableProviderId(providerRaw); + if (providerRaw && !provider) { + return reply.code(400).send(envelope(null, "invalid provider")); + } + const limit = Math.max(1, Math.min(240, Number(limitRaw) || 80)); + const refreshRaw = Array.isArray(req.query.refresh) + ? req.query.refresh[0] + : req.query.refresh; + const forceRefresh = Number(refreshRaw) > 0; + const data = await getProviderSessionsTs(provider, limit, { + forceRefresh, + }); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `provider-sessions-error: ${String(error)}`)); + } + }, + ); + + app.get<{ Querystring: QueryMap }>( + "/api/provider-parser-health", + async (req, reply) => { + try { + const providerRaw = Array.isArray(req.query.provider) + ? req.query.provider[0] + : req.query.provider; + const limitRaw = Array.isArray(req.query.limit) + ? req.query.limit[0] + : req.query.limit; + const provider = parseSessionReadableProviderId(providerRaw); + if (providerRaw && !provider) { + return reply.code(400).send(envelope(null, "invalid provider")); + } + const limit = Math.max(1, Math.min(120, Number(limitRaw) || 80)); + const refreshRaw = Array.isArray(req.query.refresh) + ? req.query.refresh[0] + : req.query.refresh; + const forceRefresh = Number(refreshRaw) > 0; + const data = await getProviderParserHealthTs(provider, limit, { + forceRefresh, + }); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send( + envelope(null, `provider-parser-health-error: ${String(error)}`), + ); + } + }, + ); +} diff --git a/apps/api-ts/src/app/routes/providers/transcript.ts b/apps/api-ts/src/app/routes/providers/transcript.ts new file mode 100644 index 0000000..d72f815 --- /dev/null +++ b/apps/api-ts/src/app/routes/providers/transcript.ts @@ -0,0 +1,50 @@ +import type { FastifyInstance } from "fastify"; +import { parseTranscriptReadableProviderId } from "../../../domains/providers/capabilities.js"; +import { getProviderSessionTranscript } from "../../../domains/providers/session-transcript-service.js"; +import { + envelope, + type QueryMap, + withSchemaVersion, +} from "../../../lib/utils.js"; + +export function registerProviderTranscriptRoutes(app: FastifyInstance): void { + app.get<{ Querystring: QueryMap }>( + "/api/session-transcript", + async (req, reply) => { + try { + const providerRaw = Array.isArray(req.query.provider) + ? req.query.provider[0] + : req.query.provider; + const fileRaw = Array.isArray(req.query.file_path) + ? req.query.file_path[0] + : req.query.file_path; + const limitRaw = Array.isArray(req.query.limit) + ? req.query.limit[0] + : req.query.limit; + const provider = parseTranscriptReadableProviderId(providerRaw); + if (!provider) { + return reply.code(400).send(envelope(null, "invalid provider")); + } + const filePath = String(fileRaw ?? "").trim(); + if (!filePath) { + return reply.code(400).send(envelope(null, "file_path required")); + } + const result = await getProviderSessionTranscript( + provider, + filePath, + Number(limitRaw) || 300, + ); + if (!result.ok) { + return reply + .code(result.statusCode) + .send(envelope(null, result.message)); + } + return reply.code(200).send(withSchemaVersion(result.data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `session-transcript-error: ${String(error)}`)); + } + }, + ); +} diff --git a/apps/api-ts/src/app/routes/platform/index.ts b/apps/api-ts/src/app/routes/system/index.ts similarity index 98% rename from apps/api-ts/src/app/routes/platform/index.ts rename to apps/api-ts/src/app/routes/system/index.ts index 3adef24..dfbc24c 100644 --- a/apps/api-ts/src/app/routes/platform/index.ts +++ b/apps/api-ts/src/app/routes/system/index.ts @@ -10,7 +10,7 @@ import { CODEX_HOME, START_TS, } from "../../../lib/constants.js"; -import { checkForUpdates } from "../../../lib/update-check.js"; +import { checkForUpdates } from "../../../domains/ops/update-check.js"; import { cleanTitleText, envelope, @@ -50,7 +50,7 @@ export type ProxyRequest = FastifyRequest<{ Body: unknown; }>; -export async function registerPlatformRoutes( +export async function registerSystemRoutes( app: FastifyInstance, deps: { getAgentRuntimeState: () => Promise; diff --git a/apps/api-ts/src/app/routes/platform/recovery.ts b/apps/api-ts/src/app/routes/system/recovery.ts similarity index 100% rename from apps/api-ts/src/app/routes/platform/recovery.ts rename to apps/api-ts/src/app/routes/system/recovery.ts diff --git a/apps/api-ts/src/app/routes/threads/bulk-actions.ts b/apps/api-ts/src/app/routes/threads/bulk-actions.ts new file mode 100644 index 0000000..2dd3424 --- /dev/null +++ b/apps/api-ts/src/app/routes/threads/bulk-actions.ts @@ -0,0 +1,101 @@ +import type { FastifyInstance } from "fastify"; +import { + BulkThreadActionResult, + type BulkThreadActionRequest, +} from "@threadlens/shared-contracts"; +import { + archiveThreadsLocalTs, + getThreadResumeCommandsTs, + setThreadPinnedTs, + unarchiveThreadsLocalTs, +} from "../../../domains/threads/state.js"; +import { envelope } from "../../../lib/utils.js"; +import { bulkThreadActionPayloadSchema } from "./schemas.js"; +import type { ThreadRouteDeps } from "./types.js"; + +export function registerBulkThreadActionRoutes( + app: FastifyInstance, + deps: ThreadRouteDeps, +): void { + app.post<{ Body: BulkThreadActionRequest }>( + "/api/bulk-thread-action", + async (req, reply) => { + const parsed = bulkThreadActionPayloadSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + + const { action, thread_ids: threadIds } = parsed.data; + const results = await Promise.all( + threadIds.map(async (threadId) => { + switch (action) { + case "pin": { + const data = await setThreadPinnedTs([threadId], true); + return { + thread_id: threadId, + ok: Boolean(data.ok), + status: data.ok ? 200 : 400, + error: data.ok ? null : String(data.error ?? "pin failed"), + data, + }; + } + case "unpin": { + const data = await setThreadPinnedTs([threadId], false); + return { + thread_id: threadId, + ok: Boolean(data.ok), + status: data.ok ? 200 : 400, + error: data.ok ? null : String(data.error ?? "unpin failed"), + data, + }; + } + case "archive_local": { + const data = await archiveThreadsLocalTs([threadId]); + return { + thread_id: threadId, + ok: Boolean(data.ok), + status: data.ok ? 200 : 400, + error: data.ok ? null : String(data.error ?? "archive failed"), + data, + }; + } + case "unarchive_local": { + const data = await unarchiveThreadsLocalTs([threadId]); + return { + thread_id: threadId, + ok: Boolean(data.ok), + status: data.ok ? 200 : 400, + error: data.ok ? null : String(data.error ?? "unarchive failed"), + data, + }; + } + case "resume_command": { + const data = getThreadResumeCommandsTs([threadId]); + return { + thread_id: threadId, + ok: Boolean(data.ok), + status: data.ok ? 200 : 400, + error: data.ok ? null : String(data.error ?? "resume command failed"), + data, + }; + } + } + }), + ); + const success = results.filter((r) => r.ok).length; + + const payload: BulkThreadActionResult = { + action, + total: threadIds.length, + success, + failed: threadIds.length - success, + results, + }; + if (success > 0) { + deps.invalidateOverviewCache(); + } + + return envelope(payload, null); + }, + ); +} diff --git a/apps/api-ts/src/app/routes/threads/cleanup.ts b/apps/api-ts/src/app/routes/threads/cleanup.ts new file mode 100644 index 0000000..52262e9 --- /dev/null +++ b/apps/api-ts/src/app/routes/threads/cleanup.ts @@ -0,0 +1,89 @@ +import type { FastifyInstance } from "fastify"; +import { + analyzeDeleteTs, + executeBackupCleanupTs, + executeLocalCleanupTs, +} from "../../../domains/threads/cleanup.js"; +import { + envelope, + withSchemaVersion, +} from "../../../lib/utils.js"; +import { + analyzeDeletePayloadSchema, + cleanupPayloadSchema, + idsPayloadSchema, +} from "./schemas.js"; +import type { ThreadRouteDeps } from "./types.js"; + +export function registerThreadCleanupRoutes( + app: FastifyInstance, + deps: ThreadRouteDeps, +): void { + app.post<{ Body: unknown }>("/api/analyze-delete", async (req, reply) => { + const parsed = analyzeDeletePayloadSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + try { + const data = await analyzeDeleteTs(parsed.data.ids, { + sessionScanLimit: parsed.data.session_scan_limit, + }); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `analyze-delete-error: ${String(error)}`)); + } + }); + + app.post<{ Body: unknown }>("/api/local-cleanup", async (req, reply) => { + const parsed = cleanupPayloadSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + try { + const data = await executeLocalCleanupTs(parsed.data.ids, { + dryRun: parsed.data.dry_run, + confirmToken: parsed.data.confirm_token, + options: parsed.data.options as { + delete_cache?: boolean; + delete_session_logs?: boolean; + clean_state_refs?: boolean; + }, + }); + const changed = + parsed.data.dry_run === false && + (Number((data as { deleted_file_count?: unknown }).deleted_file_count ?? 0) > 0 || + String((data as { mode?: unknown }).mode ?? "") === "partial" || + String((data as { mode?: unknown }).mode ?? "") === "applied"); + if (changed) { + deps.invalidateOverviewCache(); + deps.invalidateProviderSessionCache("codex"); + } + const status = data.ok ? 200 : String((data as { mode?: unknown }).mode ?? "") === "partial" ? 207 : 400; + return reply.code(status).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `local-cleanup-error: ${String(error)}`)); + } + }); + + app.post<{ Body: unknown }>("/api/local-cleanup-backups", async (req, reply) => { + const parsed = idsPayloadSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + try { + const data = await executeBackupCleanupTs(parsed.data.ids); + deps.invalidateOverviewCache(); + deps.invalidateProviderSessionCache("codex"); + const status = data.ok ? 200 : String((data as { mode?: unknown }).mode ?? "") === "partial" ? 207 : 400; + return reply.code(status).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `local-cleanup-backups-error: ${String(error)}`)); + } + }); +} diff --git a/apps/api-ts/src/app/routes/threads/forensics.ts b/apps/api-ts/src/app/routes/threads/forensics.ts new file mode 100644 index 0000000..b65ecb5 --- /dev/null +++ b/apps/api-ts/src/app/routes/threads/forensics.ts @@ -0,0 +1,25 @@ +import type { FastifyInstance } from "fastify"; +import { getThreadForensicsTs } from "../../../domains/threads/forensics.js"; +import { + envelope, + withSchemaVersion, +} from "../../../lib/utils.js"; +import { threadForensicsSchema } from "./schemas.js"; + +export function registerThreadForensicsRoutes(app: FastifyInstance): void { + app.post<{ Body: unknown }>("/api/thread-forensics", async (req, reply) => { + const parsed = threadForensicsSchema.safeParse(req.body ?? {}); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + const ids = parsed.data.ids ?? parsed.data.thread_ids ?? []; + try { + const data = await getThreadForensicsTs(ids); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `thread-forensics-error: ${String(error)}`)); + } + }); +} diff --git a/apps/api-ts/src/app/routes/threads/index.ts b/apps/api-ts/src/app/routes/threads/index.ts index e36bb1d..b8257d4 100644 --- a/apps/api-ts/src/app/routes/threads/index.ts +++ b/apps/api-ts/src/app/routes/threads/index.ts @@ -1,445 +1,22 @@ -import { execFile } from "node:child_process"; -import path from "node:path"; import type { FastifyInstance } from "fastify"; -import { z } from "zod"; -import { - BulkThreadActionResult, - type BulkThreadActionRequest, -} from "@threadlens/shared-contracts"; -import { - archiveThreadsLocalTs, - getThreadResumeCommandsTs, - renameThreadTitleTs, - setThreadPinnedTs, - unarchiveThreadsLocalTs, -} from "../../../domains/threads/state.js"; -import { - analyzeDeleteTs, - executeBackupCleanupTs, - executeLocalCleanupTs, -} from "../../../domains/threads/cleanup.js"; -import { getThreadForensicsTs } from "../../../domains/threads/forensics.js"; -import { parseSafeThreadId } from "../../../domains/threads/thread-id.js"; -import { getThreadsTs } from "../../../domains/threads/query.js"; -import { - buildSessionTranscript, -} from "../../../domains/providers/transcript.js"; -import { invalidateCodexThreadTitleMapCache } from "../../../domains/providers/title-detection.js"; -import { resolveCodexSessionPathByThreadId } from "../../../domains/providers/search.js"; -import { - envelope, - isRecord, - pathExists, - type QueryMap, - withSchemaVersion, -} from "../../../lib/utils.js"; - -async function openDirectoryInOs(directoryPath: string): Promise { - const [command, args] = - process.platform === "darwin" - ? ["open", [directoryPath]] - : process.platform === "win32" - ? ["explorer", [directoryPath]] - : ["xdg-open", [directoryPath]]; - - await new Promise((resolve, reject) => { - execFile(command, args, (error) => { - if (error) { - reject(error); - return; - } - resolve(); - }); - }); -} - -function firstQueryValue(value: string | string[] | undefined): string { - if (Array.isArray(value)) return String(value[0] ?? ""); - return String(value ?? ""); -} - -function clampTranscriptLimit(value: string | string[] | undefined): number { - const parsed = Number(firstQueryValue(value).trim()); - if (!Number.isFinite(parsed)) return 300; - return Math.max(1, Math.min(1000, Math.floor(parsed))); -} +import { registerBulkThreadActionRoutes } from "./bulk-actions.js"; +import { registerThreadCleanupRoutes } from "./cleanup.js"; +import { registerThreadForensicsRoutes } from "./forensics.js"; +import { registerThreadOpenFolderRoutes } from "./open-folder.js"; +import { registerThreadQueryRoutes } from "./query.js"; +import { registerThreadStateActionRoutes } from "./state-actions.js"; +import { registerThreadTranscriptRoutes } from "./transcript.js"; +import type { ThreadRouteDeps } from "./types.js"; export async function registerThreadRoutes( app: FastifyInstance, - deps: { - invalidateOverviewCache: () => void; - invalidateProviderSessionCache: (provider: "codex") => void; - }, + deps: ThreadRouteDeps, ): Promise { - const threadIdSchema = z.string().min(1).refine((value) => parseSafeThreadId(value) !== null, { - message: "invalid thread id", - }); - const bulkThreadActionPayloadSchema = z.object({ - action: z.enum(["pin", "unpin", "archive_local", "unarchive_local", "resume_command"]), - thread_ids: z.array(threadIdSchema).min(1).max(500), - }); - const idsPayloadSchema = z.object({ - ids: z.array(threadIdSchema).min(1).max(500), - }); - const analyzeDeletePayloadSchema = z.object({ - ids: z.array(threadIdSchema).min(1).max(500), - session_scan_limit: z.number().int().min(1).max(240).optional(), - }); - - const pinPayloadSchema = z.object({ - ids: z.array(threadIdSchema).min(1).max(500), - pinned: z.boolean().optional().default(true), - }); - - const cleanupPayloadSchema = z - .object({ - ids: z.array(threadIdSchema).min(1).max(500), - dry_run: z.boolean().optional().default(true), - options: z.unknown().optional(), - confirm_token: z.string().optional().default(""), - }) - .transform((value) => ({ - ids: value.ids, - dry_run: value.dry_run, - options: isRecord(value.options) ? value.options : {}, - confirm_token: value.confirm_token, - })); - - const renameThreadSchema = z.object({ - id: threadIdSchema, - title: z.string().min(1), - }); - - const threadOpenFolderSchema = z.object({ - thread_id: threadIdSchema, - }); - - const threadForensicsSchema = z.object({ - ids: z.array(threadIdSchema).optional(), - thread_ids: z.array(threadIdSchema).optional(), - }); - - app.post<{ Body: BulkThreadActionRequest }>( - "/api/bulk-thread-action", - async (req, reply) => { - const parsed = bulkThreadActionPayloadSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - - const { action, thread_ids: threadIds } = parsed.data; - const results = await Promise.all( - threadIds.map(async (threadId) => { - switch (action) { - case "pin": { - const data = await setThreadPinnedTs([threadId], true); - return { - thread_id: threadId, - ok: Boolean(data.ok), - status: data.ok ? 200 : 400, - error: data.ok ? null : String(data.error ?? "pin failed"), - data, - }; - } - case "unpin": { - const data = await setThreadPinnedTs([threadId], false); - return { - thread_id: threadId, - ok: Boolean(data.ok), - status: data.ok ? 200 : 400, - error: data.ok ? null : String(data.error ?? "unpin failed"), - data, - }; - } - case "archive_local": { - const data = await archiveThreadsLocalTs([threadId]); - return { - thread_id: threadId, - ok: Boolean(data.ok), - status: data.ok ? 200 : 400, - error: data.ok ? null : String(data.error ?? "archive failed"), - data, - }; - } - case "unarchive_local": { - const data = await unarchiveThreadsLocalTs([threadId]); - return { - thread_id: threadId, - ok: Boolean(data.ok), - status: data.ok ? 200 : 400, - error: data.ok ? null : String(data.error ?? "unarchive failed"), - data, - }; - } - case "resume_command": { - const data = getThreadResumeCommandsTs([threadId]); - return { - thread_id: threadId, - ok: Boolean(data.ok), - status: data.ok ? 200 : 400, - error: data.ok ? null : String(data.error ?? "resume command failed"), - data, - }; - } - } - }), - ); - const success = results.filter((r) => r.ok).length; - - const payload: BulkThreadActionResult = { - action, - total: threadIds.length, - success, - failed: threadIds.length - success, - results, - }; - if (success > 0) { - deps.invalidateOverviewCache(); - } - - return envelope(payload, null); - }, - ); - - app.get<{ Querystring: QueryMap }>("/api/threads", async (req, reply) => { - try { - const data = await getThreadsTs(req.query); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `threads-error: ${String(error)}`)); - } - }); - - app.post<{ Body: unknown }>("/api/thread-pin", async (req, reply) => { - const parsed = pinPayloadSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - try { - const data = await setThreadPinnedTs(parsed.data.ids, parsed.data.pinned); - if (!data.ok) { - return reply.code(400).send(withSchemaVersion(data)); - } - deps.invalidateOverviewCache(); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply.code(500).send(envelope(null, `thread-pin-error: ${String(error)}`)); - } - }); - - app.post<{ Body: unknown }>( - "/api/thread-archive-local", - async (req, reply) => { - const parsed = idsPayloadSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - try { - const data = await archiveThreadsLocalTs(parsed.data.ids); - if (!data.ok) { - return reply.code(400).send(withSchemaVersion(data)); - } - deps.invalidateOverviewCache(); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `thread-archive-local-error: ${String(error)}`)); - } - }, - ); - - app.post<{ Body: unknown }>( - "/api/thread-resume-command", - async (req, reply) => { - const parsed = idsPayloadSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - try { - const data = getThreadResumeCommandsTs(parsed.data.ids); - if (!data.ok) { - return reply.code(400).send(withSchemaVersion(data)); - } - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `thread-resume-command-error: ${String(error)}`)); - } - }, - ); - - app.post<{ Body: unknown }>("/api/analyze-delete", async (req, reply) => { - const parsed = analyzeDeletePayloadSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - try { - const data = await analyzeDeleteTs(parsed.data.ids, { - sessionScanLimit: parsed.data.session_scan_limit, - }); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `analyze-delete-error: ${String(error)}`)); - } - }); - - app.post<{ Body: unknown }>("/api/local-cleanup", async (req, reply) => { - const parsed = cleanupPayloadSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - try { - const data = await executeLocalCleanupTs(parsed.data.ids, { - dryRun: parsed.data.dry_run, - confirmToken: parsed.data.confirm_token, - options: parsed.data.options as { - delete_cache?: boolean; - delete_session_logs?: boolean; - clean_state_refs?: boolean; - }, - }); - const changed = - parsed.data.dry_run === false && - (Number((data as { deleted_file_count?: unknown }).deleted_file_count ?? 0) > 0 || - String((data as { mode?: unknown }).mode ?? "") === "partial" || - String((data as { mode?: unknown }).mode ?? "") === "applied"); - if (changed) { - deps.invalidateOverviewCache(); - deps.invalidateProviderSessionCache("codex"); - } - const status = data.ok ? 200 : String((data as { mode?: unknown }).mode ?? "") === "partial" ? 207 : 400; - return reply.code(status).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `local-cleanup-error: ${String(error)}`)); - } - }); - - app.post<{ Body: unknown }>("/api/local-cleanup-backups", async (req, reply) => { - const parsed = idsPayloadSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - try { - const data = await executeBackupCleanupTs(parsed.data.ids); - deps.invalidateOverviewCache(); - deps.invalidateProviderSessionCache("codex"); - const status = data.ok ? 200 : String((data as { mode?: unknown }).mode ?? "") === "partial" ? 207 : 400; - return reply.code(status).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `local-cleanup-backups-error: ${String(error)}`)); - } - }); - - app.post<{ Body: unknown }>("/api/rename-thread", async (req, reply) => { - const parsed = renameThreadSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - try { - const data = await renameThreadTitleTs(parsed.data.id, parsed.data.title); - if (!data.ok) { - return reply.code(400).send(withSchemaVersion(data)); - } - deps.invalidateOverviewCache(); - deps.invalidateProviderSessionCache("codex"); - invalidateCodexThreadTitleMapCache(); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply.code(500).send(envelope(null, `rename-thread-error: ${String(error)}`)); - } - }); - - app.post<{ Body: unknown }>("/api/thread-forensics", async (req, reply) => { - const parsed = threadForensicsSchema.safeParse(req.body ?? {}); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - const ids = parsed.data.ids ?? parsed.data.thread_ids ?? []; - try { - const data = await getThreadForensicsTs(ids); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `thread-forensics-error: ${String(error)}`)); - } - }); - - app.post<{ Body: unknown }>("/api/thread-open-folder", async (req, reply) => { - const parsed = threadOpenFolderSchema.safeParse(req.body); - if (!parsed.success) { - return reply.code(400).send(envelope(null, parsed.error.message)); - } - try { - const filePath = await resolveCodexSessionPathByThreadId(parsed.data.thread_id); - if (!filePath) { - return reply - .code(404) - .send(envelope(null, "thread session file not found")); - } - if (!(await pathExists(filePath))) { - return reply.code(404).send(envelope(null, "thread session file not found")); - } - const directoryPath = path.dirname(filePath); - if (!(await pathExists(directoryPath))) { - return reply.code(404).send(envelope(null, "thread session folder not found")); - } - await openDirectoryInOs(directoryPath); - return reply.code(200).send( - withSchemaVersion({ - ok: true, - directory_path: directoryPath, - }), - ); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `thread-open-folder-error: ${String(error)}`)); - } - }); - - app.get<{ Querystring: QueryMap }>( - "/api/thread-transcript", - async (req, reply) => { - try { - const threadRaw = Array.isArray(req.query.thread_id) - ? req.query.thread_id[0] - : req.query.thread_id; - const limitRaw = Array.isArray(req.query.limit) - ? req.query.limit[0] - : req.query.limit; - const parsedThreadId = threadIdSchema.safeParse(String(threadRaw ?? "").trim()); - if (!parsedThreadId.success) { - return reply.code(400).send(envelope(null, parsedThreadId.error.message)); - } - const threadId = parsedThreadId.data; - const filePath = await resolveCodexSessionPathByThreadId(threadId); - if (!filePath) { - return reply - .code(404) - .send(envelope(null, "thread session file not found")); - } - const data = await buildSessionTranscript( - "codex", - filePath, - clampTranscriptLimit(limitRaw), - ); - return reply.code(200).send(withSchemaVersion(data)); - } catch (error) { - return reply - .code(500) - .send(envelope(null, `thread-transcript-error: ${String(error)}`)); - } - }, - ); + registerBulkThreadActionRoutes(app, deps); + registerThreadQueryRoutes(app); + registerThreadStateActionRoutes(app, deps); + registerThreadCleanupRoutes(app, deps); + registerThreadForensicsRoutes(app); + registerThreadOpenFolderRoutes(app); + registerThreadTranscriptRoutes(app); } diff --git a/apps/api-ts/src/app/routes/threads/open-folder.ts b/apps/api-ts/src/app/routes/threads/open-folder.ts new file mode 100644 index 0000000..5e2f1e5 --- /dev/null +++ b/apps/api-ts/src/app/routes/threads/open-folder.ts @@ -0,0 +1,64 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import type { FastifyInstance } from "fastify"; +import { resolveCodexSessionPathByThreadId } from "../../../domains/providers/search.js"; +import { + envelope, + pathExists, + withSchemaVersion, +} from "../../../lib/utils.js"; +import { threadOpenFolderSchema } from "./schemas.js"; + +async function openDirectoryInOs(directoryPath: string): Promise { + const [command, args] = + process.platform === "darwin" + ? ["open", [directoryPath]] + : process.platform === "win32" + ? ["explorer", [directoryPath]] + : ["xdg-open", [directoryPath]]; + + await new Promise((resolve, reject) => { + execFile(command, args, (error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +export function registerThreadOpenFolderRoutes(app: FastifyInstance): void { + app.post<{ Body: unknown }>("/api/thread-open-folder", async (req, reply) => { + const parsed = threadOpenFolderSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + try { + const filePath = await resolveCodexSessionPathByThreadId(parsed.data.thread_id); + if (!filePath) { + return reply + .code(404) + .send(envelope(null, "thread session file not found")); + } + if (!(await pathExists(filePath))) { + return reply.code(404).send(envelope(null, "thread session file not found")); + } + const directoryPath = path.dirname(filePath); + if (!(await pathExists(directoryPath))) { + return reply.code(404).send(envelope(null, "thread session folder not found")); + } + await openDirectoryInOs(directoryPath); + return reply.code(200).send( + withSchemaVersion({ + ok: true, + directory_path: directoryPath, + }), + ); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `thread-open-folder-error: ${String(error)}`)); + } + }); +} diff --git a/apps/api-ts/src/app/routes/threads/query.ts b/apps/api-ts/src/app/routes/threads/query.ts new file mode 100644 index 0000000..de002bb --- /dev/null +++ b/apps/api-ts/src/app/routes/threads/query.ts @@ -0,0 +1,20 @@ +import type { FastifyInstance } from "fastify"; +import { getThreadsTs } from "../../../domains/threads/query.js"; +import { + envelope, + type QueryMap, + withSchemaVersion, +} from "../../../lib/utils.js"; + +export function registerThreadQueryRoutes(app: FastifyInstance): void { + app.get<{ Querystring: QueryMap }>("/api/threads", async (req, reply) => { + try { + const data = await getThreadsTs(req.query); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `threads-error: ${String(error)}`)); + } + }); +} diff --git a/apps/api-ts/src/app/routes/threads/schemas.ts b/apps/api-ts/src/app/routes/threads/schemas.ts new file mode 100644 index 0000000..14bf08e --- /dev/null +++ b/apps/api-ts/src/app/routes/threads/schemas.ts @@ -0,0 +1,54 @@ +import { z } from "zod"; +import { parseSafeThreadId } from "../../../domains/threads/thread-id.js"; +import { isRecord } from "../../../lib/utils.js"; + +export const threadIdSchema = z.string().min(1).refine((value) => parseSafeThreadId(value) !== null, { + message: "invalid thread id", +}); + +export const bulkThreadActionPayloadSchema = z.object({ + action: z.enum(["pin", "unpin", "archive_local", "unarchive_local", "resume_command"]), + thread_ids: z.array(threadIdSchema).min(1).max(500), +}); + +export const idsPayloadSchema = z.object({ + ids: z.array(threadIdSchema).min(1).max(500), +}); + +export const analyzeDeletePayloadSchema = z.object({ + ids: z.array(threadIdSchema).min(1).max(500), + session_scan_limit: z.number().int().min(1).max(240).optional(), +}); + +export const pinPayloadSchema = z.object({ + ids: z.array(threadIdSchema).min(1).max(500), + pinned: z.boolean().optional().default(true), +}); + +export const cleanupPayloadSchema = z + .object({ + ids: z.array(threadIdSchema).min(1).max(500), + dry_run: z.boolean().optional().default(true), + options: z.unknown().optional(), + confirm_token: z.string().optional().default(""), + }) + .transform((value) => ({ + ids: value.ids, + dry_run: value.dry_run, + options: isRecord(value.options) ? value.options : {}, + confirm_token: value.confirm_token, + })); + +export const renameThreadSchema = z.object({ + id: threadIdSchema, + title: z.string().min(1), +}); + +export const threadOpenFolderSchema = z.object({ + thread_id: threadIdSchema, +}); + +export const threadForensicsSchema = z.object({ + ids: z.array(threadIdSchema).optional(), + thread_ids: z.array(threadIdSchema).optional(), +}); diff --git a/apps/api-ts/src/app/routes/threads/state-actions.ts b/apps/api-ts/src/app/routes/threads/state-actions.ts new file mode 100644 index 0000000..54435f4 --- /dev/null +++ b/apps/api-ts/src/app/routes/threads/state-actions.ts @@ -0,0 +1,102 @@ +import type { FastifyInstance } from "fastify"; +import { + archiveThreadsLocalTs, + getThreadResumeCommandsTs, + renameThreadTitleTs, + setThreadPinnedTs, +} from "../../../domains/threads/state.js"; +import { invalidateCodexThreadTitleMapCache } from "../../../domains/providers/title-detection.js"; +import { + envelope, + withSchemaVersion, +} from "../../../lib/utils.js"; +import { + idsPayloadSchema, + pinPayloadSchema, + renameThreadSchema, +} from "./schemas.js"; +import type { ThreadRouteDeps } from "./types.js"; + +export function registerThreadStateActionRoutes( + app: FastifyInstance, + deps: ThreadRouteDeps, +): void { + app.post<{ Body: unknown }>("/api/thread-pin", async (req, reply) => { + const parsed = pinPayloadSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + try { + const data = await setThreadPinnedTs(parsed.data.ids, parsed.data.pinned); + if (!data.ok) { + return reply.code(400).send(withSchemaVersion(data)); + } + deps.invalidateOverviewCache(); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply.code(500).send(envelope(null, `thread-pin-error: ${String(error)}`)); + } + }); + + app.post<{ Body: unknown }>( + "/api/thread-archive-local", + async (req, reply) => { + const parsed = idsPayloadSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + try { + const data = await archiveThreadsLocalTs(parsed.data.ids); + if (!data.ok) { + return reply.code(400).send(withSchemaVersion(data)); + } + deps.invalidateOverviewCache(); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `thread-archive-local-error: ${String(error)}`)); + } + }, + ); + + app.post<{ Body: unknown }>( + "/api/thread-resume-command", + async (req, reply) => { + const parsed = idsPayloadSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + try { + const data = getThreadResumeCommandsTs(parsed.data.ids); + if (!data.ok) { + return reply.code(400).send(withSchemaVersion(data)); + } + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `thread-resume-command-error: ${String(error)}`)); + } + }, + ); + + app.post<{ Body: unknown }>("/api/rename-thread", async (req, reply) => { + const parsed = renameThreadSchema.safeParse(req.body); + if (!parsed.success) { + return reply.code(400).send(envelope(null, parsed.error.message)); + } + try { + const data = await renameThreadTitleTs(parsed.data.id, parsed.data.title); + if (!data.ok) { + return reply.code(400).send(withSchemaVersion(data)); + } + deps.invalidateOverviewCache(); + deps.invalidateProviderSessionCache("codex"); + invalidateCodexThreadTitleMapCache(); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply.code(500).send(envelope(null, `rename-thread-error: ${String(error)}`)); + } + }); +} diff --git a/apps/api-ts/src/app/routes/threads/transcript.ts b/apps/api-ts/src/app/routes/threads/transcript.ts new file mode 100644 index 0000000..6d744d7 --- /dev/null +++ b/apps/api-ts/src/app/routes/threads/transcript.ts @@ -0,0 +1,57 @@ +import type { FastifyInstance } from "fastify"; +import { buildSessionTranscript } from "../../../domains/providers/transcript.js"; +import { resolveCodexSessionPathByThreadId } from "../../../domains/providers/search.js"; +import { + envelope, + type QueryMap, + withSchemaVersion, +} from "../../../lib/utils.js"; +import { threadIdSchema } from "./schemas.js"; + +function firstQueryValue(value: string | string[] | undefined): string { + if (Array.isArray(value)) return String(value[0] ?? ""); + return String(value ?? ""); +} + +function clampTranscriptLimit(value: string | string[] | undefined): number { + const parsed = Number(firstQueryValue(value).trim()); + if (!Number.isFinite(parsed)) return 300; + return Math.max(1, Math.min(1000, Math.floor(parsed))); +} + +export function registerThreadTranscriptRoutes(app: FastifyInstance): void { + app.get<{ Querystring: QueryMap }>( + "/api/thread-transcript", + async (req, reply) => { + try { + const threadRaw = Array.isArray(req.query.thread_id) + ? req.query.thread_id[0] + : req.query.thread_id; + const limitRaw = Array.isArray(req.query.limit) + ? req.query.limit[0] + : req.query.limit; + const parsedThreadId = threadIdSchema.safeParse(String(threadRaw ?? "").trim()); + if (!parsedThreadId.success) { + return reply.code(400).send(envelope(null, parsedThreadId.error.message)); + } + const threadId = parsedThreadId.data; + const filePath = await resolveCodexSessionPathByThreadId(threadId); + if (!filePath) { + return reply + .code(404) + .send(envelope(null, "thread session file not found")); + } + const data = await buildSessionTranscript( + "codex", + filePath, + clampTranscriptLimit(limitRaw), + ); + return reply.code(200).send(withSchemaVersion(data)); + } catch (error) { + return reply + .code(500) + .send(envelope(null, `thread-transcript-error: ${String(error)}`)); + } + }, + ); +} diff --git a/apps/api-ts/src/app/routes/threads/types.ts b/apps/api-ts/src/app/routes/threads/types.ts new file mode 100644 index 0000000..2020750 --- /dev/null +++ b/apps/api-ts/src/app/routes/threads/types.ts @@ -0,0 +1,4 @@ +export type ThreadRouteDeps = { + invalidateOverviewCache: () => void; + invalidateProviderSessionCache: (provider: "codex") => void; +}; diff --git a/apps/api-ts/src/lib/update-check.test.ts b/apps/api-ts/src/domains/ops/update-check.test.ts similarity index 100% rename from apps/api-ts/src/lib/update-check.test.ts rename to apps/api-ts/src/domains/ops/update-check.test.ts diff --git a/apps/api-ts/src/lib/update-check.ts b/apps/api-ts/src/domains/ops/update-check.ts similarity index 99% rename from apps/api-ts/src/lib/update-check.ts rename to apps/api-ts/src/domains/ops/update-check.ts index 57892ed..da8bd65 100644 --- a/apps/api-ts/src/lib/update-check.ts +++ b/apps/api-ts/src/domains/ops/update-check.ts @@ -6,7 +6,7 @@ import { THREADLENS_GITHUB_RELEASE_API_URL, THREADLENS_LATEST_RELEASE_URL, UPDATE_CHECK_CACHE_FILE, -} from "./constants.js"; +} from "../../lib/constants.js"; const UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1000; const UPDATE_CHECK_FAILURE_TTL_MS = 15 * 60 * 1000; diff --git a/apps/api-ts/src/domains/providers/matrix.test.ts b/apps/api-ts/src/domains/providers/matrix.test.ts index 3e94e0d..2ae2ace 100644 --- a/apps/api-ts/src/domains/providers/matrix.test.ts +++ b/apps/api-ts/src/domains/providers/matrix.test.ts @@ -174,4 +174,35 @@ describe("provider matrix notes", () => { }, }); }); + + it("does not report ChatGPT cleanup readiness when its runtime roots are missing", () => { + const providers = buildProviderMatrixProviders({ + codexHomes: [], + codexRootExists: false, + codexSessionLogs: 0, + chatGptRootExists: false, + chatGptSessionLogs: 0, + claudeRootExists: false, + claudeSessionLogs: 0, + geminiRootExists: false, + geminiSessionLogs: 0, + geminiRoots: [], + geminiNotes: "History, tmp, and checkpoint files.", + copilotProviderRoots: [], + copilotRootExists: false, + copilotSignalFiles: 0, + }); + const chatgpt = providers.find((provider) => provider.provider === "chatgpt"); + + expect(chatgpt).toMatchObject({ + status: "missing", + capability_level: "unavailable", + capabilities: { + read_sessions: false, + analyze_context: false, + safe_cleanup: false, + hard_delete: false, + }, + }); + }); }); diff --git a/apps/api-ts/src/domains/providers/services/matrix/provider-entries.ts b/apps/api-ts/src/domains/providers/services/matrix/provider-entries.ts index b39fe1a..f11d3c9 100644 --- a/apps/api-ts/src/domains/providers/services/matrix/provider-entries.ts +++ b/apps/api-ts/src/domains/providers/services/matrix/provider-entries.ts @@ -23,8 +23,20 @@ import type { } from "./signals.js"; import type { ProviderMatrixData, + ProviderStatus, } from "../../types.js"; +function providerActionReadiness( + provider: ProviderId, + status: ProviderStatus, +) { + const runtimeReady = status !== "missing"; + return { + safeCleanup: supportsProviderCleanup(provider) && runtimeReady, + hardDelete: supportsProviderHardDelete(provider) && runtimeReady, + }; +} + export function buildProviderMatrixProviders( signals: ProviderMatrixSignals, ): ProviderMatrixData["providers"] { @@ -33,21 +45,23 @@ export function buildProviderMatrixProviders( const claudeStatus = providerStatus(signals.claudeRootExists, signals.claudeSessionLogs); const geminiStatus = providerStatus(signals.geminiRootExists, signals.geminiSessionLogs); const copilotStatus = providerStatus(signals.copilotRootExists, signals.copilotSignalFiles); - const codexCleanupReady = supportsProviderCleanup("codex") && codexStatus !== "missing"; - const codexHardDeleteReady = - supportsProviderHardDelete("codex") && codexStatus !== "missing"; + const codexReady = providerActionReadiness("codex", codexStatus); + const chatGptReady = providerActionReadiness("chatgpt", chatGptStatus); + const claudeReady = providerActionReadiness("claude", claudeStatus); + const geminiReady = providerActionReadiness("gemini", geminiStatus); + const copilotReady = providerActionReadiness("copilot", copilotStatus); return [ { provider: "codex" as ProviderId, name: providerLabel("codex"), status: codexStatus, - capability_level: capabilityLevel(codexStatus, codexCleanupReady), + capability_level: capabilityLevel(codexStatus, codexReady.safeCleanup), capabilities: { read_sessions: signals.codexRootExists, analyze_context: signals.codexSessionLogs > 0, - safe_cleanup: codexCleanupReady, - hard_delete: codexHardDeleteReady, + safe_cleanup: codexReady.safeCleanup, + hard_delete: codexReady.hardDelete, }, evidence: { roots: signals.codexHomes, @@ -59,15 +73,12 @@ export function buildProviderMatrixProviders( provider: "chatgpt" as ProviderId, name: providerLabel("chatgpt"), status: chatGptStatus, - capability_level: capabilityLevel( - chatGptStatus, - supportsProviderCleanup("chatgpt"), - ), + capability_level: capabilityLevel(chatGptStatus, chatGptReady.safeCleanup), capabilities: { read_sessions: signals.chatGptRootExists, analyze_context: signals.chatGptSessionLogs > 0, - safe_cleanup: supportsProviderCleanup("chatgpt"), - hard_delete: supportsProviderHardDelete("chatgpt"), + safe_cleanup: chatGptReady.safeCleanup, + hard_delete: chatGptReady.hardDelete, }, evidence: { roots: [CHAT_DIR], @@ -79,16 +90,12 @@ export function buildProviderMatrixProviders( provider: "claude" as ProviderId, name: providerLabel("claude"), status: claudeStatus, - capability_level: capabilityLevel( - claudeStatus, - supportsProviderCleanup("claude") && claudeStatus !== "missing", - ), + capability_level: capabilityLevel(claudeStatus, claudeReady.safeCleanup), capabilities: { read_sessions: signals.claudeRootExists, analyze_context: signals.claudeSessionLogs > 0, - safe_cleanup: supportsProviderCleanup("claude") && claudeStatus !== "missing", - hard_delete: - supportsProviderHardDelete("claude") && claudeStatus !== "missing", + safe_cleanup: claudeReady.safeCleanup, + hard_delete: claudeReady.hardDelete, }, evidence: { roots: [CLAUDE_HOME, CLAUDE_PROJECTS_DIR, CLAUDE_TRANSCRIPTS_DIR], @@ -100,16 +107,12 @@ export function buildProviderMatrixProviders( provider: "gemini" as ProviderId, name: providerLabel("gemini"), status: geminiStatus, - capability_level: capabilityLevel( - geminiStatus, - supportsProviderCleanup("gemini") && geminiStatus !== "missing", - ), + capability_level: capabilityLevel(geminiStatus, geminiReady.safeCleanup), capabilities: { read_sessions: signals.geminiRootExists, analyze_context: signals.geminiSessionLogs > 0, - safe_cleanup: supportsProviderCleanup("gemini") && geminiStatus !== "missing", - hard_delete: - supportsProviderHardDelete("gemini") && geminiStatus !== "missing", + safe_cleanup: geminiReady.safeCleanup, + hard_delete: geminiReady.hardDelete, }, evidence: { roots: signals.geminiRoots, @@ -121,17 +124,12 @@ export function buildProviderMatrixProviders( provider: "copilot" as ProviderId, name: providerLabel("copilot"), status: copilotStatus, - capability_level: capabilityLevel( - copilotStatus, - supportsProviderCleanup("copilot") && copilotStatus !== "missing", - ), + capability_level: capabilityLevel(copilotStatus, copilotReady.safeCleanup), capabilities: { read_sessions: signals.copilotRootExists, analyze_context: signals.copilotSignalFiles > 0, - safe_cleanup: - supportsProviderCleanup("copilot") && copilotStatus !== "missing", - hard_delete: - supportsProviderHardDelete("copilot") && copilotStatus !== "missing", + safe_cleanup: copilotReady.safeCleanup, + hard_delete: copilotReady.hardDelete, }, evidence: { roots: [ diff --git a/apps/api-ts/src/server.test.ts b/apps/api-ts/src/server.test.ts index 3e728db..7010d95 100644 --- a/apps/api-ts/src/server.test.ts +++ b/apps/api-ts/src/server.test.ts @@ -3,8 +3,8 @@ import path from "node:path"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { FastifyInstance } from "fastify"; import { RECOVERY_EXPORT_ROOT, UPDATE_CHECK_CACHE_FILE } from "./lib/constants"; -import { resetUpdateCheckCacheForTests } from "./lib/update-check"; -import { issueRecoveryBackupDownloadTokenForTests } from "./app/routes/platform/index"; +import { resetUpdateCheckCacheForTests } from "./domains/ops/update-check"; +import { issueRecoveryBackupDownloadTokenForTests } from "./app/routes/system/index"; import { createServer } from "./server"; vi.mock("./domains/threads/state.js", async (importOriginal) => { @@ -423,6 +423,33 @@ describe("api-ts direct endpoints", () => { expect(payload.ok).toBe(false); }); + it("GET /api/conversation-search requires a query", async () => { + const res = await app.inject({ method: "GET", url: "/api/conversation-search" }); + expect(res.statusCode).toBe(400); + const payload = res.json(); + expect(payload.ok).toBe(false); + }); + + it("GET /api/conversation-search rejects invalid providers", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/conversation-search?q=hello&provider=invalid", + }); + expect(res.statusCode).toBe(400); + const payload = res.json(); + expect(payload.ok).toBe(false); + }); + + it("GET /api/conversation-search/session-hits rejects invalid providers", async () => { + const res = await app.inject({ + method: "GET", + url: "/api/conversation-search/session-hits?q=hello&provider=invalid&session_id=s1", + }); + expect(res.statusCode).toBe(400); + const payload = res.json(); + expect(payload.ok).toBe(false); + }); + it("POST /api/provider-session-action supports dry-run preview", async () => { const res = await app.inject({ method: "POST", diff --git a/apps/web/src/app/hooks/useAppData.integration.test.ts b/apps/web/src/app/hooks/useAppData.integration.test.ts index 7c87652..721b6db 100644 --- a/apps/web/src/app/hooks/useAppData.integration.test.ts +++ b/apps/web/src/app/hooks/useAppData.integration.test.ts @@ -280,7 +280,7 @@ describe("useAppData integration", () => { mockUseProvidersData.mockReturnValue( makeProvidersData({ providerSessionRows: [], - selectedSessionPath: "/Users/example/.gemini/tmp/react-spectrum/chats/session-123.json", + selectedSessionPath: "/example/.gemini/tmp/react-spectrum/chats/session-123.json", }), ); @@ -292,12 +292,12 @@ describe("useAppData integration", () => { expect(result.selectedSession?.provider).toBe("gemini"); expect(result.selectedSession?.source).toBe("search_result"); expect(result.selectedSession?.file_path).toBe( - "/Users/example/.gemini/tmp/react-spectrum/chats/session-123.json", + "/example/.gemini/tmp/react-spectrum/chats/session-123.json", ); expect(detailArgs.selectedSession).toMatchObject({ provider: "gemini", source: "search_result", - file_path: "/Users/example/.gemini/tmp/react-spectrum/chats/session-123.json", + file_path: "/example/.gemini/tmp/react-spectrum/chats/session-123.json", }); }); diff --git a/apps/web/src/app/hooks/useAppData.test.ts b/apps/web/src/app/hooks/useAppData.test.ts index 9c948c8..d1d4054 100644 --- a/apps/web/src/app/hooks/useAppData.test.ts +++ b/apps/web/src/app/hooks/useAppData.test.ts @@ -52,10 +52,10 @@ describe("useAppData helpers", () => { }); it("builds a fallback selected session for routed provider detail paths", () => { - const fallback = selectSessionByPath([], "/Users/example/.gemini/tmp/chat/session-123.json", "gemini"); + const fallback = selectSessionByPath([], "/example/.gemini/tmp/chat/session-123.json", "gemini"); expect(fallback).toEqual( - buildFallbackSessionRow("/Users/example/.gemini/tmp/chat/session-123.json", "gemini"), + buildFallbackSessionRow("/example/.gemini/tmp/chat/session-123.json", "gemini"), ); expect(fallback?.probe.ok).toBe(false); expect(fallback?.probe.format).toBe("json"); diff --git a/apps/web/src/features/providers/components/BackupHub.test.tsx b/apps/web/src/features/providers/components/BackupHub.test.tsx index b328e8d..591a5b9 100644 --- a/apps/web/src/features/providers/components/BackupHub.test.tsx +++ b/apps/web/src/features/providers/components/BackupHub.test.tsx @@ -97,7 +97,7 @@ describe("BackupHub", () => { legacyBackupSets: [ { backup_id: "provider_actions/codex/legacy-1", - path: "/Users/example/.codex/local_cleanup_backups/provider_actions/codex/legacy-1", + path: "/example/.codex/local_cleanup_backups/provider_actions/codex/legacy-1", file_count: 2, total_bytes: 256, latest_mtime: "2026-04-24T06:00:00.000Z", @@ -110,7 +110,7 @@ describe("BackupHub", () => { expect(html).toContain("Older backups"); expect(html).toContain('class="provider-backup-legacy-block"'); expect(html).not.toContain('class="provider-backup-legacy-block" open=""'); - expect(html).toContain("/Users/example/.codex/local_cleanup_backups/provider_actions/codex/legacy-1"); + expect(html).toContain("/example/.codex/local_cleanup_backups/provider_actions/codex/legacy-1"); }); it("shows grouped backup progress while multi-provider backup is running", () => { diff --git a/apps/web/src/features/providers/model/providerPanelPresentationModel.test.ts b/apps/web/src/features/providers/model/providerPanelPresentationModel.test.ts index 8404539..01898f7 100644 --- a/apps/web/src/features/providers/model/providerPanelPresentationModel.test.ts +++ b/apps/web/src/features/providers/model/providerPanelPresentationModel.test.ts @@ -262,7 +262,7 @@ describe("providerPanelPresentationModel", () => { legacy_backup_sets: [ { backup_id: "provider_actions/codex/legacy", - path: "/Users/example/.codex/local_cleanup_backups/provider_actions/codex/legacy", + path: "/example/.codex/local_cleanup_backups/provider_actions/codex/legacy", file_count: 4, total_bytes: 256, latest_mtime: "2026-04-22T07:00:00.000Z", @@ -293,7 +293,7 @@ describe("providerPanelPresentationModel", () => { legacyBackupSets?: Array<{ path: string }>; } ).legacyBackupSets?.[0]?.path, - ).toContain("/Users/example/.codex/local_cleanup_backups"); + ).toContain("/example/.codex/local_cleanup_backups"); }); it("uses provider-scoped backup inventory on provider surfaces", () => { diff --git a/apps/web/src/features/search/model/searchPanelModel.test.ts b/apps/web/src/features/search/model/searchPanelModel.test.ts index fc006bb..fe4b724 100644 --- a/apps/web/src/features/search/model/searchPanelModel.test.ts +++ b/apps/web/src/features/search/model/searchPanelModel.test.ts @@ -102,7 +102,7 @@ describe("compactSearchTitle", () => { title: "rollout-2026-04-16T23-47-06-019d96c2-9123-7481-9127-224fad716008", display_title: "", file_path: - "/Users/example/.codex-cli/sessions/2026/04/16/rollout-2026-04-16T23-47-06-019d96c2-9123-7481-9127-224fad716008.jsonl", + "/example/.codex-cli/sessions/2026/04/16/rollout-2026-04-16T23-47-06-019d96c2-9123-7481-9127-224fad716008.jsonl", mtime: "2026-04-16T23:47:06.000Z", match_kind: "title", snippet: "rollout-2026-04-16T23-47-06-019d96c2-9123-7481-9127-224fad716008", diff --git a/apps/web/src/features/threads/components/ForensicsPanel.test.tsx b/apps/web/src/features/threads/components/ForensicsPanel.test.tsx index 949b80d..19952be 100644 --- a/apps/web/src/features/threads/components/ForensicsPanel.test.tsx +++ b/apps/web/src/features/threads/components/ForensicsPanel.test.tsx @@ -217,8 +217,8 @@ describe("ForensicsPanel", () => { strength: "strong", evidence_kind: "search_text", matched_field: "copied_context", - matched_value: "/Users/example/workspace/raw-value", - matched_excerpt: "sample excerpt from /Users/example/workspace/raw-excerpt", + matched_value: "/home/example/workspace/raw-value", + matched_excerpt: "sample excerpt from /home/example/workspace/raw-excerpt", }, ], }, @@ -227,9 +227,9 @@ describe("ForensicsPanel", () => { analysisRaw={{ ok: true, data: { - matched_excerpt: "sample excerpt from /Users/example/workspace/raw-excerpt", - matched_value: "/Users/example/workspace/raw-value", - file_path: "/Users/example/workspace/thread.jsonl", + matched_excerpt: "sample excerpt from /home/example/workspace/raw-excerpt", + matched_value: "/home/example/workspace/raw-value", + file_path: "/home/example/workspace/thread.jsonl", }, }} cleanupRaw={null} @@ -255,7 +255,7 @@ describe("ForensicsPanel", () => { expect(html).not.toContain("Impact payload (JSON)"); expect(html).not.toContain("raw-excerpt"); expect(html).not.toContain("raw-value"); - expect(html).not.toContain("/Users/example/workspace/thread.jsonl"); + expect(html).not.toContain("/home/example/workspace/thread.jsonl"); }); it("does not crash when readable cross-session evidence renders in a browser-like runtime without process", () => { @@ -294,8 +294,8 @@ describe("ForensicsPanel", () => { evidence_kind: "command_output", matched_field: "payload.command", matched_event: "event_msg", - matched_value: "/Users/example/workspace/raw-value", - matched_excerpt: "sample excerpt from /Users/example/workspace/raw-excerpt", + matched_value: "/home/example/workspace/raw-value", + matched_excerpt: "sample excerpt from /home/example/workspace/raw-excerpt", }, ], }, diff --git a/docs/ADDING_PROVIDER.md b/docs/ADDING_PROVIDER.md index 1cba6df..acab622 100644 --- a/docs/ADDING_PROVIDER.md +++ b/docs/ADDING_PROVIDER.md @@ -160,7 +160,8 @@ Useful existing tests: - `apps/api-ts/src/domains/providers/path-safety.test.ts` - `apps/api-ts/src/domains/providers/parser-fixtures.test.ts` -- `apps/api-ts/src/domains/providers/search.test.ts` +- `apps/api-ts/src/domains/providers/services/search/session-search.test.ts` +- `apps/api-ts/src/domains/providers/services/search/session-hit-search.test.ts` - `apps/api-ts/src/domains/providers/search-policy.test.ts` - `apps/api-ts/src/domains/providers/transcript.test.ts` - `apps/api-ts/src/domains/providers/matrix.test.ts` diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 21db959..edb7efe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -28,9 +28,9 @@ ThreadLens uses one local Fastify backend shared by web, TUI, and desktop. `apps/api-ts/src/app/routes` -- `platform.ts`: health, version, runtime, overview, recovery, smoke, execution graph, alert hooks, agent loops -- `providers.ts`: provider matrix, sessions, parser health, conversation search, session transcripts, provider session actions -- `threads.ts`: thread list, thread mutations, forensics, impact analysis, local cleanup +- `system/`: health, version, update check, runtime, recovery, smoke, execution graph, alert hooks, agent loops +- `providers/`: provider matrix, sessions, parser health, conversation search, session transcripts, provider session actions +- `threads/`: thread list/query, thread mutations, forensics, impact analysis, local cleanup ## Backend Layout @@ -40,19 +40,38 @@ ThreadLens uses one local Fastify backend shared by web, TUI, and desktop. app/ create-server.ts routes/ - platform.ts - providers.ts - threads.ts + providers/ + index.ts + actions.ts + matrix.ts + search.ts + sessions.ts + transcript.ts + system/ + index.ts + recovery.ts + threads/ + index.ts + bulk-actions.ts + cleanup.ts + forensics.ts + open-folder.ts + query.ts + state-actions.ts + transcript.ts domains/ providers/ - matrix.ts - path-safety.ts - probe.ts - search-helpers.ts - search.ts - actions.ts - title-detection.ts - transcript.ts + adapters/ + services/ + actions/ + matrix/ + search/ + transcripts/ + shared/ + capabilities.ts + constants.ts + index.ts + registry.ts types.ts threads/ query.ts @@ -70,11 +89,21 @@ domains/ observatory.ts alert-hooks.ts agent-loops.ts + update-check.ts +platform/ + paths.ts lib/ constants.ts - providers.ts - recovery.ts - update-check.ts + envelope.ts + fs.ts + guards.ts + http.ts + json.ts + process.ts + query.ts + schemas.ts + text.ts + time.ts utils.ts ``` @@ -99,7 +128,9 @@ i18n/ localized message catalogs and locale loading - `create-server.ts` stays focused on bootstrap and route registration - Route handlers register HTTP; domain logic lives under `domains/` -- `lib/` stays for shared constants and focused helpers +- `lib/` stays for focused cross-cutting helpers and compatibility facades +- `platform/` owns OS/env/path abstraction and does not import app or domains +- Provider services do not import provider-specific adapter modules directly; they go through the registry - Web, TUI, and desktop reuse the same API contracts ## Safety