diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index f0e7738c8d0..495f4ad4913 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -7,6 +7,13 @@ import { hybridAuthMockFns, storageServiceMock, storageServiceMockFns } from '@s import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('@sim/logger', () => ({ + createLogger: vi.fn(() => serveLogger), + logger: serveLogger, + runWithRequestContext: vi.fn((_ctx: unknown, fn: () => T): T => fn()), + getRequestContext: vi.fn(() => undefined), +})) + const { mockVerifyFileAccess, mockReadFile, @@ -18,6 +25,7 @@ const { mockCreateFileResponse, mockCreateErrorResponse, FileNotFoundError, + serveLogger, } = vi.hoisted(() => { class FileNotFoundErrorClass extends Error { constructor(message: string) { @@ -26,6 +34,7 @@ const { } } return { + serveLogger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, mockVerifyFileAccess: vi.fn(), mockReadFile: vi.fn(), mockIsUsingCloudStorage: vi.fn(), @@ -232,4 +241,32 @@ describe('File Serve API Route', () => { }) } }) + + describe('failure log level', () => { + it('records a missing file at info, not error', async () => { + /** A superseded key is an ordinary 404, not a server fault. */ + const req = new NextRequest('http://localhost:3000/api/files/serve/') + const response = await GET(req, { params: Promise.resolve({ path: [] }) }) + + expect(response.status).toBe(404) + expect(serveLogger.info).toHaveBeenCalledWith( + 'Error serving file:', + expect.objectContaining({ reason: expect.any(String) }) + ) + expect(serveLogger.error).not.toHaveBeenCalled() + }) + + it('still records a genuine failure at error', async () => { + mockVerifyFileAccess.mockRejectedValueOnce(new Error('permission backend down')) + + const req = new NextRequest( + 'http://localhost:3000/api/files/serve/workspace/ws/test-file.txt' + ) + await GET(req, { + params: Promise.resolve({ path: ['workspace', 'ws', 'test-file.txt'] }), + }).catch(() => undefined) + + expect(serveLogger.error).toHaveBeenCalled() + }) + }) }) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index b8ed3154eab..a94eee6b48d 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -26,6 +26,23 @@ import { const logger = createLogger('FilesServeAPI') +/** + * Records a failed serve at a level that matches whose fault it is. + * + * A file that is not there is an ordinary answer rather than a server fault: a + * workspace file is rewritten under a new key on every content update, so a reader + * holding the previous key lands here routinely and correctly receives a 404. Each + * handler rethrows into the outer one, so logging those at `error` reports the same + * expected 404 twice and buries the failures that do warrant attention. + */ +function logServeFailure(message: string, error: unknown): void { + if (error instanceof FileNotFoundError) { + logger.info(message, { reason: error.message }) + return + } + logger.error(message, error) +} + interface ServeOptions { /** `raw=1` — bypass all resolution and serve the stored source as-is. */ raw: boolean @@ -179,7 +196,7 @@ export const GET = withRouteHandler( return NextResponse.json({ error: 'Document is still being generated' }, { status: 409 }) } - logger.error('Error serving file:', error) + logServeFailure('Error serving file:', error) if (error instanceof FileNotFoundError) { return createErrorResponse(error) @@ -244,7 +261,7 @@ async function handleLocalFile( cacheControl: resolveServeCacheControl(options.versioned, contextParam), }) } catch (error) { - logger.error('Error reading local file:', error) + logServeFailure('Error reading local file:', error) throw error } } @@ -311,7 +328,7 @@ async function handleCloudProxy( cacheControl: resolveServeCacheControl(options.versioned, context), }) } catch (error) { - logger.error('Error downloading from cloud storage:', error) + logServeFailure('Error downloading from cloud storage:', error) throw error } } @@ -348,7 +365,7 @@ async function handleCloudProxyPublic( cacheControl: PUBLIC_ASSET_CACHE_CONTROL, }) } catch (error) { - logger.error('Error serving public cloud file:', error) + logServeFailure('Error serving public cloud file:', error) throw error } } @@ -373,7 +390,7 @@ async function handleLocalFilePublic(filename: string): Promise { cacheControl: PUBLIC_ASSET_CACHE_CONTROL, }) } catch (error) { - logger.error('Error reading public local file:', error) + logServeFailure('Error reading public local file:', error) throw error } } diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx index 83730f5465e..447c8aebb2e 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx @@ -3,7 +3,7 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react' import { cn, toast } from '@sim/emcn' import { FILE_DOC_SEED, type JoinFileDocError } from '@sim/realtime-protocol/file-doc' -import { type Extensions, generateHTML, type JSONContent } from '@tiptap/core' +import type { Extensions, JSONContent } from '@tiptap/core' import { isChangeOrigin } from '@tiptap/extension-collaboration' import { Fragment, Slice } from '@tiptap/pm/model' import { NodeSelection } from '@tiptap/pm/state' @@ -81,6 +81,44 @@ const STREAM_REPARSE_THROTTLE_MS = 120 /** Debounce before naming a still-untitled file after its leading heading, so it fires once typing settles. */ const DERIVE_TITLE_DEBOUNCE_MS = 600 +/** + * The editor's reading column — the centered, padded surface both the live editor and the read-only + * {@link ReadOnlyPlaceholder} render into, so the two are geometrically identical and the placeholder → + * live swap never reflows. Shared as one constant to keep them in lockstep. + */ +const EDITOR_SURFACE_CLASS = + 'mx-auto flex w-full max-w-[48rem] flex-1 flex-col px-8 py-6 selection:bg-[var(--selection-bg)] selection:text-[var(--text-primary)] dark:selection:bg-[var(--selection-dark)] dark:selection:text-white' + +/** + * Read-only editor that renders the already-fetched markdown while a collaborative doc waits for its + * server seed, so the pane shows content instantly instead of blocking blank on the socket round-trip + * (the seed IS the same markdown, so the swap on `collabReady` is seamless). It shares the live + * editor's extension set ({@link EXTENSIONS}) — and therefore its node views and decoration plugins + * (syntax highlighting, mention chips, images, mermaid diagrams, media embeds) — so the content is + * pixel-identical to the live editor and the swap neither repaints nor reflows. It carries no + * Collaboration extension, Y.Doc, or awareness, so it structurally cannot write to the shared document + * (a client seed would duplicate it), and `editable={false}` disables every editing affordance. Mounted + * only while the placeholder shows, so no second editor lingers once the live one takes over. + */ +interface ReadOnlyPlaceholderProps { + content: JSONContent +} + +function ReadOnlyPlaceholder({ content }: ReadOnlyPlaceholderProps) { + const editor = useEditor({ + extensions: EXTENSIONS, + editable: false, + // Render synchronously on first paint (safe — this surface is client-only, never SSR'd) so the + // placeholder appears instantly like the static HTML it replaced, instead of blanking for a frame + // while the editor mounts. + immediatelyRender: true, + shouldRerenderOnTransaction: false, + content, + editorProps: { attributes: { class: 'rich-markdown-prose' } }, + }) + return +} + interface RichMarkdownEditorProps { file: WorkspaceFileRecord workspaceId: string @@ -332,16 +370,12 @@ export function LoadedRichMarkdownEditor({ : parseMarkdownToDoc(splitFrontmatter(content).body) ) /** - * A read-only placeholder rendered from the already-fetched markdown while a collaborative doc waits - * for its server seed, so the pane shows content instantly instead of blocking blank on the socket - * round-trip (the seed IS the same markdown, so the swap on {@link collabReady} is seamless). Static - * HTML — it holds no editor, doc, or awareness, so it structurally cannot write to the Y.Doc, which - * is the invariant that keeps seeding out of the client (a client seed duplicates the doc). + * The already-fetched markdown, parsed once, for the read-only {@link ReadOnlyPlaceholder} shown while + * a collaborative doc waits for its server seed. Held only when collaborating; the local path seeds + * the live editor directly, so it needs no placeholder. */ - const [placeholderHtml] = useState(() => - collaborationEnabled - ? generateHTML(parseMarkdownToDoc(splitFrontmatter(content).body), EXTENSIONS) - : null + const [placeholderContent] = useState(() => + collaborationEnabled ? parseMarkdownToDoc(splitFrontmatter(content).body) : null ) /** * The body currently shown in the editor: seeded from a settled mount, updated on local edits (via @@ -1197,22 +1231,12 @@ export function LoadedRichMarkdownEditor({ if (images.length > 0) void insertImagesRef.current(images, at) }} /> - {showPlaceholder && placeholderHtml && ( - // Instant read-only content while the collaborative doc seeds, swapped for the live editor - // once ready. The `ProseMirror` class is load-bearing: it gives the placeholder the same base - // text layout as the live editable (prosemirror-view sets `white-space: break-spaces` and - // disables ligatures), so a line wraps identically and never re-wraps on the swap. -
+ {showPlaceholder && placeholderContent && ( + )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx new file mode 100644 index 00000000000..5b7fc95ee03 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.test.tsx @@ -0,0 +1,213 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/hooks/queries/skills', () => ({ useSkills: () => ({ data: [] }) })) +vi.mock('@/hooks/queries/mcp', () => ({ useMcpServers: () => ({ data: [] }) })) +vi.mock('@/blocks/integration-matcher', () => ({ + getIntegrationMatcher: () => ({ regex: null, byName: new Map() }), +})) +vi.mock( + '@/app/workspace/[workspaceId]/home/components/user-input/components/plus-menu-dropdown/plus-menu-dropdown', + () => ({ PlusMenuDropdown: () => null }) +) +vi.mock( + '@/app/workspace/[workspaceId]/home/components/user-input/components/skills-menu-dropdown/skills-menu-dropdown', + () => ({ SkillsMenuDropdown: () => null }) +) + +import { PromptEditor } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor' +import { usePromptEditor } from '@/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/use-prompt-editor' + +/** + * jsdom performs no layout, so the autosize inputs are stubbed: `editorWidth` + * stands for the scroller's content width and `contentHeight` for the height + * the text wraps to at that width. Narrowing raises the content height, exactly + * as rewrapping does in a browser. + */ +let contentHeight = 240 +let editorWidth = 700 +let autosizeCalls = 0 + +/** + * `observe` only registers the target: real deliveries, including the initial + * one, are asynchronous, so tests drive them explicitly. Delivering inside + * `observe` would hide the window between the mount-time measure and the first + * notification — exactly where a width change can be missed. + */ +class FakeResizeObserver implements ResizeObserver { + private static instances: FakeResizeObserver[] = [] + private readonly callback: ResizeObserverCallback + private targets: Element[] = [] + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + FakeResizeObserver.instances.push(this) + } + + observe(target: Element) { + this.targets.push(target) + } + + unobserve(target: Element) { + this.targets = this.targets.filter((t) => t !== target) + } + + disconnect() { + this.targets = [] + FakeResizeObserver.instances = FakeResizeObserver.instances.filter((i) => i !== this) + } + + deliver() { + const entries = this.targets.map( + (target) => ({ target, contentRect: { width: editorWidth } }) as ResizeObserverEntry + ) + if (entries.length > 0) this.callback(entries, this) + } + + static reset() { + FakeResizeObserver.instances = [] + } + + static observerCount() { + return FakeResizeObserver.instances.length + } + + static deliverAll() { + for (const instance of [...FakeResizeObserver.instances]) instance.deliver() + } +} + +function mountEditor() { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root: Root = createRoot(container) + + function Probe() { + const editor = usePromptEditor({ workspaceId: 'ws-1', initialValue: 'a long prompt' }) + return + } + + act(() => root.render()) + + const textarea = container.querySelector('textarea') + if (!textarea) throw new Error('textarea did not render') + + return { + textarea, + unmount: () => { + act(() => root.unmount()) + container.remove() + }, + } +} + +function resizeTo(width: number, wrappedHeight: number) { + editorWidth = width + contentHeight = wrappedHeight + act(() => FakeResizeObserver.deliverAll()) +} + +/** + * Delivers the observer's initial notification at the mounted width, putting the + * editor in the steady state a test can then resize away from. + */ +function settle() { + act(() => FakeResizeObserver.deliverAll()) +} + +describe('PromptEditor autosize', () => { + let originalScrollHeight: PropertyDescriptor | undefined + + beforeEach(() => { + contentHeight = 240 + editorWidth = 700 + autosizeCalls = 0 + FakeResizeObserver.reset() + + originalScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollHeight') + Object.defineProperty(Element.prototype, 'scrollHeight', { + configurable: true, + get() { + if (!(this instanceof HTMLTextAreaElement)) return 0 + autosizeCalls++ + return contentHeight + }, + }) + vi.stubGlobal('ResizeObserver', FakeResizeObserver) + }) + + afterEach(() => { + if (originalScrollHeight) { + Object.defineProperty(Element.prototype, 'scrollHeight', originalScrollHeight) + } + vi.unstubAllGlobals() + }) + + it('sizes the textarea to its content height on mount', () => { + const { textarea, unmount } = mountEditor() + + expect(textarea.style.height).toBe('240px') + unmount() + }) + + it('re-measures when the editor width changes so no text falls outside the textarea', () => { + const { textarea, unmount } = mountEditor() + settle() + expect(textarea.style.height).toBe('240px') + + resizeTo(340, 500) + + expect(textarea.style.height).toBe('500px') + unmount() + }) + + it('re-measures again when the editor widens back', () => { + const { textarea, unmount } = mountEditor() + settle() + + resizeTo(340, 500) + resizeTo(700, 240) + + expect(textarea.style.height).toBe('240px') + unmount() + }) + + /** Distinct from the case above: here the width moves before any delivery lands. */ + it('re-measures on the first delivery when the width changed before it arrived', () => { + const { textarea, unmount } = mountEditor() + expect(textarea.style.height).toBe('240px') + + resizeTo(340, 500) + + expect(textarea.style.height).toBe('500px') + unmount() + }) + + /** The height `autosize` writes re-notifies this observer, so this guard breaks the loop. */ + it('ignores resize notifications that do not change the width', () => { + const { textarea, unmount } = mountEditor() + settle() + const callsAfterSettle = autosizeCalls + + contentHeight = 500 + act(() => FakeResizeObserver.deliverAll()) + + expect(textarea.style.height).toBe('240px') + expect(autosizeCalls).toBe(callsAfterSettle) + unmount() + }) + + it('stops observing after unmount', () => { + const { unmount } = mountEditor() + expect(FakeResizeObserver.observerCount()).toBe(1) + + unmount() + + expect(FakeResizeObserver.observerCount()).toBe(0) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx index db58cdcf461..af7013cb84b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/user-input/components/prompt-editor/prompt-editor.tsx @@ -88,7 +88,7 @@ export function PromptEditor({ * container, letting the browser clamp a bottom-pinned transcript upward by * the input's grown height on every multi-line edit. */ - useLayoutEffect(() => { + const autosize = useCallback(() => { const textarea = textareaRef.current if (!textarea) return const scroller = scrollerRef.current @@ -96,7 +96,38 @@ export function PromptEditor({ textarea.style.height = 'auto' textarea.style.height = `${textarea.scrollHeight}px` if (scroller) scroller.style.height = '' - }, [value, textareaRef]) + }, [textareaRef]) + + useLayoutEffect(() => { + autosize() + }, [value, autosize]) + + /** + * The textarea carries an inline pixel height, so a width change (window + * resize, sidebar toggle, chat column reflow) rewraps the text taller while + * the box stays at its old height. The mirror overlay paints the full text + * regardless, so the spilled lines render over the scroller with no textarea + * beneath them — visible, scrollable text that swallows clicks instead of + * placing the caret. + * + * Only width is compared: `autosize` writes the textarea's height, which + * re-notifies this observer, so reacting to height would feed itself. The + * first delivery is measured like any other — the width can change between + * the mount-time measure and `observe()`. + */ + useEffect(() => { + const scroller = scrollerRef.current + if (!scroller) return + let lastWidth: number | null = null + const observer = new ResizeObserver(([entry]) => { + const width = entry.contentRect.width + if (width === lastWidth) return + lastWidth = width + autosize() + }) + observer.observe(scroller) + return () => observer.disconnect() + }, [autosize]) useEffect(() => { if (autoFocus && !readOnly) editor.focusAtEnd() diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index e2834ab3c7d..d18e69cbc14 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { ChevronDown } from '@sim/emcn/icons' import type { WorkflowGroup } from '@/lib/table' +import { HeaderLabel } from '@/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/header-label' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { COL_WIDTH, SELECTION_TINT_BG } from '../constants' import type { ColumnSourceInfo, DisplayColumn } from '../types' @@ -296,9 +297,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ blockIconInfo={sourceInfo?.blockIconInfo} blockMissing={blockMissing} /> - - {column.workflowGroupId ? column.headerLabel : column.name} - + ) : (
@@ -314,9 +316,10 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ blockIconInfo={sourceInfo?.blockIconInfo} blockMissing={blockMissing} /> - - {column.workflowGroupId ? column.headerLabel : column.name} - +
@@ -412,7 +418,7 @@ function MappingEntry({ controller, group, entry }: MappingEntryProps) { ) : null} {entry.candidatesTruncated ? (

- More options than shown — search by name. + Too many targets to list them all — search covers only the ones shown.

) : null} diff --git a/apps/sim/lib/uploads/core/errors.test.ts b/apps/sim/lib/uploads/core/errors.test.ts new file mode 100644 index 00000000000..974099c9e68 --- /dev/null +++ b/apps/sim/lib/uploads/core/errors.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { isObjectNotFoundError } from '@/lib/uploads/core/errors' + +describe('isObjectNotFoundError', () => { + it('matches the shapes each storage provider uses for a missing object', () => { + /** S3 HeadObject, exactly as the production payload arrived. */ + expect( + isObjectNotFoundError({ + name: 'NotFound', + $fault: 'client', + $metadata: { httpStatusCode: 404 }, + }) + ).toBe(true) + /** S3 GetObject. */ + expect(isObjectNotFoundError({ name: 'NoSuchKey', $metadata: { httpStatusCode: 404 } })).toBe( + true + ) + /** Azure Blob. */ + expect(isObjectNotFoundError({ code: 'BlobNotFound', statusCode: 404 })).toBe(true) + /** GCS, which reports a numeric code. */ + expect(isObjectNotFoundError({ code: 404 })).toBe(true) + }) + + it('reads the label from code when name carries the error class instead', () => { + /** Azure raises a `RestError`; the reason lives in `code`, not `name`. */ + expect(isObjectNotFoundError({ name: 'RestError', code: 'BlobNotFound' })).toBe(true) + expect(isObjectNotFoundError({ name: 'Error', code: 'NoSuchKey' })).toBe(true) + }) + + it('does not read a missing bucket or container as an absent object', () => { + /** + * These answer 404 too. Reading them as absence would turn a total storage + * misconfiguration into silent fail-closed reads with nothing to alert on. + */ + expect( + isObjectNotFoundError({ name: 'NoSuchBucket', $metadata: { httpStatusCode: 404 } }) + ).toBe(false) + expect( + isObjectNotFoundError({ name: 'RestError', code: 'ContainerNotFound', statusCode: 404 }) + ).toBe(false) + }) + + it('matches on status alone when the provider sends no label', () => { + expect(isObjectNotFoundError({ $metadata: { httpStatusCode: 404 } })).toBe(true) + expect(isObjectNotFoundError({ statusCode: 404 })).toBe(true) + }) + + it('does not swallow a genuine failure', () => { + expect( + isObjectNotFoundError({ name: 'AccessDenied', $metadata: { httpStatusCode: 403 } }) + ).toBe(false) + expect( + isObjectNotFoundError({ name: 'InternalError', $metadata: { httpStatusCode: 500 } }) + ).toBe(false) + expect(isObjectNotFoundError({ name: 'TimeoutError' })).toBe(false) + expect(isObjectNotFoundError({ code: 'ECONNRESET' })).toBe(false) + expect(isObjectNotFoundError({ code: 403 })).toBe(false) + }) + + it('tolerates values that are not error objects', () => { + expect(isObjectNotFoundError(null)).toBe(false) + expect(isObjectNotFoundError(undefined)).toBe(false) + expect(isObjectNotFoundError('NotFound')).toBe(false) + expect(isObjectNotFoundError(404)).toBe(false) + }) +}) diff --git a/apps/sim/lib/uploads/core/errors.ts b/apps/sim/lib/uploads/core/errors.ts new file mode 100644 index 00000000000..d3d8b5298fd --- /dev/null +++ b/apps/sim/lib/uploads/core/errors.ts @@ -0,0 +1,47 @@ +const OBJECT_NOT_FOUND_LABELS = new Set(['NotFound', 'NoSuchKey', 'BlobNotFound']) + +/** + * A missing bucket or container is a misconfiguration, not an absent object, and + * it also answers 404. Without this it would read as "no metadata" and every file + * read would fail closed with no error to alert on. + */ +const CONTAINER_NOT_FOUND_LABELS = new Set(['NoSuchBucket', 'ContainerNotFound']) + +function readLabels(error: unknown): string[] | null { + if (!error || typeof error !== 'object') return null + const { name, code } = error as { name?: unknown; code?: unknown } + /** + * `name` and `code` are both consulted: Azure raises a `RestError` whose `name` + * carries the class and whose `code` carries the reason, while the AWS SDK puts + * the reason in `name`. + */ + return [name, code].filter((value): value is string => typeof value === 'string') +} + +/** + * True when a storage provider reports that an object does not exist. + * + * Call this only from code that has just performed an object-level operation, so a + * bare 404 can be attributed to that object. A bare 404 is otherwise ambiguous — + * GCS answers a missing object and a missing bucket identically (`code: 404`, + * `errors[].reason: 'notFound'`), separable only by a human-readable message — and + * every caller here is a provider client that knows exactly what it asked for. + * + * Absence is an expected outcome of a lookup, so callers turn it into an empty + * result rather than propagating it. + * + * A network failure, a permission denial, or a provider 5xx still propagates. + */ +export function isObjectNotFoundError(error: unknown): boolean { + const labels = readLabels(error) + if (!labels) return false + if (labels.some((label) => CONTAINER_NOT_FOUND_LABELS.has(label))) return false + if (labels.some((label) => OBJECT_NOT_FOUND_LABELS.has(label))) return true + + const { code, statusCode, $metadata } = error as { + code?: unknown + statusCode?: unknown + $metadata?: { httpStatusCode?: unknown } + } + return code === 404 || statusCode === 404 || $metadata?.httpStatusCode === 404 +} diff --git a/apps/sim/lib/uploads/core/storage-client.test.ts b/apps/sim/lib/uploads/core/storage-client.test.ts new file mode 100644 index 00000000000..da6f8429d31 --- /dev/null +++ b/apps/sim/lib/uploads/core/storage-client.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetFileMetadataByKey, mockHeadS3Object } = vi.hoisted(() => ({ + mockGetFileMetadataByKey: vi.fn(), + mockHeadS3Object: vi.fn(), +})) + +vi.mock('@/lib/uploads/config', () => ({ + USE_S3_STORAGE: true, + USE_BLOB_STORAGE: false, + USE_GCS_STORAGE: false, + S3_CONFIG: { bucket: 'bucket', region: 'region' }, +})) + +vi.mock('@/lib/uploads/providers/s3/client', () => ({ + headS3Object: mockHeadS3Object, +})) + +vi.mock('@/lib/uploads/server/metadata', () => ({ + getFileMetadataByKey: mockGetFileMetadataByKey, +})) + +import { getFileMetadata } from '@/lib/uploads/core/storage-client' + +describe('getFileMetadata', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetFileMetadataByKey.mockResolvedValue(null) + }) + + it('reports an absent object as no metadata rather than throwing', async () => { + /** The provider client owns not-found and reports absence as `null`. */ + mockHeadS3Object.mockResolvedValue(null) + + await expect(getFileMetadata('workspace/ws/superseded-key.md')).resolves.toEqual({}) + }) + + it('still propagates a genuine storage failure', async () => { + mockHeadS3Object.mockRejectedValue( + Object.assign(new Error('AccessDenied'), { + name: 'AccessDenied', + $metadata: { httpStatusCode: 403 }, + }) + ) + + await expect(getFileMetadata('workspace/ws/key.md')).rejects.toThrow('AccessDenied') + }) + + it('returns provider metadata when the object exists', async () => { + mockHeadS3Object.mockResolvedValue({ size: 12, metadata: { workspaceid: 'ws-1' } }) + + await expect(getFileMetadata('workspace/ws/key.md')).resolves.toEqual({ workspaceid: 'ws-1' }) + }) + + it('treats an object carrying no metadata as no metadata', async () => { + mockHeadS3Object.mockResolvedValue({ size: 12 }) + + await expect(getFileMetadata('workspace/ws/key.md')).resolves.toEqual({}) + }) + + it('prefers the database record when one exists', async () => { + mockGetFileMetadataByKey.mockResolvedValue({ + userId: 'user-1', + workspaceId: 'ws-1', + originalName: 'doc.md', + uploadedAt: new Date('2026-01-01T00:00:00Z'), + context: 'workspace', + }) + + const metadata = await getFileMetadata('workspace/ws/key.md') + + expect(metadata.workspaceId).toBe('ws-1') + expect(mockHeadS3Object).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/uploads/core/storage-client.ts b/apps/sim/lib/uploads/core/storage-client.ts index cd5d7c9f8a1..e9112648a50 100644 --- a/apps/sim/lib/uploads/core/storage-client.ts +++ b/apps/sim/lib/uploads/core/storage-client.ts @@ -44,57 +44,46 @@ export async function getFileMetadata( } if (USE_BLOB_STORAGE) { - const { getBlobServiceClient } = await import('@/lib/uploads/providers/blob/client') + const { headBlobObject } = await import('@/lib/uploads/providers/blob/client') const { BLOB_CONFIG } = await import('@/lib/uploads/config') - - let blobServiceClient = await getBlobServiceClient() - let containerName = BLOB_CONFIG.containerName - - if (customConfig) { - const { BlobServiceClient, StorageSharedKeyCredential } = await import('@azure/storage-blob') - if (customConfig.connectionString) { - blobServiceClient = BlobServiceClient.fromConnectionString(customConfig.connectionString) - } else if (customConfig.accountName && customConfig.accountKey) { - const credential = new StorageSharedKeyCredential( - customConfig.accountName, - customConfig.accountKey - ) - blobServiceClient = new BlobServiceClient( - `https://${customConfig.accountName}.blob.core.windows.net`, - credential - ) - } - containerName = customConfig.containerName || containerName - } - - const containerClient = blobServiceClient.getContainerClient(containerName) - const blockBlobClient = containerClient.getBlockBlobClient(key) - const properties = await blockBlobClient.getProperties() - return properties.metadata || {} + /** `headBlobObject` rejects a config that names no credentials, so only pass one that does. */ + const credentialed = Boolean( + customConfig?.connectionString || (customConfig?.accountName && customConfig?.accountKey) + ) + const object = await headBlobObject( + key, + credentialed + ? { + ...customConfig, + containerName: customConfig?.containerName || BLOB_CONFIG.containerName, + } + : undefined + ) + return object?.metadata || {} } if (USE_S3_STORAGE) { - const { getS3Client } = await import('@/lib/uploads/providers/s3/client') - const { HeadObjectCommand } = await import('@aws-sdk/client-s3') + const { headS3Object } = await import('@/lib/uploads/providers/s3/client') const { S3_CONFIG } = await import('@/lib/uploads/config') - - const s3Client = getS3Client() const bucket = customConfig?.bucket || S3_CONFIG.bucket if (!bucket) { throw new Error('S3 bucket not configured') } - const command = new HeadObjectCommand({ - Bucket: bucket, - Key: key, + const object = await headS3Object(key, { + bucket, + region: customConfig?.region || S3_CONFIG.region, }) - - const response = await s3Client.send(command) - return response.Metadata || {} + return object?.metadata || {} } if (USE_GCS_STORAGE) { + /** + * Unlike the other two, this raises on a missing object rather than reporting + * absence, because GCS answers a missing object and a missing bucket the same + * way and only the caller's own bucket configuration separates them. + */ const { getGcsObjectMetadata } = await import('@/lib/uploads/providers/gcs/client') return getGcsObjectMetadata( key, diff --git a/apps/sim/lib/uploads/providers/blob/client.test.ts b/apps/sim/lib/uploads/providers/blob/client.test.ts index ee464f6ba1b..2fbd11eb777 100644 --- a/apps/sim/lib/uploads/providers/blob/client.test.ts +++ b/apps/sim/lib/uploads/providers/blob/client.test.ts @@ -209,6 +209,44 @@ describe('Azure Blob Storage Client', () => { metadata: { simuploadid: 'receipt-1' }, }) }) + + it('reports an absent blob as null rather than raising', async () => { + /** Azure names the class in `name` and the reason in `code`. */ + mockGetProperties.mockRejectedValueOnce( + Object.assign(new Error('BlobNotFound'), { + name: 'RestError', + code: 'BlobNotFound', + statusCode: 404, + }) + ) + + await expect(headBlobObject('workspace/superseded.md')).resolves.toBeNull() + }) + + it('raises when the container itself is missing', async () => { + /** Also a 404, but a misconfiguration — reporting absence would hide an outage. */ + mockGetProperties.mockRejectedValueOnce( + Object.assign(new Error('ContainerNotFound'), { + name: 'RestError', + code: 'ContainerNotFound', + statusCode: 404, + }) + ) + + await expect(headBlobObject('workspace/file.txt')).rejects.toThrow('ContainerNotFound') + }) + + it('raises on a permission failure', async () => { + mockGetProperties.mockRejectedValueOnce( + Object.assign(new Error('AuthorizationFailure'), { + name: 'RestError', + code: 'AuthorizationFailure', + statusCode: 403, + }) + ) + + await expect(headBlobObject('workspace/file.txt')).rejects.toThrow('AuthorizationFailure') + }) }) describe('deleteFromBlob', () => { diff --git a/apps/sim/lib/uploads/providers/blob/client.ts b/apps/sim/lib/uploads/providers/blob/client.ts index 4c5c12c9e7e..d762493c571 100644 --- a/apps/sim/lib/uploads/providers/blob/client.ts +++ b/apps/sim/lib/uploads/providers/blob/client.ts @@ -7,6 +7,7 @@ import { readNodeStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' import { BLOB_CONFIG } from '@/lib/uploads/config' +import { isObjectNotFoundError } from '@/lib/uploads/core/errors' import type { AzureMultipartPart, AzureMultipartUploadInit, @@ -446,9 +447,7 @@ export async function headBlobObject( ...(properties.metadata ? { metadata: properties.metadata } : {}), } } catch (err) { - const status = (err as { statusCode?: number }).statusCode - const code = (err as { code?: string }).code - if (status === 404 || code === 'BlobNotFound') { + if (isObjectNotFoundError(err)) { return null } throw err @@ -833,9 +832,7 @@ export async function abortMultipartUpload( await blockBlobClient.deleteIfExists() } } catch (error) { - const status = (error as { statusCode?: number }).statusCode - const code = (error as { code?: string }).code - if (status !== 404 && code !== 'BlobNotFound') { + if (!isObjectNotFoundError(error)) { logger.warn('Error cleaning up multipart upload:', error) } } diff --git a/apps/sim/lib/uploads/providers/gcs/client.ts b/apps/sim/lib/uploads/providers/gcs/client.ts index 11544e86dcc..76450f9d235 100644 --- a/apps/sim/lib/uploads/providers/gcs/client.ts +++ b/apps/sim/lib/uploads/providers/gcs/client.ts @@ -8,6 +8,7 @@ import { readNodeStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' import { GCS_CONFIG } from '@/lib/uploads/config' +import { isObjectNotFoundError } from '@/lib/uploads/core/errors' import type { GcsConfig, GcsMultipartPart, @@ -404,7 +405,7 @@ async function getGcsMultipartCompletionId( const metadata = await getGcsObjectMetadata(key, customConfig) return metadata[GCS_MULTIPART_UPLOAD_ID_METADATA_KEY] ?? null } catch (error) { - if ((error as { code?: number } | null)?.code === 404) return null + if (isObjectNotFoundError(error)) return null throw error } } diff --git a/apps/sim/lib/uploads/providers/s3/client.test.ts b/apps/sim/lib/uploads/providers/s3/client.test.ts index 8728671a386..ee6c834eb25 100644 --- a/apps/sim/lib/uploads/providers/s3/client.test.ts +++ b/apps/sim/lib/uploads/providers/s3/client.test.ts @@ -213,6 +213,45 @@ describe('S3 Client', () => { metadata: { simuploadid: 'receipt-1' }, }) }) + + it('reports an absent object as null rather than raising', async () => { + /** + * A workspace file is rewritten under a new key on every content update, so a + * reader holding the previous key lands here routinely. Absence is the answer, + * not a failure. + */ + mockSend.mockRejectedValueOnce( + Object.assign(new Error('NotFound'), { + name: 'NotFound', + $metadata: { httpStatusCode: 404 }, + }) + ) + + await expect(headS3Object('workspace/superseded.md')).resolves.toBeNull() + }) + + it('raises when the bucket itself is missing', async () => { + /** Also a 404, but a misconfiguration — reporting absence would hide an outage. */ + mockSend.mockRejectedValueOnce( + Object.assign(new Error('NoSuchBucket'), { + name: 'NoSuchBucket', + $metadata: { httpStatusCode: 404 }, + }) + ) + + await expect(headS3Object('workspace/file.txt')).rejects.toThrow('NoSuchBucket') + }) + + it('raises on a permission failure', async () => { + mockSend.mockRejectedValueOnce( + Object.assign(new Error('AccessDenied'), { + name: 'AccessDenied', + $metadata: { httpStatusCode: 403 }, + }) + ) + + await expect(headS3Object('workspace/file.txt')).rejects.toThrow('AccessDenied') + }) }) describe('getPresignedUrl', () => { diff --git a/apps/sim/lib/uploads/providers/s3/client.ts b/apps/sim/lib/uploads/providers/s3/client.ts index 6f3543ba620..7e6d56a9a0f 100644 --- a/apps/sim/lib/uploads/providers/s3/client.ts +++ b/apps/sim/lib/uploads/providers/s3/client.ts @@ -20,6 +20,7 @@ import { readNodeStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' import { S3_CONFIG, S3_KB_CONFIG } from '@/lib/uploads/config' +import { isObjectNotFoundError } from '@/lib/uploads/core/errors' import type { S3Config, S3MultipartPart, @@ -260,10 +261,7 @@ export async function headS3Object( ...(response.Metadata ? { metadata: response.Metadata } : {}), } } catch (error) { - const code = (error as { name?: string; $metadata?: { httpStatusCode?: number } } | null)?.name - const status = (error as { $metadata?: { httpStatusCode?: number } } | null)?.$metadata - ?.httpStatusCode - if (code === 'NotFound' || code === 'NoSuchKey' || status === 404) { + if (isObjectNotFoundError(error)) { return null } throw error diff --git a/apps/sim/package.json b/apps/sim/package.json index 6f17aa18e46..c6d1fcef3e4 100644 --- a/apps/sim/package.json +++ b/apps/sim/package.json @@ -181,13 +181,13 @@ "isolated-vm": "6.1.2", "jose": "6.0.11", "js-tiktoken": "1.0.21", - "js-yaml": "4.3.0", + "js-yaml": "4.3.1", "jsdom": "^26.0.0", "jszip": "3.10.1", "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", - "mermaid": "11.15.0", + "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", "mongodb": "6.19.0", diff --git a/apps/sim/public/library/best-ai-agent-builder-2026/cover.jpg b/apps/sim/public/library/best-ai-agent-builder-2026/cover.jpg new file mode 100644 index 00000000000..dcce413a75b Binary files /dev/null and b/apps/sim/public/library/best-ai-agent-builder-2026/cover.jpg differ diff --git a/bun.lock b/bun.lock index eceeecf58f7..2ba2c49253a 100644 --- a/bun.lock +++ b/bun.lock @@ -284,13 +284,13 @@ "isolated-vm": "6.1.2", "jose": "6.0.11", "js-tiktoken": "1.0.21", - "js-yaml": "4.3.0", + "js-yaml": "4.3.1", "jsdom": "^26.0.0", "jszip": "3.10.1", "lib0": "0.2.117", "lru-cache": "11.3.6", "mammoth": "^1.9.0", - "mermaid": "11.15.0", + "mermaid": "11.16.1", "micromatch": "4.0.8", "monaco-editor": "0.55.1", "mongodb": "6.19.0", @@ -694,7 +694,7 @@ "@next/env": "16.2.12", "drizzle-orm": "^0.45.2", "e2b": "^2.36.1", - "mermaid": "11.15.0", + "mermaid": "11.16.1", "minimatch": "^10.2.5", "next": "16.2.12", "postgres": "^3.4.5", @@ -1301,7 +1301,7 @@ "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], - "@mermaid-js/parser": ["@mermaid-js/parser@1.1.1", "", { "dependencies": { "@chevrotain/types": "~11.1.1" } }, "sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw=="], + "@mermaid-js/parser": ["@mermaid-js/parser@1.2.0", "", { "dependencies": { "@chevrotain/types": "~11.1.2" } }, "sha512-oYPyv8A4As1yH5Bx+04iQEQxXuIQDe0GKCNSRgao6z8AM9jixXIfP0vsppRLvGf+nKIOb9/LdpWA4YuJiVvESA=="], "@microsoft/fetch-event-source": ["@microsoft/fetch-event-source@2.0.1", "", {}, "sha512-W6CLUJ2eBMw3Rec70qrsEW0jOm/3twwJv21mrmj2yORiaVmVYGS4sSS5yUwvQc1ZlDLYGPnClVWmUUMagKNsfA=="], @@ -3225,7 +3225,7 @@ "js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="], - "js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "js-yaml": ["js-yaml@4.3.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ=="], "jsdom": ["jsdom@26.1.0", "", { "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", "decimal.js": "^10.5.0", "html-encoding-sniffer": "^4.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "nwsapi": "^2.2.16", "parse5": "^7.2.1", "rrweb-cssom": "^0.8.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^5.1.1", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^7.0.0", "whatwg-encoding": "^3.1.1", "whatwg-mimetype": "^4.0.0", "whatwg-url": "^14.1.1", "ws": "^8.18.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg=="], @@ -3447,7 +3447,7 @@ "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], - "mermaid": ["mermaid@11.15.0", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.1", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.1.1", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.1", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.19", "dompurify": "^3.3.1", "es-toolkit": "^1.45.1", "katex": "^0.16.25", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-pTMbcf3rWdtLiYGpmoTjHEpeY8seiy6sR+9nD7LOs8KfUbHE4lOUAprTRqRAcWSQ6MQpdX+YEsxShtGsINtPtw=="], + "mermaid": ["mermaid@11.16.1", "", { "dependencies": { "@braintree/sanitize-url": "^7.1.2", "@iconify/utils": "^3.0.2", "@mermaid-js/parser": "^1.2.0", "@types/d3": "^7.4.3", "@upsetjs/venn.js": "^2.0.0", "cytoscape": "^3.33.3", "cytoscape-cose-bilkent": "^4.1.0", "cytoscape-fcose": "^2.2.0", "d3": "^7.9.0", "d3-sankey": "^0.12.3", "dagre-d3-es": "7.0.14", "dayjs": "^1.11.20", "dompurify": "^3.3.3", "es-toolkit": "^1.45.1", "katex": "^0.16.45", "khroma": "^2.1.0", "marked": "^16.3.0", "roughjs": "^4.6.6", "stylis": "^4.3.6", "ts-dedent": "^2.2.0", "uuid": "^11.1.0 || ^12 || ^13 || ^14.0.0" } }, "sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g=="], "meshoptimizer": ["meshoptimizer@0.18.1", "", {}, "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw=="], @@ -4893,6 +4893,8 @@ "app-builder-lib/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + "app-builder-lib/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "app-builder-lib/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "app-builder-lib/which": ["which@5.0.0", "", { "dependencies": { "isexe": "^3.1.1" }, "bin": { "node-which": "bin/which.js" } }, "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ=="], @@ -4909,6 +4911,8 @@ "builder-util/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + "builder-util/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "c12/chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="], "c12/confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], @@ -4951,6 +4955,8 @@ "d3-sankey/d3-shape": ["d3-shape@1.3.7", "", { "dependencies": { "d3-path": "1" } }, "sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw=="], + "dmg-builder/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "docs/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="], "docx/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="], @@ -4979,6 +4985,8 @@ "electron-publish/mime": ["mime@2.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg=="], + "electron-updater/js-yaml": ["js-yaml@4.3.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q=="], + "electron-updater/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "electron-winstaller/fs-extra": ["fs-extra@7.0.1", "", { "dependencies": { "graceful-fs": "^4.1.2", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw=="], diff --git a/bunfig.toml b/bunfig.toml index b3c26bccfe6..1c62a63c2cf 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -5,48 +5,16 @@ exact = true minimumReleaseAge = 604800 # @typescript/native-preview stays excluded permanently: it only publishes nightly # dev builds, so every version is structurally younger than any age gate. -# The exactly pinned Pi 0.80.10 packages were vetted for the cloud-review SDK -# migration; they age out of the gate on 2026-07-24 — drop these four entries then. -# next@16.2.12, @next/env@16.2.12 and the @next/swc-* binaries carry the July 2026 security -# advisories (SSRF, cache confusion, DoS, middleware bypass — GHSA-89xv-2m56-2m9x et al., -# fixed in 16.2.11) plus the TypeScript 7 support backport (vercel/next.js#95831) that -# 16.2.11 lacks — this repo resolves typescript to 7.x, and on 16.2.11 `next build`'s -# type-check step can die with a silent SIGSEGV because the legacy TS JS API is gone in TS7. -# Published 2026-07-25, they age out of the gate on 2026-08-01 — drop these entries then, -# and re-date this note on any further bump rather than deleting the entries early: removing -# them while the pinned version is still inside the 7-day window blocks the bump outright. -# The swc binaries ship in lockstep with next and -# MUST be excluded alongside it: they are next's platform-gated optionalDependencies, so -# gating them out leaves them absent from bun.lock entirely, and `bun install -# --frozen-lockfile` then installs no compiler at all — next falls back to downloading one -# at build time, which fails in CI. -# @anthropic-ai/sdk is exactly pinned to 0.114.0, vetted for the agent-events -# streaming work (adaptive thinking display types + transform-json-schema); -# published 2026-07-23, it ages out of the gate on 2026-07-30 — drop this entry then. -# @e2b/code-interpreter (2.7.0, published 2026-07-23) and its `e2b` dependency -# (2.36.1, published 2026-07-27) carry the fix for Pi's Create PR runs dying mid-stream -# with "protocol error: received unsupported compressed output": e2b 2.36.0 moved envd's -# Connect transport off the 2.0.0-rc.3 connect-web and onto undici 8 for Node >= 22.19.0, -# which is this app's engine floor. Only these two are excluded — the rest of the chain -# (@connectrpc/connect{,-web} 2.1.2, @bufbuild/protobuf 2.13.0, undici 8.8.0) already -# clears the gate, and `tar` resolves to 7.5.21, which satisfies e2b's ^7.5.19 without -# an exception. They age out on 2026-07-30 and 2026-08-03 — drop both entries then. -minimumReleaseAgeExcludes = [ - "@typescript/native-preview", - "@earendil-works/pi-agent-core", - "@earendil-works/pi-ai", - "@earendil-works/pi-coding-agent", - "@earendil-works/pi-tui", - "next", - "@next/env", - "@next/swc-darwin-arm64", - "@next/swc-darwin-x64", - "@next/swc-linux-arm64-gnu", - "@next/swc-linux-x64-gnu", - "@anthropic-ai/sdk", - "@e2b/code-interpreter", - "e2b", -] +# mermaid 11.16.1 (published 2026-08-04) clears five open Dependabot advisories that +# 11.15.0 carries: architecture-diagram and config-API prototype pollution, radar and +# XY-chart DoS, and CSS injection into siblings of the diagram. It is inside the 7-day +# window and cannot be installed without an exception; it ages out on 2026-08-11 — drop +# the entry then, and re-date this note on any further bump rather than deleting the entry +# early, because removing it while the pinned version is still inside the window blocks the +# bump outright. js-yaml 4.3.1 (published 2026-07-31) carries the CVE-2026-59870 !!omap +# quadratic-CPU fix, which was never backported to the 4.3.0 line; it ages out on 2026-08-07, +# so that entry can go on the next touch of this file. +minimumReleaseAgeExcludes = ["@typescript/native-preview", "mermaid", "js-yaml"] [run] env = { NEXT_PUBLIC_APP_URL = "http://localhost:3000" } diff --git a/package.json b/package.json index 81e189b6b81..f0b2d645a5e 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,7 @@ "drizzle-orm": "^0.45.2", "postgres": "^3.4.5", "minimatch": "^10.2.5", - "mermaid": "11.15.0", + "mermaid": "11.16.1", "zod": "4.3.6", "e2b": "^2.36.1" },