improvement(mship): mship file fixes (#6918)

* improvement(files): preserve editable page source on round trips

* improvement(pages): harden previews and mobile rendering

* fix(copilot): keep generated API keys accessible

* fix(files): rewrite page sources after upload finalization

* fix(pages): expose compile diagnostics through VFS

* fix(copilot): surface classified tool access errors

* fix(copilot): surface actionable server tool errors
This commit is contained in:
Siddharth Ganesan
2026-08-21 10:09:31 -07:00
committed by GitHub
parent 63569a2459
commit 29acfb10ff
26 changed files with 676 additions and 138 deletions
@@ -181,6 +181,13 @@ export function buildHtmlPreviewDocument(
// appends. Raw HTML (bespoke and legacy stored-compiled pages) skips this.
if (isSimPageSource(content)) {
content = compileSimPage(content, { workspaceId })
} else if (content.trimStart().startsWith('---')) {
// Page source that does not compile yet — frontmatter still streaming in,
// or a malformed header. Never show a reader raw source: hold the empty
// themed shell (the marker pulls the stylesheet in below) until a
// compilable snapshot arrives.
content =
'<!DOCTYPE html><html><head><meta name="sim-artifact"><title>Page</title></head><body></body></html>'
}
const headInjection = [
'<meta charset="utf-8">',
@@ -426,6 +433,11 @@ const HtmlPreview = memo(function HtmlPreview({
<iframe
key={resumeNonce}
srcDoc={wrappedContent}
/* No clipboard-write delegation: this frame also renders untrusted
raw HTML uploads, and a permissions-policy grant would let their
inline scripts replace the viewer's clipboard without a gesture.
The shell's copy buttons use the selection-command fallback,
which works in the sandbox but only on a real user click. */
sandbox='allow-scripts'
referrerPolicy='no-referrer'
title='HTML Preview'
@@ -1308,7 +1308,7 @@ describe('CredentialDisplay link tag', () => {
act(() => root.unmount())
})
it('leaves a sim_key-only card alone when the turn moves on', () => {
it('renders a sim_key as a standalone reveal, not a card, even when the turn moves on', () => {
const container = document.createElement('div')
const root: Root = createRoot(container)
const data: CredentialItemData[] = [{ type: 'sim_key', name: 'Sim API key', value: 'sim-key' }]
@@ -1317,11 +1317,42 @@ describe('CredentialDisplay link tag', () => {
root.render(<SpecialTags segment={{ type: 'credential', data }} credentialAbandoned />)
})
expect(container.textContent).toContain('API key')
// The generated key is an output: the reveal chip shows the value with no
// question-style card chrome around it and no recap when abandoned.
expect(container.textContent).toContain('sim-key')
expect(container.textContent).not.toContain('API key')
expect(container.textContent).not.toContain('Skipped')
act(() => root.unmount())
})
it('keeps the sim_key reveal outside a mixed card and out of its recap', () => {
const container = document.createElement('div')
const root: Root = createRoot(container)
const data: CredentialItemData[] = [
{ type: 'sim_key', name: 'Sim API key', value: 'sim-key' },
{ type: 'secret_input', name: 'ABC_API_KEY' },
]
act(() => {
root.render(
<SpecialTags
segment={{ type: 'credential', data }}
credentialSubmission={{
integrations: [],
secrets: [{ name: 'ABC_API_KEY', status: 'saved' }],
}}
/>
)
})
// After Submit the input row collapses to the recap, but the generated key
// stays retrievable in its reveal chip and never reads as "Added".
expect(container.textContent).toContain('sim-key')
expect(container.textContent).toContain('ABC_API_KEYAdded')
expect(container.textContent).not.toContain('Sim API key')
act(() => root.unmount())
})
it('keeps a typed secret while a sibling row runs its OAuth connect', async () => {
// The card's secret drafts live in component state until its Submit, so a
// connect that navigates this tab away discards whatever the user already
@@ -2494,6 +2494,11 @@ function TerminalHandoffDisplay({ data }: { data: CredentialItemData }) {
)
}
/**
* sim_key stays in the routing set so a payload carrying one still takes the
* card path (CredentialDisplay renders its reveal separately), but it is an
* output, so the card itself never shows it as a row.
*/
const CREDENTIAL_CARD_TYPES: ReadonlySet<CredentialTagType> = new Set([
'secret_input',
'link',
@@ -2502,7 +2507,7 @@ const CREDENTIAL_CARD_TYPES: ReadonlySet<CredentialTagType> = new Set([
])
function isCredentialCardItemVisible(item: CredentialItemData, canEdit: boolean): boolean {
if (item.type === 'sim_key') return true
if (item.type === 'sim_key') return false
if (item.type === 'secret_input') return item.scope === 'personal' || canEdit
if (item.type === 'link') {
return canEdit && Boolean(item.provider) && Boolean(item.value && isSafeHttpUrl(item.value))
@@ -2582,13 +2587,8 @@ function CredentialItemDisplay({
)
}
if (data.type === 'sim_key') {
// SecretReveal masks itself when there's no value, so a value-less tag (the
// model's placeholder / persisted form) renders masked and a Sim-filled tag
// reveals the key + copy button — no separate "redacted" flag needed.
return <SecretReveal value={data.value} />
}
// sim_key never reaches here: CredentialDisplay renders its reveal chip
// standalone (SecretReveal masks itself when the tag carries no value).
return null
}
@@ -2679,18 +2679,13 @@ function CredentialInputCard({
const integrationRows = visibleRows.filter(
({ item }) => item.type === 'link' || item.type === 'service_account'
)
const secretRows = visibleRows.filter(
({ item }) => item.type === 'secret_input' || item.type === 'sim_key'
)
const requiredSecretRows = secretRows.filter(({ item }) => item.type === 'secret_input')
const secretRows = visibleRows.filter(({ item }) => item.type === 'secret_input')
const title =
integrationRows.length > 0 && secretRows.length > 0
? 'Set up credentials'
: integrationRows.length > 0
? 'Connect integrations'
: requiredSecretRows.length > 0
? 'Add secrets'
: 'API key'
: 'Add secrets'
const rows = [
...integrationRows.map(({ item, dataIndex, integrationIndex }, index) => (
<CredentialItemDisplay
@@ -2740,7 +2735,7 @@ function CredentialInputCard({
const personalVariables: Record<string, string> = {}
const enteredSecretIndexes: number[] = []
for (const { item, secretIndex } of requiredSecretRows) {
for (const { item, secretIndex } of secretRows) {
if (secretIndex === undefined) continue
const name = item.name?.trim()
const value = secretDrafts[secretIndex] ?? ''
@@ -2789,7 +2784,7 @@ function CredentialInputCard({
return true
}
const needsContinuation = integrationRows.length > 0 || requiredSecretRows.length > 0
const needsContinuation = integrationRows.length > 0 || secretRows.length > 0
const credentialSummary = [
...integrationRows.map(({ item, integrationIndex }) => ({
label: getCredentialProviderDisplayName(item.provider ?? 'Integration'),
@@ -2802,9 +2797,6 @@ function CredentialInputCard({
: ('Skipped' as const),
})),
...secretRows.map(({ item, secretIndex }) => {
if (item.type === 'sim_key') {
return { label: item.name ?? 'Sim API key', status: 'Added' as const }
}
const saved = submitted
? submitted.secrets[secretIndex ?? -1]?.status === 'saved'
: savedSecretRows.has(secretIndex ?? -1)
@@ -2817,8 +2809,6 @@ function CredentialInputCard({
// An abandoned card recaps from local progress only: a row the user connected
// or saved before moving on keeps its status, everything else reads "Skipped".
// Only a card that asked for something can be abandoned — a standalone
// `sim_key` row is a reveal widget, not a prompt, so it stays as it is.
if (submitted || locallySubmitted || (abandoned && needsContinuation)) {
return (
<InteractionCardRecap
@@ -2866,29 +2856,46 @@ export function CredentialDisplay({
abandoned?: boolean
onContinue?: (message: string) => void
}) {
// A sim_key is an OUTPUT — the workspace API key the platform filled in —
// never a setup request, so it renders as its own reveal chip outside any
// card. Inside the question-style card it read as something to submit, and
// the card's recap swallowed the key after Submit. The reveal also outlives
// submission/abandonment: the key must stay retrievable. The full payload
// still flows to the card so row indexes (OAuth controlIds, submission
// pairing) stay stable — the card simply renders no sim_key rows.
const simKeyReveals = data
.map((item, index) =>
item.type === 'sim_key' ? <SecretReveal key={`sim-key-${index}`} value={item.value} /> : null
)
.filter(Boolean)
const inputItems = data.filter((item) => item.type !== 'sim_key')
const usesCredentialCard = data.every((item) => CREDENTIAL_CARD_TYPES.has(item.type))
if (usesCredentialCard) {
return (
<CredentialInputCard
data={data}
interactionId={interactionId}
submitted={submitted}
abandoned={abandoned}
onContinue={onContinue}
/>
)
}
return (
<div className={cn(data.length > 1 && 'space-y-3')}>
{data.map((item, index) => (
const inputControls = usesCredentialCard ? (
<CredentialInputCard
data={data}
interactionId={interactionId}
submitted={submitted}
abandoned={abandoned}
onContinue={onContinue}
/>
) : inputItems.length > 0 ? (
<div className={cn(inputItems.length > 1 && 'space-y-3')}>
{inputItems.map((item, index) => (
<CredentialItemDisplay
key={`${item.type}-${item.provider ?? item.name ?? index}`}
data={item}
/>
))}
</div>
) : null
if (simKeyReveals.length === 0) return inputControls
return (
<div className='space-y-3'>
{simKeyReveals}
{inputControls}
</div>
)
}
+12 -2
View File
@@ -1,4 +1,5 @@
import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import type { getWorkflowById } from '@/lib/workflows/utils'
import { checkWorkspaceAccess, type WorkspaceAccess } from '@/lib/workspaces/permissions/utils'
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
@@ -19,12 +20,21 @@ export async function ensureWorkflowAccess(
action,
})
// Classified, not bare Errors: the copilot error projection passes a
// classified message through to the model verbatim, while an unclassified
// throw collapses into the generic "system error, please retry".
if (!result.workflow) {
throw new Error(`Workflow ${workflowId} not found`)
throw new OrchestrationError(
'not_found',
`Workflow not found: ${workflowId}. Pass the workflow's canonical id (copy it from workflows/**/meta.json or the tool result that created it) — a workflow name or @-mention is not an id.`
)
}
if (!result.allowed) {
throw new Error(result.message || 'Unauthorized workflow access')
throw new OrchestrationError(
result.status === 404 ? 'not_found' : 'forbidden',
result.message || 'Unauthorized workflow access'
)
}
return { workflow: result.workflow, workspaceId: result.workflow.workspaceId }
@@ -1,3 +1,4 @@
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { CodeLanguage } from '@/lib/execution/languages'
import { executeInSandbox } from '@/lib/execution/remote-sandbox'
@@ -28,7 +29,10 @@ export interface DocExtract {
export async function extractDocText(args: { binary: Buffer; ext: string }): Promise<DocExtract> {
const ext = args.ext.toLowerCase()
if (!isExtractableDocExt(ext)) {
throw new Error(`Cannot extract text from .${ext} (supported: pdf, pptx, docx, xlsx)`)
throw new OrchestrationError(
'validation',
`Cannot extract text from .${ext} (supported: pdf, pptx, docx, xlsx)`
)
}
const script = `
@@ -102,7 +106,7 @@ print("__SIM_RESULT__=" + json.dumps({"text": text}))
})
if (result.error) {
throw new Error(`Document extraction failed: ${result.error}`)
throw new OrchestrationError('validation', `Document extraction failed: ${result.error}`)
}
const payload = result.result as { text?: string } | null
const full = payload?.text ?? ''
@@ -1,3 +1,4 @@
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { CodeLanguage } from '@/lib/execution/languages'
import { executeInSandbox } from '@/lib/execution/remote-sandbox'
@@ -37,7 +38,10 @@ export async function renderDocToGrid(args: {
}): Promise<DocRender> {
const ext = args.ext.toLowerCase()
if (!isRenderableDocExt(ext)) {
throw new Error(`Cannot render .${ext} to images (supported: pptx, docx, pdf)`)
throw new OrchestrationError(
'validation',
`Cannot render .${ext} to images (supported: pptx, docx, pdf)`
)
}
const script = `
@@ -99,7 +103,7 @@ else:
})
if (result.error) {
throw new Error(`Document render failed: ${result.error}`)
throw new OrchestrationError('validation', `Document render failed: ${result.error}`)
}
const payload = result.result as { grid?: string | null; pageCount?: number } | null
if (!payload?.grid) {
@@ -1,5 +1,6 @@
import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case'
import type { CopilotFileDelegationContext } from '@/lib/copilot/auth/file-delegation'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager'
import { createWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders'
@@ -13,7 +14,10 @@ export function requireCopilotWorkspace(
): string {
if (!context.workspaceId) throw new Error('Copilot execution workspace is required')
if (assertedWorkspaceId && assertedWorkspaceId !== context.workspaceId) {
throw new Error('Workspace ID does not match the Copilot execution workspace')
throw new OrchestrationError(
'validation',
'Workspace ID does not match the Copilot execution workspace'
)
}
return context.workspaceId
}
@@ -14,7 +14,7 @@ import {
requireCopilotWorkspace,
} from '@/lib/copilot/tools/server/files/file-folder-application'
import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types'
import {
findWorkspaceFileFolderIdByPath,
getWorkspaceFileFolder,
@@ -119,9 +119,11 @@ async function resolveFolderIdFromPath(
label = 'Folder'
): Promise<string> {
const segments = decodeFileFolderPath(path)
if (!segments) throw new Error(`${label} path must identify a folder under files/`)
if (!segments)
throw new OrchestrationError('validation', `${label} path must identify a folder under files/`)
const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments)
if (!folderId) throw new Error(`${label} not found at files/${segments.join('/')}`)
if (!folderId)
throw new OrchestrationError('not_found', `${label} not found at files/${segments.join('/')}`)
return folderId
}
@@ -135,7 +137,11 @@ async function resolveOptionalFolderId(
const segments = decodeFileFolderPath(raw)
if (!segments) return null
const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments)
if (!folderId) throw new Error(`Target folder not found at files/${segments.join('/')}`)
if (!folderId)
throw new OrchestrationError(
'not_found',
`Target folder not found at files/${segments.join('/')}`
)
return folderId
}
@@ -1,5 +1,6 @@
import Ajv, { type ErrorObject, type ValidateFunction } from 'ajv'
import { TOOL_RUNTIME_SCHEMAS } from '@/lib/copilot/generated/tool-schemas-v1'
import { OrchestrationError } from '@/lib/core/orchestration/types'
const ajv = new Ajv({
allErrors: true,
@@ -42,7 +43,13 @@ export function validateGeneratedToolPayload<T>(
if (!validator(payload)) {
const label = schemaKind === 'parameters' ? 'input' : 'output'
throw new Error(`${toolName} ${label} validation failed: ${formatErrors(validator.errors)}`)
const message = `${toolName} ${label} validation failed: ${formatErrors(validator.errors)}`
// Input validation is the CALLER's mistake — classified, so the copilot
// error projection surfaces it verbatim and the model can fix its
// arguments instead of blind-retrying a masked "system error". Output
// validation failing is the tool's own bug and stays internal.
if (schemaKind === 'parameters') throw new OrchestrationError('validation', message)
throw new Error(message)
}
return payload
@@ -4,6 +4,7 @@ import { WebSearch } from '@/lib/copilot/generated/tool-catalog-v1'
import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
import { env } from '@/lib/core/config/env'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { executeTool } from '@/tools'
interface OnlineSearchParams {
@@ -35,7 +36,8 @@ export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, SearchRe
async execute(params: OnlineSearchParams, context?: ServerToolContext): Promise<SearchResponse> {
const logger = createLogger('SearchOnlineServerTool')
const { query, num = 10, type = 'search', gl, hl } = params
if (!query || typeof query !== 'string') throw new Error('query is required')
if (!query || typeof query !== 'string')
throw new OrchestrationError('validation', 'query is required')
const hasExaApiKey = Boolean(env.EXA_API_KEY && String(env.EXA_API_KEY).length > 0)
const hasSerperApiKey = Boolean(env.SERPER_API_KEY && String(env.SERPER_API_KEY).length > 0)
@@ -104,7 +106,8 @@ export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, SearchRe
}
if (!hasSerperApiKey) {
throw new Error(
throw new OrchestrationError(
'forbidden',
'Web search is not configured on this Sim deployment and cannot be enabled from a tool. Answer from the workspace instead (grep/glob/read, search_sim_docs) or tell the user web search is unavailable.'
)
}
@@ -126,7 +129,9 @@ export const searchOnlineServerTool: BaseServerTool<OnlineSearchParams, SearchRe
if (!result.success) {
const errorMsg = (result as { error?: string }).error ?? 'Search failed'
throw new Error(errorMsg)
// Classified so the provider's actual failure (rate limit, bad query)
// reaches the model instead of the generic system-error mask.
throw new OrchestrationError('conflict', errorMsg)
}
return {
+6 -2
View File
@@ -61,6 +61,7 @@ import { getCredentialsServerTool } from '@/lib/copilot/tools/server/user/get-cr
import { setEnvironmentVariablesServerTool } from '@/lib/copilot/tools/server/user/set-environment-variables'
import { editWorkflowServerTool } from '@/lib/copilot/tools/server/workflow/edit-workflow'
import { queryLogsServerTool } from '@/lib/copilot/tools/server/workflow/query-logs'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations'
import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay'
import { withBlockVisibility } from '@/blocks/visibility/server-context'
@@ -219,7 +220,7 @@ export async function routeExecution(
): Promise<unknown> {
const tool = getServerToolRegistry()[toolName]
if (!tool) {
throw new Error(`Unknown server tool: ${toolName}`)
throw new OrchestrationError('validation', `Unknown server tool: ${toolName}`)
}
logger.debug(
@@ -233,7 +234,10 @@ export async function routeExecution(
const action = (p?.operation ?? p?.action) as string | undefined
if (isWriteAction(toolName, action) && !copilotToolCanWrite(context?.userPermission)) {
const actionLabel = action ? `'${action}' on ` : ''
throw new Error(
// Classified so the projection surfaces it: a permission denial is
// caller-actionable (stop retrying, tell the user), not a system error.
throw new OrchestrationError(
'forbidden',
`Permission denied: ${actionLabel}${toolName} requires write access. You have '${context?.userPermission ?? 'none'}' permission.`
)
}
@@ -7,6 +7,7 @@ import { decodeJwt } from 'jose'
import { createPermissionError, verifyWorkflowAccess } from '@/lib/copilot/auth/permissions'
import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool'
import { getAllowedIntegrationsFromEnv } from '@/lib/core/config/env-flags'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment'
import { getPersonalAndWorkspaceEnv } from '@/lib/environment/utils'
import { createIntegrationCredentialVisibility } from '@/lib/integrations/credential-visibility.server'
@@ -50,7 +51,7 @@ export const getCredentialsServerTool: BaseServerTool<GetCredentialsParams, any>
workflowId: params.workflowId,
authenticatedUserId,
})
throw new Error(errorMessage)
throw new OrchestrationError('forbidden', errorMessage)
}
workspaceId = wId
@@ -7,6 +7,7 @@ import {
getDefaultWorkspaceId,
} from '@/lib/copilot/tools/handlers/access'
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { performUpdateCredential } from '@/lib/credentials/orchestration'
import { listVisibleWorkspaceCredentials } from '@/lib/credentials/queries'
import { upsertPersonalEnvVars, upsertWorkspaceEnvVars } from '@/lib/environment/utils'
@@ -67,7 +68,8 @@ function normalizeDescriptions(
if (!item || typeof item.name !== 'string' || item.description === undefined) continue
const description = item.description?.trim() ?? ''
if (description.length > DESCRIPTION_MAX_LENGTH) {
throw new Error(
throw new OrchestrationError(
'validation',
`description for ${item.name} must be at most ${DESCRIPTION_MAX_LENGTH} characters`
)
}
@@ -159,7 +161,10 @@ async function resolveWorkspaceId(
if (params.workflowId) {
const { workflow } = await ensureWorkflowAccess(params.workflowId, userId, 'write')
if (!workflow.workspaceId) {
throw new Error(`Workflow ${params.workflowId} is not associated with a workspace`)
throw new OrchestrationError(
'validation',
`Workflow ${params.workflowId} is not associated with a workspace`
)
}
return workflow.workspaceId
}
@@ -202,7 +207,10 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<
// per-workspace mirrors, so there is no single row to hold its description —
// one written here would exist in this workspace alone.
if (scope === 'personal' && Object.keys(descriptions).length > 0) {
throw new Error('description is only supported for a workspace secret')
throw new OrchestrationError(
'validation',
'description is only supported for a workspace secret'
)
}
const { variables: validatedVariables } = EnvVarSchema.parse({ variables: normalized })
const variableNames = Object.keys(validatedVariables)
@@ -249,7 +257,10 @@ export const setEnvironmentVariablesServerTool: BaseServerTool<
// A failed description never fails a stored value — but a describe-only call
// has nothing else to report, so its failure is the result.
if (descriptionFailures.length > 0 && workspaceUpdated.length === 0) {
throw new Error(`Could not describe: ${descriptionFailures.join('; ')}`)
throw new OrchestrationError(
'conflict',
`Could not describe: ${descriptionFailures.join('; ')}`
)
}
const parts: string[] = []
@@ -17,6 +17,7 @@ import {
type ServerToolContext,
} from '@/lib/copilot/tools/server/base-tool'
import { env } from '@/lib/core/config/env'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { getSocketServerUrl } from '@/lib/core/utils/urls'
import { MAX_PLAN_REQUIRED } from '@/lib/execution/remote-sandbox/workspace-sandboxes'
import {
@@ -65,7 +66,8 @@ async function getCurrentWorkflowStateFromDb(
.from(workflowTable)
.where(eq(workflowTable.id, workflowId))
.limit(1)
if (!workflowRecord) throw new Error(`Workflow ${workflowId} not found in database`)
if (!workflowRecord)
throw new OrchestrationError('not_found', `Workflow ${workflowId} not found in database`)
const normalized = await loadWorkflowFromNormalizedTables(workflowId)
if (!normalized) throw new Error('Workflow has no normalized data')
@@ -100,9 +102,9 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
const logger = createLogger('EditWorkflowServerTool')
const { operations, workflowId, currentUserWorkflow } = params
if (!Array.isArray(operations) || operations.length === 0) {
throw new Error('operations are required and must be an array')
throw new OrchestrationError('validation', 'operations are required and must be an array')
}
if (!workflowId) throw new Error('workflowId is required')
if (!workflowId) throw new OrchestrationError('validation', 'workflowId is required')
if (!context?.userId) {
throw new Error('Unauthorized workflow access')
}
@@ -113,7 +115,16 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
action: 'write',
})
if (!authorization.allowed) {
throw new Error(authorization.message || 'Unauthorized workflow access')
// Classified, not a bare Error: the copilot error projection passes a
// classified message through to the model verbatim, while an
// unclassified throw collapses into "system error, please retry" —
// which invites blind retries of a call that can never succeed.
throw new OrchestrationError(
authorization.status === 404 ? 'not_found' : 'forbidden',
authorization.status === 404
? `Workflow not found: ${workflowId}. Pass the workflow's canonical id (copy it from workflows/**/meta.json or the tool result that created it) — a workflow name or @-mention is not an id.`
: authorization.message || 'Unauthorized workflow access'
)
}
await assertWorkflowMutable(workflowId)
@@ -125,7 +136,7 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
operationsReferenceSimSandbox(operations) &&
(!workspaceId || !(await hasWorkspaceSandboxAccess(workspaceId)))
) {
throw new Error(MAX_PLAN_REQUIRED)
throw new OrchestrationError('forbidden', MAX_PLAN_REQUIRED)
}
logger.info('Executing edit_workflow', {
@@ -143,7 +154,7 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
workflowState = JSON.parse(currentUserWorkflow)
} catch (error) {
logger.error('Failed to parse currentUserWorkflow', error)
throw new Error('Invalid currentUserWorkflow format')
throw new OrchestrationError('validation', 'Invalid currentUserWorkflow format')
}
} else {
const fromDb = await getCurrentWorkflowStateFromDb(workflowId)
@@ -241,7 +252,10 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
errors: validation.errors,
warnings: validation.warnings,
})
throw new Error(`Invalid edited workflow: ${validation.errors.join('; ')}`)
throw new OrchestrationError(
'validation',
`Invalid edited workflow: ${validation.errors.join('; ')}`
)
}
if (validation.warnings.length > 0) {
@@ -377,7 +391,7 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
workflowId,
error: saveResult.error,
})
throw new Error(`Failed to save workflow: ${saveResult.error}`)
throw new OrchestrationError('conflict', `Failed to save workflow: ${saveResult.error}`)
}
// Update workflow's lastSynced timestamp
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
import { z } from 'zod'
import { QueryLogs } from '@/lib/copilot/generated/tool-catalog-v1'
import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool'
import { OrchestrationError } from '@/lib/core/orchestration/types'
import {
collectLargeValueExecutionIds,
collectLargeValueKeys,
@@ -120,7 +121,7 @@ type QueryLogsArgs = z.infer<typeof queryLogsArgsSchema>
function resolveWorkspaceId(args: QueryLogsArgs, context?: ServerToolContext): string {
const workspaceId = args.workspaceId ?? context?.workspaceId
if (!workspaceId) {
throw new Error('workspaceId is required')
throw new OrchestrationError('validation', 'workspaceId is required')
}
return workspaceId
}
+3 -1
View File
@@ -78,7 +78,9 @@ async function resolveCreateTarget(
const existing = await getWorkspaceFileByName(workspaceId, parsed.fileName, { folderId })
if (existing) {
throw new Error(`File already exists at ${parsed.vfsPath}. Use mode "overwrite" to update it.`)
throw new Error(
`File already exists at ${parsed.vfsPath}. Choose a different name to keep both files, or pass mode "overwrite" only if the user wants that existing file replaced.`
)
}
return {
+42 -1
View File
@@ -177,6 +177,11 @@ import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-wo
import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content'
import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders'
import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path'
import {
collectSimPageDiagnostics,
isSimPageSource,
SIM_PAGE_CONTENT_TYPE,
} from '@/lib/workspace-files/page-compile'
import { getWorkspaceHostContextForViewer } from '@/lib/workspaces/host-context'
import {
assertActiveWorkspaceAccess,
@@ -1549,7 +1554,12 @@ export class WorkspaceVFS {
const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(record.name) : null
const taskId = BINARY_DOC_TASKS[ext]
const isMermaidFile = ext === 'mmd' || ext === 'mermaid'
if (!e2bFmt && !taskId && !isMermaidFile) return null
// Sim pages (and legacy .html-named page source) compile-check too:
// this is the only way an agent can retrieve the "block skipped"
// diagnostics for an ALREADY-written page — without it, "find the
// malformed table" degenerates into guessing.
const maybeSimPage = record.type === SIM_PAGE_CONTENT_TYPE || ext === 'html'
if (!e2bFmt && !taskId && !isMermaidFile && !maybeSimPage) return null
const { content: buffer } = await readWorkspaceFileContent.execute({
principal: this.requireFilePrincipal(),
input: {
@@ -1565,6 +1575,37 @@ export class WorkspaceVFS {
totalLines: 1,
})
}
if (maybeSimPage && isSimPageSource(code)) {
const diagnostics = collectSimPageDiagnostics(code)
const result =
diagnostics.length === 0
? { ok: true }
: {
ok: false,
error: `${diagnostics.length} block(s) fail to compile and are omitted from the rendered page: ${diagnostics.join('; ')}`,
}
return bindWorkspaceFileResult(record, {
content: JSON.stringify(result),
totalLines: 1,
})
}
if (maybeSimPage && !e2bFmt && !taskId && !isMermaidFile) {
if (record.type === SIM_PAGE_CONTENT_TYPE) {
// A page-typed file whose bytes are not page source (e.g. a crash
// between upload registration and source restore) — report it
// rather than pretending the path does not exist.
return bindWorkspaceFileResult(record, {
content: JSON.stringify({
ok: false,
error:
'Stored content is not page source (no YAML frontmatter with a title) — the file renders as raw HTML',
}),
totalLines: 1,
})
}
// Bespoke raw HTML has no compiler to check.
return null
}
if (isMermaidFile) {
const result = await validateMermaidSource(code)
const json = JSON.stringify(result)
@@ -71,6 +71,11 @@ import {
} from '@/lib/uploads/core/storage-service'
import { MAX_WORKSPACE_FILE_SIZE, toLegacyWorkspaceFileSize } from '@/lib/uploads/shared/types'
import { isMarkdownFile } from '@/lib/uploads/utils/file-utils'
import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile'
import {
MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES,
restoreSimPageSourceBuffer,
} from '@/lib/workspace-files/page-source-embed'
import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils'
import { isUuid } from '@/executor/constants'
import type { UserFile } from '@/executor/types'
@@ -420,6 +425,10 @@ export async function uploadWorkspaceFile(
folderPath = folderTarget?.path ?? null
}
const normalizedFileName = normalizeWorkspaceFileItemName(fileName, 'File')
const pageRestore = restoreSimPageSourceBuffer(normalizedFileName, fileBuffer)
const effectiveBuffer = pageRestore?.buffer ?? fileBuffer
const effectiveName = pageRestore?.name ?? normalizedFileName
const effectiveContentType = pageRestore ? SIM_PAGE_CONTENT_TYPE : contentType
const exactName = options?.exactName ?? false
const storageBillingContext = await resolveStorageBillingContext(workspaceId)
@@ -427,8 +436,8 @@ export async function uploadWorkspaceFile(
const maxAttempts = exactName ? 1 : MAX_UPLOAD_UNIQUE_RETRIES
for (let attempt = 0; attempt < maxAttempts; attempt++) {
const uniqueName = exactName
? normalizedFileName
: await allocateUniqueWorkspaceFileName(workspaceId, normalizedFileName, folderId)
? effectiveName
: await allocateUniqueWorkspaceFileName(workspaceId, effectiveName, folderId)
if (exactName && (await fileExistsInWorkspace(workspaceId, uniqueName, folderId))) {
throw new FileConflictError(uniqueName)
}
@@ -448,9 +457,9 @@ export async function uploadWorkspaceFile(
}
const uploadResult = await uploadFile({
file: fileBuffer,
file: effectiveBuffer,
fileName: storageKey,
contentType,
contentType: effectiveContentType,
context: 'workspace',
preserveKey: true,
customKey: storageKey,
@@ -485,8 +494,8 @@ export async function uploadWorkspaceFile(
workspaceId,
folderId: activeFolderId,
originalName: uniqueName,
contentType,
size: fileBuffer.length,
contentType: effectiveContentType,
size: effectiveBuffer.length,
})
if (!inserted) {
throw new FileConflictError(uniqueName)
@@ -502,7 +511,7 @@ export async function uploadWorkspaceFile(
const usage = await incrementStorageUsageForBillingContextInTx(
tx,
storageBillingContext,
fileBuffer.length
effectiveBuffer.length
)
return { inserted, updatedUsage: usage }
})
@@ -535,7 +544,7 @@ export async function uploadWorkspaceFile(
}
if (getPostgresErrorCode(error) === '23505') {
if (exactName) {
throw new FileConflictError(normalizedFileName)
throw new FileConflictError(effectiveName)
}
logger.warn(
`Unique name conflict on upload (attempt ${attempt + 1}/${MAX_UPLOAD_UNIQUE_RETRIES}), retrying with a new name`
@@ -577,6 +586,47 @@ export interface RegisterUploadedWorkspaceFileResult {
created: boolean
}
/**
* Detects a page-source upload on the presigned/multipart path, whose bytes
* are already in storage at registration time. Returns the page registration
* values plus, for a compiled standalone download, the extracted source bytes
* to write back at the same key.
*
* The write-back must NOT happen here: until the registration transaction
* records the session's completed file id, a finalize retry re-verifies the
* stored object against the session's declared size and content type
* (assertObjectIdentity in upload-session/service.ts), and a rewritten object
* would fail that check on every retry, permanently stranding the session.
* The caller performs the rewrite strictly AFTER the transaction commits —
* from then on retries take the completed-file path and never re-verify — and
* a replay that arrives before a rewrite landed re-detects the still-compiled
* bytes and rewrites after its own commit.
*/
async function detectUploadedSimPageSource(params: {
key: string
name: string
size: number
}): Promise<{ contentType: string; name: string; size: number; rewrite: Buffer | null } | null> {
const { key, name, size } = params
if (!name.toLowerCase().endsWith('.html')) return null
if (size === 0 || size > MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES) return null
let uploaded: Buffer
try {
uploaded = await downloadFile({ key, context: 'workspace' })
} catch (error) {
logger.warn(`Page-source sniff skipped for ${key}: ${getErrorMessage(error)}`)
return null
}
const restored = restoreSimPageSourceBuffer(name, uploaded)
if (restored === null) return null
return {
contentType: SIM_PAGE_CONTENT_TYPE,
name: restored.name,
size: restored.buffer.length,
rewrite: restored.buffer === uploaded ? null : restored.buffer,
}
}
export async function registerUploadedWorkspaceFile(params: {
workspaceId: string
userId: string
@@ -604,12 +654,48 @@ export async function registerUploadedWorkspaceFile(params: {
throw new Error(`File size exceeds maximum of ${MAX_WORKSPACE_FILE_SIZE} bytes`)
}
const pageRestore = await detectUploadedSimPageSource({
key,
name: normalizedOriginalName,
size: verifiedSize,
})
const effectiveContentType = pageRestore?.contentType ?? contentType
const effectiveName = pageRestore?.name ?? normalizedOriginalName
const effectiveSize = pageRestore?.size ?? verifiedSize
/**
* Swaps the compiled bytes for the extracted page source, strictly after the
* registration transaction committed (see detectUploadedSimPageSource).
* Best-effort: the row is already committed, so a failure here leaves a
* page-typed file whose bytes are the compiled document — it still renders
* everywhere (the compiled doc is self-contained) and only loses source
* editing until rewritten, which beats failing a registration that
* succeeded.
*/
const commitPageRestoreRewrite = async () => {
if (!pageRestore?.rewrite) return
try {
await uploadFile({
file: pageRestore.rewrite,
fileName: key,
contentType: SIM_PAGE_CONTENT_TYPE,
context: 'workspace',
preserveKey: true,
customKey: key,
persistMetadata: false,
})
} catch (error) {
logger.error(`Page-source rewrite failed after registration for ${key}`, {
cause: describeError(error),
})
}
}
const registrationIdentity = {
workspaceId,
userId,
key,
contentType,
size: verifiedSize,
contentType: effectiveContentType,
size: effectiveSize,
}
const existing = await db.transaction(async (tx) => {
const found = await findWorkspaceFileByRegistrationKey(tx, key)
@@ -631,6 +717,7 @@ export async function registerUploadedWorkspaceFile(params: {
})
if (existing) {
logger.info(`Using existing metadata record for upload session: ${key}`)
await commitPageRestoreRewrite()
const pathPrefix = getServePathPrefix()
return {
file: {
@@ -651,11 +738,7 @@ export async function registerUploadedWorkspaceFile(params: {
const storageBillingContext = await resolveStorageBillingContext(workspaceId)
for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) {
const fileId = `wf_${generateShortId()}`
const displayName = await allocateUniqueWorkspaceFileName(
workspaceId,
normalizedOriginalName,
folderId
)
const displayName = await allocateUniqueWorkspaceFileName(workspaceId, effectiveName, folderId)
const finalized = await db.transaction(async (tx) => {
await acquireFolderMutationLock(tx, workspaceId, 'file')
@@ -667,8 +750,8 @@ export async function registerUploadedWorkspaceFile(params: {
workspaceId,
folderId: activeFolderId,
originalName: displayName,
contentType,
size: verifiedSize,
contentType: effectiveContentType,
size: effectiveSize,
})
if (!inserted) {
const raceWinner = await findWorkspaceFileByRegistrationKey(tx, key)
@@ -697,7 +780,7 @@ export async function registerUploadedWorkspaceFile(params: {
const updatedUsage = await incrementStorageUsageForBillingContextInTx(
tx,
storageBillingContext,
verifiedSize
effectiveSize
)
await replaceWorkspaceFileSecretProvenanceInTx(
tx,
@@ -720,6 +803,7 @@ export async function registerUploadedWorkspaceFile(params: {
void maybeNotifyStorageLimitForBillingContext(storageBillingContext, finalized.updatedUsage)
}
await commitPageRestoreRewrite()
const pathPrefix = getServePathPrefix()
return {
file: {
@@ -68,6 +68,19 @@ describe('buildHtmlPreviewDocument', () => {
}
})
// A page file streams in as source; until the frontmatter closes it cannot
// compile, and a reader must never see raw source in its place.
it('holds the empty themed shell for page source that does not compile yet', () => {
const partial = '---\ntitle: Elder Guide\nlede: Still stre'
const built = buildHtmlPreviewDocument(partial)
expect(built).not.toContain('Still stre')
expect(built).toContain('--surface-active')
})
it('still shows a bespoke raw-HTML document as-is', () => {
expect(buildHtmlPreviewDocument(PLAIN)).toContain('<p>hi</p>')
})
// The stylesheet is a floor, not a ceiling: a page that wants its own design
// still wins, so it must land before the page's own <style>.
it('injects the stylesheet ahead of the page styles', () => {
@@ -211,7 +211,10 @@ export const SIM_ARTIFACT_STYLESHEET = `
/* scrollbar-gutter keeps the centered column from shifting when content
length toggles the viewport scrollbar (e.g. switching between a long and a
short tab). */
html { scroll-behavior: smooth; scroll-padding-top: 72px; scrollbar-gutter: stable; }
/* text-size-adjust pins mobile font boosting: without it, Safari and Chrome
on phones inflate the text of wide scrollable blocks — code blocks and
tables render at a jarringly larger size than the prose around them. */
html { scroll-behavior: smooth; scroll-padding-top: 72px; scrollbar-gutter: stable; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
* { transition: none !important; animation: none !important; }
@@ -341,10 +344,16 @@ figcaption { font-size: 0.875em; line-height: 1.4285714; color: var(--text-prima
.pill--warn { background: var(--badge-orange-bg); color: var(--badge-orange-text); }
.pill--bad { background: var(--badge-error-bg); color: var(--badge-error-text); }
/* Expanding question rows — the accordion, as native <details>, no scripts. */
/* Expanding question rows — the accordion, as native <details>, no scripts.
fumadocs-ui accordion.js verbatim: a rounded-lg bg-fd-card frame with
divide-y rows; each trigger is px-3 py-2.5 font-medium with a LEADING
chevron-right (16px icon box, muted) that quarter-turns to down over
200ms when open; content sits at px-4 pb-2 in 15px. The trigger has no
hover fill — only focus-visible tints the row (has-focus-visible:
bg-fd-accent). */
.faq {
border: 1px solid var(--border);
border-radius: 0.75rem;
border-radius: 0.5rem;
background: var(--surface-2);
overflow: hidden;
}
@@ -355,29 +364,30 @@ figcaption { font-size: 0.875em; line-height: 1.4285714; color: var(--text-prima
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.7rem 1rem;
font-size: var(--text-sm);
gap: 0.5rem;
padding: 0.625rem 0.75rem;
font-weight: 500;
line-height: 1.5;
color: var(--text-primary);
}
.faq summary::-webkit-details-marker { display: none; }
.faq summary::after {
/* The lucide chevron as a border glyph, centered in its 16px box via the
side margins: points right closed, rotates to down open. */
.faq summary::before {
content: "";
width: 8px;
height: 8px;
margin: 0 4px;
flex-shrink: 0;
border-right: 1.5px solid var(--text-icon);
border-bottom: 1.5px solid var(--text-icon);
transform: rotate(45deg);
transition: transform 0.15s;
margin-top: -3px;
border-right: 1.5px solid var(--text-muted);
border-bottom: 1.5px solid var(--text-muted);
transform: rotate(-45deg);
transition: transform 0.2s;
}
.faq details[open] summary::after { transform: rotate(225deg); margin-top: 3px; }
.faq summary:hover { background: var(--surface-hover); }
.faq details > :not(summary) { padding: 0 1rem; }
.faq details > :last-child { padding-bottom: 0.9rem; margin-bottom: 0; }
.faq details[open] summary::before { transform: rotate(45deg); }
.faq summary:focus-visible { outline: none; background: var(--surface-active); }
.faq details > :not(summary) { padding: 0 1rem; font-size: 0.9375rem; }
.faq details > :last-child { padding-bottom: 0.5rem; margin-bottom: 0; }
/* The fumadocs callout: a rounded-xl bordered card at 14px with a rounded
2px color bar down the start edge (the docs strip its shadow). */
@@ -741,6 +751,27 @@ export const SIM_ARTIFACT_SHELL = `<script>
// emcn's Duplicate icon flipping to a brand-accent Check while copied.
const COPY_ICON = '<svg ' + ICON_ATTRS + '><path d="M14.25 0.75H2.75C1.64543 0.75 0.75 1.64543 0.75 2.75V14.25"/><rect x="5.25" y="5.25" width="14" height="14" rx="2"/></svg>'
const COPIED_ICON = '<svg ' + ICON_ATTRS + '><path d="M18.25 2.75L7.25 15.75L1.75 10.25"/></svg>'
// The async clipboard API is denied in the sandboxed in-app preview (the
// frame's origin is opaque, so no clipboard permission can match it); the
// selection command still honors the click's user activation there, so it
// is the fallback wherever the modern API refuses.
const copyText = async (text) => {
try {
await navigator.clipboard.writeText(text)
return true
} catch {}
const scratch = document.createElement('textarea')
scratch.value = text
scratch.setAttribute('readonly', '')
scratch.style.position = 'fixed'
scratch.style.opacity = '0'
document.body.appendChild(scratch)
scratch.select()
let copied = false
try { copied = document.execCommand('copy') } catch {}
scratch.remove()
return copied
}
for (const pre of [...main.querySelectorAll('pre')]) {
if (pre.closest('.codeblock')) continue
const codeEl = pre.querySelector('code')
@@ -754,23 +785,32 @@ export const SIM_ARTIFACT_SHELL = `<script>
let copyResetTimer
copy.addEventListener('click', async () => {
const text = (codeEl || pre).textContent || ''
try {
await navigator.clipboard.writeText(text)
copy.innerHTML = COPIED_ICON
copy.classList.add('is-copied')
copy.setAttribute('aria-label', 'Copied Text')
clearTimeout(copyResetTimer)
copyResetTimer = setTimeout(() => {
copy.innerHTML = COPY_ICON
copy.classList.remove('is-copied')
copy.setAttribute('aria-label', 'Copy Text')
}, 1500)
} catch {}
if (!(await copyText(text))) return
copy.innerHTML = COPIED_ICON
copy.classList.add('is-copied')
copy.setAttribute('aria-label', 'Copied Text')
clearTimeout(copyResetTimer)
copyResetTimer = setTimeout(() => {
copy.innerHTML = COPY_ICON
copy.classList.remove('is-copied')
copy.setAttribute('aria-label', 'Copy Text')
}, 1500)
})
pre.replaceWith(frame)
frame.append(copy, pre)
}
// Markdown tables arrive bare (only sim:table fences are pre-wrapped);
// an over-wide one must scroll inside the column — on a phone a bare
// table is what drags the whole page sideways.
for (const table of [...main.querySelectorAll('table')]) {
if (table.closest('.scroll')) continue
const wrap = document.createElement('div')
wrap.className = 'scroll'
table.replaceWith(wrap)
wrap.appendChild(table)
}
// Code-tab groups: switch panes; the docs' icon copy control targets the
// visible pane.
for (const group of [...main.querySelectorAll('.codetabs')]) {
@@ -784,16 +824,14 @@ export const SIM_ARTIFACT_SHELL = `<script>
let groupResetTimer
groupCopy.addEventListener('click', async () => {
const pane = group.querySelector('pre:not([hidden])')
try {
await navigator.clipboard.writeText((pane && pane.textContent) || '')
groupCopy.innerHTML = COPIED_ICON
groupCopy.classList.add('is-copied')
clearTimeout(groupResetTimer)
groupResetTimer = setTimeout(() => {
groupCopy.innerHTML = COPY_ICON
groupCopy.classList.remove('is-copied')
}, 1500)
} catch {}
if (!(await copyText((pane && pane.textContent) || ''))) return
groupCopy.innerHTML = COPIED_ICON
groupCopy.classList.add('is-copied')
clearTimeout(groupResetTimer)
groupResetTimer = setTimeout(() => {
groupCopy.innerHTML = COPY_ICON
groupCopy.classList.remove('is-copied')
}, 1500)
})
head.appendChild(groupCopy)
head.addEventListener('click', (event) => {
@@ -106,7 +106,7 @@ describe('compileSimPage', () => {
const source = '---\ntitle: T\n---\n```sim:kv\n- { key: }\n```'
expect(compileSimPage(source)).not.toContain('was skipped')
expect(collectSimPageDiagnostics(source)).toEqual([
'sim:kv block skipped: its payload did not match the expected shape',
'sim:kv block starting "- { key: }" skipped: its payload did not match the expected shape',
])
})
@@ -114,10 +114,35 @@ describe('compileSimPage', () => {
const source = '---\ntitle: T\n---\n```sim:cards\n- title: X\n```'
expect(compileSimPage(source)).not.toContain('sim:cards')
expect(collectSimPageDiagnostics(source)).toEqual([
'sim:cards block skipped: its payload did not match the expected shape',
'sim:cards block starting "- title: X" skipped: its payload did not match the expected shape',
])
})
// A page can carry several blocks of the same kind — the diagnostic must
// name WHICH one failed and distinguish a YAML syntax error from a shape
// mismatch, or "a table is malformed" sends the fixer hunting through all
// of them.
it('identifies the failing block by its first payload line and reports YAML errors', () => {
const source = [
'---',
'title: T',
'---',
'```sim:table',
'columns: [A, B]',
'rows:',
' - [a, b]',
'```',
'',
'```sim:table',
'columns: [C: D]',
'rows: broken',
'```',
].join('\n')
const diagnostics = collectSimPageDiagnostics(source)
expect(diagnostics).toHaveLength(1)
expect(diagnostics[0]).toContain('sim:table block starting "columns: [C: D]"')
})
it('reports nothing for a fully valid page', () => {
expect(
collectSimPageDiagnostics('---\ntitle: T\n---\n```sim:kv\n- key: A\n value: B\n```')
+14 -6
View File
@@ -1,3 +1,5 @@
import { getErrorMessage } from '@sim/utils/errors'
import { truncate } from '@sim/utils/string'
import { JSON_SCHEMA, load } from 'js-yaml'
import { marked } from 'marked'
import { z } from 'zod'
@@ -407,20 +409,26 @@ function compileBody(source: string, diagnostics?: string[]): string {
const renderer = FENCE_RENDERERS[kind]
let payload: unknown
let rendered: string | null = null
let parseError: string | null = null
if (renderer) {
try {
payload = loadYaml(body)
rendered = renderer(payload)
} catch {
rendered = null
} catch (err) {
parseError = getErrorMessage(err, 'invalid YAML')
}
if (parseError === null) rendered = renderer(payload)
}
if (rendered !== null) {
html.push(rendered)
} else {
diagnostics?.push(
`sim:${kind} block skipped: its payload did not match the expected shape`
)
// Name WHICH block failed and WHY: a page can carry several blocks
// of the same kind, and a bare "a table is malformed" sends the
// fixing agent hunting through all of them.
const preview = truncate(body.trim().split('\n')[0] ?? '', 80)
const reason = parseError
? `its payload is not valid YAML/JSON — ${truncate(parseError, 160)}`
: 'its payload did not match the expected shape'
diagnostics?.push(`sim:${kind} block starting "${preview}" skipped: ${reason}`)
}
}
index = bodyEnd + 1
@@ -4,6 +4,7 @@ import {
SIM_ARTIFACT_STYLESHEET,
} from '@/lib/workspace-files/artifact-stylesheet'
import { compileSimPage } from '@/lib/workspace-files/page-compile'
import { simPageSourceEmbedBlock } from '@/lib/workspace-files/page-source-embed'
/**
* Renders page source as a fully self-contained styled document for the
@@ -18,8 +19,10 @@ export function renderSimPageDocument(source: string, options?: { workspaceId?:
// downloads, and a downloaded page's links and images must reach Sim
// the way an absolute link in a downloaded .md does.
const compiled = compileSimPage(source, { ...options, baseUrl: getBaseUrl() })
// The embedded source is what lets a downloaded copy upload back as an
// editable page instead of frozen compiled bytes (page-source-embed.ts).
return compiled.replace(
'</head>',
`<style>${SIM_ARTIFACT_STYLESHEET}</style>${SIM_ARTIFACT_SHELL}</head>`
`<style>${SIM_ARTIFACT_STYLESHEET}</style>${SIM_ARTIFACT_SHELL}${simPageSourceEmbedBlock(source)}</head>`
)
}
@@ -0,0 +1,83 @@
/**
* @vitest-environment node
*/
import { describe, expect, it } from 'vitest'
import { compileSimPage } from '@/lib/workspace-files/page-compile'
import {
extractSimPageSource,
MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES,
restoreSimPageSourceBuffer,
simPageSourceEmbedBlock,
} from '@/lib/workspace-files/page-source-embed'
const SOURCE = `---
title: Elder Guide
lede: How the Elder works.
---
## Overview
Prose with \`inline code\`, a </script> literal, and unicode — café ★.
\`\`\`sim:callout
The must-not-miss constraint.
\`\`\`
`
/** The standalone-document shape: compiled output with the embed in its head. */
function compiledDocumentWithEmbed(source: string): string {
return compileSimPage(source, { baseUrl: 'https://sim.ai' }).replace(
'</head>',
`${simPageSourceEmbedBlock(source)}</head>`
)
}
describe('simPageSourceEmbedBlock / extractSimPageSource', () => {
it('round-trips source through a compiled document, including </script> and unicode', () => {
expect(extractSimPageSource(compiledDocumentWithEmbed(SOURCE))).toBe(SOURCE)
})
it('emits no literal </script> inside the block for source containing one', () => {
const block = simPageSourceEmbedBlock(SOURCE)
expect(block.indexOf('</script>')).toBe(block.length - '</script>'.length)
})
it('returns null for a document without an embed block', () => {
expect(extractSimPageSource(compileSimPage(SOURCE, { baseUrl: 'https://sim.ai' }))).toBeNull()
})
it('returns null when the embedded bytes are not valid page source', () => {
const forged = `<head><script type="text/x-sim-page-source">${Buffer.from(
'<p>not page source</p>'
).toString('base64')}</script></head>`
expect(extractSimPageSource(forged)).toBeNull()
})
})
describe('restoreSimPageSourceBuffer', () => {
it('restores the embedded source from a compiled .html upload and drops the extension', () => {
const restored = restoreSimPageSourceBuffer(
'Elder Guide.html',
Buffer.from(compiledDocumentWithEmbed(SOURCE), 'utf8')
)
expect(restored?.name).toBe('Elder Guide')
expect(restored?.buffer.toString('utf8')).toBe(SOURCE)
})
it('passes raw page source through unchanged (idempotent replay)', () => {
const buffer = Buffer.from(SOURCE, 'utf8')
const restored = restoreSimPageSourceBuffer('Elder Guide.html', buffer)
expect(restored?.name).toBe('Elder Guide')
expect(restored?.buffer).toBe(buffer)
})
it('ignores non-.html names, plain HTML, empty, and oversized uploads', () => {
expect(restoreSimPageSourceBuffer('notes.md', Buffer.from(SOURCE))).toBeNull()
expect(
restoreSimPageSourceBuffer('site.html', Buffer.from('<!doctype html><p>plain</p>'))
).toBeNull()
expect(restoreSimPageSourceBuffer('empty.html', Buffer.alloc(0))).toBeNull()
const oversized = Buffer.alloc(MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES + 1)
expect(restoreSimPageSourceBuffer('big.html', oversized)).toBeNull()
})
})
@@ -0,0 +1,58 @@
import { isSimPageSource } from '@/lib/workspace-files/page-compile'
/**
* A compiled standalone page document carries its own source so a download
* survives the round trip: the served/downloaded HTML stays a fully styled,
* self-contained page (tabs, theme toggle, and the TOC rail all work offline),
* and uploading it back into Sim restores the editable page source instead of
* freezing the compiled bytes. The source rides base64-encoded inside an inert
* script block — base64 output cannot contain `</script>`, so no page content
* can break out of the tag.
*/
const SIM_PAGE_SOURCE_EMBED_TYPE = 'text/x-sim-page-source'
const SIM_PAGE_SOURCE_EMBED_PATTERN =
/<script type="text\/x-sim-page-source">([A-Za-z0-9+/=\s]*)<\/script>/
/** Renders the inert head block carrying the base64-encoded page source. */
export function simPageSourceEmbedBlock(source: string): string {
return `<script type="${SIM_PAGE_SOURCE_EMBED_TYPE}">${Buffer.from(source, 'utf8').toString('base64')}</script>`
}
/**
* Recovers the embedded page source from a compiled standalone document.
* Returns null when the document carries no embed block, or when the block's
* bytes are not valid page source — the frontmatter check keeps a crafted or
* corrupted block from ever registering as a page.
*/
export function extractSimPageSource(documentHtml: string): string | null {
const match = documentHtml.match(SIM_PAGE_SOURCE_EMBED_PATTERN)
if (!match) return null
const source = Buffer.from(match[1].replace(/\s+/g, ''), 'base64').toString('utf8')
return isSimPageSource(source) ? source : null
}
/** Uploads larger than this skip the page-source sniff. */
export const MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES = 32 * 1024 * 1024
/**
* An `.html` upload whose bytes are Sim page source — raw source, or a
* compiled standalone download carrying its embedded source — is stored as a
* page: the source becomes the stored content and the display name drops
* `.html` (pages store extensionless), so a downloaded page uploads back as a
* first-class editable page instead of frozen compiled bytes. Returns null
* for every other upload.
*/
export function restoreSimPageSourceBuffer(
name: string,
buffer: Buffer
): { name: string; buffer: Buffer } | null {
if (!name.toLowerCase().endsWith('.html')) return null
if (buffer.length === 0 || buffer.length > MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES) return null
const text = buffer.toString('utf8')
const pageName = name.slice(0, -'.html'.length) || name
if (isSimPageSource(text)) return { name: pageName, buffer }
const source = extractSimPageSource(text)
if (source === null) return null
return { name: pageName, buffer: Buffer.from(source, 'utf8') }
}
@@ -78,6 +78,68 @@ describe('in-document tabs drive the on-this-page rail', () => {
})
})
describe('markdown table wrapping', () => {
// A bare table is what drags the page sideways on a phone — the shell puts
// every markdown table in the same .scroll container sim:table fences get.
it('wraps bare tables in a scroll container', async () => {
const dom = new JSDOM(
renderSimPageDocument(`---
title: Table Doc
---
## Reference
| Connection | Endpoint |
|---|---|
| shell | api.planetscale.com/v1 |
`),
domOptions
)
await tick()
const table = dom.window.document.querySelector('table')
expect(table?.parentElement?.classList.contains('scroll')).toBe(true)
})
})
describe('code block copy', () => {
// The sandboxed in-app preview denies the async clipboard API (opaque
// origin), so the shell falls back to the selection command; jsdom has no
// navigator.clipboard, which exercises exactly that path.
it('copies through the selection-command fallback and flips the button', async () => {
const dom = new JSDOM(
renderSimPageDocument(`---
title: Copy Doc
---
## Snippet
\`\`\`bash
echo hi
\`\`\`
`),
domOptions
)
const { document } = dom.window
await tick()
let copiedText: string | null = null
document.execCommand = (command: string) => {
copiedText = document.querySelector('textarea')?.value ?? null
return command === 'copy'
}
const copy = document.querySelector('.codeblock-copy')
expect(copy).not.toBeNull()
copy?.dispatchEvent(new dom.window.Event('click', { bubbles: true }))
await tick()
expect(copiedText?.trim()).toBe('echo hi')
expect(copy?.classList.contains('is-copied')).toBe(true)
// The scratch textarea is removed after the copy.
expect(document.querySelector('textarea')).toBeNull()
})
})
describe('older page shapes keep their rail', () => {
it('builds the rail for a plain untabbed page', async () => {
const dom = new JSDOM(