mirror of
https://github.com/simstudioai/sim.git
synced 2026-10-31 03:47:12 +08:00
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:
@@ -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'
|
||||
|
||||
+33
-2
@@ -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
|
||||
|
||||
+44
-37
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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```')
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user