diff --git a/.env.example b/.env.example index 1a67889..84dc531 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,6 @@ AI_GATEWAY_API_KEY= AI_GATEWAY_BASE_URL=https://ai-gateway.edgeone.link/v1 -AI_GATEWAY_MODEL=@makers/deepseek-v4-flash +AI_GATEWAY_MODEL=@makers/deepseek-v4.1-flash # Optional. Adds entries to the composer's model picker as `id|Label` pairs, # comma separated. Built-in models are already listed; use this for vendor models # whose key you bound in the console. One gateway key serves every entry, so this diff --git a/agents/_lib/agent.ts b/agents/_lib/agent.ts deleted file mode 100644 index 2b71edc..0000000 --- a/agents/_lib/agent.ts +++ /dev/null @@ -1,790 +0,0 @@ -import { - createSdkMcpServer, - query, - type Query, - type SDKMessage, - type SDKResultMessage, -} from '@anthropic-ai/claude-agent-sdk'; -import { - DEFAULT_PATH, - GATEWAY_CONVERSATION_ID_HEADER_NAME, - GATEWAY_QUOTA_BYPASS_HEADER, - GATEWAY_QUOTA_PROMPT_HEADER, - MAKERS_SKILL_NAMES, - SANDBOX_MCP_SERVER_NAME, -} from './constants.ts'; -import { - describeModelRun, - resolveConfiguredModel, - resolveRunningModelLabel, -} from './models.ts'; -import { wrapSandboxTools } from './tools/commands-wrap.ts'; -import { wrapWebSearchTool } from './tools/web-search-wrap.ts'; -import { - WEB_SEARCH_API_KEY_ENV, - isWebSearchConfigured, - isWebSearchToolName, -} from '../../shared/web-search.ts'; -import { - buildRequestGatewayCredentialsTool, - REQUEST_GATEWAY_CREDENTIALS_TOOL, -} from './project/gateway-prompt.ts'; -import { - buildProjectScaffoldTool, - buildWriteProjectFileTool, -} from './tools/project-tools.ts'; -import { buildLoadMakersSkillTool } from './tools/makers-skills.ts'; -import { buildPrompt, buildTurnPrompt } from './prompt.ts'; -import { resolveMakersProjectName } from './project/makers-deploy.ts'; -import type { - AgentProgressEvent, - CodingAgentResult, - ConversationMessage, - DeploymentInfo, - PreviewKind, - ProjectState, - ScaffoldLog, - StreamSend, -} from './types.ts'; -import { - detectFatalToolError, - sanitizeAssistantText, - truncateForStream, -} from './utils/text.ts'; -import { summarizeToolInput, summarizeToolOutput } from './utils/activity.ts'; -import { - resolveNarrationEmit, - sanitizeNarrationText, - type NarrationEmitState, -} from './utils/narration.ts'; -import { - isInstallCommand, - isMakersDeployCommand, - isPreviewCommand, - parseEchoedExitCode, - shortenToolName, -} from './utils/tool-phase.ts'; - -function pickEnvValue(context: any, key: string) { - const value = context?.env?.[key]; - return typeof value === 'string' ? value.trim() : ''; -} - -function sanitizeHeaderValue(value: string) { - return value.replace(/[\r\n]+/g, ' ').trim(); -} - -function buildAnthropicCustomHeaders(customHeaders: string, conversationId: string) { - const safeConversationId = sanitizeHeaderValue(conversationId); - return [ - customHeaders, - GATEWAY_QUOTA_BYPASS_HEADER, - GATEWAY_QUOTA_PROMPT_HEADER, - safeConversationId - ? `${GATEWAY_CONVERSATION_ID_HEADER_NAME}: ${safeConversationId}` - : '', - ].filter(Boolean).join('\n'); -} - -function extractSandboxCommand(input: unknown) { - const record = input && typeof input === 'object' ? input as Record : {}; - const command = typeof record.command === 'string' - ? record.command - : typeof record.cmd === 'string' - ? record.cmd - : ''; - return command.trim(); -} - -function isBrowserSandboxToolName(name: string) { - return name.toLowerCase().includes('browser'); -} - -function isGenericProjectWriteToolName(name: string) { - const normalized = name.toLowerCase(); - return normalized === 'files_write' - || normalized === 'write_files' - || normalized.endsWith('__files_write') - || normalized.endsWith('__write_files'); -} - -function extractVisibleNarrationDelta(event: SDKMessage) { - if (event.type !== 'stream_event') { - return ''; - } - const streamEvent = (event as any).event; - if (streamEvent?.type !== 'content_block_delta') { - return ''; - } - const delta = streamEvent.delta; - if (delta?.type === 'text_delta' && typeof delta.text === 'string') { - return sanitizeNarrationText(delta.text); - } - return ''; -} - -type StreamingToolUseBlock = { - id: string; - name: string; - inputJson: string; - input?: unknown; -}; - -function isToolUseContentBlock(block: unknown): block is { - type: string; - id?: string; - name?: string; - input?: unknown; -} { - const record = block && typeof block === 'object' - ? block as Record - : {}; - return record.type === 'tool_use' || record.type === 'mcp_tool_use'; -} - -function extractVisibleTextBlock(block: unknown) { - const record = block && typeof block === 'object' - ? block as Record - : {}; - if (record.type !== 'text' || typeof record.text !== 'string') { - return ''; - } - return sanitizeNarrationText(record.text); -} - -function parseToolInputJson(rawJson: string, fallback: unknown) { - if (!rawJson.trim()) { - return fallback ?? {}; - } - try { - return JSON.parse(rawJson); - } catch { - return fallback ?? {}; - } -} - -type ToolProgressPhase = 'scaffold' | 'code' | 'install' | 'preview' | 'link'; - -function inferToolProgress(name: string, input: unknown): { - phaseHint?: ToolProgressPhase; - fileCount?: number; -} { - const toolName = shortenToolName(name); - if (toolName === 'ensure_project_scaffold') { - return { phaseHint: 'scaffold' }; - } - if (toolName === 'files_write' || toolName === 'write_files' || toolName === 'files_make_dir' || toolName === 'files_remove') { - return { phaseHint: 'code' }; - } - if (toolName === 'write_project_file') { - return { phaseHint: 'code', fileCount: 1 }; - } - if (toolName === 'commands') { - const cmd = extractSandboxCommand(input); - if (isInstallCommand(cmd)) { - return { phaseHint: 'install' }; - } - if (isPreviewCommand(cmd) || isMakersDeployCommand(cmd)) { - return { phaseHint: 'preview' }; - } - } - return {}; -} - -export async function runCodingAgent( - context: any, - conversationId: string, - userMessage: string, - history: ConversationMessage[], - state: ProjectState, - isNewProject: boolean, - onScaffoldLog?: (log: ScaffoldLog) => void, - onProgress?: (event: AgentProgressEvent) => void, - // Fires after the scaffold succeeds (no argument) and after every - // write_project_file (with the file just written, so the pipeline can stream - // its content to the frontend instead of making it fetch the file back). - onProjectFilesChanged?: (file?: { path: string; content: string }) => void | Promise, - // Fires as soon as a direct Makers CLI command resolves a public URL so the - // UI can switch to the iframe without waiting for verification / finalize. - onPreviewReady?: (preview: { url?: string; sandboxDebugUrl?: string; kind?: PreviewKind }) => void, - // Deploy is durable product state, not an iframe preview. Stream each state - // transition independently so the UI can show running/success/failure. - onDeploymentStatus?: (deployment: DeploymentInfo) => void, - abortSignal?: AbortSignal, - // An object rather than a twelfth positional argument: the list above is long - // enough that a new slot would be easy to fill in the wrong order at one of - // the two call sites in the chat pipeline. - runOptions: { model?: string; send?: StreamSend } = {}, -): Promise { - // Prefer AI Gateway for model access, with backward-compatible Anthropic / DeepSeek config. - const apiKey = pickEnvValue(context, 'AI_GATEWAY_API_KEY') - || pickEnvValue(context, 'ANTHROPIC_API_KEY') - || pickEnvValue(context, 'DEEPSEEK_API_KEY'); - const authToken = pickEnvValue(context, 'ANTHROPIC_AUTH_TOKEN') - || pickEnvValue(context, 'DEEPSEEK_API_KEY'); - // A model picked in the composer outranks the deployment default. The choice - // was checked against this deployment's catalogue before it got here, so an - // unrecognized ID arrives as '' and the configured model still runs. - const model = (runOptions.model || '').trim() || resolveConfiguredModel(context); - const baseURL = pickEnvValue(context, 'AI_GATEWAY_BASE_URL') - || pickEnvValue(context, 'ANTHROPIC_BASE_URL') - || pickEnvValue(context, 'DEEPSEEK_BASE_URL') - || ''; - const customHeaders = pickEnvValue(context, 'ANTHROPIC_CUSTOM_HEADERS'); - const executablePath = pickEnvValue(context, 'CLAUDE_CODE_EXECUTABLE_PATH'); - - if (!apiKey && !authToken) { - return { - success: false, - output: null, - error: 'Missing AI_GATEWAY_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / DEEPSEEK_API_KEY. The agent cannot call the model.', - projectTouched: false, - filesWritten: false, - wasCreated: false, - }; - } - - if (!baseURL) { - return { - success: false, - output: null, - error: 'Missing AI_GATEWAY_BASE_URL / ANTHROPIC_BASE_URL / DEEPSEEK_BASE_URL. The agent cannot call the model.', - projectTouched: false, - filesWritten: false, - wasCreated: false, - }; - } - - const sdkEnv: Record = { - ANTHROPIC_BASE_URL: baseURL, - ANTHROPIC_MODEL: model, - // @anthropic-ai/sdk injects ANTHROPIC_CUSTOM_HEADERS into each model request. - ANTHROPIC_CUSTOM_HEADERS: buildAnthropicCustomHeaders(customHeaders, conversationId), - PATH: pickEnvValue(context, 'PATH') || DEFAULT_PATH, - HOME: pickEnvValue(context, 'HOME') || '/tmp', - CLAUDE_CONFIG_DIR: pickEnvValue(context, 'CLAUDE_CONFIG_DIR') || '/tmp/.claude', - }; - - if (apiKey) { - sdkEnv.ANTHROPIC_API_KEY = apiKey; - } - if (authToken) { - sdkEnv.ANTHROPIC_AUTH_TOKEN = authToken; - } - if (!sdkEnv.ANTHROPIC_API_KEY && authToken) { - sdkEnv.ANTHROPIC_API_KEY = authToken; - } - - // The tool callbacks below flip these as work lands in the sandbox. They live - // outside the try so the catch path can still report what was touched: a - // stream error after a write must not tell the pipeline "nothing happened", - // or the turn finalizes with withState: false and the files are lost. - let projectTouched = false; - let filesWritten = false; - let previewTouched = false; - let deploymentTouched = false; - let wasCreated = false; - // Held out here so the finally can always detach the listener and stop the - // subprocess, including when the stream throws mid-turn. - const sdkAbortController = new AbortController(); - const abortSdkQuery = () => sdkAbortController.abort(); - abortSignal?.addEventListener('abort', abortSdkQuery, { once: true }); - let sdkQuery: Query | null = null; - - try { - if (abortSignal?.aborted) { - return { - success: false, - output: null, - error: null, - projectTouched: false, - filesWritten: false, - wasCreated: false, - stopped: true, - }; - } - const mcpServerName = SANDBOX_MCP_SERVER_NAME; - const makersProjectName = resolveMakersProjectName(context, state); - if (typeof context.tools?.toClaudeMcpServer !== 'function') { - throw new Error('The current Pages Agent Runtime is missing context.tools.toClaudeMcpServer. Please upgrade to a runtime that supports the new pages-agent-toolkit Tools API.'); - } - const edgeoneMcp = context.tools.toClaudeMcpServer(mcpServerName, { alwaysLoad: true }); - const scaffoldTool = buildProjectScaffoldTool( - context, - state, - onScaffoldLog, - ({ created }) => { - projectTouched = true; - wasCreated = created; - }, - ); - const handlePreviewPublished = (preview: { url?: string; sandboxDebugUrl?: string; kind?: PreviewKind }) => { - previewTouched = true; - if (preview.url) { - onPreviewReady?.(preview); - } - }; - const handleDeploymentStatus = (deployment: DeploymentInfo) => { - deploymentTouched = true; - onDeploymentStatus?.(deployment); - }; - // A tool that cannot serve a single query should not be advertised: the - // model spends a call to learn what the environment already knows. The two - // lists have to agree, so one predicate decides for both. - const webSearchAvailable = isWebSearchConfigured( - pickEnvValue(context, WEB_SEARCH_API_KEY_ENV), - ); - const offerSandboxTool = (name: string) => - !isBrowserSandboxToolName(name) - && !isGenericProjectWriteToolName(name) - && (webSearchAvailable || !isWebSearchToolName(name)); - const sandboxTools = wrapWebSearchTool(wrapSandboxTools( - edgeoneMcp.tools.filter((tool: { name: string }) => offerSandboxTool(tool.name)), - { - context, - state, - conversationId, - send: runOptions.send, - signal: abortSignal, - onPreviewReady: handlePreviewPublished, - onDeploymentStatus: handleDeploymentStatus, - }, - )); - const sandboxAllowedTools = edgeoneMcp.allowedTools.filter(offerSandboxTool); - const writeProjectFileTool = buildWriteProjectFileTool( - context, - state, - async ({ written, content }) => { - projectTouched = true; - filesWritten = true; - await onProjectFilesChanged?.({ path: written, content }); - }, - ); - const loadMakersSkillTool = buildLoadMakersSkillTool(); - const requestGatewayTool = buildRequestGatewayCredentialsTool({ - context, - state, - conversationId, - send: runOptions.send, - }); - const mcpTools = [ - ...sandboxTools, - scaffoldTool, - loadMakersSkillTool, - writeProjectFileTool, - requestGatewayTool, - ]; - const mcpAllowedTools = [ - ...sandboxAllowedTools, - `mcp__${mcpServerName}__ensure_project_scaffold`, - `mcp__${mcpServerName}__load_makers_skill`, - `mcp__${mcpServerName}__write_project_file`, - `mcp__${mcpServerName}__${REQUEST_GATEWAY_CREDENTIALS_TOOL}`, - 'Skill', - ]; - - const sandboxMcpServer = createSdkMcpServer({ - name: mcpServerName, - tools: mcpTools, - alwaysLoad: true, - }); - - const sdkOptions: Parameters[0]['options'] = { - model, - permissionMode: 'dontAsk', - maxTurns: 100, - // Built-in local Read/Write/Bash stay off. Skill is enabled so the model - // can load official Makers skills from .claude/skills/ on demand. - tools: ['Skill'], - skills: [...MAKERS_SKILL_NAMES], - includePartialMessages: true, - mcpServers: { - [mcpServerName]: sandboxMcpServer, - }, - allowedTools: mcpAllowedTools, - strictMcpConfig: true, - // Identical on every turn of a conversation, which is the point: the - // request and the history ride along as the turn's own message below. - systemPrompt: buildPrompt( - state, - isNewProject, - mcpServerName, - makersProjectName, - resolveRunningModelLabel(context, model), - webSearchAvailable, - ), - env: sdkEnv, - cwd: process.cwd(), - settingSources: ['project'], - abortController: sdkAbortController, - // The subprocess writes here only when something is wrong, and the turn - // fails without saying which layer broke. Unconditional: a flag nobody - // set is a log nobody has when it matters. - stderr: (data: string) => { - console.warn('[claude-code]', data.trimEnd()); - }, - }; - - if (executablePath) { - sdkOptions.pathToClaudeCodeExecutable = executablePath; - } - - sdkQuery = query({ - prompt: buildTurnPrompt(userMessage, history), - options: sdkOptions, - }); - - let resultMessage: SDKResultMessage | null = null; - // Sandbox infrastructure failures, such as EdgeOne LazySandbox routes returning - // Not Found, make all later tool calls fail. Retrying only consumes turns and - // pollutes context, so stop this query immediately with a clear upper-layer error. - let fatalError: string | null = null; - // Independently record tool_use_id -> tool context so tool_result events - // can update the correct progress step even when model providers stream - // partial tool inputs differently. - const toolContextById = new Map(); - const toolStartedAtById = new Map(); - const pendingToolUseBlocks = new Map(); - const emittedToolUseProgress = new Map(); - let narrationState: NarrationEmitState = { - currentTextBlock: '', - emittedNarration: '', - }; - const SCAFFOLD_TOOL_NAME = `mcp__${mcpServerName}__ensure_project_scaffold`; - // Push file_tree immediately at most once per turn after scaffold, avoiding duplicate find calls. - let scaffoldHandled = false; - - const emitNarration = (rawText: string, uuid: string, complete = false) => { - const resolved = resolveNarrationEmit(narrationState, rawText, complete); - narrationState = resolved.state; - if (!resolved.text) { - return; - } - onProgress?.({ - type: 'text_segment', - data: { - uuid, - text: resolved.text, - }, - }); - }; - - const emitToolUseProgress = (toolUse: { - id?: string; - name?: string; - input?: unknown; - }) => { - const toolName = typeof toolUse.name === 'string' ? toolUse.name : ''; - const toolUseId = typeof toolUse.id === 'string' ? toolUse.id : ''; - const shortToolName = shortenToolName(toolName); - const command = shortToolName === 'commands' ? extractSandboxCommand(toolUse.input) : ''; - const progress = typeof toolUse.name === 'string' - ? inferToolProgress(toolName, toolUse.input) - : {}; - const inputSummary = summarizeToolInput(toolName, toolUse.input, state.appDir); - const progressSignature = JSON.stringify({ - name: toolName, - command, - phaseHint: progress.phaseHint || '', - fileCount: progress.fileCount || 0, - inputSummary, - }); - if (toolUseId) { - const previousSignature = emittedToolUseProgress.get(toolUseId); - if (previousSignature === progressSignature) { - return; - } - emittedToolUseProgress.set(toolUseId, progressSignature); - } - // Tool calls end the current narration block. Clear the per-block window so - // the next assistant text is not compared against the previous sentence. - narrationState = { - ...narrationState, - currentTextBlock: '', - }; - - if (toolUseId && typeof toolUse.name === 'string') { - toolContextById.set(toolUseId, { - name: toolUse.name, - ...(command ? { command } : {}), - }); - } - const startedAt = toolUseId - ? toolStartedAtById.get(toolUseId) || Date.now() - : Date.now(); - if (toolUseId) toolStartedAtById.set(toolUseId, startedAt); - onProgress?.({ - type: 'tool_use', - data: { - id: toolUseId, - name: toolName, - ...(command ? { command } : {}), - ...progress, - inputSummary, - startedAt, - }, - }); - }; - - for await (const event of sdkQuery as AsyncIterable) { - if (abortSignal?.aborted) { - sdkAbortController.abort(); - break; - } - // Forward structured tool progress and high-level model narration. Tool - // input JSON and non-text stream deltas stay out of the UI. - if (event.type === 'stream_event') { - emitNarration( - extractVisibleNarrationDelta(event), - typeof event.uuid === 'string' ? event.uuid : '', - false, - ); - const streamEvent = (event as any).event; - if (streamEvent?.type === 'content_block_start') { - const contentBlock = streamEvent.content_block; - // Each new text block starts a fresh dedupe window so earlier narration - // cannot suppress later phrases that share a common suffix/substring. - if (contentBlock?.type === 'text') { - narrationState = { - ...narrationState, - currentTextBlock: '', - }; - } - if (isToolUseContentBlock(contentBlock) && typeof streamEvent.index === 'number') { - pendingToolUseBlocks.set(streamEvent.index, { - id: typeof contentBlock.id === 'string' ? contentBlock.id : '', - name: typeof contentBlock.name === 'string' ? contentBlock.name : '', - inputJson: '', - input: contentBlock.input, - }); - emitToolUseProgress({ - id: contentBlock.id, - name: contentBlock.name, - input: contentBlock.input, - }); - } - } else if (streamEvent?.type === 'content_block_delta') { - const delta = streamEvent.delta; - const pendingToolUse = typeof streamEvent.index === 'number' - ? pendingToolUseBlocks.get(streamEvent.index) - : undefined; - if ( - pendingToolUse - && delta?.type === 'input_json_delta' - && typeof delta.partial_json === 'string' - ) { - pendingToolUse.inputJson += delta.partial_json; - } - } else if (streamEvent?.type === 'content_block_stop') { - const pendingToolUse = typeof streamEvent.index === 'number' - ? pendingToolUseBlocks.get(streamEvent.index) - : undefined; - if (pendingToolUse) { - pendingToolUseBlocks.delete(streamEvent.index); - emitToolUseProgress({ - id: pendingToolUse.id, - name: pendingToolUse.name, - input: parseToolInputJson(pendingToolUse.inputJson, pendingToolUse.input), - }); - } - } - } else if (event.type === 'assistant') { - const blocks = (event as any).message?.content; - if (Array.isArray(blocks)) { - for (const b of blocks) { - emitNarration( - extractVisibleTextBlock(b), - typeof event.uuid === 'string' ? event.uuid : '', - true, - ); - if (isToolUseContentBlock(b)) { - emitToolUseProgress({ - id: b.id, - name: b.name, - input: b.input, - }); - } - } - } - } else if (event.type === 'user') { - const blocks = (event as any).message?.content; - if (Array.isArray(blocks)) { - for (const b of blocks) { - if (b?.type === 'tool_result') { - const text = Array.isArray(b.content) - ? b.content.map((c: any) => (typeof c?.text === 'string' ? c.text : '')).join(' ') - : (typeof b.content === 'string' ? b.content : ''); - const toolContext = toolContextById.get(b.tool_use_id); - const toolName = toolContext?.name || ''; - const echoedExit = parseEchoedExitCode(text); - const commandFailed = typeof echoedExit === 'number' && echoedExit !== 0; - const toolFailed = b.is_error === true || commandFailed; - onProgress?.({ - type: 'tool_result', - data: { - tool_use_id: typeof b.tool_use_id === 'string' ? b.tool_use_id : '', - toolName, - ...(toolContext?.command ? { command: toolContext.command } : {}), - ok: !toolFailed, - preview: truncateForStream(text, 500), - outputSummary: summarizeToolOutput(text, state.appDir, toolName), - status: toolFailed ? 'failed' : 'completed', - endedAt: Date.now(), - }, - }); - // Once ensure_project_scaffold succeeds, notify the outer pipeline to - // push file_tree so the Files panel does not wait for the whole runCodingAgent turn. - if ( - !scaffoldHandled - && toolName === SCAFFOLD_TOOL_NAME - && b.is_error !== true - ) { - scaffoldHandled = true; - try { - await onProjectFilesChanged?.(); - } catch (err) { - console.warn('[scaffold-done] onProjectFilesChanged failed', err); - } - } - // Detect sandbox infrastructure failures only on is_error=true tool - // results, avoiding false positives from normal text containing "Not Found". - if (b.is_error === true && !fatalError) { - const fatal = detectFatalToolError(text); - if (fatal) { - fatalError = `${fatal} (tool=${toolName})`; - console.warn('[fatal] aborting agent loop:', fatalError); - } - } - } - } - } - } - if (event.type === 'result') { - resultMessage = event; - break; - } - // Exit the loop immediately after a fatal error instead of waiting for more model turns. - if (fatalError) { - break; - } - } - - if (abortSignal?.aborted || sdkAbortController.signal.aborted) { - return { - success: false, - output: null, - error: null, - projectTouched, - filesWritten, - previewTouched, - deploymentTouched, - wasCreated, - stopped: true, - }; - } - - // Fatal errors take priority over normal results, even if the SDK produced - // a result for this turn. - if (fatalError) { - return { - success: false, - output: null, - error: fatalError, - projectTouched, - filesWritten, - previewTouched, - deploymentTouched, - wasCreated, - fatal: true, - }; - } - - if (!resultMessage) { - return { - success: false, - output: null, - error: 'The model stream ended without returning a result.', - projectTouched, - filesWritten, - previewTouched, - deploymentTouched, - wasCreated, - }; - } - - // Whether the composer's model switch took effect is otherwise - // unobservable: asking the agent returns the priors of whichever model is - // answering, not this run. Logged for every finished run, failed included, - // because a substitution is a reason a run fails. - const modelRun = describeModelRun(model, resultMessage.modelUsage); - if (modelRun.mismatch) { - console.warn('[model]', `${modelRun.line} — the gateway served a model this turn did not request`); - } else { - console.info('[model]', modelRun.line); - } - - if (resultMessage.subtype !== 'success') { - return { - success: false, - output: null, - error: Array.isArray(resultMessage.errors) && resultMessage.errors.length > 0 - ? resultMessage.errors[0] - : 'Model execution failed.', - projectTouched, - filesWritten, - previewTouched, - deploymentTouched, - wasCreated, - }; - } - - return { - success: true, - output: sanitizeAssistantText((resultMessage.result || '').trim()), - error: null, - projectTouched, - filesWritten, - previewTouched, - deploymentTouched, - wasCreated, - }; - } catch(e) { - if (abortSignal?.aborted || (e instanceof Error && e.name === 'AbortError')) { - return { - success: false, - output: null, - error: null, - projectTouched, - filesWritten, - previewTouched, - deploymentTouched, - wasCreated, - stopped: true, - }; - } - console.error(e); - const message = e instanceof Error ? e.message : String(e); - const fatal = detectFatalToolError(message); - return { - success: false, - output: null, - error: fatal || message || 'Execution failed.', - projectTouched, - filesWritten, - previewTouched, - deploymentTouched, - wasCreated, - ...(fatal ? { fatal: true } : {}), - }; - } finally { - abortSignal?.removeEventListener('abort', abortSdkQuery); - // Terminate the CLI subprocess and its MCP transports on every exit path. - // Breaking the loop on a fatal error leaves them running otherwise, which - // keeps consuming turns and billing after the response is already sent. - try { - sdkQuery?.close(); - } catch (err) { - console.warn('[agent] failed to close the SDK query', err); - } - } -} diff --git a/shared/makers-deploy.ts b/agents/_lib/makers/cli-deploy.ts similarity index 97% rename from shared/makers-deploy.ts rename to agents/_lib/makers/cli-deploy.ts index 688bc68..af15f9d 100644 --- a/shared/makers-deploy.ts +++ b/agents/_lib/makers/cli-deploy.ts @@ -3,12 +3,15 @@ * Keep this free of sandbox / React imports so tests and the frontend can share it. */ -import { buildMakersDevStopScript } from './makers-dev.ts'; +import { buildMakersDevStopScript } from './cli-dev.ts'; import { buildNpmCacheReclaimScript } from './npm-install.ts'; -import type { DeploymentInfo } from './protocol.ts'; -import { shellQuote } from './shell.ts'; +import type { DeploymentInfo } from '../../../shared/protocol.ts'; +import { isMakersDeployUrl } from '../../../shared/makers-url.ts'; +import { shellQuote } from '../utils/shell.ts'; import { MAKERS_CLI_UNAVAILABLE_MESSAGE, isEdgeoneCliUnavailable } from './tool-phase.ts'; +export { isMakersDeployUrl }; + /** * Printed by the deploy command when the CLI reported a successful publish that * carried no client build. @@ -650,21 +653,6 @@ export function describeMakersDeployment( }; } -export function isMakersDeployUrl(url?: string | null): boolean { - if (!url) return false; - try { - const parsed = new URL(url); - if (parsed.pathname === '/preview/' || parsed.pathname.startsWith('/preview/')) { - return false; - } - return /(?:^|\.)edgeone\.(?:cool|ai|link)$/i.test(parsed.hostname) - || /(?:^|\.)pages\.edgeone\./i.test(parsed.hostname) - || /(?:^|\.)edgeone\.page$/i.test(parsed.hostname); - } catch { - return false; - } -} - export function redactSecret(text: string, secret: string) { if (!secret) return text; return text.split(secret).join('[redacted]'); diff --git a/shared/makers-dev.ts b/agents/_lib/makers/cli-dev.ts similarity index 66% rename from shared/makers-dev.ts rename to agents/_lib/makers/cli-dev.ts index 44c6830..ab6fa57 100644 --- a/shared/makers-dev.ts +++ b/agents/_lib/makers/cli-dev.ts @@ -9,7 +9,8 @@ import { createHash } from 'node:crypto'; import { buildNpmWarmupWaitScript } from './npm-install.ts'; -import { shellQuote } from './shell.ts'; +import { buildPreviewProxyTemplate, PROXY_REVISION_PLACEHOLDER } from './preview-proxy-source.ts'; +import { shellQuote } from '../utils/shell.ts'; export const PREVIEW_PROXY_SCRIPT_PATH = '/tmp/edgeone-preview-proxy.cjs'; @@ -158,8 +159,6 @@ export function buildMakersDevStopScript(makersPort: number, announce = '') { ].join('\n'); } -const PROXY_REVISION_PLACEHOLDER = '__PREVIEW_PROXY_REVISION__'; - function normalizePreviewPrefix(value: string) { const normalized = `/${value}`.replace(/\/+/g, '/').replace(/\/+$/, ''); return normalized === '/' ? '' : normalized; @@ -413,457 +412,6 @@ function buildPreviewProxy(listenPort: number, targetPort: number, prefix: strin return { script: template.replace(PROXY_REVISION_PLACEHOLDER, revision), revision }; } -function buildPreviewProxyTemplate( - listenPort: number, - targetPort: number, - prefix: string, -) { - const normalizedPrefix = normalizePreviewPrefix(prefix); - return `const http = require('node:http'); -const net = require('node:net'); - -const LISTEN_PORT = ${listenPort}; -const TARGET_PORT = ${targetPort}; -const PREFIX = ${JSON.stringify(normalizedPrefix)}; -const HEALTH_PATH = '/__edgeone_preview_proxy_health'; - -// Set once the upstream asks to be addressed with the prefix — see -// previewUpstreamClaimsPrefix in shared/makers-dev.ts. Until then the prefix is -// stripped, which is what makers dev and every framework with an asset-only -// prefix knob expect. -let prefixAware = false; - -// The 404-shaped form of the same claim, tried once per proxy — see -// previewPrefixProbe. Once, because an upstream that really does serve at the -// root answers a genuinely missing page with a 404 too, and re-asking on every -// one of those would double the requests to re-learn what the first probe -// already established. -let prefixProbed = false; - -function rewritePath(url) { - if (!url) return '/'; - if (prefixAware) return url; - if ( - PREFIX - && (url === PREFIX || url.startsWith(PREFIX + '/') || url.startsWith(PREFIX + '?')) - ) { - const next = url.slice(PREFIX.length); - if (!next || next === '/') return '/'; - return next.startsWith('/') ? next : '/' + next; - } - return url; -} - -// Mirrors previewCanonicalRedirect in shared/makers-dev.ts. -function canonicalRedirect(url) { - if (!PREFIX || !url) return null; - const queryStart = url.indexOf('?'); - const path = queryStart === -1 ? url : url.slice(0, queryStart); - if (path !== PREFIX) return null; - return PREFIX + '/' + (queryStart === -1 ? '' : url.slice(queryStart)); -} - -function rewriteLocation(value) { - if (!PREFIX || typeof value !== 'string' || !value.startsWith('/')) return value; - if (value === PREFIX || value.startsWith(PREFIX + '/')) return value; - return PREFIX + value; -} - -// Mirrors previewUpstreamClaimsPrefix in shared/makers-dev.ts. -function claimsPrefix(statusCode, location) { - if (!PREFIX) return false; - if (!statusCode || statusCode < 300 || statusCode >= 400) return false; - if (typeof location !== 'string' || !location) return false; - let path = location; - const schemeEnd = location.indexOf('://'); - if (schemeEnd !== -1) { - const afterHost = location.indexOf('/', schemeEnd + 3); - path = afterHost === -1 ? '/' : location.slice(afterHost); - } else if (location.charAt(0) !== '/') { - return false; - } - path = path.split('?')[0]; - return path === PREFIX || path.indexOf(PREFIX + '/') === 0; -} - -// Mirrors previewPrefixProbe in shared/makers-dev.ts. -function prefixProbe(requestUrl, forwardedPath, statusCode) { - if (!PREFIX || !requestUrl || !forwardedPath) return null; - if (statusCode !== 404) return null; - if (forwardedPath === requestUrl) return null; - const probePath = requestUrl.split('?')[0]; - if (probePath !== PREFIX && probePath.indexOf(PREFIX + '/') !== 0) return null; - return requestUrl; -} - -// Mirrors previewTrailingSlashFollow in shared/makers-dev.ts. -function trailingSlashFollow(forwardedPath, statusCode, location) { - if (!forwardedPath) return null; - if (!statusCode || statusCode < 300 || statusCode >= 400) return null; - if (typeof location !== 'string' || location.charAt(0) !== '/') return null; - const from = forwardedPath.split('?')[0]; - const to = location.split('?')[0]; - if (from === to) return null; - return withoutTrailingSlash(from) === withoutTrailingSlash(to) ? location : null; -} - -function withoutTrailingSlash(value) { - return value.length > 1 && value.charAt(value.length - 1) === '/' - ? value.slice(0, -1) - : value; -} - -function rewriteSetCookie(value) { - if (!PREFIX || typeof value !== 'string') return value; - return value.replace(/;\\s*Path=\\//gi, '; Path=' + PREFIX + '/'); -} - -// makers dev reaches its function runtime through http-proxy with xfwd -// enabled, and xfwd APPENDS to x-forwarded-proto rather than replacing it. A -// value from the sandbox gateway therefore arrives at the runtime as -// "http,http", which it concatenates into a request URL and hands to new -// Request(): ERR_INVALID_URL. The failure is then swallowed by an error -// handler that throws on its own, so nothing ever writes a response and the -// browser spins until the user gives up. Dropping the header here leaves xfwd -// setting the single value it would have set anyway, which is also what makes -// a direct curl to the CLI work today. -// The parent workspace frames this preview cross-origin, so it cannot read the -// iframe's location to fill its address bar. Nothing else can report the route -// either: makers dev serves the application, and a proxy is the only layer left -// that sees every document. This posts the real path — prefix included, which is -// what the parent strips for display and reuses when deep-linking a copied URL. -const TRACKER = ''; - -// Scanned as latin1 so one character is one byte and the match offset can index -// the buffer directly. -// -// is only the preferred landing place. A hand-written index.html may not -// have one, and the script above is now what makes in-app navigation work, so -// skipping those pages would leave exactly the simplest generated sites broken. -// The fallbacks stay below the doctype: above it the page drops into quirks -// mode, which changes how the whole document lays out. -function headInsertionPoint(buffer) { - const text = buffer.toString('latin1'); - for (const pattern of [/]*>/i, /]*>/i, /]*>/i]) { - const match = pattern.exec(text); - if (match) return match.index + match[0].length; - } - return -1; -} - -function acceptsHtml(req) { - const accept = req.headers.accept; - return typeof accept === 'string' && accept.includes('text/html'); -} - -function isHtmlResponse(headers) { - const type = headers['content-type']; - return typeof type === 'string' && type.toLowerCase().includes('text/html'); -} - -function forwardHeaders(req) { - const headers = { - ...req.headers, - host: '127.0.0.1:' + TARGET_PORT, - 'x-forwarded-prefix': PREFIX, - }; - delete headers['x-forwarded-proto']; - // TRACKER can only be spliced into an unencoded body. Asking for identity on - // navigations alone costs nothing on a loopback hop and leaves compression in - // place for the assets, which are what the encoding is actually worth. - if (acceptsHtml(req)) headers['accept-encoding'] = 'identity'; - return headers; -} - -// Buffer only up to the opening , then release and stream the rest -// untouched: a page that streams its body from a Suspense boundary has to keep -// arriving in pieces, and the shell carrying is already in the first one. -function injectTracker(upstream, res) { - const SCAN_LIMIT = 65536; - let pending = []; - let scanned = 0; - let injected = false; - - function splice(buffer) { - const at = headInsertionPoint(buffer); - // No to splice after. Prepending would land the script above the - // doctype and drop the page into quirks mode, so leave the body alone and - // let the address bar stay where it is. - if (at === -1) { - res.write(buffer); - return; - } - res.write(buffer.subarray(0, at)); - res.write(TRACKER); - res.write(buffer.subarray(at)); - } - - upstream.on('data', (chunk) => { - if (injected) { - res.write(chunk); - return; - } - pending.push(chunk); - scanned += chunk.length; - const buffer = Buffer.concat(pending); - if (headInsertionPoint(buffer) === -1 && scanned <= SCAN_LIMIT) return; - injected = true; - pending = []; - splice(buffer); - }); - upstream.on('end', () => { - if (!injected && pending.length) splice(Buffer.concat(pending)); - res.end(); - }); - upstream.on('error', () => res.end()); -} - -const server = http.createServer((req, res) => { - if ((req.url || '').split('?')[0] === HEALTH_PATH) { - res.writeHead(200, { - 'content-type': 'text/plain', - 'x-edgeone-preview-proxy': '${PROXY_REVISION_PLACEHOLDER}', - }); - res.end('ok'); - return; - } - - const canonical = canonicalRedirect(req.url); - if (canonical) { - res.writeHead(308, { location: canonical }); - res.end(); - return; - } - - forward(req, res, rewritePath(req.url), true, true, false); -}); - -function forward(req, res, path, mayRetry, mayFollow, probing) { - const headers = forwardHeaders(req); - const proxy = http.request({ - hostname: '127.0.0.1', - port: TARGET_PORT, - path, - method: req.method, - headers, - }, (upstream) => { - // The probe's answer, which is the half of previewPrefixProbe that decides. - // Anything but a second 404 means the prefixed path is a route the upstream - // knows, so it keeps the prefix from here on. Read before the branches - // below so a probe answered with a redirect still counts as knowing it. - if (probing && upstream.statusCode !== 404) prefixAware = true; - // The one response that means the prefix should not have been stripped. - // Retried rather than passed on, because handing the browser a redirect to - // a path this proxy still strips is the same request again: it would bounce - // between the two until the browser gave up. - if ( - mayRetry - && !prefixAware - && (req.method === 'GET' || req.method === 'HEAD') - && claimsPrefix(upstream.statusCode, upstream.headers.location) - ) { - prefixAware = true; - upstream.resume(); - forward(req, res, req.url || '/', false, true, false); - return; - } - // The same claim made as a 404, which is how Astro states it. Asked rather - // than concluded: the retry's status is what tells a base-mounted app apart - // from a page that is simply not there. - if ( - mayRetry - && !prefixAware - && !prefixProbed - && (req.method === 'GET' || req.method === 'HEAD') - && prefixProbe(req.url, path, upstream.statusCode) - ) { - prefixProbed = true; - upstream.resume(); - forward(req, res, req.url, false, true, true); - return; - } - // A redirect that only normalizes a trailing slash, settled here for the - // same reason — see previewTrailingSlashFollow. Once, and never from a - // follow of its own: an upstream that keeps normalizing is a loop this - // proxy would be holding open instead of the browser. - if (mayFollow && (req.method === 'GET' || req.method === 'HEAD')) { - const follow = trailingSlashFollow( - path, - upstream.statusCode, - upstream.headers.location, - ); - if (follow) { - upstream.resume(); - forward(req, res, follow, false, false, false); - return; - } - } - const responseHeaders = { ...upstream.headers }; - if (responseHeaders.location) { - responseHeaders.location = rewriteLocation(responseHeaders.location); - } - if (Array.isArray(responseHeaders['set-cookie'])) { - responseHeaders['set-cookie'] = responseHeaders['set-cookie'].map(rewriteSetCookie); - } - const injectable = isHtmlResponse(responseHeaders) - && !responseHeaders['content-encoding']; - // The body grows by TRACKER, so the declared length no longer holds. - // Dropping it hands the response to chunked encoding. - if (injectable) delete responseHeaders['content-length']; - res.writeHead(upstream.statusCode || 502, responseHeaders); - if (injectable) injectTracker(upstream, res); - else upstream.pipe(res); - }); - proxy.on('error', () => { - if (!res.headersSent) res.writeHead(502); - res.end('preview proxy error'); - }); - // Neither a retry nor a follow has a body left to send: both are reached - // only for a GET or a HEAD, and the request stream is already consumed. - if (mayRetry) req.pipe(proxy); - else proxy.end(); -} - -server.on('upgrade', (req, socket, head) => { - const path = rewritePath(req.url); - const headers = forwardHeaders(req); - const target = net.connect(TARGET_PORT, '127.0.0.1', () => { - const headerLines = Object.entries(headers).flatMap(([key, value]) => { - if (value == null) return []; - return [key + ': ' + (Array.isArray(value) ? value.join(', ') : value)]; - }); - target.write([ - (req.method || 'GET') + ' ' + path + ' HTTP/1.1', - ...headerLines, - '', - '', - ].join('\\r\\n')); - if (head && head.length) target.write(head); - target.pipe(socket); - socket.pipe(target); - }); - target.on('error', () => socket.destroy()); - socket.on('error', () => target.destroy()); -}); - -server.listen(LISTEN_PORT, '0.0.0.0'); -`; -} - export type MakersDevBackgroundOptions = { makersPort: number; previewPort: number; diff --git a/agents/_lib/project/makers-compat.ts b/agents/_lib/makers/compat/lint-script.ts similarity index 58% rename from agents/_lib/project/makers-compat.ts rename to agents/_lib/makers/compat/lint-script.ts index 030c9c8..d730a75 100644 --- a/agents/_lib/project/makers-compat.ts +++ b/agents/_lib/makers/compat/lint-script.ts @@ -1,251 +1,4 @@ -import { createHash } from 'node:crypto'; -import { readFile } from 'node:fs/promises'; -import path from 'node:path'; -import type { ProjectState } from '../types.ts'; -import { runCommandCapturingExit } from './commands.ts'; - -export type MakersValidationRule = { - skill: string; - pathPatterns: string[]; - pattern: string; - message: string; -}; - -/** - * Where a framework's platform adapter goes and whether this project needs one. - * - * `serverOutput` is deliberately separate from `adapter`: the file that decides - * whether the app renders on a server is often not the file the adapter is - * wired into. React Router declares `ssr` in react-router.config.ts and takes - * its adapter as a vite.config.ts plugin, so one field cannot serve both. - */ -export type MakersFrameworkProfile = { - id: string; - label: string; - detect: string[]; - adapter: { - package: string; - /** - * The range to declare when this agent adds the adapter itself. Optional - * because it is the platform's to state, not this repo's: absent, the - * declaration falls back to `latest`, which resolves but pins nothing. - */ - version?: string; - configFiles: string[]; - /** - * A config file that, in a shape only it can be in, silences the others. - * - * SvelteKit is why this exists. It reads exactly one config and prefers the - * Vite one, so a single option passed to `sveltekit()` discards the whole of - * a sibling `svelte.config.js` — adapter included, with no warning from the - * build. Checking the first config file that happens to exist calls that - * project correct; checking `vite.config.ts` unconditionally calls a project - * that legitimately keeps its config in `svelte.config.js` broken. - */ - configOverride?: { - files: string[]; - pattern: string; - /** Appended to the error, because "wire it in here" is baffling on its own. */ - reason: string; - }; - required: 'always' | 'server-output'; - } | null; - serverOutput?: { - files: string[]; - default: 'server' | 'static'; - serverPattern?: string; - staticPattern?: string; - }; - outputDirectory: string; - unsupported: string[]; -}; - -const FRAMEWORK_PROFILE_SKILL = 'makers-frameworks'; - -// The profiles live in a fenced block inside the vendored skill rather than in -// its frontmatter: they are nested objects, and the frontmatter reader here is a -// line-oriented approximation of YAML that cannot represent them. -const FRAMEWORK_PROFILE_BLOCK = - /\s*```json\s*\r?\n([\s\S]*?)\r?\n```/; - -export function parseMakersFrameworkProfiles(source: string): MakersFrameworkProfile[] { - const block = source.match(FRAMEWORK_PROFILE_BLOCK)?.[1]; - if (!block) return []; - const parsed = JSON.parse(block) as MakersFrameworkProfile[]; - if (!Array.isArray(parsed)) { - throw new Error('makers-framework-profiles must be a JSON array'); - } - for (const profile of parsed) { - if (!profile.id || !Array.isArray(profile.detect) || profile.detect.length === 0) { - throw new Error(`framework profile ${profile.id || '(unnamed)'} needs an id and a detect list`); - } - // Compile every pattern at load time so a malformed vendored profile fails - // here, where the message names the profile, rather than inside the sandbox - // script as a syntax error with no attribution. - for (const pattern of [ - profile.serverOutput?.serverPattern, - profile.serverOutput?.staticPattern, - profile.adapter?.configOverride?.pattern, - ]) { - if (pattern) new RegExp(pattern); - } - } - return parsed; -} - -const VALIDATION_SKILLS = [ - 'makers-agents', - 'makers-cloud-functions', - 'makers-deploy', - 'makers-edge-functions', - 'makers-env-adaption', - 'makers-frameworks', - 'makers-middleware', - 'makers-storage', -] as const; - -export const SUPPORTED_MAKERS_AGENT_FRAMEWORKS = [ - 'claude-agent-sdk', - 'openai-agents-sdk', - 'langgraph', - 'crewai', - 'deepagents', -] as const; - -function parseFrontmatterScalar(value: string) { - const trimmed = value.trim(); - if (trimmed.startsWith('"') && trimmed.endsWith('"')) { - // A double-quoted YAML scalar is close enough to JSON to reuse the parser, - // but not close enough to trust it: one stray backslash in a vendored skill - // would otherwise take down every compatibility check with a SyntaxError. - try { - return JSON.parse(trimmed) as string; - } catch { - return trimmed.slice(1, -1); - } - } - if (trimmed.startsWith("'") && trimmed.endsWith("'")) { - return trimmed.slice(1, -1).replaceAll("''", "'"); - } - return trimmed; -} - -export function parseMakersSkillValidationRules( - skill: string, - source: string, -): MakersValidationRule[] { - const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1]; - if (!frontmatter) return []; - - const pathPatterns: string[] = []; - const rules: Array<{ pattern: string; message: string }> = []; - let section: 'paths' | 'validate' | null = null; - let pendingPattern = ''; - - for (const line of frontmatter.split(/\r?\n/)) { - if (line === 'pathPatterns:') { - section = 'paths'; - continue; - } - if (line === 'validate:') { - section = 'validate'; - continue; - } - if (/^\S/.test(line)) { - section = null; - continue; - } - if (section === 'paths') { - const match = line.match(/^\s{2}-\s+(.+)$/); - if (match?.[1]) pathPatterns.push(parseFrontmatterScalar(match[1])); - continue; - } - if (section === 'validate') { - const patternMatch = line.match(/^\s{2}-\s+pattern:\s+(.+)$/); - if (patternMatch?.[1]) { - pendingPattern = parseFrontmatterScalar(patternMatch[1]); - continue; - } - const messageMatch = line.match(/^\s{4}message:\s+(.+)$/); - if (messageMatch?.[1] && pendingPattern) { - rules.push({ - pattern: pendingPattern, - message: parseFrontmatterScalar(messageMatch[1]), - }); - pendingPattern = ''; - } - } - } - - return rules.map((rule) => ({ - skill, - pathPatterns: [...pathPatterns], - ...rule, - })); -} - -function readVendoredSkill(skill: string) { - return readFile( - path.join( - process.cwd(), - '.claude', - 'skills', - 'edgeone-makers-tools', - 'references', - skill, - 'SKILL.md', - ), - 'utf8', - ); -} - -let frameworkProfilesPromise: Promise | undefined; - -export function loadMakersFrameworkProfiles(): Promise { - if (!frameworkProfilesPromise) { - const pending = readVendoredSkill(FRAMEWORK_PROFILE_SKILL).then((source) => { - const profiles = parseMakersFrameworkProfiles(source); - if (profiles.length === 0) { - throw new Error( - `${FRAMEWORK_PROFILE_SKILL} carries no framework profiles; the adapter check cannot run without them`, - ); - } - return profiles; - }); - frameworkProfilesPromise = pending.catch((error) => { - frameworkProfilesPromise = undefined; - throw error; - }); - } - return frameworkProfilesPromise; -} - -let validationRulesPromise: Promise | undefined; - -export function loadMakersValidationRules(): Promise { - if (!validationRulesPromise) { - const pending = Promise.all(VALIDATION_SKILLS.map(async (skill) => { - const source = await readVendoredSkill(skill); - return parseMakersSkillValidationRules(skill, source); - })).then((groups) => { - const rules = groups.flat(); - for (const rule of rules) { - // Fail at the source if an official vendored rule is malformed instead - // of silently dropping a compatibility check. - new RegExp(rule.pattern); - } - return rules; - }); - // Cache the success, not the attempt. Holding a rejected promise here would - // turn one unlucky read into a permanently broken compatibility check for - // every later turn on this warm instance. - validationRulesPromise = pending.catch((error) => { - validationRulesPromise = undefined; - throw error; - }); - } - return validationRulesPromise; -} +import { SUPPORTED_MAKERS_AGENT_FRAMEWORKS, type MakersFrameworkProfile, type MakersValidationRule } from './skill-rules.ts'; export function buildMakersCompatibilityScript( sourceRules: readonly MakersValidationRule[], @@ -706,115 +459,3 @@ process.stdout.write( ); `}`; } - -const COMPAT_SCRIPT_NAME = '.makers-compat-check.cjs'; - -/** - * Which script body each session already has on disk. - * - * The body is derived from the vendored skills, which are read once per - * process, so it is identical for every run of a session — and the lint runs at - * least twice a turn, once before the preview starts and once at verification. - * Re-sending it each time was a sandbox write buying nothing. - * - * Keyed by session because sessions do not share a sandbox, and paired with the - * retry below because a cache that outlives the file it describes is worse than - * no cache at all. - */ -const uploadedCompatScripts = new Map(); - -function compatScriptFingerprint(script: string) { - return createHash('sha256').update(script).digest('hex'); -} - -/** A sandbox recycled under us: the file is gone, so the lint never ran. */ -function compatScriptMissing(result: { stdout?: string; stderr?: string }) { - const output = `${result.stdout || ''}\n${result.stderr || ''}`; - return output.includes('MODULE_NOT_FOUND') - || (output.includes('Cannot find module') && output.includes(COMPAT_SCRIPT_NAME)); -} - -/** - * Run the lint so that a failure still arrives as a report. - * - * Exiting non-zero is how the lint says it found something, and it is also what - * throws away everything it found: the sandbox layer turns a failed shell into - * SANDBOX_UNKNOWN_ERROR and keeps neither stdout nor stderr, so the caller is - * handed "exit status 2" and nothing else. That reads like a broken sandbox - * rather than a project to fix — the run that prompted this spent one turn - * checking the CLI version and another guessing at a file before it landed on - * the one the lint had already named. Echoing the status keeps the shell - * successful, which is what lets the report travel as text. - */ -export function buildMakersCompatibilityCommand() { - return [ - 'set +e', - `node ../${COMPAT_SCRIPT_NAME}`, - 'echo EXIT:$?', - ].join('\n'); -} - -export async function runMakersCompatibilityCheck( - context: any, - state: ProjectState, -) { - const [rules, profiles] = await Promise.all([ - loadMakersValidationRules(), - loadMakersFrameworkProfiles(), - ]); - const script = buildMakersCompatibilityScript(rules, profiles); - const scriptPath = `${state.sessionDir}/${COMPAT_SCRIPT_NAME}`; - const fingerprint = compatScriptFingerprint(script); - const upload = async () => { - await context.sandbox.files.write(scriptPath, script); - uploadedCompatScripts.set(state.sessionDir, fingerprint); - }; - - if (uploadedCompatScripts.get(state.sessionDir) !== fingerprint) { - await upload(); - } - - const run = () => runCommandCapturingExit( - context, - buildMakersCompatibilityCommand(), - { cwd: state.appDir, timeout: 20 }, - ); - - const result = await run(); - if (result.exitCode !== 0 && compatScriptMissing(result)) { - // Not a project failure — the lint had nothing to run. Restore the file and - // ask again, because reporting this as a compatibility failure would send - // the model looking for a problem in code that was never examined. - uploadedCompatScripts.delete(state.sessionDir); - await upload(); - return run(); - } - return result; -} - -/** - * Keep fast, deterministic checks that the CLI cannot explain as clearly. - * - * The prefix rules here are about what the project must not contain: the host - * restores /preview/ in the browser, so root-absolute paths are correct, and - * what breaks is a path that carries the prefix already or a framework told to - * expect it. The exception is a subresource URL in a page nothing builds, which - * the parser fetches before the restoring shim can exist — that one has to be - * relative, and it is the only root-absolute path still rejected here. - * - * The adapter check is the exception in shape — it is about what the project - * must contain. It earns that because it is the only failure here that no other - * gate sees: preview, smoke test, and build all pass without the adapter, and - * the deployment is broken anyway. - */ -export async function assertMakersProjectCompatible( - context: any, - state: ProjectState, -) { - const result = await runMakersCompatibilityCheck(context, state); - if (result.exitCode !== 0) { - throw new Error( - `Makers compatibility check failed:\n${result.stderr || result.stdout}\nThis is the project lint, not the EdgeOne CLI: do not check the CLI version or inspect the environment. Fix only the reported project files, then rerun the same EdgeOne CLI command.`, - ); - } -} diff --git a/agents/_lib/makers/compat/run.ts b/agents/_lib/makers/compat/run.ts new file mode 100644 index 0000000..496703c --- /dev/null +++ b/agents/_lib/makers/compat/run.ts @@ -0,0 +1,119 @@ +import { requireSandbox, type AgentContext } from '../../runtime/context.ts'; +import { createHash } from 'node:crypto'; +import type { ProjectState } from '../../types.ts'; +import { runCommandCapturingExit } from '../../project/commands.ts'; +import { loadMakersFrameworkProfiles, loadMakersValidationRules } from './skill-rules.ts'; +import { buildMakersCompatibilityScript } from './lint-script.ts'; + +const COMPAT_SCRIPT_NAME = '.makers-compat-check.cjs'; + +/** + * Which script body each session already has on disk. + * + * The body is derived from the vendored skills, which are read once per + * process, so it is identical for every run of a session — and the lint runs at + * least twice a turn, once before the preview starts and once at verification. + * Re-sending it each time was a sandbox write buying nothing. + * + * Keyed by session because sessions do not share a sandbox, and paired with the + * retry below because a cache that outlives the file it describes is worse than + * no cache at all. + */ +const uploadedCompatScripts = new Map(); + +function compatScriptFingerprint(script: string) { + return createHash('sha256').update(script).digest('hex'); +} + +/** A sandbox recycled under us: the file is gone, so the lint never ran. */ +function compatScriptMissing(result: { stdout?: string; stderr?: string }) { + const output = `${result.stdout || ''}\n${result.stderr || ''}`; + return output.includes('MODULE_NOT_FOUND') + || (output.includes('Cannot find module') && output.includes(COMPAT_SCRIPT_NAME)); +} + +/** + * Run the lint so that a failure still arrives as a report. + * + * Exiting non-zero is how the lint says it found something, and it is also what + * throws away everything it found: the sandbox layer turns a failed shell into + * SANDBOX_UNKNOWN_ERROR and keeps neither stdout nor stderr, so the caller is + * handed "exit status 2" and nothing else. That reads like a broken sandbox + * rather than a project to fix — the run that prompted this spent one turn + * checking the CLI version and another guessing at a file before it landed on + * the one the lint had already named. Echoing the status keeps the shell + * successful, which is what lets the report travel as text. + */ +export function buildMakersCompatibilityCommand() { + return [ + 'set +e', + `node ../${COMPAT_SCRIPT_NAME}`, + 'echo EXIT:$?', + ].join('\n'); +} + +export async function runMakersCompatibilityCheck( + context: AgentContext, + state: ProjectState, +) { + const [rules, profiles] = await Promise.all([ + loadMakersValidationRules(), + loadMakersFrameworkProfiles(), + ]); + const script = buildMakersCompatibilityScript(rules, profiles); + const scriptPath = `${state.sessionDir}/${COMPAT_SCRIPT_NAME}`; + const fingerprint = compatScriptFingerprint(script); + const upload = async () => { + await requireSandbox(context).files.write(scriptPath, script); + uploadedCompatScripts.set(state.sessionDir, fingerprint); + }; + + if (uploadedCompatScripts.get(state.sessionDir) !== fingerprint) { + await upload(); + } + + const run = () => runCommandCapturingExit( + context, + buildMakersCompatibilityCommand(), + { cwd: state.appDir, timeout: 20 }, + ); + + const result = await run(); + if (result.exitCode !== 0 && compatScriptMissing(result)) { + // Not a project failure — the lint had nothing to run. Restore the file and + // ask again, because reporting this as a compatibility failure would send + // the model looking for a problem in code that was never examined. + uploadedCompatScripts.delete(state.sessionDir); + await upload(); + return run(); + } + return result; +} + +/** + * Keep fast, deterministic checks that the CLI cannot explain as clearly. + * + * The prefix rules here are about what the project must not contain: the host + * restores /preview/ in the browser, so root-absolute paths are correct, and + * what breaks is a path that carries the prefix already or a framework told to + * expect it. The exception is a subresource URL in a page nothing builds, which + * the parser fetches before the restoring shim can exist — that one has to be + * relative, and it is the only root-absolute path still rejected here. + * + * The adapter check is the exception in shape — it is about what the project + * must contain. It earns that because it is the only failure here that no other + * gate sees: preview, smoke test, and build all pass without the adapter, and + * the deployment is broken anyway. + */ +export async function assertMakersProjectCompatible( + context: AgentContext, + state: ProjectState, +) { + const result = await runMakersCompatibilityCheck(context, state); + if (result.exitCode !== 0) { + throw new Error( + `Makers compatibility check failed:\n${result.stderr || result.stdout}\nThis is the project lint, not the EdgeOne CLI: do not check the CLI version or inspect the environment. Fix only the reported project files, then rerun the same EdgeOne CLI command.`, + ); + } +} + diff --git a/agents/_lib/makers/compat/skill-rules.ts b/agents/_lib/makers/compat/skill-rules.ts new file mode 100644 index 0000000..7b66815 --- /dev/null +++ b/agents/_lib/makers/compat/skill-rules.ts @@ -0,0 +1,244 @@ +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +export type MakersValidationRule = { + skill: string; + pathPatterns: string[]; + pattern: string; + message: string; +}; + +/** + * Where a framework's platform adapter goes and whether this project needs one. + * + * `serverOutput` is deliberately separate from `adapter`: the file that decides + * whether the app renders on a server is often not the file the adapter is + * wired into. React Router declares `ssr` in react-router.config.ts and takes + * its adapter as a vite.config.ts plugin, so one field cannot serve both. + */ +export type MakersFrameworkProfile = { + id: string; + label: string; + detect: string[]; + adapter: { + package: string; + /** + * The range to declare when this agent adds the adapter itself. Optional + * because it is the platform's to state, not this repo's: absent, the + * declaration falls back to `latest`, which resolves but pins nothing. + */ + version?: string; + configFiles: string[]; + /** + * A config file that, in a shape only it can be in, silences the others. + * + * SvelteKit is why this exists. It reads exactly one config and prefers the + * Vite one, so a single option passed to `sveltekit()` discards the whole of + * a sibling `svelte.config.js` — adapter included, with no warning from the + * build. Checking the first config file that happens to exist calls that + * project correct; checking `vite.config.ts` unconditionally calls a project + * that legitimately keeps its config in `svelte.config.js` broken. + */ + configOverride?: { + files: string[]; + pattern: string; + /** Appended to the error, because "wire it in here" is baffling on its own. */ + reason: string; + }; + required: 'always' | 'server-output'; + } | null; + serverOutput?: { + files: string[]; + default: 'server' | 'static'; + serverPattern?: string; + staticPattern?: string; + }; + outputDirectory: string; + unsupported: string[]; +}; + +const FRAMEWORK_PROFILE_SKILL = 'makers-frameworks'; + +// The profiles live in a fenced block inside the vendored skill rather than in +// its frontmatter: they are nested objects, and the frontmatter reader here is a +// line-oriented approximation of YAML that cannot represent them. +const FRAMEWORK_PROFILE_BLOCK = + /\s*```json\s*\r?\n([\s\S]*?)\r?\n```/; + +export function parseMakersFrameworkProfiles(source: string): MakersFrameworkProfile[] { + const block = source.match(FRAMEWORK_PROFILE_BLOCK)?.[1]; + if (!block) return []; + const parsed = JSON.parse(block) as MakersFrameworkProfile[]; + if (!Array.isArray(parsed)) { + throw new Error('makers-framework-profiles must be a JSON array'); + } + for (const profile of parsed) { + if (!profile.id || !Array.isArray(profile.detect) || profile.detect.length === 0) { + throw new Error(`framework profile ${profile.id || '(unnamed)'} needs an id and a detect list`); + } + // Compile every pattern at load time so a malformed vendored profile fails + // here, where the message names the profile, rather than inside the sandbox + // script as a syntax error with no attribution. + for (const pattern of [ + profile.serverOutput?.serverPattern, + profile.serverOutput?.staticPattern, + profile.adapter?.configOverride?.pattern, + ]) { + if (pattern) new RegExp(pattern); + } + } + return parsed; +} + +const VALIDATION_SKILLS = [ + 'makers-agents', + 'makers-cloud-functions', + 'makers-deploy', + 'makers-edge-functions', + 'makers-env-adaption', + 'makers-frameworks', + 'makers-middleware', + 'makers-storage', +] as const; + +export const SUPPORTED_MAKERS_AGENT_FRAMEWORKS = [ + 'claude-agent-sdk', + 'openai-agents-sdk', + 'langgraph', + 'crewai', + 'deepagents', +] as const; + +function parseFrontmatterScalar(value: string) { + const trimmed = value.trim(); + if (trimmed.startsWith('"') && trimmed.endsWith('"')) { + // A double-quoted YAML scalar is close enough to JSON to reuse the parser, + // but not close enough to trust it: one stray backslash in a vendored skill + // would otherwise take down every compatibility check with a SyntaxError. + try { + return JSON.parse(trimmed) as string; + } catch { + return trimmed.slice(1, -1); + } + } + if (trimmed.startsWith("'") && trimmed.endsWith("'")) { + return trimmed.slice(1, -1).replaceAll("''", "'"); + } + return trimmed; +} + +export function parseMakersSkillValidationRules( + skill: string, + source: string, +): MakersValidationRule[] { + const frontmatter = source.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1]; + if (!frontmatter) return []; + + const pathPatterns: string[] = []; + const rules: Array<{ pattern: string; message: string }> = []; + let section: 'paths' | 'validate' | null = null; + let pendingPattern = ''; + + for (const line of frontmatter.split(/\r?\n/)) { + if (line === 'pathPatterns:') { + section = 'paths'; + continue; + } + if (line === 'validate:') { + section = 'validate'; + continue; + } + if (/^\S/.test(line)) { + section = null; + continue; + } + if (section === 'paths') { + const match = line.match(/^\s{2}-\s+(.+)$/); + if (match?.[1]) pathPatterns.push(parseFrontmatterScalar(match[1])); + continue; + } + if (section === 'validate') { + const patternMatch = line.match(/^\s{2}-\s+pattern:\s+(.+)$/); + if (patternMatch?.[1]) { + pendingPattern = parseFrontmatterScalar(patternMatch[1]); + continue; + } + const messageMatch = line.match(/^\s{4}message:\s+(.+)$/); + if (messageMatch?.[1] && pendingPattern) { + rules.push({ + pattern: pendingPattern, + message: parseFrontmatterScalar(messageMatch[1]), + }); + pendingPattern = ''; + } + } + } + + return rules.map((rule) => ({ + skill, + pathPatterns: [...pathPatterns], + ...rule, + })); +} + +function readVendoredSkill(skill: string) { + return readFile( + path.join( + process.cwd(), + '.claude', + 'skills', + 'edgeone-makers-tools', + 'references', + skill, + 'SKILL.md', + ), + 'utf8', + ); +} + +let frameworkProfilesPromise: Promise | undefined; + +export function loadMakersFrameworkProfiles(): Promise { + if (!frameworkProfilesPromise) { + const pending = readVendoredSkill(FRAMEWORK_PROFILE_SKILL).then((source) => { + const profiles = parseMakersFrameworkProfiles(source); + if (profiles.length === 0) { + throw new Error( + `${FRAMEWORK_PROFILE_SKILL} carries no framework profiles; the adapter check cannot run without them`, + ); + } + return profiles; + }); + frameworkProfilesPromise = pending.catch((error) => { + frameworkProfilesPromise = undefined; + throw error; + }); + } + return frameworkProfilesPromise; +} + +let validationRulesPromise: Promise | undefined; + +export function loadMakersValidationRules(): Promise { + if (!validationRulesPromise) { + const pending = Promise.all(VALIDATION_SKILLS.map(async (skill) => { + const source = await readVendoredSkill(skill); + return parseMakersSkillValidationRules(skill, source); + })).then((groups) => { + const rules = groups.flat(); + for (const rule of rules) { + // Fail at the source if an official vendored rule is malformed instead + // of silently dropping a compatibility check. + new RegExp(rule.pattern); + } + return rules; + }); + // Cache the success, not the attempt. Holding a rejected promise here would + // turn one unlucky read into a permanently broken compatibility check for + // every later turn on this warm instance. + validationRulesPromise = pending.catch((error) => { + validationRulesPromise = undefined; + throw error; + }); + } + return validationRulesPromise; +} diff --git a/agents/_lib/project/makers-declarations.ts b/agents/_lib/makers/declarations.ts similarity index 96% rename from agents/_lib/project/makers-declarations.ts rename to agents/_lib/makers/declarations.ts index 710c9da..03e87d9 100644 --- a/agents/_lib/project/makers-declarations.ts +++ b/agents/_lib/makers/declarations.ts @@ -7,15 +7,16 @@ * project already declares. Discovering them at the gate costs the user a * failed preview for something nothing had to decide. */ +import { requireSandbox, type AgentContext } from '../runtime/context.ts'; import type { ProjectState } from '../types.ts'; import { loadMakersFrameworkProfiles, SUPPORTED_MAKERS_AGENT_FRAMEWORKS, type MakersFrameworkProfile, -} from './makers-compat.ts'; -import { runSandboxCommand } from './commands.ts'; -import { readFileFromSandbox } from './fs.ts'; +} from './compat/skill-rules.ts'; +import { runSandboxCommand } from '../project/commands.ts'; +import { readFileFromSandbox } from '../project/fs.ts'; export type MakersAgentFramework = (typeof SUPPORTED_MAKERS_AGENT_FRAMEWORKS)[number]; @@ -258,7 +259,7 @@ export function withFrameworkAdapter( * the dependency is already there and only the config wiring is left. */ export async function ensureMakersFrameworkAdapter( - context: any, + context: AgentContext, state: ProjectState, packageJsonContent: string, ): Promise<{ path: string; content: string } | undefined> { @@ -268,12 +269,12 @@ export async function ensureMakersFrameworkAdapter( profiles, ); if (!content) return undefined; - await context.sandbox.files.write(`${state.appDir}/package.json`, content); + await requireSandbox(context).files.write(`${state.appDir}/package.json`, content); return { path: 'package.json', content }; } async function readProjectFile( - context: any, + context: AgentContext, state: ProjectState, relPath: string, ): Promise { @@ -301,7 +302,7 @@ async function readProjectFile( * withholds the framework rather than guessing one. */ async function readAgentImportLines( - context: any, + context: AgentContext, state: ProjectState, ): Promise { try { @@ -337,7 +338,7 @@ let declarationQueue: Promise = Promise.resolve(); * in line costs four reads and no writes. */ export function ensureMakersAgentDeclarations( - context: any, + context: AgentContext, state: ProjectState, ): Promise> { const run = declarationQueue.then( @@ -351,7 +352,7 @@ export function ensureMakersAgentDeclarations( } async function declareMakersAgentFiles( - context: any, + context: AgentContext, state: ProjectState, ): Promise> { const [edgeoneConfig, envExample, packageJson, requirements, agentSources] = await Promise.all([ @@ -372,7 +373,7 @@ async function declareMakersAgentFiles( if (envContent) written.push({ path: '.env.example', content: envContent }); for (const file of written) { - await context.sandbox.files.write(`${state.appDir}/${file.path}`, file.content); + await requireSandbox(context).files.write(`${state.appDir}/${file.path}`, file.content); } return written; } diff --git a/shared/npm-install.ts b/agents/_lib/makers/npm-install.ts similarity index 100% rename from shared/npm-install.ts rename to agents/_lib/makers/npm-install.ts diff --git a/agents/_lib/makers/preview-proxy-source.ts b/agents/_lib/makers/preview-proxy-source.ts new file mode 100644 index 0000000..c6d0d77 --- /dev/null +++ b/agents/_lib/makers/preview-proxy-source.ts @@ -0,0 +1,459 @@ +/** Generated Node proxy source. Keep in sync with the TS helpers in cli-dev.ts. */ + +export const PROXY_REVISION_PLACEHOLDER = '__PREVIEW_PROXY_REVISION__'; + +function normalizePreviewPrefix(value: string) { + const normalized = `/${value}`.replace(/\/+/g, '/').replace(/\/+$/, ''); + return normalized === '/' ? '' : normalized; +} + +export function buildPreviewProxyTemplate( + listenPort: number, + targetPort: number, + prefix: string, +) { + const normalizedPrefix = normalizePreviewPrefix(prefix); + return `const http = require('node:http'); +const net = require('node:net'); + +const LISTEN_PORT = ${listenPort}; +const TARGET_PORT = ${targetPort}; +const PREFIX = ${JSON.stringify(normalizedPrefix)}; +const HEALTH_PATH = '/__edgeone_preview_proxy_health'; + +// Set once the upstream asks to be addressed with the prefix — see +// previewUpstreamClaimsPrefix in shared/makers-dev.ts. Until then the prefix is +// stripped, which is what makers dev and every framework with an asset-only +// prefix knob expect. +let prefixAware = false; + +// The 404-shaped form of the same claim, tried once per proxy — see +// previewPrefixProbe. Once, because an upstream that really does serve at the +// root answers a genuinely missing page with a 404 too, and re-asking on every +// one of those would double the requests to re-learn what the first probe +// already established. +let prefixProbed = false; + +function rewritePath(url) { + if (!url) return '/'; + if (prefixAware) return url; + if ( + PREFIX + && (url === PREFIX || url.startsWith(PREFIX + '/') || url.startsWith(PREFIX + '?')) + ) { + const next = url.slice(PREFIX.length); + if (!next || next === '/') return '/'; + return next.startsWith('/') ? next : '/' + next; + } + return url; +} + +// Mirrors previewCanonicalRedirect in shared/makers-dev.ts. +function canonicalRedirect(url) { + if (!PREFIX || !url) return null; + const queryStart = url.indexOf('?'); + const path = queryStart === -1 ? url : url.slice(0, queryStart); + if (path !== PREFIX) return null; + return PREFIX + '/' + (queryStart === -1 ? '' : url.slice(queryStart)); +} + +function rewriteLocation(value) { + if (!PREFIX || typeof value !== 'string' || !value.startsWith('/')) return value; + if (value === PREFIX || value.startsWith(PREFIX + '/')) return value; + return PREFIX + value; +} + +// Mirrors previewUpstreamClaimsPrefix in shared/makers-dev.ts. +function claimsPrefix(statusCode, location) { + if (!PREFIX) return false; + if (!statusCode || statusCode < 300 || statusCode >= 400) return false; + if (typeof location !== 'string' || !location) return false; + let path = location; + const schemeEnd = location.indexOf('://'); + if (schemeEnd !== -1) { + const afterHost = location.indexOf('/', schemeEnd + 3); + path = afterHost === -1 ? '/' : location.slice(afterHost); + } else if (location.charAt(0) !== '/') { + return false; + } + path = path.split('?')[0]; + return path === PREFIX || path.indexOf(PREFIX + '/') === 0; +} + +// Mirrors previewPrefixProbe in shared/makers-dev.ts. +function prefixProbe(requestUrl, forwardedPath, statusCode) { + if (!PREFIX || !requestUrl || !forwardedPath) return null; + if (statusCode !== 404) return null; + if (forwardedPath === requestUrl) return null; + const probePath = requestUrl.split('?')[0]; + if (probePath !== PREFIX && probePath.indexOf(PREFIX + '/') !== 0) return null; + return requestUrl; +} + +// Mirrors previewTrailingSlashFollow in shared/makers-dev.ts. +function trailingSlashFollow(forwardedPath, statusCode, location) { + if (!forwardedPath) return null; + if (!statusCode || statusCode < 300 || statusCode >= 400) return null; + if (typeof location !== 'string' || location.charAt(0) !== '/') return null; + const from = forwardedPath.split('?')[0]; + const to = location.split('?')[0]; + if (from === to) return null; + return withoutTrailingSlash(from) === withoutTrailingSlash(to) ? location : null; +} + +function withoutTrailingSlash(value) { + return value.length > 1 && value.charAt(value.length - 1) === '/' + ? value.slice(0, -1) + : value; +} + +function rewriteSetCookie(value) { + if (!PREFIX || typeof value !== 'string') return value; + return value.replace(/;\\s*Path=\\//gi, '; Path=' + PREFIX + '/'); +} + +// makers dev reaches its function runtime through http-proxy with xfwd +// enabled, and xfwd APPENDS to x-forwarded-proto rather than replacing it. A +// value from the sandbox gateway therefore arrives at the runtime as +// "http,http", which it concatenates into a request URL and hands to new +// Request(): ERR_INVALID_URL. The failure is then swallowed by an error +// handler that throws on its own, so nothing ever writes a response and the +// browser spins until the user gives up. Dropping the header here leaves xfwd +// setting the single value it would have set anyway, which is also what makes +// a direct curl to the CLI work today. +// The parent workspace frames this preview cross-origin, so it cannot read the +// iframe's location to fill its address bar. Nothing else can report the route +// either: makers dev serves the application, and a proxy is the only layer left +// that sees every document. This posts the real path — prefix included, which is +// what the parent strips for display and reuses when deep-linking a copied URL. +const TRACKER = ''; + +// Scanned as latin1 so one character is one byte and the match offset can index +// the buffer directly. +// +// is only the preferred landing place. A hand-written index.html may not +// have one, and the script above is now what makes in-app navigation work, so +// skipping those pages would leave exactly the simplest generated sites broken. +// The fallbacks stay below the doctype: above it the page drops into quirks +// mode, which changes how the whole document lays out. +function headInsertionPoint(buffer) { + const text = buffer.toString('latin1'); + for (const pattern of [/]*>/i, /]*>/i, /]*>/i]) { + const match = pattern.exec(text); + if (match) return match.index + match[0].length; + } + return -1; +} + +function acceptsHtml(req) { + const accept = req.headers.accept; + return typeof accept === 'string' && accept.includes('text/html'); +} + +function isHtmlResponse(headers) { + const type = headers['content-type']; + return typeof type === 'string' && type.toLowerCase().includes('text/html'); +} + +function forwardHeaders(req) { + const headers = { + ...req.headers, + host: '127.0.0.1:' + TARGET_PORT, + 'x-forwarded-prefix': PREFIX, + }; + delete headers['x-forwarded-proto']; + // TRACKER can only be spliced into an unencoded body. Asking for identity on + // navigations alone costs nothing on a loopback hop and leaves compression in + // place for the assets, which are what the encoding is actually worth. + if (acceptsHtml(req)) headers['accept-encoding'] = 'identity'; + return headers; +} + +// Buffer only up to the opening , then release and stream the rest +// untouched: a page that streams its body from a Suspense boundary has to keep +// arriving in pieces, and the shell carrying is already in the first one. +function injectTracker(upstream, res) { + const SCAN_LIMIT = 65536; + let pending = []; + let scanned = 0; + let injected = false; + + function splice(buffer) { + const at = headInsertionPoint(buffer); + // No to splice after. Prepending would land the script above the + // doctype and drop the page into quirks mode, so leave the body alone and + // let the address bar stay where it is. + if (at === -1) { + res.write(buffer); + return; + } + res.write(buffer.subarray(0, at)); + res.write(TRACKER); + res.write(buffer.subarray(at)); + } + + upstream.on('data', (chunk) => { + if (injected) { + res.write(chunk); + return; + } + pending.push(chunk); + scanned += chunk.length; + const buffer = Buffer.concat(pending); + if (headInsertionPoint(buffer) === -1 && scanned <= SCAN_LIMIT) return; + injected = true; + pending = []; + splice(buffer); + }); + upstream.on('end', () => { + if (!injected && pending.length) splice(Buffer.concat(pending)); + res.end(); + }); + upstream.on('error', () => res.end()); +} + +const server = http.createServer((req, res) => { + if ((req.url || '').split('?')[0] === HEALTH_PATH) { + res.writeHead(200, { + 'content-type': 'text/plain', + 'x-edgeone-preview-proxy': '${PROXY_REVISION_PLACEHOLDER}', + }); + res.end('ok'); + return; + } + + const canonical = canonicalRedirect(req.url); + if (canonical) { + res.writeHead(308, { location: canonical }); + res.end(); + return; + } + + forward(req, res, rewritePath(req.url), true, true, false); +}); + +function forward(req, res, path, mayRetry, mayFollow, probing) { + const headers = forwardHeaders(req); + const proxy = http.request({ + hostname: '127.0.0.1', + port: TARGET_PORT, + path, + method: req.method, + headers, + }, (upstream) => { + // The probe's answer, which is the half of previewPrefixProbe that decides. + // Anything but a second 404 means the prefixed path is a route the upstream + // knows, so it keeps the prefix from here on. Read before the branches + // below so a probe answered with a redirect still counts as knowing it. + if (probing && upstream.statusCode !== 404) prefixAware = true; + // The one response that means the prefix should not have been stripped. + // Retried rather than passed on, because handing the browser a redirect to + // a path this proxy still strips is the same request again: it would bounce + // between the two until the browser gave up. + if ( + mayRetry + && !prefixAware + && (req.method === 'GET' || req.method === 'HEAD') + && claimsPrefix(upstream.statusCode, upstream.headers.location) + ) { + prefixAware = true; + upstream.resume(); + forward(req, res, req.url || '/', false, true, false); + return; + } + // The same claim made as a 404, which is how Astro states it. Asked rather + // than concluded: the retry's status is what tells a base-mounted app apart + // from a page that is simply not there. + if ( + mayRetry + && !prefixAware + && !prefixProbed + && (req.method === 'GET' || req.method === 'HEAD') + && prefixProbe(req.url, path, upstream.statusCode) + ) { + prefixProbed = true; + upstream.resume(); + forward(req, res, req.url, false, true, true); + return; + } + // A redirect that only normalizes a trailing slash, settled here for the + // same reason — see previewTrailingSlashFollow. Once, and never from a + // follow of its own: an upstream that keeps normalizing is a loop this + // proxy would be holding open instead of the browser. + if (mayFollow && (req.method === 'GET' || req.method === 'HEAD')) { + const follow = trailingSlashFollow( + path, + upstream.statusCode, + upstream.headers.location, + ); + if (follow) { + upstream.resume(); + forward(req, res, follow, false, false, false); + return; + } + } + const responseHeaders = { ...upstream.headers }; + if (responseHeaders.location) { + responseHeaders.location = rewriteLocation(responseHeaders.location); + } + if (Array.isArray(responseHeaders['set-cookie'])) { + responseHeaders['set-cookie'] = responseHeaders['set-cookie'].map(rewriteSetCookie); + } + const injectable = isHtmlResponse(responseHeaders) + && !responseHeaders['content-encoding']; + // The body grows by TRACKER, so the declared length no longer holds. + // Dropping it hands the response to chunked encoding. + if (injectable) delete responseHeaders['content-length']; + res.writeHead(upstream.statusCode || 502, responseHeaders); + if (injectable) injectTracker(upstream, res); + else upstream.pipe(res); + }); + proxy.on('error', () => { + if (!res.headersSent) res.writeHead(502); + res.end('preview proxy error'); + }); + // Neither a retry nor a follow has a body left to send: both are reached + // only for a GET or a HEAD, and the request stream is already consumed. + if (mayRetry) req.pipe(proxy); + else proxy.end(); +} + +server.on('upgrade', (req, socket, head) => { + const path = rewritePath(req.url); + const headers = forwardHeaders(req); + const target = net.connect(TARGET_PORT, '127.0.0.1', () => { + const headerLines = Object.entries(headers).flatMap(([key, value]) => { + if (value == null) return []; + return [key + ': ' + (Array.isArray(value) ? value.join(', ') : value)]; + }); + target.write([ + (req.method || 'GET') + ' ' + path + ' HTTP/1.1', + ...headerLines, + '', + '', + ].join('\\r\\n')); + if (head && head.length) target.write(head); + target.pipe(socket); + socket.pipe(target); + }); + target.on('error', () => socket.destroy()); + socket.on('error', () => target.destroy()); +}); + +server.listen(LISTEN_PORT, '0.0.0.0'); +`; +} diff --git a/agents/_lib/project/makers-deploy.ts b/agents/_lib/makers/project.ts similarity index 95% rename from agents/_lib/project/makers-deploy.ts rename to agents/_lib/makers/project.ts index 7f90905..485160a 100644 --- a/agents/_lib/project/makers-deploy.ts +++ b/agents/_lib/makers/project.ts @@ -1,3 +1,4 @@ +import type { AgentContext } from '../runtime/context.ts'; import { createHash } from 'node:crypto'; import { ConflictError, Makers } from '@edgeone/makers-sdk'; import type { ProjectState } from '../types.ts'; @@ -18,12 +19,12 @@ import { resolveMakersPublishTarget } from '../../../shared/publish-target.ts'; // every turn of the same conversation resolves to the same project. const PROJECT_NAME_PREFIX = 'vibe-coding'; -function pickEnvValue(context: any, key: string) { +function pickEnvValue(context: AgentContext, key: string) { const value = context?.env?.[key]; return typeof value === 'string' ? value.trim() : ''; } -export function resolveMakersProjectName(context: any, state: ProjectState) { +export function resolveMakersProjectName(context: AgentContext, state: ProjectState) { // An explicit name is an operator decision: honour it exactly, including the // consequence that every conversation then shares the one project. const pinned = pickEnvValue(context, 'MAKERS_DEPLOY_PROJECT_NAME'); @@ -107,7 +108,7 @@ export function parsePublishableDotEnv(content: string) { return values; } -async function readSandboxDotEnv(context: any, state: ProjectState) { +async function readSandboxDotEnv(context: AgentContext, state: ProjectState) { try { const content = await context?.sandbox?.files?.read?.(`${state.appDir}/.env`); return typeof content === 'string' ? content : ''; @@ -175,7 +176,7 @@ export async function ensureMakersPublishProject( * `setEnvs` depends on. */ export async function syncSandboxEnvToMakersProject( - context: any, + context: AgentContext, state: ProjectState, masterToken: string, projectName: string, diff --git a/agents/_lib/makers/session.ts b/agents/_lib/makers/session.ts new file mode 100644 index 0000000..4d7f797 --- /dev/null +++ b/agents/_lib/makers/session.ts @@ -0,0 +1,62 @@ +import type { AgentContext } from '../runtime/context.ts'; +import type { ProjectState } from '../types.ts'; +import { + ensureMakersPublishProject, + resolveConversationPublishArea, + resolveMakersProjectName, + syncSandboxEnvToMakersProject, +} from './project.ts'; +import { + buildSandboxMakersEnv, + prepareSandboxGatewayEnv, + resolveMakersMasterToken, + resolveSandboxMakersToken, +} from './token.ts'; + +export type PreparedMakersSession = { + masterToken: string; + sandboxToken: string; + projectName: string; + area: string; + env: Record; + gatewayKey: string; +}; + +/** + * Token, project, and env the sandbox CLI needs before a Makers command. + * Preview, deploy, and the commands wrapper all used to do this separately. + */ +export async function prepareMakersSession( + context: AgentContext, + state: ProjectState, + options: { syncEnv?: boolean } = {}, +): Promise { + const masterToken = resolveMakersMasterToken(context); + const sandboxToken = await resolveSandboxMakersToken(state, masterToken); + const gateway = await prepareSandboxGatewayEnv(context, state); + const projectName = resolveMakersProjectName(context, state); + const area = resolveConversationPublishArea(state); + await ensureMakersPublishProject( + sandboxToken, + projectName, + area, + state.makersApiRegion, + ); + if (options.syncEnv) { + await syncSandboxEnvToMakersProject( + context, + state, + masterToken, + projectName, + state.makersApiRegion, + ); + } + return { + masterToken, + sandboxToken, + projectName, + area, + env: buildSandboxMakersEnv(sandboxToken, state.makersApiRegion), + gatewayKey: gateway.AI_GATEWAY_API_KEY || '', + }; +} diff --git a/agents/_lib/project/makers-token.ts b/agents/_lib/makers/token.ts similarity index 92% rename from agents/_lib/project/makers-token.ts rename to agents/_lib/makers/token.ts index 412f799..d947f94 100644 --- a/agents/_lib/project/makers-token.ts +++ b/agents/_lib/makers/token.ts @@ -1,7 +1,9 @@ +import type { AgentContext } from '../runtime/context.ts'; import { randomUUID } from 'node:crypto'; import { Makers, MakersError } from '@edgeone/makers-sdk'; import type { ProjectState } from '../types.ts'; -import { readProjectGatewayEnv } from './gateway-prompt.ts'; +import { readProjectGatewayEnv } from '../project/gateway.ts'; +import { bindMakersApiRegion, bindMakersTenantId } from '../project/workspace-store.ts'; // Every preview start, wrapped CLI call and deploy mints its own token, so this // only has to outlive a single CLI invocation. An hour is already far more than @@ -10,12 +12,12 @@ const SUB_TOKEN_TTL_SECONDS = 60 * 60; let cachedPlatformClient: { masterToken: string; client: Makers } | null = null; -function pickEnvValue(context: any, key: string) { +function pickEnvValue(context: AgentContext, key: string) { const value = context?.env?.[key]; return typeof value === 'string' ? value.trim() : ''; } -export function resolveMakersMasterToken(context: any) { +export function resolveMakersMasterToken(context: AgentContext) { return pickEnvValue(context, 'API_TOKEN'); } @@ -28,8 +30,7 @@ export function ensureMakersTenantId(state: ProjectState) { // Keeping it server-generated prevents a client-controlled conversation ID // from selecting another tenant. const tenantId = `vibe-${randomUUID().replaceAll('-', '')}`; - state.makersTenantId = tenantId; - return tenantId; + return bindMakersTenantId(state, tenantId); } function getPlatformClient(masterToken: string) { @@ -80,7 +81,7 @@ export async function issueSandboxMakersSubToken( ? client.region : undefined; if (region) { - state.makersApiRegion = region; + bindMakersApiRegion(state, region); } return created; } catch (error) { @@ -127,7 +128,7 @@ export function describeMissingMakersRuntimeToken(output = '') { const SANDBOX_GATEWAY_KEY = 'AI_GATEWAY_API_KEY'; const SANDBOX_GATEWAY_URL = 'AI_GATEWAY_BASE_URL'; -export function resolveSandboxGatewayEnv(context: any): Record { +export function resolveSandboxGatewayEnv(context: AgentContext): Record { const key = pickEnvValue(context, SANDBOX_GATEWAY_KEY); const url = pickEnvValue(context, SANDBOX_GATEWAY_URL); return { @@ -147,7 +148,7 @@ export function resolveSandboxGatewayEnv(context: any): Record { * this helper's. A skip must still leave preview and deploy able to run. */ export async function prepareSandboxGatewayEnv( - context: any, + context: AgentContext, state: ProjectState, ) { return readProjectGatewayEnv(context, state); diff --git a/shared/tool-phase.ts b/agents/_lib/makers/tool-phase.ts similarity index 100% rename from shared/tool-phase.ts rename to agents/_lib/makers/tool-phase.ts diff --git a/agents/_lib/memory.ts b/agents/_lib/memory.ts deleted file mode 100644 index 33eaf84..0000000 --- a/agents/_lib/memory.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { HISTORY_FETCH_LIMIT } from './constants.ts'; -import { createProjectState } from './project/index.ts'; -import type { - ChatTask, - ConversationMessage, - PersistedActivityTurn, - LegacyProjectSnapshot, - ProjectState, -} from './types.ts'; -import { sanitizeAssistantText } from './utils/text.ts'; -import { appendTrimmedActivityTurn, dedupeActivityTurns } from './utils/activity.ts'; - -export async function getHistory( - context: any, - conversationId: string, - options: { excludeLatestUserMessage?: string } = {}, -): Promise { - // context.store only exposes conversation-scoped message APIs, not a generic KV store. - // Read this conversation's messages and filter them into user/assistant text pairs. - try { - const messages = await context.store.getMessages({ - conversationId, - limit: HISTORY_FETCH_LIMIT, - order: 'asc', - }); - const items = Array.isArray(messages) ? messages : (messages?.items || []); - const history = items - .filter((item: any) => item.role === 'user' || item.role === 'assistant') - .map((item: any) => ({ - role: item.role as 'user' | 'assistant', - content: typeof item.content === 'string' - ? item.content - : JSON.stringify(item.content ?? ''), - })); - - // POST /session persists the submitted user message before the detached task starts. - // Remove that one record from the prompt history; the pipeline passes - // it separately as the current user turn. - const currentMessage = options.excludeLatestUserMessage; - if (currentMessage && history.at(-1)?.role === 'user' && history.at(-1)?.content === currentMessage) { - history.pop(); - } - return history; - } catch (error: any) { - if (error?.code === 'MemoryNotFoundError') { - return []; - } - throw error; - } -} - -export async function getChatTask(context: any, conversationId: string): Promise { - try { - const conversation = await context.store.getConversation({ conversationId }); - const task = conversation?.metadata?.chatTask; - return task && typeof task === 'object' && typeof task.id === 'string' - ? task as ChatTask - : null; - } catch (error: any) { - if (error?.code === 'MemoryNotFoundError') { - return null; - } - throw error; - } -} - -export async function saveChatTask(context: any, conversationId: string, task: ChatTask) { - await context.store.updateConversation({ - conversationId, - metadata: { chatTask: task }, - }); -} - -/** - * The model this conversation last ran on. Persisted so a refresh can restore - * the picker, and so a turn that arrives without an explicit choice still runs - * on the model the conversation has been using rather than silently reverting - * to the deployment default. - */ -export async function getModelPreference( - context: any, - conversationId: string, -): Promise { - try { - const conversation = await context.store.getConversation({ conversationId }); - const stored = conversation?.metadata?.modelPreference; - return typeof stored === 'string' ? stored.trim() : ''; - } catch (error: any) { - if (error?.code !== 'MemoryNotFoundError') { - throw error; - } - return ''; - } -} - -export async function saveModelPreference( - context: any, - conversationId: string, - model: string, -) { - try { - await context.store.updateConversation({ - conversationId, - metadata: { modelPreference: model }, - }); - } catch (error: any) { - // Same first-turn race as saveProjectState: until appendMessage creates the - // conversation, updateConversation has nothing to merge into. - if (error?.code !== 'MemoryNotFoundError') { - throw error; - } - } -} - -export async function appendTurn( - context: any, - conversationId: string, - role: 'user' | 'assistant', - content: string, -) { - // Sanitize assistant content before writing history so control sequences or raw JSON - // from new concatenation paths do not pollute the next prompt. - const safeContent = role === 'assistant' ? sanitizeAssistantText(content) : content; - await context.store.appendMessage({ - conversationId, - role, - content: safeContent, - }); -} - -export async function getProjectState(context: any, conversationId: string): Promise { - // Project state is conversation metadata, not a chat message. On first access, - // the conversation may not exist yet, so fall back to the default state. - try { - const conversation = await context.store.getConversation({ conversationId }); - const stored = conversation?.metadata?.projectState as ProjectState | undefined; - if (stored && typeof stored === 'object') { - return stored; - } - } catch (error: any) { - if (error?.code !== 'MemoryNotFoundError') { - throw error; - } - } - return createProjectState(conversationId); -} - -export async function saveProjectState( - context: any, - conversationId: string, - state: ProjectState, -) { - // updateConversation shallow-merges metadata; replace projectState as a whole. - try { - await context.store.updateConversation({ - conversationId, - metadata: { projectState: state }, - }); - } catch (error: any) { - // If no messages have been written, the conversation does not exist yet and - // updateConversation throws MemoryNotFoundError. appendMessage will create it - // later in this turn, and the next saveProjectState call can write normally. - if (error?.code !== 'MemoryNotFoundError') { - throw error; - } - } -} - -// Read-only compatibility for snapshots written by template versions that stored -// the archive in conversation metadata. New writes use context.sandbox.persist(). -export async function getLegacyProjectSnapshot( - context: any, - conversationId: string, -): Promise { - try { - const conversation = await context.store.getConversation({ conversationId }); - const stored = conversation?.metadata?.projectSnapshot as LegacyProjectSnapshot | undefined; - if (stored && typeof stored === 'object' && typeof stored.base64 === 'string' && stored.base64) { - return stored; - } - } catch (error: any) { - if (error?.code !== 'MemoryNotFoundError') { - throw error; - } - } - return null; -} - -export async function clearLegacyProjectSnapshot(context: any, conversationId: string) { - try { - await context.store.updateConversation({ - conversationId, - metadata: { projectSnapshot: null }, - }); - } catch (error: any) { - if (error?.code !== 'MemoryNotFoundError') { - throw error; - } - } -} - -const ACTIVITY_TURN_LIMIT = 25; -const ACTIVITY_ITEM_LIMIT = 50; - -export async function getActivityHistory( - context: any, - conversationId: string, -): Promise { - try { - const conversation = await context.store.getConversation({ conversationId }); - const stored = conversation?.metadata?.activityHistory; - return Array.isArray(stored) - ? dedupeActivityTurns(stored.slice(-ACTIVITY_TURN_LIMIT)) - : []; - } catch (error: any) { - if (error?.code !== 'MemoryNotFoundError') throw error; - return []; - } -} - -export async function saveActivityTurn( - context: any, - conversationId: string, - turn: PersistedActivityTurn, -) { - const current = await getActivityHistory(context, conversationId); - const next = appendTrimmedActivityTurn( - current, - turn, - ACTIVITY_TURN_LIMIT, - ACTIVITY_ITEM_LIMIT, - ); - try { - await context.store.updateConversation({ - conversationId, - metadata: { activityHistory: next }, - }); - } catch (error: any) { - if (error?.code !== 'MemoryNotFoundError') throw error; - } -} diff --git a/agents/_lib/models.ts b/agents/_lib/models.ts index a16bede..c17bd96 100644 --- a/agents/_lib/models.ts +++ b/agents/_lib/models.ts @@ -1,3 +1,4 @@ +import type { AgentContext } from './runtime/context.ts'; import { resolveConfiguredModel, resolveModelCatalog, @@ -12,7 +13,7 @@ export { resolveConfiguredModel, resolveModelCatalog }; * read '' as "no choice" and fall back to the configured model, so a client that * sends an arbitrary string cannot pick what the gateway bills for. */ -export function resolveRequestedModel(context: any, requested: unknown) { +export function resolveRequestedModel(context: AgentContext, requested: unknown) { return resolveSelectedModel(resolveModelCatalog(context), requested); } @@ -21,7 +22,7 @@ export function resolveRequestedModel(context: any, requested: unknown) { * shows this label, and the agent has to say the same words the user is looking * at — a raw ID would name the platform tier no user-facing string names. */ -export function resolveRunningModelLabel(context: any, model: string) { +export function resolveRunningModelLabel(context: AgentContext, model: string) { return resolveModelLabel(resolveModelCatalog(context), model); } diff --git a/agents/_lib/pipelines/chat.ts b/agents/_lib/pipelines/chat.ts deleted file mode 100644 index ab5c64b..0000000 --- a/agents/_lib/pipelines/chat.ts +++ /dev/null @@ -1,699 +0,0 @@ -import { runCodingAgent } from '../agent.ts'; -import { AUTO_FIX_MAX_ATTEMPTS } from '../constants.ts'; -import { getHistory, saveProjectState } from '../memory.ts'; -import { getFileTree, runVerification } from '../project/index.ts'; -import type { - AgentProgressEvent, - BuildStatus, - DeploymentInfo, - FileTreeItem, - ScaffoldLog, - StreamSend, -} from '../types.ts'; -import { buildAutoFixPrompt } from '../utils/build-errors.ts'; -import { toAppRelPath } from '../utils/paths.ts'; -import { sanitizeAssistantText } from '../utils/text.ts'; -import { resolveConversationId } from '../utils/request.ts'; -import { - FILE_PUSH_MAX_BYTES, - FILE_PUSH_TURN_BUDGET_BYTES, - buildRequirementConclusionFallback, - compactUserFacingReply, - createFileTreePushController, - createProjectCheckpointController, - extendExistingSandboxTimeout, - GATEWAY_CREDENTIALS_USER_REPLY, - isGenericCompletionReply, - previewLinkFromState, - replyLocaleFor, - resolveFinishedTurn, - STOPPED_TURN_REPLY, - stripReturnedPreviewLinks, - utf8ByteLength, - withLiveDeploymentUrl, -} from './helpers.ts'; -import { createTurnLifecycle } from './turn-lifecycle.ts'; -import { prepareProjectWorkspace } from './workspace.ts'; -import { isMakersDeployUrl } from '../../../shared/makers-deploy.ts'; -import { - applyUserGatewayDecision, - isRequestGatewayCredentialsTool, -} from '../project/gateway-prompt.ts'; -import { resolveGatewayUserTurn } from '../../../shared/gateway-secret.ts'; - -export async function runChatPipeline( - context: any, - message: string, - send: StreamSend, - options: { - resetProject?: boolean; - turnId?: string; - userMessagePersisted?: boolean; - /** Validated model for this turn; '' or absent runs the configured default. */ - model?: string; - siteDomain?: string; - /** Real Models API key from the card or a chat sentence; never persisted. */ - apiKey?: string; - gatewaySkip?: boolean; - } = {}, -) { - const { conversationId } = resolveConversationId(context); - const abortSignal = context?.request?.signal as AbortSignal | undefined; - // Every reply this pipeline writes itself — stopped, failed, fallback — has to - // answer in the language of the request, so the language is decided once here - // rather than re-sniffed at each of the five places that needed it. - const replyLocale = replyLocaleFor(message); - - if (!message) { - send({ - type: 'result', - data: { - ok: false, - conversation_id: conversationId, - reply: 'Please describe the page or feature you want to build first.', - build: { status: 'skipped' as BuildStatus }, - preview: {}, - }, - }); - return; - } - - if (!conversationId) { - send({ - type: 'result', - data: { - ok: false, - conversation_id: '', - reply: 'Missing conversationId. The project workspace cannot be prepared.', - build: { status: 'skipped' as BuildStatus }, - preview: {}, - }, - }); - return; - } - - await extendExistingSandboxTimeout(context); - - send({ - type: 'status', - message: 'Running the agent workflow', - }); - - const shouldResetProject = options.resetProject === true; - const state = await prepareProjectWorkspace( - context, - conversationId, - shouldResetProject, - send, - ); - const siteDomain = String(options.siteDomain || '').trim(); - if (siteDomain && state.siteDomain !== siteDomain) { - state.siteDomain = siteDomain; - await saveProjectState(context, conversationId, state); - } - const inboundGateway = resolveGatewayUserTurn(message, options.apiKey); - message = inboundGateway.message; - if (inboundGateway.apiKey || options.gatewaySkip) { - await applyUserGatewayDecision( - context, - state, - conversationId, - { - ...(inboundGateway.apiKey ? { apiKey: inboundGateway.apiKey } : {}), - ...(options.gatewaySkip ? { skip: true } : {}), - }, - send, - ); - } - const history = shouldResetProject - ? [] - : await getHistory(context, conversationId, { - excludeLatestUserMessage: options.userMessagePersisted ? message : undefined, - }); - const isInitialProjectTurn = !state.created; - const hiddenScaffoldToolUseIds = new Set(); - const activityTurnId = options.turnId - || String(context?.run_id || `${Date.now()}-${Math.random().toString(36).slice(2)}`); - - // Mid-turn debounced snapshots + exit-path flush so a recycled sandbox still - // has a restorable workspace in project Blob storage. - const checkpoint = createProjectCheckpointController(context, conversationId, state, (persistenceError) => { - send({ - type: 'log', - phase: 'agent', - stream: 'stderr', - message: persistenceError, - }); - }); - const turn = createTurnLifecycle({ - context, - conversationId, - message, - turnId: activityTurnId, - userMessagePersisted: options.userMessagePersisted === true, - state, - checkpoint, - }); - const recordProgress = turn.recordProgress; - const finalizeTurn = turn.finalize; - - const handleScaffoldLog = (log: ScaffoldLog) => { - if (!isInitialProjectTurn) { - return; - } - send({ - type: 'log', - phase: 'scaffold', - stream: log.stream, - message: log.content, - }); - }; - const forwardProgress = (event: AgentProgressEvent) => { - // Forward structured progress events directly; the frontend renders by type. - if (event.type === 'tool_use') { - const name = event.data.name || ''; - const hideScaffold = !isInitialProjectTurn - && (name === 'ensure_project_scaffold' || name.endsWith('__ensure_project_scaffold')); - if (hideScaffold || isRequestGatewayCredentialsTool(name)) { - hiddenScaffoldToolUseIds.add(event.data.id); - return; - } - } - if (event.type === 'tool_result' && hiddenScaffoldToolUseIds.has(event.data.tool_use_id)) { - return; - } - if (event.type === 'text_segment') { - // Keep the model's step-by-step narration visible; only the final summary - // is compacted. Preview links stay out of the chat. - const text = state.previewUrl - ? stripReturnedPreviewLinks(event.data.text, state.previewUrl) - : event.data.text; - if (text.length === 0) { - return; - } - const narration = { ...event, data: { ...event.data, text } }; - recordProgress(narration); - send(narration as unknown as Record); - return; - } - recordProgress(event); - send(event as unknown as Record); - }; - const fileTreePush = createFileTreePushController(context, state, send); - // The model already handed us the full text of every file it wrote, so stream it - // to the frontend instead of making it fetch the file back over /file (which costs - // a sandbox shell round trip per click). Bounded per file and per turn so a large - // asset cannot bloat the stream or the in-process replay buffer — anything over - // budget simply falls back to /file. - let filePushBudgetBytes = FILE_PUSH_TURN_BUDGET_BYTES; - const handleProjectFilesChanged = async (file?: { path: string; content: string }) => { - if (file) { - const bytes = utf8ByteLength(file.content); - if (bytes <= FILE_PUSH_MAX_BYTES && bytes <= filePushBudgetBytes) { - filePushBudgetBytes -= bytes; - send({ - type: 'file_content', - data: { - path: toAppRelPath(file.path, state.appDir) || file.path, - content: file.content, - size: bytes, - }, - }); - } - } - // The tree follows the content so the panel does not wait for the whole - // turn, but debounced: a scaffold writes a dozen files at once and only the - // last listing is the one anybody sees. - fileTreePush.schedule(); - // Debounced store backup while the agent is still writing — covers the long - // window where files live only in the volatile sandbox. - checkpoint.schedule(); - }; - - // Switch the iframe the moment a direct Makers CLI command returns a URL, - // without waiting for verification or the finalize behind it, which can take - // several more seconds. - const handlePreviewReady = (preview: { url?: string; sandboxDebugUrl?: string; kind?: 'sandbox' | 'makers' }) => { - if (!preview.url) { - return; - } - state.previewUrl = preview.url; - state.sandboxDebugUrl = preview.sandboxDebugUrl; - state.previewKind = preview.kind || (isMakersDeployUrl(preview.url) ? 'makers' : 'sandbox'); - state.previewPublished = true; - // Persist before the turn finishes so a refresh during verification still - // resumes into the preview pane and restarts the live server. - void saveProjectState(context, conversationId, state); - send({ - type: 'preview_ready', - data: { - preview: { - url: preview.url, - sandboxDebugUrl: preview.sandboxDebugUrl, - kind: state.previewKind, - }, - download: { url: '/download', filename: 'source.zip' }, - }, - }); - }; - const handleDeploymentStatus = (deployment: DeploymentInfo) => { - state.deployment = deployment; - // The final turn commit is authoritative. This eager save keeps a completed - // deployment recoverable if the browser refreshes during later model output. - void saveProjectState(context, conversationId, state); - send({ - type: 'deployment_status', - data: deployment, - }); - }; - - // The model handles creative code work; build and service steps remain deterministic. - const modelResult = await runCodingAgent( - context, - conversationId, - message, - history, - state, - !state.created, - handleScaffoldLog, - forwardProgress, - handleProjectFilesChanged, - handlePreviewReady, - handleDeploymentStatus, - abortSignal, - { model: options.model, send }, - ); - - if (modelResult.stopped || abortSignal?.aborted) { - const stoppedReply = STOPPED_TURN_REPLY[replyLocale]; - await finalizeTurn(stoppedReply, 'stopped', { - withSnapshot: modelResult.projectTouched, - }); - send({ - type: 'result', - data: { - ok: false, - stopped: true, - reply: stoppedReply, - conversation_id: conversationId, - build: { status: 'skipped' as BuildStatus }, - preview: previewLinkFromState(state), - deployment: state.deployment, - }, - }); - return; - } - - // Dest was skipped so the user can type a key. That is not a missing preview - // and not a failed build — running verification here would paint the model's - // wrap-up as a red error and then wipe the input card when the turn ended. - if (state.gatewayPromptPending) { - const pauseReply = GATEWAY_CREDENTIALS_USER_REPLY[replyLocale]; - send({ - type: 'agent', - data: { - ok: true, - reply: pauseReply, - }, - }); - - let fileTree: FileTreeItem[] = []; - if (modelResult.projectTouched) { - await checkpoint.flush(); - fileTree = await fileTreePush.flush('Failed to read the file list.'); - } - await finalizeTurn(pauseReply, 'completed', { - withSnapshot: modelResult.projectTouched, - }); - send({ - type: 'result', - data: { - ok: true, - reply: pauseReply, - conversation_id: conversationId, - gatewayNeeded: true, - project: { - dir: state.appDir, - created: modelResult.wasCreated, - }, - build: { status: 'skipped' as BuildStatus }, - files: { - root: state.appDir, - items: fileTree, - }, - download: { url: '/download', filename: 'source.zip' }, - preview: previewLinkFromState(state), - deployment: state.deployment, - }, - }); - return; - } - const sanitizedModelOutput = modelResult.success && modelResult.output - ? sanitizeAssistantText(modelResult.output) - : ''; - const modelOutput = sanitizedModelOutput && !isGenericCompletionReply(sanitizedModelOutput) - ? sanitizedModelOutput - : ''; - const fallbackReply = modelResult.success - ? buildRequirementConclusionFallback(message, state.previewUrl ? 'ready' : 'pending') - : (modelResult.error || 'An error occurred during processing. Please try again.'); - const rawAssistantReply = stripReturnedPreviewLinks(sanitizeAssistantText( - modelOutput || fallbackReply - ) || fallbackReply, state.previewUrl); - // Only a deployment from this turn: state.deployment outlives the turn, and - // re-appending yesterday's URL to every later reply would be worse than none. - const liveDeploymentUrl = modelResult.deploymentTouched - && state.deployment?.status === 'success' - ? state.deployment.url - : undefined; - const assistantReply = withLiveDeploymentUrl( - modelResult.projectTouched - ? compactUserFacingReply(rawAssistantReply, fallbackReply) - : rawAssistantReply, - liveDeploymentUrl, - ); - - send({ - type: 'agent', - data: { - ok: modelResult.success, - reply: assistantReply, - ...(modelResult.error ? { error: modelResult.error } : {}), - }, - }); - - if (modelResult.fatal) { - await finalizeTurn(assistantReply, 'failed', { - withSnapshot: modelResult.projectTouched, - }); - - send({ - type: 'result', - data: { - ok: false, - reply: assistantReply, - conversation_id: conversationId, - build: { - status: 'skipped' as BuildStatus, - stderr: modelResult.error || assistantReply, - }, - preview: {}, - deployment: state.deployment, - }, - }); - return; - } - - if ( - !modelResult.projectTouched - && (modelResult.previewTouched || modelResult.deploymentTouched) - ) { - if (modelResult.previewTouched && state.previewUrl) { - send({ - type: 'preview_ready', - data: { - preview: { - url: state.previewUrl, - sandboxDebugUrl: state.sandboxDebugUrl, - kind: state.previewKind, - }, - }, - }); - } - - const previewReady = !modelResult.previewTouched || Boolean(state.previewUrl); - const deploymentReady = !modelResult.deploymentTouched - || state.deployment?.status === 'success'; - const operationOk = modelResult.success && previewReady && deploymentReady; - await finalizeTurn(assistantReply, operationOk ? 'completed' : 'failed'); - - send({ - type: 'result', - data: { - ok: operationOk, - reply: assistantReply, - conversation_id: conversationId, - build: { status: 'skipped' as BuildStatus }, - preview: modelResult.previewTouched - ? { - url: state.previewUrl, - sandboxDebugUrl: state.sandboxDebugUrl, - kind: state.previewKind, - ...(!state.previewUrl ? { error: 'The agent did not complete the Makers CLI preview.' } : {}), - } - : previewLinkFromState(state), - deployment: state.deployment, - }, - }); - return; - } - - if (!modelResult.projectTouched) { - await finalizeTurn(assistantReply, modelResult.success ? 'completed' : 'failed', { - withState: false, - }); - - send({ - type: 'result', - data: { - ok: modelResult.success, - reply: assistantReply, - conversation_id: conversationId, - build: { status: 'skipped' as BuildStatus }, - preview: {}, - deployment: state.deployment, - }, - }); - return; - } - - // Files are on disk now — flush before verification/auto-fix so that long - // build window cannot recycle the sandbox with only an in-memory project. - await checkpoint.flush(); - - let fileTree = await fileTreePush.flush('Failed to read the file list.'); - // A preview that came up in this turn already compiled this turn's code and - // answered its smoke tests, so the production build has nothing left to prove - // here that publishing does not prove for real. Without one, the build is the - // only evidence the project assembles at all, so it runs. - let build = await runVerification(context, state, { - previewVerified: modelResult.previewTouched && Boolean(state.previewUrl), - }); - let autoFixAttempts = 0; - let autoFixApplied = false; - let autoFixReply = ''; - - // The project has files on disk from here on, so expose a download link. The - // archive is built on demand by /download; this is just a pointer (the - // authoritative filename comes from the /download response). - const downloadLink = { url: '/download', filename: 'source.zip' }; - - if (build.fatal) { - const fatalReply = build.stderr || 'The task failed, and the remaining workflow was stopped.'; - await finalizeTurn(fatalReply, 'failed', { withSnapshot: true }); - - send({ - type: 'result', - data: { - ok: false, - reply: fatalReply, - conversation_id: conversationId, - project: { - dir: state.appDir, - created: modelResult.wasCreated, - }, - build, - files: { - root: state.appDir, - items: fileTree, - }, - download: downloadLink, - preview: {}, - deployment: state.deployment, - }, - }); - return; - } - - if (build.status === 'failed' && modelResult.success) { - autoFixAttempts = AUTO_FIX_MAX_ATTEMPTS; - autoFixApplied = true; - send({ - type: 'status', - message: `Verification failed. Running auto-fix 1/${AUTO_FIX_MAX_ATTEMPTS}`, - }); - - const autoFixPrompt = buildAutoFixPrompt( - message, - assistantReply, - build, - 1, - AUTO_FIX_MAX_ATTEMPTS, - ); - const autoFixResult = await runCodingAgent( - context, - conversationId, - autoFixPrompt, - [ - ...history, - { role: 'user', content: message }, - { role: 'assistant', content: assistantReply }, - ], - state, - false, - handleScaffoldLog, - forwardProgress, - handleProjectFilesChanged, - handlePreviewReady, - handleDeploymentStatus, - abortSignal, - // Repairing on a different model than the one that wrote the code would - // make a failed build hard to attribute to either. - { model: options.model, send }, - ); - if (autoFixResult.stopped || abortSignal?.aborted) { - const stoppedReply = STOPPED_TURN_REPLY[replyLocale]; - await finalizeTurn(stoppedReply, 'stopped', { withSnapshot: true }); - send({ - type: 'result', - data: { - ok: false, - stopped: true, - reply: stoppedReply, - conversation_id: conversationId, - build: { status: 'skipped' as BuildStatus }, - preview: previewLinkFromState(state), - deployment: state.deployment, - }, - }); - return; - } - const rawAutoFixReply = stripReturnedPreviewLinks(sanitizeAssistantText( - autoFixResult.success && autoFixResult.output - ? autoFixResult.output - : autoFixResult.error || '' - ), state.previewUrl); - autoFixReply = autoFixResult.success - ? compactUserFacingReply( - rawAutoFixReply, - buildRequirementConclusionFallback(message, state.previewUrl ? 'ready' : 'generated'), - ) - : rawAutoFixReply; - - if (autoFixReply) { - send({ - type: 'agent', - data: { - ok: autoFixResult.success, - reply: autoFixReply, - ...(autoFixResult.error ? { error: autoFixResult.error } : {}), - }, - }); - } - - fileTree = await fileTreePush.flush('Failed to read the file list after auto-fix.'); - // Deliberately the full verification, preview or not: getting here means - // something was already broken, and the repair is exactly when the cheaper - // evidence is worth the least. - build = await runVerification(context, state); - if (build.fatal) { - const fatalReply = build.stderr || 'The task failed, and the remaining workflow was stopped.'; - await finalizeTurn(fatalReply, 'failed', { withSnapshot: true }); - - send({ - type: 'result', - data: { - ok: false, - reply: fatalReply, - conversation_id: conversationId, - project: { - dir: state.appDir, - created: modelResult.wasCreated, - }, - build, - files: { - root: state.appDir, - items: fileTree, - }, - download: downloadLink, - preview: {}, - deployment: state.deployment, - }, - }); - return; - } - } - - build = { - ...build, - ...(autoFixAttempts > 0 ? { autoFixAttempts, autoFixApplied } : {}), - }; - - // Makers dev owns preview state; deployments are streamed separately. - if (state.previewUrl) { - send({ - type: 'preview_ready', - data: { - preview: { - url: state.previewUrl, - sandboxDebugUrl: state.sandboxDebugUrl, - kind: state.previewKind, - }, - }, - }); - } - - const isChinese = replyLocale === 'zh'; - const outcome = resolveFinishedTurn({ - // Scaffolding sets projectTouched and the workflow asks for it every turn, - // so it cannot stand in for this. - filesWritten: modelResult.filesWritten !== false, - previewUrl: state.previewUrl, - buildFailed: build.status === 'failed', - modelReply: stripReturnedPreviewLinks( - autoFixReply || (modelOutput ? assistantReply : ''), - state.previewUrl, - ), - fallbackReply: buildRequirementConclusionFallback( - message, - build.status !== 'failed' && state.previewUrl ? 'ready' : 'generated', - ), - failureReply: build.status === 'failed' - ? (isChinese ? '项目已生成,但检查未通过,我还需要继续修复。' : 'The project was generated, but checks still fail and need another fix.') - : (isChinese ? '项目已生成,但预览暂时不可用,请重试。' : 'The project was generated, but the preview is temporarily unavailable. Please retry.'), - }); - const previewMissing = outcome.previewMissing; - const turnFailed = outcome.failed; - const reply = withLiveDeploymentUrl(outcome.reply, liveDeploymentUrl); - - // Code first, then state, then conversation — so a crash mid-finalize still - // leaves a restorable workspace for resume after sandbox recycle. - const turnOk = modelResult.success && !turnFailed; - await finalizeTurn(reply, turnOk ? 'completed' : 'failed', { withSnapshot: true }); - - send({ - type: 'result', - data: { - ok: turnOk, - reply, - conversation_id: conversationId, - project: { - dir: state.appDir, - created: modelResult.wasCreated, - }, - build, - files: { - root: state.appDir, - items: fileTree, - }, - download: downloadLink, - preview: { - url: state.previewUrl, - sandboxDebugUrl: state.sandboxDebugUrl, - kind: state.previewKind, - ...(previewMissing ? { error: 'The agent did not complete the Makers CLI preview.' } : {}), - }, - deployment: state.deployment, - }, - }); -} diff --git a/agents/_lib/pipelines/index.ts b/agents/_lib/pipelines/index.ts deleted file mode 100644 index 3a6e3df..0000000 --- a/agents/_lib/pipelines/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { runChatPipeline } from './chat.ts'; -export { DEFAULT_DEPLOY_REQUEST, runDeployPipeline } from './deploy.ts'; -export { runFileReadPipeline } from './file-read.ts'; -export { runProjectDownloadPipeline } from './download.ts'; -export { - createProjectResumeStreamResponse, - runProjectResumePreviewPipeline, -} from './resume.ts'; diff --git a/agents/_lib/pipelines/resume.ts b/agents/_lib/pipelines/resume.ts deleted file mode 100644 index ff79798..0000000 --- a/agents/_lib/pipelines/resume.ts +++ /dev/null @@ -1,579 +0,0 @@ -import { - getActivityHistory, - getChatTask, - getHistory, - getLegacyProjectSnapshot, - getModelPreference, - getProjectState, - saveProjectState, -} from '../memory.ts'; -import { isChatTaskActive, iterateLiveChatTaskEvents } from '../chat-tasks.ts'; -import { - assertPreviewServerReady, - getFileTree, - resolvePublicLinks, - restorePersistedProject, - rewritePreviewAccessToken, - runSandboxCommand, - separateLegacyMakersDeployment, - startPreviewServer, -} from '../project/index.ts'; -import type { ChatTask, FileTreeItem, PersistedActivity, PersistedActivityTurn, ProjectState } from '../types.ts'; -import { createSSEResponse, sseEvent } from '../shared.ts'; -import { isMakersDeployUrl } from '../../../shared/makers-deploy.ts'; -import { isMakersDeployCommand, isMakersDevCommand } from '../../../shared/tool-phase.ts'; -import { resolveConversationId } from '../utils/request.ts'; -import { ensureProjectDependencies, withTimeout } from './helpers.ts'; -import { loadResumeFileContents } from './resume-files.ts'; - -function isMakersPreviewState(state: ProjectState) { - return state.previewKind === 'makers' || isMakersDeployUrl(state.previewUrl); -} - -function toolNameImpliesProject(name: string) { - return name.includes('write_project_file') - || name.includes('ensure_project_scaffold') - || name.includes('write_files') - || /__files_write$/.test(name); -} - -function activityIsMakersCli(activity: PersistedActivity) { - if (activity.kind !== 'tool' || !activity.name.includes('commands')) { - return false; - } - const command = activity.inputSummary || ''; - return isMakersDevCommand(command) || isMakersDeployCommand(command); -} - -function activityHistoryImpliesProject(activityHistory: PersistedActivityTurn[]) { - return activityHistory.some((turn) => - (turn.activities || []).some((activity: PersistedActivity) => - activity.kind === 'tool' - && (toolNameImpliesProject(activity.name || '') || activityIsMakersCli(activity)), - ), - ); -} - -function activityHistoryImpliesPreview(activityHistory: PersistedActivityTurn[]) { - return activityHistory.some((turn) => - (turn.activities || []).some((activity: PersistedActivity) => - activity.kind === 'tool' - && activity.status === 'completed' - && activity.name.includes('commands') - && isMakersDevCommand(activity.inputSummary || ''), - ), - ); -} - -function projectStateImpliesPreview(state: ProjectState, activityHistory: PersistedActivityTurn[] = []) { - return Boolean(state.previewUrl) - || Boolean(state.previewPublished) - || activityHistoryImpliesPreview(activityHistory); -} - -// Hard ceiling for the whole workspace stage so a stuck sandbox call cannot -// leave the browser spinner pending indefinitely after stop/refresh. -// A recycled sandbox may need dependencies plus a cold Makers dev startup. -const WORKSPACE_RESUME_BUDGET_MS = 600_000; -const SANDBOX_PROBE_MS = 15_000; -const RESTORE_BUDGET_MS = 45_000; -const PREVIEW_RESTART_BUDGET_MS = 540_000; - -function jsonResponse(obj: Record, status = 200) { - return new Response(JSON.stringify(obj), { - status, - headers: { - 'content-type': 'application/json; charset=utf-8', - 'cache-control': 'no-store', - }, - }); -} - -// Fast path: store reads only. No sandbox restore / npm install / preview. -// Lets the UI paint chat history immediately after a refresh. -async function loadProjectResumeHistory(context: any, conversationId: string) { - const [messages, activityHistory, snapshot, chatTask, storedState, model] = await Promise.all([ - getHistory(context, conversationId), - getActivityHistory(context, conversationId), - getLegacyProjectSnapshot(context, conversationId), - getChatTask(context, conversationId), - getProjectState(context, conversationId), - getModelPreference(context, conversationId), - ]); - const state = separateLegacyMakersDeployment(storedState); - - // Prefer a durable snapshot, but also open the workspace when the turn clearly - // touched the project (stop mid-write may race the snapshot flush; sandbox may - // still hold files that workspace resume can list). - const hasProject = Boolean(snapshot?.base64) - || Boolean(state.created) - || activityHistoryImpliesProject(activityHistory); - const hasPreview = projectStateImpliesPreview(state, activityHistory); - const activeTask = isChatTaskActive(chatTask) - ? { - id: chatTask.id, - message: chatTask.message, - status: chatTask.status, - resetProject: chatTask.resetProject === true, - createdAt: chatTask.createdAt, - startedAt: chatTask.startedAt, - } - : null; - - return { - ok: true as const, - stage: 'history' as const, - conversation_id: conversationId, - messages, - activityHistory, - activeTask, - hasProject, - hasPreview, - needsWorkspace: hasProject, - deployment: state.deployment, - // Empty until someone picks a model, which leaves the composer on whatever - // the /models route reports as this deployment's default. - model, - gatewayNeeded: state.gatewayPromptPending === true, - }; -} - -export async function runProjectResumeHistoryPipeline(context: any): Promise { - const { conversationId } = resolveConversationId(context, { allowQuery: true }); - if (!conversationId) { - return jsonResponse({ ok: false, error: 'missing conversation_id' }, 400); - } - return jsonResponse(await loadProjectResumeHistory(context, conversationId)); -} - -async function probeSandboxHasFiles(context: any, state: ProjectState) { - if (!(await context.sandbox.files.exists(state.appDir))) { - return false; - } - const tree = await getFileTree(context, state); - return tree.some((item) => item.type === 'file'); -} - -// Warm sandboxes may still be serving port 8088; otherwise install + restart. -// Makers deploy URLs are durable and must not be rewritten with envdAccessToken. -async function republishPreviewOnResume(context: any, state: ProjectState) { - if (isMakersPreviewState(state) && state.previewUrl) { - return { - url: state.previewUrl, - kind: 'makers' as const, - restarted: false, - }; - } - try { - await assertPreviewServerReady(context); - const accessToken = typeof context.sandbox?.envdAccessToken === 'string' - ? context.sandbox.envdAccessToken - : ''; - - // Prefer rotating the token on the URL the iframe already used. This keeps - // an open preview stable even when getHost() issues a fresh sandbox host. - if (state.previewUrl && accessToken) { - const rewritten = rewritePreviewAccessToken(state.previewUrl, accessToken); - if (rewritten) { - const warmLinks = await resolvePublicLinks(context); - state.previewUrl = rewritten; - state.sandboxDebugUrl = warmLinks.sandboxDebugUrl || state.sandboxDebugUrl; - return { - url: rewritten, - sandboxDebugUrl: state.sandboxDebugUrl, - restarted: false, - }; - } - } - - const warmLinks = await resolvePublicLinks(context); - if (warmLinks.previewUrl) { - state.previewUrl = warmLinks.previewUrl; - state.sandboxDebugUrl = warmLinks.sandboxDebugUrl; - return { - url: warmLinks.previewUrl, - sandboxDebugUrl: warmLinks.sandboxDebugUrl, - restarted: false, - }; - } - } catch { - // Server is not ready — fall through to a full restart. - } - - const depsReady = await ensureProjectDependencies(context, state); - if (!depsReady) { - throw new Error('Project dependencies are not available for preview resume.'); - } - - const server = await startPreviewServer(context, state); - await assertPreviewServerReady(context, server.readyPath); - const links = await resolvePublicLinks(context); - if (!links.previewUrl) { - throw new Error('Preview server started but no public preview URL was available.'); - } - state.previewUrl = links.previewUrl; - state.sandboxDebugUrl = links.sandboxDebugUrl; - return { - url: links.previewUrl, - sandboxDebugUrl: links.sandboxDebugUrl, - // The dev server is a new process: whatever an open iframe shows is dead. - restarted: true, - }; -} - -async function runWorkspaceRestoreBody(context: any, conversationId: string) { - const [storedState, chatTask, activityHistory] = await Promise.all([ - getProjectState(context, conversationId), - getChatTask(context, conversationId), - getActivityHistory(context, conversationId), - ]); - const state = separateLegacyMakersDeployment(storedState); - const hadPreview = projectStateImpliesPreview(state, activityHistory); - const generationActive = isChatTaskActive(chatTask); - - let hasFiles = false; - let restoreError: string | undefined; - try { - hasFiles = await withTimeout( - probeSandboxHasFiles(context, state), - SANDBOX_PROBE_MS, - 'sandbox file probe', - ); - } catch (error) { - hasFiles = false; - restoreError = error instanceof Error ? error.message : 'Sandbox probe failed.'; - } - - if (!hasFiles) { - try { - const restored = await withTimeout( - restorePersistedProject(context, conversationId, state, { installDependencies: false }), - RESTORE_BUDGET_MS, - 'snapshot restore', - ); - hasFiles = restored.restored; - if (!restored.restored) restoreError = restored.error; - } catch (error) { - hasFiles = false; - restoreError = error instanceof Error ? error.message : 'Snapshot restore failed.'; - } - } - - if (!hasFiles) { - return { - ok: true as const, - stage: 'workspace' as const, - conversation_id: conversationId, - hasProject: false, - preview: restoreError ? { error: restoreError } : {}, - deployment: state.deployment, - files: { root: state.appDir, items: [] as FileTreeItem[] }, - }; - } - - state.created = true; - - let items: FileTreeItem[] = []; - try { - items = await withTimeout( - getFileTree(context, state), - SANDBOX_PROBE_MS, - 'file tree', - ); - } catch { - items = []; - } - - const hasFileItems = items.some((item) => item.type === 'file'); - // Only restart preview when a Makers CLI preview previously succeeded for this - // conversation. Do NOT key off package.json — a stopped mid-generation - // project often has a scaffold but is not previewable yet. - const shouldRestartPreview = !generationActive && hasFileItems && hadPreview; - - let preview: { url?: string; sandboxDebugUrl?: string; error?: string; restarted?: boolean; kind?: 'sandbox' | 'makers' } = {}; - if (shouldRestartPreview) { - try { - preview = await withTimeout( - republishPreviewOnResume(context, state), - PREVIEW_RESTART_BUDGET_MS, - 'preview resume', - ); - state.previewPublished = true; - } catch (error) { - state.previewUrl = undefined; - state.sandboxDebugUrl = undefined; - // Keep previewPublished so the next refresh retries instead of sticking to Files. - // Keep the files panel usable; do not surface a hard preview error on resume. - console.warn( - '[resume:workspace] preview restart failed:', - error instanceof Error ? error.message : error, - ); - preview = {}; - } - } else if (!generationActive && !hadPreview) { - // Never-published / interrupted projects stay files-only. - state.previewUrl = undefined; - state.sandboxDebugUrl = undefined; - state.previewKind = undefined; - } - - try { - await saveProjectState(context, conversationId, state); - } catch { - // Non-fatal — the files payload below is still useful. - } - - return { - ok: true as const, - stage: 'workspace' as const, - conversation_id: conversationId, - hasProject: hasFileItems || Boolean(state.created), - preview, - deployment: state.deployment, - files: { root: state.appDir, items }, - gatewayNeeded: state.gatewayPromptPending === true, - ...(hasFileItems - ? { download: { url: '/download', filename: 'source.zip' } } - : {}), - }; -} - -// Slow path: restore snapshot into the sandbox (when needed), then restart the -// live preview when the project was previously publishable. -export async function runProjectResumeWorkspacePipeline(context: any): Promise { - const { conversationId } = resolveConversationId(context, { allowQuery: true }); - if (!conversationId) { - return jsonResponse({ ok: false, error: 'missing conversation_id' }, 400); - } - - try { - const payload = await withTimeout( - runWorkspaceRestoreBody(context, conversationId), - WORKSPACE_RESUME_BUDGET_MS, - 'workspace resume', - ); - return jsonResponse(payload); - } catch (error) { - const message = error instanceof Error ? error.message : 'Workspace resume failed.'; - console.warn('[resume:workspace]', message); - return jsonResponse({ - ok: true, - stage: 'workspace', - conversation_id: conversationId, - hasProject: false, - preview: { error: message }, - files: { root: '', items: [] }, - }); - } -} - -// Light path: re-mint the public preview URL (fresh envdAccessToken) without -// restoring the full workspace. Used when the SPA tab stays open but the -// iframe's access_token expires — visibility return / toolbar refresh. -// Falls back to full workspace restore when the sandbox has gone cold. -async function runPreviewRefreshBody(context: any, conversationId: string) { - const [storedState, activityHistory] = await Promise.all([ - getProjectState(context, conversationId), - getActivityHistory(context, conversationId), - ]); - const state = separateLegacyMakersDeployment(storedState); - const hadPreview = projectStateImpliesPreview(state, activityHistory); - if (!hadPreview) { - return { - ok: true as const, - stage: 'preview' as const, - conversation_id: conversationId, - preview: {}, - deployment: state.deployment, - }; - } - - try { - const preview = await republishPreviewOnResume(context, state); - state.previewPublished = true; - try { - await saveProjectState(context, conversationId, state); - } catch { - // Non-fatal — the fresh URL below is still usable for this session. - } - - return { - ok: true as const, - stage: 'preview' as const, - conversation_id: conversationId, - preview, - deployment: state.deployment, - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - console.warn('[resume:preview] remint failed, escalating to workspace restore:', message); - const workspace = await runWorkspaceRestoreBody(context, conversationId); - return { - ...workspace, - stage: 'preview' as const, - }; - } -} - -export async function runProjectResumePreviewPipeline(context: any): Promise { - const { conversationId } = resolveConversationId(context, { allowQuery: true }); - if (!conversationId) { - return jsonResponse({ ok: false, error: 'missing conversation_id' }, 400); - } - - try { - // Allow workspace-restore escalation inside the light path, so budget matches - // the slow resume ceiling (and the client abort in fetchPreviewRefresh). - const payload = await withTimeout( - runPreviewRefreshBody(context, conversationId), - WORKSPACE_RESUME_BUDGET_MS, - 'preview refresh', - ); - return jsonResponse(payload); - } catch (error) { - const message = error instanceof Error ? error.message : 'Preview refresh failed.'; - console.warn('[resume:preview]', message); - return jsonResponse({ - ok: true, - stage: 'preview', - conversation_id: conversationId, - preview: { error: message }, - }); - } -} - -const STREAM_FINISHED = Symbol('finished'); -const STREAM_ABORTED = Symbol('aborted'); - -class AsyncValueQueue { - private values: T[] = []; - private waiters: Array<(value: T) => void> = []; - - push(value: T) { - const waiter = this.waiters.shift(); - if (waiter) waiter(value); - else this.values.push(value); - } - - next() { - const value = this.values.shift(); - if (value !== undefined) return Promise.resolve(value); - return new Promise((resolve) => this.waiters.push(resolve)); - } -} - -async function* mergeSseGenerators( - generators: Array>, - signal?: AbortSignal, -): AsyncGenerator { - if (generators.length === 1) { - yield* generators[0]; - return; - } - - const queue = new AsyncValueQueue(); - let remaining = generators.length; - const abortPromise = signal - ? new Promise((resolve) => { - if (signal.aborted) resolve(STREAM_ABORTED); - else signal.addEventListener('abort', () => resolve(STREAM_ABORTED), { once: true }); - }) - : null; - - const pump = async (gen: AsyncGenerator) => { - try { - for await (const chunk of gen) { - if (signal?.aborted) return; - queue.push(chunk); - } - } finally { - remaining -= 1; - if (remaining === 0) queue.push(STREAM_FINISHED); - } - }; - - for (const gen of generators) void pump(gen); - - while (!signal?.aborted) { - const item = await (abortPromise - ? Promise.race([queue.next(), abortPromise]) - : queue.next()); - if (item === STREAM_FINISHED || item === STREAM_ABORTED) return; - yield item; - } -} - -async function* iterateWorkspaceResumeEvents( - context: any, - conversationId: string, - signal?: AbortSignal, -): AsyncGenerator { - try { - const workspace = await withTimeout( - runWorkspaceRestoreBody(context, conversationId), - WORKSPACE_RESUME_BUDGET_MS, - 'workspace resume', - ); - if (signal?.aborted) return; - yield sseEvent({ type: 'resume_workspace', data: workspace }); - - // Warm the browser's source cache over this same resume connection. The - // workspace event is sent first so the UI remains progressive; each file - // then becomes immediately browseable without a /file route call. - const fileItems = workspace.files?.items || []; - if (!signal?.aborted && fileItems.length > 0) { - const contents = await loadResumeFileContents(context, conversationId, fileItems); - for (const file of contents) { - if (signal?.aborted) return; - yield sseEvent({ type: 'resume_file_content', data: file }); - } - } - } catch (error) { - const message = error instanceof Error ? error.message : 'Workspace resume failed.'; - console.warn('[resume:stream]', message); - if (!signal?.aborted) { - yield sseEvent({ - type: 'resume_workspace', - data: { - ok: true, - stage: 'workspace', - conversation_id: conversationId, - hasProject: false, - preview: { error: message }, - files: { root: '', items: [] }, - }, - }); - } - } -} - -/** - * Session entry: history first, then workspace restore and/or a live task on - * the same SSE connection. Creating or opening a conversation always hits - * GET /session; POST /session is only for a new user message. - */ -export async function createProjectResumeStreamResponse(context: any): Promise { - const { conversationId } = resolveConversationId(context, { allowQuery: true }); - if (!conversationId) { - return jsonResponse({ ok: false, error: 'missing conversation_id' }, 400); - } - - return createSSEResponse(async function* (signal) { - const history = await loadProjectResumeHistory(context, conversationId); - yield sseEvent({ type: 'resume_history', data: history }); - - if (signal?.aborted) return; - - const storedTask = await getChatTask(context, conversationId); - const liveTask: ChatTask | null = isChatTaskActive(storedTask) ? storedTask : null; - const generators: Array> = []; - if (history.needsWorkspace) { - generators.push(iterateWorkspaceResumeEvents(context, conversationId, signal)); - } - if (liveTask) { - generators.push(iterateLiveChatTaskEvents(context, conversationId, liveTask, undefined, signal)); - } - if (generators.length === 0) return; - yield* mergeSseGenerators(generators, signal); - }, context?.request?.signal); -} diff --git a/agents/_lib/pipelines/turn-lifecycle.ts b/agents/_lib/pipelines/turn-lifecycle.ts deleted file mode 100644 index 73e6a00..0000000 --- a/agents/_lib/pipelines/turn-lifecycle.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { appendTurn, saveActivityTurn, saveProjectState } from '../memory.ts'; -import type { - AgentProgressEvent, - PersistedActivity, - ProjectState, -} from '../types.ts'; -import type { ProjectCheckpointController } from './helpers.ts'; - -type TurnStatus = 'completed' | 'failed' | 'stopped'; - -type TurnLifecycleOptions = { - context: any; - conversationId: string; - message: string; - turnId: string; - userMessagePersisted: boolean; - state: ProjectState; - checkpoint: ProjectCheckpointController; -}; - -/** Owns progress aggregation and the durable commit order for one chat turn. */ -export function createTurnLifecycle(options: TurnLifecycleOptions) { - const activities: PersistedActivity[] = []; - - const recordProgress = (event: AgentProgressEvent) => { - if (event.type === 'text_segment') { - const text = event.data.text; - if (!text) return; - const last = activities.at(-1); - if (last?.kind === 'text') { - if (last.content.endsWith(text) || last.content.endsWith(text.trim())) return; - activities[activities.length - 1] = { ...last, content: `${last.content}${text}` }; - } else { - activities.push({ kind: 'text', content: text }); - } - return; - } - - if (event.type === 'tool_use') { - const existing = activities.find( - (item): item is Extract => - item.kind === 'tool' && item.toolUseId === event.data.id, - ); - if (existing) { - existing.name = event.data.name || existing.name; - existing.inputSummary = event.data.inputSummary || existing.inputSummary; - return; - } - activities.push({ - kind: 'tool', - toolUseId: event.data.id, - name: event.data.name, - status: 'running', - inputSummary: event.data.inputSummary, - startedAt: event.data.startedAt || Date.now(), - }); - return; - } - - const existing = activities.find( - (item): item is Extract => - item.kind === 'tool' && item.toolUseId === event.data.tool_use_id, - ); - if (existing) { - existing.status = event.data.status || (event.data.ok ? 'completed' : 'failed'); - existing.outputSummary = event.data.outputSummary || event.data.preview; - existing.endedAt = event.data.endedAt || Date.now(); - } - }; - - const finalize = async ( - assistant: string, - status: TurnStatus, - finalizeOptions?: { withSnapshot?: boolean; withState?: boolean }, - ) => { - if (status === 'stopped') { - for (const activity of activities) { - if (activity.kind === 'tool' && activity.status === 'running') { - activity.status = 'stopped'; - activity.endedAt = Date.now(); - } - } - } - - // Commit order matters: snapshot → project metadata → conversation. - if (finalizeOptions?.withSnapshot === true) await options.checkpoint.flush(); - if (finalizeOptions?.withState !== false) { - await saveProjectState(options.context, options.conversationId, options.state); - } - if (!options.userMessagePersisted) { - await appendTurn(options.context, options.conversationId, 'user', options.message); - } - await appendTurn(options.context, options.conversationId, 'assistant', assistant); - await saveActivityTurn(options.context, options.conversationId, { - id: options.turnId, - user: options.message, - assistant, - status, - createdAt: Date.now(), - activities, - }); - }; - - return { recordProgress, finalize }; -} diff --git a/agents/_lib/pipelines/workspace.ts b/agents/_lib/pipelines/workspace.ts deleted file mode 100644 index 795bb6f..0000000 --- a/agents/_lib/pipelines/workspace.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { clearLegacyProjectSnapshot, getProjectState } from '../memory.ts'; -import { - createProjectState, - getFileTree, - resetProjectWorkspace, - restorePersistedProject, - separateLegacyMakersDeployment, -} from '../project/index.ts'; -import type { ProjectState, StreamSend } from '../types.ts'; - -/** Restore or reset the volatile sandbox before an agent turn starts. */ -export async function prepareProjectWorkspace( - context: any, - conversationId: string, - resetProject: boolean, - send: StreamSend, -): Promise { - const state = resetProject - ? createProjectState(conversationId) - : separateLegacyMakersDeployment(await getProjectState(context, conversationId)); - - if (resetProject) { - await resetProjectWorkspace(context, state); - await clearLegacyProjectSnapshot(context, conversationId); - // The snapshot only speeds up later sessions, so a failure here (backend - // unavailable, size cap, quota) must not abort the turn the user asked for. - try { - await context.sandbox.persist({ path: state.appDir }); - } catch (error) { - send({ - type: 'log', - phase: 'scaffold', - stream: 'stderr', - message: error instanceof Error ? error.message : 'Snapshot save failed.', - }); - } - return state; - } - - try { - let hasProjectFiles = false; - try { - if (await context.sandbox.files.exists(state.appDir)) { - const tree = await getFileTree(context, state); - hasProjectFiles = tree.some((item) => item.type === 'file'); - } - } catch { - hasProjectFiles = false; - } - - if (!hasProjectFiles) { - send({ type: 'status', message: 'Restoring project from snapshot' }); - const restored = await restorePersistedProject(context, conversationId, state); - if (!restored.restored) { - if (restored.error) { - send({ - type: 'log', - phase: 'scaffold', - stream: 'stderr', - message: restored.error, - }); - } - } else { - hasProjectFiles = true; - } - } - - await ensureWorkspaceDirectories(context, state); - if (hasProjectFiles) state.created = true; - } catch (error) { - send({ - type: 'log', - phase: 'scaffold', - stream: 'stderr', - message: error instanceof Error ? error.message : 'Snapshot restore check failed.', - }); - try { - await ensureWorkspaceDirectories(context, state); - } catch { - // Scaffold reports the actionable error if directory creation still fails. - } - } - - return state; -} - -async function ensureWorkspaceDirectories(context: any, state: ProjectState) { - await context.sandbox.files.makeDir(state.sessionDir); - await context.sandbox.files.makeDir(state.appDir); -} diff --git a/agents/_lib/project/archive.ts b/agents/_lib/project/archive.ts index 44ae9c0..9388692 100644 --- a/agents/_lib/project/archive.ts +++ b/agents/_lib/project/archive.ts @@ -1,13 +1,20 @@ +import { requireSandbox, type AgentContext } from '../runtime/context.ts'; import { ARCHIVE_EXCLUDED_DIRECTORIES, ARCHIVE_EXCLUDED_FILENAMES, DOWNLOAD_ARCHIVE_MAX_BYTES, } from '../constants.ts'; -import type { LegacyProjectSnapshot, ProjectState } from '../types.ts'; +import type { ProjectState } from '../types.ts'; import { safeSegment } from '../utils/paths.ts'; import { runSandboxCommand } from './commands.ts'; import { assertResettableProjectPath } from './state.ts'; -import { shellQuote } from '../../../shared/shell.ts'; +import { shellQuote } from '../utils/shell.ts'; + +type LegacyProjectSnapshot = { + base64: string; + filename: string; + contentType?: string; +}; type ProjectArchiveResult = | { @@ -49,10 +56,10 @@ function isArchiveBase64Valid( // Zip state.appDir inside the sandbox and return it base64-encoded. files.read // is UTF-8 only and corrupts binary, so the bytes are read out via `base64`. export async function createProjectArchive( - context: any, + context: AgentContext, state: ProjectState, ): Promise { - const sandbox = context.sandbox; + const sandbox = requireSandbox(context); const appDirExists = await sandbox.files.exists(state.appDir); if (!appDirExists) { @@ -181,11 +188,11 @@ export async function createProjectArchive( // Inverse of createProjectArchive: restore a persisted base64 archive back into // the (empty/recycled) sandbox appDir, then reinstall dependencies. Used when the // sandbox no longer has the code but a snapshot exists in the store -// (agents/_lib/memory.ts). Binary must be produced inside the sandbox via `base64 -d` — the +// (agents/_lib/session/store.ts). Binary must be produced inside the sandbox via `base64 -d` — the // sandbox files.write API is UTF-8 only — so we write the base64 as text and // decode + extract with shell, mirroring createProjectArchive's packing path. export async function restoreProjectArchive( - context: any, + context: AgentContext, state: ProjectState, snapshot: LegacyProjectSnapshot, options: { installDependencies?: boolean } = {}, @@ -195,7 +202,7 @@ export async function restoreProjectArchive( } assertResettableProjectPath(state); - const sandbox = context.sandbox; + const sandbox = requireSandbox(context); await sandbox.files.makeDir(state.sessionDir); await sandbox.files.makeDir(state.appDir); diff --git a/agents/_lib/project/commands.ts b/agents/_lib/project/commands.ts index 8524297..81f0814 100644 --- a/agents/_lib/project/commands.ts +++ b/agents/_lib/project/commands.ts @@ -1,5 +1,6 @@ -import { resolveSandboxCommandOptions } from '../../../shared/sandbox-command.ts'; -import { parseEchoedExitCode, stripEchoedExit, withExitCodeEcho } from '../utils/tool-phase.ts'; +import { requireSandbox, type SandboxCapable } from '../runtime/context.ts'; +import { resolveSandboxCommandOptions } from '../project/sandbox-command.ts'; +import { parseEchoedExitCode, stripEchoedExit, withExitCodeEcho } from '../makers/tool-phase.ts'; export { resolveSandboxCommandOptions }; @@ -18,13 +19,13 @@ type SandboxCommandResult = { }; export async function runSandboxCommand( - context: any, + context: SandboxCapable, command: string, options: SandboxCommandOptions = {}, ): Promise { const resolved = resolveSandboxCommandOptions(options); try { - const result = await context.sandbox.commands.run(command, resolved) as SandboxCommandResult; + const result = await requireSandbox(context).commands.run(command, resolved) as SandboxCommandResult; const stdout = typeof result.stdout === 'string' ? result.stdout : ''; const stderr = typeof result.stderr === 'string' ? result.stderr : ''; if (result.exitCode !== 0 && !stdout.trim() && !stderr.trim()) { @@ -45,7 +46,7 @@ export async function runSandboxCommand( } export async function runCommandCapturingExit( - context: any, + context: SandboxCapable, command: string, options: SandboxCommandOptions = {}, ): Promise { diff --git a/agents/_lib/pipelines/download.ts b/agents/_lib/project/download.ts similarity index 77% rename from agents/_lib/pipelines/download.ts rename to agents/_lib/project/download.ts index 16aa606..1bcfa50 100644 --- a/agents/_lib/pipelines/download.ts +++ b/agents/_lib/project/download.ts @@ -1,8 +1,10 @@ -import { getProjectState } from '../memory.ts'; -import { createProjectArchive, restorePersistedProject } from '../project/index.ts'; -import { resolveConversationId } from '../utils/request.ts'; +import type { AgentContext } from '../runtime/context.ts'; +import { getProjectState } from '../session/store.ts'; +import { createProjectArchive } from './archive.ts'; +import { restorePersistedProject } from './persistence.ts'; +import { resolveConversationId } from '../runtime/request.ts'; -export async function runProjectDownloadPipeline(context: any): Promise { +export async function runProjectDownloadPipeline(context: AgentContext): Promise { const { conversationId } = resolveConversationId(context, { allowQuery: true }); const jsonError = (error: string, status = 400) => new Response( diff --git a/agents/_lib/project/fs.ts b/agents/_lib/project/fs.ts index 096ed00..da9fae5 100644 --- a/agents/_lib/project/fs.ts +++ b/agents/_lib/project/fs.ts @@ -1,3 +1,4 @@ +import { requireSandbox, type SandboxCapable } from '../runtime/context.ts'; import { FILE_TREE_IGNORED_DIRECTORIES, isIgnoredFileTreePath, @@ -13,13 +14,8 @@ import { } from '../utils/file-preview.ts'; import { readFileExtension } from '../utils/paths.ts'; import { runSandboxCommand } from './commands.ts'; -import { repairNestedAppDirLayout } from './scaffold.ts'; - -export async function getFileTree(context: any, state: ProjectState): Promise { - // Heal sessions that still have the mistaken appDir/appDir/... layout before - // listing, so the Files panel shows package.json at the root. - await repairNestedAppDirLayout(context, state); +export async function getFileTree(context: SandboxCapable, state: ProjectState): Promise { const ignoredDirectoryPruneExpression = FILE_TREE_IGNORED_DIRECTORIES .map((dir) => `-path './${dir}'`) .join(' -o '); @@ -108,7 +104,7 @@ function describeReadFailure(message: string): string { } export async function readFileFromSandbox( - context: any, + context: SandboxCapable, state: ProjectState, relPath: string, ): Promise { @@ -119,10 +115,10 @@ export async function readFileFromSandbox( let content: string; try { - const result = await context.sandbox.files.read(`${state.appDir}/${relPath}`); + const result: unknown = await requireSandbox(context).files.read(`${state.appDir}/${relPath}`); if (typeof result === 'string') { content = result; - } else if (result instanceof Uint8Array) { + } else if (ArrayBuffer.isView(result)) { content = new TextDecoder().decode(result); } else if (result instanceof ArrayBuffer) { content = new TextDecoder().decode(new Uint8Array(result)); @@ -162,7 +158,7 @@ export async function readFileFromSandbox( } export async function readFilesFromSandbox( - context: any, + context: SandboxCapable, state: ProjectState, paths: string[], ): Promise> { diff --git a/agents/_lib/project/gateway-prompt.ts b/agents/_lib/project/gateway.ts similarity index 59% rename from agents/_lib/project/gateway-prompt.ts rename to agents/_lib/project/gateway.ts index a260f1f..9ff219d 100644 --- a/agents/_lib/project/gateway-prompt.ts +++ b/agents/_lib/project/gateway.ts @@ -1,18 +1,16 @@ /** * Models API key collection for a generated AI project. * - * `.env.example` declares the names. The agent checks before preview or deploy - * and asks the user when `.env` has no key. The host shows the input card; the - * next user turn carries the key (masked in the transcript) or a skip, and - * this module writes `.env` so the CLI can load it. + * `.env.example` declares the names. The host shows the input card as soon as + * it sees an AI project. Generation and preview keep going; submitting a key + * writes `.env` without opening a coding-agent turn. */ -import { tool as defineClaudeTool } from '@anthropic-ai/claude-agent-sdk'; -import { saveProjectState } from '../memory.ts'; -import type { ClaudeMcpTool, ProjectState, StreamSend } from '../types.ts'; -import { stringifyToolResult } from '../utils/text.ts'; +import { persistWorkspace, setGatewayPending, setGatewaySkipped } from './workspace-store.ts'; +import { requireSandbox, type AgentContext, type SandboxCapable } from '../runtime/context.ts'; +import type { ProjectState, StreamSend } from '../types.ts'; import { getFileTree } from './fs.ts'; -import { AGENT_GATEWAY_ENV_KEYS } from './makers-declarations.ts'; +import { AGENT_GATEWAY_ENV_KEYS } from '../makers/declarations.ts'; /** Origin the Claude Agent SDK wants. OpenAI-compatible clients need `/v1` on top. */ export const AI_GATEWAY_ORIGIN = 'https://ai-gateway.edgeone.link'; @@ -28,20 +26,13 @@ export function gatewayBaseUrlForAgentFramework(framework?: string | null) { return framework === 'claude-agent-sdk' ? AI_GATEWAY_ORIGIN : DEFAULT_AI_GATEWAY_BASE_URL; } -export const REQUEST_GATEWAY_CREDENTIALS_TOOL = 'request_gateway_credentials'; - export const GATEWAY_CREDENTIALS_PAUSE_MESSAGE = [ 'AI_GATEWAY_API_KEY is not set in the project .env.', 'The user has been shown the API key input card.', - 'End this turn now. Do not run preview or deploy, and do not call this again.', - 'A later turn will continue after they provide a key or skip.', + 'Do not run edgeone makers deploy until they provide a key or skip.', + 'A missing key is not a preview failure, but a live publish still needs the card answered.', ].join(' '); -export function isRequestGatewayCredentialsTool(name: string) { - return name === REQUEST_GATEWAY_CREDENTIALS_TOOL - || name.endsWith(`__${REQUEST_GATEWAY_CREDENTIALS_TOOL}`); -} - export function declaredGatewayKeys(content: string): string[] { return AGENT_GATEWAY_ENV_KEYS.filter((key) => ( new RegExp(`^\\s*(?:export\\s+)?${key}\\s*=`, 'm').test(content) @@ -61,9 +52,9 @@ export function envAssignmentValue(content: string, key: string): string { return value.trim(); } -async function readProjectFile(context: any, state: ProjectState, relPath: string) { +async function readProjectFile(context: SandboxCapable, state: ProjectState, relPath: string) { try { - const content = await context.sandbox.files.read(`${state.appDir}/${relPath}`); + const content = await requireSandbox(context).files.read(`${state.appDir}/${relPath}`); return typeof content === 'string' ? content : ''; } catch { return ''; @@ -71,23 +62,23 @@ async function readProjectFile(context: any, state: ProjectState, relPath: strin } export async function projectDeclaresGatewayKeys( - context: any, + context: AgentContext, state: ProjectState, ): Promise { const content = await readProjectFile(context, state, '.env.example'); return Boolean(content) && declaredGatewayKeys(content).length > 0; } -async function projectHasAgentsDirectory(context: any, state: ProjectState) { +async function projectHasAgentsDirectory(context: AgentContext, state: ProjectState) { try { - return Boolean(await context.sandbox.files.exists(`${state.appDir}/agents`)); + return Boolean(await requireSandbox(context).files.exists(`${state.appDir}/agents`)); } catch { return false; } } export async function projectNeedsGatewayKey( - context: any, + context: AgentContext, state: ProjectState, ): Promise { return await projectDeclaresGatewayKeys(context, state) @@ -95,14 +86,14 @@ export async function projectNeedsGatewayKey( } export async function sandboxGatewayKeyIsSet( - context: any, + context: AgentContext, state: ProjectState, ): Promise { const content = await readProjectFile(context, state, '.env'); return Boolean(content) && Boolean(envAssignmentValue(content, 'AI_GATEWAY_API_KEY')); } -async function readProjectAgentFramework(context: any, state: ProjectState) { +async function readProjectAgentFramework(context: AgentContext, state: ProjectState) { const content = await readProjectFile(context, state, 'edgeone.json'); if (!content) return ''; try { @@ -114,7 +105,7 @@ async function readProjectAgentFramework(context: any, state: ProjectState) { } export async function readProjectGatewayEnv( - context: any, + context: AgentContext, state: ProjectState, ): Promise> { const content = await readProjectFile(context, state, '.env'); @@ -142,7 +133,7 @@ function upsertEnvValues(content: string, values: Record) { } export async function writeSandboxGatewayEnv( - context: any, + context: AgentContext, state: ProjectState, values: Record, ) { @@ -150,18 +141,18 @@ export async function writeSandboxGatewayEnv( const envPath = `${state.appDir}/.env`; let current = ''; try { - const existing = await context.sandbox.files.read(envPath); + const existing = await requireSandbox(context).files.read(envPath); if (typeof existing === 'string') current = existing; } catch { current = ''; } const next = upsertEnvValues(current, values); if (next === current.replace(/\r\n/g, '\n').replace(/\n*$/, '\n')) return; - await context.sandbox.files.write(envPath, next); + await requireSandbox(context).files.write(envPath, next); } async function publishFileTreeAfterEnvWrite( - context: any, + context: AgentContext, state: ProjectState, send?: StreamSend, ) { @@ -175,7 +166,7 @@ async function publishFileTreeAfterEnvWrite( }, }); } catch { - // The files panel refreshes again at the end of the turn. + // The files panel still receives the listing on this SSE when a turn is live. } } @@ -184,14 +175,8 @@ export type GatewayPromptOptions = { send?: StreamSend; }; -export async function askUserForGatewayCredentials( - context: any, - state: ProjectState, - options: GatewayPromptOptions = {}, -) { - state.gatewayPromptPending = true; - await persistGatewayState(context, options.conversationId || '', state); - options.send?.({ +function emitGatewayNeeded(send: StreamSend | undefined) { + send?.({ type: 'gateway_credentials', data: { status: 'needed', @@ -200,8 +185,43 @@ export async function askUserForGatewayCredentials( }); } +function emitGatewayResolved(send: StreamSend | undefined, skipped = false) { + send?.({ + type: 'gateway_credentials', + data: { + status: 'resolved', + ...(skipped ? { skipped: true } : {}), + }, + }); +} + +export async function askUserForGatewayCredentials( + context: AgentContext, + state: ProjectState, + options: GatewayPromptOptions = {}, +) { + if (state.gatewaySkipped) return; + if (await sandboxGatewayKeyIsSet(context, state)) return; + if (state.gatewayPromptPending) { + emitGatewayNeeded(options.send); + return; + } + setGatewayPending(state, true); + await persistGatewayState(context, options.conversationId || '', state); + emitGatewayNeeded(options.send); +} + +export function writeSuggestsAiGatewayProject(relPath: string, content: string) { + const path = relPath.replace(/^\.?\//, ''); + if (path === 'agents' || path.startsWith('agents/')) return true; + if (path === '.env.example' || path.endsWith('/.env.example')) { + return declaredGatewayKeys(content).length > 0; + } + return false; +} + export async function shouldPauseForGatewayCredentials( - context: any, + context: AgentContext, state: ProjectState, ): Promise { if (state.gatewaySkipped) return false; @@ -210,7 +230,7 @@ export async function shouldPauseForGatewayCredentials( } export async function pauseForGatewayCredentialsIfNeeded( - context: any, + context: AgentContext, state: ProjectState, options: GatewayPromptOptions = {}, ): Promise { @@ -220,30 +240,30 @@ export async function pauseForGatewayCredentialsIfNeeded( } async function persistGatewayState( - context: any, + context: AgentContext, conversationId: string, state: ProjectState, ) { const id = conversationId.trim(); if (!id) return; try { - await saveProjectState(context, id, state); + await persistWorkspace(context, id, state); } catch { // The card and `.env` write are still useful without a durable flag. } } export async function applyUserGatewayDecision( - context: any, + context: AgentContext, state: ProjectState, conversationId: string, decision: { apiKey?: string; skip?: boolean }, send?: StreamSend, -) { +): Promise<{ AI_GATEWAY_API_KEY?: string; AI_GATEWAY_BASE_URL?: string }> { if (decision.skip) { - state.gatewayPromptPending = false; - state.gatewaySkipped = true; + setGatewaySkipped(state, true); await persistGatewayState(context, conversationId, state); + emitGatewayResolved(send, true); return {}; } @@ -257,82 +277,10 @@ export async function applyUserGatewayDecision( ), }; await writeSandboxGatewayEnv(context, state, values); - state.gatewayPromptPending = false; - state.gatewaySkipped = false; + setGatewayPending(state, false); + setGatewaySkipped(state, false); await persistGatewayState(context, conversationId, state); await publishFileTreeAfterEnvWrite(context, state, send); + emitGatewayResolved(send); return values; } - -export function buildRequestGatewayCredentialsTool(options: { - context: any; - state: ProjectState; - conversationId?: string; - send?: StreamSend; -}): ClaudeMcpTool { - const { context, state, conversationId, send } = options; - return defineClaudeTool( - REQUEST_GATEWAY_CREDENTIALS_TOOL, - [ - 'Before preview or deploy of an AI project, check whether .env has a non-empty AI_GATEWAY_API_KEY.', - 'If the key is already set, not required, or the user already skipped, continue with preview or deploy.', - 'If the key is missing, this shows the user the API key input card and you must end the turn.', - 'Do not run edgeone makers dest or deploy after this tool says the user has been asked.', - ].join(' '), - {}, - async () => { - if (state.gatewaySkipped) { - return { - content: [{ - type: 'text' as const, - text: stringifyToolResult({ - needed: true, - configured: false, - skipped: true, - instruction: 'The user already skipped the API key. Continue preview or deploy without writing .env. A missing key is not a preview or deploy failure.', - }), - }], - }; - } - - const needed = await projectNeedsGatewayKey(context, state); - if (!needed) { - return { - content: [{ - type: 'text' as const, - text: stringifyToolResult({ - needed: false, - instruction: 'This project does not need a Models API key. Continue.', - }), - }], - }; - } - - if (await sandboxGatewayKeyIsSet(context, state)) { - return { - content: [{ - type: 'text' as const, - text: stringifyToolResult({ - needed: true, - configured: true, - instruction: 'AI_GATEWAY_API_KEY is already set. Continue preview or deploy. Do not quote the value.', - }), - }], - }; - } - - await askUserForGatewayCredentials(context, state, { conversationId, send }); - return { - content: [{ - type: 'text' as const, - text: stringifyToolResult({ - needed: true, - configured: false, - askedUser: true, - instruction: GATEWAY_CREDENTIALS_PAUSE_MESSAGE, - }), - }], - }; - }, - ) as ClaudeMcpTool; -} diff --git a/agents/_lib/project/index.ts b/agents/_lib/project/index.ts deleted file mode 100644 index 1492597..0000000 --- a/agents/_lib/project/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -export { runCommandCapturingExit, runSandboxCommand } from './commands.ts'; -export { - createProjectState, - resetProjectWorkspace, - separateLegacyMakersDeployment, -} from './state.ts'; -export { - ensureProjectScaffold, - repairNestedAppDirLayout, - runVerification, -} from './scaffold.ts'; -export { - getFileTree, - readFileFromSandbox, - readFilesFromSandbox, - type FileReadResult, -} from './fs.ts'; -export { - resolvePublicLinks, - rewritePreviewAccessToken, - publishRunningPreview, - startPreviewServer, - assertPreviewServerReady, -} from './preview.ts'; -export { createProjectArchive, restoreProjectArchive } from './archive.ts'; -export { resolveMakersProjectName } from './makers-deploy.ts'; -export { restorePersistedProject } from './persistence.ts'; diff --git a/agents/_lib/project/layout.ts b/agents/_lib/project/layout.ts new file mode 100644 index 0000000..8c280d2 --- /dev/null +++ b/agents/_lib/project/layout.ts @@ -0,0 +1,75 @@ +import type { ProjectState, ScaffoldLog } from '../types.ts'; +import { requireSandbox, type SandboxCapable } from '../runtime/context.ts'; +import { runSandboxCommand } from './commands.ts'; +import { shellQuote } from '../utils/shell.ts'; + +// Models used to pass `${appDir}/file` into write_project_file, which joined +// appDir again and created appDir/appDir/... . Lift that nested tree back to +// the real project root when we detect the classic nesting marker. +export async function repairNestedAppDirLayout( + context: SandboxCapable, + state: ProjectState, + onLog?: (log: ScaffoldLog) => void, +): Promise { + const nestedRel = state.appDir; + // Probe before running the repair, even though the script's first line is the + // same test. The probe is not what this costs — running the script is, on + // every turn, for a legacy bug that almost no project has. Skipping the probe + // to save a round trip put a command that had barely ever run in production + // in front of the first tool of every conversation. + try { + if (!(await requireSandbox(context).files.exists(`${state.appDir}/${nestedRel}`))) { + return false; + } + } catch { + return false; + } + + let result; + try { + result = await runSandboxCommand( + context, + [ + 'set -e', + `NESTED=${shellQuote(nestedRel)}`, + 'if [ ! -d "$NESTED" ]; then exit 0; fi', + // Classic bug shape: real project under appDir/appDir, root missing package.json. + 'if [ ! -f "$NESTED/package.json" ] && [ ! -f "$NESTED/index.html" ]; then exit 0; fi', + 'if [ -f ./package.json ]; then exit 0; fi', + 'for item in "$NESTED"/*; do', + ' [ -e "$item" ] || continue', + ' name=$(basename "$item")', + ' [ "$name" = "projects" ] && continue', + ' rm -rf "./$name"', + ' mv "$item" "./$name"', + 'done', + 'rm -rf ./projects', + 'echo REPAIRED', + ].join('\n'), + { + cwd: state.appDir, + timeout: 60, + }, + ); + } catch { + // The sandbox raises on a failed command instead of returning its exit + // code, so the check below never sees one and this is the only place a + // failure can be absorbed. Absorbing it is the point: repairing a layout + // almost no project has must not cost a turn to every project that does + // not, and the scaffold that follows reports anything genuinely wrong. + return false; + } + + if (result.exitCode !== 0) { + return false; + } + + const repaired = result.stdout.includes('REPAIRED'); + if (repaired) { + onLog?.({ + stream: 'status', + content: 'Fixed nested project paths and restored files to the workspace root.', + }); + } + return repaired; +} diff --git a/agents/_lib/project/persistence.ts b/agents/_lib/project/persistence.ts index 4ecc7fc..c255a4e 100644 --- a/agents/_lib/project/persistence.ts +++ b/agents/_lib/project/persistence.ts @@ -1,16 +1,16 @@ -import { clearLegacyProjectSnapshot, getLegacyProjectSnapshot } from '../memory.ts'; +import { requireSandbox, type AgentContext } from '../runtime/context.ts'; import type { ProjectState } from '../types.ts'; import { restoreProjectArchive } from './archive.ts'; import { runSandboxCommand } from './commands.ts'; export async function restorePersistedProject( - context: any, + context: AgentContext, conversationId: string, state: ProjectState, options: { installDependencies?: boolean } = {}, -): Promise<{ restored: boolean; migratedLegacy?: boolean; error?: string }> { +): Promise<{ restored: boolean; error?: string }> { try { - const restored = await context.sandbox.restore({ path: state.appDir }); + const restored = await requireSandbox(context).restore?.({ path: state.appDir }); if (restored?.restored) { if (options.installDependencies !== false) await installDependencies(context, state); return { restored: true }; @@ -18,26 +18,16 @@ export async function restorePersistedProject( } catch (error) { return { restored: false, error: error instanceof Error ? error.message : String(error) }; } - - const legacy = await getLegacyProjectSnapshot(context, conversationId); - if (!legacy) return { restored: false }; - const restoredLegacy = await restoreProjectArchive(context, state, legacy, options); - if (!restoredLegacy.ok) return { restored: false, error: restoredLegacy.error }; - - try { - await context.sandbox.persist({ path: state.appDir }); - await clearLegacyProjectSnapshot(context, conversationId); - } catch { - // Keep the legacy metadata until migration has durably completed. - } - return { restored: true, migratedLegacy: true }; + return { restored: false }; } -async function installDependencies(context: any, state: ProjectState) { - if (!(await context.sandbox.files.exists(`${state.appDir}/package.json`))) return; - if (await context.sandbox.files.exists(`${state.appDir}/node_modules`)) return; +async function installDependencies(context: AgentContext, state: ProjectState) { + if (!(await requireSandbox(context).files.exists(`${state.appDir}/package.json`))) return; + if (await requireSandbox(context).files.exists(`${state.appDir}/node_modules`)) return; await runSandboxCommand(context, 'npm install --no-audit --no-fund', { cwd: state.appDir, timeout: 300, }); } + +export { restoreProjectArchive }; diff --git a/agents/_lib/project/preview.ts b/agents/_lib/project/preview.ts index e40832f..7947506 100644 --- a/agents/_lib/project/preview.ts +++ b/agents/_lib/project/preview.ts @@ -1,3 +1,4 @@ +import { requireSandbox, type AgentContext } from '../runtime/context.ts'; import { MAKERS_DEV_PORT, PREVIEW_ASSET_PREFIX_ENV, @@ -17,40 +18,33 @@ import { buildMakersDevBackgroundCommand, buildMakersDevLaunchCommand, parseMakersDevExitCode, -} from '../../../shared/makers-dev.ts'; +} from '../makers/cli-dev.ts'; import { makersFileSemantic } from '../../../shared/makers-file-semantics.ts'; -import { redactSecret } from '../../../shared/makers-deploy.ts'; -import { shellQuote } from '../../../shared/shell.ts'; +import { redactSecret } from '../makers/cli-deploy.ts'; +import { shellQuote } from '../utils/shell.ts'; import { MAKERS_CLI_UNAVAILABLE_ERROR_CODE, MAKERS_CLI_UNAVAILABLE_MESSAGE, isEdgeoneCliUnavailable, -} from '../../../shared/tool-phase.ts'; -import { resolveConversationId } from '../utils/request.ts'; -import { sandboxGatewayKeyIsSet } from './gateway-prompt.ts'; +} from '../makers/tool-phase.ts'; +import { resolveConversationId } from '../runtime/request.ts'; +import { sandboxGatewayKeyIsSet } from './gateway.ts'; import { runCommandCapturingExit, runSandboxCommand } from './commands.ts'; -import { assertMakersProjectCompatible } from './makers-compat.ts'; -import { - ensureMakersPublishProject, - resolveConversationPublishArea, - resolveMakersProjectName, -} from './makers-deploy.ts'; -import { - buildSandboxMakersEnv, - describeMissingMakersRuntimeToken, - prepareSandboxGatewayEnv, - resolveMakersMasterToken, - resolveSandboxMakersToken, -} from './makers-token.ts'; +import { assertMakersProjectCompatible } from '../makers/compat/run.ts'; +import { prepareMakersSession } from '../makers/session.ts'; +import { resolveConversationPublishArea, resolveMakersProjectName } from '../makers/project.ts'; +import { describeMissingMakersRuntimeToken } from '../makers/token.ts'; +import { publishPreview } from './workspace-store.ts'; // Where Makers mounts generated HTTP handlers; both are optional in a project. const CLOUD_FUNCTION_DIRECTORIES = ['cloud-functions', 'edge-functions']; -export async function resolvePublicLinks(context: any) { - const previewHost = context.sandbox.getHost(PREVIEW_PUBLIC_PORT); - const accessToken = context.sandbox.envdAccessToken; +export async function resolvePublicLinks(context: AgentContext) { + const sandbox = requireSandbox(context); + const previewHost = await Promise.resolve(sandbox.getHost?.(PREVIEW_PUBLIC_PORT)); + const accessToken = sandbox.envdAccessToken; const previewBaseUrl = normalizePublicUrl(previewHost); - const sandboxDebugUrl = normalizePublicUrl(context.sandbox.browser?.liveUrl); + const sandboxDebugUrl = normalizePublicUrl(sandbox.browser?.liveUrl); const previewUrl = (previewBaseUrl && accessToken) ? buildPublicPreviewUrl(previewBaseUrl, accessToken) @@ -119,49 +113,41 @@ export function rewritePreviewAccessToken(existingUrl: string, token: string) { * the restart, so re-running them buys a second opinion on the same code. */ export async function startPreviewServer( - context: any, + context: AgentContext, state: ProjectState, - options: { verifyRoutes?: boolean } = {}, + options: { verifyRoutes?: boolean; forceRestart?: boolean } = {}, ) { const verifyRoutes = options.verifyRoutes !== false; await assertMakersProjectCompatible(context, state); - const masterToken = resolveMakersMasterToken(context); const projectName = resolveMakersProjectName(context, state); const area = resolveConversationPublishArea(state); const launchCommand = buildMakersDevLaunchCommand(MAKERS_DEV_PORT, projectName, { area }); - let forceRestart = false; + let forceRestart = options.forceRestart === true; // makers-dev watches project files. On resume, keep a healthy process rather // than starting a second CLI instance on the same port. - const warm = await runCommandCapturingExit( - context, - probePreviewReadyCommand(), - { timeout: 5 }, - ); - if (warm.exitCode === 0) { - try { - if (verifyRoutes) { - await assertGeneratedRoutesReady(context, state); + if (!forceRestart) { + const warm = await runCommandCapturingExit( + context, + probePreviewReadyCommand(), + { timeout: 5 }, + ); + if (warm.exitCode === 0) { + try { + if (verifyRoutes) { + await assertGeneratedRoutesReady(context, state); + } + return previewServerInfo(launchCommand); + } catch (error) { + // A warm port that fails to answer is a stale server, not a preview. One + // that answers wrongly is a code bug the restart would only delay. + if (!previewFailureWarrantsRestart(error)) throw error; + forceRestart = true; } - return previewServerInfo(launchCommand); - } catch (error) { - // A warm port that fails to answer is a stale server, not a preview. One - // that answers wrongly is a code bug the restart would only delay. - if (!previewFailureWarrantsRestart(error)) throw error; - forceRestart = true; } } - // Scoped to this conversation, and redacted out of CLI output before the - // model or the UI sees it. - const sandboxToken = await resolveSandboxMakersToken(state, masterToken); - await ensureMakersPublishProject( - sandboxToken, - projectName, - area, - state.makersApiRegion, - ); - await prepareSandboxGatewayEnv(context, state); + const makers = await prepareMakersSession(context, state); const startResult = await runSandboxCommand( context, @@ -169,15 +155,15 @@ export async function startPreviewServer( makersPort: MAKERS_DEV_PORT, previewPort: PREVIEW_SERVER_PORT, previewPath: PREVIEW_PATH_PREFIX, - projectName, + projectName: makers.projectName, assetPrefixEnvName: PREVIEW_ASSET_PREFIX_ENV, forceRestart, - area, + area: makers.area, }), { cwd: state.appDir, timeout: MAKERS_DEV_LAUNCH_TIMEOUT_SECONDS, - env: buildSandboxMakersEnv(sandboxToken, state.makersApiRegion), + env: makers.env, }, ); const startOutput = [startResult.stdout, startResult.stderr].filter(Boolean).join('\n'); @@ -194,7 +180,7 @@ export async function startPreviewServer( throw new Error( redactSecret( failure, - sandboxToken, + makers.sandboxToken, ), ); } @@ -266,7 +252,7 @@ const ROUTE_LISTING_COMMAND = [ * sites, and it is why neither gate needs a project-shape flag passed in from * outside — the routes a project declares are the shape. */ -async function assertGeneratedRoutesReady(context: any, state: ProjectState) { +async function assertGeneratedRoutesReady(context: AgentContext, state: ProjectState) { const listing = await runSandboxCommand( context, ROUTE_LISTING_COMMAND, @@ -328,7 +314,7 @@ function smokeFailure(exitCode: number | undefined, detail: string, guidance: st * request still publishes a preview that looks fine until the user clicks. */ async function assertGeneratedApiRoutesReady( - context: any, + context: AgentContext, state: ProjectState, routes: string[], ) { @@ -375,7 +361,7 @@ export function agentRoutesFromListing(stdout: string) { } async function assertGeneratedAgentChatReady( - context: any, + context: AgentContext, state: ProjectState, routes: Set, ) { @@ -431,7 +417,7 @@ async function assertGeneratedAgentChatReady( * of why — an import it cannot resolve, a framework that is not installed. At * the point the route gate fails, that account is the whole answer. */ -async function readMakersDevLog(context: any) { +async function readMakersDevLog(context: AgentContext) { try { const result = await runSandboxCommand( context, @@ -446,7 +432,7 @@ async function readMakersDevLog(context: any) { } export async function publishRunningPreview( - context: any, + context: AgentContext, state: ProjectState, options: { routesAlreadyVerified?: boolean } = {}, ) { @@ -460,10 +446,11 @@ export async function publishRunningPreview( if (!links.previewUrl) { throw new Error(`Makers dev is ready, but the sandbox did not return a public URL for port ${PREVIEW_PUBLIC_PORT}.`); } - state.previewUrl = links.previewUrl; - state.sandboxDebugUrl = links.sandboxDebugUrl; - state.previewKind = 'sandbox'; - state.previewPublished = true; + publishPreview(state, { + url: links.previewUrl, + sandboxDebugUrl: links.sandboxDebugUrl, + kind: 'sandbox', + }); return { url: links.previewUrl, sandboxDebugUrl: links.sandboxDebugUrl, @@ -471,8 +458,20 @@ export async function publishRunningPreview( }; } +export async function isPreviewServerReady( + context: AgentContext, + readyPath = PREVIEW_PATH_PREFIX, +) { + const result = await runCommandCapturingExit( + context, + probePreviewReadyCommand(readyPath), + { timeout: 5 }, + ); + return result.exitCode === 0; +} + export async function assertPreviewServerReady( - context: any, + context: AgentContext, readyPath = PREVIEW_PATH_PREFIX, ) { const result = await runCommandCapturingExit( diff --git a/agents/_lib/pipelines/file-read.ts b/agents/_lib/project/read.ts similarity index 85% rename from agents/_lib/pipelines/file-read.ts rename to agents/_lib/project/read.ts index 7b34453..05aafe1 100644 --- a/agents/_lib/pipelines/file-read.ts +++ b/agents/_lib/project/read.ts @@ -1,10 +1,11 @@ +import type { AgentContext } from '../runtime/context.ts'; import { PREVIEW_BATCH_MAX_FILES } from '../constants.ts'; -import { getProjectState } from '../memory.ts'; -import { readFileFromSandbox, readFilesFromSandbox } from '../project/index.ts'; +import { getProjectState } from '../session/store.ts'; +import { readFileFromSandbox, readFilesFromSandbox } from './fs.ts'; import { toAppRelPath } from '../utils/paths.ts'; -import { getRequestQueryParam, resolveConversationId } from '../utils/request.ts'; +import { getRequestQueryParam, resolveConversationId } from '../runtime/request.ts'; -export async function runFileReadPipeline(context: any): Promise { +export async function runFileReadPipeline(context: AgentContext): Promise { const { conversationId } = resolveConversationId(context); const pathParam = getRequestQueryParam(context, 'path'); const pathsParam = getRequestQueryParam(context, 'paths'); diff --git a/shared/resume-file-cache.ts b/agents/_lib/project/resume-file-cache.ts similarity index 97% rename from shared/resume-file-cache.ts rename to agents/_lib/project/resume-file-cache.ts index 7aee8dd..abe90c8 100644 --- a/shared/resume-file-cache.ts +++ b/agents/_lib/project/resume-file-cache.ts @@ -1,4 +1,4 @@ -import type { FileTreeItem } from './protocol.ts'; +import type { FileTreeItem } from '../../../shared/protocol.ts'; export const RESUME_FILE_CACHE_MAX_FILES = 48; export const RESUME_FILE_CACHE_MAX_BYTES = 2 * 1024 * 1024; diff --git a/agents/_lib/pipelines/resume-files.ts b/agents/_lib/project/resume-files.ts similarity index 87% rename from agents/_lib/pipelines/resume-files.ts rename to agents/_lib/project/resume-files.ts index 7491ede..94ca774 100644 --- a/agents/_lib/pipelines/resume-files.ts +++ b/agents/_lib/project/resume-files.ts @@ -1,7 +1,8 @@ -import { getProjectState } from '../memory.ts'; -import { readFileFromSandbox } from '../project/index.ts'; +import type { AgentContext } from '../runtime/context.ts'; +import { getProjectState } from '../session/store.ts'; +import { readFileFromSandbox } from './fs.ts'; import type { FileTreeItem } from '../types.ts'; -import { selectResumeCacheFiles } from '../../../shared/resume-file-cache.ts'; +import { selectResumeCacheFiles } from './resume-file-cache.ts'; const RESUME_FILE_READ_BATCH_SIZE = 12; @@ -19,7 +20,7 @@ export type ResumeFileContent = { * clicking an omitted or over-budget file still falls back to /file. */ export async function loadResumeFileContents( - context: any, + context: AgentContext, conversationId: string, items: FileTreeItem[], ): Promise { diff --git a/shared/sandbox-command.ts b/agents/_lib/project/sandbox-command.ts similarity index 100% rename from shared/sandbox-command.ts rename to agents/_lib/project/sandbox-command.ts diff --git a/agents/_lib/project/scaffold.ts b/agents/_lib/project/scaffold.ts index 35428a3..fc75378 100644 --- a/agents/_lib/project/scaffold.ts +++ b/agents/_lib/project/scaffold.ts @@ -1,259 +1,10 @@ -import type { BuildResult, BuildStatus, ProjectState, ScaffoldLog } from '../types.ts'; +import { requireSandbox, type AgentContext } from '../runtime/context.ts'; +import type { BuildResult, BuildStatus, ProjectState } from '../types.ts'; import { detectFatalToolError } from '../utils/text.ts'; import { runCommandCapturingExit, runSandboxCommand } from './commands.ts'; -import { loadMakersFrameworkProfiles, runMakersCompatibilityCheck } from './makers-compat.ts'; -import { withFrameworkAdapter } from './makers-declarations.ts'; -import { applyProjectTemplate, listProjectTemplates, resolveProjectTemplate } from './templates.ts'; -import type { AppliedTemplate } from './templates.ts'; -import { shellQuote } from '../../../shared/shell.ts'; +import { runMakersCompatibilityCheck } from '../makers/compat/run.ts'; -// Models used to pass `${appDir}/file` into write_project_file, which joined -// appDir again and created appDir/appDir/... . Lift that nested tree back to -// the real project root when we detect the classic nesting marker. -export async function repairNestedAppDirLayout( - context: any, - state: ProjectState, - onLog?: (log: ScaffoldLog) => void, -): Promise { - const nestedRel = state.appDir; - // Probe before running the repair, even though the script's first line is the - // same test. The probe is not what this costs — running the script is, on - // every turn, for a legacy bug that almost no project has. Skipping the probe - // to save a round trip put a command that had barely ever run in production - // in front of the first tool of every conversation. - try { - if (!(await context.sandbox.files.exists(`${state.appDir}/${nestedRel}`))) { - return false; - } - } catch { - return false; - } - - let result; - try { - result = await runSandboxCommand( - context, - [ - 'set -e', - `NESTED=${shellQuote(nestedRel)}`, - 'if [ ! -d "$NESTED" ]; then exit 0; fi', - // Classic bug shape: real project under appDir/appDir, root missing package.json. - 'if [ ! -f "$NESTED/package.json" ] && [ ! -f "$NESTED/index.html" ]; then exit 0; fi', - 'if [ -f ./package.json ]; then exit 0; fi', - 'for item in "$NESTED"/*; do', - ' [ -e "$item" ] || continue', - ' name=$(basename "$item")', - ' [ "$name" = "projects" ] && continue', - ' rm -rf "./$name"', - ' mv "$item" "./$name"', - 'done', - 'rm -rf ./projects', - 'echo REPAIRED', - ].join('\n'), - { - cwd: state.appDir, - timeout: 60, - }, - ); - } catch { - // The sandbox raises on a failed command instead of returning its exit - // code, so the check below never sees one and this is the only place a - // failure can be absorbed. Absorbing it is the point: repairing a layout - // almost no project has must not cost a turn to every project that does - // not, and the scaffold that follows reports anything genuinely wrong. - return false; - } - - if (result.exitCode !== 0) { - return false; - } - - const repaired = result.stdout.includes('REPAIRED'); - if (repaired) { - onLog?.({ - stream: 'status', - content: 'Fixed nested project paths and restored files to the workspace root.', - }); - } - return repaired; -} - -/** - * What the workspace probe answers, beyond "is anything here". - * - * Whether the dependencies are installed is the second question, and it used to - * have no answer at all: the listing prunes node_modules, because a populated - * tree is hundreds of thousands of paths, and nothing else reported it. So a - * workspace that arrived with its dependencies already installed looked - * identical to one that did not, and the model did the only safe-looking thing - * and ran `npm install` — which on a 1.1G sandbox does not fit beside a tree - * that is already there. One session spent four minutes that way: the install - * filled the disk, died halfway, took the working tree with it, and ended with - * the project less runnable than when it started. - */ -export type ScaffoldOutcome = { - created: boolean; - dependenciesInstalled: boolean; - /** Present only when this call filled an empty workspace from a baked tree. */ - template?: AppliedTemplate; - /** - * The baked tree ids, carried back only when this call left the workspace - * empty. The miss is worth reporting because the caller cannot see it: an - * empty workspace and an empty workspace that could have been a baked chat - * agent read identically from the outside, and the model reads the first one - * as licence to write the tree by hand. - */ - available?: readonly string[]; -}; - -export type ScaffoldOptions = { - /** - * The framework the request named, if it named one. Only ever a hint: an - * unknown name, a framework with no baked template, and an omitted value all - * leave the workspace empty for the scaffolder path to fill. - */ - framework?: string; -}; - -const DEPENDENCIES_INSTALLED = 'DEPENDENCIES_INSTALLED'; - -export async function ensureProjectScaffold( - context: any, - state: ProjectState, - onLog?: (log: ScaffoldLog) => void, - options: ScaffoldOptions = {}, -): Promise { - const sandbox = context.sandbox; - onLog?.({ stream: 'status', content: `Preparing the project workspace ${state.appDir}` }); - - // appDir is sessionDir plus one segment and the create is recursive, so the - // second call only ever remade a directory the first had already made. - await sandbox.files.makeDir(state.appDir); - - await repairNestedAppDirLayout(context, state, onLog); - - const existing = await runSandboxCommand( - context, - [ - // .bin is the tell, not node_modules itself: an install that died partway - // leaves the directory standing with its executables gone, which is the - // state that has to read as "not installed" so the retry happens. - `if [ -n "$(ls -A node_modules/.bin 2>/dev/null)" ]; then echo ${DEPENDENCIES_INSTALLED}; fi`, - [ - 'find . -mindepth 1 -maxdepth 2', - "\\( -path './node_modules' -o -path './.next' -o -path './.git' -o -path './dist' -o -path './build' \\) -prune", - '-o -print', - ].join(' '), - ].join('\n'), - { - cwd: state.appDir, - timeout: 60, - }, - ); - if (existing.exitCode !== 0) { - throw new Error(existing.stderr || existing.stdout || 'Workspace inspection failed.'); - } - const lines = existing.stdout.split('\n').map((line) => line.trim()).filter(Boolean); - const dependenciesInstalled = lines.includes(DEPENDENCIES_INSTALLED); - const files = lines.filter((line) => line !== DEPENDENCIES_INSTALLED); - - // One conversation_id maps to one long-lived project. Reuse existing business - // files without overwriting them. - if (files.length) { - onLog?.({ - stream: 'status', - content: dependenciesInstalled - ? 'Existing project workspace detected, dependencies already installed; skipping initialization.' - : 'Existing project workspace detected; skipping initialization.', - }); - return { created: false, dependenciesInstalled }; - } - - const { template, available } = await applyTemplateIfBaked( - context, - state, - options.framework, - onLog, - ); - if (template) { - return { created: true, dependenciesInstalled, template }; - } - - onLog?.({ stream: 'status', content: 'Prepared an empty project workspace. Waiting for the agent to generate project files.' }); - - return { - created: true, - dependenciesInstalled, - ...(available?.length ? { available } : {}), - }; -} - -/** - * Fill the empty workspace from a baked tree, or leave it empty. - * - * Best effort in every direction, because the scaffolder path it replaces is - * still there: a framework nobody baked, a manifest that will not parse, a - * sandbox that refuses the write — each of them returns nothing, and the run - * goes on to load the reference and run the scaffolder exactly as before. The - * one thing this must not do is fail the first tool call of the conversation - * for an optimisation. - */ -async function applyTemplateIfBaked( - context: any, - state: ProjectState, - framework: string | undefined, - onLog?: (log: ScaffoldLog) => void, -): Promise<{ template?: AppliedTemplate; available?: readonly string[] }> { - try { - const template = await resolveProjectTemplate(framework); - if (!template) { - // Said out loud, and said differently for the three causes. A framework - // nobody baked is the expected miss. A build holding no baked trees at - // all is a packaging fault — it went unnoticed through every deployed - // conversation because falling back to the scaffolder looks like the - // model choosing to, and nothing here disagreed with that reading. - // - // The third is a request that names no framework: it arrives with nothing - // to resolve, and the log used to be gated on having a name to print, so - // the miss went out silent. The alias table in ./templates.ts records - // what that silence cost. - const baked = await listProjectTemplates(); - const available = baked.map((item) => item.id); - onLog?.({ - stream: 'status', - content: available.length === 0 - ? `This build carries no baked templates, so ${framework?.trim() || 'this project'} falls back to its own scaffolder.` - : framework?.trim() - ? `No baked template for ${framework}; using its own scaffolder.` - : `The request named no framework, so no baked template was applied. Baked: ${available.join(', ')}.`, - }); - return { available }; - } - const profiles = await loadMakersFrameworkProfiles(); - - const applied = await applyProjectTemplate(context, state, template, { - onLog, - // The adapter injection is hooked to write_project_file, and a template's - // package.json does not come through it. Without this, a framework whose - // platform adapter is unconditional would reach the preview gate missing - // the one dependency the install about to start could have picked up. - adaptPackageJson: (content) => withFrameworkAdapter( - { status: 'present', content }, - profiles, - ), - }); - - return { template: applied }; - } catch (error) { - onLog?.({ - stream: 'status', - content: `Could not use the baked ${framework?.trim() || 'project'} template (${ - error instanceof Error ? error.message : String(error) - }); falling back to the framework's own scaffolder.`, - }); - return {}; - } -} +export { repairNestedAppDirLayout } from './layout.ts'; /** * What the production build is still for once a preview has come up. @@ -282,7 +33,7 @@ export type VerificationOptions = { * lines is refused instead of run — nothing legitimate needs one, and the value * reaches a shell. */ -async function readDeclaredBuildCommand(context: any, state: ProjectState) { +async function readDeclaredBuildCommand(context: AgentContext, state: ProjectState) { const probe = await runSandboxCommand( context, 'node -e "try { const c=require(\'./edgeone.json\'); process.stdout.write(typeof c.buildCommand === \'string\' ? c.buildCommand : \'\'); } catch (e) { process.stdout.write(\'\'); }"', @@ -297,7 +48,7 @@ export const PRODUCTION_BUILD_DEFERRED = 'Skipped the production build: the preview server compiled this project and passed its smoke tests in this turn, which is the same evidence the build would produce for everything except bundling and prerendering. Publishing runs the real build and reports any production-only failure with its own log.'; export async function runVerification( - context: any, + context: AgentContext, state: ProjectState, options: VerificationOptions = {}, ): Promise { @@ -317,7 +68,7 @@ export async function runVerification( [compatibility.stdout.trim(), stdout.trim()].filter(Boolean).join('\n') ); - const packageExists = await context.sandbox.files.exists(`${state.appDir}/package.json`); + const packageExists = await requireSandbox(context).files.exists(`${state.appDir}/package.json`); if (packageExists) { const hasBuildScript = await runSandboxCommand( context, diff --git a/agents/_lib/project/snapshot.ts b/agents/_lib/project/snapshot.ts new file mode 100644 index 0000000..91d340c --- /dev/null +++ b/agents/_lib/project/snapshot.ts @@ -0,0 +1,90 @@ +import type { WorkspaceSnapshot } from '../../../shared/protocol.ts'; +import { getProjectState } from '../session/store.ts'; +import { getFileTree } from './fs.ts'; +import { resolveConversationId } from '../runtime/request.ts'; +import type { AgentContext } from '../runtime/context.ts'; +import type { FileTreeItem, ProjectState } from '../types.ts'; + +function previewLinkFromState(state: ProjectState) { + if (!state.previewUrl) return {}; + return { + url: state.previewUrl, + sandboxDebugUrl: state.sandboxDebugUrl, + kind: state.previewKind, + }; +} + +function jsonResponse(obj: Record, status = 200) { + return new Response(JSON.stringify(obj), { + status, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }); +} + +/** Project panel payload from in-memory state. Pass `items` only after a listing. */ +export function workspaceSnapshotFromState( + conversationId: string, + state: ProjectState, + items?: FileTreeItem[], +): WorkspaceSnapshot { + const preview = previewLinkFromState(state); + const hasFiles = items?.some((item) => item.type === 'file') === true; + return { + ok: true, + conversation_id: conversationId, + ...(items ? { files: { root: state.appDir, items } } : {}), + ...(preview.url ? { preview } : {}), + deployment: state.deployment, + build: state.lastBuild, + ...(hasFiles ? { download: { url: '/download', filename: 'source.zip' } } : {}), + }; +} + +export async function loadWorkspaceSnapshot( + context: AgentContext, + conversationId: string, +): Promise { + const state = await getProjectState(context, conversationId); + let items: FileTreeItem[] = []; + try { + items = await getFileTree(context, state); + } catch { + items = []; + } + return workspaceSnapshotFromState(conversationId, state, items); +} + +export async function runWorkspaceSnapshotPipeline(context: AgentContext): Promise { + const { conversationId } = resolveConversationId(context, { allowQuery: true }); + if (!conversationId) { + return jsonResponse({ ok: false, error: 'missing conversation_id' }, 400); + } + try { + return jsonResponse(await loadWorkspaceSnapshot(context, conversationId)); + } catch (error) { + return jsonResponse({ + ok: false, + conversation_id: conversationId, + error: error instanceof Error ? error.message : 'Failed to load the workspace.', + }, 500); + } +} + +export async function runPreviewStatusPipeline(context: AgentContext): Promise { + const { conversationId } = resolveConversationId(context, { allowQuery: true }); + if (!conversationId) { + return jsonResponse({ ok: false, error: 'missing conversation_id' }, 400); + } + const state = await getProjectState(context, conversationId); + const preview = previewLinkFromState(state); + return jsonResponse({ + ok: true, + stage: 'preview', + conversation_id: conversationId, + ...(preview.url ? { preview } : {}), + deployment: state.deployment, + }); +} diff --git a/agents/_lib/project/state.ts b/agents/_lib/project/state.ts index 9d40ce7..2e033ee 100644 --- a/agents/_lib/project/state.ts +++ b/agents/_lib/project/state.ts @@ -1,7 +1,10 @@ import type { ProjectState } from '../types.ts'; +import { requireSandbox, type SandboxCapable } from '../runtime/context.ts'; import { safeSegment } from '../utils/paths.ts'; import { runSandboxCommand } from './commands.ts'; -import { isMakersDeployUrl } from '../../../shared/makers-deploy.ts'; +import { resetWorkspaceFields } from './workspace-store.ts'; + +export { separateLegacyMakersDeployment } from './workspace-store.ts'; export function createProjectState(conversationId: string): ProjectState { const sessionDir = `projects/${safeSegment(conversationId)}`; @@ -12,36 +15,13 @@ export function createProjectState(conversationId: string): ProjectState { }; } -/** Migrate persisted state from versions that rendered a deployment as preview. */ -export function separateLegacyMakersDeployment(state: ProjectState) { - const legacyUrl = state.previewUrl; - if ( - !legacyUrl - || (state.previewKind !== 'makers' && !isMakersDeployUrl(legacyUrl)) - ) { - return state; - } - - state.deployment ??= { - status: 'success', - startedAt: 0, - finishedAt: 0, - url: legacyUrl, - }; - state.previewUrl = undefined; - state.sandboxDebugUrl = undefined; - state.previewPublished = undefined; - state.previewKind = undefined; - return state; -} - export async function resetProjectWorkspace( - context: any, + context: SandboxCapable, state: ProjectState, ) { assertResettableProjectPath(state); - const sandbox = context.sandbox; + const sandbox = requireSandbox(context); await sandbox.files.makeDir(state.sessionDir); @@ -61,12 +41,7 @@ export async function resetProjectWorkspace( } await sandbox.files.makeDir(state.appDir); - state.created = false; - state.previewUrl = undefined; - state.sandboxDebugUrl = undefined; - state.previewPublished = undefined; - state.previewKind = undefined; - state.deployment = undefined; + resetWorkspaceFields(state); return appDirExists; } diff --git a/agents/_lib/project/templates.ts b/agents/_lib/project/templates.ts deleted file mode 100644 index 58a4fbd..0000000 --- a/agents/_lib/project/templates.ts +++ /dev/null @@ -1,444 +0,0 @@ -/** - * The scaffolder's output, already on disk here, so a new project does not wait - * for it to be produced again. - * - * What a run used to spend before its first project file existed: a round trip - * to load the framework reference, then `npx create-next-app@latest`, which - * fetches the create package, fetches a template, and installs as it goes. The - * tree it produces is the same every time and depends on nothing about the - * request, so `npm run bake:templates` produces it once and commits it. - * - * Two things follow from that, and the second is the larger one: - * - * The install starts at the scaffold instead of two minutes into the turn. It - * needs only package.json, which arrives here in the first tool call rather - * than after the model has written a dozen files — the gap a measured session - * lost 73 seconds to, on top of the scaffolder's own install. - * - * And a scaffolder that cannot reach its template stops being the run's - * problem. Several of them — the ones built on giget — fetch from GitHub at run - * time rather than shipping a template in their npm package, so they fail - * wherever the sandbox's egress does not reach it, and the run degrades to - * writing a framework's boilerplate by hand. A baked template needs the - * network only on the machine that baked it. - * - * A framework with no baked template resolves to nothing and the run takes the - * old path, which is why this stays an accelerator rather than an allowlist. - */ - -import { gzipSync } from 'node:zlib'; -import { readdir, readFile } from 'node:fs/promises'; -import path from 'node:path'; -import { PREVIEW_ASSET_PREFIX_ENV } from '../constants.ts'; -import type { ProjectState, ScaffoldLog } from '../types.ts'; -import { safeSegment } from '../utils/paths.ts'; -import { buildNpmWarmupCommand } from '../../../shared/npm-install.ts'; -import { runSandboxCommand } from './commands.ts'; - -export type ProjectTemplate = { - id: string; - /** The framework reference whose Scaffold command produced this tree. */ - ref: string; - /** That command, recorded so a skill sync that changes it fails a test. */ - command: string; - bakedAt: string; - files: number; - bytes: number; -}; - -/** - * Spellings of a framework that should reach the same template. - * - * Only for names that do not survive normalization into a template id — the ids - * themselves are matched without being listed. `react` maps to the Vite - * template because that is what the prompt already asks for when a request - * names React without a framework around it; `vue` deliberately maps to - * nothing, since the baked Vite tree is the React one and handing a Vue request - * a React app is worse than scaffolding it the slow way. - */ -const TEMPLATE_ALIASES: Readonly> = { - next: 'nextjs', - nextapp: 'nextjs', - vite: 'vite-spa', - vitereact: 'vite-spa', - react: 'vite-spa', - reactts: 'vite-spa', - reacttypescript: 'vite-spa', - svelte: 'sveltekit', - remix: 'react-router', - reactrouterv7: 'react-router', - tanstack: 'tanstack-start', - nuxtjs: 'nuxt', - nuxt3: 'nuxt', - nuxt4: 'nuxt', - astrojs: 'astro', - // The agent side, where a request names the app it wants and not a framework. - // "Make an AI chat assistant" offers nothing an id can match, so without - // these the baked chat tree is reachable only by a model that already knows - // the id `deepagents` — and nothing tells it that. It went unused for - // exactly the prompts it was baked for, while the model hand-wrote a - // package.json beside it. - // - // Between the two baked agent trees deepagents is the general - // streaming-chat one, so a bare `agent` or `chat` lands there; the scaffold - // result names the tree it got, which is what lets a model that wanted the - // other one change course. - // - // The agent frameworks nobody baked — crewai, openai-agents-sdk, - // claude-agent-sdk — deliberately stay out. Handing one of those a - // deepagents tree is the `vue` mistake above: their own reference is a - // better start than the wrong template. - chat: 'deepagents', - chatbot: 'deepagents', - aichat: 'deepagents', - assistant: 'deepagents', - aiassistant: 'deepagents', - chatassistant: 'deepagents', - aichatassistant: 'deepagents', - agent: 'deepagents', - aiagent: 'deepagents', - deepagent: 'deepagents', - langgraphjs: 'langgraph', -}; - -/** Punctuation and case are what separate "Next.js" from the id "nextjs". */ -function normalizeFrameworkName(value: string) { - return value.toLowerCase().replace(/[^a-z0-9]/g, ''); -} - -/** - * Where the baked trees are, which is not one place. - * - * Locally the agent runs with cwd at the repository root, so `templates/` is - * right there. Deployed, it runs from the bundle the CLI uploads, and the - * builder puts everything named in `agents.includeFiles` under an - * `included_files/` namespace of its own — the tree keeps its shape and moves - * one level down. Only `.claude/skills` arrives at the root, and only because - * the builder copies that one by name. - * - * Naming both is what keeps a deployed run from silently taking the scaffolder - * path while the same checkout works on a laptop. - */ -const TEMPLATE_ROOTS = ['templates', path.join('included_files', 'templates')] as const; - -const NO_TEMPLATES: readonly ProjectTemplate[] = Object.freeze([]); - -type BakedTemplates = { root: string; templates: readonly ProjectTemplate[] }; - -let bakedCache: BakedTemplates | undefined; - -/** - * The manifest and the directory it was found in, resolved together so a later - * file read cannot go looking in the other candidate. - * - * Nothing is remembered until a manifest parses. Memoising the miss is what - * turned one unreadable manifest into a process that never used a baked - * template again, and the deployed runtime is exactly where that miss happens. - */ -async function loadBakedTemplates(): Promise { - if (bakedCache) return bakedCache; - - for (const candidate of TEMPLATE_ROOTS) { - const root = path.join(process.cwd(), candidate); - try { - const raw = await readFile(path.join(root, 'manifest.json'), 'utf8'); - const parsed = JSON.parse(raw) as { templates?: ProjectTemplate[] }; - bakedCache = { root, templates: Object.freeze(parsed.templates ?? []) }; - return bakedCache; - } catch { - // The other candidate, and then nothing: a build carrying no baked trees - // is a working build, and every caller treats "no template" as the old - // scaffolder path. - } - } - return undefined; -} - -export async function listProjectTemplates(): Promise { - return (await loadBakedTemplates())?.templates ?? NO_TEMPLATES; -} - -export async function resolveProjectTemplate( - framework: string | undefined, -): Promise { - if (!framework?.trim()) return undefined; - const normalized = normalizeFrameworkName(framework); - if (!normalized) return undefined; - - const templates = await listProjectTemplates(); - const wanted = TEMPLATE_ALIASES[normalized] ?? normalized; - return templates.find((template) => ( - template.id === wanted || normalizeFrameworkName(template.id) === wanted - )); -} - -type TemplateFile = { p: string; t?: string; d?: string }; - -/** - * The name a template's `.gitignore` is committed under. - * - * It cannot be committed as one: inside templates/ it would be a live ignore - * file for its own directory, and the Next.js tree's copy lists next-env.d.ts — - * so the template was a file short of what it was baked from, on a fresh clone - * only. The bake script renames it; this puts the name back on the way in. - */ -const GITIGNORE_STORED_AS = '_gitignore'; - -async function readTemplateFiles(id: string): Promise { - const baked = await loadBakedTemplates(); - if (!baked) { - throw new Error('the baked trees are not in this build'); - } - const root = path.join(baked.root, id); - const files: TemplateFile[] = []; - - async function walk(dir: string) { - for (const entry of await readdir(dir, { withFileTypes: true })) { - const target = path.join(dir, entry.name); - if (entry.isDirectory()) { - await walk(target); - continue; - } - const relative = path - .relative(root, entry.name === GITIGNORE_STORED_AS - ? path.join(dir, '.gitignore') - : target) - .replaceAll(path.sep, '/'); - const bytes = await readFile(target); - // Text as text, so the payload gzips against the whole tree rather than - // against base64 of it — the difference on a lockfile is most of the - // transfer. Binary is real here and not a hypothetical: the Next.js tree - // carries a favicon and the Vite one a PNG. - const asText = bytes.toString('utf8'); - files.push(Buffer.from(asText, 'utf8').equals(bytes) - ? { p: relative, t: asText } - : { p: relative, d: bytes.toString('base64') }); - } - } - - await walk(root); - files.sort((a, b) => a.p.localeCompare(b.p)); - return files; -} - -export const TEMPLATE_FILES_MARKER = 'TEMPLATE_FILES:'; - -/** - * A self-contained extractor, rather than a payload plus a script to read it. - * - * Two round trips is the budget — one write, one command — and inlining the - * payload spends neither on quoting: base64 is already shell-safe and - * JS-string-safe, so nothing here has to be escaped on the way through. - * - * `.cjs` because the tree being written may declare `"type": "module"`, and a - * plain `.js` extractor would then be parsed as ESM and fail on its own - * requires. - */ -function buildExtractorScript(payload: string) { - return [ - "const zlib = require('node:zlib');", - "const fs = require('node:fs');", - "const path = require('node:path');", - `const files = JSON.parse(zlib.gunzipSync(Buffer.from('${payload}', 'base64')).toString('utf8'));`, - 'for (const file of files) {', - ' const target = path.resolve(process.cwd(), file.p);', - ' fs.mkdirSync(path.dirname(target), { recursive: true });', - " fs.writeFileSync(target, file.d === undefined ? file.t : Buffer.from(file.d, 'base64'));", - '}', - `process.stdout.write('${TEMPLATE_FILES_MARKER}' + files.length + '\\n');`, - ].join('\n'); -} - -const fileCache = new Map>(); - -function cachedTemplateFiles(id: string) { - const cached = fileCache.get(id); - if (cached) return cached; - const reading = readTemplateFiles(id).then((files) => Object.freeze(files)); - fileCache.set(id, reading); - return reading; -} - -export type AppliedTemplate = { - id: string; - files: number; - /** Whether adaptPackageJson changed the manifest before it was written. */ - adapted: boolean; -}; - -/** - * Put the preview prefix option into a framework config that does not have it. - * - * The official scaffolder output never mentions the environment variable the - * host exports, so without this the model has to load makers-frameworks just - * to rewrite one line — and often rewrites the rest of the file with it. - * Returning undefined means the file is already correct or is not a config - * this function knows how to touch. - */ -export function withPreviewAssetPrefix( - relativePath: string, - content: string, -): string | undefined { - if (!content || content.includes(PREVIEW_ASSET_PREFIX_ENV)) return undefined; - const file = relativePath.replaceAll('\\', '/'); - const env = `process.env.${PREVIEW_ASSET_PREFIX_ENV}`; - - // SvelteKit is the one framework here that types its prefix as a template - // literal — `"" | \`/${string}\`` — rather than as a string, and an - // environment variable is only ever `string`. The assignment is correct at - // runtime and unprovable at compile time, so it needs the assertion said out - // loud. It goes in only for a TypeScript target: `svelte.config.js` is - // checked too, under `checkJs`, but `as` is not JavaScript. - const kitBase = /\.ts$/.test(file) ? `${env} as \`/\${string}\`` : env; - - if (/(?:^|\/)next\.config\.(?:ts|js|mjs)$/.test(file)) { - return injectAfterOpen(content, /=\s*\{/, `assetPrefix: ${env},`); - } - if (/(?:^|\/)nuxt\.config\.(?:ts|js|mjs)$/.test(file)) { - return injectAfterOpen(content, /defineNuxtConfig\(\s*\{/, `app: { baseURL: ${env} },`); - } - if (/(?:^|\/)svelte\.config\.(?:ts|js|mjs)$/.test(file)) { - return injectAfterOpen( - content, - /kit:\s*\{/, - `...(${env} ? { paths: { base: ${kitBase} } } : {}),`, - ); - } - // The second half of React Router's prefix, and the half without which the - // first does nothing. - // - // Vite's `base` moves the asset URLs and strips the prefix off the request - // before the framework sees it — but React Router's dev adapter puts it - // straight back (`nodeReq.url = nodeReq.originalUrl`, so that "React Router - // is aware of the full path"). It then matches that prefixed path against a - // basename still defaulting to '/', finds nothing, and answers every - // navigation with `No route matches URL "/preview"`. A `base` on its own is - // not an incomplete configuration here, it is an unusable one. - // - // Set in the framework's config rather than the Vite one because that is - // where React Router reads it: the value is baked into the server build and - // handed to the static handler, which is what makes the SSR side match. The - // framework also refuses to start in dev unless the basename begins with the - // base, which the shared environment variable satisfies by construction. - if (/(?:^|\/)react-router\.config\.(?:ts|js|mjs)$/.test(file)) { - return injectAfterOpen(content, /export default\s*\{/, `basename: ${env} ?? "/",`); - } - if (/(?:^|\/)(?:vite|astro)\.config\.(?:ts|js|mjs)$/.test(file)) { - // SvelteKit's documented option is kit.paths.base. The current scaffolder - // puts that kit object on the vite plugin, so the injection follows it - // rather than setting Vite's `base`, which SvelteKit ignores. - if (/\bsveltekit\s*\(/.test(content)) { - return injectAfterOpen( - content, - /sveltekit\(\s*\{/, - `...(${env} ? { paths: { base: ${kitBase} } } : {}),`, - ); - } - return injectAfterOpen(content, /defineConfig\(\s*\{/, `base: ${env},`); - } - return undefined; -} - -function injectAfterOpen(content: string, opener: RegExp, property: string) { - const match = opener.exec(content); - if (!match) return undefined; - const insertAt = match.index + match[0].length; - const after = content.slice(insertAt); - const indentMatch = /^\r?\n([ \t]+)/.exec(after); - const indent = indentMatch?.[1] ?? ' '; - return `${content.slice(0, insertAt)}\n${indent}${property}${ - indentMatch ? after : `\n${after}` - }`; -} - -export type ApplyTemplateOptions = { - onLog?: (log: ScaffoldLog) => void; - /** - * A last chance to change package.json, taken before anything is written. - * - * The ordering is the whole reason this is a hook rather than a second write - * afterwards. The install starts in the same command as the extraction, and - * it stamps package.json as it starts; a manifest edited after that no longer - * matches the stamp, so the handoff discards the finished install and the - * project pays for a second one. Editing here means there is only ever one. - */ - adaptPackageJson?: (content: string) => Promise | string | undefined; -}; - -/** - * Write the template into the workspace and start its install in one command. - * - * Chained rather than issued separately because the install is the thing being - * raced: every round trip between the files landing and `npm install` starting - * is time the user waits for at the end of the turn. The warmup is the existing - * one, so a project created from a template and one written by hand converge on - * the same install, the same handoff, and the same single-npm-process rule. - */ -export async function applyProjectTemplate( - context: any, - state: ProjectState, - template: ProjectTemplate, - options: ApplyTemplateOptions = {}, -): Promise { - const { onLog, adaptPackageJson } = options; - const files = [...(await cachedTemplateFiles(template.id))]; - - let adapted = false; - const manifestIndex = files.findIndex((file) => file.p === 'package.json'); - const manifest = manifestIndex >= 0 ? files[manifestIndex].t : undefined; - if (adaptPackageJson && manifest !== undefined) { - const replacement = await adaptPackageJson(manifest); - if (replacement !== undefined && replacement !== manifest) { - files[manifestIndex] = { p: 'package.json', t: replacement }; - adapted = true; - } - } - - for (let i = 0; i < files.length; i += 1) { - const file = files[i]; - if (file.t === undefined) continue; - const next = withPreviewAssetPrefix(file.p, file.t); - if (next !== undefined) files[i] = { p: file.p, t: next }; - } - - const payload = gzipSync(Buffer.from(JSON.stringify(files), 'utf8')).toString('base64'); - const script = buildExtractorScript(payload); - const scriptPath = `/tmp/eo-template-${safeSegment(template.id)}-${process.pid}.cjs`; - - onLog?.({ - stream: 'status', - content: `Writing the ${template.id} project template into ${state.appDir}`, - }); - - await context.sandbox.files.write(scriptPath, script); - - // set -e so a failed extraction never reaches the warmup: the warmup's first - // act is to disable it again, and an install started over a half-written tree - // is the failure this codebase already pays the most to avoid. - const result = await runSandboxCommand( - context, - [ - 'set -e', - `node ${scriptPath}`, - `rm -f ${scriptPath}`, - buildNpmWarmupCommand(), - ].join('\n'), - { cwd: state.appDir, timeout: 120 }, - ); - - const written = Number( - String(result.stdout || '').match(new RegExp(`${TEMPLATE_FILES_MARKER}(\\d+)`))?.[1], - ); - if (!written) { - throw new Error( - result.stderr || result.stdout || `Failed to write the ${template.id} template.`, - ); - } - - onLog?.({ - stream: 'status', - content: `Wrote ${written} files from the ${template.id} template and started installing its dependencies.`, - }); - - return { id: template.id, files: written, adapted }; -} diff --git a/agents/_lib/project/workspace-store.ts b/agents/_lib/project/workspace-store.ts new file mode 100644 index 0000000..a7da9b5 --- /dev/null +++ b/agents/_lib/project/workspace-store.ts @@ -0,0 +1,112 @@ +import type { BuildInfo, DeploymentInfo, PreviewKind } from '../../../shared/protocol.ts'; +import { isMakersDeployUrl } from '../../../shared/makers-url.ts'; +import type { PersistCapable } from '../runtime/context.ts'; +import { saveProjectState } from '../session/store.ts'; +import type { ProjectState } from '../types.ts'; + +export type PreviewPublication = { + url: string; + sandboxDebugUrl?: string; + kind?: PreviewKind; +}; + +/** + * The only writer of ProjectState fields. Callers mutate through these + * transitions, then persistWorkspace — saveProjectState has no other callers. + */ +export function publishPreview(state: ProjectState, preview: PreviewPublication) { + state.previewUrl = preview.url; + state.sandboxDebugUrl = preview.sandboxDebugUrl; + state.previewKind = preview.kind || (isMakersDeployUrl(preview.url) ? 'makers' : 'sandbox'); + state.previewPublished = true; + return state; +} + +export function clearPreview(state: ProjectState) { + state.previewUrl = undefined; + state.sandboxDebugUrl = undefined; + state.previewPublished = undefined; + state.previewKind = undefined; + return state; +} + +export function resetWorkspaceFields(state: ProjectState) { + state.created = false; + clearPreview(state); + state.deployment = undefined; + state.lastBuild = undefined; + return state; +} + +export function setDeployment(state: ProjectState, deployment: DeploymentInfo) { + state.deployment = deployment; + return state; +} + +export function markCreated(state: ProjectState) { + state.created = true; + return state; +} + +export function bindSiteDomain(state: ProjectState, siteDomain: string) { + const next = siteDomain.trim(); + if (!next || state.siteDomain === next) return false; + state.siteDomain = next; + return true; +} + +export function setGatewayPending(state: ProjectState, pending: boolean) { + state.gatewayPromptPending = pending; + return state; +} + +export function setGatewaySkipped(state: ProjectState, skipped: boolean) { + state.gatewaySkipped = skipped; + if (skipped) state.gatewayPromptPending = false; + return state; +} + +export function bindMakersTenantId(state: ProjectState, tenantId: string) { + if (!state.makersTenantId) { + state.makersTenantId = tenantId; + } + return state.makersTenantId; +} + +export function bindMakersApiRegion(state: ProjectState, region: 'china' | 'global') { + state.makersApiRegion = region; + return state; +} + +export function setLastBuild(state: ProjectState, build: BuildInfo) { + state.lastBuild = build; + return state; +} + +/** Migrate persisted state from versions that rendered a deployment as preview. */ +export function separateLegacyMakersDeployment(state: ProjectState) { + const legacyUrl = state.previewUrl; + if ( + !legacyUrl + || (state.previewKind !== 'makers' && !isMakersDeployUrl(legacyUrl)) + ) { + return state; + } + + state.deployment ??= { + status: 'success', + startedAt: 0, + finishedAt: 0, + url: legacyUrl, + }; + clearPreview(state); + return state; +} + +export async function persistWorkspace( + context: PersistCapable, + conversationId: string, + state: ProjectState, +) { + await saveProjectState(context, conversationId, state); +} diff --git a/agents/_lib/project/workspace.ts b/agents/_lib/project/workspace.ts new file mode 100644 index 0000000..dc3dde3 --- /dev/null +++ b/agents/_lib/project/workspace.ts @@ -0,0 +1,132 @@ +import { requireSandbox, type AgentContext } from '../runtime/context.ts'; +import { getProjectState } from '../session/store.ts'; +import { getFileTree } from './fs.ts'; +import { restorePersistedProject } from './persistence.ts'; +import { separateLegacyMakersDeployment } from './state.ts'; +import { markCreated, persistWorkspace } from './workspace-store.ts'; +import { repairNestedAppDirLayout } from './layout.ts'; +import type { ProjectState, StreamSend } from '../types.ts'; +import { withTimeout } from '../turn/checkpoint.ts'; +import { timeStage } from '../utils/timing.ts'; + +const SANDBOX_PROBE_MS = 15_000; +const RESTORE_BUDGET_MS = 45_000; +/** Long enough for a user to send their first message, short enough to expire an abandoned visit. */ +const FRESH_WORKSPACE_TTL_MS = 5 * 60 * 1000; + +/** + * GET /session?mode=create already proved the workspace is empty and created + * its directories, and the first POST /prompt lands on the same process. The + * entry is consumed on read, so a second restore — or anything that ran in + * between — probes the sandbox again. + */ +const freshWorkspaces = new Map(); + +export function markFreshWorkspace(conversationId: string, appDir: string) { + const markedAt = Date.now(); + // An abandoned visit never comes back to consume its entry, so expire on write. + for (const [key, entry] of freshWorkspaces) { + if (markedAt - entry.markedAt >= FRESH_WORKSPACE_TTL_MS) freshWorkspaces.delete(key); + } + freshWorkspaces.set(conversationId, { appDir, markedAt }); +} + +function takeFreshWorkspace(conversationId: string, appDir: string) { + const entry = freshWorkspaces.get(conversationId); + if (!entry) return false; + freshWorkspaces.delete(conversationId); + return entry.appDir === appDir && Date.now() - entry.markedAt < FRESH_WORKSPACE_TTL_MS; +} + +export async function ensureWorkspaceDirectories(context: AgentContext, state: ProjectState) { + const files = requireSandbox(context).files; + await Promise.all([ + files.makeDir(state.sessionDir), + files.makeDir(state.appDir), + ]); +} + +async function probeSandboxHasFiles(context: AgentContext, state: ProjectState) { + if (!(await requireSandbox(context).files.exists(state.appDir))) return false; + const tree = await getFileTree(context, state); + return tree.some((item) => item.type === 'file'); +} + +/** + * Restore the volatile sandbox from Blob-backed persist, used both before a + * prompt and when GET /session rebuilds the workspace. + */ +export async function restoreProjectWorkspace( + context: AgentContext, + conversationId: string, + options: { send?: StreamSend; mode?: 'prepare' | 'resume' } = {}, +): Promise<{ state: ProjectState; hasFiles: boolean; restoreError?: string }> { + const state = separateLegacyMakersDeployment(await getProjectState(context, conversationId)); + if (takeFreshWorkspace(conversationId, state.appDir)) { + return { state, hasFiles: false }; + } + await repairNestedAppDirLayout(context, state); + const send = options.send; + let hasFiles = false; + let restoreError: string | undefined; + + try { + hasFiles = await timeStage('workspace:restore', { phase: 'probe' }, () => withTimeout( + probeSandboxHasFiles(context, state), + SANDBOX_PROBE_MS, + 'sandbox file probe', + )); + } catch (error) { + hasFiles = false; + restoreError = error instanceof Error ? error.message : 'Sandbox probe failed.'; + } + + if (!hasFiles) { + try { + const restored = await timeStage('workspace:restore', { phase: 'snapshot' }, () => withTimeout( + restorePersistedProject(context, conversationId, state, { + installDependencies: options.mode !== 'resume', + }), + RESTORE_BUDGET_MS, + 'snapshot restore', + )); + hasFiles = restored.restored; + if (!restored.restored) restoreError = restored.error; + } catch (error) { + hasFiles = false; + restoreError = error instanceof Error ? error.message : 'Snapshot restore failed.'; + } + } + + try { + await ensureWorkspaceDirectories(context, state); + } catch (error) { + send?.({ + type: 'error', + error: error instanceof Error ? error.message : 'Workspace directory creation failed.', + }); + } + + if (hasFiles) markCreated(state); + if (hasFiles) { + try { + await persistWorkspace(context, conversationId, state); + } catch { + // The sandbox files are still the working copy for this turn. + } + } + + return { state, hasFiles, restoreError }; +} + +export async function prepareProjectWorkspace( + context: AgentContext, + conversationId: string, + send?: StreamSend, +): Promise { + const restored = await restoreProjectWorkspace(context, conversationId, { + send, + mode: 'prepare', + }); + return restored.state; +} diff --git a/agents/_lib/prompt.ts b/agents/_lib/prompt.ts index 5b48532..73dcf7a 100644 --- a/agents/_lib/prompt.ts +++ b/agents/_lib/prompt.ts @@ -1,12 +1,10 @@ import { - MAKERS_DEV_PORT, PREVIEW_ASSET_PREFIX_ENV, PREVIEW_PATH_PREFIX, PREVIEW_PUBLIC_PORT, - PREVIEW_SERVER_PORT, } from './constants.ts'; -import type { ConversationMessage, ProjectState } from './types.ts'; -import { resolveConversationPublishArea } from './project/makers-deploy.ts'; +import type { ProjectState } from './types.ts'; +import { resolveConversationPublishArea } from './makers/project.ts'; // The system prompt is split into named sections so each rule has an obvious // owner. The dividing line is deliberate: platform knowledge (handler @@ -16,11 +14,10 @@ import { resolveConversationPublishArea } from './project/makers-deploy.ts'; // the product's narration and reply style. Restating platform rules here would // create a second source of truth that silently drifts when the skills update. // -// Nothing that changes between turns belongs in here. The request and the -// history travel as the turn's own message (buildTurnPrompt), which keeps this -// text identical for every turn of a conversation — a prefix that changes on -// each turn can never be cached, and the request arriving twice leaves two -// copies with no way to say which one is authoritative. +// Nothing that changes between turns belongs in here. The request travels as +// the SDK user message, and resume loads history from the transcript, which +// keeps this text identical for every turn of a conversation — a prefix that +// changes on each turn can never be cached. /** Headings, so a 40-rule prompt reads as sections rather than as a wall. */ function section(title: string, body: readonly string[], spaced = false) { @@ -116,7 +113,7 @@ function buildSandboxTools(appDir: string, mcpServerName: string) { // One place says what to do about a missing CLI. The same instruction used // to appear in the workflow and in the code-quality rules as well, and // three copies of a rule are three chances for one of them to go stale. - 'A missing CLI is a platform-capability failure, not a project bug. If makers dev or deploy fails before returning a concrete CLI error, one read-only edgeone --version check is allowed. If any command returns errorCode=MAKERS_CLI_UNAVAILABLE, stop immediately and tell the user the sandbox image does not provide the CLI yet. Do not inspect PATH or installation directories, run command -v/which/npm ls, install packages, use npx, retry, or replace the prescribed command with ad-hoc shell diagnostics.', + 'A missing CLI is a platform-capability failure, not a project bug. If makers deploy fails before returning a concrete CLI error, one read-only edgeone --version check is allowed. If any command returns errorCode=MAKERS_CLI_UNAVAILABLE, stop immediately and tell the user the sandbox image does not provide the CLI yet. Do not inspect PATH or installation directories, run command -v/which/npm ls, install packages, use npx, retry, or replace the prescribed command with ad-hoc shell diagnostics.', 'Never probe or enumerate platform internals to explain a failure: no AI Gateway URLs, no model lists, no generated .edgeone output, no process or port state.', ]; } @@ -125,20 +122,20 @@ function buildSandboxPreview(appDir: string, makersProjectName: string, area: st const quotedProjectName = JSON.stringify(makersProjectName); const publishArea = area === 'overseas' ? 'overseas' : 'global'; return [ - `To publish the right-hand development preview, run edgeone makers dev --port ${MAKERS_DEV_PORT} --skip-env-sync --skip-ai-gateway-sync --name ${quotedProjectName} --area ${publishArea} once through commands with cwd=${appDir}. The commands tool keeps Makers dev running at its root, exposes it through the sandbox path adapter on port ${PREVIEW_SERVER_PORT}, and publishes sandbox.getHost(${PREVIEW_PUBLIC_PORT})${PREVIEW_PATH_PREFIX} to the preview panel. Do not add nohup, start another server, synthesize a public URL, or use a cloud deploy as the normal preview.`, + `The host starts the right-hand development preview as soon as the project workspace exists in this sandbox, and keeps that dest server watching files so later edits show up there. Do not run a preview server, add nohup, start another server, synthesize a public URL, or use a cloud deploy as the normal preview. The sandbox path adapter publishes sandbox.getHost(${PREVIEW_PUBLIC_PORT})${PREVIEW_PATH_PREFIX} to the preview panel.`, // The model has no restart primitive, and it went looking for one: a turn // that changed dependencies under a running server tried to kill it, free // its port, and relaunch it, none of which the host acts on. - 'Rerunning that same command is your only restart mechanism, and whether a restart actually happens is the host\'s decision: it probes the generated endpoints first and restarts the server when one is not mounted. Do not kill processes or free ports to force one — the host terminates the previous server itself before every launch.', + 'The host restarts the preview when generated endpoints are missing. Do not kill processes, free ports, or launch a preview server yourself — the host terminates the previous server before every launch.', // A run installed dependencies and built while the preview was up, and both // lost the race silently: the build reported a Pages Router page the project // does not have, and npm reported ENOTEMPTY on a package the server held. - 'A build or an install cannot run beside the preview, so the host stops the dev server before either and says so in that command\'s output. The preview is then down until you launch it again. Do not report a preview as running across an install or a build you issued after it.', - `Only when the user explicitly asks for a live deployment, run edgeone makers deploy --json once through commands with cwd=${appDir}. The host supplies credentials, pins the project this conversation publishes to, allows the long timeout, parses the final JSON line, and renders the result in its own deployment card.`, + 'A build or an install cannot run beside the preview, so the host stops the dev server before either and says so in that command\'s output. The preview is then down until the host starts it again. Do not report a preview as running across an install or a build you issued after it.', + `Only when the user explicitly asks for a live deployment, run edgeone makers deploy --json once through commands with cwd=${appDir}. This conversation publishes to ${quotedProjectName} with --area ${publishArea}. The host supplies credentials, pins the project this conversation publishes to, allows the long timeout, parses the final JSON line, and renders the result in its own deployment card.`, 'Never pass -n, invent a project name, or retry a failed deploy under a different one: the name identifies the user\'s site, and a deploy under a name you chose publishes somewhere nobody can find again. A deployment never replaces the right-hand preview, so do not tell the user their live site opened there.', 'Declare AI_GATEWAY_API_KEY= and AI_GATEWAY_BASE_URL= in .env.example when the project calls a model. Never write a .env file yourself, and never write an actual API key or gateway URL value into source. Generated agents read them from context.env.', - 'Before preview or deploy of an AI project — one that declares those keys in .env.example, or that has an agents/ directory — call request_gateway_credentials. If the result says the key is already configured, not required, or previously skipped, continue. If it says the user has been asked, stop this turn: do not run edgeone makers dest or deploy, and do not call the tool again. The host shows the input card. Your last user-facing sentence must ask them to enter the key or skip; do not say the preview is ready.', - 'The user may type a key in the composer in natural language, for example "我的 apikey 是 …,配置好并重新预览", or submit the input card. The host extracts it, writes .env, and the message you see is a masked API Key line — or a skip. After a provided key the host has written .env; after a skip, preview and deploy must still run — a missing key is not a preview or deploy failure. Chat in the generated app may not answer until a key is added later. Never write .env yourself and never quote an API key value, from a file or from the user.', + 'The host collects a Models API key for generated AI projects as soon as it sees one. If you load makers-agents or write agents/ files, the host shows the input card while you keep working. Do not stop this turn, do not wait for the key, and do not say the preview is blocked. Continue writing files and let the host start preview. A missing key is not a preview or deploy failure — chat in the generated app may not answer until a key is added. Never write .env yourself and never quote an API key value, from a file or from the user.', + 'The user may type a key in the composer in natural language, for example "我的 apikey 是 …,配置好并重新预览". The host extracts it, writes .env, and the message you see is a masked API Key line. Never write .env yourself and never quote an API key value.', 'The host writes AI_GATEWAY_BASE_URL already shaped for OpenAI-compatible clients. Use that value through the generated env helper; never probe, enumerate, or retry alternate gateway paths, and never concatenate /v1/chat/completions onto the base.', ]; } @@ -211,59 +208,24 @@ function buildToolContracts(appDir: string) { function buildNewProjectWorkflow(appDir: string) { return [ - 'When ensure_project_scaffold returns created=true, work through these steps in order.', - '1. Load the references this request needs with load_makers_skill and follow them for layout, routing, handler signatures, configuration files, and storage. Prefer static HTML/CSS/JS or Vite static output for ordinary UI. Do not put styles, scripts, and markup into one large index.html unless the user explicitly asks for a single-file page.', - // Eight commands went into excavating one framework's "official template": - // npm view, then tarballs downloaded and unpacked in /tmp, then a package's - // own source read to find where it fetches templates from, then the same - // again for its replacement. Every step was reasonable and the sequence had - // no bottom, because each answer was only ever "the template is elsewhere". - // The scaffolder is where it ends: it holds both the structure and the - // version set, and running it costs one command. - // - // Which command that is, though, is the reference's to say. The two copies - // this step used to carry had already drifted from it: the Next.js one was - // down to `. --yes` while the document specifies four more flags, and the - // flags are the whole difference between a scaffolder and a prompt nobody - // is there to answer. - // The scaffolder was run at build time for the frameworks with a baked - // template, so for those this step is already done before the model reads - // it. Saying so here rather than only in the tool result, because the - // instruction it contradicts is this one: a run that reaches step 2 with - // its workspace already populated would otherwise put a scaffolder into a - // directory that is no longer empty, which every one of them refuses. - // A measured Next.js turn still loaded the frameworks index and nextjs.md - // after the template landed, then rewrote next.config just to add the - // prefix line the host now writes. Both loads exist to answer Scaffold and - // assetPrefix; neither is a question once the template is applied. - 'A templateApplied in the ensure_project_scaffold result means that framework\'s scaffolder has already been run for you and its files are in place. Skip the rest of this step and go to step 3 — do not run a scaffold command, and do not re-create files that are already there. Do not load makers-frameworks just to read the Scaffold command or the asset-prefix snippet: both are already done, and the prefix option is already in the framework config. Load it only for an adapter location, a 404 convention, or an unsupported-feature rule you are about to use. Load makers-storage, makers-agents, or makers-cloud-functions only when the request actually needs those.', - `2. When the request names a framework and no template was applied, the reference loaded in step 1 gives its scaffold command under Scaffold. Copy that command exactly and run it once through commands with cwd=${appDir}, into the current directory. Do not compose one from memory and do not drop or add a flag — the flags documented there are what keep it non-interactive, and a scaffolder that stops to ask a question in a sandbox hangs the turn. ${appDir} is empty here, which those tools require, and a generous timeout is needed because it installs as it goes. This is the one case where a command may create project source files.`, + `The host has already prepared an empty project directory at ${appDir} and started the coding agent. The workspace has no files yet. Work through these steps in order.`, + '1. Load the references this request needs with load_makers_skill and follow them for layout, routing, handler signatures, configuration files, and storage. Prefer static HTML/CSS/JS or Vite static output for ordinary UI. Do not put styles, scripts, and markup into one large index.html unless the user explicitly asks for a single-file page. load_makers_skill is the first tool of a new project — do not write files or run commands before the required references are loaded.', + `2. When the request names a framework, the reference loaded in step 1 gives its scaffold command under Scaffold. Copy that command exactly and run it once through commands with cwd=${appDir}, into the current directory. Do not compose one from memory and do not drop or add a flag — the flags documented there are what keep it non-interactive, and a scaffolder that stops to ask a question in a sandbox hangs the turn. ${appDir} is empty here, which those tools require, and a generous timeout is needed because it installs as it goes. This is the one case where a command may create project source files.`, 'A framework whose reference lists no scaffold command has none worth running: write its files yourself from the values that document gives. If the scaffolder prompts, hangs, or fails, that is one attempt and it is over: write the files yourself and let the build report what is wrong. Do not try a second scaffolder, a different package name, or a flag variation.', - // Sourcing the command from the references must not read as an allowlist of - // framework names. What the platform bounds is the output shape, not the - // name: it runs any build and uploads any output directory, so static is - // unbounded, while a server bundle needs an adapter that exists. 'A framework the references do not cover is still one this platform builds, so never decline a request for not finding it listed. Derive what it needs the way makers-frameworks describes — an adapter only if it emits a server bundle, its build command and output directory declared in edgeone.json, its own asset-prefix option — then build it and report what happened.', - // The tool's own mechanics — one file per call, paths relative to appDir, - // one call per message — are stated once in the tool contracts above. What - // belongs here is only the order, which is what this workflow decides. '3. After the required references are loaded, write the project with write_project_file, one complete file per call and in dependency order. When a scaffolder ran, keep what it produced and use these calls to adapt it — the platform declarations and the entry route — rather than rewriting files it already got right. If agents/chat.ts is already in the workspace, edit that file; do not also write agents/chat/index.ts — both mount POST /chat. Otherwise write configuration and dependencies first, then styles and small modules, then the entry HTML, then any platform function or agent directories. Dependencies come before agent code specifically: the platform declarations an agent project needs are derived from the packages it declares, so a dependency file that arrives later cannot inform them.', - // "a scaffolder has not already installed them" asked the wrong question. - // A workspace can arrive with its dependencies installed by something that - // is not a scaffolder, and then this rule reads as permission to install - // over a tree that is already there — which is how a turn spent four - // minutes filling the disk, breaking the tree it had, and ending with - // nothing runnable. ensure_project_scaffold now answers the right question. - `4. Install dependencies inside ${appDir} only when the project has a package.json with dependencies and ensure_project_scaffold reported dependenciesInstalled=false (cd ${appDir} && npm install by default; Python packages are declared in the project's requirements file and installed by the platform). Do not invent nested ${appDir}/${appDir} paths.`, + `4. The host starts npm install in the background the moment package.json is written. When you run npm install yourself, that command waits for the background install and reports its result — it does not install twice. Run npm install inside ${appDir} only when the project has a package.json with dependencies that are not yet on disk (cd ${appDir} && npm install by default; Python packages are declared in the project's requirements file and installed by the platform). Do not invent nested ${appDir}/${appDir} paths.`, 'Take every dependency name and version range from the reference you loaded for that framework, and copy its dependency block as written. Versions recalled from memory are the usual cause of peer-dependency conflicts and engine mismatches, and each one costs a rewrite plus a reinstall. If a reference pins a version or caps a range, keep the pin instead of widening it to latest.', - '5. Check gateway credentials as the preview section requires, then run edgeone makers dev through commands, with the flags the sandbox preview section gives. When the command result reports a successful preview URL, stop — do not curl/fetch/code_interpreter the public URL and do not start a second preview server. For a CLI failure, quote and act on its actual error; fix generated source when appropriate, then rerun the same preview command once.', + '5. The host starts the sandbox preview. Do not curl/fetch/code_interpreter the public URL and do not start a preview server.', ]; } -const EXISTING_PROJECT_WORKFLOW = [ - 'When ensure_project_scaffold returns created=false, load only the specific Makers references required by the change with load_makers_skill, inspect only the project files directly related to the request, then make the smallest complete change needed.', - 'For bug reports, do not investigate platform internals, generated .edgeone files, running processes, ports, or external AI gateway behavior. Use at most one focused reproduction command before editing; after the edit, use at most one focused verification command, then check gateway credentials as the preview section requires and run edgeone makers dev once through commands.', -]; +function buildExistingProjectWorkflow(appDir: string) { + return [ + `When ${appDir} already contains project files, load only the specific Makers references required by the change with load_makers_skill, inspect only the project files directly related to the request, then make the smallest complete change needed.`, + 'For bug reports, do not investigate platform internals, generated .edgeone files, running processes, ports, or external AI gateway behavior. Use at most one focused reproduction command before editing; after the edit, use at most one focused verification command. The host starts the sandbox preview.', + ]; +} const CODE_QUALITY = [ // Three deliverable classes, not two: an AI agent endpoint is what most of @@ -291,9 +253,7 @@ const CODE_QUALITY = [ 'If you generate a package.json, include scripts.build. For a static HTML/CSS/JS site use "scripts": { "build": "echo skip" }. Vite/Next must use their real build script.', // The config file's extension used to be pinned to .js/.mjs here, and that // cost a delete and a rewrite on every Next.js project: create-next-app - // writes next.config.ts, so the baked template ships one, and the rule sent - // the model to replace a typed config it had just been given with one - // recalled from memory. Nothing needed it — Next has read a TypeScript config + // writes next.config.ts. Nothing needed it — Next has read a TypeScript config // since 15, and this repo deploys to the same platform with one. `If you generate a Next.js project, use the App Router and do not set basePath to ${PREVIEW_PATH_PREFIX}.`, `If you generate a Vite React project, install @vitejs/plugin-react and configure plugins: [react()]. Set base from process.env.${PREVIEW_ASSET_PREFIX_ENV} as described above, never to a literal.`, @@ -302,18 +262,9 @@ const CODE_QUALITY = [ function buildNarration(appDir: string) { return [ - 'If the user request requires creating or modifying a project, first respond with one brief natural-language sentence that you are starting, then call ensure_project_scaffold as the first tool to prepare the workspace. Do not call any other tool before ensure_project_scaffold — including Skill, load_makers_skill, files_list, files_make_dir, files_write, commands, or write_project_file.', - // The whole saving rides on this argument arriving in the first call. It is - // the only point at which the host can still put the files down and start - // the install before the model spends a turn on anything else, and the name - // is in the user's message — nothing has to be loaded to know it. - 'Pass framework to that call whenever the request names one, in whatever spelling the user used. Omit it for a plain HTML/CSS/JS page and when no framework was named — it is what the workspace is prepared from, not a decision to make on the user\'s behalf.', - `Before calling ensure_project_scaffold, do not read, write, or execute anything under ${appDir}.`, - 'That first sentence must be concise, user-visible progress narration, not a plan. Use the user language when obvious. Example: 我先准备项目环境,然后开始实现。 / I will prepare the workspace first, then start building.', + `The host has already prepared an empty workspace at ${appDir} and started the coding agent. If the user request requires creating or modifying a project, first respond with one brief natural-language sentence that you are starting, then call load_makers_skill as the first tool. Do not call write_project_file, files_write, files_list, files_make_dir, or commands before the references this request needs are loaded.`, + 'That first sentence must be concise, user-visible progress narration, not a plan. Use the user language when obvious. Example: 我先查一下这个框架的官方用法,然后开始实现。 / I will look up the framework guide first, then start building.', 'Keep narrating as you work: before each tool call or parallel group of tool calls, write one short sentence saying what you are about to do and, when you just read an error, what you think is wrong. This narration is shown to the user, so always write it in the user language, never as internal English notes, raw logs, status codes, or command lines. Example: 我先修好前端请求地址,再刷新预览。 One sentence per step — do not restate the plan or repeat what you already said.', - // The user is here for EdgeOne; Makers, its CLI and its reference documents are - // machinery they never asked about, and a sentence that names them reads as the - // agent talking about itself instead of about their project. 'Narration and the final reply are product copy. Never write the words Makers, load_makers_skill, or a makers-* document id in them, and never name your own tools, the sandbox, or the CLI. Say what the work is about instead: 我先查一下持久化存储的官方用法。 not 我先加载 makers-storage 技能。, and 预览已经启动。 not 我运行了 edgeone makers dev。 When the platform itself has to be named, call it EdgeOne.', ]; } @@ -326,7 +277,7 @@ const FINAL_REPLY = [ // page back every time, and reported the feature working. An HTML body from a // POST to a streaming endpoint is the static site answering in its place. 'An HTML document is not a verified endpoint. When a probe of a project API answers with a page instead of the response that endpoint defines, the request never reached the handler at all — that is a failure to report, not a result to read a meaning into, and never grounds for saying the feature works.', - 'After code changes, check gateway credentials as the preview section requires, then run edgeone makers dev through commands so the user can see the sandbox preview. Do not synthesize preview URLs. Run edgeone makers deploy only when the user explicitly asks to publish a live Makers URL.', + 'After code changes, the host starts the sandbox preview. Do not synthesize preview URLs. Run edgeone makers deploy only when the user explicitly asks to publish a live Makers URL.', 'Do not include preview buttons, preview links, preview URLs, or sandboxDebugUrl in the final response. The sandbox preview is shown only in the right preview panel.', 'A live deployment is the exception: when edgeone makers deploy succeeds, state that the site is live and write its complete URL, query string included, on its own line in the final response. That address is the deliverable and the user has to be able to copy it out of the conversation.', 'Do not take screenshots.', @@ -338,7 +289,7 @@ const FINAL_REPLY = [ * * Everything here is either constant or fixed for the life of the conversation, * which is what lets the model provider reuse the prefix instead of re-reading - * twenty thousand characters per turn. The request itself is buildTurnPrompt's. + * twenty thousand characters per turn. The request itself is the SDK user message. */ export function buildPrompt( state: ProjectState, @@ -348,9 +299,16 @@ export function buildPrompt( modelLabel = '', // Fixed for the life of a deployment, so this stays a cacheable prompt. webSearchAvailable = false, + replyLocale: 'zh' | 'en' | '' = '', ) { + const languageRule = replyLocale === 'zh' + ? 'Write all user-facing narration and the final reply in Chinese.' + : replyLocale === 'en' + ? 'Write all user-facing narration and the final reply in English.' + : 'Write all user-facing narration and the final reply in the language of the user request.'; return [ section('Who you are', buildIdentity(modelLabel)), + section('Language', [languageRule]), section('What you take on', SCOPE), section('Where platform knowledge comes from', buildKnowledgeSourcing(webSearchAvailable)), section('What is not a source, and when to stop looking', buildSearchDiscipline(webSearchAvailable)), @@ -365,32 +323,13 @@ export function buildPrompt( section('Sandbox: browser calls and visitor context', buildSandboxDataPlane()), section('Tool contracts', buildToolContracts(state.appDir)), section('Workflow: a new project', buildNewProjectWorkflow(state.appDir), true), - section('Workflow: an existing project', EXISTING_PROJECT_WORKFLOW), + section('Workflow: an existing project', buildExistingProjectWorkflow(state.appDir)), section('Code quality', CODE_QUALITY), section('Narration', buildNarration(state.appDir)), section('Final reply', FINAL_REPLY), isNewProject - ? 'The project workspace may not have been prepared yet.' - : 'This conversation has already prepared a project workspace.', + ? 'The project workspace is empty and ready for you to write files.' + : 'This conversation already has a project workspace with files in it.', ].join('\n\n'); } -/** - * The turn itself: what the user asked, and enough of the conversation to read - * it in context. - * - * This is the SDK's `prompt`, so the request reaches the model exactly once. - * Passing it here rather than in the system prompt is also what keeps the rules - * above byte-identical between turns. - */ -export function buildTurnPrompt(userMessage: string, history: ConversationMessage[]) { - const recentHistory = history - .slice(-8) - .map((item) => `${item.role === 'user' ? 'User' : 'Assistant'}: ${item.content}`) - .join('\n'); - - return [ - recentHistory ? `Recent conversation:\n${recentHistory}` : '', - `Current user request: ${userMessage}`, - ].filter(Boolean).join('\n\n'); -} diff --git a/agents/_lib/runtime/context.ts b/agents/_lib/runtime/context.ts new file mode 100644 index 0000000..820c3fa --- /dev/null +++ b/agents/_lib/runtime/context.ts @@ -0,0 +1,95 @@ +import type { ProjectState } from '../types.ts'; + +export type SandboxFiles = { + exists?(path: string): Promise; + read?(path: string): Promise; + write?(path: string, content: string | Uint8Array): Promise; + makeDir?(path: string): Promise; + remove?(path: string): Promise; +}; + +export type SandboxCommands = { + run?(command: string, options?: Record): Promise; +}; + +export type ReadySandboxFiles = { + exists(path: string): Promise; + read(path: string): Promise; + write(path: string, content: string | Uint8Array): Promise; + makeDir(path: string): Promise; + remove?(path: string): Promise; +}; + +export type ReadySandboxCommands = { + run(command: string, options?: Record): Promise; +}; + +export type Sandbox = { + files?: SandboxFiles; + commands?: SandboxCommands; + persist?: (options: { path: string }) => Promise; + restore?: (options: { path: string }) => Promise<{ restored?: boolean } | undefined>; + getHost?: (port: number) => Promise | string | undefined; + envdAccessToken?: string; + browser?: { liveUrl?: string }; + extendTimeout?: (seconds: number) => unknown; +}; + +export type ReadySandbox = Sandbox & { + files: ReadySandboxFiles; + commands: ReadySandboxCommands; +}; + +/** The slice of the Makers agent `context` this template actually reads. */ +export type AgentContext = { + conversation_id?: string; + run_id?: string; + env?: Record; + request?: { + body?: unknown; + headers?: Headers | Record; + signal?: AbortSignal; + url?: string; + path?: string; + query?: unknown; + params?: unknown; + [key: string]: unknown; + }; + sandbox?: Sandbox; + tools?: { + toClaudeMcpServer: (name: string, options?: { alwaysLoad?: boolean }) => { + tools: unknown[]; + allowedTools: string[]; + }; + }; + utils?: { + abortActiveRun?: (conversationId: string) => Promise<{ aborted?: boolean } | undefined>; + }; + /** Test seam: an in-memory Blob stand-in. Production uses `@edgeone/pages-blob`. */ + blobStore?: BlobStoreLike; +}; + +export type SandboxCapable = Pick; +export type PersistCapable = Pick; +export type RequestCapable = Pick; +export type EnvCapable = Pick; + +export function requireSandbox(context: SandboxCapable): ReadySandbox { + const sandbox = context.sandbox; + if (!sandbox) { + throw new Error('Sandbox is not available'); + } + return sandbox as ReadySandbox; +} + +export type BlobStoreLike = { + set: (key: string, value: string | ArrayBuffer | Blob | ReadableStream, options?: { onlyIfNew?: boolean }) => Promise; + setJSON: (key: string, value: unknown, options?: { onlyIfNew?: boolean }) => Promise; + get: (key: string, options?: { type?: 'text' | 'json' | 'arrayBuffer' | 'blob' | 'stream'; consistency?: 'strong' | 'eventual' }) => Promise; + delete: (key: string) => Promise; + list: (options?: { prefix?: string }) => Promise<{ blobs: Array<{ key: string; etag?: string }> }>; +}; + +export type WorkspaceMode = 'prepare' | 'resume'; + +export type { ProjectState }; diff --git a/agents/_lib/runtime/merge.ts b/agents/_lib/runtime/merge.ts new file mode 100644 index 0000000..fbd52d2 --- /dev/null +++ b/agents/_lib/runtime/merge.ts @@ -0,0 +1,59 @@ +const STREAM_FINISHED = Symbol('finished'); +const STREAM_ABORTED = Symbol('aborted'); + +class AsyncValueQueue { + private values: T[] = []; + private waiters: Array<(value: T) => void> = []; + + push(value: T) { + const waiter = this.waiters.shift(); + if (waiter) waiter(value); + else this.values.push(value); + } + + next() { + const value = this.values.shift(); + if (value !== undefined) return Promise.resolve(value); + return new Promise((resolve) => this.waiters.push(resolve)); + } +} + +/** Fan in several SSE generators onto one connection without waiting for the slowest. */ +export async function* mergeSseGenerators( + generators: Array>, + signal?: AbortSignal, +): AsyncGenerator { + if (generators.length === 0) return; + if (generators.length === 1) { + yield* generators[0]; + return; + } + + const queue = new AsyncValueQueue(); + let remaining = generators.length; + const abort = () => queue.push(STREAM_ABORTED); + signal?.addEventListener('abort', abort, { once: true }); + + const pumps = generators.map(async (generator) => { + try { + for await (const chunk of generator) { + if (signal?.aborted) return; + queue.push(chunk); + } + } finally { + remaining -= 1; + if (remaining === 0) queue.push(STREAM_FINISHED); + } + }); + + try { + while (!signal?.aborted) { + const item = await queue.next(); + if (item === STREAM_FINISHED || item === STREAM_ABORTED) return; + yield item; + } + } finally { + signal?.removeEventListener('abort', abort); + await Promise.allSettled(pumps); + } +} diff --git a/agents/_lib/utils/request.ts b/agents/_lib/runtime/request.ts similarity index 62% rename from agents/_lib/utils/request.ts rename to agents/_lib/runtime/request.ts index afc3102..931db35 100644 --- a/agents/_lib/utils/request.ts +++ b/agents/_lib/runtime/request.ts @@ -1,21 +1,19 @@ -// Request helpers for agent pipelines. Mirrors the query/header resolution the rest -// of the app relies on for the EdgeOne request shape. +import type { AgentContext, RequestCapable } from './context.ts'; -export function getRequestHeader(context: any, name: string): string { +export function getRequestHeader(context: RequestCapable, name: string): string { const headers = context?.request?.headers; if (!headers) return ''; - // Headers / Map-like (case-insensitive get). - if (typeof headers.get === 'function') { - return String(headers.get(name) || ''); + const maybeHeaders = headers as Headers | Record; + if (typeof (maybeHeaders as Headers).get === 'function') { + return String((maybeHeaders as Headers).get(name) || ''); } - // Plain objects: try exact / lower-case keys, then a case-insensitive scan - // (some runtimes normalize header names inconsistently). + const record = maybeHeaders as Record; const lowerName = name.toLowerCase(); - const directValue = headers[name] ?? headers[lowerName]; + const directValue = record[name] ?? record[lowerName]; const value = directValue - ?? Object.entries(headers).find(([key]) => key.toLowerCase() === lowerName)?.[1]; + ?? Object.entries(record).find(([key]) => key.toLowerCase() === lowerName)?.[1]; return typeof value === 'string' ? value : String(value || ''); } @@ -52,7 +50,10 @@ function getSearchParamFromString(rawValue: unknown, name: string): string { return ''; } -export function getRequestQueryParam(context: any, name: string): { +export function getRequestQueryParam(context: AgentContext & { + query?: unknown; + params?: unknown; +}, name: string): { value: string; source: string; } { @@ -81,15 +82,16 @@ export function getRequestQueryParam(context: any, name: string): { { source: 'context.params', value: context?.params }, ]; for (const query of queryObjects) { - if (query.value && typeof query.value.get === 'function') { - const value = query.value.get(name); + const bag = query.value as { get?: (key: string) => unknown } | Record | undefined; + if (bag && typeof (bag as { get?: unknown }).get === 'function') { + const value = (bag as { get: (key: string) => unknown }).get(name); if (value) { return { value: queryValueToString(value), source: query.source }; } continue; } - if (!query || typeof query !== 'object') continue; - const value = query.value?.[name]; + if (!bag || typeof bag !== 'object') continue; + const value = (bag as Record)[name]; const normalized = queryValueToString(value); if (normalized) { return { value: normalized, source: query.source }; @@ -99,13 +101,16 @@ export function getRequestQueryParam(context: any, name: string): { return { value: '', source: 'none' }; } -/** - * Resolve conversation id from the dual-channel routing shape used by Makers: - * context.conversation_id → makers-conversation-id → conversationId header, - * optionally falling back to query cid / conversationId (for plain navigations). - */ +export function getRequestBody(context: RequestCapable): Record { + const body = context.request?.body; + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return {}; + } + return body as Record; +} + export function resolveConversationId( - context: any, + context: AgentContext, options?: { allowQuery?: boolean }, ): { conversationId: string; source: string } { const contextConversationId = String(context?.conversation_id || ''); @@ -124,8 +129,6 @@ export function resolveConversationId( } if (options?.allowQuery) { - // Query-param fallback so a plain navigation can still target the right - // sandbox; the frontend prefers the headers. const cid = getRequestQueryParam(context, 'cid'); if (cid.value) { return { conversationId: cid.value, source: cid.source }; @@ -138,3 +141,19 @@ export function resolveConversationId( return { conversationId: '', source: 'none' }; } + +/** + * Public site root from the incoming Host, used to pick Makers acceleration + * area. Mirrors the browser hostname split: `foo.edgeone.dev` → `edgeone.dev`. + */ +export function resolveRequestSiteDomain(context: RequestCapable): string { + const forwarded = getRequestHeader(context, 'x-forwarded-host'); + const host = (forwarded || getRequestHeader(context, 'host')).split(',')[0].trim(); + const hostname = host.split(':')[0].toLowerCase(); + if (!hostname || hostname === 'localhost' || /^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { + return ''; + } + const parts = hostname.split('.'); + if (parts.length < 2) return hostname; + return parts.slice(1).join('.'); +} diff --git a/agents/_lib/shared.ts b/agents/_lib/runtime/sse.ts similarity index 87% rename from agents/_lib/shared.ts rename to agents/_lib/runtime/sse.ts index 7b18844..84b86e3 100644 --- a/agents/_lib/shared.ts +++ b/agents/_lib/runtime/sse.ts @@ -1,4 +1,6 @@ -export function sseEvent(data: Record): string { +import type { ChatStreamEvent, ResumeStreamEvent, SessionStreamEvent } from '../../../shared/protocol.ts'; + +export function sseEvent(data: SessionStreamEvent): string { return `data: ${JSON.stringify(data)}\n\n`; } @@ -64,3 +66,5 @@ export function createSSEResponse( }, }); } + +export type { ChatStreamEvent, ResumeStreamEvent, SessionStreamEvent }; diff --git a/agents/_lib/session/gateway-apply.ts b/agents/_lib/session/gateway-apply.ts new file mode 100644 index 0000000..bd004a3 --- /dev/null +++ b/agents/_lib/session/gateway-apply.ts @@ -0,0 +1,126 @@ +import type { AgentContext } from '../runtime/context.ts'; +import { applyUserGatewayDecision } from '../project/gateway.ts'; +import { + isPreviewServerReady, + publishRunningPreview, + startPreviewServer, +} from '../project/preview.ts'; +import { persistWorkspace } from '../project/workspace-store.ts'; +import { prepareProjectWorkspace } from '../project/workspace.ts'; +import { getConversationId } from './task.ts'; +import { getLiveWorkspace } from './live-workspace.ts'; +import type { ProjectState, StreamSend } from '../types.ts'; + +function jsonResponse(body: Record, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, + }); +} + +async function restartOrStartPreview( + context: AgentContext, + conversationId: string, + state: ProjectState, + send: StreamSend | undefined, + options: { forceRestart: boolean }, +) { + await startPreviewServer(context, state, { + verifyRoutes: false, + forceRestart: options.forceRestart, + }); + const preview = await publishRunningPreview(context, state, { routesAlreadyVerified: true }); + await persistWorkspace(context, conversationId, state); + const payload = { + ...preview, + restarted: options.forceRestart, + }; + send?.({ + type: 'preview_ready', + data: { + preview: payload, + download: { url: '/download', filename: 'source.zip' }, + }, + }); + return payload; +} + +/** + * Write a Models API key or skip without opening a coding-agent turn. + * A live generation keeps running; its SSE gets `gateway_credentials: resolved`. + */ +export async function applyGatewayDecisionAndRespond( + context: AgentContext, + decision: { apiKey?: string; skip?: boolean }, +) { + const conversationId = getConversationId(context); + if (!conversationId) { + return jsonResponse({ + ok: false, + error: 'Missing conversationId. The project workspace cannot be prepared.', + }, 400); + } + + const apiKey = (decision.apiKey || '').trim(); + if (!decision.skip && !apiKey) { + return jsonResponse({ + ok: false, + error: 'Provide an API key or skip.', + }, 400); + } + + try { + const live = getLiveWorkspace(conversationId); + const send = live?.send; + const state = live?.state ?? await prepareProjectWorkspace(context, conversationId, send); + const values = await applyUserGatewayDecision( + context, + state, + conversationId, + { + ...(apiKey ? { apiKey } : {}), + ...(decision.skip ? { skip: true } : {}), + }, + send, + ); + + let preview: Awaited> | undefined; + const destRunning = Boolean(state.previewUrl) || await isPreviewServerReady(context).catch(() => false); + try { + if (live) { + if (!decision.skip && destRunning) { + preview = await restartOrStartPreview(context, conversationId, state, send, { + forceRestart: true, + }); + } + } else if (state.created) { + preview = await restartOrStartPreview(context, conversationId, state, send, { + forceRestart: destRunning && !decision.skip, + }); + } + } catch (error) { + console.warn( + '[gateway] preview after apply failed', + error instanceof Error ? error.message : error, + ); + } + + return jsonResponse({ + ok: true, + conversation_id: conversationId, + applied: true, + live: Boolean(live), + skipped: Boolean(decision.skip), + configured: Boolean(values.AI_GATEWAY_API_KEY), + ...(preview ? { + preview, + download: { url: '/download', filename: 'source.zip' }, + } : {}), + }); + } catch (error) { + return jsonResponse({ + ok: false, + error: error instanceof Error ? error.message : 'Failed to apply the API key.', + }, 500); + } +} diff --git a/agents/_lib/session/live-workspace.ts b/agents/_lib/session/live-workspace.ts new file mode 100644 index 0000000..ed18614 --- /dev/null +++ b/agents/_lib/session/live-workspace.ts @@ -0,0 +1,29 @@ +import type { ProjectState, StreamSend } from '../types.ts'; + +type LiveWorkspaceBinding = { + state: ProjectState; + send?: StreamSend; +}; + +const bindings = new Map(); + +export function bindLiveWorkspace( + conversationId: string, + state: ProjectState, + send?: StreamSend, +) { + const id = conversationId.trim(); + if (!id) return; + bindings.set(id, { state, send }); +} + +export function unbindLiveWorkspace(conversationId: string) { + const id = conversationId.trim(); + if (!id) return; + bindings.delete(id); +} + +export function getLiveWorkspace(conversationId: string): LiveWorkspaceBinding | undefined { + const id = conversationId.trim(); + return id ? bindings.get(id) : undefined; +} diff --git a/agents/_lib/session/live.ts b/agents/_lib/session/live.ts new file mode 100644 index 0000000..89f753e --- /dev/null +++ b/agents/_lib/session/live.ts @@ -0,0 +1,704 @@ +import { + query, + type Query, + type SDKMessage, + type SDKResultMessage, + type SDKUserMessage, +} from '@anthropic-ai/claude-agent-sdk'; +import { + DEFAULT_PATH, + GATEWAY_CONVERSATION_ID_HEADER_NAME, + GATEWAY_QUOTA_BYPASS_HEADER, + GATEWAY_QUOTA_PROMPT_HEADER, + MAKERS_SKILL_NAMES, + SANDBOX_MCP_SERVER_NAME, +} from '../constants.ts'; +import { + describeModelRun, + resolveConfiguredModel, + resolveRunningModelLabel, +} from '../models.ts'; +import type { AgentContext } from '../runtime/context.ts'; +import { + assembleAgentTools, + emptyCodingResult, + type LiveSessionHandle, + type LiveTurnCallbacks, +} from '../tools/assemble.ts'; +import type { + AgentProgressEvent, + CodingAgentResult, + ProjectState, +} from '../types.ts'; +import { detectFatalToolError, truncateForStream } from '../utils/text.ts'; +import { sanitizeAssistantText, summarizeToolOutput } from '../../../shared/timeline.ts'; +import { parseEchoedExitCode } from '../makers/tool-phase.ts'; +import { buildPrompt } from '../prompt.ts'; +import { resolveMakersProjectName } from '../makers/project.ts'; +import { getConversationRecord, getLanguagePreference, patchConversationRecord } from './store.ts'; +import { downloadTranscript, resolveClaudeTranscriptPath, uploadTranscript } from './transcript.ts'; +import { PromptQueue } from './prompt-queue.ts'; +import { + createProgressEmitter, + describeSdkMessage, + extractVisibleNarrationDelta, + extractVisibleTextBlock, + extractVisibleThinkingBlock, + extractVisibleThinkingDelta, + formatResultUsage, + isThinkingContentBlock, + isToolUseContentBlock, + parseToolInputJson, + type StreamingToolUseBlock, +} from './stream-projector.ts'; + +type TurnWaiter = { + callbacks: LiveTurnCallbacks; + onProgress?: (event: AgentProgressEvent) => void; + resolve: (result: CodingAgentResult) => void; +}; + +type LiveQuerySession = LiveSessionHandle & { + queue: PromptQueue; + query: Query; + sessionId?: string; + transcriptPath?: string; + model: string; + turn?: TurnWaiter; + state: ProjectState; + pump: Promise; + idleTimer?: ReturnType; +}; + +/** Close a warmed process that never received a turn, so abandoned visits do not leak one. */ +export const LIVE_QUERY_IDLE_MS = 5 * 60 * 1000; + +const liveQueries = new Map(); + +export type StartLiveQueryOptions = { + context: AgentContext; + conversationId: string; + state: ProjectState; + isNewProject: boolean; + abortSignal?: AbortSignal; + model?: string; + /** Passed in rather than read back, so the preference write can run in parallel. */ + language?: string; +}; + +export type RunCodingAgentOptions = StartLiveQueryOptions & { + userMessage: string; + onProgress?: (event: AgentProgressEvent) => void; + onProjectFilesChanged?: LiveTurnCallbacks['onProjectFilesChanged']; + onPreviewReady?: LiveTurnCallbacks['onPreviewReady']; + onDeploymentStatus?: LiveTurnCallbacks['onDeploymentStatus']; + send?: LiveTurnCallbacks['send']; +}; + +function pickEnvValue(context: AgentContext, key: string) { + const value = context?.env?.[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +function sanitizeHeaderValue(value: string) { + return value.replace(/[\r\n]+/g, ' ').trim(); +} + +function buildAnthropicCustomHeaders(customHeaders: string, conversationId: string) { + const safeConversationId = sanitizeHeaderValue(conversationId); + return [ + customHeaders, + GATEWAY_QUOTA_BYPASS_HEADER, + GATEWAY_QUOTA_PROMPT_HEADER, + safeConversationId + ? `${GATEWAY_CONVERSATION_ID_HEADER_NAME}: ${safeConversationId}` + : '', + ].filter(Boolean).join('\n'); +} + +function userMessage(content: string): SDKUserMessage { + return { + type: 'user', + message: { role: 'user', content }, + parent_tool_use_id: null, + }; +} + +function flagsFrom(session: LiveQuerySession): Pick< + CodingAgentResult, + 'projectTouched' | 'filesWritten' | 'previewTouched' | 'deploymentTouched' | 'wasCreated' +> { + return { + projectTouched: session.flags.projectTouched, + filesWritten: session.flags.filesWritten, + previewTouched: session.flags.previewTouched, + deploymentTouched: session.flags.deploymentTouched, + wasCreated: false, + }; +} + +function clearIdleTimer(session: LiveQuerySession) { + if (!session.idleTimer) return; + clearTimeout(session.idleTimer); + session.idleTimer = undefined; +} + +function scheduleIdleClose(session: LiveQuerySession) { + clearIdleTimer(session); + session.idleTimer = setTimeout(() => { + if (session.turn) return; + void disposeLiveQuery(session.conversationId); + }, LIVE_QUERY_IDLE_MS); +} + +function disposeLiveQuery(conversationId: string) { + const session = liveQueries.get(conversationId); + if (!session || session.turn) return false; + clearIdleTimer(session); + liveQueries.delete(conversationId); + try { + session.query.close(); + } catch (error) { + console.warn('[agent] failed to close the idle SDK query', error); + } + session.queue.close(); + return true; +} + +async function persistTranscript(session: LiveQuerySession) { + if (!session.sessionId || !session.transcriptPath) return; + await uploadTranscript({ + context: session.context, + conversationId: session.conversationId, + sessionId: session.sessionId, + sourcePath: session.transcriptPath, + }); +} + + +async function pumpSession(session: LiveQuerySession) { + const pendingToolUseBlocks = new Map(); + const progress = createProgressEmitter({ + appDir: session.getState().appDir, + onProgress: (event) => session.turn?.onProgress?.(event), + }); + let fatalError: string | null = null; + + const finishTurn = async (result: CodingAgentResult) => { + await persistTranscript(session).catch((error) => { + console.warn('[transcript] upload failed', error); + }); + const waiter = session.turn; + session.turn = undefined; + waiter?.resolve(result); + }; + + try { + for await (const event of session.query as AsyncIterable) { + const systemEvent = event as SDKMessage & { subtype?: string; session_id?: string }; + if (event.type === 'system' && systemEvent.subtype === 'compact_boundary') { + await persistTranscript(session).catch((error) => { + console.warn('[transcript] compaction upload failed', error); + }); + } + if (typeof systemEvent.session_id === 'string' && systemEvent.session_id) { + session.sessionId = systemEvent.session_id; + if (!session.transcriptPath) { + session.transcriptPath = resolveClaudeTranscriptPath(systemEvent.session_id); + } + } + + if (!session.turn) continue; + + if (event.type === 'stream_event') { + progress.emitNarration( + extractVisibleNarrationDelta(event), + typeof event.uuid === 'string' ? event.uuid : '', + false, + ); + progress.emitThinking( + extractVisibleThinkingDelta(event), + typeof event.uuid === 'string' ? event.uuid : '', + false, + ); + const streamEvent = (event as { event?: Record }).event; + if (streamEvent?.type === 'content_block_start') { + const contentBlock = streamEvent.content_block; + if (contentBlock?.type === 'text') { + progress.beginTextBlock(); + } + if (isThinkingContentBlock(contentBlock)) { + progress.beginThinkingBlock(); + if (contentBlock?.type === 'redacted_thinking') { + progress.emitThinking('(redacted)', typeof event.uuid === 'string' ? event.uuid : '', true); + } + } + if (isToolUseContentBlock(contentBlock) && typeof streamEvent.index === 'number') { + pendingToolUseBlocks.set(streamEvent.index, { + id: typeof contentBlock.id === 'string' ? contentBlock.id : '', + name: typeof contentBlock.name === 'string' ? contentBlock.name : '', + inputJson: '', + input: contentBlock.input, + }); + progress.emitToolUseProgress({ + id: contentBlock.id, + name: contentBlock.name, + input: contentBlock.input, + }); + } + } else if (streamEvent?.type === 'content_block_delta') { + const delta = streamEvent.delta; + const pendingToolUse = typeof streamEvent.index === 'number' + ? pendingToolUseBlocks.get(streamEvent.index) + : undefined; + if (pendingToolUse && delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') { + const previousLength = pendingToolUse.inputJson.length; + pendingToolUse.inputJson += delta.partial_json; + const parsed = parseToolInputJson(pendingToolUse.inputJson, pendingToolUse.input); + const crossedChunk = Math.floor(previousLength / 120) !== Math.floor(pendingToolUse.inputJson.length / 120); + if (crossedChunk || parsed !== pendingToolUse.input) { + progress.emitToolUseProgress({ + id: pendingToolUse.id, + name: pendingToolUse.name, + input: parsed, + inputJson: pendingToolUse.inputJson, + }); + } + } + } else if (streamEvent?.type === 'content_block_stop') { + const pendingToolUse = typeof streamEvent.index === 'number' + ? pendingToolUseBlocks.get(streamEvent.index) + : undefined; + if (pendingToolUse) { + pendingToolUseBlocks.delete(streamEvent.index); + progress.emitToolUseProgress({ + id: pendingToolUse.id, + name: pendingToolUse.name, + input: parseToolInputJson(pendingToolUse.inputJson, pendingToolUse.input), + inputJson: pendingToolUse.inputJson, + }); + } + } + continue; + } + + if (event.type === 'assistant') { + const blocks = (event as { message?: { content?: unknown } }).message?.content; + if (Array.isArray(blocks)) { + for (const block of blocks) { + progress.emitNarration( + extractVisibleTextBlock(block), + typeof event.uuid === 'string' ? event.uuid : '', + true, + ); + progress.emitThinking( + extractVisibleThinkingBlock(block), + typeof event.uuid === 'string' ? event.uuid : '', + true, + ); + if (isToolUseContentBlock(block)) { + progress.emitToolUseProgress({ id: block.id, name: block.name, input: block.input }); + } + } + } + continue; + } + + if (event.type === 'user') { + const blocks = (event as { message?: { content?: unknown } }).message?.content; + if (Array.isArray(blocks)) { + for (const block of blocks) { + const record = block && typeof block === 'object' ? block as Record : {}; + if (record.type !== 'tool_result') continue; + const text = Array.isArray(record.content) + ? record.content.map((item: any) => (typeof item?.text === 'string' ? item.text : '')).join(' ') + : (typeof record.content === 'string' ? record.content : ''); + const toolUseId = typeof record.tool_use_id === 'string' ? record.tool_use_id : ''; + const toolContext = progress.toolContextById.get(toolUseId); + const toolName = toolContext?.name || ''; + const echoedExit = parseEchoedExitCode(text); + const commandFailed = typeof echoedExit === 'number' && echoedExit !== 0; + const toolFailed = record.is_error === true || commandFailed; + session.turn?.onProgress?.({ + type: 'tool_result', + data: { + id: toolUseId, + toolName, + ...(toolContext?.command ? { command: toolContext.command } : {}), + ok: !toolFailed, + preview: truncateForStream(text, 8_000), + outputSummary: summarizeToolOutput(text, session.getState().appDir, toolName), + status: toolFailed ? 'failed' : 'completed', + endedAt: Date.now(), + }, + }); + if (record.is_error === true && !fatalError) { + const fatal = detectFatalToolError(text); + if (fatal) { + fatalError = `${fatal} (tool=${toolName})`; + console.warn('[fatal] aborting agent loop:', fatalError); + } + } + } + } + if (fatalError) { + await finishTurn(emptyCodingResult({ + error: fatalError, + fatal: true, + ...flagsFrom(session), + })); + fatalError = null; + } + continue; + } + + if (event.type === 'tool_progress') { + const progressEvent = event as SDKMessage & { + tool_use_id?: string; + tool_name?: string; + elapsed_time_seconds?: number; + }; + const toolUseId = typeof progressEvent.tool_use_id === 'string' ? progressEvent.tool_use_id : ''; + const toolContext = progress.toolContextById.get(toolUseId); + const elapsed = typeof progressEvent.elapsed_time_seconds === 'number' + ? Math.max(0, Math.round(progressEvent.elapsed_time_seconds)) + : 0; + progress.emitToolUseProgress({ + id: toolUseId, + name: progressEvent.tool_name || toolContext?.name || '', + outputSummary: elapsed ? `${elapsed}s` : 'running', + }); + continue; + } + + if (event.type === 'result') { + const resultMessage = event as SDKResultMessage; + progress.emitInfo({ + infoType: 'usage', + title: 'Usage', + content: formatResultUsage(resultMessage), + }); + const modelRun = describeModelRun(session.model, resultMessage.modelUsage); + if (modelRun.mismatch) { + console.warn('[model]', `${modelRun.line} — the gateway served a model this turn did not request`); + } else { + console.info('[model]', modelRun.line); + } + if (resultMessage.subtype !== 'success') { + await finishTurn(emptyCodingResult({ + error: Array.isArray(resultMessage.errors) && resultMessage.errors.length > 0 + ? resultMessage.errors[0] + : 'Model execution failed.', + ...flagsFrom(session), + })); + } else { + await finishTurn({ + success: true, + output: sanitizeAssistantText((resultMessage.result || '').trim()), + error: null, + ...flagsFrom(session), + }); + } + pendingToolUseBlocks.clear(); + progress.resetTurn(); + fatalError = null; + continue; + } + + const info = describeSdkMessage(event); + if (info) progress.emitInfo(info); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const fatal = detectFatalToolError(message); + if (session.turn) { + await finishTurn(emptyCodingResult({ + error: fatal || message || 'Execution failed.', + ...(fatal ? { fatal: true } : {}), + ...flagsFrom(session), + })); + } + } finally { + if (session.turn) { + await finishTurn(emptyCodingResult({ + error: 'The model stream ended without returning a result.', + ...flagsFrom(session), + })); + } + liveQueries.delete(session.conversationId); + clearIdleTimer(session); + try { + session.query.close(); + } catch (error) { + console.warn('[agent] failed to close the SDK query', error); + } + session.queue.close(); + } +} + +async function startLiveQuery(options: StartLiveQueryOptions): Promise { + const { context, conversationId } = options; + const apiKey = pickEnvValue(context, 'AI_GATEWAY_API_KEY') + || pickEnvValue(context, 'ANTHROPIC_API_KEY') + || pickEnvValue(context, 'DEEPSEEK_API_KEY'); + const authToken = pickEnvValue(context, 'ANTHROPIC_AUTH_TOKEN') + || pickEnvValue(context, 'DEEPSEEK_API_KEY'); + const model = (options.model || '').trim() || resolveConfiguredModel(context); + const baseURL = pickEnvValue(context, 'AI_GATEWAY_BASE_URL') + || pickEnvValue(context, 'ANTHROPIC_BASE_URL') + || pickEnvValue(context, 'DEEPSEEK_BASE_URL') + || ''; + const customHeaders = pickEnvValue(context, 'ANTHROPIC_CUSTOM_HEADERS'); + const executablePath = pickEnvValue(context, 'CLAUDE_CODE_EXECUTABLE_PATH'); + + if (!apiKey && !authToken) { + return emptyCodingResult({ + error: 'Missing AI_GATEWAY_API_KEY / ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKEN / DEEPSEEK_API_KEY. The agent cannot call the model.', + }); + } + if (!baseURL) { + return emptyCodingResult({ + error: 'Missing AI_GATEWAY_BASE_URL / ANTHROPIC_BASE_URL / DEEPSEEK_BASE_URL. The agent cannot call the model.', + }); + } + + const session = { + conversationId, + context, + state: options.state, + getState: () => session.state, + getCallbacks: () => session.turn?.callbacks || {}, + flags: { + projectTouched: false, + filesWritten: false, + previewTouched: false, + deploymentTouched: false, + }, + queue: new PromptQueue(), + query: null as unknown as Query, + model, + pump: Promise.resolve(), + } as LiveQuerySession; + + const assembled = assembleAgentTools(session); + const sdkEnv: Record = { + ANTHROPIC_BASE_URL: baseURL, + ANTHROPIC_MODEL: model, + ANTHROPIC_CUSTOM_HEADERS: buildAnthropicCustomHeaders(customHeaders, conversationId), + PATH: pickEnvValue(context, 'PATH') || DEFAULT_PATH, + HOME: pickEnvValue(context, 'HOME') || '/tmp', + CLAUDE_CONFIG_DIR: pickEnvValue(context, 'CLAUDE_CONFIG_DIR') || '/tmp/.claude', + }; + if (apiKey) sdkEnv.ANTHROPIC_API_KEY = apiKey; + if (authToken) sdkEnv.ANTHROPIC_AUTH_TOKEN = authToken; + if (!sdkEnv.ANTHROPIC_API_KEY && authToken) sdkEnv.ANTHROPIC_API_KEY = authToken; + + const record = await getConversationRecord(context, conversationId); + if (record.claudeSessionId && record.transcriptPath) { + const restored = await downloadTranscript({ + context, + conversationId, + sessionId: record.claudeSessionId, + destPath: record.transcriptPath, + }); + if (restored) { + session.sessionId = record.claudeSessionId; + session.transcriptPath = record.transcriptPath; + } + } + + const requestedLanguage = (options.language || '').trim(); + const replyLocale = requestedLanguage === 'zh' || requestedLanguage === 'en' + ? requestedLanguage + : await getLanguagePreference(context, conversationId); + + const sdkOptions: Parameters[0]['options'] = { + model, + permissionMode: 'dontAsk', + maxTurns: 100, + tools: ['Skill'], + skills: [...MAKERS_SKILL_NAMES], + includePartialMessages: true, + persistSession: true, + mcpServers: { + [assembled.mcpServerName]: assembled.sandboxMcpServer, + }, + allowedTools: assembled.mcpAllowedTools, + strictMcpConfig: true, + systemPrompt: buildPrompt( + session.getState(), + options.isNewProject, + SANDBOX_MCP_SERVER_NAME, + resolveMakersProjectName(context, session.getState()), + resolveRunningModelLabel(context, model), + assembled.webSearchAvailable, + replyLocale, + ), + env: sdkEnv, + cwd: process.cwd(), + settingSources: ['project'], + stderr: (data: string) => { + console.warn('[claude-code]', data.trimEnd()); + }, + hooks: { + SessionStart: [{ + hooks: [async (input) => { + if (input.hook_event_name === 'SessionStart') { + session.sessionId = input.session_id; + session.transcriptPath = resolveClaudeTranscriptPath(input.session_id, { + explicitPath: input.transcript_path, + }); + // Remember the path now. Upload still waits for the turn to end; + // the Session tab reads this local file while the agent is running. + await patchConversationRecord(session.context, session.conversationId, { + claudeSessionId: input.session_id, + transcriptPath: session.transcriptPath, + }).catch((error) => { + console.warn('[transcript] session path persist failed', error); + }); + } + return {}; + }], + }], + PostCompact: [{ + hooks: [async () => { + await persistTranscript(session).catch((error) => { + console.warn('[transcript] post-compact upload failed', error); + }); + return {}; + }], + }], + }, + ...(session.sessionId ? { resume: session.sessionId } : {}), + }; + if (executablePath) sdkOptions.pathToClaudeCodeExecutable = executablePath; + + session.query = query({ + prompt: session.queue, + options: sdkOptions, + }); + session.pump = pumpSession(session); + liveQueries.set(conversationId, session); + return session; +} + +export function getLiveQuery(conversationId: string) { + return liveQueries.get(conversationId) || null; +} + +export type WarmLiveQueryResult = { + ok: boolean; + reused: boolean; + error?: string; +}; + +export async function warmLiveQuery(options: StartLiveQueryOptions): Promise { + if (options.abortSignal?.aborted) { + return { ok: false, reused: false, error: 'aborted' }; + } + + let session = liveQueries.get(options.conversationId); + const reused = Boolean(session); + if (!session) { + const started = await startLiveQuery(options); + if (!('queue' in started)) { + return { + ok: false, + reused: false, + error: started.error || 'The coding agent could not start.', + }; + } + session = started; + } else { + session.context = options.context; + session.state = options.state; + } + + if (!session.turn) scheduleIdleClose(session); + if (options.abortSignal?.aborted) { + return { ok: false, reused, error: 'aborted' }; + } + // Warm means the process and its PromptQueue exist. The CLI's session id + // arrives later through the SessionStart hook, and /prompt never needs it, + // so nothing here waits for one. + return { ok: true, reused }; +} + +export async function interruptLiveQuery(conversationId: string) { + const live = liveQueries.get(conversationId); + if (!live) return false; + try { + await live.query.interrupt(); + return true; + } catch (error) { + console.warn('[agent] interrupt failed', error); + return false; + } +} + +export async function setLiveQueryModel(conversationId: string, model: string) { + const live = liveQueries.get(conversationId); + if (!live) return false; + live.model = model; + try { + await live.query.setModel(model); + return true; + } catch (error) { + console.warn('[agent] setModel failed', error); + return false; + } +} + +export async function runCodingAgent(options: RunCodingAgentOptions): Promise { + if (options.abortSignal?.aborted) { + return emptyCodingResult({ stopped: true }); + } + + const model = (options.model || '').trim() || resolveConfiguredModel(options.context); + let session = liveQueries.get(options.conversationId); + if (!session) { + const started = await startLiveQuery(options); + if (!('queue' in started)) return started; + session = started; + } else { + session.context = options.context; + session.state = options.state; + if (model !== session.model) { + await setLiveQueryModel(options.conversationId, model); + } + } + + session.flags.projectTouched = false; + session.flags.filesWritten = false; + session.flags.previewTouched = false; + session.flags.deploymentTouched = false; + clearIdleTimer(session); + + const abort = () => { + void interruptLiveQuery(options.conversationId); + }; + options.abortSignal?.addEventListener('abort', abort, { once: true }); + + try { + const result = await new Promise((resolve) => { + session!.turn = { + callbacks: { + onProjectFilesChanged: options.onProjectFilesChanged, + onPreviewReady: options.onPreviewReady, + onDeploymentStatus: options.onDeploymentStatus, + send: options.send, + abortSignal: options.abortSignal, + }, + onProgress: options.onProgress, + resolve, + }; + session!.queue.push(userMessage(options.userMessage)); + }); + if (options.abortSignal?.aborted) { + return { ...result, success: false, stopped: true, error: null }; + } + return result; + } finally { + options.abortSignal?.removeEventListener('abort', abort); + } +} diff --git a/agents/_lib/session/prepare.ts b/agents/_lib/session/prepare.ts new file mode 100644 index 0000000..3062c48 --- /dev/null +++ b/agents/_lib/session/prepare.ts @@ -0,0 +1,178 @@ +import type { AgentContext } from '../runtime/context.ts'; +import { mergeSseGenerators } from '../runtime/merge.ts'; +import { getRequestQueryParam } from '../runtime/request.ts'; +import { sseEvent } from '../runtime/sse.ts'; +import { extendExistingSandboxTimeout } from '../turn/checkpoint.ts'; +import { ensureWorkspaceDirectories, markFreshWorkspace } from '../project/workspace.ts'; +import { getProjectState, patchConversationRecord } from './store.ts'; +import { warmLiveQuery } from './live.ts'; +import { timeStage } from '../utils/timing.ts'; +import type { + SessionPrepData, + SessionPrepMode, + SessionPrepStage, + SessionPrepStatus, +} from '../../../shared/protocol.ts'; + +export function resolveSessionPrepMode(context: AgentContext): SessionPrepMode { + return getRequestQueryParam(context, 'mode').value === 'create' ? 'create' : 'restore'; +} + +export function sessionPrepSse( + mode: SessionPrepMode, + stage: SessionPrepStage, + status: SessionPrepStatus, +): string { + const data: SessionPrepData = { mode, stage, status }; + return sseEvent({ type: 'session_prep', data }); +} + +export async function persistConversationPreferences( + context: AgentContext, + conversationId: string, + options: { model?: string; language?: string } = {}, +) { + const model = (options.model || '').trim(); + const language = (options.language || '').trim(); + await patchConversationRecord(context, conversationId, { + ...(model ? { modelPreference: model } : {}), + ...(language === 'zh' || language === 'en' ? { languagePreference: language } : {}), + }); +} + +export async function prepareSandboxWorkspace( + context: AgentContext, + conversationId: string, + options: { mode?: SessionPrepMode } = {}, +) { + await extendExistingSandboxTimeout(context); + const state = await getProjectState(context, conversationId); + await ensureWorkspaceDirectories(context, state); + // Only a create visit knows the workspace is empty; a restore may still have + // a snapshot to pull down, so its first prompt must keep probing. + if (options.mode === 'create' && !state.created) { + markFreshWorkspace(conversationId, state.appDir); + } + return state; +} + +export async function* iterateConversationPrep( + context: AgentContext, + conversationId: string, + options: { + mode: SessionPrepMode; + model?: string; + language?: string; + signal?: AbortSignal; + }, +): AsyncGenerator { + const { mode, signal } = options; + const model = (options.model || '').trim(); + const language = (options.language || '').trim(); + yield sessionPrepSse(mode, 'conversation', 'running'); + // A restore carries no preferences, so the patch would be a strong-consistency + // read plus a write that changes nothing, in front of every later stage. + if (!model && !language) { + yield sessionPrepSse(mode, 'conversation', 'done'); + return; + } + try { + await timeStage( + 'session:prep', + { mode, stage: 'conversation' }, + () => persistConversationPreferences(context, conversationId, { model, language }), + ); + if (signal?.aborted) return; + yield sessionPrepSse(mode, 'conversation', 'done'); + } catch (error) { + console.warn( + '[session:prep] conversation', + error instanceof Error ? error.message : error, + ); + if (!signal?.aborted) yield sessionPrepSse(mode, 'conversation', 'failed'); + } +} + +export async function* iterateSandboxPrepEvents( + context: AgentContext, + conversationId: string, + options: { + mode: SessionPrepMode; + signal?: AbortSignal; + }, +): AsyncGenerator { + const { mode, signal } = options; + yield sessionPrepSse(mode, 'sandbox', 'running'); + try { + await timeStage( + 'session:prep', + { mode, stage: 'sandbox' }, + () => prepareSandboxWorkspace(context, conversationId, { mode }), + ); + if (signal?.aborted) return; + yield sessionPrepSse(mode, 'sandbox', 'done'); + } catch (error) { + console.warn( + '[session:prep] sandbox', + error instanceof Error ? error.message : error, + ); + if (!signal?.aborted) yield sessionPrepSse(mode, 'sandbox', 'failed'); + } +} + +export async function* iterateAgentWarmupEvents( + context: AgentContext, + conversationId: string, + options: { + mode: SessionPrepMode; + isNewProject: boolean; + model?: string; + language?: string; + signal?: AbortSignal; + }, +): AsyncGenerator { + const { mode, signal } = options; + yield sessionPrepSse(mode, 'agent', 'running'); + try { + const state = await getProjectState(context, conversationId); + const warmed = await timeStage( + 'session:prep', + { mode, stage: 'agent' }, + () => warmLiveQuery({ + context, + conversationId, + state, + isNewProject: options.isNewProject, + model: options.model, + language: options.language, + abortSignal: signal, + }), + ); + if (signal?.aborted) return; + yield sessionPrepSse(mode, 'agent', warmed.ok ? 'done' : 'failed'); + } catch (error) { + console.warn( + '[session:prep] agent', + error instanceof Error ? error.message : error, + ); + if (!signal?.aborted) yield sessionPrepSse(mode, 'agent', 'failed'); + } +} + +/** Activate the sandbox and pre-warm the CLI together; the coding turn waits for both. */ +export async function* iterateSandboxAndAgentPrep( + context: AgentContext, + conversationId: string, + options: { + mode: SessionPrepMode; + isNewProject: boolean; + model?: string; + language?: string; + signal?: AbortSignal; + }, +): AsyncGenerator { + yield* mergeSseGenerators([ + iterateSandboxPrepEvents(context, conversationId, options), + iterateAgentWarmupEvents(context, conversationId, options), + ], options.signal); +} diff --git a/agents/_lib/session/projection.ts b/agents/_lib/session/projection.ts new file mode 100644 index 0000000..39c8e6c --- /dev/null +++ b/agents/_lib/session/projection.ts @@ -0,0 +1,190 @@ +import type { AssistantActivity, PersistedActivityTurn } from '../../../shared/protocol.ts'; +import { + appendNarrationChunk, + appendThinkingChunk, + sanitizeAssistantText, + sanitizeThinkingContent, + sealOpenThinking, + summarizeToolInput, + summarizeToolOutput, +} from '../../../shared/timeline.ts'; + +type JsonRecord = Record; + +function asRecord(value: unknown): JsonRecord { + return value && typeof value === 'object' ? value as JsonRecord : {}; +} + +function textFromContent(content: unknown): string { + if (typeof content === 'string') return sanitizeAssistantText(content); + if (!Array.isArray(content)) return ''; + return sanitizeAssistantText( + content + .map((block) => { + const record = asRecord(block); + return typeof record.text === 'string' ? record.text : ''; + }) + .join(''), + ); +} + +function commandFromInput(input: unknown) { + const record = asRecord(input); + const command = typeof record.command === 'string' + ? record.command + : typeof record.cmd === 'string' + ? record.cmd + : ''; + return command.trim(); +} + +function toolBlocks(content: unknown): JsonRecord[] { + if (!Array.isArray(content)) return []; + return content.filter((block) => { + const record = asRecord(block); + return record.type === 'tool_use' || record.type === 'mcp_tool_use' || record.type === 'tool_result'; + }).map(asRecord); +} + +/** + * Project a Claude Code JSONL transcript into the turns the workspace UI renders. + * The file is the source of truth; this is a derived view. + */ +export function projectTranscript(jsonl: string, projectDir = ''): PersistedActivityTurn[] { + const turns: PersistedActivityTurn[] = []; + const active: { turn: PersistedActivityTurn | null } = { turn: null }; + + const openTurn = (user: string, createdAt: number) => { + const turn: PersistedActivityTurn = { + id: `turn-${createdAt}-${turns.length}`, + user, + assistant: '', + status: 'completed', + createdAt, + activities: [], + }; + active.turn = turn; + turns.push(turn); + return turn; + }; + + for (const rawLine of jsonl.split('\n')) { + const line = rawLine.trim(); + if (!line) continue; + let entry: JsonRecord; + try { + entry = JSON.parse(line) as JsonRecord; + } catch { + continue; + } + const createdAt = Date.parse(String(entry.timestamp || '')) || Date.now(); + const message = asRecord(entry.message); + const content = message.content; + + if (entry.type === 'user') { + const tools = toolBlocks(content); + const text = textFromContent(content); + if (tools.some((block) => block.type === 'tool_result')) { + const turn = active.turn; + if (!turn) continue; + for (const block of tools) { + if (block.type !== 'tool_result') continue; + const id = typeof block.tool_use_id === 'string' ? block.tool_use_id : ''; + const existing = turn.activities.find( + (activity): activity is Extract => + activity.kind === 'tool' && activity.toolUseId === id, + ); + const output = typeof block.content === 'string' + ? block.content + : textFromContent(block.content); + if (existing) { + existing.status = block.is_error === true ? 'failed' : 'completed'; + existing.outputSummary = summarizeToolOutput(output, projectDir, existing.name); + existing.endedAt = createdAt; + } + } + continue; + } + if (text) openTurn(text, createdAt); + continue; + } + + if (entry.type === 'system' && entry.subtype === 'compact_boundary' && active.turn) { + const compact = asRecord(entry.compact_metadata); + active.turn.activities.push({ + kind: 'info', + infoType: 'compact', + title: 'Compact', + content: [ + compact.trigger ? `trigger=${compact.trigger}` : '', + compact.pre_tokens != null ? `pre_tokens=${compact.pre_tokens}` : '', + compact.post_tokens != null ? `post_tokens=${compact.post_tokens}` : '', + ].filter(Boolean).join('\n'), + }); + continue; + } + + if (entry.type === 'assistant' && active.turn) { + const turn = active.turn; + const text = textFromContent(content); + if (text) turn.assistant = text; + if (Array.isArray(content)) { + for (const block of content) { + const record = asRecord(block); + if (record.type === 'thinking') { + const thinking = typeof record.thinking === 'string' + ? record.thinking + : typeof record.text === 'string' ? record.text : ''; + if (thinking) { + turn.activities = appendThinkingChunk( + turn.activities, + sanitizeThinkingContent(thinking), + createdAt, + ); + } + continue; + } + if (record.type === 'redacted_thinking') { + turn.activities = appendThinkingChunk(turn.activities, '(redacted)', createdAt); + continue; + } + if (record.type === 'text' && typeof record.text === 'string') { + const narration = sanitizeAssistantText(record.text); + if (narration) { + turn.activities = appendNarrationChunk( + sealOpenThinking(turn.activities, createdAt), + narration, + ); + } + continue; + } + if (record.type !== 'tool_use' && record.type !== 'mcp_tool_use') continue; + const id = typeof record.id === 'string' ? record.id : ''; + const name = typeof record.name === 'string' ? record.name : 'tool'; + const command = commandFromInput(record.input); + turn.activities = sealOpenThinking(turn.activities, createdAt); + turn.activities.push({ + kind: 'tool', + toolUseId: id, + name, + status: 'completed', + ...(command ? { command } : {}), + inputSummary: summarizeToolInput(name, record.input, projectDir), + startedAt: createdAt, + }); + } + } else if (text) { + turn.activities = appendNarrationChunk(sealOpenThinking(turn.activities, createdAt), text); + } + } + } + + return turns; +} + +export function turnsToMessages(turns: PersistedActivityTurn[]) { + return turns.flatMap((turn) => [ + { role: 'user' as const, content: turn.user }, + { role: 'assistant' as const, content: turn.assistant }, + ]); +} diff --git a/agents/_lib/session/prompt-queue.ts b/agents/_lib/session/prompt-queue.ts new file mode 100644 index 0000000..226b70d --- /dev/null +++ b/agents/_lib/session/prompt-queue.ts @@ -0,0 +1,38 @@ +import type { SDKUserMessage } from '@anthropic-ai/claude-agent-sdk'; + +export class PromptQueue implements AsyncIterable { + private messages: SDKUserMessage[] = []; + private waiters: Array<(result: IteratorResult) => void> = []; + private closed = false; + + push(message: SDKUserMessage) { + if (this.closed) return; + const waiter = this.waiters.shift(); + if (waiter) waiter({ value: message, done: false }); + else this.messages.push(message); + } + + close() { + this.closed = true; + for (const waiter of this.waiters) { + waiter({ value: undefined as unknown as SDKUserMessage, done: true }); + } + this.waiters = []; + } + + [Symbol.asyncIterator](): AsyncIterator { + return { + next: () => { + if (this.messages.length > 0) { + return Promise.resolve({ value: this.messages.shift()!, done: false as const }); + } + if (this.closed) { + return Promise.resolve({ value: undefined as unknown as SDKUserMessage, done: true as const }); + } + return new Promise>((resolve) => { + this.waiters.push(resolve); + }); + }, + }; + } +} diff --git a/agents/_lib/session/resume.ts b/agents/_lib/session/resume.ts new file mode 100644 index 0000000..2476cc7 --- /dev/null +++ b/agents/_lib/session/resume.ts @@ -0,0 +1,470 @@ +import type { AgentContext } from '../runtime/context.ts'; +import { + getChatTask, + getConversationRecord, + getProjectState, +} from './store.ts'; +import { hasLiveChatTask, isChatTaskActive, iterateLiveChatTaskEvents, markOrphanedTaskFailed } from './task.ts'; +import { loadTranscriptJsonl } from './transcript.ts'; +import { projectTranscript, turnsToMessages } from './projection.ts'; +import { assertPreviewServerReady, resolvePublicLinks, rewritePreviewAccessToken, startPreviewServer } from '../project/preview.ts'; +import { getFileTree } from '../project/fs.ts'; +import { separateLegacyMakersDeployment } from '../project/state.ts'; +import { restoreProjectWorkspace } from '../project/workspace.ts'; +import { + clearPreview, + persistWorkspace, + publishPreview, +} from '../project/workspace-store.ts'; +import type { FileTreeItem, PersistedActivity, PersistedActivityTurn, ProjectState } from '../types.ts'; +import { createSSEResponse, sseEvent } from '../runtime/sse.ts'; +import { mergeSseGenerators } from '../runtime/merge.ts'; +import { isMakersDeployUrl } from '../../../shared/makers-url.ts'; +import { isMakersDeployCommand, isMakersDevCommand } from '../makers/tool-phase.ts'; +import { resolveConversationId, getRequestQueryParam } from '../runtime/request.ts'; +import { ensureProjectDependencies, withTimeout } from '../turn/checkpoint.ts'; +import { + iterateConversationPrep, + iterateSandboxAndAgentPrep, + resolveSessionPrepMode, + sessionPrepSse, +} from './prepare.ts'; +import { timeStage } from '../utils/timing.ts'; + +function isMakersPreviewState(state: ProjectState) { + return state.previewKind === 'makers' || isMakersDeployUrl(state.previewUrl); +} + +function toolNameImpliesProject(name: string) { + return name.includes('write_project_file') + || name.includes('write_files') + || /__files_write$/.test(name); +} + +function activityIsMakersCli(activity: PersistedActivity) { + if (activity.kind !== 'tool' || !activity.name.includes('commands')) return false; + const command = activity.inputSummary || ''; + return isMakersDevCommand(command) || isMakersDeployCommand(command); +} + +function activityHistoryImpliesProject(activityHistory: PersistedActivityTurn[]) { + return activityHistory.some((turn) => + (turn.activities || []).some((activity: PersistedActivity) => + activity.kind === 'tool' + && (toolNameImpliesProject(activity.name || '') || activityIsMakersCli(activity)), + ), + ); +} + +function activityHistoryImpliesPreview(activityHistory: PersistedActivityTurn[]) { + return activityHistory.some((turn) => + (turn.activities || []).some((activity: PersistedActivity) => + activity.kind === 'tool' + && activity.status === 'completed' + && activity.name.includes('commands') + && isMakersDevCommand(activity.command || activity.inputSummary || ''), + ), + ); +} + +function projectStateImpliesPreview(state: ProjectState, activityHistory: PersistedActivityTurn[] = []) { + return Boolean(state.previewUrl) + || Boolean(state.previewPublished) + || activityHistoryImpliesPreview(activityHistory); +} + +const WORKSPACE_RESUME_BUDGET_MS = 600_000; +const SANDBOX_PROBE_MS = 15_000; +const PREVIEW_RESTART_BUDGET_MS = 540_000; + +function jsonResponse(obj: Record, status = 200) { + return new Response(JSON.stringify(obj), { + status, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }); +} + +async function loadProjectResumeHistory(context: AgentContext, conversationId: string) { + const [record, jsonl] = await Promise.all([ + getConversationRecord(context, conversationId), + loadTranscriptJsonl(context, conversationId), + ]); + const model = record.modelPreference?.trim() || ''; + const storedLanguage = record.languagePreference; + const language = storedLanguage === 'zh' || storedLanguage === 'en' ? storedLanguage : ''; + const state = separateLegacyMakersDeployment(record.projectState); + const activityHistory = projectTranscript(jsonl, state.appDir); + const messages = turnsToMessages(activityHistory); + const storedTask = record.chatTask || null; + let activeTask = isChatTaskActive(storedTask) + && hasLiveChatTask(conversationId, storedTask.id) + ? { + id: storedTask.id, + message: storedTask.message, + status: storedTask.status, + createdAt: storedTask.createdAt, + startedAt: storedTask.startedAt, + } + : null; + if (isChatTaskActive(storedTask) && !activeTask) { + await markOrphanedTaskFailed(context, conversationId); + } + + const hasProject = Boolean(state.created) || activityHistoryImpliesProject(activityHistory); + const hasPreview = projectStateImpliesPreview(state, activityHistory); + + return { + ok: true as const, + stage: 'history' as const, + conversation_id: conversationId, + messages, + activityHistory, + activeTask, + hasProject, + hasPreview, + needsWorkspace: hasProject, + deployment: state.deployment, + model, + language: language || undefined, + gatewayNeeded: state.gatewayPromptPending === true, + gatewaySkipped: state.gatewaySkipped === true, + }; +} + +async function republishPreviewOnResume(context: AgentContext, state: ProjectState) { + if (isMakersPreviewState(state) && state.previewUrl) { + return { + url: state.previewUrl, + kind: 'makers' as const, + restarted: false, + }; + } + try { + await assertPreviewServerReady(context); + const accessToken = typeof context.sandbox?.envdAccessToken === 'string' + ? context.sandbox.envdAccessToken + : ''; + + if (state.previewUrl && accessToken) { + const rewritten = rewritePreviewAccessToken(state.previewUrl, accessToken); + if (rewritten) { + const warmLinks = await resolvePublicLinks(context); + publishPreview(state, { + url: rewritten, + sandboxDebugUrl: warmLinks.sandboxDebugUrl || state.sandboxDebugUrl, + kind: 'sandbox', + }); + return { + url: rewritten, + sandboxDebugUrl: state.sandboxDebugUrl, + restarted: false, + }; + } + } + + const warmLinks = await resolvePublicLinks(context); + if (warmLinks.previewUrl) { + publishPreview(state, { + url: warmLinks.previewUrl, + sandboxDebugUrl: warmLinks.sandboxDebugUrl, + kind: 'sandbox', + }); + return { + url: warmLinks.previewUrl, + sandboxDebugUrl: warmLinks.sandboxDebugUrl, + restarted: false, + }; + } + } catch { + // Server is not ready — fall through to a full restart. + } + + const depsReady = await timeStage( + 'resume:preview', + { phase: 'dependencies' }, + () => ensureProjectDependencies(context, state), + ); + if (!depsReady) { + throw new Error('Project dependencies are not available for preview resume.'); + } + + const server = await timeStage( + 'resume:preview', + { phase: 'server' }, + () => startPreviewServer(context, state), + ); + await assertPreviewServerReady(context, server.readyPath); + const links = await resolvePublicLinks(context); + if (!links.previewUrl) { + throw new Error('Preview server started but no public preview URL was available.'); + } + publishPreview(state, { + url: links.previewUrl, + sandboxDebugUrl: links.sandboxDebugUrl, + kind: 'sandbox', + }); + return { + url: links.previewUrl, + sandboxDebugUrl: links.sandboxDebugUrl, + restarted: true, + }; +} + +async function runWorkspaceRestoreBody(context: AgentContext, conversationId: string) { + const chatTask = await getChatTask(context, conversationId); + const restored = await restoreProjectWorkspace(context, conversationId, { mode: 'resume' }); + const state = restored.state; + const generationActive = isChatTaskActive(chatTask) && hasLiveChatTask(conversationId, chatTask.id); + + if (!restored.hasFiles) { + return { + ok: true as const, + stage: 'workspace' as const, + conversation_id: conversationId, + hasProject: false, + preview: restored.restoreError ? { error: restored.restoreError } : {}, + deployment: state.deployment, + files: { root: state.appDir, items: [] as FileTreeItem[] }, + }; + } + + let items: FileTreeItem[] = []; + try { + items = await withTimeout(getFileTree(context, state), SANDBOX_PROBE_MS, 'file tree'); + } catch { + items = []; + } + + const hasFileItems = items.some((item) => item.type === 'file'); + const shouldStartPreview = !generationActive && hasFileItems; + + let preview: { + url?: string; + sandboxDebugUrl?: string; + error?: string; + restarted?: boolean; + kind?: 'sandbox' | 'makers'; + } = {}; + if (shouldStartPreview) { + try { + preview = await withTimeout( + republishPreviewOnResume(context, state), + PREVIEW_RESTART_BUDGET_MS, + 'preview resume', + ); + } catch (error) { + clearPreview(state); + console.warn( + '[resume:workspace] preview restart failed:', + error instanceof Error ? error.message : error, + ); + preview = {}; + } + } + + try { + await persistWorkspace(context, conversationId, state); + } catch { + // Non-fatal — the files payload below is still useful. + } + + return { + ok: true as const, + stage: 'workspace' as const, + conversation_id: conversationId, + hasProject: hasFileItems || Boolean(state.created), + preview, + deployment: state.deployment, + files: { root: state.appDir, items }, + gatewayNeeded: state.gatewayPromptPending === true, + gatewaySkipped: state.gatewaySkipped === true, + ...(hasFileItems ? { download: { url: '/download', filename: 'source.zip' } } : {}), + }; +} + +async function runPreviewRefreshBody(context: AgentContext, conversationId: string) { + const storedState = await getProjectState(context, conversationId); + const state = separateLegacyMakersDeployment(storedState); + if (!state.created && !state.previewUrl && !state.previewPublished) { + return { + ok: true as const, + stage: 'preview' as const, + conversation_id: conversationId, + preview: {}, + deployment: state.deployment, + }; + } + + try { + const preview = await republishPreviewOnResume(context, state); + try { + await persistWorkspace(context, conversationId, state); + } catch { + // Non-fatal — the fresh URL below is still usable for this session. + } + + return { + ok: true as const, + stage: 'preview' as const, + conversation_id: conversationId, + preview, + deployment: state.deployment, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.warn('[resume:preview] remint failed, escalating to workspace restore:', message); + const workspace = await runWorkspaceRestoreBody(context, conversationId); + return { + ...workspace, + stage: 'preview' as const, + }; + } +} + +export async function runProjectResumePreviewPipeline(context: AgentContext): Promise { + const { conversationId } = resolveConversationId(context, { allowQuery: true }); + if (!conversationId) { + return jsonResponse({ ok: false, error: 'missing conversation_id' }, 400); + } + + try { + const payload = await withTimeout( + runPreviewRefreshBody(context, conversationId), + WORKSPACE_RESUME_BUDGET_MS, + 'preview refresh', + ); + return jsonResponse(payload); + } catch (error) { + const message = error instanceof Error ? error.message : 'Preview refresh failed.'; + console.warn('[resume:preview]', message); + return jsonResponse({ + ok: true, + stage: 'preview', + conversation_id: conversationId, + preview: { error: message }, + }); + } +} + +async function* iterateWorkspaceResumeEvents( + context: AgentContext, + conversationId: string, + mode: ReturnType, + signal?: AbortSignal, +): AsyncGenerator { + yield sessionPrepSse(mode, 'workspace', 'running'); + try { + const workspace = await timeStage('session:prep', { mode, stage: 'workspace' }, () => withTimeout( + runWorkspaceRestoreBody(context, conversationId), + WORKSPACE_RESUME_BUDGET_MS, + 'workspace resume', + )); + if (signal?.aborted) return; + yield sseEvent({ type: 'resume_workspace', data: workspace }); + yield sessionPrepSse(mode, 'workspace', 'done'); + if (workspace.preview && 'url' in workspace.preview && workspace.preview.url) { + yield sessionPrepSse(mode, 'preview', 'done'); + } + + const fileItems = workspace.files?.items || []; + const paths = fileItems.filter((item) => item.type === 'file').map((item) => item.path); + if (!signal?.aborted && paths.length > 0) { + yield sseEvent({ type: 'file_changed', data: { paths } }); + } + } catch (error) { + const message = error instanceof Error ? error.message : 'Workspace resume failed.'; + console.warn('[resume:stream]', message); + if (!signal?.aborted) { + yield sessionPrepSse(mode, 'workspace', 'failed'); + yield sseEvent({ + type: 'resume_workspace', + data: { + ok: true, + stage: 'workspace', + conversation_id: conversationId, + hasProject: false, + preview: { error: message }, + files: { root: '', items: [] }, + }, + }); + } + } +} + +export async function createProjectResumeStreamResponse(context: AgentContext): Promise { + const { conversationId } = resolveConversationId(context, { allowQuery: true }); + if (!conversationId) { + return jsonResponse({ ok: false, error: 'missing conversation_id' }, 400); + } + + const mode = resolveSessionPrepMode(context); + const model = getRequestQueryParam(context, 'model').value; + const language = getRequestQueryParam(context, 'language').value; + + return createSSEResponse(async function* (signal) { + if (mode === 'create') { + // The preference write no longer gates the sandbox and the CLI: both are + // handed model and language instead of reading them back afterwards. + yield* mergeSseGenerators([ + iterateConversationPrep(context, conversationId, { mode, model, language, signal }), + iterateSandboxAndAgentPrep(context, conversationId, { + mode, + isNewProject: true, + model, + language, + signal, + }), + ], signal); + if (!signal?.aborted) yield sessionPrepSse(mode, 'ready', 'done'); + return; + } + + yield* iterateConversationPrep(context, conversationId, { mode, model, language, signal }); + if (signal?.aborted) return; + + // The warmup needs isNewProject and the model before the transcript parse + // finishes, and the record carries both, so the two now run side by side. + const record = await getConversationRecord(context, conversationId); + const historyPromise = timeStage( + 'session:prep', + { mode, stage: 'history' }, + () => loadProjectResumeHistory(context, conversationId), + ); + async function* iterateHistoryEvent(): AsyncGenerator { + const loaded = await historyPromise; + if (signal?.aborted) return; + yield sseEvent({ type: 'resume_history', data: loaded }); + } + + yield* mergeSseGenerators([ + iterateSandboxAndAgentPrep(context, conversationId, { + mode, + isNewProject: !record.projectState.created, + model: model || (record.modelPreference || '').trim(), + language: language || record.languagePreference || '', + signal, + }), + iterateHistoryEvent(), + ], signal); + if (signal?.aborted) return; + + // Restarting the preview can cost minutes of npm install and dev server + // polling. The client unblocks here and shows local loading for the file + // tree and the preview, so neither holds the first screen. + yield sessionPrepSse(mode, 'ready', 'done'); + + const history = await historyPromise; + if (history.needsWorkspace) { + yield* iterateWorkspaceResumeEvents(context, conversationId, mode, signal); + } + if (signal?.aborted) return; + + const storedTask = await getChatTask(context, conversationId); + if (isChatTaskActive(storedTask) && hasLiveChatTask(conversationId, storedTask.id)) { + yield* iterateLiveChatTaskEvents(context, conversationId, storedTask, undefined, signal); + } + }, context?.request?.signal); +} diff --git a/agents/_lib/session/store.ts b/agents/_lib/session/store.ts new file mode 100644 index 0000000..0353524 --- /dev/null +++ b/agents/_lib/session/store.ts @@ -0,0 +1,216 @@ +import { getStore } from '@edgeone/pages-blob'; +import { createProjectState } from '../project/state.ts'; +import type { BlobStoreLike, PersistCapable } from '../runtime/context.ts'; +import type { ChatTask, ProjectState } from '../types.ts'; + +const BLOB_STORE_NAME = 'vibe-sessions'; + +export type ConversationRecord = { + claudeSessionId?: string; + transcriptPath?: string; + modelPreference?: string; + languagePreference?: 'zh' | 'en'; + projectState: ProjectState; + chatTask?: ChatTask | null; +}; + +function conversationKey(conversationId: string) { + return `conv/${conversationId}/state.json`; +} + +export function transcriptBlobKey(sessionId: string) { + return `sessions/${sessionId}.jsonl`; +} + +export function createMemoryBlobStore(): BlobStoreLike { + const data = new Map(); + return { + async set(key, value) { + if (typeof value === 'string') { + data.set(key, { kind: 'bytes', value }); + return; + } + if (value instanceof ReadableStream) { + const reader = value.getReader(); + const chunks: Uint8Array[] = []; + while (true) { + const { done, value: chunk } = await reader.read(); + if (done) break; + if (chunk) chunks.push(chunk); + } + const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const bytes = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + data.set(key, { kind: 'bytes', value: Buffer.from(bytes).toString('utf8') }); + return; + } + if (value instanceof ArrayBuffer) { + data.set(key, { kind: 'bytes', value: Buffer.from(value).toString('utf8') }); + return; + } + data.set(key, { kind: 'bytes', value: String(value) }); + }, + async setJSON(key, value) { + data.set(key, { kind: 'json', value }); + }, + async get(key, options) { + const entry = data.get(key); + if (!entry) return null; + if (options?.type === 'stream') { + const text = entry.kind === 'bytes' ? String(entry.value) : JSON.stringify(entry.value); + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(text)); + controller.close(); + }, + }); + } + if (options?.type === 'json' || entry.kind === 'json') { + return entry.kind === 'json' ? entry.value : JSON.parse(String(entry.value)); + } + return entry.kind === 'bytes' ? entry.value : JSON.stringify(entry.value); + }, + async delete(key) { + data.delete(key); + }, + async list(options) { + const prefix = options?.prefix || ''; + return { + blobs: [...data.keys()] + .filter((key) => key.startsWith(prefix)) + .map((key) => ({ key })), + }; + }, + }; +} + +export function getBlobStore(context?: PersistCapable): BlobStoreLike { + if (context?.blobStore) return context.blobStore; + return (getStore as unknown as (options: { name: string; consistency: 'strong' }) => BlobStoreLike)({ + name: BLOB_STORE_NAME, + consistency: 'strong', + }); +} + +/** + * One wake reads `conv/{id}/state.json` about a dozen times over + * strong-consistency Blob. The request `context` is the natural lifetime for a + * memo of it — a WeakMap on it cannot outlive the request or leak across + * conversations. Writes stay read-modify-write against the blob so a + * concurrent request's field is never clobbered, and refresh this entry so a + * read after a write in the same request sees the new value. + */ +const recordCache = new WeakMap>(); + +function recordCacheFor(context: { blobStore?: BlobStoreLike }) { + if (!context || typeof context !== 'object') return null; + const existing = recordCache.get(context); + if (existing) return existing; + const created = new Map(); + recordCache.set(context, created); + return created; +} + +export async function getConversationRecord( + context: { blobStore?: BlobStoreLike }, + conversationId: string, + /** `refresh` is for callers that poll for another writer's field. */ + options: { refresh?: boolean } = {}, +): Promise { + const cache = recordCacheFor(context); + if (!options.refresh) { + const cached = cache?.get(conversationId); + if (cached) return cached; + } + const stored = await getBlobStore(context).get(conversationKey(conversationId), { type: 'json' }); + const record = stored + && typeof stored === 'object' + && (stored as ConversationRecord).projectState + && typeof (stored as ConversationRecord).projectState === 'object' + ? stored as ConversationRecord + : { projectState: createProjectState(conversationId) }; + cache?.set(conversationId, record); + return record; +} + +export async function saveConversationRecord( + context: { blobStore?: BlobStoreLike }, + conversationId: string, + record: ConversationRecord, +) { + await getBlobStore(context).setJSON(conversationKey(conversationId), record); + recordCacheFor(context)?.set(conversationId, record); +} + +export async function patchConversationRecord( + context: { blobStore?: BlobStoreLike }, + conversationId: string, + patch: Partial, +) { + const current = await getConversationRecord(context, conversationId, { refresh: true }); + const next: ConversationRecord = { + ...current, + ...patch, + projectState: patch.projectState || current.projectState, + }; + await saveConversationRecord(context, conversationId, next); + return next; +} + +export async function getProjectState(context: { blobStore?: BlobStoreLike }, conversationId: string) { + return (await getConversationRecord(context, conversationId)).projectState; +} + +export async function saveProjectState( + context: { blobStore?: BlobStoreLike }, + conversationId: string, + state: ProjectState, +) { + await patchConversationRecord(context, conversationId, { projectState: state }); +} + +export async function getChatTask(context: { blobStore?: BlobStoreLike }, conversationId: string) { + return (await getConversationRecord(context, conversationId)).chatTask || null; +} + +export async function saveChatTask( + context: { blobStore?: BlobStoreLike }, + conversationId: string, + task: ChatTask | null, +) { + await patchConversationRecord(context, conversationId, { chatTask: task }); +} + +export async function getModelPreference(context: { blobStore?: BlobStoreLike }, conversationId: string) { + return (await getConversationRecord(context, conversationId)).modelPreference?.trim() || ''; +} + +export async function saveModelPreference( + context: { blobStore?: BlobStoreLike }, + conversationId: string, + model: string, +) { + await patchConversationRecord(context, conversationId, { modelPreference: model.trim() }); +} + +export async function getLanguagePreference( + context: { blobStore?: BlobStoreLike }, + conversationId: string, +) { + const value = (await getConversationRecord(context, conversationId)).languagePreference; + return value === 'zh' || value === 'en' ? value : ''; +} + +export async function saveLanguagePreference( + context: { blobStore?: BlobStoreLike }, + conversationId: string, + language: string, +) { + const next = language.trim(); + if (next !== 'zh' && next !== 'en') return; + await patchConversationRecord(context, conversationId, { languagePreference: next }); +} diff --git a/agents/_lib/session/stream-projector.ts b/agents/_lib/session/stream-projector.ts new file mode 100644 index 0000000..e86e33c --- /dev/null +++ b/agents/_lib/session/stream-projector.ts @@ -0,0 +1,414 @@ +import type { SDKMessage, SDKResultMessage } from '@anthropic-ai/claude-agent-sdk'; +import type { AgentProgressEvent } from '../types.ts'; +import type { SystemInfoType } from '../../../shared/protocol.ts'; +import { + resolveNarrationEmit, + sanitizeNarrationText, + sanitizeThinkingContent, + summarizeToolInput, + summarizeToolOutput, + type NarrationEmitState, +} from '../../../shared/timeline.ts'; +import { + isInstallCommand, + isMakersDeployCommand, + isPreviewCommand, + shortenToolName, +} from '../makers/tool-phase.ts'; + +export function extractSandboxCommand(input: unknown) { + const record = input && typeof input === 'object' ? input as Record : {}; + const command = typeof record.command === 'string' + ? record.command + : typeof record.cmd === 'string' + ? record.cmd + : ''; + return command.trim(); +} + +export function extractVisibleNarrationDelta(event: SDKMessage) { + if (event.type !== 'stream_event') return ''; + const streamEvent = (event as { event?: { type?: string; delta?: { type?: string; text?: string } } }).event; + if (streamEvent?.type !== 'content_block_delta') return ''; + const delta = streamEvent.delta; + if (delta?.type === 'text_delta' && typeof delta.text === 'string') { + return sanitizeNarrationText(delta.text); + } + return ''; +} + +export function extractVisibleThinkingDelta(event: SDKMessage) { + if (event.type !== 'stream_event') return ''; + const streamEvent = (event as { + event?: { type?: string; delta?: { type?: string; thinking?: string; text?: string } }; + }).event; + if (streamEvent?.type !== 'content_block_delta') return ''; + const delta = streamEvent.delta; + if (delta?.type !== 'thinking_delta' && delta?.type !== 'thinking') return ''; + const text = typeof delta.thinking === 'string' ? delta.thinking : delta.text; + return typeof text === 'string' ? sanitizeThinkingContent(text) : ''; +} + +export function isThinkingContentBlock(block: unknown): boolean { + const record = block && typeof block === 'object' ? block as Record : {}; + return record.type === 'thinking' || record.type === 'redacted_thinking'; +} + +export function extractVisibleThinkingBlock(block: unknown) { + const record = block && typeof block === 'object' ? block as Record : {}; + if (record.type === 'redacted_thinking') return '(redacted)'; + if (record.type !== 'thinking') return ''; + if (typeof record.thinking === 'string') return sanitizeThinkingContent(record.thinking); + if (typeof record.text === 'string') return sanitizeThinkingContent(record.text); + return ''; +} + +export type SystemInfoPayload = { + infoType: SystemInfoType; + title: string; + content: string; +}; + +function asRecord(value: unknown): Record { + return value && typeof value === 'object' ? value as Record : {}; +} + +function compactJson(value: unknown, limit = 1_500) { + const omit = new Set(['uuid', 'session_id', 'message', 'event']); + try { + const json = JSON.stringify(value, (key, nested) => (omit.has(key) ? undefined : nested)); + if (!json) return ''; + return json.length > limit ? `${json.slice(0, limit)}\n... truncated` : json; + } catch { + return ''; + } +} + +export function formatResultUsage(result: SDKResultMessage) { + const lines = [ + `subtype=${result.subtype} turns=${result.num_turns}` + + ` duration=${(result.duration_ms / 1000).toFixed(1)}s` + + ` cost=$${Number(result.total_cost_usd || 0).toFixed(4)}`, + ]; + const usage = result.usage as { input_tokens?: number; output_tokens?: number; cache_read_input_tokens?: number; cache_creation_input_tokens?: number } | undefined; + if (usage) { + lines.push( + `input=${usage.input_tokens ?? 0} output=${usage.output_tokens ?? 0}` + + ` cacheRead=${usage.cache_read_input_tokens ?? 0}` + + ` cacheWrite=${usage.cache_creation_input_tokens ?? 0}`, + ); + } + const models = Object.entries(result.modelUsage || {}); + for (const [id, modelUsage] of models) { + lines.push( + `${id} in=${modelUsage.inputTokens} out=${modelUsage.outputTokens} cost=$${Number(modelUsage.costUSD || 0).toFixed(4)}`, + ); + } + if ('errors' in result && Array.isArray(result.errors) && result.errors.length > 0) { + lines.push(result.errors.join('\n')); + } + if (result.permission_denials?.length) { + lines.push(`denied=${result.permission_denials.map((item) => item.tool_name).join(', ')}`); + } + return lines.join('\n'); +} + +function isTokenMeterEvent(event: SDKMessage) { + const type = String(event.type); + if (type === 'system') { + const subtype = typeof (event as { subtype?: unknown }).subtype === 'string' + ? (event as { subtype: string }).subtype + : ''; + return /thinking[_-]?tokens|(^|_)tokens$/i.test(subtype); + } + return /thinking[_-]?tokens/i.test(type); +} + +export function describeSdkMessage(event: SDKMessage): SystemInfoPayload | null { + if ( + event.type === 'stream_event' + || event.type === 'assistant' + || event.type === 'user' + || event.type === 'result' + || event.type === 'tool_progress' + || isTokenMeterEvent(event) + ) { + return null; + } + + if (event.type === 'system') { + const record = event as SDKMessage & { subtype?: string }; + const subtype = typeof record.subtype === 'string' ? record.subtype : ''; + if (subtype === 'init') { + const init = event as SDKMessage & { + model?: string; + tools?: string[]; + mcp_servers?: { name?: string; status?: string }[]; + skills?: string[]; + }; + const tools = Array.isArray(init.tools) ? init.tools : []; + const servers = Array.isArray(init.mcp_servers) ? init.mcp_servers : []; + const skills = Array.isArray(init.skills) ? init.skills : []; + return { + infoType: 'system', + title: 'Session', + content: [ + `model=${init.model || ''}`, + `tools=${tools.length}${tools.length ? ` ${tools.slice(0, 12).join(', ')}` : ''}`, + servers.length ? `mcp=${servers.map((server) => `${server.name}:${server.status}`).join(', ')}` : '', + skills.length ? `skills=${skills.slice(0, 12).join(', ')}` : '', + ].filter(Boolean).join('\n'), + }; + } + if (subtype === 'compact_boundary') { + const compact = asRecord((event as { compact_metadata?: unknown }).compact_metadata); + return { + infoType: 'compact', + title: 'Compact', + content: [ + `trigger=${compact.trigger || ''}`, + compact.pre_tokens != null ? `pre_tokens=${compact.pre_tokens}` : '', + compact.post_tokens != null ? `post_tokens=${compact.post_tokens}` : '', + compact.duration_ms != null ? `duration_ms=${compact.duration_ms}` : '', + ].filter(Boolean).join('\n'), + }; + } + if (subtype === 'status') { + const status = event as SDKMessage & { status?: string | null; compact_result?: string; compact_error?: string }; + return { + infoType: 'status', + title: 'Status', + content: [status.status, status.compact_result, status.compact_error].filter(Boolean).join('\n'), + }; + } + if (subtype === 'notification') { + const note = event as SDKMessage & { text?: string; key?: string }; + return { + infoType: 'system', + title: note.key || 'Notification', + content: note.text || '', + }; + } + if (subtype === 'permission_denied') { + const denied = event as SDKMessage & { tool_name?: string; message?: string; decision_reason?: string }; + return { + infoType: 'system', + title: `Denied ${denied.tool_name || 'tool'}`, + content: [denied.message, denied.decision_reason].filter(Boolean).join('\n'), + }; + } + if (subtype === 'api_retry') { + const retry = event as SDKMessage & { attempt?: number; max_retries?: number; retry_delay_ms?: number; error?: string }; + return { + infoType: 'status', + title: 'API retry', + content: `attempt ${retry.attempt}/${retry.max_retries} delay=${retry.retry_delay_ms}ms ${retry.error || ''}`.trim(), + }; + } + if (subtype === 'local_command_output') { + const output = event as SDKMessage & { content?: string }; + return { + infoType: 'system', + title: 'Command output', + content: typeof output.content === 'string' ? output.content.slice(0, 4_000) : '', + }; + } + if (subtype === 'task_started' || subtype === 'task_progress' || subtype === 'task_updated' || subtype === 'task_notification') { + const task = event as SDKMessage & { description?: string; summary?: string; task_id?: string; last_tool_name?: string }; + return { + infoType: 'status', + title: subtype.replace('task_', 'Task '), + content: [task.description, task.summary, task.last_tool_name, task.task_id].filter(Boolean).join('\n'), + }; + } + if (subtype === 'files_persisted') { + const persisted = event as SDKMessage & { files?: { filename?: string }[]; failed?: { filename?: string; error?: string }[] }; + const names = (persisted.files || []).map((file) => file.filename).filter(Boolean); + const failed = (persisted.failed || []).map((file) => `${file.filename}: ${file.error}`).filter(Boolean); + return { + infoType: 'system', + title: 'Files persisted', + content: [...names, ...failed].join('\n'), + }; + } + return null; + } + + if (event.type === 'tool_use_summary') { + const summary = event as SDKMessage & { summary?: string }; + return { infoType: 'system', title: 'Tool summary', content: summary.summary || '' }; + } + if (event.type === 'rate_limit_event') { + return { infoType: 'status', title: 'Rate limit', content: compactJson(event) }; + } + if (event.type === 'prompt_suggestion') { + const suggestion = event as SDKMessage & { suggestion?: string }; + return { infoType: 'system', title: 'Prompt suggestion', content: suggestion.suggestion || '' }; + } + + return null; +} + +export type StreamingToolUseBlock = { + id: string; + name: string; + inputJson: string; + input?: unknown; +}; + +export function isToolUseContentBlock(block: unknown): block is { + type: string; + id?: string; + name?: string; + input?: unknown; +} { + const record = block && typeof block === 'object' ? block as Record : {}; + return record.type === 'tool_use' || record.type === 'mcp_tool_use'; +} + +export function extractVisibleTextBlock(block: unknown) { + const record = block && typeof block === 'object' ? block as Record : {}; + if (record.type !== 'text' || typeof record.text !== 'string') return ''; + return sanitizeNarrationText(record.text); +} + +export function parseToolInputJson(rawJson: string, fallback: unknown) { + if (!rawJson.trim()) return fallback ?? {}; + try { + return JSON.parse(rawJson); + } catch { + return fallback ?? {}; + } +} + +type ToolProgressPhase = 'scaffold' | 'code' | 'install' | 'preview' | 'link'; + +export function inferToolProgress(name: string, input: unknown): { + phaseHint?: ToolProgressPhase; + fileCount?: number; +} { + const toolName = shortenToolName(name); + if (toolName === 'files_write' || toolName === 'write_files' || toolName === 'files_make_dir' || toolName === 'files_remove') { + return { phaseHint: 'code' }; + } + if (toolName === 'write_project_file') return { phaseHint: 'code', fileCount: 1 }; + if (toolName === 'commands') { + const cmd = extractSandboxCommand(input); + if (isInstallCommand(cmd)) return { phaseHint: 'install' }; + if (isPreviewCommand(cmd) || isMakersDeployCommand(cmd)) return { phaseHint: 'preview' }; + } + return {}; +} + +export function createProgressEmitter(options: { + appDir: string; + onProgress?: (event: AgentProgressEvent) => void; +}) { + const toolContextById = new Map(); + const toolStartedAtById = new Map(); + const emittedToolUseProgress = new Map(); + let narrationState: NarrationEmitState = { currentTextBlock: '', emittedNarration: '' }; + let thinkingState: NarrationEmitState = { currentTextBlock: '', emittedNarration: '' }; + + const emitNarration = (rawText: string, uuid: string, complete = false) => { + const resolved = resolveNarrationEmit(narrationState, rawText, complete); + narrationState = resolved.state; + if (!resolved.text) return; + options.onProgress?.({ + type: 'text_segment', + data: { uuid, text: resolved.text }, + }); + }; + + const emitThinking = (rawText: string, uuid: string, complete = false) => { + const resolved = resolveNarrationEmit(thinkingState, rawText, complete); + thinkingState = resolved.state; + if (!resolved.text) return; + options.onProgress?.({ + type: 'thinking_segment', + data: { uuid, text: resolved.text }, + }); + }; + + const emitInfo = (info: SystemInfoPayload) => { + if (!info.content.trim() && !info.title.trim()) return; + options.onProgress?.({ + type: 'system_info', + data: info, + }); + }; + + const emitToolUseProgress = (toolUse: { + id?: string; + name?: string; + input?: unknown; + inputJson?: string; + outputSummary?: string; + }) => { + const toolName = typeof toolUse.name === 'string' ? toolUse.name : ''; + const toolUseId = typeof toolUse.id === 'string' ? toolUse.id : ''; + const shortToolName = shortenToolName(toolName); + const command = shortToolName === 'commands' ? extractSandboxCommand(toolUse.input) : ''; + const progress = typeof toolUse.name === 'string' ? inferToolProgress(toolName, toolUse.input) : {}; + const hasInput = toolUse.input !== undefined || Boolean(toolUse.inputJson); + const parsedSummary = hasInput ? summarizeToolInput(toolName, toolUse.input, options.appDir) : ''; + const inputSummary = parsedSummary + || (toolUse.inputJson ? summarizeToolOutput(toolUse.inputJson, options.appDir) : ''); + const outputSummary = toolUse.outputSummary || ''; + const progressSignature = JSON.stringify({ + name: toolName, + command, + phaseHint: progress.phaseHint || '', + fileCount: progress.fileCount || 0, + inputSummary, + outputSummary, + }); + if (toolUseId) { + if (emittedToolUseProgress.get(toolUseId) === progressSignature) return; + emittedToolUseProgress.set(toolUseId, progressSignature); + } + narrationState = { ...narrationState, currentTextBlock: '' }; + if (toolUseId && typeof toolUse.name === 'string') { + toolContextById.set(toolUseId, { name: toolUse.name, ...(command ? { command } : {}) }); + } + const startedAt = toolUseId ? toolStartedAtById.get(toolUseId) || Date.now() : Date.now(); + if (toolUseId) toolStartedAtById.set(toolUseId, startedAt); + options.onProgress?.({ + type: 'tool_use', + data: { + id: toolUseId, + name: toolName, + ...(command ? { command } : {}), + ...progress, + inputSummary, + ...(outputSummary ? { outputSummary } : {}), + startedAt, + }, + }); + }; + + return { + toolContextById, + toolStartedAtById, + emitNarration, + emitThinking, + emitInfo, + emitToolUseProgress, + resetNarration() { + narrationState = { currentTextBlock: '', emittedNarration: '' }; + }, + beginTextBlock() { + narrationState = { ...narrationState, currentTextBlock: '' }; + }, + beginThinkingBlock() { + thinkingState = { ...thinkingState, currentTextBlock: '' }; + }, + resetTurn() { + toolContextById.clear(); + toolStartedAtById.clear(); + emittedToolUseProgress.clear(); + narrationState = { currentTextBlock: '', emittedNarration: '' }; + thinkingState = { currentTextBlock: '', emittedNarration: '' }; + }, + }; +} diff --git a/agents/_lib/chat-tasks.ts b/agents/_lib/session/task.ts similarity index 65% rename from agents/_lib/chat-tasks.ts rename to agents/_lib/session/task.ts index df8e3fc..99d462e 100644 --- a/agents/_lib/chat-tasks.ts +++ b/agents/_lib/session/task.ts @@ -1,22 +1,24 @@ -import { runChatPipeline } from './pipelines/chat.ts'; -import { runDeployPipeline } from './pipelines/deploy.ts'; +import type { AgentContext } from '../runtime/context.ts'; +import { runChatPipeline } from '../turn/chat.ts'; +import { runDeployPipeline } from '../turn/deploy.ts'; import { - appendTurn, getChatTask, - getModelPreference, + getLanguagePreference, saveChatTask, + saveLanguagePreference, saveModelPreference, -} from './memory.ts'; -import type { ChatTask, ChatTaskIntent, ChatTaskStatus, StreamSend } from './types.ts'; -import { createSSEResponse, sseEvent } from './shared.ts'; -import { resolveConversationId } from './utils/request.ts'; -import { resolveGatewayUserTurn } from '../../shared/gateway-secret.ts'; - -type TaskEvent = Record; +} from './store.ts'; +import { interruptLiveQuery } from './live.ts'; +import type { ChatTask, ChatTaskKind, ChatTaskStatus, StreamSend } from '../types.ts'; +import { createSSEResponse, sseEvent } from '../runtime/sse.ts'; +import { resolveConversationId } from '../runtime/request.ts'; +import { resolveGatewayUserTurn } from '../../../shared/gateway-secret.ts'; +import type { ChatStreamEvent } from '../../../shared/protocol.ts'; +import { unbindLiveWorkspace } from './live-workspace.ts'; type SequencedEvent = { sequence: number; - event: TaskEvent; + event: ChatStreamEvent; }; type TaskListener = (event: SequencedEvent) => void; @@ -27,26 +29,21 @@ type LiveChatTask = { events: SequencedEvent[]; nextSequence: number; listeners: Set; - // Detached from the SSE HTTP request: a browser refresh/disconnect must not - // stop generation. Only /stop (via abortLiveChatTask) should abort this. abortController: AbortController; runPromise?: Promise; - /** In-memory only: written to `.env` at pipeline start, never persisted. */ gatewayApiKey?: string; gatewaySkip?: boolean; }; const liveTasks = new Map(); -/** Abort in-process chat generation for a conversation (used by /stop). */ export function abortLiveChatTask(conversationId: string) { const trimmed = conversationId.trim(); if (!trimmed) return; + void interruptLiveQuery(trimmed); for (const liveTask of liveTasks.values()) { if (liveTask.conversationId === trimmed && !liveTask.abortController.signal.aborted) { liveTask.abortController.abort(); - // Mark stopped immediately so a refresh mid-unwind does not treat this as - // an in-flight task (resume only reconnects queued/running). if (liveTask.task.status === 'queued' || liveTask.task.status === 'running') { liveTask.task = { ...liveTask.task, @@ -58,8 +55,7 @@ export function abortLiveChatTask(conversationId: string) { } } -/** Persist chatTask as stopped so resume history does not reattach activeTask. */ -export async function markChatTaskStopped(context: any, conversationId: string) { +export async function markChatTaskStopped(context: AgentContext, conversationId: string) { const trimmed = conversationId.trim(); if (!trimmed) return; try { @@ -76,6 +72,20 @@ export async function markChatTaskStopped(context: any, conversationId: string) } } +export async function markOrphanedTaskFailed(context: AgentContext, conversationId: string) { + const existing = await getChatTask(context, conversationId); + if (!existing || !isChatTaskActive(existing)) return null; + if (hasLiveTask(conversationId, existing.id)) return existing; + const failed: ChatTask = { + ...existing, + status: 'failed', + finishedAt: Date.now(), + error: 'The previous generation stopped when this instance restarted.', + }; + await saveChatTask(context, conversationId, failed); + return null; +} + function taskKey(conversationId: string, taskId: string) { return `${conversationId}:${taskId}`; } @@ -87,36 +97,34 @@ function createTaskId() { return `${Date.now()}-${Math.random().toString(36).slice(2)}`; } -export function getConversationId(context: any): string { +export function getConversationId(context: AgentContext): string { return resolveConversationId(context).conversationId.trim(); } -function isTerminalEvent(event: TaskEvent) { +function isTerminalEvent(event: ChatStreamEvent) { return event.type === 'result' || event.type === 'error'; } -function statusFromResult(event: TaskEvent): ChatTaskStatus { - const data = event.data && typeof event.data === 'object' - ? event.data as Record - : {}; +function statusFromResult(event: ChatStreamEvent): ChatTaskStatus { + const data = event.type === 'result' && event.data ? event.data : {}; if (data.stopped === true) return 'stopped'; return data.ok === false ? 'failed' : 'completed'; } +function hasLiveTask(conversationId: string, taskId: string) { + return liveTasks.has(taskKey(conversationId, taskId)); +} + function getOrCreateLiveTask(conversationId: string, task: ChatTask): LiveChatTask { const key = taskKey(conversationId, task.id); const existing = liveTasks.get(key); - if (existing) { - return existing; - } + if (existing) return existing; const liveTask: LiveChatTask = { conversationId, task, - events: task.finalEvent - ? [{ sequence: 1, event: task.finalEvent }] - : [], - nextSequence: task.finalEvent ? 1 : 0, + events: [], + nextSequence: 0, listeners: new Set(), abortController: new AbortController(), }; @@ -124,61 +132,40 @@ function getOrCreateLiveTask(conversationId: string, task: ChatTask): LiveChatTa return liveTask; } -// file_content events carry whole files. Only the newest version of a path is -// worth replaying to a client that reconnects mid-run, so repeated writes to the -// same file must not pile up in the buffer. -function filePushPath(event: TaskEvent): string { - if (event.type !== 'file_content') return ''; - const data = event.data && typeof event.data === 'object' - ? event.data as Record - : {}; - return typeof data.path === 'string' ? data.path : ''; -} - -function publish(liveTask: LiveChatTask, event: TaskEvent) { - const supersededPath = filePushPath(event); - if (supersededPath) { - const previousIndex = liveTask.events.findIndex( - (record) => filePushPath(record.event) === supersededPath, - ); - if (previousIndex >= 0) { - liveTask.events.splice(previousIndex, 1); - } - } - +function publish(liveTask: LiveChatTask, event: ChatStreamEvent) { const record = { sequence: ++liveTask.nextSequence, event, }; liveTask.events.push(record); - // The event log is only a short-lived in-process replay buffer. Durable task - // state and the final result live in context.store, so a cold start does not - // turn this Map into the source of truth. if (liveTask.events.length > 2_000) { liveTask.events.splice(0, liveTask.events.length - 2_000); } - for (const listener of liveTask.listeners) { - listener(record); - } + for (const listener of liveTask.listeners) listener(record); } -export function isChatTaskActive(task: ChatTask | null | undefined): task is ChatTask { +export function isChatTaskActive( + task: ChatTask | null | undefined, +): task is ChatTask & { status: 'queued' | 'running' } { return task?.status === 'queued' || task?.status === 'running'; } +export function hasLiveChatTask(conversationId: string, taskId: string) { + const live = liveTasks.get(taskKey(conversationId, taskId)); + return Boolean(live?.runPromise); +} + type ChatTaskOptions = { - resetProject?: boolean; turnId?: string; - intent?: ChatTaskIntent; - /** Already validated against this deployment's catalogue; '' means no choice. */ + kind?: ChatTaskKind; model?: string; - siteDomain?: string; + language?: string; apiKey?: string; gatewaySkip?: boolean; }; async function createChatTask( - context: any, + context: AgentContext, message: string, options: ChatTaskOptions = {}, ) { @@ -204,26 +191,13 @@ async function createChatTask( }; } - // Appending the user message creates a brand-new conversation, which makes - // updateConversation available for the durable task record below. The stream - // pipeline knows this message is already persisted and will not append it a - // second time. - await appendTurn(context, conversationId, 'user', message); - - // A request without a choice inherits the conversation's, so the model only - // changes when someone changes it. Recording it on the task is what makes a - // reconnect replay the run that actually happened. const requestedModel = (options.model || '').trim(); - const model = requestedModel || await getModelPreference(context, conversationId); - - const siteDomain = (options.siteDomain || '').trim(); + const language = (options.language || '').trim(); const task: ChatTask = { id: taskId, message, - ...(options.intent === 'deploy' ? { intent: 'deploy' as const } : {}), - ...(siteDomain ? { siteDomain } : {}), - ...(model ? { model } : {}), - resetProject: options.resetProject === true, + ...(options.kind === 'deploy' ? { kind: 'deploy' as const } : { kind: 'prompt' as const }), + ...(requestedModel ? { model: requestedModel } : {}), status: 'queued', createdAt: Date.now(), }; @@ -231,55 +205,50 @@ async function createChatTask( if (requestedModel) { await saveModelPreference(context, conversationId, requestedModel); } + if (language === 'zh' || language === 'en') { + await saveLanguagePreference(context, conversationId, language); + } return { ok: true as const, conversationId, task }; } -function withTaskAbortSignal(context: any, signal: AbortSignal) { - // Keep the same runtime context (sandbox / store / tools) but replace the HTTP - // request signal so SSE client disconnect does not cancel the agent run. +function withTaskAbortSignal(context: AgentContext, signal: AbortSignal) { const request = context?.request && typeof context.request === 'object' ? { ...context.request, signal } : { signal }; return { ...context, request }; } -async function executeLiveTask(context: any, liveTask: LiveChatTask) { +async function executeLiveTask(context: AgentContext, liveTask: LiveChatTask) { const runningTask: ChatTask = { ...liveTask.task, status: 'running', startedAt: liveTask.task.startedAt || Date.now(), error: undefined, - finalEvent: undefined, }; liveTask.task = runningTask; - let finalEvent: TaskEvent | undefined; + let finalEvent: ChatStreamEvent | undefined; let error: string | undefined; const send: StreamSend = (event) => { publish(liveTask, event); - if (isTerminalEvent(event)) { - finalEvent = event; - } + if (isTerminalEvent(event)) finalEvent = event; }; const taskContext = withTaskAbortSignal(context, liveTask.abortController.signal); try { await saveChatTask(taskContext, liveTask.conversationId, runningTask); - publish(liveTask, { type: 'status', message: 'Starting the chat task' }); - if (liveTask.task.intent === 'deploy') { + const language = await getLanguagePreference(taskContext, liveTask.conversationId); + if (liveTask.task.kind === 'deploy') { await runDeployPipeline(taskContext, liveTask.task.message, send, { turnId: liveTask.task.id, - userMessagePersisted: true, - siteDomain: liveTask.task.siteDomain, + language: language || undefined, apiKey: liveTask.gatewayApiKey, gatewaySkip: liveTask.gatewaySkip, }); } else { await runChatPipeline(taskContext, liveTask.task.message, send, { - resetProject: liveTask.task.resetProject, turnId: liveTask.task.id, - userMessagePersisted: true, model: liveTask.task.model, - siteDomain: liveTask.task.siteDomain, + language: language || undefined, apiKey: liveTask.gatewayApiKey, gatewaySkip: liveTask.gatewaySkip, }); @@ -292,6 +261,8 @@ async function executeLiveTask(context: any, liveTask: LiveChatTask) { publish(liveTask, { type: 'error', error }); finalEvent = { type: 'error', error }; } + } finally { + unbindLiveWorkspace(liveTask.conversationId); } const current = liveTask.task; @@ -304,7 +275,6 @@ async function executeLiveTask(context: any, liveTask: LiveChatTask) { ...current, status: nextStatus, finishedAt: Date.now(), - ...(finalEvent ? { finalEvent } : {}), ...(error ? { error } : {}), }; liveTask.task = nextTask; @@ -323,7 +293,7 @@ async function executeLiveTask(context: any, liveTask: LiveChatTask) { } function ensureChatTaskStarted( - context: any, + context: AgentContext, conversationId: string, task: ChatTask, extras?: { gatewayApiKey?: string; gatewaySkip?: boolean }, @@ -358,16 +328,14 @@ class AsyncEventQueue { const ABORTED = Symbol('aborted'); -/** Replay buffered events and subscribe to the in-process task. Used by POST /session and GET /session. */ export async function* iterateLiveChatTaskEvents( - context: any, + context: AgentContext, conversationId: string, task: ChatTask, extras?: { gatewayApiKey?: string; gatewaySkip?: boolean }, signal?: AbortSignal, ): AsyncGenerator { const liveTask = ensureChatTaskStarted(context, conversationId, task, extras); - yield sseEvent({ type: 'task_started', data: { @@ -385,14 +353,10 @@ export async function* iterateLiveChatTaskEvents( try { for (const record of liveTask.events) { - if (record.sequence <= afterSequence) { - yield sseEvent(record.event); - } + if (record.sequence <= afterSequence) yield sseEvent(record.event); } if (!isChatTaskActive(liveTask.task)) { - // The task may have completed after `afterSequence` was captured but - // before replay finished. Drain that race window before closing. for (const record of liveTask.events) { if (record.sequence > afterSequence) yield sseEvent(record.event); } @@ -420,7 +384,7 @@ export async function* iterateLiveChatTaskEvents( } function createLiveTaskStreamResponse( - context: any, + context: AgentContext, conversationId: string, task: ChatTask, extras?: { gatewayApiKey?: string; gatewaySkip?: boolean }, @@ -430,9 +394,8 @@ function createLiveTaskStreamResponse( }, context?.request?.signal); } -/** Create a durable task and subscribe the same POST request to its event stream. */ export async function createChatTaskAndStreamResponse( - context: any, + context: AgentContext, message: string, options: ChatTaskOptions = {}, ) { diff --git a/agents/_lib/session/transcript.ts b/agents/_lib/session/transcript.ts new file mode 100644 index 0000000..ebfd943 --- /dev/null +++ b/agents/_lib/session/transcript.ts @@ -0,0 +1,226 @@ +import type { AgentContext } from '../runtime/context.ts'; +import { createWriteStream, existsSync, readdirSync } from 'node:fs'; +import { mkdir, readFile, stat } from 'node:fs/promises'; +import path from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { resolveConversationId } from '../runtime/request.ts'; +import { createSSEResponse, sseEvent } from '../runtime/sse.ts'; +import { getBlobStore, getConversationRecord, patchConversationRecord, transcriptBlobKey } from './store.ts'; + +const TRANSCRIPT_WATCH_MS = 250; + +const TRANSCRIPT_WARN_BYTES = 32 * 1024 * 1024; + +function toNodeReadable(value: unknown): Readable | null { + if (!value) return null; + if (value instanceof Readable) return value; + if (typeof (value as ReadableStream).getReader === 'function') { + return Readable.fromWeb(value as import('node:stream/web').ReadableStream); + } + if (typeof value === 'string') { + return Readable.from([value]); + } + return null; +} + +export async function downloadTranscript(options: { + context: { blobStore?: import('../runtime/context.ts').BlobStoreLike }; + conversationId: string; + sessionId: string; + destPath: string; +}): Promise { + const store = getBlobStore(options.context); + const body = await store.get(transcriptBlobKey(options.sessionId), { type: 'stream' }); + const readable = toNodeReadable(body); + if (!readable) return false; + + await mkdir(path.dirname(options.destPath), { recursive: true }); + await pipeline(readable, createWriteStream(options.destPath)); + return true; +} + +export async function uploadTranscript(options: { + context: { blobStore?: import('../runtime/context.ts').BlobStoreLike }; + conversationId: string; + sessionId: string; + sourcePath: string; +}): Promise { + const info = await stat(options.sourcePath).catch(() => null); + if (!info) { + console.warn('[transcript] local file missing; skip upload', options.sourcePath); + return; + } + if (info.size >= TRANSCRIPT_WARN_BYTES) { + console.warn('[transcript] large session file', { + sessionId: options.sessionId, + bytes: info.size, + }); + } + + const store = getBlobStore(options.context); + // PagesBlob's Node `fetch` PUT omits `duplex: 'half'`, which undici requires + // for a ReadableStream body. Buffer the JSONL so the PUT is a string body. + await store.set(transcriptBlobKey(options.sessionId), await readFile(options.sourcePath, 'utf8')); + await patchConversationRecord(options.context, options.conversationId, { + claudeSessionId: options.sessionId, + transcriptPath: options.sourcePath, + }); +} + +export async function readTranscriptText(filePath: string): Promise { + return readFile(filePath, 'utf8'); +} + +export type TranscriptLocateOptions = { + configDir?: string; + cwd?: string; +}; + +/** Claude persists JSONL at `$CLAUDE_CONFIG_DIR/projects//.jsonl`. */ +export function claudeProjectDirName(cwd: string): string { + return cwd.replace(/[/\\]/g, '-'); +} + +export function resolveClaudeTranscriptPath( + sessionId: string, + options?: TranscriptLocateOptions & { explicitPath?: string }, +): string { + const explicit = (options?.explicitPath || '').trim(); + if (explicit && existsSync(explicit)) return explicit; + if (!sessionId) return explicit; + + const configDir = (options?.configDir || '').trim() || '/tmp/.claude'; + const cwd = (options?.cwd || '').trim() || process.cwd(); + const conventional = path.join( + configDir, + 'projects', + claudeProjectDirName(cwd), + `${sessionId}.jsonl`, + ); + if (existsSync(conventional)) return conventional; + + const legacy = path.join(configDir, 'sessions', `${sessionId}.jsonl`); + if (existsSync(legacy)) return legacy; + + const projects = path.join(configDir, 'projects'); + if (existsSync(projects)) { + for (const entry of readdirSync(projects, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + const candidate = path.join(projects, entry.name, `${sessionId}.jsonl`); + if (existsSync(candidate)) return candidate; + } + } + + return explicit || conventional; +} + +/** + * The Claude JSONL file is the only history this product keeps. Resume and the + * Session tab both read it here so they cannot drift onto a second copy. + */ +export async function loadTranscriptJsonl( + context: { blobStore?: import('../runtime/context.ts').BlobStoreLike }, + conversationId: string, + livePath = '', + locate?: TranscriptLocateOptions & { sessionId?: string }, +): Promise { + const record = await getConversationRecord(context, conversationId); + const sessionId = locate?.sessionId || record.claudeSessionId || ''; + const resolved = resolveClaudeTranscriptPath(sessionId, { + explicitPath: livePath || record.transcriptPath, + configDir: locate?.configDir, + cwd: locate?.cwd, + }); + if (resolved && existsSync(resolved)) { + return readTranscriptText(resolved); + } + if (sessionId) { + const dest = resolved || path.join('/tmp/.claude/sessions', `${sessionId}.jsonl`); + const restored = await downloadTranscript({ + context, + conversationId, + sessionId, + destPath: dest, + }); + if (restored) return readTranscriptText(dest); + } + return ''; +} + +function jsonResponse(obj: Record, status = 200) { + return new Response(JSON.stringify(obj), { + status, + headers: { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + }, + }); +} + +export type LiveTranscriptRef = { + path?: string; + sessionId?: string; + active?: boolean; +}; + +function sleep(ms: number, signal?: AbortSignal) { + return new Promise((resolve) => { + if (signal?.aborted) { + resolve(); + return; + } + const timer = setTimeout(resolve, ms); + const onAbort = () => { + clearTimeout(timer); + resolve(); + }; + signal?.addEventListener('abort', onAbort, { once: true }); + }); +} + +/** GET /transcript — JSONL snapshots while the live file is being written. */ +export async function createTranscriptStreamResponse( + context: AgentContext, + resolveLive: (conversationId: string) => LiveTranscriptRef | null, +): Promise { + const { conversationId } = resolveConversationId(context); + if (!conversationId) { + return jsonResponse({ ok: false, error: 'missing conversation_id' }, 400); + } + + return createSSEResponse(async function* (signal) { + let lastSignature = ''; + let watching = true; + + while (!signal?.aborted && watching) { + const live = resolveLive(conversationId); + // This loop exists to notice the session id another request writes, so + // it is the one reader that must bypass the per-request record memo. + const record = !live?.sessionId || !live?.path + ? await getConversationRecord(context, conversationId, { refresh: true }) + : null; + const sessionId = live?.sessionId || record?.claudeSessionId || ''; + const transcriptPath = resolveClaudeTranscriptPath(sessionId, { + explicitPath: live?.path || record?.transcriptPath, + }); + const jsonl = await loadTranscriptJsonl(context, conversationId, transcriptPath, { sessionId }); + const data = { + ok: true as const, + conversation_id: conversationId, + sessionId, + transcriptPath, + jsonl, + live: Boolean(live?.active), + }; + const signature = `${data.sessionId}\0${data.transcriptPath}\0${data.live}\0${data.jsonl}`; + if (signature !== lastSignature) { + lastSignature = signature; + yield sseEvent({ type: 'transcript', data }); + } + watching = Boolean(live?.active); + if (!watching) break; + await sleep(TRANSCRIPT_WATCH_MS, signal); + } + }, context?.request?.signal); +} diff --git a/agents/_lib/tools/assemble.ts b/agents/_lib/tools/assemble.ts new file mode 100644 index 0000000..b525f24 --- /dev/null +++ b/agents/_lib/tools/assemble.ts @@ -0,0 +1,159 @@ +import { createSdkMcpServer } from '@anthropic-ai/claude-agent-sdk'; +import { SANDBOX_MCP_SERVER_NAME } from '../constants.ts'; +import type { AgentContext } from '../runtime/context.ts'; +import type { + ClaudeMcpTool, + CodingAgentResult, + DeploymentInfo, + PreviewKind, + ProjectState, + StreamSend, +} from '../types.ts'; +import { wrapSandboxTools, type MakersCommandLifecycle } from './commands-wrap.ts'; +import { wrapWebSearchTool } from './web-search-wrap.ts'; +import { + WEB_SEARCH_API_KEY_ENV, + isWebSearchConfigured, + isWebSearchToolName, +} from '../../../shared/web-search.ts'; +import { buildLoadMakersSkillTool } from './makers-skills.ts'; +import { buildWriteProjectFileTool } from './project-tools.ts'; + +export type LiveTurnCallbacks = { + onProjectFilesChanged?: (file?: { path: string; content: string }) => void | Promise; + onPreviewReady?: (preview: { url?: string; sandboxDebugUrl?: string; kind?: PreviewKind }) => void; + onDeploymentStatus?: (deployment: DeploymentInfo) => void; + send?: StreamSend; + abortSignal?: AbortSignal; +}; + +export type LiveSessionHandle = { + conversationId: string; + context: AgentContext; + getState: () => ProjectState; + getCallbacks: () => LiveTurnCallbacks; + flags: { + projectTouched: boolean; + filesWritten: boolean; + previewTouched: boolean; + deploymentTouched: boolean; + }; +}; + +function isBrowserSandboxToolName(name: string) { + return name.toLowerCase().includes('browser'); +} + +function isGenericProjectWriteToolName(name: string) { + const normalized = name.toLowerCase(); + return normalized === 'files_write' + || normalized === 'write_files' + || normalized.endsWith('__files_write') + || normalized.endsWith('__write_files'); +} + +function pickEnvValue(context: AgentContext, key: string) { + const value = context?.env?.[key]; + return typeof value === 'string' ? value.trim() : ''; +} + +export function assembleAgentTools(session: LiveSessionHandle) { + const context = session.context; + if (typeof context.tools?.toClaudeMcpServer !== 'function') { + throw new Error('The current Pages Agent Runtime is missing context.tools.toClaudeMcpServer. Please upgrade to a runtime that supports the new pages-agent-toolkit Tools API.'); + } + + const mcpServerName = SANDBOX_MCP_SERVER_NAME; + const edgeoneMcp = context.tools.toClaudeMcpServer(mcpServerName, { alwaysLoad: true }); + const webSearchAvailable = isWebSearchConfigured( + pickEnvValue(context, WEB_SEARCH_API_KEY_ENV), + ); + const offerSandboxTool = (name: string) => + !isBrowserSandboxToolName(name) + && !isGenericProjectWriteToolName(name) + && (webSearchAvailable || !isWebSearchToolName(name)); + + const gatewayPrompt = { + conversationId: session.conversationId, + get send() { + return session.getCallbacks().send; + }, + }; + const writeProjectFileTool = buildWriteProjectFileTool( + context, + session.getState(), + async ({ written, content }) => { + session.flags.projectTouched = true; + session.flags.filesWritten = true; + await session.getCallbacks().onProjectFilesChanged?.({ path: written, content }); + }, + gatewayPrompt, + ); + const sandboxTools = wrapWebSearchTool(wrapSandboxTools( + (edgeoneMcp.tools as ClaudeMcpTool[]).filter((tool) => offerSandboxTool(tool.name)), + { + context, + get state() { + return session.getState(); + }, + conversationId: session.conversationId, + get send() { + return session.getCallbacks().send; + }, + get signal() { + return session.getCallbacks().abortSignal; + }, + onPreviewReady: (preview) => { + session.flags.previewTouched = true; + if (preview.url) session.getCallbacks().onPreviewReady?.(preview); + }, + onDeploymentStatus: (deployment: DeploymentInfo) => { + session.flags.deploymentTouched = true; + session.getCallbacks().onDeploymentStatus?.(deployment); + }, + } as MakersCommandLifecycle, + )); + const mcpTools = [ + ...sandboxTools, + buildLoadMakersSkillTool({ + context, + get state() { + return session.getState(); + }, + conversationId: session.conversationId, + get send() { + return session.getCallbacks().send; + }, + }), + writeProjectFileTool, + ]; + const mcpAllowedTools = [ + ...edgeoneMcp.allowedTools.filter(offerSandboxTool), + `mcp__${mcpServerName}__load_makers_skill`, + `mcp__${mcpServerName}__write_project_file`, + 'Skill', + ]; + + return { + mcpServerName, + webSearchAvailable, + sandboxMcpServer: createSdkMcpServer({ + name: mcpServerName, + tools: mcpTools, + alwaysLoad: true, + }), + mcpAllowedTools, + }; +} + +export function emptyCodingResult(partial: Partial = {}): CodingAgentResult { + return { + success: false, + output: null, + error: null, + projectTouched: false, + filesWritten: false, + wasCreated: false, + ...partial, + }; +} diff --git a/agents/_lib/tools/command-preprocess.ts b/agents/_lib/tools/command-preprocess.ts new file mode 100644 index 0000000..1318b0a --- /dev/null +++ b/agents/_lib/tools/command-preprocess.ts @@ -0,0 +1,86 @@ +import { + MAKERS_DEV_PORT, +} from '../constants.ts'; +import { + buildMakersDevStopScript, +} from '../makers/cli-dev.ts'; +import { + buildNpmCacheReclaimScript, + buildNpmWarmupHandoffScript, + buildNpmWarmupWaitScript, +} from '../makers/npm-install.ts'; +import { + isBareInstallCommand, + isInstallCommand, + isScaffolderCommand, + isVerificationCommand, +} from '../makers/tool-phase.ts'; + +export function extractCommand(args: unknown) { + const record = args && typeof args === 'object' ? args as Record : {}; + const command = typeof record.command === 'string' + ? record.command + : typeof record.cmd === 'string' + ? record.cmd + : ''; + return { record, command }; +} + +export function withWrappedCommand(args: unknown, wrapped: string) { + const { record, command } = extractCommand(args); + if (!command || wrapped === command) { + return args; + } + return { + ...record, + ...(typeof record.command === 'string' ? { command: wrapped } : {}), + ...(typeof record.cmd === 'string' ? { cmd: wrapped } : {}), + }; +} + +export function withCommandOptions( + args: unknown, + command: string, + cwd: string, + env: Record, + timeout: number, +) { + const { record } = extractCommand(args); + return { + ...record, + ...(typeof record.command === 'string' ? { command } : { cmd: command }), + cwd, + env: { + ...(record.env && typeof record.env === 'object' + ? record.env as Record + : {}), + ...env, + }, + timeout, + }; +} + +const DEV_SERVER_STOPPED_NOTICE = 'The preview dev server was stopped before this command, ' + + 'because a build or an install in the same directory races it over .next and node_modules. ' + + 'The preview is down until you run `edgeone makers dev` again.'; + + +export function withDevServerStopped(command: string) { + return [buildMakersDevStopScript(MAKERS_DEV_PORT, DEV_SERVER_STOPPED_NOTICE), command].join('\n'); +} + +export function withWarmedInstall(command: string, wrapped: string) { + if (isBareInstallCommand(command)) { + return [buildNpmWarmupHandoffScript(), wrapped, buildNpmCacheReclaimScript()].join('\n'); + } + return [buildNpmWarmupWaitScript(), wrapped].join('\n'); +} + +export function shouldStopDevServer(command: string, isMakersCommand: boolean) { + return !isMakersCommand + && ( + isInstallCommand(command) + || isVerificationCommand(command) + || isScaffolderCommand(command) + ); +} diff --git a/agents/_lib/tools/command-text.ts b/agents/_lib/tools/command-text.ts new file mode 100644 index 0000000..cf115fe --- /dev/null +++ b/agents/_lib/tools/command-text.ts @@ -0,0 +1,72 @@ +import type { ClaudeMcpTool } from '../types.ts'; +import { + MAKERS_CLI_UNAVAILABLE_ERROR_CODE, + MAKERS_CLI_UNAVAILABLE_MESSAGE, +} from '../makers/tool-phase.ts'; +import { redactSecret } from '../makers/cli-deploy.ts'; + +export type ToolHandlerResult = Awaited>; + +export function textContents(result: ToolHandlerResult) { + return (result.content || []) + .flatMap((item) => item && typeof item === 'object' && 'text' in item + && typeof item.text === 'string' ? [item.text] : []) + .join('\n'); +} + +export function commandOutputFromToolResult(result: ToolHandlerResult) { + const raw = textContents(result); + const streams: string[] = []; + for (const item of result.content || []) { + if (!item || typeof item !== 'object' || !('text' in item) || typeof item.text !== 'string') { + continue; + } + try { + const parsed = JSON.parse(item.text) as Record; + if (typeof parsed.stdout === 'string') streams.push(parsed.stdout); + if (typeof parsed.stderr === 'string') streams.push(parsed.stderr); + } catch { + // Some runtime versions return raw stdout instead of a JSON envelope. + } + } + return [...streams, raw].filter(Boolean).join('\n'); +} + +export function appendText(result: ToolHandlerResult, text: string) { + return { + ...result, + content: [ + ...(result.content || []), + { type: 'text' as const, text }, + ], + }; +} + +export function withMakersCliUnavailableError( + result: ToolHandlerResult, + attemptedCommand: string, +) { + return { + ...appendText(result, JSON.stringify({ + status: 'error', + errorCode: MAKERS_CLI_UNAVAILABLE_ERROR_CODE, + retryable: false, + error: MAKERS_CLI_UNAVAILABLE_MESSAGE, + attemptedCommand, + instruction: 'Stop this preview/deploy attempt. Do not inspect PATH or installation directories, install packages, use npx, or retry. Tell the user this is a sandbox image rollout blocker, not a generated-project error.', + })), + isError: true, + }; +} + +export function redactToolResult(result: ToolHandlerResult, secret: string) { + if (!secret) return result; + return { + ...result, + content: (result.content || []).map((item) => ( + item && typeof item === 'object' && 'text' in item && typeof item.text === 'string' + ? { ...item, text: redactSecret(item.text, secret) } + : item + )), + }; +} diff --git a/agents/_lib/tools/commands-wrap.ts b/agents/_lib/tools/commands-wrap.ts index 7906e7e..f99c0cc 100644 --- a/agents/_lib/tools/commands-wrap.ts +++ b/agents/_lib/tools/commands-wrap.ts @@ -1,306 +1,45 @@ -import type { - ClaudeMcpTool, - DeploymentInfo, - PreviewKind, - ProjectState, - StreamSend, -} from '../types.ts'; +import type { ClaudeMcpTool } from '../types.ts'; +import { startPreviewServer } from '../project/preview.ts'; +import { assertMakersProjectCompatible } from '../makers/compat/run.ts'; import { - MAKERS_DEV_PORT, - PREVIEW_ASSET_PREFIX_ENV, - PREVIEW_PATH_PREFIX, - PREVIEW_SERVER_PORT, -} from '../constants.ts'; -import { assertMakersProjectCompatible } from '../project/makers-compat.ts'; + askUserForGatewayCredentials, + pauseForGatewayCredentialsIfNeeded, + shouldPauseForGatewayCredentials, +} from '../project/gateway.ts'; import { - previewFailureWarrantsRestart, - publishRunningPreview, - startPreviewServer, -} from '../project/preview.ts'; -import { - ensureMakersPublishProject, - resolveConversationPublishArea, - resolveMakersProjectName, - syncSandboxEnvToMakersProject, -} from '../project/makers-deploy.ts'; -import { pauseForGatewayCredentialsIfNeeded } from '../project/gateway-prompt.ts'; -import { - buildSandboxMakersEnv, - describeMissingMakersRuntimeToken, - prepareSandboxGatewayEnv, - resolveMakersMasterToken, - resolveSandboxMakersToken, -} from '../project/makers-token.ts'; -import { - MAKERS_DEV_LAUNCH_TIMEOUT_SECONDS, - MAKERS_DEV_PORT_DRIFT_EXIT, - buildMakersDevBackgroundCommand, - buildMakersDevStopScript, - parseMakersDevExitCode, -} from '../../../shared/makers-dev.ts'; -import { - buildMakersDeployCommand, - describeMakersDeployment, - readMakersDeployOutcome, - redactSecret, -} from '../../../shared/makers-deploy.ts'; -import { - buildNpmCacheReclaimScript, - buildNpmWarmupHandoffScript, - buildNpmWarmupWaitScript, -} from '../../../shared/npm-install.ts'; -import { - MAKERS_CLI_UNAVAILABLE_ERROR_CODE, - MAKERS_CLI_UNAVAILABLE_MESSAGE, buildEdgeoneVersionCheckCommand, forbiddenSandboxCommandReason, - isEdgeoneCliUnavailable, - isBareInstallCommand, isEdgeoneVersionCommand, - isInstallCommand, - isScaffolderCommand, isMakersDeployCommand, isMakersDevCommand, - isVerificationCommand, - shortenToolName, parseEdgeoneVersionExitCode, + isEdgeoneCliUnavailable, + shortenToolName, withExitCodeEcho, -} from '../utils/tool-phase.ts'; - -type MakersCommandLifecycle = { - context: any; - state: ProjectState; - conversationId?: string; - send?: StreamSend; - signal?: AbortSignal; - onPreviewReady?: (preview: { - url?: string; - sandboxDebugUrl?: string; - kind?: PreviewKind; - }) => void; - onDeploymentStatus?: (deployment: DeploymentInfo) => void; -}; - -function updateDeploymentStatus( - lifecycle: MakersCommandLifecycle, - deployment: DeploymentInfo, -) { - lifecycle.state.deployment = deployment; - lifecycle.onDeploymentStatus?.(deployment); -} - -function extractCommand(args: unknown) { - const record = args && typeof args === 'object' ? args as Record : {}; - const command = typeof record.command === 'string' - ? record.command - : typeof record.cmd === 'string' - ? record.cmd - : ''; - return { record, command }; -} - -function withWrappedCommand(args: unknown, wrapped: string) { - const { record, command } = extractCommand(args); - if (!command || wrapped === command) { - return args; - } - return { - ...record, - ...(typeof record.command === 'string' ? { command: wrapped } : {}), - ...(typeof record.cmd === 'string' ? { cmd: wrapped } : {}), - }; -} - -function withCommandOptions( - args: unknown, - command: string, - cwd: string, - env: Record, - timeout: number, -) { - const { record } = extractCommand(args); - return { - ...record, - ...(typeof record.command === 'string' ? { command } : { cmd: command }), - cwd, - env: { - ...(record.env && typeof record.env === 'object' - ? record.env as Record - : {}), - ...env, - }, - timeout, - }; -} - -function textContents(result: Awaited>) { - return (result.content || []) - .flatMap((item) => item && typeof item === 'object' && 'text' in item - && typeof item.text === 'string' ? [item.text] : []) - .join('\n'); -} - -function commandOutputFromToolResult(result: Awaited>) { - const raw = textContents(result); - const streams: string[] = []; - for (const item of result.content || []) { - if (!item || typeof item !== 'object' || !('text' in item) || typeof item.text !== 'string') { - continue; - } - try { - const parsed = JSON.parse(item.text) as Record; - if (typeof parsed.stdout === 'string') streams.push(parsed.stdout); - if (typeof parsed.stderr === 'string') streams.push(parsed.stderr); - } catch { - // Some runtime versions return raw stdout instead of a JSON envelope. - } - } - return [...streams, raw].filter(Boolean).join('\n'); -} - -function appendText( - result: Awaited>, - text: string, -) { - return { - ...result, - content: [ - ...(result.content || []), - { type: 'text' as const, text }, - ], - }; -} - -function withMakersCliUnavailableError( - result: Awaited>, - attemptedCommand: string, -) { - return { - ...appendText(result, JSON.stringify({ - status: 'error', - errorCode: MAKERS_CLI_UNAVAILABLE_ERROR_CODE, - retryable: false, - error: MAKERS_CLI_UNAVAILABLE_MESSAGE, - attemptedCommand, - instruction: 'Stop this preview/deploy attempt. Do not inspect PATH or installation directories, install packages, use npx, or retry. Tell the user this is a sandbox image rollout blocker, not a generated-project error.', - })), - isError: true, - }; -} - -const DEV_SERVER_STOPPED_NOTICE = 'The preview dev server was stopped before this command, ' - + 'because a build or an install in the same directory races it over .next and node_modules. ' - + 'The preview is down until you run `edgeone makers dev` again.'; - -function withDevServerStopped(command: string) { - return [buildMakersDevStopScript(MAKERS_DEV_PORT, DEV_SERVER_STOPPED_NOTICE), command].join('\n'); -} - -/** - * Let the background install the host started stand in for this one. - * - * Everything waits, because two npm processes on one node_modules is the one - * thing that must never happen — see shared/npm-install.ts. Only a bare install - * is answered outright: the warmup ran that exact command, while an install - * naming a package has to run or the package is never there. - */ -function withWarmedInstall(command: string, wrapped: string) { - if (isBareInstallCommand(command)) { - // The reclaim is only reached when the handoff did not stand in, so it - // follows an install that really ran and really filled the cache. - return [buildNpmWarmupHandoffScript(), wrapped, buildNpmCacheReclaimScript()].join('\n'); - } - return [buildNpmWarmupWaitScript(), wrapped].join('\n'); -} - -function redactToolResult( - result: Awaited>, - secret: string, -) { - if (!secret) return result; - return { - ...result, - content: (result.content || []).map((item) => ( - item && typeof item === 'object' && 'text' in item && typeof item.text === 'string' - ? { ...item, text: redactSecret(item.text, secret) } - : item - )), - }; -} - -async function prepareMakersCommand( - args: unknown, - command: string, - lifecycle: MakersCommandLifecycle, -) { - const masterToken = resolveMakersMasterToken(lifecycle.context); - const sandboxToken = await resolveSandboxMakersToken( - lifecycle.state, - masterToken, - ); - const gateway = await prepareSandboxGatewayEnv(lifecycle.context, lifecycle.state); - const env = buildSandboxMakersEnv( - sandboxToken, - lifecycle.state.makersApiRegion, - ); - // Whatever name the model typed is replaced here. It has no way to know - // which project belongs to this conversation, and a name it invents to dodge - // a collision would strand the site somewhere nobody can find again. - const projectName = resolveMakersProjectName(lifecycle.context, lifecycle.state); - const area = resolveConversationPublishArea(lifecycle.state); - await ensureMakersPublishProject( - sandboxToken, - projectName, - area, - lifecycle.state.makersApiRegion, - ); - - if (isMakersDevCommand(command)) { - return { - args: withCommandOptions( - args, - buildMakersDevBackgroundCommand({ - makersPort: MAKERS_DEV_PORT, - previewPort: PREVIEW_SERVER_PORT, - previewPath: PREVIEW_PATH_PREFIX, - projectName, - assetPrefixEnvName: PREVIEW_ASSET_PREFIX_ENV, - area, - }), - lifecycle.state.appDir, - env, - MAKERS_DEV_LAUNCH_TIMEOUT_SECONDS, - ), - kind: 'dev' as const, - sandboxToken, - gatewayKey: gateway.AI_GATEWAY_API_KEY || '', - }; - } - - await syncSandboxEnvToMakersProject( - lifecycle.context, - lifecycle.state, - masterToken, - projectName, - lifecycle.state.makersApiRegion, - ); +} from '../makers/tool-phase.ts'; +import { + appendText, + commandOutputFromToolResult, + redactToolResult, + textContents, + withMakersCliUnavailableError, +} from './command-text.ts'; +import { + extractCommand, + shouldStopDevServer, + withDevServerStopped, + withWarmedInstall, + withWrappedCommand, +} from './command-preprocess.ts'; +import { prepareMakersCommand } from './makers-command.ts'; +import type { MakersCommandLifecycle } from './makers-lifecycle.ts'; +import { handleDevCommandResult } from './preview-command-result.ts'; +import { + handleDeployCommandResult, + updateDeploymentStatus, +} from './deploy-command-result.ts'; - return { - args: withCommandOptions( - args, - buildMakersDeployCommand(projectName, command, { - stopDevPort: MAKERS_DEV_PORT, - area, - }), - lifecycle.state.appDir, - env, - 600, - ), - kind: 'deploy' as const, - sandboxToken, - gatewayKey: gateway.AI_GATEWAY_API_KEY || '', - }; -} +export type { MakersCommandLifecycle } from './makers-lifecycle.ts'; export function wrapSandboxTools( tools: ClaudeMcpTool[], @@ -340,18 +79,7 @@ export function wrapSandboxTools( startedAt: deploymentStartedAt, }); } - // An install or a build in the project directory contends with the dev - // server for .next and node_modules, and loses in ways that name - // neither: a build reports a missing file or a Pages Router page it - // does not have, and an install reports ENOTEMPTY renaming a package - // the server holds open. One run spent twenty calls reading its own - // source for a `` import that was never there. - const stopsDevServer = !isMakersCommand - && ( - isInstallCommand(command) - || isVerificationCommand(command) - || isScaffolderCommand(command) - ); + const stopsDevServer = shouldStopDevServer(command, isMakersCommand); let nextArgs = withWrappedCommand( args, isEdgeoneVersionCommand(command) @@ -365,19 +93,30 @@ export function wrapSandboxTools( | undefined; if (lifecycle && isMakersCommand) { try { - const pause = await pauseForGatewayCredentialsIfNeeded( - lifecycle.context, - lifecycle.state, - { - conversationId: lifecycle.conversationId, - send: lifecycle.send, - }, - ); - if (pause) { - return { - content: [{ type: 'text' as const, text: pause }], - isError: true, - }; + if (isDeploymentCommand) { + const pause = await pauseForGatewayCredentialsIfNeeded( + lifecycle.context, + lifecycle.state, + { + conversationId: lifecycle.conversationId, + send: lifecycle.send, + }, + ); + if (pause) { + return { + content: [{ type: 'text' as const, text: pause }], + isError: true, + }; + } + } else if (await shouldPauseForGatewayCredentials(lifecycle.context, lifecycle.state)) { + await askUserForGatewayCredentials( + lifecycle.context, + lifecycle.state, + { + conversationId: lifecycle.conversationId, + send: lifecycle.send, + }, + ); } await assertMakersProjectCompatible(lifecycle.context, lifecycle.state); makers = await prepareMakersCommand(args, command, lifecycle); @@ -394,9 +133,6 @@ export function wrapSandboxTools( }; } } - // Generic verification commands keep isError false even when EXIT:N - // is non-zero so the model can fix source. The dedicated CLI branches - // below promote captured lifecycle failures to structured tool errors. let result: Awaited>; try { result = await originalHandler(nextArgs, extra); @@ -431,24 +167,16 @@ export function wrapSandboxTools( return result; } - // Parse deploy output before redaction so a URL query value that - // happens to overlap the CLI credential is never truncated. const makersOutput = commandOutputFromToolResult(result); result = redactToolResult(result, makers.sandboxToken); result = redactToolResult(result, makers.gatewayKey); - // The deploy command stops makers dev so the build does not share its - // output directory. Restart before any of the branches below report, - // including the failing ones: the preview is how the model inspects - // what it just built, and a publish that failed is exactly when it - // needs to look. if (makers.kind === 'deploy') { try { await startPreviewServer(lifecycle.context, lifecycle.state, { verifyRoutes: false, }); } catch { - // Not part of publishing. The next preview command starts it again, - // and failing the deploy over this would call a live site broken. + // Not part of publishing. The next preview command starts it again. } } if (result.isError) { @@ -460,93 +188,17 @@ export function wrapSandboxTools( } if (makers.kind === 'dev') { - const devExitCode = parseMakersDevExitCode(makersOutput); - if (devExitCode != null && devExitCode !== 0) { - if (isEdgeoneCliUnavailable(makersOutput)) { - return withMakersCliUnavailableError(result, 'edgeone makers dev'); - } - // The one launch failure that says nothing about the project: the - // port was still held, so the CLI came up healthy somewhere the - // proxy does not look. Launching again is the fix, and the launcher - // now clears that port first, so the second attempt is not a repeat - // of the first. - if (devExitCode === MAKERS_DEV_PORT_DRIFT_EXIT) { - return { - ...appendText(result, JSON.stringify({ - status: 'error', - errorCode: 'MAKERS_DEV_PORT_DRIFT', - retryable: true, - error: 'edgeone makers dev started on a port the preview proxy does not forward to, because the previous dev server still held the expected one.', - instruction: 'Run the same preview command once more. Nothing in the generated project caused this, so do not change project files, and do not kill processes or free ports yourself — the launcher terminates the previous server before this next attempt.', - })), - isError: true, - }; - } - return { - ...appendText(result, JSON.stringify({ - status: 'error', - error: describeMissingMakersRuntimeToken(makersOutput) - || `edgeone makers dev exited with code ${devExitCode}.`, - exitCode: devExitCode, - })), - isError: true, - }; - } - try { - let preview; - try { - preview = await publishRunningPreview(lifecycle.context, lifecycle.state); - } catch (error) { - // The smoke test now retries through the rebuild window itself, so - // reaching here means the server never answered — restart it once. - // A generated agent that answers wrongly is reported as-is instead: - // its reply already proves the server and proxy work. - if (!previewFailureWarrantsRestart(error)) throw error; - await startPreviewServer(lifecycle.context, lifecycle.state); - preview = await publishRunningPreview(lifecycle.context, lifecycle.state, { - routesAlreadyVerified: true, - }); - } - lifecycle.onPreviewReady?.(preview); - return appendText(result, JSON.stringify({ - status: 'success', - preview: { - url: preview.url, - kind: preview.kind, - }, - })); - } catch (error) { - return { - ...appendText(result, error instanceof Error ? error.message : String(error)), - isError: true, - }; - } + return handleDevCommandResult(lifecycle, makers, result, makersOutput); } - const outcome = readMakersDeployOutcome(makersOutput, '', makers.sandboxToken); - if (outcome.status === 'cli-missing') { - failDeployment(outcome.error); - return withMakersCliUnavailableError(result, 'edgeone makers deploy'); - } - if (outcome.status === 'error') { - failDeployment(outcome.error); - return { - ...appendText(result, JSON.stringify({ - status: 'error', - error: outcome.error, - ...(outcome.exitCode != null ? { exitCode: outcome.exitCode } : {}), - })), - isError: true, - }; - } - updateDeploymentStatus(lifecycle, describeMakersDeployment(outcome, { - startedAt: deploymentStartedAt, - })); - const { status: _outcomeStatus, ...published } = outcome; - return appendText(result, JSON.stringify({ - status: 'published', - ...published, - })); + return handleDeployCommandResult( + lifecycle, + makers, + result, + makersOutput, + deploymentStartedAt, + failDeployment, + ); }, }; }); diff --git a/agents/_lib/tools/deploy-command-result.ts b/agents/_lib/tools/deploy-command-result.ts new file mode 100644 index 0000000..4a70080 --- /dev/null +++ b/agents/_lib/tools/deploy-command-result.ts @@ -0,0 +1,55 @@ +import { setDeployment } from '../project/workspace-store.ts'; +import { + describeMakersDeployment, + readMakersDeployOutcome, +} from '../makers/cli-deploy.ts'; +import type { DeploymentInfo } from '../types.ts'; +import { + appendText, + withMakersCliUnavailableError, + type ToolHandlerResult, +} from './command-text.ts'; +import type { MakersCommandLifecycle } from './makers-lifecycle.ts'; +import type { PreparedMakersCommand } from './makers-command.ts'; + +export function updateDeploymentStatus( + lifecycle: MakersCommandLifecycle, + deployment: DeploymentInfo, +) { + setDeployment(lifecycle.state, deployment); + lifecycle.onDeploymentStatus?.(deployment); +} + +export function handleDeployCommandResult( + lifecycle: MakersCommandLifecycle, + makers: PreparedMakersCommand, + result: ToolHandlerResult, + makersOutput: string, + deploymentStartedAt: number, + failDeployment: (error: string) => void, +): ToolHandlerResult { + const outcome = readMakersDeployOutcome(makersOutput, '', makers.sandboxToken); + if (outcome.status === 'cli-missing') { + failDeployment(outcome.error); + return withMakersCliUnavailableError(result, 'edgeone makers deploy'); + } + if (outcome.status === 'error') { + failDeployment(outcome.error); + return { + ...appendText(result, JSON.stringify({ + status: 'error', + error: outcome.error, + ...(outcome.exitCode != null ? { exitCode: outcome.exitCode } : {}), + })), + isError: true, + }; + } + updateDeploymentStatus(lifecycle, describeMakersDeployment(outcome, { + startedAt: deploymentStartedAt, + })); + const { status: _outcomeStatus, ...published } = outcome; + return appendText(result, JSON.stringify({ + status: 'published', + ...published, + })); +} diff --git a/agents/_lib/tools/makers-command.ts b/agents/_lib/tools/makers-command.ts new file mode 100644 index 0000000..1a48fa3 --- /dev/null +++ b/agents/_lib/tools/makers-command.ts @@ -0,0 +1,73 @@ +import { + MAKERS_DEV_PORT, + PREVIEW_ASSET_PREFIX_ENV, + PREVIEW_PATH_PREFIX, + PREVIEW_SERVER_PORT, +} from '../constants.ts'; +import { prepareMakersSession } from '../makers/session.ts'; +import { + MAKERS_DEV_LAUNCH_TIMEOUT_SECONDS, + buildMakersDevBackgroundCommand, +} from '../makers/cli-dev.ts'; +import { buildMakersDeployCommand } from '../makers/cli-deploy.ts'; +import { + isMakersDeployCommand, + isMakersDevCommand, +} from '../makers/tool-phase.ts'; +import { withCommandOptions } from './command-preprocess.ts'; +import type { MakersCommandLifecycle } from './makers-lifecycle.ts'; + +export type PreparedMakersCommand = { + args: unknown; + kind: 'dev' | 'deploy'; + sandboxToken: string; + gatewayKey: string; +}; + +export async function prepareMakersCommand( + args: unknown, + command: string, + lifecycle: MakersCommandLifecycle, +): Promise { + const makers = await prepareMakersSession(lifecycle.context, lifecycle.state, { + syncEnv: isMakersDeployCommand(command), + }); + + if (isMakersDevCommand(command)) { + return { + args: withCommandOptions( + args, + buildMakersDevBackgroundCommand({ + makersPort: MAKERS_DEV_PORT, + previewPort: PREVIEW_SERVER_PORT, + previewPath: PREVIEW_PATH_PREFIX, + projectName: makers.projectName, + assetPrefixEnvName: PREVIEW_ASSET_PREFIX_ENV, + area: makers.area, + }), + lifecycle.state.appDir, + makers.env, + MAKERS_DEV_LAUNCH_TIMEOUT_SECONDS, + ), + kind: 'dev' as const, + sandboxToken: makers.sandboxToken, + gatewayKey: makers.gatewayKey, + }; + } + + return { + args: withCommandOptions( + args, + buildMakersDeployCommand(makers.projectName, command, { + stopDevPort: MAKERS_DEV_PORT, + area: makers.area, + }), + lifecycle.state.appDir, + makers.env, + 600, + ), + kind: 'deploy' as const, + sandboxToken: makers.sandboxToken, + gatewayKey: makers.gatewayKey, + }; +} diff --git a/agents/_lib/tools/makers-lifecycle.ts b/agents/_lib/tools/makers-lifecycle.ts new file mode 100644 index 0000000..161f1b0 --- /dev/null +++ b/agents/_lib/tools/makers-lifecycle.ts @@ -0,0 +1,21 @@ +import type { AgentContext } from '../runtime/context.ts'; +import type { + DeploymentInfo, + PreviewKind, + ProjectState, + StreamSend, +} from '../types.ts'; + +export type MakersCommandLifecycle = { + context: AgentContext; + state: ProjectState; + conversationId?: string; + send?: StreamSend; + signal?: AbortSignal; + onPreviewReady?: (preview: { + url?: string; + sandboxDebugUrl?: string; + kind?: PreviewKind; + }) => void; + onDeploymentStatus?: (deployment: DeploymentInfo) => void; +}; diff --git a/agents/_lib/tools/makers-skills.ts b/agents/_lib/tools/makers-skills.ts index ebbe50f..87b4514 100644 --- a/agents/_lib/tools/makers-skills.ts +++ b/agents/_lib/tools/makers-skills.ts @@ -2,7 +2,9 @@ import { readdir, readFile } from 'node:fs/promises'; import path from 'node:path'; import { tool as defineClaudeTool } from '@anthropic-ai/claude-agent-sdk'; import { z } from 'zod'; -import type { ClaudeMcpTool } from '../types.ts'; +import { askUserForGatewayCredentials, type GatewayPromptOptions } from '../project/gateway.ts'; +import type { AgentContext } from '../runtime/context.ts'; +import type { ClaudeMcpTool, ProjectState } from '../types.ts'; export const MAKERS_REFERENCE_SKILL_NAMES = [ 'makers-agents', @@ -126,7 +128,10 @@ function formatUnknownReference( ].join('\n'); } -export function buildLoadMakersSkillTool() { +export function buildLoadMakersSkillTool(gateway?: { + context: AgentContext; + state: ProjectState; +} & GatewayPromptOptions) { return defineClaudeTool( 'load_makers_skill', [ @@ -150,6 +155,12 @@ export function buildLoadMakersSkillTool() { try { const skill = makersReferenceSkillSchema.parse(input.skill); const ref = typeof input.ref === 'string' ? input.ref.trim() : ''; + if (skill === 'makers-agents' && gateway) { + await askUserForGatewayCredentials(gateway.context, gateway.state, { + conversationId: gateway.conversationId, + send: gateway.send, + }).catch(() => undefined); + } if (!ref) { const [content, refs] = await Promise.all([ diff --git a/agents/_lib/tools/preview-command-result.ts b/agents/_lib/tools/preview-command-result.ts new file mode 100644 index 0000000..0e0a577 --- /dev/null +++ b/agents/_lib/tools/preview-command-result.ts @@ -0,0 +1,98 @@ +import { + previewFailureWarrantsRestart, + publishRunningPreview, + startPreviewServer, +} from '../project/preview.ts'; +import { describeMissingMakersRuntimeToken } from '../makers/token.ts'; +import { + MAKERS_DEV_PORT_DRIFT_EXIT, + parseMakersDevExitCode, +} from '../makers/cli-dev.ts'; +import { isEdgeoneCliUnavailable } from '../makers/tool-phase.ts'; +import { + appendText, + withMakersCliUnavailableError, + type ToolHandlerResult, +} from './command-text.ts'; +import type { MakersCommandLifecycle } from './makers-lifecycle.ts'; +import type { PreparedMakersCommand } from './makers-command.ts'; + +export async function handleDevCommandResult( + lifecycle: MakersCommandLifecycle, + makers: PreparedMakersCommand, + result: ToolHandlerResult, + makersOutput: string, +): Promise { + + const missingRuntimeToken = describeMissingMakersRuntimeToken(makersOutput); + if (missingRuntimeToken) { + return { + ...appendText(result, JSON.stringify({ + status: 'error', + error: missingRuntimeToken, + })), + isError: true, + }; + } + const devExitCode = parseMakersDevExitCode(makersOutput); + if (devExitCode != null && devExitCode !== 0) { + if (isEdgeoneCliUnavailable(makersOutput)) { + return withMakersCliUnavailableError(result, 'edgeone makers dev'); + } + // The one launch failure that says nothing about the project: the + // port was still held, so the CLI came up healthy somewhere the + // proxy does not look. Launching again is the fix, and the launcher + // now clears that port first, so the second attempt is not a repeat + // of the first. + if (devExitCode === MAKERS_DEV_PORT_DRIFT_EXIT) { + return { + ...appendText(result, JSON.stringify({ + status: 'error', + errorCode: 'MAKERS_DEV_PORT_DRIFT', + retryable: true, + error: 'edgeone makers dev started on a port the preview proxy does not forward to, because the previous dev server still held the expected one.', + instruction: 'Run the same preview command once more. Nothing in the generated project caused this, so do not change project files, and do not kill processes or free ports yourself — the launcher terminates the previous server before this next attempt.', + })), + isError: true, + }; + } + return { + ...appendText(result, JSON.stringify({ + status: 'error', + error: describeMissingMakersRuntimeToken(makersOutput) + || `edgeone makers dev exited with code ${devExitCode}.`, + exitCode: devExitCode, + })), + isError: true, + }; + } + try { + let preview; + try { + preview = await publishRunningPreview(lifecycle.context, lifecycle.state); + } catch (error) { + // The smoke test now retries through the rebuild window itself, so + // reaching here means the server never answered — restart it once. + // A generated agent that answers wrongly is reported as-is instead: + // its reply already proves the server and proxy work. + if (!previewFailureWarrantsRestart(error)) throw error; + await startPreviewServer(lifecycle.context, lifecycle.state); + preview = await publishRunningPreview(lifecycle.context, lifecycle.state, { + routesAlreadyVerified: true, + }); + } + lifecycle.onPreviewReady?.(preview); + return appendText(result, JSON.stringify({ + status: 'success', + preview: { + url: preview.url, + kind: preview.kind, + }, + })); + } catch (error) { + return { + ...appendText(result, error instanceof Error ? error.message : String(error)), + isError: true, + }; + } +} diff --git a/agents/_lib/tools/project-tools.ts b/agents/_lib/tools/project-tools.ts index 9474810..1fff7ca 100644 --- a/agents/_lib/tools/project-tools.ts +++ b/agents/_lib/tools/project-tools.ts @@ -1,13 +1,18 @@ +import { requireSandbox, type AgentContext } from '../runtime/context.ts'; import { tool as defineClaudeTool } from '@anthropic-ai/claude-agent-sdk'; import { z } from 'zod'; -import { ensureProjectScaffold } from '../project/index.ts'; -import { buildNpmWarmupCommand } from '../../../shared/npm-install.ts'; +import { markCreated } from '../project/workspace-store.ts'; +import { buildNpmWarmupCommand } from '../makers/npm-install.ts'; import { ensureMakersAgentDeclarations, ensureMakersFrameworkAdapter, -} from '../project/makers-declarations.ts'; -import type { ScaffoldOutcome } from '../project/scaffold.ts'; -import type { ClaudeMcpTool, ProjectState, ScaffoldLog } from '../types.ts'; +} from '../makers/declarations.ts'; +import { + askUserForGatewayCredentials, + writeSuggestsAiGatewayProject, + type GatewayPromptOptions, +} from '../project/gateway.ts'; +import type { ClaudeMcpTool, ProjectState } from '../types.ts'; import { getBlockedProjectWriteReason, toAppRelPath } from '../utils/paths.ts'; import { stringifyToolResult } from '../utils/text.ts'; @@ -18,127 +23,13 @@ const writeProjectFileInputSchema = { content: z.string().describe('Complete UTF-8 contents for that one file.'), }; -const scaffoldInputSchema = { - framework: z - .string() - .optional() - .describe( - 'The web framework the user asked for, if the request named one — for example "Next.js", "Vite", "Nuxt", "Astro", "SvelteKit". Omit it for a plain HTML/CSS/JS page or when no framework was named. When a baked template exists for it, the workspace comes back already holding that framework\'s project files with the install running, and no scaffolder needs to be run.', - ), -}; - -/** - * What the model is told about the workspace it just asked for. - * - * One function rather than a literal with two conditional spreads in it, - * because both of them wanted to set installHint and the second silently won. - * There is only ever one right answer to "should I install", and the order it - * is decided in here is the order the cases actually rank: a populated - * node_modules settles it whatever else happened, then an install this call - * started, then nothing to say. - */ -export function describeScaffold( - state: ProjectState, - outcome: ScaffoldOutcome, -): Record { - const { created, dependenciesInstalled, template, available } = outcome; - return { - created, - appDir: state.appDir, - dependenciesInstalled, - // The scaffolder step, reported as already done. The model has no listing - // of the workspace, so without this it reaches step 2 of the workflow and - // runs a scaffolder into a directory that is no longer empty. - ...(template - ? { - templateApplied: template.id, - templateFiles: template.files, - scaffolderHint: [ - `The ${template.id} scaffolder has already been run for you and its ${template.files} files are in ${state.appDir}. Do not run a scaffold command. Adapt what is there — the platform declarations and the entry route — rather than rewriting files it already got right. The preview asset-prefix option is already in the framework config; do not set it again.`, - ...(template.id === 'deepagents' || template.id === 'langgraph' - ? ['The chat endpoint is agents/chat.ts. Edit that file; do not create agents/chat/index.ts — both mount POST /chat.'] - : []), - ].join(' '), - ...(template.adapted - ? { - adapterHint: 'This framework\'s platform adapter was added to package.json before the install started, so the dependency is already on its way. Wiring it into the framework config is still yours to do; makers-frameworks says where it goes.', - } - : {}), - } - // The trees that were there and went unused, named so the miss is - // recoverable. A status log is not enough — only this result reaches the - // model, so a gap here reads to it as "there is no template for this" - // rather than "you did not ask for one", and it goes on to write the tree - // by hand beside a baked one. - : available?.length - ? { - templatesAvailable: available, - templatesHint: `No baked template was applied, because the framework argument matched none. These are baked and ready: ${available.join(', ')}. If one of them fits what you are about to build, call ensure_project_scaffold again with that id as framework — the workspace is still empty, so it will be filled from the baked tree, install and all. Prefer that over writing package.json and an entry file by hand. If none fits, carry on and generate the project yourself.`, - } - : {}), - // Said outright, because the listing above cannot show it and the model's - // default reading of a project it did not install is that it needs - // installing. The disk is the reason it must not: the cache npm fills to - // install is about as large as the tree it installs, and only one of them - // fits beside the other here. - ...(dependenciesInstalled - ? { - installHint: 'node_modules is already populated and its executables work. Do not run npm install — the download cache would not fit beside the existing tree, and a failed install leaves the tree unusable. Install only when you add a dependency, and then name it (npm install ).', - } - : template - ? { - installHint: 'The install for this template is already running against this package.json. Run npm install only after you add a package to it.', - } - : {}), - writePathHint: 'write_project_file path is relative to appDir (e.g. package.json, src/App.tsx), never prefix with appDir', - }; -} - -export function buildProjectScaffoldTool( - context: any, - state: ProjectState, - onLog?: (log: ScaffoldLog) => void, - onResult?: (result: { created: boolean }) => void, -) { - return defineClaudeTool( - 'ensure_project_scaffold', - 'Prepare or reuse the project workspace in the EdgeOne sandbox before any project file reads or writes. Always pass framework: the framework the request names, or, when it names none, the kind of app being built (chat, agent, react). Baked templates are matched from it, and a workspace prepared from one arrives with its files and its install already started; omitting it is what leaves the workspace empty.', - scaffoldInputSchema, - async (input) => { - try { - const requested = input as { framework?: unknown }; - const { created, dependenciesInstalled, template } = await ensureProjectScaffold( - context, - state, - onLog, - { framework: typeof requested.framework === 'string' ? requested.framework : undefined }, - ); - state.created = true; - onResult?.({ created }); - return { - content: [{ - type: 'text' as const, - text: stringifyToolResult( - describeScaffold(state, { created, dependenciesInstalled, template }), - ), - }], - }; - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - return { - content: [{ type: 'text' as const, text: message }], - isError: true, - }; - } - }, - ) as ClaudeMcpTool; -} export function buildWriteProjectFileTool( - context: any, + context: AgentContext, state: ProjectState, // The content is handed back so the pipeline can push it straight to the // frontend, which then renders the file without a /file round trip. onResult?: (result: { written: string; content: string }) => void | Promise, + gateway?: GatewayPromptOptions, ) { return defineClaudeTool( 'write_project_file', @@ -163,15 +54,16 @@ export function buildWriteProjectFileTool( const parent = relPath.split('/').slice(0, -1).join('/'); if (parent) { - await context.sandbox.files.makeDir(`${state.appDir}/${parent}`); + await requireSandbox(context).files.makeDir(`${state.appDir}/${parent}`); } - await context.sandbox.files.write(`${state.appDir}/${relPath}`, file.content); + await requireSandbox(context).files.write(`${state.appDir}/${relPath}`, file.content); + markCreated(state); await onResult?.({ written: relPath, content: file.content }); // An agents/ project needs agents.framework and .env.example declared, // and meeting that at the preview gate instead costs the user a failed - // attempt. Values for those keys are collected in a later user turn, - // not written here. Best effort: the lint remains the authority, so a - // failure here costs the old behaviour and nothing more. + // attempt. The host collects values for those keys on its own card. + // Best effort: the lint remains the authority, so a failure here costs + // the old behaviour and nothing more. let adapterAdded = false; const declared = relPath.startsWith('agents/') ? await ensureMakersAgentDeclarations(context, state).catch(() => []) @@ -191,13 +83,19 @@ export function buildWriteProjectFileTool( declared.push(adapter); adapterAdded = true; } - await context.sandbox.commands + await requireSandbox(context).commands .run(buildNpmWarmupCommand(), { cwd: state.appDir }) .catch(() => undefined); } for (const declaration of declared) { await onResult?.({ written: declaration.path, content: declaration.content }); } + if ( + writeSuggestsAiGatewayProject(relPath, file.content) + || declared.some((declaration) => writeSuggestsAiGatewayProject(declaration.path, declaration.content)) + ) { + await askUserForGatewayCredentials(context, state, gateway).catch(() => undefined); + } return { content: [{ type: 'text' as const, @@ -221,4 +119,4 @@ export function buildWriteProjectFileTool( } }, ) as ClaudeMcpTool; -} \ No newline at end of file +} diff --git a/agents/_lib/tools/web-search-wrap.ts b/agents/_lib/tools/web-search-wrap.ts index d60f5d1..6eea99f 100644 --- a/agents/_lib/tools/web-search-wrap.ts +++ b/agents/_lib/tools/web-search-wrap.ts @@ -1,5 +1,5 @@ import type { ClaudeMcpTool } from '../types.ts'; -import { shortenToolName } from '../../../shared/tool-phase.ts'; +import { shortenToolName } from '../makers/tool-phase.ts'; import { WEB_SEARCH_API_KEY_ENV, WEB_SEARCH_TOOL_NAME, diff --git a/agents/_lib/turn/auto-fix.ts b/agents/_lib/turn/auto-fix.ts new file mode 100644 index 0000000..a99fdaa --- /dev/null +++ b/agents/_lib/turn/auto-fix.ts @@ -0,0 +1,61 @@ +import { AUTO_FIX_MAX_ATTEMPTS } from '../constants.ts'; +import { runCodingAgent } from '../session/live.ts'; +import type { + AgentProgressEvent, + BuildResult, + CodingAgentResult, + DeploymentInfo, + PreviewKind, + ProjectState, + StreamSend, +} from '../types.ts'; +import { buildAutoFixPrompt } from '../utils/build-errors.ts'; +import type { AgentContext } from '../runtime/context.ts'; + +export type AutoFixTurnInput = { + context: AgentContext; + conversationId: string; + message: string; + state: ProjectState; + assistantReply: string; + build: BuildResult; + onProgress: (event: AgentProgressEvent) => void; + onProjectFilesChanged: (file?: { path: string; content: string }) => Promise; + onPreviewReady: (preview: { + url?: string; + sandboxDebugUrl?: string; + kind?: PreviewKind; + }) => void; + onDeploymentStatus: (deployment: DeploymentInfo) => void; + abortSignal?: AbortSignal; + model?: string; + send: StreamSend; +}; + +export async function runAutoFixTurn(input: AutoFixTurnInput): Promise<{ + result: CodingAgentResult; + prompt: string; +}> { + const prompt = buildAutoFixPrompt( + input.message, + input.assistantReply, + input.build, + 1, + AUTO_FIX_MAX_ATTEMPTS, + ); + const result = await runCodingAgent({ + context: input.context, + conversationId: input.conversationId, + userMessage: prompt, + state: input.state, + isNewProject: false, + onProgress: input.onProgress, + onProjectFilesChanged: input.onProjectFilesChanged, + onPreviewReady: input.onPreviewReady, + onDeploymentStatus: input.onDeploymentStatus, + abortSignal: input.abortSignal, + model: input.model, + send: input.send, + }); + return { result, prompt }; +} diff --git a/agents/_lib/turn/chat.ts b/agents/_lib/turn/chat.ts new file mode 100644 index 0000000..e950954 --- /dev/null +++ b/agents/_lib/turn/chat.ts @@ -0,0 +1,476 @@ +import type { AgentContext } from '../runtime/context.ts'; +import { AUTO_FIX_MAX_ATTEMPTS } from '../constants.ts'; +import { runCodingAgent } from '../session/live.ts'; +import { runVerification } from '../project/scaffold.ts'; +import { publishRunningPreview, startPreviewServer } from '../project/preview.ts'; +import { + bindSiteDomain, + persistWorkspace, + publishPreview, + setDeployment, + setLastBuild, +} from '../project/workspace-store.ts'; +import { workspaceSnapshotFromState } from '../project/snapshot.ts'; +import type { + AgentProgressEvent, + DeploymentInfo, + FileTreeItem, + StreamSend, +} from '../types.ts'; +import { toAppRelPath } from '../utils/paths.ts'; +import { sanitizeAssistantText } from '../../../shared/timeline.ts'; +import { resolveConversationId, resolveRequestSiteDomain } from '../runtime/request.ts'; +import { + compactUserFacingReply, + createFileTreePushController, + createProjectCheckpointController, + extendExistingSandboxTimeout, + isGenericCompletionReply, + previewLinkFromState, + replyLocaleFor, + resolveFinishedTurn, + STOPPED_TURN_REPLY, + stripReturnedPreviewLinks, + withLiveDeploymentUrl, + buildRequirementConclusionFallback, +} from './checkpoint.ts'; +import { bindLiveWorkspace } from '../session/live-workspace.ts'; +import { createTurnLifecycle } from './lifecycle.ts'; +import { prepareProjectWorkspace } from '../project/workspace.ts'; +import { applyUserGatewayDecision } from '../project/gateway.ts'; +import { resolveGatewayUserTurn } from '../../../shared/gateway-secret.ts'; +import { runAutoFixTurn } from './auto-fix.ts'; +import { sendTurnResult } from './result.ts'; +import type { ChatResponse } from '../../../shared/protocol.ts'; +import type { ReplyLocale } from '../../../shared/user-facing-reply.ts'; + +function slimResult( + conversationId: string, + extra: Omit, +): ChatResponse { + return { conversation_id: conversationId, ...extra }; +} + +export async function runChatPipeline( + context: AgentContext, + message: string, + send: StreamSend, + options: { + turnId?: string; + /** Validated model for this turn; '' or absent runs the configured default. */ + model?: string; + language?: ReplyLocale | string; + /** Real Models API key from the card or a chat sentence; never persisted. */ + apiKey?: string; + gatewaySkip?: boolean; + } = {}, +) { + const { conversationId } = resolveConversationId(context); + const abortSignal = context?.request?.signal as AbortSignal | undefined; + const replyLocale = replyLocaleFor(message, options.language); + + if (!message) { + sendTurnResult(send, slimResult(conversationId, { + ok: false, + reply: 'Please describe the page or feature you want to build first.', + })); + return; + } + + if (!conversationId) { + sendTurnResult(send, slimResult('', { + ok: false, + reply: 'Missing conversationId. The project workspace cannot be prepared.', + })); + return; + } + + await extendExistingSandboxTimeout(context); + + const state = await prepareProjectWorkspace( + context, + conversationId, + send, + ); + if (bindSiteDomain(state, resolveRequestSiteDomain(context))) { + await persistWorkspace(context, conversationId, state); + } + const inboundGateway = resolveGatewayUserTurn(message, options.apiKey); + message = inboundGateway.message; + if (inboundGateway.apiKey || options.gatewaySkip) { + await applyUserGatewayDecision( + context, + state, + conversationId, + { + ...(inboundGateway.apiKey ? { apiKey: inboundGateway.apiKey } : {}), + ...(options.gatewaySkip ? { skip: true } : {}), + }, + send, + ); + } + bindLiveWorkspace(conversationId, state, send); + const activityTurnId = options.turnId + || String(context?.run_id || `${Date.now()}-${Math.random().toString(36).slice(2)}`); + + const checkpoint = createProjectCheckpointController(context, conversationId, state, (persistenceError) => { + console.warn('[checkpoint]', persistenceError); + }); + const turn = createTurnLifecycle({ + context, + conversationId, + message, + turnId: activityTurnId, + state, + checkpoint, + }); + const recordProgress = turn.recordProgress; + const finalizeTurn = turn.finalize; + + const forwardProgress = (event: AgentProgressEvent) => { + if (event.type === 'text_segment') { + const text = state.previewUrl + ? stripReturnedPreviewLinks(event.data?.text || '', state.previewUrl) + : event.data?.text || ''; + if (text.length === 0) { + return; + } + const narration = { ...event, data: { ...event.data, text } }; + recordProgress(narration); + send(narration); + return; + } + if (event.type === 'thinking_segment' && !event.data?.text) { + return; + } + if (event.type === 'system_info' && !event.data?.content && !event.data?.title) { + return; + } + recordProgress(event); + send(event); + }; + const fileTreePush = createFileTreePushController(context, state, send); + let flushedItems: FileTreeItem[] | undefined; + const rememberTree = (items: FileTreeItem[]) => { + if (items.length > 0) flushedItems = items; + }; + const finishResult = (extra: Omit) => { + sendTurnResult( + send, + slimResult(conversationId, extra), + workspaceSnapshotFromState(conversationId, state, flushedItems), + ); + }; + const handleProjectFilesChanged = async (file?: { path: string; content: string }) => { + if (file) { + const path = toAppRelPath(file.path, state.appDir) || file.path; + send({ type: 'file_changed', data: { paths: [path] } }); + } + fileTreePush.schedule(); + checkpoint.schedule(); + }; + + const handlePreviewReady = async (preview: { url?: string; sandboxDebugUrl?: string; kind?: 'sandbox' | 'makers' }) => { + const url = preview.url; + if (!url) { + return; + } + publishPreview(state, { + url, + sandboxDebugUrl: preview.sandboxDebugUrl, + kind: preview.kind, + }); + await persistWorkspace(context, conversationId, state); + send({ + type: 'preview_ready', + data: { + preview: { + url, + sandboxDebugUrl: preview.sandboxDebugUrl, + kind: state.previewKind, + }, + download: { url: '/download', filename: 'source.zip' }, + }, + }); + }; + let hostPreviewInFlight: Promise | null = null; + const startHostPreview = async (reason: string) => { + if (hostPreviewInFlight) return hostPreviewInFlight; + hostPreviewInFlight = (async () => { + try { + await startPreviewServer(context, state); + const preview = await publishRunningPreview(context, state, { routesAlreadyVerified: true }); + await handlePreviewReady(preview); + return Boolean(state.previewUrl); + } catch (error) { + console.warn( + reason, + error instanceof Error ? error.message : error, + ); + return false; + } finally { + hostPreviewInFlight = null; + } + })(); + return hostPreviewInFlight; + }; + const handleDeploymentStatus = (deployment: DeploymentInfo) => { + setDeployment(state, deployment); + void persistWorkspace(context, conversationId, state); + send({ + type: 'deployment_status', + data: deployment, + }); + }; + + if (state.created) { + void startHostPreview('[preview] workspace ready:'); + } + + const modelResult = await runCodingAgent({ + context, + conversationId, + userMessage: message, + state, + isNewProject: !state.created, + onProgress: forwardProgress, + onProjectFilesChanged: handleProjectFilesChanged, + onPreviewReady: handlePreviewReady, + onDeploymentStatus: handleDeploymentStatus, + abortSignal, + model: options.model, + language: options.language, + send, + }); + + if (modelResult.stopped || abortSignal?.aborted) { + const stoppedReply = STOPPED_TURN_REPLY[replyLocale]; + await finalizeTurn(stoppedReply, 'stopped', { + withSnapshot: modelResult.projectTouched, + }); + finishResult({ + ok: false, + stopped: true, + reply: stoppedReply, + }); + return; + } + + const sanitizedModelOutput = modelResult.success && modelResult.output + ? sanitizeAssistantText(modelResult.output) + : ''; + const modelOutput = sanitizedModelOutput && !isGenericCompletionReply(sanitizedModelOutput) + ? sanitizedModelOutput + : ''; + const fallbackReply = modelResult.success + ? buildRequirementConclusionFallback(message, state.previewUrl ? 'ready' : 'pending') + : (modelResult.error || 'An error occurred during processing. Please try again.'); + const rawAssistantReply = stripReturnedPreviewLinks(sanitizeAssistantText( + modelOutput || fallbackReply + ) || fallbackReply, state.previewUrl); + const liveDeploymentUrl = modelResult.deploymentTouched + && state.deployment?.status === 'success' + ? state.deployment.url + : undefined; + const assistantReply = withLiveDeploymentUrl( + modelResult.projectTouched + ? compactUserFacingReply(rawAssistantReply, fallbackReply) + : rawAssistantReply, + liveDeploymentUrl, + ); + + send({ + type: 'agent', + data: { + ok: modelResult.success, + reply: assistantReply, + ...(modelResult.error ? { error: modelResult.error } : {}), + }, + }); + + if (modelResult.fatal) { + await finalizeTurn(assistantReply, 'failed', { + withSnapshot: modelResult.projectTouched, + }); + finishResult({ + ok: false, + reply: assistantReply, + error: modelResult.error || undefined, + }); + return; + } + + if (!modelResult.projectTouched) { + // The model no longer launches preview. A finished project with no URL + // still needs the host to start it — including Q&A turns after a write + // that never set projectTouched, or a previous turn that skipped dest. + if (state.created && !state.previewUrl) { + await checkpoint.flush(); + await startHostPreview('[preview] host start failed:'); + } else if (modelResult.previewTouched && state.previewUrl) { + send({ + type: 'preview_ready', + data: { + preview: previewLinkFromState(state), + }, + }); + } + + const previewReady = !modelResult.previewTouched || Boolean(state.previewUrl); + const deploymentReady = !modelResult.deploymentTouched + || state.deployment?.status === 'success'; + const operationOk = modelResult.success && previewReady && deploymentReady; + await finalizeTurn(assistantReply, operationOk ? 'completed' : 'failed', { + withState: Boolean(state.previewUrl) || modelResult.deploymentTouched, + }); + finishResult({ + ok: operationOk, + reply: assistantReply, + }); + return; + } + + await checkpoint.flush(); + + let previewVerified = Boolean(state.previewUrl); + if (!previewVerified) { + previewVerified = await startHostPreview('[preview] host start failed:'); + } + + rememberTree(await fileTreePush.flush('Failed to read the file list.')); + let build = await runVerification(context, state, { + previewVerified, + }); + let autoFixAttempts = 0; + let autoFixApplied = false; + let autoFixReply = ''; + + if (build.fatal) { + setLastBuild(state, build); + await persistWorkspace(context, conversationId, state); + const fatalReply = build.stderr || 'The task failed, and the remaining workflow was stopped.'; + await finalizeTurn(fatalReply, 'failed', { withSnapshot: true }); + finishResult({ + ok: false, + reply: fatalReply, + }); + return; + } + + if (build.status === 'failed' && modelResult.success) { + autoFixAttempts = AUTO_FIX_MAX_ATTEMPTS; + autoFixApplied = true; + const { result: autoFixResult } = await runAutoFixTurn({ + context, + conversationId, + message, + state, + assistantReply, + build, + onProgress: forwardProgress, + onProjectFilesChanged: handleProjectFilesChanged, + onPreviewReady: handlePreviewReady, + onDeploymentStatus: handleDeploymentStatus, + abortSignal, + model: options.model, + send, + }); + if (autoFixResult.stopped || abortSignal?.aborted) { + const stoppedReply = STOPPED_TURN_REPLY[replyLocale]; + await finalizeTurn(stoppedReply, 'stopped', { withSnapshot: true }); + finishResult({ + ok: false, + stopped: true, + reply: stoppedReply, + }); + return; + } + const rawAutoFixReply = stripReturnedPreviewLinks(sanitizeAssistantText( + autoFixResult.success && autoFixResult.output + ? autoFixResult.output + : autoFixResult.error || '' + ), state.previewUrl); + autoFixReply = autoFixResult.success + ? compactUserFacingReply( + rawAutoFixReply, + buildRequirementConclusionFallback(message, state.previewUrl ? 'ready' : 'generated'), + ) + : rawAutoFixReply; + + if (autoFixReply) { + send({ + type: 'agent', + data: { + ok: autoFixResult.success, + reply: autoFixReply, + ...(autoFixResult.error ? { error: autoFixResult.error } : {}), + }, + }); + } + + rememberTree(await fileTreePush.flush('Failed to read the file list after auto-fix.')); + build = await runVerification(context, state); + if (build.fatal) { + setLastBuild(state, build); + await persistWorkspace(context, conversationId, state); + const fatalReply = build.stderr || 'The task failed, and the remaining workflow was stopped.'; + await finalizeTurn(fatalReply, 'failed', { withSnapshot: true }); + finishResult({ + ok: false, + reply: fatalReply, + }); + return; + } + + if (!previewVerified) { + previewVerified = await startHostPreview('[preview] host start after auto-fix failed:'); + } + } + + build = { + ...build, + ...(autoFixAttempts > 0 ? { autoFixAttempts, autoFixApplied } : {}), + }; + setLastBuild(state, build); + await persistWorkspace(context, conversationId, state); + + if (state.previewUrl) { + send({ + type: 'preview_ready', + data: { + preview: { + ...previewLinkFromState(state), + ...(modelResult.filesWritten ? { restarted: true } : {}), + }, + }, + }); + } + + const isChinese = replyLocale === 'zh'; + const outcome = resolveFinishedTurn({ + filesWritten: modelResult.filesWritten !== false, + previewUrl: state.previewUrl, + buildFailed: build.status === 'failed', + modelReply: stripReturnedPreviewLinks( + autoFixReply || (modelOutput ? assistantReply : ''), + state.previewUrl, + ), + fallbackReply: buildRequirementConclusionFallback( + message, + build.status !== 'failed' && state.previewUrl ? 'ready' : 'generated', + ), + failureReply: build.status === 'failed' + ? (isChinese ? '项目已生成,但检查未通过,我还需要继续修复。' : 'The project was generated, but checks still fail and need another fix.') + : (isChinese ? '项目已生成,但预览暂时不可用,请重试。' : 'The project was generated, but the preview is temporarily unavailable. Please retry.'), + }); + const turnFailed = outcome.failed; + const reply = withLiveDeploymentUrl(outcome.reply, liveDeploymentUrl); + + const turnOk = modelResult.success && !turnFailed; + await finalizeTurn(reply, turnOk ? 'completed' : 'failed', { withSnapshot: true }); + + finishResult({ + ok: turnOk, + reply, + }); +} diff --git a/agents/_lib/pipelines/helpers.ts b/agents/_lib/turn/checkpoint.ts similarity index 87% rename from agents/_lib/pipelines/helpers.ts rename to agents/_lib/turn/checkpoint.ts index 08ab2a4..26d22b9 100644 --- a/agents/_lib/pipelines/helpers.ts +++ b/agents/_lib/turn/checkpoint.ts @@ -1,8 +1,9 @@ -import { getFileTree, runSandboxCommand } from '../project/index.ts'; +import { requireSandbox, type AgentContext } from '../runtime/context.ts'; +import { getFileTree } from '../project/fs.ts'; +import { runSandboxCommand } from '../project/commands.ts'; import type { FileTreeItem, ProjectState, StreamSend } from '../types.ts'; export { compactUserFacingReply, - GATEWAY_CREDENTIALS_USER_REPLY, replyLocaleFor, resolveFinishedTurn, STOPPED_TURN_REPLY, @@ -26,12 +27,12 @@ export function previewLinkFromState(state: ProjectState) { * carries source without node_modules, and both the preview server and the * Makers build need dependencies on disk. */ -export async function ensureProjectDependencies(context: any, state: ProjectState) { - const hasPackageJson = await context.sandbox.files.exists(`${state.appDir}/package.json`); +export async function ensureProjectDependencies(context: AgentContext, state: ProjectState) { + const hasPackageJson = await requireSandbox(context).files.exists(`${state.appDir}/package.json`); if (!hasPackageJson) { return false; } - const hasNodeModules = await context.sandbox.files.exists(`${state.appDir}/node_modules`); + const hasNodeModules = await requireSandbox(context).files.exists(`${state.appDir}/node_modules`); if (hasNodeModules) { return true; } @@ -44,18 +45,6 @@ export async function ensureProjectDependencies(context: any, state: ProjectStat const SANDBOX_EXTENSION_SECONDS = 1800; -// Caps for streaming generated file contents to the frontend (see -// handleProjectFilesChanged). Per-file keeps a single large asset off the stream; -// the per-turn budget bounds how much the replay buffer can hold. -export const FILE_PUSH_MAX_BYTES = 96 * 1024; -export const FILE_PUSH_TURN_BUDGET_BYTES = 2 * 1024 * 1024; - -const utf8Encoder = new TextEncoder(); - -export function utf8ByteLength(value: string) { - return utf8Encoder.encode(value).length; -} - /** Reject if `promise` does not settle within `ms`. Clears the timer on settle. */ export async function withTimeout(promise: Promise, ms: number, label: string): Promise { let timer: ReturnType | undefined; @@ -137,7 +126,7 @@ export function isGenericCompletionReply(text: string) { || /^theagentdidnotreturnanythingdisplayable$/i.test(normalized); } -export async function extendExistingSandboxTimeout(context: any) { +export async function extendExistingSandboxTimeout(context: AgentContext) { const sandbox = context?.sandbox as SandboxWithTimeoutExtension | undefined; if (!sandbox || typeof sandbox.extendTimeout !== 'function') { return; @@ -157,12 +146,12 @@ export async function extendExistingSandboxTimeout(context: any) { // Persist the project through the sandbox SDK. Archive bytes travel directly from // the sandbox to project Blob storage and never enter conversation metadata. export async function persistProjectSnapshot( - context: any, + context: AgentContext, conversationId: string, state: ProjectState, ): Promise { try { - await context.sandbox.persist({ path: state.appDir }); + await requireSandbox(context).persist?.({ path: state.appDir }); return true; } catch (error) { // Losing a snapshot silently means the next resume rebuilds from an older @@ -191,7 +180,7 @@ export type ProjectCheckpointController = { // Mid-turn + exit-path persistence controller. schedule() is cheap and coalesces; // flush() forces a final sandbox-to-Blob write on stop/fatal/success paths. export function createProjectCheckpointController( - context: any, + context: AgentContext, conversationId: string, state: ProjectState, onFailure?: (message: string) => void, @@ -256,7 +245,7 @@ export type FileTreePushController = { // shows the newest listing. Bursts now collapse into a single read, and reads // never overlap. export function createFileTreePushController( - context: any, + context: AgentContext, state: ProjectState, send: StreamSend, ): FileTreePushController { @@ -275,13 +264,10 @@ export function createFileTreePushController( }); return items; } catch (error) { - // Non-fatal: the turn pushes the final tree again when it completes. - send({ - type: 'log', - phase: 'agent', - stream: 'stderr', - message: error instanceof Error ? error.message : fallbackMessage, - }); + // Non-fatal: the turn reuses whatever listing it already has for the + // closing workspace event, and GET /workspace remains a pull fallback. + + console.warn('[file-tree]', error instanceof Error ? error.message : fallbackMessage); return []; } }; diff --git a/agents/_lib/pipelines/deploy.ts b/agents/_lib/turn/deploy.ts similarity index 79% rename from agents/_lib/pipelines/deploy.ts rename to agents/_lib/turn/deploy.ts index ac30fad..b5c39b0 100644 --- a/agents/_lib/pipelines/deploy.ts +++ b/agents/_lib/turn/deploy.ts @@ -1,30 +1,27 @@ +import type { AgentContext } from '../runtime/context.ts'; import { MAKERS_DEV_PORT } from '../constants.ts'; -import { saveProjectState } from '../memory.ts'; -import { getFileTree, runSandboxCommand } from '../project/index.ts'; -import { assertMakersProjectCompatible } from '../project/makers-compat.ts'; +import { getFileTree } from '../project/fs.ts'; +import { runSandboxCommand } from '../project/commands.ts'; +import { startPreviewServer } from '../project/preview.ts'; +import { bindSiteDomain, persistWorkspace, setDeployment } from '../project/workspace-store.ts'; +import { assertMakersProjectCompatible } from '../makers/compat/run.ts'; import { - ensureMakersPublishProject, resolveConversationPublishArea, resolveMakersProjectName, - syncSandboxEnvToMakersProject, -} from '../project/makers-deploy.ts'; -import { startPreviewServer } from '../project/preview.ts'; +} from '../makers/project.ts'; import { applyUserGatewayDecision, askUserForGatewayCredentials, shouldPauseForGatewayCredentials, -} from '../project/gateway-prompt.ts'; +} from '../project/gateway.ts'; import { resolveGatewayUserTurn } from '../../../shared/gateway-secret.ts'; -import { - buildSandboxMakersEnv, - describeMissingMakersRuntimeToken, - prepareSandboxGatewayEnv, - resolveMakersMasterToken, - resolveSandboxMakersToken, -} from '../project/makers-token.ts'; +import { describeMissingMakersRuntimeToken } from '../makers/token.ts'; +import { prepareMakersSession } from '../makers/session.ts'; +import { workspaceSnapshotFromState } from '../project/snapshot.ts'; import type { AgentProgressEvent, DeploymentInfo, + FileTreeItem, StreamSend, } from '../types.ts'; import { @@ -37,21 +34,21 @@ import { parseMakersDeployProgress, readMakersDeployOutcome, redactSecret, -} from '../../../shared/makers-deploy.ts'; -import { resolveConversationId } from '../utils/request.ts'; +} from '../makers/cli-deploy.ts'; +import { resolveConversationId, resolveRequestSiteDomain } from '../runtime/request.ts'; import { createProjectCheckpointController, ensureProjectDependencies, - extendExistingSandboxTimeout, - previewLinkFromState, - withLiveDeploymentUrl, -} from './helpers.ts'; -import { createTurnLifecycle } from './turn-lifecycle.ts'; -import { prepareProjectWorkspace } from './workspace.ts'; + extendExistingSandboxTimeout, + replyLocaleFor, + withLiveDeploymentUrl, +} from './checkpoint.ts'; +import { createTurnLifecycle } from './lifecycle.ts'; +import { prepareProjectWorkspace } from '../project/workspace.ts'; +import { bindLiveWorkspace } from '../session/live-workspace.ts'; +import { sendTurnResult } from './result.ts'; /** Used when an API caller asks to publish without wording the request itself. */ -export const DEFAULT_DEPLOY_REQUEST = 'Deploy this project'; - const DEPLOY_TIMEOUT_SECONDS = 600; /** Only has to start the background script, so it never needs the publish budget. */ @@ -89,14 +86,14 @@ const COPY = { noProject: '还没有可部署的项目,请先生成一个项目。', success: '已发布到线上。', failedPrefix: '部署失败:', - needGateway: '请先在下方填写 Models API Key,填写后我会继续部署。', + needGateway: '请先添加 API 密钥,添加后我会继续部署。', }, en: { missingConversation: 'Missing conversationId, so this project cannot be deployed.', noProject: 'There is no project to deploy yet. Generate one first.', success: 'The project is live.', failedPrefix: 'Deploy failed: ', - needGateway: 'Enter a Models API Key below. I will continue the deploy after that.', + needGateway: 'Add an API key below. I will continue the deploy after that.', }, } as const; @@ -120,7 +117,7 @@ function summarizeDeployError(error: string) { * turn a live site into a reported failure. */ async function publishWithProgress( - context: any, + context: AgentContext, target: { projectName: string; appDir: string; env: Record; area: string }, onTail: (tail: string) => void, ): Promise<{ log: string; timedOut: boolean }> { @@ -177,42 +174,34 @@ async function publishWithProgress( * is what keeps a publish and a generation from touching the sandbox at once. */ export async function runDeployPipeline( - context: any, + context: AgentContext, message: string, send: StreamSend, options: { turnId?: string; - userMessagePersisted?: boolean; - siteDomain?: string; + language?: string; apiKey?: string; gatewaySkip?: boolean; } = {}, ) { const { conversationId } = resolveConversationId(context); - const request = message.trim() || DEFAULT_DEPLOY_REQUEST; - const copy = /[\u3400-\u9fff]/.test(request) ? COPY.zh : COPY.en; + const request = message.trim() || 'Deploy this project'; + const copy = replyLocaleFor(request, options.language) === 'zh' ? COPY.zh : COPY.en; if (!conversationId) { - send({ - type: 'result', - data: { - ok: false, - conversation_id: '', - reply: copy.missingConversation, - preview: {}, - }, + sendTurnResult(send, { + ok: false, + conversation_id: '', + reply: copy.missingConversation, }); return; } await extendExistingSandboxTimeout(context); - send({ type: 'status', message: 'Publishing the project to Makers' }); - const state = await prepareProjectWorkspace(context, conversationId, false, send); - const siteDomain = String(options.siteDomain || '').trim(); - if (siteDomain && state.siteDomain !== siteDomain) { - state.siteDomain = siteDomain; - await saveProjectState(context, conversationId, state); + const state = await prepareProjectWorkspace(context, conversationId, send); + if (bindSiteDomain(state, resolveRequestSiteDomain(context))) { + await persistWorkspace(context, conversationId, state); } const turn = createTurnLifecycle({ context, @@ -220,7 +209,6 @@ export async function runDeployPipeline( message: request, turnId: options.turnId || String(context?.run_id || `${Date.now()}-${Math.random().toString(36).slice(2)}`), - userMessagePersisted: options.userMessagePersisted === true, state, // Publishing writes no project files, so nothing here ever needs a snapshot. checkpoint: createProjectCheckpointController(context, conversationId, state), @@ -228,27 +216,27 @@ export async function runDeployPipeline( // No `build` field: publishing runs no verification, and reporting one would // clear whatever the last generation said about the project. + let files: FileTreeItem[] = []; const finish = async (reply: string, status: 'completed' | 'failed') => { await turn.finalize(reply, status); - send({ - type: 'result', - data: { - ok: status === 'completed', - reply, - conversation_id: conversationId, - ...(state.gatewayPromptPending ? { gatewayNeeded: true } : {}), - preview: previewLinkFromState(state), - deployment: state.deployment, - }, - }); + sendTurnResult(send, { + ok: status === 'completed', + reply, + conversation_id: conversationId, + }, workspaceSnapshotFromState( + conversationId, + state, + files.length > 0 ? files : undefined, + )); }; - const files = await getFileTree(context, state).catch(() => []); + files = await getFileTree(context, state).catch(() => []); if (!files.some((item) => item.type === 'file')) { await finish(copy.noProject, 'failed'); return; } + bindLiveWorkspace(conversationId, state, send); const inboundGateway = resolveGatewayUserTurn(request, options.apiKey); if (inboundGateway.apiKey || options.gatewaySkip) { await applyUserGatewayDecision( @@ -273,13 +261,11 @@ export async function runDeployPipeline( const toolUseId = `deploy-${startedAt}`; const emit = (event: AgentProgressEvent) => { turn.recordProgress(event); - send(event as unknown as Record); + send(event); }; const publish = (deployment: DeploymentInfo) => { - state.deployment = deployment; - // Eagerly persisted so a refresh mid-publish resumes into the same state - // the deployment bar was showing. - void saveProjectState(context, conversationId, state); + setDeployment(state, deployment); + void persistWorkspace(context, conversationId, state); send({ type: 'deployment_status', data: deployment }); }; // `detail` is whatever the CLI printed when it failed without phrasing the @@ -296,7 +282,7 @@ export async function runDeployPipeline( emit({ type: 'tool_result', data: { - tool_use_id: toolUseId, + id: toolUseId, toolName: 'commands', ok: false, preview: '', @@ -326,30 +312,10 @@ export async function runDeployPipeline( try { await assertMakersProjectCompatible(context, state); await ensureProjectDependencies(context, state); - const masterToken = resolveMakersMasterToken(context); - sandboxToken = await resolveSandboxMakersToken( - state, - masterToken, - ); - await ensureMakersPublishProject( - sandboxToken, - resolveMakersProjectName(context, state), - resolveConversationPublishArea(state), - state.makersApiRegion, - ); - await syncSandboxEnvToMakersProject( - context, - state, - masterToken, - resolveMakersProjectName(context, state), - state.makersApiRegion, - ); - const gateway = await prepareSandboxGatewayEnv(context, state); - sandboxEnv = buildSandboxMakersEnv( - sandboxToken, - state.makersApiRegion, - ); - gatewayKey = gateway.AI_GATEWAY_API_KEY || ''; + const makers = await prepareMakersSession(context, state, { syncEnv: true }); + sandboxToken = makers.sandboxToken; + sandboxEnv = makers.env; + gatewayKey = makers.gatewayKey; } catch (error) { await fail(error instanceof Error ? error.message : String(error)); return; @@ -441,7 +407,7 @@ export async function runDeployPipeline( emit({ type: 'tool_result', data: { - tool_use_id: toolUseId, + id: toolUseId, toolName: 'commands', ok: true, preview: '', diff --git a/agents/_lib/turn/lifecycle.ts b/agents/_lib/turn/lifecycle.ts new file mode 100644 index 0000000..202d338 --- /dev/null +++ b/agents/_lib/turn/lifecycle.ts @@ -0,0 +1,58 @@ +import type { AgentContext } from '../runtime/context.ts'; +import { persistWorkspace } from '../project/workspace-store.ts'; +import type { AgentProgressEvent, ProjectState } from '../types.ts'; +import type { ProjectCheckpointController } from './checkpoint.ts'; +import { applyStreamEvent, sealOpenThinking } from '../../../shared/timeline.ts'; +import type { PersistedActivityTurn } from '../../../shared/protocol.ts'; + +type TurnStatus = 'completed' | 'failed' | 'stopped'; + +type TurnLifecycleOptions = { + context: AgentContext; + conversationId: string; + message: string; + turnId: string; + state: ProjectState; + checkpoint: ProjectCheckpointController; +}; + +/** Owns in-memory progress folding and the durable commit order for one turn. */ +export function createTurnLifecycle(options: TurnLifecycleOptions) { + let turn: PersistedActivityTurn = { + id: options.turnId, + user: options.message, + assistant: '', + status: 'completed', + createdAt: Date.now(), + activities: [], + }; + + const recordProgress = (event: AgentProgressEvent) => { + turn = applyStreamEvent(turn, event); + }; + + const finalize = async ( + assistant: string, + status: TurnStatus, + finalizeOptions?: { withSnapshot?: boolean; withState?: boolean }, + ) => { + turn = { ...turn, assistant, status, activities: sealOpenThinking(turn.activities) }; + if (status === 'stopped') { + turn = { + ...turn, + activities: turn.activities.map((activity) => ( + activity.kind === 'tool' && activity.status === 'running' + ? { ...activity, status: 'stopped' as const, endedAt: Date.now() } + : activity + )), + }; + } + + if (finalizeOptions?.withSnapshot === true) await options.checkpoint.flush(); + if (finalizeOptions?.withState !== false) { + await persistWorkspace(options.context, options.conversationId, options.state); + } + }; + + return { recordProgress, finalize }; +} diff --git a/agents/_lib/turn/result.ts b/agents/_lib/turn/result.ts new file mode 100644 index 0000000..f62410f --- /dev/null +++ b/agents/_lib/turn/result.ts @@ -0,0 +1,13 @@ +import type { ChatResponse, WorkspaceSnapshot } from '../../../shared/protocol.ts'; +import type { StreamSend } from '../types.ts'; + +export function sendTurnResult( + send: StreamSend, + data: ChatResponse, + snapshot?: WorkspaceSnapshot, +) { + if (snapshot) { + send({ type: 'workspace', data: snapshot }); + } + send({ type: 'result', data }); +} diff --git a/agents/_lib/types.ts b/agents/_lib/types.ts index c55b877..f7103f9 100644 --- a/agents/_lib/types.ts +++ b/agents/_lib/types.ts @@ -1,16 +1,20 @@ import type { SdkMcpToolDefinition } from '@anthropic-ai/claude-agent-sdk'; import type { - ActivityStatus, + BuildInfo, BuildStatus, + ChatStreamEvent, DeploymentInfo, PreviewKind, } from '../../shared/protocol.ts'; export type { ActivityStatus, + AssistantActivity as PersistedActivity, + BuildInfo, BuildStatus, DeploymentInfo, FileTreeItem, + PersistedActivityTurn, PreviewKind, } from '../../shared/protocol.ts'; @@ -31,28 +35,14 @@ export type ProjectState = { previewKind?: PreviewKind; /** Latest live deployment, kept separate from the sandbox preview iframe. */ deployment?: DeploymentInfo; + /** Last verification result; streamed on `workspace` and also on GET /workspace. */ + lastBuild?: BuildInfo; /** The host is waiting for a Models API key in the next user turn. */ gatewayPromptPending?: boolean; /** The user skipped the Models API key for this conversation. */ gatewaySkipped?: boolean; }; -// A base64 archive of the whole project, persisted outside the volatile sandbox so -// the code survives sandbox recycling (see agents/_lib/memory.ts snapshot helpers). The -// fields mirror createProjectArchive's success result plus a write timestamp. -export type LegacyProjectSnapshot = { - base64: string; - filename: string; - contentType: string; - size: number; - updatedAt: number; -}; - -export type ConversationMessage = { - role: 'user' | 'assistant'; - content: string; -}; - export type ChatTaskStatus = 'queued' | 'running' | 'completed' | 'failed' | 'stopped'; /** @@ -60,52 +50,24 @@ export type ChatTaskStatus = 'queued' | 'running' | 'completed' | 'failed' | 'st * command, so 'deploy' skips the model entirely — but it still occupies the * same slot, so it cannot race a generation over the same sandbox. */ -export type ChatTaskIntent = 'chat' | 'deploy'; +export type ChatTaskKind = 'prompt' | 'deploy'; export type ChatTask = { id: string; message: string; - /** Absent on tasks persisted before deploy became a task of its own. */ - intent?: ChatTaskIntent; + kind?: ChatTaskKind; /** Public site root from the browser; picks overseas vs global acceleration. */ siteDomain?: string; /** Model this turn runs on. Absent means the deployment's configured default. */ model?: string; - resetProject: boolean; status: ChatTaskStatus; createdAt: number; startedAt?: number; finishedAt?: number; - finalEvent?: Record; error?: string; }; -export type PersistedActivity = - | { - kind: 'text'; - content: string; - } - | { - kind: 'tool'; - toolUseId: string; - name: string; - status: ActivityStatus; - inputSummary?: string; - outputSummary?: string; - startedAt: number; - endedAt?: number; - }; - -export type PersistedActivityTurn = { - id: string; - user: string; - assistant: string; - status: 'completed' | 'failed' | 'stopped'; - createdAt: number; - activities: PersistedActivity[]; -}; - -export type StreamSend = (event: Record) => void; +export type StreamSend = (event: ChatStreamEvent) => void; export type ScaffoldLog = { stream: 'status' | 'stdout' | 'stderr'; @@ -119,9 +81,7 @@ export type CodingAgentResult = { projectTouched: boolean; /** * Whether this turn wrote a project file, as opposed to merely reaching the - * project. Scaffolding sets projectTouched and the workflow asks for it on - * every turn, so that flag cannot tell a build apart from a turn that only - * answered a question — and answering one is not a build that failed. + * project. Answering a question is not a build that failed. */ filesWritten?: boolean; previewTouched?: boolean; @@ -140,42 +100,9 @@ export type BuildResult = { fatal?: boolean; }; -// Progress events streamed to the frontend. tool_use is the model's tool request, -// and tool_result is the tool response. The assistant message renders these live. -export type AgentProgressEvent = - | { - type: 'tool_use'; - data: { - id: string; - name: string; - command?: string; - phaseHint?: 'scaffold' | 'code' | 'install' | 'preview' | 'link'; - fileCount?: number; - inputSummary?: string; - /** Output from a call still in flight; see the note in protocol.ts. */ - outputSummary?: string; - startedAt?: number; - }; - } - | { - type: 'tool_result'; - data: { - tool_use_id: string; - toolName?: string; - command?: string; - ok: boolean; - preview: string; - outputSummary?: string; - status?: ActivityStatus; - endedAt?: number; - }; - } - | { - type: 'text_segment'; - data: { - uuid: string; - text: string; - }; - }; +export type AgentProgressEvent = Extract< + ChatStreamEvent, + { type: 'tool_use' | 'tool_result' | 'text_segment' | 'thinking_segment' | 'system_info' } +>; export type ClaudeMcpTool = SdkMcpToolDefinition; diff --git a/agents/_lib/utils/activity.ts b/agents/_lib/utils/activity.ts deleted file mode 100644 index f8fff20..0000000 --- a/agents/_lib/utils/activity.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { PersistedActivityTurn } from '../types.ts'; - -const SUMMARY_LIMIT = 2_000; -const SENSITIVE_KEY = /(authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key|credential)/i; - -function truncate(value: string, limit = SUMMARY_LIMIT) { - const normalized = value.replace(/\x1b\[[0-9;?]*[~A-Za-z]/g, '').trim(); - return normalized.length > limit ? `${normalized.slice(0, limit)}\n... truncated` : normalized; -} - -function redactInlineSecrets(value: string) { - return value - .replace(/(authorization\s*:\s*)(?:bearer\s+)?[^"'\s]+(?:\s+[^"'\s]+)?/gi, '$1[REDACTED]') - .replace(/((?:authorization|cookie|password|passwd|secret|token|api[_-]?key|private[_-]?key)\s*[:=]\s*)([^\s,;]+)/gi, '$1[REDACTED]') - .replace(/(bearer\s+)[A-Za-z0-9._~+\/-]+/gi, '$1[REDACTED]'); -} - -function safeValue(value: unknown, projectDir: string, depth = 0): unknown { - if (depth > 4) return '[nested value omitted]'; - if (typeof value === 'string') { - const withoutProjectPath = projectDir ? value.split(projectDir).join('') : value; - return truncate(redactInlineSecrets(withoutProjectPath), 600); - } - if (typeof value === 'number' || typeof value === 'boolean' || value == null) return value; - if (Array.isArray(value)) return value.slice(0, 20).map((item) => safeValue(item, projectDir, depth + 1)); - if (typeof value === 'object') { - return Object.fromEntries( - Object.entries(value as Record) - .slice(0, 30) - .map(([key, child]) => [ - key, - SENSITIVE_KEY.test(key) ? '[REDACTED]' : safeValue(child, projectDir, depth + 1), - ]), - ); - } - return String(value); -} - -function summarizeFileWrites(input: Record) { - const files = Array.isArray(input.files) ? input.files : []; - if (files.length === 0) return ''; - return files.slice(0, 30).map((file) => { - const record = file && typeof file === 'object' ? file as Record : {}; - const path = typeof record.path === 'string' ? record.path : ''; - const length = typeof record.content === 'string' ? record.content.length : 0; - return `${path} (${length.toLocaleString('en-US')} chars)`; - }).join('\n'); -} - -export function summarizeToolInput(name: string, input: unknown, projectDir = '') { - const record = input && typeof input === 'object' ? input as Record : {}; - const shortName = name.replace(/^mcp__[^_]+__/, ''); - - if (shortName === 'Skill' || shortName === 'load_makers_skill') { - const skill = typeof record.skill === 'string' ? record.skill : ''; - const ref = typeof record.ref === 'string' ? record.ref.trim() : ''; - // A deeper document is a second load of a reference already on screen, so - // without the ref the two rows are indistinguishable and the timeline looks - // like it is repeating itself. Kept plain when there is no ref, which is - // every row persisted before this and every load of an overview. - return truncate(ref ? JSON.stringify({ skill, ref }) : skill, 200); - } - if (shortName === 'write_project_files') { - return truncate(summarizeFileWrites(record) || 'Project files'); - } - if (shortName === 'write_project_file' || shortName === 'files_write' || shortName === 'write_files') { - if (typeof record.path !== 'string' && typeof record.content !== 'string') return ''; - const path = typeof record.path === 'string' ? record.path : ''; - const length = typeof record.content === 'string' ? record.content.length : 0; - return `${path} (${length.toLocaleString('en-US')} chars)`; - } - if (shortName === 'commands') { - const command = typeof record.command === 'string' - ? record.command - : typeof record.cmd === 'string' - ? record.cmd - : ''; - return truncate(redactInlineSecrets(projectDir ? command.split(projectDir).join('') : command)); - } - if ( - shortName === 'files_make_dir' - || shortName === 'files_remove' - || shortName === 'files_exists' - || shortName === 'files_read' - || shortName === 'files_list' - ) { - const path = typeof record.path === 'string' - ? record.path - : typeof record.file_path === 'string' - ? record.file_path - : ''; - return path ? truncate(projectDir ? path.split(projectDir).join('') : path) : ''; - } - - return truncate(JSON.stringify(safeValue(record, projectDir), null, 2)); -} - -export function summarizeToolOutput(value: string, projectDir = '', name = '') { - // The SDK answers a successful Skill call with "Launching skill: ", - // which only repeats the row header. Keep real failures. - if (name.replace(/^mcp__[^_]+__/, '') === 'Skill' && /^launching skill:/i.test(value.trim())) { - return ''; - } - if ( - name.replace(/^mcp__[^_]+__/, '') === 'load_makers_skill' - && /^---\s*\nname:/i.test(value.trim()) - ) { - return ''; - } - const withoutProjectPath = projectDir ? value.split(projectDir).join('') : value; - return truncate(redactInlineSecrets(withoutProjectPath)); -} - -export function appendTrimmedActivityTurn( - current: PersistedActivityTurn[], - turn: PersistedActivityTurn, - turnLimit = 25, - itemLimit = 50, -) { - const nextTurn = { ...turn, activities: turn.activities.slice(-itemLimit) }; - return [...current.filter((item) => item.id !== turn.id), nextTurn].slice(-turnLimit); -} - -export function dedupeActivityTurns(turns: PersistedActivityTurn[]) { - const result: PersistedActivityTurn[] = []; - for (const turn of turns) { - const previous = result.at(-1); - const isRetryDuplicate = previous - && previous.user === turn.user - && previous.assistant === turn.assistant - && previous.status === turn.status - && Math.abs(previous.createdAt - turn.createdAt) < 30_000; - if (!isRetryDuplicate) { - result.push(turn); - continue; - } - if (turn.activities.length >= previous.activities.length) { - result[result.length - 1] = turn; - } - } - return result; -} diff --git a/agents/_lib/utils/narration.ts b/agents/_lib/utils/narration.ts deleted file mode 100644 index d741d9a..0000000 --- a/agents/_lib/utils/narration.ts +++ /dev/null @@ -1,119 +0,0 @@ -export function sanitizeNarrationText(input: string) { - if (!input) return ''; - return input - .replace(/\x1b\[[0-9;?]*[~A-Za-z]/g, '') - .replace(/\[20[01]~/g, '') - .replace(/\x1b\][^\x07]*\x07/g, '') - .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, '') - .replace(/]*>/gi, '') - .replace(/<\/think>/gi, '') - .replace(/\n{4,}/g, '\n\n\n'); -} - -/** - * Shortest accumulated block that a repeated delta can be measured against. - * Below it, "the delta repeats everything so far" is a coincidence between two - * short fragments rather than evidence of a re-send. - */ -const MIN_RESEND_PREFIX = 8; - -export type NarrationEmitState = { - /** Text already streamed for the current assistant text block. */ - currentTextBlock: string; - /** All narration emitted for the whole agent turn. */ - emittedNarration: string; -}; - -/** - * Resolve the next narration chunk to emit. - * - * Stream deltas are incremental; complete assistant snapshots may repeat the - * already-streamed prefix. Only the missing suffix should be forwarded, and - * dedupe is scoped to the current text block so earlier phrases like - * "简洁好用的 Todolist" cannot swallow a later "用的 Todolist". - */ -export function resolveNarrationEmit( - state: NarrationEmitState, - rawText: string, - complete = false, -): { state: NarrationEmitState; text: string | null } { - const text = sanitizeNarrationText(rawText); - if (!text) { - return { state, text: null }; - } - - if (complete) { - const trimmed = text.trim(); - if (!trimmed) { - return { state, text: null }; - } - - const streamed = state.currentTextBlock; - const streamedTrimmed = streamed.trimEnd(); - - if (streamed.includes(trimmed) || streamedTrimmed === trimmed) { - return { state, text: null }; - } - - let nextChunk = trimmed; - if (streamed && trimmed.startsWith(streamed)) { - nextChunk = trimmed.slice(streamed.length); - } else if (streamedTrimmed && trimmed.startsWith(streamedTrimmed)) { - nextChunk = trimmed.slice(streamedTrimmed.length); - } else if (streamed) { - // Stream and snapshot diverged — keep the streamed text as source of truth. - return { state, text: null }; - } else { - // Empty block window (e.g. after a tool call cleared it). Skip only when this - // exact snapshot was already emitted as the trailing narration — use endsWith - // so earlier phrases like "简洁好用的 …" cannot swallow "用的 …". - const emittedTrimmed = state.emittedNarration.trimEnd(); - if (emittedTrimmed.endsWith(trimmed)) { - return { state, text: null }; - } - nextChunk = trimmed; - } - - nextChunk = sanitizeNarrationText(nextChunk); - if (!nextChunk.trim()) { - return { state, text: null }; - } - - const currentTextBlock = sanitizeNarrationText(`${streamed}${nextChunk}`); - const emittedNarration = sanitizeNarrationText(`${state.emittedNarration}${nextChunk}`); - return { - state: { currentTextBlock, emittedNarration }, - text: nextChunk, - }; - } - - // Incremental delta. Some providers re-send the whole block in place of the - // new fragment, which is only safely recognisable as an exact prefix of a - // block long enough that a genuine fragment could not repeat it by accident. - // Nothing here may compare against the tail: deltas are token-sized, so a - // chunk like "a" landing after an "a" is ordinary text, and dropping it - // quietly corrupts whatever it belonged to — a URL loses a character and - // still looks like a URL. - if ( - state.currentTextBlock.length >= MIN_RESEND_PREFIX - && text.startsWith(state.currentTextBlock) - ) { - const remainder = text.slice(state.currentTextBlock.length); - if (!remainder) { - return { state, text: null }; - } - const currentTextBlock = sanitizeNarrationText(`${state.currentTextBlock}${remainder}`); - const emittedNarration = sanitizeNarrationText(`${state.emittedNarration}${remainder}`); - return { - state: { currentTextBlock, emittedNarration }, - text: remainder, - }; - } - - const currentTextBlock = sanitizeNarrationText(`${state.currentTextBlock}${text}`); - const emittedNarration = sanitizeNarrationText(`${state.emittedNarration}${text}`); - return { - state: { currentTextBlock, emittedNarration }, - text, - }; -} diff --git a/shared/shell.ts b/agents/_lib/utils/shell.ts similarity index 100% rename from shared/shell.ts rename to agents/_lib/utils/shell.ts diff --git a/agents/_lib/utils/text.ts b/agents/_lib/utils/text.ts index 83cf520..88de2b0 100644 --- a/agents/_lib/utils/text.ts +++ b/agents/_lib/utils/text.ts @@ -1,4 +1,4 @@ -export { sanitizeAssistantText } from '../../../shared/sanitize-assistant-text.ts'; +export { sanitizeAssistantText } from '../../../shared/timeline.ts'; export function stringifyToolResult(result: unknown) { if (typeof result === 'string') { diff --git a/agents/_lib/utils/timing.ts b/agents/_lib/utils/timing.ts new file mode 100644 index 0000000..61bf7ed --- /dev/null +++ b/agents/_lib/utils/timing.ts @@ -0,0 +1,24 @@ +/** + * Wake latency is otherwise unmeasurable from production: the only other + * signal is the frontend progress bar, whose stage durations are hardcoded. + */ +export async function timeStage( + scope: string, + details: Record, + run: () => Promise, +): Promise { + const startedAt = Date.now(); + let failed = false; + try { + return await run(); + } catch (error) { + failed = true; + throw error; + } finally { + console.info(`[${scope}]`, { + ...details, + ms: Date.now() - startedAt, + ...(failed ? { failed: true } : {}), + }); + } +} diff --git a/agents/_lib/utils/tool-phase.ts b/agents/_lib/utils/tool-phase.ts deleted file mode 100644 index b974d08..0000000 --- a/agents/_lib/utils/tool-phase.ts +++ /dev/null @@ -1,20 +0,0 @@ -export { - MAKERS_CLI_UNAVAILABLE_ERROR_CODE, - MAKERS_CLI_UNAVAILABLE_MESSAGE, - buildEdgeoneVersionCheckCommand, - forbiddenSandboxCommandReason, - isEdgeoneVersionCommand, - isEdgeoneCliUnavailable, - isBareInstallCommand, - isInstallCommand, - isScaffolderCommand, - isMakersDeployCommand, - isMakersDevCommand, - isPreviewCommand, - isVerificationCommand, - parseEchoedExitCode, - parseEdgeoneVersionExitCode, - shortenToolName, - stripEchoedExit, - withExitCodeEcho, -} from '../../../shared/tool-phase.ts'; diff --git a/agents/deploy.ts b/agents/deploy.ts new file mode 100644 index 0000000..bf03550 --- /dev/null +++ b/agents/deploy.ts @@ -0,0 +1,26 @@ +import type { AgentContext } from './_lib/runtime/context.ts'; +import { createChatTaskAndStreamResponse } from './_lib/session/task.ts'; +import { getRequestBody } from './_lib/runtime/request.ts'; + +/** Publish the current project. Deterministic — does not call the model. */ +export async function onRequestPost(context: AgentContext) { + const body = getRequestBody(context); + try { + const apiKey = String(body.apiKey || '').trim(); + return await createChatTaskAndStreamResponse(context, String(body.message || '').trim(), { + kind: 'deploy', + turnId: String(body.turnId || '').trim() || undefined, + language: String(body.language || '').trim() || undefined, + ...(apiKey ? { apiKey } : {}), + ...(body.gatewaySkip === true ? { gatewaySkip: true } : {}), + }); + } catch (error) { + return new Response(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : 'Failed to start the deploy task.', + }), { + status: 500, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, + }); + } +} diff --git a/agents/download.ts b/agents/download.ts index d2ff360..0177005 100644 --- a/agents/download.ts +++ b/agents/download.ts @@ -1,5 +1,6 @@ -import { runProjectDownloadPipeline } from './_lib/pipelines/index.ts'; +import type { AgentContext } from './_lib/runtime/context.ts'; +import { runProjectDownloadPipeline } from './_lib/project/download.ts'; -export async function onRequest(context: any) { +export async function onRequest(context: AgentContext) { return runProjectDownloadPipeline(context); } diff --git a/agents/file.ts b/agents/file.ts index 0a2731e..f6c0378 100644 --- a/agents/file.ts +++ b/agents/file.ts @@ -1,5 +1,6 @@ -import { runFileReadPipeline } from './_lib/pipelines/index.ts'; +import type { AgentContext } from './_lib/runtime/context.ts'; +import { runFileReadPipeline } from './_lib/project/read.ts'; -export async function onRequest(context: any) { +export async function onRequest(context: AgentContext) { return runFileReadPipeline(context); } diff --git a/agents/preview.ts b/agents/preview.ts index def209e..16cb685 100644 --- a/agents/preview.ts +++ b/agents/preview.ts @@ -1,6 +1,13 @@ -import { runProjectResumePreviewPipeline } from './_lib/pipelines/index.ts'; +import type { AgentContext } from './_lib/runtime/context.ts'; +import { runProjectResumePreviewPipeline } from './_lib/session/resume.ts'; +import { runPreviewStatusPipeline } from './_lib/project/snapshot.ts'; + +/** Current preview URL without restarting the server. */ +export async function onRequestGet(context: AgentContext) { + return runPreviewStatusPipeline(context); +} /** Re-mint the public preview URL without restoring the full workspace. */ -export async function onRequestPost(context: any) { +export async function onRequestPost(context: AgentContext) { return runProjectResumePreviewPipeline(context); } diff --git a/agents/prompt.ts b/agents/prompt.ts new file mode 100644 index 0000000..9c9dc8e --- /dev/null +++ b/agents/prompt.ts @@ -0,0 +1,46 @@ +import type { AgentContext } from './_lib/runtime/context.ts'; +import { applyGatewayDecisionAndRespond } from './_lib/session/gateway-apply.ts'; +import { createChatTaskAndStreamResponse } from './_lib/session/task.ts'; +import { resolveRequestedModel } from './_lib/models.ts'; +import { getRequestBody } from './_lib/runtime/request.ts'; + +/** Submit a user message. Generation streams back as SSE. */ +export async function onRequestPost(context: AgentContext) { + const body = getRequestBody(context); + const message = String(body.message || '').trim(); + const apiKey = String(body.apiKey || '').trim(); + const gatewaySkip = body.gatewaySkip === true; + if (!message && (apiKey || gatewaySkip)) { + return applyGatewayDecisionAndRespond(context, { + ...(apiKey ? { apiKey } : {}), + ...(gatewaySkip ? { skip: true } : {}), + }); + } + if (!message) { + return new Response(JSON.stringify({ + ok: false, + error: 'Please describe the page or feature you want to build first.', + }), { + status: 400, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, + }); + } + + try { + return await createChatTaskAndStreamResponse(context, message, { + kind: 'prompt', + turnId: String(body.turnId || '').trim() || undefined, + model: resolveRequestedModel(context, body.model), + language: String(body.language || '').trim() || undefined, + ...(apiKey ? { apiKey } : {}), + }); + } catch (error) { + return new Response(JSON.stringify({ + ok: false, + error: error instanceof Error ? error.message : 'Failed to start the chat task.', + }), { + status: 500, + headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, + }); + } +} diff --git a/agents/session.ts b/agents/session.ts index 3f78eff..fdf49ef 100644 --- a/agents/session.ts +++ b/agents/session.ts @@ -1,50 +1,7 @@ -import { createChatTaskAndStreamResponse } from './_lib/chat-tasks.ts'; -import { createProjectResumeStreamResponse, DEFAULT_DEPLOY_REQUEST } from './_lib/pipelines/index.ts'; -import { resolveRequestedModel } from './_lib/models.ts'; +import type { AgentContext } from './_lib/runtime/context.ts'; +import { createProjectResumeStreamResponse } from './_lib/session/resume.ts'; /** Session entry: history, workspace, and an in-flight task's SSE on one GET. */ -export async function onRequestGet(context: any) { +export async function onRequestGet(context: AgentContext) { return createProjectResumeStreamResponse(context); } - -/** Submit a turn. Only called when the user sends text (or publish). */ -export async function onRequestPost(context: any) { - const body = context?.request?.body || {}; - // Publishing occupies the same task slot as a generation. Reconnect after - // refresh goes through GET /session, not this method. - const intent = body?.intent === 'deploy' ? 'deploy' as const : 'chat' as const; - const message = String(body?.message || '').trim() - || (intent === 'deploy' ? DEFAULT_DEPLOY_REQUEST : ''); - if (!message) { - return new Response(JSON.stringify({ - ok: false, - error: 'Please describe the page or feature you want to build first.', - }), { - status: 400, - headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, - }); - } - - try { - const apiKey = String(body?.apiKey || '').trim(); - return await createChatTaskAndStreamResponse(context, message, { - intent, - resetProject: body?.resetProject === true, - turnId: String(body?.turnId || '').trim() || undefined, - // Anything this deployment does not offer resolves to '', so a client - // cannot name an arbitrary model and have it billed through the gateway. - model: resolveRequestedModel(context, body?.model), - siteDomain: String(body?.siteDomain || '').trim() || undefined, - ...(apiKey ? { apiKey } : {}), - ...(body?.gatewaySkip === true ? { gatewaySkip: true } : {}), - }); - } catch (error) { - return new Response(JSON.stringify({ - ok: false, - error: error instanceof Error ? error.message : 'Failed to start the chat task.', - }), { - status: 500, - headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, - }); - } -} diff --git a/agents/stop.ts b/agents/stop.ts index 129c0bc..295e51d 100644 --- a/agents/stop.ts +++ b/agents/stop.ts @@ -1,11 +1,13 @@ -import { abortLiveChatTask, markChatTaskStopped } from './_lib/chat-tasks.ts'; -import { getProjectState, saveActivityTurn, saveProjectState } from './_lib/memory.ts'; -import { persistProjectSnapshot } from './_lib/pipelines/helpers.ts'; -import type { PersistedActivity } from './_lib/types.ts'; -import { replyLocaleFor, STOPPED_TURN_REPLY } from '../shared/user-facing-reply.ts'; +import type { AgentContext } from './_lib/runtime/context.ts'; +import { abortLiveChatTask, markChatTaskStopped } from './_lib/session/task.ts'; +import { getProjectState } from './_lib/session/store.ts'; +import { persistProjectSnapshot } from './_lib/turn/checkpoint.ts'; +import { markCreated, persistWorkspace } from './_lib/project/workspace-store.ts'; +import { getRequestBody } from './_lib/runtime/request.ts'; -export async function onRequest(context: any) { - const conversationId = String(context?.request?.body?.conversation_id || '').trim(); +export async function onRequest(context: AgentContext) { + const body = getRequestBody(context); + const conversationId = String(body.conversation_id || '').trim(); if (!conversationId) { return new Response(JSON.stringify({ ok: false, error: 'missing conversation_id' }), { status: 400, @@ -14,19 +16,10 @@ export async function onRequest(context: any) { } try { - const discardProject = context?.request?.body?.discardProject === true; - // Stop the detached in-process run first (SSE disconnect no longer aborts it). + const discardProject = body.discardProject === true; abortLiveChatTask(conversationId); - // Persist stopped before unwind finishes so refresh/resume does not see an - // activeTask and duplicate the activityHistory user/assistant rows. await markChatTaskStopped(context, conversationId); - // Cancel the platform run before touching the sandbox. A long-running install - // or build can otherwise make the snapshot command queue behind the very work - // this endpoint is trying to stop. const result = await context.utils?.abortActiveRun?.(conversationId); - // "Stop and start new" intentionally abandons this conversation, so avoid a - // full zip -> base64 -> store round trip that the new workspace will never use. - // A normal Stop still snapshots immediately for same-conversation resume. let persisted: boolean | undefined; if (!discardProject) { try { @@ -34,35 +27,13 @@ export async function onRequest(context: any) { const saved = await persistProjectSnapshot(context, conversationId, state); persisted = saved; if (saved && !state.created) { - state.created = true; - await saveProjectState(context, conversationId, state); + markCreated(state); + await persistWorkspace(context, conversationId, state); } } catch (error) { console.warn('[stop] project snapshot failed', error); } } - const rawTurn = context?.request?.body?.turn; - if (rawTurn && typeof rawTurn === 'object') { - const turn = rawTurn as Record; - const user = String(turn.user || '').slice(0, 20_000); - const assistant = STOPPED_TURN_REPLY[replyLocaleFor(user)]; - const activities = (Array.isArray(turn.activities) ? turn.activities : []) - .slice(-50) - .filter((activity): activity is PersistedActivity => Boolean(activity) && typeof activity === 'object') - .map((activity) => activity.kind === 'tool' && activity.status === 'running' - ? { ...activity, status: 'stopped' as const, endedAt: Date.now() } - : activity); - if (user) { - await saveActivityTurn(context, conversationId, { - id: String(turn.id || context.run_id || Date.now()), - user, - assistant, - status: 'stopped', - createdAt: Number(turn.createdAt) || Date.now(), - activities, - }); - } - } return new Response(JSON.stringify({ ok: true, conversation_id: conversationId, @@ -77,7 +48,7 @@ export async function onRequest(context: any) { error: error instanceof Error ? error.message : 'Failed to stop the active run.', }), { status: 500, - headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, + headers: { 'content-type': 'application/json; charset=utf-8' }, }); } } diff --git a/agents/transcript.ts b/agents/transcript.ts new file mode 100644 index 0000000..c463614 --- /dev/null +++ b/agents/transcript.ts @@ -0,0 +1,18 @@ +import type { AgentContext } from './_lib/runtime/context.ts'; +import { getLiveQuery } from './_lib/session/live.ts'; +import { createTranscriptStreamResponse, resolveClaudeTranscriptPath } from './_lib/session/transcript.ts'; + +/** Session source of truth: stream the Claude JSONL file, unprojected. */ +export async function onRequestGet(context: AgentContext) { + return createTranscriptStreamResponse(context, (conversationId) => { + const live = getLiveQuery(conversationId); + if (!live) return null; + return { + path: resolveClaudeTranscriptPath(live.sessionId || '', { + explicitPath: live.transcriptPath, + }), + sessionId: live.sessionId, + active: Boolean(live.turn), + }; + }); +} diff --git a/agents/workspace.ts b/agents/workspace.ts new file mode 100644 index 0000000..060c458 --- /dev/null +++ b/agents/workspace.ts @@ -0,0 +1,7 @@ +import type { AgentContext } from './_lib/runtime/context.ts'; +import { runWorkspaceSnapshotPipeline } from './_lib/project/snapshot.ts'; + +/** Current files, preview, deployment, and download — independent of the chat stream. */ +export async function onRequestGet(context: AgentContext) { + return runWorkspaceSnapshotPipeline(context); +} diff --git a/app/components/agent-conversation.tsx b/app/components/agent-conversation.tsx deleted file mode 100644 index 3850b73..0000000 --- a/app/components/agent-conversation.tsx +++ /dev/null @@ -1,568 +0,0 @@ -'use client'; - -import { FormEvent, ReactNode, memo, useEffect, useMemo, useRef, useState } from 'react'; -import { - AppWindow, - ArrowUp, - BookOpen, - Check, - ChevronRight, - CircleAlert, - Copy, - FilePenLine, - FilePlus2, - FolderPlus, - FolderSearch, - Monitor, - Rocket, - Search, - Square, - SquareTerminal, - Trash2, - X, -} from 'lucide-react'; -import ReactMarkdown from 'react-markdown'; -import remarkGfm from 'remark-gfm'; -import { - buildAssistantTimeline, - lastTimelineText, - trailingTimelineContent, - type AssistantTimelineToolItem, -} from '../lib/assistant-timeline'; -import { - presentToolActivity, - toolActionTier, - type ReferenceTopic, - type ToolAction, - type ToolPresentation, -} from '../lib/tool-activity'; -import { withoutPlatformName } from '../../shared/platform-name'; -import { ModelPicker } from './model-picker'; -import type { - ActivityStatus, - AssistantActivity, -} from '../../shared/protocol'; -import type { ModelOption } from '../../shared/models'; - -export type ConversationMessage = { - id: string; - role: 'user' | 'assistant'; - content: string; - activities?: AssistantActivity[]; - status?: 'running' | 'done' | 'error' | 'stopped'; -}; - -type ConversationCopy = { - running: string; - completed: string; - failed: string; - stopped: string; - input: string; - output: string; - placeholder: string; - send: string; - stop: string; - modelLabel: string; - toolActions: Record; - referenceTopics: Record; - referenceDetail: string; - copyLink: string; - linkCopied: string; -}; - -export type DeployOfferCopy = { - prompt: string; - deploy: string; - dismiss: string; -}; - -export type GatewayPromptCopy = { - title: string; - docs: string; - docsUrl: string; - apiKey: string; - continue: string; - skip: string; -}; - -function actionLabel(action: ToolAction, copy: ConversationCopy) { - return copy.toolActions[action]; -} - -/** What the row names: a topic for reference loads, a path or command otherwise. */ -function targetLabel(presentation: ToolPresentation, copy: ConversationCopy) { - if (!presentation.topic) return withoutPlatformName(presentation.target || ''); - const topic = copy.referenceTopics[presentation.topic]; - return presentation.detailed ? `${topic} · ${copy.referenceDetail}` : topic; -} - -function ActionIcon({ action }: { action: ToolAction }) { - const props = { className: 'tool-activity-action-icon', 'aria-hidden': true } as const; - if (action === 'Environment Preparing') return ; - if (action === 'Glob') return ; - if (action === 'Read file') return ; - if (action === 'Write file') return ; - if (action === 'Edit file') return ; - if (action === 'Create folder') return ; - if (action === 'Delete file') return ; - if (action === 'Create preview') return ; - if (action === 'Deploy project') return ; - if (action === 'Load skill') return ; - return ; -} - -function ActivityIcon({ status, action }: { status: ActivityStatus; action: ToolAction }) { - if (status === 'running') { - return ; - } - if (status === 'failed') return ; - if (status === 'stopped') return ; - return ; -} - -/** - * One status for a row that stands for several calls: a run still going says so - * until its last step lands, and a step that broke outranks the ones that did - * not, because the row is the only place it can be reported. - */ -function rowStatus(steps: readonly Extract[]): ActivityStatus { - for (const status of ['running', 'failed', 'stopped'] as const) { - if (steps.some((step) => step.status === status)) return status; - } - return 'completed'; -} - -function ToolActivityRow({ item, copy, previouslyReadPaths }: { - item: AssistantTimelineToolItem; - copy: ConversationCopy; - previouslyReadPaths: ReadonlySet; -}) { - const [open, setOpen] = useState(false); - const steps = [item.activity, ...item.repeats]; - const status = rowStatus(steps); - const presentation = presentToolActivity(item.activity, previouslyReadPaths); - const target = targetLabel(presentation, copy); - const label = status === 'running' - ? copy.running - : status === 'completed' - ? copy.completed - : status === 'failed' - ? copy.failed - : copy.stopped; - // Every folded call keeps its own input and output, so the panel reads as one - // section per call and the row hides a line rather than the work behind it. - const details = steps.filter((step) => step.inputSummary || step.outputSummary); - - return ( -
- - {open && ( -
- {details.length === 0 ? ( -

{label}

- ) : details.map((step, position) => ( -
- {step.inputSummary && ( -
- {copy.input} -
{withoutPlatformName(step.inputSummary)}
-
- )} - {step.outputSummary && ( -
- {copy.output} -
{withoutPlatformName(step.outputSummary)}
-
- )} -
- ))} -
- )} -
- ); -} - -function plainText(node: ReactNode): string { - if (typeof node === 'string') return node; - if (typeof node === 'number') return String(node); - if (Array.isArray(node)) return node.map(plainText).join(''); - return ''; -} - -function ConversationLink({ href, copy, children }: { - href?: string; - copy: ConversationCopy; - children?: ReactNode; -}) { - const [copied, setCopied] = useState(false); - const url = href || ''; - // An address spelled out in full is something the user takes elsewhere. A link - // behind words is meant to be followed, and a button beside it would only - // crowd the sentence it sits in. - const isAddress = Boolean(url) && plainText(children).trim() === url; - - useEffect(() => { - if (!copied) return; - const timer = window.setTimeout(() => setCopied(false), 1600); - return () => window.clearTimeout(timer); - }, [copied]); - - const anchor = ( - - {children} - - ); - - if (!isAddress) { - return anchor; - } - - const label = copied ? copy.linkCopied : copy.copyLink; - const handleCopy = async () => { - if (!navigator.clipboard) return; - try { - await navigator.clipboard.writeText(url); - setCopied(true); - } catch { - setCopied(false); - } - }; - - return ( - - {anchor} - - - ); -} - -function Markdown({ content, copy }: { content: string; copy: ConversationCopy }) { - return ( -
- ( - {children} - ), - }} - > - {withoutPlatformName(content)} - -
- ); -} - -// Memoized because the streaming turn is the only one that changes: the chat -// reducer hands back every other message unchanged, so without this each token -// rebuilt the timeline and the read-path scan for the whole conversation. -const AssistantTurn = memo(function AssistantTurn({ message, copy }: { - message: ConversationMessage; - copy: ConversationCopy; -}) { - const activities = message.activities ?? []; - const blocks = useMemo(() => buildAssistantTimeline(activities), [activities]); - // What each tool row may treat as already-read, so a repeated Read of the same - // file can render as a revisit. Built as a running prefix, hence one snapshot - // per activity rather than one shared set. - const previouslyReadPaths = useMemo(() => { - const readPaths = new Set(); - return activities.map((activity) => { - const snapshot = new Set(readPaths); - if (activity.kind === 'tool') { - const presentation = presentToolActivity(activity); - if (presentation.action === 'Read file' && presentation.target) { - readPaths.add(presentation.target); - } - } - return snapshot; - }); - }, [activities]); - const lastText = lastTimelineText(blocks); - const trailing = trailingTimelineContent(lastText?.content, message.content, message.status); - const hasRunningTool = activities.some( - (activity) => activity.kind === 'tool' && activity.status === 'running', - ); - - return ( -
-
- {blocks.map((block) => { - if (block.kind === 'text') { - return ; - } - - return ( -
- {block.items.map((item) => ( - - ))} -
- ); - })} - {trailing && ( - message.status === 'error' ? ( -
-
- ) : ( - - ) - )} - {message.status === 'running' && !hasRunningTool && ( -
- - - -
- )} -
-
- ); -}); - -export function AgentConversation({ - messages, - input, - loading, - canSend, - compact, - copy, - models, - model, - onModelChange, - onInputChange, - onSubmit, - onStop, - deployOffer, - onDeployOffer, - onDismissDeployOffer, - gatewayPrompt, - gatewayBusy, - onGatewaySubmit, - onGatewaySkip, -}: { - messages: ConversationMessage[]; - input: string; - loading: boolean; - canSend: boolean; - compact: boolean; - copy: ConversationCopy; - models: readonly ModelOption[]; - model: string; - onModelChange: (model: string) => void; - onInputChange: (value: string) => void; - onSubmit: () => void; - onStop: () => void; - deployOffer?: DeployOfferCopy | null; - onDeployOffer?: () => void; - onDismissDeployOffer?: () => void; - gatewayPrompt?: GatewayPromptCopy | null; - gatewayBusy?: boolean; - onGatewaySubmit?: (values: { apiKey: string }) => void; - onGatewaySkip?: () => void; -}) { - const [gatewayApiKey, setGatewayApiKey] = useState(''); - const gatewayInputRef = useRef(null); - const gatewayVisible = Boolean(gatewayPrompt); - - useEffect(() => { - if (!gatewayVisible) { - setGatewayApiKey(''); - return; - } - // The card mounts only after the assistant turn has finished, so focus - // can land immediately instead of waiting on a disabled input. - const node = gatewayInputRef.current; - if (!node || node.disabled) return; - node.focus(); - }, [gatewayVisible]); - const scrollRef = useRef(null); - const followOutputRef = useRef(true); - const signature = messages.map((message) => [ - message.id, - message.status, - message.content, - message.activities?.map((activity) => activity.kind === 'text' - ? activity.content - : `${activity.toolUseId}:${activity.status}:${activity.outputSummary || ''}`).join('|'), - ].join(':')).join('\n'); - - useEffect(() => { - const node = scrollRef.current; - if (node && followOutputRef.current) node.scrollTop = node.scrollHeight; - }, [signature]); - - const submit = (event: FormEvent) => { - event.preventDefault(); - onSubmit(); - }; - - return ( -
-
{ - const node = event.currentTarget; - followOutputRef.current = node.scrollHeight - node.scrollTop - node.clientHeight < 72; - }} - > -
- {messages.map((message) => message.role === 'user' ? ( -
-
{message.content}
-
- ) : ( - - ))} -
-
-
- {gatewayPrompt && ( -
{ - event.preventDefault(); - if (gatewayBusy) return; - onGatewaySubmit?.({ - apiKey: gatewayApiKey.trim(), - }); - }} - > -
-

{gatewayPrompt.title}

-

- - {gatewayPrompt.docs} - -

-
- -
- - -
-
- )} - {deployOffer && ( -
- {deployOffer.prompt} -
- - -
-
- )} -
-