From a0b67487f9bbd332f55e75f4ad2d8f06f102ec5c Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Tue, 12 May 2026 20:36:32 +0200 Subject: [PATCH 1/4] Add doce preview tool UI registry and backend skeleton Registers get_doce_preview_status, read_doce_preview_logs, and restart_doce_preview in the chat tool registry, adds their service/schema skeleton, and updates the agent system prompt in DOCE.md to guide correct tool usage order. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/chat/tools/registry.tsx | 32 ++ src/server/ai-tools/doce-preview/index.ts | 23 ++ src/server/ai-tools/doce-preview/schemas.ts | 91 ++++++ src/server/ai-tools/doce-preview/service.ts | 308 ++++++++++++++++++++ templates/astro-starter/DOCE.md | 16 +- 5 files changed, 465 insertions(+), 5 deletions(-) create mode 100644 src/server/ai-tools/doce-preview/index.ts create mode 100644 src/server/ai-tools/doce-preview/schemas.ts create mode 100644 src/server/ai-tools/doce-preview/service.ts diff --git a/src/components/chat/tools/registry.tsx b/src/components/chat/tools/registry.tsx index 68bb626e..6f6c48a8 100644 --- a/src/components/chat/tools/registry.tsx +++ b/src/components/chat/tools/registry.tsx @@ -321,3 +321,35 @@ registerTool("context7_get-library-docs", { return typeof obj.topic === "string" ? obj.topic : null; }, }); + +// Doce Preview tools (internal) +registerTool("get_doce_preview_status", { + name: "Preview Status", + icon: Search, + iconClass: "text-blue-500", + getContext: (input) => { + if (!input || typeof input !== "object") return null; + return (input as Record).projectId as string | null; + }, +}); + +registerTool("read_doce_preview_logs", { + name: "Preview Logs", + icon: FileText, + iconClass: "text-amber-500", + getContext: (input) => { + if (!input || typeof input !== "object") return null; + const mode = (input as Record).mode as string; + return mode ?? "summary"; + }, +}); + +registerTool("restart_doce_preview", { + name: "Restart Preview", + icon: RefreshCw, + iconClass: "text-green-500", + getContext: (input) => { + if (!input || typeof input !== "object") return null; + return (input as Record).projectId as string | null; + }, +}); 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/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 From 08482b30d99573b1142725f524d2bcf5d44dadd7 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Wed, 13 May 2026 18:50:04 +0200 Subject: [PATCH 2/4] Wire doce preview tools into OpenCode runtime Generates 3 hidden OpenCode custom tools (get_doce_preview_status, read_doce_preview_logs, restart_doce_preview) at startup under ${DATA_PATH}/opencode/tools/. Each tool resolves projectId from its session directory, reads a per-project token, and POSTs to a new internal API at /api/internal/ai-tools/preview/{action} that executes the existing service layer scoped to the project owner. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- .../api/internal/ai-tools/preview/logs.ts | 42 +++++ .../api/internal/ai-tools/preview/restart.ts | 40 +++++ .../api/internal/ai-tools/preview/status.ts | 39 +++++ src/server/ai-tools/internalRequest.ts | 90 +++++++++++ src/server/ai-tools/projectToken.ts | 61 ++++++++ src/server/opencode/config.ts | 17 +++ src/server/opencode/docePreviewToolsSource.ts | 143 ++++++++++++++++++ src/server/opencode/runtime.ts | 3 + src/server/projects/setup.ts | 4 + .../queue/handlers/opencodeSessionCreate.ts | 13 ++ templates/astro-starter/.gitignore | 3 + 11 files changed, 455 insertions(+) create mode 100644 src/pages/api/internal/ai-tools/preview/logs.ts create mode 100644 src/pages/api/internal/ai-tools/preview/restart.ts create mode 100644 src/pages/api/internal/ai-tools/preview/status.ts create mode 100644 src/server/ai-tools/internalRequest.ts create mode 100644 src/server/ai-tools/projectToken.ts create mode 100644 src/server/opencode/docePreviewToolsSource.ts 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/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..eb5027c8 --- /dev/null +++ b/src/server/opencode/docePreviewToolsSource.ts @@ -0,0 +1,143 @@ +const SHARED_HELPER = ` +import * as fs from "node:fs/promises"; +import * as path from "node:path"; + +const TOKEN_FILENAME = ".doce-internal-token"; + +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 = path.join(directory, 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(path.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`; +} + +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.", + args: {}, + async execute(_args, context) { + return callDoceInternal("status", context); + }, +}); +`.trim()); + +export const READ_DOCE_PREVIEW_LOGS_SOURCE = toolFile(` +export default tool({ + description: + "Read recent logs from the doce preview server. Use mode 'summary' to get the last error line (cheapest), 'tail' for the most recent log bytes, or 'sinceOffset' to read incrementally. Prefer 'summary' first.", + args: { + mode: tool.schema.enum(["summary", "tail", "sinceOffset"]).default("summary").describe("Read mode"), + maxBytes: tool.schema.number().int().min(256).max(16384).optional().describe("Max bytes to return for 'tail' mode"), + offset: tool.schema.number().int().min(0).optional().describe("Byte offset for 'sinceOffset' mode"), + }, + async execute(args, context) { + return callDoceInternal("logs", context, { + mode: args.mode, + maxBytes: args.maxBytes, + offset: args.offset, + }); + }, +}); +`.trim()); + +export const RESTART_DOCE_PREVIEW_SOURCE = toolFile(` +export default tool({ + description: + "Restart the doce preview container. Only use after confirming via get_doce_preview_status and read_doce_preview_logs that the preview is actually unhealthy. Provide a short 'reason'.", + args: { + reason: tool.schema.string().max(300).optional().describe("Why the restart is needed"), + }, + async execute(args, context) { + return callDoceInternal("restart", context, { reason: args.reason }); + }, +}); +`.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..d104e1d2 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", }; } 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/ From 8e84bdf03bc6987a40db0d5f2831d92f975e7d2c Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Wed, 13 May 2026 19:51:19 +0200 Subject: [PATCH 3/4] Replace JSON tool-call dumps with per-tool details renderers Tools now declare an optional getDetails({input,output,status}) that returns a short summary string for the expanded panel. Default expanded view falls back to a tiny raw output preview (instead of the full Input/Output JSON blocks), or "No additional details." when there is nothing to show. Drops the leftover colored iconClass on the doce preview tools and registers focused summaries (status -> output summary/error, logs -> summary + extracted signal, restart -> reason + result summary). Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/components/chat/ToolCallDisplay.tsx | 67 +++++++++++++------------ src/components/chat/tools/registry.tsx | 41 +++++++++++---- 2 files changed, 66 insertions(+), 42 deletions(-) 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 6f6c48a8..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; } /** @@ -323,33 +333,44 @@ registerTool("context7_get-library-docs", { }); // 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, - iconClass: "text-blue-500", - getContext: (input) => { - if (!input || typeof input !== "object") return null; - return (input as Record).projectId as string | null; - }, + getDetails: ({ output }) => + readField(output, "summary") ?? readField(output, "error"), }); registerTool("read_doce_preview_logs", { name: "Preview Logs", icon: FileText, - iconClass: "text-amber-500", 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, - iconClass: "text-green-500", - getContext: (input) => { - if (!input || typeof input !== "object") return null; - return (input as Record).projectId as string | null; + 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; }, }); From 3b5165460044e2987d963f8837a29e63e3101377 Mon Sep 17 00:00:00 2001 From: Pablo P Varela Date: Wed, 13 May 2026 21:34:31 +0200 Subject: [PATCH 4/4] Make doce preview tools resilient to OpenCode 1.14.48 quirks Two integration fixes for the OpenCode custom-tool wiring: 1. runtime: always re-emit ~/.config/opencode/{opencode.json,tools,plugins} before deciding to reuse a running opencode. Previously these only got written on a fresh spawn, so a running daemon would never see updated tool sources. 2. tool sources: drop the `args` field on all three doce preview tools and drop the node:path import. OpenCode 1.14.48 crashes any tool call that declares an `args` object (even {}) with "undefined is not an object (evaluating 'g.split')" inside its internal wrapper. Tools without `args` execute correctly. The tools now read sensible defaults from session context instead of taking model-provided arguments. Co-Authored-By: Claude Sonnet 4.6 (1M context) --- src/server/opencode/docePreviewToolsSource.ts | 41 ++++++++----------- src/server/opencode/runtime.ts | 5 +++ 2 files changed, 23 insertions(+), 23 deletions(-) diff --git a/src/server/opencode/docePreviewToolsSource.ts b/src/server/opencode/docePreviewToolsSource.ts index eb5027c8..ee309758 100644 --- a/src/server/opencode/docePreviewToolsSource.ts +++ b/src/server/opencode/docePreviewToolsSource.ts @@ -1,8 +1,8 @@ const SHARED_HELPER = ` import * as fs from "node:fs/promises"; -import * as path from "node:path"; const TOKEN_FILENAME = ".doce-internal-token"; +const SEP = "/"; function resolveDirectory(context) { if (!context) return null; @@ -17,7 +17,7 @@ function resolveDirectory(context) { async function readToken(directory) { try { - const tokenPath = path.join(directory, TOKEN_FILENAME); + const tokenPath = directory.replace(/\\/+$/, "") + SEP + TOKEN_FILENAME; const content = await fs.readFile(tokenPath, "utf-8"); return content.trim() || null; } catch { @@ -27,7 +27,7 @@ async function readToken(directory) { function extractProjectId(directory) { if (!directory || typeof directory !== "string") return null; - const segments = directory.split(path.sep).filter(Boolean); + const segments = directory.split(SEP).filter(Boolean); const previewIndex = segments.lastIndexOf("preview"); if (previewIndex < 1) return null; return segments[previewIndex - 1] ?? null; @@ -90,11 +90,18 @@ 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.", - args: {}, + "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); }, @@ -104,18 +111,9 @@ export default tool({ export const READ_DOCE_PREVIEW_LOGS_SOURCE = toolFile(` export default tool({ description: - "Read recent logs from the doce preview server. Use mode 'summary' to get the last error line (cheapest), 'tail' for the most recent log bytes, or 'sinceOffset' to read incrementally. Prefer 'summary' first.", - args: { - mode: tool.schema.enum(["summary", "tail", "sinceOffset"]).default("summary").describe("Read mode"), - maxBytes: tool.schema.number().int().min(256).max(16384).optional().describe("Max bytes to return for 'tail' mode"), - offset: tool.schema.number().int().min(0).optional().describe("Byte offset for 'sinceOffset' mode"), - }, - async execute(args, context) { - return callDoceInternal("logs", context, { - mode: args.mode, - maxBytes: args.maxBytes, - offset: args.offset, - }); + "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()); @@ -123,12 +121,9 @@ export default tool({ export const RESTART_DOCE_PREVIEW_SOURCE = toolFile(` export default tool({ description: - "Restart the doce preview container. Only use after confirming via get_doce_preview_status and read_doce_preview_logs that the preview is actually unhealthy. Provide a short 'reason'.", - args: { - reason: tool.schema.string().max(300).optional().describe("Why the restart is needed"), - }, - async execute(args, context) { - return callDoceInternal("restart", context, { reason: args.reason }); + "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()); diff --git a/src/server/opencode/runtime.ts b/src/server/opencode/runtime.ts index d104e1d2..06fd772d 100644 --- a/src/server/opencode/runtime.ts +++ b/src/server/opencode/runtime.ts @@ -242,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( {