diff --git a/src/components/chat/ToolCallDisplay.tsx b/src/components/chat/ToolCallDisplay.tsx index cf884bb4..746b4745 100644 --- a/src/components/chat/ToolCallDisplay.tsx +++ b/src/components/chat/ToolCallDisplay.tsx @@ -106,38 +106,41 @@ export function ToolCallDisplay({ {formatOutput(toolCall.output)} ) : ( - <> - {toolCall.input !== undefined && toolCall.input !== null && ( -
-
- Input: -
-
-										{formatOutput(toolCall.input)}
-									
-
- )} - {toolCall.output !== undefined && toolCall.output !== null && ( -
-
- Output: -
-
-										{formatOutput(toolCall.output).slice(0, 500)}
-									
-
- )} - {toolCall.error !== undefined && ( -
-
- Error: -
-
-										{formatOutput(toolCall.error)}
-									
-
- )} - + (() => { + const details = toolInfo.getDetails?.({ + input: toolCall.input, + output: toolCall.output, + status: toolCall.status, + }); + return ( + <> + {details && ( +
+											{details}
+										
+ )} + {toolCall.error !== undefined && ( +
+											{formatOutput(toolCall.error)}
+										
+ )} + {!details && toolCall.error === undefined && ( + <> + {toolCall.output !== undefined && + toolCall.output !== null ? ( +
+													{formatOutput(toolCall.output).slice(0, 600)}
+												
+ ) : ( +
+ No additional details. +
+ )} + + )} + + ); + })() )} )} diff --git a/src/components/chat/tools/registry.tsx b/src/components/chat/tools/registry.tsx index 68bb626e..adb7a2bf 100644 --- a/src/components/chat/tools/registry.tsx +++ b/src/components/chat/tools/registry.tsx @@ -31,6 +31,16 @@ export interface ToolInfo { openFileOnClick?: boolean; /** Extract file path from tool input for openFileOnClick */ getFilePath?: (input: unknown) => string | null; + /** + * Short text shown when the tool call is expanded. Should be a few short + * lines at most. Return null/empty to render nothing extra (errors are + * always shown by the host). + */ + getDetails?: (args: { + input: unknown; + output: unknown; + status: "running" | "success" | "error"; + }) => string | null; } /** @@ -321,3 +331,46 @@ registerTool("context7_get-library-docs", { return typeof obj.topic === "string" ? obj.topic : null; }, }); + +// Doce Preview tools (internal) +function readField(value: unknown, key: string): string | null { + if (!value || typeof value !== "object") return null; + const v = (value as Record)[key]; + return typeof v === "string" && v.trim() ? v.trim() : null; +} + +registerTool("get_doce_preview_status", { + name: "Preview Status", + icon: Search, + getDetails: ({ output }) => + readField(output, "summary") ?? readField(output, "error"), +}); + +registerTool("read_doce_preview_logs", { + name: "Preview Logs", + icon: FileText, + getContext: (input) => { + if (!input || typeof input !== "object") return null; + const mode = (input as Record).mode as string; + return mode ?? "summary"; + }, + getDetails: ({ output }) => { + const signal = readField(output, "extractedSignal"); + const summary = readField(output, "summary"); + const error = readField(output, "error"); + if (signal && summary) return `${summary}\n${signal}`; + return summary ?? signal ?? error; + }, +}); + +registerTool("restart_doce_preview", { + name: "Restart Preview", + icon: RefreshCw, + getDetails: ({ input, output }) => { + const reason = readField(input, "reason"); + const summary = readField(output, "summary"); + const error = readField(output, "error"); + const parts = [reason, summary ?? error].filter(Boolean); + return parts.length ? parts.join("\n") : null; + }, +}); diff --git a/src/pages/api/internal/ai-tools/preview/logs.ts b/src/pages/api/internal/ai-tools/preview/logs.ts new file mode 100644 index 00000000..adc0c5aa --- /dev/null +++ b/src/pages/api/internal/ai-tools/preview/logs.ts @@ -0,0 +1,42 @@ +import type { APIRoute } from "astro"; +import { readDocePreviewLogs } from "@/server/ai-tools/doce-preview"; +import { readDocePreviewLogsInput } from "@/server/ai-tools/doce-preview/schemas"; +import { + authorizeInternalCall, + jsonResponse, + parseInternalRequest, +} from "@/server/ai-tools/internalRequest"; +import { logger } from "@/server/logger"; + +export const POST: APIRoute = async ({ request }) => { + const parsed = await parseInternalRequest(request); + if (!parsed.ok) return parsed.response; + + const auth = await authorizeInternalCall(parsed.body); + if (!auth.ok) return auth.response; + + const inputResult = readDocePreviewLogsInput.safeParse({ + projectId: auth.result.projectId, + mode: parsed.body.mode, + maxBytes: parsed.body.maxBytes, + offset: parsed.body.offset, + }); + if (!inputResult.success) { + return jsonResponse(400, { error: inputResult.error.message }); + } + + try { + const output = await readDocePreviewLogs( + inputResult.data, + auth.result.ownerUserId, + ); + return jsonResponse(200, output); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal error"; + logger.error( + { projectId: auth.result.projectId, error: message }, + "read_doce_preview_logs failed", + ); + return jsonResponse(500, { error: message }); + } +}; diff --git a/src/pages/api/internal/ai-tools/preview/restart.ts b/src/pages/api/internal/ai-tools/preview/restart.ts new file mode 100644 index 00000000..db4b79f4 --- /dev/null +++ b/src/pages/api/internal/ai-tools/preview/restart.ts @@ -0,0 +1,40 @@ +import type { APIRoute } from "astro"; +import { restartDocePreview } from "@/server/ai-tools/doce-preview"; +import { restartDocePreviewInput } from "@/server/ai-tools/doce-preview/schemas"; +import { + authorizeInternalCall, + jsonResponse, + parseInternalRequest, +} from "@/server/ai-tools/internalRequest"; +import { logger } from "@/server/logger"; + +export const POST: APIRoute = async ({ request }) => { + const parsed = await parseInternalRequest(request); + if (!parsed.ok) return parsed.response; + + const auth = await authorizeInternalCall(parsed.body); + if (!auth.ok) return auth.response; + + const inputResult = restartDocePreviewInput.safeParse({ + projectId: auth.result.projectId, + reason: parsed.body.reason, + }); + if (!inputResult.success) { + return jsonResponse(400, { error: inputResult.error.message }); + } + + try { + const output = await restartDocePreview( + inputResult.data, + auth.result.ownerUserId, + ); + return jsonResponse(200, output); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal error"; + logger.error( + { projectId: auth.result.projectId, error: message }, + "restart_doce_preview failed", + ); + return jsonResponse(500, { error: message }); + } +}; diff --git a/src/pages/api/internal/ai-tools/preview/status.ts b/src/pages/api/internal/ai-tools/preview/status.ts new file mode 100644 index 00000000..af6e4256 --- /dev/null +++ b/src/pages/api/internal/ai-tools/preview/status.ts @@ -0,0 +1,39 @@ +import type { APIRoute } from "astro"; +import { getDocePreviewStatus } from "@/server/ai-tools/doce-preview"; +import { getDocePreviewStatusInput } from "@/server/ai-tools/doce-preview/schemas"; +import { + authorizeInternalCall, + jsonResponse, + parseInternalRequest, +} from "@/server/ai-tools/internalRequest"; +import { logger } from "@/server/logger"; + +export const POST: APIRoute = async ({ request }) => { + const parsed = await parseInternalRequest(request); + if (!parsed.ok) return parsed.response; + + const auth = await authorizeInternalCall(parsed.body); + if (!auth.ok) return auth.response; + + const inputResult = getDocePreviewStatusInput.safeParse({ + projectId: auth.result.projectId, + }); + if (!inputResult.success) { + return jsonResponse(400, { error: inputResult.error.message }); + } + + try { + const output = await getDocePreviewStatus( + inputResult.data, + auth.result.ownerUserId, + ); + return jsonResponse(200, output); + } catch (error) { + const message = error instanceof Error ? error.message : "Internal error"; + logger.error( + { projectId: auth.result.projectId, error: message }, + "get_doce_preview_status failed", + ); + return jsonResponse(500, { error: message }); + } +}; diff --git a/src/server/ai-tools/doce-preview/index.ts b/src/server/ai-tools/doce-preview/index.ts new file mode 100644 index 00000000..9c5c6c33 --- /dev/null +++ b/src/server/ai-tools/doce-preview/index.ts @@ -0,0 +1,23 @@ +export { + getDocePreviewStatus, + readDocePreviewLogs, + restartDocePreview, +} from "./service"; + +export { + getDocePreviewStatusInput, + getDocePreviewStatusOutput, + readDocePreviewLogsInput, + readDocePreviewLogsOutput, + restartDocePreviewInput, + restartDocePreviewOutput, +} from "./schemas"; + +export type { + GetDocePreviewStatusInput, + GetDocePreviewStatusOutput, + ReadDocePreviewLogsInput, + ReadDocePreviewLogsOutput, + RestartDocePreviewInput, + RestartDocePreviewOutput, +} from "./schemas"; diff --git a/src/server/ai-tools/doce-preview/schemas.ts b/src/server/ai-tools/doce-preview/schemas.ts new file mode 100644 index 00000000..3c73ea85 --- /dev/null +++ b/src/server/ai-tools/doce-preview/schemas.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; + +// ============================================================================ +// get_doce_preview_status +// ============================================================================ + +export const getDocePreviewStatusInput = z.object({ + projectId: z.string().min(1), +}); + +export const getDocePreviewStatusOutput = z.object({ + ok: z.boolean(), + projectId: z.string(), + projectStatus: z.string(), + preview: z.object({ + reachable: z.boolean(), + url: z.string().optional(), + httpStatus: z.number().int().optional(), + }), + containers: z.array( + z.object({ + service: z.string(), + state: z.string(), + health: z.string().optional(), + }), + ), + logStreamingActive: z.boolean().optional(), + summary: z.string(), +}); + +export type GetDocePreviewStatusInput = z.infer< + typeof getDocePreviewStatusInput +>; +export type GetDocePreviewStatusOutput = z.infer< + typeof getDocePreviewStatusOutput +>; + +// ============================================================================ +// read_doce_preview_logs +// ============================================================================ + +export const readDocePreviewLogsInput = z.object({ + projectId: z.string().min(1), + mode: z.enum(["summary", "tail", "sinceOffset"]).default("summary"), + maxBytes: z.number().int().min(256).max(16384).optional(), + offset: z.number().int().min(0).optional(), +}); + +export const readDocePreviewLogsOutput = z.object({ + ok: z.boolean(), + projectId: z.string(), + mode: z.enum(["summary", "tail", "sinceOffset"]), + content: z.string().optional(), + nextOffset: z.number().int().optional(), + truncated: z.boolean().optional(), + extractedSignal: z.string().nullable().optional(), + summary: z.string(), +}); + +export type ReadDocePreviewLogsInput = z.infer< + typeof readDocePreviewLogsInput +>; +export type ReadDocePreviewLogsOutput = z.infer< + typeof readDocePreviewLogsOutput +>; + +// ============================================================================ +// restart_doce_preview +// ============================================================================ + +export const restartDocePreviewInput = z.object({ + projectId: z.string().min(1), + reason: z.string().max(300).optional(), +}); + +export const restartDocePreviewOutput = z.object({ + ok: z.boolean(), + projectId: z.string(), + restarted: z.boolean(), + command: z.literal("docker compose restart preview"), + previewReachableAfterRestart: z.boolean().optional(), + summary: z.string(), + error: z.string().optional(), +}); + +export type RestartDocePreviewInput = z.infer< + typeof restartDocePreviewInput +>; +export type RestartDocePreviewOutput = z.infer< + typeof restartDocePreviewOutput +>; diff --git a/src/server/ai-tools/doce-preview/service.ts b/src/server/ai-tools/doce-preview/service.ts new file mode 100644 index 00000000..c6b0920f --- /dev/null +++ b/src/server/ai-tools/doce-preview/service.ts @@ -0,0 +1,308 @@ +import * as path from "node:path"; +import { composePs, parseComposePs, runComposeCommand } from "@/server/docker/compose"; +import { + extractLastErrorLine, + readLogFromOffset, + readLogTail, +} from "@/server/docker/logs"; +import { logger } from "@/server/logger"; +import { checkPreviewReady } from "@/server/projects/health"; +import { getProjectPreviewPath } from "@/server/projects/paths"; +import { getProjectById } from "@/server/projects/projects.model"; +import type { + GetDocePreviewStatusInput, + GetDocePreviewStatusOutput, + ReadDocePreviewLogsInput, + ReadDocePreviewLogsOutput, + RestartDocePreviewInput, + RestartDocePreviewOutput, +} from "./schemas"; + +// ============================================================================ +// get_doce_preview_status +// ============================================================================ + +export async function getDocePreviewStatus( + input: GetDocePreviewStatusInput, + userId: string, +): Promise { + const { projectId } = input; + + const project = await loadAndVerifyProject(projectId, userId); + if (!project) { + return buildErrorStatus(projectId, "Project not found or access denied"); + } + + const previewPath = getProjectPreviewPath(projectId); + const composeResult = await composePs(projectId, previewPath); + const containers = composeResult.success + ? parseComposePs(composeResult.stdout) + : []; + + const previewReachable = await checkPreviewReady(projectId); + + const summary = buildStatusSummary({ + projectStatus: project.status, + previewReachable, + containers, + }); + + return { + ok: true, + projectId, + projectStatus: project.status, + preview: { + reachable: previewReachable, + }, + containers: containers.map((c) => ({ + service: c.service, + state: c.state, + health: c.health, + })), + summary, + }; +} + +function buildStatusSummary(args: { + projectStatus: string; + previewReachable: boolean; + containers: Array<{ service: string; state: string }>; +}): string { + if (args.previewReachable) { + return "Preview is reachable and serving traffic."; + } + + const previewContainer = args.containers.find((c) => c.service === "preview"); + if (!previewContainer) { + return "Preview container not found; containers may not be running."; + } + + if (previewContainer.state !== "running") { + return `Preview container is ${previewContainer.state}.`; + } + + return "Preview container is running but not responding to health checks."; +} + +function buildErrorStatus( + projectId: string, + message: string, +): GetDocePreviewStatusOutput { + return { + ok: false, + projectId, + projectStatus: "unknown", + preview: { reachable: false }, + containers: [], + summary: message, + }; +} + +// ============================================================================ +// read_doce_preview_logs +// ============================================================================ + +export async function readDocePreviewLogs( + input: ReadDocePreviewLogsInput, + userId: string, +): Promise { + const { projectId, mode, maxBytes = 8192, offset } = input; + + const project = await loadAndVerifyProject(projectId, userId); + if (!project) { + return buildErrorLogs(projectId, mode, "Project not found or access denied"); + } + + const previewPath = getProjectPreviewPath(projectId); + const logsDir = path.join(previewPath, "logs"); + + try { + if (mode === "summary") { + return await readLogsSummary(projectId, logsDir); + } + + if (mode === "tail") { + return await readLogsTail(projectId, logsDir, maxBytes); + } + + if (mode === "sinceOffset" && offset !== undefined) { + return await readLogsSince(projectId, logsDir, offset); + } + + return buildErrorLogs(projectId, mode, "Invalid offset for sinceOffset mode"); + } catch (error) { + logger.error({ error, projectId, mode }, "Failed to read preview logs"); + return buildErrorLogs( + projectId, + mode, + error instanceof Error ? error.message : "Unknown error", + ); + } +} + +async function readLogsSummary( + projectId: string, + logsDir: string, +): Promise { + const signal = await extractLastErrorLine(logsDir); + + return { + ok: true, + projectId, + mode: "summary", + extractedSignal: signal, + summary: signal + ? `Last error found in recent logs: ${signal}` + : "No obvious error found in recent logs.", + }; +} + +async function readLogsTail( + projectId: string, + logsDir: string, + maxBytes: number, +): Promise { + const { content, offset, truncated } = await readLogTail(logsDir, maxBytes); + const signal = await extractLastErrorLine(logsDir); + + return { + ok: true, + projectId, + mode: "tail", + content, + nextOffset: offset, + truncated, + extractedSignal: signal, + summary: signal + ? `Returned recent log tail; likely issue: ${signal}` + : "Returned recent log tail; no obvious error detected.", + }; +} + +async function readLogsSince( + projectId: string, + logsDir: string, + offset: number, +): Promise { + const { content, nextOffset } = await readLogFromOffset(logsDir, offset); + + return { + ok: true, + projectId, + mode: "sinceOffset", + content, + nextOffset, + summary: content + ? `Read ${content.length} bytes of new log content.` + : "No new log content since last offset.", + }; +} + +function buildErrorLogs( + projectId: string, + mode: string, + message: string, +): ReadDocePreviewLogsOutput { + return { + ok: false, + projectId, + mode: mode as "summary" | "tail" | "sinceOffset", + summary: message, + }; +} + +// ============================================================================ +// restart_doce_preview +// ============================================================================ + +const RESTART_CHECK_TIMEOUT_MS = 30_000; +const RESTART_CHECK_INTERVAL_MS = 1000; + +export async function restartDocePreview( + input: RestartDocePreviewInput, + userId: string, +): Promise { + const { projectId, reason } = input; + + logger.info({ projectId, reason }, "Restarting preview container"); + + const project = await loadAndVerifyProject(projectId, userId); + if (!project) { + return buildErrorRestart(projectId, "Project not found or access denied"); + } + + const previewPath = getProjectPreviewPath(projectId); + + const result = await runComposeCommand(projectId, previewPath, [ + "restart", + "preview", + ]); + + if (!result.success) { + logger.error( + { projectId, error: result.stderr }, + "Failed to restart preview container", + ); + return buildErrorRestart( + projectId, + `Restart command failed: ${result.stderr.slice(0, 200)}`, + ); + } + + const reachable = await waitForPreviewReady(projectId); + + return { + ok: reachable, + projectId, + restarted: true, + command: "docker compose restart preview", + previewReachableAfterRestart: reachable, + summary: reachable + ? "Preview restarted successfully and is reachable." + : "Restart command succeeded but preview is still unhealthy.", + }; +} + +async function waitForPreviewReady(projectId: string): Promise { + const deadline = Date.now() + RESTART_CHECK_TIMEOUT_MS; + + while (Date.now() < deadline) { + if (await checkPreviewReady(projectId)) { + return true; + } + await sleep(RESTART_CHECK_INTERVAL_MS); + } + + return false; +} + +function buildErrorRestart( + projectId: string, + message: string, +): RestartDocePreviewOutput { + return { + ok: false, + projectId, + restarted: false, + command: "docker compose restart preview", + previewReachableAfterRestart: false, + summary: message, + error: message, + }; +} + +// ============================================================================ +// Shared helpers +// ============================================================================ + +async function loadAndVerifyProject(projectId: string, userId: string) { + const project = await getProjectById(projectId); + if (!project || project.ownerUserId !== userId) { + return null; + } + return project; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/server/ai-tools/internalRequest.ts b/src/server/ai-tools/internalRequest.ts new file mode 100644 index 00000000..1b6abf46 --- /dev/null +++ b/src/server/ai-tools/internalRequest.ts @@ -0,0 +1,90 @@ +import { logger } from "@/server/logger"; +import { getProjectById } from "@/server/projects/projects.model"; +import { verifyProjectInternalToken } from "./projectToken"; + +export interface InternalCallResult { + projectId: string; + ownerUserId: string; +} + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +export interface InternalRequestBody { + projectId?: unknown; + token?: unknown; +} + +export async function parseInternalRequest( + request: Request, +): Promise< + { ok: true; body: Record } | { ok: false; response: Response } +> { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return { ok: false, response: jsonResponse(400, { error: "Invalid JSON" }) }; + } + + if (typeof raw !== "object" || raw === null) { + return { + ok: false, + response: jsonResponse(400, { error: "Body must be an object" }), + }; + } + + return { ok: true, body: raw as Record }; +} + +export async function authorizeInternalCall( + body: Record, +): Promise< + { ok: true; result: InternalCallResult } | { ok: false; response: Response } +> { + const projectId = + typeof body.projectId === "string" ? body.projectId : undefined; + const token = typeof body.token === "string" ? body.token : undefined; + + if (!projectId) { + return { + ok: false, + response: jsonResponse(400, { error: "projectId is required" }), + }; + } + + if (!token) { + return { + ok: false, + response: jsonResponse(401, { error: "token is required" }), + }; + } + + const valid = await verifyProjectInternalToken(projectId, token); + if (!valid) { + logger.warn({ projectId }, "Rejected internal tool call: invalid token"); + return { + ok: false, + response: jsonResponse(403, { error: "Invalid project token" }), + }; + } + + const project = await getProjectById(projectId); + if (!project) { + return { + ok: false, + response: jsonResponse(404, { error: "Project not found" }), + }; + } + + return { + ok: true, + result: { projectId, ownerUserId: project.ownerUserId }, + }; +} + +export { jsonResponse }; diff --git a/src/server/ai-tools/projectToken.ts b/src/server/ai-tools/projectToken.ts new file mode 100644 index 00000000..7ab50584 --- /dev/null +++ b/src/server/ai-tools/projectToken.ts @@ -0,0 +1,61 @@ +import { randomBytes } from "node:crypto"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { getProjectPreviewPath } from "@/server/projects/paths"; + +const TOKEN_FILENAME = ".doce-internal-token"; +const TOKEN_BYTES = 32; + +function getTokenPath(projectId: string): string { + return path.join(getProjectPreviewPath(projectId), TOKEN_FILENAME); +} + +export async function ensureProjectInternalToken( + projectId: string, +): Promise { + const tokenPath = getTokenPath(projectId); + + try { + const existing = (await fs.readFile(tokenPath, "utf-8")).trim(); + if (existing.length >= 32) { + return existing; + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + } + + const token = randomBytes(TOKEN_BYTES).toString("hex"); + await fs.mkdir(path.dirname(tokenPath), { recursive: true }); + await fs.writeFile(tokenPath, `${token}\n`, { mode: 0o600 }); + return token; +} + +export async function readProjectInternalToken( + projectId: string, +): Promise { + try { + const token = (await fs.readFile(getTokenPath(projectId), "utf-8")).trim(); + return token.length >= 32 ? token : null; + } catch { + return null; + } +} + +export async function verifyProjectInternalToken( + projectId: string, + candidate: string | null | undefined, +): Promise { + if (!candidate) return false; + const stored = await readProjectInternalToken(projectId); + if (!stored) return false; + if (stored.length !== candidate.length) return false; + + let mismatch = 0; + for (let i = 0; i < stored.length; i += 1) { + mismatch |= stored.charCodeAt(i) ^ candidate.charCodeAt(i); + } + return mismatch === 0; +} diff --git a/src/server/opencode/config.ts b/src/server/opencode/config.ts index 930b4969..f5c7d253 100644 --- a/src/server/opencode/config.ts +++ b/src/server/opencode/config.ts @@ -2,6 +2,7 @@ import * as fs from "node:fs/promises"; import * as path from "node:path"; import { logger } from "@/server/logger"; import { DOCE_COMPACTION_PLUGIN_SOURCE } from "@/server/opencode/doceCompactionPluginSource"; +import { DOCE_PREVIEW_TOOL_FILES } from "@/server/opencode/docePreviewToolsSource"; import { getDataPath, getGlobalOpencodeConfigPath, @@ -105,6 +106,21 @@ async function ensureGlobalDoceCompactionPlugin(): Promise { ); } +async function ensureGlobalDocePreviewTools(): Promise { + const toolsDirectory = path.join(getDataPath(), "opencode", "tools"); + await fs.mkdir(toolsDirectory, { recursive: true }); + + for (const { filename, source } of DOCE_PREVIEW_TOOL_FILES) { + const filePath = path.join(toolsDirectory, filename); + await fs.writeFile(filePath, source); + } + + logger.debug( + { toolsDirectory, count: DOCE_PREVIEW_TOOL_FILES.length }, + "Ensured doce preview OpenCode custom tools", + ); +} + export async function ensureGlobalOpencodeConfig(): Promise { const configPath = getGlobalOpencodeConfigPath(); await fs.mkdir(path.dirname(configPath), { recursive: true }); @@ -118,5 +134,6 @@ export async function ensureGlobalOpencodeConfig(): Promise { await fs.writeFile(configPath, `${JSON.stringify(nextConfig, null, 2)}\n`); await ensureGlobalDoceCompactionPlugin(); + await ensureGlobalDocePreviewTools(); logger.debug({ configPath }, "Ensured permissive global OpenCode config"); } diff --git a/src/server/opencode/docePreviewToolsSource.ts b/src/server/opencode/docePreviewToolsSource.ts new file mode 100644 index 00000000..ee309758 --- /dev/null +++ b/src/server/opencode/docePreviewToolsSource.ts @@ -0,0 +1,138 @@ +const SHARED_HELPER = ` +import * as fs from "node:fs/promises"; + +const TOKEN_FILENAME = ".doce-internal-token"; +const SEP = "/"; + +function resolveDirectory(context) { + if (!context) return null; + return ( + context.directory || + context.worktree || + context.cwd || + (context.session && (context.session.directory || context.session.worktree)) || + null + ); +} + +async function readToken(directory) { + try { + const tokenPath = directory.replace(/\\/+$/, "") + SEP + TOKEN_FILENAME; + const content = await fs.readFile(tokenPath, "utf-8"); + return content.trim() || null; + } catch { + return null; + } +} + +function extractProjectId(directory) { + if (!directory || typeof directory !== "string") return null; + const segments = directory.split(SEP).filter(Boolean); + const previewIndex = segments.lastIndexOf("preview"); + if (previewIndex < 1) return null; + return segments[previewIndex - 1] ?? null; +} + +function getBaseUrl() { + return process.env.DOCE_INTERNAL_BASE_URL || "http://127.0.0.1:4321"; +} + +async function callDoceInternal(action, context, extra) { + const directory = resolveDirectory(context); + if (!directory) { + return { + ok: false, + error: "OpenCode context did not provide a directory", + contextKeys: context ? Object.keys(context) : [], + }; + } + + const projectId = extractProjectId(directory); + if (!projectId) { + return { + ok: false, + error: "Could not resolve projectId from session directory", + directory, + }; + } + + const token = await readToken(directory); + if (!token) { + return { ok: false, error: "Missing internal project token", directory }; + } + + const url = getBaseUrl() + "/api/internal/ai-tools/preview/" + action; + const body = { projectId, token, ...(extra || {}) }; + + const response = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + const text = await response.text(); + let parsed; + try { + parsed = JSON.parse(text); + } catch { + parsed = { raw: text }; + } + + if (!response.ok) { + return { ok: false, status: response.status, error: parsed?.error || text }; + } + + return parsed; +} +`.trim(); + +function toolFile(body: string): string { + return `import { tool } from "@opencode-ai/plugin";\n${SHARED_HELPER}\n\n${body}\n`; +} + +// NOTE: We intentionally do NOT declare an "args" field on these tool +// definitions. OpenCode 1.14.48 has a bug where any tool that declares an +// "args" field (even an empty {}) fails its first call with +// 'undefined is not an object (evaluating "g.split")' inside the runtime +// wrapper. Tools without "args" execute correctly. So these tools take no +// arguments from the model and read sensible defaults from the session +// context (directory + project token). + +export const GET_DOCE_PREVIEW_STATUS_SOURCE = toolFile(` +export default tool({ + description: + "Check whether the doce preview server is healthy and reachable. Returns container state and a human-readable summary. Use this first when the preview seems broken. Takes no arguments.", + async execute(_args, context) { + return callDoceInternal("status", context); + }, +}); +`.trim()); + +export const READ_DOCE_PREVIEW_LOGS_SOURCE = toolFile(` +export default tool({ + description: + "Read recent doce preview logs (last error line + a tail of recent log bytes). Use this to diagnose preview issues. Takes no arguments.", + async execute(_args, context) { + return callDoceInternal("logs", context, { mode: "tail", maxBytes: 4096 }); + }, +}); +`.trim()); + +export const RESTART_DOCE_PREVIEW_SOURCE = toolFile(` +export default tool({ + description: + "Restart the doce preview container. Only call this after confirming via get_doce_preview_status and read_doce_preview_logs that the preview is actually unhealthy. Takes no arguments.", + async execute(_args, context) { + return callDoceInternal("restart", context, { reason: "Agent-triggered preview restart" }); + }, +}); +`.trim()); + +export const DOCE_PREVIEW_TOOL_FILES: Array<{ + filename: string; + source: string; +}> = [ + { filename: "get_doce_preview_status.ts", source: GET_DOCE_PREVIEW_STATUS_SOURCE }, + { filename: "read_doce_preview_logs.ts", source: READ_DOCE_PREVIEW_LOGS_SOURCE }, + { filename: "restart_doce_preview.ts", source: RESTART_DOCE_PREVIEW_SOURCE }, +]; diff --git a/src/server/opencode/runtime.ts b/src/server/opencode/runtime.ts index 45702105..06fd772d 100644 --- a/src/server/opencode/runtime.ts +++ b/src/server/opencode/runtime.ts @@ -217,6 +217,9 @@ function getOpencodeEnvironment(): NodeJS.ProcessEnv { // Use the real HOME so the runtime shares auth and data with the CLI XDG_CONFIG_HOME: dataPath, XDG_CACHE_HOME: `${dataPath}/cache`, + // Base URL used by internal OpenCode custom tools to reach the doce API + DOCE_INTERNAL_BASE_URL: + process.env.DOCE_INTERNAL_BASE_URL || "http://127.0.0.1:4321", }; } @@ -239,6 +242,11 @@ async function waitForOpencodeReady(): Promise { async function startOpencodeProcess(): Promise { await ensureRequiredOpencodeVersion(); + // Always ensure our config + custom tools + plugins are up-to-date on disk + // so a reused opencode picks them up on its next reload, and a fresh spawn + // sees them immediately. + await ensureOpencodeDirectories(); + if (await checkOpencodeServerReady(getOpencodePort(), 1_000)) { logger.info( { diff --git a/src/server/projects/setup.ts b/src/server/projects/setup.ts index 28c87834..aafd2db8 100644 --- a/src/server/projects/setup.ts +++ b/src/server/projects/setup.ts @@ -1,6 +1,7 @@ import { spawn } from "node:child_process"; import * as fs from "node:fs/promises"; import * as path from "node:path"; +import { ensureProjectInternalToken } from "@/server/ai-tools/projectToken"; import { logger } from "@/server/logger"; import { allocateProjectProductionPort } from "@/server/ports/allocate"; import { resolveHostPath } from "./hostPaths"; @@ -57,6 +58,9 @@ export async function setupProjectFilesystem( await fs.mkdir(path.join(previewPath, "logs"), { recursive: true }); await fs.mkdir(path.join(productionPath, "logs"), { recursive: true }); + // Generate per-project token used by internal OpenCode tools + await ensureProjectInternalToken(projectId); + return { projectPath, productionPort }; } diff --git a/src/server/queue/handlers/opencodeSessionCreate.ts b/src/server/queue/handlers/opencodeSessionCreate.ts index 814e7ec3..8eeb1f37 100644 --- a/src/server/queue/handlers/opencodeSessionCreate.ts +++ b/src/server/queue/handlers/opencodeSessionCreate.ts @@ -1,4 +1,5 @@ import { Effect } from "effect"; +import { ensureProjectInternalToken } from "@/server/ai-tools/projectToken"; import { FALLBACK_MODEL } from "@/server/config/models"; import { OpenCodeSessionError, ProjectError } from "@/server/effect/errors"; import type { QueueJobContext } from "@/server/effect/queue.worker"; @@ -51,6 +52,18 @@ export function handleOpencodeSessionCreate( let isSessionRecovery = false; const projectDirectory = getProjectPreviewPathFromRoot(project.pathOnDisk); + // Ensure the internal token exists so OpenCode custom tools can authenticate + yield* Effect.tryPromise({ + try: () => ensureProjectInternalToken(project.id), + catch: (error) => + new ProjectError({ + projectId: project.id, + operation: "ensureProjectInternalToken", + message: error instanceof Error ? error.message : String(error), + cause: error, + }), + }); + if (project.bootstrapSessionId) { const hasExistingSession = yield* Effect.tryPromise({ try: async () => { diff --git a/templates/astro-starter/.gitignore b/templates/astro-starter/.gitignore index cd48c0d0..edb4817b 100644 --- a/templates/astro-starter/.gitignore +++ b/templates/astro-starter/.gitignore @@ -17,6 +17,9 @@ pnpm-debug.log* .env.local .env.*.local +# Doce internal +.doce-internal-token + # IDE .vscode/ .idea/ diff --git a/templates/astro-starter/DOCE.md b/templates/astro-starter/DOCE.md index fd90ecb4..a1d70975 100644 --- a/templates/astro-starter/DOCE.md +++ b/templates/astro-starter/DOCE.md @@ -22,7 +22,10 @@ You are running inside **doce.dev**, a web-based AI development environment. The - **Icons**: lucide-react - **Dev server**: Already running on port 4321 (handled by the platform) - **Working directory**: `/app` -- **Recovery tools**: Custom tools `restart_dev_server` and `read_server_logs` are available for preview troubleshooting +- **Recovery tools**: Custom tools are available for preview troubleshooting: + - `get_doce_preview_status` - Check if the preview is healthy and running + - `read_doce_preview_logs` - Read recent logs to diagnose issues (prefer "summary" mode first) + - `restart_doce_preview` - Restart the preview server when it's stuck or crashed ## Available UI Components @@ -62,10 +65,13 @@ If you need additional shadcn components not in the starter: - Don't tell the user to run commands - the platform handles that - Don't reinstall or reconfigure Tailwind - it's already set up correctly - Don't run any commands that interfere with Doce, like "pnpm build" or "pnpm dev". There's already a dev server running and you might break it. -- If the preview server gets stuck or stops responding, use `restart_dev_server` instead of trying to start a second dev server manually. -- Use `restart_dev_server` only when needed for preview recovery, not as a routine step. -- When debugging preview issues, use `read_server_logs` to inspect recent app/docker logs before guessing. -- Prefer reading logs first, then restarting only if the logs or preview behavior indicate the dev server is unhealthy. +- If the preview server gets stuck or stops responding, use `restart_doce_preview` instead of trying to start a second dev server manually. +- Use `restart_doce_preview` only when needed for preview recovery, not as a routine step. +- When debugging preview issues, follow this order: + 1. Check `get_doce_preview_status` to see if the preview is reachable + 2. Use `read_doce_preview_logs` with mode "summary" to quickly identify the issue + 3. Only use `restart_doce_preview` if status/logs indicate the preview is unhealthy +- Avoid repeated raw log reads unless necessary. ## What TO do