diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4940a8d6b34..cf2f27d7836 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: # Detect shell-code changes on dev/staging pushes. Web-only changes never # need a desktop build (installed shells load the web app live); changes to # the Electron app or the bridge packages trigger a per-env prerelease build - # (dev → alpha channel, staging → beta) that the env's update feed + # (dev → dev stream, staging → staging) that the env's update feed # (/api/desktop/update) starts offering automatically. detect-desktop-changes: name: Detect Desktop Changes @@ -707,10 +707,10 @@ jobs: secrets: inherit # Per-env desktop prereleases: a dev/staging push that touches shell code - # publishes a channel-tagged GitHub prerelease (vX.Y.Z-alpha.N from dev, - # vX.Y.Z-beta.N from staging). Each environment's /api/desktop/update feed - # offers only its channel, so dev-pointed shells pick up alpha builds, - # staging-pointed shells beta builds, and prod-pointed shells stable + # publishes an environment-tagged GitHub prerelease (vX.Y.Z-dev.N from dev, + # vX.Y.Z-staging.N from staging). Each environment's /api/desktop/update feed + # offers only its stream, so dev-pointed shells pick up dev builds, + # staging-pointed shells staging builds, and prod-pointed shells stable # releases — independently. Unlike stable releases, prereleases build even # before the Apple signing secrets exist — unsigned, so the update pipeline # is testable end to end; installed shells detect the missing Developer ID @@ -738,7 +738,7 @@ jobs: GH_REPO: ${{ github.repository }} SIGNED: ${{ needs.check-desktop-signing.outputs.configured }} run: | - if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; APP_NAME="Sim Dev"; else CHANNEL=beta; APP_NAME="Sim Staging"; fi + if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=dev; APP_NAME="Sim Dev"; else CHANNEL=staging; APP_NAME="Sim Staging"; fi # Prerelease core = next patch after the latest stable release, so # channel builds always outrank the stable they are built on top of # and are always superseded by the next stable. The run-attempt @@ -825,9 +825,9 @@ jobs: steps: - name: Delete stale prereleases run: | - if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi + if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNELS='(dev|alpha)'; else CHANNELS='(staging|beta)'; fi gh release list --limit 100 --json tagName,isPrerelease,isDraft,createdAt \ - --jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNEL}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" | + --jq "[.[] | select(.isPrerelease and (.isDraft | not) and (.tagName | test(\"-${CHANNELS}\\\\.\")))] | sort_by(.createdAt) | reverse | .[5:] | .[].tagName" | while read -r TAG; do [ -n "$TAG" ] || continue echo "Deleting stale prerelease $TAG" @@ -836,11 +836,11 @@ jobs: - name: Delete leftover draft prereleases run: | - if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNEL=alpha; else CHANNEL=beta; fi + if [ "$GITHUB_REF" = "refs/heads/dev" ]; then CHANNELS='(dev|alpha)'; else CHANNELS='(staging|beta)'; fi # Drafts have no tag ref, so delete by release id via the API # (gh release delete resolves by tag, which is ambiguous for drafts). gh api "repos/${GH_REPO}/releases?per_page=100" \ - --jq ".[] | select(.draft and (.tag_name | test(\"-${CHANNEL}\\\\.\"))) | .id" | + --jq ".[] | select(.draft and (.tag_name | test(\"-${CHANNELS}\\\\.\"))) | .id" | while read -r ID; do [ -n "$ID" ] || continue echo "Deleting leftover draft release $ID" diff --git a/.github/workflows/desktop-release.yml b/.github/workflows/desktop-release.yml index 17d7e298a5d..a160d22c37f 100644 --- a/.github/workflows/desktop-release.yml +++ b/.github/workflows/desktop-release.yml @@ -91,8 +91,9 @@ jobs: exit 1 fi - # Prerelease versions carry their environment in the tag: -alpha.N is a - # dev build, -beta.N a staging build. The channel decides the app's + # Prerelease versions carry their environment in the tag: -dev.N is a + # dev build, -staging.N a staging build. Legacy -alpha/-beta tags remain + # accepted while already-published builds age out. The channel decides the app's # identity (name/bundle id — a separate app per environment, installable # side by side) and the default origin baked into the bundle, which in # turn selects the update feed the installed app polls. @@ -102,9 +103,9 @@ jobs: VERSION: ${{ inputs.version }} run: | case "$VERSION" in - *-alpha.*) + *-dev.*|*-alpha.*) NAME='Sim Dev'; APP_ID=ai.sim.desktop.dev; ORIGIN=https://www.dev.sim.ai ;; - *-beta.*) + *-staging.*|*-beta.*) NAME='Sim Staging'; APP_ID=ai.sim.desktop.staging; ORIGIN=https://www.staging.sim.ai ;; *) NAME='Sim'; APP_ID=ai.sim.desktop; ORIGIN='' ;; diff --git a/apps/desktop/README.md b/apps/desktop/README.md index ee0d772730a..4446cb53f80 100644 --- a/apps/desktop/README.md +++ b/apps/desktop/README.md @@ -168,7 +168,7 @@ Raw local file bytes are never exposed through the preload bridge and cannot be ## Auto-update, channels, rollout, rollback - `electron-updater` reads the GitHub Releases feed (`publish` is pinned to `simstudioai/sim`); deltas via `.zip.blockmap`. Install is prompt-based (Restart Now / Later; Later installs on quit) — never forced mid-session. -- Channels: stable builds (`X.Y.Z`) follow `latest`; `-beta.N` builds follow `beta` (never attach a beta `latest-mac.yml` to a stable tag). +- Streams: production follows stable `X.Y.Z` releases, dev follows `-dev.N`, and staging follows `-staging.N`. The feed still recognizes legacy `-alpha.N`/`-beta.N` releases during migration. - Staged rollout: after publishing, edit `stagingPercentage: 10` into the release's `latest-mac.yml`, then raise as crash metrics stay clean. - Rollback: a pulled release must be superseded by a **higher** version — users on the broken build will not reinstall an equal one. (A blocked-versions kill-switch was removed as unwired dead code; reintroduce it in `updater.ts` if a remote config source ever exists to feed it.) - Ship the DMG and tell users to install to `/Applications` — App Translocation breaks Squirrel.Mac updates from quarantined paths. diff --git a/apps/desktop/src/main/updater.test.ts b/apps/desktop/src/main/updater.test.ts index b2ad5db2670..6feece68361 100644 --- a/apps/desktop/src/main/updater.test.ts +++ b/apps/desktop/src/main/updater.test.ts @@ -8,6 +8,7 @@ const autoUpdaterMock = { allowDowngrade: false, autoDownload: true, autoInstallOnAppQuit: false, + autoRunAppAfterInstall: true, logger: null as unknown, on: vi.fn(), setFeedURL: vi.fn(), @@ -25,6 +26,7 @@ import { isNewerVersion, parseSemver, resolveUpdateChannel, + type UpdaterHandle, updateCheckIntervalMs, } from '@/main/updater' @@ -35,15 +37,20 @@ describe('resolveUpdateChannel', () => { }) it('maps prerelease versions to their channel', () => { - expect(resolveUpdateChannel('1.2.3-beta.1')).toBe('beta') - expect(resolveUpdateChannel('1.2.3-alpha.2')).toBe('alpha') + expect(resolveUpdateChannel('1.2.3-dev.2')).toBe('dev') + expect(resolveUpdateChannel('1.2.3-staging.1')).toBe('staging') + }) + + it('keeps legacy alpha and beta builds on their environment streams', () => { + expect(resolveUpdateChannel('1.2.3-alpha.2')).toBe('dev') + expect(resolveUpdateChannel('1.2.3-beta.1')).toBe('staging') }) }) describe('updateCheckIntervalMs', () => { it('checks dev and staging builds every five minutes', () => { - expect(updateCheckIntervalMs('1.2.3-alpha.2')).toBe(5 * 60 * 1000) - expect(updateCheckIntervalMs('1.2.3-beta.1')).toBe(5 * 60 * 1000) + expect(updateCheckIntervalMs('1.2.3-dev.2')).toBe(5 * 60 * 1000) + expect(updateCheckIntervalMs('1.2.3-staging.1')).toBe(5 * 60 * 1000) }) it('checks production builds every thirty minutes', () => { @@ -61,6 +68,7 @@ describe('parseSemver', () => { it('returns null for garbage', () => { expect(parseSemver('latest')).toBeNull() expect(parseSemver('1.2')).toBeNull() + expect(parseSemver('1.2.3garbage')).toBeNull() expect(parseSemver('')).toBeNull() }) }) @@ -137,7 +145,11 @@ describe('initUpdater state machine', () => { } } - async function createUpdater(options?: { autoDownload?: boolean; feedAvailable?: boolean }) { + async function createUpdater(options?: { + autoDownload?: boolean + feedAvailable?: boolean | 'no-release' + probeOriginFeed?: (feedUrl: string) => Promise + }) { const states: DesktopUpdateState[] = [] const handle = initUpdater({ getWindow: () => null, @@ -147,7 +159,7 @@ describe('initUpdater state machine', () => { onStateChange: (state) => states.push(state), loadAutoUpdater: () => autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'], - probeOriginFeed: async () => options?.feedAvailable ?? false, + probeOriginFeed: options?.probeOriginFeed ?? (async () => options?.feedAvailable ?? false), canSelfUpdate: async () => true, }) // Engine selection (signature detection) resolves asynchronously. @@ -162,7 +174,7 @@ describe('initUpdater state machine', () => { autoUpdaterMock.checkForUpdates.mockClear() autoUpdaterMock.downloadUpdate.mockClear() autoUpdaterMock.quitAndInstall.mockClear() - // Keep the update-downloaded dialog from resolving into quitAndInstall. + autoUpdaterMock.autoRunAppAfterInstall = false vi.mocked(dialog.showMessageBox).mockResolvedValue({ response: 1, checkboxChecked: false }) }) @@ -188,12 +200,14 @@ describe('initUpdater state machine', () => { { status: 'downloading', version: '2.0.0', percent: 42 }, { status: 'ready', version: '2.0.0' }, ]) + expect(dialog.showMessageBox).not.toHaveBeenCalled() + expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() handle.install() expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) - it('stops at available and downloads on demand when auto-download is off', async () => { + it('downloads, installs, and relaunches from one Update action', async () => { autoUpdaterMock.autoDownload = false const { handle } = await createUpdater({ autoDownload: false }) @@ -203,6 +217,11 @@ describe('initUpdater state machine', () => { handle.check() expect(autoUpdaterMock.downloadUpdate).toHaveBeenCalledTimes(1) expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + + emit('update-downloaded', { version: '2.0.0' }) + expect(dialog.showMessageBox).not.toHaveBeenCalled() + expect(autoUpdaterMock.autoRunAppAfterInstall).toBe(true) + expect(autoUpdaterMock.quitAndInstall).toHaveBeenCalledTimes(1) }) it('checks from idle and ignores re-entrant checks while busy', async () => { @@ -216,6 +235,31 @@ describe('initUpdater state machine', () => { expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) }) + it('does not lose an interactive check while updater capability is initializing', async () => { + let resolveCapability: ((capable: boolean) => void) | undefined + const capability = new Promise((resolve) => { + resolveCapability = resolve + }) + const states: DesktopUpdateState[] = [] + const handle = initUpdater({ + getWindow: () => null, + events, + appOrigin: () => 'https://www.dev.sim.ai', + onStateChange: (state) => states.push(state), + loadAutoUpdater: () => + autoUpdaterMock as unknown as typeof import('electron-updater')['autoUpdater'], + probeOriginFeed: async () => true, + canSelfUpdate: () => capability, + }) + + handle.check() + expect(states).toEqual([{ status: 'checking' }]) + + resolveCapability?.(true) + await vi.advanceTimersByTimeAsync(0) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + it('resets to idle when a downloaded update is a blocked downgrade', async () => { const { handle } = await createUpdater() emit('update-downloaded', { version: '0.0.1' }) @@ -224,6 +268,17 @@ describe('initUpdater state machine', () => { expect(autoUpdaterMock.quitAndInstall).not.toHaveBeenCalled() }) + it('never exposes an equal, older, malformed, or cross-stream candidate as an update', async () => { + const { handle } = await createUpdater() + + for (const version of ['1.0.0', '0.9.9', 'nightly', '2.0.0-dev.1']) { + emit('update-available', { version }) + expect(handle.getState()).toEqual({ status: 'idle' }) + } + + expect(autoUpdaterMock.downloadUpdate).not.toHaveBeenCalled() + }) + it('surfaces updater errors and recovers via update-not-available', async () => { const { handle } = await createUpdater() emit('error', new Error('feed unreachable')) @@ -233,7 +288,8 @@ describe('initUpdater state machine', () => { }) it('switches to the per-env origin feed when the origin serves one', async () => { - await createUpdater({ feedAvailable: true }) + const { handle } = await createUpdater({ feedAvailable: true }) + handle.check() await vi.advanceTimersByTimeAsync(0) expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledWith({ provider: 'generic', @@ -244,27 +300,70 @@ describe('initUpdater state machine', () => { }) it('keeps the packaged GitHub feed when the origin has no feed', async () => { - await createUpdater({ feedAvailable: false }) + const { handle } = await createUpdater({ feedAvailable: false }) + handle.check() await vi.advanceTimersByTimeAsync(0) expect(autoUpdaterMock.setFeedURL).not.toHaveBeenCalled() }) - it('skips checks on prerelease builds when the origin feed is down', async () => { + it('completes an interactive check immediately when the environment has no release', async () => { + const { handle, states } = await createUpdater({ feedAvailable: 'no-release' }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + + expect(states).toEqual([{ status: 'checking' }, { status: 'idle' }]) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + }) + + it('re-probes a no-release environment so a newly published update appears without restart', async () => { + const probeOriginFeed = vi + .fn<(feedUrl: string) => Promise>() + .mockResolvedValueOnce('no-release') + .mockResolvedValueOnce(true) + const { handle } = await createUpdater({ probeOriginFeed }) + + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(handle.getState()).toEqual({ status: 'idle' }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + + handle.check() + await vi.advanceTimersByTimeAsync(0) + expect(probeOriginFeed).toHaveBeenCalledTimes(2) + expect(autoUpdaterMock.setFeedURL).toHaveBeenCalledTimes(1) + expect(autoUpdaterMock.checkForUpdates).toHaveBeenCalledTimes(1) + }) + + it('recovers an interactive check when the feed probe times out', async () => { + const probeOriginFeed = vi.fn(() => new Promise(() => {})) + const { handle } = await createUpdater({ probeOriginFeed }) + + handle.check() + expect(handle.getState()).toEqual({ status: 'checking' }) + await vi.advanceTimersByTimeAsync(10_000) + + expect(handle.getState()).toEqual({ status: 'error' }) + expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + }) + + it('fails interactive checks promptly on prerelease builds when the origin feed is down', async () => { // The GitHub fallback is stable-only: a Sim Dev shell can never apply a // prod-identity artifact, so it must not check against it. - vi.mocked(app.getVersion).mockReturnValue('1.0.1-alpha.7') + vi.mocked(app.getVersion).mockReturnValue('1.0.1-dev.7') try { const { handle } = await createUpdater({ feedAvailable: false }) handle.check() await vi.advanceTimersByTimeAsync(0) expect(autoUpdaterMock.checkForUpdates).not.toHaveBeenCalled() + expect(handle.getState()).toEqual({ status: 'error' }) } finally { vi.mocked(app.getVersion).mockReturnValue('1.0.0') } }) it('checks prerelease builds normally through the origin feed', async () => { - vi.mocked(app.getVersion).mockReturnValue('1.0.1-alpha.7') + vi.mocked(app.getVersion).mockReturnValue('1.0.1-dev.7') try { const { handle } = await createUpdater({ feedAvailable: true }) handle.check() @@ -276,8 +375,8 @@ describe('initUpdater state machine', () => { }) it.each([ - ['1.0.1-alpha.7', 5 * 60 * 1000], - ['1.0.1-beta.7', 5 * 60 * 1000], + ['1.0.1-dev.7', 5 * 60 * 1000], + ['1.0.1-staging.7', 5 * 60 * 1000], ['1.0.1', 30 * 60 * 1000], ])('schedules %s update polling every %i milliseconds', async (version, interval) => { vi.mocked(app.getVersion).mockReturnValue(version) @@ -505,11 +604,34 @@ describe('checkForUpdatesInteractive', () => { await vi.advanceTimersByTimeAsync(0) expect(dialog.showMessageBox).toHaveBeenCalledWith( - expect.objectContaining({ message: 'Sim is up to date' }) + expect.objectContaining({ + type: 'info', + buttons: ['OK'], + defaultId: 0, + message: 'You’re up to date!', + detail: `Sim ${app.getVersion()} is currently the newest version available.`, + }) ) expect(shell.openExternal).not.toHaveBeenCalled() }) + it('fails a hung interactive check after twelve seconds instead of waiting thirty', async () => { + const handle: UpdaterHandle = { + setAutoDownload: () => {}, + getState: () => ({ status: 'checking' }), + check: vi.fn(), + install: vi.fn(), + onState: () => () => {}, + } + + checkForUpdatesInteractive({ getWindow: () => null, events, handle }) + await vi.advanceTimersByTimeAsync(12_000) + + expect(dialog.showMessageBox).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Could not check for updates' }) + ) + }) + it('only explains packaged-build updates when unpackaged', async () => { ;(app as unknown as { isPackaged: boolean }).isPackaged = false checkForUpdatesInteractive({ getWindow: () => null, events, handle: null }) diff --git a/apps/desktop/src/main/updater.ts b/apps/desktop/src/main/updater.ts index c35d5e9eaec..31333eb1403 100644 --- a/apps/desktop/src/main/updater.ts +++ b/apps/desktop/src/main/updater.ts @@ -12,14 +12,17 @@ const logger = createLogger('DesktopUpdater') const INITIAL_CHECK_DELAY_MS = 10_000 const PRERELEASE_CHECK_INTERVAL_MS = 5 * 60 * 1000 const STABLE_CHECK_INTERVAL_MS = 30 * 60 * 1000 +const UPDATE_CHECK_TIMEOUT_MS = 10_000 +const INTERACTIVE_FEEDBACK_TIMEOUT_MS = 12_000 +const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' -export type UpdateChannel = 'latest' | 'beta' | 'alpha' +export type UpdateChannel = 'latest' | 'staging' | 'dev' /** * The per-environment update feed served by the Sim deployment this shell is * pointed at (`/api/desktop/update/latest-mac.yml`). Each environment pins - * which shell build its clients are offered — dev serves alpha builds, - * staging beta, prod stable — so the environment, not the client, is the + * which shell build its clients are offered — dev serves dev builds, + * staging serves staging builds, prod stable — so the environment, not the client, is the * channel. Returns null for origins that can't host a feed. */ export function feedUrlForOrigin(origin: string): string | null { @@ -59,16 +62,16 @@ function isReleaseAssetUrl(rawUrl: string): boolean { /** * Maps the running version to its update channel: prerelease builds follow * their prerelease channel, stable builds only ever see stable releases. - * Channels are strictly isolated — alpha/beta builds carry their own app + * Channels are strictly isolated — dev/staging builds carry their own app * identity (Sim Dev / Sim Staging) and update only via their origin feed; * the packaged GitHub fallback feed is stable-only. */ export function resolveUpdateChannel(version: string): UpdateChannel { - if (version.includes('-alpha')) { - return 'alpha' + if (/-dev(?:\.|$)/.test(version) || /-alpha(?:\.|$)/.test(version)) { + return 'dev' } - if (version.includes('-beta')) { - return 'beta' + if (/-staging(?:\.|$)/.test(version) || /-beta(?:\.|$)/.test(version)) { + return 'staging' } return 'latest' } @@ -93,7 +96,7 @@ interface ParsedSemver { * misconfigured feed). */ export function parseSemver(version: string): ParsedSemver | null { - const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/.exec(version.trim()) + const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(version.trim()) if (!match) { return null } @@ -183,7 +186,7 @@ export interface UpdaterDeps { /** Test seam: overrides the lazy electron-updater load. */ loadAutoUpdater?: () => typeof import('electron-updater')['autoUpdater'] /** Test seam: overrides the origin feed availability probe. */ - probeOriginFeed?: (feedUrl: string) => Promise + probeOriginFeed?: (feedUrl: string) => Promise /** * Test seam: overrides Squirrel self-update capability detection (whether * the running bundle carries a real Developer ID signature). @@ -227,6 +230,14 @@ export function isNewerVersion(candidateVersion: string, currentVersion: string) return isDowngrade(candidateVersion, currentVersion) } +/** A signed shell may only install a strictly newer build from its own environment stream. */ +function isValidAutomaticUpdate(candidateVersion: string, currentVersion: string): boolean { + return ( + resolveUpdateChannel(candidateVersion) === resolveUpdateChannel(currentVersion) && + isNewerVersion(candidateVersion, currentVersion) + ) +} + /** * Whether Squirrel.Mac can swap this bundle in place. It validates a * downloaded update against the running app's code signature, so only builds @@ -263,7 +274,8 @@ async function detectSelfUpdateCapability(): Promise { * the download in the browser), `install` performs the `ready` action. */ interface UpdateEngine { - check(): void + /** `interactive` requests must always publish a terminal state for their waiting UI. */ + check(interactive?: boolean): void advance(): void install(): void setAutoDownload(enabled: boolean): void @@ -276,7 +288,7 @@ interface UpdateEngine { * renderer for the settings update UI and the minimum-shell-version gate. * * Developer-ID-signed builds use electron-updater (background download, - * install on user confirmation — never mid-session without consent). Builds + * then install and relaunch from an explicit Update action). Builds * that can't self-update (ad-hoc signed: local installs, pre-signing CI * prereleases) still poll the same feed but surface `available` as a manual * download link, so the whole pipeline is testable before signing exists. @@ -288,6 +300,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const currentVersion = app.getVersion() let state: DesktopUpdateState = { status: 'idle' } + let installAfterDownload = false const listeners = new Set<(state: DesktopUpdateState) => void>() const setState = (next: DesktopUpdateState) => { state = next @@ -311,6 +324,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { autoUpdater.channel = resolveUpdateChannel(currentVersion) autoUpdater.allowDowngrade = false autoUpdater.autoDownload = deps.autoDownload?.() ?? true + // Explicit Update actions must reopen Sim after Squirrel swaps the bundle. + autoUpdater.autoRunAppAfterInstall = true // Never install without vetting the downloaded version first. Enabled per // download in the update-downloaded handler, but only for accepted updates // — so a blocked/downgrade build that was already downloaded is never @@ -318,15 +333,39 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { autoUpdater.autoInstallOnAppQuit = false autoUpdater.logger = null + let activeCheckId: number | null = null + let nextCheckId = 0 + let checkTimeout: ReturnType | null = null + const finishCheck = () => { + activeCheckId = null + if (checkTimeout !== null) { + clearTimeout(checkTimeout) + checkTimeout = null + } + } + autoUpdater.on('checking-for-update', () => { setState({ status: 'checking' }) }) autoUpdater.on('update-not-available', () => { + finishCheck() + installAfterDownload = false setState({ status: 'idle' }) }) autoUpdater.on('update-available', (info) => { + finishCheck() + if (!isValidAutomaticUpdate(info.version, currentVersion)) { + installAfterDownload = false + autoUpdater.autoInstallOnAppQuit = false + deps.events.record('update_blocked_version', { + version: info.version, + reason: 'not-newer', + }) + setState({ status: 'idle' }) + return + } deps.events.record('update_check', { available: info.version }) // With auto-download on, download-progress events follow immediately; // `available` is the terminal state only when downloads are manual. @@ -345,7 +384,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }) autoUpdater.on('update-downloaded', (info) => { - if (isDowngrade(currentVersion, info.version)) { + if (!isValidAutomaticUpdate(info.version, currentVersion)) { + installAfterDownload = false autoUpdater.autoInstallOnAppQuit = false deps.events.record('update_blocked_version', { version: info.version }) setState({ status: 'idle' }) @@ -354,24 +394,15 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { autoUpdater.autoInstallOnAppQuit = true deps.events.record('update_downloaded', { version: info.version }) setState({ status: 'ready', version: info.version }) - const win = deps.getWindow() - const options = { - type: 'info' as const, - buttons: ['Restart Now', 'Later'], - defaultId: 0, - cancelId: 1, - message: `Sim ${info.version} is ready to install`, - detail: 'Restart to finish updating. If you choose Later, the update installs on quit.', + if (installAfterDownload) { + installAfterDownload = false + autoUpdater.quitAndInstall() } - const prompt = win ? dialog.showMessageBox(win, options) : dialog.showMessageBox(options) - void prompt.then(({ response }) => { - if (response === 0) { - autoUpdater.quitAndInstall() - } - }) }) autoUpdater.on('error', (error) => { + finishCheck() + installAfterDownload = false deps.events.record('update_error', { message: getErrorMessage(error, 'unknown') }) setState({ status: 'error', version: state.version }) }) @@ -391,23 +422,37 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const probeOriginFeed = deps.probeOriginFeed ?? (async (feedUrl: string) => { - const response = await net.fetch(`${feedUrl}/latest-mac.yml`) - return response.ok + const response = await net.fetch(`${feedUrl}/latest-mac.yml`, { + signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS), + }) + if (response.ok) return true + return response.status === 404 && response.headers.get(FEED_STATUS_HEADER) === 'no-release' + ? 'no-release' + : false }) - const feedConfigured: Promise = (async () => { + type FeedResolution = 'origin' | 'fallback' | 'no-release' | 'skip' + let originFeedConfigured = false + let feedProbeInFlight: Promise | null = null + const resolveFeedForCheck = async (): Promise => { + if (originFeedConfigured) return 'origin' const feedUrl = feedUrlForOrigin(deps.appOrigin()) const stableBuild = resolveUpdateChannel(currentVersion) === 'latest' if (!feedUrl) { - return stableBuild + return stableBuild ? 'fallback' : 'skip' } try { - if (!(await probeOriginFeed(feedUrl))) { + const availability = await probeOriginFeed(feedUrl) + if (availability === 'no-release') { + return 'no-release' + } + if (!availability) { throw new Error('feed responded non-OK') } autoUpdater.setFeedURL({ provider: 'generic', url: feedUrl, channel: 'latest' }) autoUpdater.channel = 'latest' + originFeedConfigured = true deps.events.record('update_feed', { url: feedUrl }) - return true + return 'origin' } catch (error) { logger.warn( stableBuild @@ -415,23 +460,69 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { : 'Origin update feed unavailable; prerelease build skips update checks', { feedUrl, message: getErrorMessage(error, 'unknown') } ) - return stableBuild + return stableBuild ? 'fallback' : 'skip' } - })() + } + const feedForCheck = (): Promise => { + if (originFeedConfigured) return Promise.resolve('origin') + if (feedProbeInFlight) return feedProbeInFlight + feedProbeInFlight = resolveFeedForCheck().finally(() => { + feedProbeInFlight = null + }) + return feedProbeInFlight + } return { - check() { - void feedConfigured.then((mayCheck) => { - if (!mayCheck) { + check(interactive = false) { + if ( + activeCheckId !== null || + state.status === 'available' || + state.status === 'downloading' || + state.status === 'ready' + ) { + return + } + const checkId = ++nextCheckId + activeCheckId = checkId + if (interactive) { + setState({ status: 'checking' }) + } + checkTimeout = setTimeout(() => { + if (activeCheckId !== checkId) return + finishCheck() + deps.events.record('update_error', { message: 'Update check timed out' }) + if (state.status === 'checking') setState({ status: 'error' }) + }, UPDATE_CHECK_TIMEOUT_MS) + void feedForCheck().then((feed) => { + if (activeCheckId !== checkId) return + if (feed === 'no-release') { + finishCheck() + if (interactive && state.status === 'checking') setState({ status: 'idle' }) return } - autoUpdater.checkForUpdates().catch((error) => { - logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') }) - }) + if (feed === 'skip') { + finishCheck() + if (interactive && state.status === 'checking') setState({ status: 'error' }) + return + } + autoUpdater + .checkForUpdates() + .then(() => { + if (activeCheckId !== checkId) return + finishCheck() + if (interactive && state.status === 'checking') setState({ status: 'error' }) + }) + .catch((error) => { + if (activeCheckId !== checkId) return + finishCheck() + logger.warn('Update check failed', { message: getErrorMessage(error, 'unknown') }) + if (state.status === 'checking') setState({ status: 'error' }) + }) }) }, advance() { autoUpdater.downloadUpdate().catch((error) => { + installAfterDownload = false logger.warn('Update download failed', { message: getErrorMessage(error, 'unknown') }) setState({ status: 'error', version: state.version }) }) @@ -449,12 +540,17 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { const fetchManifest = deps.fetchManifest ?? (async (url: string) => { - const response = await net.fetch(url) + const response = await net.fetch(url, { + signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS), + }) return response.ok ? await response.text() : null }) let downloadUrl: string | null = null + let checkInFlight = false const doCheck = async () => { + if (checkInFlight || state.status === 'available') return + checkInFlight = true setState({ status: 'checking', manual: true }) try { const feedUrl = feedUrlForOrigin(deps.appOrigin()) @@ -497,6 +593,8 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } catch (error) { logger.warn('Manual update check failed', { message: getErrorMessage(error, 'unknown') }) setState({ status: 'error', version: state.version, manual: true }) + } finally { + checkInFlight = false } } @@ -520,6 +618,7 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { let engine: UpdateEngine | null = null let pendingAutoDownload: boolean | null = null + let pendingInteractiveCheck = false const canSelfUpdate = deps.canSelfUpdate ?? detectSelfUpdateCapability void canSelfUpdate() @@ -527,6 +626,10 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { .then((capable) => { engine = capable ? buildAutoEngine() : buildManualEngine() if (!engine) { + if (pendingInteractiveCheck) { + pendingInteractiveCheck = false + setState({ status: 'error' }) + } return } if (pendingAutoDownload !== null) { @@ -535,6 +638,10 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { if (!capable) { deps.events.record('update_manual_mode', {}) } + if (pendingInteractiveCheck) { + pendingInteractiveCheck = false + engine.check(true) + } const check = () => engine?.check() setTimeout(check, INITIAL_CHECK_DELAY_MS) setInterval(check, updateCheckIntervalMs(currentVersion)) @@ -550,14 +657,20 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { }, getState: () => state, check() { - if (!engine || state.status === 'checking' || state.status === 'downloading') { + if (state.status === 'checking' || state.status === 'downloading') { + return + } + if (!engine) { + pendingInteractiveCheck = true + setState({ status: 'checking' }) return } if (state.status === 'available') { + installAfterDownload = !state.manual engine.advance() return } - engine.check() + engine.check(true) }, install() { if (!engine) { @@ -580,8 +693,6 @@ export function initUpdater(deps: UpdaterDeps): UpdaterHandle { } } -const INTERACTIVE_CHECK_TIMEOUT_MS = 30_000 - /** * Menu-triggered manual check with user-visible feedback. A thin dialog layer * over the updater handle — it drives whichever pipeline initUpdater selected @@ -652,7 +763,13 @@ export function checkForUpdatesInteractive( }) return default: - void showDialog({ type: 'info', message: 'Sim is up to date' }) + void showDialog({ + type: 'info', + buttons: ['OK'], + defaultId: 0, + message: 'You’re up to date!', + detail: `Sim ${app.getVersion()} is currently the newest version available.`, + }) } } @@ -680,6 +797,6 @@ export function checkForUpdatesInteractive( } finish(state) }) - const timeout = setTimeout(() => finish(handle.getState()), INTERACTIVE_CHECK_TIMEOUT_MS) + const timeout = setTimeout(() => finish({ status: 'error' }), INTERACTIVE_FEEDBACK_TIMEOUT_MS) handle.check() } diff --git a/apps/sim/app/api/copilot/chat/abort/route.test.ts b/apps/sim/app/api/copilot/chat/abort/route.test.ts new file mode 100644 index 00000000000..f3655d0f825 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/abort/route.test.ts @@ -0,0 +1,105 @@ +/** + * @vitest-environment node + */ +import { createMockRequest } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAbortActiveStream, + mockAuthenticate, + mockGetLatestRunForStream, + mockReleasePendingChatStream, + mockRequestExplicitStreamAbort, + mockWaitForPendingChatStream, + order, +} = vi.hoisted(() => { + const order: string[] = [] + return { + order, + mockAbortActiveStream: vi.fn(async () => { + order.push('abortActiveStream') + return true + }), + mockRequestExplicitStreamAbort: vi.fn(async () => { + order.push('requestExplicitStreamAbort') + }), + mockAuthenticate: vi.fn(), + mockGetLatestRunForStream: vi.fn(), + mockWaitForPendingChatStream: vi.fn(), + mockReleasePendingChatStream: vi.fn(), + } +}) + +vi.mock('@/lib/copilot/request/http', () => ({ + authenticateCopilotRequestSessionOnly: mockAuthenticate, +})) +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + getLatestRunForStream: mockGetLatestRunForStream, +})) +vi.mock('@/lib/copilot/request/session', () => ({ + abortActiveStream: mockAbortActiveStream, + waitForPendingChatStream: mockWaitForPendingChatStream, + releasePendingChatStream: mockReleasePendingChatStream, +})) +vi.mock('@/lib/copilot/request/session/explicit-abort', () => ({ + requestExplicitStreamAbort: mockRequestExplicitStreamAbort, +})) + +import { POST } from '@/app/api/copilot/chat/abort/route' + +function abortRequest() { + return createMockRequest('POST', { streamId: 'stream-1', chatId: 'chat-1' }) +} + +describe('POST /api/copilot/chat/abort', () => { + beforeEach(() => { + vi.clearAllMocks() + order.length = 0 + mockAuthenticate.mockResolvedValue({ userId: 'user-1', isAuthenticated: true }) + mockGetLatestRunForStream.mockResolvedValue({ chatId: 'chat-1', workspaceId: 'workspace-1' }) + mockWaitForPendingChatStream.mockResolvedValue(true) + }) + + /** + * The ordering invariant, not an implementation detail: `abortActiveStream` + * is what drops the SSE, and Go decides "user stop vs. client disconnect" + * the instant it sees that drop by consuming a marker exactly once. Marking + * Go second lost that race on ~84% of stops and persisted deliberate stops + * as unexpected terminations carrying a synthetic `provider_error`. + */ + it('writes the Go abort marker before tearing down the local stream', async () => { + const response = await POST(abortRequest()) + + expect(response.status).toBe(200) + expect(order).toEqual(['requestExplicitStreamAbort', 'abortActiveStream']) + }) + + it('still aborts locally when the Go marker write fails', async () => { + mockRequestExplicitStreamAbort.mockRejectedValueOnce(new Error('go unreachable')) + + const response = await POST(abortRequest()) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toMatchObject({ aborted: true, settled: true }) + expect(mockAbortActiveStream).toHaveBeenCalledWith('stream-1') + }) + + it('force-releases the chat stream lock when the stream never settles', async () => { + mockWaitForPendingChatStream.mockResolvedValue(false) + + const response = await POST(abortRequest()) + + await expect(response.json()).resolves.toMatchObject({ settled: false, forceReleased: true }) + expect(mockReleasePendingChatStream).toHaveBeenCalledWith('chat-1', 'stream-1') + }) + + it('rejects an unauthenticated caller without touching either abort path', async () => { + mockAuthenticate.mockResolvedValue({ userId: undefined, isAuthenticated: false }) + + const response = await POST(abortRequest()) + + expect(response.status).toBe(401) + expect(mockRequestExplicitStreamAbort).not.toHaveBeenCalled() + expect(mockAbortActiveStream).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts index 17630d34be0..bd90ccf1083 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -73,9 +73,24 @@ export const POST = withRouteHandler((request: NextRequest) => const workspaceId = run?.workspaceId ?? undefined if (chatId) rootSpan.setAttribute(TraceAttr.ChatId, chatId) - const aborted = await abortActiveStream(streamId) - rootSpan.setAttribute(TraceAttr.CopilotAbortLocalAborted, aborted) - + // ORDER IS LOAD-BEARING: Go's abort marker must be durable BEFORE + // anything tears down the SSE. + // + // `abortActiveStream` is what triggers that teardown — directly when + // this box holds the stream, otherwise via the Redis marker its 250ms + // poller picks up on the box that does. The moment Go sees the socket + // close it decides "user stop vs. client disconnect" by consuming its + // own marker, once, with no retry. Writing that marker second lost the + // race on ~84% of stops: Go read an absent marker, filed a deliberate + // Stop as an unexpected termination, and persisted the turn's orphaned + // tool calls with a synthetic `provider_error` result that the + // assistant then read back and reported to the user as a vendor outage. + // + // The reorder costs the Go round-trip (~400ms median) before generation + // actually stops. Perceived stop latency is unchanged — the client marks + // the turn stopped optimistically before this request is even sent — and + // the added wait is bounded by the timeout below, leaving the settle + // wait that follows well inside the client's own 15s budget. let goAbortOk = false try { await requestExplicitStreamAbort({ @@ -87,13 +102,20 @@ export const POST = withRouteHandler((request: NextRequest) => }) goAbortOk = true } catch (err) { - logger.warn('Explicit abort marker request failed after local abort', { + // Never let a failed or slow marker write block the user's Stop — fall + // through and abort locally regardless. Go re-checks the marker when + // its stream goroutine exits, so a write that lands late still + // classifies correctly. + logger.warn('Explicit abort marker request failed; aborting locally anyway', { streamId, error: getErrorMessage(err), }) } rootSpan.setAttribute(TraceAttr.CopilotAbortGoMarkerOk, goAbortOk) + const aborted = await abortActiveStream(streamId) + rootSpan.setAttribute(TraceAttr.CopilotAbortLocalAborted, aborted) + if (chatId) { const settled = await withCopilotSpan( TraceSpan.CopilotChatAbortWaitSettle, diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts new file mode 100644 index 00000000000..2c4c89adadb --- /dev/null +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.test.ts @@ -0,0 +1,100 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { MANIFEST_ASSET_NAME } from '@/lib/desktop/update-feed' +import { GET } from '@/app/api/desktop/update/latest-mac.yml/route' + +const RELEASES_URL = 'https://api.github.com/repos/simstudioai/sim/releases?per_page=30' +const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' + +function release(tag: string) { + return { + tag_name: tag, + draft: false, + prerelease: tag.includes('-'), + assets: [ + { + name: MANIFEST_ASSET_NAME, + browser_download_url: `https://downloads.example/${tag}/${MANIFEST_ASSET_NAME}`, + }, + ], + } +} + +function manifest(version: string) { + return [`version: ${version}`, 'files:', ` - url: Sim-${version}-universal-mac.zip`].join('\n') +} + +async function getFeed(hostname: string): Promise { + return GET(new NextRequest(`https://${hostname}/api/desktop/update/latest-mac.yml`), undefined) +} + +describe('desktop update manifest route', () => { + const fetchMock = vi.fn() + + beforeEach(() => { + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it.each([ + ['www.dev.sim.ai', 'v1.2.0-dev.4', '1.2.0-dev.4'], + ['www.staging.sim.ai', 'v1.2.0-staging.5', '1.2.0-staging.5'], + ['www.sim.ai', 'v1.1.0', '1.1.0'], + ])('serves the newest release for %s', async (hostname, tag, version) => { + fetchMock.mockImplementation(async (input: string | URL | Request) => { + const url = String(input) + if (url === RELEASES_URL) { + return Response.json([ + release('v1.2.0-dev.4'), + release('v1.2.0-staging.5'), + release('v1.1.0'), + ]) + } + if (url === `https://downloads.example/${tag}/${MANIFEST_ASSET_NAME}`) { + return new Response(manifest(version)) + } + return new Response(null, { status: 404 }) + }) + + const response = await getFeed(hostname) + const body = await response.text() + + expect(response.status).toBe(200) + expect(response.headers.get(FEED_STATUS_HEADER)).toBe('release') + expect(body).toContain(`version: ${version}`) + expect(body).toContain( + `https://github.com/simstudioai/sim/releases/download/${tag}/Sim-${version}-universal-mac.zip` + ) + }) + + it('reports an authoritative no-release result for production with only prereleases', async () => { + fetchMock.mockResolvedValueOnce( + Response.json([release('v1.2.0-dev.4'), release('v1.2.0-staging.5')]) + ) + + const response = await getFeed('www.sim.ai') + + expect(response.status).toBe(404) + expect(response.headers.get(FEED_STATUS_HEADER)).toBe('no-release') + expect(await response.json()).toMatchObject({ error: 'No desktop release for channel latest' }) + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('rejects a manifest whose version does not match its selected release', async () => { + fetchMock + .mockResolvedValueOnce(Response.json([release('v1.2.0-dev.4')])) + .mockResolvedValueOnce(new Response(manifest('1.2.0-staging.5'))) + + const response = await getFeed('www.dev.sim.ai') + + expect(response.status).toBe(502) + expect(await response.json()).toMatchObject({ error: 'Release manifest unavailable' }) + }) +}) diff --git a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts index e005aad407a..897589a5b00 100644 --- a/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts +++ b/apps/sim/app/api/desktop/update/latest-mac.yml/route.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' -import { env } from '@/lib/core/config/env' +import { type NextRequest, NextResponse } from 'next/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { channelForHostname, @@ -18,6 +17,7 @@ const logger = createLogger('DesktopUpdateFeedAPI') * shells within this window (their own check cadence is hours anyway). */ const REVALIDATE_SECONDS = 300 +const FEED_STATUS_HEADER = 'x-sim-desktop-update-feed' const RELEASES_API_URL = `https://api.github.com/repos/${DESKTOP_RELEASE_REPO}/releases?per_page=30` @@ -29,9 +29,12 @@ const RELEASES_API_URL = `https://api.github.com/repos/${DESKTOP_RELEASE_REPO}/r * design: the updater's HTTP client carries no session, and the response * only describes public GitHub release artifacts. */ -export const GET = withRouteHandler(async (): Promise => { - const hostname = new URL(env.NEXT_PUBLIC_APP_URL).hostname - const channel = channelForHostname(hostname) +export const GET = withRouteHandler(async (request: NextRequest): Promise => { + // The same deployment configuration can be promoted across environments, so + // its baked NEXT_PUBLIC_APP_URL is not authoritative for this public feed. + // The hostname the installed shell actually requested is the channel: + // dev -> dev, staging -> staging, and prod/self-hosted -> stable. + const channel = channelForHostname(request.nextUrl.hostname) // A token raises the GitHub API quota from 60/h per NAT IP to 5000/h. // Optional: the repo is public, so the feed works without one. @@ -56,7 +59,10 @@ export const GET = withRouteHandler(async (): Promise => { if (!release) { return NextResponse.json( { error: `No desktop release for channel ${channel}` }, - { status: 404 } + { + status: 404, + headers: { [FEED_STATUS_HEADER]: 'no-release' }, + } ) } @@ -81,13 +87,24 @@ export const GET = withRouteHandler(async (): Promise => { }) return NextResponse.json({ error: 'Release manifest unavailable' }, { status: 502 }) } - const manifest = rewriteManifestUrls(await manifestResponse.text(), release.tag_name) + const manifestSource = await manifestResponse.text() + const manifestVersion = /^version:\s*(\S+)\s*$/m.exec(manifestSource)?.[1] + const releaseVersion = release.tag_name.replace(/^v/, '') + if (manifestVersion !== releaseVersion) { + logger.error('Updater manifest version does not match its release', { + tag: release.tag_name, + manifestVersion, + }) + return NextResponse.json({ error: 'Release manifest unavailable' }, { status: 502 }) + } + const manifest = rewriteManifestUrls(manifestSource, release.tag_name) return new NextResponse(manifest, { status: 200, headers: { 'content-type': 'text/yaml; charset=utf-8', 'cache-control': `public, max-age=${REVALIDATE_SECONDS}`, + [FEED_STATUS_HEADER]: 'release', }, }) }) diff --git a/apps/sim/app/api/tools/file/manage/route.test.ts b/apps/sim/app/api/tools/file/manage/route.test.ts index 74e2cec1505..d255243c2a4 100644 --- a/apps/sim/app/api/tools/file/manage/route.test.ts +++ b/apps/sim/app/api/tools/file/manage/route.test.ts @@ -2,7 +2,6 @@ * @vitest-environment node */ import { createMockRequest, hybridAuthMockFns } from '@sim/testing' -import JSZip from 'jszip' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -10,6 +9,7 @@ const { mockAssertToolFileAccess, mockDownloadServableFileFromStorage, mockDownloadFileFromStorage, + mockDecompressArchiveBufferToWorkspaceFiles, mockEnsureWorkspaceFileFolderPath, mockFetchWorkspaceFileBuffer, mockGetBoundWorkspaceFileSecretProvenance, @@ -26,6 +26,7 @@ const { mockAssertToolFileAccess: vi.fn(), mockDownloadServableFileFromStorage: vi.fn(), mockDownloadFileFromStorage: vi.fn(), + mockDecompressArchiveBufferToWorkspaceFiles: vi.fn(), mockEnsureWorkspaceFileFolderPath: vi.fn(), mockFetchWorkspaceFileBuffer: vi.fn(), mockGetBoundWorkspaceFileSecretProvenance: vi.fn(), @@ -39,6 +40,15 @@ const { mockUploadWorkspaceFile: vi.fn(), })) +vi.mock('@/lib/uploads/archive', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + decompressArchiveBufferToWorkspaceFiles: (...args: unknown[]) => + mockDecompressArchiveBufferToWorkspaceFiles(...args), + } +}) + vi.mock('@/lib/file-parsers', () => ({ isSupportedFileType: vi.fn(() => false), parseBuffer: vi.fn(), @@ -594,12 +604,9 @@ describe('POST /api/tools/file/manage content provenance', () => { ) }) - it('extracts a secret-bearing archive with unknown output provenance', async () => { - const zip = new JSZip() - zip.file('child.txt', 'secret-value') - mockDownloadFileFromStorage.mockResolvedValue( - Buffer.from(await zip.generateAsync({ type: 'uint8array' })) - ) + it('passes secret-bearing archive provenance to the decompressor', async () => { + const archiveBuffer = Buffer.from('archive-bytes') + mockDownloadFileFromStorage.mockResolvedValue(archiveBuffer) mockGetWorkspaceFile.mockResolvedValue({ ...workspaceFile('archive'), name: 'archive.zip', @@ -609,6 +616,21 @@ describe('POST /api/tools/file/manage content provenance', () => { status: 'exact', entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], }) + mockDecompressArchiveBufferToWorkspaceFiles.mockResolvedValue({ + extracted: [ + { + id: 'new-file', + name: 'child.txt', + key: 'workspace/workspace-1/child.txt', + url: '/api/files/serve/new-file', + size: 12, + type: 'text/plain', + context: 'workspace', + }, + ], + skipped: 0, + skippedUnsafePaths: [], + }) const response = await POST( createMockRequest('POST', { @@ -620,18 +642,16 @@ describe('POST /api/tools/file/manage content provenance', () => { expect(response.status).toBe(200) expect(mockDownloadFileFromStorage).toHaveBeenCalledTimes(1) - expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('secret-value'), - 'child.txt', - 'text/plain', - { - exactName: true, - folderId: null, - folderPath: undefined, - secretProvenance: { status: 'unknown' }, - } + expect(mockDecompressArchiveBufferToWorkspaceFiles).toHaveBeenCalledWith( + archiveBuffer, + expect.objectContaining({ + workspaceId: 'workspace-1', + principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'user-1' }), + secretProvenance: { + status: 'exact', + entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], + }, + }) ) }) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx index def9e6f7839..c143c72bb4a 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx @@ -401,6 +401,8 @@ interface ChatContentProps { questionAnswers?: string[] /** Transcript-derived status payload for this message's credential card. */ credentialSubmission?: CredentialSubmissionPayload + /** The user moved on without submitting this message's credential card. */ + credentialAbandoned?: boolean onOptionSelect?: (id: string) => void onQuestionDismiss?: () => void onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void @@ -420,6 +422,7 @@ function ChatContentInner({ isStreaming = false, questionAnswers, credentialSubmission, + credentialAbandoned, onOptionSelect, onQuestionDismiss, onWorkspaceResourceSelect, @@ -645,6 +648,7 @@ function ChatContentInner({ interactionId={`${messageId ?? 'message'}:${group.index}`} questionAnswers={questionAnswers} credentialSubmission={credentialSubmission} + credentialAbandoned={credentialAbandoned} onOptionSelect={onOptionSelect} onQuestionDismiss={onQuestionDismiss} /> diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts index 5265b9f3668..b1d034876d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts @@ -253,7 +253,7 @@ describe('QuestionDisplay', () => { container.remove() }) - it('uses Continue before the final single-select page instead of advancing on selection', () => { + it('advances a multi-page single-select on selection, with no Continue row', () => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true const container = document.createElement('div') document.body.appendChild(container) @@ -269,25 +269,51 @@ describe('QuestionDisplay', () => { ) }) + expect( + Array.from(container.querySelectorAll('button')).some((button) => + ['Continue', 'Submit'].includes(button.textContent ?? '') + ) + ).toBe(false) + const firstOption = Array.from(container.querySelectorAll('button')).find( (button) => button.textContent === 'Keep the newest entry' ) act(() => firstOption?.click()) - expect(container.textContent).toContain(QUESTIONS[0].prompt) + expect(container.textContent).toContain(QUESTIONS[1].prompt) expect(onSelect).not.toHaveBeenCalled() - const continueButton = Array.from(container.querySelectorAll('button')).find( - (button) => button.textContent === 'Continue' + + const finalOption = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Cancel' ) - expect(continueButton?.disabled).toBe(false) - act(() => continueButton?.click()) + act(() => finalOption?.click()) - expect(container.textContent).toContain(QUESTIONS[1].prompt) - expect( - Array.from(container.querySelectorAll('button')).some( - (button) => button.textContent === 'Submit' + expect(onSelect).toHaveBeenCalledWith( + 'How should I handle the duplicates? — Keep the newest entry\n' + + 'Delete 4 archived workflows? — Cancel' + ) + + act(() => root.unmount()) + container.remove() + }) + + it('keeps the single-select arrow inert until the free-text box has content', () => { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + + act(() => { + root.render( + createElement(QuestionDisplay, { + data: [QUESTIONS[0]], + onSelect: () => undefined, + }) ) - ).toBe(true) + }) + + const arrow = container.querySelector('button[aria-label="Submit answer"]') + expect(arrow?.disabled).toBe(true) act(() => root.unmount()) container.remove() diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx index 9357aa8fa61..49dc7d9f4c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx @@ -160,7 +160,6 @@ export function QuestionDisplay({ const options = question.options const selected = selectedByStep[step] ?? [] const isMulti = question.type === 'multi_select' - const usesStepAction = isMulti || data.length > 1 const commitCustom = (): string[] => { const next = [...customByStep] @@ -200,7 +199,7 @@ export function QuestionDisplay({ customs[step] = '' setCustomByStep(customs) setFreeText('') - if (!usesStepAction) finishStep(selections, customs) + finishStep(selections, customs) } const handleMultiToggle = (label: string) => { @@ -212,17 +211,13 @@ export function QuestionDisplay({ setSelectedByStep(selections) } - /** Confirms the current page, then advances or submits the whole batch. */ - const submitCurrentStep = () => { - const customs = commitCustom() - if (isMulti) { - finishStep(selectedByStep, customs) - return - } - const selections = [...selectedByStep] - if ((customs[step] ?? '').trim()) selections[step] = [] - setSelectedByStep(selections) - finishStep(selections, customs) + /** + * multi_select only: confirms the current page's checked rows, then advances + * or submits the whole batch. single_select needs no confirm step — a row + * click or the free-text arrow is itself the answer. + */ + const submitMultiStep = () => { + finishStep(selectedByStep, commitCustom()) } /** Sets whether the typed "Something else" text counts — never touches the text. */ @@ -255,6 +250,10 @@ export function QuestionDisplay({ } const canSubmitStep = !disabled && stepAnswered(step) + // The single_select arrow submits the typed text specifically, so it tracks + // the text rather than the step: a row selected on an earlier visit must not + // arm an arrow that would replace it with an empty answer. + const canSubmitFreeText = !disabled && freeText.trim().length > 0 return ( @@ -410,19 +409,24 @@ export function QuestionDisplay({ event.currentTarget.blur() return } - if (event.key === 'Enter' && canSubmitStep) { + if (event.key !== 'Enter') return + if (isMulti) { + if (!canSubmitStep) return event.preventDefault() - if (usesStepAction) submitCurrentStep() - else submitSingleFreeText() + submitMultiStep() + return } + if (!canSubmitFreeText) return + event.preventDefault() + submitSingleFreeText() }} aria-label={question.prompt} /> - {usesStepAction && ( + {isMulti && ( } /> )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index 1a42d76caf7..80798ad2b09 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -1176,6 +1176,69 @@ describe('CredentialDisplay link tag', () => { act(() => root.unmount()) }) + it('recaps an abandoned card as skipped instead of leaving a live form', () => { + const container = document.createElement('div') + const root: Root = createRoot(container) + const data: CredentialItemData[] = [ + { type: 'secret_input', name: 'ABC_API_KEY' }, + { type: 'secret_input', name: 'BED_API_KEY' }, + ] + + act(() => { + root.render() + }) + + expect(container.textContent).not.toContain('Add secrets') + expect(container.textContent).toContain('ABC_API_KEYSkipped') + expect(container.textContent).toContain('BED_API_KEYSkipped') + expect(container.querySelector('input')).toBeNull() + act(() => root.unmount()) + }) + + it('restores a finished connect into an abandoned recap on a fresh mount', () => { + const attempt = createOAuthChatAttempt({ + workspaceId: 'workspace-1', + providerId: 'google-email', + baseProviderId: 'google', + displayName: 'Gmail', + controlId: 'credential-card:0', + baselineCredentialIds: [], + }) + setOAuthChatAttemptStatus(attempt.id, 'connected') + const container = document.createElement('div') + const root: Root = createRoot(container) + const data: CredentialItemData[] = [ + { + type: 'link', + provider: 'google-email', + value: 'https://sim.test/api/auth/oauth2/authorize?providerId=google-email', + }, + { type: 'secret_input', name: 'ABC_API_KEY' }, + ] + + act(() => { + root.render() + }) + + expect(container.textContent).toContain('GmailConnected') + expect(container.textContent).toContain('ABC_API_KEYSkipped') + act(() => root.unmount()) + }) + + it('leaves a sim_key-only card alone when the turn moves on', () => { + const container = document.createElement('div') + const root: Root = createRoot(container) + const data: CredentialItemData[] = [{ type: 'sim_key', name: 'Sim API key', value: 'sim-key' }] + + act(() => { + root.render() + }) + + expect(container.textContent).toContain('API key') + expect(container.textContent).not.toContain('Skipped') + act(() => root.unmount()) + }) + it('keeps a typed secret while a sibling row runs its OAuth connect', async () => { // The card's secret drafts live in component state until its Submit, so a // connect that navigates this tab away discards whatever the user already diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 744eb5f3592..0562ce7c616 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -1,6 +1,6 @@ 'use client' -import { createElement, lazy, Suspense, useMemo, useState } from 'react' +import { createElement, lazy, Suspense, useEffect, useMemo, useState } from 'react' import { ArrowRight, Check, @@ -22,6 +22,7 @@ import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions' import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport' import { isHosted } from '@/lib/core/config/env-flags' import { isSafeHttpUrl } from '@/lib/core/utils/urls' +import { readLatestOAuthChatAttempt } from '@/lib/credentials/oauth-chat-attempt' import { getDesktopBridge } from '@/lib/desktop' import { desktopChatScopeId } from '@/lib/desktop/chat-scope' import { @@ -44,7 +45,10 @@ import { parseQuestionAnswerMessage, QuestionDisplay, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/question' -import { useOAuthChipConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection' +import { + resolveOAuthChipTarget, + useOAuthChipConnection, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection' import type { ChatMessageContext, MothershipResource, @@ -1449,6 +1453,8 @@ interface SpecialTagsProps { questionAnswers?: string[] /** Transcript-derived status payload for this message's credential card. */ credentialSubmission?: CredentialSubmissionPayload + /** The user moved on without submitting this message's credential card. */ + credentialAbandoned?: boolean onOptionSelect?: (id: string) => void onQuestionDismiss?: () => void onWorkspaceResourceSelect?: (resource: WorkspaceResourceRef) => void @@ -1463,6 +1469,7 @@ export function SpecialTags({ interactionId, questionAnswers, credentialSubmission, + credentialAbandoned, onOptionSelect, onQuestionDismiss, onWorkspaceResourceSelect, @@ -1480,6 +1487,7 @@ export function SpecialTags({ data={segment.data} interactionId={interactionId} submitted={credentialSubmission} + abandoned={credentialAbandoned} onContinue={onOptionSelect} /> ) @@ -2337,11 +2345,13 @@ function CredentialInputCard({ data, interactionId, submitted, + abandoned, onContinue, }: { data: CredentialTagData interactionId?: string submitted?: CredentialSubmissionPayload + abandoned?: boolean onContinue?: (message: string) => void }) { const { workspaceId } = useParams<{ workspaceId: string }>() @@ -2356,6 +2366,47 @@ function CredentialInputCard({ ) const [locallySubmitted, setLocallySubmitted] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false) + const controlIdPrefix = interactionId ?? 'credential-card' + + /** + * An abandoned card recaps from progress its rows made, but it replaces those + * rows — so on a fresh mount nothing is left to report a connect the user + * already finished, and the recap would read "Skipped" over it. An OAuth + * connect records a row-scoped attempt that outlives the mount, so restore + * the verdict from there. + * + * Only OAuth rows need this. A secret is written solely by Submit, which ends + * the card as a real submission instead; a service-account connect never + * outlives its own row either way. + */ + useEffect(() => { + if (!abandoned) return + const restored = new Set() + let restoreIndex = 0 + for (const [dataIndex, item] of data.entries()) { + if (item.type !== 'link' && item.type !== 'service_account') continue + const index = restoreIndex++ + if (item.type !== 'link') continue + const { providerId, reconnectCredentialId } = resolveOAuthChipTarget( + item.value, + item.provider + ) + if (!providerId) continue + const attempt = readLatestOAuthChatAttempt({ + workspaceId, + providerId, + controlId: `${controlIdPrefix}:${dataIndex}`, + credentialId: reconnectCredentialId, + }) + if (attempt?.status === 'connected') restored.add(index) + } + if (restored.size === 0) return + setConnectedIntegrationRows((current) => { + if (Array.from(restored).every((index) => current.has(index))) return current + return new Set([...current, ...restored]) + }) + }, [abandoned, controlIdPrefix, data, workspaceId]) + let integrationIndex = 0 let secretIndex = 0 const indexedRows = data.map((item, dataIndex) => ({ @@ -2388,7 +2439,7 @@ function CredentialInputCard({ 0} onConnected={() => @@ -2505,7 +2556,11 @@ function CredentialInputCard({ }), ] - if (submitted || locallySubmitted) { + // An abandoned card recaps from local progress only: a row the user connected + // or saved before moving on keeps its status, everything else reads "Skipped". + // Only a card that asked for something can be abandoned — a standalone + // `sim_key` row is a reveal widget, not a prompt, so it stays as it is. + if (submitted || locallySubmitted || (abandoned && needsContinuation)) { return ( ({ label: item.label, values: [item.status] }))} @@ -2543,11 +2598,13 @@ export function CredentialDisplay({ data, interactionId, submitted, + abandoned, onContinue, }: { data: CredentialTagData interactionId?: string submitted?: CredentialSubmissionPayload + abandoned?: boolean onContinue?: (message: string) => void }) { const usesCredentialCard = data.every((item) => CREDENTIAL_CARD_TYPES.has(item.type)) @@ -2558,6 +2615,7 @@ export function CredentialDisplay({ data={data} interactionId={interactionId} submitted={submitted} + abandoned={abandoned} onContinue={onContinue} /> ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts index cfe965c603d..9829532420e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/use-oauth-chip-connection.ts @@ -82,6 +82,39 @@ function isPopupStillOpen(popup: { window: Window } | null): boolean { return observePopup(popup) === 'live' } +export interface OAuthChipTarget { + /** Provider the authorize URL connects; falls back to the tag's own slug. */ + providerId: string + /** Present when the URL re-authorizes an existing credential in place. */ + reconnectCredentialId?: string +} + +/** + * Reads the connect target out of an authorize URL. Shared with the credential + * card's recap, which has to look up a row's stored attempt without mounting + * the row — the attempt key is derived from exactly these two fields. + */ +export function resolveOAuthChipTarget(connectUrl?: string, provider?: string): OAuthChipTarget { + if (!connectUrl) return { providerId: provider ?? '' } + let url: URL + try { + url = new URL(connectUrl) + } catch { + return { providerId: provider ?? '' } + } + const reconnectCredentialId = url.searchParams.get('credentialId') ?? undefined + if (url.pathname === '/api/auth/instagram/authorize') { + return { providerId: 'instagram', reconnectCredentialId } + } + if (url.pathname === '/api/auth/shopify/authorize') { + return { providerId: 'shopify', reconnectCredentialId } + } + if (url.pathname === '/api/auth/trello/authorize') { + return { providerId: 'trello', reconnectCredentialId } + } + return { providerId: url.searchParams.get('providerId') ?? provider ?? '', reconnectCredentialId } +} + interface UseOAuthChipConnectionParams { /** Authorize URL streamed by the agent; provider and reconnect scope are read from it. */ connectUrl?: string @@ -138,27 +171,10 @@ export function useOAuthChipConnection({ // A connect URL carrying a credentialId re-authorizes that existing // credential in place (reconnect) rather than creating a new one. - const reconnectCredentialId = useMemo(() => { - if (!connectUrl) return undefined - try { - return new URL(connectUrl).searchParams.get('credentialId') ?? undefined - } catch { - return undefined - } - }, [connectUrl]) - - const providerId = useMemo(() => { - if (!connectUrl) return provider ?? '' - try { - const url = new URL(connectUrl) - if (url.pathname === '/api/auth/instagram/authorize') return 'instagram' - if (url.pathname === '/api/auth/shopify/authorize') return 'shopify' - if (url.pathname === '/api/auth/trello/authorize') return 'trello' - return url.searchParams.get('providerId') ?? provider ?? '' - } catch { - return provider ?? '' - } - }, [connectUrl, provider]) + const { providerId, reconnectCredentialId } = useMemo( + () => resolveOAuthChipTarget(connectUrl, provider), + [connectUrl, provider] + ) const baseProviderId = parseProvider(providerId as OAuthProvider).baseProvider const { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index cc65ba262d6..4b230fb4fd3 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -802,6 +802,8 @@ interface MessageContentProps { questionAnswers?: string[] /** Transcript-derived status payload for this message's credential card. */ credentialSubmission?: CredentialSubmissionPayload + /** The user moved on without submitting this message's credential card. */ + credentialAbandoned?: boolean onOptionSelect?: (id: string) => void onQuestionDismiss?: () => void onPhaseChange?: (phase: MessagePhase) => void @@ -823,6 +825,7 @@ function MessageContentInner({ isLast = false, questionAnswers, credentialSubmission, + credentialAbandoned, onOptionSelect, onQuestionDismiss, onPhaseChange, @@ -926,6 +929,7 @@ function MessageContentInner({ })} questionAnswers={questionAnswers} credentialSubmission={credentialSubmission} + credentialAbandoned={credentialAbandoned} onOptionSelect={onOptionSelect} onQuestionDismiss={onQuestionDismiss} onWorkspaceResourceSelect={onWorkspaceResourceSelect} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 6a847e0d8b6..8d4bccd9c9f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -192,6 +192,8 @@ interface AssistantMessageRowProps { questionAnswers?: string[] /** Transcript-derived status payload for this message's credential card. */ credentialSubmission?: CredentialSubmissionPayload + /** The user moved on without submitting this message's credential card. */ + credentialAbandoned?: boolean rowClassName: string onOptionSelect?: (id: string) => void onAnimatingChange?: (animating: boolean) => void @@ -204,6 +206,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ precedingUserContent, questionAnswers, credentialSubmission, + credentialAbandoned, rowClassName, onOptionSelect, onAnimatingChange, @@ -270,6 +273,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ isLast={isLast} questionAnswers={questionAnswers} credentialSubmission={credentialSubmission} + credentialAbandoned={credentialAbandoned} onOptionSelect={onOptionSelect} onQuestionDismiss={handleQuestionDismiss} onPhaseChange={setPhase} @@ -519,22 +523,34 @@ export function MothershipChat({ * Pairs each assistant question/credential card with the user message that * completed it. The paired user message is hidden — the answered card IS the * user turn — and the assistant row renders a recap both live and on reload. + * + * A credential card the user talked past instead of submitting is marked + * abandoned: the turn is over, so it collapses to the same recap (every row + * it has no progress for reads "Skipped") rather than sitting in the + * transcript as a live form nobody can complete anymore. */ const interactionPairing = useMemo(() => { const answersByIndex: Array = [] const credentialSubmissionByIndex: Array = [] + const credentialAbandonedByIndex: Array = [] const hiddenUserByIndex: Array = [] + let lastUserIndex = -1 + for (const [index, message] of messages.entries()) { + if (message.role === 'user') lastUserIndex = index + } for (const [index, message] of messages.entries()) { if (message.role !== 'assistant') continue - // Check the answering user message BEFORE scanning content: a pairing - // needs one anyway, and this skips the O(content) `includes` scan over - // the still-growing streaming message (always the last row) on every - // snapshot flush. + // Check the surrounding user turns BEFORE scanning content: a pairing + // needs an answering message and abandonment needs a later one, and this + // skips the O(content) `includes` scan over the still-growing streaming + // message (always the last row) on every snapshot flush. const next = messages[index + 1] - if (!next || next.role !== 'user' || !next.content) continue - if (message.content?.includes('')) { + const answer = next?.role === 'user' && next.content ? next.content : null + const superseded = index < lastUserIndex + if (!answer && !superseded) continue + if (answer && message.content?.includes('')) { const questions = parseLastQuestionTag(message.content) - const answers = questions ? parseQuestionAnswerMessage(questions, next.content) : null + const answers = questions ? parseQuestionAnswerMessage(questions, answer) : null if (answers) { answersByIndex[index] = answers hiddenUserByIndex[index + 1] = true @@ -543,16 +559,22 @@ export function MothershipChat({ } if (message.content?.includes('')) { const credentials = parseLastCredentialTag(message.content) - const submission = credentials - ? parseCredentialSubmissionProgress(credentials, next.content) - : null + const submission = + answer && credentials ? parseCredentialSubmissionProgress(credentials, answer) : null if (submission) { credentialSubmissionByIndex[index] = submission hiddenUserByIndex[index + 1] = true + } else if (superseded) { + credentialAbandonedByIndex[index] = true } } } - return { answersByIndex, credentialSubmissionByIndex, hiddenUserByIndex } + return { + answersByIndex, + credentialSubmissionByIndex, + credentialAbandonedByIndex, + hiddenUserByIndex, + } }, [messages]) /** @@ -714,6 +736,7 @@ export function MothershipChat({ precedingUserContent={precedingUserContentByIndex[index]} questionAnswers={interactionPairing.answersByIndex[index]} credentialSubmission={interactionPairing.credentialSubmissionByIndex[index]} + credentialAbandoned={interactionPairing.credentialAbandonedByIndex[index]} rowClassName={cn(styles.assistantRow, styles.rowGap)} onOptionSelect={isLast ? stableOnOptionSelect : undefined} onAnimatingChange={isLast ? setLastRowAnimating : undefined} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx index 2a59312a4d4..c322ba5d78f 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/desktop/desktop.tsx @@ -5,26 +5,13 @@ import type { DesktopPreferenceKey, DesktopPreferences, DesktopUpdateState, - LocalFilesystemMount, - LocalFilesystemResponse, } from '@sim/desktop-bridge' -import { Chip, ChipConfirmModal, Label, Switch, toast } from '@sim/emcn' -import { Folder } from '@sim/emcn/icons' +import { Label, Switch, toast } from '@sim/emcn' import { useParams, useRouter } from 'next/navigation' import { getDesktopBridge, getDesktopShellVersion, getDesktopUpdates } from '@/lib/desktop' -import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { SettingsEmptyState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' -function getMounts(response: LocalFilesystemResponse): LocalFilesystemMount[] | null { - return response.ok && 'mounts' in response.data ? response.data.mounts : null -} - interface PreferenceRowProps { id: string label: string @@ -42,70 +29,26 @@ function PreferenceRow({ id, label, checked, disabled, onCheckedChange }: Prefer ) } -interface UpdateChip { - label: string - disabled?: boolean - onClick: () => void -} - -/** The Updates section's single action, driven by the shell update pipeline. */ -function updateChipFor(state: DesktopUpdateState): UpdateChip { - const updates = getDesktopUpdates() - const check = () => updates?.check() - switch (state.status) { - case 'checking': - return { label: 'Checking...', disabled: true, onClick: () => {} } - case 'available': - return { label: 'Download update', onClick: check } - case 'downloading': - return { - label: state.percent !== undefined ? `Downloading ${state.percent}%` : 'Downloading...', - disabled: true, - onClick: () => {}, - } - case 'ready': - return { label: 'Restart to update', onClick: () => updates?.install() } - case 'error': - return { label: 'Try again', onClick: check } - default: - return { label: 'Check for updates', onClick: check } - } -} - export function Desktop() { const params = useParams() const router = useRouter() const workspaceId = params.workspaceId as string const [preferences, setPreferences] = useState(null) - const [mounts, setMounts] = useState([]) const [pendingPreference, setPendingPreference] = useState(null) - const [mountToForget, setMountToForget] = useState(null) - const [mountMutationPending, setMountMutationPending] = useState(false) const [updateState, setUpdateState] = useState({ status: 'idle' }) const [shellVersion, setShellVersion] = useState(undefined) - const refreshMounts = useCallback(async () => { - const bridge = getDesktopBridge() - if (!bridge) return - const response = await bridge.localFilesystem({ operation: 'list_mounts' }) - const nextMounts = getMounts(response) - if (nextMounts) { - setMounts(nextMounts) - return - } - toast.error('Could not load folder access') - }, []) - useEffect(() => { const bridge = getDesktopBridge() if (!bridge) { router.replace(`/workspace/${workspaceId}/settings/general`) return } - void Promise.all([bridge.settings.getPreferences(), refreshMounts()]) - .then(([nextPreferences]) => setPreferences(nextPreferences)) + void bridge.settings + .getPreferences() + .then(setPreferences) .catch(() => toast.error('Could not load desktop settings')) - }, [refreshMounts, router, workspaceId]) + }, [router, workspaceId]) useEffect(() => { setShellVersion(getDesktopShellVersion()) @@ -132,49 +75,6 @@ export function Desktop() { } }, []) - const addFolder = useCallback(async () => { - const bridge = getDesktopBridge() - if (!bridge) return - setMountMutationPending(true) - try { - const response = await bridge.localFilesystem({ operation: 'mount_directory' }) - if (!response.ok) { - toast.error('Could not add folder access') - return - } - await refreshMounts() - } finally { - setMountMutationPending(false) - } - }, [refreshMounts]) - - const revealFolder = useCallback(async (mount: LocalFilesystemMount) => { - const bridge = getDesktopBridge() - if (!bridge) return - const response = await bridge.localFilesystem({ operation: 'reveal_mount', uri: mount.uri }) - if (!response.ok) toast.error(`Could not open folder: ${response.error}`) - }, []) - - const forgetFolder = useCallback(async () => { - const bridge = getDesktopBridge() - if (!bridge || !mountToForget) return - setMountMutationPending(true) - try { - const response = await bridge.localFilesystem({ - operation: 'forget_mount', - uri: mountToForget.uri, - }) - if (!response.ok) { - toast.error('Could not revoke folder access') - return - } - setMountToForget(null) - await refreshMounts() - } finally { - setMountMutationPending(false) - } - }, [mountToForget, refreshMounts]) - if (!preferences) { return null } @@ -183,151 +83,70 @@ export function Desktop() { !preferences.notificationsEnabled || pendingPreference === 'notificationsEnabled' return ( - <> - - -
- void updatePreference('notificationsEnabled', checked)} - /> - void updatePreference('notificationSounds', checked)} - /> - - void updatePreference('notificationsOnlyWhenUnfocused', checked) - } - /> -
-
- - -
- void updatePreference('launchAtLogin', checked)} - /> - void updatePreference('trayEnabled', checked)} - /> -
-
- - { - const chip = updateChipFor(updateState) - return ( - - {chip.label} - - ) - })()} - > -
- void updatePreference('autoDownloadUpdates', checked)} - /> - {shellVersion && ( -
- - - {updateState.status === 'ready' && updateState.version - ? `${shellVersion} → ${updateState.version} on restart` - : shellVersion} - -
- )} -
-
- - void addFolder()} disabled={mountMutationPending}> - Add folder - - } - > - {mounts.length === 0 ? ( - - No folder access granted. Chat can only read folders you add here. - - ) : ( -
- {mounts.map((mount) => ( - } - iconVariant='plain' - title={mount.name} - onClick={() => void revealFolder(mount)} - clickLabel={`Show ${mount.name} in the file manager`} - badge={ - !mount.remembered ? ( - - Until app restarts - - ) : undefined - } - trailing={ - setMountToForget(mount), - }, - ]} - /> - } - /> - ))} + + +
+ {shellVersion && ( +
+ + + {updateState.status === 'ready' && updateState.version + ? `${shellVersion} → ${updateState.version} on restart` + : shellVersion} +
)} - - - - !open && setMountToForget(null)} - title='Revoke folder access' - text={[ - 'Sim will no longer be able to read ', - { text: mountToForget?.name ?? 'this folder', bold: true }, - '. You can grant access again at any time.', - ]} - confirm={{ - label: 'Revoke access', - pending: mountMutationPending, - pendingLabel: 'Revoking...', - onClick: () => void forgetFolder(), - }} - /> - + void updatePreference('launchAtLogin', checked)} + /> + void updatePreference('trayEnabled', checked)} + /> + void updatePreference('autoDownloadUpdates', checked)} + /> +
+
+ + +
+ void updatePreference('notificationsEnabled', checked)} + /> + void updatePreference('notificationSounds', checked)} + /> + + void updatePreference('notificationsOnlyWhenUnfocused', checked) + } + /> +
+
+
) } diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx new file mode 100644 index 00000000000..1c1d2f1650a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.test.tsx @@ -0,0 +1,162 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const desktopMocks = vi.hoisted(() => ({ + getState: vi.fn(), + onState: vi.fn(), + check: vi.fn(), + install: vi.fn(), + listener: null as ((state: unknown) => void) | null, + unsubscribe: vi.fn(), +})) + +vi.mock('@/lib/desktop', () => ({ + getDesktopUpdates: () => ({ + getState: desktopMocks.getState, + onState: desktopMocks.onState, + check: desktopMocks.check, + install: desktopMocks.install, + }), +})) +vi.mock('@/hooks/queries/user-profile', () => ({ + useUserProfile: () => ({ data: { id: 'user-1', name: 'Ada', email: 'ada@sim.ai' } }), +})) +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: { user: { id: 'user-1' } } }), +})) +vi.mock('@/lib/billing/workspace-permissions', () => ({ + canViewWorkspaceBillingSettings: () => true, +})) +vi.mock('@/lib/core/config/env-flags', () => ({ isBillingEnabled: true })) +vi.mock('@/lib/workspaces/colors', () => ({ getUserColor: () => '#000000' })) +vi.mock('@/hooks/use-workspace-invite-policy', () => ({ + useWorkspaceInvitePolicy: () => ({ isInvitationsDisabled: false }), +})) +vi.mock('@/app/workspace/[workspaceId]/providers/workspace-host-provider', () => ({ + useWorkspaceHostContext: () => null, +})) +vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/sidebar', () => ({ + SidebarTooltip: ({ children }: { children: React.ReactNode }) => children, +})) +vi.mock('@/components/icons', () => ({ + SlackIcon: ({ className }: { className?: string }) => , +})) + +import { SidebarFooter } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer' + +let container: HTMLDivElement +let root: Root + +async function renderFooter(initialState: Record) { + desktopMocks.getState.mockResolvedValue(initialState) + await act(async () => { + root.render( + {}} + onOpenDocs={() => {}} + onJoinSlack={() => {}} + onContactSupport={() => {}} + /> + ) + }) +} + +function helpTrigger(): HTMLButtonElement { + const trigger = container.querySelector('[data-item-id="help"]') + if (!trigger) throw new Error('Help trigger was not rendered') + return trigger +} + +function openHelpMenu() { + act(() => { + helpTrigger().dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, button: 0, ctrlKey: false }) + ) + }) +} + +function menuItem(label: string): HTMLElement { + const item = [...document.querySelectorAll('[role="menuitem"]')].find( + (candidate) => candidate.textContent === label + ) + if (!item) throw new Error(`Menu item "${label}" was not rendered`) + return item +} + +beforeEach(() => { + vi.clearAllMocks() + desktopMocks.listener = null + desktopMocks.onState.mockImplementation((listener) => { + desktopMocks.listener = listener + return desktopMocks.unsubscribe + }) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) +}) + +afterEach(() => { + act(() => root.unmount()) + container.remove() +}) + +describe('SidebarFooter desktop update affordance', () => { + it('keeps the ordinary help treatment when no update is available', async () => { + await renderFooter({ status: 'idle' }) + + expect(helpTrigger()).toHaveAttribute('aria-label', 'Help') + expect(helpTrigger()).not.toHaveClass('bg-[var(--text-primary)]') + expect(helpTrigger()).toHaveClass('h-[30px]', 'px-2') + expect(helpTrigger().querySelector('circle')).toBeInTheDocument() + openHelpMenu() + expect(document.querySelector('[role="menu"]')).not.toHaveTextContent('Update') + expect(menuItem('Docs')).toBeVisible() + }) + + it('replaces Help with a same-size primary update icon and starts it from the same menu', async () => { + await renderFooter({ status: 'available', version: '1.4.0' }) + + expect(helpTrigger()).toHaveAttribute('aria-label', 'Help, update available') + expect(helpTrigger()).toHaveClass('h-[30px]', 'px-2') + expect(helpTrigger()).not.toHaveClass('bg-[var(--text-primary)]') + expect(helpTrigger().querySelector('circle')).not.toBeInTheDocument() + expect(helpTrigger().querySelector('span')).toHaveClass( + 'size-[17px]', + 'rounded-full', + 'bg-[var(--text-primary)]' + ) + expect(helpTrigger().querySelector('svg')).toHaveClass('size-[11px]') + expect(helpTrigger().querySelector('svg')).toHaveAttribute('viewBox', '-1.75 -1.75 24 24') + openHelpMenu() + expect(menuItem('Update').querySelector('img')).toHaveAttribute( + 'src', + '/favicon/favicon-32x32.png' + ) + act(() => menuItem('Update').click()) + + expect(desktopMocks.check).toHaveBeenCalledTimes(1) + expect(desktopMocks.install).not.toHaveBeenCalled() + }) + + it('turns the menu action into restart-and-install when the update is ready', async () => { + await renderFooter({ status: 'idle' }) + + act(() => { + desktopMocks.listener?.({ status: 'ready', version: '1.4.0' }) + }) + expect(helpTrigger().querySelector('span')).toHaveClass('bg-[var(--text-primary)]') + openHelpMenu() + act(() => menuItem('Update').click()) + + expect(desktopMocks.install).toHaveBeenCalledTimes(1) + expect(desktopMocks.check).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx index 912d9d42c90..a4f30229d4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/sidebar-footer/sidebar-footer.tsx @@ -1,22 +1,26 @@ 'use client' -import type { ComponentType } from 'react' +import { type ComponentType, useEffect, useState } from 'react' +import type { DesktopUpdateState } from '@sim/desktop-bridge' import { Chip, chipContentLabelClass, + chipPrimaryFillTokens, chipVariants, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, Skeleton, } from '@sim/emcn' -import { BookOpen, Credit, HelpCircle, Settings, Trash, Users } from '@sim/emcn/icons' +import { BookOpen, Credit, Download, HelpCircle, Settings, Trash, Users } from '@sim/emcn/icons' import { SlackIcon } from '@/components/icons' import { useSession } from '@/lib/auth/auth-client' import { canViewWorkspaceBillingSettings } from '@/lib/billing/workspace-permissions' import { isBillingEnabled } from '@/lib/core/config/env-flags' +import { getDesktopUpdates } from '@/lib/desktop' import { getUserColor } from '@/lib/workspaces/colors' import { useWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider' import type { SettingsSection } from '@/app/workspace/[workspaceId]/settings/navigation' @@ -45,6 +49,36 @@ const PROFILE_MENU_ITEMS: readonly { { section: 'recently-deleted', label: 'Recently deleted', icon: Trash }, ] +function hasAvailableDesktopUpdate(state: DesktopUpdateState): boolean { + return state.status === 'available' || state.status === 'downloading' || state.status === 'ready' +} + +function desktopUpdateActionLabel(state: DesktopUpdateState): string { + if (state.status === 'downloading') { + return state.percent === undefined + ? 'Downloading update…' + : `Downloading update ${state.percent}%` + } + return 'Update' +} + +/** Compact primary update circle using the same footprint as the surrounding sidebar icons. */ +function DesktopUpdateIcon({ className }: { className?: string }) { + return ( + + {/* Download's default viewBox is asymmetric around its paths. Center the + artwork itself, not merely its SVG box, inside the avatar-sized circle. */} + + + ) +} + interface SidebarFooterProps { workspaceId: string isCollapsed: boolean @@ -91,8 +125,37 @@ export function SidebarFooter({ const { data: session } = useSession() const hostContext = useWorkspaceHostContext() const { isInvitationsDisabled } = useWorkspaceInvitePolicy(workspaceId) + const [updateState, setUpdateState] = useState({ status: 'idle' }) + + useEffect(() => { + const updates = getDesktopUpdates() + if (!updates) return + + let stateEventReceived = false + const unsubscribe = updates.onState((state) => { + stateEventReceived = true + setUpdateState(state) + }) + void updates + .getState() + .then((state) => { + if (!stateEventReceived) setUpdateState(state) + }) + .catch(() => {}) + return unsubscribe + }, []) const name = profile ? profile.name?.trim() || profile.email : '' + const updateAvailable = hasAvailableDesktopUpdate(updateState) + + const handleUpdateSelect = () => { + const updates = getDesktopUpdates() + if (updateState.status === 'ready') { + updates?.install() + } else if (updateState.status === 'available') { + updates?.check() + } + } /** * Subscription is dropped for viewers the Billing page would turn away — a @@ -216,12 +279,15 @@ export function SidebarFooter({ */ const helpMenu = ( - + {/* Anchored to whichever edge the trigger sits on, so the menu never overhangs the rail. */} + {updateAvailable && ( + <> + + + {desktopUpdateActionLabel(updateState)} + + + + )} Docs diff --git a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts b/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts index 61dc48c3630..1fad4c11323 100644 --- a/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts +++ b/apps/sim/lib/copilot/generated/trace-attribute-values-v1.ts @@ -19,6 +19,7 @@ export type AbortBackendValue = (typeof AbortBackend)[AbortBackendKey] export const AbortRedisResult = { Error: 'error', + Miss: 'miss', Ok: 'ok', Slow: 'slow', } as const diff --git a/apps/sim/lib/desktop/update-feed.test.ts b/apps/sim/lib/desktop/update-feed.test.ts index 6d1cb2b2e8a..c89023d6997 100644 --- a/apps/sim/lib/desktop/update-feed.test.ts +++ b/apps/sim/lib/desktop/update-feed.test.ts @@ -24,10 +24,10 @@ function release( describe('channelForHostname', () => { it('maps hosted environments to their channels', () => { - expect(channelForHostname('dev.sim.ai')).toBe('alpha') - expect(channelForHostname('www.dev.sim.ai')).toBe('alpha') - expect(channelForHostname('staging.sim.ai')).toBe('beta') - expect(channelForHostname('www.staging.sim.ai')).toBe('beta') + expect(channelForHostname('dev.sim.ai')).toBe('dev') + expect(channelForHostname('www.dev.sim.ai')).toBe('dev') + expect(channelForHostname('staging.sim.ai')).toBe('staging') + expect(channelForHostname('www.staging.sim.ai')).toBe('staging') expect(channelForHostname('sim.ai')).toBe('latest') expect(channelForHostname('www.sim.ai')).toBe('latest') }) @@ -41,45 +41,62 @@ describe('channelForHostname', () => { describe('channelOfVersion', () => { it('classifies versions by prerelease tag', () => { expect(channelOfVersion('0.5.24')).toBe('latest') - expect(channelOfVersion('0.5.25-beta.3')).toBe('beta') - expect(channelOfVersion('0.5.25-alpha.412')).toBe('alpha') + expect(channelOfVersion('0.5.25-staging.3')).toBe('staging') + expect(channelOfVersion('0.5.25-dev.412')).toBe('dev') + }) + + it('classifies legacy alpha and beta tags with their environment', () => { + expect(channelOfVersion('0.5.25-alpha.412')).toBe('dev') + expect(channelOfVersion('0.5.25-beta.3')).toBe('staging') }) }) describe('selectReleaseForChannel', () => { const releases = [ - release('v0.5.25-alpha.412'), + release('v0.5.25-dev.412'), release('v0.5.24'), - release('v0.5.25-beta.2'), + release('v0.5.25-staging.2'), release('v0.5.23'), - release('v0.5.26-alpha.1', { draft: true }), + release('v0.5.26-dev.1', { draft: true }), ] it('offers stable-only to the latest channel', () => { expect(selectReleaseForChannel(releases, 'latest')?.tag_name).toBe('v0.5.24') }) - it('offers only beta builds to the beta channel', () => { - expect(selectReleaseForChannel(releases, 'beta')?.tag_name).toBe('v0.5.25-beta.2') + it('offers only staging builds to the staging stream', () => { + expect(selectReleaseForChannel(releases, 'staging')?.tag_name).toBe('v0.5.25-staging.2') }) - it('offers only alpha builds to the alpha channel, never beta builds', () => { - // Dev and staging both cut prereleases of the same next core version; - // semver ranks beta above alpha there, so cross-channel leakage would - // put staging builds on dev clients. - expect(selectReleaseForChannel(releases, 'alpha')?.tag_name).toBe('v0.5.25-alpha.412') + it('offers only dev builds to the dev stream, never staging builds', () => { + expect(selectReleaseForChannel(releases, 'dev')?.tag_name).toBe('v0.5.25-dev.412') + }) + + it('keeps already-published alpha and beta releases eligible during migration', () => { + expect(selectReleaseForChannel([release('v0.5.25-alpha.412')], 'dev')?.tag_name).toBe( + 'v0.5.25-alpha.412' + ) + expect(selectReleaseForChannel([release('v0.5.25-beta.2')], 'staging')?.tag_name).toBe( + 'v0.5.25-beta.2' + ) }) it('never serves stable prod-identity builds to prerelease channels', () => { - // Alpha/beta are internal channels with their own app identity (Sim Dev / + // Dev/staging are internal streams with their own app identity (Sim Dev / // Sim Staging); a stable Sim.app artifact can't be applied by those // shells, so a newer stable must not shadow the channel's own builds. const withNewStable = [...releases, release('v0.5.25')] - expect(selectReleaseForChannel(withNewStable, 'alpha')?.tag_name).toBe('v0.5.25-alpha.412') - expect(selectReleaseForChannel(withNewStable, 'beta')?.tag_name).toBe('v0.5.25-beta.2') + expect(selectReleaseForChannel(withNewStable, 'dev')?.tag_name).toBe('v0.5.25-dev.412') + expect(selectReleaseForChannel(withNewStable, 'staging')?.tag_name).toBe('v0.5.25-staging.2') expect(selectReleaseForChannel(withNewStable, 'latest')?.tag_name).toBe('v0.5.25') }) + it('reports no production release when only prereleases exist', () => { + expect( + selectReleaseForChannel([release('v0.5.25-dev.412'), release('v0.5.25-staging.2')], 'latest') + ).toBeNull() + }) + it('skips stable-tagged releases flagged prerelease on the latest channel', () => { const flagged = [release('v0.5.25', { prerelease: true }), release('v0.5.24')] expect(selectReleaseForChannel(flagged, 'latest')?.tag_name).toBe('v0.5.24') @@ -89,10 +106,10 @@ describe('selectReleaseForChannel', () => { // A release whose build failed (or is mid-upload) must not take the // channel down; the previous good release keeps serving. const withBrokenNewest = [ - release('v0.5.25-alpha.413', { assets: [{ name: 'Sim-0.5.25-alpha.413-universal.dmg' }] }), - release('v0.5.25-alpha.412'), + release('v0.5.25-dev.413', { assets: [{ name: 'Sim-0.5.25-dev.413-universal.dmg' }] }), + release('v0.5.25-dev.412'), ] - expect(selectReleaseForChannel(withBrokenNewest, 'alpha')?.tag_name).toBe('v0.5.25-alpha.412') + expect(selectReleaseForChannel(withBrokenNewest, 'dev')?.tag_name).toBe('v0.5.25-dev.412') }) it('tolerates release listings without asset data', () => { @@ -101,10 +118,8 @@ describe('selectReleaseForChannel', () => { }) it('skips drafts and unparseable tags', () => { - expect(selectReleaseForChannel([release('v0.5.26-alpha.1', { draft: true })], 'alpha')).toBe( - null - ) - expect(selectReleaseForChannel([release('nightly')], 'alpha')).toBe(null) + expect(selectReleaseForChannel([release('v0.5.26-dev.1', { draft: true })], 'dev')).toBe(null) + expect(selectReleaseForChannel([release('nightly')], 'dev')).toBe(null) }) }) diff --git a/apps/sim/lib/desktop/update-feed.ts b/apps/sim/lib/desktop/update-feed.ts index 969477db124..3f6e7e959f5 100644 --- a/apps/sim/lib/desktop/update-feed.ts +++ b/apps/sim/lib/desktop/update-feed.ts @@ -6,16 +6,17 @@ * GitHub feed, so each environment independently controls which shell build * its clients are offered. The environment IS the channel: * - * - dev.sim.ai → `alpha` (per-push prerelease builds from `dev`) - * - staging.sim.ai → `beta` (per-push prerelease builds from `staging`) + * - dev.sim.ai → `dev` (per-push prerelease builds from `dev`) + * - staging.sim.ai → `staging` (per-push prerelease builds from `staging`) * - sim.ai + self-hosted/unknown → `latest` (stable vX.Y.Z releases only) * * Artifacts stay on GitHub Releases (dumb storage); the feed route picks the * right release for its channel and serves that release's electron-updater * manifest with download URLs rewritten to absolute GitHub asset URLs. * - * Channels are strictly isolated: alpha serves only `-alpha.` prereleases, - * beta only `-beta.` prereleases, and `latest` only stable releases. Builds + * Streams are strictly isolated: dev serves `-dev.` prereleases, staging + * `-staging.`, and `latest` only stable releases. The legacy `-alpha.` and + * `-beta.` tags remain readable so already-published builds keep updating. Builds * carry per-channel app identity (Sim Dev / Sim Staging / Sim), so serving a * stable prod-identity artifact to a dev shell would offer an update * Squirrel.Mac cannot apply (bundle-id mismatch) — each channel only ever @@ -25,24 +26,24 @@ import { compareVersions } from '@/lib/desktop/min-version' export const DESKTOP_RELEASE_REPO = 'simstudioai/sim' -export type DesktopUpdateChannel = 'alpha' | 'beta' | 'latest' +export type DesktopUpdateChannel = 'dev' | 'staging' | 'latest' /** Maps a deployment hostname to its desktop update channel. */ export function channelForHostname(hostname: string): DesktopUpdateChannel { const host = hostname.toLowerCase() if (host === 'dev.sim.ai' || host.endsWith('.dev.sim.ai')) { - return 'alpha' + return 'dev' } if (host === 'staging.sim.ai' || host.endsWith('.staging.sim.ai')) { - return 'beta' + return 'staging' } return 'latest' } /** The channel a specific version belongs to, from its prerelease tag. */ export function channelOfVersion(version: string): DesktopUpdateChannel { - if (version.includes('-alpha.')) return 'alpha' - if (version.includes('-beta.')) return 'beta' + if (version.includes('-dev.') || version.includes('-alpha.')) return 'dev' + if (version.includes('-staging.') || version.includes('-beta.')) return 'staging' return 'latest' } diff --git a/apps/sim/lib/execution/remote-sandbox/cli-tools-boundary.test.ts b/apps/sim/lib/execution/remote-sandbox/cli-tools-boundary.test.ts index a7dcd390b91..532f0f91502 100644 --- a/apps/sim/lib/execution/remote-sandbox/cli-tools-boundary.test.ts +++ b/apps/sim/lib/execution/remote-sandbox/cli-tools-boundary.test.ts @@ -1,7 +1,7 @@ /** * @vitest-environment node */ -import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { existsSync, readdirSync, readFileSync } from 'node:fs' import { dirname, extname, join, resolve } from 'node:path' import { describe, expect, it } from 'vitest' @@ -77,7 +77,12 @@ function runtimeModuleSpecifiers(source: string): string[] { return [...specifiers] } -function resolveLocalModule(importer: string, specifier: string, root: string): string | null { +function resolveLocalModule( + importer: string, + specifier: string, + root: string, + sourcePaths: ReadonlySet +): string | null { let base: string if (specifier.startsWith('@/')) { base = resolve(root, specifier.slice(2)) @@ -98,23 +103,23 @@ function resolveLocalModule(importer: string, specifier: string, root: string): join(base, 'index.js'), join(base, 'index.jsx'), ] - return ( - candidates.find((candidate) => existsSync(candidate) && statSync(candidate).isFile()) ?? null - ) + return candidates.find((candidate) => sourcePaths.has(candidate)) ?? null } function clientPathToServerModule(root: string, target: string): string[] | null { const sources = listProductionSourceFiles(root) + const sourcePaths = new Set(sources) const sourceByPath = new Map(sources.map((path) => [path, readFileSync(path, 'utf8')])) const clientRoots = sources.filter((path) => hasUseClientDirective(sourceByPath.get(path) ?? '')) const parent = new Map(clientRoots.map((path) => [path, null])) const pending = [...clientRoots] + let pendingIndex = 0 - while (pending.length > 0) { - const importer = pending.shift() + while (pendingIndex < pending.length) { + const importer = pending[pendingIndex++] if (!importer) continue for (const specifier of runtimeModuleSpecifiers(sourceByPath.get(importer) ?? '')) { - const imported = resolveLocalModule(importer, specifier, root) + const imported = resolveLocalModule(importer, specifier, root, sourcePaths) if (!imported || parent.has(imported)) continue parent.set(imported, importer) if (imported === target) { @@ -139,5 +144,5 @@ describe('sandbox CLI client boundary', () => { const path = clientPathToServerModule(root, target) expect(path ? path.map((entry) => entry.slice(root.length + 1)) : null).toBeNull() - }) + }, 30_000) }) diff --git a/packages/testing/src/mocks/database.mock.ts b/packages/testing/src/mocks/database.mock.ts index e6da8a5b51e..3342fc15416 100644 --- a/packages/testing/src/mocks/database.mock.ts +++ b/packages/testing/src/mocks/database.mock.ts @@ -17,7 +17,7 @@ export function createMockSql() { const sqlFn = (strings: TemplateStringsArray, ...values: any[]) => { if (values.some((value) => value instanceof Date)) { throw new Error( - 'sql`…${date}` interpolates a Date without an encoder, so drizzle never runs ' + + `sql\`…\${date}\` interpolates a Date without an encoder, so drizzle never runs ` + 'the column mapping and postgres-js receives an unserialized Date ' + '(ERR_INVALID_ARG_TYPE). Bind through the matching column: ' + 'sql.param(date, table.timestampColumn).'