From 568e675eb389adbddf521b3321b00f7bc9a6aa1b Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 11:11:17 +0100 Subject: [PATCH 01/31] feat(opencode): add custom block integration --- apps/sim/app/api/opencode/agents/route.ts | 50 ++ apps/sim/app/api/opencode/models/route.ts | 55 ++ apps/sim/app/api/opencode/providers/route.ts | 50 ++ apps/sim/app/api/opencode/repos/route.ts | 49 ++ .../app/api/tools/opencode/messages/route.ts | 46 ++ .../app/api/tools/opencode/prompt/route.ts | 233 ++++++++ .../sim/app/api/tools/opencode/repos/route.ts | 39 ++ .../components/combobox/combobox.tsx | 45 +- .../components/dropdown/dropdown.tsx | 45 +- apps/sim/blocks/blocks/opencode.ts | 254 +++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 23 + apps/sim/lib/opencode/client.ts | 38 ++ apps/sim/lib/opencode/errors.ts | 83 +++ apps/sim/lib/opencode/service.ts | 502 ++++++++++++++++++ apps/sim/next.config.ts | 16 + apps/sim/package.json | 2 + apps/sim/tools/opencode/get_messages.ts | 60 +++ apps/sim/tools/opencode/index.ts | 5 + apps/sim/tools/opencode/list_repos.ts | 41 ++ apps/sim/tools/opencode/prompt.ts | 89 ++++ apps/sim/tools/opencode/types.ts | 58 ++ apps/sim/tools/registry.ts | 8 + apps/sim/vitest.config.ts | 4 + 24 files changed, 1779 insertions(+), 18 deletions(-) create mode 100644 apps/sim/app/api/opencode/agents/route.ts create mode 100644 apps/sim/app/api/opencode/models/route.ts create mode 100644 apps/sim/app/api/opencode/providers/route.ts create mode 100644 apps/sim/app/api/opencode/repos/route.ts create mode 100644 apps/sim/app/api/tools/opencode/messages/route.ts create mode 100644 apps/sim/app/api/tools/opencode/prompt/route.ts create mode 100644 apps/sim/app/api/tools/opencode/repos/route.ts create mode 100644 apps/sim/blocks/blocks/opencode.ts create mode 100644 apps/sim/lib/opencode/client.ts create mode 100644 apps/sim/lib/opencode/errors.ts create mode 100644 apps/sim/lib/opencode/service.ts create mode 100644 apps/sim/tools/opencode/get_messages.ts create mode 100644 apps/sim/tools/opencode/index.ts create mode 100644 apps/sim/tools/opencode/list_repos.ts create mode 100644 apps/sim/tools/opencode/prompt.ts create mode 100644 apps/sim/tools/opencode/types.ts diff --git a/apps/sim/app/api/opencode/agents/route.ts b/apps/sim/app/api/opencode/agents/route.ts new file mode 100644 index 00000000000..1d0a818052f --- /dev/null +++ b/apps/sim/app/api/opencode/agents/route.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getOpenCodeRouteError } from '@/lib/opencode/errors' +import { listOpenCodeAgents } from '@/lib/opencode/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('OpenCodeAgentsAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized OpenCode agents access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const access = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!access.exists) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + if (!access.hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const repository = request.nextUrl.searchParams.get('repository') || undefined + const agents = await listOpenCodeAgents(repository) + return NextResponse.json({ data: agents }) + } catch (error) { + const routeError = getOpenCodeRouteError(error, 'agents') + logger.error(`[${requestId}] Failed to fetch OpenCode agents`, { + error, + status: routeError.status, + responseMessage: routeError.message, + }) + return NextResponse.json({ error: routeError.message }, { status: routeError.status }) + } +} diff --git a/apps/sim/app/api/opencode/models/route.ts b/apps/sim/app/api/opencode/models/route.ts new file mode 100644 index 00000000000..42deb015b8b --- /dev/null +++ b/apps/sim/app/api/opencode/models/route.ts @@ -0,0 +1,55 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getOpenCodeRouteError } from '@/lib/opencode/errors' +import { listOpenCodeModels } from '@/lib/opencode/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('OpenCodeModelsAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized OpenCode models access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const providerId = request.nextUrl.searchParams.get('providerId') + if (!providerId) { + return NextResponse.json({ error: 'providerId is required' }, { status: 400 }) + } + + const access = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!access.exists) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + if (!access.hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const repository = request.nextUrl.searchParams.get('repository') || undefined + const models = await listOpenCodeModels(providerId, repository) + return NextResponse.json({ data: models }) + } catch (error) { + const routeError = getOpenCodeRouteError(error, 'models') + logger.error(`[${requestId}] Failed to fetch OpenCode models`, { + error, + status: routeError.status, + responseMessage: routeError.message, + }) + return NextResponse.json({ error: routeError.message }, { status: routeError.status }) + } +} diff --git a/apps/sim/app/api/opencode/providers/route.ts b/apps/sim/app/api/opencode/providers/route.ts new file mode 100644 index 00000000000..3d7296557fe --- /dev/null +++ b/apps/sim/app/api/opencode/providers/route.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getOpenCodeRouteError } from '@/lib/opencode/errors' +import { listOpenCodeProviders } from '@/lib/opencode/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('OpenCodeProvidersAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized OpenCode providers access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const access = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!access.exists) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + if (!access.hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const repository = request.nextUrl.searchParams.get('repository') || undefined + const providers = await listOpenCodeProviders(repository) + return NextResponse.json({ data: providers }) + } catch (error) { + const routeError = getOpenCodeRouteError(error, 'providers') + logger.error(`[${requestId}] Failed to fetch OpenCode providers`, { + error, + status: routeError.status, + responseMessage: routeError.message, + }) + return NextResponse.json({ error: routeError.message }, { status: routeError.status }) + } +} diff --git a/apps/sim/app/api/opencode/repos/route.ts b/apps/sim/app/api/opencode/repos/route.ts new file mode 100644 index 00000000000..d9f239081aa --- /dev/null +++ b/apps/sim/app/api/opencode/repos/route.ts @@ -0,0 +1,49 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getOpenCodeRouteError } from '@/lib/opencode/errors' +import { listOpenCodeRepositories } from '@/lib/opencode/service' +import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('OpenCodeReposAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function GET(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success || !authResult.userId) { + logger.warn(`[${requestId}] Unauthorized OpenCode repos access attempt`) + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + const workspaceId = request.nextUrl.searchParams.get('workspaceId') + if (!workspaceId) { + return NextResponse.json({ error: 'workspaceId is required' }, { status: 400 }) + } + + const access = await checkWorkspaceAccess(workspaceId, authResult.userId) + if (!access.exists) { + return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) + } + + if (!access.hasAccess) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }) + } + + const repositories = await listOpenCodeRepositories() + return NextResponse.json({ data: repositories }) + } catch (error) { + const routeError = getOpenCodeRouteError(error, 'repositories') + logger.error(`[${requestId}] Failed to fetch OpenCode repositories`, { + error, + status: routeError.status, + responseMessage: routeError.message, + }) + return NextResponse.json({ error: routeError.message }, { status: routeError.status }) + } +} diff --git a/apps/sim/app/api/tools/opencode/messages/route.ts b/apps/sim/app/api/tools/opencode/messages/route.ts new file mode 100644 index 00000000000..cfb454836fc --- /dev/null +++ b/apps/sim/app/api/tools/opencode/messages/route.ts @@ -0,0 +1,46 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { getOpenCodeMessages } from '@/lib/opencode/service' + +const logger = createLogger('OpenCodeMessagesToolAPI') + +const OpenCodeMessagesSchema = z.object({ + repository: z.string().min(1, 'repository is required'), + threadId: z.string().min(1, 'threadId is required'), +}) + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized OpenCode messages request`) + return NextResponse.json({ error: authResult.error || 'Unauthorized' }, { status: 401 }) + } + + const body = OpenCodeMessagesSchema.parse(await request.json()) + const messages = await getOpenCodeMessages(body.repository, body.threadId) + + return NextResponse.json({ + success: true, + output: { + threadId: body.threadId, + messages, + count: messages.length, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Failed to fetch OpenCode messages`, { error }) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch OpenCode messages' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/opencode/prompt/route.ts b/apps/sim/app/api/tools/opencode/prompt/route.ts new file mode 100644 index 00000000000..880444e05dc --- /dev/null +++ b/apps/sim/app/api/tools/opencode/prompt/route.ts @@ -0,0 +1,233 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { + buildOpenCodeSessionMemoryKey, + buildOpenCodeSessionTitle, + createOpenCodeSession, + getStoredOpenCodeSession, + logOpenCodeFailure, + promptOpenCodeSession, + resolveOpenCodeRepositoryOption, + shouldRetryWithFreshOpenCodeSession, + storeOpenCodeSession, +} from '@/lib/opencode/service' + +const logger = createLogger('OpenCodePromptToolAPI') + +const optionalTrimmedStringSchema = z.preprocess( + (value) => (value === null ? undefined : value), + z.string().optional() +) + +const OpenCodePromptSchema = z.object({ + repository: z.string().min(1, 'repository is required'), + systemPrompt: optionalTrimmedStringSchema, + providerId: z.string().min(1, 'providerId is required'), + modelId: z.string().min(1, 'modelId is required'), + agent: optionalTrimmedStringSchema, + prompt: z.string().min(1, 'prompt is required'), + newThread: z.union([z.boolean(), z.string()]).optional(), + _context: z + .object({ + workspaceId: z.string().optional(), + workflowId: z.string().optional(), + userId: z.string().optional(), + executionId: z.string().optional(), + }) + .passthrough() + .optional(), +}) + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +function coerceBoolean(value: boolean | string | undefined): boolean { + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'string') { + return value.toLowerCase() === 'true' + } + + return false +} + +function getSessionOwnerKey(params: z.infer): string { + if (params._context?.userId) { + return `user:${params._context.userId}` + } + + if (params._context?.executionId) { + return `execution:${params._context.executionId}` + } + + return 'anonymous' +} + +function buildSuccessResponse(threadId: string, content: string, cost?: number): NextResponse { + return NextResponse.json({ + success: true, + output: { + content, + threadId, + ...(typeof cost === 'number' ? { cost } : {}), + }, + }) +} + +function buildErrorResponse( + threadId: string, + content: string, + cost: number | undefined, + error: string +): NextResponse { + return NextResponse.json({ + success: true, + output: { + content, + threadId, + ...(typeof cost === 'number' ? { cost } : {}), + error, + }, + }) +} + +async function executePrompt( + params: z.infer, + repository: string, + threadId: string, + prompt: string, + providerId: string, + modelId: string +) { + return promptOpenCodeSession({ + repository, + sessionId: threadId, + prompt, + systemPrompt: params.systemPrompt?.trim() || undefined, + providerId, + modelId, + agent: params.agent?.trim() || undefined, + }) +} + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized OpenCode prompt request`) + return NextResponse.json({ error: authResult.error || 'Unauthorized' }, { status: 401 }) + } + + const body = OpenCodePromptSchema.parse(await request.json()) + const workspaceId = body._context?.workspaceId + const workflowId = body._context?.workflowId + + if (!workspaceId || !workflowId) { + return NextResponse.json( + { error: 'workspaceId and workflowId are required in execution context' }, + { status: 400 } + ) + } + + const repositoryOption = await resolveOpenCodeRepositoryOption(body.repository.trim()) + const repositoryId = repositoryOption.id + const prompt = body.prompt.trim() + const providerId = body.providerId.trim() + const modelId = body.modelId.trim() + const sessionOwnerKey = getSessionOwnerKey(body) + const memoryKey = buildOpenCodeSessionMemoryKey(workflowId, sessionOwnerKey) + const newThread = coerceBoolean(body.newThread) + const storedThread = newThread ? null : await getStoredOpenCodeSession(workspaceId, memoryKey) + let threadId = + storedThread && storedThread.repository === repositoryId ? storedThread.sessionId : undefined + + if (!threadId) { + const session = await createOpenCodeSession( + repositoryId, + buildOpenCodeSessionTitle(repositoryId, sessionOwnerKey) + ) + threadId = session.id + } + + try { + const result = await executePrompt(body, repositoryId, threadId, prompt, providerId, modelId) + + await storeOpenCodeSession(workspaceId, memoryKey, { + sessionId: result.threadId, + repository: repositoryId, + updatedAt: new Date().toISOString(), + }) + + if (result.assistantError) { + return buildErrorResponse( + result.threadId, + result.content, + result.cost, + result.assistantError + ) + } + + return buildSuccessResponse(result.threadId, result.content, result.cost) + } catch (error) { + if (threadId && !newThread && shouldRetryWithFreshOpenCodeSession(error)) { + try { + const freshSession = await createOpenCodeSession( + repositoryId, + buildOpenCodeSessionTitle(repositoryId, sessionOwnerKey) + ) + const result = await executePrompt( + body, + repositoryId, + freshSession.id, + prompt, + providerId, + modelId + ) + + await storeOpenCodeSession(workspaceId, memoryKey, { + sessionId: result.threadId, + repository: repositoryId, + updatedAt: new Date().toISOString(), + }) + + if (result.assistantError) { + return buildErrorResponse( + result.threadId, + result.content, + result.cost, + result.assistantError + ) + } + + return buildSuccessResponse(result.threadId, result.content, result.cost) + } catch (retryError) { + await logOpenCodeFailure( + 'Failed to retry OpenCode prompt with a fresh session', + retryError + ) + + const errorMessage = + retryError instanceof Error ? retryError.message : 'OpenCode prompt retry failed' + return buildErrorResponse(threadId, '', undefined, errorMessage) + } + } + + await logOpenCodeFailure('Failed to execute OpenCode prompt', error) + const errorMessage = error instanceof Error ? error.message : 'OpenCode prompt failed' + return buildErrorResponse(threadId || '', '', undefined, errorMessage) + } + } catch (error) { + logger.error(`[${requestId}] Failed to execute OpenCode prompt tool`, { error }) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to execute OpenCode prompt' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/api/tools/opencode/repos/route.ts b/apps/sim/app/api/tools/opencode/repos/route.ts new file mode 100644 index 00000000000..1b438592c88 --- /dev/null +++ b/apps/sim/app/api/tools/opencode/repos/route.ts @@ -0,0 +1,39 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { checkInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { listOpenCodeRepositories } from '@/lib/opencode/service' + +const logger = createLogger('OpenCodeReposToolAPI') + +export const dynamic = 'force-dynamic' +export const runtime = 'nodejs' + +export async function POST(request: NextRequest) { + const requestId = generateRequestId() + + try { + const authResult = await checkInternalAuth(request, { requireWorkflowId: false }) + if (!authResult.success) { + logger.warn(`[${requestId}] Unauthorized OpenCode repos request`) + return NextResponse.json({ error: authResult.error || 'Unauthorized' }, { status: 401 }) + } + + await request.text() + const repositories = await listOpenCodeRepositories() + + return NextResponse.json({ + success: true, + output: { + repositories, + count: repositories.length, + }, + }) + } catch (error) { + logger.error(`[${requestId}] Failed to fetch OpenCode repositories`, { error }) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Failed to fetch repositories' }, + { status: 500 } + ) + } +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index c0ef0933264..7fd3aad2ce6 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -39,7 +39,7 @@ type ComboBoxOption = */ interface ComboBoxProps { /** Available options for selection - can be static array or function that returns options */ - options: ComboBoxOption[] | (() => ComboBoxOption[]) + options?: ComboBoxOption[] | (() => ComboBoxOption[]) /** Default value to use when no value is set */ defaultValue?: string /** ID of the parent block */ @@ -123,15 +123,28 @@ export const ComboBox = memo(function ComboBox({ const [fetchedOptions, setFetchedOptions] = useState>([]) const [isLoadingOptions, setIsLoadingOptions] = useState(false) const [fetchError, setFetchError] = useState(null) + const [hasAttemptedOptionsFetch, setHasAttemptedOptionsFetch] = useState(false) const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) const previousDependencyValuesRef = useRef('') + const isOptionsFetchInFlightRef = useRef(false) /** * Fetches options from the async fetchOptions function if provided */ - const fetchOptionsIfNeeded = useCallback(async () => { - if (!fetchOptions || isPreview || disabled) return + const fetchOptionsIfNeeded = useCallback(async (force = false) => { + if ( + !fetchOptions || + isPreview || + disabled || + isLoadingOptions || + (!force && hasAttemptedOptionsFetch) || + isOptionsFetchInFlightRef.current + ) { + return + } + isOptionsFetchInFlightRef.current = true + setHasAttemptedOptionsFetch(true) setIsLoadingOptions(true) setFetchError(null) try { @@ -142,9 +155,18 @@ export const ComboBox = memo(function ComboBox({ setFetchError(errorMessage) setFetchedOptions([]) } finally { + isOptionsFetchInFlightRef.current = false setIsLoadingOptions(false) } - }, [fetchOptions, blockId, subBlockId, isPreview, disabled]) + }, [ + fetchOptions, + blockId, + subBlockId, + isPreview, + disabled, + isLoadingOptions, + hasAttemptedOptionsFetch, + ]) // Determine the active value based on mode (preview vs. controlled vs. store) const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue @@ -154,7 +176,8 @@ export const ComboBox = memo(function ComboBox({ // Evaluate static options if provided as a function const staticOptions = useMemo(() => { - const opts = typeof options === 'function' ? options() : options + const resolvedOptions = typeof options === 'function' ? options() : options + const opts = Array.isArray(resolvedOptions) ? resolvedOptions : [] if (subBlockId === 'model') { return opts.filter((opt) => { @@ -307,6 +330,8 @@ export const ComboBox = memo(function ComboBox({ currentDependencyValuesStr !== previousDependencyValuesStr ) { setFetchedOptions([]) + setFetchError(null) + setHasAttemptedOptionsFetch(false) setHydratedOption(null) } @@ -322,7 +347,8 @@ export const ComboBox = memo(function ComboBox({ !disabled && fetchedOptions.length === 0 && !isLoadingOptions && - !fetchError + !fetchError && + !hasAttemptedOptionsFetch ) { fetchOptionsIfNeeded() } @@ -334,6 +360,7 @@ export const ComboBox = memo(function ComboBox({ fetchedOptions.length, isLoadingOptions, fetchError, + hasAttemptedOptionsFetch, dependencyValues, ]) @@ -428,11 +455,11 @@ export const ComboBox = memo(function ComboBox({ */ const handleOpenChange = useCallback( (open: boolean) => { - if (open) { - void fetchOptionsIfNeeded() + if (open && fetchedOptions.length === 0) { + void fetchOptionsIfNeeded(fetchError !== null) } }, - [fetchOptionsIfNeeded] + [fetchError, fetchOptionsIfNeeded, fetchedOptions.length] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 3a56fcfabf1..76042f7d023 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -32,7 +32,7 @@ type DropdownOption = */ interface DropdownProps { /** Static options array or function that returns options */ - options: DropdownOption[] | (() => DropdownOption[]) + options?: DropdownOption[] | (() => DropdownOption[]) /** Default value to select when no value is set */ defaultValue?: string /** Unique identifier for the block */ @@ -127,10 +127,12 @@ export const Dropdown = memo(function Dropdown({ const [fetchedOptions, setFetchedOptions] = useState>([]) const [isLoadingOptions, setIsLoadingOptions] = useState(false) const [fetchError, setFetchError] = useState(null) + const [hasAttemptedOptionsFetch, setHasAttemptedOptionsFetch] = useState(false) const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) const previousModeRef = useRef(null) const previousDependencyValuesRef = useRef('') + const isOptionsFetchInFlightRef = useRef(false) const [builderData, setBuilderData] = useSubBlockValue(blockId, 'builderData') const [data, setData] = useSubBlockValue(blockId, 'data') @@ -154,9 +156,20 @@ export const Dropdown = memo(function Dropdown({ : [] : null - const fetchOptionsIfNeeded = useCallback(async () => { - if (!fetchOptions || isPreview || disabled) return + const fetchOptionsIfNeeded = useCallback(async (force = false) => { + if ( + !fetchOptions || + isPreview || + disabled || + isLoadingOptions || + (!force && hasAttemptedOptionsFetch) || + isOptionsFetchInFlightRef.current + ) { + return + } + isOptionsFetchInFlightRef.current = true + setHasAttemptedOptionsFetch(true) setIsLoadingOptions(true) setFetchError(null) try { @@ -167,24 +180,34 @@ export const Dropdown = memo(function Dropdown({ setFetchError(errorMessage) setFetchedOptions([]) } finally { + isOptionsFetchInFlightRef.current = false setIsLoadingOptions(false) } - }, [fetchOptions, blockId, subBlockId, isPreview, disabled]) + }, [ + fetchOptions, + blockId, + subBlockId, + isPreview, + disabled, + isLoadingOptions, + hasAttemptedOptionsFetch, + ]) /** * Handles combobox open state changes to trigger option fetching */ const handleOpenChange = useCallback( (open: boolean) => { - if (open) { - void fetchOptionsIfNeeded() + if (open && fetchedOptions.length === 0) { + void fetchOptionsIfNeeded(fetchError !== null) } }, - [fetchOptionsIfNeeded] + [fetchError, fetchOptionsIfNeeded, fetchedOptions.length] ) const evaluatedOptions = useMemo(() => { - return typeof options === 'function' ? options() : options + const resolvedOptions = typeof options === 'function' ? options() : options + return Array.isArray(resolvedOptions) ? resolvedOptions : [] }, [options]) const normalizedFetchedOptions = useMemo(() => { @@ -370,6 +393,8 @@ export const Dropdown = memo(function Dropdown({ currentDependencyValuesStr !== previousDependencyValuesStr ) { setFetchedOptions([]) + setFetchError(null) + setHasAttemptedOptionsFetch(false) setHydratedOption(null) } @@ -387,7 +412,8 @@ export const Dropdown = memo(function Dropdown({ !disabled && fetchedOptions.length === 0 && !isLoadingOptions && - !fetchError + !fetchError && + !hasAttemptedOptionsFetch ) { fetchOptionsIfNeeded() } @@ -399,6 +425,7 @@ export const Dropdown = memo(function Dropdown({ fetchedOptions.length, isLoadingOptions, fetchError, + hasAttemptedOptionsFetch, dependencyValues, ]) diff --git a/apps/sim/blocks/blocks/opencode.ts b/apps/sim/blocks/blocks/opencode.ts new file mode 100644 index 00000000000..31b26c78102 --- /dev/null +++ b/apps/sim/blocks/blocks/opencode.ts @@ -0,0 +1,254 @@ +import { OpenCodeIcon } from '@/components/icons' +import type { BlockConfig } from '@/blocks/types' +import type { OpenCodePromptResponse } from '@/tools/opencode/types' + +function coerceBoolean(value: unknown): boolean { + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'string') { + return value.toLowerCase() === 'true' + } + + return false +} + +function getOptionalString(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined + } + + const trimmedValue = value.trim() + return trimmedValue ? trimmedValue : undefined +} + +async function getOpenCodeBlockValues(blockId: string): Promise> { + const { useSubBlockStore } = await import('@/stores/workflows/subblock/store') + const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') + + const activeWorkflowId = useWorkflowRegistry.getState().activeWorkflowId + if (!activeWorkflowId) { + return {} + } + + return useSubBlockStore.getState().workflowValues[activeWorkflowId]?.[blockId] || {} +} + +async function getOpenCodeWorkspaceId(): Promise { + const { useWorkflowRegistry } = await import('@/stores/workflows/registry/store') + return useWorkflowRegistry.getState().hydration.workspaceId +} + +async function fetchOpenCodeOptions( + route: string, + query: Record +): Promise> { + const workspaceId = await getOpenCodeWorkspaceId() + if (!workspaceId) { + return [] + } + + const searchParams = new URLSearchParams({ workspaceId }) + for (const [key, value] of Object.entries(query)) { + if (value) { + searchParams.set(key, value) + } + } + + const response = await fetch(`${route}?${searchParams.toString()}`) + const result = (await response.json().catch(() => null)) as { + data?: Array<{ id: string; label: string }> + error?: string + } | null + + if (!response.ok) { + throw new Error(result?.error || `Request failed with status ${response.status}`) + } + + return Array.isArray(result?.data) ? result.data.map(({ id, label }) => ({ id, label })) : [] +} + +async function fetchOpenCodeOptionById( + route: string, + optionId: string, + query: Record +): Promise<{ label: string; id: string } | null> { + if (!optionId) { + return { label: 'None', id: '' } + } + + const options = await fetchOpenCodeOptions(route, query) + return options.find((option) => option.id === optionId) || null +} + +export const OpenCodeBlock: BlockConfig = { + type: 'opencode', + name: 'OpenCode', + description: 'Run a fixed-repository OpenCode expert inside a workflow.', + longDescription: + 'Use the internal OpenCode server from a workflow with a fixed repository, system prompt, provider, model, and optional agent preset. The workflow can then be deployed as MCP or A2A using the normal Workflow Deployment flow.', + docsLink: 'https://docs.sim.ai/tools/opencode', + category: 'tools', + bgColor: '#111827', + icon: OpenCodeIcon, + subBlocks: [ + { + id: 'repository', + title: 'Repository', + type: 'dropdown', + options: [], + placeholder: 'Select a repository', + required: true, + fetchOptions: async () => fetchOpenCodeOptions('/api/opencode/repos', {}), + fetchOptionById: async (blockId, _subBlockId, optionId) => + fetchOpenCodeOptionById('/api/opencode/repos', optionId, {}), + }, + { + id: 'systemPrompt', + title: 'System Prompt', + type: 'long-input', + placeholder: 'Define the role, rules, and behaviour for this OpenCode agent', + rows: 8, + }, + { + id: 'providerId', + title: 'Model Provider', + type: 'dropdown', + options: [], + placeholder: 'Select a provider', + required: true, + dependsOn: ['repository'], + fetchOptions: async (blockId: string) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + return fetchOpenCodeOptions('/api/opencode/providers', { repository }) + }, + fetchOptionById: async (blockId, _subBlockId, optionId) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + return fetchOpenCodeOptionById('/api/opencode/providers', optionId, { + repository, + }) + }, + }, + { + id: 'modelId', + title: 'Model ID', + type: 'combobox', + options: [], + placeholder: 'Select a model', + required: true, + searchable: true, + dependsOn: ['repository', 'providerId'], + fetchOptions: async (blockId: string) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + const providerId = typeof values.providerId === 'string' ? values.providerId : undefined + + if (!providerId) { + return [] + } + + return fetchOpenCodeOptions('/api/opencode/models', { repository, providerId }) + }, + fetchOptionById: async (blockId, _subBlockId, optionId) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + const providerId = typeof values.providerId === 'string' ? values.providerId : undefined + + if (!providerId) { + return null + } + + return fetchOpenCodeOptionById('/api/opencode/models', optionId, { + repository, + providerId, + }) + }, + }, + { + id: 'agent', + title: 'Agent', + type: 'dropdown', + options: [], + placeholder: 'Optional OpenCode agent preset', + dependsOn: ['repository'], + fetchOptions: async (blockId: string) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + const agents = await fetchOpenCodeOptions('/api/opencode/agents', { repository }) + return [{ label: 'None', id: '' }, ...agents] + }, + fetchOptionById: async (blockId, _subBlockId, optionId) => { + const values = await getOpenCodeBlockValues(blockId) + const repository = typeof values.repository === 'string' ? values.repository : undefined + return fetchOpenCodeOptionById('/api/opencode/agents', optionId, { + repository, + }) + }, + }, + { + id: 'prompt', + title: 'Prompt', + type: 'long-input', + placeholder: 'Map this to the runtime input, e.g. ', + required: true, + rows: 5, + }, + { + id: 'newThreadToggle', + title: 'New Thread', + type: 'switch', + canonicalParamId: 'newThread', + mode: 'basic', + defaultValue: false, + description: 'Start a fresh OpenCode thread instead of reusing the caller thread.', + }, + { + id: 'newThreadExpression', + title: 'New Thread', + type: 'short-input', + canonicalParamId: 'newThread', + mode: 'advanced', + placeholder: 'false or ', + description: 'Boolean expression used at runtime to force a new thread.', + }, + ], + tools: { + access: ['opencode_get_messages', 'opencode_list_repos', 'opencode_prompt'], + config: { + tool: () => 'opencode_prompt', + params: (params) => ({ + repository: params.repository, + systemPrompt: getOptionalString(params.systemPrompt), + providerId: params.providerId, + modelId: params.modelId, + ...(getOptionalString(params.agent) ? { agent: getOptionalString(params.agent) } : {}), + prompt: params.prompt, + newThread: coerceBoolean(params.newThread), + }), + }, + }, + inputs: { + repository: { type: 'string', description: 'Repository selected for the workflow' }, + systemPrompt: { type: 'string', description: 'System prompt applied to the OpenCode agent' }, + providerId: { type: 'string', description: 'OpenCode provider identifier' }, + modelId: { type: 'string', description: 'OpenCode model identifier' }, + agent: { type: 'string', description: 'Optional OpenCode agent preset name' }, + prompt: { type: 'string', description: 'Runtime prompt sent by the caller' }, + newThread: { + type: 'boolean', + description: 'Whether to force creation of a new OpenCode thread for the caller', + }, + }, + outputs: { + content: { type: 'string', description: 'Assistant text returned by OpenCode' }, + threadId: { type: 'string', description: 'OpenCode thread identifier used for the call' }, + cost: { + type: 'number', + description: 'Estimated OpenCode cost for the assistant response', + }, + error: { type: 'string', description: 'Error message if the OpenCode call fails' }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index a857038e021..909d02ce58b 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -126,6 +126,7 @@ import { OktaBlock } from '@/blocks/blocks/okta' import { OneDriveBlock } from '@/blocks/blocks/onedrive' import { OnePasswordBlock } from '@/blocks/blocks/onepassword' import { OpenAIBlock } from '@/blocks/blocks/openai' +import { OpenCodeBlock } from '@/blocks/blocks/opencode' import { OutlookBlock } from '@/blocks/blocks/outlook' import { PagerDutyBlock } from '@/blocks/blocks/pagerduty' import { ParallelBlock } from '@/blocks/blocks/parallel' @@ -346,6 +347,7 @@ export const registry: Record = { onepassword: OnePasswordBlock, onedrive: OneDriveBlock, openai: OpenAIBlock, + opencode: OpenCodeBlock, outlook: OutlookBlock, pagerduty: PagerDutyBlock, parallel_ai: ParallelBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index a7ee06c5e8d..46cb89dfae1 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -56,6 +56,29 @@ export function AgentIcon(props: SVGProps) { ) } +export function OpenCodeIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function ApiIcon(props: SVGProps) { return ( haystack.includes(needle)) +} + +export function getOpenCodeRouteError(error: unknown, resourceName: string): OpenCodeRouteError { + const message = getErrorMessage(error) + const normalized = message.toLowerCase() + + if ( + includesAny(normalized, [ + 'repository is required', + 'unknown opencode repository', + 'providerid is required', + ]) + ) { + return { + status: 400, + message, + } + } + + if (normalized.includes('credentials are not configured')) { + return { + status: 500, + message: 'OpenCode credentials are not configured in the app environment.', + } + } + + if (includesAny(normalized, ['401', '403', 'unauthorized', 'forbidden'])) { + return { + status: 502, + message: + 'OpenCode authentication failed. Align OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD with the running OpenCode server.', + } + } + + if ( + includesAny(normalized, [ + 'econnrefused', + 'enotfound', + 'fetch failed', + 'socket hang up', + 'timed out', + 'timeout', + ]) + ) { + return { + status: 503, + message: `OpenCode server is unreachable at ${getOpenCodeBaseUrl()}.`, + } + } + + return { + status: 500, + message: `Failed to fetch OpenCode ${resourceName}.`, + } +} diff --git a/apps/sim/lib/opencode/service.ts b/apps/sim/lib/opencode/service.ts new file mode 100644 index 00000000000..cc0e4258bd0 --- /dev/null +++ b/apps/sim/lib/opencode/service.ts @@ -0,0 +1,502 @@ +import { randomUUID } from 'node:crypto' +import type { + Agent, + AssistantMessage, + Model, + Part, + Project, + Provider, + SessionPromptResponse, +} from '@opencode-ai/sdk' +import { db } from '@sim/db' +import { memory } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import { createOpenCodeClient } from '@/lib/opencode/client' + +const logger = createLogger('OpenCodeService') +const OPEN_CODE_REPOSITORY_ROOT = '/app/repos' + +export interface OpenCodeRepositoryOption { + id: string + label: string + directory: string + projectId: string +} + +export interface OpenCodeProviderOption { + id: string + label: string +} + +export interface OpenCodeModelOption { + id: string + label: string + providerId: string +} + +export interface OpenCodeAgentOption { + id: string + label: string + description?: string +} + +export interface OpenCodeStoredSession { + sessionId: string + repository: string + updatedAt: string +} + +export interface OpenCodePromptRequest { + repository: string + prompt: string + providerId: string + modelId: string + systemPrompt?: string + agent?: string + sessionId?: string + title?: string +} + +export interface OpenCodePromptResult { + content: string + threadId: string + cost?: number + providerId?: string + modelId?: string + assistantError?: string +} + +export interface OpenCodeMessageItem { + messageId: string + role: 'user' | 'assistant' + content: string + cost?: number + providerId?: string + modelId?: string + createdAt: number +} + +function stripGitSuffix(value: string): string { + return value.endsWith('.git') ? value.slice(0, -4) : value +} + +function parseConfiguredRepositoryName(repositoryUrl: string): string | null { + const trimmedUrl = repositoryUrl.trim() + if (!trimmedUrl) { + return null + } + + try { + const url = new URL(trimmedUrl) + const segments = url.pathname + .split('/') + .map((segment) => segment.trim()) + .filter(Boolean) + + if (segments.length === 0) { + return null + } + + return stripGitSuffix(segments[segments.length - 1]) + } catch (error) { + logger.warn('Failed to parse OpenCode repository URL from OPENCODE_REPOS', { + repositoryUrl: trimmedUrl, + error, + }) + return null + } +} + +function listConfiguredOpenCodeRepositoryNames(): string[] { + const configuredRepositories = process.env.OPENCODE_REPOS?.split(',') + .map((item) => item.trim()) + .filter(Boolean) + + if (!configuredRepositories || configuredRepositories.length === 0) { + return [] + } + + const uniqueRepositories = new Map() + + for (const repositoryUrl of configuredRepositories) { + const repositoryName = parseConfiguredRepositoryName(repositoryUrl) + if (!repositoryName) { + continue + } + + if (uniqueRepositories.has(repositoryName)) { + logger.warn('Duplicate OpenCode repository name in OPENCODE_REPOS', { + repositoryName, + repositoryUrl, + }) + continue + } + + uniqueRepositories.set(repositoryName, repositoryUrl) + } + + return Array.from(uniqueRepositories.keys()).sort((left, right) => left.localeCompare(right)) +} + +function getRepositoryName(repository: string): string { + if (repository.startsWith(OPEN_CODE_REPOSITORY_ROOT)) { + return repository.slice(OPEN_CODE_REPOSITORY_ROOT.length + 1) + } + + return repository +} + +function buildOpenCodeRepositoryDirectory(repository: string): string { + return `${OPEN_CODE_REPOSITORY_ROOT}/${repository}` +} + +function isProjectInsideRepositoryRoot(project: Project): boolean { + return project.worktree.startsWith(`${OPEN_CODE_REPOSITORY_ROOT}/`) +} + +function mapProjectToRepositoryOption(project: Project): OpenCodeRepositoryOption { + const repository = getRepositoryName(project.worktree) + + return { + id: repository, + label: repository, + directory: buildOpenCodeRepositoryDirectory(repository), + projectId: project.id, + } +} + +function mapProviderToOption(provider: Provider): OpenCodeProviderOption { + return { + id: provider.id, + label: provider.name, + } +} + +function mapModelToOption(providerId: string, model: Model): OpenCodeModelOption { + return { + id: model.id, + label: model.name || model.id, + providerId, + } +} + +function mapAgentToOption(agent: Agent): OpenCodeAgentOption { + return { + id: agent.name, + label: agent.name, + description: agent.description, + } +} + +function getAssistantErrorMessage(error: AssistantMessage['error']): string | undefined { + if (!error) { + return undefined + } + + if ('data' in error && error.data && typeof error.data === 'object' && 'message' in error.data) { + const message = error.data.message + if (typeof message === 'string' && message.trim()) { + return message + } + } + + return error.name +} + +export function extractOpenCodeText(parts: Part[]): string { + return parts + .filter((part): part is Extract => part.type === 'text') + .map((part) => part.text.trim()) + .filter(Boolean) + .join('\n\n') +} + +function mapPromptResponseToResult(response: SessionPromptResponse): OpenCodePromptResult { + return { + content: extractOpenCodeText(response.parts), + threadId: response.info.sessionID, + cost: response.info.cost, + providerId: response.info.providerID, + modelId: response.info.modelID, + assistantError: getAssistantErrorMessage(response.info.error), + } +} + +export async function listOpenCodeRepositories(): Promise { + const client = createOpenCodeClient() + const projectResult = await client.project.list({ throwOnError: true }) + const projects = projectResult.data + const configuredRepositories = listConfiguredOpenCodeRepositoryNames() + + if (configuredRepositories.length > 0) { + const projectsByDirectory = new Map( + projects + .filter(isProjectInsideRepositoryRoot) + .map((project) => [project.worktree, project] as const) + ) + + return configuredRepositories.map((repository) => { + const directory = buildOpenCodeRepositoryDirectory(repository) + const project = projectsByDirectory.get(directory) + + return { + id: repository, + label: repository, + directory, + projectId: project?.id || `configured:${repository}`, + } + }) + } + + const repositories = projects + .filter(isProjectInsideRepositoryRoot) + .map(mapProjectToRepositoryOption) + .sort((left, right) => left.label.localeCompare(right.label)) + + const uniqueRepositories = new Map() + for (const repository of repositories) { + uniqueRepositories.set(repository.id, repository) + } + + return Array.from(uniqueRepositories.values()) +} + +export async function resolveOpenCodeRepositoryOption( + repository: string +): Promise { + const normalizedRepository = repository.trim() + if (!normalizedRepository) { + throw new Error('repository is required') + } + + const repositories = await listOpenCodeRepositories() + const repositoryOption = repositories.find((item) => item.id === normalizedRepository) + + if (!repositoryOption) { + throw new Error(`Unknown OpenCode repository: ${normalizedRepository}`) + } + + return repositoryOption +} + +export async function listOpenCodeProviders( + repository?: string +): Promise { + const client = createOpenCodeClient() + const directory = repository + ? (await resolveOpenCodeRepositoryOption(repository)).directory + : undefined + const configResult = await client.config.providers({ + query: directory ? { directory } : undefined, + throwOnError: true, + }) + const providers = configResult.data.providers + + return providers + .map((provider) => mapProviderToOption(provider)) + .sort((left, right) => left.label.localeCompare(right.label)) +} + +export async function listOpenCodeModels( + providerId: string, + repository?: string +): Promise { + const client = createOpenCodeClient() + const directory = repository + ? (await resolveOpenCodeRepositoryOption(repository)).directory + : undefined + const configResult = await client.config.providers({ + query: directory ? { directory } : undefined, + throwOnError: true, + }) + const providers = configResult.data.providers + + const provider = providers.find((item) => item.id === providerId) + if (!provider) { + return [] + } + + return Object.values(provider.models) + .map((model) => mapModelToOption(providerId, model)) + .sort((left, right) => left.label.localeCompare(right.label)) +} + +export async function listOpenCodeAgents(repository?: string): Promise { + const client = createOpenCodeClient() + const directory = repository + ? (await resolveOpenCodeRepositoryOption(repository)).directory + : undefined + const agentResult = await client.app.agents({ + query: directory ? { directory } : undefined, + throwOnError: true, + }) + const agents = agentResult.data + + return agents.map(mapAgentToOption).sort((left, right) => left.label.localeCompare(right.label)) +} + +export async function createOpenCodeSession( + repository: string, + title?: string +): Promise<{ id: string }> { + const client = createOpenCodeClient() + const repositoryOption = await resolveOpenCodeRepositoryOption(repository) + const sessionResult = await client.session.create({ + query: { directory: repositoryOption.directory }, + body: title ? { title } : undefined, + throwOnError: true, + }) + + return { id: sessionResult.data.id } +} + +export async function promptOpenCodeSession( + request: OpenCodePromptRequest +): Promise { + const client = createOpenCodeClient() + const repositoryOption = await resolveOpenCodeRepositoryOption(request.repository) + const directory = repositoryOption.directory + const sessionId = + request.sessionId || (await createOpenCodeSession(request.repository, request.title)).id + + const response = await client.session.prompt({ + path: { id: sessionId }, + query: { directory }, + body: { + parts: [{ type: 'text', text: request.prompt }], + ...(request.systemPrompt ? { system: request.systemPrompt } : {}), + ...(request.agent ? { agent: request.agent } : {}), + model: { + providerID: request.providerId, + modelID: request.modelId, + }, + }, + throwOnError: true, + }) + + return mapPromptResponseToResult(response.data) +} + +export async function getOpenCodeMessages( + repository: string, + sessionId: string +): Promise { + const client = createOpenCodeClient() + const repositoryOption = await resolveOpenCodeRepositoryOption(repository) + const response = await client.session.messages({ + path: { id: sessionId }, + query: { directory: repositoryOption.directory }, + throwOnError: true, + }) + + return response.data.map((message) => { + const baseItem = { + messageId: message.info.id, + role: message.info.role, + content: extractOpenCodeText(message.parts), + createdAt: message.info.time.created, + } + + if (message.info.role === 'assistant') { + return { + ...baseItem, + cost: message.info.cost, + providerId: message.info.providerID, + modelId: message.info.modelID, + } + } + + return baseItem + }) +} + +export async function getStoredOpenCodeSession( + workspaceId: string, + key: string +): Promise { + const result = await db + .select({ data: memory.data }) + .from(memory) + .where(and(eq(memory.workspaceId, workspaceId), eq(memory.key, key), isNull(memory.deletedAt))) + .limit(1) + + if (result.length === 0) { + return null + } + + const data = result[0].data + if (!data || typeof data !== 'object' || Array.isArray(data)) { + return null + } + + const sessionId = 'sessionId' in data ? data.sessionId : undefined + const repository = 'repository' in data ? data.repository : undefined + const updatedAt = 'updatedAt' in data ? data.updatedAt : undefined + + if ( + typeof sessionId !== 'string' || + typeof repository !== 'string' || + typeof updatedAt !== 'string' + ) { + return null + } + + return { sessionId, repository, updatedAt } +} + +export async function storeOpenCodeSession( + workspaceId: string, + key: string, + value: OpenCodeStoredSession +): Promise { + const now = new Date() + + await db + .insert(memory) + .values({ + id: randomUUID(), + workspaceId, + key, + data: value, + createdAt: now, + updatedAt: now, + }) + .onConflictDoUpdate({ + target: [memory.workspaceId, memory.key], + set: { + data: value, + updatedAt: now, + deletedAt: null, + }, + }) +} + +export function buildOpenCodeSessionMemoryKey(workflowId: string, userKey: string): string { + return `opencode:session:${workflowId}:${userKey}` +} + +export function buildOpenCodeSessionTitle(repository: string, userKey: string): string { + return `SIMAI ${getRepositoryName(repository)} ${userKey}` +} + +export function shouldRetryWithFreshOpenCodeSession(error: unknown): boolean { + const message = + error instanceof Error + ? error.message + : typeof error === 'string' + ? error + : JSON.stringify(error) + + const normalized = message.toLowerCase() + return ( + normalized.includes('404') || + normalized.includes('not found') || + normalized.includes('session') || + normalized.includes('does not exist') + ) +} + +export async function logOpenCodeFailure(message: string, error: unknown): Promise { + logger.error(message, { error }) +} diff --git a/apps/sim/next.config.ts b/apps/sim/next.config.ts index 139dd979775..28db8ee7371 100644 --- a/apps/sim/next.config.ts +++ b/apps/sim/next.config.ts @@ -1,3 +1,4 @@ +import path from 'path' import type { NextConfig } from 'next' import { env, getEnv, isTruthy } from './lib/core/config/env' import { isDev } from './lib/core/config/feature-flags' @@ -8,7 +9,19 @@ import { getWorkflowExecutionCSPPolicy, } from './lib/core/security/csp' +const OPEN_CODE_SDK_DIST_ABSOLUTE = path.resolve( + __dirname, + '../../node_modules/@opencode-ai/sdk/dist/index.js' +) +const OPEN_CODE_SDK_DIST_PROJECT_RELATIVE = '../../node_modules/@opencode-ai/sdk/dist/index.js' + const nextConfig: NextConfig = { + webpack: (config) => { + config.resolve = config.resolve || {} + config.resolve.alias = config.resolve.alias || {} + config.resolve.alias['@opencode-ai/sdk'] = OPEN_CODE_SDK_DIST_ABSOLUTE + return config + }, devIndicators: false, images: { remotePatterns: [ @@ -76,6 +89,9 @@ const nextConfig: NextConfig = { output: isTruthy(env.DOCKER_BUILD) ? 'standalone' : undefined, turbopack: { resolveExtensions: ['.tsx', '.ts', '.jsx', '.js', '.mjs', '.json'], + resolveAlias: { + '@opencode-ai/sdk': OPEN_CODE_SDK_DIST_PROJECT_RELATIVE, + }, }, serverExternalPackages: [ '@1password/sdk', diff --git a/apps/sim/package.json b/apps/sim/package.json index c588585b3dd..98dc028ca3a 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -12,6 +12,7 @@ "dev:webpack": "next dev --webpack", "dev:sockets": "bun run socket/index.ts", "dev:full": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev\" \"bun run dev:sockets\"", + "dev:full:webpack": "bunx concurrently -n \"App,Realtime\" -c \"cyan,magenta\" \"bun run dev:webpack\" \"bun run dev:sockets\"", "build": "bun run build:pptx-worker && next build", "build:pptx-worker": "bun build ./lib/execution/pptx-worker.cjs --target=node --format=cjs --outfile ./dist/pptx-worker.cjs", "start": "next start", @@ -50,6 +51,7 @@ "@linear/sdk": "40.0.0", "@marsidev/react-turnstile": "1.4.2", "@modelcontextprotocol/sdk": "1.20.2", + "@opencode-ai/sdk": "0.8.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-jaeger": "2.1.0", "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", diff --git a/apps/sim/tools/opencode/get_messages.ts b/apps/sim/tools/opencode/get_messages.ts new file mode 100644 index 00000000000..7e679078516 --- /dev/null +++ b/apps/sim/tools/opencode/get_messages.ts @@ -0,0 +1,60 @@ +import type { OpenCodeGetMessagesParams, OpenCodeGetMessagesResponse } from '@/tools/opencode/types' +import type { ToolConfig } from '@/tools/types' + +export const openCodeGetMessagesTool: ToolConfig< + OpenCodeGetMessagesParams, + OpenCodeGetMessagesResponse +> = { + id: 'opencode_get_messages', + name: 'OpenCode Get Messages', + description: 'Retrieve the current message history for an OpenCode thread.', + version: '1.0.0', + + params: { + repository: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Repository configured for the OpenCode session.', + }, + threadId: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'OpenCode thread ID to inspect.', + }, + }, + + request: { + url: '/api/tools/opencode/messages', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + repository: params.repository, + threadId: params.threadId, + }), + }, + + outputs: { + threadId: { type: 'string', description: 'OpenCode thread identifier' }, + messages: { + type: 'array', + description: 'Messages currently stored in the OpenCode thread.', + items: { + type: 'object', + properties: { + messageId: { type: 'string', description: 'Message identifier' }, + role: { type: 'string', description: 'Message role' }, + content: { type: 'string', description: 'Extracted text content' }, + cost: { type: 'number', description: 'Estimated cost for assistant messages' }, + providerId: { type: 'string', description: 'Provider used for assistant messages' }, + modelId: { type: 'string', description: 'Model used for assistant messages' }, + createdAt: { type: 'number', description: 'Unix timestamp in milliseconds' }, + }, + }, + }, + count: { type: 'number', description: 'Number of messages returned' }, + }, +} diff --git a/apps/sim/tools/opencode/index.ts b/apps/sim/tools/opencode/index.ts new file mode 100644 index 00000000000..eefa1c166b8 --- /dev/null +++ b/apps/sim/tools/opencode/index.ts @@ -0,0 +1,5 @@ +import { openCodeGetMessagesTool } from '@/tools/opencode/get_messages' +import { openCodeListReposTool } from '@/tools/opencode/list_repos' +import { openCodePromptTool } from '@/tools/opencode/prompt' + +export { openCodeGetMessagesTool, openCodeListReposTool, openCodePromptTool } diff --git a/apps/sim/tools/opencode/list_repos.ts b/apps/sim/tools/opencode/list_repos.ts new file mode 100644 index 00000000000..bc75652aed9 --- /dev/null +++ b/apps/sim/tools/opencode/list_repos.ts @@ -0,0 +1,41 @@ +import type { OpenCodeListReposResponse } from '@/tools/opencode/types' +import type { ToolConfig } from '@/tools/types' + +export const openCodeListReposTool: ToolConfig, OpenCodeListReposResponse> = { + id: 'opencode_list_repos', + name: 'OpenCode List Repositories', + description: 'List the repositories currently available in the internal OpenCode server.', + version: '1.0.0', + + params: {}, + + request: { + url: '/api/tools/opencode/repos', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: () => ({}), + }, + + outputs: { + repositories: { + type: 'array', + description: 'Repositories available in OpenCode.', + items: { + type: 'object', + properties: { + id: { type: 'string', description: 'Repository identifier' }, + label: { type: 'string', description: 'Repository label' }, + directory: { type: 'string', description: 'Absolute directory mounted in OpenCode' }, + projectId: { + type: 'string', + description: + 'OpenCode project identifier when registered, otherwise a configured fallback', + }, + }, + }, + }, + count: { type: 'number', description: 'Number of repositories returned' }, + }, +} diff --git a/apps/sim/tools/opencode/prompt.ts b/apps/sim/tools/opencode/prompt.ts new file mode 100644 index 00000000000..c5614403cdf --- /dev/null +++ b/apps/sim/tools/opencode/prompt.ts @@ -0,0 +1,89 @@ +import type { OpenCodePromptParams, OpenCodePromptResponse } from '@/tools/opencode/types' +import type { ToolConfig } from '@/tools/types' + +export const openCodePromptTool: ToolConfig = { + id: 'opencode_prompt', + name: 'OpenCode Prompt', + description: + 'Create or continue an OpenCode thread for the authenticated workflow caller and send a prompt.', + version: '1.0.0', + + params: { + repository: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Repository configured for this workflow.', + }, + systemPrompt: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'System prompt used for the OpenCode assistant.', + }, + providerId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'LLM provider identifier configured in OpenCode.', + }, + modelId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Model identifier configured in OpenCode.', + }, + agent: { + type: 'string', + required: false, + visibility: 'user-only', + description: 'Optional OpenCode agent preset name.', + }, + prompt: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Prompt to send to the configured OpenCode assistant.', + }, + newThread: { + type: 'boolean', + required: false, + default: false, + visibility: 'user-or-llm', + description: 'Create a new thread instead of reusing the current caller thread.', + }, + }, + + request: { + url: '/api/tools/opencode/prompt', + method: 'POST', + headers: () => ({ + 'Content-Type': 'application/json', + }), + body: (params) => ({ + repository: params.repository, + systemPrompt: params.systemPrompt, + providerId: params.providerId, + modelId: params.modelId, + agent: params.agent, + prompt: params.prompt, + newThread: params.newThread, + _context: params._context, + }), + }, + + outputs: { + content: { type: 'string', description: 'Assistant text returned by OpenCode' }, + threadId: { type: 'string', description: 'OpenCode thread identifier used for this response' }, + cost: { + type: 'number', + description: 'Estimated cost returned by OpenCode for the assistant message', + optional: true, + }, + error: { + type: 'string', + description: 'Error message if the OpenCode request failed', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/opencode/types.ts b/apps/sim/tools/opencode/types.ts new file mode 100644 index 00000000000..20193ce1dd6 --- /dev/null +++ b/apps/sim/tools/opencode/types.ts @@ -0,0 +1,58 @@ +import type { ToolResponse, WorkflowToolExecutionContext } from '@/tools/types' + +export interface OpenCodePromptParams { + repository: string + prompt: string + providerId: string + modelId: string + systemPrompt?: string + agent?: string + newThread?: boolean | string + _context?: WorkflowToolExecutionContext +} + +export interface OpenCodeRepositoryItem { + id: string + label: string + directory: string + projectId: string +} + +export interface OpenCodeListReposResponse extends ToolResponse { + output: { + repositories: OpenCodeRepositoryItem[] + count: number + } +} + +export interface OpenCodePromptResponse extends ToolResponse { + output: { + content: string + threadId: string + cost?: number + error?: string + } +} + +export interface OpenCodeGetMessagesParams { + repository: string + threadId: string +} + +export interface OpenCodeMessage { + messageId: string + role: 'user' | 'assistant' + content: string + cost?: number + providerId?: string + modelId?: string + createdAt: number +} + +export interface OpenCodeGetMessagesResponse extends ToolResponse { + output: { + threadId: string + messages: OpenCodeMessage[] + count: number + } +} diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index be98b26b3de..4d18cbf95db 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -1613,6 +1613,11 @@ import { onepasswordUpdateItemTool, } from '@/tools/onepassword' import { openAIEmbeddingsTool, openAIImageTool } from '@/tools/openai' +import { + openCodeGetMessagesTool, + openCodeListReposTool, + openCodePromptTool, +} from '@/tools/opencode' import { outlookCopyTool, outlookDeleteTool, @@ -3994,6 +3999,9 @@ export const tools: Record = { microsoft_ad_list_group_members: microsoftAdListGroupMembersTool, microsoft_ad_add_group_member: microsoftAdAddGroupMemberTool, microsoft_ad_remove_group_member: microsoftAdRemoveGroupMemberTool, + opencode_get_messages: openCodeGetMessagesTool, + opencode_list_repos: openCodeListReposTool, + opencode_prompt: openCodePromptTool, microsoft_teams_read_chat: microsoftTeamsReadChatTool, microsoft_teams_write_chat: microsoftTeamsWriteChatTool, microsoft_teams_read_channel: microsoftTeamsReadChannelTool, diff --git a/apps/sim/vitest.config.ts b/apps/sim/vitest.config.ts index f5aec399e8b..8395f5c982f 100644 --- a/apps/sim/vitest.config.ts +++ b/apps/sim/vitest.config.ts @@ -47,6 +47,10 @@ export default defineConfig({ find: '@sim/logger', replacement: path.resolve(__dirname, '../../packages/logger/src'), }, + { + find: '@opencode-ai/sdk', + replacement: path.resolve(__dirname, '../../node_modules/@opencode-ai/sdk/dist/index.js'), + }, { find: '@/stores/console/store', replacement: path.resolve(__dirname, 'stores/console/store.ts'), From 314656246e2a2dbf6855c5aba80ba7e057cf8305 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 14:31:46 +0100 Subject: [PATCH 02/31] feat(opencode): add optional runtime overlay --- README.md | 136 ++++++++++++++++++++++- apps/sim/.env.example | 19 ++++ apps/sim/blocks/blocks/opencode.ts | 6 +- apps/sim/lib/core/config/env.ts | 2 + bun.lock | 85 +++++++++++++++ docker-compose.opencode.local.yml | 47 ++++++++ docker-compose.opencode.yml | 45 ++++++++ docker/opencode.Dockerfile | 38 +++++++ docker/opencode/README.md | 169 +++++++++++++++++++++++++++++ docker/opencode/entrypoint.sh | 109 +++++++++++++++++++ docker/opencode/git-askpass.sh | 26 +++++ docker/opencode/healthcheck.sh | 14 +++ docker/opencode/sync-repos.sh | 100 +++++++++++++++++ 13 files changed, 794 insertions(+), 2 deletions(-) create mode 100644 docker-compose.opencode.local.yml create mode 100644 docker-compose.opencode.yml create mode 100644 docker/opencode.Dockerfile create mode 100644 docker/opencode/README.md create mode 100755 docker/opencode/entrypoint.sh create mode 100755 docker/opencode/git-askpass.sh create mode 100755 docker/opencode/healthcheck.sh create mode 100755 docker/opencode/sync-repos.sh diff --git a/README.md b/README.md index 17e2ad1ae50..34f8a88d701 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,15 @@ Docker must be installed and running on your machine. ### Self-hosted: Docker Compose +For local Docker builds, use the local compose file: + +```bash +git clone https://github.com/simstudioai/sim.git && cd sim +docker compose -f docker-compose.local.yml up -d --build +``` + +For a cloud or production-style deployment, use the published images: + ```bash git clone https://github.com/simstudioai/sim.git && cd sim docker compose -f docker-compose.prod.yml up -d @@ -70,6 +79,110 @@ docker compose -f docker-compose.prod.yml up -d Open [http://localhost:3000](http://localhost:3000) +#### OpenCode Setup + +OpenCode is opt-in. By default the `OpenCode` block stays hidden so the base Sim UX and deployment path remain unchanged. + +Minimum setup: + +```bash +cp apps/sim/.env.example apps/sim/.env +``` + +Then add these values to `apps/sim/.env`: + +```env +NEXT_PUBLIC_OPENCODE_ENABLED=true +OPENCODE_SERVER_USERNAME=opencode +OPENCODE_SERVER_PASSWORD=change-me +OPENCODE_REPOS=https://github.com/octocat/Hello-World.git + +# Pick at least one provider key that OpenCode can use +GEMINI_API_KEY=your-gemini-key +# or OPENAI_API_KEY=... +# or ANTHROPIC_API_KEY=... +``` + +If you want private repositories: + +```env +# Generic HTTPS or Azure Repos +GIT_USERNAME=your-user-or-email +GIT_TOKEN=your-token-or-pat + +# Optional GitHub-only fallback +GITHUB_TOKEN=your-github-token +``` + +Important: + +- The `OpenCode` block remains hidden unless `NEXT_PUBLIC_OPENCODE_ENABLED=true` is set on the Sim app. +- `docker compose` reads environment from the shell, not from `apps/sim/.env` automatically. +- If you want the app and the OpenCode runtime to use the same credentials, load that file before starting compose: + +```bash +set -a +source apps/sim/.env +set +a +docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build +``` + +Local vs production behavior: + +- `docker-compose.local.yml` + - remains unchanged +- `docker-compose.opencode.local.yml` + - adds OpenCode locally without changing the base local compose file + - publishes `OPENCODE_PORT` to the host so `next dev` on the host can talk to OpenCode + - defaults `OPENCODE_SERVER_USERNAME=opencode` + - defaults `OPENCODE_SERVER_PASSWORD=dev-opencode-password` if you do not set one explicitly +- `docker-compose.prod.yml` + - contains the upstream-style base deployment only +- `docker-compose.opencode.yml` + - adds the `opencode` service as a production overlay + - builds the OpenCode runtime locally from this repository instead of requiring an official Sim-hosted image + - injects the required `NEXT_PUBLIC_OPENCODE_ENABLED` and `OPENCODE_*` variables into `simstudio` + - keeps OpenCode internal to the Docker network with `expose`, not a published host port + - expects `OPENCODE_SERVER_PASSWORD` to be set explicitly + +Production deploy command: + +```bash +docker compose -f docker-compose.prod.yml -f docker-compose.opencode.yml up -d --build +``` + +For local hot reload with `next dev` on the host, also set this in `apps/sim/.env`: + +```env +OPENCODE_BASE_URL=http://127.0.0.1:4096 +``` + +Then start only the optional OpenCode runtime: + +```bash +docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build opencode +``` + +Without that override, host-side Next.js cannot reliably reach the Docker service alias. + +Notes: + +- If `OPENCODE_REPOS` is empty, `opencode` still starts but no repositories are cloned. +- Repositories are cloned into `/app/repos/`. +- Private Azure Repos must use `https` plus `GIT_USERNAME` and `GIT_TOKEN`; the container will not prompt interactively for passwords. +- `GOOGLE_GENERATIVE_AI_API_KEY` is optional; the optional overlays map it automatically from `GEMINI_API_KEY` if not set. +- If you prefer to run OpenCode in separate infrastructure, skip the overlays and point Sim at that deployment with `OPENCODE_BASE_URL`, `OPENCODE_SERVER_USERNAME`, and `OPENCODE_SERVER_PASSWORD`. + +Basic verification after startup: + +```bash +curl -u "opencode:change-me" http://127.0.0.1:4096/global/health +``` + +If you changed the username, password, or port, use those values instead. + +See [`docker/opencode/README.md`](docker/opencode/README.md) for service-specific verification steps and runtime behavior. + #### Using Local Models with Ollama Run Sim with local AI models using [Ollama](https://ollama.ai) - no external APIs required: @@ -136,6 +249,26 @@ cp packages/db/.env.example packages/db/.env # Edit both .env files to set DATABASE_URL="postgresql://postgres:your_password@localhost:5432/simstudio" ``` +If you want to use the OpenCode workflow block while running `next dev` on the host, also set these in `apps/sim/.env`: + +```env +NEXT_PUBLIC_OPENCODE_ENABLED=true +OPENCODE_BASE_URL=http://127.0.0.1:4096 +OPENCODE_SERVER_USERNAME=opencode +OPENCODE_SERVER_PASSWORD=change-me +OPENCODE_REPOS=https://github.com/octocat/Hello-World.git +GEMINI_API_KEY=your-gemini-key +``` + +Then export the same environment before starting the OpenCode container so the app and Docker use identical credentials: + +```bash +set -a +source apps/sim/.env +set +a +docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build opencode +``` + 4. Run migrations: ```bash @@ -146,9 +279,10 @@ cd packages/db && bunx drizzle-kit migrate --config=./drizzle.config.ts ```bash bun run dev:full # Starts both Next.js app and realtime socket server +bun run dev:full:webpack # Same, but using Webpack instead of Turbopack ``` -Or run separately: `bun run dev` (Next.js) and `cd apps/sim && bun run dev:sockets` (realtime). +Or run separately: `bun run dev` (Next.js/Turbopack), `cd apps/sim && bun run dev:webpack` (Next.js/Webpack), and `cd apps/sim && bun run dev:sockets` (realtime). ## Copilot API Keys diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 6c22b09eef4..b3c39e424f7 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -29,6 +29,25 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # VLLM_BASE_URL=http://localhost:8000 # Base URL for your self-hosted vLLM (OpenAI-compatible) # VLLM_API_KEY= # Optional bearer token if your vLLM instance requires auth +# Internal OpenCode Service (Optional, opt-in) +# NEXT_PUBLIC_OPENCODE_ENABLED=true # Required to show the OpenCode block in the UI +# # Leave unset to keep the block hidden and preserve the default Sim UX +# OPENCODE_BASE_URL=http://127.0.0.1:4096 # Use this when SIM runs on the host (for example, `bun run dev`) and OpenCode runs in Docker +# OPENCODE_BASE_URL=http://opencode:4096 # Use this when SIM and OpenCode both run in Docker Compose +# # Or point this to any separate OpenCode deployment that implements the same auth contract +# OPENCODE_PORT=4096 +# OPENCODE_SERVER_USERNAME=opencode +# OPENCODE_SERVER_PASSWORD=change-me # Required for the internal OpenCode service +# OPENCODE_REPOS=https://github.com/org/ui-components,https://github.com/org/design-tokens +# OPENCODE_REPOS=https://dev.azure.com/org/project/_git/repo # Azure Repos over HTTPS also works +# GIT_USERNAME= # Optional HTTPS git username for private repos, including Azure Repos +# GIT_TOKEN= # Optional HTTPS git token/PAT for private repos, including Azure Repos +# GITHUB_TOKEN= # Optional GitHub token fallback for private GitHub repos +# OPENAI_API_KEY= # OpenCode can use any supported provider key from the environment +# ANTHROPIC_API_KEY= # Optional if you prefer Anthropic for OpenCode +# GEMINI_API_KEY= # Optional if you prefer Gemini for OpenCode +# GOOGLE_GENERATIVE_AI_API_KEY= # Optional explicit alias for OpenCode's Google provider; defaults from GEMINI_API_KEY in the optional compose overlays + # Admin API (Optional - for self-hosted GitOps) # ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import. # Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces diff --git a/apps/sim/blocks/blocks/opencode.ts b/apps/sim/blocks/blocks/opencode.ts index 31b26c78102..96fbf40090d 100644 --- a/apps/sim/blocks/blocks/opencode.ts +++ b/apps/sim/blocks/blocks/opencode.ts @@ -1,7 +1,10 @@ -import { OpenCodeIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' +import { OpenCodeIcon } from '@/components/icons' +import { getEnv, isTruthy } from '@/lib/core/config/env' import type { OpenCodePromptResponse } from '@/tools/opencode/types' +const isOpenCodeEnabled = isTruthy(getEnv('NEXT_PUBLIC_OPENCODE_ENABLED')) + function coerceBoolean(value: unknown): boolean { if (typeof value === 'boolean') { return value @@ -92,6 +95,7 @@ export const OpenCodeBlock: BlockConfig = { category: 'tools', bgColor: '#111827', icon: OpenCodeIcon, + hideFromToolbar: !isOpenCodeEnabled, subBlocks: [ { id: 'repository', diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 65ac812ec86..4c9d86c7db2 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -413,6 +413,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_PUBLIC_API: z.boolean().optional(), // Disable public API access UI toggle globally NEXT_PUBLIC_INBOX_ENABLED: z.boolean().optional(), // Enable inbox (Sim Mailer) on self-hosted NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: z.boolean().optional().default(true), // Control visibility of email/password login forms + NEXT_PUBLIC_OPENCODE_ENABLED: z.boolean().optional(), // Show the OpenCode block and related UI when a compatible runtime is configured NEXT_PUBLIC_TURNSTILE_SITE_KEY: z.string().min(1).optional(), // Cloudflare Turnstile site key for captcha widget }, @@ -447,6 +448,7 @@ export const env = createEnv({ NEXT_PUBLIC_DISABLE_PUBLIC_API: process.env.NEXT_PUBLIC_DISABLE_PUBLIC_API, NEXT_PUBLIC_INBOX_ENABLED: process.env.NEXT_PUBLIC_INBOX_ENABLED, NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED: process.env.NEXT_PUBLIC_EMAIL_PASSWORD_SIGNUP_ENABLED, + NEXT_PUBLIC_OPENCODE_ENABLED: process.env.NEXT_PUBLIC_OPENCODE_ENABLED, NEXT_PUBLIC_TURNSTILE_SITE_KEY: process.env.NEXT_PUBLIC_TURNSTILE_SITE_KEY, NEXT_PUBLIC_E2B_ENABLED: process.env.NEXT_PUBLIC_E2B_ENABLED, NEXT_PUBLIC_COPILOT_TRAINING_ENABLED: process.env.NEXT_PUBLIC_COPILOT_TRAINING_ENABLED, diff --git a/bun.lock b/bun.lock index ed31b1d954d..6976e77847e 100644 --- a/bun.lock +++ b/bun.lock @@ -75,6 +75,7 @@ "@linear/sdk": "40.0.0", "@marsidev/react-turnstile": "1.4.2", "@modelcontextprotocol/sdk": "1.20.2", + "@opencode-ai/sdk": "0.8.0", "@opentelemetry/api": "^1.9.0", "@opentelemetry/exporter-jaeger": "2.1.0", "@opentelemetry/exporter-trace-otlp-http": "^0.200.0", @@ -730,6 +731,10 @@ "@hexagon/base64": ["@hexagon/base64@1.1.28", "", {}, "sha512-lhqDEAvWixy3bZ+UOYbPwUbBkwBq5C1LAJ/xPC8Oi+lL54oyakv/npbA0aU2hgCsx/1NUd4IBvV03+aUBWxerw=="], + "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.0.6", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.0", "lodash": "^4.17.21" } }, "sha512-yktiFZoWPtEW8QKS65eqKwA5MTKp88CyiL8q72WynrBs/73SAaxlSWlA2zW/DZlywZ5hX1OYzrCC0wFdvO9c2w=="], + + "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.81.0", "", { "dependencies": { "@hey-api/json-schema-ref-parser": "1.0.6", "ansi-colors": "4.1.3", "c12": "2.0.1", "color-support": "1.1.3", "commander": "13.0.0", "handlebars": "4.7.8", "js-yaml": "4.1.0", "open": "10.1.2", "semver": "7.7.2" }, "peerDependencies": { "typescript": "^5.5.3" }, "bin": { "openapi-ts": "bin/index.cjs" } }, "sha512-PoJukNBkUfHOoMDpN33bBETX49TUhy7Hu8Sa0jslOvFndvZ5VjQr4Nl/Dzjb9LG1Lp5HjybyTJMA6a1zYk/q6A=="], + "@hookform/resolvers": ["@hookform/resolvers@4.1.3", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ=="], "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], @@ -804,6 +809,8 @@ "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + "@jsdevtools/ono": ["@jsdevtools/ono@7.1.3", "", {}, "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg=="], + "@jsonhero/path": ["@jsonhero/path@1.0.21", "", {}, "sha512-gVUDj/92acpVoJwsVJ/RuWOaHyG4oFzn898WNGQItLCTQ+hOaVlEaImhwE1WqOTf+l3dGOUkbSiVKlb3q1hd1Q=="], "@kurkle/color": ["@kurkle/color@0.3.4", "", {}, "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w=="], @@ -904,6 +911,8 @@ "@octokit/types": ["@octokit/types@14.1.0", "", { "dependencies": { "@octokit/openapi-types": "^25.1.0" } }, "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@0.8.0", "", { "dependencies": { "@hey-api/openapi-ts": "0.81.0" } }, "sha512-MekFgqYcsdNyX2mNq12faigEq41hVn5jDhq9YL5QlatUzhd8g7iQY192bHi9Kml0e0qdGWfbDx6qjLlYsy4EfQ=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.200.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-IKJBQxh91qJ+3ssRly5hYEJ8NDHu9oY/B1PXVSCWf7zytmYO9RNLB0Ox9XQ/fJ8m6gY6Q6NtBWlmXfaXt5Uc4Q=="], @@ -1696,6 +1705,8 @@ "ansi-color": ["ansi-color@0.2.2", "", {}, "sha512-qPx7iZZDHITYrrfzaUFXQpIcF2xYifcQHQflP1pFz8yY3lfU6GgCHb0+hJD7nimYKO7f2iaYYwBpZ+GaNcAhcA=="], + "ansi-colors": ["ansi-colors@4.1.3", "", {}, "sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw=="], + "ansi-escapes": ["ansi-escapes@4.3.2", "", { "dependencies": { "type-fest": "^0.21.3" } }, "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ=="], "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], @@ -1828,6 +1839,8 @@ "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], "c12": ["c12@3.1.0", "", { "dependencies": { "chokidar": "^4.0.3", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^16.6.1", "exsolve": "^1.0.7", "giget": "^2.0.0", "jiti": "^2.4.2", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^1.0.0", "pkg-types": "^2.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw=="], @@ -1922,6 +1935,8 @@ "color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="], + "color-support": ["color-support@1.1.3", "", { "bin": { "color-support": "bin.js" } }, "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg=="], + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], @@ -2048,8 +2063,14 @@ "deepmerge-ts": ["deepmerge-ts@7.1.5", "", {}, "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw=="], + "default-browser": ["default-browser@5.5.0", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + "defaults": ["defaults@1.0.4", "", { "dependencies": { "clone": "^1.0.2" } }, "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A=="], + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], @@ -2316,6 +2337,8 @@ "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + "fs-minipass": ["fs-minipass@2.1.0", "", { "dependencies": { "minipass": "^3.0.0" } }, "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg=="], + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fumadocs-core": ["fumadocs-core@16.6.7", "", { "dependencies": { "@formatjs/intl-localematcher": "^0.8.1", "@orama/orama": "^3.1.18", "@shikijs/rehype": "^3.23.0", "@shikijs/transformers": "^3.23.0", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", "image-size": "^2.0.2", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "negotiator": "^1.0.0", "npm-to-yarn": "^3.0.1", "path-to-regexp": "^8.3.0", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", "scroll-into-view-if-needed": "^3.1.0", "shiki": "^3.23.0", "tinyglobby": "^0.2.15", "unified": "^11.0.5", "unist-util-visit": "^5.1.0", "vfile": "^6.0.3" }, "peerDependencies": { "@mdx-js/mdx": "*", "@mixedbread/sdk": "^0.46.0", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", "@types/estree-jsx": "*", "@types/hast": "*", "@types/mdast": "*", "@types/react": "*", "algoliasearch": "5.x.x", "lucide-react": "*", "next": "16.x.x", "react": "^19.2.0", "react-dom": "^19.2.0", "react-router": "7.x.x", "waku": "^0.26.0 || ^0.27.0 || ^1.0.0", "zod": "4.x.x" }, "optionalPeers": ["@mdx-js/mdx", "@mixedbread/sdk", "@orama/core", "@oramacloud/client", "@tanstack/react-router", "@types/estree-jsx", "@types/hast", "@types/mdast", "@types/react", "algoliasearch", "lucide-react", "next", "react", "react-dom", "react-router", "waku", "zod"] }, "sha512-Re68KbSJkjrZP3zhWqdWfd8Oo1/3H5ql3FOa+lCJkjJSDsrPbeCqvQ7zVaWrnZ4j5BnIvRtKDrDEPXfDqSNOqA=="], @@ -2380,6 +2403,8 @@ "gtoken": ["gtoken@8.0.0", "", { "dependencies": { "gaxios": "^7.0.0", "jws": "^4.0.0" } }, "sha512-+CqsMbHPiSTdtSO14O51eMNlrp9N79gmeqmXeouJOhfucAedHw9noVe/n5uJk3tbKE6a+6ZCQg3RPhVhHByAIw=="], + "handlebars": ["handlebars@4.7.8", "", { "dependencies": { "minimist": "^1.2.5", "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, "optionalDependencies": { "uglify-js": "^3.1.4" }, "bin": { "handlebars": "bin/handlebars" } }, "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ=="], + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], @@ -2506,6 +2531,8 @@ "is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="], + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + "is-interactive": ["is-interactive@2.0.0", "", {}, "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ=="], "is-lite": ["is-lite@1.2.1", "", {}, "sha512-pgF+L5bxC+10hLBgf6R2P4ZZUBOQIIacbdo8YvuCP8/JvsWxG7aZ9p10DYuLtifFci4l3VITphhMlMV4Y+urPw=="], @@ -2862,6 +2889,8 @@ "mitt": ["mitt@3.0.1", "", {}, "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="], + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], "mlly": ["mlly@1.8.2", "", { "dependencies": { "acorn": "^8.16.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "ufo": "^1.6.3" } }, "sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA=="], @@ -2904,6 +2933,8 @@ "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "neo-async": ["neo-async@2.6.2", "", {}, "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw=="], + "neo4j-driver": ["neo4j-driver@6.0.1", "", { "dependencies": { "neo4j-driver-bolt-connection": "6.0.1", "neo4j-driver-core": "6.0.1", "rxjs": "^7.8.2" } }, "sha512-8DDF2MwEJNz7y7cp97x4u8fmVIP4CWS8qNBxdwxTG0fWtsS+2NdeC+7uXwmmuFOpHvkfXqv63uWY73bfDtOH8Q=="], "neo4j-driver-bolt-connection": ["neo4j-driver-bolt-connection@6.0.1", "", { "dependencies": { "buffer": "^6.0.3", "neo4j-driver-core": "6.0.1", "string_decoder": "^1.3.0" } }, "sha512-1KyG73TO+CwnYJisdHD0sjUw9yR+P5q3JFcmVPzsHT4/whzCjuXSMpmY4jZcHH2PdY2cBUq4l/6WcDiPMxW2UA=="], @@ -2988,6 +3019,8 @@ "oniguruma-to-es": ["oniguruma-to-es@4.3.5", "", { "dependencies": { "oniguruma-parser": "^0.12.1", "regex": "^6.1.0", "regex-recursion": "^6.0.2" } }, "sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ=="], + "open": ["open@10.1.2", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "is-wsl": "^3.1.0" } }, "sha512-cxN6aIDPz6rm8hbebcP7vrQNhvRcveZoJU72Y7vskh4oIm+BZwBECnx5nTmrlres1Qapvx27Qo1Auukpf8PKXw=="], + "openai": ["openai@4.104.0", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" }, "peerDependencies": { "ws": "^8.18.0", "zod": "^3.23.8" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA=="], "openapi-fetch": ["openapi-fetch@0.14.1", "", { "dependencies": { "openapi-typescript-helpers": "^0.0.15" } }, "sha512-l7RarRHxlEZYjMLd/PR0slfMVse2/vvIAGm75/F7J6MlQ8/b9uUQmUF2kCPrQhJqMXSxmYWObVgeYXbFYzZR+A=="], @@ -3306,6 +3339,8 @@ "rss-parser": ["rss-parser@3.13.0", "", { "dependencies": { "entities": "^2.0.3", "xml2js": "^0.5.0" } }, "sha512-7jWUBV5yGN3rqMMj7CZufl/291QAhvrrGpDNE4k/02ZchL0npisiYYqULF71jCEKoIiHvK/Q2e6IkDwPziT7+w=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + "run-async": ["run-async@2.4.1", "", {}, "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="], "run-exclusive": ["run-exclusive@2.2.19", "", { "dependencies": { "minimal-polyfills": "^2.2.3" } }, "sha512-K3mdoAi7tjJ/qT7Flj90L7QyPozwUaAG+CVhkdDje4HLKXUYC3N/Jzkau3flHVDLQVhiHBtcimVodMjN9egYbA=="], @@ -3612,6 +3647,8 @@ "ufo": ["ufo@1.6.3", "", {}, "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q=="], + "uglify-js": ["uglify-js@3.19.3", "", { "bin": { "uglifyjs": "bin/uglifyjs" } }, "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ=="], + "uid2": ["uid2@1.0.0", "", {}, "sha512-+I6aJUv63YAcY9n4mQreLUt0d4lvwkkopDNmpomkAUz0fAkEMV9pRWxN0EjhW1YfRhcuyHg2v3mwddCDW1+LFQ=="], "ulid": ["ulid@2.4.0", "", { "bin": { "ulid": "bin/cli.js" } }, "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg=="], @@ -3724,6 +3761,8 @@ "word": ["word@0.3.0", "", {}, "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA=="], + "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], + "wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], @@ -3920,6 +3959,14 @@ "@fumari/json-schema-to-typescript/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "@hey-api/openapi-ts/c12": ["c12@2.0.1", "", { "dependencies": { "chokidar": "^4.0.1", "confbox": "^0.1.7", "defu": "^6.1.4", "dotenv": "^16.4.5", "giget": "^1.2.3", "jiti": "^2.3.0", "mlly": "^1.7.1", "ohash": "^1.1.4", "pathe": "^1.1.2", "perfect-debounce": "^1.0.0", "pkg-types": "^1.2.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "^0.3.5" }, "optionalPeers": ["magicast"] }, "sha512-Z4JgsKXHG37C6PYUtIxCfLJZvo6FyhHJoClwwb9ftUkLpPSkuYqn6Tr+vnaN8hymm0kIbcg6Ey3kv/Q71k5w/A=="], + + "@hey-api/openapi-ts/commander": ["commander@13.0.0", "", {}, "sha512-oPYleIY8wmTVzkvQq10AEok6YcTC4sRUBl8F9gVuwchGVUCTbl/vhLTaQqutuuySYOsu8YTgV+OxKc/8Yvx+mQ=="], + + "@hey-api/openapi-ts/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + "@inquirer/external-editor/iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="], "@langchain/core/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], @@ -4216,6 +4263,8 @@ "form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + "fs-minipass/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "fumadocs-core/shiki": ["shiki@3.23.0", "", { "dependencies": { "@shikijs/core": "3.23.0", "@shikijs/engine-javascript": "3.23.0", "@shikijs/engine-oniguruma": "3.23.0", "@shikijs/langs": "3.23.0", "@shikijs/themes": "3.23.0", "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4" } }, "sha512-55Dj73uq9ZXL5zyeRPzHQsK7Nbyt6Y10k5s7OjuFZGMhpp4r/rsLBH0o/0fstIzX1Lep9VxefWljK/SKCzygIA=="], "fumadocs-mdx/esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], @@ -4244,6 +4293,8 @@ "groq-sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + "handlebars/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + "hexer/process": ["process@0.10.1", "", {}, "sha512-dyIett8dgGIZ/TXKUzeYExt7WA6ldDzys9vTDU/cCA9L17Ypme+KzS+NjQCjpn9xsvi/shbMC+yP/BcFMBz0NA=="], "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], @@ -4260,6 +4311,8 @@ "inquirer/ora": ["ora@5.4.1", "", { "dependencies": { "bl": "^4.1.0", "chalk": "^4.1.0", "cli-cursor": "^3.1.0", "cli-spinners": "^2.5.0", "is-interactive": "^1.0.0", "is-unicode-supported": "^0.1.0", "log-symbols": "^4.1.0", "strip-ansi": "^6.0.0", "wcwidth": "^1.0.1" } }, "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ=="], + "is-inside-container/is-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + "isomorphic-unfetch/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -4312,6 +4365,8 @@ "ollama-ai-provider-v2/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "open/is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + "openai/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], "openai/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], @@ -4566,6 +4621,16 @@ "@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@hey-api/openapi-ts/c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], + + "@hey-api/openapi-ts/c12/giget": ["giget@1.2.5", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.5.4", "pathe": "^2.0.3", "tar": "^6.2.1" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug=="], + + "@hey-api/openapi-ts/c12/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "@hey-api/openapi-ts/c12/ohash": ["ohash@1.1.6", "", {}, "sha512-TBu7PtV8YkAZn0tSxobKY2n2aAQva936lhRrj6957aDaCf9IEtqsKbgMzXE/F/sjqYOwmrukeORHNLe5glk7Cg=="], + + "@hey-api/openapi-ts/c12/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], + "@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], "@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -4672,6 +4737,8 @@ "form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + "fs-minipass/minipass/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "fumadocs-core/shiki/@shikijs/core": ["@shikijs/core@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "@types/hast": "^3.0.4", "hast-util-to-html": "^9.0.5" } }, "sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA=="], "fumadocs-core/shiki/@shikijs/engine-javascript": ["@shikijs/engine-javascript@3.23.0", "", { "dependencies": { "@shikijs/types": "3.23.0", "@shikijs/vscode-textmate": "^10.0.2", "oniguruma-to-es": "^4.3.4" } }, "sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA=="], @@ -5060,6 +5127,14 @@ "@cerebras/cerebras_cloud_sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "@hey-api/openapi-ts/c12/chokidar/readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], + + "@hey-api/openapi-ts/c12/giget/nypm": ["nypm@0.5.4", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "pathe": "^2.0.3", "pkg-types": "^1.3.1", "tinyexec": "^0.3.2", "ufo": "^1.5.4" }, "bin": { "nypm": "dist/cli.mjs" } }, "sha512-X0SNNrZiGU8/e/zAB7sCTtdxWTMSIO73q+xuKgglm2Yvzwlo8UoC5FNySQFCvl84uPaeADkqHUZUkWy4aH4xOA=="], + + "@hey-api/openapi-ts/c12/giget/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "@hey-api/openapi-ts/c12/giget/tar": ["tar@6.2.1", "", { "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", "minipass": "^5.0.0", "minizlib": "^2.1.1", "mkdirp": "^1.0.3", "yallist": "^4.0.0" } }, "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A=="], + "@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g=="], "@trigger.dev/core/@opentelemetry/exporter-trace-otlp-http/@opentelemetry/otlp-transformer/@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.0.1", "", { "dependencies": { "@opentelemetry/core": "2.0.1", "@opentelemetry/resources": "2.0.1" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-wf8OaJoSnujMAHWR3g+/hGvNcsC16rf9s1So4JlMiFaFHiE4HpIA3oUh+uWZQ7CNuK8gVW/pQSkgoa5HkkOl0g=="], @@ -5150,6 +5225,14 @@ "@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "@hey-api/openapi-ts/c12/giget/tar/chownr": ["chownr@2.0.0", "", {}, "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ=="], + + "@hey-api/openapi-ts/c12/giget/tar/minipass": ["minipass@5.0.0", "", {}, "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ=="], + + "@hey-api/openapi-ts/c12/giget/tar/minizlib": ["minizlib@2.1.2", "", { "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" } }, "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg=="], + + "@hey-api/openapi-ts/c12/giget/tar/yallist": ["yallist@4.0.0", "", {}, "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A=="], + "@trigger.dev/core/socket.io/engine.io/@types/node/undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], "lint-staged/listr2/cli-truncate/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], @@ -5178,6 +5261,8 @@ "test-exclude/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "@hey-api/openapi-ts/c12/giget/tar/minizlib/minipass": ["minipass@3.3.6", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw=="], + "lint-staged/listr2/cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "lint-staged/listr2/log-update/cli-cursor/restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], diff --git a/docker-compose.opencode.local.yml b/docker-compose.opencode.local.yml new file mode 100644 index 00000000000..6b216f9d957 --- /dev/null +++ b/docker-compose.opencode.local.yml @@ -0,0 +1,47 @@ +services: + opencode: + build: + context: . + dockerfile: docker/opencode.Dockerfile + restart: unless-stopped + ports: + - '${OPENCODE_PORT:-4096}:${OPENCODE_PORT:-4096}' + expose: + - '4096' + environment: + - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-dev-opencode-password} + - OPENCODE_REPOS=${OPENCODE_REPOS:-} + - GIT_USERNAME=${GIT_USERNAME:-} + - GIT_TOKEN=${GIT_TOKEN:-} + - GITHUB_TOKEN=${GITHUB_TOKEN:-} + - OPENAI_API_KEY=${OPENAI_API_KEY:-${OPENAI_API_KEY_1:-}} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-${ANTHROPIC_API_KEY_1:-}} + - GEMINI_API_KEY=${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}} + - GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY:-${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}}} + volumes: + - opencode_repos:/app/repos + - opencode_data:/home/opencode/.local/share/opencode + healthcheck: + test: ['CMD', '/usr/local/bin/opencode-healthcheck.sh'] + interval: 90s + timeout: 5s + retries: 3 + start_period: 15s + + simstudio: + environment: + - NEXT_PUBLIC_OPENCODE_ENABLED=${NEXT_PUBLIC_OPENCODE_ENABLED:-true} + - OPENCODE_BASE_URL=${OPENCODE_BASE_URL:-http://opencode:${OPENCODE_PORT:-4096}} + - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-dev-opencode-password} + - OPENCODE_REPOS=${OPENCODE_REPOS:-} + depends_on: + opencode: + condition: service_healthy + +volumes: + opencode_data: + opencode_repos: diff --git a/docker-compose.opencode.yml b/docker-compose.opencode.yml new file mode 100644 index 00000000000..5114c9b6c3b --- /dev/null +++ b/docker-compose.opencode.yml @@ -0,0 +1,45 @@ +services: + opencode: + build: + context: . + dockerfile: docker/opencode.Dockerfile + restart: unless-stopped + expose: + - '4096' + environment: + - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD} + - OPENCODE_REPOS=${OPENCODE_REPOS:-} + - GIT_USERNAME=${GIT_USERNAME:-} + - GIT_TOKEN=${GIT_TOKEN:-} + - GITHUB_TOKEN=${GITHUB_TOKEN:-} + - OPENAI_API_KEY=${OPENAI_API_KEY:-${OPENAI_API_KEY_1:-}} + - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY:-${ANTHROPIC_API_KEY_1:-}} + - GEMINI_API_KEY=${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}} + - GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY:-${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}}} + volumes: + - opencode_repos:/app/repos + - opencode_data:/home/opencode/.local/share/opencode + healthcheck: + test: ['CMD', '/usr/local/bin/opencode-healthcheck.sh'] + interval: 90s + timeout: 5s + retries: 3 + start_period: 15s + + simstudio: + environment: + - NEXT_PUBLIC_OPENCODE_ENABLED=${NEXT_PUBLIC_OPENCODE_ENABLED:-true} + - OPENCODE_BASE_URL=${OPENCODE_BASE_URL:-http://opencode:${OPENCODE_PORT:-4096}} + - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD} + - OPENCODE_REPOS=${OPENCODE_REPOS:-} + depends_on: + opencode: + condition: service_healthy + +volumes: + opencode_data: + opencode_repos: diff --git a/docker/opencode.Dockerfile b/docker/opencode.Dockerfile new file mode 100644 index 00000000000..31dc520bc96 --- /dev/null +++ b/docker/opencode.Dockerfile @@ -0,0 +1,38 @@ +FROM node:22-bookworm-slim + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + cron \ + curl \ + git \ + gosu \ + && update-ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g opencode-ai + +RUN groupadd -g 1001 opencode && \ + useradd -m -u 1001 -g opencode -s /bin/bash opencode + +WORKDIR /app + +RUN mkdir -p \ + /app/repos \ + /home/opencode/.config/opencode \ + /home/opencode/.local/state \ + /home/opencode/.local/share/opencode + +COPY docker/opencode/entrypoint.sh /usr/local/bin/opencode-entrypoint.sh +COPY docker/opencode/git-askpass.sh /usr/local/bin/git-askpass.sh +COPY docker/opencode/healthcheck.sh /usr/local/bin/opencode-healthcheck.sh +COPY docker/opencode/sync-repos.sh /usr/local/bin/sync-repos.sh + +ENV HOME=/home/opencode +ENV OPENCODE_PORT=4096 + +EXPOSE 4096 + +ENTRYPOINT ["/usr/local/bin/opencode-entrypoint.sh"] diff --git a/docker/opencode/README.md b/docker/opencode/README.md new file mode 100644 index 00000000000..80354036a61 --- /dev/null +++ b/docker/opencode/README.md @@ -0,0 +1,169 @@ +# OpenCode Service + +This service runs `opencode serve` for Sim. It backs the optional `OpenCode` workflow block and can also be queried by internal tooling against one or more cloned repositories. + +## What it provides + +- HTTP service on `http://opencode:4096` inside Docker, with an optional published host port for local development +- HTTP basic auth via `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` +- Persistent OpenCode storage in `~/.local/share/opencode` +- Optional multi-repo sync into `/app/repos` +- Global read-only OpenCode permissions + +## Required configuration + +At minimum, set: + +```env +OPENCODE_SERVER_USERNAME=opencode +OPENCODE_SERVER_PASSWORD=change-me +OPENCODE_REPOS=https://github.com/octocat/Hello-World.git +GEMINI_API_KEY=your-gemini-key +``` + +Notes: + +- The UI block is intentionally hidden until `NEXT_PUBLIC_OPENCODE_ENABLED=true` is set on the Sim app. +- `OPENCODE_SERVER_USERNAME` defaults to `opencode` in the optional compose overlays if omitted. +- `docker-compose.opencode.local.yml` defaults `OPENCODE_SERVER_PASSWORD` to `dev-opencode-password`, but setting it explicitly is safer and avoids app/container credential drift. +- `docker-compose.opencode.yml` requires `OPENCODE_SERVER_PASSWORD` to be provided from the environment. +- OpenCode needs at least one provider key to answer prompts: + - `OPENAI_API_KEY` + - `ANTHROPIC_API_KEY` + - `GEMINI_API_KEY` + - `GOOGLE_GENERATIVE_AI_API_KEY` +- In the optional compose overlays, `GOOGLE_GENERATIVE_AI_API_KEY` is automatically derived from `GEMINI_API_KEY` if not set explicitly. + +## Configure repositories + +Set `OPENCODE_REPOS` to a comma-separated list of HTTPS repository URLs. + +```bash +OPENCODE_REPOS=https://github.com/org/ui-components,https://github.com/org/design-tokens +``` + +Azure Repos over HTTPS is also supported. Example: + +```bash +OPENCODE_REPOS=https://dev.azure.com/org/project/_git/repo +``` + +Each repository is cloned into `/app/repos/`. On restart, existing clones are updated with `git pull --ff-only`. A background cron sync retries every 15 minutes. + +For private repositories, provide HTTPS credentials with one of these options: + +- `GIT_USERNAME` and `GIT_TOKEN` +- `GITHUB_TOKEN` for GitHub HTTPS access + +For Azure Repos, use `GIT_USERNAME` plus an Azure DevOps PAT in `GIT_TOKEN`. The container uses non-interactive `GIT_ASKPASS`, so it will not stop to ask for a password in the terminal during clone or pull. + +If a clone or pull fails, the service logs the error and continues syncing the remaining repositories. + +## Local development on the host + +If you run `next dev` on the host instead of inside Docker, the app must reach OpenCode through the published host port. + +Add this to `apps/sim/.env`: + +```env +NEXT_PUBLIC_OPENCODE_ENABLED=true +OPENCODE_BASE_URL=http://127.0.0.1:4096 +OPENCODE_SERVER_USERNAME=opencode +OPENCODE_SERVER_PASSWORD=change-me +``` + +Then load the same environment into your shell before starting the OpenCode container: + +```bash +set -a +source apps/sim/.env +set +a +docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build opencode +``` + +This matters because `apps/sim/.env` configures the host-side Next.js app, but `docker compose` only sees variables present in the shell environment. + +If Sim itself also runs in Docker, use the same local overlay without targeting just `opencode`: + +```bash +docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build +``` + +## Verify the service + +Verification differs slightly between local and production-style compose. + +### Local compose + +`docker-compose.opencode.local.yml` publishes `OPENCODE_PORT` to the host, so this should work from the host: + +```bash +curl -u "$OPENCODE_SERVER_USERNAME:$OPENCODE_SERVER_PASSWORD" \ + http://127.0.0.1:${OPENCODE_PORT:-4096}/global/health +``` + +Create a session from the host: + +```bash +curl -u "$OPENCODE_SERVER_USERNAME:$OPENCODE_SERVER_PASSWORD" \ + -H "Content-Type: application/json" \ + -d '{"title":"test"}' \ + http://127.0.0.1:${OPENCODE_PORT:-4096}/session +``` + +### Production-style compose + +Production should use the base compose plus the OpenCode overlay: + +```bash +docker compose -f docker-compose.prod.yml -f docker-compose.opencode.yml up -d --build +``` + +The overlay injects `NEXT_PUBLIC_OPENCODE_ENABLED`, `OPENCODE_BASE_URL`, `OPENCODE_PORT`, `OPENCODE_SERVER_USERNAME`, and `OPENCODE_SERVER_PASSWORD` into `simstudio`, so the app can authenticate against the internal OpenCode server without changing `docker-compose.prod.yml`. + +If you prefer to run OpenCode in separate infrastructure, skip the overlay and set the same app variables directly on the Sim deployment. + +OpenCode stays internal to the Docker network, so verify from another container: + +```bash +docker compose -f docker-compose.prod.yml -f docker-compose.opencode.yml exec simstudio \ + curl -u "$OPENCODE_SERVER_USERNAME:$OPENCODE_SERVER_PASSWORD" \ + http://opencode:${OPENCODE_PORT:-4096}/global/health +``` + +Create a session: + +```bash +docker compose -f docker-compose.prod.yml -f docker-compose.opencode.yml exec simstudio \ + curl -u "$OPENCODE_SERVER_USERNAME:$OPENCODE_SERVER_PASSWORD" \ + -H "Content-Type: application/json" \ + -d '{"title":"test"}' \ + http://opencode:${OPENCODE_PORT:-4096}/session +``` + +Useful runtime checks: + +```bash +docker logs --tail 100 sim-opencode-1 +docker exec sim-opencode-1 env | grep OPENCODE +docker exec sim-opencode-1 env | grep -E 'OPENAI|ANTHROPIC|GEMINI|GOOGLE_GENERATIVE' +``` + +Expected signals: + +- `opencode server listening on http://0.0.0.0:4096` +- `[opencode-sync] Updated ` or clone logs +- the same username/password and provider env vars you expect the app to use + +Before accepting a deployment, validate the read-only permission config with a real prompt against a cloned repository. The check should confirm that OpenCode can still read files while `edit`, `bash`, and web-capable tools remain blocked. If the wildcard rule prevents normal reads, remove `permission."*": "deny"` and keep the explicit tool denies as the fallback. + +## Repo-specific behavior + +Each cloned repository can keep its own `AGENTS.md` and `opencode.json` at the repo root. OpenCode will use those when a future client targets that repository directory. + +The SDK also supports injecting extra per-session context without triggering a reply by calling `session.prompt` with `noReply: true`. The current Sim block can evolve to use this for dynamic runtime instructions on top of repository-local configuration. + +## Notes + +- Session retention is not managed yet. OpenCode data persists until the `opencode_data` volume is pruned. +- The compose overlays are convenience wrappers. The app can also target any compatible external OpenCode deployment through `OPENCODE_BASE_URL` plus the same server credentials. diff --git a/docker/opencode/entrypoint.sh b/docker/opencode/entrypoint.sh new file mode 100755 index 00000000000..c347cdb2741 --- /dev/null +++ b/docker/opencode/entrypoint.sh @@ -0,0 +1,109 @@ +#!/usr/bin/env bash +set -euo pipefail + +log() { + printf '[opencode-entrypoint] %s\n' "$*" +} + +write_runtime_env() { + local env_file="/home/opencode/.config/opencode/runtime-env.sh" + local vars=( + HOME + PATH + OPENCODE_REPOS + GIT_USERNAME + GIT_TOKEN + GITHUB_TOKEN + OPENAI_API_KEY + ANTHROPIC_API_KEY + GEMINI_API_KEY + GOOGLE_GENERATIVE_AI_API_KEY + ) + + umask 077 + : >"$env_file" + + for name in "${vars[@]}"; do + if [[ -v "$name" ]]; then + printf 'export %s=%q\n' "$name" "${!name}" >>"$env_file" + fi + done + + chown opencode:opencode "$env_file" +} + +write_global_config() { + cat >/home/opencode/.config/opencode/opencode.json </etc/cron.d/opencode-sync <<'EOF' +SHELL=/bin/bash +PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin +*/15 * * * * opencode /usr/local/bin/sync-repos.sh >> /proc/1/fd/1 2>> /proc/1/fd/2 +EOF + chmod 0644 /etc/cron.d/opencode-sync +} + +main() { + : "${OPENCODE_PORT:=4096}" + : "${OPENCODE_SERVER_USERNAME:=opencode}" + + if [[ -z "${OPENCODE_SERVER_PASSWORD:-}" ]]; then + log "OPENCODE_SERVER_PASSWORD is required" + exit 1 + fi + + if [[ -z "${GOOGLE_GENERATIVE_AI_API_KEY:-}" && -n "${GEMINI_API_KEY:-}" ]]; then + export GOOGLE_GENERATIVE_AI_API_KEY="${GEMINI_API_KEY}" + fi + + mkdir -p /app/repos /home/opencode/.config/opencode /home/opencode/.local/share/opencode /home/opencode/.local/state + chown -R opencode:opencode /app/repos /home/opencode/.config /home/opencode/.local/share /home/opencode/.local/state + + write_runtime_env + write_global_config + install_cron + + if [[ -z "${OPENAI_API_KEY:-}" && -z "${ANTHROPIC_API_KEY:-}" && -z "${GEMINI_API_KEY:-}" && -z "${GOOGLE_GENERATIVE_AI_API_KEY:-}" ]]; then + log "No provider API key detected in environment; server will start but prompts may fail" + fi + + if ! gosu opencode /usr/local/bin/sync-repos.sh; then + log "Repository sync completed with errors" + fi + cron + + cd /app + exec gosu opencode opencode serve --hostname 0.0.0.0 --port "${OPENCODE_PORT}" +} + +main "$@" diff --git a/docker/opencode/git-askpass.sh b/docker/opencode/git-askpass.sh new file mode 100755 index 00000000000..d72c6eeb39c --- /dev/null +++ b/docker/opencode/git-askpass.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +prompt="${1:-}" + +if [[ "$prompt" == Username* ]]; then + if [[ "$prompt" == *github.com* && -n "${GITHUB_TOKEN:-}" && -z "${GIT_TOKEN:-}" ]]; then + printf '%s\n' "${GITHUB_USERNAME:-x-access-token}" + exit 0 + fi + + printf '%s\n' "${GIT_USERNAME:-git}" + exit 0 +fi + +if [[ "$prompt" == Password* ]]; then + if [[ "$prompt" == *github.com* && -n "${GITHUB_TOKEN:-}" && -z "${GIT_TOKEN:-}" ]]; then + printf '%s\n' "${GITHUB_TOKEN}" + exit 0 + fi + + printf '%s\n' "${GIT_TOKEN:-${GITHUB_TOKEN:-}}" + exit 0 +fi + +printf '\n' diff --git a/docker/opencode/healthcheck.sh b/docker/opencode/healthcheck.sh new file mode 100755 index 00000000000..073faaa06ea --- /dev/null +++ b/docker/opencode/healthcheck.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail + +port="${OPENCODE_PORT:-4096}" +user="${OPENCODE_SERVER_USERNAME:-opencode}" +password="${OPENCODE_SERVER_PASSWORD:-}" + +if [[ -z "$password" ]]; then + exit 1 +fi + +curl --silent --show-error --fail \ + -u "${user}:${password}" \ + "http://127.0.0.1:${port}/global/health" >/dev/null diff --git a/docker/opencode/sync-repos.sh b/docker/opencode/sync-repos.sh new file mode 100755 index 00000000000..0576a54a7ae --- /dev/null +++ b/docker/opencode/sync-repos.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -uo pipefail + +if [[ -f /home/opencode/.config/opencode/runtime-env.sh ]]; then + source /home/opencode/.config/opencode/runtime-env.sh +fi + +export HOME="${HOME:-/home/opencode}" +export GIT_TERMINAL_PROMPT=0 +export GIT_ASKPASS=/usr/local/bin/git-askpass.sh + +log() { + printf '[opencode-sync] %s\n' "$*" +} + +trim() { + local value="$1" + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + printf '%s' "$value" +} + +sync_repo() { + local repo_url="$1" + local repo_name="$2" + local repo_dir="/app/repos/${repo_name}" + + if [[ -d "$repo_dir/.git" ]]; then + if git -C "$repo_dir" pull --ff-only; then + log "Updated ${repo_name}" + return 0 + fi + + log "Failed to update ${repo_name} from ${repo_url}" + return 1 + fi + + if [[ -e "$repo_dir" ]]; then + log "Skipping ${repo_url}; target path ${repo_dir} exists and is not a git repository" + return 1 + fi + + if git clone "$repo_url" "$repo_dir"; then + log "Cloned ${repo_name}" + return 0 + fi + + rm -rf "$repo_dir" + log "Failed to clone ${repo_url}" + return 1 +} + +main() { + local repos_raw="${OPENCODE_REPOS:-}" + + mkdir -p /app/repos + + if [[ -z "$repos_raw" ]]; then + log "No repositories configured" + exit 0 + fi + + local -A seen_names=() + local repo_url + local repo_name + local sync_failed=0 + + IFS=',' read -r -a repo_items <<<"$repos_raw" + for repo_item in "${repo_items[@]}"; do + repo_url="$(trim "$repo_item")" + if [[ -z "$repo_url" ]]; then + continue + fi + + repo_name="${repo_url##*/}" + repo_name="${repo_name%.git}" + + if [[ -z "$repo_name" ]]; then + log "Skipping invalid repository URL: ${repo_url}" + sync_failed=1 + continue + fi + + if [[ -n "${seen_names[$repo_name]:-}" ]]; then + log "Skipping ${repo_url}; repository name ${repo_name} collides with ${seen_names[$repo_name]}" + sync_failed=1 + continue + fi + + seen_names["$repo_name"]="$repo_url" + + if ! sync_repo "$repo_url" "$repo_name"; then + sync_failed=1 + fi + done + + exit "$sync_failed" +} + +main "$@" From 7014fa4e0512882a06f49468c5f0f158e1abc2c3 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 14:50:37 +0100 Subject: [PATCH 03/31] fix(opencode): harden external runtime contract --- README.md | 9 ++++++--- apps/sim/.env.example | 1 + apps/sim/lib/opencode/service.ts | 25 ++++++++++++++++++++----- docker-compose.opencode.local.yml | 4 +++- docker-compose.opencode.yml | 8 +++++--- docker/opencode/README.md | 13 ++++++++----- docker/opencode/entrypoint.sh | 6 ++++-- docker/opencode/sync-repos.sh | 5 +++-- 8 files changed, 50 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 34f8a88d701..76df4f58c4f 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,7 @@ Then add these values to `apps/sim/.env`: ```env NEXT_PUBLIC_OPENCODE_ENABLED=true +OPENCODE_REPOSITORY_ROOT=/app/repos OPENCODE_SERVER_USERNAME=opencode OPENCODE_SERVER_PASSWORD=change-me OPENCODE_REPOS=https://github.com/octocat/Hello-World.git @@ -134,6 +135,7 @@ Local vs production behavior: - `docker-compose.opencode.local.yml` - adds OpenCode locally without changing the base local compose file - publishes `OPENCODE_PORT` to the host so `next dev` on the host can talk to OpenCode + - defaults `OPENCODE_REPOSITORY_ROOT=/app/repos` - defaults `OPENCODE_SERVER_USERNAME=opencode` - defaults `OPENCODE_SERVER_PASSWORD=dev-opencode-password` if you do not set one explicitly - `docker-compose.prod.yml` @@ -143,7 +145,8 @@ Local vs production behavior: - builds the OpenCode runtime locally from this repository instead of requiring an official Sim-hosted image - injects the required `NEXT_PUBLIC_OPENCODE_ENABLED` and `OPENCODE_*` variables into `simstudio` - keeps OpenCode internal to the Docker network with `expose`, not a published host port - - expects `OPENCODE_SERVER_PASSWORD` to be set explicitly + - defaults `OPENCODE_REPOSITORY_ROOT=/app/repos` + - requires `OPENCODE_SERVER_PASSWORD` to be set explicitly before `docker compose` starts Production deploy command: @@ -168,10 +171,10 @@ Without that override, host-side Next.js cannot reliably reach the Docker servic Notes: - If `OPENCODE_REPOS` is empty, `opencode` still starts but no repositories are cloned. -- Repositories are cloned into `/app/repos/`. +- Repositories are cloned into `${OPENCODE_REPOSITORY_ROOT:-/app/repos}/`. - Private Azure Repos must use `https` plus `GIT_USERNAME` and `GIT_TOKEN`; the container will not prompt interactively for passwords. - `GOOGLE_GENERATIVE_AI_API_KEY` is optional; the optional overlays map it automatically from `GEMINI_API_KEY` if not set. -- If you prefer to run OpenCode in separate infrastructure, skip the overlays and point Sim at that deployment with `OPENCODE_BASE_URL`, `OPENCODE_SERVER_USERNAME`, and `OPENCODE_SERVER_PASSWORD`. +- If you prefer to run OpenCode in separate infrastructure, skip the overlays and point Sim at that deployment with `OPENCODE_BASE_URL`, `OPENCODE_SERVER_USERNAME`, `OPENCODE_SERVER_PASSWORD`, and the matching `OPENCODE_REPOSITORY_ROOT`. Basic verification after startup: diff --git a/apps/sim/.env.example b/apps/sim/.env.example index b3c39e424f7..5887d154692 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -36,6 +36,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener # OPENCODE_BASE_URL=http://opencode:4096 # Use this when SIM and OpenCode both run in Docker Compose # # Or point this to any separate OpenCode deployment that implements the same auth contract # OPENCODE_PORT=4096 +# OPENCODE_REPOSITORY_ROOT=/app/repos # Must match the repository root used by the OpenCode runtime, including external deployments # OPENCODE_SERVER_USERNAME=opencode # OPENCODE_SERVER_PASSWORD=change-me # Required for the internal OpenCode service # OPENCODE_REPOS=https://github.com/org/ui-components,https://github.com/org/design-tokens diff --git a/apps/sim/lib/opencode/service.ts b/apps/sim/lib/opencode/service.ts index cc0e4258bd0..fb001777f8d 100644 --- a/apps/sim/lib/opencode/service.ts +++ b/apps/sim/lib/opencode/service.ts @@ -15,7 +15,7 @@ import { and, eq, isNull } from 'drizzle-orm' import { createOpenCodeClient } from '@/lib/opencode/client' const logger = createLogger('OpenCodeService') -const OPEN_CODE_REPOSITORY_ROOT = '/app/repos' +const DEFAULT_OPEN_CODE_REPOSITORY_ROOT = '/app/repos' export interface OpenCodeRepositoryOption { id: string @@ -77,6 +77,19 @@ export interface OpenCodeMessageItem { createdAt: number } +function getOpenCodeRepositoryRoot(): string { + const configuredRoot = process.env.OPENCODE_REPOSITORY_ROOT?.trim() + if (!configuredRoot) { + return DEFAULT_OPEN_CODE_REPOSITORY_ROOT + } + + if (configuredRoot === '/') { + return configuredRoot + } + + return configuredRoot.replace(/\/+$/, '') +} + function stripGitSuffix(value: string): string { return value.endsWith('.git') ? value.slice(0, -4) : value } @@ -140,19 +153,21 @@ function listConfiguredOpenCodeRepositoryNames(): string[] { } function getRepositoryName(repository: string): string { - if (repository.startsWith(OPEN_CODE_REPOSITORY_ROOT)) { - return repository.slice(OPEN_CODE_REPOSITORY_ROOT.length + 1) + const repositoryRoot = getOpenCodeRepositoryRoot() + + if (repository.startsWith(`${repositoryRoot}/`)) { + return repository.slice(repositoryRoot.length + 1) } return repository } function buildOpenCodeRepositoryDirectory(repository: string): string { - return `${OPEN_CODE_REPOSITORY_ROOT}/${repository}` + return `${getOpenCodeRepositoryRoot()}/${repository}` } function isProjectInsideRepositoryRoot(project: Project): boolean { - return project.worktree.startsWith(`${OPEN_CODE_REPOSITORY_ROOT}/`) + return project.worktree.startsWith(`${getOpenCodeRepositoryRoot()}/`) } function mapProjectToRepositoryOption(project: Project): OpenCodeRepositoryOption { diff --git a/docker-compose.opencode.local.yml b/docker-compose.opencode.local.yml index 6b216f9d957..4a9ab5ef349 100644 --- a/docker-compose.opencode.local.yml +++ b/docker-compose.opencode.local.yml @@ -10,6 +10,7 @@ services: - '4096' environment: - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_REPOSITORY_ROOT=${OPENCODE_REPOSITORY_ROOT:-/app/repos} - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-dev-opencode-password} - OPENCODE_REPOS=${OPENCODE_REPOS:-} @@ -21,7 +22,7 @@ services: - GEMINI_API_KEY=${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}} - GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY:-${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}}} volumes: - - opencode_repos:/app/repos + - opencode_repos:${OPENCODE_REPOSITORY_ROOT:-/app/repos} - opencode_data:/home/opencode/.local/share/opencode healthcheck: test: ['CMD', '/usr/local/bin/opencode-healthcheck.sh'] @@ -35,6 +36,7 @@ services: - NEXT_PUBLIC_OPENCODE_ENABLED=${NEXT_PUBLIC_OPENCODE_ENABLED:-true} - OPENCODE_BASE_URL=${OPENCODE_BASE_URL:-http://opencode:${OPENCODE_PORT:-4096}} - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_REPOSITORY_ROOT=${OPENCODE_REPOSITORY_ROOT:-/app/repos} - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:-dev-opencode-password} - OPENCODE_REPOS=${OPENCODE_REPOS:-} diff --git a/docker-compose.opencode.yml b/docker-compose.opencode.yml index 5114c9b6c3b..669e621801b 100644 --- a/docker-compose.opencode.yml +++ b/docker-compose.opencode.yml @@ -8,8 +8,9 @@ services: - '4096' environment: - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_REPOSITORY_ROOT=${OPENCODE_REPOSITORY_ROOT:-/app/repos} - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} - - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:?OPENCODE_SERVER_PASSWORD is required for docker-compose.opencode.yml} - OPENCODE_REPOS=${OPENCODE_REPOS:-} - GIT_USERNAME=${GIT_USERNAME:-} - GIT_TOKEN=${GIT_TOKEN:-} @@ -19,7 +20,7 @@ services: - GEMINI_API_KEY=${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}} - GOOGLE_GENERATIVE_AI_API_KEY=${GOOGLE_GENERATIVE_AI_API_KEY:-${GEMINI_API_KEY:-${GEMINI_API_KEY_1:-}}} volumes: - - opencode_repos:/app/repos + - opencode_repos:${OPENCODE_REPOSITORY_ROOT:-/app/repos} - opencode_data:/home/opencode/.local/share/opencode healthcheck: test: ['CMD', '/usr/local/bin/opencode-healthcheck.sh'] @@ -33,8 +34,9 @@ services: - NEXT_PUBLIC_OPENCODE_ENABLED=${NEXT_PUBLIC_OPENCODE_ENABLED:-true} - OPENCODE_BASE_URL=${OPENCODE_BASE_URL:-http://opencode:${OPENCODE_PORT:-4096}} - OPENCODE_PORT=${OPENCODE_PORT:-4096} + - OPENCODE_REPOSITORY_ROOT=${OPENCODE_REPOSITORY_ROOT:-/app/repos} - OPENCODE_SERVER_USERNAME=${OPENCODE_SERVER_USERNAME:-opencode} - - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD} + - OPENCODE_SERVER_PASSWORD=${OPENCODE_SERVER_PASSWORD:?OPENCODE_SERVER_PASSWORD is required for docker-compose.opencode.yml} - OPENCODE_REPOS=${OPENCODE_REPOS:-} depends_on: opencode: diff --git a/docker/opencode/README.md b/docker/opencode/README.md index 80354036a61..147ac88a450 100644 --- a/docker/opencode/README.md +++ b/docker/opencode/README.md @@ -15,6 +15,7 @@ This service runs `opencode serve` for Sim. It backs the optional `OpenCode` wor At minimum, set: ```env +OPENCODE_REPOSITORY_ROOT=/app/repos OPENCODE_SERVER_USERNAME=opencode OPENCODE_SERVER_PASSWORD=change-me OPENCODE_REPOS=https://github.com/octocat/Hello-World.git @@ -24,9 +25,10 @@ GEMINI_API_KEY=your-gemini-key Notes: - The UI block is intentionally hidden until `NEXT_PUBLIC_OPENCODE_ENABLED=true` is set on the Sim app. +- `OPENCODE_REPOSITORY_ROOT` defaults to `/app/repos` and must match the path Sim uses when it resolves repository directories. - `OPENCODE_SERVER_USERNAME` defaults to `opencode` in the optional compose overlays if omitted. - `docker-compose.opencode.local.yml` defaults `OPENCODE_SERVER_PASSWORD` to `dev-opencode-password`, but setting it explicitly is safer and avoids app/container credential drift. -- `docker-compose.opencode.yml` requires `OPENCODE_SERVER_PASSWORD` to be provided from the environment. +- `docker-compose.opencode.yml` requires `OPENCODE_SERVER_PASSWORD` to be provided from the environment before `docker compose` starts. - OpenCode needs at least one provider key to answer prompts: - `OPENAI_API_KEY` - `ANTHROPIC_API_KEY` @@ -48,7 +50,7 @@ Azure Repos over HTTPS is also supported. Example: OPENCODE_REPOS=https://dev.azure.com/org/project/_git/repo ``` -Each repository is cloned into `/app/repos/`. On restart, existing clones are updated with `git pull --ff-only`. A background cron sync retries every 15 minutes. +Each repository is cloned into `${OPENCODE_REPOSITORY_ROOT:-/app/repos}/`. On restart, existing clones are updated with `git pull --ff-only`. A background cron sync retries every 15 minutes. For private repositories, provide HTTPS credentials with one of these options: @@ -68,6 +70,7 @@ Add this to `apps/sim/.env`: ```env NEXT_PUBLIC_OPENCODE_ENABLED=true OPENCODE_BASE_URL=http://127.0.0.1:4096 +OPENCODE_REPOSITORY_ROOT=/app/repos OPENCODE_SERVER_USERNAME=opencode OPENCODE_SERVER_PASSWORD=change-me ``` @@ -119,9 +122,9 @@ Production should use the base compose plus the OpenCode overlay: docker compose -f docker-compose.prod.yml -f docker-compose.opencode.yml up -d --build ``` -The overlay injects `NEXT_PUBLIC_OPENCODE_ENABLED`, `OPENCODE_BASE_URL`, `OPENCODE_PORT`, `OPENCODE_SERVER_USERNAME`, and `OPENCODE_SERVER_PASSWORD` into `simstudio`, so the app can authenticate against the internal OpenCode server without changing `docker-compose.prod.yml`. +The overlay injects `NEXT_PUBLIC_OPENCODE_ENABLED`, `OPENCODE_BASE_URL`, `OPENCODE_PORT`, `OPENCODE_REPOSITORY_ROOT`, `OPENCODE_SERVER_USERNAME`, and `OPENCODE_SERVER_PASSWORD` into `simstudio`, so the app can authenticate against the internal OpenCode server without changing `docker-compose.prod.yml`. -If you prefer to run OpenCode in separate infrastructure, skip the overlay and set the same app variables directly on the Sim deployment. +If you prefer to run OpenCode in separate infrastructure, skip the overlay and set the same app variables directly on the Sim deployment. The external OpenCode runtime must expose project worktrees under the same `OPENCODE_REPOSITORY_ROOT` that Sim is configured to use. OpenCode stays internal to the Docker network, so verify from another container: @@ -166,4 +169,4 @@ The SDK also supports injecting extra per-session context without triggering a r ## Notes - Session retention is not managed yet. OpenCode data persists until the `opencode_data` volume is pruned. -- The compose overlays are convenience wrappers. The app can also target any compatible external OpenCode deployment through `OPENCODE_BASE_URL` plus the same server credentials. +- The compose overlays are convenience wrappers. The app can also target any compatible external OpenCode deployment through `OPENCODE_BASE_URL`, the same server credentials, and the same `OPENCODE_REPOSITORY_ROOT`. diff --git a/docker/opencode/entrypoint.sh b/docker/opencode/entrypoint.sh index c347cdb2741..7d4c93a91bb 100755 --- a/docker/opencode/entrypoint.sh +++ b/docker/opencode/entrypoint.sh @@ -11,6 +11,7 @@ write_runtime_env() { HOME PATH OPENCODE_REPOS + OPENCODE_REPOSITORY_ROOT GIT_USERNAME GIT_TOKEN GITHUB_TOKEN @@ -76,6 +77,7 @@ EOF main() { : "${OPENCODE_PORT:=4096}" : "${OPENCODE_SERVER_USERNAME:=opencode}" + : "${OPENCODE_REPOSITORY_ROOT:=/app/repos}" if [[ -z "${OPENCODE_SERVER_PASSWORD:-}" ]]; then log "OPENCODE_SERVER_PASSWORD is required" @@ -86,8 +88,8 @@ main() { export GOOGLE_GENERATIVE_AI_API_KEY="${GEMINI_API_KEY}" fi - mkdir -p /app/repos /home/opencode/.config/opencode /home/opencode/.local/share/opencode /home/opencode/.local/state - chown -R opencode:opencode /app/repos /home/opencode/.config /home/opencode/.local/share /home/opencode/.local/state + mkdir -p "${OPENCODE_REPOSITORY_ROOT}" /home/opencode/.config/opencode /home/opencode/.local/share/opencode /home/opencode/.local/state + chown -R opencode:opencode "${OPENCODE_REPOSITORY_ROOT}" /home/opencode/.config /home/opencode/.local/share /home/opencode/.local/state write_runtime_env write_global_config diff --git a/docker/opencode/sync-repos.sh b/docker/opencode/sync-repos.sh index 0576a54a7ae..b95f525c23d 100755 --- a/docker/opencode/sync-repos.sh +++ b/docker/opencode/sync-repos.sh @@ -8,6 +8,7 @@ fi export HOME="${HOME:-/home/opencode}" export GIT_TERMINAL_PROMPT=0 export GIT_ASKPASS=/usr/local/bin/git-askpass.sh +export OPENCODE_REPOSITORY_ROOT="${OPENCODE_REPOSITORY_ROOT:-/app/repos}" log() { printf '[opencode-sync] %s\n' "$*" @@ -23,7 +24,7 @@ trim() { sync_repo() { local repo_url="$1" local repo_name="$2" - local repo_dir="/app/repos/${repo_name}" + local repo_dir="${OPENCODE_REPOSITORY_ROOT}/${repo_name}" if [[ -d "$repo_dir/.git" ]]; then if git -C "$repo_dir" pull --ff-only; then @@ -53,7 +54,7 @@ sync_repo() { main() { local repos_raw="${OPENCODE_REPOS:-}" - mkdir -p /app/repos + mkdir -p "${OPENCODE_REPOSITORY_ROOT}" if [[ -z "$repos_raw" ]]; then log "No repositories configured" From 33b834bd93f3fc87bf1f27b310236968494c0d00 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 14:54:36 +0100 Subject: [PATCH 04/31] docs(opencode): add deployment checklists --- README.md | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/README.md b/README.md index 76df4f58c4f..ed98f97de90 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,37 @@ Open [http://localhost:3000](http://localhost:3000) OpenCode is opt-in. By default the `OpenCode` block stays hidden so the base Sim UX and deployment path remain unchanged. +Quick deploy paths: + +##### Sim only + +- Do not set `NEXT_PUBLIC_OPENCODE_ENABLED`. +- Do not set any `OPENCODE_*` variables. +- Do not use `docker-compose.opencode.yml` or `docker-compose.opencode.local.yml`. +- Start Sim with the normal upstream flow. + +##### Sim + OpenCode local overlay + +- Set `NEXT_PUBLIC_OPENCODE_ENABLED=true`. +- Set `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD`. +- Set `OPENCODE_REPOSITORY_ROOT=/app/repos` unless you intentionally changed the runtime root. +- Set `OPENCODE_REPOS` to one or more HTTPS repository URLs. +- Set at least one provider key such as `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`, `GEMINI_API_KEY`, or `GOOGLE_GENERATIVE_AI_API_KEY`. +- Set `GIT_USERNAME` and `GIT_TOKEN` or `GITHUB_TOKEN` if any repository is private. +- For host-side `next dev`, also set `OPENCODE_BASE_URL=http://127.0.0.1:4096`. +- Start with `docker compose -f docker-compose.local.yml -f docker-compose.opencode.local.yml up -d --build`. + +##### Sim + external OpenCode runtime + +- Set `NEXT_PUBLIC_OPENCODE_ENABLED=true`. +- Set `OPENCODE_BASE_URL` to the external OpenCode server. +- Set `OPENCODE_SERVER_USERNAME` and `OPENCODE_SERVER_PASSWORD` to the credentials expected by that server. +- Set `OPENCODE_REPOSITORY_ROOT` to the same worktree root used by the external OpenCode deployment. +- Set `OPENCODE_REPOS` to the repository catalog you expect the runtime to clone or expose. +- Ensure the external runtime already has at least one provider key configured. +- Ensure the external runtime can clone private repositories with the right git credentials if needed. +- Verify `/global/health` and one real prompt before exposing the block to users. + Minimum setup: ```bash From cacd46a8aa41d3369075f73501723f4514f5a501 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 16:10:31 +0100 Subject: [PATCH 05/31] test(opencode): cover route contracts --- apps/sim/app/api/opencode/repos/route.test.ts | 128 ++++++++ .../api/tools/opencode/prompt/route.test.ts | 276 ++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 apps/sim/app/api/opencode/repos/route.test.ts create mode 100644 apps/sim/app/api/tools/opencode/prompt/route.test.ts diff --git a/apps/sim/app/api/opencode/repos/route.test.ts b/apps/sim/app/api/opencode/repos/route.test.ts new file mode 100644 index 00000000000..95ff2a11153 --- /dev/null +++ b/apps/sim/app/api/opencode/repos/route.test.ts @@ -0,0 +1,128 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckSessionOrInternalAuth, mockCheckWorkspaceAccess, mockListOpenCodeRepositories } = + vi.hoisted(() => ({ + mockCheckSessionOrInternalAuth: vi.fn(), + mockCheckWorkspaceAccess: vi.fn(), + mockListOpenCodeRepositories: vi.fn(), + })) + +vi.mock('@/lib/auth/hybrid', () => ({ + AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' }, + checkSessionOrInternalAuth: mockCheckSessionOrInternalAuth, +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('test-request-id'), +})) + +vi.mock('@/lib/opencode/service', () => ({ + listOpenCodeRepositories: mockListOpenCodeRepositories, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + checkWorkspaceAccess: mockCheckWorkspaceAccess, +})) + +import { GET } from '@/app/api/opencode/repos/route' + +describe('GET /api/opencode/repos', () => { + function createRequest(query = ''): NextRequest { + return new NextRequest(new URL(`http://localhost:3000/api/opencode/repos${query}`)) + } + + beforeEach(() => { + vi.clearAllMocks() + + mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: true, + userId: 'user-123', + }) + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + }) + mockListOpenCodeRepositories.mockResolvedValue([ + { + id: 'repo-a', + label: 'repo-a', + directory: '/app/repos/repo-a', + projectId: 'project-1', + }, + ]) + }) + + it('returns 401 when unauthenticated', async () => { + mockCheckSessionOrInternalAuth.mockResolvedValue({ + success: false, + userId: null, + }) + + const response = await GET(createRequest('?workspaceId=ws-1')) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ error: 'Unauthorized' }) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + expect(mockListOpenCodeRepositories).not.toHaveBeenCalled() + }) + + it('returns 400 when workspaceId is missing', async () => { + const response = await GET(createRequest()) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ error: 'workspaceId is required' }) + expect(mockCheckWorkspaceAccess).not.toHaveBeenCalled() + }) + + it('returns 404 when workspace does not exist', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: false, + hasAccess: false, + }) + + const response = await GET(createRequest('?workspaceId=ws-404')) + const data = await response.json() + + expect(response.status).toBe(404) + expect(data).toEqual({ error: 'Workspace not found' }) + expect(mockListOpenCodeRepositories).not.toHaveBeenCalled() + }) + + it('returns 403 when the user does not have access to the workspace', async () => { + mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: false, + }) + + const response = await GET(createRequest('?workspaceId=ws-1')) + const data = await response.json() + + expect(response.status).toBe(403) + expect(data).toEqual({ error: 'Access denied' }) + expect(mockListOpenCodeRepositories).not.toHaveBeenCalled() + }) + + it('returns repository options when the request is authorized', async () => { + const response = await GET(createRequest('?workspaceId=ws-1')) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockCheckWorkspaceAccess).toHaveBeenCalledWith('ws-1', 'user-123') + expect(data).toEqual({ + data: [ + { + id: 'repo-a', + label: 'repo-a', + directory: '/app/repos/repo-a', + projectId: 'project-1', + }, + ], + }) + }) +}) diff --git a/apps/sim/app/api/tools/opencode/prompt/route.test.ts b/apps/sim/app/api/tools/opencode/prompt/route.test.ts new file mode 100644 index 00000000000..15ac6867118 --- /dev/null +++ b/apps/sim/app/api/tools/opencode/prompt/route.test.ts @@ -0,0 +1,276 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckInternalAuth, + mockBuildOpenCodeSessionMemoryKey, + mockBuildOpenCodeSessionTitle, + mockCreateOpenCodeSession, + mockGetStoredOpenCodeSession, + mockLogOpenCodeFailure, + mockPromptOpenCodeSession, + mockResolveOpenCodeRepositoryOption, + mockShouldRetryWithFreshOpenCodeSession, + mockStoreOpenCodeSession, +} = vi.hoisted(() => ({ + mockCheckInternalAuth: vi.fn(), + mockBuildOpenCodeSessionMemoryKey: vi.fn(), + mockBuildOpenCodeSessionTitle: vi.fn(), + mockCreateOpenCodeSession: vi.fn(), + mockGetStoredOpenCodeSession: vi.fn(), + mockLogOpenCodeFailure: vi.fn(), + mockPromptOpenCodeSession: vi.fn(), + mockResolveOpenCodeRepositoryOption: vi.fn(), + mockShouldRetryWithFreshOpenCodeSession: vi.fn(), + mockStoreOpenCodeSession: vi.fn(), +})) + +vi.mock('@/lib/auth/hybrid', () => ({ + AuthType: { SESSION: 'session', API_KEY: 'api_key', INTERNAL_JWT: 'internal_jwt' }, + checkInternalAuth: mockCheckInternalAuth, +})) + +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('test-request-id'), +})) + +vi.mock('@/lib/opencode/service', () => ({ + buildOpenCodeSessionMemoryKey: mockBuildOpenCodeSessionMemoryKey, + buildOpenCodeSessionTitle: mockBuildOpenCodeSessionTitle, + createOpenCodeSession: mockCreateOpenCodeSession, + getStoredOpenCodeSession: mockGetStoredOpenCodeSession, + logOpenCodeFailure: mockLogOpenCodeFailure, + promptOpenCodeSession: mockPromptOpenCodeSession, + resolveOpenCodeRepositoryOption: mockResolveOpenCodeRepositoryOption, + shouldRetryWithFreshOpenCodeSession: mockShouldRetryWithFreshOpenCodeSession, + storeOpenCodeSession: mockStoreOpenCodeSession, +})) + +import { POST } from '@/app/api/tools/opencode/prompt/route' + +describe('POST /api/tools/opencode/prompt', () => { + beforeEach(() => { + vi.clearAllMocks() + + mockCheckInternalAuth.mockResolvedValue({ + success: true, + userId: 'internal-user', + }) + mockResolveOpenCodeRepositoryOption.mockResolvedValue({ + id: 'repo-a', + label: 'repo-a', + directory: '/app/repos/repo-a', + projectId: 'project-1', + }) + mockBuildOpenCodeSessionMemoryKey.mockReturnValue('memory-key') + mockBuildOpenCodeSessionTitle.mockReturnValue('session-title') + mockGetStoredOpenCodeSession.mockResolvedValue(null) + mockCreateOpenCodeSession.mockResolvedValue({ id: 'session-1' }) + mockPromptOpenCodeSession.mockResolvedValue({ + content: 'OpenCode result', + threadId: 'session-1', + cost: 1.25, + }) + mockStoreOpenCodeSession.mockResolvedValue(undefined) + mockShouldRetryWithFreshOpenCodeSession.mockReturnValue(false) + mockLogOpenCodeFailure.mockResolvedValue(undefined) + }) + + it('returns 401 when internal auth fails', async () => { + mockCheckInternalAuth.mockResolvedValue({ + success: false, + error: 'Unauthorized', + }) + + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'hello', + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(401) + expect(data).toEqual({ error: 'Unauthorized' }) + }) + + it('returns 400 when workflow execution context is incomplete', async () => { + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'hello', + _context: { + workspaceId: 'ws-1', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(400) + expect(data).toEqual({ + error: 'workspaceId and workflowId are required in execution context', + }) + }) + + it('creates a new OpenCode session when no stored session exists', async () => { + const request = createMockRequest('POST', { + repository: ' repo-a ', + providerId: ' provider-a ', + modelId: ' model-a ', + systemPrompt: ' system prompt ', + agent: ' planner ', + prompt: ' explain the change ', + _context: { + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-123', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockResolveOpenCodeRepositoryOption).toHaveBeenCalledWith('repo-a') + expect(mockBuildOpenCodeSessionMemoryKey).toHaveBeenCalledWith('wf-1', 'user:user-123') + expect(mockGetStoredOpenCodeSession).toHaveBeenCalledWith('ws-1', 'memory-key') + expect(mockBuildOpenCodeSessionTitle).toHaveBeenCalledWith('repo-a', 'user:user-123') + expect(mockCreateOpenCodeSession).toHaveBeenCalledWith('repo-a', 'session-title') + expect(mockPromptOpenCodeSession).toHaveBeenCalledWith({ + repository: 'repo-a', + sessionId: 'session-1', + prompt: 'explain the change', + systemPrompt: 'system prompt', + providerId: 'provider-a', + modelId: 'model-a', + agent: 'planner', + }) + expect(mockStoreOpenCodeSession).toHaveBeenCalledWith( + 'ws-1', + 'memory-key', + expect.objectContaining({ + sessionId: 'session-1', + repository: 'repo-a', + updatedAt: expect.any(String), + }) + ) + expect(data).toEqual({ + success: true, + output: { + content: 'OpenCode result', + threadId: 'session-1', + cost: 1.25, + }, + }) + }) + + it('reuses an existing stored session for the same repository', async () => { + mockGetStoredOpenCodeSession.mockResolvedValue({ + sessionId: 'stored-session', + repository: 'repo-a', + updatedAt: '2026-03-25T00:00:00.000Z', + }) + mockPromptOpenCodeSession.mockResolvedValue({ + content: 'Reused session result', + threadId: 'stored-session', + }) + + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'continue', + _context: { + workspaceId: 'ws-1', + workflowId: 'wf-1', + executionId: 'exec-1', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockBuildOpenCodeSessionMemoryKey).toHaveBeenCalledWith('wf-1', 'execution:exec-1') + expect(mockCreateOpenCodeSession).not.toHaveBeenCalled() + expect(mockPromptOpenCodeSession).toHaveBeenCalledWith({ + repository: 'repo-a', + sessionId: 'stored-session', + prompt: 'continue', + systemPrompt: undefined, + providerId: 'provider-a', + modelId: 'model-a', + agent: undefined, + }) + expect(data).toEqual({ + success: true, + output: { + content: 'Reused session result', + threadId: 'stored-session', + }, + }) + }) + + it('retries with a fresh session when the stored session is stale', async () => { + mockGetStoredOpenCodeSession.mockResolvedValue({ + sessionId: 'stale-session', + repository: 'repo-a', + updatedAt: '2026-03-25T00:00:00.000Z', + }) + mockPromptOpenCodeSession + .mockRejectedValueOnce(new Error('session not found')) + .mockResolvedValueOnce({ + content: 'Recovered result', + threadId: 'fresh-session', + cost: 2.5, + }) + mockShouldRetryWithFreshOpenCodeSession.mockReturnValue(true) + mockCreateOpenCodeSession.mockResolvedValue({ id: 'fresh-session' }) + + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'retry please', + _context: { + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-123', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockPromptOpenCodeSession).toHaveBeenCalledTimes(2) + expect(mockShouldRetryWithFreshOpenCodeSession).toHaveBeenCalledWith( + expect.objectContaining({ message: 'session not found' }) + ) + expect(mockCreateOpenCodeSession).toHaveBeenCalledWith('repo-a', 'session-title') + expect(mockStoreOpenCodeSession).toHaveBeenCalledWith( + 'ws-1', + 'memory-key', + expect.objectContaining({ + sessionId: 'fresh-session', + repository: 'repo-a', + }) + ) + expect(mockLogOpenCodeFailure).not.toHaveBeenCalled() + expect(data).toEqual({ + success: true, + output: { + content: 'Recovered result', + threadId: 'fresh-session', + cost: 2.5, + }, + }) + }) +}) From 6160d1c16abb418c027c27197ff43a0a9ef8de0f Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 16:17:28 +0100 Subject: [PATCH 06/31] fix(opencode): address review feedback --- .../components/combobox/combobox.tsx | 2 +- .../components/dropdown/dropdown.tsx | 2 +- apps/sim/lib/opencode/service.test.ts | 49 +++++++++++++++++++ apps/sim/lib/opencode/service.ts | 3 +- 4 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 apps/sim/lib/opencode/service.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 7fd3aad2ce6..ae0dd507894 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -455,7 +455,7 @@ export const ComboBox = memo(function ComboBox({ */ const handleOpenChange = useCallback( (open: boolean) => { - if (open && fetchedOptions.length === 0) { + if (open && (fetchedOptions.length === 0 || fetchError !== null)) { void fetchOptionsIfNeeded(fetchError !== null) } }, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 76042f7d023..daa99f7b3fa 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -198,7 +198,7 @@ export const Dropdown = memo(function Dropdown({ */ const handleOpenChange = useCallback( (open: boolean) => { - if (open && fetchedOptions.length === 0) { + if (open && (fetchedOptions.length === 0 || fetchError !== null)) { void fetchOptionsIfNeeded(fetchError !== null) } }, diff --git a/apps/sim/lib/opencode/service.test.ts b/apps/sim/lib/opencode/service.test.ts new file mode 100644 index 00000000000..34f3b92bd18 --- /dev/null +++ b/apps/sim/lib/opencode/service.test.ts @@ -0,0 +1,49 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/db', () => ({ + db: {}, +})) + +vi.mock('@sim/db/schema', () => ({ + memory: {}, +})) + +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn().mockReturnValue({ + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + trace: vi.fn(), + fatal: vi.fn(), + child: vi.fn(), + }), +})) + +vi.mock('drizzle-orm', () => ({ + and: vi.fn(), + eq: vi.fn(), + isNull: vi.fn(), +})) + +vi.mock('@/lib/opencode/client', () => ({ + createOpenCodeClient: vi.fn(), +})) + +import { shouldRetryWithFreshOpenCodeSession } from '@/lib/opencode/service' + +describe('shouldRetryWithFreshOpenCodeSession', () => { + it('returns true for stale-session errors', () => { + expect(shouldRetryWithFreshOpenCodeSession(new Error('404 session not found'))).toBe(true) + expect(shouldRetryWithFreshOpenCodeSession('session does not exist')).toBe(true) + }) + + it('returns false for unrelated session errors', () => { + expect(shouldRetryWithFreshOpenCodeSession(new Error('session limit exceeded'))).toBe(false) + expect(shouldRetryWithFreshOpenCodeSession('invalid session format')).toBe(false) + }) +}) diff --git a/apps/sim/lib/opencode/service.ts b/apps/sim/lib/opencode/service.ts index fb001777f8d..f0689b719ec 100644 --- a/apps/sim/lib/opencode/service.ts +++ b/apps/sim/lib/opencode/service.ts @@ -507,7 +507,8 @@ export function shouldRetryWithFreshOpenCodeSession(error: unknown): boolean { return ( normalized.includes('404') || normalized.includes('not found') || - normalized.includes('session') || + normalized.includes('session not found') || + normalized.includes('session does not exist') || normalized.includes('does not exist') ) } From 8ec0c5aeac4118a3528f1ad8af75205d046ecf9b Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 16:22:39 +0100 Subject: [PATCH 07/31] fix(opencode): harden runtime defaults --- apps/sim/lib/opencode/client.ts | 3 ++- docker/opencode.Dockerfile | 4 +++- docker/opencode/git-askpass.sh | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/opencode/client.ts b/apps/sim/lib/opencode/client.ts index 036a2107219..408faca1d50 100644 --- a/apps/sim/lib/opencode/client.ts +++ b/apps/sim/lib/opencode/client.ts @@ -4,6 +4,7 @@ import { createOpencodeClient } from '@opencode-ai/sdk' const OPEN_CODE_HOST = 'opencode' const OPEN_CODE_LOCALHOST = '127.0.0.1' const OPEN_CODE_DEFAULT_PORT = '4096' +const IS_DOCKER_RUNTIME = existsSync('/.dockerenv') function getOpenCodeBasicAuthHeader(): string { const username = process.env.OPENCODE_SERVER_USERNAME @@ -23,7 +24,7 @@ export function getOpenCodeBaseUrl(): string { } const port = process.env.OPENCODE_PORT || OPEN_CODE_DEFAULT_PORT - const host = existsSync('/.dockerenv') ? OPEN_CODE_HOST : OPEN_CODE_LOCALHOST + const host = IS_DOCKER_RUNTIME ? OPEN_CODE_HOST : OPEN_CODE_LOCALHOST return `http://${host}:${port}` } diff --git a/docker/opencode.Dockerfile b/docker/opencode.Dockerfile index 31dc520bc96..ca16787e960 100644 --- a/docker/opencode.Dockerfile +++ b/docker/opencode.Dockerfile @@ -1,5 +1,7 @@ FROM node:22-bookworm-slim +ARG OPENCODE_AI_VERSION=0.8.0 + RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ --mount=type=cache,target=/var/lib/apt,sharing=locked \ apt-get update && apt-get install -y --no-install-recommends \ @@ -12,7 +14,7 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ && update-ca-certificates \ && rm -rf /var/lib/apt/lists/* -RUN npm install -g opencode-ai +RUN npm install -g "opencode-ai@${OPENCODE_AI_VERSION}" RUN groupadd -g 1001 opencode && \ useradd -m -u 1001 -g opencode -s /bin/bash opencode diff --git a/docker/opencode/git-askpass.sh b/docker/opencode/git-askpass.sh index d72c6eeb39c..d3f823da3ea 100755 --- a/docker/opencode/git-askpass.sh +++ b/docker/opencode/git-askpass.sh @@ -19,7 +19,7 @@ if [[ "$prompt" == Password* ]]; then exit 0 fi - printf '%s\n' "${GIT_TOKEN:-${GITHUB_TOKEN:-}}" + printf '%s\n' "${GIT_TOKEN:-}" exit 0 fi From d59f7a1487ee7ca586b425e999e513273f949df3 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 16:30:16 +0100 Subject: [PATCH 08/31] fix(opencode): narrow stale session retries --- apps/sim/lib/opencode/service.test.ts | 3 +++ apps/sim/lib/opencode/service.ts | 21 ++++++++++++++------- 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/apps/sim/lib/opencode/service.test.ts b/apps/sim/lib/opencode/service.test.ts index 34f3b92bd18..f9d51c5d1de 100644 --- a/apps/sim/lib/opencode/service.test.ts +++ b/apps/sim/lib/opencode/service.test.ts @@ -40,10 +40,13 @@ describe('shouldRetryWithFreshOpenCodeSession', () => { it('returns true for stale-session errors', () => { expect(shouldRetryWithFreshOpenCodeSession(new Error('404 session not found'))).toBe(true) expect(shouldRetryWithFreshOpenCodeSession('session does not exist')).toBe(true) + expect(shouldRetryWithFreshOpenCodeSession('unknown session')).toBe(true) }) it('returns false for unrelated session errors', () => { expect(shouldRetryWithFreshOpenCodeSession(new Error('session limit exceeded'))).toBe(false) expect(shouldRetryWithFreshOpenCodeSession('invalid session format')).toBe(false) + expect(shouldRetryWithFreshOpenCodeSession('model not found')).toBe(false) + expect(shouldRetryWithFreshOpenCodeSession('provider does not exist')).toBe(false) }) }) diff --git a/apps/sim/lib/opencode/service.ts b/apps/sim/lib/opencode/service.ts index f0689b719ec..5221afaaba8 100644 --- a/apps/sim/lib/opencode/service.ts +++ b/apps/sim/lib/opencode/service.ts @@ -504,13 +504,20 @@ export function shouldRetryWithFreshOpenCodeSession(error: unknown): boolean { : JSON.stringify(error) const normalized = message.toLowerCase() - return ( - normalized.includes('404') || - normalized.includes('not found') || - normalized.includes('session not found') || - normalized.includes('session does not exist') || - normalized.includes('does not exist') - ) + const staleSessionPatterns = [ + 'session not found', + 'session does not exist', + 'session was not found', + 'session no longer exists', + 'unknown session', + 'invalid session id', + ] + + if (staleSessionPatterns.some((pattern) => normalized.includes(pattern))) { + return true + } + + return normalized.includes('404') && normalized.includes('session') } export async function logOpenCodeFailure(message: string, error: unknown): Promise { From 1e174f75a9e04344b21ad5ed42deb114f6e4e465 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 16:42:00 +0100 Subject: [PATCH 09/31] fix(opencode): avoid redundant resolution and url leaks --- .../api/tools/opencode/prompt/route.test.ts | 24 +++++- .../app/api/tools/opencode/prompt/route.ts | 28 +++++-- apps/sim/lib/opencode/errors.test.ts | 22 +++++ apps/sim/lib/opencode/errors.ts | 5 +- apps/sim/lib/opencode/service.test.ts | 80 ++++++++++++++++++- apps/sim/lib/opencode/service.ts | 35 ++++++-- 6 files changed, 175 insertions(+), 19 deletions(-) create mode 100644 apps/sim/lib/opencode/errors.test.ts diff --git a/apps/sim/app/api/tools/opencode/prompt/route.test.ts b/apps/sim/app/api/tools/opencode/prompt/route.test.ts index 15ac6867118..1f2a1abc2bb 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.test.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.test.ts @@ -142,9 +142,19 @@ describe('POST /api/tools/opencode/prompt', () => { expect(mockBuildOpenCodeSessionMemoryKey).toHaveBeenCalledWith('wf-1', 'user:user-123') expect(mockGetStoredOpenCodeSession).toHaveBeenCalledWith('ws-1', 'memory-key') expect(mockBuildOpenCodeSessionTitle).toHaveBeenCalledWith('repo-a', 'user:user-123') - expect(mockCreateOpenCodeSession).toHaveBeenCalledWith('repo-a', 'session-title') + expect(mockCreateOpenCodeSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'repo-a', + directory: '/app/repos/repo-a', + }), + 'session-title' + ) expect(mockPromptOpenCodeSession).toHaveBeenCalledWith({ repository: 'repo-a', + repositoryOption: expect.objectContaining({ + id: 'repo-a', + directory: '/app/repos/repo-a', + }), sessionId: 'session-1', prompt: 'explain the change', systemPrompt: 'system prompt', @@ -202,6 +212,10 @@ describe('POST /api/tools/opencode/prompt', () => { expect(mockCreateOpenCodeSession).not.toHaveBeenCalled() expect(mockPromptOpenCodeSession).toHaveBeenCalledWith({ repository: 'repo-a', + repositoryOption: expect.objectContaining({ + id: 'repo-a', + directory: '/app/repos/repo-a', + }), sessionId: 'stored-session', prompt: 'continue', systemPrompt: undefined, @@ -254,7 +268,13 @@ describe('POST /api/tools/opencode/prompt', () => { expect(mockShouldRetryWithFreshOpenCodeSession).toHaveBeenCalledWith( expect.objectContaining({ message: 'session not found' }) ) - expect(mockCreateOpenCodeSession).toHaveBeenCalledWith('repo-a', 'session-title') + expect(mockCreateOpenCodeSession).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'repo-a', + directory: '/app/repos/repo-a', + }), + 'session-title' + ) expect(mockStoreOpenCodeSession).toHaveBeenCalledWith( 'ws-1', 'memory-key', diff --git a/apps/sim/app/api/tools/opencode/prompt/route.ts b/apps/sim/app/api/tools/opencode/prompt/route.ts index 880444e05dc..ded2f7cab14 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.ts @@ -99,6 +99,7 @@ function buildErrorResponse( async function executePrompt( params: z.infer, repository: string, + repositoryOption: Awaited>, threadId: string, prompt: string, providerId: string, @@ -106,6 +107,7 @@ async function executePrompt( ) { return promptOpenCodeSession({ repository, + repositoryOption, sessionId: threadId, prompt, systemPrompt: params.systemPrompt?.trim() || undefined, @@ -146,18 +148,28 @@ export async function POST(request: NextRequest) { const newThread = coerceBoolean(body.newThread) const storedThread = newThread ? null : await getStoredOpenCodeSession(workspaceId, memoryKey) let threadId = - storedThread && storedThread.repository === repositoryId ? storedThread.sessionId : undefined + storedThread && storedThread.repository === repositoryId + ? storedThread.sessionId + : undefined if (!threadId) { const session = await createOpenCodeSession( - repositoryId, + repositoryOption, buildOpenCodeSessionTitle(repositoryId, sessionOwnerKey) ) threadId = session.id } try { - const result = await executePrompt(body, repositoryId, threadId, prompt, providerId, modelId) + const result = await executePrompt( + body, + repositoryId, + repositoryOption, + threadId, + prompt, + providerId, + modelId + ) await storeOpenCodeSession(workspaceId, memoryKey, { sessionId: result.threadId, @@ -179,12 +191,13 @@ export async function POST(request: NextRequest) { if (threadId && !newThread && shouldRetryWithFreshOpenCodeSession(error)) { try { const freshSession = await createOpenCodeSession( - repositoryId, + repositoryOption, buildOpenCodeSessionTitle(repositoryId, sessionOwnerKey) ) const result = await executePrompt( body, repositoryId, + repositoryOption, freshSession.id, prompt, providerId, @@ -214,13 +227,16 @@ export async function POST(request: NextRequest) { ) const errorMessage = - retryError instanceof Error ? retryError.message : 'OpenCode prompt retry failed' + retryError instanceof Error + ? retryError.message + : 'OpenCode prompt retry failed' return buildErrorResponse(threadId, '', undefined, errorMessage) } } await logOpenCodeFailure('Failed to execute OpenCode prompt', error) - const errorMessage = error instanceof Error ? error.message : 'OpenCode prompt failed' + const errorMessage = + error instanceof Error ? error.message : 'OpenCode prompt failed' return buildErrorResponse(threadId || '', '', undefined, errorMessage) } } catch (error) { diff --git a/apps/sim/lib/opencode/errors.test.ts b/apps/sim/lib/opencode/errors.test.ts new file mode 100644 index 00000000000..e1bd7ead55b --- /dev/null +++ b/apps/sim/lib/opencode/errors.test.ts @@ -0,0 +1,22 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { getOpenCodeRouteError } from '@/lib/opencode/errors' + +describe('getOpenCodeRouteError', () => { + it('does not leak the internal OpenCode base URL in connectivity errors', () => { + const error = getOpenCodeRouteError( + new Error('fetch failed for http://opencode:4096/session'), + 'repositories', + ) + + expect(error).toEqual({ + status: 503, + message: + 'OpenCode server is unreachable. Check OPENCODE_BASE_URL and the runtime network configuration.', + }) + expect(error.message).not.toContain('http://opencode:4096') + }) +}) diff --git a/apps/sim/lib/opencode/errors.ts b/apps/sim/lib/opencode/errors.ts index 3dc97f3b6bc..377841ebd3a 100644 --- a/apps/sim/lib/opencode/errors.ts +++ b/apps/sim/lib/opencode/errors.ts @@ -1,5 +1,3 @@ -import { getOpenCodeBaseUrl } from '@/lib/opencode/client' - export interface OpenCodeRouteError { status: number message: string @@ -72,7 +70,8 @@ export function getOpenCodeRouteError(error: unknown, resourceName: string): Ope ) { return { status: 503, - message: `OpenCode server is unreachable at ${getOpenCodeBaseUrl()}.`, + message: + 'OpenCode server is unreachable. Check OPENCODE_BASE_URL and the runtime network configuration.', } } diff --git a/apps/sim/lib/opencode/service.test.ts b/apps/sim/lib/opencode/service.test.ts index f9d51c5d1de..e6a4ad166ca 100644 --- a/apps/sim/lib/opencode/service.test.ts +++ b/apps/sim/lib/opencode/service.test.ts @@ -4,6 +4,10 @@ import { describe, expect, it, vi } from 'vitest' +const { mockCreateOpenCodeClient } = vi.hoisted(() => ({ + mockCreateOpenCodeClient: vi.fn(), +})) + vi.mock('@sim/db', () => ({ db: {}, })) @@ -31,10 +35,13 @@ vi.mock('drizzle-orm', () => ({ })) vi.mock('@/lib/opencode/client', () => ({ - createOpenCodeClient: vi.fn(), + createOpenCodeClient: mockCreateOpenCodeClient, })) -import { shouldRetryWithFreshOpenCodeSession } from '@/lib/opencode/service' +import { + promptOpenCodeSession, + shouldRetryWithFreshOpenCodeSession, +} from '@/lib/opencode/service' describe('shouldRetryWithFreshOpenCodeSession', () => { it('returns true for stale-session errors', () => { @@ -50,3 +57,72 @@ describe('shouldRetryWithFreshOpenCodeSession', () => { expect(shouldRetryWithFreshOpenCodeSession('provider does not exist')).toBe(false) }) }) + +describe('promptOpenCodeSession', () => { + it('reuses the provided repository option without resolving repositories again', async () => { + const mockSessionCreate = vi.fn().mockResolvedValue({ + data: { id: 'session-1' }, + }) + const mockSessionPrompt = vi.fn().mockResolvedValue({ + data: { + info: { + sessionID: 'session-1', + cost: 0.75, + providerID: 'provider-a', + modelID: 'model-a', + }, + parts: [{ type: 'text', text: 'OpenCode result' }], + }, + }) + + mockCreateOpenCodeClient.mockReturnValue({ + project: { + list: vi.fn(), + }, + session: { + create: mockSessionCreate, + prompt: mockSessionPrompt, + }, + }) + + const result = await promptOpenCodeSession({ + repository: 'repo-a', + repositoryOption: { + id: 'repo-a', + label: 'repo-a', + directory: '/app/repos/repo-a', + projectId: 'project-1', + }, + prompt: 'Explain the change', + providerId: 'provider-a', + modelId: 'model-a', + title: 'session-title', + }) + + expect(mockSessionCreate).toHaveBeenCalledWith({ + query: { directory: '/app/repos/repo-a' }, + body: { title: 'session-title' }, + throwOnError: true, + }) + expect(mockSessionPrompt).toHaveBeenCalledWith({ + path: { id: 'session-1' }, + query: { directory: '/app/repos/repo-a' }, + body: { + parts: [{ type: 'text', text: 'Explain the change' }], + model: { + providerID: 'provider-a', + modelID: 'model-a', + }, + }, + throwOnError: true, + }) + expect(result).toEqual({ + content: 'OpenCode result', + threadId: 'session-1', + cost: 0.75, + providerId: 'provider-a', + modelId: 'model-a', + assistantError: undefined, + }) + }) +}) diff --git a/apps/sim/lib/opencode/service.ts b/apps/sim/lib/opencode/service.ts index 5221afaaba8..1e70d999e14 100644 --- a/apps/sim/lib/opencode/service.ts +++ b/apps/sim/lib/opencode/service.ts @@ -52,6 +52,7 @@ export interface OpenCodePromptRequest { prompt: string providerId: string modelId: string + repositoryOption?: OpenCodeRepositoryOption systemPrompt?: string agent?: string sessionId?: string @@ -295,6 +296,16 @@ export async function resolveOpenCodeRepositoryOption( return repositoryOption } +async function getOpenCodeRepositoryOption( + repository: string | OpenCodeRepositoryOption +): Promise { + if (typeof repository === 'string') { + return resolveOpenCodeRepositoryOption(repository) + } + + return repository +} + export async function listOpenCodeProviders( repository?: string ): Promise { @@ -352,11 +363,11 @@ export async function listOpenCodeAgents(repository?: string): Promise { const client = createOpenCodeClient() - const repositoryOption = await resolveOpenCodeRepositoryOption(repository) + const repositoryOption = await getOpenCodeRepositoryOption(repository) const sessionResult = await client.session.create({ query: { directory: repositoryOption.directory }, body: title ? { title } : undefined, @@ -370,10 +381,13 @@ export async function promptOpenCodeSession( request: OpenCodePromptRequest ): Promise { const client = createOpenCodeClient() - const repositoryOption = await resolveOpenCodeRepositoryOption(request.repository) + const repositoryOption = + request.repositoryOption || + (await resolveOpenCodeRepositoryOption(request.repository)) const directory = repositoryOption.directory const sessionId = - request.sessionId || (await createOpenCodeSession(request.repository, request.title)).id + request.sessionId || + (await createOpenCodeSession(repositoryOption, request.title)).id const response = await client.session.prompt({ path: { id: sessionId }, @@ -433,7 +447,13 @@ export async function getStoredOpenCodeSession( const result = await db .select({ data: memory.data }) .from(memory) - .where(and(eq(memory.workspaceId, workspaceId), eq(memory.key, key), isNull(memory.deletedAt))) + .where( + and( + eq(memory.workspaceId, workspaceId), + eq(memory.key, key), + isNull(memory.deletedAt) + ) + ) .limit(1) if (result.length === 0) { @@ -520,6 +540,9 @@ export function shouldRetryWithFreshOpenCodeSession(error: unknown): boolean { return normalized.includes('404') && normalized.includes('session') } -export async function logOpenCodeFailure(message: string, error: unknown): Promise { +export async function logOpenCodeFailure( + message: string, + error: unknown +): Promise { logger.error(message, { error }) } From 35fac8dd354accb818af5e0461fe352114804ef7 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 16:49:45 +0100 Subject: [PATCH 10/31] fix(opencode): clean up low severity review notes --- .../sim/app/api/tools/opencode/prompt/route.ts | 15 ++------------- .../sub-block/components/combobox/combobox.tsx | 2 -- .../sub-block/components/dropdown/dropdown.tsx | 2 -- apps/sim/blocks/blocks/opencode.ts | 15 ++------------- apps/sim/lib/opencode/utils.test.ts | 18 ++++++++++++++++++ apps/sim/lib/opencode/utils.ts | 11 +++++++++++ 6 files changed, 33 insertions(+), 30 deletions(-) create mode 100644 apps/sim/lib/opencode/utils.test.ts create mode 100644 apps/sim/lib/opencode/utils.ts diff --git a/apps/sim/app/api/tools/opencode/prompt/route.ts b/apps/sim/app/api/tools/opencode/prompt/route.ts index ded2f7cab14..98c04704339 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.ts @@ -14,6 +14,7 @@ import { shouldRetryWithFreshOpenCodeSession, storeOpenCodeSession, } from '@/lib/opencode/service' +import { coerceOpenCodeBoolean } from '@/lib/opencode/utils' const logger = createLogger('OpenCodePromptToolAPI') @@ -44,18 +45,6 @@ const OpenCodePromptSchema = z.object({ export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -function coerceBoolean(value: boolean | string | undefined): boolean { - if (typeof value === 'boolean') { - return value - } - - if (typeof value === 'string') { - return value.toLowerCase() === 'true' - } - - return false -} - function getSessionOwnerKey(params: z.infer): string { if (params._context?.userId) { return `user:${params._context.userId}` @@ -145,7 +134,7 @@ export async function POST(request: NextRequest) { const modelId = body.modelId.trim() const sessionOwnerKey = getSessionOwnerKey(body) const memoryKey = buildOpenCodeSessionMemoryKey(workflowId, sessionOwnerKey) - const newThread = coerceBoolean(body.newThread) + const newThread = coerceOpenCodeBoolean(body.newThread) const storedThread = newThread ? null : await getStoredOpenCodeSession(workspaceId, memoryKey) let threadId = storedThread && storedThread.repository === repositoryId diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index ae0dd507894..772cad8d01a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -136,7 +136,6 @@ export const ComboBox = memo(function ComboBox({ !fetchOptions || isPreview || disabled || - isLoadingOptions || (!force && hasAttemptedOptionsFetch) || isOptionsFetchInFlightRef.current ) { @@ -164,7 +163,6 @@ export const ComboBox = memo(function ComboBox({ subBlockId, isPreview, disabled, - isLoadingOptions, hasAttemptedOptionsFetch, ]) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index daa99f7b3fa..8090e83db61 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -161,7 +161,6 @@ export const Dropdown = memo(function Dropdown({ !fetchOptions || isPreview || disabled || - isLoadingOptions || (!force && hasAttemptedOptionsFetch) || isOptionsFetchInFlightRef.current ) { @@ -189,7 +188,6 @@ export const Dropdown = memo(function Dropdown({ subBlockId, isPreview, disabled, - isLoadingOptions, hasAttemptedOptionsFetch, ]) diff --git a/apps/sim/blocks/blocks/opencode.ts b/apps/sim/blocks/blocks/opencode.ts index 96fbf40090d..aebfb8c6b57 100644 --- a/apps/sim/blocks/blocks/opencode.ts +++ b/apps/sim/blocks/blocks/opencode.ts @@ -1,22 +1,11 @@ import type { BlockConfig } from '@/blocks/types' import { OpenCodeIcon } from '@/components/icons' import { getEnv, isTruthy } from '@/lib/core/config/env' +import { coerceOpenCodeBoolean } from '@/lib/opencode/utils' import type { OpenCodePromptResponse } from '@/tools/opencode/types' const isOpenCodeEnabled = isTruthy(getEnv('NEXT_PUBLIC_OPENCODE_ENABLED')) -function coerceBoolean(value: unknown): boolean { - if (typeof value === 'boolean') { - return value - } - - if (typeof value === 'string') { - return value.toLowerCase() === 'true' - } - - return false -} - function getOptionalString(value: unknown): string | undefined { if (typeof value !== 'string') { return undefined @@ -230,7 +219,7 @@ export const OpenCodeBlock: BlockConfig = { modelId: params.modelId, ...(getOptionalString(params.agent) ? { agent: getOptionalString(params.agent) } : {}), prompt: params.prompt, - newThread: coerceBoolean(params.newThread), + newThread: coerceOpenCodeBoolean(params.newThread), }), }, }, diff --git a/apps/sim/lib/opencode/utils.test.ts b/apps/sim/lib/opencode/utils.test.ts new file mode 100644 index 00000000000..e76fd26b0d6 --- /dev/null +++ b/apps/sim/lib/opencode/utils.test.ts @@ -0,0 +1,18 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { coerceOpenCodeBoolean } from '@/lib/opencode/utils' + +describe('coerceOpenCodeBoolean', () => { + it('coerces booleans and string booleans consistently', () => { + expect(coerceOpenCodeBoolean(true)).toBe(true) + expect(coerceOpenCodeBoolean(false)).toBe(false) + expect(coerceOpenCodeBoolean('true')).toBe(true) + expect(coerceOpenCodeBoolean('TRUE')).toBe(true) + expect(coerceOpenCodeBoolean('false')).toBe(false) + expect(coerceOpenCodeBoolean(undefined)).toBe(false) + expect(coerceOpenCodeBoolean(null)).toBe(false) + }) +}) diff --git a/apps/sim/lib/opencode/utils.ts b/apps/sim/lib/opencode/utils.ts new file mode 100644 index 00000000000..46cb5207198 --- /dev/null +++ b/apps/sim/lib/opencode/utils.ts @@ -0,0 +1,11 @@ +export function coerceOpenCodeBoolean(value: unknown): boolean { + if (typeof value === 'boolean') { + return value + } + + if (typeof value === 'string') { + return value.toLowerCase() === 'true' + } + + return false +} From 35949bb162d3d3ab80aef709ec62d6a7a290a435 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 16:55:53 +0100 Subject: [PATCH 11/31] fix(opencode): harden root path and retry errors --- apps/sim/lib/opencode/service.test.ts | 41 ++++++++++++++++++++++++++- apps/sim/lib/opencode/service.ts | 28 +++++++++++++----- 2 files changed, 61 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/opencode/service.test.ts b/apps/sim/lib/opencode/service.test.ts index e6a4ad166ca..ea91b88fc27 100644 --- a/apps/sim/lib/opencode/service.test.ts +++ b/apps/sim/lib/opencode/service.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' const { mockCreateOpenCodeClient } = vi.hoisted(() => ({ mockCreateOpenCodeClient: vi.fn(), @@ -39,10 +39,19 @@ vi.mock('@/lib/opencode/client', () => ({ })) import { + listOpenCodeRepositories, promptOpenCodeSession, shouldRetryWithFreshOpenCodeSession, } from '@/lib/opencode/service' +beforeEach(() => { + vi.clearAllMocks() +}) + +afterEach(() => { + vi.unstubAllEnvs() +}) + describe('shouldRetryWithFreshOpenCodeSession', () => { it('returns true for stale-session errors', () => { expect(shouldRetryWithFreshOpenCodeSession(new Error('404 session not found'))).toBe(true) @@ -56,6 +65,36 @@ describe('shouldRetryWithFreshOpenCodeSession', () => { expect(shouldRetryWithFreshOpenCodeSession('model not found')).toBe(false) expect(shouldRetryWithFreshOpenCodeSession('provider does not exist')).toBe(false) }) + + it('does not crash for undefined, symbol, or function errors', () => { + expect(() => shouldRetryWithFreshOpenCodeSession(undefined)).not.toThrow() + expect(() => shouldRetryWithFreshOpenCodeSession(Symbol('session'))).not.toThrow() + expect(() => shouldRetryWithFreshOpenCodeSession(() => 'session')).not.toThrow() + expect(shouldRetryWithFreshOpenCodeSession(undefined)).toBe(false) + }) +}) + +describe('listOpenCodeRepositories', () => { + it('handles OPENCODE_REPOSITORY_ROOT set to / without double slashes', async () => { + vi.stubEnv('OPENCODE_REPOSITORY_ROOT', '/') + + mockCreateOpenCodeClient.mockReturnValue({ + project: { + list: vi.fn().mockResolvedValue({ + data: [{ id: 'project-1', worktree: '/repo-a' }], + }), + }, + }) + + await expect(listOpenCodeRepositories()).resolves.toEqual([ + { + id: 'repo-a', + label: 'repo-a', + directory: '/repo-a', + projectId: 'project-1', + }, + ]) + }) }) describe('promptOpenCodeSession', () => { diff --git a/apps/sim/lib/opencode/service.ts b/apps/sim/lib/opencode/service.ts index 1e70d999e14..c72e293132d 100644 --- a/apps/sim/lib/opencode/service.ts +++ b/apps/sim/lib/opencode/service.ts @@ -85,7 +85,7 @@ function getOpenCodeRepositoryRoot(): string { } if (configuredRoot === '/') { - return configuredRoot + return '' } return configuredRoot.replace(/\/+$/, '') @@ -516,12 +516,7 @@ export function buildOpenCodeSessionTitle(repository: string, userKey: string): } export function shouldRetryWithFreshOpenCodeSession(error: unknown): boolean { - const message = - error instanceof Error - ? error.message - : typeof error === 'string' - ? error - : JSON.stringify(error) + const message = getOpenCodeRetryErrorMessage(error) const normalized = message.toLowerCase() const staleSessionPatterns = [ @@ -540,6 +535,25 @@ export function shouldRetryWithFreshOpenCodeSession(error: unknown): boolean { return normalized.includes('404') && normalized.includes('session') } +function getOpenCodeRetryErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message + } + + if (typeof error === 'string') { + return error + } + + try { + const serialized = JSON.stringify(error) + if (typeof serialized === 'string') { + return serialized + } + } catch {} + + return String(error ?? '') +} + export async function logOpenCodeFailure( message: string, error: unknown From 3458868ba23087199c0ada6c876d8d4d95f22bd4 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 17:02:12 +0100 Subject: [PATCH 12/31] refactor(opencode): keep base url helper private --- apps/sim/lib/opencode/client.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/opencode/client.ts b/apps/sim/lib/opencode/client.ts index 408faca1d50..7a999b99149 100644 --- a/apps/sim/lib/opencode/client.ts +++ b/apps/sim/lib/opencode/client.ts @@ -17,7 +17,7 @@ function getOpenCodeBasicAuthHeader(): string { return `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}` } -export function getOpenCodeBaseUrl(): string { +function getOpenCodeBaseUrl(): string { const explicitBaseUrl = process.env.OPENCODE_BASE_URL?.trim() if (explicitBaseUrl) { return explicitBaseUrl From a27de0d7c67d90a70c9d8ff4a04ef4e019d404d2 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 17:10:02 +0100 Subject: [PATCH 13/31] fix(editor): avoid stale open-change fetch gating --- .../components/sub-block/components/combobox/combobox.tsx | 4 ++-- .../components/sub-block/components/dropdown/dropdown.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 772cad8d01a..19a2ae50d10 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -453,11 +453,11 @@ export const ComboBox = memo(function ComboBox({ */ const handleOpenChange = useCallback( (open: boolean) => { - if (open && (fetchedOptions.length === 0 || fetchError !== null)) { + if (open) { void fetchOptionsIfNeeded(fetchError !== null) } }, - [fetchError, fetchOptionsIfNeeded, fetchedOptions.length] + [fetchError, fetchOptionsIfNeeded] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 8090e83db61..10c233dd0b9 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -196,11 +196,11 @@ export const Dropdown = memo(function Dropdown({ */ const handleOpenChange = useCallback( (open: boolean) => { - if (open && (fetchedOptions.length === 0 || fetchError !== null)) { + if (open) { void fetchOptionsIfNeeded(fetchError !== null) } }, - [fetchError, fetchOptionsIfNeeded, fetchedOptions.length] + [fetchError, fetchOptionsIfNeeded] ) const evaluatedOptions = useMemo(() => { From a8fb07354dfc3a766faa544eaaf59888ef05826a Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 17:19:23 +0100 Subject: [PATCH 14/31] fix(opencode): persist fresh retry sessions --- .../api/tools/opencode/prompt/route.test.ts | 51 +++++++++++++++++++ .../app/api/tools/opencode/prompt/route.ts | 12 ++++- 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/tools/opencode/prompt/route.test.ts b/apps/sim/app/api/tools/opencode/prompt/route.test.ts index 1f2a1abc2bb..fc07c2dd6eb 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.test.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.test.ts @@ -293,4 +293,55 @@ describe('POST /api/tools/opencode/prompt', () => { }, }) }) + + it('stores and returns the fresh session id when the retry prompt fails', async () => { + mockGetStoredOpenCodeSession.mockResolvedValue({ + sessionId: 'stale-session', + repository: 'repo-a', + updatedAt: '2026-03-25T00:00:00.000Z', + }) + mockPromptOpenCodeSession + .mockRejectedValueOnce(new Error('session not found')) + .mockRejectedValueOnce(new Error('provider unavailable')) + mockShouldRetryWithFreshOpenCodeSession.mockReturnValue(true) + mockCreateOpenCodeSession.mockResolvedValue({ id: 'fresh-session' }) + + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'retry please', + _context: { + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-123', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockPromptOpenCodeSession).toHaveBeenCalledTimes(2) + expect(mockStoreOpenCodeSession).toHaveBeenCalledWith( + 'ws-1', + 'memory-key', + expect.objectContaining({ + sessionId: 'fresh-session', + repository: 'repo-a', + }) + ) + expect(mockLogOpenCodeFailure).toHaveBeenCalledWith( + 'Failed to retry OpenCode prompt with a fresh session', + expect.objectContaining({ message: 'provider unavailable' }) + ) + expect(data).toEqual({ + success: true, + output: { + content: '', + threadId: 'fresh-session', + error: 'provider unavailable', + }, + }) + }) }) diff --git a/apps/sim/app/api/tools/opencode/prompt/route.ts b/apps/sim/app/api/tools/opencode/prompt/route.ts index 98c04704339..ed626e8c651 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.ts @@ -178,11 +178,21 @@ export async function POST(request: NextRequest) { return buildSuccessResponse(result.threadId, result.content, result.cost) } catch (error) { if (threadId && !newThread && shouldRetryWithFreshOpenCodeSession(error)) { + let freshSessionId = threadId + try { const freshSession = await createOpenCodeSession( repositoryOption, buildOpenCodeSessionTitle(repositoryId, sessionOwnerKey) ) + freshSessionId = freshSession.id + + await storeOpenCodeSession(workspaceId, memoryKey, { + sessionId: freshSessionId, + repository: repositoryId, + updatedAt: new Date().toISOString(), + }) + const result = await executePrompt( body, repositoryId, @@ -219,7 +229,7 @@ export async function POST(request: NextRequest) { retryError instanceof Error ? retryError.message : 'OpenCode prompt retry failed' - return buildErrorResponse(threadId, '', undefined, errorMessage) + return buildErrorResponse(freshSessionId, '', undefined, errorMessage) } } From 2bb744a38ac22f1be638aa35f98754363951852e Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 17:28:24 +0100 Subject: [PATCH 15/31] fix(opencode): tighten retry and entrypoint guards --- .../api/tools/opencode/prompt/route.test.ts | 37 +++++++++++++++++++ .../app/api/tools/opencode/prompt/route.ts | 7 ++-- apps/sim/lib/opencode/service.ts | 2 +- docker/opencode/entrypoint.sh | 11 ++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/apps/sim/app/api/tools/opencode/prompt/route.test.ts b/apps/sim/app/api/tools/opencode/prompt/route.test.ts index fc07c2dd6eb..48832e4737a 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.test.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.test.ts @@ -294,6 +294,43 @@ describe('POST /api/tools/opencode/prompt', () => { }) }) + it('does not retry when a newly created session fails immediately', async () => { + mockPromptOpenCodeSession.mockRejectedValueOnce(new Error('session not found')) + mockShouldRetryWithFreshOpenCodeSession.mockReturnValue(true) + mockCreateOpenCodeSession.mockResolvedValue({ id: 'fresh-session' }) + + const request = createMockRequest('POST', { + repository: 'repo-a', + providerId: 'provider-a', + modelId: 'model-a', + prompt: 'retry please', + _context: { + workspaceId: 'ws-1', + workflowId: 'wf-1', + userId: 'user-123', + }, + }) + + const response = await POST(request as never) + const data = await response.json() + + expect(response.status).toBe(200) + expect(mockCreateOpenCodeSession).toHaveBeenCalledTimes(1) + expect(mockPromptOpenCodeSession).toHaveBeenCalledTimes(1) + expect(mockLogOpenCodeFailure).toHaveBeenCalledWith( + 'Failed to execute OpenCode prompt', + expect.objectContaining({ message: 'session not found' }) + ) + expect(data).toEqual({ + success: true, + output: { + content: '', + threadId: 'fresh-session', + error: 'session not found', + }, + }) + }) + it('stores and returns the fresh session id when the retry prompt fails', async () => { mockGetStoredOpenCodeSession.mockResolvedValue({ sessionId: 'stale-session', diff --git a/apps/sim/app/api/tools/opencode/prompt/route.ts b/apps/sim/app/api/tools/opencode/prompt/route.ts index ed626e8c651..255722c5945 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.ts @@ -136,10 +136,9 @@ export async function POST(request: NextRequest) { const memoryKey = buildOpenCodeSessionMemoryKey(workflowId, sessionOwnerKey) const newThread = coerceOpenCodeBoolean(body.newThread) const storedThread = newThread ? null : await getStoredOpenCodeSession(workspaceId, memoryKey) + const reusedStoredThread = Boolean(storedThread && storedThread.repository === repositoryId) let threadId = - storedThread && storedThread.repository === repositoryId - ? storedThread.sessionId - : undefined + reusedStoredThread ? storedThread.sessionId : undefined if (!threadId) { const session = await createOpenCodeSession( @@ -177,7 +176,7 @@ export async function POST(request: NextRequest) { return buildSuccessResponse(result.threadId, result.content, result.cost) } catch (error) { - if (threadId && !newThread && shouldRetryWithFreshOpenCodeSession(error)) { + if (reusedStoredThread && threadId && !newThread && shouldRetryWithFreshOpenCodeSession(error)) { let freshSessionId = threadId try { diff --git a/apps/sim/lib/opencode/service.ts b/apps/sim/lib/opencode/service.ts index c72e293132d..12ebb4f953b 100644 --- a/apps/sim/lib/opencode/service.ts +++ b/apps/sim/lib/opencode/service.ts @@ -220,7 +220,7 @@ function getAssistantErrorMessage(error: AssistantMessage['error']): string | un return error.name } -export function extractOpenCodeText(parts: Part[]): string { +function extractOpenCodeText(parts: Part[]): string { return parts .filter((part): part is Extract => part.type === 'text') .map((part) => part.text.trim()) diff --git a/docker/opencode/entrypoint.sh b/docker/opencode/entrypoint.sh index 7d4c93a91bb..6e3660edd03 100755 --- a/docker/opencode/entrypoint.sh +++ b/docker/opencode/entrypoint.sh @@ -5,6 +5,15 @@ log() { printf '[opencode-entrypoint] %s\n' "$*" } +validate_port() { + local port="$1" + + if [[ ! "$port" =~ ^[0-9]+$ ]]; then + log "OPENCODE_PORT must be a numeric TCP port" + exit 1 + fi +} + write_runtime_env() { local env_file="/home/opencode/.config/opencode/runtime-env.sh" local vars=( @@ -88,6 +97,8 @@ main() { export GOOGLE_GENERATIVE_AI_API_KEY="${GEMINI_API_KEY}" fi + validate_port "${OPENCODE_PORT}" + mkdir -p "${OPENCODE_REPOSITORY_ROOT}" /home/opencode/.config/opencode /home/opencode/.local/share/opencode /home/opencode/.local/state chown -R opencode:opencode "${OPENCODE_REPOSITORY_ROOT}" /home/opencode/.config /home/opencode/.local/share /home/opencode/.local/state From 5ab2b5f4cf64e266fea62a02acb80e0dd26ab075 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 18:33:34 +0100 Subject: [PATCH 16/31] fix(editor): stabilize async option refetching --- apps/sim/app/api/tools/opencode/prompt/route.ts | 2 +- .../sub-block/components/combobox/combobox.tsx | 16 +++++++++++----- .../sub-block/components/dropdown/dropdown.tsx | 16 +++++++++++----- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/apps/sim/app/api/tools/opencode/prompt/route.ts b/apps/sim/app/api/tools/opencode/prompt/route.ts index 255722c5945..8da145fa324 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.ts @@ -176,7 +176,7 @@ export async function POST(request: NextRequest) { return buildSuccessResponse(result.threadId, result.content, result.cost) } catch (error) { - if (reusedStoredThread && threadId && !newThread && shouldRetryWithFreshOpenCodeSession(error)) { + if (reusedStoredThread && threadId && shouldRetryWithFreshOpenCodeSession(error)) { let freshSessionId = threadId try { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 19a2ae50d10..b3ac18ecc16 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -127,23 +127,27 @@ export const ComboBox = memo(function ComboBox({ const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) const previousDependencyValuesRef = useRef('') const isOptionsFetchInFlightRef = useRef(false) + const fetchErrorRef = useRef(null) + const hasAttemptedOptionsFetchRef = useRef(false) /** * Fetches options from the async fetchOptions function if provided */ - const fetchOptionsIfNeeded = useCallback(async (force = false) => { + const fetchOptionsIfNeeded = useCallback(async (force = fetchErrorRef.current !== null) => { if ( !fetchOptions || isPreview || disabled || - (!force && hasAttemptedOptionsFetch) || + (!force && hasAttemptedOptionsFetchRef.current) || isOptionsFetchInFlightRef.current ) { return } isOptionsFetchInFlightRef.current = true + hasAttemptedOptionsFetchRef.current = true setHasAttemptedOptionsFetch(true) + fetchErrorRef.current = null setIsLoadingOptions(true) setFetchError(null) try { @@ -151,6 +155,7 @@ export const ComboBox = memo(function ComboBox({ setFetchedOptions(options) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' + fetchErrorRef.current = errorMessage setFetchError(errorMessage) setFetchedOptions([]) } finally { @@ -163,7 +168,6 @@ export const ComboBox = memo(function ComboBox({ subBlockId, isPreview, disabled, - hasAttemptedOptionsFetch, ]) // Determine the active value based on mode (preview vs. controlled vs. store) @@ -328,7 +332,9 @@ export const ComboBox = memo(function ComboBox({ currentDependencyValuesStr !== previousDependencyValuesStr ) { setFetchedOptions([]) + fetchErrorRef.current = null setFetchError(null) + hasAttemptedOptionsFetchRef.current = false setHasAttemptedOptionsFetch(false) setHydratedOption(null) } @@ -454,10 +460,10 @@ export const ComboBox = memo(function ComboBox({ const handleOpenChange = useCallback( (open: boolean) => { if (open) { - void fetchOptionsIfNeeded(fetchError !== null) + void fetchOptionsIfNeeded() } }, - [fetchError, fetchOptionsIfNeeded] + [fetchOptionsIfNeeded] ) /** diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 10c233dd0b9..40b0a38a64a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -133,6 +133,8 @@ export const Dropdown = memo(function Dropdown({ const previousModeRef = useRef(null) const previousDependencyValuesRef = useRef('') const isOptionsFetchInFlightRef = useRef(false) + const fetchErrorRef = useRef(null) + const hasAttemptedOptionsFetchRef = useRef(false) const [builderData, setBuilderData] = useSubBlockValue(blockId, 'builderData') const [data, setData] = useSubBlockValue(blockId, 'data') @@ -156,19 +158,21 @@ export const Dropdown = memo(function Dropdown({ : [] : null - const fetchOptionsIfNeeded = useCallback(async (force = false) => { + const fetchOptionsIfNeeded = useCallback(async (force = fetchErrorRef.current !== null) => { if ( !fetchOptions || isPreview || disabled || - (!force && hasAttemptedOptionsFetch) || + (!force && hasAttemptedOptionsFetchRef.current) || isOptionsFetchInFlightRef.current ) { return } isOptionsFetchInFlightRef.current = true + hasAttemptedOptionsFetchRef.current = true setHasAttemptedOptionsFetch(true) + fetchErrorRef.current = null setIsLoadingOptions(true) setFetchError(null) try { @@ -176,6 +180,7 @@ export const Dropdown = memo(function Dropdown({ setFetchedOptions(options) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' + fetchErrorRef.current = errorMessage setFetchError(errorMessage) setFetchedOptions([]) } finally { @@ -188,7 +193,6 @@ export const Dropdown = memo(function Dropdown({ subBlockId, isPreview, disabled, - hasAttemptedOptionsFetch, ]) /** @@ -197,10 +201,10 @@ export const Dropdown = memo(function Dropdown({ const handleOpenChange = useCallback( (open: boolean) => { if (open) { - void fetchOptionsIfNeeded(fetchError !== null) + void fetchOptionsIfNeeded() } }, - [fetchError, fetchOptionsIfNeeded] + [fetchOptionsIfNeeded] ) const evaluatedOptions = useMemo(() => { @@ -391,7 +395,9 @@ export const Dropdown = memo(function Dropdown({ currentDependencyValuesStr !== previousDependencyValuesStr ) { setFetchedOptions([]) + fetchErrorRef.current = null setFetchError(null) + hasAttemptedOptionsFetchRef.current = false setHasAttemptedOptionsFetch(false) setHydratedOption(null) } From d246b507c22fbf3777c045c6643f7f2e04c67a18 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 18:37:30 +0100 Subject: [PATCH 17/31] docs(opencode): add branch status summary --- PR-3761-BRANCH-STATUS.md | 68 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 PR-3761-BRANCH-STATUS.md diff --git a/PR-3761-BRANCH-STATUS.md b/PR-3761-BRANCH-STATUS.md new file mode 100644 index 00000000000..d9a80d96e35 --- /dev/null +++ b/PR-3761-BRANCH-STATUS.md @@ -0,0 +1,68 @@ +# PR 3761 Branch Status + +## Scope + +This note summarizes the branches involved in PR `#3761` and the final state of each one from this worktree. + +PR: `https://github.com/simstudioai/sim/pull/3761` + +## Branches + +### `staging` + +- Role: base branch of PR `#3761` +- Expected state: unchanged by this worktree +- Relationship to this work: `feat/opencode-optional-runtime` is intended to merge into `staging` + +### `feat/opencode-optional-runtime` + +- Role: feature branch for the OpenCode integration and optional runtime overlay +- Local state: in sync with `origin/feat/opencode-optional-runtime` +- Worktree state at the end of this session: clean +- Latest commit at the end of this session: `5ab2b5f4c` + +### `origin/feat/opencode-optional-runtime` + +- Role: remote branch backing PR `#3761` +- Remote state at the end of this session: matches local `feat/opencode-optional-runtime` +- Latest pushed commit at the end of this session: `5ab2b5f4c` + +## Final State Of `feat/opencode-optional-runtime` + +At the end of this session, the branch contains: + +- OpenCode block integration in Sim +- OpenCode tools, API routes, and `apps/sim/lib/opencode` +- optional OpenCode runtime overlay under `docker/` and dedicated compose files +- OpenCode hidden by default behind `NEXT_PUBLIC_OPENCODE_ENABLED` +- `docker-compose.local.yml` and `docker-compose.prod.yml` preserved as defaults +- external runtime hardening, including configurable `OPENCODE_REPOSITORY_ROOT` +- fail-fast production overlay behavior when `OPENCODE_SERVER_PASSWORD` is missing +- focused fixes for review feedback around: + - stale session retry handling + - repository resolution reuse + - internal URL leakage in route errors + - Docker runtime detection caching + - async selector refetch behavior in dropdown/combobox + - OpenCode retry session persistence + - root-path and retry-error hardening + - entrypoint port validation + +## Final Commit Sequence Applied In This Session + +- `1e174f75a` `fix(opencode): avoid redundant resolution and url leaks` +- `35fac8dd3` `fix(opencode): clean up low severity review notes` +- `35949bb16` `fix(opencode): harden root path and retry errors` +- `3458868ba` `refactor(opencode): keep base url helper private` +- `a27de0d7c` `fix(editor): avoid stale open-change fetch gating` +- `a8fb07354` `fix(opencode): persist fresh retry sessions` +- `2bb744a38` `fix(opencode): tighten retry and entrypoint guards` +- `5ab2b5f4c` `fix(editor): stabilize async option refetching` + +## End-State Summary + +- Current branch: `feat/opencode-optional-runtime` +- Base branch: `staging` +- PR branch remote: `origin/feat/opencode-optional-runtime` +- Local/remote divergence at the end of this session: none +- Worktree cleanliness at the end of this session: clean From f1156e9f216f086634048bc5320c02ebc73aacd4 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Wed, 25 Mar 2026 18:38:51 +0100 Subject: [PATCH 18/31] docs(opencode): clarify branch scope and overlap --- PR-3761-BRANCH-STATUS.md | 172 +++++++++++++++++++++++++++------------ 1 file changed, 120 insertions(+), 52 deletions(-) diff --git a/PR-3761-BRANCH-STATUS.md b/PR-3761-BRANCH-STATUS.md index d9a80d96e35..6fb0ef3ab82 100644 --- a/PR-3761-BRANCH-STATUS.md +++ b/PR-3761-BRANCH-STATUS.md @@ -1,8 +1,8 @@ # PR 3761 Branch Status -## Scope +## Purpose -This note summarizes the branches involved in PR `#3761` and the final state of each one from this worktree. +This note describes what each relevant branch contains and how responsibilities are split, so it is easier to see where changes overlap. PR: `https://github.com/simstudioai/sim/pull/3761` @@ -10,59 +10,127 @@ PR: `https://github.com/simstudioai/sim/pull/3761` ### `staging` -- Role: base branch of PR `#3761` -- Expected state: unchanged by this worktree -- Relationship to this work: `feat/opencode-optional-runtime` is intended to merge into `staging` +Base branch for PR `#3761`. + +What it contains: + +- current shared integration baseline before OpenCode optional runtime lands +- current default compose and deployment behavior +- no PR-specific branch-only review fixes from this worktree + +What it does **not** contain yet: + +- the OpenCode branch work described below until PR `#3761` is merged ### `feat/opencode-optional-runtime` -- Role: feature branch for the OpenCode integration and optional runtime overlay -- Local state: in sync with `origin/feat/opencode-optional-runtime` -- Worktree state at the end of this session: clean -- Latest commit at the end of this session: `5ab2b5f4c` +Feature branch for PR `#3761`. + +Primary responsibility: + +- add the OpenCode integration and its optional runtime overlay without changing the existing default local/prod setups + +What it contains: + +- OpenCode block in Sim +- OpenCode tools +- OpenCode API routes +- `apps/sim/lib/opencode` +- wiring for `@opencode-ai/sdk` in Next/Vitest +- async dropdown/combobox support needed by the integration +- optional runtime files under `docker/opencode/` +- `docker-compose.opencode.yml` +- `docker-compose.opencode.local.yml` +- deployment/runtime hardening for: + - `OPENCODE_REPOSITORY_ROOT` + - `OPENCODE_SERVER_PASSWORD` + - retry/session handling + - route error behavior + - OpenCode runtime config guards + +What this branch intentionally preserves: + +- `docker-compose.local.yml` stays as the default local setup +- `docker-compose.prod.yml` stays as the default production setup +- OpenCode remains hidden by default behind `NEXT_PUBLIC_OPENCODE_ENABLED` ### `origin/feat/opencode-optional-runtime` -- Role: remote branch backing PR `#3761` -- Remote state at the end of this session: matches local `feat/opencode-optional-runtime` -- Latest pushed commit at the end of this session: `5ab2b5f4c` - -## Final State Of `feat/opencode-optional-runtime` - -At the end of this session, the branch contains: - -- OpenCode block integration in Sim -- OpenCode tools, API routes, and `apps/sim/lib/opencode` -- optional OpenCode runtime overlay under `docker/` and dedicated compose files -- OpenCode hidden by default behind `NEXT_PUBLIC_OPENCODE_ENABLED` -- `docker-compose.local.yml` and `docker-compose.prod.yml` preserved as defaults -- external runtime hardening, including configurable `OPENCODE_REPOSITORY_ROOT` -- fail-fast production overlay behavior when `OPENCODE_SERVER_PASSWORD` is missing -- focused fixes for review feedback around: - - stale session retry handling - - repository resolution reuse - - internal URL leakage in route errors - - Docker runtime detection caching - - async selector refetch behavior in dropdown/combobox - - OpenCode retry session persistence - - root-path and retry-error hardening - - entrypoint port validation - -## Final Commit Sequence Applied In This Session - -- `1e174f75a` `fix(opencode): avoid redundant resolution and url leaks` -- `35fac8dd3` `fix(opencode): clean up low severity review notes` -- `35949bb16` `fix(opencode): harden root path and retry errors` -- `3458868ba` `refactor(opencode): keep base url helper private` -- `a27de0d7c` `fix(editor): avoid stale open-change fetch gating` -- `a8fb07354` `fix(opencode): persist fresh retry sessions` -- `2bb744a38` `fix(opencode): tighten retry and entrypoint guards` -- `5ab2b5f4c` `fix(editor): stabilize async option refetching` - -## End-State Summary - -- Current branch: `feat/opencode-optional-runtime` -- Base branch: `staging` -- PR branch remote: `origin/feat/opencode-optional-runtime` -- Local/remote divergence at the end of this session: none -- Worktree cleanliness at the end of this session: clean +Remote branch backing PR `#3761`. + +Expected relationship: + +- should match local `feat/opencode-optional-runtime` +- if local and remote diverge, local work has not been pushed yet or remote changed externally + +## Overlap And Boundaries + +### Product / app layer + +Owned here in `feat/opencode-optional-runtime`: + +- OpenCode block/tool/route/lib implementation +- editor support required by the OpenCode selectors + +Possible overlap area: + +- shared editor components like dropdown/combobox +- these are not OpenCode-only files, but this branch touches them only where needed for OpenCode async option behavior + +### Runtime / deployment layer + +Owned here in `feat/opencode-optional-runtime`: + +- optional OpenCode container/runtime bootstrap +- optional compose overlays + +Boundary: + +- this branch should not replace the default local/prod compose files as the main path +- it only adds overlays and guards around the optional runtime + +### Review-fix layer + +Many follow-up changes in this branch are not separate features. + +They are: + +- hardening fixes +- correctness fixes +- small refactors +- review-driven adjustments on top of the same OpenCode feature branch + +That means several files now contain both: + +- original feature work +- later review fixes + +So if something feels like it is "overlapping", that is expected: the branch has accumulated refinement passes on top of the original OpenCode implementation rather than splitting them into separate branches. + +## Final Branch State + +At the end of this session: + +- current branch: `feat/opencode-optional-runtime` +- base branch: `staging` +- remote tracking branch: `origin/feat/opencode-optional-runtime` +- local/remote divergence: none +- worktree state: clean + +## Practical Reading Guide + +If you want to understand the branch quickly, read it in this order: + +1. `apps/sim/lib/opencode/` +2. `apps/sim/app/api/opencode/` +3. `apps/sim/app/api/tools/opencode/` +4. `apps/sim/blocks/blocks/opencode.ts` +5. `docker/opencode/` +6. `docker-compose.opencode.yml` +7. `docker-compose.opencode.local.yml` + +If you want to understand where overlap happened, check these shared files next: + +- `apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx` +- `apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx` +- `apps/sim/app/api/tools/opencode/prompt/route.ts` From cb57bad94289bb5b7bb174cf3cec04827fdbb6d8 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 09:09:46 +0100 Subject: [PATCH 19/31] docs(opencode): remove internal branch note from PR --- PR-3761-BRANCH-STATUS.md | 136 --------------------------------------- 1 file changed, 136 deletions(-) delete mode 100644 PR-3761-BRANCH-STATUS.md diff --git a/PR-3761-BRANCH-STATUS.md b/PR-3761-BRANCH-STATUS.md deleted file mode 100644 index 6fb0ef3ab82..00000000000 --- a/PR-3761-BRANCH-STATUS.md +++ /dev/null @@ -1,136 +0,0 @@ -# PR 3761 Branch Status - -## Purpose - -This note describes what each relevant branch contains and how responsibilities are split, so it is easier to see where changes overlap. - -PR: `https://github.com/simstudioai/sim/pull/3761` - -## Branches - -### `staging` - -Base branch for PR `#3761`. - -What it contains: - -- current shared integration baseline before OpenCode optional runtime lands -- current default compose and deployment behavior -- no PR-specific branch-only review fixes from this worktree - -What it does **not** contain yet: - -- the OpenCode branch work described below until PR `#3761` is merged - -### `feat/opencode-optional-runtime` - -Feature branch for PR `#3761`. - -Primary responsibility: - -- add the OpenCode integration and its optional runtime overlay without changing the existing default local/prod setups - -What it contains: - -- OpenCode block in Sim -- OpenCode tools -- OpenCode API routes -- `apps/sim/lib/opencode` -- wiring for `@opencode-ai/sdk` in Next/Vitest -- async dropdown/combobox support needed by the integration -- optional runtime files under `docker/opencode/` -- `docker-compose.opencode.yml` -- `docker-compose.opencode.local.yml` -- deployment/runtime hardening for: - - `OPENCODE_REPOSITORY_ROOT` - - `OPENCODE_SERVER_PASSWORD` - - retry/session handling - - route error behavior - - OpenCode runtime config guards - -What this branch intentionally preserves: - -- `docker-compose.local.yml` stays as the default local setup -- `docker-compose.prod.yml` stays as the default production setup -- OpenCode remains hidden by default behind `NEXT_PUBLIC_OPENCODE_ENABLED` - -### `origin/feat/opencode-optional-runtime` - -Remote branch backing PR `#3761`. - -Expected relationship: - -- should match local `feat/opencode-optional-runtime` -- if local and remote diverge, local work has not been pushed yet or remote changed externally - -## Overlap And Boundaries - -### Product / app layer - -Owned here in `feat/opencode-optional-runtime`: - -- OpenCode block/tool/route/lib implementation -- editor support required by the OpenCode selectors - -Possible overlap area: - -- shared editor components like dropdown/combobox -- these are not OpenCode-only files, but this branch touches them only where needed for OpenCode async option behavior - -### Runtime / deployment layer - -Owned here in `feat/opencode-optional-runtime`: - -- optional OpenCode container/runtime bootstrap -- optional compose overlays - -Boundary: - -- this branch should not replace the default local/prod compose files as the main path -- it only adds overlays and guards around the optional runtime - -### Review-fix layer - -Many follow-up changes in this branch are not separate features. - -They are: - -- hardening fixes -- correctness fixes -- small refactors -- review-driven adjustments on top of the same OpenCode feature branch - -That means several files now contain both: - -- original feature work -- later review fixes - -So if something feels like it is "overlapping", that is expected: the branch has accumulated refinement passes on top of the original OpenCode implementation rather than splitting them into separate branches. - -## Final Branch State - -At the end of this session: - -- current branch: `feat/opencode-optional-runtime` -- base branch: `staging` -- remote tracking branch: `origin/feat/opencode-optional-runtime` -- local/remote divergence: none -- worktree state: clean - -## Practical Reading Guide - -If you want to understand the branch quickly, read it in this order: - -1. `apps/sim/lib/opencode/` -2. `apps/sim/app/api/opencode/` -3. `apps/sim/app/api/tools/opencode/` -4. `apps/sim/blocks/blocks/opencode.ts` -5. `docker/opencode/` -6. `docker-compose.opencode.yml` -7. `docker-compose.opencode.local.yml` - -If you want to understand where overlap happened, check these shared files next: - -- `apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx` -- `apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx` -- `apps/sim/app/api/tools/opencode/prompt/route.ts` From e4c40ae527f74e68766df8066eae0f82f9483eae Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 09:26:09 +0100 Subject: [PATCH 20/31] fix opencode review follow-ups --- apps/sim/app/api/tools/opencode/prompt/route.ts | 8 ++++---- docker/opencode/entrypoint.sh | 5 +++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/tools/opencode/prompt/route.ts b/apps/sim/app/api/tools/opencode/prompt/route.ts index 8da145fa324..1574f31d77e 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.ts @@ -136,9 +136,9 @@ export async function POST(request: NextRequest) { const memoryKey = buildOpenCodeSessionMemoryKey(workflowId, sessionOwnerKey) const newThread = coerceOpenCodeBoolean(body.newThread) const storedThread = newThread ? null : await getStoredOpenCodeSession(workspaceId, memoryKey) - const reusedStoredThread = Boolean(storedThread && storedThread.repository === repositoryId) - let threadId = - reusedStoredThread ? storedThread.sessionId : undefined + const reusableStoredThread = + storedThread && storedThread.repository === repositoryId ? storedThread : null + let threadId = reusableStoredThread?.sessionId if (!threadId) { const session = await createOpenCodeSession( @@ -176,7 +176,7 @@ export async function POST(request: NextRequest) { return buildSuccessResponse(result.threadId, result.content, result.cost) } catch (error) { - if (reusedStoredThread && threadId && shouldRetryWithFreshOpenCodeSession(error)) { + if (reusableStoredThread && threadId && shouldRetryWithFreshOpenCodeSession(error)) { let freshSessionId = threadId try { diff --git a/docker/opencode/entrypoint.sh b/docker/opencode/entrypoint.sh index 6e3660edd03..5810e9c2926 100755 --- a/docker/opencode/entrypoint.sh +++ b/docker/opencode/entrypoint.sh @@ -19,8 +19,12 @@ write_runtime_env() { local vars=( HOME PATH + GIT_ASKPASS OPENCODE_REPOS + OPENCODE_PORT OPENCODE_REPOSITORY_ROOT + OPENCODE_SERVER_PASSWORD + OPENCODE_SERVER_USERNAME GIT_USERNAME GIT_TOKEN GITHUB_TOKEN @@ -87,6 +91,7 @@ main() { : "${OPENCODE_PORT:=4096}" : "${OPENCODE_SERVER_USERNAME:=opencode}" : "${OPENCODE_REPOSITORY_ROOT:=/app/repos}" + export GIT_ASKPASS="${GIT_ASKPASS:-/usr/local/bin/git-askpass.sh}" if [[ -z "${OPENCODE_SERVER_PASSWORD:-}" ]]; then log "OPENCODE_SERVER_PASSWORD is required" From d543a9dbd313d985b728a7668ce8861fc1862427 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 09:39:33 +0100 Subject: [PATCH 21/31] fix opencode async selector refresh --- .../components/combobox/combobox.tsx | 69 +++++++++---------- .../components/dropdown/dropdown.tsx | 69 +++++++++---------- 2 files changed, 66 insertions(+), 72 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index b3ac18ecc16..ab4efec633a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -133,42 +133,39 @@ export const ComboBox = memo(function ComboBox({ /** * Fetches options from the async fetchOptions function if provided */ - const fetchOptionsIfNeeded = useCallback(async (force = fetchErrorRef.current !== null) => { - if ( - !fetchOptions || - isPreview || - disabled || - (!force && hasAttemptedOptionsFetchRef.current) || - isOptionsFetchInFlightRef.current - ) { - return - } + const fetchOptionsIfNeeded = useCallback( + async (force = fetchErrorRef.current !== null) => { + if ( + !fetchOptions || + isPreview || + disabled || + (!force && hasAttemptedOptionsFetchRef.current) || + isOptionsFetchInFlightRef.current + ) { + return + } - isOptionsFetchInFlightRef.current = true - hasAttemptedOptionsFetchRef.current = true - setHasAttemptedOptionsFetch(true) - fetchErrorRef.current = null - setIsLoadingOptions(true) - setFetchError(null) - try { - const options = await fetchOptions(blockId, subBlockId) - setFetchedOptions(options) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' - fetchErrorRef.current = errorMessage - setFetchError(errorMessage) - setFetchedOptions([]) - } finally { - isOptionsFetchInFlightRef.current = false - setIsLoadingOptions(false) - } - }, [ - fetchOptions, - blockId, - subBlockId, - isPreview, - disabled, - ]) + isOptionsFetchInFlightRef.current = true + hasAttemptedOptionsFetchRef.current = true + setHasAttemptedOptionsFetch(true) + fetchErrorRef.current = null + setIsLoadingOptions(true) + setFetchError(null) + try { + const options = await fetchOptions(blockId, subBlockId) + setFetchedOptions(options) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' + fetchErrorRef.current = errorMessage + setFetchError(errorMessage) + setFetchedOptions([]) + } finally { + isOptionsFetchInFlightRef.current = false + setIsLoadingOptions(false) + } + }, + [fetchOptions, blockId, subBlockId, isPreview, disabled] + ) // Determine the active value based on mode (preview vs. controlled vs. store) const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue @@ -460,7 +457,7 @@ export const ComboBox = memo(function ComboBox({ const handleOpenChange = useCallback( (open: boolean) => { if (open) { - void fetchOptionsIfNeeded() + void fetchOptionsIfNeeded(true) } }, [fetchOptionsIfNeeded] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 40b0a38a64a..a1febda7a23 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -158,42 +158,39 @@ export const Dropdown = memo(function Dropdown({ : [] : null - const fetchOptionsIfNeeded = useCallback(async (force = fetchErrorRef.current !== null) => { - if ( - !fetchOptions || - isPreview || - disabled || - (!force && hasAttemptedOptionsFetchRef.current) || - isOptionsFetchInFlightRef.current - ) { - return - } + const fetchOptionsIfNeeded = useCallback( + async (force = fetchErrorRef.current !== null) => { + if ( + !fetchOptions || + isPreview || + disabled || + (!force && hasAttemptedOptionsFetchRef.current) || + isOptionsFetchInFlightRef.current + ) { + return + } - isOptionsFetchInFlightRef.current = true - hasAttemptedOptionsFetchRef.current = true - setHasAttemptedOptionsFetch(true) - fetchErrorRef.current = null - setIsLoadingOptions(true) - setFetchError(null) - try { - const options = await fetchOptions(blockId, subBlockId) - setFetchedOptions(options) - } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' - fetchErrorRef.current = errorMessage - setFetchError(errorMessage) - setFetchedOptions([]) - } finally { - isOptionsFetchInFlightRef.current = false - setIsLoadingOptions(false) - } - }, [ - fetchOptions, - blockId, - subBlockId, - isPreview, - disabled, - ]) + isOptionsFetchInFlightRef.current = true + hasAttemptedOptionsFetchRef.current = true + setHasAttemptedOptionsFetch(true) + fetchErrorRef.current = null + setIsLoadingOptions(true) + setFetchError(null) + try { + const options = await fetchOptions(blockId, subBlockId) + setFetchedOptions(options) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' + fetchErrorRef.current = errorMessage + setFetchError(errorMessage) + setFetchedOptions([]) + } finally { + isOptionsFetchInFlightRef.current = false + setIsLoadingOptions(false) + } + }, + [fetchOptions, blockId, subBlockId, isPreview, disabled] + ) /** * Handles combobox open state changes to trigger option fetching @@ -201,7 +198,7 @@ export const Dropdown = memo(function Dropdown({ const handleOpenChange = useCallback( (open: boolean) => { if (open) { - void fetchOptionsIfNeeded() + void fetchOptionsIfNeeded(true) } }, [fetchOptionsIfNeeded] From 59be14f8b55c75b23b7342596eac3b3031baceed Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 09:45:03 +0100 Subject: [PATCH 22/31] fix opencode async selector force default --- .../components/sub-block/components/combobox/combobox.tsx | 2 +- .../components/sub-block/components/dropdown/dropdown.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index ab4efec633a..85c9484286b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -134,7 +134,7 @@ export const ComboBox = memo(function ComboBox({ * Fetches options from the async fetchOptions function if provided */ const fetchOptionsIfNeeded = useCallback( - async (force = fetchErrorRef.current !== null) => { + async (force = false) => { if ( !fetchOptions || isPreview || diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index a1febda7a23..a4c9eda9c4c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -159,7 +159,7 @@ export const Dropdown = memo(function Dropdown({ : null const fetchOptionsIfNeeded = useCallback( - async (force = fetchErrorRef.current !== null) => { + async (force = false) => { if ( !fetchOptions || isPreview || From 0ca5e247bd2e6879eab75e43042cd92471479b57 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 09:55:57 +0100 Subject: [PATCH 23/31] clean up opencode async selector refs --- .../components/sub-block/components/combobox/combobox.tsx | 4 ---- .../components/sub-block/components/dropdown/dropdown.tsx | 4 ---- 2 files changed, 8 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 85c9484286b..ca070283594 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -127,7 +127,6 @@ export const ComboBox = memo(function ComboBox({ const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) const previousDependencyValuesRef = useRef('') const isOptionsFetchInFlightRef = useRef(false) - const fetchErrorRef = useRef(null) const hasAttemptedOptionsFetchRef = useRef(false) /** @@ -148,7 +147,6 @@ export const ComboBox = memo(function ComboBox({ isOptionsFetchInFlightRef.current = true hasAttemptedOptionsFetchRef.current = true setHasAttemptedOptionsFetch(true) - fetchErrorRef.current = null setIsLoadingOptions(true) setFetchError(null) try { @@ -156,7 +154,6 @@ export const ComboBox = memo(function ComboBox({ setFetchedOptions(options) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' - fetchErrorRef.current = errorMessage setFetchError(errorMessage) setFetchedOptions([]) } finally { @@ -329,7 +326,6 @@ export const ComboBox = memo(function ComboBox({ currentDependencyValuesStr !== previousDependencyValuesStr ) { setFetchedOptions([]) - fetchErrorRef.current = null setFetchError(null) hasAttemptedOptionsFetchRef.current = false setHasAttemptedOptionsFetch(false) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index a4c9eda9c4c..817ae9c8d51 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -133,7 +133,6 @@ export const Dropdown = memo(function Dropdown({ const previousModeRef = useRef(null) const previousDependencyValuesRef = useRef('') const isOptionsFetchInFlightRef = useRef(false) - const fetchErrorRef = useRef(null) const hasAttemptedOptionsFetchRef = useRef(false) const [builderData, setBuilderData] = useSubBlockValue(blockId, 'builderData') @@ -173,7 +172,6 @@ export const Dropdown = memo(function Dropdown({ isOptionsFetchInFlightRef.current = true hasAttemptedOptionsFetchRef.current = true setHasAttemptedOptionsFetch(true) - fetchErrorRef.current = null setIsLoadingOptions(true) setFetchError(null) try { @@ -181,7 +179,6 @@ export const Dropdown = memo(function Dropdown({ setFetchedOptions(options) } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' - fetchErrorRef.current = errorMessage setFetchError(errorMessage) setFetchedOptions([]) } finally { @@ -392,7 +389,6 @@ export const Dropdown = memo(function Dropdown({ currentDependencyValuesStr !== previousDependencyValuesStr ) { setFetchedOptions([]) - fetchErrorRef.current = null setFetchError(null) hasAttemptedOptionsFetchRef.current = false setHasAttemptedOptionsFetch(false) From d77d875e67affb56f06699288d690e20cc70e5e5 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 10:09:49 +0100 Subject: [PATCH 24/31] guard opencode async selector stale fetches --- .../sub-block/components/combobox/combobox.tsx | 9 +++++++++ .../sub-block/components/dropdown/dropdown.tsx | 9 +++++++++ 2 files changed, 18 insertions(+) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index ca070283594..50da79d5770 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -126,6 +126,7 @@ export const ComboBox = memo(function ComboBox({ const [hasAttemptedOptionsFetch, setHasAttemptedOptionsFetch] = useState(false) const [hydratedOption, setHydratedOption] = useState<{ label: string; id: string } | null>(null) const previousDependencyValuesRef = useRef('') + const optionsFetchVersionRef = useRef(0) const isOptionsFetchInFlightRef = useRef(false) const hasAttemptedOptionsFetchRef = useRef(false) @@ -144,6 +145,7 @@ export const ComboBox = memo(function ComboBox({ return } + const fetchVersion = optionsFetchVersionRef.current isOptionsFetchInFlightRef.current = true hasAttemptedOptionsFetchRef.current = true setHasAttemptedOptionsFetch(true) @@ -151,8 +153,14 @@ export const ComboBox = memo(function ComboBox({ setFetchError(null) try { const options = await fetchOptions(blockId, subBlockId) + if (fetchVersion !== optionsFetchVersionRef.current) { + return + } setFetchedOptions(options) } catch (error) { + if (fetchVersion !== optionsFetchVersionRef.current) { + return + } const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' setFetchError(errorMessage) setFetchedOptions([]) @@ -325,6 +333,7 @@ export const ComboBox = memo(function ComboBox({ previousDependencyValuesStr && currentDependencyValuesStr !== previousDependencyValuesStr ) { + optionsFetchVersionRef.current += 1 setFetchedOptions([]) setFetchError(null) hasAttemptedOptionsFetchRef.current = false diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 817ae9c8d51..6883699114a 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -132,6 +132,7 @@ export const Dropdown = memo(function Dropdown({ const previousModeRef = useRef(null) const previousDependencyValuesRef = useRef('') + const optionsFetchVersionRef = useRef(0) const isOptionsFetchInFlightRef = useRef(false) const hasAttemptedOptionsFetchRef = useRef(false) @@ -169,6 +170,7 @@ export const Dropdown = memo(function Dropdown({ return } + const fetchVersion = optionsFetchVersionRef.current isOptionsFetchInFlightRef.current = true hasAttemptedOptionsFetchRef.current = true setHasAttemptedOptionsFetch(true) @@ -176,8 +178,14 @@ export const Dropdown = memo(function Dropdown({ setFetchError(null) try { const options = await fetchOptions(blockId, subBlockId) + if (fetchVersion !== optionsFetchVersionRef.current) { + return + } setFetchedOptions(options) } catch (error) { + if (fetchVersion !== optionsFetchVersionRef.current) { + return + } const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' setFetchError(errorMessage) setFetchedOptions([]) @@ -388,6 +396,7 @@ export const Dropdown = memo(function Dropdown({ previousDependencyValuesStr && currentDependencyValuesStr !== previousDependencyValuesStr ) { + optionsFetchVersionRef.current += 1 setFetchedOptions([]) setFetchError(null) hasAttemptedOptionsFetchRef.current = false From e77557c47dd5990c3cfef765fabee0092723d1c0 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 10:51:14 +0100 Subject: [PATCH 25/31] fix opencode selector stale reload and lint --- .../app/api/tools/opencode/prompt/route.ts | 7 ++----- .../components/combobox/combobox.tsx | 10 +++++++++- .../components/dropdown/dropdown.tsx | 10 +++++++++- apps/sim/blocks/blocks/opencode.ts | 8 ++++++-- apps/sim/lib/opencode/errors.test.ts | 2 +- apps/sim/lib/opencode/service.ts | 19 ++++--------------- 6 files changed, 31 insertions(+), 25 deletions(-) diff --git a/apps/sim/app/api/tools/opencode/prompt/route.ts b/apps/sim/app/api/tools/opencode/prompt/route.ts index 1574f31d77e..1ed34295218 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.ts @@ -225,16 +225,13 @@ export async function POST(request: NextRequest) { ) const errorMessage = - retryError instanceof Error - ? retryError.message - : 'OpenCode prompt retry failed' + retryError instanceof Error ? retryError.message : 'OpenCode prompt retry failed' return buildErrorResponse(freshSessionId, '', undefined, errorMessage) } } await logOpenCodeFailure('Failed to execute OpenCode prompt', error) - const errorMessage = - error instanceof Error ? error.message : 'OpenCode prompt failed' + const errorMessage = error instanceof Error ? error.message : 'OpenCode prompt failed' return buildErrorResponse(threadId || '', '', undefined, errorMessage) } } catch (error) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index 50da79d5770..c39b31fc920 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -146,6 +146,7 @@ export const ComboBox = memo(function ComboBox({ } const fetchVersion = optionsFetchVersionRef.current + let shouldTriggerReplacementFetch = false isOptionsFetchInFlightRef.current = true hasAttemptedOptionsFetchRef.current = true setHasAttemptedOptionsFetch(true) @@ -154,11 +155,13 @@ export const ComboBox = memo(function ComboBox({ try { const options = await fetchOptions(blockId, subBlockId) if (fetchVersion !== optionsFetchVersionRef.current) { + shouldTriggerReplacementFetch = true return } setFetchedOptions(options) } catch (error) { if (fetchVersion !== optionsFetchVersionRef.current) { + shouldTriggerReplacementFetch = true return } const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' @@ -166,7 +169,12 @@ export const ComboBox = memo(function ComboBox({ setFetchedOptions([]) } finally { isOptionsFetchInFlightRef.current = false - setIsLoadingOptions(false) + + if (shouldTriggerReplacementFetch) { + void fetchOptionsIfNeeded(true) + } else { + setIsLoadingOptions(false) + } } }, [fetchOptions, blockId, subBlockId, isPreview, disabled] diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 6883699114a..e45e2e3d4e8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -171,6 +171,7 @@ export const Dropdown = memo(function Dropdown({ } const fetchVersion = optionsFetchVersionRef.current + let shouldTriggerReplacementFetch = false isOptionsFetchInFlightRef.current = true hasAttemptedOptionsFetchRef.current = true setHasAttemptedOptionsFetch(true) @@ -179,11 +180,13 @@ export const Dropdown = memo(function Dropdown({ try { const options = await fetchOptions(blockId, subBlockId) if (fetchVersion !== optionsFetchVersionRef.current) { + shouldTriggerReplacementFetch = true return } setFetchedOptions(options) } catch (error) { if (fetchVersion !== optionsFetchVersionRef.current) { + shouldTriggerReplacementFetch = true return } const errorMessage = error instanceof Error ? error.message : 'Failed to fetch options' @@ -191,7 +194,12 @@ export const Dropdown = memo(function Dropdown({ setFetchedOptions([]) } finally { isOptionsFetchInFlightRef.current = false - setIsLoadingOptions(false) + + if (shouldTriggerReplacementFetch) { + void fetchOptionsIfNeeded(true) + } else { + setIsLoadingOptions(false) + } } }, [fetchOptions, blockId, subBlockId, isPreview, disabled] diff --git a/apps/sim/blocks/blocks/opencode.ts b/apps/sim/blocks/blocks/opencode.ts index aebfb8c6b57..68b9a1a00b6 100644 --- a/apps/sim/blocks/blocks/opencode.ts +++ b/apps/sim/blocks/blocks/opencode.ts @@ -1,7 +1,7 @@ -import type { BlockConfig } from '@/blocks/types' import { OpenCodeIcon } from '@/components/icons' import { getEnv, isTruthy } from '@/lib/core/config/env' import { coerceOpenCodeBoolean } from '@/lib/opencode/utils' +import type { BlockConfig } from '@/blocks/types' import type { OpenCodePromptResponse } from '@/tools/opencode/types' const isOpenCodeEnabled = isTruthy(getEnv('NEXT_PUBLIC_OPENCODE_ENABLED')) @@ -67,7 +67,7 @@ async function fetchOpenCodeOptionById( query: Record ): Promise<{ label: string; id: string } | null> { if (!optionId) { - return { label: 'None', id: '' } + return null } const options = await fetchOpenCodeOptions(route, query) @@ -174,6 +174,10 @@ export const OpenCodeBlock: BlockConfig = { return [{ label: 'None', id: '' }, ...agents] }, fetchOptionById: async (blockId, _subBlockId, optionId) => { + if (!optionId) { + return { label: 'None', id: '' } + } + const values = await getOpenCodeBlockValues(blockId) const repository = typeof values.repository === 'string' ? values.repository : undefined return fetchOpenCodeOptionById('/api/opencode/agents', optionId, { diff --git a/apps/sim/lib/opencode/errors.test.ts b/apps/sim/lib/opencode/errors.test.ts index e1bd7ead55b..6ee09b8c581 100644 --- a/apps/sim/lib/opencode/errors.test.ts +++ b/apps/sim/lib/opencode/errors.test.ts @@ -9,7 +9,7 @@ describe('getOpenCodeRouteError', () => { it('does not leak the internal OpenCode base URL in connectivity errors', () => { const error = getOpenCodeRouteError( new Error('fetch failed for http://opencode:4096/session'), - 'repositories', + 'repositories' ) expect(error).toEqual({ diff --git a/apps/sim/lib/opencode/service.ts b/apps/sim/lib/opencode/service.ts index 12ebb4f953b..5d86977cdfa 100644 --- a/apps/sim/lib/opencode/service.ts +++ b/apps/sim/lib/opencode/service.ts @@ -382,12 +382,10 @@ export async function promptOpenCodeSession( ): Promise { const client = createOpenCodeClient() const repositoryOption = - request.repositoryOption || - (await resolveOpenCodeRepositoryOption(request.repository)) + request.repositoryOption || (await resolveOpenCodeRepositoryOption(request.repository)) const directory = repositoryOption.directory const sessionId = - request.sessionId || - (await createOpenCodeSession(repositoryOption, request.title)).id + request.sessionId || (await createOpenCodeSession(repositoryOption, request.title)).id const response = await client.session.prompt({ path: { id: sessionId }, @@ -447,13 +445,7 @@ export async function getStoredOpenCodeSession( const result = await db .select({ data: memory.data }) .from(memory) - .where( - and( - eq(memory.workspaceId, workspaceId), - eq(memory.key, key), - isNull(memory.deletedAt) - ) - ) + .where(and(eq(memory.workspaceId, workspaceId), eq(memory.key, key), isNull(memory.deletedAt))) .limit(1) if (result.length === 0) { @@ -554,9 +546,6 @@ function getOpenCodeRetryErrorMessage(error: unknown): string { return String(error ?? '') } -export async function logOpenCodeFailure( - message: string, - error: unknown -): Promise { +export async function logOpenCodeFailure(message: string, error: unknown): Promise { logger.error(message, { error }) } From 4ab5c68111216179cf4ce5a26ad7fc379f09bdef Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 15:54:16 +0100 Subject: [PATCH 26/31] fix opencode docker script permissions --- docker/opencode.Dockerfile | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docker/opencode.Dockerfile b/docker/opencode.Dockerfile index ca16787e960..2c9fd316b51 100644 --- a/docker/opencode.Dockerfile +++ b/docker/opencode.Dockerfile @@ -31,6 +31,11 @@ COPY docker/opencode/entrypoint.sh /usr/local/bin/opencode-entrypoint.sh COPY docker/opencode/git-askpass.sh /usr/local/bin/git-askpass.sh COPY docker/opencode/healthcheck.sh /usr/local/bin/opencode-healthcheck.sh COPY docker/opencode/sync-repos.sh /usr/local/bin/sync-repos.sh +RUN chmod 755 \ + /usr/local/bin/opencode-entrypoint.sh \ + /usr/local/bin/git-askpass.sh \ + /usr/local/bin/opencode-healthcheck.sh \ + /usr/local/bin/sync-repos.sh ENV HOME=/home/opencode ENV OPENCODE_PORT=4096 From 314e41011a482715cabee825bec2fadf7f3fe312 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 16:04:55 +0100 Subject: [PATCH 27/31] fix opencode client reuse and selector refetch --- .../components/combobox/combobox.tsx | 7 ++- .../components/dropdown/dropdown.tsx | 7 ++- apps/sim/lib/opencode/client.test.ts | 52 +++++++++++++++++++ apps/sim/lib/opencode/client.ts | 21 +++++--- 4 files changed, 79 insertions(+), 8 deletions(-) create mode 100644 apps/sim/lib/opencode/client.test.ts diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index c39b31fc920..aef1a4d48d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -129,6 +129,7 @@ export const ComboBox = memo(function ComboBox({ const optionsFetchVersionRef = useRef(0) const isOptionsFetchInFlightRef = useRef(false) const hasAttemptedOptionsFetchRef = useRef(false) + const fetchOptionsIfNeededRef = useRef<((force?: boolean) => Promise) | null>(null) /** * Fetches options from the async fetchOptions function if provided @@ -171,7 +172,7 @@ export const ComboBox = memo(function ComboBox({ isOptionsFetchInFlightRef.current = false if (shouldTriggerReplacementFetch) { - void fetchOptionsIfNeeded(true) + void fetchOptionsIfNeededRef.current?.(true) } else { setIsLoadingOptions(false) } @@ -180,6 +181,10 @@ export const ComboBox = memo(function ComboBox({ [fetchOptions, blockId, subBlockId, isPreview, disabled] ) + useEffect(() => { + fetchOptionsIfNeededRef.current = fetchOptionsIfNeeded + }, [fetchOptionsIfNeeded]) + // Determine the active value based on mode (preview vs. controlled vs. store) const value = isPreview ? previewValue : propValue !== undefined ? propValue : storeValue diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index e45e2e3d4e8..3087dffbf64 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -135,6 +135,7 @@ export const Dropdown = memo(function Dropdown({ const optionsFetchVersionRef = useRef(0) const isOptionsFetchInFlightRef = useRef(false) const hasAttemptedOptionsFetchRef = useRef(false) + const fetchOptionsIfNeededRef = useRef<((force?: boolean) => Promise) | null>(null) const [builderData, setBuilderData] = useSubBlockValue(blockId, 'builderData') const [data, setData] = useSubBlockValue(blockId, 'data') @@ -196,7 +197,7 @@ export const Dropdown = memo(function Dropdown({ isOptionsFetchInFlightRef.current = false if (shouldTriggerReplacementFetch) { - void fetchOptionsIfNeeded(true) + void fetchOptionsIfNeededRef.current?.(true) } else { setIsLoadingOptions(false) } @@ -205,6 +206,10 @@ export const Dropdown = memo(function Dropdown({ [fetchOptions, blockId, subBlockId, isPreview, disabled] ) + useEffect(() => { + fetchOptionsIfNeededRef.current = fetchOptionsIfNeeded + }, [fetchOptionsIfNeeded]) + /** * Handles combobox open state changes to trigger option fetching */ diff --git a/apps/sim/lib/opencode/client.test.ts b/apps/sim/lib/opencode/client.test.ts new file mode 100644 index 00000000000..cee9f4597d9 --- /dev/null +++ b/apps/sim/lib/opencode/client.test.ts @@ -0,0 +1,52 @@ +/** + * @vitest-environment node + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCreateOpencodeClient } = vi.hoisted(() => ({ + mockCreateOpencodeClient: vi.fn(), +})) + +vi.mock('@opencode-ai/sdk', () => ({ + createOpencodeClient: mockCreateOpencodeClient, +})) + +import { createOpenCodeClient, resetOpenCodeClientForTesting } from '@/lib/opencode/client' + +describe('createOpenCodeClient', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.stubEnv('OPENCODE_BASE_URL', 'http://localhost:4096') + vi.stubEnv('OPENCODE_SERVER_USERNAME', 'opencode') + vi.stubEnv('OPENCODE_SERVER_PASSWORD', 'password') + mockCreateOpencodeClient.mockReturnValue({ session: {} }) + resetOpenCodeClientForTesting() + }) + + afterEach(() => { + vi.unstubAllEnvs() + resetOpenCodeClientForTesting() + }) + + it('reuses the same client instance across calls', () => { + const firstClient = createOpenCodeClient() + const secondClient = createOpenCodeClient() + + expect(firstClient).toBe(secondClient) + expect(mockCreateOpencodeClient).toHaveBeenCalledTimes(1) + }) + + it('recreates the client after resetting the cache', () => { + const firstClient = { session: { id: 'first' } } + const secondClient = { session: { id: 'second' } } + mockCreateOpencodeClient.mockReturnValueOnce(firstClient).mockReturnValueOnce(secondClient) + + const initialClient = createOpenCodeClient() + resetOpenCodeClientForTesting() + const recreatedClient = createOpenCodeClient() + + expect(initialClient).toBe(firstClient) + expect(recreatedClient).toBe(secondClient) + expect(mockCreateOpencodeClient).toHaveBeenCalledTimes(2) + }) +}) diff --git a/apps/sim/lib/opencode/client.ts b/apps/sim/lib/opencode/client.ts index 7a999b99149..95dfd174ad4 100644 --- a/apps/sim/lib/opencode/client.ts +++ b/apps/sim/lib/opencode/client.ts @@ -5,6 +5,7 @@ const OPEN_CODE_HOST = 'opencode' const OPEN_CODE_LOCALHOST = '127.0.0.1' const OPEN_CODE_DEFAULT_PORT = '4096' const IS_DOCKER_RUNTIME = existsSync('/.dockerenv') +let cachedOpenCodeClient: ReturnType | null = null function getOpenCodeBasicAuthHeader(): string { const username = process.env.OPENCODE_SERVER_USERNAME @@ -30,10 +31,18 @@ function getOpenCodeBaseUrl(): string { } export function createOpenCodeClient() { - return createOpencodeClient({ - baseUrl: getOpenCodeBaseUrl(), - headers: { - Authorization: getOpenCodeBasicAuthHeader(), - }, - }) + if (!cachedOpenCodeClient) { + cachedOpenCodeClient = createOpencodeClient({ + baseUrl: getOpenCodeBaseUrl(), + headers: { + Authorization: getOpenCodeBasicAuthHeader(), + }, + }) + } + + return cachedOpenCodeClient +} + +export function resetOpenCodeClientForTesting(): void { + cachedOpenCodeClient = null } From 780311eedcf1c81caec7d0902d7ad5c29e62f718 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 16:34:00 +0100 Subject: [PATCH 28/31] fix opencode connectivity error classification --- apps/sim/lib/opencode/errors.test.ts | 13 +++++++++++++ apps/sim/lib/opencode/errors.ts | 16 ++++++++-------- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/apps/sim/lib/opencode/errors.test.ts b/apps/sim/lib/opencode/errors.test.ts index 6ee09b8c581..a7483b3a3df 100644 --- a/apps/sim/lib/opencode/errors.test.ts +++ b/apps/sim/lib/opencode/errors.test.ts @@ -19,4 +19,17 @@ describe('getOpenCodeRouteError', () => { }) expect(error.message).not.toContain('http://opencode:4096') }) + + it('prioritizes connectivity errors over auth substring matches', () => { + const error = getOpenCodeRouteError( + new Error('fetch failed for http://127.0.0.1:4013/session'), + 'repositories' + ) + + expect(error).toEqual({ + status: 503, + message: + 'OpenCode server is unreachable. Check OPENCODE_BASE_URL and the runtime network configuration.', + }) + }) }) diff --git a/apps/sim/lib/opencode/errors.ts b/apps/sim/lib/opencode/errors.ts index 377841ebd3a..88933078e6b 100644 --- a/apps/sim/lib/opencode/errors.ts +++ b/apps/sim/lib/opencode/errors.ts @@ -50,14 +50,6 @@ export function getOpenCodeRouteError(error: unknown, resourceName: string): Ope } } - if (includesAny(normalized, ['401', '403', 'unauthorized', 'forbidden'])) { - return { - status: 502, - message: - 'OpenCode authentication failed. Align OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD with the running OpenCode server.', - } - } - if ( includesAny(normalized, [ 'econnrefused', @@ -75,6 +67,14 @@ export function getOpenCodeRouteError(error: unknown, resourceName: string): Ope } } + if (includesAny(normalized, ['401', '403', 'unauthorized', 'forbidden'])) { + return { + status: 502, + message: + 'OpenCode authentication failed. Align OPENCODE_SERVER_USERNAME and OPENCODE_SERVER_PASSWORD with the running OpenCode server.', + } + } + return { status: 500, message: `Failed to fetch OpenCode ${resourceName}.`, From 7fc5621f544257e741a2b448cc94553a21791b52 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 16:42:13 +0100 Subject: [PATCH 29/31] fix opencode client refresh and selector loading --- .../sub-block/components/combobox/combobox.tsx | 5 +++-- .../sub-block/components/dropdown/dropdown.tsx | 5 +++-- apps/sim/lib/opencode/client.test.ts | 14 ++++++++++++++ apps/sim/lib/opencode/client.ts | 18 +++++++++++++++--- 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx index aef1a4d48d0..29c424cce6b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/combobox/combobox.tsx @@ -170,9 +170,10 @@ export const ComboBox = memo(function ComboBox({ setFetchedOptions([]) } finally { isOptionsFetchInFlightRef.current = false + const replacementFetch = fetchOptionsIfNeededRef.current - if (shouldTriggerReplacementFetch) { - void fetchOptionsIfNeededRef.current?.(true) + if (shouldTriggerReplacementFetch && replacementFetch) { + void replacementFetch(true) } else { setIsLoadingOptions(false) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx index 3087dffbf64..56bf35245ef 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/dropdown/dropdown.tsx @@ -195,9 +195,10 @@ export const Dropdown = memo(function Dropdown({ setFetchedOptions([]) } finally { isOptionsFetchInFlightRef.current = false + const replacementFetch = fetchOptionsIfNeededRef.current - if (shouldTriggerReplacementFetch) { - void fetchOptionsIfNeededRef.current?.(true) + if (shouldTriggerReplacementFetch && replacementFetch) { + void replacementFetch(true) } else { setIsLoadingOptions(false) } diff --git a/apps/sim/lib/opencode/client.test.ts b/apps/sim/lib/opencode/client.test.ts index cee9f4597d9..df4f8fef9bb 100644 --- a/apps/sim/lib/opencode/client.test.ts +++ b/apps/sim/lib/opencode/client.test.ts @@ -49,4 +49,18 @@ describe('createOpenCodeClient', () => { expect(recreatedClient).toBe(secondClient) expect(mockCreateOpencodeClient).toHaveBeenCalledTimes(2) }) + + it('recreates the client when credentials change at runtime', () => { + const firstClient = { session: { id: 'first' } } + const secondClient = { session: { id: 'second' } } + mockCreateOpencodeClient.mockReturnValueOnce(firstClient).mockReturnValueOnce(secondClient) + + const initialClient = createOpenCodeClient() + vi.stubEnv('OPENCODE_SERVER_PASSWORD', 'rotated-password') + const refreshedClient = createOpenCodeClient() + + expect(initialClient).toBe(firstClient) + expect(refreshedClient).toBe(secondClient) + expect(mockCreateOpencodeClient).toHaveBeenCalledTimes(2) + }) }) diff --git a/apps/sim/lib/opencode/client.ts b/apps/sim/lib/opencode/client.ts index 95dfd174ad4..c33bd2d1ac9 100644 --- a/apps/sim/lib/opencode/client.ts +++ b/apps/sim/lib/opencode/client.ts @@ -6,6 +6,7 @@ const OPEN_CODE_LOCALHOST = '127.0.0.1' const OPEN_CODE_DEFAULT_PORT = '4096' const IS_DOCKER_RUNTIME = existsSync('/.dockerenv') let cachedOpenCodeClient: ReturnType | null = null +let cachedOpenCodeClientKey: string | null = null function getOpenCodeBasicAuthHeader(): string { const username = process.env.OPENCODE_SERVER_USERNAME @@ -30,14 +31,24 @@ function getOpenCodeBaseUrl(): string { return `http://${host}:${port}` } +function getOpenCodeClientKey(): string { + return JSON.stringify({ + baseUrl: getOpenCodeBaseUrl(), + authorization: getOpenCodeBasicAuthHeader(), + }) +} + export function createOpenCodeClient() { - if (!cachedOpenCodeClient) { + const clientKey = getOpenCodeClientKey() + + if (!cachedOpenCodeClient || cachedOpenCodeClientKey !== clientKey) { cachedOpenCodeClient = createOpencodeClient({ - baseUrl: getOpenCodeBaseUrl(), + baseUrl: JSON.parse(clientKey).baseUrl, headers: { - Authorization: getOpenCodeBasicAuthHeader(), + Authorization: JSON.parse(clientKey).authorization, }, }) + cachedOpenCodeClientKey = clientKey } return cachedOpenCodeClient @@ -45,4 +56,5 @@ export function createOpenCodeClient() { export function resetOpenCodeClientForTesting(): void { cachedOpenCodeClient = null + cachedOpenCodeClientKey = null } From 2b76ffa224e9ec052e23fb5601f6eb138a6b0b85 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 16:51:31 +0100 Subject: [PATCH 30/31] fix opencode client cleanup and prompt schema naming --- apps/sim/app/api/tools/opencode/prompt/route.ts | 6 +++--- apps/sim/lib/opencode/client.ts | 16 +++++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/sim/app/api/tools/opencode/prompt/route.ts b/apps/sim/app/api/tools/opencode/prompt/route.ts index 1ed34295218..4dba2406051 100644 --- a/apps/sim/app/api/tools/opencode/prompt/route.ts +++ b/apps/sim/app/api/tools/opencode/prompt/route.ts @@ -18,17 +18,17 @@ import { coerceOpenCodeBoolean } from '@/lib/opencode/utils' const logger = createLogger('OpenCodePromptToolAPI') -const optionalTrimmedStringSchema = z.preprocess( +const optionalNullableStringSchema = z.preprocess( (value) => (value === null ? undefined : value), z.string().optional() ) const OpenCodePromptSchema = z.object({ repository: z.string().min(1, 'repository is required'), - systemPrompt: optionalTrimmedStringSchema, + systemPrompt: optionalNullableStringSchema, providerId: z.string().min(1, 'providerId is required'), modelId: z.string().min(1, 'modelId is required'), - agent: optionalTrimmedStringSchema, + agent: optionalNullableStringSchema, prompt: z.string().min(1, 'prompt is required'), newThread: z.union([z.boolean(), z.string()]).optional(), _context: z diff --git a/apps/sim/lib/opencode/client.ts b/apps/sim/lib/opencode/client.ts index c33bd2d1ac9..7b49650aab7 100644 --- a/apps/sim/lib/opencode/client.ts +++ b/apps/sim/lib/opencode/client.ts @@ -32,20 +32,22 @@ function getOpenCodeBaseUrl(): string { } function getOpenCodeClientKey(): string { - return JSON.stringify({ - baseUrl: getOpenCodeBaseUrl(), - authorization: getOpenCodeBasicAuthHeader(), - }) + const baseUrl = getOpenCodeBaseUrl() + const authorization = getOpenCodeBasicAuthHeader() + + return JSON.stringify({ baseUrl, authorization }) } export function createOpenCodeClient() { - const clientKey = getOpenCodeClientKey() + const baseUrl = getOpenCodeBaseUrl() + const authorization = getOpenCodeBasicAuthHeader() + const clientKey = JSON.stringify({ baseUrl, authorization }) if (!cachedOpenCodeClient || cachedOpenCodeClientKey !== clientKey) { cachedOpenCodeClient = createOpencodeClient({ - baseUrl: JSON.parse(clientKey).baseUrl, + baseUrl, headers: { - Authorization: JSON.parse(clientKey).authorization, + Authorization: authorization, }, }) cachedOpenCodeClientKey = clientKey From 13f4b56b8ca9844bbe904de73055e471fc713da5 Mon Sep 17 00:00:00 2001 From: Danigm-dev Date: Thu, 26 Mar 2026 16:56:01 +0100 Subject: [PATCH 31/31] fix opencode client key helper reuse --- apps/sim/lib/opencode/client.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/sim/lib/opencode/client.ts b/apps/sim/lib/opencode/client.ts index 7b49650aab7..5e53b834cb4 100644 --- a/apps/sim/lib/opencode/client.ts +++ b/apps/sim/lib/opencode/client.ts @@ -31,17 +31,14 @@ function getOpenCodeBaseUrl(): string { return `http://${host}:${port}` } -function getOpenCodeClientKey(): string { - const baseUrl = getOpenCodeBaseUrl() - const authorization = getOpenCodeBasicAuthHeader() - +function getOpenCodeClientKey(baseUrl: string, authorization: string): string { return JSON.stringify({ baseUrl, authorization }) } export function createOpenCodeClient() { const baseUrl = getOpenCodeBaseUrl() const authorization = getOpenCodeBasicAuthHeader() - const clientKey = JSON.stringify({ baseUrl, authorization }) + const clientKey = getOpenCodeClientKey(baseUrl, authorization) if (!cachedOpenCodeClient || cachedOpenCodeClientKey !== clientKey) { cachedOpenCodeClient = createOpencodeClient({